Skip to main content

muse2/simulation/
market.rs

1//! Code for creating sets of markets.
2use super::optimisation::DispatchRun;
3use crate::agent::Agent;
4use crate::asset::{Asset, AssetCapacity, AssetIterator, AssetRef, AssetState};
5use crate::commodity::{Commodity, CommodityID};
6use crate::model::Model;
7use crate::output::DataWriter;
8use crate::region::RegionID;
9use crate::simulation::investment::{
10    AllDemandMap, DemandMap, calculate_candidate_asset_capacity_scale, select_best_assets,
11    update_net_demand_map,
12};
13use crate::simulation::prices::Prices;
14use crate::time_slice::TimeSliceInfo;
15use crate::units::{Capacity, Dimensionless, Flow};
16use anyhow::{Context, Result};
17use indexmap::IndexMap;
18use itertools::{Itertools, chain};
19use log::debug;
20use std::collections::HashMap;
21use std::fmt::Display;
22
23/// Represents a set of markets which are invested in together.
24#[derive(PartialEq, Debug, Clone, Eq, Hash)]
25pub enum MarketSet {
26    /// Assets are selected for a single market using [`select_assets_for_single_market`]
27    Single((CommodityID, RegionID)),
28    /// Assets are selected for a group of markets which forms a cycle.
29    /// Experimental: handled by [`select_assets_for_cycle`] and guarded by the broken options
30    /// parameter.
31    Cycle(Vec<(CommodityID, RegionID)>),
32    /// Assets are selected for a layer of independent [`MarketSet`]s
33    Layer(Vec<MarketSet>),
34}
35
36impl MarketSet {
37    /// Recursively iterate over all markets contained in this `MarketSet`.
38    pub fn iter_markets<'a>(
39        &'a self,
40    ) -> Box<dyn Iterator<Item = &'a (CommodityID, RegionID)> + 'a> {
41        match self {
42            MarketSet::Single(market) => Box::new(std::iter::once(market)),
43            MarketSet::Cycle(markets) => Box::new(markets.iter()),
44            MarketSet::Layer(set) => Box::new(set.iter().flat_map(|s| s.iter_markets())),
45        }
46    }
47
48    /// Selects assets for this market set variant and passes through the shared
49    /// context needed by single-market, cycle, or layered selection.
50    ///
51    /// # Arguments
52    ///
53    /// * `model` – Simulation model supplying parameters, processes, and dispatch.
54    /// * `year` – Planning year being solved.
55    /// * `demand` – Net demand profiles available to all markets before selection.
56    /// * `existing_assets` – Assets already commissioned in the system.
57    /// * `prices` – Commodity price assumptions to use when valuing investments.
58    /// * `seen_markets` – Markets for which investments have already been settled.
59    /// * `previously_selected_assets` – Assets chosen in earlier market sets.
60    /// * `writer` – Data sink used to log optimisation artefacts.
61    #[allow(clippy::too_many_arguments)]
62    pub fn select_assets(
63        &self,
64        model: &Model,
65        year: u32,
66        demand: &AllDemandMap,
67        existing_assets: &[AssetRef],
68        prices: &Prices,
69        seen_markets: &[(CommodityID, RegionID)],
70        previously_selected_assets: &[AssetRef],
71        writer: &mut DataWriter,
72    ) -> Result<Vec<AssetRef>> {
73        match self {
74            MarketSet::Single((commodity_id, region_id)) => select_assets_for_single_market(
75                model,
76                commodity_id,
77                region_id,
78                year,
79                demand,
80                existing_assets,
81                prices,
82                writer,
83            ),
84            MarketSet::Cycle(markets) => {
85                debug!("Starting investment for cycle '{self}'");
86                select_assets_for_cycle(
87                    model,
88                    markets,
89                    year,
90                    demand,
91                    existing_assets,
92                    prices,
93                    seen_markets,
94                    previously_selected_assets,
95                    writer,
96                )
97                .with_context(|| {
98                    format!(
99                        "Investments failed for market set {self} with cyclical dependencies. \
100                         Please note that the investment algorithm is currently experimental for \
101                         models with circular commodity dependencies and may not be able to find \
102                         a solution in all cases."
103                    )
104                })
105            }
106            MarketSet::Layer(investment_sets) => {
107                debug!("Starting asset selection for layer '{self}'");
108                let mut all_assets = Vec::new();
109                for investment_set in investment_sets {
110                    let assets = investment_set.select_assets(
111                        model,
112                        year,
113                        demand,
114                        existing_assets,
115                        prices,
116                        seen_markets,
117                        previously_selected_assets,
118                        writer,
119                    )?;
120                    all_assets.extend(assets);
121                }
122                debug!("Completed asset selection for layer '{self}'");
123                Ok(all_assets)
124            }
125        }
126    }
127}
128
129impl Display for MarketSet {
130    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131        match self {
132            MarketSet::Single((commodity_id, region_id)) => {
133                write!(f, "{commodity_id}|{region_id}")
134            }
135            MarketSet::Cycle(markets) => {
136                write!(
137                    f,
138                    "({})",
139                    markets.iter().map(|(c, r)| format!("{c}|{r}")).join(", ")
140                )
141            }
142            MarketSet::Layer(ids) => {
143                write!(f, "[{}]", ids.iter().join(", "))
144            }
145        }
146    }
147}
148
149/// Select assets for a single market in a given year
150///
151/// Returns a list of assets that are selected for investment for this market in this year.
152#[allow(clippy::too_many_arguments)]
153pub fn select_assets_for_single_market(
154    model: &Model,
155    commodity_id: &CommodityID,
156    region_id: &RegionID,
157    year: u32,
158    demand: &AllDemandMap,
159    existing_assets: &[AssetRef],
160    prices: &Prices,
161    writer: &mut DataWriter,
162) -> Result<Vec<AssetRef>> {
163    let commodity = &model.commodities[commodity_id];
164
165    let mut selected_assets = Vec::new();
166    for (agent, commodity_portion) in
167        get_responsible_agents(model.agents.values(), commodity_id, region_id, year)
168    {
169        debug!(
170            "Running asset selection for agent '{}' in market '{}|{}'",
171            agent.id, commodity_id, region_id
172        );
173
174        // Get demand portion for this market for this agent in this year
175        let demand_portion_for_market = get_demand_portion_for_market(
176            &model.time_slice_info,
177            demand,
178            commodity_id,
179            region_id,
180            commodity_portion,
181        );
182
183        // Existing and candidate assets from which to choose
184        let opt_assets = get_asset_options(
185            existing_assets,
186            &demand_portion_for_market,
187            agent,
188            commodity,
189            region_id,
190            year,
191            model.parameters.capacity_limit_factor,
192        )
193        .collect::<Vec<_>>();
194
195        // Calculate investment limits for candidate assets
196        let candidate_investment_limits =
197            collect_investment_limits_for_candidates(&opt_assets, commodity_portion);
198
199        // Choose assets from among existing pool and candidates
200        let best_assets = select_best_assets(
201            model,
202            opt_assets,
203            candidate_investment_limits,
204            commodity,
205            agent,
206            region_id,
207            prices,
208            demand_portion_for_market,
209            year,
210            writer,
211        )?;
212        selected_assets.extend(best_assets);
213    }
214
215    Ok(selected_assets)
216}
217
218/// Iterates through the a pre-ordered set of markets forming a cycle, selecting assets for each
219/// market in turn.
220///
221/// Dispatch optimisation is performed after each market is visited to rebalance demand.
222/// While dispatching, newly selected (`Ready`) assets are given flexible capacity (bounded by
223/// `capacity_margin`) so small demand shifts caused by later markets can be absorbed. After all
224/// markets have been visited once, the final set of assets is returned, applying any capacity
225/// adjustments from the final full-system dispatch optimisation.
226///
227/// Dispatch may fail at any point if new demands are encountered for previously visited markets,
228/// and the `capacity_margin` is not sufficient to absorb the demand shift. At this point, the
229/// simulation is terminated with an error prompting the user to increase the `capacity_margin`.
230/// A longer-term solution (TODO) may be to trigger re-investment for the affected markets. Other
231/// yet-to-implement features may also help to stabilise the cycle, such as capacity growth limits.
232#[allow(clippy::too_many_arguments)]
233pub fn select_assets_for_cycle(
234    model: &Model,
235    markets: &[(CommodityID, RegionID)],
236    year: u32,
237    demand: &AllDemandMap,
238    existing_assets: &[AssetRef],
239    prices: &Prices,
240    seen_markets: &[(CommodityID, RegionID)],
241    previously_selected_assets: &[AssetRef],
242    writer: &mut DataWriter,
243) -> Result<Vec<AssetRef>> {
244    // Precompute a joined string for logging
245    let markets_str = markets.iter().map(|(c, r)| format!("{c}|{r}")).join(", ");
246
247    // Iterate over the markets to select assets
248    let mut current_demand = demand.clone();
249    let mut assets_for_cycle = IndexMap::new();
250    let mut last_solution = None;
251    for (idx, (commodity_id, region_id)) in markets.iter().enumerate() {
252        // Select assets for this market
253        let assets = select_assets_for_single_market(
254            model,
255            commodity_id,
256            region_id,
257            year,
258            &current_demand,
259            existing_assets,
260            prices,
261            writer,
262        )?;
263        assets_for_cycle.insert((commodity_id.clone(), region_id.clone()), assets);
264
265        // Assemble full list of assets for dispatch (previously selected + all chosen so far)
266        let mut all_assets = previously_selected_assets.to_vec();
267        let assets_for_cycle_flat: Vec<_> = assets_for_cycle
268            .values()
269            .flat_map(|v| v.iter().cloned())
270            .collect();
271        all_assets.extend_from_slice(&assets_for_cycle_flat);
272
273        // We balance all previously seen markets plus all cycle markets up to and including this one
274        let mut markets_to_balance = seen_markets.to_vec();
275        markets_to_balance.extend_from_slice(&markets[0..=idx]);
276
277        // We allow all `Ready` state assets to have flexible capacity
278        let flexible_capacity_assets: Vec<_> = assets_for_cycle_flat
279            .iter()
280            .filter(|asset| matches!(asset.state(), AssetState::Ready { .. }))
281            .cloned()
282            .collect();
283
284        // Retrieve installable capacity limits for flexible capacity assets.
285        let mut agent_share_cache = HashMap::new();
286        let capacity_limits = flexible_capacity_assets
287            .iter()
288            .filter_map(|asset| {
289                let agent_id = asset.agent_id().unwrap();
290                let commodity_id = asset.primary_output_commodity().unwrap();
291                let agent_share = *agent_share_cache
292                    .entry((agent_id, commodity_id))
293                    .or_insert_with(|| {
294                        model.agents[agent_id].commodity_portions[&(commodity_id.clone(), year)]
295                    });
296                asset
297                    .max_installable_capacity(agent_share)
298                    .map(|max_capacity| (asset.clone(), max_capacity))
299            })
300            .collect::<HashMap<_, _>>();
301
302        // Run dispatch
303        let solution = DispatchRun::new(model, &all_assets, year)
304            .with_market_balance_subset(&markets_to_balance)
305            .with_flexible_capacity_assets(
306                &flexible_capacity_assets,
307                Some(&capacity_limits),
308                // Gives newly selected cycle assets limited capacity wiggle-room; existing assets stay fixed.
309                model.parameters.capacity_margin,
310            )
311            .run(
312                &format!("cycle ({markets_str}) post {commodity_id}|{region_id} investment"),
313                writer,
314            )
315            .with_context(|| {
316                format!(
317                    "Cycle balancing failed for cycle ({markets_str}), capacity_margin: {}. \
318                     Try increasing the capacity_margin.",
319                    model.parameters.capacity_margin
320                )
321            })?;
322
323        // Calculate new net demand map with all assets selected so far
324        current_demand.clone_from(demand);
325        update_net_demand_map(
326            &mut current_demand,
327            &solution.create_flow_map(),
328            &assets_for_cycle_flat,
329        );
330        last_solution = Some(solution);
331    }
332
333    // Finally, update flexible capacity assets based on the final solution
334    let mut all_cycle_assets: Vec<_> = assets_for_cycle.into_values().flatten().collect();
335    if let Some(solution) = last_solution {
336        let new_capacities: HashMap<_, _> = solution.iter_capacity().collect();
337        for asset in &mut all_cycle_assets {
338            if let Some(new_capacity) = new_capacities.get(asset) {
339                debug!(
340                    "Capacity of asset '{}' modified during cycle balancing ({} to {})",
341                    asset.process_id(),
342                    asset.total_capacity(),
343                    new_capacity.total_capacity()
344                );
345                asset.make_mut().set_capacity(*new_capacity);
346            }
347        }
348    }
349
350    Ok(all_cycle_assets)
351}
352
353/// Get a portion of the demand profile for this market
354fn get_demand_portion_for_market(
355    time_slice_info: &TimeSliceInfo,
356    demand: &AllDemandMap,
357    commodity_id: &CommodityID,
358    region_id: &RegionID,
359    commodity_portion: Dimensionless,
360) -> DemandMap {
361    time_slice_info
362        .iter_ids()
363        .map(|time_slice| {
364            (
365                time_slice.clone(),
366                commodity_portion
367                    * *demand
368                        .get(&(commodity_id.clone(), region_id.clone(), time_slice.clone()))
369                        .unwrap_or(&Flow(0.0)),
370            )
371        })
372        .collect()
373}
374
375/// Get the agents responsible for a given market in a given year along with the commodity
376/// portion for which they are responsible
377fn get_responsible_agents<'a, I>(
378    agents: I,
379    commodity_id: &'a CommodityID,
380    region_id: &'a RegionID,
381    year: u32,
382) -> impl Iterator<Item = (&'a Agent, Dimensionless)>
383where
384    I: Iterator<Item = &'a Agent>,
385{
386    agents.filter_map(move |agent| {
387        if !agent.regions.contains(region_id) {
388            return None;
389        }
390        let portion = agent
391            .commodity_portions
392            .get(&(commodity_id.clone(), year))?;
393
394        Some((agent, *portion))
395    })
396}
397
398/// Get options from existing and potential assets for the given parameters
399fn get_asset_options<'a>(
400    all_existing_assets: &'a [AssetRef],
401    demand: &'a DemandMap,
402    agent: &'a Agent,
403    commodity: &'a Commodity,
404    region_id: &'a RegionID,
405    year: u32,
406    capacity_limit_factor: Dimensionless,
407) -> impl Iterator<Item = AssetRef> + 'a {
408    // Get existing assets which produce the commodity of interest
409    let existing_assets = all_existing_assets
410        .iter()
411        .filter_agent(&agent.id)
412        .filter_region(region_id)
413        .filter_primary_producers_of(&commodity.id)
414        .cloned();
415
416    // Get candidates assets which produce the commodity of interest
417    let candidate_assets = get_candidate_assets(
418        demand,
419        agent,
420        region_id,
421        commodity,
422        year,
423        capacity_limit_factor,
424    );
425
426    chain(existing_assets, candidate_assets)
427}
428
429/// Get candidate assets which produce a particular commodity for a given agent
430///
431/// Each candidate is assigned a capacity. For divisible assets, the capacity is set to 1 unit.
432/// For indivisible assets, a capacity is calculated based on the total demand for the commodity and
433/// the asset's maximum annual production per unit capacity
434/// (see `calculate_candidate_asset_capacity_scale`), then multiplied by `capacity_limit_factor`.
435fn get_candidate_assets<'a>(
436    demand: &'a DemandMap,
437    agent: &'a Agent,
438    region_id: &'a RegionID,
439    commodity: &'a Commodity,
440    year: u32,
441    capacity_limit_factor: Dimensionless,
442) -> impl Iterator<Item = AssetRef> + 'a {
443    agent
444        .iter_search_space(region_id, &commodity.id, year)
445        .map(move |process| {
446            // Create asset with zero capacity, which will be updated below
447            let mut asset =
448                Asset::new_candidate(process.clone(), region_id.clone(), Capacity(0.0), year)
449                    .unwrap();
450
451            // Set capacity of the candidate
452            // This will serve as the upper limit when appraising the asset (may later be
453            // constrained by process addition limits and demand-limiting capacity)
454            let asset_capacity = if let Some(unit_size) = asset.unit_size() {
455                AssetCapacity::Discrete(1, unit_size)
456            } else {
457                let capacity_scale =
458                    calculate_candidate_asset_capacity_scale(&asset, commodity, demand);
459                AssetCapacity::Continuous(capacity_scale * capacity_limit_factor)
460            };
461            asset.set_capacity(asset_capacity);
462            asset.into()
463        })
464}
465
466/// Investment limits are based on any annual addition limits specified by the process, scaled
467/// according to the agent's portion of the commodity demand and the number of years elapsed since
468/// the previous milestone year.
469pub fn collect_investment_limits_for_candidates(
470    opt_assets: &[AssetRef],
471    commodity_portion: Dimensionless,
472) -> HashMap<AssetRef, AssetCapacity> {
473    opt_assets
474        .iter()
475        .filter(|asset| asset.is_candidate())
476        .filter_map(|asset| {
477            asset
478                .max_installable_capacity(commodity_portion)
479                .map(|limit_capacity| (asset.clone(), limit_capacity))
480        })
481        .collect()
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487    use crate::fixture::{
488        asset, process, process_activity_limits_map, process_flows_map, process_parameter_map,
489        region_id,
490    };
491    use crate::process::{
492        Process, ProcessActivityLimitsMap, ProcessFlowsMap, ProcessInvestmentConstraint,
493        ProcessInvestmentConstraintsMap, ProcessParameterMap,
494    };
495    use crate::region::RegionID;
496    use crate::units::Dimensionless;
497    use crate::units::{ActivityPerCapacity, Capacity};
498    use indexmap::IndexSet;
499    use rstest::{fixture, rstest};
500    use std::rc::Rc;
501    use std::slice::from_ref;
502
503    #[rstest]
504    fn collect_investment_limits_for_candidates_empty_list() {
505        let result = collect_investment_limits_for_candidates(&[], Dimensionless(1.0));
506        assert!(result.is_empty());
507    }
508
509    #[fixture]
510    fn commissioned_asset(asset: Asset) -> AssetRef {
511        asset.into()
512    }
513
514    #[fixture]
515    fn uncommissioned_asset_without_limit(process: Process, region_id: RegionID) -> AssetRef {
516        Asset::new_candidate(Rc::new(process), region_id, Capacity(10.0), 2015)
517            .unwrap()
518            .into()
519    }
520
521    #[fixture]
522    fn uncommissioned_asset_with_limit(
523        region_id: RegionID,
524        process_activity_limits_map: ProcessActivityLimitsMap,
525        process_flows_map: ProcessFlowsMap,
526        process_parameter_map: ProcessParameterMap,
527    ) -> AssetRef {
528        let region_ids: IndexSet<RegionID> = [region_id.clone()].into();
529
530        let mut constraints = ProcessInvestmentConstraintsMap::new();
531
532        constraints.insert(
533            (region_id.clone(), 2015),
534            Rc::new(ProcessInvestmentConstraint {
535                addition_limit: Some(Capacity(10.0)),
536            }),
537        );
538
539        let process = Process {
540            id: "constrained_process".into(),
541            description: String::new(),
542            years: 2010..=2020,
543            activity_limits: process_activity_limits_map,
544            flows: process_flows_map,
545            parameters: process_parameter_map,
546            regions: region_ids,
547            primary_output: None,
548            capacity_to_activity: ActivityPerCapacity(1.0),
549            investment_constraints: constraints,
550            unit_size: None,
551        };
552
553        Asset::new_candidate(Rc::new(process), region_id, Capacity(15.0), 2015)
554            .unwrap()
555            .into()
556    }
557
558    #[rstest]
559    fn commissioned_assets_are_excluded(commissioned_asset: AssetRef) {
560        let result = collect_investment_limits_for_candidates(
561            from_ref(&commissioned_asset),
562            Dimensionless(1.0),
563        );
564
565        assert!(!result.contains_key(&commissioned_asset));
566    }
567
568    #[rstest]
569    fn candidate_assets_without_limits_are_excluded(uncommissioned_asset_without_limit: AssetRef) {
570        let result = collect_investment_limits_for_candidates(
571            from_ref(&uncommissioned_asset_without_limit),
572            Dimensionless(1.0),
573        );
574
575        assert!(!result.contains_key(&uncommissioned_asset_without_limit));
576    }
577
578    #[rstest]
579    fn candidate_assets_with_limits_are_included(uncommissioned_asset_with_limit: AssetRef) {
580        let result = collect_investment_limits_for_candidates(
581            from_ref(&uncommissioned_asset_with_limit),
582            Dimensionless(1.0),
583        );
584
585        assert!(result.contains_key(&uncommissioned_asset_with_limit));
586    }
587}