From 8e20eba1ff00b9fe6f5bce0492e0d305831eef6b Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Thu, 24 Apr 2025 11:59:54 +0100 Subject: [PATCH 1/8] Minimal working version --- examples/simple/agent_cost_limits.csv | 5 ++ examples/simple/agents.csv | 10 ++-- src/agent.rs | 19 ++++-- src/input/agent.rs | 24 +++----- src/input/agent/commodity.rs | 7 +-- src/input/agent/cost_limit.rs | 86 +++++++++++++++++++++++++++ src/input/agent/objective.rs | 5 +- 7 files changed, 123 insertions(+), 33 deletions(-) create mode 100644 examples/simple/agent_cost_limits.csv create mode 100644 src/input/agent/cost_limit.rs diff --git a/examples/simple/agent_cost_limits.csv b/examples/simple/agent_cost_limits.csv new file mode 100644 index 000000000..4b1e98617 --- /dev/null +++ b/examples/simple/agent_cost_limits.csv @@ -0,0 +1,5 @@ +agent_id,capex_limit,annual_cost_limit,year +A0_GEX,,,all +A0_GPR,,,all +A0_ELC,,,all +A0_RES,,,all diff --git a/examples/simple/agents.csv b/examples/simple/agents.csv index afde616f2..34c21e44f 100644 --- a/examples/simple/agents.csv +++ b/examples/simple/agents.csv @@ -1,5 +1,5 @@ -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,,, +id,description,decision_rule,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 b0de8a60b..e12e00f7a 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -7,6 +7,7 @@ use crate::region::RegionSelection; use indexmap::IndexMap; use serde::Deserialize; use serde_string_enum::DeserializeLabeledStringEnum; +use std::collections::HashMap; use std::collections::HashSet; use std::rc::Rc; @@ -15,6 +16,9 @@ define_id_type! {AgentID} /// A map of [`Agent`]s, keyed by agent ID pub type AgentMap = IndexMap; +/// A map of cost limits for each agent, keyed by year +pub type CostLimitsMap = HashMap; + /// An agent in the simulation #[derive(Debug, Clone, PartialEq)] pub struct Agent { @@ -28,10 +32,8 @@ pub struct Agent { pub search_space: Vec, /// The decision rule that the agent uses to decide investment. pub decision_rule: DecisionRule, - /// The maximum capital cost the agent will pay. - pub capex_limit: Option, - /// The maximum annual operating cost (fuel plus var_opex etc) that the agent will pay. - pub annual_cost_limit: Option, + /// Cost limits (e.g. capital cost, annual operating cost) + pub cost_limits: CostLimitsMap, /// The regions in which this agent operates. pub regions: RegionSelection, /// The agent's objectives. @@ -39,6 +41,15 @@ pub struct Agent { } define_id_getter! {Agent, AgentID} +/// The cost limits for an agent in a particular year +#[derive(Debug, Clone, PartialEq)] +pub struct CostLimits { + /// The maximum capital cost the agent will pay. + pub capex_limit: Option, + /// The maximum annual operating cost (fuel plus var_opex etc) that the agent will pay. + pub annual_cost_limit: Option, +} + /// Which processes apply to this agent #[derive(Debug, Clone, PartialEq)] pub enum SearchSpace { diff --git a/src/input/agent.rs b/src/input/agent.rs index a5b7f95ce..0d8e98de2 100644 --- a/src/input/agent.rs +++ b/src/input/agent.rs @@ -1,6 +1,6 @@ //! Code for reading in agent-related data from CSV files. use super::*; -use crate::agent::{Agent, AgentID, AgentMap, DecisionRule}; +use crate::agent::{Agent, AgentID, AgentMap, CostLimitsMap, DecisionRule}; use crate::commodity::CommodityMap; use crate::process::ProcessMap; use crate::region::{RegionID, RegionSelection}; @@ -17,6 +17,8 @@ mod search_space; use search_space::read_agent_search_space; mod commodity; use commodity::read_agent_commodities; +mod cost_limit; +use cost_limit::read_agent_cost_limits; const AGENT_FILE_NAME: &str = "agents.csv"; @@ -29,10 +31,6 @@ struct AgentRaw { description: String, /// The decision rule that the agent uses to decide investment. decision_rule: String, - /// The maximum capital cost the agent will pay. - capex_limit: Option, - /// The maximum annual operating cost (fuel plus var_opex etc) that the agent will pay. - annual_cost_limit: Option, /// The tolerance around the main objective to consider secondary objectives. decision_lexico_tolerance: Option, } @@ -71,6 +69,7 @@ pub fn read_agents( )?; let mut agent_commodities = read_agent_commodities(model_dir, &agents, commodities, region_ids, milestone_years)?; + let mut cost_limts = read_agent_cost_limits(model_dir, &agents, milestone_years)?; for (id, agent) in agents.iter_mut() { agent.regions = agent_regions.remove(id).unwrap(); @@ -79,6 +78,7 @@ pub fn read_agents( agent.search_space = search_space; } agent.commodities = agent_commodities.remove(id).unwrap(); + agent.cost_limits = cost_limts.remove(id).unwrap_or_default(); } Ok(agents) @@ -132,8 +132,7 @@ where commodities: Vec::new(), search_space: Vec::new(), decision_rule, - capex_limit: agent_raw.capex_limit, - annual_cost_limit: agent_raw.annual_cost_limit, + cost_limits: CostLimitsMap::new(), regions: RegionSelection::default(), objectives: Vec::new(), }; @@ -161,8 +160,6 @@ mod tests { id: "agent".into(), description: "".into(), decision_rule: "single".into(), - capex_limit: None, - annual_cost_limit: None, decision_lexico_tolerance: None, }; let agent_out = Agent { @@ -171,8 +168,7 @@ mod tests { commodities: Vec::new(), search_space: Vec::new(), decision_rule: DecisionRule::Single, - capex_limit: None, - annual_cost_limit: None, + cost_limits: CostLimitsMap::new(), regions: RegionSelection::default(), objectives: Vec::new(), }; @@ -186,16 +182,12 @@ mod tests { id: "agent".into(), description: "".into(), decision_rule: "single".into(), - capex_limit: None, - annual_cost_limit: None, decision_lexico_tolerance: None, }, AgentRaw { id: "agent".into(), description: "".into(), decision_rule: "single".into(), - capex_limit: None, - annual_cost_limit: None, decision_lexico_tolerance: None, }, ]; @@ -206,8 +198,6 @@ mod tests { id: "agent".into(), description: "".into(), decision_rule: "lexico".into(), - capex_limit: None, - annual_cost_limit: None, decision_lexico_tolerance: None, }; 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 index 1e984e15c..60e04dc1f 100644 --- a/src/input/agent/commodity.rs +++ b/src/input/agent/commodity.rs @@ -51,7 +51,7 @@ impl AgentCommodityRaw { } } -/// Read agent objective info from the agent_commodities.csv file. +/// Read agent commodities info from the agent_commodities.csv file. /// /// # Arguments /// @@ -207,7 +207,7 @@ fn validate_agent_commodities( #[cfg(test)] mod tests { use super::*; - use crate::agent::{Agent, DecisionRule}; + use crate::agent::{Agent, CostLimitsMap, DecisionRule}; use crate::commodity::{Commodity, CommodityCostMap, CommodityID, CommodityType, DemandMap}; use crate::region::RegionSelection; use crate::time_slice::TimeSliceLevel; @@ -271,8 +271,7 @@ mod tests { commodities: Vec::new(), search_space: Vec::new(), decision_rule: DecisionRule::Single, - capex_limit: None, - annual_cost_limit: None, + cost_limits: CostLimitsMap::new(), regions: RegionSelection::default(), objectives: Vec::new(), }, diff --git a/src/input/agent/cost_limit.rs b/src/input/agent/cost_limit.rs new file mode 100644 index 000000000..9948dbc5a --- /dev/null +++ b/src/input/agent/cost_limit.rs @@ -0,0 +1,86 @@ +//! Code for reading the agent cost limits CSV file. +use super::super::*; +use crate::agent::{AgentID, AgentMap, CostLimits, CostLimitsMap}; +use crate::year::{deserialize_year, YearSelection}; +use anyhow::{Context, Result}; +use serde::Deserialize; +use std::collections::HashMap; +use std::path::Path; + +const AGENT_COST_LIMITS_FILE_NAME: &str = "agent_cost_limits.csv"; + +#[derive(PartialEq, Debug, Deserialize)] +struct AgentCostLimitRaw { + agent_id: String, + capex_limit: Option, + annual_cost_limit: Option, + #[serde(deserialize_with = "deserialize_year")] + year: YearSelection, +} + +impl AgentCostLimitRaw { + fn to_cost_limit(&self) -> Result { + Ok(CostLimits { + capex_limit: self.capex_limit, + annual_cost_limit: self.annual_cost_limit, + }) + } +} + +/// Read agent cost limits info from the agent_cost_limits.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_cost_limits( + model_dir: &Path, + agents: &AgentMap, + milestone_years: &[u32], +) -> Result> { + let file_path = model_dir.join(AGENT_COST_LIMITS_FILE_NAME); + let agent_cost_limits_csv = read_csv(&file_path)?; + read_agent_cost_limits_from_iter(agent_cost_limits_csv, agents, milestone_years) + .with_context(|| input_err_msg(&file_path)) +} + +fn read_agent_cost_limits_from_iter( + iter: I, + agents: &AgentMap, + milestone_years: &[u32], +) -> Result> +where + I: Iterator, +{ + let mut map: HashMap = HashMap::new(); + for agent_cost_limits_raw in iter { + let cost_limits = agent_cost_limits_raw.to_cost_limit()?; + let year = agent_cost_limits_raw.year; + + // Get agent ID + let (id, _agent) = agents + .get_key_value(agent_cost_limits_raw.agent_id.as_str()) + .context("Invalid agent ID")?; + + // Get or create entry in the map + let entry = map.entry(id.clone()).or_default(); + + // Insert cost limits for the specified year(s) + match year { + YearSelection::All => { + for year in milestone_years { + entry.insert(*year, cost_limits.clone()); + } + } + YearSelection::Some(years) => { + for year in years { + entry.insert(year, cost_limits.clone()); + } + } + } + } + Ok(map) +} diff --git a/src/input/agent/objective.rs b/src/input/agent/objective.rs index 94fe9e062..34815175f 100644 --- a/src/input/agent/objective.rs +++ b/src/input/agent/objective.rs @@ -169,8 +169,8 @@ fn check_agent_objectives( #[cfg(test)] mod tests { use super::*; - use crate::agent::Agent; use crate::agent::ObjectiveType; + use crate::agent::{Agent, CostLimitsMap}; use crate::region::RegionSelection; #[test] @@ -225,8 +225,7 @@ mod tests { commodities: Vec::new(), search_space: Vec::new(), decision_rule: DecisionRule::Single, - capex_limit: None, - annual_cost_limit: None, + cost_limits: CostLimitsMap::new(), regions: RegionSelection::All, objectives: Vec::new(), }, From 5b0ee56a902f1630d794c2b3a5db8310ee68b8cc Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Thu, 24 Apr 2025 12:39:49 +0100 Subject: [PATCH 2/8] Add validation check --- src/input/agent/cost_limit.rs | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/input/agent/cost_limit.rs b/src/input/agent/cost_limit.rs index 9948dbc5a..50e28da85 100644 --- a/src/input/agent/cost_limit.rs +++ b/src/input/agent/cost_limit.rs @@ -19,11 +19,11 @@ struct AgentCostLimitRaw { } impl AgentCostLimitRaw { - fn to_cost_limit(&self) -> Result { - Ok(CostLimits { + fn to_cost_limit(&self) -> CostLimits { + CostLimits { capex_limit: self.capex_limit, annual_cost_limit: self.annual_cost_limit, - }) + } } } @@ -57,7 +57,7 @@ where { let mut map: HashMap = HashMap::new(); for agent_cost_limits_raw in iter { - let cost_limits = agent_cost_limits_raw.to_cost_limit()?; + let cost_limits = agent_cost_limits_raw.to_cost_limit(); let year = agent_cost_limits_raw.year; // Get agent ID @@ -82,5 +82,19 @@ where } } } + + // Validation: if cost limits are specified for an agent, they must be present for all years. + for (id, cost_limits) in map.iter() { + for year in milestone_years { + if !cost_limits.contains_key(year) { + return Err(anyhow::anyhow!( + "Agent {} is missing cost limits for year {}", + id, + year + )); + } + } + } + Ok(map) } From 234eec2f4bddbf409853e721fe24ba42dc3c6bb5 Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Thu, 24 Apr 2025 12:45:34 +0100 Subject: [PATCH 3/8] Refactoring --- src/agent.rs | 8 ++++---- src/input/agent.rs | 8 ++++---- src/input/agent/commodity.rs | 4 ++-- src/input/agent/cost_limit.rs | 33 ++++++++++++++++----------------- src/input/agent/objective.rs | 4 ++-- 5 files changed, 28 insertions(+), 29 deletions(-) diff --git a/src/agent.rs b/src/agent.rs index e12e00f7a..7ac78169c 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -16,8 +16,8 @@ define_id_type! {AgentID} /// A map of [`Agent`]s, keyed by agent ID pub type AgentMap = IndexMap; -/// A map of cost limits for each agent, keyed by year -pub type CostLimitsMap = HashMap; +/// A map of cost limits for an agent, keyed by year +pub type AgentCostLimitsMap = HashMap; /// An agent in the simulation #[derive(Debug, Clone, PartialEq)] @@ -33,7 +33,7 @@ pub struct Agent { /// The decision rule that the agent uses to decide investment. pub decision_rule: DecisionRule, /// Cost limits (e.g. capital cost, annual operating cost) - pub cost_limits: CostLimitsMap, + pub cost_limits: AgentCostLimitsMap, /// The regions in which this agent operates. pub regions: RegionSelection, /// The agent's objectives. @@ -43,7 +43,7 @@ define_id_getter! {Agent, AgentID} /// The cost limits for an agent in a particular year #[derive(Debug, Clone, PartialEq)] -pub struct CostLimits { +pub struct AgentCostLimits { /// The maximum capital cost the agent will pay. pub capex_limit: Option, /// The maximum annual operating cost (fuel plus var_opex etc) that the agent will pay. diff --git a/src/input/agent.rs b/src/input/agent.rs index 0d8e98de2..2e54f2429 100644 --- a/src/input/agent.rs +++ b/src/input/agent.rs @@ -1,6 +1,6 @@ //! Code for reading in agent-related data from CSV files. use super::*; -use crate::agent::{Agent, AgentID, AgentMap, CostLimitsMap, DecisionRule}; +use crate::agent::{Agent, AgentCostLimitsMap, AgentID, AgentMap, DecisionRule}; use crate::commodity::CommodityMap; use crate::process::ProcessMap; use crate::region::{RegionID, RegionSelection}; @@ -69,7 +69,7 @@ pub fn read_agents( )?; let mut agent_commodities = read_agent_commodities(model_dir, &agents, commodities, region_ids, milestone_years)?; - let mut cost_limts = read_agent_cost_limits(model_dir, &agents, milestone_years)?; + let mut cost_limts = read_agent_cost_limits(model_dir, &agent_ids, milestone_years)?; for (id, agent) in agents.iter_mut() { agent.regions = agent_regions.remove(id).unwrap(); @@ -132,7 +132,7 @@ where commodities: Vec::new(), search_space: Vec::new(), decision_rule, - cost_limits: CostLimitsMap::new(), + cost_limits: AgentCostLimitsMap::new(), regions: RegionSelection::default(), objectives: Vec::new(), }; @@ -168,7 +168,7 @@ mod tests { commodities: Vec::new(), search_space: Vec::new(), decision_rule: DecisionRule::Single, - cost_limits: CostLimitsMap::new(), + cost_limits: AgentCostLimitsMap::new(), regions: RegionSelection::default(), objectives: Vec::new(), }; diff --git a/src/input/agent/commodity.rs b/src/input/agent/commodity.rs index 60e04dc1f..2e4975660 100644 --- a/src/input/agent/commodity.rs +++ b/src/input/agent/commodity.rs @@ -207,7 +207,7 @@ fn validate_agent_commodities( #[cfg(test)] mod tests { use super::*; - use crate::agent::{Agent, CostLimitsMap, DecisionRule}; + use crate::agent::{Agent, AgentCostLimitsMap, DecisionRule}; use crate::commodity::{Commodity, CommodityCostMap, CommodityID, CommodityType, DemandMap}; use crate::region::RegionSelection; use crate::time_slice::TimeSliceLevel; @@ -271,7 +271,7 @@ mod tests { commodities: Vec::new(), search_space: Vec::new(), decision_rule: DecisionRule::Single, - cost_limits: CostLimitsMap::new(), + cost_limits: AgentCostLimitsMap::new(), regions: RegionSelection::default(), objectives: Vec::new(), }, diff --git a/src/input/agent/cost_limit.rs b/src/input/agent/cost_limit.rs index 50e28da85..3da4f58f7 100644 --- a/src/input/agent/cost_limit.rs +++ b/src/input/agent/cost_limit.rs @@ -1,6 +1,7 @@ //! Code for reading the agent cost limits CSV file. use super::super::*; -use crate::agent::{AgentID, AgentMap, CostLimits, CostLimitsMap}; +use crate::agent::{AgentCostLimits, AgentCostLimitsMap, AgentID}; +use crate::id::IDCollection; use crate::year::{deserialize_year, YearSelection}; use anyhow::{Context, Result}; use serde::Deserialize; @@ -10,7 +11,7 @@ use std::path::Path; const AGENT_COST_LIMITS_FILE_NAME: &str = "agent_cost_limits.csv"; #[derive(PartialEq, Debug, Deserialize)] -struct AgentCostLimitRaw { +struct AgentCostLimitsRaw { agent_id: String, capex_limit: Option, annual_cost_limit: Option, @@ -18,9 +19,9 @@ struct AgentCostLimitRaw { year: YearSelection, } -impl AgentCostLimitRaw { - fn to_cost_limit(&self) -> CostLimits { - CostLimits { +impl AgentCostLimitsRaw { + fn to_agent_cost_limits(&self) -> AgentCostLimits { + AgentCostLimits { capex_limit: self.capex_limit, annual_cost_limit: self.annual_cost_limit, } @@ -38,35 +39,33 @@ impl AgentCostLimitRaw { /// A map of Agents, with the agent ID as the key pub fn read_agent_cost_limits( model_dir: &Path, - agents: &AgentMap, + agent_ids: &HashSet, milestone_years: &[u32], -) -> Result> { +) -> Result> { let file_path = model_dir.join(AGENT_COST_LIMITS_FILE_NAME); let agent_cost_limits_csv = read_csv(&file_path)?; - read_agent_cost_limits_from_iter(agent_cost_limits_csv, agents, milestone_years) + read_agent_cost_limits_from_iter(agent_cost_limits_csv, agent_ids, milestone_years) .with_context(|| input_err_msg(&file_path)) } fn read_agent_cost_limits_from_iter( iter: I, - agents: &AgentMap, + agent_ids: &HashSet, milestone_years: &[u32], -) -> Result> +) -> Result> where - I: Iterator, + I: Iterator, { - let mut map: HashMap = HashMap::new(); + let mut map: HashMap = HashMap::new(); for agent_cost_limits_raw in iter { - let cost_limits = agent_cost_limits_raw.to_cost_limit(); + let cost_limits = agent_cost_limits_raw.to_agent_cost_limits(); let year = agent_cost_limits_raw.year; // Get agent ID - let (id, _agent) = agents - .get_key_value(agent_cost_limits_raw.agent_id.as_str()) - .context("Invalid agent ID")?; + let agent_id = agent_ids.get_id_by_str(&agent_cost_limits_raw.agent_id)?; // Get or create entry in the map - let entry = map.entry(id.clone()).or_default(); + let entry = map.entry(agent_id.clone()).or_default(); // Insert cost limits for the specified year(s) match year { diff --git a/src/input/agent/objective.rs b/src/input/agent/objective.rs index 34815175f..07c0a4139 100644 --- a/src/input/agent/objective.rs +++ b/src/input/agent/objective.rs @@ -170,7 +170,7 @@ fn check_agent_objectives( mod tests { use super::*; use crate::agent::ObjectiveType; - use crate::agent::{Agent, CostLimitsMap}; + use crate::agent::{Agent, AgentCostLimitsMap}; use crate::region::RegionSelection; #[test] @@ -225,7 +225,7 @@ mod tests { commodities: Vec::new(), search_space: Vec::new(), decision_rule: DecisionRule::Single, - cost_limits: CostLimitsMap::new(), + cost_limits: AgentCostLimitsMap::new(), regions: RegionSelection::All, objectives: Vec::new(), }, From 43995e3c61458166a581be3ae10311cc73fec578 Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Thu, 24 Apr 2025 12:47:11 +0100 Subject: [PATCH 4/8] Support for agents with no cost limits --- src/input/agent.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/input/agent.rs b/src/input/agent.rs index 2e54f2429..e300456bc 100644 --- a/src/input/agent.rs +++ b/src/input/agent.rs @@ -69,7 +69,7 @@ pub fn read_agents( )?; let mut agent_commodities = read_agent_commodities(model_dir, &agents, commodities, region_ids, milestone_years)?; - let mut cost_limts = read_agent_cost_limits(model_dir, &agent_ids, milestone_years)?; + let mut cost_limits = read_agent_cost_limits(model_dir, &agent_ids, milestone_years)?; for (id, agent) in agents.iter_mut() { agent.regions = agent_regions.remove(id).unwrap(); @@ -78,7 +78,9 @@ pub fn read_agents( agent.search_space = search_space; } agent.commodities = agent_commodities.remove(id).unwrap(); - agent.cost_limits = cost_limts.remove(id).unwrap_or_default(); + if let Some(cost_limits) = cost_limits.remove(id) { + agent.cost_limits = cost_limits; + } } Ok(agents) From fd9fdbed98d304bcef80daf9c1c37559dfabc6f0 Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Thu, 24 Apr 2025 12:58:51 +0100 Subject: [PATCH 5/8] Add tests --- src/input/agent/cost_limit.rs | 111 ++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/src/input/agent/cost_limit.rs b/src/input/agent/cost_limit.rs index 3da4f58f7..cdf7d74b8 100644 --- a/src/input/agent/cost_limit.rs +++ b/src/input/agent/cost_limit.rs @@ -97,3 +97,114 @@ where Ok(map) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::agent::AgentCostLimits; + use crate::year::YearSelection; + use std::collections::HashSet; + + fn create_agent_cost_limits_raw( + agent_id: &str, + capex_limit: Option, + annual_cost_limit: Option, + year: YearSelection, + ) -> AgentCostLimitsRaw { + AgentCostLimitsRaw { + agent_id: agent_id.to_string(), + capex_limit, + annual_cost_limit, + year, + } + } + + #[test] + fn test_read_agent_cost_limits_from_iter_all_years() { + let agent_ids: HashSet = ["Agent1", "Agent2"] + .iter() + .map(|&id| AgentID::from(id)) + .collect(); + let milestone_years = vec![2020, 2025]; + + let iter = vec![ + create_agent_cost_limits_raw("Agent1", Some(100.0), Some(200.0), YearSelection::All), + create_agent_cost_limits_raw("Agent2", Some(150.0), Some(250.0), YearSelection::All), + ] + .into_iter(); + + let result = read_agent_cost_limits_from_iter(iter, &agent_ids, &milestone_years).unwrap(); + + assert_eq!(result.len(), 2); + for year in milestone_years { + assert_eq!( + result[&AgentID::from("Agent1")][&year], + AgentCostLimits { + capex_limit: Some(100.0), + annual_cost_limit: Some(200.0), + } + ); + assert_eq!( + result[&AgentID::from("Agent2")][&year], + AgentCostLimits { + capex_limit: Some(150.0), + annual_cost_limit: Some(250.0), + } + ); + } + } + + #[test] + fn test_read_agent_cost_limits_from_iter_some_years() { + let agent_ids: HashSet = ["Agent1"].iter().map(|&id| AgentID::from(id)).collect(); + let milestone_years = vec![2020, 2025]; + + let iter = vec![create_agent_cost_limits_raw( + "Agent1", + Some(100.0), + Some(200.0), + YearSelection::Some([2020, 2025].into_iter().collect()), + )] + .into_iter(); + + let result = read_agent_cost_limits_from_iter(iter, &agent_ids, &milestone_years).unwrap(); + + assert_eq!(result.len(), 1); + assert_eq!( + result[&AgentID::from("Agent1")][&2020], + AgentCostLimits { + capex_limit: Some(100.0), + annual_cost_limit: Some(200.0), + } + ); + assert_eq!( + result[&AgentID::from("Agent1")][&2025], + AgentCostLimits { + capex_limit: Some(100.0), + annual_cost_limit: Some(200.0), + } + ); + } + + #[test] + fn test_read_agent_cost_limits_from_iter_missing_years() { + let agent_ids: HashSet = ["Agent1"].iter().map(|&id| AgentID::from(id)).collect(); + let milestone_years = vec![2020, 2025]; + + let iter = vec![create_agent_cost_limits_raw( + "Agent1", + Some(100.0), + Some(200.0), + YearSelection::Some([2020].into_iter().collect()), + )] + .into_iter(); + + let result = read_agent_cost_limits_from_iter(iter, &agent_ids, &milestone_years); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().to_string(), + "Agent Agent1 is missing cost limits for year 2025" + ); + } +} From 265ac65e2dd844b847dc61eac586290d9f42df72 Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Mon, 28 Apr 2025 13:21:30 +0100 Subject: [PATCH 6/8] Update to use parse_year_str --- src/input/agent/cost_limit.rs | 33 +++++++++++---------------------- 1 file changed, 11 insertions(+), 22 deletions(-) diff --git a/src/input/agent/cost_limit.rs b/src/input/agent/cost_limit.rs index cdf7d74b8..81290bf54 100644 --- a/src/input/agent/cost_limit.rs +++ b/src/input/agent/cost_limit.rs @@ -2,7 +2,7 @@ use super::super::*; use crate::agent::{AgentCostLimits, AgentCostLimitsMap, AgentID}; use crate::id::IDCollection; -use crate::year::{deserialize_year, YearSelection}; +use crate::year::parse_year_str; use anyhow::{Context, Result}; use serde::Deserialize; use std::collections::HashMap; @@ -13,10 +13,9 @@ const AGENT_COST_LIMITS_FILE_NAME: &str = "agent_cost_limits.csv"; #[derive(PartialEq, Debug, Deserialize)] struct AgentCostLimitsRaw { agent_id: String, + year: String, capex_limit: Option, annual_cost_limit: Option, - #[serde(deserialize_with = "deserialize_year")] - year: YearSelection, } impl AgentCostLimitsRaw { @@ -59,7 +58,7 @@ where let mut map: HashMap = HashMap::new(); for agent_cost_limits_raw in iter { let cost_limits = agent_cost_limits_raw.to_agent_cost_limits(); - let year = agent_cost_limits_raw.year; + let years = parse_year_str(&agent_cost_limits_raw.year, milestone_years)?; // Get agent ID let agent_id = agent_ids.get_id_by_str(&agent_cost_limits_raw.agent_id)?; @@ -68,17 +67,8 @@ where let entry = map.entry(agent_id.clone()).or_default(); // Insert cost limits for the specified year(s) - match year { - YearSelection::All => { - for year in milestone_years { - entry.insert(*year, cost_limits.clone()); - } - } - YearSelection::Some(years) => { - for year in years { - entry.insert(year, cost_limits.clone()); - } - } + for year in years { + entry.insert(year, cost_limits.clone()); } } @@ -102,20 +92,19 @@ where mod tests { use super::*; use crate::agent::AgentCostLimits; - use crate::year::YearSelection; use std::collections::HashSet; fn create_agent_cost_limits_raw( agent_id: &str, + year: &str, capex_limit: Option, annual_cost_limit: Option, - year: YearSelection, ) -> AgentCostLimitsRaw { AgentCostLimitsRaw { agent_id: agent_id.to_string(), + year: year.to_string(), capex_limit, annual_cost_limit, - year, } } @@ -128,8 +117,8 @@ mod tests { let milestone_years = vec![2020, 2025]; let iter = vec![ - create_agent_cost_limits_raw("Agent1", Some(100.0), Some(200.0), YearSelection::All), - create_agent_cost_limits_raw("Agent2", Some(150.0), Some(250.0), YearSelection::All), + create_agent_cost_limits_raw("Agent1", "all", Some(100.0), Some(200.0)), + create_agent_cost_limits_raw("Agent2", "all", Some(150.0), Some(250.0)), ] .into_iter(); @@ -161,9 +150,9 @@ mod tests { let iter = vec![create_agent_cost_limits_raw( "Agent1", + "2020;2025", Some(100.0), Some(200.0), - YearSelection::Some([2020, 2025].into_iter().collect()), )] .into_iter(); @@ -193,9 +182,9 @@ mod tests { let iter = vec![create_agent_cost_limits_raw( "Agent1", + "2020", Some(100.0), Some(200.0), - YearSelection::Some([2020].into_iter().collect()), )] .into_iter(); From bafa9ef421066122241f2c9199bfed23141c3a9e Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Mon, 28 Apr 2025 13:24:40 +0100 Subject: [PATCH 7/8] Switch column order --- examples/simple/agent_cost_limits.csv | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/examples/simple/agent_cost_limits.csv b/examples/simple/agent_cost_limits.csv index 4b1e98617..1f238c450 100644 --- a/examples/simple/agent_cost_limits.csv +++ b/examples/simple/agent_cost_limits.csv @@ -1,5 +1,5 @@ -agent_id,capex_limit,annual_cost_limit,year -A0_GEX,,,all -A0_GPR,,,all -A0_ELC,,,all -A0_RES,,,all +agent_id,year,capex_limit,annual_cost_limit +A0_GEX,all,, +A0_GPR,all,, +A0_ELC,all,, +A0_RES,all,, From e850bafb697090820f243459756bd659e962d487 Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Mon, 28 Apr 2025 19:53:47 +0100 Subject: [PATCH 8/8] Apply suggestions Co-authored-by: alexdewar --- src/input/agent/commodity.rs | 4 ++-- src/input/agent/cost_limit.rs | 25 +++++++++++-------------- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/src/input/agent/commodity.rs b/src/input/agent/commodity.rs index 2e4975660..f68a0d2d7 100644 --- a/src/input/agent/commodity.rs +++ b/src/input/agent/commodity.rs @@ -216,7 +216,7 @@ mod tests { #[test] fn test_agent_commodity_raw_to_agent_commodity() { - let milestone_years = vec![2020, 2021, 2022]; + let milestone_years = [2020, 2021, 2022]; let commodity = Rc::new(Commodity { id: "commodity1".into(), description: "A commodity".into(), @@ -288,7 +288,7 @@ mod tests { }), )]); let region_ids = HashSet::from([RegionID::new("region1")]); - let milestone_years = vec![2020]; + let milestone_years = [2020]; // Valid case let agent_commodity = AgentCommodity { diff --git a/src/input/agent/cost_limit.rs b/src/input/agent/cost_limit.rs index 81290bf54..fa1e385f9 100644 --- a/src/input/agent/cost_limit.rs +++ b/src/input/agent/cost_limit.rs @@ -42,7 +42,7 @@ pub fn read_agent_cost_limits( milestone_years: &[u32], ) -> Result> { let file_path = model_dir.join(AGENT_COST_LIMITS_FILE_NAME); - let agent_cost_limits_csv = read_csv(&file_path)?; + let agent_cost_limits_csv = read_csv_optional(&file_path)?; read_agent_cost_limits_from_iter(agent_cost_limits_csv, agent_ids, milestone_years) .with_context(|| input_err_msg(&file_path)) } @@ -75,13 +75,10 @@ where // Validation: if cost limits are specified for an agent, they must be present for all years. for (id, cost_limits) in map.iter() { for year in milestone_years { - if !cost_limits.contains_key(year) { - return Err(anyhow::anyhow!( - "Agent {} is missing cost limits for year {}", - id, - year - )); - } + ensure!( + cost_limits.contains_key(year), + "Agent {id} is missing cost limits for year {year}" + ); } } @@ -114,9 +111,9 @@ mod tests { .iter() .map(|&id| AgentID::from(id)) .collect(); - let milestone_years = vec![2020, 2025]; + let milestone_years = [2020, 2025]; - let iter = vec![ + let iter = [ create_agent_cost_limits_raw("Agent1", "all", Some(100.0), Some(200.0)), create_agent_cost_limits_raw("Agent2", "all", Some(150.0), Some(250.0)), ] @@ -146,9 +143,9 @@ mod tests { #[test] fn test_read_agent_cost_limits_from_iter_some_years() { let agent_ids: HashSet = ["Agent1"].iter().map(|&id| AgentID::from(id)).collect(); - let milestone_years = vec![2020, 2025]; + let milestone_years = [2020, 2025]; - let iter = vec![create_agent_cost_limits_raw( + let iter = [create_agent_cost_limits_raw( "Agent1", "2020;2025", Some(100.0), @@ -178,9 +175,9 @@ mod tests { #[test] fn test_read_agent_cost_limits_from_iter_missing_years() { let agent_ids: HashSet = ["Agent1"].iter().map(|&id| AgentID::from(id)).collect(); - let milestone_years = vec![2020, 2025]; + let milestone_years = [2020, 2025]; - let iter = vec![create_agent_cost_limits_raw( + let iter = [create_agent_cost_limits_raw( "Agent1", "2020", Some(100.0),