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, AssetRef};
5use crate::commodity::{Commodity, CommodityID, CommodityMap};
6use crate::model::Model;
7use crate::output::DataWriter;
8use crate::process::ProcessID;
9use crate::region::RegionID;
10use crate::simulation::prices::Prices;
11use crate::time_slice::{TimeSliceID, TimeSliceInfo, TimeSliceLevel, TimeSliceSelection};
12use crate::timeit::InvestmentTimer;
13use crate::units::{ActivityPerCapacity, Capacity, Dimensionless, Flow, FlowPerCapacity};
14use anyhow::{Result, ensure};
15use context_manager;
16use indexmap::IndexMap;
17use itertools::Itertools;
18use log::{debug, warn};
19use rayon::prelude::*;
20use std::collections::HashMap;
21use strum::IntoEnumIterator;
22
23pub mod appraisal;
24use appraisal::coefficients::calculate_coefficients_for_assets;
25use appraisal::{
26    AppraisalOutput, appraise_investment, count_equal_and_best_appraisal_outputs,
27    sort_and_filter_appraisal_outputs,
28};
29
30/// A map of demand across time slices for a specific market
31pub type DemandMap = IndexMap<TimeSliceID, Flow>;
32
33/// Demand for a given combination of commodity, region and time slice
34pub type AllDemandMap = IndexMap<(CommodityID, RegionID, TimeSliceID), Flow>;
35
36/// Perform agent investment to determine capacity investment of new assets for next milestone year.
37///
38/// # Arguments
39///
40/// * `model` - The model
41/// * `year` - Current milestone year
42/// * `existing_assets` - The asset pool (commissioned and otherwise)
43/// * `prices` - Commodity prices calculated in the previous full system dispatch
44/// * `writer` - Data writer
45///
46/// # Returns
47///
48/// The assets selected (including retained commissioned assets) for the given planning `year` or an
49/// error.
50#[context_manager::wrap(InvestmentTimer)]
51pub fn perform_agent_investment(
52    model: &Model,
53    year: u32,
54    existing_assets: &[AssetRef],
55    prices: &Prices,
56    writer: &mut DataWriter,
57) -> Result<Vec<AssetRef>> {
58    // Initialise net demand map
59    let mut net_demand =
60        flatten_preset_demands_for_year(&model.commodities, &model.time_slice_info, year);
61
62    // Keep a list of all the assets selected
63    // This includes Commissioned assets that are selected for retention, and new Ready assets
64    let mut all_selected_assets = Vec::new();
65
66    let investment_order = &model.investment_order[&year];
67    debug!(
68        "Investment order for year '{year}': {}",
69        investment_order.iter().join(" -> ")
70    );
71
72    // Keep track of the markets that have been seen so far. This will be used to apply
73    // balance constraints in the dispatch optimisation - we only apply balance constraints for
74    // markets that have been seen so far.
75    let mut seen_markets = Vec::new();
76
77    // Iterate over market sets in the investment order for this year
78    for market_set in investment_order {
79        // Select assets for this market set
80        let selected_assets = market_set.select_assets(
81            model,
82            year,
83            &net_demand,
84            existing_assets,
85            prices,
86            &seen_markets,
87            &all_selected_assets,
88            writer,
89        )?;
90
91        // Update our list of seen markets
92        for market in market_set.iter_markets() {
93            seen_markets.push(market.clone());
94        }
95
96        // If no assets have been selected, skip dispatch optimisation
97        // **TODO**: this probably means there's no demand for the market, which we could
98        // presumably preempt
99        if selected_assets.is_empty() {
100            debug!("No assets selected for '{market_set}'");
101            continue;
102        }
103
104        // Add the selected assets to the list of all selected assets
105        all_selected_assets.extend(selected_assets.iter().cloned());
106
107        // Perform dispatch optimisation with assets that have been selected so far
108        // **TODO**: presumably we only need to do this for selected_assets, as assets added in
109        // previous iterations should not change
110        debug!("Running post-investment dispatch for '{market_set}'");
111
112        // As upstream markets by definition will not yet have producers, we explicitly set
113        // their prices using external values so that they don't appear free
114        let solution = DispatchRun::new(model, &all_selected_assets, year)
115            .without_commodity_constraints()
116            .with_market_balance_subset(&seen_markets)
117            .with_input_prices(&prices.shadow)
118            .run(&format!("post {market_set} investment"), writer)?;
119
120        // Update demand map with flows from newly added assets
121        update_net_demand_map(
122            &mut net_demand,
123            &solution.create_flow_map(),
124            &selected_assets,
125        );
126    }
127
128    Ok(all_selected_assets)
129}
130
131/// Flatten the preset commodity demands for a given year into a map of commodity, region and
132/// time slice to demand.
133///
134/// Since demands for some commodities may be specified at a coarser time slice level, we need to
135/// distribute these demands over all time slices. Note: the way that we do this distribution is
136/// irrelevant, as demands will only be balanced to the appropriate level, but we still need to do
137/// this for the solver to work.
138///
139/// **TODO**: these assumptions may need to be revisited, e.g. when we come to storage technologies
140pub fn flatten_preset_demands_for_year(
141    commodities: &CommodityMap,
142    time_slice_info: &TimeSliceInfo,
143    year: u32,
144) -> AllDemandMap {
145    let mut demand_map = AllDemandMap::new();
146    for (commodity_id, commodity) in commodities {
147        for ((region_id, data_year, time_slice_selection), demand) in &commodity.demand {
148            if *data_year != year {
149                continue;
150            }
151
152            // We split the demand equally over all time slices in the selection
153            // NOTE: since demands will only be balanced to the time slice level of the commodity
154            // it doesn't matter how we do this distribution, only the total matters.
155            #[allow(clippy::cast_precision_loss)]
156            let n_time_slices = time_slice_selection.iter(time_slice_info).count() as f64;
157            let demand_per_slice = *demand / Dimensionless(n_time_slices);
158            for (time_slice, _) in time_slice_selection.iter(time_slice_info) {
159                demand_map.insert(
160                    (commodity_id.clone(), region_id.clone(), time_slice.clone()),
161                    demand_per_slice,
162                );
163            }
164        }
165    }
166    demand_map
167}
168
169/// Update net demand map with flows from a set of assets
170///
171/// Non-primary output flows are ignored. This way, demand profiles aren't affected by production
172/// of side-products from other assets. The result is that all commodity demands must be met by
173/// assets with that commodity as their primary output. Effectively, agents do not see production of
174/// side-products from other assets when making investment decisions.
175///
176/// TODO: this is a very flawed approach. The proper solution might be for agents to consider
177/// multiple commodities simultaneously, but that would require substantial work to implement.
178pub fn update_net_demand_map(demand: &mut AllDemandMap, flows: &FlowMap, assets: &[AssetRef]) {
179    for ((asset, commodity_id, time_slice), flow) in flows {
180        if assets.contains(asset) {
181            let key = (
182                commodity_id.clone(),
183                asset.region_id().clone(),
184                time_slice.clone(),
185            );
186
187            // Only consider input flows and output flows from the primary output commodity
188            // (excluding secondary outputs)
189            if (flow < &Flow(0.0))
190                || asset
191                    .primary_output()
192                    .is_some_and(|p| &p.commodity.id == commodity_id)
193            {
194                // Note: we use the negative of the flow as input flows are negative in the flow map.
195                demand
196                    .entry(key)
197                    .and_modify(|value| *value -= *flow)
198                    .or_insert(-*flow);
199            }
200        }
201    }
202}
203
204/// Calculates a characteristic capacity scale for a candidate asset.
205///
206/// The returned value is the capacity that would satisfy the total annual demand assuming the asset
207/// operates at its maximum annual activity for the entire year. It ignores finer-grained activity
208/// constraints and temporal variations in demand.
209///
210/// For processes that do not define a tranche size, this value is later scaled by
211/// `capacity_tranche_fraction` to set the capacity of one candidate investment tranche.
212///
213/// If the asset has zero maximum annual supply, zero capacity is returned. This indicates that the
214/// asset is non-feasible, and will be excluded from consideration by `select_best_assets`.
215pub fn calculate_candidate_asset_capacity_scale(
216    asset: &Asset,
217    commodity: &Commodity,
218    demand: &DemandMap,
219) -> Capacity {
220    let coeff = asset.get_flow(&commodity.id).unwrap().coeff;
221    let max_annual_supply_per_capacity = *asset
222        .get_activity_per_capacity_limits_for_selection(&TimeSliceSelection::Annual)
223        .end()
224        * coeff;
225    if max_annual_supply_per_capacity < FlowPerCapacity::EPSILON {
226        return Capacity(0.0);
227    }
228    let annual_demand = demand.values().copied().sum::<Flow>();
229    annual_demand / max_annual_supply_per_capacity
230}
231
232/// Returns the minimum installed capacity required for `asset` to satisfy the demand that it can
233/// potentially serve, accounting for its activity constraints.
234///
235/// The returned value is the maximum capacity requirement implied by any time-slice selection,
236/// since constraints at coarser aggregation levels (e.g. seasonal or annual limits) can require
237/// more capacity than constraints at the finest time-slice level.
238///
239/// Demand is evaluated using the commodity's balance level. Demand within a balance bucket is
240/// treated as fungible: if the asset is capable of operating in any constituent time slice of a
241/// bucket, then all demand in that bucket is considered serviceable by the asset.
242///
243/// Selections whose maximum supply is zero are ignored. Such selections would otherwise imply an
244/// infinite capacity requirement and therefore provide no useful lower bound.
245pub fn get_demand_limiting_capacity(
246    time_slice_info: &TimeSliceInfo,
247    asset: &Asset,
248    commodity: &Commodity,
249    demand: &DemandMap,
250) -> Capacity {
251    let coeff = asset.get_flow(&commodity.id).unwrap().coeff;
252    let mut capacity = Capacity(0.0);
253    let mut demand_cache: HashMap<_, Flow> = HashMap::new();
254
255    // Calculate demand-limiting capacity at each timeslice level and take the max.
256    for level in TimeSliceLevel::iter() {
257        for selection in time_slice_info.iter_selections_at_level(level) {
258            // Maximum supply within this selection according to the asset's activity limits.
259            let max_supply_for_selection = *asset
260                .get_activity_per_capacity_limits_for_selection(&selection)
261                .end()
262                * coeff;
263
264            // Selections with zero supply would imply infinite demand-limiting capacity,
265            // so they do not contribute to the maximum.
266            if max_supply_for_selection == FlowPerCapacity(0.0) {
267                continue;
268            }
269
270            // Serviceable demand within this selection.
271            //
272            // Demand is effectively grouped into balance buckets at the commodity's
273            // balance level. A balance bucket contributes if:
274            //   1. The bucket is contained within this selection, and
275            //   2. The asset can operate in at least one constituent timeslice
276            //      within that bucket.
277            //
278            // Demand within a balance bucket is fungible, so if the asset can serve
279            // any timeslice in the bucket, all demand in that bucket is considered
280            // serviceable.
281            let demand_selection_level = level.max(commodity.time_slice_level);
282            let demand_selection = selection
283                .containing_selection_at_level(demand_selection_level)
284                .unwrap();
285            let serviceable_demand_for_selection = *demand_cache
286                .entry(demand_selection.clone())
287                .or_insert_with(|| {
288                    demand_selection
289                        .iter_at_level(time_slice_info, commodity.time_slice_level)
290                        .unwrap()
291                        .filter(|(bucket, _)| {
292                            bucket.iter(time_slice_info).any(|(ts, _)| {
293                                *asset.get_activity_per_capacity_limits(ts).end()
294                                    > ActivityPerCapacity(0.0)
295                            })
296                        })
297                        .map(|(bucket, _)| {
298                            bucket
299                                .iter(time_slice_info)
300                                .map(|(ts, _)| demand[ts])
301                                .sum::<Flow>()
302                        })
303                        .sum()
304                });
305
306            // Calculate demand-limiting capacity for this selection and take the
307            // maximum across all selections.
308            capacity = capacity.max(serviceable_demand_for_selection / max_supply_for_selection);
309        }
310    }
311
312    capacity
313}
314
315/// Print debug message if there are multiple equally good outputs
316fn log_on_equal_appraisal_outputs(
317    outputs: &[AppraisalOutput],
318    agent_id: &AgentID,
319    commodity_id: &CommodityID,
320    region_id: &RegionID,
321) {
322    if outputs.is_empty() {
323        return;
324    }
325
326    let num_identical = count_equal_and_best_appraisal_outputs(outputs);
327
328    if num_identical > 0 {
329        let asset_details = outputs[..=num_identical]
330            .iter()
331            .map(|output| {
332                let asset = &output.asset;
333                format!(
334                    "Process ID: '{}' (State: {}{}, Commission year: {})",
335                    asset.process_id(),
336                    asset.state(),
337                    asset
338                        .id()
339                        .map(|id| format!(", Asset ID: {id}"))
340                        .unwrap_or_default(),
341                    asset.commission_year()
342                )
343            })
344            .join(", ");
345        debug!(
346            "Found equally good appraisals for Agent ID: {agent_id}, Commodity: '{commodity_id}', \
347            Region: {region_id}. Options: [{asset_details}]. Selecting first option.",
348        );
349    }
350}
351
352/// Get the best assets for meeting demand for the given commodity
353#[allow(clippy::too_many_arguments)]
354pub fn select_best_assets(
355    model: &Model,
356    mut opt_assets: Vec<AssetRef>,
357    agent_addition_limits: HashMap<ProcessID, Capacity>,
358    agent_total_limits: HashMap<ProcessID, Capacity>,
359    commodity: &Commodity,
360    agent: &Agent,
361    region_id: &RegionID,
362    prices: &Prices,
363    mut demand: DemandMap,
364    year: u32,
365    writer: &mut DataWriter,
366) -> Result<Vec<AssetRef>> {
367    let objective_type = &agent.objectives[&year];
368
369    // Remaining addition limits for candidate processes
370    // Initialised as the full agent addition limits, and reduced as candidate assets are selected
371    let mut remaining_agent_addition_limits = agent_addition_limits;
372
373    // Remaining total capacity for all assets
374    // Initialised as full agent total limits, and reduced as each asset is selection
375    let mut remaining_agent_total_limits = agent_total_limits;
376
377    // Store commissioned tranches available for retention and replace assets with single tranches
378    let mut available_retention_tranches =
379        prepare_commissioned_assets_for_retention(&mut opt_assets);
380
381    // Calculate coefficients for all asset options according to the agent's objective
382    let coefficients =
383        calculate_coefficients_for_assets(model, objective_type, &opt_assets, prices, year);
384
385    // Iteratively select the best asset until demand is met
386    let mut round = 0;
387    let mut best_assets: Vec<AssetRef> = Vec::new();
388    while is_any_remaining_demand(
389        &demand,
390        model.parameters.remaining_demand_absolute_tolerance,
391    ) {
392        // Remove assets that would exceed the remaining limits for their processes from the options
393        // The addition limit applies only to candidate assets, the total limit applies to all assets
394        remove_assets_exceeding_agent_limits(
395            &mut opt_assets,
396            &remaining_agent_addition_limits,
397            true,
398        );
399        remove_assets_exceeding_agent_limits(&mut opt_assets, &remaining_agent_total_limits, false);
400        ensure!(
401            !opt_assets.is_empty(),
402            "Failed to meet demand for commodity '{}' in region '{}' with provided investment \
403            options. This may be due to overly restrictive process investment constraints.",
404            commodity.id,
405            region_id
406        );
407        // Appraise all options in parallel: each asset's appraisal is independent (all shared
408        // state is read-only within this block), so we can safely use Rayon here.
409        // Each HiGHS solve inside `appraise_investment` is configured to use only one thread
410        // (via `parallel="off"`) to avoid over-subscription.
411        let mut outputs: Vec<AppraisalOutput> = opt_assets
412            .par_iter()
413            .map(|asset| -> Result<Option<AppraisalOutput>> {
414                // Skip assets with zero capacity
415                if asset.total_capacity() <= Capacity(0.0) {
416                    return Ok(None);
417                }
418
419                Ok(Some(appraise_investment(
420                    model,
421                    asset,
422                    commodity,
423                    objective_type,
424                    &coefficients[asset],
425                    &demand,
426                )?))
427            })
428            .collect::<Result<Vec<_>>>()? // propagate any solver error
429            .into_iter()
430            .flatten()
431            .collect();
432
433        // Save appraisal results
434        writer.write_appraisal_debug_info(
435            year,
436            &format!("{} {} round {}", commodity.id, agent.id, round),
437            &outputs,
438            &demand,
439        )?;
440
441        // Sort by investment priority and discard non-feasible options
442        let num_nonfeasible = sort_and_filter_appraisal_outputs(&mut outputs);
443
444        // If none of the remaining options are feasible, we terminate the loop. We may still be
445        // able to meet the full demands with assets selected so far, so we continue anyway with a
446        // warning.
447        if outputs.is_empty() {
448            warn!(
449                "Investment appraisal completed with unmet demand for commodity '{}', region '{}', \
450                year '{}', agent '{}'. {} non-feasible investments were not considered. \
451                This unmet demand may still be satisfied during the full system dispatch.",
452                commodity.id, region_id, year, agent.id, num_nonfeasible
453            );
454            break;
455        }
456
457        // Warn if there are multiple equally good assets
458        log_on_equal_appraisal_outputs(&outputs, &agent.id, &commodity.id, region_id);
459
460        let best_output = outputs.into_iter().next().unwrap();
461
462        // Log the selected asset
463        debug!(
464            "Selected {} asset '{}' (capacity: {})",
465            best_output.asset.state(),
466            best_output.asset.process_id(),
467            best_output.asset.total_capacity()
468        );
469
470        // Update the remaining selection state
471        update_selection_state(
472            &best_output.asset,
473            &mut opt_assets,
474            &mut remaining_agent_addition_limits,
475            &mut remaining_agent_total_limits,
476            &mut available_retention_tranches,
477        );
478
479        // Record the selected asset
480        record_asset_selection(best_output.asset, &mut best_assets);
481
482        demand = best_output.unmet_demand;
483        round += 1;
484    }
485
486    // Convert Candidate assets to Ready
487    // At this point we also assign the agent ID to the asset
488    for asset in &mut best_assets {
489        if asset.is_candidate() {
490            asset
491                .make_mut()
492                .select_candidate_for_investment(agent.id.clone());
493        }
494    }
495
496    Ok(best_assets)
497}
498
499/// Prepare existing assets for reappraisal.
500///
501/// Assets are replaced in `assets` with an asset representing a single tranche, as they are
502/// appraised one tranche at a time. Returns a map from the asset to its original number of tranches.
503fn prepare_commissioned_assets_for_retention(assets: &mut [AssetRef]) -> HashMap<AssetRef, u32> {
504    let mut available_retention_tranches = HashMap::new();
505
506    for asset in assets.iter_mut().filter(|asset| asset.is_commissioned()) {
507        let num_tranches = asset.num_tranches();
508
509        // Replace with single tranche as we appraise one tranche at a time
510        *asset = asset.clone().as_single_tranche();
511
512        // Store remaining tranches
513        available_retention_tranches.insert(asset.clone(), num_tranches);
514    }
515
516    available_retention_tranches
517}
518
519/// Check whether there is any remaining demand that is unmet in any time slice
520fn is_any_remaining_demand(demand: &DemandMap, absolute_tolerance: Flow) -> bool {
521    demand.values().any(|flow| *flow > absolute_tolerance)
522}
523
524/// Remove assets that exceed the provided process limits for one complete tranche.
525fn remove_assets_exceeding_agent_limits(
526    opt_assets: &mut Vec<AssetRef>,
527    remaining_agent_limits: &HashMap<ProcessID, Capacity>,
528    only_candidates: bool,
529) {
530    opt_assets.retain(|asset| {
531        (only_candidates && !asset.is_candidate())
532            || remaining_agent_limits
533                .get(asset.process_id())
534                .is_none_or(|limit| *limit >= asset.total_capacity())
535    });
536}
537
538// Update remaining process capacity limit with the capacity of a selected asset
539fn subtract_capacity_from_remaining_limit(
540    best_asset: &AssetRef,
541    remaining_agent_limits: &mut HashMap<ProcessID, Capacity>,
542) {
543    if let Some(remaining_capacity) = remaining_agent_limits.get_mut(best_asset.process_id()) {
544        *remaining_capacity -= best_asset.total_capacity();
545        assert!(
546            *remaining_capacity >= Capacity(0.0),
547            "Remaining Capacity has fallen below zero"
548        );
549    }
550}
551
552/// Update the remaining investment options and selection state.
553///
554/// If the asset is a candidate, its one-tranche capacity is subtracted from
555/// `remaining_agent_addition_limits` (if applicable) to ensure that process addition limits are not
556/// exceeded. If the asset is commissioned, one tranche is subtracted from
557/// `available_retention_tranches` to ensure that retention does not invent new capacity.
558///
559/// # Arguments
560///
561/// * `best_asset` - The asset that has been selected as the best option in this round
562/// * `opt_assets` - The list of remaining asset options to be considered in future rounds
563/// * `remaining_agent_addition_limits` - The remaining agent addition limits for processes
564/// * `remaining_agent_total_limit` - The remaining capacity for processes
565/// * `available_retention_tranches` - The commissioned tranches available for retention
566fn update_selection_state(
567    best_asset: &AssetRef,
568    opt_assets: &mut Vec<AssetRef>,
569    remaining_agent_addition_limits: &mut HashMap<ProcessID, Capacity>,
570    remaining_agent_total_limits: &mut HashMap<ProcessID, Capacity>,
571    available_retention_tranches: &mut HashMap<AssetRef, u32>,
572) {
573    // Subtract asset capacity from the total capacity limit, if applicable.
574    subtract_capacity_from_remaining_limit(best_asset, remaining_agent_total_limits);
575
576    // Update the remaining agent addition limit for the selected asset, if applicable, and remove it
577    // from the options if the limit is exhausted.
578    if best_asset.is_candidate() {
579        // Candidate assets: remove capacity from the investment limit, if applicable.
580        subtract_capacity_from_remaining_limit(best_asset, remaining_agent_addition_limits);
581    } else {
582        // Commissioned assets: we've appraised a single tranche, so remove one tranche from the
583        // available retention count for this asset.
584        let remaining = available_retention_tranches.get_mut(best_asset).unwrap();
585        *remaining = remaining.saturating_sub(1);
586
587        // If all tranches have been selected, remove the asset from the investment options.
588        if *remaining == 0 {
589            let old_idx = opt_assets
590                .iter()
591                .position(|asset| *asset == *best_asset)
592                .unwrap();
593            opt_assets.swap_remove(old_idx);
594            available_retention_tranches.remove(best_asset);
595        }
596    }
597}
598
599/// Record a selected asset. Candidate selections represent one tranche; repeated selections of the
600/// same candidate increase the resulting asset's number of tranches while preserving its tranche
601/// size.
602///
603/// # Arguments
604///
605/// * `best_asset` - The asset that has been selected as the best option in this round
606/// * `best_assets` - The list of assets that have been selected so far
607fn record_asset_selection(best_asset: AssetRef, best_assets: &mut Vec<AssetRef>) {
608    assert!(
609        best_asset.is_commissioned() || best_asset.is_candidate(),
610        "Invalid asset type"
611    );
612
613    // Add the selected asset to the list of best assets, or add one tranche if it's already there.
614    if let Some(existing_asset) = best_assets.iter_mut().find(|asset| **asset == best_asset) {
615        // If the asset is already selected, add the additional required tranche
616        existing_asset
617            .make_mut()
618            .increase_capacity(best_asset.capacity());
619    } else {
620        // Otherwise add it to the list of best assets. Selected assets are unmothballed.
621        best_assets.push(best_asset.with_no_mothballed_tranches());
622    }
623}
624
625#[cfg(test)]
626mod tests {
627    use super::*;
628    use crate::commodity::Commodity;
629    use crate::fixture::{
630        agent_id, asset, process, process_activity_limits_map, process_flows_map, svd_commodity,
631        time_slice, time_slice_info, time_slice_info2,
632    };
633    use crate::process::{ActivityLimits, FlowType, Process, ProcessFlow};
634    use crate::time_slice::{TimeSliceID, TimeSliceInfo, TimeSliceSelection};
635    use crate::units::Dimensionless;
636    use crate::units::{Flow, FlowPerActivity, MoneyPerFlow};
637    use indexmap::indexmap;
638    use rstest::{fixture, rstest};
639
640    use std::sync::Arc;
641
642    #[rstest]
643    fn get_demand_limiting_capacity_works(
644        time_slice: TimeSliceID,
645        time_slice_info: TimeSliceInfo,
646        svd_commodity: Commodity,
647        mut process: Process,
648    ) {
649        // Add flows for the process using the existing commodity fixture
650        let commodity_rc = Arc::new(svd_commodity);
651        let process_flow = ProcessFlow {
652            commodity: Arc::clone(&commodity_rc),
653            coeff: FlowPerActivity(2.0), // 2 units of flow per unit of activity
654            kind: FlowType::Fixed,
655            cost: MoneyPerFlow(0.0),
656        };
657        let process_flows = indexmap! { commodity_rc.id.clone() => process_flow.clone() };
658        let process_flows_map = process_flows_map(process.regions.clone(), Arc::new(process_flows));
659        process.flows = process_flows_map;
660
661        // Create asset with the configured process
662        let asset = asset(process);
663
664        // Create demand map - demand of 10.0 for our time slice
665        let demand = indexmap! { time_slice.clone() => Flow(10.0)};
666
667        // Call the function
668        let result = get_demand_limiting_capacity(&time_slice_info, &asset, &commodity_rc, &demand);
669
670        // Expected calculation:
671        // max_flow_per_cap = activity_per_capacity_limit (1.0) * coeff (2.0) = 2.0
672        // required_capacity = demand (10.0) / max_flow_per_cap (2.0) = 5.0
673        assert_eq!(result, Capacity(5.0));
674    }
675
676    #[rstest]
677    fn get_demand_limiting_capacity_multiple_time_slices(
678        time_slice_info2: TimeSliceInfo,
679        svd_commodity: Commodity,
680        mut process: Process,
681    ) {
682        let (time_slice1, time_slice2) =
683            time_slice_info2.time_slices.keys().collect_tuple().unwrap();
684
685        // Add flows for the process using the existing commodity fixture
686        let commodity_rc = Arc::new(svd_commodity);
687        let process_flow = ProcessFlow {
688            commodity: Arc::clone(&commodity_rc),
689            coeff: FlowPerActivity(1.0), // 1 unit of flow per unit of activity
690            kind: FlowType::Fixed,
691            cost: MoneyPerFlow(0.0),
692        };
693        let process_flows = indexmap! { commodity_rc.id.clone() => process_flow.clone() };
694        let process_flows_map = process_flows_map(process.regions.clone(), Arc::new(process_flows));
695        process.flows = process_flows_map;
696
697        // Add activity limits for the process
698        let mut limits = ActivityLimits::new_with_full_availability(&time_slice_info2);
699        limits.add_time_slice_limit(time_slice1.clone(), Dimensionless(0.0)..=Dimensionless(0.2));
700        limits.add_time_slice_limit(time_slice2.clone(), Dimensionless(0.0)..=Dimensionless(0.0));
701        let limits_map = process_activity_limits_map(process.regions.clone(), limits);
702        process.activity_limits = limits_map;
703
704        // Create asset with the configured process
705        let asset = asset(process);
706
707        // Create demand map with different demands for each time slice
708        let demand = indexmap! {
709            time_slice1.clone() => Flow(4.0), // Requires capacity of 4.0/0.2 = 20.0
710            time_slice2.clone() => Flow(3.0), // Would require infinite capacity, but should be skipped
711        };
712
713        // Call the function
714        let result =
715            get_demand_limiting_capacity(&time_slice_info2, &asset, &commodity_rc, &demand);
716
717        // Expected: maximum of the capacity requirements across time slices (excluding zero limit)
718        // Time slice 1: demand (4.0) / (activity_limit (0.2) * coeff (1.0)) = 20.0
719        // Time slice 2: skipped due to zero activity limit
720        // Maximum = 20.0
721        assert_eq!(result, Capacity(20.0));
722    }
723
724    #[rstest]
725    fn get_demand_limiting_capacity_uses_coarser_limits(
726        time_slice_info2: TimeSliceInfo,
727        svd_commodity: Commodity,
728        mut process: Process,
729    ) {
730        let (time_slice1, time_slice2) =
731            time_slice_info2.time_slices.keys().collect_tuple().unwrap();
732
733        // Configure a 1:1 activity-to-flow relationship.
734        let commodity_rc = Arc::new(svd_commodity);
735        let process_flow = ProcessFlow {
736            commodity: Arc::clone(&commodity_rc),
737            coeff: FlowPerActivity(1.0),
738            kind: FlowType::Fixed,
739            cost: MoneyPerFlow(0.0),
740        };
741
742        let process_flows = indexmap! { commodity_rc.id.clone() => process_flow.clone() };
743        process.flows = process_flows_map(process.regions.clone(), Arc::new(process_flows));
744
745        // Fine-grained limits imply a capacity requirement of 5:
746        //   TS1: 5 / 1 = 5
747        //   TS2: 5 / 1 = 5
748        //
749        // The annual limit implies:
750        //   (5 + 5) / 0.5 = 20
751        //
752        // The function should return the larger value.
753        let limits = HashMap::from([
754            (
755                TimeSliceSelection::Single(time_slice1.clone()),
756                Dimensionless(0.0)..=Dimensionless(1.0),
757            ),
758            (
759                TimeSliceSelection::Single(time_slice2.clone()),
760                Dimensionless(0.0)..=Dimensionless(1.0),
761            ),
762            (
763                TimeSliceSelection::Annual,
764                Dimensionless(0.0)..=Dimensionless(0.5),
765            ),
766        ]);
767
768        process.activity_limits = process_activity_limits_map(
769            process.regions.clone(),
770            ActivityLimits::new_from_limits(&limits, &time_slice_info2).unwrap(),
771        );
772
773        let asset = asset(process);
774
775        let demand = indexmap! {
776            time_slice1.clone() => Flow(5.0),
777            time_slice2.clone() => Flow(5.0),
778        };
779
780        let result =
781            get_demand_limiting_capacity(&time_slice_info2, &asset, &commodity_rc, &demand);
782
783        assert_eq!(result, Capacity(20.0));
784    }
785
786    #[rstest]
787    #[case(Flow(10.0), Dimensionless(1.0), Capacity(10.0))] // normal: demand / (limit * coeff) = 10 / (1 * 1) = 10
788    #[case(Flow(0.0), Dimensionless(1.0), Capacity(0.0))] // zero demand → zero capacity
789    #[case(Flow(10.0), Dimensionless(0.5), Capacity(20.0))] // activity limit < 1: 10 / (0.5 * 1) = 20
790    #[case(Flow(10.0), Dimensionless(0.0), Capacity(0.0))] // activity limit = 0 → zero capacity
791    fn calculate_asset_capacity_scale_works(
792        time_slice: TimeSliceID,
793        time_slice_info: TimeSliceInfo,
794        svd_commodity: Commodity,
795        mut process: Process,
796        #[case] demand_value: Flow,
797        #[case] activity_limit: Dimensionless,
798        #[case] expected: Capacity,
799    ) {
800        let commodity_rc = Arc::new(svd_commodity);
801        let process_flow = ProcessFlow {
802            commodity: Arc::clone(&commodity_rc),
803            coeff: FlowPerActivity(1.0),
804            kind: FlowType::Fixed,
805            cost: MoneyPerFlow(0.0),
806        };
807        process.flows = process_flows_map(
808            process.regions.clone(),
809            Arc::new(indexmap! { commodity_rc.id.clone() => process_flow }),
810        );
811
812        let mut limits = ActivityLimits::new_with_full_availability(&time_slice_info);
813        limits.add_time_slice_limit(time_slice.clone(), Dimensionless(0.0)..=activity_limit);
814        process.activity_limits = process_activity_limits_map(process.regions.clone(), limits);
815
816        let asset = asset(process);
817        let demand = indexmap! { time_slice => demand_value };
818        assert_eq!(
819            calculate_candidate_asset_capacity_scale(&asset, &commodity_rc, &demand),
820            expected
821        );
822    }
823
824    #[rstest]
825    fn subtract_capacity_from_remaining_limit_works(asset: Asset) {
826        let mut remaining_agent_limits =
827            HashMap::from([(asset.process_id().clone(), Capacity(10.0))]);
828        subtract_capacity_from_remaining_limit(
829            &AssetRef::from(asset.clone()),
830            &mut remaining_agent_limits,
831        );
832
833        assert_eq!(
834            remaining_agent_limits[asset.process_id()].clone(),
835            Capacity(8.0)
836        );
837    }
838
839    #[fixture]
840    fn commissioned_asset(agent_id: AgentID, asset: Asset) -> Asset {
841        Asset::new_commissioned(
842            agent_id,
843            Arc::new(asset.process().clone()),
844            asset.region_id().clone(),
845            asset.capacity(),
846            asset.commission_year(),
847        )
848        .unwrap()
849    }
850
851    #[fixture]
852    fn candidate_asset(asset: Asset) -> Asset {
853        Asset::new_candidate(
854            Arc::new(asset.process().clone()),
855            asset.region_id().clone(),
856            asset.capacity().tranche_size(),
857            asset.commission_year(),
858        )
859        .unwrap()
860    }
861
862    #[rstest]
863    fn remove_assets_exceeding_agent_limits_works(
864        commissioned_asset: Asset,
865        candidate_asset: Asset,
866    ) {
867        let mut opt_assets = vec![
868            commissioned_asset.clone().into(),
869            candidate_asset.clone().into(),
870        ];
871        let limits = HashMap::from([(commissioned_asset.process_id().clone(), Capacity(1.0))]);
872
873        // Check only the candidate asset is removed when `only_candidates` is true
874        remove_assets_exceeding_agent_limits(&mut opt_assets, &limits, true);
875        assert_eq!(opt_assets.len(), 1);
876        assert_eq!(opt_assets[0], commissioned_asset.clone().into());
877
878        // Check all assets are removed when `only_candidates` is false
879        opt_assets.push(candidate_asset.clone().into());
880        remove_assets_exceeding_agent_limits(&mut opt_assets, &limits, false);
881        assert!(opt_assets.is_empty());
882    }
883
884    #[rstest]
885    #[case(false)]
886    #[case(true)]
887    fn update_selection_state_works(
888        commissioned_asset: Asset,
889        candidate_asset: Asset,
890        #[case] commissioned: bool,
891    ) {
892        let best_asset: AssetRef = if commissioned {
893            commissioned_asset.clone().into()
894        } else {
895            candidate_asset.clone().into()
896        };
897        let mut opt_assets = vec![best_asset.clone()];
898        let mut addition_limits = HashMap::from([(best_asset.process_id().clone(), Capacity(2.0))]);
899        let mut total_limits = HashMap::from([(best_asset.process_id().clone(), Capacity(2.0))]);
900        let mut retention_tranches = HashMap::from([(best_asset.clone(), 1)]);
901
902        update_selection_state(
903            &best_asset,
904            &mut opt_assets,
905            &mut addition_limits,
906            &mut total_limits,
907            &mut retention_tranches,
908        );
909
910        if commissioned {
911            // Expect total_limits to reduce and retention_tranches to be removed
912            assert_eq!(total_limits[best_asset.process_id()], Capacity(0.0));
913            assert!(!retention_tranches.contains_key(&best_asset));
914
915            // Expect addition_limits to remain unchanged
916            assert_eq!(addition_limits[best_asset.process_id()], Capacity(2.0));
917        } else {
918            // Expect total_limits and addition_limits to reduce
919            assert_eq!(total_limits[best_asset.process_id()], Capacity(0.0));
920            assert_eq!(addition_limits[best_asset.process_id()], Capacity(0.0));
921
922            // Expect retention_tranches to remain unchanged
923            assert_eq!(retention_tranches[&best_asset], 1);
924        }
925    }
926}