use super::optimisation::{DispatchRun, FlowMap};
use crate::agent::{Agent, AgentID};
use crate::asset::{Asset, AssetCapacity, AssetIterator, AssetRef, AssetState};
use crate::commodity::{Commodity, CommodityID, CommodityMap};
use crate::model::Model;
use crate::output::DataWriter;
use crate::region::RegionID;
use crate::simulation::CommodityPrices;
use crate::time_slice::{TimeSliceID, TimeSliceInfo};
use crate::units::{Capacity, Dimensionless, Flow, FlowPerCapacity};
use anyhow::{Context, Result, bail, ensure};
use indexmap::IndexMap;
use itertools::{Itertools, chain};
use log::debug;
use std::collections::{HashMap, HashSet};
use std::fmt::Display;
pub mod appraisal;
use appraisal::coefficients::calculate_coefficients_for_assets;
use appraisal::{
AppraisalOutput, appraise_investment, count_equal_and_best_appraisal_outputs,
sort_and_filter_appraisal_outputs,
};
type DemandMap = IndexMap<TimeSliceID, Flow>;
type AllDemandMap = IndexMap<(CommodityID, RegionID, TimeSliceID), Flow>;
#[derive(PartialEq, Debug, Clone, Eq, Hash)]
pub enum InvestmentSet {
Single((CommodityID, RegionID)),
Cycle(Vec<(CommodityID, RegionID)>),
Layer(Vec<InvestmentSet>),
}
impl InvestmentSet {
pub fn iter_markets<'a>(
&'a self,
) -> Box<dyn Iterator<Item = &'a (CommodityID, RegionID)> + 'a> {
match self {
InvestmentSet::Single(market) => Box::new(std::iter::once(market)),
InvestmentSet::Cycle(markets) => Box::new(markets.iter()),
InvestmentSet::Layer(set) => Box::new(set.iter().flat_map(|s| s.iter_markets())),
}
}
#[allow(clippy::too_many_arguments)]
fn select_assets(
&self,
model: &Model,
year: u32,
demand: &AllDemandMap,
existing_assets: &[AssetRef],
prices: &CommodityPrices,
seen_markets: &[(CommodityID, RegionID)],
previously_selected_assets: &[AssetRef],
writer: &mut DataWriter,
) -> Result<Vec<AssetRef>> {
match self {
InvestmentSet::Single((commodity_id, region_id)) => select_assets_for_single_market(
model,
commodity_id,
region_id,
year,
demand,
existing_assets,
prices,
writer,
),
InvestmentSet::Cycle(markets) => {
debug!("Starting investment for cycle '{self}'");
select_assets_for_cycle(
model,
markets,
year,
demand,
existing_assets,
prices,
seen_markets,
previously_selected_assets,
writer,
)
.with_context(|| {
format!(
"Investments failed for market set {self} with cyclical dependencies. \
Please note that the investment algorithm is currently experimental for \
models with circular commodity dependencies and may not be able to find \
a solution in all cases."
)
})
}
InvestmentSet::Layer(investment_sets) => {
debug!("Starting asset selection for layer '{self}'");
let mut all_assets = Vec::new();
for investment_set in investment_sets {
let assets = investment_set.select_assets(
model,
year,
demand,
existing_assets,
prices,
seen_markets,
previously_selected_assets,
writer,
)?;
all_assets.extend(assets);
}
debug!("Completed asset selection for layer '{self}'");
Ok(all_assets)
}
}
}
}
impl Display for InvestmentSet {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
InvestmentSet::Single((commodity_id, region_id)) => {
write!(f, "{commodity_id}|{region_id}")
}
InvestmentSet::Cycle(markets) => {
write!(
f,
"({})",
markets.iter().map(|(c, r)| format!("{c}|{r}")).join(", ")
)
}
InvestmentSet::Layer(ids) => {
write!(f, "[{}]", ids.iter().join(", "))
}
}
}
}
pub fn perform_agent_investment(
model: &Model,
year: u32,
existing_assets: &[AssetRef],
prices: &CommodityPrices,
writer: &mut DataWriter,
) -> Result<Vec<AssetRef>> {
let mut net_demand =
flatten_preset_demands_for_year(&model.commodities, &model.time_slice_info, year);
let mut all_selected_assets = Vec::new();
let investment_order = &model.investment_order[&year];
debug!(
"Investment order for year '{year}': {}",
investment_order.iter().join(" -> ")
);
let mut seen_markets = Vec::new();
for investment_set in investment_order {
let selected_assets = investment_set.select_assets(
model,
year,
&net_demand,
existing_assets,
prices,
&seen_markets,
&all_selected_assets,
writer,
)?;
for market in investment_set.iter_markets() {
seen_markets.push(market.clone());
}
if selected_assets.is_empty() {
debug!("No assets selected for '{investment_set}'");
continue;
}
all_selected_assets.extend(selected_assets.iter().cloned());
debug!("Running post-investment dispatch for '{investment_set}'");
let solution = DispatchRun::new(model, &all_selected_assets, year)
.with_market_balance_subset(&seen_markets)
.with_input_prices(prices)
.run(&format!("post {investment_set} investment"), writer)?;
update_net_demand_map(
&mut net_demand,
&solution.create_flow_map(),
&selected_assets,
);
}
Ok(all_selected_assets)
}
#[allow(clippy::too_many_arguments)]
fn select_assets_for_single_market(
model: &Model,
commodity_id: &CommodityID,
region_id: &RegionID,
year: u32,
demand: &AllDemandMap,
existing_assets: &[AssetRef],
prices: &CommodityPrices,
writer: &mut DataWriter,
) -> Result<Vec<AssetRef>> {
let commodity = &model.commodities[commodity_id];
let mut selected_assets = Vec::new();
for (agent, commodity_portion) in
get_responsible_agents(model.agents.values(), commodity_id, region_id, year)
{
debug!(
"Running asset selection for agent '{}' in market '{}|{}'",
&agent.id, commodity_id, region_id
);
let demand_portion_for_market = get_demand_portion_for_market(
&model.time_slice_info,
demand,
commodity_id,
region_id,
commodity_portion,
);
let opt_assets = get_asset_options(
&model.time_slice_info,
existing_assets,
&demand_portion_for_market,
agent,
commodity,
region_id,
year,
)
.collect::<Vec<_>>();
let investment_limits =
calculate_investment_limits_for_candidates(&opt_assets, commodity_portion);
let best_assets = select_best_assets(
model,
opt_assets,
investment_limits,
commodity,
agent,
region_id,
prices,
demand_portion_for_market,
year,
writer,
)?;
selected_assets.extend(best_assets);
}
Ok(selected_assets)
}
#[allow(clippy::too_many_arguments)]
fn select_assets_for_cycle(
model: &Model,
markets: &[(CommodityID, RegionID)],
year: u32,
demand: &AllDemandMap,
existing_assets: &[AssetRef],
prices: &CommodityPrices,
seen_markets: &[(CommodityID, RegionID)],
previously_selected_assets: &[AssetRef],
writer: &mut DataWriter,
) -> Result<Vec<AssetRef>> {
let markets_str = markets.iter().map(|(c, r)| format!("{c}|{r}")).join(", ");
let mut current_demand = demand.clone();
let mut assets_for_cycle = IndexMap::new();
let mut last_solution = None;
for (idx, (commodity_id, region_id)) in markets.iter().enumerate() {
let assets = select_assets_for_single_market(
model,
commodity_id,
region_id,
year,
¤t_demand,
existing_assets,
prices,
writer,
)?;
assets_for_cycle.insert((commodity_id.clone(), region_id.clone()), assets);
let mut all_assets = previously_selected_assets.to_vec();
let assets_for_cycle_flat: Vec<_> = assets_for_cycle
.values()
.flat_map(|v| v.iter().cloned())
.collect();
all_assets.extend_from_slice(&assets_for_cycle_flat);
let mut markets_to_balance = seen_markets.to_vec();
markets_to_balance.extend_from_slice(&markets[0..=idx]);
let flexible_capacity_assets: Vec<_> = assets_for_cycle_flat
.iter()
.filter(|asset| matches!(asset.state(), AssetState::Selected { .. }))
.cloned()
.collect();
let key = (commodity_id.clone(), year);
let mut agent_share_cache = HashMap::new();
let capacity_limits = flexible_capacity_assets
.iter()
.filter_map(|asset| {
let agent_id = asset.agent_id().unwrap();
let agent_share = *agent_share_cache
.entry(agent_id.clone())
.or_insert_with(|| model.agents[agent_id].commodity_portions[&key]);
asset
.max_installable_capacity(agent_share)
.map(|max_capacity| (asset.clone(), max_capacity))
})
.collect::<HashMap<_, _>>();
let solution = DispatchRun::new(model, &all_assets, year)
.with_market_balance_subset(&markets_to_balance)
.with_flexible_capacity_assets(
&flexible_capacity_assets,
Some(&capacity_limits),
model.parameters.capacity_margin,
)
.run(
&format!("cycle ({markets_str}) post {commodity_id}|{region_id} investment",),
writer,
)
.with_context(|| {
format!(
"Cycle balancing failed for cycle ({markets_str}), capacity_margin: {}. \
Try increasing the capacity_margin.",
model.parameters.capacity_margin
)
})?;
current_demand.clone_from(demand);
update_net_demand_map(
&mut current_demand,
&solution.create_flow_map(),
&assets_for_cycle_flat,
);
last_solution = Some(solution);
}
let mut all_cycle_assets: Vec<_> = assets_for_cycle.into_values().flatten().collect();
if let Some(solution) = last_solution {
let new_capacities: HashMap<_, _> = solution.iter_capacity().collect();
for asset in &mut all_cycle_assets {
if let Some(new_capacity) = new_capacities.get(asset) {
debug!(
"Capacity of asset '{}' modified during cycle balancing ({} to {})",
asset.process_id(),
asset.total_capacity(),
new_capacity.total_capacity()
);
asset.make_mut().set_capacity(*new_capacity);
}
}
}
Ok(all_cycle_assets)
}
fn flatten_preset_demands_for_year(
commodities: &CommodityMap,
time_slice_info: &TimeSliceInfo,
year: u32,
) -> AllDemandMap {
let mut demand_map = AllDemandMap::new();
for (commodity_id, commodity) in commodities {
for ((region_id, data_year, time_slice_selection), demand) in &commodity.demand {
if *data_year != year {
continue;
}
#[allow(clippy::cast_precision_loss)]
let n_time_slices = time_slice_selection.iter(time_slice_info).count() as f64;
let demand_per_slice = *demand / Dimensionless(n_time_slices);
for (time_slice, _) in time_slice_selection.iter(time_slice_info) {
demand_map.insert(
(commodity_id.clone(), region_id.clone(), time_slice.clone()),
demand_per_slice,
);
}
}
}
demand_map
}
fn update_net_demand_map(demand: &mut AllDemandMap, flows: &FlowMap, assets: &[AssetRef]) {
for ((asset, commodity_id, time_slice), flow) in flows {
if assets.contains(asset) {
let key = (
commodity_id.clone(),
asset.region_id().clone(),
time_slice.clone(),
);
if (flow < &Flow(0.0))
|| asset
.primary_output()
.is_some_and(|p| &p.commodity.id == commodity_id)
{
demand
.entry(key)
.and_modify(|value| *value -= *flow)
.or_insert(-*flow);
}
}
}
}
fn get_demand_portion_for_market(
time_slice_info: &TimeSliceInfo,
demand: &AllDemandMap,
commodity_id: &CommodityID,
region_id: &RegionID,
commodity_portion: Dimensionless,
) -> DemandMap {
time_slice_info
.iter_ids()
.map(|time_slice| {
(
time_slice.clone(),
commodity_portion
* *demand
.get(&(commodity_id.clone(), region_id.clone(), time_slice.clone()))
.unwrap_or(&Flow(0.0)),
)
})
.collect()
}
fn get_responsible_agents<'a, I>(
agents: I,
commodity_id: &'a CommodityID,
region_id: &'a RegionID,
year: u32,
) -> impl Iterator<Item = (&'a Agent, Dimensionless)>
where
I: Iterator<Item = &'a Agent>,
{
agents.filter_map(move |agent| {
if !agent.regions.contains(region_id) {
return None;
}
let portion = agent
.commodity_portions
.get(&(commodity_id.clone(), year))?;
Some((agent, *portion))
})
}
fn get_demand_limiting_capacity(
time_slice_info: &TimeSliceInfo,
asset: &Asset,
commodity: &Commodity,
demand: &DemandMap,
) -> Capacity {
let coeff = asset.get_flow(&commodity.id).unwrap().coeff;
let mut capacity = Capacity(0.0);
for time_slice_selection in time_slice_info.iter_selections_at_level(commodity.time_slice_level)
{
let demand_for_selection: Flow = time_slice_selection
.iter(time_slice_info)
.map(|(time_slice, _)| demand[time_slice])
.sum();
for (time_slice, _) in time_slice_selection.iter(time_slice_info) {
let max_flow_per_cap =
*asset.get_activity_per_capacity_limits(time_slice).end() * coeff;
if max_flow_per_cap != FlowPerCapacity(0.0) {
capacity = capacity.max(demand_for_selection / max_flow_per_cap);
}
}
}
capacity
}
fn get_asset_options<'a>(
time_slice_info: &'a TimeSliceInfo,
all_existing_assets: &'a [AssetRef],
demand: &'a DemandMap,
agent: &'a Agent,
commodity: &'a Commodity,
region_id: &'a RegionID,
year: u32,
) -> impl Iterator<Item = AssetRef> + 'a {
let existing_assets = all_existing_assets
.iter()
.filter_agent(&agent.id)
.filter_region(region_id)
.filter_primary_producers_of(&commodity.id)
.cloned();
let candidate_assets =
get_candidate_assets(time_slice_info, demand, agent, region_id, commodity, year);
chain(existing_assets, candidate_assets)
}
fn get_candidate_assets<'a>(
time_slice_info: &'a TimeSliceInfo,
demand: &'a DemandMap,
agent: &'a Agent,
region_id: &'a RegionID,
commodity: &'a Commodity,
year: u32,
) -> impl Iterator<Item = AssetRef> + 'a {
agent
.iter_possible_producers_of(region_id, &commodity.id, year)
.map(move |process| {
let mut asset =
Asset::new_candidate(process.clone(), region_id.clone(), Capacity(0.0), year)
.unwrap();
let capacity = get_demand_limiting_capacity(time_slice_info, &asset, commodity, demand);
let asset_capacity = AssetCapacity::from_capacity(capacity, asset.unit_size());
asset.set_capacity(asset_capacity);
asset.into()
})
}
fn log_on_equal_appraisal_outputs(
outputs: &[AppraisalOutput],
agent_id: &AgentID,
commodity_id: &CommodityID,
region_id: &RegionID,
) {
if outputs.is_empty() {
return;
}
let num_identical = count_equal_and_best_appraisal_outputs(outputs);
if num_identical > 0 {
let asset_details = outputs[..=num_identical]
.iter()
.map(|output| {
let asset = &output.asset;
format!(
"Process ID: '{}' (State: {}{}, Commission year: {})",
asset.process_id(),
asset.state(),
asset
.id()
.map(|id| format!(", Asset ID: {id}"))
.unwrap_or_default(),
asset.commission_year()
)
})
.join(", ");
debug!(
"Found equally good appraisals for Agent ID: {agent_id}, Commodity: '{commodity_id}', \
Region: {region_id}. Options: [{asset_details}]. Selecting first option.",
);
}
}
fn calculate_investment_limits_for_candidates(
opt_assets: &[AssetRef],
commodity_portion: Dimensionless,
) -> HashMap<AssetRef, AssetCapacity> {
opt_assets
.iter()
.filter(|asset| !asset.is_commissioned())
.map(|asset| {
let mut cap = asset.capacity();
if let Some(limit_capacity) = asset.max_installable_capacity(commodity_portion) {
cap = cap.min(limit_capacity);
}
(asset.clone(), cap)
})
.collect()
}
#[allow(clippy::too_many_arguments)]
fn select_best_assets(
model: &Model,
mut opt_assets: Vec<AssetRef>,
investment_limits: HashMap<AssetRef, AssetCapacity>,
commodity: &Commodity,
agent: &Agent,
region_id: &RegionID,
prices: &CommodityPrices,
mut demand: DemandMap,
year: u32,
writer: &mut DataWriter,
) -> Result<Vec<AssetRef>> {
let objective_type = &agent.objectives[&year];
let mut remaining_candidate_capacity = investment_limits;
let coefficients =
calculate_coefficients_for_assets(model, objective_type, &opt_assets, prices, year);
let mut round = 0;
let mut best_assets: Vec<AssetRef> = Vec::new();
while is_any_remaining_demand(
&demand,
model.parameters.remaining_demand_absolute_tolerance,
) {
ensure!(
!opt_assets.is_empty(),
"Failed to meet demand for commodity '{}' in region '{}' with provided investment \
options. This may be due to overly restrictive process investment constraints.",
&commodity.id,
region_id
);
let mut seen_groups = HashSet::new();
let mut outputs_for_opts = Vec::new();
for asset in &opt_assets {
let max_capacity = (!asset.is_commissioned()).then(|| {
let tranche_capacity = asset
.capacity()
.apply_limit_factor(model.parameters.capacity_limit_factor);
let remaining_capacity = remaining_candidate_capacity[asset];
tranche_capacity.min(remaining_capacity)
});
if let Some(group_id) = asset.group_id()
&& !seen_groups.insert(group_id)
{
continue;
}
let output = appraise_investment(
model,
asset,
max_capacity,
commodity,
objective_type,
&coefficients[asset],
&demand,
)?;
outputs_for_opts.push(output);
}
writer.write_appraisal_debug_info(
year,
&format!("{} {} round {}", &commodity.id, &agent.id, round),
&outputs_for_opts,
&demand,
)?;
sort_and_filter_appraisal_outputs(&mut outputs_for_opts);
if outputs_for_opts.is_empty() {
let remaining_demands: Vec<_> = demand
.iter()
.filter(|(_, flow)| **flow > Flow(0.0))
.map(|(time_slice, flow)| format!("{} : {:e}", time_slice, flow.value()))
.collect();
bail!(
"No feasible investment options left for \
commodity '{}', region '{}', year '{}', agent '{}' after appraisal.\n\
Remaining unmet demand (time_slice : flow):\n{}",
&commodity.id,
region_id,
year,
agent.id,
remaining_demands.join("\n")
);
}
log_on_equal_appraisal_outputs(&outputs_for_opts, &agent.id, &commodity.id, region_id);
let best_output = outputs_for_opts.into_iter().next().unwrap();
debug!(
"Selected {} asset '{}' (capacity: {})",
&best_output.asset.state(),
&best_output.asset.process_id(),
best_output.capacity.total_capacity()
);
update_assets(
best_output.asset,
best_output.capacity,
&mut opt_assets,
&mut remaining_candidate_capacity,
&mut best_assets,
);
demand = best_output.unmet_demand;
round += 1;
}
for asset in &mut best_assets {
if let AssetState::Candidate = asset.state() {
asset
.make_mut()
.select_candidate_for_investment(agent.id.clone());
}
}
Ok(best_assets)
}
fn is_any_remaining_demand(demand: &DemandMap, absolute_tolerance: Flow) -> bool {
demand.values().any(|flow| *flow > absolute_tolerance)
}
fn update_assets(
mut best_asset: AssetRef,
capacity: AssetCapacity,
opt_assets: &mut Vec<AssetRef>,
remaining_candidate_capacity: &mut HashMap<AssetRef, AssetCapacity>,
best_assets: &mut Vec<AssetRef>,
) {
match best_asset.state() {
AssetState::Commissioned { .. } => {
opt_assets.retain(|asset| *asset != best_asset);
best_assets.push(best_asset);
}
AssetState::Candidate => {
let remaining_capacity = remaining_candidate_capacity.get_mut(&best_asset).unwrap();
*remaining_capacity = *remaining_capacity - capacity;
if remaining_capacity.total_capacity() <= Capacity(0.0) {
let old_idx = opt_assets
.iter()
.position(|asset| *asset == best_asset)
.unwrap();
opt_assets.swap_remove(old_idx);
remaining_candidate_capacity.remove(&best_asset);
}
if let Some(existing_asset) = best_assets.iter_mut().find(|asset| **asset == best_asset)
{
existing_asset.make_mut().increase_capacity(capacity);
} else {
best_asset.make_mut().set_capacity(capacity);
best_assets.push(best_asset);
}
}
_ => panic!("update_assets should only be called with Commissioned or Candidate assets"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commodity::Commodity;
use crate::fixture::{
agent_id, asset, process, process_activity_limits_map, process_flows_map,
process_investment_constraints, process_parameter_map, region_id, svd_commodity,
time_slice, time_slice_info, time_slice_info2,
};
use crate::process::{
ActivityLimits, FlowType, Process, ProcessActivityLimitsMap, ProcessFlow, ProcessFlowsMap,
ProcessInvestmentConstraint, ProcessInvestmentConstraintsMap, ProcessParameterMap,
};
use crate::region::RegionID;
use crate::time_slice::{TimeSliceID, TimeSliceInfo};
use crate::units::Dimensionless;
use crate::units::{ActivityPerCapacity, Capacity, Flow, FlowPerActivity, MoneyPerFlow};
use indexmap::{IndexSet, indexmap};
use rstest::rstest;
use std::rc::Rc;
#[rstest]
fn get_demand_limiting_capacity_works(
time_slice: TimeSliceID,
time_slice_info: TimeSliceInfo,
svd_commodity: Commodity,
mut process: Process,
) {
let commodity_rc = Rc::new(svd_commodity);
let process_flow = ProcessFlow {
commodity: Rc::clone(&commodity_rc),
coeff: FlowPerActivity(2.0), kind: FlowType::Fixed,
cost: MoneyPerFlow(0.0),
};
let process_flows = indexmap! { commodity_rc.id.clone() => process_flow.clone() };
let process_flows_map = process_flows_map(process.regions.clone(), Rc::new(process_flows));
process.flows = process_flows_map;
let asset = asset(process);
let demand = indexmap! { time_slice.clone() => Flow(10.0)};
let result = get_demand_limiting_capacity(&time_slice_info, &asset, &commodity_rc, &demand);
assert_eq!(result, Capacity(5.0));
}
#[rstest]
fn get_demand_limiting_capacity_multiple_time_slices(
time_slice_info2: TimeSliceInfo,
svd_commodity: Commodity,
mut process: Process,
) {
let (time_slice1, time_slice2) =
time_slice_info2.time_slices.keys().collect_tuple().unwrap();
let commodity_rc = Rc::new(svd_commodity);
let process_flow = ProcessFlow {
commodity: Rc::clone(&commodity_rc),
coeff: FlowPerActivity(1.0), kind: FlowType::Fixed,
cost: MoneyPerFlow(0.0),
};
let process_flows = indexmap! { commodity_rc.id.clone() => process_flow.clone() };
let process_flows_map = process_flows_map(process.regions.clone(), Rc::new(process_flows));
process.flows = process_flows_map;
let mut limits = ActivityLimits::new_with_full_availability(&time_slice_info2);
limits.add_time_slice_limit(time_slice1.clone(), Dimensionless(0.0)..=Dimensionless(0.2));
limits.add_time_slice_limit(time_slice2.clone(), Dimensionless(0.0)..=Dimensionless(0.0));
let limits_map = process_activity_limits_map(process.regions.clone(), limits);
process.activity_limits = limits_map;
let asset = asset(process);
let demand = indexmap! {
time_slice1.clone() => Flow(4.0), time_slice2.clone() => Flow(3.0), };
let result =
get_demand_limiting_capacity(&time_slice_info2, &asset, &commodity_rc, &demand);
assert_eq!(result, Capacity(20.0));
}
#[rstest]
fn calculate_investment_limits_for_candidates_empty_list() {
let opt_assets: Vec<AssetRef> = vec![];
let commodity_portion = Dimensionless(1.0);
let result = calculate_investment_limits_for_candidates(&opt_assets, commodity_portion);
assert!(result.is_empty());
}
#[rstest]
fn calculate_investment_limits_for_candidates_commissioned_assets_filtered(
process: Process,
region_id: RegionID,
agent_id: AgentID,
) {
let process_rc = Rc::new(process);
let capacity = Capacity(10.0);
let commissioned_asset = Asset::new_commissioned(
agent_id.clone(),
process_rc.clone(),
region_id.clone(),
capacity,
2015,
)
.unwrap();
let candidate_asset =
Asset::new_candidate(process_rc.clone(), region_id.clone(), capacity, 2015).unwrap();
let candidate_asset_ref = AssetRef::from(candidate_asset);
let opt_assets = vec![
AssetRef::from(commissioned_asset),
candidate_asset_ref.clone(),
];
let commodity_portion = Dimensionless(1.0);
let result = calculate_investment_limits_for_candidates(&opt_assets, commodity_portion);
assert_eq!(result.len(), 1);
assert!(result.contains_key(&candidate_asset_ref));
}
#[rstest]
fn calculate_investment_limits_for_candidates_no_investment_constraints(
process: Process,
region_id: RegionID,
) {
let process_rc = Rc::new(process);
let capacity = Capacity(15.0);
let candidate_asset = Asset::new_candidate(process_rc, region_id, capacity, 2015).unwrap();
let opt_assets = vec![AssetRef::from(candidate_asset.clone())];
let commodity_portion = Dimensionless(0.8);
let result = calculate_investment_limits_for_candidates(&opt_assets, commodity_portion);
assert_eq!(result.len(), 1);
let asset_ref = AssetRef::from(candidate_asset);
assert_eq!(result[&asset_ref], AssetCapacity::Continuous(capacity));
}
#[rstest]
#[case(Capacity(15.0), Capacity(10.0))]
#[case(Capacity(5.0), Capacity(5.0))]
fn calculate_investment_limits_for_candidates_with_constraints(
region_id: RegionID,
process_activity_limits_map: ProcessActivityLimitsMap,
process_flows_map: ProcessFlowsMap,
process_parameter_map: ProcessParameterMap,
#[case] asset_capacity: Capacity,
#[case] expected_limit: Capacity,
) {
let region_ids: IndexSet<RegionID> = [region_id.clone()].into();
let constraint = ProcessInvestmentConstraint {
addition_limit: Some(Capacity(10.0)),
};
let mut constraints = ProcessInvestmentConstraintsMap::new();
constraints.insert((region_id.clone(), 2015), Rc::new(constraint));
let process = Process {
id: "constrained_process".into(),
description: "Process with constraints".into(),
years: 2010..=2020,
activity_limits: process_activity_limits_map,
flows: process_flows_map,
parameters: process_parameter_map,
regions: region_ids,
primary_output: None,
capacity_to_activity: ActivityPerCapacity(1.0),
investment_constraints: constraints,
unit_size: None,
};
let process_rc = Rc::new(process);
let candidate_asset =
Asset::new_candidate(process_rc, region_id, asset_capacity, 2015).unwrap();
let opt_assets = vec![AssetRef::from(candidate_asset.clone())];
let commodity_portion = Dimensionless(1.0);
let result = calculate_investment_limits_for_candidates(&opt_assets, commodity_portion);
assert_eq!(result.len(), 1);
let asset_ref = AssetRef::from(candidate_asset);
assert_eq!(
result[&asset_ref],
AssetCapacity::Continuous(expected_limit)
);
}
#[rstest]
fn calculate_investment_limits_for_candidates_multiple_assets(
region_id: RegionID,
process_activity_limits_map: ProcessActivityLimitsMap,
process_flows_map: ProcessFlowsMap,
process_parameter_map: ProcessParameterMap,
) {
let region_ids: IndexSet<RegionID> = [region_id.clone()].into();
let constraint1 = ProcessInvestmentConstraint {
addition_limit: Some(Capacity(12.0)),
};
let mut constraints1 = ProcessInvestmentConstraintsMap::new();
constraints1.insert((region_id.clone(), 2015), Rc::new(constraint1));
let process1 = Process {
id: "process1".into(),
description: "First process".into(),
years: 2010..=2020,
activity_limits: process_activity_limits_map.clone(),
flows: process_flows_map.clone(),
parameters: process_parameter_map.clone(),
regions: region_ids.clone(),
primary_output: None,
capacity_to_activity: ActivityPerCapacity(1.0),
investment_constraints: constraints1,
unit_size: None,
};
let process2 = Process {
id: "process2".into(),
description: "Second process".into(),
years: 2010..=2020,
activity_limits: process_activity_limits_map,
flows: process_flows_map,
parameters: process_parameter_map,
regions: region_ids,
primary_output: None,
capacity_to_activity: ActivityPerCapacity(1.0),
investment_constraints: process_investment_constraints(),
unit_size: None,
};
let process1_rc = Rc::new(process1);
let process2_rc = Rc::new(process2);
let candidate1 =
Asset::new_candidate(process1_rc, region_id.clone(), Capacity(20.0), 2015).unwrap();
let candidate2 = Asset::new_candidate(process2_rc, region_id, Capacity(8.0), 2015).unwrap();
let opt_assets = vec![
AssetRef::from(candidate1.clone()),
AssetRef::from(candidate2.clone()),
];
let commodity_portion = Dimensionless(0.75);
let result = calculate_investment_limits_for_candidates(&opt_assets, commodity_portion);
assert_eq!(result.len(), 2);
let asset1_ref = AssetRef::from(candidate1);
assert_eq!(
result[&asset1_ref],
AssetCapacity::Continuous(Capacity(9.0))
);
let asset2_ref = AssetRef::from(candidate2);
assert_eq!(
result[&asset2_ref],
AssetCapacity::Continuous(Capacity(8.0))
);
}
#[rstest]
fn calculate_investment_limits_for_candidates_discrete_capacity(
region_id: RegionID,
process_activity_limits_map: crate::process::ProcessActivityLimitsMap,
process_flows_map: crate::process::ProcessFlowsMap,
process_parameter_map: crate::process::ProcessParameterMap,
) {
let region_ids: IndexSet<RegionID> = [region_id.clone()].into();
let constraint = ProcessInvestmentConstraint {
addition_limit: Some(Capacity(35.0)), };
let mut constraints = ProcessInvestmentConstraintsMap::new();
constraints.insert((region_id.clone(), 2015), Rc::new(constraint));
let process = Process {
id: "discrete_process".into(),
description: "Process with discrete units".into(),
years: 2010..=2020,
activity_limits: process_activity_limits_map,
flows: process_flows_map,
parameters: process_parameter_map,
regions: region_ids,
primary_output: None,
capacity_to_activity: ActivityPerCapacity(1.0),
investment_constraints: constraints,
unit_size: Some(Capacity(10.0)), };
let process_rc = Rc::new(process);
let capacity = Capacity(50.0);
let candidate_asset = Asset::new_candidate(process_rc, region_id, capacity, 2015).unwrap();
let opt_assets = vec![AssetRef::from(candidate_asset.clone())];
let commodity_portion = Dimensionless(1.0);
let result = calculate_investment_limits_for_candidates(&opt_assets, commodity_portion);
assert_eq!(result.len(), 1);
let asset_ref = AssetRef::from(candidate_asset);
assert_eq!(
result[&asset_ref],
AssetCapacity::Discrete(3, Capacity(10.0))
);
assert_eq!(result[&asset_ref].total_capacity(), Capacity(30.0));
}
}