Skip to main content

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 validating the
4//! `model.toml` configuration used by the model. Validation functions ensure sensible numeric
5//! 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 itertools::Itertools;
13use log::warn;
14use serde::{Deserialize, Deserializer};
15use std::path::Path;
16use std::sync::OnceLock;
17use toml::Table;
18
19const MODEL_PARAMETERS_FILE_NAME: &str = "model.toml";
20
21/// The key in `model.toml` which enables potentially dangerous model options.
22///
23/// If this option is present and true, the model will permit certain experimental or unsafe
24/// behaviours that are normally disallowed.
25pub const ALLOW_DANGEROUS_OPTION_NAME: &str = "please_give_me_broken_results";
26
27/// Global flag indicating whether potentially dangerous model options have been enabled.
28///
29/// This is stored in a `OnceLock` and must be set exactly once during startup (see
30/// [`set_dangerous_model_options_flag`]).
31static DANGEROUS_OPTIONS_ENABLED: OnceLock<bool> = OnceLock::new();
32
33/// The default value for the `remaining_demand_absolute_tolerance` parameter
34const DEFAULT_REMAINING_DEMAND_ABSOLUTE_TOLERANCE: Flow = Flow(1e-12);
35
36/// Whether potentially dangerous model options were enabled by the loaded config.
37///
38/// # Panics
39///
40/// Panics if the global flag has not been set yet (the flag should be set by
41/// [`ModelParameters::from_path`] during program initialisation).
42pub fn dangerous_model_options_enabled() -> bool {
43    *DANGEROUS_OPTIONS_ENABLED
44        .get()
45        .expect("Dangerous options flag not set")
46}
47
48/// Set the global flag indicating whether potentially dangerous model options are enabled.
49///
50/// Can only be called once; subsequent calls will panic (except in tests, where it can be called
51/// multiple times so long as the value is the same).
52fn set_dangerous_model_options_flag(enabled: bool) {
53    let result = DANGEROUS_OPTIONS_ENABLED.set(enabled);
54    if result.is_err() {
55        if cfg!(test) {
56            // Sanity check
57            assert_eq!(enabled, dangerous_model_options_enabled());
58        } else {
59            panic!("Attempted to set DANGEROUS_OPTIONS_ENABLED twice");
60        }
61    }
62}
63
64/// Model parameters as defined in the `model.toml` file.
65///
66/// NOTE: If you add or change a field in this struct, you must also update the schema in
67/// `schemas/input/model.yaml`.
68#[derive(Deserialize)]
69#[serde(default)]
70pub struct ModelParameters {
71    /// Milestone years
72    pub milestone_years: Vec<u32>,
73    /// Allow potentially dangerous options to be enabled.
74    #[serde(rename = "please_give_me_broken_results")] // Can't use constant here :-(
75    pub allow_dangerous_options: bool,
76    /// The (small) value of capacity given to candidate assets.
77    ///
78    /// Don't change unless you know what you're doing.
79    pub candidate_asset_capacity: Capacity,
80    /// Affects the maximum capacity that can be given to a newly created asset.
81    ///
82    /// It is the proportion of maximum capacity that could be required across time slices.
83    #[serde(deserialize_with = "deserialise_proportion_nonzero")]
84    pub capacity_limit_factor: Dimensionless,
85    /// The cost applied to unmet demand.
86    ///
87    /// Currently this only applies to the LCOX appraisal.
88    pub value_of_lost_load: MoneyPerFlow,
89    /// The maximum number of iterations to run the "ironing out" step of agent investment for
90    pub max_ironing_out_iterations: u32,
91    /// The relative tolerance for price convergence in the ironing out loop
92    pub price_tolerance: Dimensionless,
93    /// Slack applied during cycle balancing, allowing newly selected assets to flex their capacity
94    /// by this proportion.
95    ///
96    /// Existing assets remain fixed; this gives newly selected assets the wiggle-room to absorb
97    /// small demand changes before we would otherwise need to break for re-investment.
98    pub capacity_margin: Dimensionless,
99    /// Number of years an asset can remain unused before being decommissioned
100    pub mothball_years: u32,
101    /// Absolute tolerance when checking if remaining demand is close enough to zero
102    pub remaining_demand_absolute_tolerance: Flow,
103    /// Options for the HiGHS solver.
104    ///
105    /// For a full list of options, see [the HiGHS documentation].
106    ///
107    /// [the HiGHS documentation]: https://ergo-code.github.io/HiGHS/stable/options/definitions/
108    pub highs: HighsOptions,
109}
110
111impl Default for ModelParameters {
112    fn default() -> Self {
113        Self {
114            // Required parameters.
115            // milestone_years cannot be empty and we validate this when loading model.toml files.
116            milestone_years: Vec::default(),
117
118            // Default values for optional parameters
119            allow_dangerous_options: false,
120            candidate_asset_capacity: Capacity(1e-4),
121            capacity_limit_factor: Dimensionless(0.1),
122            value_of_lost_load: MoneyPerFlow(1e9),
123            max_ironing_out_iterations: 1,
124            price_tolerance: Dimensionless(1e-6),
125            capacity_margin: Dimensionless(0.2),
126            mothball_years: 0,
127            remaining_demand_absolute_tolerance: DEFAULT_REMAINING_DEMAND_ABSOLUTE_TOLERANCE,
128            highs: HighsOptions::default(),
129        }
130    }
131}
132
133/// Defines the TOML table holding the sub-tables to define HiGHS options
134#[derive(Default)]
135pub struct HighsOptions {
136    /// HiGHS options applied to dispatch optimisation
137    pub dispatch_options: Table,
138    /// HiGHS options applied to appraisal optimisation
139    pub appraisal_options: Table,
140}
141
142impl<'de> Deserialize<'de> for HighsOptions {
143    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
144    where
145        D: Deserializer<'de>,
146    {
147        #[derive(Default, Deserialize)]
148        #[serde(default)]
149        #[allow(clippy::struct_field_names)]
150        struct RawHighsOptions {
151            global_options: Table,
152            dispatch_options: Table,
153            appraisal_options: Table,
154        }
155
156        let RawHighsOptions {
157            global_options,
158            mut dispatch_options,
159            mut appraisal_options,
160        } = RawHighsOptions::deserialize(deserializer)?;
161
162        let append_global_options = |options: &mut Table| {
163            for (option, value) in &global_options {
164                options
165                    .entry(option.clone())
166                    .or_insert_with(|| value.clone());
167            }
168        };
169        append_global_options(&mut dispatch_options);
170        append_global_options(&mut appraisal_options);
171
172        Ok(Self {
173            dispatch_options,
174            appraisal_options,
175        })
176    }
177}
178
179impl HighsOptions {
180    /// Log custom HiGHS options set by user, if any
181    fn log_options(&self) {
182        fn log_highs_options(name: &str, options: &Table) {
183            if options.is_empty() {
184                return;
185            }
186
187            let options_str = options
188                .iter()
189                .format_with("\n  - ", |(opt, val), f| f(&format_args!("{opt} = {val}")))
190                .to_string();
191            warn!("Using custom HiGHS options for {name}:\n  - {options_str}");
192        }
193
194        log_highs_options("dispatch", &self.dispatch_options);
195        log_highs_options("appraisal", &self.appraisal_options);
196    }
197
198    /// Check whether any options have been set
199    pub fn is_empty(&self) -> bool {
200        self.dispatch_options.is_empty() && self.appraisal_options.is_empty()
201    }
202}
203
204/// Check that the `milestone_years` parameter is valid
205fn check_milestone_years(years: &[u32]) -> Result<()> {
206    ensure!(
207        !years.is_empty(),
208        "`milestone_years` must be provided and non-empty"
209    );
210
211    ensure!(
212        is_sorted_and_unique(years),
213        "`milestone_years` must be composed of unique values in order"
214    );
215
216    Ok(())
217}
218
219/// Check that the `value_of_lost_load` parameter is valid
220fn check_value_of_lost_load(value: MoneyPerFlow) -> Result<()> {
221    ensure!(
222        value.is_finite() && value > MoneyPerFlow(0.0),
223        "value_of_lost_load must be a finite number greater than zero"
224    );
225
226    Ok(())
227}
228
229/// Check that the `max_ironing_out_iterations` parameter is valid
230fn check_max_ironing_out_iterations(value: u32) -> Result<()> {
231    ensure!(value > 0, "max_ironing_out_iterations cannot be zero");
232
233    Ok(())
234}
235
236/// Check the `price_tolerance` parameter is valid
237fn check_price_tolerance(value: Dimensionless) -> Result<()> {
238    ensure!(
239        value.is_finite() && value >= Dimensionless(0.0),
240        "price_tolerance must be a finite number greater than or equal to zero"
241    );
242
243    Ok(())
244}
245
246fn check_remaining_demand_absolute_tolerance(
247    dangerous_options_enabled: bool,
248    value: Flow,
249) -> Result<()> {
250    ensure!(
251        value.is_finite() && value >= Flow(0.0),
252        "remaining_demand_absolute_tolerance must be a finite number greater than or equal to zero"
253    );
254
255    if !dangerous_options_enabled {
256        ensure!(
257            value == DEFAULT_REMAINING_DEMAND_ABSOLUTE_TOLERANCE,
258            "Setting a remaining_demand_absolute_tolerance different from the default value of \
259            {:e} is potentially dangerous, set {ALLOW_DANGEROUS_OPTION_NAME} to true if you want \
260            to allow this.",
261            DEFAULT_REMAINING_DEMAND_ABSOLUTE_TOLERANCE.value()
262        );
263    }
264
265    Ok(())
266}
267
268/// Check that the `capacity_margin` parameter is valid
269fn check_capacity_margin(value: Dimensionless) -> Result<()> {
270    ensure!(
271        value.is_finite() && value >= Dimensionless(0.0),
272        "capacity_margin must be a finite number greater than or equal to zero"
273    );
274
275    Ok(())
276}
277
278/// Check the custom HiGHS options are valid.
279///
280/// Note that we cannot know whether the options specified exist and are of the correct type until
281/// we attempt to use them. We could check for types that are never valid (e.g. an array), but as
282/// we're checking later anyway, we don't bother.
283fn check_highs_options(dangerous_options_enabled: bool, highs: &HighsOptions) -> Result<()> {
284    ensure!(
285        dangerous_options_enabled || highs.is_empty(),
286        "Cannot set custom HiGHS options without enabling {ALLOW_DANGEROUS_OPTION_NAME}"
287    );
288
289    Ok(())
290}
291
292impl ModelParameters {
293    /// Read a model file from the specified directory.
294    ///
295    /// # Arguments
296    ///
297    /// * `model_dir` - Folder containing model configuration files
298    ///
299    /// # Returns
300    ///
301    /// The model file contents as a [`ModelParameters`] struct or an error if the file is invalid
302    pub fn from_path<P: AsRef<Path>>(model_dir: P) -> Result<ModelParameters> {
303        let file_path = model_dir.as_ref().join(MODEL_PARAMETERS_FILE_NAME);
304        let model_params: ModelParameters = read_toml(&file_path)?;
305
306        set_dangerous_model_options_flag(model_params.allow_dangerous_options);
307
308        model_params
309            .validate()
310            .with_context(|| input_err_msg(file_path))?;
311
312        model_params.highs.log_options();
313
314        Ok(model_params)
315    }
316
317    /// Validate parameters after reading in file
318    fn validate(&self) -> Result<()> {
319        if self.allow_dangerous_options {
320            warn!(
321                "!!! You've enabled the {ALLOW_DANGEROUS_OPTION_NAME} option. !!!\n\
322                I see you like to live dangerously 😈. This option should ONLY be used by \
323                developers as it can cause peculiar behaviour that breaks things. NEVER enable it \
324                for results you actually care about or want to publish. You have been warned!"
325            );
326        }
327
328        // milestone_years
329        check_milestone_years(&self.milestone_years)?;
330
331        // capacity_limit_factor already validated with deserialise_proportion_nonzero
332
333        // candidate_asset_capacity
334        check_capacity_valid_for_asset(self.candidate_asset_capacity)
335            .context("Invalid value for candidate_asset_capacity")?;
336
337        // value_of_lost_load
338        check_value_of_lost_load(self.value_of_lost_load)?;
339
340        // max_ironing_out_iterations
341        check_max_ironing_out_iterations(self.max_ironing_out_iterations)?;
342
343        // price_tolerance
344        check_price_tolerance(self.price_tolerance)?;
345
346        // capacity_margin
347        check_capacity_margin(self.capacity_margin)?;
348
349        // remaining_demand_absolute_tolerance
350        check_remaining_demand_absolute_tolerance(
351            self.allow_dangerous_options,
352            self.remaining_demand_absolute_tolerance,
353        )?;
354
355        check_highs_options(self.allow_dangerous_options, &self.highs)?;
356
357        Ok(())
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364    use rstest::rstest;
365    use std::fmt::Display;
366    use std::fs::File;
367    use std::io::Write;
368    use tempfile::tempdir;
369
370    /// Helper function to assert validation result based on expected validity
371    fn assert_validation_result<T, U: Display>(
372        result: Result<T>,
373        expected_valid: bool,
374        value: U,
375        expected_error_fragment: &str,
376    ) {
377        if expected_valid {
378            assert!(
379                result.is_ok(),
380                "Expected value {} to be valid, but got error: {:?}",
381                value,
382                result.err()
383            );
384        } else {
385            assert!(
386                result.is_err(),
387                "Expected value {value} to be invalid, but it was accepted",
388            );
389            let error_message = result.err().unwrap().to_string();
390            assert!(
391                error_message.contains(expected_error_fragment),
392                "Error message should mention the validation constraint, got: {error_message}",
393            );
394        }
395    }
396
397    #[test]
398    fn check_milestone_years_works() {
399        // Valid
400        check_milestone_years(&[1]).unwrap();
401        check_milestone_years(&[1, 2]).unwrap();
402
403        // Invalid
404        assert!(check_milestone_years(&[]).is_err());
405        assert!(check_milestone_years(&[1, 1]).is_err());
406        assert!(check_milestone_years(&[2, 1]).is_err());
407    }
408
409    #[test]
410    fn model_params_from_path() {
411        let dir = tempdir().unwrap();
412        {
413            let mut file = File::create(dir.path().join(MODEL_PARAMETERS_FILE_NAME)).unwrap();
414            writeln!(file, "milestone_years = [2020, 2100]").unwrap();
415        }
416
417        let model_params = ModelParameters::from_path(dir.path()).unwrap();
418        assert_eq!(model_params.milestone_years, [2020, 2100]);
419    }
420
421    #[test]
422    fn model_params_deserialisation_copies_highs_global_options() {
423        let model_params: ModelParameters = toml::from_str(
424            "
425            milestone_years = [2020, 2100]
426
427            [highs.global_options]
428            output_flag = true
429
430            [highs.dispatch_options]
431            log_to_console = false
432            ",
433        )
434        .unwrap();
435
436        assert_eq!(
437            model_params.highs.dispatch_options["output_flag"],
438            toml::Value::Boolean(true)
439        );
440        assert_eq!(
441            model_params.highs.dispatch_options["log_to_console"],
442            toml::Value::Boolean(false)
443        );
444        assert_eq!(
445            model_params.highs.appraisal_options["output_flag"],
446            toml::Value::Boolean(true)
447        );
448    }
449
450    #[test]
451    fn highs_options_deserialisation_copies_global_options() {
452        let highs: HighsOptions = toml::from_str(
453            "
454            [global_options]
455            output_flag = true
456            log_to_console = true
457
458            [dispatch_options]
459            primal_feasibility_tolerance = 1e-5
460
461            [appraisal_options]
462            optimality_tolerance = 1e-5
463            ",
464        )
465        .unwrap();
466
467        assert_eq!(
468            highs.dispatch_options["output_flag"],
469            toml::Value::Boolean(true)
470        );
471        assert_eq!(
472            highs.dispatch_options["log_to_console"],
473            toml::Value::Boolean(true)
474        );
475        assert_eq!(
476            highs.appraisal_options["output_flag"],
477            toml::Value::Boolean(true)
478        );
479        assert_eq!(
480            highs.appraisal_options["log_to_console"],
481            toml::Value::Boolean(true)
482        );
483    }
484
485    #[test]
486    fn highs_options_deserialisation_preserves_specific_options() {
487        let highs: HighsOptions = toml::from_str(
488            "
489            [global_options]
490            output_flag = true
491            log_to_console = true
492
493            [dispatch_options]
494            output_flag = false
495
496            [appraisal_options]
497            log_to_console = false
498            ",
499        )
500        .unwrap();
501
502        assert_eq!(
503            highs.dispatch_options["output_flag"],
504            toml::Value::Boolean(false)
505        );
506        assert_eq!(
507            highs.dispatch_options["log_to_console"],
508            toml::Value::Boolean(true)
509        );
510        assert_eq!(
511            highs.appraisal_options["output_flag"],
512            toml::Value::Boolean(true)
513        );
514        assert_eq!(
515            highs.appraisal_options["log_to_console"],
516            toml::Value::Boolean(false)
517        );
518    }
519
520    #[rstest]
521    #[case(1.0, true)] // Valid positive value
522    #[case(1e-10, true)] // Valid very small positive value
523    #[case(1e9, true)] // Valid large value (default)
524    #[case(f64::MAX, true)] // Valid maximum finite value
525    #[case(0.0, false)] // Invalid: exactly zero
526    #[case(-1.0, false)] // Invalid: negative value
527    #[case(-1e-10, false)] // Invalid: very small negative value
528    #[case(f64::INFINITY, false)] // Invalid: infinite value
529    #[case(f64::NEG_INFINITY, false)] // Invalid: negative infinite value
530    #[case(f64::NAN, false)] // Invalid: NaN value
531    fn check_value_of_lost_load_works(#[case] value: f64, #[case] expected_valid: bool) {
532        let money_per_flow = MoneyPerFlow::new(value);
533        let result = check_value_of_lost_load(money_per_flow);
534
535        assert_validation_result(
536            result,
537            expected_valid,
538            value,
539            "value_of_lost_load must be a finite number greater than zero",
540        );
541    }
542
543    #[rstest]
544    #[case(1, true)] // Valid minimum value
545    #[case(10, true)] // Valid default value
546    #[case(100, true)] // Valid large value
547    #[case(u32::MAX, true)] // Valid maximum value
548    #[case(0, false)] // Invalid: zero
549    fn check_max_ironing_out_iterations_works(#[case] value: u32, #[case] expected_valid: bool) {
550        let result = check_max_ironing_out_iterations(value);
551
552        assert_validation_result(
553            result,
554            expected_valid,
555            value,
556            "max_ironing_out_iterations cannot be zero",
557        );
558    }
559
560    #[rstest]
561    #[case(0.0, true)] // Valid minimum value (exactly zero)
562    #[case(1e-10, true)] // Valid very small positive value
563    #[case(1e-6, true)] // Valid default value
564    #[case(1.0, true)] // Valid larger value
565    #[case(f64::MAX, true)] // Valid maximum finite value
566    #[case(-1e-10, false)] // Invalid: negative value
567    #[case(-1.0, false)] // Invalid: negative value
568    #[case(f64::INFINITY, false)] // Invalid: infinite value
569    #[case(f64::NEG_INFINITY, false)] // Invalid: negative infinite value
570    #[case(f64::NAN, false)] // Invalid: NaN value
571    fn check_price_tolerance_works(#[case] value: f64, #[case] expected_valid: bool) {
572        let dimensionless = Dimensionless::new(value);
573        let result = check_price_tolerance(dimensionless);
574
575        assert_validation_result(
576            result,
577            expected_valid,
578            value,
579            "price_tolerance must be a finite number greater than or equal to zero",
580        );
581    }
582
583    #[rstest]
584    #[case(true, 0.0, true)] // Valid minimum value dangerous options allowed
585    #[case(true, 1e-10, true)] // Valid value with dangerous options allowed
586    #[case(true, 1e-15, true)] // Valid value with dangerous options allowed
587    #[case(false, 1e-12, true)] // Valid value same as default, no dangerous options needed
588    #[case(true, 1.0, true)] // Valid larger value with dangerous options allowed
589    #[case(true, f64::MAX, true)] // Valid maximum finite value with dangerous options allowed
590    #[case(true, -1e-10, false)] // Invalid: negative value
591    #[case(true, f64::INFINITY, false)] // Invalid: positive infinity
592    #[case(true, f64::NEG_INFINITY, false)] // Invalid: negative infinity
593    #[case(true, f64::NAN, false)] // Invalid: NaN
594    #[case(false, -1e-10, false)] // Invalid: negative value
595    #[case(false, f64::INFINITY, false)] // Invalid: positive infinity
596    #[case(false, f64::NEG_INFINITY, false)] // Invalid: negative infinity
597    #[case(false, f64::NAN, false)] // Invalid: NaN
598    fn check_remaining_demand_absolute_tolerance_works(
599        #[case] allow_dangerous_options: bool,
600        #[case] value: f64,
601        #[case] expected_valid: bool,
602    ) {
603        let flow = Flow::new(value);
604        let result = check_remaining_demand_absolute_tolerance(allow_dangerous_options, flow);
605
606        assert_validation_result(
607            result,
608            expected_valid,
609            value,
610            "remaining_demand_absolute_tolerance must be a finite number greater than or equal to zero",
611        );
612    }
613
614    #[rstest]
615    #[case(0.0)] // smaller than default
616    #[case(1e-10)] // Larger than default (1e-12)
617    #[case(1.0)] // Well above default
618    #[case(f64::MAX)] // Maximum finite value
619    fn check_remaining_demand_absolute_tolerance_requires_dangerous_options_if_non_default(
620        #[case] value: f64,
621    ) {
622        let flow = Flow::new(value);
623        let result = check_remaining_demand_absolute_tolerance(false, flow);
624        assert_validation_result(
625            result,
626            false,
627            value,
628            "Setting a remaining_demand_absolute_tolerance different from the default value of \
629            1e-12 is potentially dangerous, set please_give_me_broken_results to true if you want \
630            to allow this.",
631        );
632    }
633
634    #[rstest]
635    #[case(0.0, true)] // Valid minimum value
636    #[case(0.2, true)] // Valid default value
637    #[case(10.0, true)] // Valid large value
638    #[case(-1e-6, false)] // Invalid: negative margin
639    #[case(f64::INFINITY, false)] // Invalid: infinite value
640    #[case(f64::NEG_INFINITY, false)] // Invalid: negative infinite value
641    #[case(f64::NAN, false)] // Invalid: NaN value
642    fn check_capacity_margin_works(#[case] value: f64, #[case] expected_valid: bool) {
643        let result = check_capacity_margin(Dimensionless(value));
644
645        assert_validation_result(
646            result,
647            expected_valid,
648            value,
649            "capacity_margin must be a finite number greater than or equal to zero",
650        );
651    }
652}