muse2/input/agent/
search_space.rs

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