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, MoneyPerFlow};
11use anyhow::{Context, Result, ensure};
12use log::warn;
13use serde::Deserialize;
14use std::path::Path;
15use std::sync::OnceLock;
16
17const MODEL_PARAMETERS_FILE_NAME: &str = "model.toml";
18
19pub const ALLOW_BROKEN_OPTION_NAME: &str = "please_give_me_broken_results";
24
25static BROKEN_OPTIONS_ALLOWED: OnceLock<bool> = OnceLock::new();
30
31pub fn broken_model_options_allowed() -> bool {
38 *BROKEN_OPTIONS_ALLOWED
39 .get()
40 .expect("Broken options flag not set")
41}
42
43fn set_broken_model_options_flag(allowed: bool) {
48 let result = BROKEN_OPTIONS_ALLOWED.set(allowed);
49 if result.is_err() {
50 if cfg!(test) {
51 assert_eq!(allowed, broken_model_options_allowed());
53 } else {
54 panic!("Attempted to set BROKEN_OPTIONS_ALLOWED twice");
55 }
56 }
57}
58
59macro_rules! define_unit_param_default {
60 ($name:ident, $type: ty, $value: expr) => {
61 fn $name() -> $type {
62 <$type>::new($value)
63 }
64 };
65}
66
67macro_rules! define_param_default {
68 ($name:ident, $type: ty, $value: expr) => {
69 fn $name() -> $type {
70 $value
71 }
72 };
73}
74
75define_unit_param_default!(default_candidate_asset_capacity, Capacity, 0.0001);
76define_unit_param_default!(default_capacity_limit_factor, Dimensionless, 0.1);
77define_unit_param_default!(default_value_of_lost_load, MoneyPerFlow, 1e9);
78define_unit_param_default!(default_price_tolerance, Dimensionless, 1e-6);
79define_param_default!(default_max_ironing_out_iterations, u32, 10);
80define_param_default!(default_capacity_margin, f64, 0.2);
81define_param_default!(default_mothball_years, u32, 0);
82
83#[derive(Debug, Deserialize, PartialEq)]
88pub struct ModelParameters {
89 pub milestone_years: Vec<u32>,
91 #[serde(default, rename = "please_give_me_broken_results")] pub allow_broken_options: bool,
94 #[serde(default = "default_candidate_asset_capacity")]
98 pub candidate_asset_capacity: Capacity,
99 #[serde(default = "default_capacity_limit_factor")]
103 #[serde(deserialize_with = "deserialise_proportion_nonzero")]
104 pub capacity_limit_factor: Dimensionless,
105 #[serde(default = "default_value_of_lost_load")]
109 pub value_of_lost_load: MoneyPerFlow,
110 #[serde(default = "default_max_ironing_out_iterations")]
112 pub max_ironing_out_iterations: u32,
113 #[serde(default = "default_price_tolerance")]
115 pub price_tolerance: Dimensionless,
116 #[serde(default = "default_capacity_margin")]
122 pub capacity_margin: f64,
123 #[serde(default = "default_mothball_years")]
125 pub mothball_years: u32,
126}
127
128fn check_milestone_years(years: &[u32]) -> Result<()> {
130 ensure!(!years.is_empty(), "`milestone_years` is empty");
131
132 ensure!(
133 is_sorted_and_unique(years),
134 "`milestone_years` must be composed of unique values in order"
135 );
136
137 Ok(())
138}
139
140fn check_value_of_lost_load(value: MoneyPerFlow) -> Result<()> {
142 ensure!(
143 value.is_finite() && value > MoneyPerFlow(0.0),
144 "value_of_lost_load must be a finite number greater than zero"
145 );
146
147 Ok(())
148}
149
150fn check_max_ironing_out_iterations(value: u32) -> Result<()> {
152 ensure!(value > 0, "max_ironing_out_iterations cannot be zero");
153
154 Ok(())
155}
156
157fn check_price_tolerance(value: Dimensionless) -> Result<()> {
159 ensure!(
160 value.is_finite() && value >= Dimensionless(0.0),
161 "price_tolerance must be a finite number greater than or equal to zero"
162 );
163
164 Ok(())
165}
166
167fn check_capacity_margin(value: f64) -> Result<()> {
169 ensure!(
170 value.is_finite() && value >= 0.0,
171 "capacity_margin must be a finite number greater than or equal to zero"
172 );
173
174 Ok(())
175}
176
177impl ModelParameters {
178 pub fn from_path<P: AsRef<Path>>(model_dir: P) -> Result<ModelParameters> {
188 let file_path = model_dir.as_ref().join(MODEL_PARAMETERS_FILE_NAME);
189 let model_params: ModelParameters = read_toml(&file_path)?;
190
191 set_broken_model_options_flag(model_params.allow_broken_options);
192
193 model_params
194 .validate()
195 .with_context(|| input_err_msg(file_path))?;
196
197 Ok(model_params)
198 }
199
200 fn validate(&self) -> Result<()> {
202 if self.allow_broken_options {
203 warn!(
204 "!!! You've enabled the {ALLOW_BROKEN_OPTION_NAME} option. !!!\n\
205 I see you like to live dangerously 😈. This option should ONLY be used by \
206 developers as it can cause peculiar behaviour that breaks things. NEVER enable it \
207 for results you actually care about or want to publish. You have been warned!"
208 );
209 }
210
211 check_milestone_years(&self.milestone_years)?;
213
214 check_capacity_valid_for_asset(self.candidate_asset_capacity)
218 .context("Invalid value for candidate_asset_capacity")?;
219
220 check_value_of_lost_load(self.value_of_lost_load)?;
222
223 check_max_ironing_out_iterations(self.max_ironing_out_iterations)?;
225
226 check_price_tolerance(self.price_tolerance)?;
228
229 check_capacity_margin(self.capacity_margin)?;
231
232 Ok(())
233 }
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239 use rstest::rstest;
240 use std::fmt::Display;
241 use std::fs::File;
242 use std::io::Write;
243 use tempfile::tempdir;
244
245 fn assert_validation_result<T, U: Display>(
247 result: Result<T>,
248 expected_valid: bool,
249 value: U,
250 expected_error_fragment: &str,
251 ) {
252 if expected_valid {
253 assert!(
254 result.is_ok(),
255 "Expected value {} to be valid, but got error: {:?}",
256 value,
257 result.err()
258 );
259 } else {
260 assert!(
261 result.is_err(),
262 "Expected value {value} to be invalid, but it was accepted",
263 );
264 let error_message = result.err().unwrap().to_string();
265 assert!(
266 error_message.contains(expected_error_fragment),
267 "Error message should mention the validation constraint, got: {error_message}",
268 );
269 }
270 }
271
272 #[test]
273 fn check_milestone_years_works() {
274 check_milestone_years(&[1]).unwrap();
276 check_milestone_years(&[1, 2]).unwrap();
277
278 assert!(check_milestone_years(&[]).is_err());
280 assert!(check_milestone_years(&[1, 1]).is_err());
281 assert!(check_milestone_years(&[2, 1]).is_err());
282 }
283
284 #[test]
285 fn model_params_from_path() {
286 let dir = tempdir().unwrap();
287 {
288 let mut file = File::create(dir.path().join(MODEL_PARAMETERS_FILE_NAME)).unwrap();
289 writeln!(file, "milestone_years = [2020, 2100]").unwrap();
290 }
291
292 let model_params = ModelParameters::from_path(dir.path()).unwrap();
293 assert_eq!(model_params.milestone_years, [2020, 2100]);
294 }
295
296 #[rstest]
297 #[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) {
308 let money_per_flow = MoneyPerFlow::new(value);
309 let result = check_value_of_lost_load(money_per_flow);
310
311 assert_validation_result(
312 result,
313 expected_valid,
314 value,
315 "value_of_lost_load must be a finite number greater than zero",
316 );
317 }
318
319 #[rstest]
320 #[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) {
326 let result = check_max_ironing_out_iterations(value);
327
328 assert_validation_result(
329 result,
330 expected_valid,
331 value,
332 "max_ironing_out_iterations cannot be zero",
333 );
334 }
335
336 #[rstest]
337 #[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) {
348 let dimensionless = Dimensionless::new(value);
349 let result = check_price_tolerance(dimensionless);
350
351 assert_validation_result(
352 result,
353 expected_valid,
354 value,
355 "price_tolerance must be a finite number greater than or equal to zero",
356 );
357 }
358
359 #[rstest]
360 #[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) {
368 let result = check_capacity_margin(value);
369
370 assert_validation_result(
371 result,
372 expected_valid,
373 value,
374 "capacity_margin must be a finite number greater than or equal to zero",
375 );
376 }
377}