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::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/// * `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: Each specified commodity must have data for all years
116    for (id, portions) in agent_commodity_portions {
117        // Colate set of commodities for this agent
118        let commodity_ids: HashSet<_> = portions.keys().map(|(id, _)| id).collect();
119
120        // Check that each commodity has data for all milestone years
121        for commodity_id in commodity_ids {
122            for year in milestone_years {
123                ensure!(
124                    portions.contains_key(&(commodity_id.clone(), *year)),
125                    "Agent {id} does not have data for commodity {commodity_id} in year {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 {
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 {
152        ensure!(
153            approx_eq!(Dimensionless, *portion, Dimensionless(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);
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 {commodity_id} in year {year} and region {region} is not covered"
183                );
184            }
185        }
186    }
187
188    Ok(())
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use crate::agent::{Agent, AgentObjectiveMap, AgentSearchSpaceMap, DecisionRule};
195    use crate::commodity::{
196        Commodity, CommodityID, CommodityLevyMap, CommodityType, DemandMap, PricingStrategy,
197    };
198    use crate::time_slice::TimeSliceLevel;
199    use indexmap::IndexMap;
200    use std::rc::Rc;
201
202    #[test]
203    fn validate_agent_commodity_portions_works() {
204        let region_ids = IndexSet::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: AgentSearchSpaceMap::new(),
213                decision_rule: DecisionRule::Single,
214                regions: region_ids.clone(),
215                objectives: AgentObjectiveMap::new(),
216            },
217        )]);
218        let mut commodities = IndexMap::from([(
219            CommodityID::new("commodity1"),
220            Rc::new(Commodity {
221                id: "commodity1".into(),
222                description: "A commodity".into(),
223                kind: CommodityType::SupplyEqualsDemand,
224                time_slice_level: TimeSliceLevel::Annual,
225                pricing_strategy: PricingStrategy::Shadow,
226                levies_prod: CommodityLevyMap::new(),
227                levies_cons: CommodityLevyMap::new(),
228                demand: DemandMap::new(),
229            }),
230        )]);
231
232        // Valid case
233        let mut map = AgentCommodityPortionsMap::new();
234        map.insert(("commodity1".into(), 2020), Dimensionless(1.0));
235        let agent_commodity_portions = HashMap::from([("agent1".into(), map)]);
236        validate_agent_commodity_portions(
237            &agent_commodity_portions,
238            &agents,
239            &commodities,
240            &region_ids,
241            &milestone_years,
242        )
243        .unwrap();
244
245        // Invalid case: portions do not sum to 1
246        let mut map_v2 = AgentCommodityPortionsMap::new();
247        map_v2.insert(("commodity1".into(), 2020), Dimensionless(0.5));
248        let agent_commodities_v2 = HashMap::from([("agent1".into(), map_v2)]);
249        assert!(
250            validate_agent_commodity_portions(
251                &agent_commodities_v2,
252                &agents,
253                &commodities,
254                &region_ids,
255                &milestone_years
256            )
257            .is_err()
258        );
259
260        // Invalid case: SED commodity without associated commodity portions
261        commodities.insert(
262            CommodityID::new("commodity2"),
263            Rc::new(Commodity {
264                id: "commodity2".into(),
265                description: "Another commodity".into(),
266                kind: CommodityType::SupplyEqualsDemand,
267                time_slice_level: TimeSliceLevel::Annual,
268                pricing_strategy: PricingStrategy::Shadow,
269                levies_prod: CommodityLevyMap::new(),
270                levies_cons: CommodityLevyMap::new(),
271                demand: DemandMap::new(),
272            }),
273        );
274        assert!(
275            validate_agent_commodity_portions(
276                &agent_commodity_portions,
277                &agents,
278                &commodities,
279                &region_ids,
280                &milestone_years
281            )
282            .is_err()
283        );
284    }
285}