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 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 diff --git a/schemas/input/agent_search_space.yaml b/schemas/input/agent_search_spaces.yaml similarity index 86% rename from schemas/input/agent_search_space.yaml rename to schemas/input/agent_search_spaces.yaml index 3980c926b..657eb097b 100644 --- a/schemas/input/agent_search_space.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. diff --git a/src/agent.rs b/src/agent.rs index 4be6efb45..a8178e01a 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}; @@ -18,8 +18,8 @@ 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 -pub type AgentSearchSpaceMap = HashMap<(CommodityID, u32), Rc>>>; +/// 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. /// @@ -48,19 +48,19 @@ 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>( - &'a self, + /// 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( + &self, region_id: &RegionID, - commodity_id: &'a CommodityID, + commodity_id: &CommodityID, year: u32, - ) -> impl Iterator> + use<'a> { - let flows_key = (region_id.clone(), year); - self.search_space[&(commodity_id.clone(), year)] - .iter() - .filter(move |process| { - process.flows[&flows_key][commodity_id].direction() == FlowDirection::Output - }) + ) -> impl Iterator> { + self.search_space[&(commodity_id.clone(), region_id.clone(), year)].iter() } } 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 7630f745e..ef2b32d36 100644 --- a/src/input/agent/search_space.rs +++ b/src/input/agent/search_space.rs @@ -4,94 +4,147 @@ 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}; -use itertools::Itertools; +use anyhow::{Context, Result, ensure}; +use itertools::{Itertools, iproduct}; use serde::Deserialize; 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<(CommodityID, RegionID, 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, + commodity_id, + &years, + processes, + producers, + |commodity_id, region_id, year, search_space| { + try_insert(map, &(commodity_id, region_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: &Agent, + commodity_id: &CommodityID, + years: &[u32], + processes: &ProcessMap, + producers: &ProducersMap, + mut f: F, +) -> Result<()> +where + 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 (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 { - search_space - .split(';') - .map(|id| { - let process = processes - .get(id.trim()) - .with_context(|| format!("Invalid process '{id}'"))?; - Ok(process.clone()) - }) - .try_collect() + // 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 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 (region_id, year) in regions_and_years { + f( + commodity_id.clone(), + region_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 /// @@ -104,20 +157,20 @@ fn parse_search_space_str(search_space: &str, processes: &ProcessMap) -> Result< /// # 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 iter = read_csv_optional::(&file_path)?; - read_agent_search_space_from_iter(iter, agents, processes, commodity_ids, milestone_years) + let file_path = model_dir.join(AGENT_SEARCH_SPACES_FILE_NAME); + let iter = read_csv_optional::(&file_path)?; + 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, @@ -125,30 +178,20 @@ 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(), - )?; - } } for (agent_id, agent) in agents { @@ -158,7 +201,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,129 +213,336 @@ 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 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(|| { - Rc::new(get_all_producers(processes, commodity_id, *year).collect()) - }); + 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 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 commodity, region and year combination +fn get_producers_map(agents: &AgentMap, processes: &ProcessMap) -> ProducersMap { + // 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() { + 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 } #[cfg(test)] mod tests { use super::*; - use crate::fixture::{agents, assert_error, region_ids}; - use crate::process::{ - ProcessActivityLimitsMap, ProcessFlowsMap, ProcessID, ProcessInvestmentConstraintsMap, - ProcessParameterMap, + 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, region_id, + }, + patch::FilePatch, }; - use crate::region::RegionID; - use crate::units::ActivityPerCapacity; - use indexmap::IndexSet; + use indexmap::indexmap; + use map_macro::hash_map; 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 + }) + } + + #[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 search_space_raw_into_search_space_valid( - agents: AgentMap, - processes: ProcessMap, - commodity_ids: HashSet, + fn empty_search_space_returns_error(agent: Agent, commodity_id: CommodityID) { + let result = for_each_year_in_search_space( + "", + &agent, + &commodity_id, + &[2020], + &ProcessMap::new(), + &ProducersMap::new(), + |_, _, _, _| Ok(()), + ); + assert_error!(result, "No processes provided"); + } + + #[rstest] + fn all_calls_f_for_each_year( + agent: Agent, + commodity_id: CommodityID, + region_id: RegionID, + process1: Rc, + process2: Rc, ) { - // Valid search space - let raw = AgentSearchSpaceRaw { - agent_id: "agent1".into(), - commodity_id: "commodity1".into(), - years: "2020".into(), - search_space: "A;B".into(), + 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()]) }; - raw.into_agent_search_space(&agents, &processes, &commodity_ids, &[2020]) - .unwrap(); + let mut calls: Vec<(u32, Rc>>)> = Vec::new(); + for_each_year_in_search_space( + "all", + &agent, + &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_commodity_id( - agents: AgentMap, + fn specific_process_calls_f_for_each_year( + agent: Agent, + commodity_id: CommodityID, + region_id: RegionID, processes: ProcessMap, - commodity_ids: HashSet, ) { - // Invalid commodity ID - let raw = AgentSearchSpaceRaw { - agent_id: "agent1".into(), - commodity_id: "invalid_commodity".into(), - years: "2020".into(), - search_space: "A;B".into(), + let process = processes.values().next().unwrap().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 }; - assert_error!( - raw.into_agent_search_space(&agents, &processes, &commodity_ids, &[2020]), - "Unknown ID invalid_commodity found" - ); + let mut calls: Vec<(u32, Rc>>)> = Vec::new(); + for_each_year_in_search_space( + "process1", + &agent, + &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 search_space_raw_into_search_space_invalid_process_id( - agents: AgentMap, - processes: ProcessMap, - commodity_ids: HashSet, + fn multiple_process_ids_calls_f_with_all_processes( + agent: Agent, + commodity_id: CommodityID, + region_id: RegionID, + process1: Rc, + process2: Rc, ) { - // Invalid process ID - let raw = AgentSearchSpaceRaw { - agent_id: "agent1".into(), - commodity_id: "commodity1".into(), - years: "2020".into(), - search_space: "A;D".into(), + let producers = hash_map! { + (commodity_id.clone(), region_id.clone(), 2020) => Rc::new(vec![process1.clone(), process2.clone()]) }; - assert_error!( - raw.into_agent_search_space(&agents, &processes, &commodity_ids, &[2020]), - "Invalid process 'D'" + let processes: ProcessMap = indexmap! { + process1.id.clone() => process1.clone(), + process2.id.clone() => process2.clone(), + }; + let mut calls: Vec<(u32, Rc>>)> = Vec::new(); + for_each_year_in_search_space( + "process1;process2", + &agent, + &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_SPACES_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_SPACES_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_SPACES_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_SPACES_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_SPACES_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_SPACES_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_SPACES_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_SPACES_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_SPACES_FILE_NAME).with_replacement(&[ + "agent_id,commodity_id,years,search_space", + "A0_GEX,GASPRD,all,GASPRC", + ]) + ], + "Process 'GASPRC' does not produce commodity 'GASPRD' in region 'GBR' in year 2020" ); } } 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() { 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)