muse2/input/agent/
commodity_portion.rs

1//! Code for reading the agent commodities 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::region::RegionID;
7use crate::units::Dimensionless;
8use crate::year::parse_year_str;
9use anyhow::{Context, Result, ensure};
10use float_cmp::approx_eq;
11use indexmap::IndexSet;
12use serde::Deserialize;
13use std::collections::{HashMap, HashSet};
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///
37/// # Returns
38///
39/// A map of Agents, with the agent ID as the key
40pub fn read_agent_commodity_portions(
41    model_dir: &Path,
42    agents: &AgentMap,
43    commodities: &CommodityMap,
44    region_ids: &IndexSet<RegionID>,
45    milestone_years: &[u32],
46) -> Result<HashMap<AgentID, AgentCommodityPortionsMap>> {
47    let file_path = model_dir.join(AGENT_COMMODITIES_FILE_NAME);
48    let agent_commodity_portions_csv = read_csv(&file_path)?;
49    read_agent_commodity_portions_from_iter(
50        agent_commodity_portions_csv,
51        agents,
52        commodities,
53        region_ids,
54        milestone_years,
55    )
56    .with_context(|| input_err_msg(&file_path))
57}
58
59fn read_agent_commodity_portions_from_iter<I>(
60    iter: I,
61    agents: &AgentMap,
62    commodities: &CommodityMap,
63    region_ids: &IndexSet<RegionID>,
64    milestone_years: &[u32],
65) -> Result<HashMap<AgentID, AgentCommodityPortionsMap>>
66where
67    I: Iterator<Item = AgentCommodityPortionRaw>,
68{
69    let mut agent_commodity_portions = HashMap::new();
70    for agent_commodity_portion_raw in iter {
71        // Get agent ID
72        let agent_id_raw = agent_commodity_portion_raw.agent_id.as_str();
73        let id = agents.get_id(agent_id_raw)?;
74
75        // Get/create entry for agent
76        let entry = agent_commodity_portions
77            .entry(id.clone())
78            .or_insert_with(AgentCommodityPortionsMap::new);
79
80        // Insert portion for the commodity/year(s)
81        let commodity_id_raw = agent_commodity_portion_raw.commodity_id.as_str();
82        let commodity_id = commodities.get_id(commodity_id_raw)?;
83        let years = parse_year_str(&agent_commodity_portion_raw.years, 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: &IndexSet<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<_> = portions.keys().map(|(id, _)| id).collect();
115
116        // Check that each commodity has data for all milestone years
117        for commodity_id in commodity_ids {
118            for year in milestone_years {
119                ensure!(
120                    portions.contains_key(&(commodity_id.clone(), *year)),
121                    "Agent {id} does not have data for commodity {commodity_id} in year {year}"
122                );
123            }
124        }
125    }
126
127    // CHECK 2: Total portions for each commodity/year/region must sum to 1
128    // First step is to create a map with the key as (commodity_id, year, region_id), and the value
129    // as the sum of the portions for that key across all agents
130    let mut summed_portions = HashMap::new();
131    for (id, agent_commodity_portions) in agent_commodity_portions {
132        let agent = agents.get(id).context("Invalid agent ID")?;
133        for ((commodity_id, year), portion) in agent_commodity_portions {
134            for region in region_ids {
135                if agent.regions.contains(region) {
136                    let key = (commodity_id, year, region);
137                    summed_portions
138                        .entry(key)
139                        .and_modify(|v| *v += *portion)
140                        .or_insert(*portion);
141                }
142            }
143        }
144    }
145
146    // We then check the map to ensure values for each key are 1
147    for (key, portion) in &summed_portions {
148        ensure!(
149            approx_eq!(Dimensionless, *portion, Dimensionless(1.0), epsilon = 1e-5),
150            "Commodity {} in year {} and region {} does not sum to 1.0",
151            key.0,
152            key.1,
153            key.2
154        );
155    }
156
157    // CHECK 3: All commodities of SVD or SED type must be covered for all regions and years
158    // This checks the same summed_portions map as above, just checking the keys
159    // We first need to create a list of SVD and SED commodities to check against
160    let svd_and_sed_commodities = commodities
161        .iter()
162        .filter(|(_, commodity)| {
163            matches!(
164                commodity.kind,
165                CommodityType::SupplyEqualsDemand | CommodityType::ServiceDemand
166            )
167        })
168        .map(|(id, _)| id);
169
170    // Check that summed_portions contains all SVD/SED commodities for all regions and milestone
171    // years
172    for commodity_id in svd_and_sed_commodities {
173        for year in milestone_years {
174            for region in region_ids {
175                let key = (commodity_id, year, region);
176                ensure!(
177                    summed_portions.contains_key(&key),
178                    "Commodity {commodity_id} in year {year} and region {region} is not covered"
179                );
180            }
181        }
182    }
183
184    Ok(())
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use crate::agent::{
191        Agent, AgentCostLimitsMap, AgentObjectiveMap, AgentSearchSpaceMap, DecisionRule,
192    };
193    use crate::commodity::{Commodity, CommodityID, CommodityLevyMap, CommodityType, DemandMap};
194    use crate::time_slice::TimeSliceLevel;
195    use indexmap::IndexMap;
196    use std::rc::Rc;
197
198    #[test]
199    fn test_validate_agent_commodity_portions() {
200        let region_ids = IndexSet::from([RegionID::new("region1"), RegionID::new("region2")]);
201        let milestone_years = [2020];
202        let agents = IndexMap::from([(
203            AgentID::new("agent1"),
204            Agent {
205                id: "agent1".into(),
206                description: "An agent".into(),
207                commodity_portions: AgentCommodityPortionsMap::new(),
208                search_space: AgentSearchSpaceMap::new(),
209                decision_rule: DecisionRule::Single,
210                cost_limits: AgentCostLimitsMap::new(),
211                regions: region_ids.clone(),
212                objectives: AgentObjectiveMap::new(),
213            },
214        )]);
215        let mut commodities = IndexMap::from([(
216            CommodityID::new("commodity1"),
217            Rc::new(Commodity {
218                id: "commodity1".into(),
219                description: "A commodity".into(),
220                kind: CommodityType::SupplyEqualsDemand,
221                time_slice_level: TimeSliceLevel::Annual,
222                levies: CommodityLevyMap::new(),
223                demand: DemandMap::new(),
224            }),
225        )]);
226
227        // Valid case
228        let mut map = AgentCommodityPortionsMap::new();
229        map.insert(("commodity1".into(), 2020), Dimensionless(1.0));
230        let agent_commodity_portions = HashMap::from([("agent1".into(), map)]);
231        assert!(
232            validate_agent_commodity_portions(
233                &agent_commodity_portions,
234                &agents,
235                &commodities,
236                &region_ids,
237                &milestone_years
238            )
239            .is_ok()
240        );
241
242        // Invalid case: portions do not sum to 1
243        let mut map_v2 = AgentCommodityPortionsMap::new();
244        map_v2.insert(("commodity1".into(), 2020), Dimensionless(0.5));
245        let agent_commodities_v2 = HashMap::from([("agent1".into(), map_v2)]);
246        assert!(
247            validate_agent_commodity_portions(
248                &agent_commodities_v2,
249                &agents,
250                &commodities,
251                &region_ids,
252                &milestone_years
253            )
254            .is_err()
255        );
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                levies: CommodityLevyMap::new(),
266                demand: DemandMap::new(),
267            }),
268        );
269        assert!(
270            validate_agent_commodity_portions(
271                &agent_commodity_portions,
272                &agents,
273                &commodities,
274                &region_ids,
275                &milestone_years
276            )
277            .is_err()
278        );
279    }
280}