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, Flow, 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_unit_param_default!(default_remaining_demand_absolute_tolerance, Flow, 1e-12);
80define_param_default!(default_max_ironing_out_iterations, u32, 10);
81define_param_default!(default_capacity_margin, f64, 0.2);
82define_param_default!(default_mothball_years, u32, 0);
83
84/// Model parameters as defined in the `model.toml` file.
85///
86/// NOTE: If you add or change a field in this struct, you must also update the schema in
87/// `schemas/input/model.yaml`.
88#[derive(Debug, Deserialize, PartialEq)]
89pub struct ModelParameters {
90    /// Milestone years
91    pub milestone_years: Vec<u32>,
92    /// Allow known-broken options to be enabled.
93    #[serde(default, rename = "please_give_me_broken_results")] // Can't use constant here :-(
94    pub allow_broken_options: bool,
95    /// The (small) value of capacity given to candidate assets.
96    ///
97    /// Don't change unless you know what you're doing.
98    #[serde(default = "default_candidate_asset_capacity")]
99    pub candidate_asset_capacity: Capacity,
100    /// Affects the maximum capacity that can be given to a newly created asset.
101    ///
102    /// It is the proportion of maximum capacity that could be required across time slices.
103    #[serde(default = "default_capacity_limit_factor")]
104    #[serde(deserialize_with = "deserialise_proportion_nonzero")]
105    pub capacity_limit_factor: Dimensionless,
106    /// The cost applied to unmet demand.
107    ///
108    /// Currently this only applies to the LCOX appraisal.
109    #[serde(default = "default_value_of_lost_load")]
110    pub value_of_lost_load: MoneyPerFlow,
111    /// The maximum number of iterations to run the "ironing out" step of agent investment for
112    #[serde(default = "default_max_ironing_out_iterations")]
113    pub max_ironing_out_iterations: u32,
114    /// The relative tolerance for price convergence in the ironing out loop
115    #[serde(default = "default_price_tolerance")]
116    pub price_tolerance: Dimensionless,
117    /// Slack applied during cycle balancing, allowing newly selected assets to flex their capacity
118    /// by this proportion.
119    ///
120    /// Existing assets remain fixed; this gives newly selected assets the wiggle-room to absorb
121    /// small demand changes before we would otherwise need to break for re-investment.
122    #[serde(default = "default_capacity_margin")]
123    pub capacity_margin: f64,
124    /// Number of years an asset can remain unused before being decommissioned
125    #[serde(default = "default_mothball_years")]
126    pub mothball_years: u32,
127    /// Absolute tolerance when checking if remaining demand is close enough to zero
128    #[serde(default = "default_remaining_demand_absolute_tolerance")]
129    pub remaining_demand_absolute_tolerance: Flow,
130}
131
132/// Check that the `milestone_years` parameter is valid
133fn check_milestone_years(years: &[u32]) -> Result<()> {
134    ensure!(!years.is_empty(), "`milestone_years` is empty");
135
136    ensure!(
137        is_sorted_and_unique(years),
138        "`milestone_years` must be composed of unique values in order"
139    );
140
141    Ok(())
142}
143
144/// Check that the `value_of_lost_load` parameter is valid
145fn check_value_of_lost_load(value: MoneyPerFlow) -> Result<()> {
146    ensure!(
147        value.is_finite() && value > MoneyPerFlow(0.0),
148        "value_of_lost_load must be a finite number greater than zero"
149    );
150
151    Ok(())
152}
153
154/// Check that the `max_ironing_out_iterations` parameter is valid
155fn check_max_ironing_out_iterations(value: u32) -> Result<()> {
156    ensure!(value > 0, "max_ironing_out_iterations cannot be zero");
157
158    Ok(())
159}
160
161/// Check the `price_tolerance` parameter is valid
162fn check_price_tolerance(value: Dimensionless) -> Result<()> {
163    ensure!(
164        value.is_finite() && value >= Dimensionless(0.0),
165        "price_tolerance must be a finite number greater than or equal to zero"
166    );
167
168    Ok(())
169}
170
171fn check_remaining_demand_absolute_tolerance(
172    allow_broken_options: bool,
173    value: Flow,
174) -> Result<()> {
175    ensure!(
176        value.is_finite() && value >= Flow(0.0),
177        "remaining_demand_absolute_tolerance must be a finite number greater than or equal to zero"
178    );
179
180    let default_value = default_remaining_demand_absolute_tolerance();
181    if !allow_broken_options {
182        ensure!(
183            value == default_value,
184            "Setting a remaining_demand_absolute_tolerance different from the default value of {:e} \
185             is potentially dangerous, set please_give_me_broken_results to true \
186             if you want to allow this.",
187            default_value.0
188        );
189    }
190
191    Ok(())
192}
193
194/// Check that the `capacity_margin` parameter is valid
195fn check_capacity_margin(value: f64) -> Result<()> {
196    ensure!(
197        value.is_finite() && value >= 0.0,
198        "capacity_margin must be a finite number greater than or equal to zero"
199    );
200
201    Ok(())
202}
203
204impl ModelParameters {
205    /// Read a model file from the specified directory.
206    ///
207    /// # Arguments
208    ///
209    /// * `model_dir` - Folder containing model configuration files
210    ///
211    /// # Returns
212    ///
213    /// The model file contents as a [`ModelParameters`] struct or an error if the file is invalid
214    pub fn from_path<P: AsRef<Path>>(model_dir: P) -> Result<ModelParameters> {
215        let file_path = model_dir.as_ref().join(MODEL_PARAMETERS_FILE_NAME);
216        let model_params: ModelParameters = read_toml(&file_path)?;
217
218        set_broken_model_options_flag(model_params.allow_broken_options);
219
220        model_params
221            .validate()
222            .with_context(|| input_err_msg(file_path))?;
223
224        Ok(model_params)
225    }
226
227    /// Validate parameters after reading in file
228    fn validate(&self) -> Result<()> {
229        if self.allow_broken_options {
230            warn!(
231                "!!! You've enabled the {ALLOW_BROKEN_OPTION_NAME} option. !!!\n\
232                I see you like to live dangerously 😈. This option should ONLY be used by \
233                developers as it can cause peculiar behaviour that breaks things. NEVER enable it \
234                for results you actually care about or want to publish. You have been warned!"
235            );
236        }
237
238        // milestone_years
239        check_milestone_years(&self.milestone_years)?;
240
241        // capacity_limit_factor already validated with deserialise_proportion_nonzero
242
243        // candidate_asset_capacity
244        check_capacity_valid_for_asset(self.candidate_asset_capacity)
245            .context("Invalid value for candidate_asset_capacity")?;
246
247        // value_of_lost_load
248        check_value_of_lost_load(self.value_of_lost_load)?;
249
250        // max_ironing_out_iterations
251        check_max_ironing_out_iterations(self.max_ironing_out_iterations)?;
252
253        // price_tolerance
254        check_price_tolerance(self.price_tolerance)?;
255
256        // capacity_margin
257        check_capacity_margin(self.capacity_margin)?;
258
259        // remaining_demand_absolute_tolerance
260        check_remaining_demand_absolute_tolerance(
261            self.allow_broken_options,
262            self.remaining_demand_absolute_tolerance,
263        )?;
264
265        Ok(())
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272    use rstest::rstest;
273    use std::fmt::Display;
274    use std::fs::File;
275    use std::io::Write;
276    use tempfile::tempdir;
277
278    /// Helper function to assert validation result based on expected validity
279    fn assert_validation_result<T, U: Display>(
280        result: Result<T>,
281        expected_valid: bool,
282        value: U,
283        expected_error_fragment: &str,
284    ) {
285        if expected_valid {
286            assert!(
287                result.is_ok(),
288                "Expected value {} to be valid, but got error: {:?}",
289                value,
290                result.err()
291            );
292        } else {
293            assert!(
294                result.is_err(),
295                "Expected value {value} to be invalid, but it was accepted",
296            );
297            let error_message = result.err().unwrap().to_string();
298            assert!(
299                error_message.contains(expected_error_fragment),
300                "Error message should mention the validation constraint, got: {error_message}",
301            );
302        }
303    }
304
305    #[test]
306    fn check_milestone_years_works() {
307        // Valid
308        check_milestone_years(&[1]).unwrap();
309        check_milestone_years(&[1, 2]).unwrap();
310
311        // Invalid
312        assert!(check_milestone_years(&[]).is_err());
313        assert!(check_milestone_years(&[1, 1]).is_err());
314        assert!(check_milestone_years(&[2, 1]).is_err());
315    }
316
317    #[test]
318    fn model_params_from_path() {
319        let dir = tempdir().unwrap();
320        {
321            let mut file = File::create(dir.path().join(MODEL_PARAMETERS_FILE_NAME)).unwrap();
322            writeln!(file, "milestone_years = [2020, 2100]").unwrap();
323        }
324
325        let model_params = ModelParameters::from_path(dir.path()).unwrap();
326        assert_eq!(model_params.milestone_years, [2020, 2100]);
327    }
328
329    #[rstest]
330    #[case(1.0, true)] // Valid positive value
331    #[case(1e-10, true)] // Valid very small positive value
332    #[case(1e9, true)] // Valid large value (default)
333    #[case(f64::MAX, true)] // Valid maximum finite value
334    #[case(0.0, false)] // Invalid: exactly zero
335    #[case(-1.0, false)] // Invalid: negative value
336    #[case(-1e-10, false)] // Invalid: very small negative value
337    #[case(f64::INFINITY, false)] // Invalid: infinite value
338    #[case(f64::NEG_INFINITY, false)] // Invalid: negative infinite value
339    #[case(f64::NAN, false)] // Invalid: NaN value
340    fn check_value_of_lost_load_works(#[case] value: f64, #[case] expected_valid: bool) {
341        let money_per_flow = MoneyPerFlow::new(value);
342        let result = check_value_of_lost_load(money_per_flow);
343
344        assert_validation_result(
345            result,
346            expected_valid,
347            value,
348            "value_of_lost_load must be a finite number greater than zero",
349        );
350    }
351
352    #[rstest]
353    #[case(1, true)] // Valid minimum value
354    #[case(10, true)] // Valid default value
355    #[case(100, true)] // Valid large value
356    #[case(u32::MAX, true)] // Valid maximum value
357    #[case(0, false)] // Invalid: zero
358    fn check_max_ironing_out_iterations_works(#[case] value: u32, #[case] expected_valid: bool) {
359        let result = check_max_ironing_out_iterations(value);
360
361        assert_validation_result(
362            result,
363            expected_valid,
364            value,
365            "max_ironing_out_iterations cannot be zero",
366        );
367    }
368
369    #[rstest]
370    #[case(0.0, true)] // Valid minimum value (exactly zero)
371    #[case(1e-10, true)] // Valid very small positive value
372    #[case(1e-6, true)] // Valid default value
373    #[case(1.0, true)] // Valid larger value
374    #[case(f64::MAX, true)] // Valid maximum finite value
375    #[case(-1e-10, false)] // Invalid: negative value
376    #[case(-1.0, false)] // Invalid: negative value
377    #[case(f64::INFINITY, false)] // Invalid: infinite value
378    #[case(f64::NEG_INFINITY, false)] // Invalid: negative infinite value
379    #[case(f64::NAN, false)] // Invalid: NaN value
380    fn check_price_tolerance_works(#[case] value: f64, #[case] expected_valid: bool) {
381        let dimensionless = Dimensionless::new(value);
382        let result = check_price_tolerance(dimensionless);
383
384        assert_validation_result(
385            result,
386            expected_valid,
387            value,
388            "price_tolerance must be a finite number greater than or equal to zero",
389        );
390    }
391
392    #[rstest]
393    #[case(true, 0.0, true)] // Valid minimum value broken options allowed
394    #[case(true, 1e-10, true)] // Valid value with broken options allowed
395    #[case(true, 1e-15, true)] // Valid value with broken options allowed
396    #[case(false, 1e-12, true)] // Valid value same as default, no broken options needed
397    #[case(true, 1.0, true)] // Valid larger value with broken options allowed
398    #[case(true, f64::MAX, true)] // Valid maximum finite value with broken options allowed
399    #[case(true, -1e-10, false)] // Invalid: negative value
400    #[case(true, f64::INFINITY, false)] // Invalid: positive infinity
401    #[case(true, f64::NEG_INFINITY, false)] // Invalid: negative infinity
402    #[case(true, f64::NAN, false)] // Invalid: NaN
403    #[case(false, -1e-10, false)] // Invalid: negative value
404    #[case(false, f64::INFINITY, false)] // Invalid: positive infinity
405    #[case(false, f64::NEG_INFINITY, false)] // Invalid: negative infinity
406    #[case(false, f64::NAN, false)] // Invalid: NaN
407    fn check_remaining_demand_absolute_tolerance_works(
408        #[case] allow_broken_options: bool,
409        #[case] value: f64,
410        #[case] expected_valid: bool,
411    ) {
412        let flow = Flow::new(value);
413        let result = check_remaining_demand_absolute_tolerance(allow_broken_options, flow);
414
415        assert_validation_result(
416            result,
417            expected_valid,
418            value,
419            "remaining_demand_absolute_tolerance must be a finite number greater than or equal to zero",
420        );
421    }
422
423    #[rstest]
424    #[case(0.0)] // smaller than default
425    #[case(1e-10)] // Larger than default (1e-12)
426    #[case(1.0)] // Well above default
427    #[case(f64::MAX)] // Maximum finite value
428    fn check_remaining_demand_absolute_tolerance_requires_broken_options_if_non_default(
429        #[case] value: f64,
430    ) {
431        let flow = Flow::new(value);
432        let result = check_remaining_demand_absolute_tolerance(false, flow);
433        assert_validation_result(
434            result,
435            false,
436            value,
437            "Setting a remaining_demand_absolute_tolerance different from the default value \
438             of 1e-12 is potentially dangerous, set \
439             please_give_me_broken_results to true if you want to allow this.",
440        );
441    }
442
443    #[rstest]
444    #[case(0.0, true)] // Valid minimum value
445    #[case(0.2, true)] // Valid default value
446    #[case(10.0, true)] // Valid large value
447    #[case(-1e-6, false)] // Invalid: negative margin
448    #[case(f64::INFINITY, false)] // Invalid: infinite value
449    #[case(f64::NEG_INFINITY, false)] // Invalid: negative infinite value
450    #[case(f64::NAN, false)] // Invalid: NaN value
451    fn check_capacity_margin_works(#[case] value: f64, #[case] expected_valid: bool) {
452        let result = check_capacity_margin(value);
453
454        assert_validation_result(
455            result,
456            expected_valid,
457            value,
458            "capacity_margin must be a finite number greater than or equal to zero",
459        );
460    }
461}