Skip to main content

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 mut agent_share_cache = HashMap::new();
388        let capacity_limits = flexible_capacity_assets
389            .iter()
390            .filter_map(|asset| {
391                let agent_id = asset.agent_id().unwrap();
392                let commodity_id = asset.primary_output_commodity().unwrap();
393                let agent_share = *agent_share_cache
394                    .entry((agent_id, commodity_id))
395                    .or_insert_with(|| {
396                        model.agents[agent_id].commodity_portions[&(commodity_id.clone(), year)]
397                    });
398                asset
399                    .max_installable_capacity(agent_share)
400                    .map(|max_capacity| (asset.clone(), max_capacity))
401            })
402            .collect::<HashMap<_, _>>();
403
404        // Run dispatch
405        let solution = DispatchRun::new(model, &all_assets, year)
406            .with_market_balance_subset(&markets_to_balance)
407            .with_flexible_capacity_assets(
408                &flexible_capacity_assets,
409                Some(&capacity_limits),
410                // Gives newly selected cycle assets limited capacity wiggle-room; existing assets stay fixed.
411                model.parameters.capacity_margin,
412            )
413            .run(
414                &format!("cycle ({markets_str}) post {commodity_id}|{region_id} investment"),
415                writer,
416            )
417            .with_context(|| {
418                format!(
419                    "Cycle balancing failed for cycle ({markets_str}), capacity_margin: {}. \
420                     Try increasing the capacity_margin.",
421                    model.parameters.capacity_margin
422                )
423            })?;
424
425        // Calculate new net demand map with all assets selected so far
426        current_demand.clone_from(demand);
427        update_net_demand_map(
428            &mut current_demand,
429            &solution.create_flow_map(),
430            &assets_for_cycle_flat,
431        );
432        last_solution = Some(solution);
433    }
434
435    // Finally, update flexible capacity assets based on the final solution
436    let mut all_cycle_assets: Vec<_> = assets_for_cycle.into_values().flatten().collect();
437    if let Some(solution) = last_solution {
438        let new_capacities: HashMap<_, _> = solution.iter_capacity().collect();
439        for asset in &mut all_cycle_assets {
440            if let Some(new_capacity) = new_capacities.get(asset) {
441                debug!(
442                    "Capacity of asset '{}' modified during cycle balancing ({} to {})",
443                    asset.process_id(),
444                    asset.total_capacity(),
445                    new_capacity.total_capacity()
446                );
447                asset.make_mut().set_capacity(*new_capacity);
448            }
449        }
450    }
451
452    Ok(all_cycle_assets)
453}
454
455/// Flatten the preset commodity demands for a given year into a map of commodity, region and
456/// time slice to demand.
457///
458/// Since demands for some commodities may be specified at a coarser time slice level, we need to
459/// distribute these demands over all time slices. Note: the way that we do this distribution is
460/// irrelevant, as demands will only be balanced to the appropriate level, but we still need to do
461/// this for the solver to work.
462///
463/// **TODO**: these assumptions may need to be revisited, e.g. when we come to storage technologies
464fn flatten_preset_demands_for_year(
465    commodities: &CommodityMap,
466    time_slice_info: &TimeSliceInfo,
467    year: u32,
468) -> AllDemandMap {
469    let mut demand_map = AllDemandMap::new();
470    for (commodity_id, commodity) in commodities {
471        for ((region_id, data_year, time_slice_selection), demand) in &commodity.demand {
472            if *data_year != year {
473                continue;
474            }
475
476            // We split the demand equally over all time slices in the selection
477            // NOTE: since demands will only be balanced to the time slice level of the commodity
478            // it doesn't matter how we do this distribution, only the total matters.
479            #[allow(clippy::cast_precision_loss)]
480            let n_time_slices = time_slice_selection.iter(time_slice_info).count() as f64;
481            let demand_per_slice = *demand / Dimensionless(n_time_slices);
482            for (time_slice, _) in time_slice_selection.iter(time_slice_info) {
483                demand_map.insert(
484                    (commodity_id.clone(), region_id.clone(), time_slice.clone()),
485                    demand_per_slice,
486                );
487            }
488        }
489    }
490    demand_map
491}
492
493/// Update net demand map with flows from a set of assets
494///
495/// Non-primary output flows are ignored. This way, demand profiles aren't affected by production
496/// of side-products from other assets. The result is that all commodity demands must be met by
497/// assets with that commodity as their primary output. Effectively, agents do not see production of
498/// side-products from other assets when making investment decisions.
499///
500/// TODO: this is a very flawed approach. The proper solution might be for agents to consider
501/// multiple commodities simultaneously, but that would require substantial work to implement.
502fn update_net_demand_map(demand: &mut AllDemandMap, flows: &FlowMap, assets: &[AssetRef]) {
503    for ((asset, commodity_id, time_slice), flow) in flows {
504        if assets.contains(asset) {
505            let key = (
506                commodity_id.clone(),
507                asset.region_id().clone(),
508                time_slice.clone(),
509            );
510
511            // Only consider input flows and output flows from the primary output commodity
512            // (excluding secondary outputs)
513            if (flow < &Flow(0.0))
514                || asset
515                    .primary_output()
516                    .is_some_and(|p| &p.commodity.id == commodity_id)
517            {
518                // Note: we use the negative of the flow as input flows are negative in the flow map.
519                demand
520                    .entry(key)
521                    .and_modify(|value| *value -= *flow)
522                    .or_insert(-*flow);
523            }
524        }
525    }
526}
527
528/// Get a portion of the demand profile for this market
529fn get_demand_portion_for_market(
530    time_slice_info: &TimeSliceInfo,
531    demand: &AllDemandMap,
532    commodity_id: &CommodityID,
533    region_id: &RegionID,
534    commodity_portion: Dimensionless,
535) -> DemandMap {
536    time_slice_info
537        .iter_ids()
538        .map(|time_slice| {
539            (
540                time_slice.clone(),
541                commodity_portion
542                    * *demand
543                        .get(&(commodity_id.clone(), region_id.clone(), time_slice.clone()))
544                        .unwrap_or(&Flow(0.0)),
545            )
546        })
547        .collect()
548}
549
550/// Get the agents responsible for a given market in a given year along with the commodity
551/// portion for which they are responsible
552fn get_responsible_agents<'a, I>(
553    agents: I,
554    commodity_id: &'a CommodityID,
555    region_id: &'a RegionID,
556    year: u32,
557) -> impl Iterator<Item = (&'a Agent, Dimensionless)>
558where
559    I: Iterator<Item = &'a Agent>,
560{
561    agents.filter_map(move |agent| {
562        if !agent.regions.contains(region_id) {
563            return None;
564        }
565        let portion = agent
566            .commodity_portions
567            .get(&(commodity_id.clone(), year))?;
568
569        Some((agent, *portion))
570    })
571}
572
573/// Get the maximum required capacity across time slices
574fn get_demand_limiting_capacity(
575    time_slice_info: &TimeSliceInfo,
576    asset: &Asset,
577    commodity: &Commodity,
578    demand: &DemandMap,
579) -> Capacity {
580    let coeff = asset.get_flow(&commodity.id).unwrap().coeff;
581    let mut capacity = Capacity(0.0);
582
583    for time_slice_selection in time_slice_info.iter_selections_at_level(commodity.time_slice_level)
584    {
585        let demand_for_selection: Flow = time_slice_selection
586            .iter(time_slice_info)
587            .map(|(time_slice, _)| demand[time_slice])
588            .sum();
589
590        // Calculate max capacity required for this time slice selection
591        // For commodities with a coarse time slice level, we have to allow the possibility that all
592        // of the demand gets served by production in a single time slice
593        for (time_slice, _) in time_slice_selection.iter(time_slice_info) {
594            let max_flow_per_cap =
595                *asset.get_activity_per_capacity_limits(time_slice).end() * coeff;
596            if max_flow_per_cap != FlowPerCapacity(0.0) {
597                capacity = capacity.max(demand_for_selection / max_flow_per_cap);
598            }
599        }
600    }
601
602    capacity
603}
604
605/// Get options from existing and potential assets for the given parameters
606fn get_asset_options<'a>(
607    time_slice_info: &'a TimeSliceInfo,
608    all_existing_assets: &'a [AssetRef],
609    demand: &'a DemandMap,
610    agent: &'a Agent,
611    commodity: &'a Commodity,
612    region_id: &'a RegionID,
613    year: u32,
614) -> impl Iterator<Item = AssetRef> + 'a {
615    // Get existing assets which produce the commodity of interest
616    let existing_assets = all_existing_assets
617        .iter()
618        .filter_agent(&agent.id)
619        .filter_region(region_id)
620        .filter_primary_producers_of(&commodity.id)
621        .cloned();
622
623    // Get candidates assets which produce the commodity of interest
624    let candidate_assets =
625        get_candidate_assets(time_slice_info, demand, agent, region_id, commodity, year);
626
627    chain(existing_assets, candidate_assets)
628}
629
630/// Get candidate assets which produce a particular commodity for a given agent
631fn get_candidate_assets<'a>(
632    time_slice_info: &'a TimeSliceInfo,
633    demand: &'a DemandMap,
634    agent: &'a Agent,
635    region_id: &'a RegionID,
636    commodity: &'a Commodity,
637    year: u32,
638) -> impl Iterator<Item = AssetRef> + 'a {
639    agent
640        .iter_search_space(region_id, &commodity.id, year)
641        .map(move |process| {
642            let mut asset =
643                Asset::new_candidate(process.clone(), region_id.clone(), Capacity(0.0), year)
644                    .unwrap();
645
646            // Set capacity based on demand
647            // This will serve as the upper limit when appraising the asset
648            let capacity = get_demand_limiting_capacity(time_slice_info, &asset, commodity, demand);
649            let asset_capacity = AssetCapacity::from_capacity(capacity, asset.unit_size());
650            asset.set_capacity(asset_capacity);
651
652            asset.into()
653        })
654}
655
656/// Print debug message if there are multiple equally good outputs
657fn log_on_equal_appraisal_outputs(
658    outputs: &[AppraisalOutput],
659    agent_id: &AgentID,
660    commodity_id: &CommodityID,
661    region_id: &RegionID,
662) {
663    if outputs.is_empty() {
664        return;
665    }
666
667    let num_identical = count_equal_and_best_appraisal_outputs(outputs);
668
669    if num_identical > 0 {
670        let asset_details = outputs[..=num_identical]
671            .iter()
672            .map(|output| {
673                let asset = &output.asset;
674                format!(
675                    "Process ID: '{}' (State: {}{}, Commission year: {})",
676                    asset.process_id(),
677                    asset.state(),
678                    asset
679                        .id()
680                        .map(|id| format!(", Asset ID: {id}"))
681                        .unwrap_or_default(),
682                    asset.commission_year()
683                )
684            })
685            .join(", ");
686        debug!(
687            "Found equally good appraisals for Agent ID: {agent_id}, Commodity: '{commodity_id}', \
688            Region: {region_id}. Options: [{asset_details}]. Selecting first option.",
689        );
690    }
691}
692
693/// Calculate investment limits for an agent's candidate assets in a given year
694///
695/// Investment limits are based on demand for the commodity (capacity cannot exceed that needed to
696/// meet demand), and any annual addition limits specified by the process (scaled according to the
697/// agent's portion of the commodity demand and the number of years elapsed since the previous
698/// milestone year).
699fn calculate_investment_limits_for_candidates(
700    opt_assets: &[AssetRef],
701    commodity_portion: Dimensionless,
702) -> HashMap<AssetRef, AssetCapacity> {
703    // Calculate limits for each candidate asset
704    opt_assets
705        .iter()
706        .filter(|asset| !asset.is_commissioned())
707        .map(|asset| {
708            // Start off with the demand-limiting capacity (pre-calculated when creating candidate)
709            let mut cap = asset.capacity();
710
711            // Cap by the addition limits of the process, if specified
712            if let Some(limit_capacity) = asset.max_installable_capacity(commodity_portion) {
713                cap = cap.min(limit_capacity);
714            }
715
716            (asset.clone(), cap)
717        })
718        .collect()
719}
720
721/// Get the best assets for meeting demand for the given commodity
722#[allow(clippy::too_many_arguments)]
723fn select_best_assets(
724    model: &Model,
725    mut opt_assets: Vec<AssetRef>,
726    investment_limits: HashMap<AssetRef, AssetCapacity>,
727    commodity: &Commodity,
728    agent: &Agent,
729    region_id: &RegionID,
730    prices: &CommodityPrices,
731    mut demand: DemandMap,
732    year: u32,
733    writer: &mut DataWriter,
734) -> Result<Vec<AssetRef>> {
735    let objective_type = &agent.objectives[&year];
736    let mut remaining_candidate_capacity = investment_limits;
737
738    // Calculate coefficients for all asset options according to the agent's objective
739    let coefficients =
740        calculate_coefficients_for_assets(model, objective_type, &opt_assets, prices, year);
741
742    // Iteratively select the best asset until demand is met
743    let mut round = 0;
744    let mut best_assets: Vec<AssetRef> = Vec::new();
745    while is_any_remaining_demand(
746        &demand,
747        model.parameters.remaining_demand_absolute_tolerance,
748    ) {
749        ensure!(
750            !opt_assets.is_empty(),
751            "Failed to meet demand for commodity '{}' in region '{}' with provided investment \
752            options. This may be due to overly restrictive process investment constraints.",
753            &commodity.id,
754            region_id
755        );
756
757        // Since all assets with the same `group_id` are identical, we only need to appraise one
758        // from each group.
759        let mut seen_groups = HashSet::new();
760
761        // Appraise all options
762        let mut outputs_for_opts = Vec::new();
763        for asset in &opt_assets {
764            // Skip any assets from groups we've already seen
765            if let Some(group_id) = asset.group_id()
766                && !seen_groups.insert(group_id)
767            {
768                continue;
769            }
770
771            // For candidates, determine the maximum capacity that can be invested in this round.
772            // This is whichever is the smallest of the tranche size (based on demand limiting
773            // capacity before investment), the remaining available capacity for the candidate and
774            // the demand limiting capacity recalculated based on demand unserved by the other
775            // selected assets.
776            let max_capacity = (!asset.is_commissioned()).then(|| {
777                let tranche_capacity = asset
778                    .capacity()
779                    .apply_limit_factor(model.parameters.capacity_limit_factor);
780                let dlc = AssetCapacity::from_capacity(
781                    get_demand_limiting_capacity(&model.time_slice_info, asset, commodity, &demand),
782                    asset.unit_size(),
783                );
784                let remaining_capacity = remaining_candidate_capacity[asset];
785
786                tranche_capacity.min(dlc).min(remaining_capacity)
787            });
788
789            let output = appraise_investment(
790                model,
791                asset,
792                max_capacity,
793                commodity,
794                objective_type,
795                &coefficients[asset],
796                &demand,
797            )?;
798            outputs_for_opts.push(output);
799        }
800
801        // Save appraisal results
802        writer.write_appraisal_debug_info(
803            year,
804            &format!("{} {} round {}", &commodity.id, &agent.id, round),
805            &outputs_for_opts,
806            &demand,
807        )?;
808
809        // Sort by investment priority and discord non-feasible options
810        sort_and_filter_appraisal_outputs(&mut outputs_for_opts);
811
812        // Check if there are any remaining options. If not, we cannot meet demand, so have to bail
813        // out.
814        if outputs_for_opts.is_empty() {
815            let remaining_demands: Vec<_> = demand
816                .iter()
817                .filter(|(_, flow)| **flow > Flow(0.0))
818                .map(|(time_slice, flow)| format!("{} : {:e}", time_slice, flow.value()))
819                .collect();
820
821            bail!(
822                "No feasible investment options left for \
823                commodity '{}', region '{}', year '{}', agent '{}' after appraisal.\n\
824                Remaining unmet demand (time_slice : flow):\n{}",
825                &commodity.id,
826                region_id,
827                year,
828                agent.id,
829                remaining_demands.join("\n")
830            );
831        }
832
833        // Warn if there are multiple equally good assets
834        log_on_equal_appraisal_outputs(&outputs_for_opts, &agent.id, &commodity.id, region_id);
835
836        let best_output = outputs_for_opts.into_iter().next().unwrap();
837
838        // Log the selected asset
839        debug!(
840            "Selected {} asset '{}' (capacity: {})",
841            &best_output.asset.state(),
842            &best_output.asset.process_id(),
843            best_output.capacity.total_capacity()
844        );
845
846        // Update the assets and remaining candidate capacity
847        update_assets(
848            best_output.asset,
849            best_output.capacity,
850            &mut opt_assets,
851            &mut remaining_candidate_capacity,
852            &mut best_assets,
853        );
854
855        demand = best_output.unmet_demand;
856        round += 1;
857    }
858
859    // Convert Candidate assets to Selected
860    // At this point we also assign the agent ID to the asset
861    for asset in &mut best_assets {
862        if let AssetState::Candidate = asset.state() {
863            asset
864                .make_mut()
865                .select_candidate_for_investment(agent.id.clone());
866        }
867    }
868
869    Ok(best_assets)
870}
871
872/// Check whether there is any remaining demand that is unmet in any time slice
873fn is_any_remaining_demand(demand: &DemandMap, absolute_tolerance: Flow) -> bool {
874    demand.values().any(|flow| *flow > absolute_tolerance)
875}
876
877/// Update capacity of chosen asset, if needed, and update both asset options and chosen assets
878fn update_assets(
879    mut best_asset: AssetRef,
880    capacity: AssetCapacity,
881    opt_assets: &mut Vec<AssetRef>,
882    remaining_candidate_capacity: &mut HashMap<AssetRef, AssetCapacity>,
883    best_assets: &mut Vec<AssetRef>,
884) {
885    match best_asset.state() {
886        AssetState::Commissioned { .. } => {
887            // Remove this asset from the options
888            opt_assets.retain(|asset| *asset != best_asset);
889            best_assets.push(best_asset);
890        }
891        AssetState::Candidate => {
892            // Remove this capacity from the available remaining capacity for this asset
893            let remaining_capacity = remaining_candidate_capacity.get_mut(&best_asset).unwrap();
894            *remaining_capacity = *remaining_capacity - capacity;
895
896            // If there's no capacity remaining, remove the asset from the options
897            if remaining_capacity.total_capacity() <= Capacity(0.0) {
898                let old_idx = opt_assets
899                    .iter()
900                    .position(|asset| *asset == best_asset)
901                    .unwrap();
902                opt_assets.swap_remove(old_idx);
903                remaining_candidate_capacity.remove(&best_asset);
904            }
905
906            if let Some(existing_asset) = best_assets.iter_mut().find(|asset| **asset == best_asset)
907            {
908                // If the asset is already in the list of best assets, add the additional required capacity
909                existing_asset.make_mut().increase_capacity(capacity);
910            } else {
911                // Otherwise, update the capacity of the chosen asset and add it to the list of best assets
912                best_asset.make_mut().set_capacity(capacity);
913                best_assets.push(best_asset);
914            }
915        }
916        _ => panic!("update_assets should only be called with Commissioned or Candidate assets"),
917    }
918}
919
920#[cfg(test)]
921mod tests {
922    use super::*;
923    use crate::commodity::Commodity;
924    use crate::fixture::{
925        agent_id, asset, process, process_activity_limits_map, process_flows_map,
926        process_investment_constraints, process_parameter_map, region_id, svd_commodity,
927        time_slice, time_slice_info, time_slice_info2,
928    };
929    use crate::process::{
930        ActivityLimits, FlowType, Process, ProcessActivityLimitsMap, ProcessFlow, ProcessFlowsMap,
931        ProcessInvestmentConstraint, ProcessInvestmentConstraintsMap, ProcessParameterMap,
932    };
933    use crate::region::RegionID;
934    use crate::time_slice::{TimeSliceID, TimeSliceInfo};
935    use crate::units::Dimensionless;
936    use crate::units::{ActivityPerCapacity, Capacity, Flow, FlowPerActivity, MoneyPerFlow};
937    use indexmap::{IndexSet, indexmap};
938    use rstest::rstest;
939    use std::rc::Rc;
940
941    #[rstest]
942    fn get_demand_limiting_capacity_works(
943        time_slice: TimeSliceID,
944        time_slice_info: TimeSliceInfo,
945        svd_commodity: Commodity,
946        mut process: Process,
947    ) {
948        // Add flows for the process using the existing commodity fixture
949        let commodity_rc = Rc::new(svd_commodity);
950        let process_flow = ProcessFlow {
951            commodity: Rc::clone(&commodity_rc),
952            coeff: FlowPerActivity(2.0), // 2 units of flow per unit of activity
953            kind: FlowType::Fixed,
954            cost: MoneyPerFlow(0.0),
955        };
956        let process_flows = indexmap! { commodity_rc.id.clone() => process_flow.clone() };
957        let process_flows_map = process_flows_map(process.regions.clone(), Rc::new(process_flows));
958        process.flows = process_flows_map;
959
960        // Create asset with the configured process
961        let asset = asset(process);
962
963        // Create demand map - demand of 10.0 for our time slice
964        let demand = indexmap! { time_slice.clone() => Flow(10.0)};
965
966        // Call the function
967        let result = get_demand_limiting_capacity(&time_slice_info, &asset, &commodity_rc, &demand);
968
969        // Expected calculation:
970        // max_flow_per_cap = activity_per_capacity_limit (1.0) * coeff (2.0) = 2.0
971        // required_capacity = demand (10.0) / max_flow_per_cap (2.0) = 5.0
972        assert_eq!(result, Capacity(5.0));
973    }
974
975    #[rstest]
976    fn get_demand_limiting_capacity_multiple_time_slices(
977        time_slice_info2: TimeSliceInfo,
978        svd_commodity: Commodity,
979        mut process: Process,
980    ) {
981        let (time_slice1, time_slice2) =
982            time_slice_info2.time_slices.keys().collect_tuple().unwrap();
983
984        // Add flows for the process using the existing commodity fixture
985        let commodity_rc = Rc::new(svd_commodity);
986        let process_flow = ProcessFlow {
987            commodity: Rc::clone(&commodity_rc),
988            coeff: FlowPerActivity(1.0), // 1 unit of flow per unit of activity
989            kind: FlowType::Fixed,
990            cost: MoneyPerFlow(0.0),
991        };
992        let process_flows = indexmap! { commodity_rc.id.clone() => process_flow.clone() };
993        let process_flows_map = process_flows_map(process.regions.clone(), Rc::new(process_flows));
994        process.flows = process_flows_map;
995
996        // Add activity limits for the process
997        let mut limits = ActivityLimits::new_with_full_availability(&time_slice_info2);
998        limits.add_time_slice_limit(time_slice1.clone(), Dimensionless(0.0)..=Dimensionless(0.2));
999        limits.add_time_slice_limit(time_slice2.clone(), Dimensionless(0.0)..=Dimensionless(0.0));
1000        let limits_map = process_activity_limits_map(process.regions.clone(), limits);
1001        process.activity_limits = limits_map;
1002
1003        // Create asset with the configured process
1004        let asset = asset(process);
1005
1006        // Create demand map with different demands for each time slice
1007        let demand = indexmap! {
1008            time_slice1.clone() => Flow(4.0), // Requires capacity of 4.0/0.2 = 20.0
1009            time_slice2.clone() => Flow(3.0), // Would require infinite capacity, but should be skipped
1010        };
1011
1012        // Call the function
1013        let result =
1014            get_demand_limiting_capacity(&time_slice_info2, &asset, &commodity_rc, &demand);
1015
1016        // Expected: maximum of the capacity requirements across time slices (excluding zero limit)
1017        // Time slice 1: demand (4.0) / (activity_limit (0.2) * coeff (1.0)) = 20.0
1018        // Time slice 2: skipped due to zero activity limit
1019        // Maximum = 20.0
1020        assert_eq!(result, Capacity(20.0));
1021    }
1022
1023    #[rstest]
1024    fn calculate_investment_limits_for_candidates_empty_list() {
1025        // Test with empty list of assets
1026        let opt_assets: Vec<AssetRef> = vec![];
1027        let commodity_portion = Dimensionless(1.0);
1028
1029        let result = calculate_investment_limits_for_candidates(&opt_assets, commodity_portion);
1030
1031        assert!(result.is_empty());
1032    }
1033
1034    #[rstest]
1035    fn calculate_investment_limits_for_candidates_commissioned_assets_filtered(
1036        process: Process,
1037        region_id: RegionID,
1038        agent_id: AgentID,
1039    ) {
1040        // Create a mix of commissioned and candidate assets
1041        let process_rc = Rc::new(process);
1042        let capacity = Capacity(10.0);
1043
1044        // Create commissioned asset - should be filtered out
1045        let commissioned_asset = Asset::new_commissioned(
1046            agent_id.clone(),
1047            process_rc.clone(),
1048            region_id.clone(),
1049            capacity,
1050            2015,
1051        )
1052        .unwrap();
1053
1054        // Create candidate asset - should be included
1055        let candidate_asset =
1056            Asset::new_candidate(process_rc.clone(), region_id.clone(), capacity, 2015).unwrap();
1057
1058        let candidate_asset_ref = AssetRef::from(candidate_asset);
1059        let opt_assets = vec![
1060            AssetRef::from(commissioned_asset),
1061            candidate_asset_ref.clone(),
1062        ];
1063        let commodity_portion = Dimensionless(1.0);
1064
1065        let result = calculate_investment_limits_for_candidates(&opt_assets, commodity_portion);
1066
1067        // Only the candidate asset should be in the result
1068        assert_eq!(result.len(), 1);
1069        assert!(result.contains_key(&candidate_asset_ref));
1070    }
1071
1072    #[rstest]
1073    fn calculate_investment_limits_for_candidates_no_investment_constraints(
1074        process: Process,
1075        region_id: RegionID,
1076    ) {
1077        // Create candidate asset without investment constraints
1078        let process_rc = Rc::new(process);
1079        let capacity = Capacity(15.0);
1080
1081        let candidate_asset = Asset::new_candidate(process_rc, region_id, capacity, 2015).unwrap();
1082
1083        let opt_assets = vec![AssetRef::from(candidate_asset.clone())];
1084        let commodity_portion = Dimensionless(0.8);
1085
1086        let result = calculate_investment_limits_for_candidates(&opt_assets, commodity_portion);
1087
1088        // Should return the asset's original capacity since no constraints apply
1089        assert_eq!(result.len(), 1);
1090        let asset_ref = AssetRef::from(candidate_asset);
1091        assert_eq!(result[&asset_ref], AssetCapacity::Continuous(capacity));
1092    }
1093
1094    #[rstest]
1095    // Asset capacity higher than constraint -> limited by constraint
1096    #[case(Capacity(15.0), Capacity(10.0))]
1097    // Asset capacity lower than constraint -> limited by asset capacity
1098    #[case(Capacity(5.0), Capacity(5.0))]
1099    fn calculate_investment_limits_for_candidates_with_constraints(
1100        region_id: RegionID,
1101        process_activity_limits_map: ProcessActivityLimitsMap,
1102        process_flows_map: ProcessFlowsMap,
1103        process_parameter_map: ProcessParameterMap,
1104        #[case] asset_capacity: Capacity,
1105        #[case] expected_limit: Capacity,
1106    ) {
1107        let region_ids: IndexSet<RegionID> = [region_id.clone()].into();
1108
1109        // Add investment constraint with addition limit
1110        let constraint = ProcessInvestmentConstraint {
1111            addition_limit: Some(Capacity(10.0)),
1112        };
1113        let mut constraints = ProcessInvestmentConstraintsMap::new();
1114        constraints.insert((region_id.clone(), 2015), Rc::new(constraint));
1115
1116        let process = Process {
1117            id: "constrained_process".into(),
1118            description: "Process with constraints".into(),
1119            years: 2010..=2020,
1120            activity_limits: process_activity_limits_map,
1121            flows: process_flows_map,
1122            parameters: process_parameter_map,
1123            regions: region_ids,
1124            primary_output: None,
1125            capacity_to_activity: ActivityPerCapacity(1.0),
1126            investment_constraints: constraints,
1127            unit_size: None,
1128        };
1129
1130        let process_rc = Rc::new(process);
1131
1132        let candidate_asset =
1133            Asset::new_candidate(process_rc, region_id, asset_capacity, 2015).unwrap();
1134
1135        let opt_assets = vec![AssetRef::from(candidate_asset.clone())];
1136        let commodity_portion = Dimensionless(1.0);
1137
1138        let result = calculate_investment_limits_for_candidates(&opt_assets, commodity_portion);
1139
1140        // Should be limited by the minimum of asset capacity and constraint
1141        assert_eq!(result.len(), 1);
1142        let asset_ref = AssetRef::from(candidate_asset);
1143        assert_eq!(
1144            result[&asset_ref],
1145            AssetCapacity::Continuous(expected_limit)
1146        );
1147    }
1148
1149    #[rstest]
1150    fn calculate_investment_limits_for_candidates_multiple_assets(
1151        region_id: RegionID,
1152        process_activity_limits_map: ProcessActivityLimitsMap,
1153        process_flows_map: ProcessFlowsMap,
1154        process_parameter_map: ProcessParameterMap,
1155    ) {
1156        let region_ids: IndexSet<RegionID> = [region_id.clone()].into();
1157
1158        // Create first process with constraints
1159        let constraint1 = ProcessInvestmentConstraint {
1160            addition_limit: Some(Capacity(12.0)),
1161        };
1162        let mut constraints1 = ProcessInvestmentConstraintsMap::new();
1163        constraints1.insert((region_id.clone(), 2015), Rc::new(constraint1));
1164
1165        let process1 = Process {
1166            id: "process1".into(),
1167            description: "First process".into(),
1168            years: 2010..=2020,
1169            activity_limits: process_activity_limits_map.clone(),
1170            flows: process_flows_map.clone(),
1171            parameters: process_parameter_map.clone(),
1172            regions: region_ids.clone(),
1173            primary_output: None,
1174            capacity_to_activity: ActivityPerCapacity(1.0),
1175            investment_constraints: constraints1,
1176            unit_size: None,
1177        };
1178
1179        // Create second process without constraints
1180        let process2 = Process {
1181            id: "process2".into(),
1182            description: "Second process".into(),
1183            years: 2010..=2020,
1184            activity_limits: process_activity_limits_map,
1185            flows: process_flows_map,
1186            parameters: process_parameter_map,
1187            regions: region_ids,
1188            primary_output: None,
1189            capacity_to_activity: ActivityPerCapacity(1.0),
1190            investment_constraints: process_investment_constraints(),
1191            unit_size: None,
1192        };
1193
1194        let process1_rc = Rc::new(process1);
1195        let process2_rc = Rc::new(process2);
1196
1197        let candidate1 =
1198            Asset::new_candidate(process1_rc, region_id.clone(), Capacity(20.0), 2015).unwrap();
1199
1200        let candidate2 = Asset::new_candidate(process2_rc, region_id, Capacity(8.0), 2015).unwrap();
1201
1202        let opt_assets = vec![
1203            AssetRef::from(candidate1.clone()),
1204            AssetRef::from(candidate2.clone()),
1205        ];
1206        let commodity_portion = Dimensionless(0.75);
1207
1208        let result = calculate_investment_limits_for_candidates(&opt_assets, commodity_portion);
1209
1210        // Should have both assets in result
1211        assert_eq!(result.len(), 2);
1212
1213        // First asset should be limited by constraint: 12.0 * 0.75 = 9.0
1214        let asset1_ref = AssetRef::from(candidate1);
1215        assert_eq!(
1216            result[&asset1_ref],
1217            AssetCapacity::Continuous(Capacity(9.0))
1218        );
1219
1220        // Second asset should use its original capacity (no constraints)
1221        let asset2_ref = AssetRef::from(candidate2);
1222        assert_eq!(
1223            result[&asset2_ref],
1224            AssetCapacity::Continuous(Capacity(8.0))
1225        );
1226    }
1227
1228    #[rstest]
1229    fn calculate_investment_limits_for_candidates_discrete_capacity(
1230        region_id: RegionID,
1231        process_activity_limits_map: crate::process::ProcessActivityLimitsMap,
1232        process_flows_map: crate::process::ProcessFlowsMap,
1233        process_parameter_map: crate::process::ProcessParameterMap,
1234    ) {
1235        let region_ids: IndexSet<RegionID> = [region_id.clone()].into();
1236
1237        // Add investment constraint
1238        let constraint = ProcessInvestmentConstraint {
1239            addition_limit: Some(Capacity(35.0)), // Enough for 3.5 units at 10.0 each
1240        };
1241        let mut constraints = ProcessInvestmentConstraintsMap::new();
1242        constraints.insert((region_id.clone(), 2015), Rc::new(constraint));
1243
1244        let process = Process {
1245            id: "discrete_process".into(),
1246            description: "Process with discrete units".into(),
1247            years: 2010..=2020,
1248            activity_limits: process_activity_limits_map,
1249            flows: process_flows_map,
1250            parameters: process_parameter_map,
1251            regions: region_ids,
1252            primary_output: None,
1253            capacity_to_activity: ActivityPerCapacity(1.0),
1254            investment_constraints: constraints,
1255            unit_size: Some(Capacity(10.0)), // Discrete units of 10.0 capacity each
1256        };
1257
1258        let process_rc = Rc::new(process);
1259        let capacity = Capacity(50.0); // 5 units at 10.0 each
1260
1261        let candidate_asset = Asset::new_candidate(process_rc, region_id, capacity, 2015).unwrap();
1262
1263        let opt_assets = vec![AssetRef::from(candidate_asset.clone())];
1264        let commodity_portion = Dimensionless(1.0);
1265
1266        let result = calculate_investment_limits_for_candidates(&opt_assets, commodity_portion);
1267
1268        // Should be limited by constraint and rounded down to whole units
1269        // Constraint: 35.0, divided by unit size 10.0 = 3.5 -> floor to 3 units = 30.0
1270        assert_eq!(result.len(), 1);
1271        let asset_ref = AssetRef::from(candidate_asset);
1272        assert_eq!(
1273            result[&asset_ref],
1274            AssetCapacity::Discrete(3, Capacity(10.0))
1275        );
1276        assert_eq!(result[&asset_ref].total_capacity(), Capacity(30.0));
1277    }
1278}