1use 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
28pub type DemandMap = IndexMap<TimeSliceID, Flow>;
30
31pub type AllDemandMap = IndexMap<(CommodityID, RegionID, TimeSliceID), Flow>;
33
34#[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 let mut net_demand =
58 flatten_preset_demands_for_year(&model.commodities, &model.time_slice_info, year);
59
60 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 let mut seen_markets = Vec::new();
74
75 for market_set in investment_order {
77 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 for market in market_set.iter_markets() {
91 seen_markets.push(market.clone());
92 }
93
94 if selected_assets.is_empty() {
98 debug!("No assets selected for '{market_set}'");
99 continue;
100 }
101
102 all_selected_assets.extend(selected_assets.iter().cloned());
104
105 debug!("Running post-investment dispatch for '{market_set}'");
109
110 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_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
128fn 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 #[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
166pub 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 if (flow < &Flow(0.0))
187 || asset
188 .primary_output()
189 .is_some_and(|p| &p.commodity.id == commodity_id)
190 {
191 demand
193 .entry(key)
194 .and_modify(|value| *value -= *flow)
195 .or_insert(-*flow);
196 }
197 }
198 }
199}
200
201pub 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
229pub 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 for level in TimeSliceLevel::iter() {
254 for selection in time_slice_info.iter_selections_at_level(level) {
255 let max_supply_for_selection = *asset
257 .get_activity_per_capacity_limits_for_selection(&selection)
258 .end()
259 * coeff;
260
261 if max_supply_for_selection == FlowPerCapacity(0.0) {
264 continue;
265 }
266
267 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 capacity = capacity.max(serviceable_demand_for_selection / max_supply_for_selection);
306 }
307 }
308
309 capacity
310}
311
312fn 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#[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 let mut remaining_capacities = candidate_investment_limits;
367
368 prepare_commissioned_divisible_assets(&mut opt_assets, &mut remaining_capacities);
370
371 let coefficients =
373 calculate_coefficients_for_assets(model, objective_type, &opt_assets, prices, year);
374
375 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 let mut outputs = Vec::new();
392 for asset in &opt_assets {
393 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 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 writer.write_appraisal_debug_info(
432 year,
433 &format!("{} {} round {}", commodity.id, agent.id, round),
434 &outputs,
435 &demand,
436 )?;
437
438 let num_nonfeasible = sort_and_filter_appraisal_outputs(&mut outputs);
440
441 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 log_on_equal_appraisal_outputs(&outputs, &agent.id, &commodity.id, region_id);
456
457 let best_output = outputs.into_iter().next().unwrap();
458
459 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_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 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
492fn 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 *asset = asset.clone().with_subset_of_units(1);
508
509 remaining_capacities.insert(asset.clone(), full_capacity);
511 }
512}
513
514fn is_any_remaining_demand(demand: &DemandMap, absolute_tolerance: Flow) -> bool {
516 demand.values().any(|flow| *flow > absolute_tolerance)
517}
518
519fn 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 if let Some(remaining_capacity) = remaining_capacities.get_mut(&best_asset) {
537 *remaining_capacity = *remaining_capacity - best_asset.capacity();
538
539 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 existing_asset
554 .make_mut()
555 .increase_capacity(best_asset.capacity());
556 } else {
557 best_assets.push(best_asset.with_no_mothballed_units());
559 }
560 } else {
561 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 let commodity_rc = Rc::new(svd_commodity);
592 let process_flow = ProcessFlow {
593 commodity: Rc::clone(&commodity_rc),
594 coeff: FlowPerActivity(2.0), 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 let asset = asset(process);
604
605 let demand = indexmap! { time_slice.clone() => Flow(10.0)};
607
608 let result = get_demand_limiting_capacity(&time_slice_info, &asset, &commodity_rc, &demand);
610
611 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 let commodity_rc = Rc::new(svd_commodity);
628 let process_flow = ProcessFlow {
629 commodity: Rc::clone(&commodity_rc),
630 coeff: FlowPerActivity(1.0), 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 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 let asset = asset(process);
647
648 let demand = indexmap! {
650 time_slice1.clone() => Flow(4.0), time_slice2.clone() => Flow(3.0), };
653
654 let result =
656 get_demand_limiting_capacity(&time_slice_info2, &asset, &commodity_rc, &demand);
657
658 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 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 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))] #[case(Flow(0.0), Dimensionless(1.0), Capacity(0.0))] #[case(Flow(10.0), Dimensionless(0.5), Capacity(20.0))] #[case(Flow(10.0), Dimensionless(0.0), Capacity(0.0))] 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}