1use 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
21pub const ALLOW_DANGEROUS_OPTION_NAME: &str = "please_give_me_broken_results";
26
27static DANGEROUS_OPTIONS_ENABLED: OnceLock<bool> = OnceLock::new();
32
33const DEFAULT_REMAINING_DEMAND_ABSOLUTE_TOLERANCE: Flow = Flow(1e-12);
35
36pub fn dangerous_model_options_enabled() -> bool {
43 *DANGEROUS_OPTIONS_ENABLED
44 .get()
45 .expect("Dangerous options flag not set")
46}
47
48fn 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 assert_eq!(enabled, dangerous_model_options_enabled());
58 } else {
59 panic!("Attempted to set DANGEROUS_OPTIONS_ENABLED twice");
60 }
61 }
62}
63
64#[derive(Deserialize)]
69#[serde(default)]
70pub struct ModelParameters {
71 pub milestone_years: Vec<u32>,
73 #[serde(rename = "please_give_me_broken_results")] pub allow_dangerous_options: bool,
76 pub candidate_asset_capacity: Capacity,
80 #[serde(deserialize_with = "deserialise_proportion_nonzero")]
84 pub capacity_limit_factor: Dimensionless,
85 pub value_of_lost_load: MoneyPerFlow,
89 pub max_ironing_out_iterations: u32,
91 pub price_tolerance: Dimensionless,
93 pub capacity_margin: Dimensionless,
99 pub mothball_years: u32,
101 pub remaining_demand_absolute_tolerance: Flow,
103 pub highs: HighsOptions,
109}
110
111impl Default for ModelParameters {
112 fn default() -> Self {
113 Self {
114 milestone_years: Vec::default(),
117
118 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#[derive(Default)]
135pub struct HighsOptions {
136 pub dispatch_options: Table,
138 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 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 pub fn is_empty(&self) -> bool {
200 self.dispatch_options.is_empty() && self.appraisal_options.is_empty()
201 }
202}
203
204fn 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
219fn 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
229fn check_max_ironing_out_iterations(value: u32) -> Result<()> {
231 ensure!(value > 0, "max_ironing_out_iterations cannot be zero");
232
233 Ok(())
234}
235
236fn 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
268fn 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
278fn 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 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 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 check_milestone_years(&self.milestone_years)?;
330
331 check_capacity_valid_for_asset(self.candidate_asset_capacity)
335 .context("Invalid value for candidate_asset_capacity")?;
336
337 check_value_of_lost_load(self.value_of_lost_load)?;
339
340 check_max_ironing_out_iterations(self.max_ironing_out_iterations)?;
342
343 check_price_tolerance(self.price_tolerance)?;
345
346 check_capacity_margin(self.capacity_margin)?;
348
349 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 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 check_milestone_years(&[1]).unwrap();
401 check_milestone_years(&[1, 2]).unwrap();
402
403 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)] #[case(1e-10, true)] #[case(1e9, true)] #[case(f64::MAX, true)] #[case(0.0, false)] #[case(-1.0, false)] #[case(-1e-10, false)] #[case(f64::INFINITY, false)] #[case(f64::NEG_INFINITY, false)] #[case(f64::NAN, false)] 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)] #[case(10, true)] #[case(100, true)] #[case(u32::MAX, true)] #[case(0, false)] 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)] #[case(1e-10, true)] #[case(1e-6, true)] #[case(1.0, true)] #[case(f64::MAX, true)] #[case(-1e-10, false)] #[case(-1.0, false)] #[case(f64::INFINITY, false)] #[case(f64::NEG_INFINITY, false)] #[case(f64::NAN, false)] 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)] #[case(true, 1e-10, true)] #[case(true, 1e-15, true)] #[case(false, 1e-12, true)] #[case(true, 1.0, true)] #[case(true, f64::MAX, true)] #[case(true, -1e-10, false)] #[case(true, f64::INFINITY, false)] #[case(true, f64::NEG_INFINITY, false)] #[case(true, f64::NAN, false)] #[case(false, -1e-10, false)] #[case(false, f64::INFINITY, false)] #[case(false, f64::NEG_INFINITY, false)] #[case(false, f64::NAN, false)] 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)] #[case(1e-10)] #[case(1.0)] #[case(f64::MAX)] 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)] #[case(0.2, true)] #[case(10.0, true)] #[case(-1e-6, false)] #[case(f64::INFINITY, false)] #[case(f64::NEG_INFINITY, false)] #[case(f64::NAN, false)] 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}