diff --git a/docs/dispatch_optimisation.md b/docs/dispatch_optimisation.md index 8927fa972..bbb6c5eee 100644 --- a/docs/dispatch_optimisation.md +++ b/docs/dispatch_optimisation.md @@ -52,9 +52,6 @@ for all commodity flows that the process has (except *pac1*). Where *pac1* is th primary activity commodity for the asset (i.e. all input and output flows are made proportional to *pac1* flow). -Note – need to handle cases where a flow is set to zero in the input data – should raise a -warning that the value has been ignored, specifying which region/asset/commodity. - **TBD** - cases where time slice level of the commodity is seasonal or annual. ### Commodity-flexible assets diff --git a/src/input/process.rs b/src/input/process.rs index ee26e15a9..199c08d44 100644 --- a/src/input/process.rs +++ b/src/input/process.rs @@ -1,7 +1,8 @@ //! Code for reading process-related information from CSV files. use crate::commodity::Commodity; use crate::input::*; -use crate::process::Process; +use crate::process::{Process, ProcessAvailability, ProcessFlow, ProcessParameter}; +use crate::region::RegionSelection; use crate::time_slice::TimeSliceInfo; use anyhow::Result; use serde::Deserialize; @@ -40,6 +41,9 @@ struct ProcessDescription { } define_id_getter! {ProcessDescription} +/// A map of process-related data structures, grouped by process ID +type GroupedMap = HashMap, Vec>; + /// Read process information from the specified CSV files. /// /// # Arguments @@ -61,36 +65,204 @@ pub fn read_processes( year_range: &RangeInclusive, ) -> Result, Rc>> { let file_path = model_dir.join(PROCESSES_FILE_NAME); - let mut descriptions = read_csv_id_file::(&file_path)?; + let descriptions = read_csv_id_file::(&file_path)?; let process_ids = HashSet::from_iter(descriptions.keys().cloned()); - let mut availabilities = read_process_availabilities(model_dir, &process_ids, time_slice_info)?; - let mut flows = read_process_flows(model_dir, &process_ids, commodities)?; - let mut pacs = read_process_pacs(model_dir, &process_ids, commodities, &flows)?; - let mut parameters = read_process_parameters(model_dir, &process_ids, year_range)?; - let mut regions = read_process_regions(model_dir, &process_ids, region_ids)?; + let availabilities = read_process_availabilities(model_dir, &process_ids, time_slice_info)?; + let flows = read_process_flows(model_dir, &process_ids, commodities)?; + let pacs = read_process_pacs(model_dir, &process_ids, commodities, &flows)?; + let parameters = read_process_parameters(model_dir, &process_ids, year_range)?; + let regions = read_process_regions(model_dir, &process_ids, region_ids)?; + + create_process_map( + descriptions.into_values(), + availabilities, + flows, + pacs, + parameters, + regions, + ) +} + +fn create_process_map( + descriptions: I, + availabilities: GroupedMap, + flows: GroupedMap, + pacs: GroupedMap>, + parameters: HashMap, ProcessParameter>, + regions: HashMap, RegionSelection>, +) -> Result, Rc>> +where + I: Iterator, +{ + // Need to be mutable as we remove elements as we go along + let mut availabilities = availabilities; + let mut flows = flows; + let mut pacs = pacs; + let mut parameters = parameters; + let mut regions = regions; - Ok(process_ids - .into_iter() - .map(|id| { - // We know entry is present - let desc = descriptions.remove(&id).unwrap(); + 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 pacs = pacs + .remove(id) + .with_context(|| format!("No PACs defined for process {id}"))?; + let parameter = parameters + .remove(id) + .with_context(|| format!("No parameters defined for process {id}"))?; - // We've already checked that these exist for each process - let parameter = parameters.remove(&id).unwrap(); - let regions = regions.remove(&id).unwrap(); + // We've already checked that regions are defined for each process + let regions = regions.remove(id).unwrap(); let process = Process { - id: desc.id, - description: desc.description, - availabilities: availabilities.remove(&id).unwrap_or_default(), - flows: flows.remove(&id).unwrap_or_default(), - pacs: pacs.remove(&id).unwrap_or_default(), + id: Rc::clone(id), + description: description.description, + availabilities, + flows, + pacs, parameter, regions, }; - (id, process.into()) + Ok((description.id, process.into())) }) - .collect()) + .process_results(|iter| iter.collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + + struct ProcessData { + descriptions: Vec, + availabilities: GroupedMap, + flows: GroupedMap, + pacs: GroupedMap>, + parameters: HashMap, ProcessParameter>, + regions: HashMap, RegionSelection>, + } + + /// Returns example data (without errors) for processes + fn get_process_data() -> ProcessData { + let descriptions = vec![ + ProcessDescription { + id: Rc::from("process1"), + description: "Process 1".to_string(), + }, + ProcessDescription { + id: Rc::from("process2"), + description: "Process 2".to_string(), + }, + ]; + + let availabilities = ["process1", "process2"] + .into_iter() + .map(|id| (id.into(), vec![])) + .collect(); + + let flows = ["process1", "process2"] + .into_iter() + .map(|id| (id.into(), vec![])) + .collect(); + + let pacs = ["process1", "process2"] + .into_iter() + .map(|id| (id.into(), vec![])) + .collect(); + + let parameters = ["process1", "process2"] + .into_iter() + .map(|id| { + let parameter = ProcessParameter { + process_id: id.to_string(), + years: 2010..=2020, + capital_cost: 0.0, + fixed_operating_cost: 0.0, + variable_operating_cost: 0.0, + lifetime: 1, + discount_rate: 1.0, + cap2act: 0.0, + }; + + (id.into(), parameter) + }) + .collect(); + + let regions = ["process1", "process2"] + .into_iter() + .map(|id| (id.into(), RegionSelection::All)) + .collect(); + + ProcessData { + descriptions, + availabilities, + flows, + pacs, + parameters, + regions, + } + } + + #[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.pacs, + 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.pacs, + 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_pacs() { + test_missing!(pacs); + } + + #[test] + fn test_create_process_map_missing_flows() { + test_missing!(flows); + } + + #[test] + fn test_create_process_map_missing_parameters() { + test_missing!(parameters); + } } diff --git a/src/input/process/availability.rs b/src/input/process/availability.rs index 63dd828ae..d678a39df 100644 --- a/src/input/process/availability.rs +++ b/src/input/process/availability.rs @@ -3,7 +3,7 @@ use super::define_process_id_getter; use crate::input::*; use crate::process::{LimitType, ProcessAvailability}; use crate::time_slice::TimeSliceInfo; -use anyhow::{ensure, Context, Result}; +use anyhow::{Context, Result}; use itertools::Itertools; use serde::Deserialize; use std::collections::{HashMap, HashSet}; @@ -44,23 +44,15 @@ fn read_process_availabilities_from_iter( where I: Iterator, { - let availabilities = iter - .map(|record| -> Result<_> { - let time_slice = time_slice_info.get_selection(&record.time_slice)?; - - Ok(ProcessAvailability { - process_id: record.process_id, - limit_type: record.limit_type, - time_slice, - value: record.value, - }) + iter.map(|record| -> Result<_> { + let time_slice = time_slice_info.get_selection(&record.time_slice)?; + + Ok(ProcessAvailability { + process_id: record.process_id, + limit_type: record.limit_type, + time_slice, + value: record.value, }) - .process_results(|iter| iter.into_id_map(process_ids))??; - - ensure!( - availabilities.len() >= process_ids.len(), - "Every process must have at least one availability period" - ); - - Ok(availabilities) + }) + .process_results(|iter| iter.into_id_map(process_ids))? } diff --git a/src/input/process/flow.rs b/src/input/process/flow.rs index cdc93374e..b8c118fb4 100644 --- a/src/input/process/flow.rs +++ b/src/input/process/flow.rs @@ -3,7 +3,7 @@ use super::define_process_id_getter; use crate::commodity::Commodity; use crate::input::*; use crate::process::{FlowType, ProcessFlow}; -use anyhow::{Context, Result}; +use anyhow::{ensure, Context, Result}; use itertools::Itertools; use serde::Deserialize; use std::collections::{HashMap, HashSet}; @@ -46,17 +46,39 @@ fn read_process_flows_from_iter( where I: Iterator, { - iter.map(|flow_raw| -> Result { + iter.map(|flow| -> Result { let commodity = commodities - .get(flow_raw.commodity_id.as_str()) - .with_context(|| format!("{} is not a valid commodity ID", &flow_raw.commodity_id))?; + .get(flow.commodity_id.as_str()) + .with_context(|| format!("{} is not a valid commodity ID", &flow.commodity_id))?; + + ensure!(flow.flow != 0.0, "Flow cannot be zero"); + + // Check that flow is not infinity, nan, etc. + ensure!( + flow.flow.is_normal(), + "Invalid value for flow ({})", + flow.flow + ); + + // **TODO**: https://github.com/EnergySystemsModellingLab/MUSE_2.0/issues/300 + ensure!( + flow.flow_type == FlowType::Fixed, + "Commodity flexible assets are not currently supported" + ); + + if let Some(flow_cost) = flow.flow_cost { + ensure!( + (0.0..f64::INFINITY).contains(&flow_cost), + "Invalid value for flow cost ({flow_cost}). Must be >=0." + ) + } Ok(ProcessFlow { - process_id: flow_raw.process_id, + process_id: flow.process_id, commodity: Rc::clone(commodity), - flow: flow_raw.flow, - flow_type: flow_raw.flow_type, - flow_cost: flow_raw.flow_cost.unwrap_or(0.0), + flow: flow.flow, + flow_type: flow.flow_type, + flow_cost: flow.flow_cost.unwrap_or(0.0), }) }) .process_results(|iter| iter.into_id_map(process_ids))? @@ -67,6 +89,7 @@ mod test { use super::*; use crate::commodity::{CommodityCostMap, CommodityType}; use crate::time_slice::TimeSliceLevel; + use std::iter; #[test] fn test_read_process_flows_from_iter_good() { @@ -190,4 +213,76 @@ mod test { .is_err() ); } + + #[test] + fn test_read_process_flows_from_iter_bad_flow() { + let process_ids = iter::once("id1".into()).collect(); + let commodities = iter::once(Commodity { + id: "commodity1".into(), + description: "Some description".into(), + kind: CommodityType::InputCommodity, + time_slice_level: TimeSliceLevel::Annual, + costs: CommodityCostMap::new(), + demand_by_region: HashMap::new(), + }) + .map(|c| (c.id.clone(), Rc::new(c))) + .collect(); + + macro_rules! check_bad_flow { + ($flow:expr) => { + let flow = ProcessFlowRaw { + process_id: "id1".into(), + commodity_id: "commodity1".into(), + flow: $flow, + flow_type: FlowType::Fixed, + flow_cost: Some(1.0), + }; + assert!( + read_process_flows_from_iter(iter::once(flow), &process_ids, &commodities) + .is_err() + ); + }; + } + + check_bad_flow!(0.0); + check_bad_flow!(f64::NEG_INFINITY); + check_bad_flow!(f64::INFINITY); + check_bad_flow!(f64::NAN); + } + + #[test] + fn test_read_process_flows_from_iter_flow_cost() { + let process_ids = iter::once("id1".into()).collect(); + let commodities = iter::once(Commodity { + id: "commodity1".into(), + description: "Some description".into(), + kind: CommodityType::InputCommodity, + time_slice_level: TimeSliceLevel::Annual, + costs: CommodityCostMap::new(), + demand_by_region: HashMap::new(), + }) + .map(|c| (c.id.clone(), Rc::new(c))) + .collect(); + + macro_rules! is_flow_cost_ok { + ($flow_cost:expr) => {{ + let flow = ProcessFlowRaw { + process_id: "id1".into(), + commodity_id: "commodity1".into(), + flow: 1.0, + flow_type: FlowType::Fixed, + flow_cost: Some($flow_cost), + }; + + read_process_flows_from_iter(iter::once(flow), &process_ids, &commodities).is_ok() + }}; + } + + assert!(is_flow_cost_ok!(0.0)); + assert!(is_flow_cost_ok!(1.0)); + assert!(is_flow_cost_ok!(100.0)); + assert!(!is_flow_cost_ok!(f64::NEG_INFINITY)); + assert!(!is_flow_cost_ok!(f64::INFINITY)); + assert!(!is_flow_cost_ok!(f64::NAN)); + } } diff --git a/src/input/process/parameter.rs b/src/input/process/parameter.rs index 3fdf51d48..d4bd0a2d0 100644 --- a/src/input/process/parameter.rs +++ b/src/input/process/parameter.rs @@ -134,10 +134,6 @@ where "More than one parameter provided for process {id}" ); } - ensure!( - params.len() == process_ids.len(), - "Each process must have an associated parameter" - ); Ok(params) } @@ -398,29 +394,4 @@ mod tests { ) .is_err()); } - - #[test] - fn test_read_process_parameters_from_iter_bad_process_missing_param() { - 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), - cap2act: Some(1.0), - }]; - - assert!(read_process_parameters_from_iter( - params_raw.into_iter(), - &process_ids, - &year_range - ) - .is_err()); - } }