Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions examples/muse1_default/process_investment_constraints.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
process_id,regions,commission_years,addition_limit
gassupply1,R1,all,10
gasCCGT,R1,all,10
windturbine,R1,all,10
gasboiler,R1,all,10
heatpump,R1,all,10
6 changes: 6 additions & 0 deletions examples/two_regions/process_investment_constraints.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
process_id,regions,commission_years,addition_limit
gassupply1,R1;R2,all,10
gasCCGT,R1;R2,all,10
windturbine,R1;R2,all,10
gasboiler,R1;R2,all,10
heatpump,R1;R2,all,10
82 changes: 82 additions & 0 deletions src/asset.rs
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,22 @@ impl AssetCapacity {
}
}

/// Create an `AssetCapacity` from a total capacity and optional unit size
///
/// If a unit size is provided, the capacity is represented as a discrete number of units,
/// calculated as the floor of (capacity / `unit_size`). If no unit size is provided, the
/// capacity is represented as continuous.
pub fn from_capacity_floor(capacity: Capacity, unit_size: Option<Capacity>) -> Self {
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
match unit_size {
Some(size) => {
let num_units = (capacity / size).value().floor() as u32;
AssetCapacity::Discrete(num_units, size)
}
None => AssetCapacity::Continuous(capacity),
}
}

/// Returns the total capacity represented by this `AssetCapacity`.
pub fn total_capacity(&self) -> Capacity {
match self {
Expand Down Expand Up @@ -1046,6 +1062,30 @@ impl Asset {
let child_asset = AssetRef::from(Rc::new(child_asset));
std::iter::repeat_n(child_asset, n_units as usize).collect()
}

/// For uncommissioned assets, get the maximum capacity permitted to be installed based on the
/// investment constraints for the asset's process.
///
/// The limit is taken from the process's investment constraints for the asset's region and
/// commission year, and the portion of the commodity demand being considered.
///
/// For divisible assets, the returned capacity will be rounded down to the nearest multiple of
/// the asset's unit size.
pub fn max_installable_capacity(
&self,
commodity_portion: Dimensionless,
) -> Option<AssetCapacity> {
assert!(
!self.is_commissioned(),
"max_installable_capacity can only be called on uncommissioned assets"
);

self.process
.investment_constraints
.get(&(self.region_id.clone(), self.commission_year))
.and_then(|c| c.get_addition_limit().map(|l| l * commodity_portion))
.map(|limit| AssetCapacity::from_capacity_floor(limit, self.unit_size()))
}
}

#[allow(clippy::missing_fields_in_debug)]
Expand Down Expand Up @@ -1524,6 +1564,27 @@ mod tests {
assert_eq!(got.total_capacity(), expected_total);
}

#[rstest]
#[case::exact_multiple(Capacity(12.0), Some(Capacity(4.0)), Some(3), Capacity(12.0))]
#[case::rounded_down(Capacity(11.0), Some(Capacity(4.0)), Some(2), Capacity(8.0))]
#[case::unit_size_greater_than_capacity(
Capacity(3.0),
Some(Capacity(4.0)),
Some(0),
Capacity(0.0)
)]
#[case::continuous(Capacity(5.5), None, None, Capacity(5.5))]
fn from_capacity_floor(
#[case] capacity: Capacity,
#[case] unit_size: Option<Capacity>,
#[case] expected_n: Option<u32>,
#[case] expected_total: Capacity,
) {
let got = AssetCapacity::from_capacity_floor(capacity, unit_size);
assert_eq!(got.n_units(), expected_n);
assert_eq!(got.total_capacity(), expected_total);
}

#[rstest]
#[case::round_up(3u32, Capacity(4.0), Dimensionless(0.5), 2u32)]
#[case::exact(3u32, Capacity(4.0), Dimensionless(0.33), 1u32)]
Expand Down Expand Up @@ -2303,4 +2364,25 @@ mod tests {
"Agent A0_GEX has asset with commission year 2060, not within process GASDRV commission years: 2020..=2050"
);
}

#[rstest]
fn max_installable_capacity(mut process: Process, region_id: RegionID) {
// Set an addition limit of 3 for (region, year 2015)
process.investment_constraints.insert(
(region_id.clone(), 2015),
Rc::new(crate::process::ProcessInvestmentConstraint {
addition_limit: Some(Capacity(3.0)),
}),
);
let process_rc = Rc::new(process);

// Create a candidate asset with commission year 2015
let asset =
Asset::new_candidate(process_rc.clone(), region_id.clone(), Capacity(1.0), 2015)
.unwrap();

// commodity_portion = 0.5 -> limit = 3 * 0.5 = 1.5
let result = asset.max_installable_capacity(Dimensionless(0.5));
assert_eq!(result, Some(AssetCapacity::Continuous(Capacity(1.5))));
}
}
92 changes: 56 additions & 36 deletions src/input/process/investment_constraints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use crate::process::{
ProcessID, ProcessInvestmentConstraint, ProcessInvestmentConstraintsMap, ProcessMap,
};
use crate::region::parse_region_str;
use crate::units::{Capacity, Dimensionless};
use crate::year::parse_year_str;
use anyhow::{Context, Result, ensure};
use itertools::iproduct;
Expand All @@ -21,15 +22,15 @@ struct ProcessInvestmentConstraintRaw {
process_id: String,
regions: String,
commission_years: String,
addition_limit: f64,
addition_limit: Capacity,
}

impl ProcessInvestmentConstraintRaw {
/// Validate the constraint record for logical consistency and required fields
fn validate(&self) -> Result<()> {
// Validate that value is finite
ensure!(
self.addition_limit.is_finite() && self.addition_limit >= 0.0,
self.addition_limit.is_finite() && self.addition_limit >= Capacity(0.0),
"Invalid value for addition constraint: '{}'; must be non-negative and finite.",
self.addition_limit
);
Expand Down Expand Up @@ -118,11 +119,29 @@ where
})?;

// Create constraints for each region and year combination
let constraint = Rc::new(ProcessInvestmentConstraint {
addition_limit: Some(record.addition_limit),
});
// For a given milestone year, the addition limit should be multiplied
// by the number of years since the previous milestone year. Any
// addition limits specified for the first milestone year are ignored.
let process_map = map.entry(process_id.clone()).or_default();
for (region, &year) in iproduct!(&record_regions, &constraint_years) {
// Calculate years since previous milestone year
// We can ignore constraints in the first milestone year as no investments are performed then
let idx = milestone_years.iter().position(|y| *y == year).expect(
"Year should be in milestone_years since it was validated by parse_year_str",
);
if idx == 0 {
continue;
}
let prev_year = milestone_years[idx - 1];
let years_since_prev = year - prev_year;

// Multiply the addition limit by the number of years since previous milestone.
let scaled_limit = record.addition_limit * Dimensionless(years_since_prev as f64);

let constraint = Rc::new(ProcessInvestmentConstraint {
addition_limit: Some(scaled_limit),
});

try_insert(process_map, &(region.clone(), year), constraint.clone())?;
}
}
Expand All @@ -136,7 +155,7 @@ mod tests {
use crate::region::RegionID;
use rstest::rstest;

fn validate_raw_constraint(addition_limit: f64) -> Result<()> {
fn validate_raw_constraint(addition_limit: Capacity) -> Result<()> {
let constraint = ProcessInvestmentConstraintRaw {
process_id: "test_process".into(),
regions: "ALL".into(),
Expand All @@ -155,7 +174,7 @@ mod tests {
process_id: "process1".into(),
regions: "GBR".into(),
commission_years: "ALL".into(), // Should apply to milestone years [2012, 2016]
addition_limit: 100.0,
addition_limit: Capacity(100.0),
}];

let result = read_process_investment_constraints_from_iter(
Expand Down Expand Up @@ -201,19 +220,19 @@ mod tests {
process_id: "process1".into(),
regions: "GBR".into(),
commission_years: "2010".into(),
addition_limit: 100.0,
addition_limit: Capacity(100.0),
},
ProcessInvestmentConstraintRaw {
process_id: "process1".into(),
regions: "ALL".into(),
commission_years: "2015".into(),
addition_limit: 200.0,
addition_limit: Capacity(200.0),
},
ProcessInvestmentConstraintRaw {
process_id: "process1".into(),
regions: "USA".into(),
commission_years: "2020".into(),
addition_limit: 50.0,
addition_limit: Capacity(50.0),
},
];

Expand All @@ -234,32 +253,32 @@ mod tests {
let gbr_region: RegionID = "GBR".into();
let usa_region: RegionID = "USA".into();

// Check GBR 2010 constraint
let gbr_2010 = process_constraints
.get(&(gbr_region.clone(), 2010))
.expect("GBR 2010 constraint should exist");
assert_eq!(gbr_2010.addition_limit, Some(100.0));
// GBR 2010 constraint is for the first milestone year and should be ignored
assert!(
!process_constraints.contains_key(&(gbr_region.clone(), 2010)),
"GBR 2010 constraint should not exist"
);

// Check GBR 2015 constraint (from ALL regions)
// Check GBR 2015 constraint (from ALL regions), scaled by years since previous milestone (5 years)
let gbr_2015 = process_constraints
.get(&(gbr_region, 2015))
.expect("GBR 2015 constraint should exist");
assert_eq!(gbr_2015.addition_limit, Some(200.0));
assert_eq!(gbr_2015.addition_limit, Some(Capacity(200.0 * 5.0)));

// Check USA 2015 constraint (from ALL regions)
// Check USA 2015 constraint (from ALL regions), scaled by 5 years
let usa_2015 = process_constraints
.get(&(usa_region.clone(), 2015))
.expect("USA 2015 constraint should exist");
assert_eq!(usa_2015.addition_limit, Some(200.0));
assert_eq!(usa_2015.addition_limit, Some(Capacity(200.0 * 5.0)));

// Check USA 2020 constraint
// Check USA 2020 constraint, scaled by years since previous milestone (5 years)
let usa_2020 = process_constraints
.get(&(usa_region, 2020))
.expect("USA 2020 constraint should exist");
assert_eq!(usa_2020.addition_limit, Some(50.0));
assert_eq!(usa_2020.addition_limit, Some(Capacity(50.0 * 5.0)));

// Verify total number of constraints (2 GBR + 2 USA = 4)
assert_eq!(process_constraints.len(), 4);
// Verify total number of constraints (GBR 2015, USA 2015, USA 2020 = 3)
assert_eq!(process_constraints.len(), 3);
}

#[rstest]
Expand All @@ -272,7 +291,7 @@ mod tests {
process_id: "process1".into(),
regions: "ALL".into(),
commission_years: "ALL".into(),
addition_limit: 75.0,
addition_limit: Capacity(75.0),
}];

// Read constraints into the map
Expand All @@ -292,21 +311,22 @@ mod tests {
let gbr_region: RegionID = "GBR".into();
let usa_region: RegionID = "USA".into();

// Verify constraint exists for all region-year combinations
for &year in &milestone_years {
// Verify constraint exists for all region-year combinations except the first milestone year
for &year in &milestone_years[1..] {
let gbr_constraint = process_constraints
.get(&(gbr_region.clone(), year))
.unwrap_or_else(|| panic!("GBR {year} constraint should exist"));
assert_eq!(gbr_constraint.addition_limit, Some(75.0));
// scaled by years since previous milestone (5 years)
assert_eq!(gbr_constraint.addition_limit, Some(Capacity(75.0 * 5.0)));

let usa_constraint = process_constraints
.get(&(usa_region.clone(), year))
.unwrap_or_else(|| panic!("USA {year} constraint should exist"));
assert_eq!(usa_constraint.addition_limit, Some(75.0));
assert_eq!(usa_constraint.addition_limit, Some(Capacity(75.0 * 5.0)));
}

// Verify total number of constraints (2 regions × 3 years = 6)
assert_eq!(process_constraints.len(), 6);
// Verify total number of constraints (2 regions × 2 years = 4)
assert_eq!(process_constraints.len(), 4);
}

#[rstest]
Expand All @@ -319,7 +339,7 @@ mod tests {
process_id: "process1".into(),
regions: "GBR".into(),
commission_years: "2025".into(), // Outside milestone years (2010-2020)
addition_limit: 100.0,
addition_limit: Capacity(100.0),
}];

// Should fail with milestone year validation error
Expand All @@ -337,15 +357,15 @@ mod tests {
#[test]
fn validate_addition_with_finite_value() {
// Valid: addition constraint with positive value
let valid = validate_raw_constraint(10.0);
let valid = validate_raw_constraint(Capacity(10.0));
valid.unwrap();

// Valid: addition constraint with zero value
let valid = validate_raw_constraint(0.0);
let valid = validate_raw_constraint(Capacity(0.0));
valid.unwrap();

// Not valid: addition constraint with negative value
let invalid = validate_raw_constraint(-10.0);
let invalid = validate_raw_constraint(Capacity(-10.0));
assert_error!(
invalid,
"Invalid value for addition constraint: '-10'; must be non-negative and finite."
Expand All @@ -355,14 +375,14 @@ mod tests {
#[test]
fn validate_addition_rejects_infinite() {
// Invalid: infinite value
let invalid = validate_raw_constraint(f64::INFINITY);
let invalid = validate_raw_constraint(Capacity(f64::INFINITY));
assert_error!(
invalid,
"Invalid value for addition constraint: 'inf'; must be non-negative and finite."
);

// Invalid: NaN value
let invalid = validate_raw_constraint(f64::NAN);
let invalid = validate_raw_constraint(Capacity(f64::NAN));
assert_error!(
invalid,
"Invalid value for addition constraint: 'NaN'; must be non-negative and finite."
Expand Down
21 changes: 17 additions & 4 deletions src/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -497,12 +497,25 @@ pub struct ProcessParameter {
}

/// A constraint imposed on investments in the process
///
/// Constraints apply to a specific milestone year, and have been pre-scaled from annual limits
/// defined in the input data to account for the number of years since the previous milestone year.
#[derive(PartialEq, Debug, Clone)]
pub struct ProcessInvestmentConstraint {
/// Addition constraint: Yearly limit an agent can invest
/// in the process, shared according to the agent's
/// proportion of the processes primary commodity demand
pub addition_limit: Option<f64>,
/// Addition constraint: Limit an agent can invest in the process, shared according to the
/// agent's proportion of the process's primary commodity demand
pub addition_limit: Option<Capacity>,
}

impl ProcessInvestmentConstraint {
/// Calculate the effective addition limit
///
/// For now, this just returns `addition_limit`, but in the future when we add growth
/// limits and total capacity limits, this will have more complex logic which will depend on the
/// current total capacity.
pub fn get_addition_limit(&self) -> Option<Capacity> {
self.addition_limit
}
}

#[cfg(test)]
Expand Down
Loading