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