1use 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
22pub const ALLOW_DANGEROUS_OPTION_NAME: &str = "please_give_me_broken_results";
27
28static DANGEROUS_OPTIONS_ENABLED: OnceLock<bool> = OnceLock::new();
33
34const DEFAULT_REMAINING_DEMAND_ABSOLUTE_TOLERANCE: Flow = Flow(1e-12);
36
37pub fn dangerous_model_options_enabled() -> bool {
44 *DANGEROUS_OPTIONS_ENABLED
45 .get()
46 .expect("Dangerous options flag not set")
47}
48
49fn 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 assert_eq!(enabled, dangerous_model_options_enabled());
59 } else {
60 panic!("Attempted to set DANGEROUS_OPTIONS_ENABLED twice");
61 }
62 }
63}
64
65#[derive(Deserialize)]
70#[serde(default)]
71pub struct ModelParameters {
72 pub milestone_years: Vec<u32>,
74 pub currency: Option<String>,
77 #[serde(rename = "please_give_me_broken_results")] pub allow_dangerous_options: bool,
80 pub candidate_asset_capacity: Capacity,
84 #[serde(deserialize_with = "deserialise_finite_non_negative")]
88 pub commodity_balance_epsilon: Flow,
89 #[serde(deserialize_with = "deserialise_proportion_nonzero")]
94 pub capacity_tranche_fraction: Dimensionless,
95 pub fallback_pricing_strategy: PricingStrategy,
101 pub value_of_lost_load: MoneyPerFlow,
105 #[serde(deserialize_with = "deserialise_finite_non_negative")]
107 pub seasonal_utilisation_penalty: MoneyPerCapacityPerYear,
108 #[serde(deserialize_with = "deserialise_finite_non_negative")]
110 pub annual_utilisation_penalty: MoneyPerCapacityPerYear,
111 pub max_ironing_out_iterations: u32,
113 #[serde(deserialize_with = "deserialise_finite_non_negative")]
115 pub price_tolerance: Dimensionless,
116 pub price_cycle_iterations: u32,
118 #[serde(deserialize_with = "deserialise_finite_non_negative")]
124 pub capacity_margin: Dimensionless,
125 pub mothball_years: u32,
127 #[serde(deserialize_with = "deserialise_finite_non_negative")]
129 pub remaining_demand_absolute_tolerance: Flow,
130 pub highs: HighsOptions,
136}
137
138impl Default for ModelParameters {
139 fn default() -> Self {
140 Self {
141 milestone_years: Vec::default(),
144
145 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#[derive(Default)]
168pub struct HighsOptions {
169 pub dispatch_options: Table,
171 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 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 pub fn is_empty(&self) -> bool {
233 self.dispatch_options.is_empty() && self.appraisal_options.is_empty()
234 }
235}
236
237fn 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
252fn 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
262fn check_max_ironing_out_iterations(value: u32) -> Result<()> {
264 ensure!(value > 0, "max_ironing_out_iterations cannot be zero");
265
266 Ok(())
267}
268
269fn check_price_cycle_iterations(value: u32) -> Result<()> {
271 ensure!(value > 0, "price_cycle_iterations cannot be zero");
272
273 Ok(())
274}
275
276fn 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
294fn 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 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 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 check_milestone_years(&self.milestone_years)?;
346
347 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 check_value_of_lost_load(self.value_of_lost_load)?;
362
363 check_max_ironing_out_iterations(self.max_ironing_out_iterations)?;
365
366 check_price_cycle_iterations(self.price_cycle_iterations)?;
370
371 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 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 check_milestone_years(&[1]).unwrap();
426 check_milestone_years(&[1, 2]).unwrap();
427
428 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)] #[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) {
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)] #[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) {
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)] #[case(true, 1.0, true)] #[case(false, 1e-12, true)] #[case(false, 1.0, false)] 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)] #[case(1e-10)] #[case(1.0)] #[case(f64::MAX)] 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}