1use super::optimisation::DispatchRun;
3use crate::agent::Agent;
4use crate::asset::{Asset, AssetCapacity, AssetIterator, AssetRef, AssetState};
5use crate::commodity::{Commodity, CommodityID};
6use crate::model::Model;
7use crate::output::DataWriter;
8use crate::region::RegionID;
9use crate::simulation::investment::{
10 AllDemandMap, DemandMap, calculate_candidate_asset_capacity_scale, select_best_assets,
11 update_net_demand_map,
12};
13use crate::simulation::prices::Prices;
14use crate::time_slice::TimeSliceInfo;
15use crate::units::{Capacity, Dimensionless, Flow};
16use anyhow::{Context, Result};
17use indexmap::IndexMap;
18use itertools::{Itertools, chain};
19use log::debug;
20use std::collections::HashMap;
21use std::fmt::Display;
22
23#[derive(PartialEq, Debug, Clone, Eq, Hash)]
25pub enum MarketSet {
26 Single((CommodityID, RegionID)),
28 Cycle(Vec<(CommodityID, RegionID)>),
32 Layer(Vec<MarketSet>),
34}
35
36impl MarketSet {
37 pub fn iter_markets<'a>(
39 &'a self,
40 ) -> Box<dyn Iterator<Item = &'a (CommodityID, RegionID)> + 'a> {
41 match self {
42 MarketSet::Single(market) => Box::new(std::iter::once(market)),
43 MarketSet::Cycle(markets) => Box::new(markets.iter()),
44 MarketSet::Layer(set) => Box::new(set.iter().flat_map(|s| s.iter_markets())),
45 }
46 }
47
48 #[allow(clippy::too_many_arguments)]
62 pub fn select_assets(
63 &self,
64 model: &Model,
65 year: u32,
66 demand: &AllDemandMap,
67 existing_assets: &[AssetRef],
68 prices: &Prices,
69 seen_markets: &[(CommodityID, RegionID)],
70 previously_selected_assets: &[AssetRef],
71 writer: &mut DataWriter,
72 ) -> Result<Vec<AssetRef>> {
73 match self {
74 MarketSet::Single((commodity_id, region_id)) => select_assets_for_single_market(
75 model,
76 commodity_id,
77 region_id,
78 year,
79 demand,
80 existing_assets,
81 prices,
82 writer,
83 ),
84 MarketSet::Cycle(markets) => {
85 debug!("Starting investment for cycle '{self}'");
86 select_assets_for_cycle(
87 model,
88 markets,
89 year,
90 demand,
91 existing_assets,
92 prices,
93 seen_markets,
94 previously_selected_assets,
95 writer,
96 )
97 .with_context(|| {
98 format!(
99 "Investments failed for market set {self} with cyclical dependencies. \
100 Please note that the investment algorithm is currently experimental for \
101 models with circular commodity dependencies and may not be able to find \
102 a solution in all cases."
103 )
104 })
105 }
106 MarketSet::Layer(investment_sets) => {
107 debug!("Starting asset selection for layer '{self}'");
108 let mut all_assets = Vec::new();
109 for investment_set in investment_sets {
110 let assets = investment_set.select_assets(
111 model,
112 year,
113 demand,
114 existing_assets,
115 prices,
116 seen_markets,
117 previously_selected_assets,
118 writer,
119 )?;
120 all_assets.extend(assets);
121 }
122 debug!("Completed asset selection for layer '{self}'");
123 Ok(all_assets)
124 }
125 }
126 }
127}
128
129impl Display for MarketSet {
130 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131 match self {
132 MarketSet::Single((commodity_id, region_id)) => {
133 write!(f, "{commodity_id}|{region_id}")
134 }
135 MarketSet::Cycle(markets) => {
136 write!(
137 f,
138 "({})",
139 markets.iter().map(|(c, r)| format!("{c}|{r}")).join(", ")
140 )
141 }
142 MarketSet::Layer(ids) => {
143 write!(f, "[{}]", ids.iter().join(", "))
144 }
145 }
146 }
147}
148
149#[allow(clippy::too_many_arguments)]
153pub fn select_assets_for_single_market(
154 model: &Model,
155 commodity_id: &CommodityID,
156 region_id: &RegionID,
157 year: u32,
158 demand: &AllDemandMap,
159 existing_assets: &[AssetRef],
160 prices: &Prices,
161 writer: &mut DataWriter,
162) -> Result<Vec<AssetRef>> {
163 let commodity = &model.commodities[commodity_id];
164
165 let mut selected_assets = Vec::new();
166 for (agent, commodity_portion) in
167 get_responsible_agents(model.agents.values(), commodity_id, region_id, year)
168 {
169 debug!(
170 "Running asset selection for agent '{}' in market '{}|{}'",
171 agent.id, commodity_id, region_id
172 );
173
174 let demand_portion_for_market = get_demand_portion_for_market(
176 &model.time_slice_info,
177 demand,
178 commodity_id,
179 region_id,
180 commodity_portion,
181 );
182
183 let opt_assets = get_asset_options(
185 existing_assets,
186 &demand_portion_for_market,
187 agent,
188 commodity,
189 region_id,
190 year,
191 model.parameters.capacity_limit_factor,
192 )
193 .collect::<Vec<_>>();
194
195 let candidate_investment_limits =
197 collect_investment_limits_for_candidates(&opt_assets, commodity_portion);
198
199 let best_assets = select_best_assets(
201 model,
202 opt_assets,
203 candidate_investment_limits,
204 commodity,
205 agent,
206 region_id,
207 prices,
208 demand_portion_for_market,
209 year,
210 writer,
211 )?;
212 selected_assets.extend(best_assets);
213 }
214
215 Ok(selected_assets)
216}
217
218#[allow(clippy::too_many_arguments)]
233pub fn select_assets_for_cycle(
234 model: &Model,
235 markets: &[(CommodityID, RegionID)],
236 year: u32,
237 demand: &AllDemandMap,
238 existing_assets: &[AssetRef],
239 prices: &Prices,
240 seen_markets: &[(CommodityID, RegionID)],
241 previously_selected_assets: &[AssetRef],
242 writer: &mut DataWriter,
243) -> Result<Vec<AssetRef>> {
244 let markets_str = markets.iter().map(|(c, r)| format!("{c}|{r}")).join(", ");
246
247 let mut current_demand = demand.clone();
249 let mut assets_for_cycle = IndexMap::new();
250 let mut last_solution = None;
251 for (idx, (commodity_id, region_id)) in markets.iter().enumerate() {
252 let assets = select_assets_for_single_market(
254 model,
255 commodity_id,
256 region_id,
257 year,
258 ¤t_demand,
259 existing_assets,
260 prices,
261 writer,
262 )?;
263 assets_for_cycle.insert((commodity_id.clone(), region_id.clone()), assets);
264
265 let mut all_assets = previously_selected_assets.to_vec();
267 let assets_for_cycle_flat: Vec<_> = assets_for_cycle
268 .values()
269 .flat_map(|v| v.iter().cloned())
270 .collect();
271 all_assets.extend_from_slice(&assets_for_cycle_flat);
272
273 let mut markets_to_balance = seen_markets.to_vec();
275 markets_to_balance.extend_from_slice(&markets[0..=idx]);
276
277 let flexible_capacity_assets: Vec<_> = assets_for_cycle_flat
279 .iter()
280 .filter(|asset| matches!(asset.state(), AssetState::Ready { .. }))
281 .cloned()
282 .collect();
283
284 let mut agent_share_cache = HashMap::new();
286 let capacity_limits = flexible_capacity_assets
287 .iter()
288 .filter_map(|asset| {
289 let agent_id = asset.agent_id().unwrap();
290 let commodity_id = asset.primary_output_commodity().unwrap();
291 let agent_share = *agent_share_cache
292 .entry((agent_id, commodity_id))
293 .or_insert_with(|| {
294 model.agents[agent_id].commodity_portions[&(commodity_id.clone(), year)]
295 });
296 asset
297 .max_installable_capacity(agent_share)
298 .map(|max_capacity| (asset.clone(), max_capacity))
299 })
300 .collect::<HashMap<_, _>>();
301
302 let solution = DispatchRun::new(model, &all_assets, year)
304 .with_market_balance_subset(&markets_to_balance)
305 .with_flexible_capacity_assets(
306 &flexible_capacity_assets,
307 Some(&capacity_limits),
308 model.parameters.capacity_margin,
310 )
311 .run(
312 &format!("cycle ({markets_str}) post {commodity_id}|{region_id} investment"),
313 writer,
314 )
315 .with_context(|| {
316 format!(
317 "Cycle balancing failed for cycle ({markets_str}), capacity_margin: {}. \
318 Try increasing the capacity_margin.",
319 model.parameters.capacity_margin
320 )
321 })?;
322
323 current_demand.clone_from(demand);
325 update_net_demand_map(
326 &mut current_demand,
327 &solution.create_flow_map(),
328 &assets_for_cycle_flat,
329 );
330 last_solution = Some(solution);
331 }
332
333 let mut all_cycle_assets: Vec<_> = assets_for_cycle.into_values().flatten().collect();
335 if let Some(solution) = last_solution {
336 let new_capacities: HashMap<_, _> = solution.iter_capacity().collect();
337 for asset in &mut all_cycle_assets {
338 if let Some(new_capacity) = new_capacities.get(asset) {
339 debug!(
340 "Capacity of asset '{}' modified during cycle balancing ({} to {})",
341 asset.process_id(),
342 asset.total_capacity(),
343 new_capacity.total_capacity()
344 );
345 asset.make_mut().set_capacity(*new_capacity);
346 }
347 }
348 }
349
350 Ok(all_cycle_assets)
351}
352
353fn get_demand_portion_for_market(
355 time_slice_info: &TimeSliceInfo,
356 demand: &AllDemandMap,
357 commodity_id: &CommodityID,
358 region_id: &RegionID,
359 commodity_portion: Dimensionless,
360) -> DemandMap {
361 time_slice_info
362 .iter_ids()
363 .map(|time_slice| {
364 (
365 time_slice.clone(),
366 commodity_portion
367 * *demand
368 .get(&(commodity_id.clone(), region_id.clone(), time_slice.clone()))
369 .unwrap_or(&Flow(0.0)),
370 )
371 })
372 .collect()
373}
374
375fn get_responsible_agents<'a, I>(
378 agents: I,
379 commodity_id: &'a CommodityID,
380 region_id: &'a RegionID,
381 year: u32,
382) -> impl Iterator<Item = (&'a Agent, Dimensionless)>
383where
384 I: Iterator<Item = &'a Agent>,
385{
386 agents.filter_map(move |agent| {
387 if !agent.regions.contains(region_id) {
388 return None;
389 }
390 let portion = agent
391 .commodity_portions
392 .get(&(commodity_id.clone(), year))?;
393
394 Some((agent, *portion))
395 })
396}
397
398fn get_asset_options<'a>(
400 all_existing_assets: &'a [AssetRef],
401 demand: &'a DemandMap,
402 agent: &'a Agent,
403 commodity: &'a Commodity,
404 region_id: &'a RegionID,
405 year: u32,
406 capacity_limit_factor: Dimensionless,
407) -> impl Iterator<Item = AssetRef> + 'a {
408 let existing_assets = all_existing_assets
410 .iter()
411 .filter_agent(&agent.id)
412 .filter_region(region_id)
413 .filter_primary_producers_of(&commodity.id)
414 .cloned();
415
416 let candidate_assets = get_candidate_assets(
418 demand,
419 agent,
420 region_id,
421 commodity,
422 year,
423 capacity_limit_factor,
424 );
425
426 chain(existing_assets, candidate_assets)
427}
428
429fn get_candidate_assets<'a>(
436 demand: &'a DemandMap,
437 agent: &'a Agent,
438 region_id: &'a RegionID,
439 commodity: &'a Commodity,
440 year: u32,
441 capacity_limit_factor: Dimensionless,
442) -> impl Iterator<Item = AssetRef> + 'a {
443 agent
444 .iter_search_space(region_id, &commodity.id, year)
445 .map(move |process| {
446 let mut asset =
448 Asset::new_candidate(process.clone(), region_id.clone(), Capacity(0.0), year)
449 .unwrap();
450
451 let asset_capacity = if let Some(unit_size) = asset.unit_size() {
455 AssetCapacity::Discrete(1, unit_size)
456 } else {
457 let capacity_scale =
458 calculate_candidate_asset_capacity_scale(&asset, commodity, demand);
459 AssetCapacity::Continuous(capacity_scale * capacity_limit_factor)
460 };
461 asset.set_capacity(asset_capacity);
462 asset.into()
463 })
464}
465
466pub fn collect_investment_limits_for_candidates(
470 opt_assets: &[AssetRef],
471 commodity_portion: Dimensionless,
472) -> HashMap<AssetRef, AssetCapacity> {
473 opt_assets
474 .iter()
475 .filter(|asset| asset.is_candidate())
476 .filter_map(|asset| {
477 asset
478 .max_installable_capacity(commodity_portion)
479 .map(|limit_capacity| (asset.clone(), limit_capacity))
480 })
481 .collect()
482}
483
484#[cfg(test)]
485mod tests {
486 use super::*;
487 use crate::fixture::{
488 asset, process, process_activity_limits_map, process_flows_map, process_parameter_map,
489 region_id,
490 };
491 use crate::process::{
492 Process, ProcessActivityLimitsMap, ProcessFlowsMap, ProcessInvestmentConstraint,
493 ProcessInvestmentConstraintsMap, ProcessParameterMap,
494 };
495 use crate::region::RegionID;
496 use crate::units::Dimensionless;
497 use crate::units::{ActivityPerCapacity, Capacity};
498 use indexmap::IndexSet;
499 use rstest::{fixture, rstest};
500 use std::rc::Rc;
501 use std::slice::from_ref;
502
503 #[rstest]
504 fn collect_investment_limits_for_candidates_empty_list() {
505 let result = collect_investment_limits_for_candidates(&[], Dimensionless(1.0));
506 assert!(result.is_empty());
507 }
508
509 #[fixture]
510 fn commissioned_asset(asset: Asset) -> AssetRef {
511 asset.into()
512 }
513
514 #[fixture]
515 fn uncommissioned_asset_without_limit(process: Process, region_id: RegionID) -> AssetRef {
516 Asset::new_candidate(Rc::new(process), region_id, Capacity(10.0), 2015)
517 .unwrap()
518 .into()
519 }
520
521 #[fixture]
522 fn uncommissioned_asset_with_limit(
523 region_id: RegionID,
524 process_activity_limits_map: ProcessActivityLimitsMap,
525 process_flows_map: ProcessFlowsMap,
526 process_parameter_map: ProcessParameterMap,
527 ) -> AssetRef {
528 let region_ids: IndexSet<RegionID> = [region_id.clone()].into();
529
530 let mut constraints = ProcessInvestmentConstraintsMap::new();
531
532 constraints.insert(
533 (region_id.clone(), 2015),
534 Rc::new(ProcessInvestmentConstraint {
535 addition_limit: Some(Capacity(10.0)),
536 }),
537 );
538
539 let process = Process {
540 id: "constrained_process".into(),
541 description: String::new(),
542 years: 2010..=2020,
543 activity_limits: process_activity_limits_map,
544 flows: process_flows_map,
545 parameters: process_parameter_map,
546 regions: region_ids,
547 primary_output: None,
548 capacity_to_activity: ActivityPerCapacity(1.0),
549 investment_constraints: constraints,
550 unit_size: None,
551 };
552
553 Asset::new_candidate(Rc::new(process), region_id, Capacity(15.0), 2015)
554 .unwrap()
555 .into()
556 }
557
558 #[rstest]
559 fn commissioned_assets_are_excluded(commissioned_asset: AssetRef) {
560 let result = collect_investment_limits_for_candidates(
561 from_ref(&commissioned_asset),
562 Dimensionless(1.0),
563 );
564
565 assert!(!result.contains_key(&commissioned_asset));
566 }
567
568 #[rstest]
569 fn candidate_assets_without_limits_are_excluded(uncommissioned_asset_without_limit: AssetRef) {
570 let result = collect_investment_limits_for_candidates(
571 from_ref(&uncommissioned_asset_without_limit),
572 Dimensionless(1.0),
573 );
574
575 assert!(!result.contains_key(&uncommissioned_asset_without_limit));
576 }
577
578 #[rstest]
579 fn candidate_assets_with_limits_are_included(uncommissioned_asset_with_limit: AssetRef) {
580 let result = collect_investment_limits_for_candidates(
581 from_ref(&uncommissioned_asset_with_limit),
582 Dimensionless(1.0),
583 );
584
585 assert!(result.contains_key(&uncommissioned_asset_with_limit));
586 }
587}