Skip to content
Draft
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
41 changes: 21 additions & 20 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;
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 @@ -136,7 +137,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 +156,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 +202,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 @@ -238,25 +239,25 @@ mod tests {
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));
assert_eq!(gbr_2010.addition_limit, Some(Capacity(100.0)));

// Check GBR 2015 constraint (from ALL regions)
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)));

// Check USA 2015 constraint (from ALL regions)
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)));

// Check USA 2020 constraint
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)));

// Verify total number of constraints (2 GBR + 2 USA = 4)
assert_eq!(process_constraints.len(), 4);
Expand All @@ -272,7 +273,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 @@ -297,12 +298,12 @@ mod tests {
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));
assert_eq!(gbr_constraint.addition_limit, Some(Capacity(75.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)));
}

// Verify total number of constraints (2 regions × 3 years = 6)
Expand All @@ -319,7 +320,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 +338,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 +356,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
13 changes: 12 additions & 1 deletion src/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -502,7 +502,18 @@ 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>,
pub addition_limit: Option<Capacity>,
}

impl ProcessInvestmentConstraint {
/// Get the addition limit, if any
///
/// 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
79 changes: 70 additions & 9 deletions src/simulation/investment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,63 @@ fn warn_on_equal_appraisal_outputs(
}
}

/// Calculate investment limits for candidate assets for a given agent in a given market and year
///
/// Investment limits are based on demand for the commodity (capacity cannot exceed that needed to
/// meet demand), and any addition limits specified by the process (scaled according to the agent's
/// portion of the commodity demand).
fn calculate_investment_limits_for_candidates(
opt_assets: &[AssetRef],
agent_portion: Dimensionless,
year: u32,
milestone_years: &[u32],
) -> HashMap<AssetRef, AssetCapacity> {
// Check that the year is a milestone year and not the first milestone year (since we must
// calculate the number of years since the previous milestone year below)
assert!(milestone_years.contains(&year) && year != milestone_years[0],);

// Calculate number of years elapsed since previous milestone year
let previous_milestone_year = *milestone_years
.iter()
.rev()
.skip_while(|&&y| y != year)
.nth(1)
.unwrap();
let years_elapsed = Dimensionless((year - previous_milestone_year) as f64);

// Calculate limits for each candidate asset
opt_assets
.iter()
.filter(|asset| !asset.is_commissioned())
.map(|asset| {
// Sanity check: if the year does not match the asset's commission year, then
// something is wrong
assert_eq!(asset.commission_year(), year);

// Demand-limiting capacity (pre-calculated when creating candidate)
let mut cap = asset.capacity();

// Further capped by addition limits of the process, if specified
// These are scaled according to the agent's portion of the commodity demand and the
// number of years elapsed since the previous milestone year.
if let Some(limit) = asset
.process()
.investment_constraints
.get(&(asset.region_id().clone(), year))
.and_then(|c| {
c.get_addition_limit()
.map(|l| l * years_elapsed * agent_portion)
})
{
let limit_capacity = AssetCapacity::from_capacity(limit, asset.unit_size());
cap = cap.min(limit_capacity);
}

(asset.clone(), cap)
})
.collect()
}

/// Get the best assets for meeting demand for the given commodity
#[allow(clippy::too_many_arguments)]
fn select_best_assets(
Expand All @@ -687,13 +744,16 @@ fn select_best_assets(
let coefficients =
calculate_coefficients_for_assets(model, objective_type, &opt_assets, prices, year);

let mut remaining_candidate_capacity = HashMap::from_iter(
opt_assets
.iter()
.filter(|asset| !asset.is_commissioned())
.map(|asset| (asset.clone(), asset.capacity())),
// Calculate investment limits for candidate assets
let agent_portion = agent.commodity_portions[&(commodity.id.clone(), year)];
let mut remaining_candidate_capacity = calculate_investment_limits_for_candidates(
&opt_assets,
agent_portion,
year,
&model.parameters.milestone_years,
);

// Iteratively select the best asset until demand is met
let mut round = 0;
let mut best_assets: Vec<AssetRef> = Vec::new();
while is_any_remaining_demand(&demand) {
Expand All @@ -710,13 +770,14 @@ fn select_best_assets(
// Appraise all options
let mut outputs_for_opts = Vec::new();
for asset in &opt_assets {
// For candidates, determine the maximum capacity that can be invested in this round,
// according to the tranche size and remaining capacity limits.
let max_capacity = (!asset.is_commissioned()).then(|| {
let max_capacity = asset
let tranche_capacity = asset
.capacity()
.apply_limit_factor(model.parameters.capacity_limit_factor);

let remaining_capacity = remaining_candidate_capacity[asset];
max_capacity.min(remaining_capacity)
tranche_capacity.min(remaining_capacity)
});

// Skip any assets from groups we've already seen
Expand Down Expand Up @@ -788,7 +849,7 @@ fn select_best_assets(
best_output.capacity.total_capacity()
);

// Update the assets
// Update the assets and remaining candidate capacity
update_assets(
best_output.asset,
best_output.capacity,
Expand Down