Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions benches/assets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,13 +95,15 @@
candidates: &[AssetRef],
writer: &mut DataWriter,
) -> Prices {
let solution_existing = DispatchRun::new(model, base_year_assets, BASE_YEAR)
let market_demands = collect_preset_demands_for_year(&model.commodities, BASE_YEAR);

Check failure on line 98 in benches/assets.rs

View workflow job for this annotation

GitHub Actions / pre-commit

cannot find function `collect_preset_demands_for_year` in this scope

Check failure on line 98 in benches/assets.rs

View workflow job for this annotation

GitHub Actions / Run benchmarks

cannot find function `collect_preset_demands_for_year` in this scope
let solution_existing = DispatchRun::new(model, base_year_assets, BASE_YEAR, &market_demands)
.run("bench setup: without candidates", writer)
.expect("Dispatch without candidates failed");
let solution_with_candidates = DispatchRun::new(model, base_year_assets, BASE_YEAR)
.with_candidates(candidates)
.run("bench setup: with candidates", writer)
.expect("Dispatch with candidates failed");
let solution_with_candidates =
DispatchRun::new(model, base_year_assets, BASE_YEAR, &market_demands)
.with_candidates(candidates)
.run("bench setup: with candidates", writer)
.expect("Dispatch with candidates failed");

calculate_prices(
model,
Expand Down
4 changes: 0 additions & 4 deletions schemas/input/model.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -91,10 +91,6 @@ properties:
type: number
description: The relative tolerance for price convergence in the ironing out loop
default: 1e-6
capacity_margin:
type: number
description: Slack proportion for assets selected during cycle balancing to absorb small demand shifts
default: 0.2
mothball_years:
type: integer
default: 0
Expand Down
10 changes: 0 additions & 10 deletions src/model/parameters.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,13 +113,6 @@ pub struct ModelParameters {
/// The relative tolerance for price convergence in the ironing out loop
#[serde(deserialize_with = "deserialise_finite_non_negative")]
pub price_tolerance: Dimensionless,
/// Slack applied during cycle balancing, allowing newly selected assets to flex their capacity
/// by this proportion.
///
/// Existing assets remain fixed; this gives newly selected assets the wiggle-room to absorb
/// small demand changes before we would otherwise need to break for re-investment.
#[serde(deserialize_with = "deserialise_finite_non_negative")]
pub capacity_margin: Dimensionless,
/// Number of years an asset can remain unused before being decommissioned
pub mothball_years: u32,
/// Absolute tolerance when checking if remaining demand is close enough to zero
Expand Down Expand Up @@ -152,7 +145,6 @@ impl Default for ModelParameters {
annual_utilisation_penalty: MoneyPerCapacityPerYear(1e-6),
max_ironing_out_iterations: 1,
price_tolerance: Dimensionless(1e-6),
capacity_margin: Dimensionless(0.2),
mothball_years: 0,
remaining_demand_absolute_tolerance: DEFAULT_REMAINING_DEMAND_ABSOLUTE_TOLERANCE,
highs: HighsOptions::default(),
Expand Down Expand Up @@ -355,8 +347,6 @@ impl ModelParameters {

// price_tolerance already validated with deserialise_finite_non_negative

// capacity_margin already validated with deserialise_finite_non_negative

// remaining_demand_absolute_tolerance already validated with
// deserialise_finite_non_negative; check remaining constraints here
check_remaining_demand_absolute_tolerance(
Expand Down
11 changes: 7 additions & 4 deletions src/simulation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use std::sync::Arc;
pub mod optimisation;
use optimisation::{DispatchRun, FlowMap};
pub mod investment;
use investment::perform_agent_investment;
use investment::{flatten_preset_demands_for_year, perform_agent_investment};
pub mod market;
pub mod prices;
pub use prices::PriceMap;
Expand Down Expand Up @@ -178,13 +178,16 @@ fn run_dispatch_for_year(
debug_assert!(assets.iter().all(|asset| !asset.is_candidate()));
debug_assert!(candidates.iter().all(|asset| asset.is_candidate()));

let market_demands =
flatten_preset_demands_for_year(&model.commodities, &model.time_slice_info, year);

// Run dispatch optimisation with existing assets only, if there are any. If not, then assume no
// flows (i.e. all are zero)
let (solution_existing, flow_map) = if assets.is_empty() {
(None, FlowMap::default())
} else {
let solution =
DispatchRun::new(model, assets, year).run("final without candidates", writer)?;
let solution = DispatchRun::new(model, assets, year, &market_demands)
.run("final without candidates", writer)?;
let flow_map = solution.create_flow_map();
(Some(solution), flow_map)
};
Expand All @@ -195,7 +198,7 @@ fn run_dispatch_for_year(
None
} else {
Some(
DispatchRun::new(model, assets, year)
DispatchRun::new(model, assets, year, &market_demands)
.with_candidates(candidates)
.run("final with candidates", writer)?,
)
Expand Down
17 changes: 5 additions & 12 deletions src/simulation/investment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,9 @@ pub fn perform_agent_investment(
writer: &mut DataWriter,
) -> Result<Vec<AssetRef>> {
// Initialise net demand map
let mut net_demand =
let preset_demands =
flatten_preset_demands_for_year(&model.commodities, &model.time_slice_info, year);
let mut net_demand = preset_demands.clone();

// Keep a list of all the assets selected
// This includes Commissioned assets that are selected for retention, and new Ready assets
Expand All @@ -77,16 +78,8 @@ pub fn perform_agent_investment(
// Iterate over market sets in the investment order for this year
for market_set in investment_order {
// Select assets for this market set
let selected_assets = market_set.select_assets(
model,
year,
&net_demand,
existing_assets,
prices,
&seen_markets,
&all_selected_assets,
writer,
)?;
let selected_assets =
market_set.select_assets(model, year, &net_demand, existing_assets, prices, writer)?;

// Update our list of seen markets
for market in market_set.iter_markets() {
Expand All @@ -111,7 +104,7 @@ pub fn perform_agent_investment(

// As upstream markets by definition will not yet have producers, we explicitly set
// their prices using external values so that they don't appear free
let solution = DispatchRun::new(model, &all_selected_assets, year)
let solution = DispatchRun::new(model, &all_selected_assets, year, &preset_demands)
.without_commodity_constraints()
.with_market_balance_subset(&seen_markets)
.with_input_prices(&prices.shadow)
Expand Down
138 changes: 28 additions & 110 deletions src/simulation/market.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
//! Code for creating sets of markets.
use super::optimisation::DispatchRun;
use crate::agent::Agent;
use crate::asset::{Asset, AssetCapacity, AssetIterator, AssetRef, AssetState};
use crate::asset::{Asset, AssetCapacity, AssetIterator, AssetRef};
use crate::commodity::{Commodity, CommodityID};
use crate::model::Model;
use crate::output::DataWriter;
Expand All @@ -15,7 +15,6 @@ use crate::simulation::prices::Prices;
use crate::time_slice::TimeSliceInfo;
use crate::units::{Capacity, Dimensionless, Flow};
use anyhow::{Context, Result};
use indexmap::IndexMap;
use itertools::{Itertools, chain};
use log::debug;
use std::collections::HashMap;
Expand Down Expand Up @@ -56,8 +55,6 @@ impl MarketSet {
/// * `demand` – Net demand profiles available to all markets before selection.
/// * `existing_assets` – Assets already commissioned in the system.
/// * `prices` – Commodity price assumptions to use when valuing investments.
/// * `seen_markets` – Markets for which investments have already been settled.
/// * `previously_selected_assets` – Assets chosen in earlier market sets.
/// * `writer` – Data sink used to log optimisation artefacts.
#[allow(clippy::too_many_arguments)]
pub fn select_assets(
Expand All @@ -67,8 +64,6 @@ impl MarketSet {
demand: &AllDemandMap,
existing_assets: &[AssetRef],
prices: &Prices,
seen_markets: &[(CommodityID, RegionID)],
previously_selected_assets: &[AssetRef],
writer: &mut DataWriter,
) -> Result<Vec<AssetRef>> {
match self {
Expand All @@ -91,8 +86,6 @@ impl MarketSet {
demand,
existing_assets,
prices,
seen_markets,
previously_selected_assets,
writer,
)
.with_context(|| {
Expand All @@ -114,8 +107,6 @@ impl MarketSet {
demand,
existing_assets,
prices,
seen_markets,
previously_selected_assets,
writer,
)?;
all_assets.extend(assets);
Expand Down Expand Up @@ -236,17 +227,9 @@ pub fn select_assets_for_single_market(
/// Iterates through the a pre-ordered set of markets forming a cycle, selecting assets for each
/// market in turn.
///
/// Dispatch optimisation is performed after each market is visited to rebalance demand.
/// While dispatching, newly selected (`Ready`) assets are given flexible capacity (bounded by
/// `capacity_margin`) so small demand shifts caused by later markets can be absorbed. After all
/// markets have been visited once, the final set of assets is returned, applying any capacity
/// adjustments from the final full-system dispatch optimisation.
/// Dispatch optimisation is performed after each market is visited.
///
/// Dispatch may fail at any point if new demands are encountered for previously visited markets,
/// and the `capacity_margin` is not sufficient to absorb the demand shift. At this point, the
/// simulation is terminated with an error prompting the user to increase the `capacity_margin`.
/// A longer-term solution (TODO) may be to trigger re-investment for the affected markets. Other
/// yet-to-implement features may also help to stabilise the cycle, such as capacity growth limits.
/// Dispatch may fail at any point if new demands are encountered for previously visited markets.
#[allow(clippy::too_many_arguments)]
pub fn select_assets_for_cycle(
model: &Model,
Expand All @@ -255,122 +238,57 @@ pub fn select_assets_for_cycle(
demand: &AllDemandMap,
existing_assets: &[AssetRef],
prices: &Prices,
seen_markets: &[(CommodityID, RegionID)],
previously_selected_assets: &[AssetRef],
writer: &mut DataWriter,
) -> Result<Vec<AssetRef>> {
// Precompute a joined string for logging
let markets_str = markets.iter().map(|(c, r)| format!("{c}|{r}")).join(", ");

// Iterate over the markets to select assets
let mut current_demand = demand.clone();
let mut assets_for_cycle = IndexMap::new();
let mut last_solution = None;
for (idx, (commodity_id, region_id)) in markets.iter().enumerate() {
let mut net_demand = demand.clone();
let mut all_selected_assets = Vec::new();
for market in markets {
let (commodity_id, region_id) = market.clone();

// Select assets for this market
let assets = select_assets_for_single_market(
let selected_assets = select_assets_for_single_market(
model,
commodity_id,
region_id,
&commodity_id,
&region_id,
year,
&current_demand,
&net_demand,
existing_assets,
prices,
writer,
)?;
assets_for_cycle.insert((commodity_id.clone(), region_id.clone()), assets);

// Assemble full list of assets for dispatch (previously selected + all chosen so far)
let mut all_assets = previously_selected_assets.to_vec();
let assets_for_cycle_flat: Vec<_> = assets_for_cycle
.values()
.flat_map(|v| v.iter().cloned())
.collect();
all_assets.extend_from_slice(&assets_for_cycle_flat);

// We balance all previously seen markets plus all cycle markets up to and including this one
let mut markets_to_balance = seen_markets.to_vec();
markets_to_balance.extend_from_slice(&markets[0..=idx]);

// We allow all `Ready` state assets to have flexible capacity
let flexible_capacity_assets: Vec<_> = assets_for_cycle_flat
.iter()
.filter(|asset| matches!(asset.state(), AssetState::Ready { .. }))
.cloned()
.collect();

// Retrieve installable capacity limits for flexible capacity assets.
let mut agent_share_cache = HashMap::new();
let capacity_limits = flexible_capacity_assets
.iter()
.filter_map(|asset| {
let agent_id = asset.agent_id().unwrap();
let commodity_id = asset.primary_output_commodity().unwrap();
let agent_share = *agent_share_cache
.entry((agent_id, commodity_id))
.or_insert_with(|| {
model.agents[agent_id].commodity_portions[&(commodity_id.clone(), year)]
});
asset
.process()
.agent_addition_limit(asset.region_id(), asset.commission_year(), agent_share)
.map(|max_capacity| (asset.clone(), max_capacity))
})
.collect::<HashMap<_, _>>();

// If no assets have been selected, skip dispatch optimisation
// **TODO**: this probably means there's no demand for the market, which we could
// presumably preempt
if selected_assets.is_empty() {
continue;
}

all_selected_assets.extend(selected_assets.iter().cloned());

// Run dispatch
let solution = DispatchRun::new(model, &all_assets, year)
let solution = DispatchRun::new(model, &selected_assets, year, &net_demand)
.without_commodity_constraints()
.with_market_balance_subset(&markets_to_balance)
.with_flexible_capacity_assets(
&flexible_capacity_assets,
Some(&capacity_limits),
// Gives newly selected cycle assets limited capacity wiggle-room; existing assets stay fixed.
model.parameters.capacity_margin,
)
.with_market_balance_subset(std::slice::from_ref(market))
.run(
&format!("cycle ({markets_str}) post {commodity_id}|{region_id} investment"),
writer,
)
.with_context(|| {
format!(
"Cycle balancing failed for cycle ({markets_str}), capacity_margin: {}. \
Try increasing the capacity_margin.",
model.parameters.capacity_margin
)
})?;
.with_context(|| format!("Dispatch failed for cycle ({markets_str})"))?;

// Calculate new net demand map with all assets selected so far
current_demand.clone_from(demand);
// Update demand map with flows from newly selected assets
update_net_demand_map(
&mut current_demand,
&mut net_demand,
&solution.create_flow_map(),
&assets_for_cycle_flat,
&selected_assets,
);
last_solution = Some(solution);
}

// Finally, update flexible capacity assets based on the final solution
let mut all_cycle_assets: Vec<_> = assets_for_cycle.into_values().flatten().collect();
if let Some(solution) = last_solution {
let new_capacities: HashMap<_, _> = solution.iter_capacity().collect();
for asset in &mut all_cycle_assets {
if let Some(new_capacity) = new_capacities.get(asset) {
debug!(
"Capacity of asset '{}' modified during cycle balancing ({} to {})",
asset.process_id(),
asset.total_capacity(),
new_capacity.total_capacity()
);
asset.make_mut().set_capacity(*new_capacity);
}
}
}

// Drop any assets who's capacities were dropped to zero
all_cycle_assets.retain(|asset| asset.num_tranches() > 0);

Ok(all_cycle_assets)
Ok(all_selected_assets)
}

/// Get a portion of the demand profile for this market
Expand Down
Loading
Loading