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