muse2/input/
agent.rs

1//! Code for reading in agent-related data from CSV files.
2use super::{input_err_msg, read_csv};
3use crate::agent::{
4    Agent, AgentCommodityPortionsMap, AgentID, AgentMap, AgentObjectiveMap, AgentSearchSpaceMap,
5    DecisionRule,
6};
7use crate::commodity::CommodityMap;
8use crate::process::ProcessMap;
9use crate::region::{RegionID, parse_region_str};
10use anyhow::{Context, Result, bail, ensure};
11use indexmap::IndexSet;
12use serde::Deserialize;
13use std::path::Path;
14
15mod objective;
16use objective::read_agent_objectives;
17mod search_space;
18use search_space::read_agent_search_space;
19mod commodity_portion;
20use commodity_portion::read_agent_commodity_portions;
21
22const AGENT_FILE_NAME: &str = "agents.csv";
23
24/// An agent in the simulation
25#[derive(Debug, Deserialize, PartialEq, Clone)]
26struct AgentRaw {
27    /// A unique identifier for the agent.
28    id: String,
29    /// A text description of the agent.
30    description: String,
31    /// The region(s) in which the agent operates.
32    regions: String,
33    /// The decision rule that the agent uses to decide investment.
34    decision_rule: String,
35    /// The tolerance around the main objective to consider secondary objectives.
36    decision_lexico_tolerance: Option<f64>,
37}
38
39/// Read agents info from various CSV files.
40///
41/// # Arguments
42///
43/// * `model_dir` - Folder containing model configuration files
44/// * `commodities` - Commodities for the model
45/// * `processes` - Processes available in the model
46/// * `region_ids` - The possible valid region IDs
47/// * `milestone_years` - Milestone years in the model
48///
49/// # Returns
50///
51/// A map of agents keyed by agent ID
52pub fn read_agents(
53    model_dir: &Path,
54    commodities: &CommodityMap,
55    processes: &ProcessMap,
56    region_ids: &IndexSet<RegionID>,
57    milestone_years: &[u32],
58) -> Result<AgentMap> {
59    let mut agents = read_agents_file(model_dir, region_ids)?;
60
61    // We read commodity portions first as they are required by `read_agent_search_space`
62    let mut agent_commodities = read_agent_commodity_portions(
63        model_dir,
64        &agents,
65        commodities,
66        region_ids,
67        milestone_years,
68    )?;
69    for (id, agent) in &mut agents {
70        agent.commodity_portions = agent_commodities
71            .remove(id)
72            .with_context(|| format!("Missing commodity portions for agent {id}"))?;
73    }
74
75    let mut objectives = read_agent_objectives(model_dir, &agents, milestone_years)?;
76    let commodity_ids = commodities.keys().cloned().collect();
77    let mut search_spaces = read_agent_search_space(
78        model_dir,
79        &agents,
80        processes,
81        &commodity_ids,
82        milestone_years,
83    )?;
84
85    for (id, agent) in &mut agents {
86        agent.objectives = objectives.remove(id).unwrap();
87        agent.search_space = search_spaces.remove(id).unwrap();
88    }
89
90    Ok(agents)
91}
92
93/// Read agents info from the `agents.csv` file.
94///
95/// # Arguments
96///
97/// * `model_dir` - Folder containing model configuration files
98/// * `region_ids` - The possible valid region IDs
99///
100/// # Returns
101///
102/// A map of agents keyed by agent ID
103fn read_agents_file(model_dir: &Path, region_ids: &IndexSet<RegionID>) -> Result<AgentMap> {
104    let file_path = model_dir.join(AGENT_FILE_NAME);
105    let agents_csv = read_csv(&file_path)?;
106    read_agents_file_from_iter(agents_csv, region_ids).with_context(|| input_err_msg(&file_path))
107}
108
109/// Read agents info from an iterator.
110fn read_agents_file_from_iter<I>(iter: I, region_ids: &IndexSet<RegionID>) -> Result<AgentMap>
111where
112    I: Iterator<Item = AgentRaw>,
113{
114    let mut agents = AgentMap::new();
115    for agent_raw in iter {
116        // Parse region ID
117        let regions = parse_region_str(&agent_raw.regions, region_ids)?;
118
119        // Parse decision rule
120        let decision_rule = match agent_raw.decision_rule.to_ascii_lowercase().as_str() {
121            "single" => DecisionRule::Single,
122            "weighted" => DecisionRule::Weighted,
123            "lexico" => {
124                let tolerance = agent_raw
125                    .decision_lexico_tolerance
126                    .with_context(|| "Missing tolerance for lexico decision rule")?;
127                ensure!(
128                    tolerance >= 0.0,
129                    "Lexico tolerance must be non-negative, got {tolerance}"
130                );
131                DecisionRule::Lexicographical { tolerance }
132            }
133            invalid_rule => bail!("Invalid decision rule: {invalid_rule}"),
134        };
135
136        ensure!(
137            decision_rule == DecisionRule::Single,
138            "Currently only the \"single\" decision rule is supported"
139        );
140
141        let agent = Agent {
142            id: AgentID(agent_raw.id.into()),
143            description: agent_raw.description,
144            commodity_portions: AgentCommodityPortionsMap::new(),
145            search_space: AgentSearchSpaceMap::new(),
146            decision_rule,
147            regions,
148            objectives: AgentObjectiveMap::new(),
149        };
150
151        ensure!(
152            agents.insert(agent.id.clone(), agent).is_none(),
153            "Duplicate agent ID"
154        );
155    }
156
157    Ok(agents)
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use crate::agent::DecisionRule;
164    use std::iter;
165
166    #[test]
167    fn read_agents_file_from_iter_works() {
168        // Valid case
169        let region_ids = IndexSet::from(["GBR".into()]);
170        let agent = AgentRaw {
171            id: "agent".into(),
172            description: String::new(),
173            decision_rule: "single".into(),
174            decision_lexico_tolerance: None,
175            regions: "GBR".into(),
176        };
177        let agent_out = Agent {
178            id: "agent".into(),
179            description: String::new(),
180            commodity_portions: AgentCommodityPortionsMap::new(),
181            search_space: AgentSearchSpaceMap::new(),
182            decision_rule: DecisionRule::Single,
183            regions: IndexSet::from(["GBR".into()]),
184            objectives: AgentObjectiveMap::new(),
185        };
186        let expected = AgentMap::from_iter(iter::once(("agent".into(), agent_out)));
187        let actual = read_agents_file_from_iter(iter::once(agent), &region_ids).unwrap();
188        assert_eq!(actual, expected);
189
190        // Duplicate agent ID
191        let agents = [
192            AgentRaw {
193                id: "agent".into(),
194                description: String::new(),
195                decision_rule: "single".into(),
196                decision_lexico_tolerance: None,
197                regions: "GBR".into(),
198            },
199            AgentRaw {
200                id: "agent".into(),
201                description: String::new(),
202                decision_rule: "single".into(),
203                decision_lexico_tolerance: None,
204                regions: "GBR".into(),
205            },
206        ];
207        read_agents_file_from_iter(agents.into_iter(), &region_ids).unwrap_err();
208
209        // Lexico tolerance missing for lexico decision rule
210        let agent = AgentRaw {
211            id: "agent".into(),
212            description: String::new(),
213            decision_rule: "lexico".into(),
214            decision_lexico_tolerance: None,
215            regions: "GBR".into(),
216        };
217        read_agents_file_from_iter(iter::once(agent), &region_ids).unwrap_err();
218    }
219}