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