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_and_filter_appraisal_outputs,
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 by investment priority and discord non-feasible options
800        sort_and_filter_appraisal_outputs(&mut outputs_for_opts);
801
802        // Check if there are any remaining options. If not, we cannot meet demand, so have to bail
803        // out.
804        if outputs_for_opts.is_empty() {
805            let remaining_demands: Vec<_> = demand
806                .iter()
807                .filter(|(_, flow)| **flow > Flow(0.0))
808                .map(|(time_slice, flow)| format!("{} : {:e}", time_slice, flow.value()))
809                .collect();
810
811            bail!(
812                "No feasible investment options left for \
813                commodity '{}', region '{}', year '{}', agent '{}' after appraisal.\n\
814                Remaining unmet demand (time_slice : flow):\n{}",
815                &commodity.id,
816                region_id,
817                year,
818                agent.id,
819                remaining_demands.join("\n")
820            );
821        }
822
823        // Warn if there are multiple equally good assets
824        log_on_equal_appraisal_outputs(&outputs_for_opts, &agent.id, &commodity.id, region_id);
825
826        let best_output = outputs_for_opts.into_iter().next().unwrap();
827
828        // Log the selected asset
829        debug!(
830            "Selected {} asset '{}' (capacity: {})",
831            &best_output.asset.state(),
832            &best_output.asset.process_id(),
833            best_output.capacity.total_capacity()
834        );
835
836        // Update the assets and remaining candidate capacity
837        update_assets(
838            best_output.asset,
839            best_output.capacity,
840            &mut opt_assets,
841            &mut remaining_candidate_capacity,
842            &mut best_assets,
843        );
844
845        demand = best_output.unmet_demand;
846        round += 1;
847    }
848
849    // Convert Candidate assets to Selected
850    // At this point we also assign the agent ID to the asset
851    for asset in &mut best_assets {
852        if let AssetState::Candidate = asset.state() {
853            asset
854                .make_mut()
855                .select_candidate_for_investment(agent.id.clone());
856        }
857    }
858
859    Ok(best_assets)
860}
861
862/// Check whether there is any remaining demand that is unmet in any time slice
863fn is_any_remaining_demand(demand: &DemandMap, absolute_tolerance: Flow) -> bool {
864    demand.values().any(|flow| *flow > absolute_tolerance)
865}
866
867/// Update capacity of chosen asset, if needed, and update both asset options and chosen assets
868fn update_assets(
869    mut best_asset: AssetRef,
870    capacity: AssetCapacity,
871    opt_assets: &mut Vec<AssetRef>,
872    remaining_candidate_capacity: &mut HashMap<AssetRef, AssetCapacity>,
873    best_assets: &mut Vec<AssetRef>,
874) {
875    match best_asset.state() {
876        AssetState::Commissioned { .. } => {
877            // Remove this asset from the options
878            opt_assets.retain(|asset| *asset != best_asset);
879            best_assets.push(best_asset);
880        }
881        AssetState::Candidate => {
882            // Remove this capacity from the available remaining capacity for this asset
883            let remaining_capacity = remaining_candidate_capacity.get_mut(&best_asset).unwrap();
884            *remaining_capacity = *remaining_capacity - capacity;
885
886            // If there's no capacity remaining, remove the asset from the options
887            if remaining_capacity.total_capacity() <= Capacity(0.0) {
888                let old_idx = opt_assets
889                    .iter()
890                    .position(|asset| *asset == best_asset)
891                    .unwrap();
892                opt_assets.swap_remove(old_idx);
893                remaining_candidate_capacity.remove(&best_asset);
894            }
895
896            if let Some(existing_asset) = best_assets.iter_mut().find(|asset| **asset == best_asset)
897            {
898                // If the asset is already in the list of best assets, add the additional required capacity
899                existing_asset.make_mut().increase_capacity(capacity);
900            } else {
901                // Otherwise, update the capacity of the chosen asset and add it to the list of best assets
902                best_asset.make_mut().set_capacity(capacity);
903                best_assets.push(best_asset);
904            }
905        }
906        _ => panic!("update_assets should only be called with Commissioned or Candidate assets"),
907    }
908}
909
910#[cfg(test)]
911mod tests {
912    use super::*;
913    use crate::commodity::Commodity;
914    use crate::fixture::{
915        agent_id, asset, process, process_activity_limits_map, process_flows_map,
916        process_investment_constraints, process_parameter_map, region_id, svd_commodity,
917        time_slice, time_slice_info, time_slice_info2,
918    };
919    use crate::process::{
920        ActivityLimits, FlowType, Process, ProcessActivityLimitsMap, ProcessFlow, ProcessFlowsMap,
921        ProcessInvestmentConstraint, ProcessInvestmentConstraintsMap, ProcessParameterMap,
922    };
923    use crate::region::RegionID;
924    use crate::time_slice::{TimeSliceID, TimeSliceInfo};
925    use crate::units::Dimensionless;
926    use crate::units::{ActivityPerCapacity, Capacity, Flow, FlowPerActivity, MoneyPerFlow};
927    use indexmap::{IndexSet, indexmap};
928    use rstest::rstest;
929    use std::rc::Rc;
930
931    #[rstest]
932    fn get_demand_limiting_capacity_works(
933        time_slice: TimeSliceID,
934        time_slice_info: TimeSliceInfo,
935        svd_commodity: Commodity,
936        mut process: Process,
937    ) {
938        // Add flows for the process using the existing commodity fixture
939        let commodity_rc = Rc::new(svd_commodity);
940        let process_flow = ProcessFlow {
941            commodity: Rc::clone(&commodity_rc),
942            coeff: FlowPerActivity(2.0), // 2 units of flow per unit of activity
943            kind: FlowType::Fixed,
944            cost: MoneyPerFlow(0.0),
945        };
946        let process_flows = indexmap! { commodity_rc.id.clone() => process_flow.clone() };
947        let process_flows_map = process_flows_map(process.regions.clone(), Rc::new(process_flows));
948        process.flows = process_flows_map;
949
950        // Create asset with the configured process
951        let asset = asset(process);
952
953        // Create demand map - demand of 10.0 for our time slice
954        let demand = indexmap! { time_slice.clone() => Flow(10.0)};
955
956        // Call the function
957        let result = get_demand_limiting_capacity(&time_slice_info, &asset, &commodity_rc, &demand);
958
959        // Expected calculation:
960        // max_flow_per_cap = activity_per_capacity_limit (1.0) * coeff (2.0) = 2.0
961        // required_capacity = demand (10.0) / max_flow_per_cap (2.0) = 5.0
962        assert_eq!(result, Capacity(5.0));
963    }
964
965    #[rstest]
966    fn get_demand_limiting_capacity_multiple_time_slices(
967        time_slice_info2: TimeSliceInfo,
968        svd_commodity: Commodity,
969        mut process: Process,
970    ) {
971        let (time_slice1, time_slice2) =
972            time_slice_info2.time_slices.keys().collect_tuple().unwrap();
973
974        // Add flows for the process using the existing commodity fixture
975        let commodity_rc = Rc::new(svd_commodity);
976        let process_flow = ProcessFlow {
977            commodity: Rc::clone(&commodity_rc),
978            coeff: FlowPerActivity(1.0), // 1 unit of flow per unit of activity
979            kind: FlowType::Fixed,
980            cost: MoneyPerFlow(0.0),
981        };
982        let process_flows = indexmap! { commodity_rc.id.clone() => process_flow.clone() };
983        let process_flows_map = process_flows_map(process.regions.clone(), Rc::new(process_flows));
984        process.flows = process_flows_map;
985
986        // Add activity limits for the process
987        let mut limits = ActivityLimits::new_with_full_availability(&time_slice_info2);
988        limits.add_time_slice_limit(time_slice1.clone(), Dimensionless(0.0)..=Dimensionless(0.2));
989        limits.add_time_slice_limit(time_slice2.clone(), Dimensionless(0.0)..=Dimensionless(0.0));
990        let limits_map = process_activity_limits_map(process.regions.clone(), limits);
991        process.activity_limits = limits_map;
992
993        // Create asset with the configured process
994        let asset = asset(process);
995
996        // Create demand map with different demands for each time slice
997        let demand = indexmap! {
998            time_slice1.clone() => Flow(4.0), // Requires capacity of 4.0/0.2 = 20.0
999            time_slice2.clone() => Flow(3.0), // Would require infinite capacity, but should be skipped
1000        };
1001
1002        // Call the function
1003        let result =
1004            get_demand_limiting_capacity(&time_slice_info2, &asset, &commodity_rc, &demand);
1005
1006        // Expected: maximum of the capacity requirements across time slices (excluding zero limit)
1007        // Time slice 1: demand (4.0) / (activity_limit (0.2) * coeff (1.0)) = 20.0
1008        // Time slice 2: skipped due to zero activity limit
1009        // Maximum = 20.0
1010        assert_eq!(result, Capacity(20.0));
1011    }
1012
1013    #[rstest]
1014    fn calculate_investment_limits_for_candidates_empty_list() {
1015        // Test with empty list of assets
1016        let opt_assets: Vec<AssetRef> = vec![];
1017        let commodity_portion = Dimensionless(1.0);
1018
1019        let result = calculate_investment_limits_for_candidates(&opt_assets, commodity_portion);
1020
1021        assert!(result.is_empty());
1022    }
1023
1024    #[rstest]
1025    fn calculate_investment_limits_for_candidates_commissioned_assets_filtered(
1026        process: Process,
1027        region_id: RegionID,
1028        agent_id: AgentID,
1029    ) {
1030        // Create a mix of commissioned and candidate assets
1031        let process_rc = Rc::new(process);
1032        let capacity = Capacity(10.0);
1033
1034        // Create commissioned asset - should be filtered out
1035        let commissioned_asset = Asset::new_commissioned(
1036            agent_id.clone(),
1037            process_rc.clone(),
1038            region_id.clone(),
1039            capacity,
1040            2015,
1041        )
1042        .unwrap();
1043
1044        // Create candidate asset - should be included
1045        let candidate_asset =
1046            Asset::new_candidate(process_rc.clone(), region_id.clone(), capacity, 2015).unwrap();
1047
1048        let candidate_asset_ref = AssetRef::from(candidate_asset);
1049        let opt_assets = vec![
1050            AssetRef::from(commissioned_asset),
1051            candidate_asset_ref.clone(),
1052        ];
1053        let commodity_portion = Dimensionless(1.0);
1054
1055        let result = calculate_investment_limits_for_candidates(&opt_assets, commodity_portion);
1056
1057        // Only the candidate asset should be in the result
1058        assert_eq!(result.len(), 1);
1059        assert!(result.contains_key(&candidate_asset_ref));
1060    }
1061
1062    #[rstest]
1063    fn calculate_investment_limits_for_candidates_no_investment_constraints(
1064        process: Process,
1065        region_id: RegionID,
1066    ) {
1067        // Create candidate asset without investment constraints
1068        let process_rc = Rc::new(process);
1069        let capacity = Capacity(15.0);
1070
1071        let candidate_asset = Asset::new_candidate(process_rc, region_id, capacity, 2015).unwrap();
1072
1073        let opt_assets = vec![AssetRef::from(candidate_asset.clone())];
1074        let commodity_portion = Dimensionless(0.8);
1075
1076        let result = calculate_investment_limits_for_candidates(&opt_assets, commodity_portion);
1077
1078        // Should return the asset's original capacity since no constraints apply
1079        assert_eq!(result.len(), 1);
1080        let asset_ref = AssetRef::from(candidate_asset);
1081        assert_eq!(result[&asset_ref], AssetCapacity::Continuous(capacity));
1082    }
1083
1084    #[rstest]
1085    // Asset capacity higher than constraint -> limited by constraint
1086    #[case(Capacity(15.0), Capacity(10.0))]
1087    // Asset capacity lower than constraint -> limited by asset capacity
1088    #[case(Capacity(5.0), Capacity(5.0))]
1089    fn calculate_investment_limits_for_candidates_with_constraints(
1090        region_id: RegionID,
1091        process_activity_limits_map: ProcessActivityLimitsMap,
1092        process_flows_map: ProcessFlowsMap,
1093        process_parameter_map: ProcessParameterMap,
1094        #[case] asset_capacity: Capacity,
1095        #[case] expected_limit: Capacity,
1096    ) {
1097        let region_ids: IndexSet<RegionID> = [region_id.clone()].into();
1098
1099        // Add investment constraint with addition limit
1100        let constraint = ProcessInvestmentConstraint {
1101            addition_limit: Some(Capacity(10.0)),
1102        };
1103        let mut constraints = ProcessInvestmentConstraintsMap::new();
1104        constraints.insert((region_id.clone(), 2015), Rc::new(constraint));
1105
1106        let process = Process {
1107            id: "constrained_process".into(),
1108            description: "Process with constraints".into(),
1109            years: 2010..=2020,
1110            activity_limits: process_activity_limits_map,
1111            flows: process_flows_map,
1112            parameters: process_parameter_map,
1113            regions: region_ids,
1114            primary_output: None,
1115            capacity_to_activity: ActivityPerCapacity(1.0),
1116            investment_constraints: constraints,
1117            unit_size: None,
1118        };
1119
1120        let process_rc = Rc::new(process);
1121
1122        let candidate_asset =
1123            Asset::new_candidate(process_rc, region_id, asset_capacity, 2015).unwrap();
1124
1125        let opt_assets = vec![AssetRef::from(candidate_asset.clone())];
1126        let commodity_portion = Dimensionless(1.0);
1127
1128        let result = calculate_investment_limits_for_candidates(&opt_assets, commodity_portion);
1129
1130        // Should be limited by the minimum of asset capacity and constraint
1131        assert_eq!(result.len(), 1);
1132        let asset_ref = AssetRef::from(candidate_asset);
1133        assert_eq!(
1134            result[&asset_ref],
1135            AssetCapacity::Continuous(expected_limit)
1136        );
1137    }
1138
1139    #[rstest]
1140    fn calculate_investment_limits_for_candidates_multiple_assets(
1141        region_id: RegionID,
1142        process_activity_limits_map: ProcessActivityLimitsMap,
1143        process_flows_map: ProcessFlowsMap,
1144        process_parameter_map: ProcessParameterMap,
1145    ) {
1146        let region_ids: IndexSet<RegionID> = [region_id.clone()].into();
1147
1148        // Create first process with constraints
1149        let constraint1 = ProcessInvestmentConstraint {
1150            addition_limit: Some(Capacity(12.0)),
1151        };
1152        let mut constraints1 = ProcessInvestmentConstraintsMap::new();
1153        constraints1.insert((region_id.clone(), 2015), Rc::new(constraint1));
1154
1155        let process1 = Process {
1156            id: "process1".into(),
1157            description: "First process".into(),
1158            years: 2010..=2020,
1159            activity_limits: process_activity_limits_map.clone(),
1160            flows: process_flows_map.clone(),
1161            parameters: process_parameter_map.clone(),
1162            regions: region_ids.clone(),
1163            primary_output: None,
1164            capacity_to_activity: ActivityPerCapacity(1.0),
1165            investment_constraints: constraints1,
1166            unit_size: None,
1167        };
1168
1169        // Create second process without constraints
1170        let process2 = Process {
1171            id: "process2".into(),
1172            description: "Second process".into(),
1173            years: 2010..=2020,
1174            activity_limits: process_activity_limits_map,
1175            flows: process_flows_map,
1176            parameters: process_parameter_map,
1177            regions: region_ids,
1178            primary_output: None,
1179            capacity_to_activity: ActivityPerCapacity(1.0),
1180            investment_constraints: process_investment_constraints(),
1181            unit_size: None,
1182        };
1183
1184        let process1_rc = Rc::new(process1);
1185        let process2_rc = Rc::new(process2);
1186
1187        let candidate1 =
1188            Asset::new_candidate(process1_rc, region_id.clone(), Capacity(20.0), 2015).unwrap();
1189
1190        let candidate2 = Asset::new_candidate(process2_rc, region_id, Capacity(8.0), 2015).unwrap();
1191
1192        let opt_assets = vec![
1193            AssetRef::from(candidate1.clone()),
1194            AssetRef::from(candidate2.clone()),
1195        ];
1196        let commodity_portion = Dimensionless(0.75);
1197
1198        let result = calculate_investment_limits_for_candidates(&opt_assets, commodity_portion);
1199
1200        // Should have both assets in result
1201        assert_eq!(result.len(), 2);
1202
1203        // First asset should be limited by constraint: 12.0 * 0.75 = 9.0
1204        let asset1_ref = AssetRef::from(candidate1);
1205        assert_eq!(
1206            result[&asset1_ref],
1207            AssetCapacity::Continuous(Capacity(9.0))
1208        );
1209
1210        // Second asset should use its original capacity (no constraints)
1211        let asset2_ref = AssetRef::from(candidate2);
1212        assert_eq!(
1213            result[&asset2_ref],
1214            AssetCapacity::Continuous(Capacity(8.0))
1215        );
1216    }
1217
1218    #[rstest]
1219    fn calculate_investment_limits_for_candidates_discrete_capacity(
1220        region_id: RegionID,
1221        process_activity_limits_map: crate::process::ProcessActivityLimitsMap,
1222        process_flows_map: crate::process::ProcessFlowsMap,
1223        process_parameter_map: crate::process::ProcessParameterMap,
1224    ) {
1225        let region_ids: IndexSet<RegionID> = [region_id.clone()].into();
1226
1227        // Add investment constraint
1228        let constraint = ProcessInvestmentConstraint {
1229            addition_limit: Some(Capacity(35.0)), // Enough for 3.5 units at 10.0 each
1230        };
1231        let mut constraints = ProcessInvestmentConstraintsMap::new();
1232        constraints.insert((region_id.clone(), 2015), Rc::new(constraint));
1233
1234        let process = Process {
1235            id: "discrete_process".into(),
1236            description: "Process with discrete units".into(),
1237            years: 2010..=2020,
1238            activity_limits: process_activity_limits_map,
1239            flows: process_flows_map,
1240            parameters: process_parameter_map,
1241            regions: region_ids,
1242            primary_output: None,
1243            capacity_to_activity: ActivityPerCapacity(1.0),
1244            investment_constraints: constraints,
1245            unit_size: Some(Capacity(10.0)), // Discrete units of 10.0 capacity each
1246        };
1247
1248        let process_rc = Rc::new(process);
1249        let capacity = Capacity(50.0); // 5 units at 10.0 each
1250
1251        let candidate_asset = Asset::new_candidate(process_rc, region_id, capacity, 2015).unwrap();
1252
1253        let opt_assets = vec![AssetRef::from(candidate_asset.clone())];
1254        let commodity_portion = Dimensionless(1.0);
1255
1256        let result = calculate_investment_limits_for_candidates(&opt_assets, commodity_portion);
1257
1258        // Should be limited by constraint and rounded down to whole units
1259        // Constraint: 35.0, divided by unit size 10.0 = 3.5 -> floor to 3 units = 30.0
1260        assert_eq!(result.len(), 1);
1261        let asset_ref = AssetRef::from(candidate_asset);
1262        assert_eq!(
1263            result[&asset_ref],
1264            AssetCapacity::Discrete(3, Capacity(10.0))
1265        );
1266        assert_eq!(result[&asset_ref].total_capacity(), Capacity(30.0));
1267    }
1268}