muse2/model/
parameters.rs

1//! Read and validate model parameters from `model.toml`.
2//!
3//! This module defines the `ModelParameters` struct and helpers for loading and
4//! validating the `model.toml` configuration used by the model. Validation
5//! functions ensure sensible numeric ranges and invariants for runtime use.
6use crate::asset::check_capacity_valid_for_asset;
7use crate::input::{
8    deserialise_proportion_nonzero, input_err_msg, is_sorted_and_unique, read_toml,
9};
10use crate::units::{Capacity, Dimensionless, MoneyPerFlow};
11use anyhow::{Context, Result, ensure};
12use log::warn;
13use serde::Deserialize;
14use std::path::Path;
15use std::sync::OnceLock;
16
17const MODEL_PARAMETERS_FILE_NAME: &str = "model.toml";
18
19/// The key in `model.toml` which enables known-broken model options.
20///
21/// If this option is present and true, the model will permit certain
22/// experimental or unsafe behaviours that are normally disallowed.
23pub const ALLOW_BROKEN_OPTION_NAME: &str = "please_give_me_broken_results";
24
25/// Global flag indicating whether broken model options have been enabled.
26///
27/// This is stored in a `OnceLock` and must be set exactly once during
28/// startup (see `set_broken_model_options_flag`).
29static BROKEN_OPTIONS_ALLOWED: OnceLock<bool> = OnceLock::new();
30
31/// Return whether broken model options were enabled by the loaded config.
32///
33/// # Panics
34///
35/// Panics if the global flag has not been set yet (the flag should be set by
36/// `ModelParameters::from_path` during program initialization).
37pub fn broken_model_options_allowed() -> bool {
38    *BROKEN_OPTIONS_ALLOWED
39        .get()
40        .expect("Broken options flag not set")
41}
42
43/// Set the global flag indicating whether broken model options are allowed.
44///
45/// Can only be called once; subsequent calls will panic (except in tests, where it can be called
46/// multiple times so long as the value is the same).
47fn set_broken_model_options_flag(allowed: bool) {
48    let result = BROKEN_OPTIONS_ALLOWED.set(allowed);
49    if result.is_err() {
50        if cfg!(test) {
51            // Sanity check
52            assert_eq!(allowed, broken_model_options_allowed());
53        } else {
54            panic!("Attempted to set BROKEN_OPTIONS_ALLOWED twice");
55        }
56    }
57}
58
59macro_rules! define_unit_param_default {
60    ($name:ident, $type: ty, $value: expr) => {
61        fn $name() -> $type {
62            <$type>::new($value)
63        }
64    };
65}
66
67macro_rules! define_param_default {
68    ($name:ident, $type: ty, $value: expr) => {
69        fn $name() -> $type {
70            $value
71        }
72    };
73}
74
75define_unit_param_default!(default_candidate_asset_capacity, Capacity, 0.0001);
76define_unit_param_default!(default_capacity_limit_factor, Dimensionless, 0.1);
77define_unit_param_default!(default_value_of_lost_load, MoneyPerFlow, 1e9);
78define_unit_param_default!(default_price_tolerance, Dimensionless, 1e-6);
79define_param_default!(default_max_ironing_out_iterations, u32, 10);
80define_param_default!(default_capacity_margin, f64, 0.2);
81define_param_default!(default_mothball_years, u32, 0);
82
83/// Model parameters as defined in the `model.toml` file.
84///
85/// NOTE: If you add or change a field in this struct, you must also update the schema in
86/// `schemas/input/model.yaml`.
87#[derive(Debug, Deserialize, PartialEq)]
88pub struct ModelParameters {
89    /// Milestone years
90    pub milestone_years: Vec<u32>,
91    /// Allow known-broken options to be enabled.
92    #[serde(default, rename = "please_give_me_broken_results")] // Can't use constant here :-(
93    pub allow_broken_options: bool,
94    /// The (small) value of capacity given to candidate assets.
95    ///
96    /// Don't change unless you know what you're doing.
97    #[serde(default = "default_candidate_asset_capacity")]
98    pub candidate_asset_capacity: Capacity,
99    /// Affects the maximum capacity that can be given to a newly created asset.
100    ///
101    /// It is the proportion of maximum capacity that could be required across time slices.
102    #[serde(default = "default_capacity_limit_factor")]
103    #[serde(deserialize_with = "deserialise_proportion_nonzero")]
104    pub capacity_limit_factor: Dimensionless,
105    /// The cost applied to unmet demand.
106    ///
107    /// Currently this only applies to the LCOX appraisal.
108    #[serde(default = "default_value_of_lost_load")]
109    pub value_of_lost_load: MoneyPerFlow,
110    /// The maximum number of iterations to run the "ironing out" step of agent investment for
111    #[serde(default = "default_max_ironing_out_iterations")]
112    pub max_ironing_out_iterations: u32,
113    /// The relative tolerance for price convergence in the ironing out loop
114    #[serde(default = "default_price_tolerance")]
115    pub price_tolerance: Dimensionless,
116    /// Slack applied during cycle balancing, allowing newly selected assets to flex their capacity
117    /// by this proportion.
118    ///
119    /// Existing assets remain fixed; this gives newly selected assets the wiggle-room to absorb
120    /// small demand changes before we would otherwise need to break for re-investment.
121    #[serde(default = "default_capacity_margin")]
122    pub capacity_margin: f64,
123    /// Number of years an asset can remain unused before being decommissioned
124    #[serde(default = "default_mothball_years")]
125    pub mothball_years: u32,
126}
127
128/// Check that the `milestone_years` parameter is valid
129fn check_milestone_years(years: &[u32]) -> Result<()> {
130    ensure!(!years.is_empty(), "`milestone_years` is empty");
131
132    ensure!(
133        is_sorted_and_unique(years),
134        "`milestone_years` must be composed of unique values in order"
135    );
136
137    Ok(())
138}
139
140/// Check that the `value_of_lost_load` parameter is valid
141fn check_value_of_lost_load(value: MoneyPerFlow) -> Result<()> {
142    ensure!(
143        value.is_finite() && value > MoneyPerFlow(0.0),
144        "value_of_lost_load must be a finite number greater than zero"
145    );
146
147    Ok(())
148}
149
150/// Check that the `max_ironing_out_iterations` parameter is valid
151fn check_max_ironing_out_iterations(value: u32) -> Result<()> {
152    ensure!(value > 0, "max_ironing_out_iterations cannot be zero");
153
154    Ok(())
155}
156
157/// Check the `price_tolerance` parameter is valid
158fn check_price_tolerance(value: Dimensionless) -> Result<()> {
159    ensure!(
160        value.is_finite() && value >= Dimensionless(0.0),
161        "price_tolerance must be a finite number greater than or equal to zero"
162    );
163
164    Ok(())
165}
166
167/// Check that the `capacity_margin` parameter is valid
168fn check_capacity_margin(value: f64) -> Result<()> {
169    ensure!(
170        value.is_finite() && value >= 0.0,
171        "capacity_margin must be a finite number greater than or equal to zero"
172    );
173
174    Ok(())
175}
176
177impl ModelParameters {
178    /// Read a model file from the specified directory.
179    ///
180    /// # Arguments
181    ///
182    /// * `model_dir` - Folder containing model configuration files
183    ///
184    /// # Returns
185    ///
186    /// The model file contents as a [`ModelParameters`] struct or an error if the file is invalid
187    pub fn from_path<P: AsRef<Path>>(model_dir: P) -> Result<ModelParameters> {
188        let file_path = model_dir.as_ref().join(MODEL_PARAMETERS_FILE_NAME);
189        let model_params: ModelParameters = read_toml(&file_path)?;
190
191        set_broken_model_options_flag(model_params.allow_broken_options);
192
193        model_params
194            .validate()
195            .with_context(|| input_err_msg(file_path))?;
196
197        Ok(model_params)
198    }
199
200    /// Validate parameters after reading in file
201    fn validate(&self) -> Result<()> {
202        if self.allow_broken_options {
203            warn!(
204                "!!! You've enabled the {ALLOW_BROKEN_OPTION_NAME} option. !!!\n\
205                I see you like to live dangerously 😈. This option should ONLY be used by \
206                developers as it can cause peculiar behaviour that breaks things. NEVER enable it \
207                for results you actually care about or want to publish. You have been warned!"
208            );
209        }
210
211        // milestone_years
212        check_milestone_years(&self.milestone_years)?;
213
214        // capacity_limit_factor already validated with deserialise_proportion_nonzero
215
216        // candidate_asset_capacity
217        check_capacity_valid_for_asset(self.candidate_asset_capacity)
218            .context("Invalid value for candidate_asset_capacity")?;
219
220        // value_of_lost_load
221        check_value_of_lost_load(self.value_of_lost_load)?;
222
223        // max_ironing_out_iterations
224        check_max_ironing_out_iterations(self.max_ironing_out_iterations)?;
225
226        // price_tolerance
227        check_price_tolerance(self.price_tolerance)?;
228
229        // capacity_margin
230        check_capacity_margin(self.capacity_margin)?;
231
232        Ok(())
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use rstest::rstest;
240    use std::fmt::Display;
241    use std::fs::File;
242    use std::io::Write;
243    use tempfile::tempdir;
244
245    /// Helper function to assert validation result based on expected validity
246    fn assert_validation_result<T, U: Display>(
247        result: Result<T>,
248        expected_valid: bool,
249        value: U,
250        expected_error_fragment: &str,
251    ) {
252        if expected_valid {
253            assert!(
254                result.is_ok(),
255                "Expected value {} to be valid, but got error: {:?}",
256                value,
257                result.err()
258            );
259        } else {
260            assert!(
261                result.is_err(),
262                "Expected value {value} to be invalid, but it was accepted",
263            );
264            let error_message = result.err().unwrap().to_string();
265            assert!(
266                error_message.contains(expected_error_fragment),
267                "Error message should mention the validation constraint, got: {error_message}",
268            );
269        }
270    }
271
272    #[test]
273    fn check_milestone_years_works() {
274        // Valid
275        check_milestone_years(&[1]).unwrap();
276        check_milestone_years(&[1, 2]).unwrap();
277
278        // Invalid
279        assert!(check_milestone_years(&[]).is_err());
280        assert!(check_milestone_years(&[1, 1]).is_err());
281        assert!(check_milestone_years(&[2, 1]).is_err());
282    }
283
284    #[test]
285    fn model_params_from_path() {
286        let dir = tempdir().unwrap();
287        {
288            let mut file = File::create(dir.path().join(MODEL_PARAMETERS_FILE_NAME)).unwrap();
289            writeln!(file, "milestone_years = [2020, 2100]").unwrap();
290        }
291
292        let model_params = ModelParameters::from_path(dir.path()).unwrap();
293        assert_eq!(model_params.milestone_years, [2020, 2100]);
294    }
295
296    #[rstest]
297    #[case(1.0, true)] // Valid positive value
298    #[case(1e-10, true)] // Valid very small positive value
299    #[case(1e9, true)] // Valid large value (default)
300    #[case(f64::MAX, true)] // Valid maximum finite value
301    #[case(0.0, false)] // Invalid: exactly zero
302    #[case(-1.0, false)] // Invalid: negative value
303    #[case(-1e-10, false)] // Invalid: very small negative value
304    #[case(f64::INFINITY, false)] // Invalid: infinite value
305    #[case(f64::NEG_INFINITY, false)] // Invalid: negative infinite value
306    #[case(f64::NAN, false)] // Invalid: NaN value
307    fn check_value_of_lost_load_works(#[case] value: f64, #[case] expected_valid: bool) {
308        let money_per_flow = MoneyPerFlow::new(value);
309        let result = check_value_of_lost_load(money_per_flow);
310
311        assert_validation_result(
312            result,
313            expected_valid,
314            value,
315            "value_of_lost_load must be a finite number greater than zero",
316        );
317    }
318
319    #[rstest]
320    #[case(1, true)] // Valid minimum value
321    #[case(10, true)] // Valid default value
322    #[case(100, true)] // Valid large value
323    #[case(u32::MAX, true)] // Valid maximum value
324    #[case(0, false)] // Invalid: zero
325    fn check_max_ironing_out_iterations_works(#[case] value: u32, #[case] expected_valid: bool) {
326        let result = check_max_ironing_out_iterations(value);
327
328        assert_validation_result(
329            result,
330            expected_valid,
331            value,
332            "max_ironing_out_iterations cannot be zero",
333        );
334    }
335
336    #[rstest]
337    #[case(0.0, true)] // Valid minimum value (exactly zero)
338    #[case(1e-10, true)] // Valid very small positive value
339    #[case(1e-6, true)] // Valid default value
340    #[case(1.0, true)] // Valid larger value
341    #[case(f64::MAX, true)] // Valid maximum finite value
342    #[case(-1e-10, false)] // Invalid: negative value
343    #[case(-1.0, false)] // Invalid: negative value
344    #[case(f64::INFINITY, false)] // Invalid: infinite value
345    #[case(f64::NEG_INFINITY, false)] // Invalid: negative infinite value
346    #[case(f64::NAN, false)] // Invalid: NaN value
347    fn check_price_tolerance_works(#[case] value: f64, #[case] expected_valid: bool) {
348        let dimensionless = Dimensionless::new(value);
349        let result = check_price_tolerance(dimensionless);
350
351        assert_validation_result(
352            result,
353            expected_valid,
354            value,
355            "price_tolerance must be a finite number greater than or equal to zero",
356        );
357    }
358
359    #[rstest]
360    #[case(0.0, true)] // Valid minimum value
361    #[case(0.2, true)] // Valid default value
362    #[case(10.0, true)] // Valid large value
363    #[case(-1e-6, false)] // Invalid: negative margin
364    #[case(f64::INFINITY, false)] // Invalid: infinite value
365    #[case(f64::NEG_INFINITY, false)] // Invalid: negative infinite value
366    #[case(f64::NAN, false)] // Invalid: NaN value
367    fn check_capacity_margin_works(#[case] value: f64, #[case] expected_valid: bool) {
368        let result = check_capacity_margin(value);
369
370        assert_validation_result(
371            result,
372            expected_valid,
373            value,
374            "capacity_margin must be a finite number greater than or equal to zero",
375        );
376    }
377}