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}