muse2/
model.rs

1//! The model represents the static input data provided by the user.
2use crate::agent::AgentMap;
3use crate::asset::check_capacity_valid_for_asset;
4use crate::commodity::{CommodityID, CommodityMap};
5use crate::input::{
6    deserialise_proportion_nonzero, input_err_msg, is_sorted_and_unique, read_toml,
7};
8use crate::process::ProcessMap;
9use crate::region::{RegionID, RegionMap};
10use crate::time_slice::TimeSliceInfo;
11use crate::units::{Capacity, Dimensionless, MoneyPerFlow};
12use anyhow::{Context, Result, ensure};
13use log::warn;
14use serde::Deserialize;
15use serde_string_enum::DeserializeLabeledStringEnum;
16use std::collections::HashMap;
17use std::path::{Path, PathBuf};
18
19const MODEL_FILE_NAME: &str = "model.toml";
20
21macro_rules! define_unit_param_default {
22    ($name:ident, $type: ty, $value: expr) => {
23        fn $name() -> $type {
24            <$type>::new($value)
25        }
26    };
27}
28
29macro_rules! define_param_default {
30    ($name:ident, $type: ty, $value: expr) => {
31        fn $name() -> $type {
32            $value
33        }
34    };
35}
36
37define_unit_param_default!(default_candidate_asset_capacity, Capacity, 0.0001);
38define_unit_param_default!(default_capacity_limit_factor, Dimensionless, 0.1);
39define_unit_param_default!(default_value_of_lost_load, MoneyPerFlow, 1e9);
40define_unit_param_default!(default_price_tolerance, Dimensionless, 1e-6);
41define_param_default!(default_max_ironing_out_iterations, u32, 10);
42
43/// Model definition
44pub struct Model {
45    /// Path to model folder
46    pub model_path: PathBuf,
47    /// Parameters from the model TOML file
48    pub parameters: ModelFile,
49    /// Agents for the simulation
50    pub agents: AgentMap,
51    /// Commodities for the simulation
52    pub commodities: CommodityMap,
53    /// Processes for the simulation
54    pub processes: ProcessMap,
55    /// Information about seasons and time slices
56    pub time_slice_info: TimeSliceInfo,
57    /// Regions for the simulation
58    pub regions: RegionMap,
59    /// Commodity ordering for each region and year
60    pub commodity_order: HashMap<(RegionID, u32), Vec<CommodityID>>,
61}
62
63/// Represents the contents of the entire model file.
64#[derive(Debug, Deserialize, PartialEq)]
65pub struct ModelFile {
66    /// Milestone years
67    pub milestone_years: Vec<u32>,
68    /// The (small) value of capacity given to candidate assets.
69    ///
70    /// Don't change unless you know what you're doing.
71    #[serde(default = "default_candidate_asset_capacity")]
72    pub candidate_asset_capacity: Capacity,
73    /// Defines the strategy used for calculating commodity prices
74    #[serde(default)]
75    pub pricing_strategy: PricingStrategy,
76    /// Affects the maximum capacity that can be given to a newly created asset.
77    ///
78    /// It is the proportion of maximum capacity that could be required across time slices.
79    #[serde(default = "default_capacity_limit_factor")]
80    #[serde(deserialize_with = "deserialise_proportion_nonzero")]
81    pub capacity_limit_factor: Dimensionless,
82    /// The cost applied to unmet demand.
83    ///
84    /// Currently this only applies to the LCOX appraisal.
85    #[serde(default = "default_value_of_lost_load")]
86    pub value_of_lost_load: MoneyPerFlow,
87    /// The maximum number of iterations to run the "ironing out" step of agent investment for
88    #[serde(default = "default_max_ironing_out_iterations")]
89    pub max_ironing_out_iterations: u32,
90    /// The relative tolerance for price convergence in the ironing out loop
91    #[serde(default = "default_price_tolerance")]
92    pub price_tolerance: Dimensionless,
93}
94
95/// The strategy used for calculating commodity prices
96#[derive(DeserializeLabeledStringEnum, Debug, PartialEq, Default)]
97pub enum PricingStrategy {
98    /// Take commodity prices directly from the shadow prices
99    #[default]
100    #[string = "shadow_prices"]
101    ShadowPrices,
102    /// Adjust shadow prices for scarcity
103    #[string = "scarcity_adjusted"]
104    ScarcityAdjusted,
105}
106
107/// Check that the milestone years parameter is valid
108///
109/// # Arguments
110///
111/// * `years` - Integer list of milestone years
112///
113/// # Returns
114///
115/// An error if the milestone years are invalid
116fn check_milestone_years(years: &[u32]) -> Result<()> {
117    ensure!(!years.is_empty(), "`milestone_years` is empty");
118
119    ensure!(
120        is_sorted_and_unique(years),
121        "`milestone_years` must be composed of unique values in order"
122    );
123
124    Ok(())
125}
126
127impl ModelFile {
128    /// Read a model file from the specified directory.
129    ///
130    /// # Arguments
131    ///
132    /// * `model_dir` - Folder containing model configuration files
133    ///
134    /// # Returns
135    ///
136    /// The model file contents as a `ModelFile` struct or an error if the file is invalid
137    pub fn from_path<P: AsRef<Path>>(model_dir: P) -> Result<ModelFile> {
138        let file_path = model_dir.as_ref().join(MODEL_FILE_NAME);
139        let model_file: ModelFile = read_toml(&file_path)?;
140
141        if model_file.pricing_strategy == PricingStrategy::ScarcityAdjusted {
142            warn!(
143                "The pricing strategy is set to 'scarcity_adjusted'. Commodity prices may be \
144                incorrect if assets have more than one output commodity. See: {}/issues/677",
145                env!("CARGO_PKG_REPOSITORY")
146            );
147        }
148
149        let validate = || -> Result<()> {
150            check_milestone_years(&model_file.milestone_years)?;
151            check_capacity_valid_for_asset(model_file.candidate_asset_capacity)
152                .context("Invalid value for candidate_asset_capacity")?;
153
154            Ok(())
155        };
156        validate().with_context(|| input_err_msg(file_path))?;
157
158        Ok(model_file)
159    }
160}
161
162impl Model {
163    /// Iterate over the model's milestone years.
164    pub fn iter_years(&self) -> impl Iterator<Item = u32> + '_ {
165        self.parameters.milestone_years.iter().copied()
166    }
167
168    /// Iterate over the model's regions (region IDs).
169    pub fn iter_regions(&self) -> impl Iterator<Item = &RegionID> + '_ {
170        self.regions.keys()
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use std::fs::File;
178    use std::io::Write;
179    use tempfile::tempdir;
180
181    #[test]
182    fn test_check_milestone_years() {
183        // Valid
184        assert!(check_milestone_years(&[1]).is_ok());
185        assert!(check_milestone_years(&[1, 2]).is_ok());
186
187        // Invalid
188        assert!(check_milestone_years(&[]).is_err());
189        assert!(check_milestone_years(&[1, 1]).is_err());
190        assert!(check_milestone_years(&[2, 1]).is_err());
191    }
192
193    #[test]
194    fn test_model_file_from_path() {
195        let dir = tempdir().unwrap();
196        {
197            let mut file = File::create(dir.path().join(MODEL_FILE_NAME)).unwrap();
198            writeln!(file, "milestone_years = [2020, 2100]").unwrap();
199        }
200
201        let model_file = ModelFile::from_path(dir.path()).unwrap();
202        assert_eq!(model_file.milestone_years, [2020, 2100]);
203    }
204}