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 #[serde(deserialize_with = "deserialise_finite_non_negative")]
122 pub capacity_margin: Dimensionless,
123 pub mothball_years: u32,
125 #[serde(deserialize_with = "deserialise_finite_non_negative")]
127 pub remaining_demand_absolute_tolerance: Flow,
128 pub highs: HighsOptions,
134}
135
136impl Default for ModelParameters {
137 fn default() -> Self {
138 Self {
139 milestone_years: Vec::default(),
142
143 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#[derive(Default)]
165pub struct HighsOptions {
166 pub dispatch_options: Table,
168 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 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 pub fn is_empty(&self) -> bool {
230 self.dispatch_options.is_empty() && self.appraisal_options.is_empty()
231 }
232}
233
234fn 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
249fn 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
259fn check_max_ironing_out_iterations(value: u32) -> Result<()> {
261 ensure!(value > 0, "max_ironing_out_iterations cannot be zero");
262
263 Ok(())
264}
265
266fn 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
284fn 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 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 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 check_milestone_years(&self.milestone_years)?;
336
337 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 check_value_of_lost_load(self.value_of_lost_load)?;
352
353 check_max_ironing_out_iterations(self.max_ironing_out_iterations)?;
355
356 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 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 check_milestone_years(&[1]).unwrap();
413 check_milestone_years(&[1, 2]).unwrap();
414
415 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)] #[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) {
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)] #[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) {
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)] #[case(true, 1.0, true)] #[case(false, 1e-12, true)] #[case(false, 1.0, false)] 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)] #[case(1e-10)] #[case(1.0)] #[case(f64::MAX)] 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}