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    /// 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(deserialize_with = "deserialise_finite_non_negative")]
122    pub capacity_margin: Dimensionless,
123    /// Number of years an asset can remain unused before being decommissioned
124    pub mothball_years: u32,
125    /// Absolute tolerance when checking if remaining demand is close enough to zero
126    #[serde(deserialize_with = "deserialise_finite_non_negative")]
127    pub remaining_demand_absolute_tolerance: Flow,
128    /// Options for the HiGHS solver.
129    ///
130    /// For a full list of options, see [the HiGHS documentation].
131    ///
132    /// [the HiGHS documentation]: https://ergo-code.github.io/HiGHS/stable/options/definitions/
133    pub highs: HighsOptions,
134}
135
136impl Default for ModelParameters {
137    fn default() -> Self {
138        Self {
139            // Required parameters.
140            // milestone_years cannot be empty and we validate this when loading model.toml files.
141            milestone_years: Vec::default(),
142
143            // Default values for optional parameters
144            currency: None,
145            allow_dangerous_options: false,
146            candidate_asset_capacity: Capacity(1e-4),
147            commodity_balance_epsilon: Flow(1e-6),
148            capacity_tranche_fraction: Dimensionless(0.05),
149            fallback_pricing_strategy: PricingStrategy::FullCostAverage,
150            value_of_lost_load: MoneyPerFlow(1e9),
151            seasonal_utilisation_penalty: MoneyPerCapacityPerYear(1e-6),
152            annual_utilisation_penalty: MoneyPerCapacityPerYear(1e-6),
153            max_ironing_out_iterations: 1,
154            price_tolerance: Dimensionless(1e-6),
155            capacity_margin: Dimensionless(0.2),
156            mothball_years: 0,
157            remaining_demand_absolute_tolerance: DEFAULT_REMAINING_DEMAND_ABSOLUTE_TOLERANCE,
158            highs: HighsOptions::default(),
159        }
160    }
161}
162
163/// Defines the TOML table holding the sub-tables to define HiGHS options
164#[derive(Default)]
165pub struct HighsOptions {
166    /// HiGHS options applied to dispatch optimisation
167    pub dispatch_options: Table,
168    /// HiGHS options applied to appraisal optimisation
169    pub appraisal_options: Table,
170}
171
172impl<'de> Deserialize<'de> for HighsOptions {
173    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
174    where
175        D: Deserializer<'de>,
176    {
177        #[derive(Default, Deserialize)]
178        #[serde(default)]
179        #[allow(clippy::struct_field_names)]
180        struct RawHighsOptions {
181            global_options: Table,
182            dispatch_options: Table,
183            appraisal_options: Table,
184        }
185
186        let RawHighsOptions {
187            global_options,
188            mut dispatch_options,
189            mut appraisal_options,
190        } = RawHighsOptions::deserialize(deserializer)?;
191
192        let append_global_options = |options: &mut Table| {
193            for (option, value) in &global_options {
194                options
195                    .entry(option.clone())
196                    .or_insert_with(|| value.clone());
197            }
198        };
199        append_global_options(&mut dispatch_options);
200        append_global_options(&mut appraisal_options);
201
202        Ok(Self {
203            dispatch_options,
204            appraisal_options,
205        })
206    }
207}
208
209impl HighsOptions {
210    /// Log custom HiGHS options set by user, if any
211    fn log_options(&self) {
212        fn log_highs_options(name: &str, options: &Table) {
213            if options.is_empty() {
214                return;
215            }
216
217            let options_str = options
218                .iter()
219                .format_with("\n  - ", |(opt, val), f| f(&format_args!("{opt} = {val}")))
220                .to_string();
221            warn!("Using custom HiGHS options for {name}:\n  - {options_str}");
222        }
223
224        log_highs_options("dispatch", &self.dispatch_options);
225        log_highs_options("appraisal", &self.appraisal_options);
226    }
227
228    /// Check whether any options have been set
229    pub fn is_empty(&self) -> bool {
230        self.dispatch_options.is_empty() && self.appraisal_options.is_empty()
231    }
232}
233
234/// Check that the `milestone_years` parameter is valid
235fn check_milestone_years(years: &[u32]) -> Result<()> {
236    ensure!(
237        !years.is_empty(),
238        "`milestone_years` must be provided and non-empty"
239    );
240
241    ensure!(
242        is_sorted_and_unique(years),
243        "`milestone_years` must be composed of unique values in order"
244    );
245
246    Ok(())
247}
248
249/// Check that the `value_of_lost_load` parameter is valid
250fn check_value_of_lost_load(value: MoneyPerFlow) -> Result<()> {
251    ensure!(
252        value.is_finite() && value > MoneyPerFlow(0.0),
253        "value_of_lost_load must be a finite number greater than zero"
254    );
255
256    Ok(())
257}
258
259/// Check that the `max_ironing_out_iterations` parameter is valid
260fn check_max_ironing_out_iterations(value: u32) -> Result<()> {
261    ensure!(value > 0, "max_ironing_out_iterations cannot be zero");
262
263    Ok(())
264}
265
266/// Check that the `remaining_demand_absolute_tolerance` parameter is valid.
267fn check_remaining_demand_absolute_tolerance(
268    dangerous_options_enabled: bool,
269    value: Flow,
270) -> Result<()> {
271    if !dangerous_options_enabled {
272        ensure!(
273            value == DEFAULT_REMAINING_DEMAND_ABSOLUTE_TOLERANCE,
274            "Setting a remaining_demand_absolute_tolerance different from the default value of \
275            {:e} is potentially dangerous, set {ALLOW_DANGEROUS_OPTION_NAME} to true if you want \
276            to allow this.",
277            DEFAULT_REMAINING_DEMAND_ABSOLUTE_TOLERANCE.value()
278        );
279    }
280
281    Ok(())
282}
283
284/// Check the custom HiGHS options are valid.
285///
286/// Note that we cannot know whether the options specified exist and are of the correct type until
287/// we attempt to use them. We could check for types that are never valid (e.g. an array), but as
288/// we're checking later anyway, we don't bother.
289fn check_highs_options(dangerous_options_enabled: bool, highs: &HighsOptions) -> Result<()> {
290    ensure!(
291        dangerous_options_enabled || highs.is_empty(),
292        "Cannot set custom HiGHS options without enabling {ALLOW_DANGEROUS_OPTION_NAME}"
293    );
294
295    Ok(())
296}
297
298impl ModelParameters {
299    /// Read a model file from the specified directory.
300    ///
301    /// # Arguments
302    ///
303    /// * `model_dir` - Folder containing model configuration files
304    ///
305    /// # Returns
306    ///
307    /// The model file contents as a [`ModelParameters`] struct or an error if the file is invalid
308    pub fn from_path<P: AsRef<Path>>(model_dir: P) -> Result<ModelParameters> {
309        let file_path = model_dir.as_ref().join(MODEL_PARAMETERS_FILE_NAME);
310        let model_params: ModelParameters = read_toml(&file_path)?;
311
312        set_dangerous_model_options_flag(model_params.allow_dangerous_options);
313
314        model_params
315            .validate()
316            .with_context(|| input_err_msg(file_path))?;
317
318        model_params.highs.log_options();
319
320        Ok(model_params)
321    }
322
323    /// Validate parameters after reading in file
324    fn validate(&self) -> Result<()> {
325        if self.allow_dangerous_options {
326            warn!(
327                "!!! You've enabled the {ALLOW_DANGEROUS_OPTION_NAME} option. !!!\n\
328                I see you like to live dangerously 😈. This option should ONLY be used by \
329                developers as it can cause peculiar behaviour that breaks things. NEVER enable it \
330                for results you actually care about or want to publish. You have been warned!"
331            );
332        }
333
334        // milestone_years
335        check_milestone_years(&self.milestone_years)?;
336
337        // capacity_tranche_fraction already validated with deserialise_proportion_nonzero
338
339        // fallback_pricing_strategy already validated by deserialisation
340
341        // candidate_asset_capacity
342        ensure!(
343            self.candidate_asset_capacity.is_finite()
344                && self.candidate_asset_capacity > Capacity(0.0),
345            "candidate_asset_capacity must be a finite, positive number"
346        );
347
348        // commodity_balance_epsilon already validated with deserialise_finite_non_negative
349
350        // value_of_lost_load
351        check_value_of_lost_load(self.value_of_lost_load)?;
352
353        // max_ironing_out_iterations
354        check_max_ironing_out_iterations(self.max_ironing_out_iterations)?;
355
356        // price_tolerance already validated with deserialise_finite_non_negative
357
358        // capacity_margin already validated with deserialise_finite_non_negative
359
360        // remaining_demand_absolute_tolerance already validated with
361        // deserialise_finite_non_negative; check remaining constraints here
362        check_remaining_demand_absolute_tolerance(
363            self.allow_dangerous_options,
364            self.remaining_demand_absolute_tolerance,
365        )?;
366
367        check_highs_options(self.allow_dangerous_options, &self.highs)?;
368
369        Ok(())
370    }
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376    use rstest::rstest;
377    use std::fmt::Display;
378    use std::fs::File;
379    use std::io::Write;
380    use tempfile::tempdir;
381
382    /// Helper function to assert validation result based on expected validity
383    fn assert_validation_result<T, U: Display>(
384        result: Result<T>,
385        expected_valid: bool,
386        value: U,
387        expected_error_fragment: &str,
388    ) {
389        if expected_valid {
390            assert!(
391                result.is_ok(),
392                "Expected value {} to be valid, but got error: {:?}",
393                value,
394                result.err()
395            );
396        } else {
397            assert!(
398                result.is_err(),
399                "Expected value {value} to be invalid, but it was accepted",
400            );
401            let error_message = result.err().unwrap().to_string();
402            assert!(
403                error_message.contains(expected_error_fragment),
404                "Error message should mention the validation constraint, got: {error_message}",
405            );
406        }
407    }
408
409    #[test]
410    fn check_milestone_years_works() {
411        // Valid
412        check_milestone_years(&[1]).unwrap();
413        check_milestone_years(&[1, 2]).unwrap();
414
415        // Invalid
416        assert!(check_milestone_years(&[]).is_err());
417        assert!(check_milestone_years(&[1, 1]).is_err());
418        assert!(check_milestone_years(&[2, 1]).is_err());
419    }
420
421    #[test]
422    fn model_params_from_path() {
423        let dir = tempdir().unwrap();
424        {
425            let mut file = File::create(dir.path().join(MODEL_PARAMETERS_FILE_NAME)).unwrap();
426            writeln!(file, "milestone_years = [2020, 2100]").unwrap();
427        }
428
429        let model_params = ModelParameters::from_path(dir.path()).unwrap();
430        assert_eq!(model_params.milestone_years, [2020, 2100]);
431    }
432
433    #[test]
434    fn model_params_currency_is_optional_metadata() {
435        let with_currency: ModelParameters = toml::from_str(
436            "
437            milestone_years = [2020, 2100]
438            currency = \"MUSD2010\"
439            ",
440        )
441        .unwrap();
442        assert_eq!(with_currency.currency.as_deref(), Some("MUSD2010"));
443
444        let without_currency: ModelParameters =
445            toml::from_str("milestone_years = [2020, 2100]").unwrap();
446        assert_eq!(without_currency.currency, None);
447    }
448
449    #[test]
450    fn model_params_deserialisation_copies_highs_global_options() {
451        let model_params: ModelParameters = toml::from_str(
452            "
453            milestone_years = [2020, 2100]
454
455            [highs.global_options]
456            output_flag = true
457
458            [highs.dispatch_options]
459            log_to_console = false
460            ",
461        )
462        .unwrap();
463
464        assert_eq!(
465            model_params.highs.dispatch_options["output_flag"],
466            toml::Value::Boolean(true)
467        );
468        assert_eq!(
469            model_params.highs.dispatch_options["log_to_console"],
470            toml::Value::Boolean(false)
471        );
472        assert_eq!(
473            model_params.highs.appraisal_options["output_flag"],
474            toml::Value::Boolean(true)
475        );
476    }
477
478    #[test]
479    fn highs_options_deserialisation_copies_global_options() {
480        let highs: HighsOptions = toml::from_str(
481            "
482            [global_options]
483            output_flag = true
484            log_to_console = true
485
486            [dispatch_options]
487            primal_feasibility_tolerance = 1e-5
488
489            [appraisal_options]
490            optimality_tolerance = 1e-5
491            ",
492        )
493        .unwrap();
494
495        assert_eq!(
496            highs.dispatch_options["output_flag"],
497            toml::Value::Boolean(true)
498        );
499        assert_eq!(
500            highs.dispatch_options["log_to_console"],
501            toml::Value::Boolean(true)
502        );
503        assert_eq!(
504            highs.appraisal_options["output_flag"],
505            toml::Value::Boolean(true)
506        );
507        assert_eq!(
508            highs.appraisal_options["log_to_console"],
509            toml::Value::Boolean(true)
510        );
511    }
512
513    #[test]
514    fn highs_options_deserialisation_preserves_specific_options() {
515        let highs: HighsOptions = toml::from_str(
516            "
517            [global_options]
518            output_flag = true
519            log_to_console = true
520
521            [dispatch_options]
522            output_flag = false
523
524            [appraisal_options]
525            log_to_console = false
526            ",
527        )
528        .unwrap();
529
530        assert_eq!(
531            highs.dispatch_options["output_flag"],
532            toml::Value::Boolean(false)
533        );
534        assert_eq!(
535            highs.dispatch_options["log_to_console"],
536            toml::Value::Boolean(true)
537        );
538        assert_eq!(
539            highs.appraisal_options["output_flag"],
540            toml::Value::Boolean(true)
541        );
542        assert_eq!(
543            highs.appraisal_options["log_to_console"],
544            toml::Value::Boolean(false)
545        );
546    }
547
548    #[rstest]
549    #[case(1.0, true)] // Valid positive value
550    #[case(1e-10, true)] // Valid very small positive value
551    #[case(1e9, true)] // Valid large value (default)
552    #[case(f64::MAX, true)] // Valid maximum finite value
553    #[case(0.0, false)] // Invalid: exactly zero
554    #[case(-1.0, false)] // Invalid: negative value
555    #[case(-1e-10, false)] // Invalid: very small negative value
556    #[case(f64::INFINITY, false)] // Invalid: infinite value
557    #[case(f64::NEG_INFINITY, false)] // Invalid: negative infinite value
558    #[case(f64::NAN, false)] // Invalid: NaN value
559    fn check_value_of_lost_load_works(#[case] value: f64, #[case] expected_valid: bool) {
560        let money_per_flow = MoneyPerFlow::new(value);
561        let result = check_value_of_lost_load(money_per_flow);
562
563        assert_validation_result(
564            result,
565            expected_valid,
566            value,
567            "value_of_lost_load must be a finite number greater than zero",
568        );
569    }
570
571    #[rstest]
572    #[case(1, true)] // Valid minimum value
573    #[case(10, true)] // Valid default value
574    #[case(100, true)] // Valid large value
575    #[case(u32::MAX, true)] // Valid maximum value
576    #[case(0, false)] // Invalid: zero
577    fn check_max_ironing_out_iterations_works(#[case] value: u32, #[case] expected_valid: bool) {
578        let result = check_max_ironing_out_iterations(value);
579
580        assert_validation_result(
581            result,
582            expected_valid,
583            value,
584            "max_ironing_out_iterations cannot be zero",
585        );
586    }
587
588    #[rstest]
589    #[case(true, 1e-12, true)] // Valid: default value, dangerous options allowed
590    #[case(true, 1.0, true)] // Valid: non-default value with dangerous options allowed
591    #[case(false, 1e-12, true)] // Valid: default value, no dangerous options needed
592    #[case(false, 1.0, false)] // Invalid: non-default value without dangerous options
593    fn check_remaining_demand_absolute_tolerance_works(
594        #[case] allow_dangerous_options: bool,
595        #[case] value: f64,
596        #[case] expected_valid: bool,
597    ) {
598        let flow = Flow::new(value);
599        let result = check_remaining_demand_absolute_tolerance(allow_dangerous_options, flow);
600
601        assert_validation_result(
602            result,
603            expected_valid,
604            value,
605            "Setting a remaining_demand_absolute_tolerance different from the default value of \
606            1e-12 is potentially dangerous, set please_give_me_broken_results to true if you want \
607            to allow this.",
608        );
609    }
610
611    #[rstest]
612    #[case(0.0)] // smaller than default
613    #[case(1e-10)] // Larger than default (1e-12)
614    #[case(1.0)] // Well above default
615    #[case(f64::MAX)] // Maximum finite value
616    fn check_remaining_demand_absolute_tolerance_requires_dangerous_options_if_non_default(
617        #[case] value: f64,
618    ) {
619        let flow = Flow::new(value);
620        let result = check_remaining_demand_absolute_tolerance(false, flow);
621        assert_validation_result(
622            result,
623            false,
624            value,
625            "Setting a remaining_demand_absolute_tolerance different from the default value of \
626            1e-12 is potentially dangerous, set please_give_me_broken_results to true if you want \
627            to allow this.",
628        );
629    }
630}