muse2/simulation/
investment.rs

1//! Code for performing agent investment.
2use super::optimisation::{DispatchRun, FlowMap};
3use crate::agent::Agent;
4use crate::asset::{Asset, AssetIterator, AssetRef, AssetState};
5use crate::commodity::{Commodity, CommodityID, CommodityMap};
6use crate::model::Model;
7use crate::output::DataWriter;
8use crate::region::RegionID;
9use crate::simulation::CommodityPrices;
10use crate::time_slice::{TimeSliceID, TimeSliceInfo};
11use crate::units::{Capacity, Dimensionless, Flow, FlowPerCapacity};
12use anyhow::{Result, ensure};
13use indexmap::IndexMap;
14use itertools::{chain, iproduct};
15use log::debug;
16use std::collections::HashMap;
17
18pub mod appraisal;
19use appraisal::coefficients::calculate_coefficients_for_assets;
20use appraisal::{AppraisalOutput, appraise_investment};
21
22/// A map of demand across time slices for a specific commodity and region
23type DemandMap = IndexMap<TimeSliceID, Flow>;
24
25/// Demand for a given combination of commodity, region and time slice
26type AllDemandMap = IndexMap<(CommodityID, RegionID, TimeSliceID), Flow>;
27
28/// Perform agent investment to determine capacity investment of new assets for next milestone year.
29///
30/// # Arguments
31///
32/// * `model` - The model
33/// * `year` - Current milestone year
34/// * `assets` - The asset pool
35/// * `prices` - Commodity prices
36/// * `writer` - Data writer
37pub fn perform_agent_investment(
38    model: &Model,
39    year: u32,
40    existing_assets: &[AssetRef],
41    prices: &CommodityPrices,
42    writer: &mut DataWriter,
43) -> Result<Vec<AssetRef>> {
44    // Initialise demand map
45    let mut demand =
46        flatten_preset_demands_for_year(&model.commodities, &model.time_slice_info, year);
47
48    // Keep a list of all the assets selected
49    // This includes Commissioned assets that are selected for retention, and new Selected assets
50    let mut all_selected_assets = Vec::new();
51
52    for region_id in model.iter_regions() {
53        let cur_commodities = &model.commodity_order[&(region_id.clone(), year)];
54
55        // Prices to be used for input flows for commodities not produced in dispatch run
56        let mut external_prices =
57            get_prices_for_commodities(prices, &model.time_slice_info, region_id, cur_commodities);
58        let mut seen_commodities = Vec::new();
59        for commodity_id in cur_commodities {
60            seen_commodities.push(commodity_id.clone());
61            let commodity = &model.commodities[commodity_id];
62
63            // Remove prices for already-seen commodities. Commodities which are produced by at
64            // least one asset in the dispatch run will have prices produced endogenously (via the
65            // commodity balance constraints), but commodities for which investment has not yet been
66            // performed will, by definition, not have any producers. For these, we provide prices
67            // from the previous dispatch run otherwise they will appear to be free to the model.
68            for time_slice in model.time_slice_info.iter_ids() {
69                external_prices.remove(commodity_id, region_id, time_slice);
70            }
71
72            // List of assets selected/retained for this region/commodity
73            let mut selected_assets = Vec::new();
74
75            for (agent, commodity_portion) in
76                get_responsible_agents(model.agents.values(), commodity_id, region_id, year)
77            {
78                debug!(
79                    "Running investment for agent '{}' with commodity '{}' in region '{}'",
80                    &agent.id, commodity_id, region_id
81                );
82
83                // Get demand portion for this commodity for this agent in this region/year
84                let demand_portion_for_commodity = get_demand_portion_for_commodity(
85                    &model.time_slice_info,
86                    &demand,
87                    commodity_id,
88                    region_id,
89                    commodity_portion,
90                );
91
92                // Existing and candidate assets from which to choose
93                let opt_assets = get_asset_options(
94                    &model.time_slice_info,
95                    existing_assets,
96                    &demand_portion_for_commodity,
97                    agent,
98                    commodity,
99                    region_id,
100                    year,
101                )
102                .collect();
103
104                // Choose assets from among existing pool and candidates
105                let best_assets = select_best_assets(
106                    model,
107                    opt_assets,
108                    commodity,
109                    agent,
110                    prices,
111                    demand_portion_for_commodity,
112                    year,
113                    writer,
114                )?;
115                selected_assets.extend(best_assets);
116            }
117
118            // If no assets have been selected for this region/commodity, skip dispatch optimisation
119            // **TODO**: this probably means there's no demand for the commodity, which we could
120            // presumably preempt
121            if selected_assets.is_empty() {
122                continue;
123            }
124
125            // Add the selected assets to the list of all selected assets
126            all_selected_assets.extend(selected_assets.iter().cloned());
127
128            // Perform dispatch optimisation with assets that have been selected so far
129            // **TODO**: presumably we only need to do this for selected_assets, as assets added in
130            // previous iterations should not change
131            debug!(
132                "Running post-investment dispatch for commodity '{commodity_id}' in region '{region_id}'"
133            );
134
135            // As upstream commodities by definition will not yet have producers, we explicitly set
136            // their prices using previous values so that they don't appear free
137            let solution = DispatchRun::new(model, &all_selected_assets, year)
138                .with_commodity_subset(&seen_commodities)
139                .with_input_prices(&external_prices)
140                .run(
141                    &format!("post {commodity_id}/{region_id} investment"),
142                    writer,
143                )?;
144
145            // Update demand map with flows from newly added assets
146            update_demand_map(&mut demand, &solution.create_flow_map(), &selected_assets);
147        }
148    }
149
150    Ok(all_selected_assets)
151}
152
153/// Flatten the preset commodity demands for a given year into a map of commodity, region and
154/// time slice to demand.
155///
156/// Since demands for some commodities may be specified at a coarser time slice level, we need to
157/// distribute these demands over all time slices. Note: the way that we do this distribution is
158/// irrelevant, as demands will only be balanced to the appropriate level, but we still need to do
159/// this for the solver to work.
160///
161/// **TODO**: these assumptions may need to be revisited, e.g. when we come to storage technologies
162fn flatten_preset_demands_for_year(
163    commodities: &CommodityMap,
164    time_slice_info: &TimeSliceInfo,
165    year: u32,
166) -> AllDemandMap {
167    let mut demand_map = AllDemandMap::new();
168    for (commodity_id, commodity) in commodities {
169        for ((region_id, data_year, time_slice_selection), demand) in &commodity.demand {
170            if *data_year != year {
171                continue;
172            }
173
174            // We split the demand equally over all time slices in the selection
175            // NOTE: since demands will only be balanced to the time slice level of the commodity
176            // it doesn't matter how we do this distribution, only the total matters.
177            #[allow(clippy::cast_precision_loss)]
178            let n_time_slices = time_slice_selection.iter(time_slice_info).count() as f64;
179            let demand_per_slice = *demand / Dimensionless(n_time_slices);
180            for (time_slice, _) in time_slice_selection.iter(time_slice_info) {
181                demand_map.insert(
182                    (commodity_id.clone(), region_id.clone(), time_slice.clone()),
183                    demand_per_slice,
184                );
185            }
186        }
187    }
188    demand_map
189}
190
191/// Update demand map with flows from a set of assets
192fn update_demand_map(demand: &mut AllDemandMap, flows: &FlowMap, assets: &[AssetRef]) {
193    for ((asset, commodity_id, time_slice), flow) in flows {
194        if assets.contains(asset) {
195            let key = (
196                commodity_id.clone(),
197                asset.region_id().clone(),
198                time_slice.clone(),
199            );
200
201            // Note: we use the negative of the flow as input flows are negative in the flow map.
202            demand
203                .entry(key)
204                .and_modify(|value| *value -= *flow)
205                .or_insert(-*flow);
206        }
207    }
208}
209
210/// Get a portion of the demand profile for this commodity and region
211fn get_demand_portion_for_commodity(
212    time_slice_info: &TimeSliceInfo,
213    demand: &AllDemandMap,
214    commodity_id: &CommodityID,
215    region_id: &RegionID,
216    commodity_portion: Dimensionless,
217) -> DemandMap {
218    time_slice_info
219        .iter_ids()
220        .map(|time_slice| {
221            (
222                time_slice.clone(),
223                commodity_portion
224                    * *demand
225                        .get(&(commodity_id.clone(), region_id.clone(), time_slice.clone()))
226                        .unwrap_or(&Flow(0.0)),
227            )
228        })
229        .collect()
230}
231
232/// Get the agents responsible for a given commodity in a given year along with the commodity
233/// portion for which they are responsible
234fn get_responsible_agents<'a, I>(
235    agents: I,
236    commodity_id: &'a CommodityID,
237    region_id: &'a RegionID,
238    year: u32,
239) -> impl Iterator<Item = (&'a Agent, Dimensionless)>
240where
241    I: Iterator<Item = &'a Agent>,
242{
243    agents.filter_map(move |agent| {
244        if !agent.regions.contains(region_id) {
245            return None;
246        }
247        let portion = agent
248            .commodity_portions
249            .get(&(commodity_id.clone(), year))?;
250
251        Some((agent, *portion))
252    })
253}
254
255/// Get the maximum required capacity across time slices
256fn get_demand_limiting_capacity(
257    time_slice_info: &TimeSliceInfo,
258    asset: &Asset,
259    commodity: &Commodity,
260    demand: &DemandMap,
261) -> Capacity {
262    let coeff = asset.get_flow(&commodity.id).unwrap().coeff;
263    let mut capacity = Capacity(0.0);
264
265    for time_slice_selection in time_slice_info.iter_selections_at_level(commodity.time_slice_level)
266    {
267        let demand_for_selection: Flow = time_slice_selection
268            .iter(time_slice_info)
269            .map(|(time_slice, _)| demand[time_slice])
270            .sum();
271
272        // Calculate max capacity required for this time slice selection
273        // For commodities with a coarse time slice level, we have to allow the possibility that all
274        // of the demand gets served by production in a single time slice
275        for (time_slice, _) in time_slice_selection.iter(time_slice_info) {
276            let max_flow_per_cap =
277                *asset.get_activity_per_capacity_limits(time_slice).end() * coeff;
278            if max_flow_per_cap != FlowPerCapacity(0.0) {
279                capacity = capacity.max(demand_for_selection / max_flow_per_cap);
280            }
281        }
282    }
283
284    capacity
285}
286
287/// Get options from existing and potential assets for the given parameters
288fn get_asset_options<'a>(
289    time_slice_info: &'a TimeSliceInfo,
290    all_existing_assets: &'a [AssetRef],
291    demand: &'a DemandMap,
292    agent: &'a Agent,
293    commodity: &'a Commodity,
294    region_id: &'a RegionID,
295    year: u32,
296) -> impl Iterator<Item = AssetRef> + 'a {
297    // Get existing assets which produce the commodity of interest
298    let existing_assets = all_existing_assets
299        .iter()
300        .filter_agent(&agent.id)
301        .filter_region(region_id)
302        .filter_primary_producers_of(&commodity.id)
303        .cloned();
304
305    // Get candidates assets which produce the commodity of interest
306    let candidate_assets =
307        get_candidate_assets(time_slice_info, demand, agent, region_id, commodity, year);
308
309    chain(existing_assets, candidate_assets)
310}
311
312/// Get candidate assets which produce a particular commodity for a given agent
313fn get_candidate_assets<'a>(
314    time_slice_info: &'a TimeSliceInfo,
315    demand: &'a DemandMap,
316    agent: &'a Agent,
317    region_id: &'a RegionID,
318    commodity: &'a Commodity,
319    year: u32,
320) -> impl Iterator<Item = AssetRef> + 'a {
321    agent
322        .iter_possible_producers_of(region_id, &commodity.id, year)
323        .map(move |process| {
324            let mut asset =
325                Asset::new_candidate(process.clone(), region_id.clone(), Capacity(0.0), year)
326                    .unwrap();
327            asset.set_capacity(get_demand_limiting_capacity(
328                time_slice_info,
329                &asset,
330                commodity,
331                demand,
332            ));
333
334            asset.into()
335        })
336}
337
338/// Get a map of prices for a subset of commodities
339fn get_prices_for_commodities(
340    prices: &CommodityPrices,
341    time_slice_info: &TimeSliceInfo,
342    region_id: &RegionID,
343    commodities: &[CommodityID],
344) -> CommodityPrices {
345    iproduct!(commodities.iter(), time_slice_info.iter_ids())
346        .map(|(commodity_id, time_slice)| {
347            let price = prices.get(commodity_id, region_id, time_slice).unwrap();
348            (commodity_id, region_id, time_slice, price)
349        })
350        .collect()
351}
352
353/// Get the best assets for meeting demand for the given commodity
354#[allow(clippy::too_many_arguments)]
355fn select_best_assets(
356    model: &Model,
357    mut opt_assets: Vec<AssetRef>,
358    commodity: &Commodity,
359    agent: &Agent,
360    prices: &CommodityPrices,
361    mut demand: DemandMap,
362    year: u32,
363    writer: &mut DataWriter,
364) -> Result<Vec<AssetRef>> {
365    let objective_type = &agent.objectives[&year];
366
367    // Calculate coefficients for all asset options according to the agent's objective
368    let coefficients =
369        calculate_coefficients_for_assets(model, objective_type, &opt_assets, prices, year);
370
371    let mut remaining_candidate_capacity = HashMap::from_iter(
372        opt_assets
373            .iter()
374            .filter(|asset| !asset.is_commissioned())
375            .map(|asset| (asset.clone(), asset.capacity())),
376    );
377
378    let mut round = 0;
379    let mut best_assets: Vec<AssetRef> = Vec::new();
380    while is_any_remaining_demand(&demand) {
381        ensure!(
382            !opt_assets.is_empty(),
383            "Failed to meet demand for commodity '{}' with provided assets",
384            &commodity.id
385        );
386
387        // Appraise all options
388        let mut outputs_for_opts = Vec::new();
389        for asset in &opt_assets {
390            let max_capacity = (!asset.is_commissioned()).then(|| {
391                let max_capacity = model.parameters.capacity_limit_factor * asset.capacity();
392                let remaining_capacity = remaining_candidate_capacity[asset];
393                max_capacity.min(remaining_capacity)
394            });
395
396            let output = appraise_investment(
397                model,
398                asset,
399                max_capacity,
400                commodity,
401                objective_type,
402                &coefficients[asset],
403                &demand,
404            )?;
405
406            // Store the appraisal results if the capacity is positive. If capacity is zero,
407            // this means the asset is infeasible for investment. This can happen if the asset has
408            // zero activity limits for all time slices with demand. This can also happen due to a
409            // known issue with the NPV objective, for which we do not currently have a solution
410            // (see https://github.com/EnergySystemsModellingLab/MUSE2/issues/716).
411            if output.capacity > Capacity(0.0) {
412                outputs_for_opts.push(output);
413            } else {
414                debug!(
415                    "Skipping candidate '{}' with zero capacity",
416                    asset.process_id()
417                );
418            }
419        }
420
421        // Make sure there are some options to consider
422        ensure!(
423            !outputs_for_opts.is_empty(),
424            "No feasible investment options for commodity '{}'",
425            &commodity.id
426        );
427
428        // Save appraisal results
429        writer.write_appraisal_debug_info(
430            year,
431            &format!("{} {} round {}", &commodity.id, &agent.id, round),
432            &outputs_for_opts,
433        )?;
434
435        // Select the best investment option
436        let best_output = outputs_for_opts
437            .into_iter()
438            .min_by(AppraisalOutput::compare_metric)
439            .unwrap();
440
441        // Log the selected asset
442        debug!(
443            "Selected {} asset '{}' (capacity: {})",
444            &best_output.asset.state(),
445            &best_output.asset.process_id(),
446            best_output.capacity
447        );
448
449        // Update the assets
450        update_assets(
451            best_output.asset,
452            best_output.capacity,
453            &mut opt_assets,
454            &mut remaining_candidate_capacity,
455            &mut best_assets,
456        );
457
458        demand = best_output.unmet_demand;
459        round += 1;
460    }
461
462    // Convert Candidate assets to Selected
463    // At this point we also assign the agent ID to the asset
464    for asset in &mut best_assets {
465        if let AssetState::Candidate = asset.state() {
466            asset
467                .make_mut()
468                .select_candidate_for_investment(agent.id.clone());
469        }
470    }
471
472    Ok(best_assets)
473}
474
475/// Check whether there is any remaining demand that is unmet in any time slice
476fn is_any_remaining_demand(demand: &DemandMap) -> bool {
477    demand.values().any(|flow| *flow > Flow(0.0))
478}
479
480/// Update capacity of chosen asset, if needed, and update both asset options and chosen assets
481fn update_assets(
482    mut best_asset: AssetRef,
483    capacity: Capacity,
484    opt_assets: &mut Vec<AssetRef>,
485    remaining_candidate_capacity: &mut HashMap<AssetRef, Capacity>,
486    best_assets: &mut Vec<AssetRef>,
487) {
488    match best_asset.state() {
489        AssetState::Commissioned { .. } => {
490            // Remove this asset from the options
491            opt_assets.retain(|asset| *asset != best_asset);
492            best_assets.push(best_asset);
493        }
494        AssetState::Candidate => {
495            // Remove this capacity from the available remaining capacity for this asset
496            let remaining_capacity = remaining_candidate_capacity.get_mut(&best_asset).unwrap();
497            *remaining_capacity -= capacity;
498
499            // If there's no capacity remaining, remove the asset from the options
500            if *remaining_capacity <= Capacity(0.0) {
501                let old_idx = opt_assets
502                    .iter()
503                    .position(|asset| *asset == best_asset)
504                    .unwrap();
505                opt_assets.swap_remove(old_idx);
506                remaining_candidate_capacity.remove(&best_asset);
507            }
508
509            if let Some(existing_asset) = best_assets.iter_mut().find(|asset| **asset == best_asset)
510            {
511                // If the asset is already in the list of best assets, add the additional required capacity
512                existing_asset.make_mut().increase_capacity(capacity);
513            } else {
514                // Otherwise, update the capacity of the chosen asset and add it to the list of best assets
515                best_asset.make_mut().set_capacity(capacity);
516                best_assets.push(best_asset);
517            }
518        }
519        _ => panic!("update_assets should only be called with Commissioned or Candidate assets"),
520    }
521}
522
523#[cfg(test)]
524mod tests {
525    use super::*;
526    use crate::commodity::Commodity;
527    use crate::fixture::{
528        asset, process, process_parameter_map, region_id, svd_commodity, time_slice,
529        time_slice_info, time_slice_info2,
530    };
531    use crate::process::{FlowType, ProcessFlow};
532    use crate::region::RegionID;
533    use crate::time_slice::{TimeSliceID, TimeSliceInfo};
534    use crate::units::{Dimensionless, Flow, FlowPerActivity, MoneyPerFlow};
535    use indexmap::indexmap;
536    use itertools::Itertools;
537    use map_macro::hash_map;
538    use rstest::rstest;
539    use std::rc::Rc;
540
541    #[rstest]
542    fn test_get_demand_limiting_capacity(
543        time_slice: TimeSliceID,
544        region_id: RegionID,
545        time_slice_info: TimeSliceInfo,
546        svd_commodity: Commodity,
547    ) {
548        // Create a process flow using the existing commodity fixture
549        let commodity_rc = Rc::new(svd_commodity);
550        let process_flow = ProcessFlow {
551            commodity: Rc::clone(&commodity_rc),
552            coeff: FlowPerActivity(2.0), // 2 units of flow per unit of activity
553            kind: FlowType::Fixed,
554            cost: MoneyPerFlow(0.0),
555        };
556
557        // Create a process with the flows and activity limits
558        let mut process = process(
559            [region_id.clone()].into_iter().collect(),
560            process_parameter_map([region_id.clone()].into_iter().collect()),
561        );
562
563        // Add the flow to the process
564        process.flows.insert(
565            (region_id.clone(), 2015), // Using default commission year from fixture
566            Rc::new(
567                [(commodity_rc.id.clone(), process_flow)]
568                    .into_iter()
569                    .collect(),
570            ),
571        );
572
573        // Add activity limits
574        process.activity_limits.insert(
575            (region_id.clone(), 2015),
576            Rc::new(hash_map! {time_slice.clone() => Dimensionless(0.0)..=Dimensionless(1.0)}),
577        );
578
579        // Create asset with the configured process
580        let asset = asset(process);
581
582        // Create demand map - demand of 10.0 for our time slice
583        let demand = indexmap! { time_slice.clone() => Flow(10.0)};
584
585        // Call the function
586        let result = get_demand_limiting_capacity(&time_slice_info, &asset, &commodity_rc, &demand);
587
588        // Expected calculation:
589        // max_flow_per_cap = activity_per_capacity_limit (1.0) * coeff (2.0) = 2.0
590        // required_capacity = demand (10.0) / max_flow_per_cap (2.0) = 5.0
591        assert_eq!(result, Capacity(5.0));
592    }
593
594    #[rstest]
595    fn test_get_demand_limiting_capacity_multiple_time_slices(
596        time_slice_info2: TimeSliceInfo,
597        svd_commodity: Commodity,
598        region_id: RegionID,
599    ) {
600        // Create time slices from the fixture (day and night)
601        let (time_slice1, time_slice2) =
602            time_slice_info2.time_slices.keys().collect_tuple().unwrap();
603
604        // Create a process flow using the existing commodity fixture
605        let commodity_rc = Rc::new(svd_commodity);
606        let process_flow = ProcessFlow {
607            commodity: Rc::clone(&commodity_rc),
608            coeff: FlowPerActivity(1.0), // 1 unit of flow per unit of activity
609            kind: FlowType::Fixed,
610            cost: MoneyPerFlow(0.0),
611        };
612
613        // Create a process with the flows and activity limits
614        let mut process = process(
615            [region_id.clone()].into_iter().collect(),
616            process_parameter_map([region_id.clone()].into_iter().collect()),
617        );
618
619        // Add the flow to the process
620        process.flows.insert(
621            (region_id.clone(), 2015), // Using default commission year from fixture
622            Rc::new(
623                [(commodity_rc.id.clone(), process_flow)]
624                    .into_iter()
625                    .collect(),
626            ),
627        );
628
629        // Add activity limits for both time slices with different limits
630        let limits = hash_map! {
631            // Higher limit for day
632            time_slice1.clone() => Dimensionless(0.0)..=Dimensionless(2.0),
633            // Zero limit for night - should be skipped
634            time_slice2.clone() => Dimensionless(0.0)..=Dimensionless(0.0)
635        };
636        process
637            .activity_limits
638            .insert((region_id.clone(), 2015), limits.into());
639
640        // Create asset with the configured process
641        let asset = asset(process);
642
643        // Create demand map with different demands for each time slice
644        let demand = indexmap! {
645            time_slice1.clone() => Flow(4.0), // Requires capacity of 4.0/2.0 = 2.0
646            time_slice2.clone() => Flow(3.0), // Would require infinite capacity, but should be skipped
647        };
648
649        // Call the function
650        let result =
651            get_demand_limiting_capacity(&time_slice_info2, &asset, &commodity_rc, &demand);
652
653        // Expected: maximum of the capacity requirements across time slices (excluding zero limit)
654        // Time slice 1: demand (4.0) / (activity_limit (2.0) * coeff (1.0)) = 2.0
655        // Time slice 2: skipped due to zero activity limit
656        // Maximum = 2.0
657        assert_eq!(result, Capacity(2.0));
658    }
659}