Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
b2c3488
Apply investment limits based on agent demand share
tsmbland Jan 23, 2026
6dbc3e6
Scale limit based on number of years
tsmbland Jan 23, 2026
e2bfc4b
Update example models
tsmbland Jan 23, 2026
6c28f0f
Streamline arguments to calculate_investment_limits_for_candidates
tsmbland Jan 23, 2026
864b199
Small tidy ups
tsmbland Jan 26, 2026
4ac9e84
Add tests
tsmbland Jan 26, 2026
6c4c6c5
Pre-compute investment limits, better error message
tsmbland Jan 26, 2026
da9fafe
max_installable_capacity Asset function
tsmbland Jan 26, 2026
4fc39e6
Use u32 for number of years
tsmbland Jan 26, 2026
4afd749
Pre-scale limits
tsmbland Jan 26, 2026
2a36db4
Simplify calculate_investment_limits_for_candidates
tsmbland Jan 27, 2026
b2eb10d
Apply constraints in circularities model
tsmbland Jan 27, 2026
9da63c4
Merge branch 'main' into investment_limits
tsmbland Jan 27, 2026
8f05fb6
Copilot fixes
tsmbland Jan 27, 2026
25304ed
Merge branch 'investment_limits' of https://github.com/EnergySystemsM…
tsmbland Jan 27, 2026
51cf7b3
Remove unnecessary clone()
alexdewar Feb 10, 2026
518ea91
Tweak doc comment
alexdewar Feb 10, 2026
3da198d
Add tests for `calculate_investment_limits_for_candidates`
alexdewar Feb 11, 2026
8694f06
AGENTS.md: Add instructions for tests
alexdewar Feb 11, 2026
e3d890b
Asset::max_installable_capacity: Check `commodity_portion` is in range
alexdewar Feb 11, 2026
52c7729
Remove now-stale note from schema
alexdewar Feb 11, 2026
4d1132c
schemas/input/process_investment_constraints.yaml: Fixes and formatting
alexdewar Feb 11, 2026
7652932
Add `CapacityPerYear` unit type
alexdewar Feb 10, 2026
aa045cd
Use `CapacityPerYear` type to represent unscaled addition limits
alexdewar Feb 10, 2026
7ed1ab0
Merge remote-tracking branch 'origin/main' into investment_limits
alexdewar Feb 11, 2026
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
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,6 @@ For Rust code:

- Prefer `use` imports to fully qualified paths
- Prefer named format arguments (e.g. `"{x}"`) over positional formatting (e.g. `"{}", x`)
- Test function names should not be prefixed with `test_`
- Prefer using parameterised tests (using `rstest`) over separate ones where testing similar
functionality
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
17 changes: 8 additions & 9 deletions schemas/input/process_investment_constraints.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,6 @@ $schema: https://specs.frictionlessdata.io/schemas/table-schema.json
description: |
Constraints on the amount agents can invest in processes.

notes: |
Not implemented yet! This file is reserved for future use.

fields:
- name: process_id
type: string
Expand All @@ -15,15 +12,17 @@ fields:
notes: |
One or more region IDs, separated by semicolons or the string `all`. Must be regions in which
the process operates.
- name: commission years
- name: commission_years
type: string
description: The milestone year(s) to which this entry applies
notes: One or more milestone years separated by semicolons, `all` to select all years or a year
range in the form 'start..end' to select all valid years within range, inclusive. Either 'start'
notes: |
One or more milestone years separated by semicolons, `all` to select all years or a year range
in the form 'start..end' to select all valid years within range, inclusive. Either 'start'
'end' or both can be omitted, which will set the corresponding limit to the minimum or maximum
valid year, respectively. Must be within the process's year range.
- name: addition limit
- name: addition_limit
type: number
description: Yearly constraint on the amount agents can invest in the process
notes: The addition limit is allocated evenly between all agents using their proportion
of the processes primary commodity demand.
notes: |
The addition limit is allocated evenly between all agents using their proportion of the
process's primary commodity demand.
86 changes: 86 additions & 0 deletions src/asset.rs
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,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 @@ -1074,6 +1090,34 @@ impl Asset {
AssetCapacity::Continuous(_) => None,
}
}

/// For non-commissioned 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"
);
assert!(
commodity_portion >= Dimensionless(0.0) && commodity_portion <= Dimensionless(1.0),
"commodity_portion must be between 0 and 1 inclusive"
);

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 @@ -1548,6 +1592,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 @@ -2384,4 +2449,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))));
}
}
93 changes: 57 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::{CapacityPerYear, Year};
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: CapacityPerYear,
}

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 >= CapacityPerYear(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 * Year(years_since_prev as f64);

let constraint = Rc::new(ProcessInvestmentConstraint {
addition_limit: Some(scaled_limit),
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@Aurashk Do you know if there was a particular motivation in making addition_limit optional? As far as I can see, all the limits that are None could just not be put into the map, with the same effect.

});

try_insert(process_map, &(region.clone(), year), constraint.clone())?;
}
}
Expand All @@ -134,9 +153,10 @@ mod tests {
use super::*;
use crate::fixture::{assert_error, processes};
use crate::region::RegionID;
use crate::units::Capacity;
use rstest::rstest;

fn validate_raw_constraint(addition_limit: f64) -> Result<()> {
fn validate_raw_constraint(addition_limit: CapacityPerYear) -> Result<()> {
let constraint = ProcessInvestmentConstraintRaw {
process_id: "test_process".into(),
regions: "ALL".into(),
Expand All @@ -155,7 +175,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: CapacityPerYear(100.0),
}];

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

Expand All @@ -234,32 +254,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 +292,7 @@ mod tests {
process_id: "process1".into(),
regions: "ALL".into(),
commission_years: "ALL".into(),
addition_limit: 75.0,
addition_limit: CapacityPerYear(75.0),
}];

// Read constraints into the map
Expand All @@ -292,21 +312,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 +340,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: CapacityPerYear(100.0),
}];

// Should fail with milestone year validation error
Expand All @@ -337,15 +358,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(CapacityPerYear(10.0));
valid.unwrap();

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

// Not valid: addition constraint with negative value
let invalid = validate_raw_constraint(-10.0);
let invalid = validate_raw_constraint(CapacityPerYear(-10.0));
assert_error!(
invalid,
"Invalid value for addition constraint: '-10'; must be non-negative and finite."
Expand All @@ -355,14 +376,14 @@ mod tests {
#[test]
fn validate_addition_rejects_infinite() {
// Invalid: infinite value
let invalid = validate_raw_constraint(f64::INFINITY);
let invalid = validate_raw_constraint(CapacityPerYear(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(CapacityPerYear(f64::NAN));
assert_error!(
invalid,
"Invalid value for addition constraint: 'NaN'; must be non-negative and finite."
Expand Down
Loading