From e4ee9549b2b736daf1f8027644669897e57eab34 Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Wed, 19 Mar 2025 13:44:44 +0000 Subject: [PATCH 1/7] Add struct and table --- examples/simple/agent_search_space.csv | 1 + src/agent.rs | 13 +++++++++++++ 2 files changed, 14 insertions(+) create mode 100644 examples/simple/agent_search_space.csv diff --git a/examples/simple/agent_search_space.csv b/examples/simple/agent_search_space.csv new file mode 100644 index 000000000..fb48fb3e5 --- /dev/null +++ b/examples/simple/agent_search_space.csv @@ -0,0 +1 @@ +agent_id,commodity_id,year,process_option diff --git a/src/agent.rs b/src/agent.rs index 9289d25f3..af8665c7c 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -48,6 +48,19 @@ pub enum SearchSpace { Some(HashSet>), } +/// Search space for an agent +#[derive(Debug, Clone, PartialEq)] +pub struct AgentSearchSpace { + /// Unique agent id identifying the agent this search space belongs to + pub agent_id: String, + /// The year the objective is relevant for + pub year: u32, + /// The commodity to apply the search space to + pub commodity: Rc, + /// The agent's search space + pub search_space: SearchSpace, +} + /// The decision rule for a particular objective #[derive(Debug, Clone, PartialEq)] pub enum DecisionRule { From b96c2673b0bf0721e8bafb6a540cdff86253cfdc Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Wed, 19 Mar 2025 15:22:55 +0000 Subject: [PATCH 2/7] First draft at search_space module --- src/agent.rs | 2 +- src/input/agent.rs | 75 +++++------------------- src/input/agent/objective.rs | 4 +- src/input/agent/search_space.rs | 100 ++++++++++++++++++++++++++++++++ 4 files changed, 118 insertions(+), 63 deletions(-) create mode 100644 src/input/agent/search_space.rs diff --git a/src/agent.rs b/src/agent.rs index af8665c7c..3bcdf2fd2 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -26,7 +26,7 @@ pub struct Agent { /// The proportion of the commodity production that the agent is responsible for. pub commodity_portion: f64, /// The processes that the agent will consider investing in. - pub search_space: SearchSpace, + 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. diff --git a/src/input/agent.rs b/src/input/agent.rs index e2c045035..c26a3163d 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, AgentMap, DecisionRule, SearchSpace}; +use crate::agent::{Agent, AgentMap, DecisionRule}; use crate::commodity::CommodityMap; use crate::process::ProcessMap; use crate::region::RegionSelection; @@ -14,6 +14,8 @@ pub mod objective; use objective::read_agent_objectives; pub mod region; use region::read_agent_regions; +pub mod search_space; +use search_space::read_agent_search_space; const AGENT_FILE_NAME: &str = "agents.csv"; @@ -29,9 +31,6 @@ struct AgentRaw { /// The proportion of the commodity production that the agent is responsible for. #[serde(deserialize_with = "deserialise_proportion_nonzero")] commodity_portion: f64, - /// The processes that the agent will consider investing in. Expressed as process IDs separated - /// by semicolons or `None`, meaning all processes. - search_space: Option, /// The decision rule that the agent uses to decide investment. decision_rule: String, /// The maximum capital cost the agent will pay. @@ -62,15 +61,17 @@ pub fn read_agents( milestone_years: &[u32], ) -> Result { let process_ids = processes.keys().cloned().collect(); - let mut agents = read_agents_file(model_dir, commodities, &process_ids)?; + let mut agents = read_agents_file(model_dir, commodities)?; let agent_ids = agents.keys().cloned().collect(); let mut agent_regions = read_agent_regions(model_dir, &agent_ids, region_ids)?; let mut objectives = read_agent_objectives(model_dir, &agents, milestone_years)?; + let mut search_spaces = read_agent_search_space(model_dir, &agents, &process_ids, commodities)?; for (id, agent) in agents.iter_mut() { agent.regions = agent_regions.remove(id).unwrap(); agent.objectives = objectives.remove(id).unwrap(); + agent.search_space = search_spaces.remove(id).unwrap(); } Ok(agents) @@ -87,23 +88,14 @@ 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, - process_ids: &HashSet>, -) -> Result { +pub fn read_agents_file(model_dir: &Path, commodities: &CommodityMap) -> 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, process_ids) - .with_context(|| input_err_msg(&file_path)) + read_agents_file_from_iter(agents_csv, commodities).with_context(|| input_err_msg(&file_path)) } /// Read agents info from an iterator. -fn read_agents_file_from_iter( - iter: I, - commodities: &CommodityMap, - process_ids: &HashSet>, -) -> Result +fn read_agents_file_from_iter(iter: I, commodities: &CommodityMap) -> Result where I: Iterator, { @@ -113,19 +105,6 @@ where .get(agent_raw.commodity_id.as_str()) .context("Invalid commodity ID")?; - // Parse search space string - let search_space = match agent_raw.search_space { - None => SearchSpace::AllProcesses, - Some(processes) => { - let mut set = HashSet::new(); - for id in processes.split(';') { - set.insert(process_ids.get_id(id)?); - } - - SearchSpace::Some(set) - } - }; - // Parse decision rule let decision_rule = match agent_raw.decision_rule.to_ascii_lowercase().as_str() { "single" => DecisionRule::Single, @@ -149,7 +128,7 @@ where description: agent_raw.description, commodity: Rc::clone(commodity), commodity_portion: agent_raw.commodity_portion, - search_space, + search_space: Vec::new(), decision_rule, capex_limit: agent_raw.capex_limit, annual_cost_limit: agent_raw.annual_cost_limit, @@ -177,7 +156,6 @@ mod tests { #[test] fn test_read_agents_file_from_iter() { - let process_ids = ["A".into(), "B".into(), "C".into()].into_iter().collect(); let commodity = Rc::new(Commodity { id: "commodity1".into(), description: "A commodity".into(), @@ -189,13 +167,11 @@ mod tests { let commodities = iter::once(("commodity1".into(), Rc::clone(&commodity))).collect(); // Valid case - let search_space = HashSet::from_iter(["A".into(), "B".into()]); let agent = AgentRaw { id: "agent".into(), description: "".into(), commodity_id: "commodity1".into(), commodity_portion: 1.0, - search_space: Some("A;B".into()), decision_rule: "single".into(), capex_limit: None, annual_cost_limit: None, @@ -206,7 +182,7 @@ mod tests { description: "".into(), commodity, commodity_portion: 1.0, - search_space: SearchSpace::Some(search_space), + search_space: Vec::new(), decision_rule: DecisionRule::Single, capex_limit: None, annual_cost_limit: None, @@ -214,8 +190,7 @@ 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, &process_ids).unwrap(); + let actual = read_agents_file_from_iter(iter::once(agent), &commodities).unwrap(); assert_eq!(actual, expected); // Invalid commodity ID @@ -224,27 +199,12 @@ mod tests { description: "".into(), commodity_id: "made_up_commodity".into(), commodity_portion: 1.0, - search_space: None, - 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, &process_ids).is_err()); - - // Invalid process ID - let agent = AgentRaw { - id: "agent".into(), - description: "".into(), - commodity_id: "commodity1".into(), - commodity_portion: 1.0, - search_space: Some("A;D".into()), 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, &process_ids).is_err()); + assert!(read_agents_file_from_iter(iter::once(agent), &commodities).is_err()); // Duplicate agent ID let agents = [ @@ -253,7 +213,6 @@ mod tests { description: "".into(), commodity_id: "commodity1".into(), commodity_portion: 1.0, - search_space: None, decision_rule: "single".into(), capex_limit: None, annual_cost_limit: None, @@ -264,16 +223,13 @@ mod tests { description: "".into(), commodity_id: "commodity1".into(), commodity_portion: 1.0, - search_space: None, 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, &process_ids).is_err() - ); + assert!(read_agents_file_from_iter(agents.into_iter(), &commodities).is_err()); // Lexico tolerance missing for lexico decision rule let agent = AgentRaw { @@ -281,12 +237,11 @@ mod tests { description: "".into(), commodity_id: "commodity1".into(), commodity_portion: 1.0, - search_space: None, 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, &process_ids).is_err()); + assert!(read_agents_file_from_iter(iter::once(agent), &commodities).is_err()); } } diff --git a/src/input/agent/objective.rs b/src/input/agent/objective.rs index 27eec8489..57c900895 100644 --- a/src/input/agent/objective.rs +++ b/src/input/agent/objective.rs @@ -124,7 +124,7 @@ fn check_objective_parameter( #[cfg(test)] mod tests { use super::*; - use crate::agent::{ObjectiveType, SearchSpace}; + use crate::agent::ObjectiveType; use crate::commodity::{Commodity, CommodityCostMap, CommodityType, DemandMap}; use crate::region::RegionSelection; use crate::time_slice::TimeSliceLevel; @@ -181,7 +181,7 @@ mod tests { description: "".into(), commodity, commodity_portion: 1.0, - search_space: SearchSpace::AllProcesses, + search_space: Vec::new(), decision_rule: DecisionRule::Single, capex_limit: None, annual_cost_limit: None, diff --git a/src/input/agent/search_space.rs b/src/input/agent/search_space.rs new file mode 100644 index 000000000..e6c72bd22 --- /dev/null +++ b/src/input/agent/search_space.rs @@ -0,0 +1,100 @@ +//! Code for reading the agent search space CSV file. +use super::super::*; +use crate::agent::{AgentMap, AgentSearchSpace, SearchSpace}; +use crate::commodity::CommodityMap; +use anyhow::{Context, Result}; +use serde::Deserialize; +use std::collections::HashMap; +use std::path::Path; +use std::rc::Rc; + +const AGENT_SEARCH_SPACE_FILE_NAME: &str = "agent_search_space.csv"; + +#[derive(PartialEq, Debug, Deserialize)] +struct AgentSearchSpaceRaw { + pub agent_id: String, + pub commodity_id: String, + pub year: u32, + pub process_option: Option, +} + +impl AgentSearchSpaceRaw { + pub fn to_agent_search_space( + &self, + process_ids: &HashSet>, + commodities: &CommodityMap, + ) -> Result { + // Parse process_option string + let search_space = match &self.process_option { + None => SearchSpace::AllProcesses, + Some(processes) => { + let mut set = HashSet::new(); + for id in processes.split(';') { + set.insert(process_ids.get_id(id)?); + } + SearchSpace::Some(set) + } + }; + + // Get commodity + let commodity = commodities + .get(self.commodity_id.as_str()) + .context("Invalid commodity ID")?; + + // Create AgentSearchSpace + Ok(AgentSearchSpace { + agent_id: self.agent_id.clone(), + year: self.year, + commodity: Rc::clone(commodity), + search_space, + }) + } +} + +/// Read agent search space info from the agent_search_space.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_search_space( + model_dir: &Path, + agents: &AgentMap, + process_ids: &HashSet>, + commodities: &CommodityMap, +) -> Result, Vec>> { + let file_path = model_dir.join(AGENT_SEARCH_SPACE_FILE_NAME); + let iter = read_csv::(&file_path)?; + read_agent_search_space_from_iter(iter, agents, process_ids, commodities) + .with_context(|| input_err_msg(&file_path)) +} + +fn read_agent_search_space_from_iter( + iter: I, + agents: &AgentMap, + process_ids: &HashSet>, + commodities: &CommodityMap, +) -> Result, Vec>> +where + I: Iterator, +{ + let mut search_spaces = HashMap::new(); + for search_space in iter { + let search_space = search_space.to_agent_search_space(process_ids, commodities)?; + + let (id, _agent) = agents + .get_key_value(search_space.agent_id.as_str()) + .context("Invalid agent ID")?; + + // Append to Vec with the corresponding key or create + search_spaces + .entry(Rc::clone(id)) + .or_insert_with(|| Vec::with_capacity(1)) + .push(search_space); + } + + Ok(search_spaces) +} From f5090591a4f8fe80ab1a62aac8083b3dbe9f95e5 Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Wed, 19 Mar 2025 15:45:44 +0000 Subject: [PATCH 3/7] Allow empty search spaces --- src/input/agent.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/input/agent.rs b/src/input/agent.rs index c26a3163d..a2a0964dd 100644 --- a/src/input/agent.rs +++ b/src/input/agent.rs @@ -71,7 +71,9 @@ pub fn read_agents( for (id, agent) in agents.iter_mut() { agent.regions = agent_regions.remove(id).unwrap(); agent.objectives = objectives.remove(id).unwrap(); - agent.search_space = search_spaces.remove(id).unwrap(); + if let Some(search_space) = search_spaces.remove(id) { + agent.search_space = search_space; + } } Ok(agents) From 68ff9c0ed0aae6d6fbeb93c6c7f8ce3ed0b4747c Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Wed, 19 Mar 2025 16:13:41 +0000 Subject: [PATCH 4/7] Add test --- src/input/agent/search_space.rs | 55 +++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/src/input/agent/search_space.rs b/src/input/agent/search_space.rs index e6c72bd22..5e81c7385 100644 --- a/src/input/agent/search_space.rs +++ b/src/input/agent/search_space.rs @@ -98,3 +98,58 @@ where Ok(search_spaces) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::commodity::{Commodity, CommodityCostMap, CommodityType, DemandMap}; + use crate::time_slice::TimeSliceLevel; + use std::iter; + + #[test] + fn test_search_space_raw_into_search_space() { + let process_ids = ["A".into(), "B".into(), "C".into()].into_iter().collect(); + 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 search space + let raw = AgentSearchSpaceRaw { + agent_id: "agent1".into(), + commodity_id: "commodity1".into(), + year: 2020, + process_option: Some("A;B".into()), + }; + assert!(raw + .to_agent_search_space(&process_ids, &commodities) + .is_ok()); + + // Invalid commodity ID + let raw = AgentSearchSpaceRaw { + agent_id: "agent1".into(), + commodity_id: "invalid_commodity".into(), + year: 2020, + process_option: Some("A;B".into()), + }; + assert!(raw + .to_agent_search_space(&process_ids, &commodities) + .is_err()); + + // Invalid process ID + let raw = AgentSearchSpaceRaw { + agent_id: "agent1".into(), + commodity_id: "commodity1".into(), + year: 2020, + process_option: Some("A;D".into()), + }; + assert!(raw + .to_agent_search_space(&process_ids, &commodities) + .is_err()); + } +} From 0284ee8efbe9fa1387b1d83f4b9989ea885f52f4 Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Wed, 19 Mar 2025 16:51:21 +0000 Subject: [PATCH 5/7] Rename process_option --- examples/simple/agent_search_space.csv | 2 +- src/input/agent/search_space.rs | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/examples/simple/agent_search_space.csv b/examples/simple/agent_search_space.csv index fb48fb3e5..b2942ec7d 100644 --- a/examples/simple/agent_search_space.csv +++ b/examples/simple/agent_search_space.csv @@ -1 +1 @@ -agent_id,commodity_id,year,process_option +agent_id,commodity_id,year,search_space diff --git a/src/input/agent/search_space.rs b/src/input/agent/search_space.rs index 5e81c7385..9a7b77210 100644 --- a/src/input/agent/search_space.rs +++ b/src/input/agent/search_space.rs @@ -15,7 +15,7 @@ struct AgentSearchSpaceRaw { pub agent_id: String, pub commodity_id: String, pub year: u32, - pub process_option: Option, + pub search_space: Option, } impl AgentSearchSpaceRaw { @@ -24,8 +24,8 @@ impl AgentSearchSpaceRaw { process_ids: &HashSet>, commodities: &CommodityMap, ) -> Result { - // Parse process_option string - let search_space = match &self.process_option { + // Parse search_space string + let search_space = match &self.search_space { None => SearchSpace::AllProcesses, Some(processes) => { let mut set = HashSet::new(); @@ -124,7 +124,7 @@ mod tests { agent_id: "agent1".into(), commodity_id: "commodity1".into(), year: 2020, - process_option: Some("A;B".into()), + search_space: Some("A;B".into()), }; assert!(raw .to_agent_search_space(&process_ids, &commodities) @@ -135,7 +135,7 @@ mod tests { agent_id: "agent1".into(), commodity_id: "invalid_commodity".into(), year: 2020, - process_option: Some("A;B".into()), + search_space: Some("A;B".into()), }; assert!(raw .to_agent_search_space(&process_ids, &commodities) @@ -146,7 +146,7 @@ mod tests { agent_id: "agent1".into(), commodity_id: "commodity1".into(), year: 2020, - process_option: Some("A;D".into()), + search_space: Some("A;D".into()), }; assert!(raw .to_agent_search_space(&process_ids, &commodities) From 6d2c3aaea4ef8c13f79b11227ea9ada6c2720254 Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Wed, 19 Mar 2025 17:01:26 +0000 Subject: [PATCH 6/7] Add parameter descriptions --- src/input/agent/search_space.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/input/agent/search_space.rs b/src/input/agent/search_space.rs index 9a7b77210..7c7b0c819 100644 --- a/src/input/agent/search_space.rs +++ b/src/input/agent/search_space.rs @@ -12,9 +12,14 @@ const AGENT_SEARCH_SPACE_FILE_NAME: &str = "agent_search_space.csv"; #[derive(PartialEq, Debug, Deserialize)] struct AgentSearchSpaceRaw { + /// The agent to apply the search space to. pub agent_id: String, + /// The commodity to apply the search space to. pub commodity_id: String, + /// The year to apply the search space to. pub year: u32, + /// The processes that the agent will consider investing in. Expressed as process IDs separated + /// by semicolons or `None`, meaning all processes. pub search_space: Option, } From 8fb4e42246e3378b82f24dbc8c4f9110671ac224 Mon Sep 17 00:00:00 2001 From: Tom Bland Date: Wed, 19 Mar 2025 17:13:36 +0000 Subject: [PATCH 7/7] Check milestone years --- src/input/agent.rs | 8 +++++++- src/input/agent/search_space.rs | 21 ++++++++++++++++----- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/input/agent.rs b/src/input/agent.rs index a2a0964dd..984dc5dbb 100644 --- a/src/input/agent.rs +++ b/src/input/agent.rs @@ -66,7 +66,13 @@ pub fn read_agents( let mut agent_regions = read_agent_regions(model_dir, &agent_ids, region_ids)?; let mut objectives = read_agent_objectives(model_dir, &agents, milestone_years)?; - let mut search_spaces = read_agent_search_space(model_dir, &agents, &process_ids, commodities)?; + let mut search_spaces = read_agent_search_space( + model_dir, + &agents, + &process_ids, + commodities, + milestone_years, + )?; for (id, agent) in agents.iter_mut() { agent.regions = agent_regions.remove(id).unwrap(); diff --git a/src/input/agent/search_space.rs b/src/input/agent/search_space.rs index 7c7b0c819..0f19f8053 100644 --- a/src/input/agent/search_space.rs +++ b/src/input/agent/search_space.rs @@ -28,6 +28,7 @@ impl AgentSearchSpaceRaw { &self, process_ids: &HashSet>, commodities: &CommodityMap, + milestone_years: &[u32], ) -> Result { // Parse search_space string let search_space = match &self.search_space { @@ -46,6 +47,13 @@ impl AgentSearchSpaceRaw { .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 AgentSearchSpace Ok(AgentSearchSpace { agent_id: self.agent_id.clone(), @@ -70,10 +78,11 @@ pub fn read_agent_search_space( agents: &AgentMap, process_ids: &HashSet>, commodities: &CommodityMap, + milestone_years: &[u32], ) -> Result, Vec>> { let file_path = model_dir.join(AGENT_SEARCH_SPACE_FILE_NAME); let iter = read_csv::(&file_path)?; - read_agent_search_space_from_iter(iter, agents, process_ids, commodities) + read_agent_search_space_from_iter(iter, agents, process_ids, commodities, milestone_years) .with_context(|| input_err_msg(&file_path)) } @@ -82,13 +91,15 @@ fn read_agent_search_space_from_iter( agents: &AgentMap, process_ids: &HashSet>, commodities: &CommodityMap, + milestone_years: &[u32], ) -> Result, Vec>> where I: Iterator, { let mut search_spaces = HashMap::new(); for search_space in iter { - let search_space = search_space.to_agent_search_space(process_ids, commodities)?; + let search_space = + search_space.to_agent_search_space(process_ids, commodities, milestone_years)?; let (id, _agent) = agents .get_key_value(search_space.agent_id.as_str()) @@ -132,7 +143,7 @@ mod tests { search_space: Some("A;B".into()), }; assert!(raw - .to_agent_search_space(&process_ids, &commodities) + .to_agent_search_space(&process_ids, &commodities, &[2020]) .is_ok()); // Invalid commodity ID @@ -143,7 +154,7 @@ mod tests { search_space: Some("A;B".into()), }; assert!(raw - .to_agent_search_space(&process_ids, &commodities) + .to_agent_search_space(&process_ids, &commodities, &[2020]) .is_err()); // Invalid process ID @@ -154,7 +165,7 @@ mod tests { search_space: Some("A;D".into()), }; assert!(raw - .to_agent_search_space(&process_ids, &commodities) + .to_agent_search_space(&process_ids, &commodities, &[2020]) .is_err()); } }