Skip to main content

muse2/input/agent/
search_space.rs

1//! Code for reading agent search space data from a 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::{GetIDValue, IDCollection};
6use crate::input::parse_year_str;
7use crate::process::{Process, ProcessMap};
8use crate::region::RegionID;
9use anyhow::{Context, Result, ensure};
10use itertools::{Itertools, iproduct};
11use serde::Deserialize;
12use std::collections::{HashMap, HashSet};
13use std::path::Path;
14use std::rc::Rc;
15
16const AGENT_SEARCH_SPACES_FILE_NAME: &str = "agent_search_spaces.csv";
17
18type ProducersMap = HashMap<(CommodityID, RegionID, u32), Rc<Vec<Rc<Process>>>>;
19
20#[derive(PartialEq, Debug, Deserialize)]
21struct SearchSpaceEntry {
22    /// The agent to which this search space applies
23    agent_id: String,
24    /// The commodity to which this search space applies
25    commodity_id: String,
26    /// The year(s) to which the search space applies
27    years: String,
28    /// The processes that the agent will consider investing in.
29    ///
30    /// This can be process IDs separated by semicolons or "all".
31    search_space: String,
32}
33
34/// Add a new entry to the search space map.
35///
36/// # Returns
37///
38/// Returns an error if the entry is invalid or there is already an existing entry for the same
39/// key in `map`.
40fn add_entry_to_search_space_map(
41    entry: &SearchSpaceEntry,
42    agents: &AgentMap,
43    processes: &ProcessMap,
44    commodity_ids: &HashSet<CommodityID>,
45    milestone_years: &[u32],
46    producers: &ProducersMap,
47    map: &mut HashMap<AgentID, AgentSearchSpaceMap>,
48) -> Result<()> {
49    let (agent_id, agent) = agents.get_id_value(&entry.agent_id)?;
50    let commodity_id = commodity_ids.get_id(&entry.commodity_id)?;
51    let years = parse_year_str(&entry.years, milestone_years)?;
52    ensure!(
53        years.iter().all(|year| agent
54            .commodity_portions
55            .contains_key(&(commodity_id.clone(), *year))),
56        "Agent '{agent_id}' is not responsible for commodity '{commodity_id}' in at least some of \
57        the specified years: {years:?}",
58    );
59
60    let map = map.entry(agent.id.clone()).or_default();
61    for_each_year_in_search_space(
62        &entry.search_space,
63        agent,
64        commodity_id,
65        &years,
66        processes,
67        producers,
68        |commodity_id, region_id, year, search_space| {
69            try_insert(map, &(commodity_id, region_id, year), search_space)
70                .context("Overlapping entries in search space file")
71        },
72    )?;
73
74    Ok(())
75}
76
77/// Parse the search space string and iterate over the processed search space for each year
78fn for_each_year_in_search_space<F>(
79    search_space: &str,
80    agent: &Agent,
81    commodity_id: &CommodityID,
82    years: &[u32],
83    processes: &ProcessMap,
84    producers: &ProducersMap,
85    mut f: F,
86) -> Result<()>
87where
88    F: FnMut(CommodityID, RegionID, u32, Rc<Vec<Rc<Process>>>) -> Result<()>,
89{
90    ensure!(!search_space.is_empty(), "No processes provided");
91
92    let regions_and_years = iproduct!(agent.regions.iter(), years.iter().copied());
93    if search_space.eq_ignore_ascii_case("all") {
94        // Iterate over all possible producers for each year
95        for (region_id, year) in regions_and_years {
96            let search_space = &producers[&(commodity_id.clone(), region_id.clone(), year)];
97            f(
98                commodity_id.clone(),
99                region_id.clone(),
100                year,
101                search_space.clone(),
102            )?;
103        }
104    } else {
105        // Check each process ID in turn
106        let search_space: Rc<Vec<_>> = Rc::new(
107            search_space
108                .split(';')
109                .map(|process_id_str| {
110                    let (_, process) = processes.get_id_value(process_id_str.trim())?;
111
112                    // Check that the specified process is a possibility for all specified regions
113                    // and years
114                    for (region_id, year) in regions_and_years.clone() {
115                        let producers =
116                            &producers[&(commodity_id.clone(), region_id.clone(), year)];
117                        ensure!(
118                            producers.iter().any(|producer| producer.id == process.id),
119                            "Process '{}' does not produce commodity '{commodity_id}' in region \
120                            '{region_id}' in year {year}",
121                            &process.id
122                        );
123                    }
124
125                    Ok(process.clone())
126                })
127                .try_collect()?,
128        );
129
130        for (region_id, year) in regions_and_years {
131            f(
132                commodity_id.clone(),
133                region_id.clone(),
134                year,
135                search_space.clone(),
136            )?;
137        }
138    }
139
140    Ok(())
141}
142
143/// Read agent search space info from the `agent_search_spaces.csv` file.
144///
145/// # Arguments
146///
147/// * `model_dir` - Folder containing model configuration files
148/// * `agents` - Map of agents
149/// * `processes` - Map of processes
150/// * `commodity_ids` - All possible valid commodity IDs
151/// * `milestone_years` - The milestone years of the simulation
152///
153/// # Returns
154///
155/// A `HashMap` mapping `AgentID` to `AgentSearchSpaceMap`.
156pub fn read_agent_search_spaces(
157    model_dir: &Path,
158    agents: &AgentMap,
159    processes: &ProcessMap,
160    commodity_ids: &HashSet<CommodityID>,
161    milestone_years: &[u32],
162) -> Result<HashMap<AgentID, AgentSearchSpaceMap>> {
163    let file_path = model_dir.join(AGENT_SEARCH_SPACES_FILE_NAME);
164    let iter = read_csv_optional::<SearchSpaceEntry>(&file_path)?;
165    read_agent_search_spaces_from_iter(iter, agents, processes, commodity_ids, milestone_years)
166        .with_context(|| input_err_msg(&file_path))
167}
168
169fn read_agent_search_spaces_from_iter<I>(
170    iter: I,
171    agents: &AgentMap,
172    processes: &ProcessMap,
173    commodity_ids: &HashSet<CommodityID>,
174    milestone_years: &[u32],
175) -> Result<HashMap<AgentID, AgentSearchSpaceMap>>
176where
177    I: Iterator<Item = SearchSpaceEntry>,
178{
179    let producers = get_producers_map(agents, processes);
180    let mut search_spaces = HashMap::new();
181    for entry in iter {
182        add_entry_to_search_space_map(
183            &entry,
184            agents,
185            processes,
186            commodity_ids,
187            milestone_years,
188            &producers,
189            &mut search_spaces,
190        )?;
191    }
192
193    for (agent_id, agent) in agents {
194        // Get or create search space map
195        let search_space = search_spaces
196            .entry(agent_id.clone())
197            .or_insert_with(AgentSearchSpaceMap::new);
198
199        // Add missing entries for commodities/years
200        fill_missing_search_space_entries(agent, &producers, search_space);
201    }
202
203    Ok(search_spaces)
204}
205
206/// Fill missing entries for the search space map for all commodities/milestone years.
207///
208/// The entries are filled will all producers of the given commodity in the given year. Only
209/// producers which operate in at least one of the same regions as the agent are considered.
210fn fill_missing_search_space_entries(
211    agent: &Agent,
212    producers: &ProducersMap,
213    search_space: &mut AgentSearchSpaceMap,
214) {
215    // Agents all have commodity portions and this field should have been assigned already
216    assert!(!agent.commodity_portions.is_empty());
217
218    for (commodity_id, year) in agent.commodity_portions.keys() {
219        for region_id in &agent.regions {
220            let key = (commodity_id.clone(), region_id.clone(), *year);
221            search_space
222                .entry(key.clone())
223                .or_insert_with(|| producers[&key].clone());
224        }
225    }
226}
227
228/// Get a map of all the producers for each commodity, region and year combination
229fn get_producers_map(agents: &AgentMap, processes: &ProcessMap) -> ProducersMap {
230    // First, work out every combination of commodity/region/year we care about and populate map
231    // with empty entries
232    let mut map = ProducersMap::new();
233    for agent in agents.values() {
234        for (commodity_id, year) in agent.commodity_portions.keys() {
235            for region_id in &agent.regions {
236                map.entry((commodity_id.clone(), region_id.clone(), *year))
237                    .or_default();
238            }
239        }
240    }
241
242    // Now go through map and fill up the Vecs with the relevant processes
243    for ((commodity_id, region_id, year), vec) in &mut map {
244        let producers = processes
245            .values()
246            .filter(move |process| {
247                process.active_for_year(*year)
248                    && process.primary_output.as_ref() == Some(commodity_id)
249                    && process.regions.contains(region_id)
250            })
251            .cloned();
252        Rc::get_mut(vec).unwrap().extend(producers);
253    }
254
255    map
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261    use crate::{
262        agent::{AgentCommodityPortionsMap, AgentObjectiveMap, DecisionRule},
263        fixture::{
264            agent_id, assert_error, assert_patched_runs_ok_simple,
265            assert_validate_fails_with_simple, commodity_id, process, processes, region_id,
266        },
267        patch::FilePatch,
268    };
269    use indexmap::indexmap;
270    use map_macro::hash_map;
271    use rstest::{fixture, rstest};
272    use std::iter;
273
274    #[fixture]
275    fn process1(process: Process) -> Rc<Process> {
276        Rc::new(process)
277    }
278
279    #[fixture]
280    fn process2(process: Process) -> Rc<Process> {
281        Rc::new(Process {
282            id: "process2".into(),
283            ..process
284        })
285    }
286
287    #[fixture]
288    fn agent(agent_id: AgentID, region_id: RegionID) -> Agent {
289        Agent {
290            id: agent_id,
291            description: String::new(),
292            commodity_portions: AgentCommodityPortionsMap::new(),
293            search_space: AgentSearchSpaceMap::new(),
294            decision_rule: DecisionRule::Single,
295            regions: iter::once(region_id).collect(),
296            objectives: AgentObjectiveMap::new(),
297        }
298    }
299
300    #[rstest]
301    fn empty_search_space_returns_error(agent: Agent, commodity_id: CommodityID) {
302        let result = for_each_year_in_search_space(
303            "",
304            &agent,
305            &commodity_id,
306            &[2020],
307            &ProcessMap::new(),
308            &ProducersMap::new(),
309            |_, _, _, _| Ok(()),
310        );
311        assert_error!(result, "No processes provided");
312    }
313
314    #[rstest]
315    fn all_calls_f_for_each_year(
316        agent: Agent,
317        commodity_id: CommodityID,
318        region_id: RegionID,
319        process1: Rc<Process>,
320        process2: Rc<Process>,
321    ) {
322        let producers = hash_map! {
323            (commodity_id.clone(), region_id.clone(), 2020) => Rc::new(vec![process1.clone()]),
324            (commodity_id.clone(), region_id.clone(), 2030) => Rc::new(vec![process2.clone()])
325        };
326        let mut calls: Vec<(u32, Rc<Vec<Rc<Process>>>)> = Vec::new();
327        for_each_year_in_search_space(
328            "all",
329            &agent,
330            &commodity_id,
331            &[2020, 2030],
332            &ProcessMap::new(),
333            &producers,
334            |_, _, year, search_space| {
335                calls.push((year, search_space));
336                Ok(())
337            },
338        )
339        .unwrap();
340        assert_eq!(calls.len(), 2);
341        assert_eq!(calls[0].0, 2020);
342        assert_eq!(calls[0].1.len(), 1);
343        assert_eq!(calls[0].1[0].id, process1.id);
344        assert_eq!(calls[1].0, 2030);
345        assert_eq!(calls[1].1.len(), 1);
346        assert_eq!(calls[1].1[0].id, process2.id);
347    }
348
349    #[rstest]
350    fn specific_process_calls_f_for_each_year(
351        agent: Agent,
352        commodity_id: CommodityID,
353        region_id: RegionID,
354        processes: ProcessMap,
355    ) {
356        let process = processes.values().next().unwrap().clone();
357        let value = Rc::new(vec![process.clone()]);
358        let producers = hash_map! {
359            (commodity_id.clone(), region_id.clone(), 2020) => value.clone(),
360            (commodity_id.clone(), region_id.clone(), 2030) => value
361        };
362        let mut calls: Vec<(u32, Rc<Vec<Rc<Process>>>)> = Vec::new();
363        for_each_year_in_search_space(
364            "process1",
365            &agent,
366            &commodity_id,
367            &[2020, 2030],
368            &processes,
369            &producers,
370            |_, _, year, search_space| {
371                calls.push((year, search_space));
372                Ok(())
373            },
374        )
375        .unwrap();
376        assert_eq!(calls.len(), 2);
377        assert_eq!(calls[0].0, 2020);
378        assert_eq!(calls[1].0, 2030);
379        assert_eq!(calls[0].1.len(), 1);
380        assert_eq!(calls[0].1[0].id, process.id);
381        // Both years receive the same Rc-wrapped search space
382        assert!(Rc::ptr_eq(&calls[0].1, &calls[1].1));
383    }
384
385    #[rstest]
386    fn multiple_process_ids_calls_f_with_all_processes(
387        agent: Agent,
388        commodity_id: CommodityID,
389        region_id: RegionID,
390        process1: Rc<Process>,
391        process2: Rc<Process>,
392    ) {
393        let producers = hash_map! {
394            (commodity_id.clone(), region_id.clone(), 2020) => Rc::new(vec![process1.clone(), process2.clone()])
395        };
396        let processes: ProcessMap = indexmap! {
397            process1.id.clone() => process1.clone(),
398            process2.id.clone() => process2.clone(),
399        };
400        let mut calls: Vec<(u32, Rc<Vec<Rc<Process>>>)> = Vec::new();
401        for_each_year_in_search_space(
402            "process1;process2",
403            &agent,
404            &commodity_id,
405            &[2020],
406            &processes,
407            &producers,
408            |_, _, year, search_space| {
409                calls.push((year, search_space));
410                Ok(())
411            },
412        )
413        .unwrap();
414        assert_eq!(calls.len(), 1);
415        assert_eq!(calls[0].1.len(), 2);
416        assert_eq!(calls[0].1[0].id, process1.id);
417        assert_eq!(calls[0].1[1].id, process2.id);
418    }
419
420    #[test]
421    fn model_runs_with_search_space_file1() {
422        // Check that it runs with everything set to all
423        assert_patched_runs_ok_simple!(vec![
424            FilePatch::new(AGENT_SEARCH_SPACES_FILE_NAME).with_replacement(&[
425                "agent_id,commodity_id,years,search_space",
426                "A0_GEX,GASPRD,all,all",
427                "A0_GPR,GASNAT,all,all",
428                "A0_ELC,ELCTRI,all,all",
429                "A0_RES,RSHEAT,all,all",
430            ])
431        ]);
432    }
433
434    #[test]
435    fn model_runs_with_search_space_file2() {
436        // Check that it runs with a more complex file
437        assert_patched_runs_ok_simple!(vec![
438            FilePatch::new(AGENT_SEARCH_SPACES_FILE_NAME).with_replacement(&[
439                "agent_id,commodity_id,years,search_space",
440                "A0_GEX,GASPRD,all,GASDRV",
441                "A0_GPR,GASNAT,2020,all",
442                "A0_GPR,GASNAT,2030,GASPRC",
443                "A0_GPR,GASNAT,2040,all",
444                "A0_ELC,ELCTRI,all,all",
445                "A0_RES,RSHEAT,2020,RGASBR;RELCHP",
446                "A0_RES,RSHEAT,2030,RGASBR ; RELCHP",
447                "A0_RES,RSHEAT,2040,RGASBR;RELCHP",
448            ])
449        ]);
450    }
451
452    #[test]
453    fn not_responsible_for_commodity() {
454        assert_validate_fails_with_simple!(
455            vec![
456                FilePatch::new(AGENT_SEARCH_SPACES_FILE_NAME).with_replacement(&[
457                    "agent_id,commodity_id,years,search_space",
458                    "A0_GEX,ELCTRI,all,all",
459                ]),
460            ],
461            "Agent 'A0_GEX' is not responsible for commodity 'ELCTRI' in at least some of the \
462            specified years: [2020, 2030, 2040]"
463        );
464    }
465
466    #[test]
467    fn unknown_agent_id_fails() {
468        assert_validate_fails_with_simple!(
469            vec![
470                FilePatch::new(AGENT_SEARCH_SPACES_FILE_NAME).with_replacement(&[
471                    "agent_id,commodity_id,years,search_space",
472                    "UNKNOWN_AGENT,GASPRD,all,all",
473                ])
474            ],
475            "Unknown agent ID 'UNKNOWN_AGENT'"
476        );
477    }
478
479    #[test]
480    fn unknown_commodity_id_fails() {
481        assert_validate_fails_with_simple!(
482            vec![
483                FilePatch::new(AGENT_SEARCH_SPACES_FILE_NAME).with_replacement(&[
484                    "agent_id,commodity_id,years,search_space",
485                    "A0_GEX,UNKNOWN_COMMODITY,all,all",
486                ])
487            ],
488            "Unknown commodity ID 'UNKNOWN_COMMODITY'"
489        );
490    }
491
492    #[test]
493    fn invalid_year_fails() {
494        assert_validate_fails_with_simple!(
495            vec![
496                FilePatch::new(AGENT_SEARCH_SPACES_FILE_NAME).with_replacement(&[
497                    "agent_id,commodity_id,years,search_space",
498                    "A0_GEX,GASPRD,9999,all",
499                ])
500            ],
501            "Invalid year: 9999"
502        );
503    }
504
505    #[test]
506    fn overlapping_entries_fails() {
507        assert_validate_fails_with_simple!(
508            vec![
509                FilePatch::new(AGENT_SEARCH_SPACES_FILE_NAME).with_replacement(&[
510                    "agent_id,commodity_id,years,search_space",
511                    "A0_GEX,GASPRD,2020,all",
512                    "A0_GEX,GASPRD,2020,GASDRV",
513                ])
514            ],
515            "Overlapping entries in search space file"
516        );
517    }
518
519    #[test]
520    fn invalid_search_space_fails() {
521        assert_validate_fails_with_simple!(
522            vec![
523                FilePatch::new(AGENT_SEARCH_SPACES_FILE_NAME).with_replacement(&[
524                    "agent_id,commodity_id,years,search_space",
525                    "A0_GEX,GASPRD,all,NONEXISTENT_PROCESS",
526                ])
527            ],
528            "Unknown process ID 'NONEXISTENT_PROCESS'"
529        );
530    }
531
532    #[test]
533    fn process_not_valid_producer_fails() {
534        assert_validate_fails_with_simple!(
535            vec![
536                FilePatch::new(AGENT_SEARCH_SPACES_FILE_NAME).with_replacement(&[
537                    "agent_id,commodity_id,years,search_space",
538                    "A0_GEX,GASPRD,all,GASPRC",
539                ])
540            ],
541            "Process 'GASPRC' does not produce commodity 'GASPRD' in region 'GBR' in year 2020"
542        );
543    }
544}