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