1use 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
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) {
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 #[serde(rename = "please_give_me_broken_results")] pub allow_dangerous_options: bool,
77 pub candidate_asset_capacity: Capacity,
81 pub commodity_balance_epsilon: Flow,
85 #[serde(deserialize_with = "deserialise_proportion_nonzero")]
89 pub capacity_limit_factor: Dimensionless,
90 pub fallback_pricing_strategy: PricingStrategy,
96 pub value_of_lost_load: MoneyPerFlow,
100 pub max_ironing_out_iterations: u32,
102 pub price_tolerance: Dimensionless,
104 pub capacity_margin: Dimensionless,
110 pub mothball_years: u32,
112 pub remaining_demand_absolute_tolerance: Flow,
114 pub highs: HighsOptions,
120}
121
122impl Default for ModelParameters {
123 fn default() -> Self {
124 Self {
125 milestone_years: Vec::default(),
128
129 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#[derive(Default)]
148pub struct HighsOptions {
149 pub dispatch_options: Table,
151 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 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 pub fn is_empty(&self) -> bool {
213 self.dispatch_options.is_empty() && self.appraisal_options.is_empty()
214 }
215}
216
217fn 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
232fn 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
242fn check_max_ironing_out_iterations(value: u32) -> Result<()> {
244 ensure!(value > 0, "max_ironing_out_iterations cannot be zero");
245
246 Ok(())
247}
248
249fn 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
281fn 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
291fn 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 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 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 check_milestone_years(&self.milestone_years)?;
343
344 check_capacity_valid_for_asset(self.candidate_asset_capacity)
350 .context("Invalid value for candidate_asset_capacity")?;
351
352 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 check_value_of_lost_load(self.value_of_lost_load)?;
361
362 check_max_ironing_out_iterations(self.max_ironing_out_iterations)?;
364
365 check_price_tolerance(self.price_tolerance)?;
367
368 check_capacity_margin(self.capacity_margin)?;
370
371 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 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 check_milestone_years(&[1]).unwrap();
423 check_milestone_years(&[1, 2]).unwrap();
424
425 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)] #[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) {
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)] #[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) {
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)] #[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) {
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)] #[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(
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)] #[case(1e-10)] #[case(1.0)] #[case(f64::MAX)] 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)] #[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) {
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}