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