Skip to main content

muse2/input/agent/
commodity_portion.rs

1//! Code for reading agent commodity portions from a CSV file.
2use super::super::{deserialise_proportion_nonzero, input_err_msg, read_csv, try_insert};
3use crate::agent::{AgentCommodityPortionsMap, AgentID, AgentMap};
4use crate::commodity::{CommodityMap, CommodityType};
5use crate::id::IDCollection;
6use crate::input::parse_year_str;
7use crate::region::RegionID;
8use crate::units::Dimensionless;
9use anyhow::{Context, Result, ensure};
10use float_cmp::approx_eq;
11use indexmap::{IndexMap, IndexSet};
12use serde::Deserialize;
13use std::collections::HashMap;
14use std::path::Path;
15
16const AGENT_COMMODITIES_FILE_NAME: &str = "agent_commodity_portions.csv";
17
18#[derive(PartialEq, Debug, Deserialize)]
19struct AgentCommodityPortionRaw {
20    /// Unique agent id identifying the agent.
21    agent_id: String,
22    /// The commodity that the agent is responsible for.
23    commodity_id: String,
24    /// The year(s) the commodity portion applies to.
25    years: String,
26    /// The proportion of the commodity production that the agent is responsible for.
27    #[serde(deserialize_with = "deserialise_proportion_nonzero")]
28    commodity_portion: Dimensionless,
29}
30
31/// Read agent commodity portions info from the `agent_commodity_portions.csv` file.
32///
33/// # Arguments
34///
35/// * `model_dir` - Folder containing model configuration files
36/// * `agents` - Known agents in the model
37/// * `commodities` - Known commodities in the model
38/// * `region_ids` - Known region identifiers
39/// * `milestone_years` - Milestone years used by the model
40///
41/// # Returns
42///
43/// A `HashMap` mapping `AgentID` to `AgentCommodityPortionsMap`.
44pub fn read_agent_commodity_portions(
45    model_dir: &Path,
46    agents: &AgentMap,
47    commodities: &CommodityMap,
48    region_ids: &IndexSet<RegionID>,
49    milestone_years: &[u32],
50) -> Result<HashMap<AgentID, AgentCommodityPortionsMap>> {
51    let file_path = model_dir.join(AGENT_COMMODITIES_FILE_NAME);
52    let agent_commodity_portions_csv = read_csv(&file_path)?;
53    read_agent_commodity_portions_from_iter(
54        agent_commodity_portions_csv,
55        agents,
56        commodities,
57        region_ids,
58        milestone_years,
59    )
60    .with_context(|| input_err_msg(&file_path))
61}
62
63fn read_agent_commodity_portions_from_iter<I>(
64    iter: I,
65    agents: &AgentMap,
66    commodities: &CommodityMap,
67    region_ids: &IndexSet<RegionID>,
68    milestone_years: &[u32],
69) -> Result<HashMap<AgentID, AgentCommodityPortionsMap>>
70where
71    I: Iterator<Item = AgentCommodityPortionRaw>,
72{
73    let mut agent_commodity_portions = HashMap::new();
74    for agent_commodity_portion_raw in iter {
75        // Get agent ID
76        let agent_id_raw = agent_commodity_portion_raw.agent_id.as_str();
77        let id = agents.get_id(agent_id_raw)?;
78
79        // Get/create entry for agent
80        let entry = agent_commodity_portions
81            .entry(id.clone())
82            .or_insert_with(AgentCommodityPortionsMap::new);
83
84        // Insert portion for the commodity/year(s)
85        let commodity_id_raw = agent_commodity_portion_raw.commodity_id.as_str();
86        let commodity_id = commodities.get_id(commodity_id_raw)?;
87        let years = parse_year_str(&agent_commodity_portion_raw.years, milestone_years)?;
88        for year in years {
89            try_insert(
90                entry,
91                &(commodity_id.clone(), year),
92                agent_commodity_portion_raw.commodity_portion,
93            )?;
94        }
95    }
96
97    validate_agent_commodity_portions(
98        &agent_commodity_portions,
99        agents,
100        commodities,
101        region_ids,
102        milestone_years,
103    )?;
104
105    Ok(agent_commodity_portions)
106}
107
108fn validate_agent_commodity_portions(
109    agent_commodity_portions: &HashMap<AgentID, AgentCommodityPortionsMap>,
110    agents: &AgentMap,
111    commodities: &CommodityMap,
112    region_ids: &IndexSet<RegionID>,
113    milestone_years: &[u32],
114) -> Result<()> {
115    // CHECK 1: Commodities of type OTH are not invested in, so should not be included in portions
116    for (id, portions) in agent_commodity_portions {
117        // Collate set of commodities for this agent
118        let commodity_ids: IndexSet<_> = portions.keys().map(|(id, _)| id).collect();
119
120        // Check that none of these commodities are of type OTH
121        for commodity_id in commodity_ids {
122            ensure!(
123                commodities[commodity_id].kind != CommodityType::Other,
124                "Agent {id} contains portion for commodity {commodity_id} which is of type OTH"
125            );
126        }
127    }
128
129    // CHECK 2: Each specified commodity must have data for all years
130    for (id, portions) in agent_commodity_portions {
131        // Collate set of commodities for this agent
132        let commodity_ids: IndexSet<_> = portions.keys().map(|(id, _)| id).collect();
133
134        // Check that each commodity has data for all milestone years
135        for commodity_id in commodity_ids {
136            for year in milestone_years {
137                ensure!(
138                    portions.contains_key(&(commodity_id.clone(), *year)),
139                    "Agent {id} does not have data for commodity {commodity_id} in year {year}"
140                );
141            }
142        }
143    }
144
145    // CHECK 3: Total portions for each commodity/year/region must sum to 1
146    // First step is to create a map with the key as (commodity_id, year, region_id), and the value
147    // as the sum of the portions for that key across all agents
148    let mut summed_portions = IndexMap::new();
149    for (id, agent_commodity_portions) in agent_commodity_portions {
150        let agent = &agents[id];
151        for ((commodity_id, year), portion) in agent_commodity_portions {
152            for region in region_ids {
153                if agent.regions.contains(region) {
154                    let key = (commodity_id, year, region);
155                    summed_portions
156                        .entry(key)
157                        .and_modify(|v| *v += *portion)
158                        .or_insert(*portion);
159                }
160            }
161        }
162    }
163
164    // We then check the map to ensure values for each key are 1
165    for (key, portion) in &summed_portions {
166        ensure!(
167            approx_eq!(Dimensionless, *portion, Dimensionless(1.0), epsilon = 1e-5),
168            "Commodity {} in year {} and region {} does not sum to 1.0",
169            key.0,
170            key.1,
171            key.2
172        );
173    }
174
175    // CHECK 4: All commodities of SVD or SED type must be covered for all regions and years
176    // This checks the same summed_portions map as above, just checking the keys
177    // We first need to create a list of SVD and SED commodities to check against
178    let svd_and_sed_commodities = commodities
179        .iter()
180        .filter(|(_, commodity)| {
181            matches!(
182                commodity.kind,
183                CommodityType::SupplyEqualsDemand | CommodityType::ServiceDemand
184            )
185        })
186        .map(|(id, _)| id);
187
188    // Check that summed_portions contains all SVD/SED commodities for all regions and milestone
189    // years
190    for commodity_id in svd_and_sed_commodities {
191        for year in milestone_years {
192            for region in region_ids {
193                let key = (commodity_id, year, region);
194                ensure!(
195                    summed_portions.contains_key(&key),
196                    "Commodity {commodity_id} in year {year} and region {region} is not covered"
197                );
198            }
199        }
200    }
201
202    Ok(())
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use crate::commodity::{Commodity, CommodityID};
209    use crate::fixture::{
210        agents, assert_error, other_commodity, region_ids, sed_commodity, svd_commodity,
211    };
212    use indexmap::IndexMap;
213    use rstest::{fixture, rstest};
214    use std::rc::Rc;
215
216    #[fixture]
217    fn milestone_years() -> [u32; 1] {
218        [2020]
219    }
220
221    #[fixture]
222    fn commodities(svd_commodity: Commodity, other_commodity: Commodity) -> CommodityMap {
223        IndexMap::from([
224            ("commodity1".into(), Rc::new(svd_commodity)),
225            ("other_commodity".into(), Rc::new(other_commodity)),
226        ])
227    }
228
229    #[fixture]
230    fn agent_commodity_portions() -> HashMap<AgentID, AgentCommodityPortionsMap> {
231        let mut map = AgentCommodityPortionsMap::new();
232        map.insert(("commodity1".into(), 2020), Dimensionless(1.0));
233        HashMap::from([("agent1".into(), map)])
234    }
235
236    #[rstest]
237    fn validate_agent_commodity_portions_valid_case(
238        agent_commodity_portions: HashMap<AgentID, AgentCommodityPortionsMap>,
239        agents: AgentMap,
240        commodities: CommodityMap,
241        region_ids: IndexSet<RegionID>,
242        milestone_years: [u32; 1],
243    ) {
244        validate_agent_commodity_portions(
245            &agent_commodity_portions,
246            &agents,
247            &commodities,
248            &region_ids,
249            &milestone_years,
250        )
251        .unwrap();
252    }
253
254    #[rstest]
255    fn validate_agent_commodity_portions_other_type_commodity(
256        mut agent_commodity_portions: HashMap<AgentID, AgentCommodityPortionsMap>,
257        agents: AgentMap,
258        commodities: CommodityMap,
259        region_ids: IndexSet<RegionID>,
260        milestone_years: [u32; 1],
261    ) {
262        // Invalid case: includes commodity of type Other
263        agent_commodity_portions
264            .get_mut(&AgentID::new("agent1"))
265            .unwrap()
266            .insert(("other_commodity".into(), 2020), Dimensionless(0.5));
267        assert_error!(
268            validate_agent_commodity_portions(
269                &agent_commodity_portions,
270                &agents,
271                &commodities,
272                &region_ids,
273                &milestone_years
274            ),
275            "Agent agent1 contains portion for commodity other_commodity which is of type OTH"
276        );
277    }
278
279    #[rstest]
280    fn validate_agent_commodity_portions_missing_year(
281        agent_commodity_portions: HashMap<AgentID, AgentCommodityPortionsMap>,
282        agents: AgentMap,
283        commodities: CommodityMap,
284        region_ids: IndexSet<RegionID>,
285    ) {
286        let milestone_years = [2020, 2030];
287        assert_error!(
288            validate_agent_commodity_portions(
289                &agent_commodity_portions,
290                &agents,
291                &commodities,
292                &region_ids,
293                &milestone_years,
294            ),
295            "Agent agent1 does not have data for commodity commodity1 in year 2030"
296        );
297    }
298
299    #[rstest]
300    fn validate_agent_commodity_portions_dont_sum_to_one(
301        mut agent_commodity_portions: HashMap<AgentID, AgentCommodityPortionsMap>,
302        agents: AgentMap,
303        commodities: CommodityMap,
304        region_ids: IndexSet<RegionID>,
305        milestone_years: [u32; 1],
306    ) {
307        // Invalid case: portions do not sum to 1
308        agent_commodity_portions
309            .get_mut(&AgentID::new("agent1"))
310            .unwrap()
311            .insert(("commodity1".into(), 2020), Dimensionless(0.5));
312        assert_error!(
313            validate_agent_commodity_portions(
314                &agent_commodity_portions,
315                &agents,
316                &commodities,
317                &region_ids,
318                &milestone_years,
319            ),
320            "Commodity commodity1 in year 2020 and region GBR does not sum to 1.0"
321        );
322    }
323
324    #[rstest]
325    fn validate_agent_commodity_portions_missing_portions(
326        agent_commodity_portions: HashMap<AgentID, AgentCommodityPortionsMap>,
327        agents: AgentMap,
328        mut commodities: CommodityMap,
329        region_ids: IndexSet<RegionID>,
330        milestone_years: [u32; 1],
331        sed_commodity: Commodity,
332    ) {
333        // Invalid case: SED commodity without associated commodity portions
334        commodities.insert(CommodityID::new("sed_commodity"), Rc::new(sed_commodity));
335        assert_error!(
336            validate_agent_commodity_portions(
337                &agent_commodity_portions,
338                &agents,
339                &commodities,
340                &region_ids,
341                &milestone_years
342            ),
343            "Commodity sed_commodity in year 2020 and region GBR is not covered"
344        );
345    }
346}