diff --git a/examples/simple/agent_cost_limits.csv b/examples/simple/agent_cost_limits.csv new file mode 100644 index 000000000..1f238c450 --- /dev/null +++ b/examples/simple/agent_cost_limits.csv @@ -0,0 +1,5 @@ +agent_id,year,capex_limit,annual_cost_limit +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..7ac78169c 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 an agent, keyed by year +pub type AgentCostLimitsMap = 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: AgentCostLimitsMap, /// 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 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. + 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..e300456bc 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, AgentCostLimitsMap, AgentID, AgentMap, 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_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(); @@ -79,6 +78,9 @@ pub fn read_agents( agent.search_space = search_space; } agent.commodities = agent_commodities.remove(id).unwrap(); + if let Some(cost_limits) = cost_limits.remove(id) { + agent.cost_limits = cost_limits; + } } Ok(agents) @@ -132,8 +134,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: AgentCostLimitsMap::new(), regions: RegionSelection::default(), objectives: Vec::new(), }; @@ -161,8 +162,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 +170,7 @@ mod tests { commodities: Vec::new(), search_space: Vec::new(), decision_rule: DecisionRule::Single, - capex_limit: None, - annual_cost_limit: None, + cost_limits: AgentCostLimitsMap::new(), regions: RegionSelection::default(), objectives: Vec::new(), }; @@ -186,16 +184,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 +200,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..f68a0d2d7 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, AgentCostLimitsMap, DecisionRule}; use crate::commodity::{Commodity, CommodityCostMap, CommodityID, CommodityType, DemandMap}; use crate::region::RegionSelection; use crate::time_slice::TimeSliceLevel; @@ -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(), @@ -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: AgentCostLimitsMap::new(), regions: RegionSelection::default(), objectives: Vec::new(), }, @@ -289,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 new file mode 100644 index 000000000..fa1e385f9 --- /dev/null +++ b/src/input/agent/cost_limit.rs @@ -0,0 +1,196 @@ +//! Code for reading the agent cost limits CSV file. +use super::super::*; +use crate::agent::{AgentCostLimits, AgentCostLimitsMap, AgentID}; +use crate::id::IDCollection; +use crate::year::parse_year_str; +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 AgentCostLimitsRaw { + agent_id: String, + year: String, + capex_limit: Option, + annual_cost_limit: Option, +} + +impl AgentCostLimitsRaw { + fn to_agent_cost_limits(&self) -> AgentCostLimits { + AgentCostLimits { + 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, + agent_ids: &HashSet, + milestone_years: &[u32], +) -> Result> { + let file_path = model_dir.join(AGENT_COST_LIMITS_FILE_NAME); + 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)) +} + +fn read_agent_cost_limits_from_iter( + iter: I, + agent_ids: &HashSet, + 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_agent_cost_limits(); + 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)?; + + // Get or create entry in the map + let entry = map.entry(agent_id.clone()).or_default(); + + // Insert cost limits for the specified year(s) + for year in years { + entry.insert(year, cost_limits.clone()); + } + } + + // 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 { + ensure!( + cost_limits.contains_key(year), + "Agent {id} is missing cost limits for year {year}" + ); + } + } + + Ok(map) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agent::AgentCostLimits; + use std::collections::HashSet; + + fn create_agent_cost_limits_raw( + agent_id: &str, + year: &str, + capex_limit: Option, + annual_cost_limit: Option, + ) -> AgentCostLimitsRaw { + AgentCostLimitsRaw { + agent_id: agent_id.to_string(), + year: year.to_string(), + capex_limit, + annual_cost_limit, + } + } + + #[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 = [2020, 2025]; + + 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)), + ] + .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 = [2020, 2025]; + + let iter = [create_agent_cost_limits_raw( + "Agent1", + "2020;2025", + Some(100.0), + Some(200.0), + )] + .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 = [2020, 2025]; + + let iter = [create_agent_cost_limits_raw( + "Agent1", + "2020", + Some(100.0), + Some(200.0), + )] + .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" + ); + } +} diff --git a/src/input/agent/objective.rs b/src/input/agent/objective.rs index 94fe9e062..07c0a4139 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, AgentCostLimitsMap}; 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: AgentCostLimitsMap::new(), regions: RegionSelection::All, objectives: Vec::new(), },