From fac8bb63c43c628cf44b60642b4fb693267d506e Mon Sep 17 00:00:00 2001 From: Alex Dewar Date: Mon, 18 May 2026 14:05:43 +0100 Subject: [PATCH 01/10] Rationalise and rename `Agent::iter_possible_producers_of` This method was pointlessly checking whether the relevant processes in the agent's search space have the given commodity as an output in the given region and year, when we already know that the flow must be an output for any of these processes. It's enough to check that the region matches. --- src/agent.rs | 19 +++++++++++-------- src/simulation/investment.rs | 2 +- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/agent.rs b/src/agent.rs index 4be6efb45..bc64c7620 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -2,7 +2,7 @@ //! assets. use crate::commodity::CommodityID; use crate::id::define_id_type; -use crate::process::{FlowDirection, Process}; +use crate::process::Process; use crate::region::RegionID; use crate::units::Dimensionless; use indexmap::{IndexMap, IndexSet}; @@ -48,19 +48,22 @@ pub struct Agent { impl Agent { /// Get all the processes in this agent's search space which produce the commodity in the given - /// year - pub fn iter_possible_producers_of<'a>( + /// region and year. + /// + /// # Panics + /// + /// If the agent does not operate in the given region or is not responsible for the given + /// commodity in the given year. + pub fn iter_search_space<'a>( &'a self, - region_id: &RegionID, + region_id: &'a RegionID, commodity_id: &'a CommodityID, year: u32, ) -> impl Iterator> + use<'a> { - let flows_key = (region_id.clone(), year); + assert!(self.regions.contains(region_id)); self.search_space[&(commodity_id.clone(), year)] .iter() - .filter(move |process| { - process.flows[&flows_key][commodity_id].direction() == FlowDirection::Output - }) + .filter(move |process| process.regions.contains(region_id)) } } diff --git a/src/simulation/investment.rs b/src/simulation/investment.rs index 3fe91ef2b..b6feee954 100644 --- a/src/simulation/investment.rs +++ b/src/simulation/investment.rs @@ -635,7 +635,7 @@ fn get_candidate_assets<'a>( year: u32, ) -> impl Iterator + 'a { agent - .iter_possible_producers_of(region_id, &commodity.id, year) + .iter_search_space(region_id, &commodity.id, year) .map(move |process| { let mut asset = Asset::new_candidate(process.clone(), region_id.clone(), Capacity(0.0), year) From 9cdf31adf01cb49c38434487432e9614d3e7d92c Mon Sep 17 00:00:00 2001 From: Alex Dewar Date: Thu, 14 May 2026 16:10:08 +0100 Subject: [PATCH 02/10] search_space.rs: Work out all possible producing processes at start --- src/input/agent/search_space.rs | 50 ++++++++++++++++++++++----------- 1 file changed, 33 insertions(+), 17 deletions(-) diff --git a/src/input/agent/search_space.rs b/src/input/agent/search_space.rs index 7630f745e..8f9250bb3 100644 --- a/src/input/agent/search_space.rs +++ b/src/input/agent/search_space.rs @@ -14,6 +14,8 @@ use std::rc::Rc; const AGENT_SEARCH_SPACE_FILE_NAME: &str = "agent_search_space.csv"; +type ProducersMap = HashMap<(AgentID, CommodityID, u32), Rc>>>; + #[derive(PartialEq, Debug, Deserialize)] struct AgentSearchSpaceRaw { /// The agent to apply the search space to. @@ -151,6 +153,7 @@ where } } + let producers = get_producers_map(agents, processes); for (agent_id, agent) in agents { // Get or create search space map let search_space = search_spaces @@ -158,7 +161,7 @@ where .or_insert_with(AgentSearchSpaceMap::new); // Add missing entries for commodities/years - fill_missing_search_space_entries(agent, processes, search_space); + fill_missing_search_space_entries(agent, &producers, search_space); } Ok(search_spaces) @@ -170,7 +173,7 @@ where /// producers which operate in at least one of the same regions as the agent are considered. fn fill_missing_search_space_entries( agent: &Agent, - processes: &ProcessMap, + producers: &ProducersMap, search_space: &mut AgentSearchSpaceMap, ) { // Agents all have commodity portions and this field should have been assigned already @@ -178,24 +181,37 @@ fn fill_missing_search_space_entries( for (commodity_id, year) in agent.commodity_portions.keys() { let key = (commodity_id.clone(), *year); - search_space.entry(key).or_insert_with(|| { - Rc::new(get_all_producers(processes, commodity_id, *year).collect()) - }); + search_space + .entry(key) + .or_insert_with(|| producers[&(agent.id.clone(), commodity_id.clone(), *year)].clone()); } } -/// Get all processes active in the relevant year and regions which produce the given commodity -fn get_all_producers<'a>( - processes: &'a ProcessMap, - commodity_id: &'a CommodityID, - year: u32, -) -> impl Iterator> + 'a { - processes - .values() - .filter(move |process| { - process.active_for_year(year) && process.primary_output.as_ref() == Some(commodity_id) - }) - .cloned() +/// Get a map of all the producers for each agent, for each commodity and year combination +fn get_producers_map(agents: &AgentMap, processes: &ProcessMap) -> ProducersMap { + let mut map = HashMap::new(); + for (agent_id, agent) in agents { + for (commodity_id, year) in agent.commodity_portions.keys() { + let producers = processes + .values() + .filter(move |process| { + process.active_for_year(*year) + && process.primary_output.as_ref() == Some(commodity_id) + && !process.regions.is_disjoint(&agent.regions) + }) + .cloned() + .collect_vec(); + + try_insert( + &mut map, + &(agent_id.clone(), commodity_id.clone(), *year), + Rc::new(producers), + ) + .expect("Unexpected duplicate element"); + } + } + + map } #[cfg(test)] From a3c6105ebffa6273bf2adb22ac502b3a0a127661 Mon Sep 17 00:00:00 2001 From: Alex Dewar Date: Mon, 18 May 2026 15:35:38 +0100 Subject: [PATCH 03/10] FilePatch: Allow for "replacing" files which don't exist in base model --- src/patch.rs | 25 +------------------------ 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/src/patch.rs b/src/patch.rs index 6b0b843e1..544c5a3bc 100644 --- a/src/patch.rs +++ b/src/patch.rs @@ -220,14 +220,8 @@ impl FilePatch { fn apply(&self, base_model_dir: &Path) -> Result { // Read and validate the base file path let base_path = base_model_dir.join(&self.filename); - ensure!( - base_path.exists() && base_path.is_file(), - "Base file for patching does not exist: {}", - base_path.display() - ); - // If this patch is a full replacement, validate the base file exists - // (checked above) and return the replacement content + // If this patch is a full replacement, return the replacement content. if let Some(content) = &self.replacement_content { return Ok(content.clone()); } @@ -491,23 +485,6 @@ mod tests { .with_addition("c,d"); } - #[test] - fn file_patch_with_replacement_missing_base_file_fails() { - let model_patch = ModelPatch::from_example("simple").with_file_patch( - FilePatch::new("not_a_real_file.csv").with_replacement(&["x,y", "1,2"]), - ); - - let expected = format!( - "Base file for patching does not exist: {}", - std::path::PathBuf::from("examples") - .join("simple") - .join("not_a_real_file.csv") - .display() - ); - - assert_error!(model_patch.build_to_tempdir(), expected); - } - #[test] #[should_panic(expected = "Line 1 has 2 columns but line 0 has 3")] fn file_patch_replacement_column_count_mismatch_panics() { From 7ce9b43cfecc5abf4554f8f74a7bebeb5970ea3c Mon Sep 17 00:00:00 2001 From: Alex Dewar Date: Thu, 14 May 2026 15:18:27 +0100 Subject: [PATCH 04/10] Fix: Only select producers of commodity if user inputs `all` Fixes #1290. --- src/input/agent/search_space.rs | 533 +++++++++++++++++++++++--------- 1 file changed, 383 insertions(+), 150 deletions(-) diff --git a/src/input/agent/search_space.rs b/src/input/agent/search_space.rs index 8f9250bb3..ede96b930 100644 --- a/src/input/agent/search_space.rs +++ b/src/input/agent/search_space.rs @@ -5,7 +5,7 @@ use crate::commodity::CommodityID; use crate::id::IDCollection; use crate::process::{Process, ProcessMap}; use crate::year::parse_year_str; -use anyhow::{Context, Result}; +use anyhow::{Context, Result, ensure}; use itertools::Itertools; use serde::Deserialize; use std::collections::{HashMap, HashSet}; @@ -17,83 +17,118 @@ const AGENT_SEARCH_SPACE_FILE_NAME: &str = "agent_search_space.csv"; type ProducersMap = HashMap<(AgentID, CommodityID, u32), Rc>>>; #[derive(PartialEq, Debug, Deserialize)] -struct AgentSearchSpaceRaw { - /// The agent to apply the search space to. +struct SearchSpaceEntry { + /// The agent to which this search space applies agent_id: String, - /// The commodity to apply the search space to. + /// The commodity to which this search space applies commodity_id: String, - /// The year(s) to apply the search space to. + /// The year(s) to which the search space applies years: String, - /// The processes that the agent will consider investing in. Expressed as process IDs separated - /// by semicolons or `None`, meaning all processes. + /// The processes that the agent will consider investing in. + /// + /// This can be process IDs separated by semicolons or "all". search_space: String, } -/// Search space for an agent -#[derive(Debug)] -struct AgentSearchSpace { - /// The agent to which this search space applies - agent_id: AgentID, - /// The commodity to apply the search space to - commodity_id: CommodityID, - /// The year(s) the search space applies to - years: Vec, - /// The agent's search space - search_space: Rc>>, -} +/// Add a new entry to the search space map. +/// +/// # Returns +/// +/// Returns an error if the entry is invalid or there is already an existing entry for the same +/// key in `map`. +fn add_entry_to_search_space_map( + entry: &SearchSpaceEntry, + agents: &AgentMap, + processes: &ProcessMap, + commodity_ids: &HashSet, + milestone_years: &[u32], + producers: &ProducersMap, + map: &mut HashMap, +) -> Result<()> { + let (agent_id, agent) = agents + .get_key_value(entry.agent_id.as_str()) + .with_context(|| format!("Invalid agent ID '{}'", &entry.agent_id))?; + let commodity_id = commodity_ids.get_id(&entry.commodity_id)?; + let years = parse_year_str(&entry.years, milestone_years)?; + ensure!( + years.iter().all(|year| agent + .commodity_portions + .contains_key(&(commodity_id.clone(), *year))), + "Agent '{agent_id}' is not responsible for commodity '{commodity_id}' in at least some of \ + the specified years: {years:?}", + ); -impl AgentSearchSpaceRaw { - fn into_agent_search_space( - self, - agents: &AgentMap, - processes: &ProcessMap, - commodity_ids: &HashSet, - milestone_years: &[u32], - ) -> Result { - // Parse search_space string - let search_space = Rc::new(parse_search_space_str(&self.search_space, processes)?); - - // Get commodity - let commodity_id = commodity_ids.get_id(&self.commodity_id)?; - - // Check that the year is a valid milestone year - let year = parse_year_str(&self.years, milestone_years)?; - - let agent_id = agents.get_id(&self.agent_id)?; - - Ok(AgentSearchSpace { - agent_id: agent_id.clone(), - commodity_id: commodity_id.clone(), - years: year, - search_space, - }) - } + let map = map.entry(agent.id.clone()).or_default(); + for_each_year_in_search_space( + &entry.search_space, + agent_id, + commodity_id, + &years, + processes, + producers, + |commodity_id, year, search_space| { + try_insert(map, &(commodity_id, year), search_space) + .context("Overlapping entries in search space file") + }, + )?; + + Ok(()) } -/// Parse a string representing the processes the agent will invest in. -/// -/// This string can either be: -/// * Empty, meaning all processes -/// * "all", meaning the same -/// * A list of process IDs separated by semicolons -fn parse_search_space_str(search_space: &str, processes: &ProcessMap) -> Result>> { - let search_space = search_space.trim(); - if search_space.is_empty() || search_space.eq_ignore_ascii_case("all") { - Ok(processes.values().cloned().collect()) +/// Parse the search space string and iterate over the processed search space for each year +fn for_each_year_in_search_space( + search_space: &str, + agent_id: &AgentID, + commodity_id: &CommodityID, + years: &[u32], + processes: &ProcessMap, + producers: &ProducersMap, + mut f: F, +) -> Result<()> +where + F: FnMut(CommodityID, u32, Rc>>) -> Result<()>, +{ + ensure!(!search_space.is_empty(), "No processes provided"); + + if search_space.eq_ignore_ascii_case("all") { + // Iterate over all possible producers for each year + for &year in years { + let search_space = &producers[&(agent_id.clone(), commodity_id.clone(), year)]; + f(commodity_id.clone(), year, search_space.clone())?; + } } else { - search_space + // Check each process ID in turn + let search_space: Rc> = Rc::new(search_space .split(';') - .map(|id| { + .map(|process_id_str| { let process = processes - .get(id.trim()) - .with_context(|| format!("Invalid process '{id}'"))?; + .get(process_id_str.trim()) + .with_context(|| format!("Invalid process ID '{process_id_str}'"))?; + + // Check that the specified process is a possibility for all specified years + for &year in years { + let producers = &producers[&(agent_id.clone(), commodity_id.clone(), year)]; + ensure!( + producers.iter().any(|producer| producer.id == process.id), + "Process '{}' does not produce commodity '{commodity_id}' in year {year} \ + in any of the valid regions for agent '{agent_id}'", + &process.id + ); + } + Ok(process.clone()) }) - .try_collect() + .try_collect()?); + + for &year in years { + f(commodity_id.clone(), year, search_space.clone())?; + } } + + Ok(()) } -/// Read agent search space info from the `agent_search_space.csv` file. +/// Read agent search space info from the `agent_search_spaces.csv` file. /// /// # Arguments /// @@ -114,7 +149,7 @@ pub fn read_agent_search_space( milestone_years: &[u32], ) -> Result> { let file_path = model_dir.join(AGENT_SEARCH_SPACE_FILE_NAME); - let iter = read_csv_optional::(&file_path)?; + let iter = read_csv_optional::(&file_path)?; read_agent_search_space_from_iter(iter, agents, processes, commodity_ids, milestone_years) .with_context(|| input_err_msg(&file_path)) } @@ -127,33 +162,22 @@ fn read_agent_search_space_from_iter( milestone_years: &[u32], ) -> Result> where - I: Iterator, + I: Iterator, { + let producers = get_producers_map(agents, processes); let mut search_spaces = HashMap::new(); - for search_space_raw in iter { - let search_space = search_space_raw.into_agent_search_space( + for entry in iter { + add_entry_to_search_space_map( + &entry, agents, processes, commodity_ids, milestone_years, + &producers, + &mut search_spaces, )?; - - // Get or create search space map - let map = search_spaces - .entry(search_space.agent_id) - .or_insert_with(AgentSearchSpaceMap::new); - - // Store process IDs - for year in search_space.years { - try_insert( - map, - &(search_space.commodity_id.clone(), year), - search_space.search_space.clone(), - )?; - } } - let producers = get_producers_map(agents, processes); for (agent_id, agent) in agents { // Get or create search space map let search_space = search_spaces @@ -217,98 +241,307 @@ fn get_producers_map(agents: &AgentMap, processes: &ProcessMap) -> ProducersMap #[cfg(test)] mod tests { use super::*; - use crate::fixture::{agents, assert_error, region_ids}; - use crate::process::{ - ProcessActivityLimitsMap, ProcessFlowsMap, ProcessID, ProcessInvestmentConstraintsMap, - ProcessParameterMap, + use crate::{ + fixture::{ + agent_id, assert_error, assert_patched_runs_ok_simple, + assert_validate_fails_with_simple, commodity_id, process, processes, + }, + patch::FilePatch, }; - use crate::region::RegionID; - use crate::units::ActivityPerCapacity; - use indexmap::IndexSet; + use indexmap::indexmap; use rstest::{fixture, rstest}; - use std::iter; #[fixture] - pub fn processes(region_ids: IndexSet) -> ProcessMap { - ["A", "B", "C"] - .map(|id| { - let id: ProcessID = id.into(); - let process = Process { - id: id.clone(), - description: "Description".into(), - years: 2010..=2020, - activity_limits: ProcessActivityLimitsMap::new(), - flows: ProcessFlowsMap::new(), - parameters: ProcessParameterMap::new(), - regions: region_ids.clone(), - primary_output: None, - capacity_to_activity: ActivityPerCapacity(1.0), - investment_constraints: ProcessInvestmentConstraintsMap::new(), - unit_size: None, - }; - (id, process.into()) - }) - .into_iter() - .collect() + fn process1(process: Process) -> Rc { + Rc::new(process) } #[fixture] - fn commodity_ids() -> HashSet { - iter::once("commodity1".into()).collect() + fn process2(process: Process) -> Rc { + Rc::new(Process { + id: "process2".into(), + ..process + }) } #[rstest] - fn search_space_raw_into_search_space_valid( - agents: AgentMap, - processes: ProcessMap, - commodity_ids: HashSet, + fn empty_search_space_returns_error(agent_id: AgentID, commodity_id: CommodityID) { + let result = for_each_year_in_search_space( + "", + &agent_id, + &commodity_id, + &[2020], + &ProcessMap::new(), + &ProducersMap::new(), + |_, _, _| Ok(()), + ); + assert_error!(result, "No processes provided"); + } + + #[rstest] + #[case("all")] + #[case("ALL")] + #[case("All")] + fn all_is_case_insensitive( + #[case] search_space: &str, + agent_id: AgentID, + commodity_id: CommodityID, + process: Process, ) { - // Valid search space - let raw = AgentSearchSpaceRaw { - agent_id: "agent1".into(), - commodity_id: "commodity1".into(), - years: "2020".into(), - search_space: "A;B".into(), - }; - raw.into_agent_search_space(&agents, &processes, &commodity_ids, &[2020]) - .unwrap(); + let mut producers = ProducersMap::new(); + producers.insert( + (agent_id.clone(), commodity_id.clone(), 2020), + Rc::new(vec![Rc::new(process)]), + ); + let result = for_each_year_in_search_space( + search_space, + &agent_id, + &commodity_id, + &[2020], + &ProcessMap::new(), + &producers, + |_, _, _| Ok(()), + ); + result.unwrap(); } #[rstest] - fn search_space_raw_into_search_space_invalid_commodity_id( - agents: AgentMap, - processes: ProcessMap, - commodity_ids: HashSet, + fn all_calls_f_for_each_year( + agent_id: AgentID, + commodity_id: CommodityID, + process1: Rc, + process2: Rc, ) { - // Invalid commodity ID - let raw = AgentSearchSpaceRaw { - agent_id: "agent1".into(), - commodity_id: "invalid_commodity".into(), - years: "2020".into(), - search_space: "A;B".into(), - }; - assert_error!( - raw.into_agent_search_space(&agents, &processes, &commodity_ids, &[2020]), - "Unknown ID invalid_commodity found" + let mut producers = ProducersMap::new(); + producers.insert( + (agent_id.clone(), commodity_id.clone(), 2020), + Rc::new(vec![process1.clone()]), ); + producers.insert( + (agent_id.clone(), commodity_id.clone(), 2030), + Rc::new(vec![process2.clone()]), + ); + let mut calls: Vec<(u32, Rc>>)> = Vec::new(); + for_each_year_in_search_space( + "all", + &agent_id, + &commodity_id, + &[2020, 2030], + &ProcessMap::new(), + &producers, + |_, year, search_space| { + calls.push((year, search_space)); + Ok(()) + }, + ) + .unwrap(); + assert_eq!(calls.len(), 2); + assert_eq!(calls[0].0, 2020); + assert_eq!(calls[0].1.len(), 1); + assert_eq!(calls[0].1[0].id, process1.id); + assert_eq!(calls[1].0, 2030); + assert_eq!(calls[1].1.len(), 1); + assert_eq!(calls[1].1[0].id, process2.id); } #[rstest] - fn search_space_raw_into_search_space_invalid_process_id( - agents: AgentMap, + fn specific_process_calls_f_for_each_year( + agent_id: AgentID, + commodity_id: CommodityID, processes: ProcessMap, - commodity_ids: HashSet, ) { - // Invalid process ID - let raw = AgentSearchSpaceRaw { - agent_id: "agent1".into(), - commodity_id: "commodity1".into(), - years: "2020".into(), - search_space: "A;D".into(), + let process = processes.values().next().unwrap().clone(); + let mut producers = ProducersMap::new(); + producers.insert( + (agent_id.clone(), commodity_id.clone(), 2020), + Rc::new(vec![process.clone()]), + ); + producers.insert( + (agent_id.clone(), commodity_id.clone(), 2030), + Rc::new(vec![process.clone()]), + ); + let mut calls: Vec<(u32, Rc>>)> = Vec::new(); + for_each_year_in_search_space( + "process1", + &agent_id, + &commodity_id, + &[2020, 2030], + &processes, + &producers, + |_, year, search_space| { + calls.push((year, search_space)); + Ok(()) + }, + ) + .unwrap(); + assert_eq!(calls.len(), 2); + assert_eq!(calls[0].0, 2020); + assert_eq!(calls[1].0, 2030); + assert_eq!(calls[0].1.len(), 1); + assert_eq!(calls[0].1[0].id, process.id); + // Both years receive the same Rc-wrapped search space + assert!(Rc::ptr_eq(&calls[0].1, &calls[1].1)); + } + + #[rstest] + fn multiple_process_ids_calls_f_with_all_processes( + agent_id: AgentID, + commodity_id: CommodityID, + process1: Rc, + process2: Rc, + ) { + let mut producers = ProducersMap::new(); + producers.insert( + (agent_id.clone(), commodity_id.clone(), 2020), + Rc::new(vec![process1.clone(), process2.clone()]), + ); + let processes: ProcessMap = indexmap! { + process1.id.clone() => process1.clone(), + process2.id.clone() => process2.clone(), }; - assert_error!( - raw.into_agent_search_space(&agents, &processes, &commodity_ids, &[2020]), - "Invalid process 'D'" + let mut calls: Vec<(u32, Rc>>)> = Vec::new(); + for_each_year_in_search_space( + "process1;process2", + &agent_id, + &commodity_id, + &[2020], + &processes, + &producers, + |_, year, search_space| { + calls.push((year, search_space)); + Ok(()) + }, + ) + .unwrap(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].1.len(), 2); + assert_eq!(calls[0].1[0].id, process1.id); + assert_eq!(calls[0].1[1].id, process2.id); + } + + #[test] + fn model_runs_with_search_space_file1() { + // Check that it runs with everything set to all + assert_patched_runs_ok_simple!(vec![ + FilePatch::new(AGENT_SEARCH_SPACE_FILE_NAME).with_replacement(&[ + "agent_id,commodity_id,years,search_space", + "A0_GEX,GASPRD,all,all", + "A0_GPR,GASNAT,all,all", + "A0_ELC,ELCTRI,all,all", + "A0_RES,RSHEAT,all,all", + ]) + ]); + } + + #[test] + fn model_runs_with_search_space_file2() { + // Check that it runs with a more complex file + assert_patched_runs_ok_simple!(vec![ + FilePatch::new(AGENT_SEARCH_SPACE_FILE_NAME).with_replacement(&[ + "agent_id,commodity_id,years,search_space", + "A0_GEX,GASPRD,all,GASDRV", + "A0_GPR,GASNAT,2020,all", + "A0_GPR,GASNAT,2030,GASPRC", + "A0_GPR,GASNAT,2040,all", + "A0_ELC,ELCTRI,all,all", + "A0_RES,RSHEAT,2020,RGASBR;RELCHP", + "A0_RES,RSHEAT,2030,RGASBR ; RELCHP", + "A0_RES,RSHEAT,2040,RGASBR;RELCHP", + ]) + ]); + } + + #[test] + fn not_responsible_for_commodity() { + assert_validate_fails_with_simple!( + vec![ + FilePatch::new(AGENT_SEARCH_SPACE_FILE_NAME).with_replacement(&[ + "agent_id,commodity_id,years,search_space", + "A0_GEX,ELCTRI,all,all", + ]), + ], + "Agent 'A0_GEX' is not responsible for commodity 'ELCTRI' in at least some of the \ + specified years: [2020, 2030, 2040]" + ); + } + + #[test] + fn unknown_agent_id_fails() { + assert_validate_fails_with_simple!( + vec![ + FilePatch::new(AGENT_SEARCH_SPACE_FILE_NAME).with_replacement(&[ + "agent_id,commodity_id,years,search_space", + "UNKNOWN_AGENT,GASPRD,all,all", + ]) + ], + "Invalid agent ID 'UNKNOWN_AGENT'" + ); + } + + #[test] + fn unknown_commodity_id_fails() { + assert_validate_fails_with_simple!( + vec![ + FilePatch::new(AGENT_SEARCH_SPACE_FILE_NAME).with_replacement(&[ + "agent_id,commodity_id,years,search_space", + "A0_GEX,UNKNOWN_COMMODITY,all,all", + ]) + ], + "Unknown ID UNKNOWN_COMMODITY found" + ); + } + + #[test] + fn invalid_year_fails() { + assert_validate_fails_with_simple!( + vec![ + FilePatch::new(AGENT_SEARCH_SPACE_FILE_NAME).with_replacement(&[ + "agent_id,commodity_id,years,search_space", + "A0_GEX,GASPRD,9999,all", + ]) + ], + "Invalid year: 9999" + ); + } + + #[test] + fn overlapping_entries_fails() { + assert_validate_fails_with_simple!( + vec![ + FilePatch::new(AGENT_SEARCH_SPACE_FILE_NAME).with_replacement(&[ + "agent_id,commodity_id,years,search_space", + "A0_GEX,GASPRD,2020,all", + "A0_GEX,GASPRD,2020,GASDRV", + ]) + ], + "Overlapping entries in search space file" + ); + } + + #[test] + fn invalid_search_space_fails() { + assert_validate_fails_with_simple!( + vec![ + FilePatch::new(AGENT_SEARCH_SPACE_FILE_NAME).with_replacement(&[ + "agent_id,commodity_id,years,search_space", + "A0_GEX,GASPRD,all,NONEXISTENT_PROCESS", + ]) + ], + "Invalid process ID 'NONEXISTENT_PROCESS'" + ); + } + + #[test] + fn process_not_valid_producer_fails() { + assert_validate_fails_with_simple!( + vec![ + FilePatch::new(AGENT_SEARCH_SPACE_FILE_NAME).with_replacement(&[ + "agent_id,commodity_id,years,search_space", + "A0_GEX,GASPRD,all,GASPRC", + ]) + ], + "Process 'GASPRC' does not produce commodity 'GASPRD' in year 2020 \ + in any of the valid regions for agent 'A0_GEX'" ); } } From fc3b4cc9b4ec776a587c831158a4f778b6adc4f8 Mon Sep 17 00:00:00 2001 From: Alex Dewar Date: Mon, 18 May 2026 17:12:56 +0100 Subject: [PATCH 05/10] examples/circularity: Remove empty `agent_search_space.csv` file --- examples/circularity/agent_search_space.csv | 1 - 1 file changed, 1 deletion(-) delete mode 100644 examples/circularity/agent_search_space.csv diff --git a/examples/circularity/agent_search_space.csv b/examples/circularity/agent_search_space.csv deleted file mode 100644 index 13f6cf903..000000000 --- a/examples/circularity/agent_search_space.csv +++ /dev/null @@ -1 +0,0 @@ -agent_id,commodity_id,years,search_space From 0859aa6a304b59a69085ff9f30bff31e53341612 Mon Sep 17 00:00:00 2001 From: Alex Dewar Date: Mon, 18 May 2026 16:02:33 +0100 Subject: [PATCH 06/10] Rename `agent_search_space.csv` to `agent_search_spaces.csv` for consistency Also update var names etc. where appropriate. --- ...ch_space.yaml => agent_search_spaces.yaml} | 0 src/input/agent.rs | 6 ++-- src/input/agent/search_space.rs | 28 +++++++++---------- 3 files changed, 17 insertions(+), 17 deletions(-) rename schemas/input/{agent_search_space.yaml => agent_search_spaces.yaml} (100%) diff --git a/schemas/input/agent_search_space.yaml b/schemas/input/agent_search_spaces.yaml similarity index 100% rename from schemas/input/agent_search_space.yaml rename to schemas/input/agent_search_spaces.yaml diff --git a/src/input/agent.rs b/src/input/agent.rs index aa86fb3ac..9b89e04e6 100644 --- a/src/input/agent.rs +++ b/src/input/agent.rs @@ -15,7 +15,7 @@ use std::path::Path; mod objective; use objective::read_agent_objectives; mod search_space; -use search_space::read_agent_search_space; +use search_space::read_agent_search_spaces; mod commodity_portion; use commodity_portion::read_agent_commodity_portions; @@ -58,7 +58,7 @@ pub fn read_agents( ) -> Result { let mut agents = read_agents_file(model_dir, region_ids)?; - // We read commodity portions first as they are required by `read_agent_search_space` + // We read commodity portions first as they are required by `read_agent_search_spaces` let mut agent_commodities = read_agent_commodity_portions( model_dir, &agents, @@ -74,7 +74,7 @@ pub fn read_agents( let mut objectives = read_agent_objectives(model_dir, &agents, milestone_years)?; let commodity_ids = commodities.keys().cloned().collect(); - let mut search_spaces = read_agent_search_space( + let mut search_spaces = read_agent_search_spaces( model_dir, &agents, processes, diff --git a/src/input/agent/search_space.rs b/src/input/agent/search_space.rs index ede96b930..ec6307b25 100644 --- a/src/input/agent/search_space.rs +++ b/src/input/agent/search_space.rs @@ -12,7 +12,7 @@ use std::collections::{HashMap, HashSet}; use std::path::Path; use std::rc::Rc; -const AGENT_SEARCH_SPACE_FILE_NAME: &str = "agent_search_space.csv"; +const AGENT_SEARCH_SPACES_FILE_NAME: &str = "agent_search_spaces.csv"; type ProducersMap = HashMap<(AgentID, CommodityID, u32), Rc>>>; @@ -141,20 +141,20 @@ where /// # Returns /// /// A `HashMap` mapping `AgentID` to `AgentSearchSpaceMap`. -pub fn read_agent_search_space( +pub fn read_agent_search_spaces( model_dir: &Path, agents: &AgentMap, processes: &ProcessMap, commodity_ids: &HashSet, milestone_years: &[u32], ) -> Result> { - let file_path = model_dir.join(AGENT_SEARCH_SPACE_FILE_NAME); + let file_path = model_dir.join(AGENT_SEARCH_SPACES_FILE_NAME); let iter = read_csv_optional::(&file_path)?; - read_agent_search_space_from_iter(iter, agents, processes, commodity_ids, milestone_years) + read_agent_search_spaces_from_iter(iter, agents, processes, commodity_ids, milestone_years) .with_context(|| input_err_msg(&file_path)) } -fn read_agent_search_space_from_iter( +fn read_agent_search_spaces_from_iter( iter: I, agents: &AgentMap, processes: &ProcessMap, @@ -423,7 +423,7 @@ mod tests { fn model_runs_with_search_space_file1() { // Check that it runs with everything set to all assert_patched_runs_ok_simple!(vec![ - FilePatch::new(AGENT_SEARCH_SPACE_FILE_NAME).with_replacement(&[ + FilePatch::new(AGENT_SEARCH_SPACES_FILE_NAME).with_replacement(&[ "agent_id,commodity_id,years,search_space", "A0_GEX,GASPRD,all,all", "A0_GPR,GASNAT,all,all", @@ -437,7 +437,7 @@ mod tests { fn model_runs_with_search_space_file2() { // Check that it runs with a more complex file assert_patched_runs_ok_simple!(vec![ - FilePatch::new(AGENT_SEARCH_SPACE_FILE_NAME).with_replacement(&[ + FilePatch::new(AGENT_SEARCH_SPACES_FILE_NAME).with_replacement(&[ "agent_id,commodity_id,years,search_space", "A0_GEX,GASPRD,all,GASDRV", "A0_GPR,GASNAT,2020,all", @@ -455,7 +455,7 @@ mod tests { fn not_responsible_for_commodity() { assert_validate_fails_with_simple!( vec![ - FilePatch::new(AGENT_SEARCH_SPACE_FILE_NAME).with_replacement(&[ + FilePatch::new(AGENT_SEARCH_SPACES_FILE_NAME).with_replacement(&[ "agent_id,commodity_id,years,search_space", "A0_GEX,ELCTRI,all,all", ]), @@ -469,7 +469,7 @@ mod tests { fn unknown_agent_id_fails() { assert_validate_fails_with_simple!( vec![ - FilePatch::new(AGENT_SEARCH_SPACE_FILE_NAME).with_replacement(&[ + FilePatch::new(AGENT_SEARCH_SPACES_FILE_NAME).with_replacement(&[ "agent_id,commodity_id,years,search_space", "UNKNOWN_AGENT,GASPRD,all,all", ]) @@ -482,7 +482,7 @@ mod tests { fn unknown_commodity_id_fails() { assert_validate_fails_with_simple!( vec![ - FilePatch::new(AGENT_SEARCH_SPACE_FILE_NAME).with_replacement(&[ + FilePatch::new(AGENT_SEARCH_SPACES_FILE_NAME).with_replacement(&[ "agent_id,commodity_id,years,search_space", "A0_GEX,UNKNOWN_COMMODITY,all,all", ]) @@ -495,7 +495,7 @@ mod tests { fn invalid_year_fails() { assert_validate_fails_with_simple!( vec![ - FilePatch::new(AGENT_SEARCH_SPACE_FILE_NAME).with_replacement(&[ + FilePatch::new(AGENT_SEARCH_SPACES_FILE_NAME).with_replacement(&[ "agent_id,commodity_id,years,search_space", "A0_GEX,GASPRD,9999,all", ]) @@ -508,7 +508,7 @@ mod tests { fn overlapping_entries_fails() { assert_validate_fails_with_simple!( vec![ - FilePatch::new(AGENT_SEARCH_SPACE_FILE_NAME).with_replacement(&[ + FilePatch::new(AGENT_SEARCH_SPACES_FILE_NAME).with_replacement(&[ "agent_id,commodity_id,years,search_space", "A0_GEX,GASPRD,2020,all", "A0_GEX,GASPRD,2020,GASDRV", @@ -522,7 +522,7 @@ mod tests { fn invalid_search_space_fails() { assert_validate_fails_with_simple!( vec![ - FilePatch::new(AGENT_SEARCH_SPACE_FILE_NAME).with_replacement(&[ + FilePatch::new(AGENT_SEARCH_SPACES_FILE_NAME).with_replacement(&[ "agent_id,commodity_id,years,search_space", "A0_GEX,GASPRD,all,NONEXISTENT_PROCESS", ]) @@ -535,7 +535,7 @@ mod tests { fn process_not_valid_producer_fails() { assert_validate_fails_with_simple!( vec![ - FilePatch::new(AGENT_SEARCH_SPACE_FILE_NAME).with_replacement(&[ + FilePatch::new(AGENT_SEARCH_SPACES_FILE_NAME).with_replacement(&[ "agent_id,commodity_id,years,search_space", "A0_GEX,GASPRD,all,GASPRC", ]) From c6a3d429cd9f0d8a2c44ab909b485c8e8fa7c13f Mon Sep 17 00:00:00 2001 From: Alex Dewar Date: Mon, 18 May 2026 17:10:39 +0100 Subject: [PATCH 07/10] Update release notes --- docs/release_notes/upcoming.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/release_notes/upcoming.md b/docs/release_notes/upcoming.md index 03e02c58b..cae0956a4 100644 --- a/docs/release_notes/upcoming.md +++ b/docs/release_notes/upcoming.md @@ -21,12 +21,16 @@ ready to be released, carry out the following steps: ## Breaking changes - Changed the default `pricing_strategy` for SED/SVD commodities from "shadow" to "full_average" ([#1281]) +- The `agent_search_space.csv` input file has been renamed to `agent_search_spaces.csv` for + consistency ([#1293]) ## Bug fixes - Fix misleading warning message for assets decommissioned before simulation start ([#1259]) +- Fix parsing and validation of agent search space file ([#1293]) [highs-opts-docs]: https://energysystemsmodellinglab.github.io/MUSE2/developer_guide/custom_highs_options.html [#1259]: https://github.com/EnergySystemsModellingLab/MUSE2/pull/1259 -[#1281]: https://github.com/EnergySystemsModellingLab/MUSE2/pull/1281 [#1276]: https://github.com/EnergySystemsModellingLab/MUSE2/pull/1276 +[#1281]: https://github.com/EnergySystemsModellingLab/MUSE2/pull/1281 +[#1293]: https://github.com/EnergySystemsModellingLab/MUSE2/pull/1293 From deef65dd5a6f11ac923eaf450bbcfd2139bf00e7 Mon Sep 17 00:00:00 2001 From: Alex Dewar Date: Tue, 19 May 2026 11:55:57 +0100 Subject: [PATCH 08/10] Group search space by region, as well as commodity and year Suggested by @tsmbland. --- src/agent.rs | 17 +-- src/input/agent/search_space.rs | 243 ++++++++++++++++---------------- 2 files changed, 129 insertions(+), 131 deletions(-) diff --git a/src/agent.rs b/src/agent.rs index bc64c7620..2bddde1fd 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -19,7 +19,7 @@ pub type AgentMap = IndexMap; pub type AgentCommodityPortionsMap = HashMap<(CommodityID, u32), Dimensionless>; /// A map for the agent's search space, keyed by commodity and year -pub type AgentSearchSpaceMap = HashMap<(CommodityID, u32), Rc>>>; +pub type AgentSearchSpaceMap = HashMap<(CommodityID, RegionID, u32), Rc>>>; /// A map of objectives for an agent, keyed by year. /// @@ -54,16 +54,13 @@ impl Agent { /// /// If the agent does not operate in the given region or is not responsible for the given /// commodity in the given year. - pub fn iter_search_space<'a>( - &'a self, - region_id: &'a RegionID, - commodity_id: &'a CommodityID, + pub fn iter_search_space( + &self, + region_id: &RegionID, + commodity_id: &CommodityID, year: u32, - ) -> impl Iterator> + use<'a> { - assert!(self.regions.contains(region_id)); - self.search_space[&(commodity_id.clone(), year)] - .iter() - .filter(move |process| process.regions.contains(region_id)) + ) -> impl Iterator> { + self.search_space[&(commodity_id.clone(), region_id.clone(), year)].iter() } } diff --git a/src/input/agent/search_space.rs b/src/input/agent/search_space.rs index ec6307b25..ef2b32d36 100644 --- a/src/input/agent/search_space.rs +++ b/src/input/agent/search_space.rs @@ -4,9 +4,10 @@ use crate::agent::{Agent, AgentID, AgentMap, AgentSearchSpaceMap}; use crate::commodity::CommodityID; use crate::id::IDCollection; use crate::process::{Process, ProcessMap}; +use crate::region::RegionID; use crate::year::parse_year_str; use anyhow::{Context, Result, ensure}; -use itertools::Itertools; +use itertools::{Itertools, iproduct}; use serde::Deserialize; use std::collections::{HashMap, HashSet}; use std::path::Path; @@ -14,7 +15,7 @@ use std::rc::Rc; const AGENT_SEARCH_SPACES_FILE_NAME: &str = "agent_search_spaces.csv"; -type ProducersMap = HashMap<(AgentID, CommodityID, u32), Rc>>>; +type ProducersMap = HashMap<(CommodityID, RegionID, u32), Rc>>>; #[derive(PartialEq, Debug, Deserialize)] struct SearchSpaceEntry { @@ -61,13 +62,13 @@ fn add_entry_to_search_space_map( let map = map.entry(agent.id.clone()).or_default(); for_each_year_in_search_space( &entry.search_space, - agent_id, + agent, commodity_id, &years, processes, producers, - |commodity_id, year, search_space| { - try_insert(map, &(commodity_id, year), search_space) + |commodity_id, region_id, year, search_space| { + try_insert(map, &(commodity_id, region_id, year), search_space) .context("Overlapping entries in search space file") }, )?; @@ -78,7 +79,7 @@ fn add_entry_to_search_space_map( /// Parse the search space string and iterate over the processed search space for each year fn for_each_year_in_search_space( search_space: &str, - agent_id: &AgentID, + agent: &Agent, commodity_id: &CommodityID, years: &[u32], processes: &ProcessMap, @@ -86,42 +87,57 @@ fn for_each_year_in_search_space( mut f: F, ) -> Result<()> where - F: FnMut(CommodityID, u32, Rc>>) -> Result<()>, + F: FnMut(CommodityID, RegionID, u32, Rc>>) -> Result<()>, { ensure!(!search_space.is_empty(), "No processes provided"); + let regions_and_years = iproduct!(agent.regions.iter(), years.iter().copied()); if search_space.eq_ignore_ascii_case("all") { // Iterate over all possible producers for each year - for &year in years { - let search_space = &producers[&(agent_id.clone(), commodity_id.clone(), year)]; - f(commodity_id.clone(), year, search_space.clone())?; + for (region_id, year) in regions_and_years { + let search_space = &producers[&(commodity_id.clone(), region_id.clone(), year)]; + f( + commodity_id.clone(), + region_id.clone(), + year, + search_space.clone(), + )?; } } else { // Check each process ID in turn - let search_space: Rc> = Rc::new(search_space - .split(';') - .map(|process_id_str| { - let process = processes - .get(process_id_str.trim()) - .with_context(|| format!("Invalid process ID '{process_id_str}'"))?; - - // Check that the specified process is a possibility for all specified years - for &year in years { - let producers = &producers[&(agent_id.clone(), commodity_id.clone(), year)]; - ensure!( - producers.iter().any(|producer| producer.id == process.id), - "Process '{}' does not produce commodity '{commodity_id}' in year {year} \ - in any of the valid regions for agent '{agent_id}'", - &process.id - ); - } - - Ok(process.clone()) - }) - .try_collect()?); + let search_space: Rc> = Rc::new( + search_space + .split(';') + .map(|process_id_str| { + let process = processes + .get(process_id_str.trim()) + .with_context(|| format!("Invalid process ID '{process_id_str}'"))?; + + // Check that the specified process is a possibility for all specified regions + // and years + for (region_id, year) in regions_and_years.clone() { + let producers = + &producers[&(commodity_id.clone(), region_id.clone(), year)]; + ensure!( + producers.iter().any(|producer| producer.id == process.id), + "Process '{}' does not produce commodity '{commodity_id}' in region \ + '{region_id}' in year {year}", + &process.id + ); + } + + Ok(process.clone()) + }) + .try_collect()?, + ); - for &year in years { - f(commodity_id.clone(), year, search_space.clone())?; + for (region_id, year) in regions_and_years { + f( + commodity_id.clone(), + region_id.clone(), + year, + search_space.clone(), + )?; } } @@ -204,37 +220,42 @@ fn fill_missing_search_space_entries( assert!(!agent.commodity_portions.is_empty()); for (commodity_id, year) in agent.commodity_portions.keys() { - let key = (commodity_id.clone(), *year); - search_space - .entry(key) - .or_insert_with(|| producers[&(agent.id.clone(), commodity_id.clone(), *year)].clone()); + for region_id in &agent.regions { + let key = (commodity_id.clone(), region_id.clone(), *year); + search_space + .entry(key.clone()) + .or_insert_with(|| producers[&key].clone()); + } } } -/// Get a map of all the producers for each agent, for each commodity and year combination +/// Get a map of all the producers for each commodity, region and year combination fn get_producers_map(agents: &AgentMap, processes: &ProcessMap) -> ProducersMap { - let mut map = HashMap::new(); - for (agent_id, agent) in agents { + // First, work out every combination of commodity/region/year we care about and populate map + // with empty entries + let mut map = ProducersMap::new(); + for agent in agents.values() { for (commodity_id, year) in agent.commodity_portions.keys() { - let producers = processes - .values() - .filter(move |process| { - process.active_for_year(*year) - && process.primary_output.as_ref() == Some(commodity_id) - && !process.regions.is_disjoint(&agent.regions) - }) - .cloned() - .collect_vec(); - - try_insert( - &mut map, - &(agent_id.clone(), commodity_id.clone(), *year), - Rc::new(producers), - ) - .expect("Unexpected duplicate element"); + for region_id in &agent.regions { + map.entry((commodity_id.clone(), region_id.clone(), *year)) + .or_default(); + } } } + // Now go through map and fill up the Vecs with the relevant processes + for ((commodity_id, region_id, year), vec) in &mut map { + let producers = processes + .values() + .filter(move |process| { + process.active_for_year(*year) + && process.primary_output.as_ref() == Some(commodity_id) + && process.regions.contains(region_id) + }) + .cloned(); + Rc::get_mut(vec).unwrap().extend(producers); + } + map } @@ -242,14 +263,17 @@ fn get_producers_map(agents: &AgentMap, processes: &ProcessMap) -> ProducersMap mod tests { use super::*; use crate::{ + agent::{AgentCommodityPortionsMap, AgentObjectiveMap, DecisionRule}, fixture::{ agent_id, assert_error, assert_patched_runs_ok_simple, - assert_validate_fails_with_simple, commodity_id, process, processes, + assert_validate_fails_with_simple, commodity_id, process, processes, region_id, }, patch::FilePatch, }; use indexmap::indexmap; + use map_macro::hash_map; use rstest::{fixture, rstest}; + use std::iter; #[fixture] fn process1(process: Process) -> Rc { @@ -264,72 +288,54 @@ mod tests { }) } + #[fixture] + fn agent(agent_id: AgentID, region_id: RegionID) -> Agent { + Agent { + id: agent_id, + description: String::new(), + commodity_portions: AgentCommodityPortionsMap::new(), + search_space: AgentSearchSpaceMap::new(), + decision_rule: DecisionRule::Single, + regions: iter::once(region_id).collect(), + objectives: AgentObjectiveMap::new(), + } + } + #[rstest] - fn empty_search_space_returns_error(agent_id: AgentID, commodity_id: CommodityID) { + fn empty_search_space_returns_error(agent: Agent, commodity_id: CommodityID) { let result = for_each_year_in_search_space( "", - &agent_id, + &agent, &commodity_id, &[2020], &ProcessMap::new(), &ProducersMap::new(), - |_, _, _| Ok(()), + |_, _, _, _| Ok(()), ); assert_error!(result, "No processes provided"); } - #[rstest] - #[case("all")] - #[case("ALL")] - #[case("All")] - fn all_is_case_insensitive( - #[case] search_space: &str, - agent_id: AgentID, - commodity_id: CommodityID, - process: Process, - ) { - let mut producers = ProducersMap::new(); - producers.insert( - (agent_id.clone(), commodity_id.clone(), 2020), - Rc::new(vec![Rc::new(process)]), - ); - let result = for_each_year_in_search_space( - search_space, - &agent_id, - &commodity_id, - &[2020], - &ProcessMap::new(), - &producers, - |_, _, _| Ok(()), - ); - result.unwrap(); - } - #[rstest] fn all_calls_f_for_each_year( - agent_id: AgentID, + agent: Agent, commodity_id: CommodityID, + region_id: RegionID, process1: Rc, process2: Rc, ) { - let mut producers = ProducersMap::new(); - producers.insert( - (agent_id.clone(), commodity_id.clone(), 2020), - Rc::new(vec![process1.clone()]), - ); - producers.insert( - (agent_id.clone(), commodity_id.clone(), 2030), - Rc::new(vec![process2.clone()]), - ); + let producers = hash_map! { + (commodity_id.clone(), region_id.clone(), 2020) => Rc::new(vec![process1.clone()]), + (commodity_id.clone(), region_id.clone(), 2030) => Rc::new(vec![process2.clone()]) + }; let mut calls: Vec<(u32, Rc>>)> = Vec::new(); for_each_year_in_search_space( "all", - &agent_id, + &agent, &commodity_id, &[2020, 2030], &ProcessMap::new(), &producers, - |_, year, search_space| { + |_, _, year, search_space| { calls.push((year, search_space)); Ok(()) }, @@ -346,29 +352,26 @@ mod tests { #[rstest] fn specific_process_calls_f_for_each_year( - agent_id: AgentID, + agent: Agent, commodity_id: CommodityID, + region_id: RegionID, processes: ProcessMap, ) { let process = processes.values().next().unwrap().clone(); - let mut producers = ProducersMap::new(); - producers.insert( - (agent_id.clone(), commodity_id.clone(), 2020), - Rc::new(vec![process.clone()]), - ); - producers.insert( - (agent_id.clone(), commodity_id.clone(), 2030), - Rc::new(vec![process.clone()]), - ); + let value = Rc::new(vec![process.clone()]); + let producers = hash_map! { + (commodity_id.clone(), region_id.clone(), 2020) => value.clone(), + (commodity_id.clone(), region_id.clone(), 2030) => value + }; let mut calls: Vec<(u32, Rc>>)> = Vec::new(); for_each_year_in_search_space( "process1", - &agent_id, + &agent, &commodity_id, &[2020, 2030], &processes, &producers, - |_, year, search_space| { + |_, _, year, search_space| { calls.push((year, search_space)); Ok(()) }, @@ -385,16 +388,15 @@ mod tests { #[rstest] fn multiple_process_ids_calls_f_with_all_processes( - agent_id: AgentID, + agent: Agent, commodity_id: CommodityID, + region_id: RegionID, process1: Rc, process2: Rc, ) { - let mut producers = ProducersMap::new(); - producers.insert( - (agent_id.clone(), commodity_id.clone(), 2020), - Rc::new(vec![process1.clone(), process2.clone()]), - ); + let producers = hash_map! { + (commodity_id.clone(), region_id.clone(), 2020) => Rc::new(vec![process1.clone(), process2.clone()]) + }; let processes: ProcessMap = indexmap! { process1.id.clone() => process1.clone(), process2.id.clone() => process2.clone(), @@ -402,12 +404,12 @@ mod tests { let mut calls: Vec<(u32, Rc>>)> = Vec::new(); for_each_year_in_search_space( "process1;process2", - &agent_id, + &agent, &commodity_id, &[2020], &processes, &producers, - |_, year, search_space| { + |_, _, year, search_space| { calls.push((year, search_space)); Ok(()) }, @@ -540,8 +542,7 @@ mod tests { "A0_GEX,GASPRD,all,GASPRC", ]) ], - "Process 'GASPRC' does not produce commodity 'GASPRD' in year 2020 \ - in any of the valid regions for agent 'A0_GEX'" + "Process 'GASPRC' does not produce commodity 'GASPRD' in region 'GBR' in year 2020" ); } } From be05852cb0e87003def69e84ca48b84599e5e344 Mon Sep 17 00:00:00 2001 From: Alex Dewar Date: Tue, 19 May 2026 12:12:42 +0100 Subject: [PATCH 09/10] Fix text in search spaces schema --- schemas/input/agent_search_spaces.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/schemas/input/agent_search_spaces.yaml b/schemas/input/agent_search_spaces.yaml index 3980c926b..657eb097b 100644 --- a/schemas/input/agent_search_spaces.yaml +++ b/schemas/input/agent_search_spaces.yaml @@ -25,5 +25,5 @@ fields: type: string description: The processes in which this agent will invest notes: | - One or more process IDs separated by semicolons. If this field is empty or `all`, all - processes will be considered. + One or more process IDs separated by semicolons or `all`, meaning all processes + producing the relevant commodity in the given year in a relevant region. From 0b562347453bf7540bce9348d834391caeb083e3 Mon Sep 17 00:00:00 2001 From: Alex Dewar Date: Tue, 19 May 2026 12:29:04 +0100 Subject: [PATCH 10/10] Fix doc comment for `AgentSearchSpaceMap` Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/agent.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agent.rs b/src/agent.rs index 2bddde1fd..a8178e01a 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -18,7 +18,7 @@ pub type AgentMap = IndexMap; /// A map of commodity portions for an agent, keyed by commodity and year pub type AgentCommodityPortionsMap = HashMap<(CommodityID, u32), Dimensionless>; -/// A map for the agent's search space, keyed by commodity and year +/// A map for the agent's search space, keyed by commodity, region, and year pub type AgentSearchSpaceMap = HashMap<(CommodityID, RegionID, u32), Rc>>>; /// A map of objectives for an agent, keyed by year.