muse2/input/agent/
search_space.rs

1//! Code for reading the agent search space CSV file.
2use super::super::{input_err_msg, read_csv_optional, try_insert};
3use crate::agent::{Agent, AgentID, AgentMap, AgentSearchSpaceMap};
4use crate::commodity::CommodityID;
5use crate::id::IDCollection;
6use crate::process::{Process, ProcessMap};
7use crate::year::parse_year_str;
8use anyhow::{Context, Result};
9use itertools::Itertools;
10use serde::Deserialize;
11use std::collections::{HashMap, HashSet};
12use std::path::Path;
13use std::rc::Rc;
14
15const AGENT_SEARCH_SPACE_FILE_NAME: &str = "agent_search_space.csv";
16
17#[derive(PartialEq, Debug, Deserialize)]
18struct AgentSearchSpaceRaw {
19    /// The agent to apply the search space to.
20    agent_id: String,
21    /// The commodity to apply the search space to.
22    commodity_id: String,
23    /// The year(s) to apply the search space to.
24    years: String,
25    /// The processes that the agent will consider investing in. Expressed as process IDs separated
26    /// by semicolons or `None`, meaning all processes.
27    search_space: String,
28}
29
30/// Search space for an agent
31#[derive(Debug)]
32struct AgentSearchSpace {
33    /// The agent to which this search space applies
34    agent_id: AgentID,
35    /// The commodity to apply the search space to
36    commodity_id: CommodityID,
37    /// The year(s) the objective is relevant for
38    years: Vec<u32>,
39    /// The agent's search space
40    search_space: Rc<Vec<Rc<Process>>>,
41}
42
43impl AgentSearchSpaceRaw {
44    fn into_agent_search_space(
45        self,
46        agents: &AgentMap,
47        processes: &ProcessMap,
48        commodity_ids: &HashSet<CommodityID>,
49        milestone_years: &[u32],
50    ) -> Result<AgentSearchSpace> {
51        // Parse search_space string
52        let search_space = Rc::new(parse_search_space_str(&self.search_space, processes)?);
53
54        // Get commodity
55        let commodity_id = commodity_ids.get_id(&self.commodity_id)?;
56
57        // Check that the year is a valid milestone year
58        let year = parse_year_str(&self.years, milestone_years)?;
59
60        let agent_id = agents.get_id(&self.agent_id)?;
61
62        Ok(AgentSearchSpace {
63            agent_id: agent_id.clone(),
64            commodity_id: commodity_id.clone(),
65            years: year,
66            search_space,
67        })
68    }
69}
70
71/// Parse a string representing the processes the agent will invest in.
72///
73/// This string can either be:
74///  * Empty, meaning all processes
75///  * "all", meaning the same
76///  * A list of process IDs separated by semicolons
77fn parse_search_space_str(search_space: &str, processes: &ProcessMap) -> Result<Vec<Rc<Process>>> {
78    let search_space = search_space.trim();
79    if search_space.is_empty() || search_space.eq_ignore_ascii_case("all") {
80        Ok(processes.values().cloned().collect())
81    } else {
82        search_space
83            .split(';')
84            .map(|id| {
85                let process = processes
86                    .get(id.trim())
87                    .with_context(|| format!("Invalid process '{id}'"))?;
88                Ok(process.clone())
89            })
90            .try_collect()
91    }
92}
93
94/// Read agent search space info from the `agent_search_space.csv` file.
95///
96/// # Arguments
97///
98/// * `model_dir` - Folder containing model configuration files
99/// * `agents` - Map of agents
100/// * `processes` - Map of processes
101/// * `commodity_ids` - All possible valid commodity IDs
102/// * `milestone_years` - The milestone years of the simulation
103///
104/// # Returns
105///
106/// A map of Agents, with the agent ID as the key
107pub fn read_agent_search_space(
108    model_dir: &Path,
109    agents: &AgentMap,
110    processes: &ProcessMap,
111    commodity_ids: &HashSet<CommodityID>,
112    milestone_years: &[u32],
113) -> Result<HashMap<AgentID, AgentSearchSpaceMap>> {
114    let file_path = model_dir.join(AGENT_SEARCH_SPACE_FILE_NAME);
115    let iter = read_csv_optional::<AgentSearchSpaceRaw>(&file_path)?;
116    read_agent_search_space_from_iter(iter, agents, processes, commodity_ids, milestone_years)
117        .with_context(|| input_err_msg(&file_path))
118}
119
120fn read_agent_search_space_from_iter<I>(
121    iter: I,
122    agents: &AgentMap,
123    processes: &ProcessMap,
124    commodity_ids: &HashSet<CommodityID>,
125    milestone_years: &[u32],
126) -> Result<HashMap<AgentID, AgentSearchSpaceMap>>
127where
128    I: Iterator<Item = AgentSearchSpaceRaw>,
129{
130    let mut search_spaces = HashMap::new();
131    for search_space_raw in iter {
132        let search_space = search_space_raw.into_agent_search_space(
133            agents,
134            processes,
135            commodity_ids,
136            milestone_years,
137        )?;
138
139        // Get or create search space map
140        let map = search_spaces
141            .entry(search_space.agent_id)
142            .or_insert_with(AgentSearchSpaceMap::new);
143
144        // Store process IDs
145        for year in search_space.years {
146            try_insert(
147                map,
148                &(search_space.commodity_id.clone(), year),
149                search_space.search_space.clone(),
150            )?;
151        }
152    }
153
154    for (agent_id, agent) in agents {
155        // Get or create search space map
156        let search_space = search_spaces
157            .entry(agent_id.clone())
158            .or_insert_with(AgentSearchSpaceMap::new);
159
160        // Add missing entries for commodities/years
161        fill_missing_search_space_entries(agent, processes, search_space);
162    }
163
164    Ok(search_spaces)
165}
166
167/// Fill missing entries for the search space map for all commodities/milestone years.
168///
169/// The entries are filled will all producers of the given commodity in the given year. Only
170/// producers which operate in at least one of the same regions as the agent are considered.
171fn fill_missing_search_space_entries(
172    agent: &Agent,
173    processes: &ProcessMap,
174    search_space: &mut AgentSearchSpaceMap,
175) {
176    // Agents all have commodity portions and this field should have been assigned already
177    assert!(!agent.commodity_portions.is_empty());
178
179    for (commodity_id, year) in agent.commodity_portions.keys() {
180        let key = (commodity_id.clone(), *year);
181        search_space.entry(key).or_insert_with(|| {
182            Rc::new(get_all_producers(processes, commodity_id, *year).collect())
183        });
184    }
185}
186
187/// Get all processes active in the relevant year and regions which produce the given commodity
188fn get_all_producers<'a>(
189    processes: &'a ProcessMap,
190    commodity_id: &'a CommodityID,
191    year: u32,
192) -> impl Iterator<Item = Rc<Process>> + 'a {
193    processes
194        .values()
195        .filter(move |process| {
196            process.active_for_year(year) && process.primary_output.as_ref() == Some(commodity_id)
197        })
198        .cloned()
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use crate::fixture::{agents, assert_error, region_ids};
205    use crate::process::{
206        ProcessActivityLimitsMap, ProcessFlowsMap, ProcessID, ProcessParameterMap,
207    };
208    use crate::region::RegionID;
209    use crate::units::ActivityPerCapacity;
210    use indexmap::IndexSet;
211    use rstest::{fixture, rstest};
212    use std::iter;
213
214    #[fixture]
215    pub fn processes(region_ids: IndexSet<RegionID>) -> ProcessMap {
216        ["A", "B", "C"]
217            .map(|id| {
218                let id: ProcessID = id.into();
219                let process = Process {
220                    id: id.clone(),
221                    description: "Description".into(),
222                    years: vec![2010, 2020],
223                    activity_limits: ProcessActivityLimitsMap::new(),
224                    flows: ProcessFlowsMap::new(),
225                    parameters: ProcessParameterMap::new(),
226                    regions: region_ids.clone(),
227                    primary_output: None,
228                    capacity_to_activity: ActivityPerCapacity(1.0),
229                };
230                (id, process.into())
231            })
232            .into_iter()
233            .collect()
234    }
235
236    #[fixture]
237    fn commodity_ids() -> HashSet<CommodityID> {
238        iter::once("commodity1".into()).collect()
239    }
240
241    #[rstest]
242    fn test_search_space_raw_into_search_space_valid(
243        agents: AgentMap,
244        processes: ProcessMap,
245        commodity_ids: HashSet<CommodityID>,
246    ) {
247        // Valid search space
248        let raw = AgentSearchSpaceRaw {
249            agent_id: "agent1".into(),
250            commodity_id: "commodity1".into(),
251            years: "2020".into(),
252            search_space: "A;B".into(),
253        };
254        assert!(
255            raw.into_agent_search_space(&agents, &processes, &commodity_ids, &[2020])
256                .is_ok()
257        );
258    }
259
260    #[rstest]
261    fn test_search_space_raw_into_search_space_invalid_commodity_id(
262        agents: AgentMap,
263        processes: ProcessMap,
264        commodity_ids: HashSet<CommodityID>,
265    ) {
266        // Invalid commodity ID
267        let raw = AgentSearchSpaceRaw {
268            agent_id: "agent1".into(),
269            commodity_id: "invalid_commodity".into(),
270            years: "2020".into(),
271            search_space: "A;B".into(),
272        };
273        assert_error!(
274            raw.into_agent_search_space(&agents, &processes, &commodity_ids, &[2020]),
275            "Unknown ID invalid_commodity found"
276        );
277    }
278
279    #[rstest]
280    fn test_search_space_raw_into_search_space_invalid_process_id(
281        agents: AgentMap,
282        processes: ProcessMap,
283        commodity_ids: HashSet<CommodityID>,
284    ) {
285        // Invalid process ID
286        let raw = AgentSearchSpaceRaw {
287            agent_id: "agent1".into(),
288            commodity_id: "commodity1".into(),
289            years: "2020".into(),
290            search_space: "A;D".into(),
291        };
292        assert_error!(
293            raw.into_agent_search_space(&agents, &processes, &commodity_ids, &[2020]),
294            "Invalid process 'D'"
295        );
296    }
297}