Skip to main content

mollendorff_forge/tornado/
config.rs

1//! Tornado Diagram Configuration
2//!
3//! Handles parsing and validation of sensitivity analysis definitions.
4
5use serde::{Deserialize, Serialize};
6
7/// Configuration for an input variable range
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct InputRange {
10    /// Variable name
11    pub name: String,
12    /// Low value for sensitivity
13    pub low: f64,
14    /// High value for sensitivity
15    pub high: f64,
16    /// Base value (optional, uses model default if not specified)
17    pub base: Option<f64>,
18}
19
20impl InputRange {
21    /// Create a new input range
22    #[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    /// Set the base value
33    #[must_use]
34    pub const fn with_base(mut self, base: f64) -> Self {
35        self.base = Some(base);
36        self
37    }
38
39    /// Validate the range
40    ///
41    /// # Errors
42    ///
43    /// Returns an error if low >= high or base is outside the range.
44    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    /// Get the base value, defaulting to midpoint if not specified
63    #[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/// Configuration for tornado diagram
71#[derive(Debug, Clone, Default, Serialize, Deserialize)]
72pub struct TornadoConfig {
73    /// Output variable to analyze
74    pub output: String,
75    /// Input variables with ranges
76    #[serde(default)]
77    pub inputs: Vec<InputRange>,
78    /// Number of steps for sensitivity (default: 2 for low/high only)
79    #[serde(default = "default_steps")]
80    pub steps: usize,
81}
82
83const fn default_steps() -> usize {
84    2
85}
86
87impl TornadoConfig {
88    /// Create a new configuration
89    #[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    /// Add an input variable
99    #[must_use]
100    pub fn with_input(mut self, input: InputRange) -> Self {
101        self.inputs.push(input);
102        self
103    }
104
105    /// Set the number of steps
106    #[must_use]
107    pub const fn with_steps(mut self, steps: usize) -> Self {
108        self.steps = steps;
109        self
110    }
111
112    /// Validate the configuration
113    ///
114    /// # Errors
115    ///
116    /// Returns an error if no output or inputs are specified, or steps < 2.
117    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// Financial math: exact float comparison validated against Excel/Gnumeric/R
140#[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)); // Low > high
169
170        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}