diff --git a/examples/simple/process_parameters.csv b/examples/simple/process_parameters.csv index 3f7564125..b9cb5d37a 100644 --- a/examples/simple/process_parameters.csv +++ b/examples/simple/process_parameters.csv @@ -1,7 +1,7 @@ -process_id,start_year,end_year,capital_cost,fixed_operating_cost,variable_operating_cost,lifetime,discount_rate,capacity_to_activity -GASDRV,2020,2030,10.0,0.3,2.0,25,0.1,1.0 -GASPRC,2020,2030,7.0,0.21,0.5,25,0.1,1.0 -WNDFRM,2020,2030,1000.0,30.0,0.4,25,0.1,31.54 -GASCGT,2020,2030,700.0,21.0,0.55,30,0.1,31.54 -RGASBR,2020,2030,55.56,1.6668,0.16,15,0.1,1.0 -RELCHP,2020,2030,138.9,4.167,0.17,15,0.1,1.0 +process_id,year,capital_cost,fixed_operating_cost,variable_operating_cost,lifetime,discount_rate,capacity_to_activity +GASDRV,all,10.0,0.3,2.0,25,0.1,1.0 +GASPRC,all,7.0,0.21,0.5,25,0.1,1.0 +WNDFRM,all,1000.0,30.0,0.4,25,0.1,31.54 +GASCGT,all,700.0,21.0,0.55,30,0.1,31.54 +RGASBR,all,55.56,1.6668,0.16,15,0.1,1.0 +RELCHP,all,138.9,4.167,0.17,15,0.1,1.0 diff --git a/examples/simple/processes.csv b/examples/simple/processes.csv index b55837398..3acf39f33 100644 --- a/examples/simple/processes.csv +++ b/examples/simple/processes.csv @@ -1,7 +1,7 @@ -id,description -GASDRV,Dry gas extraction -GASPRC,Gas processing -WNDFRM,Wind farm -GASCGT,Gas combined cycle turbine -RGASBR,Gas boiler -RELCHP,Heat pump +id,description,start_year,end_year +GASDRV,Dry gas extraction,2020,2030 +GASPRC,Gas processing,2020,2030 +WNDFRM,Wind farm,2020,2030 +GASCGT,Gas combined cycle turbine,2020,2030 +RGASBR,Gas boiler,2020,2030 +RELCHP,Heat pump,2020,2030 diff --git a/src/asset.rs b/src/asset.rs index d59ee00b0..a4c5d8c1e 100644 --- a/src/asset.rs +++ b/src/asset.rs @@ -58,7 +58,13 @@ impl Asset { /// The last year in which this asset should be decommissioned pub fn decommission_year(&self) -> u32 { - self.commission_year + self.process.parameter.lifetime + self.commission_year + + self + .process + .parameters + .get(&self.commission_year) + .unwrap() + .lifetime } /// Get the energy limits for this asset in a particular time slice @@ -74,7 +80,13 @@ impl Asset { /// Maximum activity for this asset (PAC energy produced/consumed per year) pub fn maximum_activity(&self) -> f64 { - self.capacity * self.process.parameter.capacity_to_activity + self.capacity + * self + .process + .parameters + .get(&self.commission_year) + .unwrap() + .capacity_to_activity } } @@ -188,11 +200,14 @@ impl AssetPool { mod tests { use super::*; use crate::commodity::{CommodityCostMap, CommodityType, DemandMap}; - use crate::process::{EnergyLimitsMap, FlowType, Process, ProcessFlow, ProcessParameter}; + use crate::process::{ + EnergyLimitsMap, FlowType, Process, ProcessFlow, ProcessParameter, ProcessParameterMap, + }; use crate::region::RegionSelection; use crate::time_slice::TimeSliceLevel; use itertools::{assert_equal, Itertools}; use std::iter; + use std::ops::RangeInclusive; #[test] fn test_asset_get_energy_limits() { @@ -201,7 +216,6 @@ mod tests { time_of_day: "day".into(), }; let process_param = ProcessParameter { - years: 2010..=2020, capital_cost: 5.0, fixed_operating_cost: 2.0, variable_operating_cost: 1.0, @@ -209,6 +223,11 @@ mod tests { discount_rate: 0.9, capacity_to_activity: 3.0, }; + let years = RangeInclusive::new(2010, 2020).collect_vec(); + let process_parameter_map: ProcessParameterMap = years + .iter() + .map(|&year| (year, process_param.clone())) + .collect(); let commodity = Rc::new(Commodity { id: "commodity1".into(), description: "Some description".into(), @@ -230,9 +249,10 @@ mod tests { let process = Rc::new(Process { id: "process1".into(), description: "Description".into(), + years: 2010..=2020, energy_limits, flows: vec![flow.clone()], - parameter: process_param.clone(), + parameters: process_parameter_map, regions: RegionSelection::All, }); let asset = Asset { @@ -249,7 +269,6 @@ mod tests { fn create_asset_pool() -> AssetPool { let process_param = ProcessParameter { - years: 2010..=2020, capital_cost: 5.0, fixed_operating_cost: 2.0, variable_operating_cost: 1.0, @@ -257,12 +276,18 @@ mod tests { discount_rate: 0.9, capacity_to_activity: 1.0, }; + let years = RangeInclusive::new(2010, 2020).collect_vec(); + let process_parameter_map: ProcessParameterMap = years + .iter() + .map(|&year| (year, process_param.clone())) + .collect(); let process = Rc::new(Process { id: "process1".into(), description: "Description".into(), + years: 2010..=2020, energy_limits: EnergyLimitsMap::new(), flows: vec![], - parameter: process_param.clone(), + parameters: process_parameter_map, regions: RegionSelection::All, }); let future = [2020, 2010] diff --git a/src/input.rs b/src/input.rs index 2780f1a50..758373484 100644 --- a/src/input.rs +++ b/src/input.rs @@ -7,8 +7,9 @@ use float_cmp::approx_eq; use indexmap::IndexMap; use itertools::Itertools; use serde::de::{Deserialize, DeserializeOwned, Deserializer}; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::fs; +use std::hash::Hash; use std::path::Path; mod agent; @@ -137,6 +138,20 @@ where Ok(()) } +/// Inserts a key-value pair into a HashMap if the key does not already exist. +/// +/// If the key already exists, it returns an error with a message indicating the key's existence. +pub fn try_insert(map: &mut HashMap, key: K, value: V) -> Result<()> +where + K: Eq + Hash + Clone + std::fmt::Display, +{ + let existing = map.insert(key.clone(), value); + match existing { + Some(_) => bail!("Key {} already exists in the map", key), + None => Ok(()), + } +} + /// Read a model from the specified directory. /// /// # Arguments diff --git a/src/input/asset.rs b/src/input/asset.rs index f789fd575..a99772a5b 100644 --- a/src/input/asset.rs +++ b/src/input/asset.rs @@ -95,28 +95,20 @@ where #[cfg(test)] mod tests { use super::*; - use crate::process::{EnergyLimitsMap, Process, ProcessParameter}; + use crate::process::{EnergyLimitsMap, Process, ProcessParameterMap}; use crate::region::RegionSelection; use itertools::assert_equal; use std::iter; #[test] fn test_read_assets_from_iter() { - let process_param = ProcessParameter { - years: 2010..=2020, - capital_cost: 5.0, - fixed_operating_cost: 2.0, - variable_operating_cost: 1.0, - lifetime: 5, - discount_rate: 0.9, - capacity_to_activity: 1.0, - }; let process = Rc::new(Process { id: "process1".into(), description: "Description".into(), + years: 2010..=2020, energy_limits: EnergyLimitsMap::new(), flows: vec![], - parameter: process_param.clone(), + parameters: ProcessParameterMap::new(), regions: RegionSelection::All, }); let processes = [(process.id.clone(), Rc::clone(&process))] @@ -189,9 +181,10 @@ mod tests { let process = Rc::new(Process { id: "process1".into(), description: "Description".into(), + years: 2010..=2020, energy_limits: EnergyLimitsMap::new(), flows: vec![], - parameter: process_param, + parameters: ProcessParameterMap::new(), regions: RegionSelection::Some(["GBR".into()].into_iter().collect()), }); let asset_in = AssetRaw { diff --git a/src/input/process.rs b/src/input/process.rs index 329cbb20e..ede487686 100644 --- a/src/input/process.rs +++ b/src/input/process.rs @@ -2,13 +2,14 @@ use super::*; use crate::commodity::{Commodity, CommodityID, CommodityMap, CommodityType}; use crate::process::{ - EnergyLimitsMap, Process, ProcessFlow, ProcessID, ProcessMap, ProcessParameter, + EnergyLimitsMap, Process, ProcessFlow, ProcessID, ProcessMap, ProcessParameterMap, }; use crate::region::{RegionID, RegionSelection}; use crate::time_slice::TimeSliceInfo; -use anyhow::{bail, ensure, Context, Result}; +use anyhow::{bail, ensure, Context, Ok, Result}; use serde::Deserialize; use std::collections::{HashMap, HashSet}; +use std::ops::RangeInclusive; use std::path::Path; use std::rc::Rc; @@ -25,11 +26,13 @@ use region::read_process_regions; const PROCESSES_FILE_NAME: &str = "processes.csv"; #[derive(PartialEq, Debug, Deserialize)] -struct ProcessDescription { +struct ProcessRaw { id: ProcessID, description: String, + start_year: Option, + end_year: Option, } -define_id_getter! {ProcessDescription, ProcessID} +define_id_getter! {ProcessRaw, ProcessID} /// Read process information from the specified CSV files. /// @@ -51,15 +54,15 @@ pub fn read_processes( time_slice_info: &TimeSliceInfo, milestone_years: &[u32], ) -> Result { - let file_path = model_dir.join(PROCESSES_FILE_NAME); - let descriptions = read_csv_id_file::(&file_path)?; - let process_ids = HashSet::from_iter(descriptions.keys().cloned()); - - let availabilities = read_process_availabilities(model_dir, &process_ids, time_slice_info)?; - let flows = read_process_flows(model_dir, &process_ids, commodities)?; let year_range = milestone_years[0]..=milestone_years[milestone_years.len() - 1]; - let parameters = read_process_parameters(model_dir, &process_ids, &year_range)?; - let regions = read_process_regions(model_dir, &process_ids, region_ids)?; + let mut processes = read_processes_file(model_dir, &year_range)?; + let process_ids = processes.keys().cloned().collect(); + + let mut energy_limits = read_process_availabilities(model_dir, &process_ids, time_slice_info)?; + let mut flows = read_process_flows(model_dir, &process_ids, commodities)?; + let mut parameters = + read_process_parameters(model_dir, &process_ids, &processes, milestone_years)?; + let mut regions = read_process_regions(model_dir, &process_ids, region_ids)?; // Validate commodities after the flows have been read validate_commodities( @@ -69,16 +72,80 @@ pub fn read_processes( milestone_years, time_slice_info, ¶meters, - &availabilities, + &energy_limits, )?; - create_process_map( - descriptions.into_values(), - availabilities, - flows, - parameters, - regions, - ) + // Add data to Process objects + for (id, process) in processes.iter_mut() { + process.energy_limits = energy_limits + .remove(id) + .with_context(|| format!("Missing availabilities for process {id}"))?; + process.flows = flows + .remove(id) + .with_context(|| format!("Missing flows for process {id}"))?; + process.parameters = parameters + .remove(id) + .with_context(|| format!("Missing parameters for process {id}"))?; + process.regions = regions + .remove(id) + .with_context(|| format!("Missing regions for process {id}"))?; + } + + // Create ProcessMap + let mut process_map = ProcessMap::new(); + for (id, process) in processes { + process_map.insert(id, process.into()); + } + + Ok(process_map) +} + +fn read_processes_file( + model_dir: &Path, + year_range: &RangeInclusive, +) -> Result> { + let file_path = model_dir.join(PROCESSES_FILE_NAME); + let processes_csv = read_csv(&file_path)?; + read_processes_file_from_iter(processes_csv, year_range) + .with_context(|| input_err_msg(&file_path)) +} + +fn read_processes_file_from_iter( + iter: I, + year_range: &RangeInclusive, +) -> Result> +where + I: Iterator, +{ + let mut processes = HashMap::new(); + for process_raw in iter { + let start_year = process_raw.start_year.unwrap_or(*year_range.start()); + let end_year = process_raw.end_year.unwrap_or(*year_range.end()); + + // Check year range is valid + ensure!( + start_year <= end_year, + "Error in parameter for process {}: start_year > end_year", + process_raw.id + ); + + let process = Process { + id: process_raw.id.clone(), + description: process_raw.description, + years: start_year..=end_year, + energy_limits: EnergyLimitsMap::new(), + flows: Vec::new(), + parameters: ProcessParameterMap::new(), + regions: RegionSelection::default(), + }; + + ensure!( + processes.insert(process_raw.id, process).is_none(), + "Duplicate process ID" + ); + } + + Ok(processes) } struct ValidationParams<'a> { @@ -86,7 +153,7 @@ struct ValidationParams<'a> { region_ids: &'a HashSet, milestone_years: &'a [u32], time_slice_info: &'a TimeSliceInfo, - parameters: &'a HashMap, + parameters: &'a HashMap, availabilities: &'a HashMap, } @@ -97,7 +164,7 @@ fn validate_commodities( region_ids: &HashSet, milestone_years: &[u32], time_slice_info: &TimeSliceInfo, - parameters: &HashMap, + parameters: &HashMap, availabilities: &HashMap, ) -> anyhow::Result<()> { let params = ValidationParams { @@ -175,7 +242,7 @@ fn validate_svd_commodity( .parameters .get(&*flow.process_id) .unwrap() - .years + .keys() .contains(&year) && params .availabilities @@ -206,50 +273,10 @@ fn validate_svd_commodity( Ok(()) } -fn create_process_map( - descriptions: I, - mut availabilities: HashMap, - mut flows: HashMap>, - mut parameters: HashMap, - mut regions: HashMap, -) -> Result -where - I: Iterator, -{ - descriptions - .map(|description| { - let id = &description.id; - let availabilities = availabilities - .remove(id) - .with_context(|| format!("No availabilities defined for process {id}"))?; - let flows = flows - .remove(id) - .with_context(|| format!("No commodity flows defined for process {id}"))?; - let parameter = parameters - .remove(id) - .with_context(|| format!("No parameters defined for process {id}"))?; - - // We've already checked that regions are defined for each process - let regions = regions.remove(id).unwrap(); - - let process = Process { - id: id.clone(), - description: description.description, - energy_limits: availabilities, - flows, - parameter, - regions, - }; - - Ok((description.id, process.into())) - }) - .try_collect() -} - #[cfg(test)] mod tests { use crate::commodity::{CommodityCostMap, DemandMap}; - use crate::process::FlowType; + use crate::process::{FlowType, ProcessParameter, ProcessParameterMap}; use crate::time_slice::TimeSliceID; use crate::time_slice::TimeSliceLevel; use std::iter; @@ -257,27 +284,13 @@ mod tests { use super::*; struct ProcessData { - descriptions: Vec, availabilities: HashMap, - flows: HashMap>, - parameters: HashMap, - regions: HashMap, + parameters: HashMap, region_ids: HashSet, } /// Returns example data (without errors) for processes fn get_process_data() -> ProcessData { - let descriptions = vec![ - ProcessDescription { - id: "process1".into(), - description: "Process 1".to_string(), - }, - ProcessDescription { - id: "process2".into(), - description: "Process 2".to_string(), - }, - ]; - let availabilities = ["process1", "process2"] .into_iter() .map(|id| { @@ -293,16 +306,11 @@ mod tests { }) .collect(); - let flows = ["process1", "process2"] - .into_iter() - .map(|id| (id.into(), vec![])) - .collect(); - let parameters = ["process1", "process2"] .into_iter() .map(|id| { + let mut parameter_map: ProcessParameterMap = HashMap::new(); let parameter = ProcessParameter { - years: 2010..=2020, capital_cost: 0.0, fixed_operating_cost: 0.0, variable_operating_cost: 0.0, @@ -310,77 +318,22 @@ mod tests { discount_rate: 1.0, capacity_to_activity: 0.0, }; - - (id.into(), parameter) + for year in [2010, 2020] { + parameter_map.insert(year, parameter.clone()); + } + (id.into(), parameter_map) }) .collect(); - let regions = ["process1", "process2"] - .into_iter() - .map(|id| (id.into(), RegionSelection::All)) - .collect(); - let region_ids = HashSet::from_iter(iter::once("GBR".into())); ProcessData { - descriptions, availabilities, - flows, parameters, - regions, region_ids, } } - #[test] - fn test_create_process_map_success() { - let data = get_process_data(); - let result = create_process_map( - data.descriptions.into_iter(), - data.availabilities, - data.flows, - data.parameters, - data.regions, - ) - .unwrap(); - - assert_eq!(result.len(), 2); - assert!(result.contains_key("process1")); - assert!(result.contains_key("process2")); - } - - /// Generate code for a test with data missing for a given field - macro_rules! test_missing { - ($field:ident) => { - let mut data = get_process_data(); - data.$field.remove("process1"); - - let result = create_process_map( - data.descriptions.into_iter(), - data.availabilities, - data.flows, - data.parameters, - data.regions, - ); - assert!(result.is_err()); - }; - } - - #[test] - fn test_create_process_map_missing_availabilities() { - test_missing!(availabilities); - } - - #[test] - fn test_create_process_map_missing_flows() { - test_missing!(flows); - } - - #[test] - fn test_create_process_map_missing_parameters() { - test_missing!(parameters); - } - #[test] fn test_validate_commodities() { let data = get_process_data(); diff --git a/src/input/process/parameter.rs b/src/input/process/parameter.rs index 3878f044e..2b760903c 100644 --- a/src/input/process/parameter.rs +++ b/src/input/process/parameter.rs @@ -1,12 +1,12 @@ //! Code for reading process parameters CSV file use super::super::*; use crate::id::IDCollection; -use crate::process::{ProcessID, ProcessParameter}; +use crate::process::{Process, ProcessID, ProcessParameter, ProcessParameterMap}; +use crate::year::parse_year_str; use ::log::warn; use anyhow::{ensure, Context, Result}; use serde::Deserialize; use std::collections::{HashMap, HashSet}; -use std::ops::RangeInclusive; use std::path::Path; const PROCESS_PARAMETERS_FILE_NAME: &str = "process_parameters.csv"; @@ -14,8 +14,7 @@ const PROCESS_PARAMETERS_FILE_NAME: &str = "process_parameters.csv"; #[derive(PartialEq, Debug, Deserialize)] struct ProcessParameterRaw { process_id: String, - start_year: Option, - end_year: Option, + year: String, capital_cost: f64, fixed_operating_cost: f64, variable_operating_cost: f64, @@ -25,21 +24,10 @@ struct ProcessParameterRaw { } impl ProcessParameterRaw { - fn into_parameter(self, year_range: &RangeInclusive) -> Result { - let start_year = self.start_year.unwrap_or(*year_range.start()); - let end_year = self.end_year.unwrap_or(*year_range.end()); - - // Check year range is valid - ensure!( - start_year <= end_year, - "Error in parameter for process {}: start_year > end_year", - self.process_id - ); - + fn into_parameter(self) -> Result { self.validate()?; Ok(ProcessParameter { - years: start_year..=end_year, capital_cost: self.capital_cost, fixed_operating_cost: self.fixed_operating_cost, variable_operating_cost: self.variable_operating_cost, @@ -106,31 +94,65 @@ impl ProcessParameterRaw { pub fn read_process_parameters( model_dir: &Path, process_ids: &HashSet, - year_range: &RangeInclusive, -) -> Result> { + processes: &HashMap, + milestone_years: &[u32], +) -> Result> { let file_path = model_dir.join(PROCESS_PARAMETERS_FILE_NAME); let iter = read_csv::(&file_path)?; - read_process_parameters_from_iter(iter, process_ids, year_range) + read_process_parameters_from_iter(iter, process_ids, processes, milestone_years) .with_context(|| input_err_msg(&file_path)) } fn read_process_parameters_from_iter( iter: I, process_ids: &HashSet, - year_range: &RangeInclusive, -) -> Result> + processes: &HashMap, + milestone_years: &[u32], +) -> Result> where I: Iterator, { - let mut params = HashMap::new(); + let mut params: HashMap = HashMap::new(); for param_raw in iter { let id = process_ids.get_id_by_str(¶m_raw.process_id)?; - let param = param_raw.into_parameter(year_range)?; + + let entry = params.entry(id.clone()).or_default(); + let process = processes + .get(&id) + .with_context(|| format!("Process {id} not found"))?; + let process_year_range = &process.years; + let process_years: Vec = milestone_years + .iter() + .copied() + .filter(|year| process_year_range.contains(year)) + .collect(); + + let parameter_years = + parse_year_str(¶m_raw.year, &process_years).with_context(|| { + format!("Invalid year for process {id}. Valid years are {process_years:?}") + })?; + let param = param_raw.into_parameter()?; + for year in parameter_years { + try_insert(entry, year, param.clone())?; + } + } + + // Check parameters cover all years of the process + for (id, parameter) in params.iter() { + let year_range = &processes.get(id).unwrap().years; + let reference_years: HashSet = milestone_years + .iter() + .copied() + .filter(|year| year_range.contains(year)) + .collect(); + let parameter_years: HashSet = parameter.keys().copied().collect(); ensure!( - params.insert(id.clone(), param).is_none(), - "More than one parameter provided for process {id}" + parameter_years == reference_years, + "Error in parameters for process {}: years do not match the process years", + id ); } + Ok(params) } @@ -139,32 +161,24 @@ mod tests { use super::*; fn create_param_raw( - start_year: Option, - end_year: Option, lifetime: u32, discount_rate: Option, capacity_to_activity: Option, ) -> ProcessParameterRaw { ProcessParameterRaw { process_id: "id".to_string(), - start_year, - end_year, capital_cost: 0.0, fixed_operating_cost: 0.0, variable_operating_cost: 0.0, lifetime, discount_rate, capacity_to_activity, + year: "all".to_string(), } } - fn create_param( - years: RangeInclusive, - discount_rate: f64, - capacity_to_activity: f64, - ) -> ProcessParameter { + fn create_param(discount_rate: f64, capacity_to_activity: f64) -> ProcessParameter { ProcessParameter { - years, capital_cost: 0.0, fixed_operating_cost: 0.0, variable_operating_cost: 0.0, @@ -176,216 +190,40 @@ mod tests { #[test] fn test_param_raw_into_param_ok() { - let year_range = 2000..=2100; - // No missing values - let raw = create_param_raw(Some(2010), Some(2020), 1, Some(1.0), Some(0.0)); - assert_eq!( - raw.into_parameter(&year_range).unwrap(), - create_param(2010..=2020, 1.0, 0.0) - ); - - // Missing years - let raw = create_param_raw(None, None, 1, Some(1.0), Some(0.0)); - assert_eq!( - raw.into_parameter(&year_range).unwrap(), - create_param(2000..=2100, 1.0, 0.0) - ); + let raw = create_param_raw(1, Some(1.0), Some(0.0)); + assert_eq!(raw.into_parameter().unwrap(), create_param(1.0, 0.0)); // Missing discount_rate - let raw = create_param_raw(Some(2010), Some(2020), 1, None, Some(0.0)); - assert_eq!( - raw.into_parameter(&year_range).unwrap(), - create_param(2010..=2020, 0.0, 0.0) - ); + let raw = create_param_raw(1, None, Some(0.0)); + assert_eq!(raw.into_parameter().unwrap(), create_param(0.0, 0.0)); // Missing capacity_to_activity - let raw = create_param_raw(Some(2010), Some(2020), 1, Some(1.0), None); - assert_eq!( - raw.into_parameter(&year_range).unwrap(), - create_param(2010..=2020, 1.0, 1.0) - ); - } - - #[test] - fn test_param_raw_into_param_good_years() { - let year_range = 2000..=2100; - - // Normal case - assert!( - create_param_raw(Some(2000), Some(2100), 1, Some(1.0), Some(0.0)) - .into_parameter(&year_range) - .is_ok() - ); - - // start_year out of range - this is permitted - assert!( - create_param_raw(Some(1999), Some(2100), 1, Some(1.0), Some(0.0)) - .into_parameter(&year_range) - .is_ok() - ); - - // end_year out of range - this is permitted - assert!( - create_param_raw(Some(2000), Some(2101), 1, Some(1.0), Some(0.0)) - .into_parameter(&year_range) - .is_ok() - ); - } - - #[test] - #[should_panic] - fn test_param_raw_into_param_bad_years() { - let year_range = 2000..=2100; - - // start_year after end_year - assert!( - create_param_raw(Some(2001), Some(2000), 1, Some(1.0), Some(0.0)) - .into_parameter(&year_range) - .is_ok() - ); + let raw = create_param_raw(1, Some(1.0), None); + assert_eq!(raw.into_parameter().unwrap(), create_param(1.0, 1.0)); } #[test] fn test_param_raw_validate_bad_lifetime() { // lifetime = 0 - assert!( - create_param_raw(Some(2000), Some(2100), 0, Some(1.0), Some(0.0)) - .validate() - .is_err() - ); + assert!(create_param_raw(0, Some(1.0), Some(0.0)) + .validate() + .is_err()); } #[test] fn test_param_raw_validate_bad_discount_rate() { // discount rate = -1 - assert!( - create_param_raw(Some(2000), Some(2100), 0, Some(-1.0), Some(0.0)) - .validate() - .is_err() - ); + assert!(create_param_raw(0, Some(-1.0), Some(0.0)) + .validate() + .is_err()); } #[test] fn test_param_raw_validate_bad_capt2act() { // capt2act = -1 - assert!( - create_param_raw(Some(2000), Some(2100), 0, Some(1.0), Some(-1.0)) - .validate() - .is_err() - ); - } - - #[test] - fn test_read_process_parameters_from_iter_good() { - let year_range = 2000..=2100; - let process_ids = ["A".into(), "B".into()].into_iter().collect(); - - let params_raw = [ - ProcessParameterRaw { - process_id: "A".into(), - start_year: Some(2010), - end_year: Some(2020), - capital_cost: 1.0, - fixed_operating_cost: 1.0, - variable_operating_cost: 1.0, - lifetime: 10, - discount_rate: Some(1.0), - capacity_to_activity: Some(1.0), - }, - ProcessParameterRaw { - process_id: "B".into(), - start_year: Some(2015), - end_year: Some(2020), - capital_cost: 1.0, - fixed_operating_cost: 1.0, - variable_operating_cost: 1.0, - lifetime: 10, - discount_rate: Some(1.0), - capacity_to_activity: Some(1.0), - }, - ]; - - let expected: HashMap = [ - ( - "A".into(), - ProcessParameter { - years: 2010..=2020, - capital_cost: 1.0, - fixed_operating_cost: 1.0, - variable_operating_cost: 1.0, - lifetime: 10, - discount_rate: 1.0, - capacity_to_activity: 1.0, - }, - ), - ( - "B".into(), - ProcessParameter { - years: 2015..=2020, - capital_cost: 1.0, - fixed_operating_cost: 1.0, - variable_operating_cost: 1.0, - lifetime: 10, - discount_rate: 1.0, - capacity_to_activity: 1.0, - }, - ), - ] - .into_iter() - .collect(); - let actual = - read_process_parameters_from_iter(params_raw.into_iter(), &process_ids, &year_range) - .unwrap(); - assert_eq!(expected, actual); - } - - #[test] - fn test_read_process_parameters_from_iter_bad_multiple_params() { - let year_range = 2000..=2100; - let process_ids = ["A".into(), "B".into()].into_iter().collect(); - - let params_raw = [ - ProcessParameterRaw { - process_id: "A".into(), - start_year: Some(2010), - end_year: Some(2020), - capital_cost: 1.0, - fixed_operating_cost: 1.0, - variable_operating_cost: 1.0, - lifetime: 10, - discount_rate: Some(1.0), - capacity_to_activity: Some(1.0), - }, - ProcessParameterRaw { - process_id: "B".into(), - start_year: Some(2015), - end_year: Some(2020), - capital_cost: 1.0, - fixed_operating_cost: 1.0, - variable_operating_cost: 1.0, - lifetime: 10, - discount_rate: Some(1.0), - capacity_to_activity: Some(1.0), - }, - ProcessParameterRaw { - process_id: "A".into(), - start_year: Some(2015), - end_year: Some(2020), - capital_cost: 1.0, - fixed_operating_cost: 1.0, - variable_operating_cost: 1.0, - lifetime: 10, - discount_rate: Some(1.0), - capacity_to_activity: Some(1.0), - }, - ]; - - assert!(read_process_parameters_from_iter( - params_raw.into_iter(), - &process_ids, - &year_range - ) - .is_err()); + assert!(create_param_raw(0, Some(1.0), Some(-1.0)) + .validate() + .is_err()); } } diff --git a/src/lib.rs b/src/lib.rs index 2290ab0d5..76a9757e1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,3 +14,4 @@ pub mod region; pub mod settings; pub mod simulation; pub mod time_slice; +pub mod year; diff --git a/src/output.rs b/src/output.rs index 66d3a0438..62722acfd 100644 --- a/src/output.rs +++ b/src/output.rs @@ -181,7 +181,7 @@ impl DataWriter { #[cfg(test)] mod tests { use super::*; - use crate::process::{Process, ProcessParameter}; + use crate::process::{Process, ProcessParameterMap}; use crate::region::RegionSelection; use crate::time_slice::TimeSliceID; use itertools::{assert_equal, Itertools}; @@ -194,21 +194,13 @@ mod tests { let region_id = "GBR".into(); let agent_id = "agent1".into(); let commission_year = 2015; - let process_param = ProcessParameter { - years: 2010..=2020, - capital_cost: 5.0, - fixed_operating_cost: 2.0, - variable_operating_cost: 1.0, - lifetime: 5, - discount_rate: 0.9, - capacity_to_activity: 3.0, - }; let process = Rc::new(Process { id: process_id, description: "Description".into(), + years: 2010..=2020, energy_limits: HashMap::new(), flows: vec![], - parameter: process_param.clone(), + parameters: ProcessParameterMap::new(), regions: RegionSelection::All, }); diff --git a/src/process.rs b/src/process.rs index 9bddf2803..92d78d629 100644 --- a/src/process.rs +++ b/src/process.rs @@ -23,12 +23,14 @@ pub struct Process { pub id: ProcessID, /// A human-readable description for the process (e.g. dry gas extraction) pub description: String, + /// The years in which this process is available for investment + pub years: RangeInclusive, /// Limits on PAC energy consumption/production for each time slice (as a fraction of maximum) pub energy_limits: EnergyLimitsMap, /// Maximum annual commodity flows for this process pub flows: Vec, /// Additional parameters for this process - pub parameter: ProcessParameter, + pub parameters: ProcessParameterMap, /// The regions in which this process can operate pub regions: RegionSelection, } @@ -58,6 +60,9 @@ impl Process { /// availability. pub type EnergyLimitsMap = HashMap>; +/// A map of [`ProcessParameter`]s, keyed by year +pub type ProcessParameterMap = HashMap; + /// Represents a maximum annual commodity flow for a given process #[derive(PartialEq, Debug, Deserialize, Clone)] pub struct ProcessFlow { @@ -97,8 +102,6 @@ pub enum FlowType { /// Additional parameters for a process #[derive(PartialEq, Clone, Debug, Deserialize)] pub struct ProcessParameter { - /// The years in which this process is available for investment - pub years: RangeInclusive, /// Overnight capital cost per unit capacity pub capital_cost: f64, /// Annual operating cost per unit capacity diff --git a/src/simulation/optimisation.rs b/src/simulation/optimisation.rs index 020fe948d..b9c33ee8b 100644 --- a/src/simulation/optimisation.rs +++ b/src/simulation/optimisation.rs @@ -229,7 +229,12 @@ fn calculate_cost_coefficient( // Only applies if commodity is PAC if flow.is_pac { - coeff += asset.process.parameter.variable_operating_cost + coeff += asset + .process + .parameters + .get(&asset.commission_year) + .unwrap() + .variable_operating_cost } // If there is a user-provided cost for this commodity, include it @@ -263,7 +268,9 @@ fn calculate_cost_coefficient( mod tests { use super::*; use crate::commodity::{Commodity, CommodityCost, CommodityCostMap, CommodityType, DemandMap}; - use crate::process::{EnergyLimitsMap, FlowType, Process, ProcessParameter}; + use crate::process::{ + EnergyLimitsMap, FlowType, Process, ProcessParameter, ProcessParameterMap, + }; use crate::region::RegionSelection; use crate::time_slice::TimeSliceLevel; use float_cmp::assert_approx_eq; @@ -275,7 +282,6 @@ mod tests { costs: CommodityCostMap, ) -> (Asset, ProcessFlow) { let process_param = ProcessParameter { - years: 2010..=2020, capital_cost: 5.0, fixed_operating_cost: 2.0, variable_operating_cost: 1.0, @@ -283,6 +289,9 @@ mod tests { discount_rate: 0.9, capacity_to_activity: 1.0, }; + let mut process_parameter_map = ProcessParameterMap::new(); + process_parameter_map.insert(2010, process_param.clone()); + process_parameter_map.insert(2020, process_param.clone()); let commodity = Rc::new(Commodity { id: "commodity1".into(), description: "Some description".into(), @@ -302,9 +311,10 @@ mod tests { let process = Rc::new(Process { id: "process1".into(), description: "Description".into(), + years: 2010..=2020, energy_limits: EnergyLimitsMap::new(), flows: vec![flow.clone()], - parameter: process_param.clone(), + parameters: process_parameter_map, regions: RegionSelection::All, }); let asset = Asset::new( diff --git a/src/year.rs b/src/year.rs new file mode 100644 index 000000000..99f3b0dc1 --- /dev/null +++ b/src/year.rs @@ -0,0 +1,26 @@ +//! Code for working with years. +use anyhow::{ensure, Result}; + +/// Parse a string of years separated by semicolons into a vector of u32 years. +/// +/// The string can be either "all" (case-insensitive), a single year, or a semicolon-separated list +/// of years (e.g. "2020;2021;2022" or "2020; 2021; 2022") +pub fn parse_year_str(s: &str, milestone_years: &[u32]) -> Result> { + let s = s.trim(); + ensure!(!s.is_empty(), "No years provided"); + + if s.eq_ignore_ascii_case("all") { + return Ok(Vec::from_iter(milestone_years.iter().copied())); + } + + s.split(";") + .map(|y| { + let year = y.trim().parse::()?; + ensure!( + milestone_years.binary_search(&year).is_ok(), + "Invalid year {year}" + ); + Ok(year) + }) + .collect() +}