mollendorff_forge/tornado/
config.rs1use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct InputRange {
10 pub name: String,
12 pub low: f64,
14 pub high: f64,
16 pub base: Option<f64>,
18}
19
20impl InputRange {
21 #[must_use]
23 pub fn new(name: &str, low: f64, high: f64) -> Self {
24 Self {
25 name: name.to_string(),
26 low,
27 high,
28 base: None,
29 }
30 }
31
32 #[must_use]
34 pub const fn with_base(mut self, base: f64) -> Self {
35 self.base = Some(base);
36 self
37 }
38
39 pub fn validate(&self) -> Result<(), String> {
45 if self.low >= self.high {
46 return Err(format!(
47 "Input '{}': low ({}) must be less than high ({})",
48 self.name, self.low, self.high
49 ));
50 }
51 if let Some(base) = self.base {
52 if base < self.low || base > self.high {
53 return Err(format!(
54 "Input '{}': base ({}) must be between low ({}) and high ({})",
55 self.name, base, self.low, self.high
56 ));
57 }
58 }
59 Ok(())
60 }
61
62 #[must_use]
64 pub fn base_value(&self) -> f64 {
65 self.base
66 .unwrap_or_else(|| f64::midpoint(self.low, self.high))
67 }
68}
69
70#[derive(Debug, Clone, Default, Serialize, Deserialize)]
72pub struct TornadoConfig {
73 pub output: String,
75 #[serde(default)]
77 pub inputs: Vec<InputRange>,
78 #[serde(default = "default_steps")]
80 pub steps: usize,
81}
82
83const fn default_steps() -> usize {
84 2
85}
86
87impl TornadoConfig {
88 #[must_use]
90 pub fn new(output: &str) -> Self {
91 Self {
92 output: output.to_string(),
93 inputs: Vec::new(),
94 steps: 2,
95 }
96 }
97
98 #[must_use]
100 pub fn with_input(mut self, input: InputRange) -> Self {
101 self.inputs.push(input);
102 self
103 }
104
105 #[must_use]
107 pub const fn with_steps(mut self, steps: usize) -> Self {
108 self.steps = steps;
109 self
110 }
111
112 pub fn validate(&self) -> Result<(), String> {
118 if self.output.is_empty() {
119 return Err("Output variable must be specified".to_string());
120 }
121
122 if self.inputs.is_empty() {
123 return Err("At least one input variable must be specified".to_string());
124 }
125
126 if self.steps < 2 {
127 return Err("Steps must be at least 2".to_string());
128 }
129
130 for input in &self.inputs {
131 input.validate()?;
132 }
133
134 Ok(())
135 }
136}
137
138#[cfg(test)]
139#[allow(clippy::float_cmp)]
141mod config_tests {
142 use super::*;
143
144 #[test]
145 fn test_config_validation() {
146 let config = TornadoConfig::new("npv")
147 .with_input(InputRange::new("revenue_growth", 0.02, 0.08))
148 .with_input(InputRange::new("discount_rate", 0.08, 0.12));
149
150 assert!(config.validate().is_ok());
151 }
152
153 #[test]
154 fn test_empty_output_rejected() {
155 let config = TornadoConfig::new("").with_input(InputRange::new("x", 0.0, 1.0));
156
157 assert!(config.validate().is_err());
158 }
159
160 #[test]
161 fn test_no_inputs_rejected() {
162 let config = TornadoConfig::new("output");
163 assert!(config.validate().is_err());
164 }
165
166 #[test]
167 fn test_invalid_range_rejected() {
168 let config = TornadoConfig::new("output").with_input(InputRange::new("x", 1.0, 0.0)); assert!(config.validate().is_err());
171 }
172
173 #[test]
174 fn test_base_value() {
175 let input = InputRange::new("x", 0.0, 10.0);
176 assert_eq!(input.base_value(), 5.0);
177
178 let input_with_base = InputRange::new("x", 0.0, 10.0).with_base(3.0);
179 assert_eq!(input_with_base.base_value(), 3.0);
180 }
181}