vpp_sim/
scenario.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4#[derive(Debug, Clone)]
5pub struct ScenarioConfig {
6    pub houses: u32,
7    pub feeder_kw: f32,
8    pub seed: u64,
9    pub steps_per_day: usize,
10    pub solar_kw_peak_per_house: f32,
11    pub dr_start_step: usize,
12    pub dr_end_step: usize,
13    pub dr_reduction_kw_per_house: f32,
14}
15
16impl Default for ScenarioConfig {
17    fn default() -> Self {
18        Self {
19            houses: 1,
20            feeder_kw: 5.0,
21            seed: 42,
22            steps_per_day: 24,
23            solar_kw_peak_per_house: 5.0,
24            dr_start_step: 17,
25            dr_end_step: 21,
26            dr_reduction_kw_per_house: 1.5,
27        }
28    }
29}
30
31impl ScenarioConfig {
32    pub fn from_path(path: &Path) -> Result<Self, String> {
33        let resolved_path = resolve_scenario_path(path);
34        let raw = fs::read_to_string(&resolved_path).map_err(|err| {
35            format!(
36                "failed to read scenario `{}`: {err}",
37                resolved_path.display()
38            )
39        })?;
40
41        let ext = resolved_path
42            .extension()
43            .and_then(|s| s.to_str())
44            .unwrap_or("");
45        let pairs = match ext {
46            "toml" => parse_flat_toml_table(&raw).map_err(|err| {
47                format!(
48                    "invalid TOML in scenario `{}`: {err}",
49                    resolved_path.display()
50                )
51            })?,
52            _ => {
53                return Err(format!(
54                    "unsupported scenario format for `{}` (expected .toml)",
55                    resolved_path.display()
56                ));
57            }
58        };
59
60        Self::from_kv_pairs(&pairs)
61            .map_err(|err| format!("invalid scenario `{}`: {err}", resolved_path.display()))
62    }
63
64    pub fn from_preset(name: &str) -> Result<Self, String> {
65        let scenario_path = PathBuf::from("scenarios").join(format!("{name}.toml"));
66        if scenario_path.exists() {
67            return Self::from_path(&scenario_path);
68        }
69
70        match name {
71            "demo" => Ok(Self::default()),
72            _ => Err(format!(
73                "invalid value for `preset`: unknown preset `{name}` (expected `demo` or file `{}`)",
74                scenario_path.display()
75            )),
76        }
77    }
78
79    fn from_kv_pairs(obj: &[(String, String)]) -> Result<Self, String> {
80        for (key, _) in obj {
81            match key.as_str() {
82                "houses"
83                | "feeder_kw"
84                | "seed"
85                | "steps_per_day"
86                | "solar_kw_peak_per_house"
87                | "dr_start_step"
88                | "dr_end_step"
89                | "dr_reduction_kw_per_house" => {}
90                _ => return Err(format!("at `$.{key}`: unknown key")),
91            }
92        }
93
94        let houses = parse_u32(find_value(obj, "houses"), "$.houses", 1)?;
95        let feeder_kw = parse_f32(find_value(obj, "feeder_kw"), "$.feeder_kw", 5.0)?;
96        let seed = parse_u64(find_value(obj, "seed"), "$.seed", 42)?;
97        let steps_per_day = parse_usize(find_value(obj, "steps_per_day"), "$.steps_per_day", 24)?;
98        let solar_kw_peak_per_house = parse_f32(
99            find_value(obj, "solar_kw_peak_per_house"),
100            "$.solar_kw_peak_per_house",
101            5.0,
102        )?;
103        let dr_start_step = parse_usize(find_value(obj, "dr_start_step"), "$.dr_start_step", 17)?;
104        let dr_end_step = parse_usize(find_value(obj, "dr_end_step"), "$.dr_end_step", 21)?;
105        let dr_reduction_kw_per_house = parse_f32(
106            find_value(obj, "dr_reduction_kw_per_house"),
107            "$.dr_reduction_kw_per_house",
108            1.5,
109        )?;
110
111        if houses == 0 {
112            return Err("at `$.houses`: must be > 0".to_string());
113        }
114        if feeder_kw <= 0.0 {
115            return Err("at `$.feeder_kw`: must be > 0".to_string());
116        }
117        if steps_per_day == 0 {
118            return Err("at `$.steps_per_day`: must be > 0".to_string());
119        }
120        if solar_kw_peak_per_house < 0.0 {
121            return Err("at `$.solar_kw_peak_per_house`: must be >= 0".to_string());
122        }
123        if dr_start_step >= steps_per_day {
124            return Err("at `$.dr_start_step`: must be < steps_per_day".to_string());
125        }
126        if dr_end_step > steps_per_day {
127            return Err("at `$.dr_end_step`: must be <= steps_per_day".to_string());
128        }
129        if dr_start_step >= dr_end_step {
130            return Err("at `$.dr_start_step`: must be < dr_end_step".to_string());
131        }
132        if dr_reduction_kw_per_house < 0.0 {
133            return Err("at `$.dr_reduction_kw_per_house`: must be >= 0".to_string());
134        }
135
136        Ok(Self {
137            houses,
138            feeder_kw,
139            seed,
140            steps_per_day,
141            solar_kw_peak_per_house,
142            dr_start_step,
143            dr_end_step,
144            dr_reduction_kw_per_house,
145        })
146    }
147}
148
149fn resolve_scenario_path(path: &Path) -> PathBuf {
150    if path.exists() {
151        return path.to_path_buf();
152    }
153
154    let fallback = PathBuf::from("scenarios").join(path);
155    if fallback.exists() {
156        fallback
157    } else {
158        path.to_path_buf()
159    }
160}
161
162fn find_value<'a>(pairs: &'a [(String, String)], key: &str) -> Option<&'a str> {
163    pairs
164        .iter()
165        .find_map(|(k, v)| if k == key { Some(v.as_str()) } else { None })
166}
167
168fn parse_u32(value: Option<&str>, path: &str, default: u32) -> Result<u32, String> {
169    let Some(v) = value else {
170        return Ok(default);
171    };
172    let n = v
173        .parse::<u64>()
174        .map_err(|_| format!("at `{path}`: expected unsigned integer"))?;
175    u32::try_from(n).map_err(|_| format!("at `{path}`: value out of range for u32"))
176}
177
178fn parse_u64(value: Option<&str>, path: &str, default: u64) -> Result<u64, String> {
179    let Some(v) = value else {
180        return Ok(default);
181    };
182    v.parse::<u64>()
183        .map_err(|_| format!("at `{path}`: expected unsigned integer"))
184}
185
186fn parse_usize(value: Option<&str>, path: &str, default: usize) -> Result<usize, String> {
187    let Some(v) = value else {
188        return Ok(default);
189    };
190    let n = v
191        .parse::<u64>()
192        .map_err(|_| format!("at `{path}`: expected unsigned integer"))?;
193    usize::try_from(n).map_err(|_| format!("at `{path}`: value out of range for usize"))
194}
195
196fn parse_f32(value: Option<&str>, path: &str, default: f32) -> Result<f32, String> {
197    let Some(v) = value else {
198        return Ok(default);
199    };
200    let n = v
201        .parse::<f64>()
202        .map_err(|_| format!("at `{path}`: expected number"))?;
203    if !n.is_finite() {
204        return Err(format!("at `{path}`: expected finite number"));
205    }
206    Ok(n as f32)
207}
208
209fn parse_flat_toml_table(raw: &str) -> Result<Vec<(String, String)>, String> {
210    let value: toml::Value = raw
211        .parse::<toml::Value>()
212        .map_err(|err| format!("failed to parse TOML: {err}"))?;
213    let table = value
214        .as_table()
215        .ok_or_else(|| "expected top-level TOML table".to_string())?;
216
217    let mut pairs = Vec::with_capacity(table.len());
218    for (key, value) in table {
219        let as_string = toml_value_to_numeric_string(value, key)?;
220        pairs.push((key.clone(), as_string));
221    }
222    Ok(pairs)
223}
224
225fn toml_value_to_numeric_string(value: &toml::Value, key: &str) -> Result<String, String> {
226    match value {
227        toml::Value::Integer(n) => Ok(n.to_string()),
228        toml::Value::Float(n) => Ok(n.to_string()),
229        _ => Err(format!(
230            "at `$.{key}`: expected numeric value (integer or float)"
231        )),
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::{ScenarioConfig, parse_flat_toml_table};
238    use std::path::Path;
239
240    #[test]
241    fn scenario_validation_includes_offending_key_path() {
242        let value = vec![("houses".to_string(), "0".to_string())];
243        let err = ScenarioConfig::from_kv_pairs(&value).expect_err("must fail");
244        assert!(err.contains("$.houses"));
245    }
246
247    #[test]
248    fn unknown_key_reports_path() {
249        let value = vec![("bad_key".to_string(), "1".to_string())];
250        let err = ScenarioConfig::from_kv_pairs(&value).expect_err("must fail");
251        assert!(err.contains("$.bad_key"));
252    }
253
254    #[test]
255    fn parses_flat_toml_table() {
256        let pairs =
257            parse_flat_toml_table("houses = 2\nfeeder_kw = 10.5\nseed = 9").expect("toml parse");
258        assert!(pairs.iter().any(|(k, v)| k == "houses" && v == "2"));
259        assert!(pairs.iter().any(|(k, v)| k == "feeder_kw" && v == "10.5"));
260    }
261
262    #[test]
263    fn bare_filename_resolves_from_scenarios_dir() {
264        let cfg = ScenarioConfig::from_path(Path::new("baseline.toml"))
265            .expect("baseline preset from scenarios dir should load");
266        assert!(cfg.houses > 0);
267    }
268}