muse2/input/agent/
commodity_portion.rs

1//! Code for reading the agent commodities CSV file.
2use super::super::*;
3use crate::agent::{AgentCommodityPortionsMap, AgentID, AgentMap};
4use crate::commodity::{CommodityID, CommodityMap, CommodityType};
5use crate::region::RegionID;
6use crate::year::parse_year_str;
7use anyhow::{ensure, Context, Result};
8use serde::Deserialize;
9use std::collections::HashMap;
10use std::path::Path;
11
12const AGENT_COMMODITIES_FILE_NAME: &str = "agent_commodity_portions.csv";
13
14#[derive(PartialEq, Debug, Deserialize)]
15struct AgentCommodityPortionRaw {
16    /// Unique agent id identifying the agent.
17    agent_id: String,
18    /// The commodity that the agent is responsible for.
19    commodity_id: String,
20    /// The year the commodity portion applies to.
21    year: String,
22    /// The proportion of the commodity production that the agent is responsible for.
23    #[serde(deserialize_with = "deserialise_proportion_nonzero")]
24    commodity_portion: f64,
25}
26
27/// Read agent commodity portions info from the agent_commodity_portions.csv file.
28///
29/// # Arguments
30///
31/// * `model_dir` - Folder containing model configuration files
32///
33/// # Returns
34///
35/// A map of Agents, with the agent ID as the key
36pub fn read_agent_commodity_portions(
37    model_dir: &Path,
38    agents: &AgentMap,
39    commodities: &CommodityMap,
40    region_ids: &HashSet<RegionID>,
41    milestone_years: &[u32],
42) -> Result<HashMap<AgentID, AgentCommodityPortionsMap>> {
43    let file_path = model_dir.join(AGENT_COMMODITIES_FILE_NAME);
44    let agent_commodity_portions_csv = read_csv(&file_path)?;
45    read_agent_commodity_portions_from_iter(
46        agent_commodity_portions_csv,
47        agents,
48        commodities,
49        region_ids,
50        milestone_years,
51    )
52    .with_context(|| input_err_msg(&file_path))
53}
54
55fn read_agent_commodity_portions_from_iter<I>(
56    iter: I,
57    agents: &AgentMap,
58    commodities: &CommodityMap,
59    region_ids: &HashSet<RegionID>,
60    milestone_years: &[u32],
61) -> Result<HashMap<AgentID, AgentCommodityPortionsMap>>
62where
63    I: Iterator<Item = AgentCommodityPortionRaw>,
64{
65    let mut agent_commodity_portions = HashMap::new();
66    for agent_commodity_portion_raw in iter {
67        // Get agent ID
68        let agent_id_raw = agent_commodity_portion_raw.agent_id.as_str();
69        let (id, _agent) = agents
70            .get_key_value(agent_id_raw)
71            .with_context(|| format!("Invalid agent ID {agent_id_raw}"))?;
72
73        // Get/create entry for agent
74        let entry = agent_commodity_portions
75            .entry(id.clone())
76            .or_insert_with(AgentCommodityPortionsMap::new);
77
78        // Insert portion for the commodity/year(s)
79        let commodity_id_raw = agent_commodity_portion_raw.commodity_id.as_str();
80        let (commodity_id, _commodity) = commodities
81            .get_key_value(commodity_id_raw)
82            .with_context(|| format!("Invalid commodity ID {commodity_id_raw}"))?;
83        let years = parse_year_str(&agent_commodity_portion_raw.year, milestone_years)?;
84        for year in years {
85            try_insert(
86                entry,
87                (commodity_id.clone(), year),
88                agent_commodity_portion_raw.commodity_portion,
89            )?;
90        }
91    }
92
93    validate_agent_commodity_portions(
94        &agent_commodity_portions,
95        agents,
96        commodities,
97        region_ids,
98        milestone_years,
99    )?;
100
101    Ok(agent_commodity_portions)
102}
103
104fn validate_agent_commodity_portions(
105    agent_commodity_portions: &HashMap<AgentID, AgentCommodityPortionsMap>,
106    agents: &AgentMap,
107    commodities: &CommodityMap,
108    region_ids: &HashSet<RegionID>,
109    milestone_years: &[u32],
110) -> Result<()> {
111    // CHECK 1: Each specified commodity must have data for all years
112    for (id, portions) in agent_commodity_portions {
113        // Colate set of commodities for this agent
114        let commodity_ids: HashSet<CommodityID> =
115            HashSet::from_iter(portions.keys().map(|(id, _)| id.clone()));
116
117        // Check that each commodity has data for all milestone years
118        for commodity_id in commodity_ids {
119            for year in milestone_years {
120                ensure!(
121                    portions.contains_key(&(commodity_id.clone(), *year)),
122                    "Agent {} does not have data for commodity {} in year {}",
123                    id,
124                    commodity_id,
125                    year
126                );
127            }
128        }
129    }
130
131    // CHECK 2: Total portions for each commodity/year/region must sum to 1
132    // First step is to create a map with the key as (commodity_id, year, region_id), and the value
133    // as the sum of the portions for that key across all agents
134    let mut summed_portions = HashMap::new();
135    for (id, agent_commodity_portions) in agent_commodity_portions.iter() {
136        let agent = agents.get(id).context("Invalid agent ID")?;
137        for ((commodity_id, year), portion) in agent_commodity_portions {
138            for region in region_ids {
139                if agent.regions.contains(region) {
140                    let key = (commodity_id, year, region);
141                    summed_portions
142                        .entry(key)
143                        .and_modify(|v| *v += *portion)
144                        .or_insert(*portion);
145                }
146            }
147        }
148    }
149
150    // We then check the map to ensure values for each key are 1
151    for (key, portion) in summed_portions.iter() {
152        ensure!(
153            approx_eq!(f64, *portion, 1.0, epsilon = 1e-5),
154            "Commodity {} in year {} and region {} does not sum to 1.0",
155            key.0,
156            key.1,
157            key.2
158        );
159    }
160
161    // CHECK 3: All commodities of SVD or SED type must be covered for all regions and years
162    // This checks the same summed_portions map as above, just checking the keys
163    // We first need to create a list of SVD and SED commodities to check against
164    let svd_and_sed_commodities = commodities
165        .iter()
166        .filter(|(_, commodity)| {
167            matches!(
168                commodity.kind,
169                CommodityType::SupplyEqualsDemand | CommodityType::ServiceDemand
170            )
171        })
172        .map(|(id, _)| id.clone());
173
174    // Check that summed_portions contains all SVD/SED commodities for all regions and milestone
175    // years
176    for commodity_id in svd_and_sed_commodities {
177        for year in milestone_years {
178            for region in region_ids {
179                let key = (&commodity_id, year, region);
180                ensure!(
181                    summed_portions.contains_key(&key),
182                    "Commodity {} in year {} and region {} is not covered",
183                    commodity_id,
184                    year,
185                    region
186                );
187            }
188        }
189    }
190
191    Ok(())
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197    use crate::agent::{Agent, AgentCostLimitsMap, DecisionRule};
198    use crate::commodity::{Commodity, CommodityCostMap, CommodityID, CommodityType, DemandMap};
199    use crate::time_slice::TimeSliceLevel;
200    use std::rc::Rc;
201
202    #[test]
203    fn test_validate_agent_commodity_portions() {
204        let region_ids = HashSet::from([RegionID::new("region1"), RegionID::new("region2")]);
205        let milestone_years = [2020];
206        let agents = IndexMap::from([(
207            AgentID::new("agent1"),
208            Agent {
209                id: "agent1".into(),
210                description: "An agent".into(),
211                commodity_portions: AgentCommodityPortionsMap::new(),
212                search_space: Vec::new(),
213                decision_rule: DecisionRule::Single,
214                cost_limits: AgentCostLimitsMap::new(),
215                regions: region_ids.clone(),
216                objectives: Vec::new(),
217            },
218        )]);
219        let mut commodities = IndexMap::from([(
220            CommodityID::new("commodity1"),
221            Rc::new(Commodity {
222                id: "commodity1".into(),
223                description: "A commodity".into(),
224                kind: CommodityType::SupplyEqualsDemand,
225                time_slice_level: TimeSliceLevel::Annual,
226                costs: CommodityCostMap::new(),
227                demand: DemandMap::new(),
228            }),
229        )]);
230
231        // Valid case
232        let mut map = AgentCommodityPortionsMap::new();
233        map.insert(("commodity1".into(), 2020), 1.0);
234        let agent_commodity_portions = HashMap::from([("agent1".into(), map)]);
235        assert!(validate_agent_commodity_portions(
236            &agent_commodity_portions,
237            &agents,
238            &commodities,
239            &region_ids,
240            &milestone_years
241        )
242        .is_ok());
243
244        // Invalid case: portions do not sum to 1
245        let mut map_v2 = AgentCommodityPortionsMap::new();
246        map_v2.insert(("commodity1".into(), 2020), 0.5);
247        let agent_commodities_v2 = HashMap::from([("agent1".into(), map_v2)]);
248        assert!(validate_agent_commodity_portions(
249            &agent_commodities_v2,
250            &agents,
251            &commodities,
252            &region_ids,
253            &milestone_years
254        )
255        .is_err());
256
257        // Invalid case: SED commodity without associated commodity portions
258        commodities.insert(
259            CommodityID::new("commodity2"),
260            Rc::new(Commodity {
261                id: "commodity2".into(),
262                description: "Another commodity".into(),
263                kind: CommodityType::SupplyEqualsDemand,
264                time_slice_level: TimeSliceLevel::Annual,
265                costs: CommodityCostMap::new(),
266                demand: DemandMap::new(),
267            }),
268        );
269        assert!(validate_agent_commodity_portions(
270            &agent_commodity_portions,
271            &agents,
272            &commodities,
273            &region_ids,
274            &milestone_years
275        )
276        .is_err());
277    }
278}