Skip to content
10 changes: 10 additions & 0 deletions src/asset.rs
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,16 @@ impl Asset {
lb..=ub
}

/// Get the activity limits per unit of capacity for this asset for a given time slice selection
pub fn get_activity_per_capacity_limits_for_selection(
&self,
time_slice_selection: &TimeSliceSelection,
) -> RangeInclusive<ActivityPerCapacity> {
let limits = self.activity_limits.get_limit(time_slice_selection);
let cap2act = self.process.capacity_to_activity;
(cap2act * *limits.start())..=(cap2act * *limits.end())
}

/// Iterate over activity limits for this asset
pub fn iter_activity_limits(
&self,
Expand Down
154 changes: 134 additions & 20 deletions src/simulation/investment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,15 @@ use crate::model::Model;
use crate::output::DataWriter;
use crate::region::RegionID;
use crate::simulation::prices::Prices;
use crate::time_slice::{TimeSliceID, TimeSliceInfo};
use crate::units::{Capacity, Dimensionless, Flow, FlowPerCapacity};
use crate::time_slice::{TimeSliceID, TimeSliceInfo, TimeSliceLevel};
use crate::units::{ActivityPerCapacity, 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;
use strum::IntoEnumIterator;

pub mod appraisal;
use appraisal::coefficients::calculate_coefficients_for_assets;
Expand Down Expand Up @@ -570,7 +571,19 @@ where
})
}

/// Get the maximum required capacity across time slices
/// Returns the minimum installed capacity required for `asset` to satisfy the demand that it can
/// potentially serve, accounting for its activity constraints.
///
/// The returned value is the maximum capacity requirement implied by any time-slice selection,
/// since constraints at coarser aggregation levels (e.g. seasonal or annual limits) can require
/// more capacity than constraints at the finest time-slice level.
///
/// Demand is evaluated using the commodity's balance level. Demand within a balance bucket is
/// treated as fungible: if the asset is capable of operating in any constituent time slice of a
/// bucket, then all demand in that bucket is considered serviceable by the asset.
///
/// Selections whose maximum supply is zero are ignored. Such selections would otherwise imply an
/// infinite capacity requirement and therefore provide no useful lower bound.
Comment on lines +574 to +586
fn get_demand_limiting_capacity(
time_slice_info: &TimeSliceInfo,
asset: &Asset,
Expand All @@ -579,23 +592,62 @@ fn get_demand_limiting_capacity(
) -> 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();

// Calculate max capacity required for this time slice selection
// For commodities with a coarse time slice level, we have to allow the possibility that all
// of the demand gets served by production in a single time slice
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);
let mut demand_cache: HashMap<_, Flow> = HashMap::new();

// Calculate demand-limiting capacity at each timeslice level and take the max.
for level in TimeSliceLevel::iter() {
for selection in time_slice_info.iter_selections_at_level(level) {
// Maximum supply within this selection according to the asset's activity limits.
let max_supply_for_selection = *asset
.get_activity_per_capacity_limits_for_selection(&selection)
.end()
* coeff;

// Selections with zero supply would imply infinite demand-limiting capacity,
// so they do not contribute to the maximum.
if max_supply_for_selection == FlowPerCapacity(0.0) {
continue;
}

// Serviceable demand within this selection.
//
// Demand is effectively grouped into balance buckets at the commodity's
// balance level. A balance bucket contributes if:
// 1. The bucket is contained within this selection, and
// 2. The asset can operate in at least one constituent timeslice
// within that bucket.
//
// Demand within a balance bucket is fungible, so if the asset can serve
// any timeslice in the bucket, all demand in that bucket is considered
// serviceable.
let demand_selection_level = level.max(commodity.time_slice_level);
let demand_selection = selection
.containing_selection_at_level(demand_selection_level)
.unwrap();
let serviceable_demand_for_selection = *demand_cache
.entry(demand_selection.clone())
.or_insert_with(|| {
demand_selection
.iter_at_level(time_slice_info, commodity.time_slice_level)
.unwrap()
.filter(|(bucket, _)| {
bucket.iter(time_slice_info).any(|(ts, _)| {
*asset.get_activity_per_capacity_limits(ts).end()
> ActivityPerCapacity(0.0)
})
})
.map(|(bucket, _)| {
bucket
.iter(time_slice_info)
.map(|(ts, _)| demand[ts])
.sum::<Flow>()
})
.sum()
});

// Calculate demand-limiting capacity for this selection and take the
// maximum across all selections.
capacity = capacity.max(serviceable_demand_for_selection / max_supply_for_selection);
}
}

Expand Down Expand Up @@ -931,7 +983,7 @@ mod tests {
ProcessInvestmentConstraint, ProcessInvestmentConstraintsMap, ProcessParameterMap,
};
use crate::region::RegionID;
use crate::time_slice::{TimeSliceID, TimeSliceInfo};
use crate::time_slice::{TimeSliceID, TimeSliceInfo, TimeSliceSelection};
use crate::units::Dimensionless;
use crate::units::{ActivityPerCapacity, Capacity, Flow, FlowPerActivity, MoneyPerFlow};
use indexmap::{IndexSet, indexmap};
Expand Down Expand Up @@ -1020,6 +1072,68 @@ mod tests {
assert_eq!(result, Capacity(20.0));
}

#[rstest]
fn get_demand_limiting_capacity_uses_coarser_limits(
time_slice_info2: TimeSliceInfo,
svd_commodity: Commodity,
mut process: Process,
) {
let (time_slice1, time_slice2) =
time_slice_info2.time_slices.keys().collect_tuple().unwrap();

// Configure a 1:1 activity-to-flow relationship.
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() };
process.flows = process_flows_map(process.regions.clone(), Rc::new(process_flows));

// Fine-grained limits imply a capacity requirement of 5:
// TS1: 5 / 1 = 5
// TS2: 5 / 1 = 5
//
// The annual limit implies:
// (5 + 5) / 0.5 = 20
//
// The function should return the larger value.
let limits = HashMap::from([
(
TimeSliceSelection::Single(time_slice1.clone()),
Dimensionless(0.0)..=Dimensionless(1.0),
),
(
TimeSliceSelection::Single(time_slice2.clone()),
Dimensionless(0.0)..=Dimensionless(1.0),
),
(
TimeSliceSelection::Annual,
Dimensionless(0.0)..=Dimensionless(0.5),
),
]);

process.activity_limits = process_activity_limits_map(
process.regions.clone(),
ActivityLimits::new_from_limits(&limits, &time_slice_info2).unwrap(),
);

let asset = asset(process);

let demand = indexmap! {
time_slice1.clone() => Flow(5.0),
time_slice2.clone() => Flow(5.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() {
// Test with empty list of assets
Expand Down
33 changes: 32 additions & 1 deletion src/time_slice.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,29 @@ impl TimeSliceSelection {
}
}

/// Get the [`TimeSliceSelection`] containing this selection at the specified level.
pub fn containing_selection_at_level(
&self,
level: TimeSliceLevel,
) -> Option<TimeSliceSelection> {
if level < self.level() {
return None;
}

Comment on lines +97 to +105

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with Copilot: I'd return None if the input is invalid instead of panicking. I know it won't panic in the one place this function's actually being used, but I think it's clearer if you have to explicitly unwrap() it at the call site.

let mut selection = self.clone();
while selection.level() < level {
selection = match selection {
TimeSliceSelection::Single(time_slice_id) => {
TimeSliceSelection::Season(time_slice_id.season.clone())
}
TimeSliceSelection::Season(_) => TimeSliceSelection::Annual,
TimeSliceSelection::Annual => unreachable!(),
};
}

Some(selection)
}

/// Iterate over the subset of time slices in this selection
pub fn iter<'a>(
&'a self,
Expand Down Expand Up @@ -192,7 +215,15 @@ impl Display for TimeSliceSelection {

/// The time granularity for a particular operation
#[derive(
PartialEq, PartialOrd, Copy, Clone, Debug, DeserializeLabeledStringEnum, strum::EnumIter,
PartialEq,
Eq,
PartialOrd,
Ord,
Copy,
Clone,
Debug,
DeserializeLabeledStringEnum,
strum::EnumIter,
)]
pub enum TimeSliceLevel {
/// Treat individual time slices separately
Expand Down
61 changes: 29 additions & 32 deletions tests/data/muse1_default/asset_capacities.csv
Original file line number Diff line number Diff line change
@@ -1,50 +1,47 @@
milestone_year,asset_id,group_id,capacity,num_units
2020,0,,24.0,
2020,1,,19.0,
2025,0,,24.0,
2025,2,,23.939952120095757,
2025,3,,9.575980848038302,
2025,4,,5.107189785620431,
2030,0,,24.0,
2025,3,,13.299973400053199,
2025,4,,1.3299973400053235,
2030,2,,23.939952120095757,
2030,3,,9.575980848038302,
2030,4,,5.107189785620431,
2030,3,,13.299973400053199,
2030,4,,1.3299973400053235,
2030,5,,5.939988120023763,
2030,6,,5.975988048023905,
2030,6,,12.73337453325093,
2035,2,,23.939952120095757,
2035,3,,9.575980848038302,
2035,3,,13.299973400053199,
2035,5,,5.939988120023763,
2035,6,,5.975988048023905,
2035,6,,12.73337453325093,
2035,7,,6.119987760024477,
2035,8,,5.875188249623504,
2035,8,,11.679816640366745,
2040,2,,23.939952120095757,
2040,3,,9.575980848038302,
2040,3,,13.299973400053199,
2040,5,,5.939988120023763,
2040,6,,5.975988048023905,
2040,6,,12.73337453325093,
2040,7,,6.119987760024477,
2040,8,,5.875188249623504,
2040,9,,5.939988120023762,
2040,10,,16.775966448067106,
2040,11,,2.6567946864106284,
2040,8,,11.679816640366745,
2040,9,,5.939988120023761,
2040,10,,1.0999978000044013,
2045,2,,23.939952120095757,
2045,3,,9.575980848038302,
2045,3,,13.299973400053199,
2045,5,,5.939988120023763,
2045,6,,5.975988048023905,
2045,6,,12.73337453325093,
2045,7,,6.119987760024477,
2045,8,,5.875188249623504,
2045,9,,5.939988120023762,
2045,10,,16.775966448067106,
2045,12,,5.939988120023759,
2045,13,,8.320303359393275,
2045,8,,11.679816640366745,
2045,9,,5.939988120023761,
2045,10,,1.0999978000044013,
2045,11,,5.939988120023761,
2045,12,,6.4042111915775575,
2050,2,,23.939952120095757,
2050,3,,9.575980848038302,
2050,3,,13.299973400053199,
2050,5,,5.939988120023763,
2050,6,,5.975988048023905,
2050,6,,12.73337453325093,
2050,7,,6.119987760024477,
2050,8,,5.875188249623504,
2050,9,,5.939988120023762,
2050,10,,16.775966448067106,
2050,12,,5.939988120023759,
2050,13,,8.320303359393275,
2050,14,,6.1199877600244745,
2050,15,,5.703540592918827,
2050,8,,11.679816640366745,
2050,9,,5.939988120023761,
2050,10,,1.0999978000044013,
2050,11,,5.939988120023761,
2050,12,,6.4042111915775575,
2050,13,,6.11998776002448,
2050,14,,6.950586098827813,
9 changes: 4 additions & 5 deletions tests/data/muse1_default/assets.csv
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,7 @@ asset_id,group_id,process_id,region_id,agent_id,commission_year
8,,windturbine,R1,A1_PWR,2035
9,,heatpump,R1,A1_RES,2040
10,,windturbine,R1,A1_PWR,2040
11,,gasCCGT,R1,A1_PWR,2040
12,,heatpump,R1,A1_RES,2045
13,,windturbine,R1,A1_PWR,2045
14,,heatpump,R1,A1_RES,2050
15,,windturbine,R1,A1_PWR,2050
11,,heatpump,R1,A1_RES,2045
12,,windturbine,R1,A1_PWR,2045
13,,heatpump,R1,A1_RES,2050
14,,windturbine,R1,A1_PWR,2050
Loading
Loading