use super::super::input_err_msg;
use crate::input::{read_csv_optional, try_insert};
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;
use serde::Deserialize;
use std::collections::HashMap;
use std::path::Path;
use std::rc::Rc;
const PROCESS_INVESTMENT_CONSTRAINTS_FILE_NAME: &str = "process_investment_constraints.csv";
#[derive(PartialEq, Debug, Deserialize)]
struct ProcessInvestmentConstraintRaw {
process_id: String,
regions: String,
commission_years: String,
addition_limit: CapacityPerYear,
}
impl ProcessInvestmentConstraintRaw {
fn validate(&self) -> Result<()> {
ensure!(
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
);
Ok(())
}
}
pub fn read_process_investment_constraints(
model_dir: &Path,
processes: &ProcessMap,
milestone_years: &[u32],
) -> Result<HashMap<ProcessID, ProcessInvestmentConstraintsMap>> {
let file_path = model_dir.join(PROCESS_INVESTMENT_CONSTRAINTS_FILE_NAME);
let constraints_csv = read_csv_optional(&file_path)?;
read_process_investment_constraints_from_iter(constraints_csv, processes, milestone_years)
.with_context(|| input_err_msg(&file_path))
}
fn read_process_investment_constraints_from_iter<I>(
iter: I,
processes: &ProcessMap,
milestone_years: &[u32],
) -> Result<HashMap<ProcessID, ProcessInvestmentConstraintsMap>>
where
I: Iterator<Item = ProcessInvestmentConstraintRaw>,
{
let mut map: HashMap<ProcessID, ProcessInvestmentConstraintsMap> = HashMap::new();
for record in iter {
record.validate()?;
let (process_id, process) = processes
.get_key_value(record.process_id.as_str())
.with_context(|| format!("Process {} not found", record.process_id))?;
let process_regions = &process.regions;
let record_regions =
parse_region_str(&record.regions, process_regions).with_context(|| {
format!(
"Invalid region for process {process_id}. Valid regions are {process_regions:?}"
)
})?;
let milestone_years_in_process_range: Vec<u32> = milestone_years
.iter()
.copied()
.filter(|year| process.years.contains(year))
.collect();
let constraint_years = parse_year_str(&record.commission_years, &milestone_years_in_process_range)
.with_context(|| {
format!(
"Invalid year for constraint on process {process_id}. Valid years are {milestone_years_in_process_range:?}",
)
})?;
let process_map = map.entry(process_id.clone()).or_default();
for (region, &year) in iproduct!(&record_regions, &constraint_years) {
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;
let scaled_limit = record.addition_limit * Year(years_since_prev as f64);
let constraint = Rc::new(ProcessInvestmentConstraint {
addition_limit: Some(scaled_limit),
});
try_insert(process_map, &(region.clone(), year), constraint.clone())?;
}
}
Ok(map)
}
#[cfg(test)]
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: CapacityPerYear) -> Result<()> {
let constraint = ProcessInvestmentConstraintRaw {
process_id: "test_process".into(),
regions: "ALL".into(),
commission_years: "2030".into(),
addition_limit,
};
constraint.validate()
}
#[rstest]
fn read_constraints_only_uses_milestone_years_within_process_range(processes: ProcessMap) {
let milestone_years = vec![2008, 2012, 2016];
let constraints = vec![ProcessInvestmentConstraintRaw {
process_id: "process1".into(),
regions: "GBR".into(),
commission_years: "ALL".into(), addition_limit: CapacityPerYear(100.0),
}];
let result = read_process_investment_constraints_from_iter(
constraints.into_iter(),
&processes,
&milestone_years,
)
.unwrap();
let process_id: ProcessID = "process1".into();
let process_constraints = result
.get(&process_id)
.expect("Process constraints should exist");
let gbr_region: RegionID = "GBR".into();
assert_eq!(process_constraints.len(), 2);
assert!(process_constraints.contains_key(&(gbr_region.clone(), 2012)));
assert!(process_constraints.contains_key(&(gbr_region.clone(), 2016)));
let process = processes.get(&process_id).unwrap();
for year in process.years.clone() {
if ![2012, 2016].contains(&year) {
assert!(
!process_constraints.contains_key(&(gbr_region.clone(), year)),
"Should not contain constraint for year {year}"
);
}
}
}
#[rstest]
fn read_process_investment_constraints_from_iter_works(processes: ProcessMap) {
let milestone_years: Vec<u32> = vec![2010, 2015, 2020];
let constraints = vec![
ProcessInvestmentConstraintRaw {
process_id: "process1".into(),
regions: "GBR".into(),
commission_years: "2010".into(),
addition_limit: CapacityPerYear(100.0),
},
ProcessInvestmentConstraintRaw {
process_id: "process1".into(),
regions: "ALL".into(),
commission_years: "2015".into(),
addition_limit: CapacityPerYear(200.0),
},
ProcessInvestmentConstraintRaw {
process_id: "process1".into(),
regions: "USA".into(),
commission_years: "2020".into(),
addition_limit: CapacityPerYear(50.0),
},
];
let result = read_process_investment_constraints_from_iter(
constraints.into_iter(),
&processes,
&milestone_years,
)
.unwrap();
let process_id: ProcessID = "process1".into();
let process_constraints = result
.get(&process_id)
.expect("Process constraints should exist");
let gbr_region: RegionID = "GBR".into();
let usa_region: RegionID = "USA".into();
assert!(
!process_constraints.contains_key(&(gbr_region.clone(), 2010)),
"GBR 2010 constraint should not exist"
);
let gbr_2015 = process_constraints
.get(&(gbr_region, 2015))
.expect("GBR 2015 constraint should exist");
assert_eq!(gbr_2015.addition_limit, Some(Capacity(200.0 * 5.0)));
let usa_2015 = process_constraints
.get(&(usa_region.clone(), 2015))
.expect("USA 2015 constraint should exist");
assert_eq!(usa_2015.addition_limit, Some(Capacity(200.0 * 5.0)));
let usa_2020 = process_constraints
.get(&(usa_region, 2020))
.expect("USA 2020 constraint should exist");
assert_eq!(usa_2020.addition_limit, Some(Capacity(50.0 * 5.0)));
assert_eq!(process_constraints.len(), 3);
}
#[rstest]
fn read_constraints_all_regions_all_years(processes: ProcessMap) {
let milestone_years: Vec<u32> = vec![2010, 2015, 2020];
let constraints = vec![ProcessInvestmentConstraintRaw {
process_id: "process1".into(),
regions: "ALL".into(),
commission_years: "ALL".into(),
addition_limit: CapacityPerYear(75.0),
}];
let result = read_process_investment_constraints_from_iter(
constraints.into_iter(),
&processes,
&milestone_years,
)
.unwrap();
let process_id: ProcessID = "process1".into();
let process_constraints = result
.get(&process_id)
.expect("Process constraints should exist");
let gbr_region: RegionID = "GBR".into();
let usa_region: RegionID = "USA".into();
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(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(Capacity(75.0 * 5.0)));
}
assert_eq!(process_constraints.len(), 4);
}
#[rstest]
fn read_constraints_year_outside_milestone_years(processes: ProcessMap) {
let milestone_years = vec![2010, 2015, 2020];
let constraints = vec![ProcessInvestmentConstraintRaw {
process_id: "process1".into(),
regions: "GBR".into(),
commission_years: "2025".into(), addition_limit: CapacityPerYear(100.0),
}];
let result = read_process_investment_constraints_from_iter(
constraints.into_iter(),
&processes,
&milestone_years,
);
assert_error!(
result,
"Invalid year for constraint on process process1. Valid years are [2010, 2015, 2020]"
);
}
#[test]
fn validate_addition_with_finite_value() {
let valid = validate_raw_constraint(CapacityPerYear(10.0));
valid.unwrap();
let valid = validate_raw_constraint(CapacityPerYear(0.0));
valid.unwrap();
let invalid = validate_raw_constraint(CapacityPerYear(-10.0));
assert_error!(
invalid,
"Invalid value for addition constraint: '-10'; must be non-negative and finite."
);
}
#[test]
fn validate_addition_rejects_infinite() {
let invalid = validate_raw_constraint(CapacityPerYear(f64::INFINITY));
assert_error!(
invalid,
"Invalid value for addition constraint: 'inf'; must be non-negative and finite."
);
let invalid = validate_raw_constraint(CapacityPerYear(f64::NAN));
assert_error!(
invalid,
"Invalid value for addition constraint: 'NaN'; must be non-negative and finite."
);
}
}