muse2/simulation/
investment.rs

1//! Code for performing agent investment.
2use super::optimisation::{DispatchRun, FlowMap};
3use crate::agent::{Agent, AgentID};
4use crate::asset::{Asset, AssetCapacity, AssetIterator, AssetRef, AssetState};
5use crate::commodity::{Commodity, CommodityID, CommodityMap};
6use crate::model::Model;
7use crate::output::DataWriter;
8use crate::region::RegionID;
9use crate::simulation::CommodityPrices;
10use crate::time_slice::{TimeSliceID, TimeSliceInfo};
11use crate::units::{Capacity, Dimensionless, Flow, FlowPerCapacity};
12use anyhow::{Context, Result, bail, ensure};
13use indexmap::IndexMap;
14use itertools::{Itertools, chain};
15use log::debug;
16use std::collections::{HashMap, HashSet};
17use std::fmt::Display;
18
19pub mod appraisal;
20use appraisal::coefficients::calculate_coefficients_for_assets;
21use appraisal::{
22    AppraisalOutput, appraise_investment, count_equal_and_best_appraisal_outputs,
23    sort_appraisal_outputs_by_investment_priority,
24};
25
26/// A map of demand across time slices for a specific market
27type DemandMap = IndexMap<TimeSliceID, Flow>;
28
29/// Demand for a given combination of commodity, region and time slice
30type AllDemandMap = IndexMap<(CommodityID, RegionID, TimeSliceID), Flow>;
31
32/// Represents a set of markets which are invested in together.
33#[derive(PartialEq, Debug, Clone, Eq, Hash)]
34pub enum InvestmentSet {
35    /// Assets are selected for a single market using `select_assets_for_single_market`
36    Single((CommodityID, RegionID)),
37    /// Assets are selected for a group of markets which forms a cycle.
38    /// Experimental: handled by `select_assets_for_cycle` and guarded by the broken options
39    /// parameter.
40    Cycle(Vec<(CommodityID, RegionID)>),
41    /// Assets are selected for a layer of independent `InvestmentSet`s
42    Layer(Vec<InvestmentSet>),
43}
44
45impl InvestmentSet {
46    /// Recursively iterate over all markets contained in this `InvestmentSet`.
47    pub fn iter_markets<'a>(
48        &'a self,
49    ) -> Box<dyn Iterator<Item = &'a (CommodityID, RegionID)> + 'a> {
50        match self {
51            InvestmentSet::Single(market) => Box::new(std::iter::once(market)),
52            InvestmentSet::Cycle(markets) => Box::new(markets.iter()),
53            InvestmentSet::Layer(set) => Box::new(set.iter().flat_map(|s| s.iter_markets())),
54        }
55    }
56
57    /// Selects assets for this investment set variant and passes through the shared
58    /// context needed by single-market, cycle, or layered selection.
59    ///
60    /// # Arguments
61    ///
62    /// * `model` – Simulation model supplying parameters, processes, and dispatch.
63    /// * `year` – Planning year being solved.
64    /// * `demand` – Net demand profiles available to all markets before selection.
65    /// * `existing_assets` – Assets already commissioned in the system.
66    /// * `prices` – Commodity price assumptions to use when valuing investments.
67    /// * `seen_markets` – Markets for which investments have already been settled.
68    /// * `previously_selected_assets` – Assets chosen in earlier investment sets.
69    /// * `writer` – Data sink used to log optimisation artefacts.
70    #[allow(clippy::too_many_arguments)]
71    fn select_assets(
72        &self,
73        model: &Model,
74        year: u32,
75        demand: &AllDemandMap,
76        existing_assets: &[AssetRef],
77        prices: &CommodityPrices,
78        seen_markets: &[(CommodityID, RegionID)],
79        previously_selected_assets: &[AssetRef],
80        writer: &mut DataWriter,
81    ) -> Result<Vec<AssetRef>> {
82        match self {
83            InvestmentSet::Single((commodity_id, region_id)) => select_assets_for_single_market(
84                model,
85                commodity_id,
86                region_id,
87                year,
88                demand,
89                existing_assets,
90                prices,
91                writer,
92            ),
93            InvestmentSet::Cycle(markets) => {
94                debug!("Starting investment for cycle '{self}'");
95                select_assets_for_cycle(
96                    model,
97                    markets,
98                    year,
99                    demand,
100                    existing_assets,
101                    prices,
102                    seen_markets,
103                    previously_selected_assets,
104                    writer,
105                )
106                .with_context(|| {
107                    format!(
108                        "Investments failed for market set {self} with cyclical dependencies. \
109                         Please note that the investment algorithm is currently experimental for \
110                         models with circular commodity dependencies and may not be able to find \
111                         a solution in all cases."
112                    )
113                })
114            }
115            InvestmentSet::Layer(investment_sets) => {
116                debug!("Starting asset selection for layer '{self}'");
117                let mut all_assets = Vec::new();
118                for investment_set in investment_sets {
119                    let assets = investment_set.select_assets(
120                        model,
121                        year,
122                        demand,
123                        existing_assets,
124                        prices,
125                        seen_markets,
126                        previously_selected_assets,
127                        writer,
128                    )?;
129                    all_assets.extend(assets);
130                }
131                debug!("Completed asset selection for layer '{self}'");
132                Ok(all_assets)
133            }
134        }
135    }
136}
137
138impl Display for InvestmentSet {
139    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140        match self {
141            InvestmentSet::Single((commodity_id, region_id)) => {
142                write!(f, "{commodity_id}|{region_id}")
143            }
144            InvestmentSet::Cycle(markets) => {
145                write!(
146                    f,
147                    "({})",
148                    markets.iter().map(|(c, r)| format!("{c}|{r}")).join(", ")
149                )
150            }
151            InvestmentSet::Layer(ids) => {
152                write!(f, "[{}]", ids.iter().join(", "))
153            }
154        }
155    }
156}
157
158/// Perform agent investment to determine capacity investment of new assets for next milestone year.
159///
160/// # Arguments
161///
162/// * `model` - The model
163/// * `year` - Current milestone year
164/// * `existing_assets` - The asset pool (commissioned and otherwise)
165/// * `prices` - Commodity prices calculated in the previous full system dispatch
166/// * `writer` - Data writer
167///
168/// # Returns
169///
170/// The assets selected (including retained commissioned assets) for the given planning `year` or an
171/// error.
172pub fn perform_agent_investment(
173    model: &Model,
174    year: u32,
175    existing_assets: &[AssetRef],
176    prices: &CommodityPrices,
177    writer: &mut DataWriter,
178) -> Result<Vec<AssetRef>> {
179    // Initialise net demand map
180    let mut net_demand =
181        flatten_preset_demands_for_year(&model.commodities, &model.time_slice_info, year);
182
183    // Keep a list of all the assets selected
184    // This includes Commissioned assets that are selected for retention, and new Selected assets
185    let mut all_selected_assets = Vec::new();
186
187    let investment_order = &model.investment_order[&year];
188    debug!(
189        "Investment order for year '{year}': {}",
190        investment_order.iter().join(" -> ")
191    );
192
193    // Keep track of the markets that have been seen so far. This will be used to apply
194    // balance constraints in the dispatch optimisation - we only apply balance constraints for
195    // markets that have been seen so far.
196    let mut seen_markets = Vec::new();
197
198    // Iterate over investment sets in the investment order for this year
199    for investment_set in investment_order {
200        // Select assets for this investment set
201        let selected_assets = investment_set.select_assets(
202            model,
203            year,
204            &net_demand,
205            existing_assets,
206            prices,
207            &seen_markets,
208            &all_selected_assets,
209            writer,
210        )?;
211
212        // Update our list of seen markets
213        for market in investment_set.iter_markets() {
214            seen_markets.push(market.clone());
215        }
216
217        // If no assets have been selected, skip dispatch optimisation
218        // **TODO**: this probably means there's no demand for the market, which we could
219        // presumably preempt
220        if selected_assets.is_empty() {
221            debug!("No assets selected for '{investment_set}'");
222            continue;
223        }
224
225        // Add the selected assets to the list of all selected assets
226        all_selected_assets.extend(selected_assets.iter().cloned());
227
228        // Perform dispatch optimisation with assets that have been selected so far
229        // **TODO**: presumably we only need to do this for selected_assets, as assets added in
230        // previous iterations should not change
231        debug!("Running post-investment dispatch for '{investment_set}'");
232
233        // As upstream markets by definition will not yet have producers, we explicitly set
234        // their prices using external values so that they don't appear free
235        let solution = DispatchRun::new(model, &all_selected_assets, year)
236            .with_market_balance_subset(&seen_markets)
237            .with_input_prices(prices)
238            .run(&format!("post {investment_set} investment"), writer)?;
239
240        // Update demand map with flows from newly added assets
241        update_net_demand_map(
242            &mut net_demand,
243            &solution.create_flow_map(),
244            &selected_assets,
245        );
246    }
247
248    Ok(all_selected_assets)
249}
250
251/// Select assets for a single market in a given year
252///
253/// Returns a list of assets that are selected for investment for this market in this year.
254#[allow(clippy::too_many_arguments)]
255fn select_assets_for_single_market(
256    model: &Model,
257    commodity_id: &CommodityID,
258    region_id: &RegionID,
259    year: u32,
260    demand: &AllDemandMap,
261    existing_assets: &[AssetRef],
262    prices: &CommodityPrices,
263    writer: &mut DataWriter,
264) -> Result<Vec<AssetRef>> {
265    let commodity = &model.commodities[commodity_id];
266
267    let mut selected_assets = Vec::new();
268    for (agent, commodity_portion) in
269        get_responsible_agents(model.agents.values(), commodity_id, region_id, year)
270    {
271        debug!(
272            "Running asset selection for agent '{}' in market '{}|{}'",
273            &agent.id, commodity_id, region_id
274        );
275
276        // Get demand portion for this market for this agent in this year
277        let demand_portion_for_market = get_demand_portion_for_market(
278            &model.time_slice_info,
279            demand,
280            commodity_id,
281            region_id,
282            commodity_portion,
283        );
284
285        // Existing and candidate assets from which to choose
286        let opt_assets = get_asset_options(
287            &model.time_slice_info,
288            existing_assets,
289            &demand_portion_for_market,
290            agent,
291            commodity,
292            region_id,
293            year,
294        )
295        .collect::<Vec<_>>();
296
297        // Calculate investment limits for candidate assets
298        let investment_limits =
299            calculate_investment_limits_for_candidates(&opt_assets, commodity_portion);
300
301        // Choose assets from among existing pool and candidates
302        let best_assets = select_best_assets(
303            model,
304            opt_assets,
305            investment_limits,
306            commodity,
307            agent,
308            region_id,
309            prices,
310            demand_portion_for_market,
311            year,
312            writer,
313        )?;
314        selected_assets.extend(best_assets);
315    }
316
317    Ok(selected_assets)
318}
319
320/// Iterates through the a pre-ordered set of markets forming a cycle, selecting assets for each
321/// market in turn.
322///
323/// Dispatch optimisation is performed after each market is visited to rebalance demand.
324/// While dispatching, newly selected (`Selected`) assets are given flexible capacity (bounded by
325/// `capacity_margin`) so small demand shifts caused by later markets can be absorbed. After all
326/// markets have been visited once, the final set of assets is returned, applying any capacity
327/// adjustments from the final full-system dispatch optimisation.
328///
329/// Dispatch may fail at any point if new demands are encountered for previously visited markets,
330/// and the `capacity_margin` is not sufficient to absorb the demand shift. At this point, the
331/// simulation is terminated with an error prompting the user to increase the `capacity_margin`.
332/// A longer-term solution (TODO) may be to trigger re-investment for the affected markets. Other
333/// yet-to-implement features may also help to stabilise the cycle, such as capacity growth limits.
334#[allow(clippy::too_many_arguments)]
335fn select_assets_for_cycle(
336    model: &Model,
337    markets: &[(CommodityID, RegionID)],
338    year: u32,
339    demand: &AllDemandMap,
340    existing_assets: &[AssetRef],
341    prices: &CommodityPrices,
342    seen_markets: &[(CommodityID, RegionID)],
343    previously_selected_assets: &[AssetRef],
344    writer: &mut DataWriter,
345) -> Result<Vec<AssetRef>> {
346    // Precompute a joined string for logging
347    let markets_str = markets.iter().map(|(c, r)| format!("{c}|{r}")).join(", ");
348
349    // Iterate over the markets to select assets
350    let mut current_demand = demand.clone();
351    let mut assets_for_cycle = IndexMap::new();
352    let mut last_solution = None;
353    for (idx, (commodity_id, region_id)) in markets.iter().enumerate() {
354        // Select assets for this market
355        let assets = select_assets_for_single_market(
356            model,
357            commodity_id,
358            region_id,
359            year,
360            &current_demand,
361            existing_assets,
362            prices,
363            writer,
364        )?;
365        assets_for_cycle.insert((commodity_id.clone(), region_id.clone()), assets);
366
367        // Assemble full list of assets for dispatch (previously selected + all chosen so far)
368        let mut all_assets = previously_selected_assets.to_vec();
369        let assets_for_cycle_flat: Vec<_> = assets_for_cycle
370            .values()
371            .flat_map(|v| v.iter().cloned())
372            .collect();
373        all_assets.extend_from_slice(&assets_for_cycle_flat);
374
375        // We balance all previously seen markets plus all cycle markets up to and including this one
376        let mut markets_to_balance = seen_markets.to_vec();
377        markets_to_balance.extend_from_slice(&markets[0..=idx]);
378
379        // We allow all `Selected` state assets to have flexible capacity
380        let flexible_capacity_assets: Vec<_> = assets_for_cycle_flat
381            .iter()
382            .filter(|asset| matches!(asset.state(), AssetState::Selected { .. }))
383            .cloned()
384            .collect();
385
386        // Retrieve installable capacity limits for flexible capacity assets.
387        let key = (commodity_id.clone(), year);
388        let mut agent_share_cache = HashMap::new();
389        let capacity_limits = flexible_capacity_assets
390            .iter()
391            .filter_map(|asset| {
392                let agent_id = asset.agent_id().unwrap();
393                let agent_share = *agent_share_cache
394                    .entry(agent_id.clone())
395                    .or_insert_with(|| model.agents[agent_id].commodity_portions[&key]);
396                asset
397                    .max_installable_capacity(agent_share)
398                    .map(|max_capacity| (asset.clone(), max_capacity))
399            })
400            .collect::<HashMap<_, _>>();
401
402        // Run dispatch
403        let solution = DispatchRun::new(model, &all_assets, year)
404            .with_market_balance_subset(&markets_to_balance)
405            .with_flexible_capacity_assets(
406                &flexible_capacity_assets,
407                Some(&capacity_limits),
408                // Gives newly selected cycle assets limited capacity wiggle-room; existing assets stay fixed.
409                model.parameters.capacity_margin,
410            )
411            .run(
412                &format!("cycle ({markets_str}) post {commodity_id}|{region_id} investment",),
413                writer,
414            )
415            .with_context(|| {
416                format!(
417                    "Cycle balancing failed for cycle ({markets_str}), capacity_margin: {}. \
418                     Try increasing the capacity_margin.",
419                    model.parameters.capacity_margin
420                )
421            })?;
422
423        // Calculate new net demand map with all assets selected so far
424        current_demand.clone_from(demand);
425        update_net_demand_map(
426            &mut current_demand,
427            &solution.create_flow_map(),
428            &assets_for_cycle_flat,
429        );
430        last_solution = Some(solution);
431    }
432
433    // Finally, update flexible capacity assets based on the final solution
434    let mut all_cycle_assets: Vec<_> = assets_for_cycle.into_values().flatten().collect();
435    if let Some(solution) = last_solution {
436        let new_capacities: HashMap<_, _> = solution.iter_capacity().collect();
437        for asset in &mut all_cycle_assets {
438            if let Some(new_capacity) = new_capacities.get(asset) {
439                debug!(
440                    "Capacity of asset '{}' modified during cycle balancing ({} to {})",
441                    asset.process_id(),
442                    asset.total_capacity(),
443                    new_capacity.total_capacity()
444                );
445                asset.make_mut().set_capacity(*new_capacity);
446            }
447        }
448    }
449
450    Ok(all_cycle_assets)
451}
452
453/// Flatten the preset commodity demands for a given year into a map of commodity, region and
454/// time slice to demand.
455///
456/// Since demands for some commodities may be specified at a coarser time slice level, we need to
457/// distribute these demands over all time slices. Note: the way that we do this distribution is
458/// irrelevant, as demands will only be balanced to the appropriate level, but we still need to do
459/// this for the solver to work.
460///
461/// **TODO**: these assumptions may need to be revisited, e.g. when we come to storage technologies
462fn flatten_preset_demands_for_year(
463    commodities: &CommodityMap,
464    time_slice_info: &TimeSliceInfo,
465    year: u32,
466) -> AllDemandMap {
467    let mut demand_map = AllDemandMap::new();
468    for (commodity_id, commodity) in commodities {
469        for ((region_id, data_year, time_slice_selection), demand) in &commodity.demand {
470            if *data_year != year {
471                continue;
472            }
473
474            // We split the demand equally over all time slices in the selection
475            // NOTE: since demands will only be balanced to the time slice level of the commodity
476            // it doesn't matter how we do this distribution, only the total matters.
477            #[allow(clippy::cast_precision_loss)]
478            let n_time_slices = time_slice_selection.iter(time_slice_info).count() as f64;
479            let demand_per_slice = *demand / Dimensionless(n_time_slices);
480            for (time_slice, _) in time_slice_selection.iter(time_slice_info) {
481                demand_map.insert(
482                    (commodity_id.clone(), region_id.clone(), time_slice.clone()),
483                    demand_per_slice,
484                );
485            }
486        }
487    }
488    demand_map
489}
490
491/// Update net demand map with flows from a set of assets
492///
493/// Non-primary output flows are ignored. This way, demand profiles aren't affected by production
494/// of side-products from other assets. The result is that all commodity demands must be met by
495/// assets with that commodity as their primary output. Effectively, agents do not see production of
496/// side-products from other assets when making investment decisions.
497///
498/// TODO: this is a very flawed approach. The proper solution might be for agents to consider
499/// multiple commodities simultaneously, but that would require substantial work to implement.
500fn update_net_demand_map(demand: &mut AllDemandMap, flows: &FlowMap, assets: &[AssetRef]) {
501    for ((asset, commodity_id, time_slice), flow) in flows {
502        if assets.contains(asset) {
503            let key = (
504                commodity_id.clone(),
505                asset.region_id().clone(),
506                time_slice.clone(),
507            );
508
509            // Only consider input flows and output flows from the primary output commodity
510            // (excluding secondary outputs)
511            if (flow < &Flow(0.0))
512                || asset
513                    .primary_output()
514                    .is_some_and(|p| &p.commodity.id == commodity_id)
515            {
516                // Note: we use the negative of the flow as input flows are negative in the flow map.
517                demand
518                    .entry(key)
519                    .and_modify(|value| *value -= *flow)
520                    .or_insert(-*flow);
521            }
522        }
523    }
524}
525
526/// Get a portion of the demand profile for this market
527fn get_demand_portion_for_market(
528    time_slice_info: &TimeSliceInfo,
529    demand: &AllDemandMap,
530    commodity_id: &CommodityID,
531    region_id: &RegionID,
532    commodity_portion: Dimensionless,
533) -> DemandMap {
534    time_slice_info
535        .iter_ids()
536        .map(|time_slice| {
537            (
538                time_slice.clone(),
539                commodity_portion
540                    * *demand
541                        .get(&(commodity_id.clone(), region_id.clone(), time_slice.clone()))
542                        .unwrap_or(&Flow(0.0)),
543            )
544        })
545        .collect()
546}
547
548/// Get the agents responsible for a given market in a given year along with the commodity
549/// portion for which they are responsible
550fn get_responsible_agents<'a, I>(
551    agents: I,
552    commodity_id: &'a CommodityID,
553    region_id: &'a RegionID,
554    year: u32,
555) -> impl Iterator<Item = (&'a Agent, Dimensionless)>
556where
557    I: Iterator<Item = &'a Agent>,
558{
559    agents.filter_map(move |agent| {
560        if !agent.regions.contains(region_id) {
561            return None;
562        }
563        let portion = agent
564            .commodity_portions
565            .get(&(commodity_id.clone(), year))?;
566
567        Some((agent, *portion))
568    })
569}
570
571/// Get the maximum required capacity across time slices
572fn get_demand_limiting_capacity(
573    time_slice_info: &TimeSliceInfo,
574    asset: &Asset,
575    commodity: &Commodity,
576    demand: &DemandMap,
577) -> Capacity {
578    let coeff = asset.get_flow(&commodity.id).unwrap().coeff;
579    let mut capacity = Capacity(0.0);
580
581    for time_slice_selection in time_slice_info.iter_selections_at_level(commodity.time_slice_level)
582    {
583        let demand_for_selection: Flow = time_slice_selection
584            .iter(time_slice_info)
585            .map(|(time_slice, _)| demand[time_slice])
586            .sum();
587
588        // Calculate max capacity required for this time slice selection
589        // For commodities with a coarse time slice level, we have to allow the possibility that all
590        // of the demand gets served by production in a single time slice
591        for (time_slice, _) in time_slice_selection.iter(time_slice_info) {
592            let max_flow_per_cap =
593                *asset.get_activity_per_capacity_limits(time_slice).end() * coeff;
594            if max_flow_per_cap != FlowPerCapacity(0.0) {
595                capacity = capacity.max(demand_for_selection / max_flow_per_cap);
596            }
597        }
598    }
599
600    capacity
601}
602
603/// Get options from existing and potential assets for the given parameters
604fn get_asset_options<'a>(
605    time_slice_info: &'a TimeSliceInfo,
606    all_existing_assets: &'a [AssetRef],
607    demand: &'a DemandMap,
608    agent: &'a Agent,
609    commodity: &'a Commodity,
610    region_id: &'a RegionID,
611    year: u32,
612) -> impl Iterator<Item = AssetRef> + 'a {
613    // Get existing assets which produce the commodity of interest
614    let existing_assets = all_existing_assets
615        .iter()
616        .filter_agent(&agent.id)
617        .filter_region(region_id)
618        .filter_primary_producers_of(&commodity.id)
619        .cloned();
620
621    // Get candidates assets which produce the commodity of interest
622    let candidate_assets =
623        get_candidate_assets(time_slice_info, demand, agent, region_id, commodity, year);
624
625    chain(existing_assets, candidate_assets)
626}
627
628/// Get candidate assets which produce a particular commodity for a given agent
629fn get_candidate_assets<'a>(
630    time_slice_info: &'a TimeSliceInfo,
631    demand: &'a DemandMap,
632    agent: &'a Agent,
633    region_id: &'a RegionID,
634    commodity: &'a Commodity,
635    year: u32,
636) -> impl Iterator<Item = AssetRef> + 'a {
637    agent
638        .iter_possible_producers_of(region_id, &commodity.id, year)
639        .map(move |process| {
640            let mut asset =
641                Asset::new_candidate(process.clone(), region_id.clone(), Capacity(0.0), year)
642                    .unwrap();
643
644            // Set capacity based on demand
645            // This will serve as the upper limit when appraising the asset
646            let capacity = get_demand_limiting_capacity(time_slice_info, &asset, commodity, demand);
647            let asset_capacity = AssetCapacity::from_capacity(capacity, asset.unit_size());
648            asset.set_capacity(asset_capacity);
649
650            asset.into()
651        })
652}
653
654/// Print debug message if there are multiple equally good outputs
655fn log_on_equal_appraisal_outputs(
656    outputs: &[AppraisalOutput],
657    agent_id: &AgentID,
658    commodity_id: &CommodityID,
659    region_id: &RegionID,
660) {
661    if outputs.is_empty() {
662        return;
663    }
664
665    let num_identical = count_equal_and_best_appraisal_outputs(outputs);
666
667    if num_identical > 0 {
668        let asset_details = outputs[..=num_identical]
669            .iter()
670            .map(|output| {
671                let asset = &output.asset;
672                format!(
673                    "Process ID: '{}' (State: {}{}, Commission year: {})",
674                    asset.process_id(),
675                    asset.state(),
676                    asset
677                        .id()
678                        .map(|id| format!(", Asset ID: {id}"))
679                        .unwrap_or_default(),
680                    asset.commission_year()
681                )
682            })
683            .join(", ");
684        debug!(
685            "Found equally good appraisals for Agent ID: {agent_id}, Commodity: '{commodity_id}', \
686            Region: {region_id}. Options: [{asset_details}]. Selecting first option.",
687        );
688    }
689}
690
691/// Calculate investment limits for an agent's candidate assets in a given year
692///
693/// Investment limits are based on demand for the commodity (capacity cannot exceed that needed to
694/// meet demand), and any annual addition limits specified by the process (scaled according to the
695/// agent's portion of the commodity demand and the number of years elapsed since the previous
696/// milestone year).
697fn calculate_investment_limits_for_candidates(
698    opt_assets: &[AssetRef],
699    commodity_portion: Dimensionless,
700) -> HashMap<AssetRef, AssetCapacity> {
701    // Calculate limits for each candidate asset
702    opt_assets
703        .iter()
704        .filter(|asset| !asset.is_commissioned())
705        .map(|asset| {
706            // Start off with the demand-limiting capacity (pre-calculated when creating candidate)
707            let mut cap = asset.capacity();
708
709            // Cap by the addition limits of the process, if specified
710            if let Some(limit_capacity) = asset.max_installable_capacity(commodity_portion) {
711                cap = cap.min(limit_capacity);
712            }
713
714            (asset.clone(), cap)
715        })
716        .collect()
717}
718
719/// Get the best assets for meeting demand for the given commodity
720#[allow(clippy::too_many_arguments)]
721fn select_best_assets(
722    model: &Model,
723    mut opt_assets: Vec<AssetRef>,
724    investment_limits: HashMap<AssetRef, AssetCapacity>,
725    commodity: &Commodity,
726    agent: &Agent,
727    region_id: &RegionID,
728    prices: &CommodityPrices,
729    mut demand: DemandMap,
730    year: u32,
731    writer: &mut DataWriter,
732) -> Result<Vec<AssetRef>> {
733    let objective_type = &agent.objectives[&year];
734    let mut remaining_candidate_capacity = investment_limits;
735
736    // Calculate coefficients for all asset options according to the agent's objective
737    let coefficients =
738        calculate_coefficients_for_assets(model, objective_type, &opt_assets, prices, year);
739
740    // Iteratively select the best asset until demand is met
741    let mut round = 0;
742    let mut best_assets: Vec<AssetRef> = Vec::new();
743    while is_any_remaining_demand(
744        &demand,
745        model.parameters.remaining_demand_absolute_tolerance,
746    ) {
747        ensure!(
748            !opt_assets.is_empty(),
749            "Failed to meet demand for commodity '{}' in region '{}' with provided investment \
750            options. This may be due to overly restrictive process investment constraints.",
751            &commodity.id,
752            region_id
753        );
754
755        // Since all assets with the same `group_id` are identical, we only need to appraise one
756        // from each group.
757        let mut seen_groups = HashSet::new();
758
759        // Appraise all options
760        let mut outputs_for_opts = Vec::new();
761        for asset in &opt_assets {
762            // For candidates, determine the maximum capacity that can be invested in this round,
763            // according to the tranche size and remaining capacity limits.
764            let max_capacity = (!asset.is_commissioned()).then(|| {
765                let tranche_capacity = asset
766                    .capacity()
767                    .apply_limit_factor(model.parameters.capacity_limit_factor);
768                let remaining_capacity = remaining_candidate_capacity[asset];
769                tranche_capacity.min(remaining_capacity)
770            });
771
772            // Skip any assets from groups we've already seen
773            if let Some(group_id) = asset.group_id()
774                && !seen_groups.insert(group_id)
775            {
776                continue;
777            }
778
779            let output = appraise_investment(
780                model,
781                asset,
782                max_capacity,
783                commodity,
784                objective_type,
785                &coefficients[asset],
786                &demand,
787            )?;
788            outputs_for_opts.push(output);
789        }
790
791        // Save appraisal results
792        writer.write_appraisal_debug_info(
793            year,
794            &format!("{} {} round {}", &commodity.id, &agent.id, round),
795            &outputs_for_opts,
796            &demand,
797        )?;
798
799        sort_appraisal_outputs_by_investment_priority(&mut outputs_for_opts);
800
801        // Check if all options have zero capacity. If so, we cannot meet demand, so have to bail
802        // out.
803        //
804        // This may happen if:
805        // - the asset has zero activity limits for all time slices with
806        // demand.
807        // - known issue with the NPV objective
808        // (see https://github.com/EnergySystemsModellingLab/MUSE2/issues/716).
809        if outputs_for_opts.is_empty() {
810            let remaining_demands: Vec<_> = demand
811                .iter()
812                .filter(|(_, flow)| **flow > Flow(0.0))
813                .map(|(time_slice, flow)| format!("{} : {:e}", time_slice, flow.value()))
814                .collect();
815
816            bail!(
817                "No feasible investment options left for \
818                commodity '{}', region '{}', year '{}', agent '{}' after appraisal.\n\
819                Remaining unmet demand (time_slice : flow):\n{}",
820                &commodity.id,
821                region_id,
822                year,
823                agent.id,
824                remaining_demands.join("\n")
825            );
826        }
827
828        // Warn if there are multiple equally good assets
829        log_on_equal_appraisal_outputs(&outputs_for_opts, &agent.id, &commodity.id, region_id);
830
831        let best_output = outputs_for_opts.into_iter().next().unwrap();
832
833        // Log the selected asset
834        debug!(
835            "Selected {} asset '{}' (capacity: {})",
836            &best_output.asset.state(),
837            &best_output.asset.process_id(),
838            best_output.capacity.total_capacity()
839        );
840
841        // Update the assets and remaining candidate capacity
842        update_assets(
843            best_output.asset,
844            best_output.capacity,
845            &mut opt_assets,
846            &mut remaining_candidate_capacity,
847            &mut best_assets,
848        );
849
850        demand = best_output.unmet_demand;
851        round += 1;
852    }
853
854    // Convert Candidate assets to Selected
855    // At this point we also assign the agent ID to the asset
856    for asset in &mut best_assets {
857        if let AssetState::Candidate = asset.state() {
858            asset
859                .make_mut()
860                .select_candidate_for_investment(agent.id.clone());
861        }
862    }
863
864    Ok(best_assets)
865}
866
867/// Check whether there is any remaining demand that is unmet in any time slice
868fn is_any_remaining_demand(demand: &DemandMap, absolute_tolerance: Flow) -> bool {
869    demand.values().any(|flow| *flow > absolute_tolerance)
870}
871
872/// Update capacity of chosen asset, if needed, and update both asset options and chosen assets
873fn update_assets(
874    mut best_asset: AssetRef,
875    capacity: AssetCapacity,
876    opt_assets: &mut Vec<AssetRef>,
877    remaining_candidate_capacity: &mut HashMap<AssetRef, AssetCapacity>,
878    best_assets: &mut Vec<AssetRef>,
879) {
880    match best_asset.state() {
881        AssetState::Commissioned { .. } => {
882            // Remove this asset from the options
883            opt_assets.retain(|asset| *asset != best_asset);
884            best_assets.push(best_asset);
885        }
886        AssetState::Candidate => {
887            // Remove this capacity from the available remaining capacity for this asset
888            let remaining_capacity = remaining_candidate_capacity.get_mut(&best_asset).unwrap();
889            *remaining_capacity = *remaining_capacity - capacity;
890
891            // If there's no capacity remaining, remove the asset from the options
892            if remaining_capacity.total_capacity() <= Capacity(0.0) {
893                let old_idx = opt_assets
894                    .iter()
895                    .position(|asset| *asset == best_asset)
896                    .unwrap();
897                opt_assets.swap_remove(old_idx);
898                remaining_candidate_capacity.remove(&best_asset);
899            }
900
901            if let Some(existing_asset) = best_assets.iter_mut().find(|asset| **asset == best_asset)
902            {
903                // If the asset is already in the list of best assets, add the additional required capacity
904                existing_asset.make_mut().increase_capacity(capacity);
905            } else {
906                // Otherwise, update the capacity of the chosen asset and add it to the list of best assets
907                best_asset.make_mut().set_capacity(capacity);
908                best_assets.push(best_asset);
909            }
910        }
911        _ => panic!("update_assets should only be called with Commissioned or Candidate assets"),
912    }
913}
914
915#[cfg(test)]
916mod tests {
917    use super::*;
918    use crate::commodity::Commodity;
919    use crate::fixture::{
920        agent_id, asset, process, process_activity_limits_map, process_flows_map,
921        process_investment_constraints, process_parameter_map, region_id, svd_commodity,
922        time_slice, time_slice_info, time_slice_info2,
923    };
924    use crate::process::{
925        ActivityLimits, FlowType, Process, ProcessActivityLimitsMap, ProcessFlow, ProcessFlowsMap,
926        ProcessInvestmentConstraint, ProcessInvestmentConstraintsMap, ProcessParameterMap,
927    };
928    use crate::region::RegionID;
929    use crate::time_slice::{TimeSliceID, TimeSliceInfo};
930    use crate::units::Dimensionless;
931    use crate::units::{ActivityPerCapacity, Capacity, Flow, FlowPerActivity, MoneyPerFlow};
932    use indexmap::{IndexSet, indexmap};
933    use rstest::rstest;
934    use std::rc::Rc;
935
936    #[rstest]
937    fn get_demand_limiting_capacity_works(
938        time_slice: TimeSliceID,
939        time_slice_info: TimeSliceInfo,
940        svd_commodity: Commodity,
941        mut process: Process,
942    ) {
943        // Add flows for the process using the existing commodity fixture
944        let commodity_rc = Rc::new(svd_commodity);
945        let process_flow = ProcessFlow {
946            commodity: Rc::clone(&commodity_rc),
947            coeff: FlowPerActivity(2.0), // 2 units of flow per unit of activity
948            kind: FlowType::Fixed,
949            cost: MoneyPerFlow(0.0),
950        };
951        let process_flows = indexmap! { commodity_rc.id.clone() => process_flow.clone() };
952        let process_flows_map = process_flows_map(process.regions.clone(), Rc::new(process_flows));
953        process.flows = process_flows_map;
954
955        // Create asset with the configured process
956        let asset = asset(process);
957
958        // Create demand map - demand of 10.0 for our time slice
959        let demand = indexmap! { time_slice.clone() => Flow(10.0)};
960
961        // Call the function
962        let result = get_demand_limiting_capacity(&time_slice_info, &asset, &commodity_rc, &demand);
963
964        // Expected calculation:
965        // max_flow_per_cap = activity_per_capacity_limit (1.0) * coeff (2.0) = 2.0
966        // required_capacity = demand (10.0) / max_flow_per_cap (2.0) = 5.0
967        assert_eq!(result, Capacity(5.0));
968    }
969
970    #[rstest]
971    fn get_demand_limiting_capacity_multiple_time_slices(
972        time_slice_info2: TimeSliceInfo,
973        svd_commodity: Commodity,
974        mut process: Process,
975    ) {
976        let (time_slice1, time_slice2) =
977            time_slice_info2.time_slices.keys().collect_tuple().unwrap();
978
979        // Add flows for the process using the existing commodity fixture
980        let commodity_rc = Rc::new(svd_commodity);
981        let process_flow = ProcessFlow {
982            commodity: Rc::clone(&commodity_rc),
983            coeff: FlowPerActivity(1.0), // 1 unit of flow per unit of activity
984            kind: FlowType::Fixed,
985            cost: MoneyPerFlow(0.0),
986        };
987        let process_flows = indexmap! { commodity_rc.id.clone() => process_flow.clone() };
988        let process_flows_map = process_flows_map(process.regions.clone(), Rc::new(process_flows));
989        process.flows = process_flows_map;
990
991        // Add activity limits for the process
992        let mut limits = ActivityLimits::new_with_full_availability(&time_slice_info2);
993        limits.add_time_slice_limit(time_slice1.clone(), Dimensionless(0.0)..=Dimensionless(0.2));
994        limits.add_time_slice_limit(time_slice2.clone(), Dimensionless(0.0)..=Dimensionless(0.0));
995        let limits_map = process_activity_limits_map(process.regions.clone(), limits);
996        process.activity_limits = limits_map;
997
998        // Create asset with the configured process
999        let asset = asset(process);
1000
1001        // Create demand map with different demands for each time slice
1002        let demand = indexmap! {
1003            time_slice1.clone() => Flow(4.0), // Requires capacity of 4.0/0.2 = 20.0
1004            time_slice2.clone() => Flow(3.0), // Would require infinite capacity, but should be skipped
1005        };
1006
1007        // Call the function
1008        let result =
1009            get_demand_limiting_capacity(&time_slice_info2, &asset, &commodity_rc, &demand);
1010
1011        // Expected: maximum of the capacity requirements across time slices (excluding zero limit)
1012        // Time slice 1: demand (4.0) / (activity_limit (0.2) * coeff (1.0)) = 20.0
1013        // Time slice 2: skipped due to zero activity limit
1014        // Maximum = 20.0
1015        assert_eq!(result, Capacity(20.0));
1016    }
1017
1018    #[rstest]
1019    fn calculate_investment_limits_for_candidates_empty_list() {
1020        // Test with empty list of assets
1021        let opt_assets: Vec<AssetRef> = vec![];
1022        let commodity_portion = Dimensionless(1.0);
1023
1024        let result = calculate_investment_limits_for_candidates(&opt_assets, commodity_portion);
1025
1026        assert!(result.is_empty());
1027    }
1028
1029    #[rstest]
1030    fn calculate_investment_limits_for_candidates_commissioned_assets_filtered(
1031        process: Process,
1032        region_id: RegionID,
1033        agent_id: AgentID,
1034    ) {
1035        // Create a mix of commissioned and candidate assets
1036        let process_rc = Rc::new(process);
1037        let capacity = Capacity(10.0);
1038
1039        // Create commissioned asset - should be filtered out
1040        let commissioned_asset = Asset::new_commissioned(
1041            agent_id.clone(),
1042            process_rc.clone(),
1043            region_id.clone(),
1044            capacity,
1045            2015,
1046        )
1047        .unwrap();
1048
1049        // Create candidate asset - should be included
1050        let candidate_asset =
1051            Asset::new_candidate(process_rc.clone(), region_id.clone(), capacity, 2015).unwrap();
1052
1053        let candidate_asset_ref = AssetRef::from(candidate_asset);
1054        let opt_assets = vec![
1055            AssetRef::from(commissioned_asset),
1056            candidate_asset_ref.clone(),
1057        ];
1058        let commodity_portion = Dimensionless(1.0);
1059
1060        let result = calculate_investment_limits_for_candidates(&opt_assets, commodity_portion);
1061
1062        // Only the candidate asset should be in the result
1063        assert_eq!(result.len(), 1);
1064        assert!(result.contains_key(&candidate_asset_ref));
1065    }
1066
1067    #[rstest]
1068    fn calculate_investment_limits_for_candidates_no_investment_constraints(
1069        process: Process,
1070        region_id: RegionID,
1071    ) {
1072        // Create candidate asset without investment constraints
1073        let process_rc = Rc::new(process);
1074        let capacity = Capacity(15.0);
1075
1076        let candidate_asset = Asset::new_candidate(process_rc, region_id, capacity, 2015).unwrap();
1077
1078        let opt_assets = vec![AssetRef::from(candidate_asset.clone())];
1079        let commodity_portion = Dimensionless(0.8);
1080
1081        let result = calculate_investment_limits_for_candidates(&opt_assets, commodity_portion);
1082
1083        // Should return the asset's original capacity since no constraints apply
1084        assert_eq!(result.len(), 1);
1085        let asset_ref = AssetRef::from(candidate_asset);
1086        assert_eq!(result[&asset_ref], AssetCapacity::Continuous(capacity));
1087    }
1088
1089    #[rstest]
1090    // Asset capacity higher than constraint -> limited by constraint
1091    #[case(Capacity(15.0), Capacity(10.0))]
1092    // Asset capacity lower than constraint -> limited by asset capacity
1093    #[case(Capacity(5.0), Capacity(5.0))]
1094    fn calculate_investment_limits_for_candidates_with_constraints(
1095        region_id: RegionID,
1096        process_activity_limits_map: ProcessActivityLimitsMap,
1097        process_flows_map: ProcessFlowsMap,
1098        process_parameter_map: ProcessParameterMap,
1099        #[case] asset_capacity: Capacity,
1100        #[case] expected_limit: Capacity,
1101    ) {
1102        let region_ids: IndexSet<RegionID> = [region_id.clone()].into();
1103
1104        // Add investment constraint with addition limit
1105        let constraint = ProcessInvestmentConstraint {
1106            addition_limit: Some(Capacity(10.0)),
1107        };
1108        let mut constraints = ProcessInvestmentConstraintsMap::new();
1109        constraints.insert((region_id.clone(), 2015), Rc::new(constraint));
1110
1111        let process = Process {
1112            id: "constrained_process".into(),
1113            description: "Process with constraints".into(),
1114            years: 2010..=2020,
1115            activity_limits: process_activity_limits_map,
1116            flows: process_flows_map,
1117            parameters: process_parameter_map,
1118            regions: region_ids,
1119            primary_output: None,
1120            capacity_to_activity: ActivityPerCapacity(1.0),
1121            investment_constraints: constraints,
1122            unit_size: None,
1123        };
1124
1125        let process_rc = Rc::new(process);
1126
1127        let candidate_asset =
1128            Asset::new_candidate(process_rc, region_id, asset_capacity, 2015).unwrap();
1129
1130        let opt_assets = vec![AssetRef::from(candidate_asset.clone())];
1131        let commodity_portion = Dimensionless(1.0);
1132
1133        let result = calculate_investment_limits_for_candidates(&opt_assets, commodity_portion);
1134
1135        // Should be limited by the minimum of asset capacity and constraint
1136        assert_eq!(result.len(), 1);
1137        let asset_ref = AssetRef::from(candidate_asset);
1138        assert_eq!(
1139            result[&asset_ref],
1140            AssetCapacity::Continuous(expected_limit)
1141        );
1142    }
1143
1144    #[rstest]
1145    fn calculate_investment_limits_for_candidates_multiple_assets(
1146        region_id: RegionID,
1147        process_activity_limits_map: ProcessActivityLimitsMap,
1148        process_flows_map: ProcessFlowsMap,
1149        process_parameter_map: ProcessParameterMap,
1150    ) {
1151        let region_ids: IndexSet<RegionID> = [region_id.clone()].into();
1152
1153        // Create first process with constraints
1154        let constraint1 = ProcessInvestmentConstraint {
1155            addition_limit: Some(Capacity(12.0)),
1156        };
1157        let mut constraints1 = ProcessInvestmentConstraintsMap::new();
1158        constraints1.insert((region_id.clone(), 2015), Rc::new(constraint1));
1159
1160        let process1 = Process {
1161            id: "process1".into(),
1162            description: "First process".into(),
1163            years: 2010..=2020,
1164            activity_limits: process_activity_limits_map.clone(),
1165            flows: process_flows_map.clone(),
1166            parameters: process_parameter_map.clone(),
1167            regions: region_ids.clone(),
1168            primary_output: None,
1169            capacity_to_activity: ActivityPerCapacity(1.0),
1170            investment_constraints: constraints1,
1171            unit_size: None,
1172        };
1173
1174        // Create second process without constraints
1175        let process2 = Process {
1176            id: "process2".into(),
1177            description: "Second process".into(),
1178            years: 2010..=2020,
1179            activity_limits: process_activity_limits_map,
1180            flows: process_flows_map,
1181            parameters: process_parameter_map,
1182            regions: region_ids,
1183            primary_output: None,
1184            capacity_to_activity: ActivityPerCapacity(1.0),
1185            investment_constraints: process_investment_constraints(),
1186            unit_size: None,
1187        };
1188
1189        let process1_rc = Rc::new(process1);
1190        let process2_rc = Rc::new(process2);
1191
1192        let candidate1 =
1193            Asset::new_candidate(process1_rc, region_id.clone(), Capacity(20.0), 2015).unwrap();
1194
1195        let candidate2 = Asset::new_candidate(process2_rc, region_id, Capacity(8.0), 2015).unwrap();
1196
1197        let opt_assets = vec![
1198            AssetRef::from(candidate1.clone()),
1199            AssetRef::from(candidate2.clone()),
1200        ];
1201        let commodity_portion = Dimensionless(0.75);
1202
1203        let result = calculate_investment_limits_for_candidates(&opt_assets, commodity_portion);
1204
1205        // Should have both assets in result
1206        assert_eq!(result.len(), 2);
1207
1208        // First asset should be limited by constraint: 12.0 * 0.75 = 9.0
1209        let asset1_ref = AssetRef::from(candidate1);
1210        assert_eq!(
1211            result[&asset1_ref],
1212            AssetCapacity::Continuous(Capacity(9.0))
1213        );
1214
1215        // Second asset should use its original capacity (no constraints)
1216        let asset2_ref = AssetRef::from(candidate2);
1217        assert_eq!(
1218            result[&asset2_ref],
1219            AssetCapacity::Continuous(Capacity(8.0))
1220        );
1221    }
1222
1223    #[rstest]
1224    fn calculate_investment_limits_for_candidates_discrete_capacity(
1225        region_id: RegionID,
1226        process_activity_limits_map: crate::process::ProcessActivityLimitsMap,
1227        process_flows_map: crate::process::ProcessFlowsMap,
1228        process_parameter_map: crate::process::ProcessParameterMap,
1229    ) {
1230        let region_ids: IndexSet<RegionID> = [region_id.clone()].into();
1231
1232        // Add investment constraint
1233        let constraint = ProcessInvestmentConstraint {
1234            addition_limit: Some(Capacity(35.0)), // Enough for 3.5 units at 10.0 each
1235        };
1236        let mut constraints = ProcessInvestmentConstraintsMap::new();
1237        constraints.insert((region_id.clone(), 2015), Rc::new(constraint));
1238
1239        let process = Process {
1240            id: "discrete_process".into(),
1241            description: "Process with discrete units".into(),
1242            years: 2010..=2020,
1243            activity_limits: process_activity_limits_map,
1244            flows: process_flows_map,
1245            parameters: process_parameter_map,
1246            regions: region_ids,
1247            primary_output: None,
1248            capacity_to_activity: ActivityPerCapacity(1.0),
1249            investment_constraints: constraints,
1250            unit_size: Some(Capacity(10.0)), // Discrete units of 10.0 capacity each
1251        };
1252
1253        let process_rc = Rc::new(process);
1254        let capacity = Capacity(50.0); // 5 units at 10.0 each
1255
1256        let candidate_asset = Asset::new_candidate(process_rc, region_id, capacity, 2015).unwrap();
1257
1258        let opt_assets = vec![AssetRef::from(candidate_asset.clone())];
1259        let commodity_portion = Dimensionless(1.0);
1260
1261        let result = calculate_investment_limits_for_candidates(&opt_assets, commodity_portion);
1262
1263        // Should be limited by constraint and rounded down to whole units
1264        // Constraint: 35.0, divided by unit size 10.0 = 3.5 -> floor to 3 units = 30.0
1265        assert_eq!(result.len(), 1);
1266        let asset_ref = AssetRef::from(candidate_asset);
1267        assert_eq!(
1268            result[&asset_ref],
1269            AssetCapacity::Discrete(3, Capacity(10.0))
1270        );
1271        assert_eq!(result[&asset_ref].total_capacity(), Capacity(30.0));
1272    }
1273}