diff --git a/examples/simple/agent_commodities.csv b/examples/simple/agent_commodities.csv new file mode 100644 index 000000000..8017d42a9 --- /dev/null +++ b/examples/simple/agent_commodities.csv @@ -0,0 +1,9 @@ +agent_id,commodity_id,year,commodity_portion +A0_GEX,GASPRD,2020,1 +A0_GEX,GASPRD,2030,1 +A0_GPR,GASNAT,2020,1 +A0_GPR,GASNAT,2030,1 +A0_ELC,ELCTRI,2020,1 +A0_ELC,ELCTRI,2030,1 +A0_RES,RSHEAT,2020,1 +A0_RES,RSHEAT,2030,1 diff --git a/examples/simple/agents.csv b/examples/simple/agents.csv index 8114bb9f2..afde616f2 100644 --- a/examples/simple/agents.csv +++ b/examples/simple/agents.csv @@ -1,5 +1,5 @@ -id,description,commodity_id,commodity_portion,search_space,decision_rule,capex_limit,annual_cost_limit,decision_lexico_tolerance -A0_GEX,Gas extractors,GASPRD,1,,single,,, -A0_GPR,Gas processors,GASNAT,1,,single,,, -A0_ELC,Electricity generators,ELCTRI,1,,single,,, -A0_RES,Residential consumer,RSHEAT,1,,single,,, +id,description,decision_rule,capex_limit,annual_cost_limit,decision_lexico_tolerance +A0_GEX,Gas extractors,single,,, +A0_GPR,Gas processors,single,,, +A0_ELC,Electricity generators,single,,, +A0_RES,Residential consumer,single,,, diff --git a/src/agent.rs b/src/agent.rs index 47348543c..55766e04f 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -18,10 +18,8 @@ pub struct Agent { pub id: Rc, /// A text description of the agent. pub description: String, - /// The commodity that the agent produces (could be a service demand too). - pub commodity: Rc, - /// The proportion of the commodity production that the agent is responsible for. - pub commodity_portion: f64, + /// The commodities that the agent is responsible for servicing. + pub commodities: Vec, /// The processes that the agent will consider investing in. pub search_space: Vec, /// The decision rule that the agent uses to decide investment. @@ -87,6 +85,17 @@ pub struct AgentObjective { pub decision_lexico_order: Option, } +/// A commodity that the agent is responsible for servicing, with associated commodity portion +#[derive(Debug, Clone, Deserialize, PartialEq)] +pub struct AgentCommodity { + /// The year the commodity portion applies to. + pub year: u32, + /// The commodity that the agent is responsible for servicing. + pub commodity: Rc, + /// The proportion of the commodity production that the agent is responsible for. + pub commodity_portion: f64, +} + /// The type of objective for the agent /// /// **TODO** Add more objective types diff --git a/src/input/agent.rs b/src/input/agent.rs index 984dc5dbb..354575151 100644 --- a/src/input/agent.rs +++ b/src/input/agent.rs @@ -16,6 +16,8 @@ pub mod region; use region::read_agent_regions; pub mod search_space; use search_space::read_agent_search_space; +pub mod commodity; +use commodity::read_agent_commodities; const AGENT_FILE_NAME: &str = "agents.csv"; @@ -26,11 +28,6 @@ struct AgentRaw { id: Rc, /// A text description of the agent. description: String, - /// The commodity that the agent produces (could be a service demand too). - commodity_id: String, - /// The proportion of the commodity production that the agent is responsible for. - #[serde(deserialize_with = "deserialise_proportion_nonzero")] - commodity_portion: f64, /// The decision rule that the agent uses to decide investment. decision_rule: String, /// The maximum capital cost the agent will pay. @@ -61,7 +58,7 @@ pub fn read_agents( milestone_years: &[u32], ) -> Result { let process_ids = processes.keys().cloned().collect(); - let mut agents = read_agents_file(model_dir, commodities)?; + let mut agents = read_agents_file(model_dir)?; let agent_ids = agents.keys().cloned().collect(); let mut agent_regions = read_agent_regions(model_dir, &agent_ids, region_ids)?; @@ -73,6 +70,8 @@ pub fn read_agents( commodities, milestone_years, )?; + let mut agent_commodities = + read_agent_commodities(model_dir, &agents, commodities, region_ids, milestone_years)?; for (id, agent) in agents.iter_mut() { agent.regions = agent_regions.remove(id).unwrap(); @@ -80,6 +79,7 @@ pub fn read_agents( if let Some(search_space) = search_spaces.remove(id) { agent.search_space = search_space; } + agent.commodities = agent_commodities.remove(id).unwrap(); } Ok(agents) @@ -96,23 +96,19 @@ pub fn read_agents( /// # Returns /// /// A map of Agents, with the agent ID as the key -pub fn read_agents_file(model_dir: &Path, commodities: &CommodityMap) -> Result { +pub fn read_agents_file(model_dir: &Path) -> Result { let file_path = model_dir.join(AGENT_FILE_NAME); let agents_csv = read_csv(&file_path)?; - read_agents_file_from_iter(agents_csv, commodities).with_context(|| input_err_msg(&file_path)) + read_agents_file_from_iter(agents_csv).with_context(|| input_err_msg(&file_path)) } /// Read agents info from an iterator. -fn read_agents_file_from_iter(iter: I, commodities: &CommodityMap) -> Result +fn read_agents_file_from_iter(iter: I) -> Result where I: Iterator, { let mut agents = AgentMap::new(); for agent_raw in iter { - let commodity = commodities - .get(agent_raw.commodity_id.as_str()) - .context("Invalid commodity ID")?; - // Parse decision rule let decision_rule = match agent_raw.decision_rule.to_ascii_lowercase().as_str() { "single" => DecisionRule::Single, @@ -134,8 +130,7 @@ where let agent = Agent { id: Rc::clone(&agent_raw.id), description: agent_raw.description, - commodity: Rc::clone(commodity), - commodity_portion: agent_raw.commodity_portion, + commodities: Vec::new(), search_space: Vec::new(), decision_rule, capex_limit: agent_raw.capex_limit, @@ -157,29 +152,15 @@ where mod tests { use super::*; use crate::agent::DecisionRule; - use crate::commodity::{Commodity, CommodityCostMap, CommodityType, DemandMap}; use crate::region::RegionSelection; - use crate::time_slice::TimeSliceLevel; use std::iter; #[test] fn test_read_agents_file_from_iter() { - let commodity = Rc::new(Commodity { - id: "commodity1".into(), - description: "A commodity".into(), - kind: CommodityType::SupplyEqualsDemand, - time_slice_level: TimeSliceLevel::Annual, - costs: CommodityCostMap::new(), - demand: DemandMap::new(), - }); - let commodities = iter::once(("commodity1".into(), Rc::clone(&commodity))).collect(); - // Valid case let agent = AgentRaw { id: "agent".into(), description: "".into(), - commodity_id: "commodity1".into(), - commodity_portion: 1.0, decision_rule: "single".into(), capex_limit: None, annual_cost_limit: None, @@ -188,8 +169,7 @@ mod tests { let agent_out = Agent { id: "agent".into(), description: "".into(), - commodity, - commodity_portion: 1.0, + commodities: Vec::new(), search_space: Vec::new(), decision_rule: DecisionRule::Single, capex_limit: None, @@ -198,29 +178,14 @@ mod tests { objectives: Vec::new(), }; let expected = AgentMap::from_iter(iter::once(("agent".into(), agent_out))); - let actual = read_agents_file_from_iter(iter::once(agent), &commodities).unwrap(); + let actual = read_agents_file_from_iter(iter::once(agent)).unwrap(); assert_eq!(actual, expected); - // Invalid commodity ID - let agent = AgentRaw { - id: "agent".into(), - description: "".into(), - commodity_id: "made_up_commodity".into(), - commodity_portion: 1.0, - decision_rule: "single".into(), - capex_limit: None, - annual_cost_limit: None, - decision_lexico_tolerance: None, - }; - assert!(read_agents_file_from_iter(iter::once(agent), &commodities).is_err()); - // Duplicate agent ID let agents = [ AgentRaw { id: "agent".into(), description: "".into(), - commodity_id: "commodity1".into(), - commodity_portion: 1.0, decision_rule: "single".into(), capex_limit: None, annual_cost_limit: None, @@ -229,27 +194,23 @@ mod tests { AgentRaw { id: "agent".into(), description: "".into(), - commodity_id: "commodity1".into(), - commodity_portion: 1.0, decision_rule: "single".into(), capex_limit: None, annual_cost_limit: None, decision_lexico_tolerance: None, }, ]; - assert!(read_agents_file_from_iter(agents.into_iter(), &commodities).is_err()); + assert!(read_agents_file_from_iter(agents.into_iter()).is_err()); // Lexico tolerance missing for lexico decision rule let agent = AgentRaw { id: "agent".into(), description: "".into(), - commodity_id: "commodity1".into(), - commodity_portion: 1.0, decision_rule: "lexico".into(), capex_limit: None, annual_cost_limit: None, decision_lexico_tolerance: None, }; - assert!(read_agents_file_from_iter(iter::once(agent), &commodities).is_err()); + assert!(read_agents_file_from_iter(iter::once(agent)).is_err()); } } diff --git a/src/input/agent/commodity.rs b/src/input/agent/commodity.rs new file mode 100644 index 000000000..bf0424ffe --- /dev/null +++ b/src/input/agent/commodity.rs @@ -0,0 +1,346 @@ +//! Code for reading the agent commodities CSV file. +use super::super::*; +use crate::agent::{AgentCommodity, AgentMap}; +use crate::commodity::{CommodityMap, CommodityType}; +use anyhow::{ensure, Context, Result}; +use serde::Deserialize; +use std::collections::HashMap; +use std::path::Path; +use std::rc::Rc; + +const AGENT_COMMODITIES_FILE_NAME: &str = "agent_commodities.csv"; + +#[derive(PartialEq, Debug, Deserialize)] +struct AgentCommodityRaw { + /// Unique agent id identifying the agent. + pub agent_id: String, + /// The commodity that the agent is responsible for. + pub commodity_id: String, + /// The year the commodity portion applies to. + pub year: u32, + /// The proportion of the commodity production that the agent is responsible for. + #[serde(deserialize_with = "deserialise_proportion_nonzero")] + pub commodity_portion: f64, +} + +impl AgentCommodityRaw { + pub fn to_agent_commodity( + &self, + commodities: &CommodityMap, + milestone_years: &[u32], + ) -> Result { + // Get commodity + let commodity = commodities + .get(self.commodity_id.as_str()) + .context("Invalid commodity ID")?; + + // Check that the year is a valid milestone year + ensure!( + milestone_years.binary_search(&self.year).is_ok(), + "Invalid milestone year {}", + self.year + ); + + // Create AgentCommodity + Ok(AgentCommodity { + year: self.year, + commodity: Rc::clone(commodity), + commodity_portion: self.commodity_portion, + }) + } +} + +/// Read agent objective info from the agent_commodities.csv file. +/// +/// # Arguments +/// +/// * `model_dir` - Folder containing model configuration files +/// +/// # Returns +/// +/// A map of Agents, with the agent ID as the key +pub fn read_agent_commodities( + model_dir: &Path, + agents: &AgentMap, + commodities: &CommodityMap, + region_ids: &HashSet>, + milestone_years: &[u32], +) -> Result, Vec>> { + let file_path = model_dir.join(AGENT_COMMODITIES_FILE_NAME); + let agent_commodities_csv = read_csv(&file_path)?; + read_agent_commodities_from_iter( + agent_commodities_csv, + agents, + commodities, + region_ids, + milestone_years, + ) + .with_context(|| input_err_msg(&file_path)) +} + +fn read_agent_commodities_from_iter( + iter: I, + agents: &AgentMap, + commodities: &CommodityMap, + region_ids: &HashSet>, + milestone_years: &[u32], +) -> Result, Vec>> +where + I: Iterator, +{ + let mut agent_commodities = HashMap::new(); + for agent_commodity_raw in iter { + let agent_commodity = + agent_commodity_raw.to_agent_commodity(commodities, milestone_years)?; + + let (id, _agent) = agents + .get_key_value(agent_commodity_raw.agent_id.as_str()) + .context("Invalid agent ID")?; + + // Append to Vec with the corresponding key or create + agent_commodities + .entry(Rc::clone(id)) + .or_insert_with(|| Vec::with_capacity(1)) + .push(agent_commodity); + } + + validate_agent_commodities( + &agent_commodities, + agents, + commodities, + region_ids, + milestone_years, + )?; + + Ok(agent_commodities) +} + +fn validate_agent_commodities( + agent_commodities: &HashMap, Vec>, + agents: &AgentMap, + commodities: &CommodityMap, + region_ids: &HashSet>, + milestone_years: &[u32], +) -> Result<()> { + // CHECK 1: For each agent there must be at least one commodity for all years + for (id, agent_commodities) in agent_commodities.iter() { + let mut years = HashSet::new(); + for agent_commodity in agent_commodities { + years.insert(agent_commodity.year); + } + for year in milestone_years { + ensure!( + years.contains(year), + "Agent {} does not have a commodity for year {}", + id, + year + ); + } + } + + // CHECK 2: Total portions for each commodity/year/region must sum to 1 + // First step is to create a map with the key as (commodity_id, year, region_id), and the value + // as the sum of the portions for that key across all agents + let mut summed_portions = HashMap::new(); + for (id, agent_commodities) in agent_commodities.iter() { + let agent = agents.get(id).context("Invalid agent ID")?; + for agent_commodity in agent_commodities { + let commodity_id = agent_commodity.commodity.get_id(); + let portion = agent_commodity.commodity_portion; + for region in region_ids { + if agent.regions.contains(region) { + let key = (commodity_id, agent_commodity.year, region); + summed_portions + .entry(key) + .and_modify(|v| *v += portion) + .or_insert(portion); + } + } + } + } + + // We then check the map to ensure values for each key are 1 + for (key, portion) in summed_portions.iter() { + ensure!( + approx_eq!(f64, *portion, 1.0, epsilon = 1e-5), + "Commodity {} in year {} and region {} does not sum to 1.0", + key.0, + key.1, + key.2 + ); + } + + // CHECK 3: All commodities of SVD or SED type must be covered for all regions and years + // This checks the same summed_portions map as above, just checking the keys + // We first need to create a list of SVD and SED commodities to check against + let svd_and_sed_commodities = commodities + .iter() + .filter(|(_, commodity)| { + matches!( + commodity.kind, + CommodityType::SupplyEqualsDemand | CommodityType::ServiceDemand + ) + }) + .map(|(id, _)| Rc::clone(id)); + + // Check that summed_portions contains all SVD/SED commodities for all regions and milestone + // years + for commodity_id in svd_and_sed_commodities { + for year in milestone_years { + for region in region_ids { + let key = (&*commodity_id, *year, region); + ensure!( + summed_portions.contains_key(&key), + "Commodity {} in year {} and region {} is not covered", + commodity_id, + year, + region + ); + } + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agent::{Agent, DecisionRule}; + use crate::commodity::{Commodity, CommodityCostMap, CommodityType, DemandMap}; + use crate::region::RegionSelection; + use crate::time_slice::TimeSliceLevel; + + use std::iter; + + #[test] + fn test_agent_commodity_raw_to_agent_commodity() { + let milestone_years = vec![2020, 2021, 2022]; + let commodity = Rc::new(Commodity { + id: "commodity1".into(), + description: "A commodity".into(), + kind: CommodityType::SupplyEqualsDemand, + time_slice_level: TimeSliceLevel::Annual, + costs: CommodityCostMap::new(), + demand: DemandMap::new(), + }); + let commodities = iter::once(("commodity1".into(), Rc::clone(&commodity))).collect(); + + // Valid case + let raw = AgentCommodityRaw { + agent_id: "agent1".into(), + commodity_id: "commodity1".into(), + year: 2020, + commodity_portion: 1.0, + }; + assert!(raw + .to_agent_commodity(&commodities, &milestone_years) + .is_ok()); + + // Invalid case: year not in milestone years + let raw = AgentCommodityRaw { + agent_id: "agent1".into(), + commodity_id: "commodity1".into(), + year: 2019, + commodity_portion: 1.0, + }; + assert!(raw + .to_agent_commodity(&commodities, &milestone_years) + .is_err()); + + // Invalid case: invalid commodity ID + let raw = AgentCommodityRaw { + agent_id: "agent1".into(), + commodity_id: "invalid_commodity".into(), + year: 2020, + commodity_portion: 1.0, + }; + assert!(raw + .to_agent_commodity(&commodities, &milestone_years) + .is_err()); + } + + #[test] + fn test_validate_agent_commodities() { + let agents = IndexMap::from([( + Rc::from("agent1"), + Agent { + id: Rc::from("agent1"), + description: "An agent".into(), + commodities: Vec::new(), + search_space: Vec::new(), + decision_rule: DecisionRule::Single, + capex_limit: None, + annual_cost_limit: None, + regions: RegionSelection::default(), + objectives: Vec::new(), + }, + )]); + let mut commodities = IndexMap::from([( + Rc::from("commodity1"), + Rc::new(Commodity { + id: "commodity1".into(), + description: "A commodity".into(), + kind: CommodityType::SupplyEqualsDemand, + time_slice_level: TimeSliceLevel::Annual, + costs: CommodityCostMap::new(), + demand: DemandMap::new(), + }), + )]); + let region_ids = HashSet::from([Rc::from("region1")]); + let milestone_years = vec![2020]; + + // Valid case + let agent_commodity = AgentCommodity { + year: 2020, + commodity: Rc::clone(commodities.get("commodity1").unwrap()), + commodity_portion: 1.0, + }; + let agent_commodities = HashMap::from([(Rc::from("agent1"), vec![agent_commodity])]); + assert!(validate_agent_commodities( + &agent_commodities, + &agents, + &commodities, + ®ion_ids, + &milestone_years + ) + .is_ok()); + + // Invalid case: portions do not sum to 1 + let agent_commodity_v2 = AgentCommodity { + year: 2020, + commodity: Rc::clone(commodities.get("commodity1").unwrap()), + commodity_portion: 0.5, + }; + let agent_commodities_v2 = HashMap::from([(Rc::from("agent1"), vec![agent_commodity_v2])]); + assert!(validate_agent_commodities( + &agent_commodities_v2, + &agents, + &commodities, + ®ion_ids, + &milestone_years + ) + .is_err()); + + // Invalid case: SED commodity without associated commodity portions + commodities.insert( + Rc::from("commodity2"), + Rc::new(Commodity { + id: "commodity2".into(), + description: "Another commodity".into(), + kind: CommodityType::SupplyEqualsDemand, + time_slice_level: TimeSliceLevel::Annual, + costs: CommodityCostMap::new(), + demand: DemandMap::new(), + }), + ); + assert!(validate_agent_commodities( + &agent_commodities, + &agents, + &commodities, + ®ion_ids, + &milestone_years + ) + .is_err()); + } +} diff --git a/src/input/agent/objective.rs b/src/input/agent/objective.rs index a0f160f46..8a071226c 100644 --- a/src/input/agent/objective.rs +++ b/src/input/agent/objective.rs @@ -173,9 +173,7 @@ fn check_agent_objectives( mod tests { use super::*; use crate::agent::ObjectiveType; - use crate::commodity::{Commodity, CommodityCostMap, CommodityType, DemandMap}; use crate::region::RegionSelection; - use crate::time_slice::TimeSliceLevel; #[test] fn test_check_objective_parameter() { @@ -221,21 +219,12 @@ mod tests { #[test] fn test_read_agent_objectives_from_iter() { - let commodity = Rc::new(Commodity { - id: "commodity1".into(), - description: "A commodity".into(), - kind: CommodityType::SupplyEqualsDemand, - time_slice_level: TimeSliceLevel::Annual, - costs: CommodityCostMap::new(), - demand: DemandMap::new(), - }); let agents = [( "agent".into(), Agent { id: "agent".into(), description: "".into(), - commodity, - commodity_portion: 1.0, + commodities: Vec::new(), search_space: Vec::new(), decision_rule: DecisionRule::Single, capex_limit: None,