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