diff --git a/Cargo.lock b/Cargo.lock index 0bed881..8ac9923 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -235,6 +235,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "syn" version = "2.0.117" @@ -246,6 +255,47 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -265,6 +315,7 @@ dependencies = [ "rand", "serde", "serde_json", + "toml", ] [[package]] @@ -319,6 +370,15 @@ dependencies = [ "semver", ] +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index b268047..981fc99 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,3 +7,4 @@ edition = "2024" rand = "0.10" serde = { version = "1", features = ["derive"] } serde_json = "1" +toml = "0.8" diff --git a/README.md b/README.md index 088632b..0065cd8 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ cargo run --release -- --preset demo ``` ```bash -cargo run --release -- --scenario /path/to/scenario.json +cargo run --release -- --scenario /path/to/scenario.toml ``` ```bash @@ -84,13 +84,15 @@ curl -s "http://127.0.0.1:8080/telemetry?from=4&to=8" Example scenario file: -```json -{ - "houses": 20, - "feeder_kw": 200, - "seed": 42, - "steps_per_day": 24 -} +```toml +houses = 20 +feeder_kw = 200.0 +seed = 42 +steps_per_day = 24 +solar_kw_peak_per_house = 5.0 +dr_start_step = 17 +dr_end_step = 21 +dr_reduction_kw_per_house = 1.5 ``` #### Example output: @@ -121,6 +123,45 @@ Notes: - `--telemetry-out` writes CSV columns: `timestep,time_hr,target_kw,feeder_kw,tracking_error_kw,baseload_kw,solar_kw,ev_requested_kw,ev_dispatched_kw,battery_kw,battery_soc,dr_requested_kw,dr_achieved_kw,limit_ok` +### Scenario Presets (TOML) + +Canonical scenario format is TOML. Built-in presets live under `./scenarios/`: + +- `baseline.toml` +- `high_solar.toml` +- `dr_event.toml` + +Run them via CLI: + +```bash +cargo run --release -- --scenario scenarios/baseline.toml +``` + +```bash +cargo run --release -- --scenario scenarios/high_solar.toml +``` + +```bash +cargo run --release -- --scenario scenarios/dr_event.toml +``` + +Bare filenames are also resolved from `./scenarios`, for example: + +```bash +cargo run --release -- --scenario high_solar.toml +``` + +Scenario schema keys: + +- `houses` (u32, > 0) +- `feeder_kw` (f32, > 0) +- `seed` (u64) +- `steps_per_day` (usize, > 0) +- `solar_kw_peak_per_house` (f32, >= 0) +- `dr_start_step` (usize, `< steps_per_day`) +- `dr_end_step` (usize, `<= steps_per_day` and `> dr_start_step`) +- `dr_reduction_kw_per_house` (f32, >= 0) + ### HTTP API (schema v1) The API serves JSON objects using the same schema v1 field names as telemetry CSV. diff --git a/scenarios/baseline.toml b/scenarios/baseline.toml new file mode 100644 index 0000000..d284396 --- /dev/null +++ b/scenarios/baseline.toml @@ -0,0 +1,9 @@ +# Baseline scenario: balanced defaults. +houses = 20 +feeder_kw = 200.0 +seed = 42 +steps_per_day = 24 +solar_kw_peak_per_house = 5.0 +dr_start_step = 17 +dr_end_step = 21 +dr_reduction_kw_per_house = 1.5 diff --git a/scenarios/dr_event.toml b/scenarios/dr_event.toml new file mode 100644 index 0000000..f2d8f0e --- /dev/null +++ b/scenarios/dr_event.toml @@ -0,0 +1,9 @@ +# DR-event scenario: stronger and earlier demand response window. +houses = 20 +feeder_kw = 200.0 +seed = 42 +steps_per_day = 24 +solar_kw_peak_per_house = 5.0 +dr_start_step = 10 +dr_end_step = 16 +dr_reduction_kw_per_house = 3.0 diff --git a/scenarios/high_solar.toml b/scenarios/high_solar.toml new file mode 100644 index 0000000..627ca79 --- /dev/null +++ b/scenarios/high_solar.toml @@ -0,0 +1,9 @@ +# High-solar scenario: stronger daytime solar penetration. +houses = 20 +feeder_kw = 200.0 +seed = 42 +steps_per_day = 24 +solar_kw_peak_per_house = 12.0 +dr_start_step = 17 +dr_end_step = 21 +dr_reduction_kw_per_house = 0.5 diff --git a/src/cli.rs b/src/cli.rs index c0180bb..3611b68 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -33,7 +33,7 @@ fn parse_options(args: &[String]) -> Result { "--scenario" => { i += 1; let path = args.get(i).ok_or_else(|| { - "missing value for --scenario (expected a JSON file path)".to_string() + "missing value for --scenario (expected a .toml scenario file path)".to_string() })?; if scenario.replace(PathBuf::from(path)).is_some() { return Err("--scenario provided more than once".to_string()); diff --git a/src/main.rs b/src/main.rs index 573ad8f..74f82e4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,7 +26,7 @@ fn main() { }; let scenario = if let Some(path) = opts.scenario.as_deref() { - match ScenarioConfig::from_json_path(path) { + match ScenarioConfig::from_path(path) { Ok(s) => s, Err(err) => { eprintln!("Error: {err}"); diff --git a/src/runner.rs b/src/runner.rs index 0279c1b..1fe12f0 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -52,12 +52,12 @@ pub fn run_scenario(config: &ScenarioConfig, print_readable_log: bool) -> Simula let target_schedule = DayAheadSchedule::flat_target(&load_forecast); let mut pv = SolarPv::new( - 5.0 * houses, /* kw_peak */ - steps_per_day, /* steps_per_day */ - 6, /* sunrise_idx (6 AM) */ - 18, /* sunset_idx (6 PM) */ - 0.05, /* noise_std */ - config.seed.wrapping_add(1), /* seed */ + config.solar_kw_peak_per_house * houses, /* kw_peak */ + steps_per_day, /* steps_per_day */ + 6, /* sunrise_idx (6 AM) */ + 18, /* sunset_idx (6 PM) */ + 0.05, /* noise_std */ + config.seed.wrapping_add(1), /* seed */ ); let solar_device = pv.device_type(); @@ -90,8 +90,11 @@ pub fn run_scenario(config: &ScenarioConfig, print_readable_log: bool) -> Simula config.feeder_kw * 0.8, /* max_export_kw */ ); - // Example external DR event: request 1.5kW reduction from hour 17 to 21. - let dr_event = DemandResponseEvent::new(17, 21, 1.5 * houses); + let dr_event = DemandResponseEvent::new( + config.dr_start_step, + config.dr_end_step, + config.dr_reduction_kw_per_house * houses, + ); let controller = NaiveRtController; @@ -227,6 +230,7 @@ mod tests { feeder_kw: 40.0, seed: 777, steps_per_day: 24, + ..ScenarioConfig::default() }; let run_a = run_scenario(&scenario, false); diff --git a/src/scenario.rs b/src/scenario.rs index f2c150a..de06990 100644 --- a/src/scenario.rs +++ b/src/scenario.rs @@ -7,6 +7,10 @@ pub struct ScenarioConfig { pub feeder_kw: f32, pub seed: u64, pub steps_per_day: usize, + pub solar_kw_peak_per_house: f32, + pub dr_start_step: usize, + pub dr_end_step: usize, + pub dr_reduction_kw_per_house: f32, } impl Default for ScenarioConfig { @@ -16,24 +20,51 @@ impl Default for ScenarioConfig { feeder_kw: 5.0, seed: 42, steps_per_day: 24, + solar_kw_peak_per_house: 5.0, + dr_start_step: 17, + dr_end_step: 21, + dr_reduction_kw_per_house: 1.5, } } } impl ScenarioConfig { - pub fn from_json_path(path: &Path) -> Result { - let raw = fs::read_to_string(path) - .map_err(|err| format!("failed to read scenario `{}`: {err}", path.display()))?; - let pairs = parse_flat_json_object(&raw) - .map_err(|err| format!("invalid JSON in scenario `{}`: {err}", path.display()))?; + pub fn from_path(path: &Path) -> Result { + let resolved_path = resolve_scenario_path(path); + let raw = fs::read_to_string(&resolved_path).map_err(|err| { + format!( + "failed to read scenario `{}`: {err}", + resolved_path.display() + ) + })?; + + let ext = resolved_path + .extension() + .and_then(|s| s.to_str()) + .unwrap_or(""); + let pairs = match ext { + "toml" => parse_flat_toml_table(&raw).map_err(|err| { + format!( + "invalid TOML in scenario `{}`: {err}", + resolved_path.display() + ) + })?, + _ => { + return Err(format!( + "unsupported scenario format for `{}` (expected .toml)", + resolved_path.display() + )); + } + }; + Self::from_kv_pairs(&pairs) - .map_err(|err| format!("invalid scenario `{}`: {err}", path.display())) + .map_err(|err| format!("invalid scenario `{}`: {err}", resolved_path.display())) } pub fn from_preset(name: &str) -> Result { - let scenario_path = PathBuf::from("scenarios").join(format!("{name}.json")); + let scenario_path = PathBuf::from("scenarios").join(format!("{name}.toml")); if scenario_path.exists() { - return Self::from_json_path(&scenario_path); + return Self::from_path(&scenario_path); } match name { @@ -48,7 +79,14 @@ impl ScenarioConfig { fn from_kv_pairs(obj: &[(String, String)]) -> Result { for (key, _) in obj { match key.as_str() { - "houses" | "feeder_kw" | "seed" | "steps_per_day" => {} + "houses" + | "feeder_kw" + | "seed" + | "steps_per_day" + | "solar_kw_peak_per_house" + | "dr_start_step" + | "dr_end_step" + | "dr_reduction_kw_per_house" => {} _ => return Err(format!("at `$.{key}`: unknown key")), } } @@ -57,6 +95,18 @@ impl ScenarioConfig { let feeder_kw = parse_f32(find_value(obj, "feeder_kw"), "$.feeder_kw", 5.0)?; let seed = parse_u64(find_value(obj, "seed"), "$.seed", 42)?; let steps_per_day = parse_usize(find_value(obj, "steps_per_day"), "$.steps_per_day", 24)?; + let solar_kw_peak_per_house = parse_f32( + find_value(obj, "solar_kw_peak_per_house"), + "$.solar_kw_peak_per_house", + 5.0, + )?; + let dr_start_step = parse_usize(find_value(obj, "dr_start_step"), "$.dr_start_step", 17)?; + let dr_end_step = parse_usize(find_value(obj, "dr_end_step"), "$.dr_end_step", 21)?; + let dr_reduction_kw_per_house = parse_f32( + find_value(obj, "dr_reduction_kw_per_house"), + "$.dr_reduction_kw_per_house", + 1.5, + )?; if houses == 0 { return Err("at `$.houses`: must be > 0".to_string()); @@ -67,16 +117,48 @@ impl ScenarioConfig { if steps_per_day == 0 { return Err("at `$.steps_per_day`: must be > 0".to_string()); } + if solar_kw_peak_per_house < 0.0 { + return Err("at `$.solar_kw_peak_per_house`: must be >= 0".to_string()); + } + if dr_start_step >= steps_per_day { + return Err("at `$.dr_start_step`: must be < steps_per_day".to_string()); + } + if dr_end_step > steps_per_day { + return Err("at `$.dr_end_step`: must be <= steps_per_day".to_string()); + } + if dr_start_step >= dr_end_step { + return Err("at `$.dr_start_step`: must be < dr_end_step".to_string()); + } + if dr_reduction_kw_per_house < 0.0 { + return Err("at `$.dr_reduction_kw_per_house`: must be >= 0".to_string()); + } Ok(Self { houses, feeder_kw, seed, steps_per_day, + solar_kw_peak_per_house, + dr_start_step, + dr_end_step, + dr_reduction_kw_per_house, }) } } +fn resolve_scenario_path(path: &Path) -> PathBuf { + if path.exists() { + return path.to_path_buf(); + } + + let fallback = PathBuf::from("scenarios").join(path); + if fallback.exists() { + fallback + } else { + path.to_path_buf() + } +} + fn find_value<'a>(pairs: &'a [(String, String)], key: &str) -> Option<&'a str> { pairs .iter() @@ -124,118 +206,36 @@ fn parse_f32(value: Option<&str>, path: &str, default: f32) -> Result Result, String> { - let mut i = 0usize; - let bytes = raw.as_bytes(); - skip_ws(bytes, &mut i); - expect_char(bytes, &mut i, b'{')?; - skip_ws(bytes, &mut i); - - let mut pairs = Vec::new(); - if i < bytes.len() && bytes[i] == b'}' { - i += 1; - skip_ws(bytes, &mut i); - if i != bytes.len() { - return Err(format!("unexpected trailing content at byte {i}")); - } - return Ok(pairs); - } - - loop { - skip_ws(bytes, &mut i); - let key = parse_json_string(bytes, &mut i)?; - skip_ws(bytes, &mut i); - expect_char(bytes, &mut i, b':')?; - skip_ws(bytes, &mut i); - let value = parse_json_number_literal(bytes, &mut i)?; - pairs.push((key, value)); - skip_ws(bytes, &mut i); - - if i >= bytes.len() { - return Err("expected `,` or `}` at end of object".to_string()); - } - match bytes[i] { - b',' => { - i += 1; - } - b'}' => { - i += 1; - break; - } - _ => return Err(format!("expected `,` or `}}` at byte {i}")), - } - } +fn parse_flat_toml_table(raw: &str) -> Result, String> { + let value: toml::Value = raw + .parse::() + .map_err(|err| format!("failed to parse TOML: {err}"))?; + let table = value + .as_table() + .ok_or_else(|| "expected top-level TOML table".to_string())?; - skip_ws(bytes, &mut i); - if i != bytes.len() { - return Err(format!("unexpected trailing content at byte {i}")); + let mut pairs = Vec::with_capacity(table.len()); + for (key, value) in table { + let as_string = toml_value_to_numeric_string(value, key)?; + pairs.push((key.clone(), as_string)); } Ok(pairs) } -fn skip_ws(bytes: &[u8], i: &mut usize) { - while *i < bytes.len() && bytes[*i].is_ascii_whitespace() { - *i += 1; +fn toml_value_to_numeric_string(value: &toml::Value, key: &str) -> Result { + match value { + toml::Value::Integer(n) => Ok(n.to_string()), + toml::Value::Float(n) => Ok(n.to_string()), + _ => Err(format!( + "at `$.{key}`: expected numeric value (integer or float)" + )), } } -fn expect_char(bytes: &[u8], i: &mut usize, ch: u8) -> Result<(), String> { - if *i >= bytes.len() { - return Err(format!("expected `{}` at end of input", ch as char)); - } - if bytes[*i] != ch { - return Err(format!("expected `{}` at byte {}", ch as char, *i)); - } - *i += 1; - Ok(()) -} - -fn parse_json_string(bytes: &[u8], i: &mut usize) -> Result { - expect_char(bytes, i, b'"')?; - let start = *i; - while *i < bytes.len() { - let c = bytes[*i]; - if c == b'\\' { - return Err(format!( - "unsupported escaped string at byte {} (only plain keys are supported)", - *i - )); - } - if c == b'"' { - let s = std::str::from_utf8(&bytes[start..*i]) - .map_err(|_| format!("invalid UTF-8 in string at byte {start}"))?; - *i += 1; - return Ok(s.to_string()); - } - *i += 1; - } - Err("unterminated string".to_string()) -} - -fn parse_json_number_literal(bytes: &[u8], i: &mut usize) -> Result { - let start = *i; - if *i < bytes.len() && (bytes[*i] == b'+' || bytes[*i] == b'-') { - *i += 1; - } - while *i < bytes.len() { - let c = bytes[*i]; - if c.is_ascii_digit() || c == b'.' || c == b'e' || c == b'E' || c == b'+' || c == b'-' { - *i += 1; - } else { - break; - } - } - if start == *i { - return Err(format!("expected number at byte {start}")); - } - let value = std::str::from_utf8(&bytes[start..*i]) - .map_err(|_| format!("invalid number bytes at byte {start}"))?; - Ok(value.to_string()) -} - #[cfg(test)] mod tests { - use super::ScenarioConfig; + use super::{ScenarioConfig, parse_flat_toml_table}; + use std::path::Path; #[test] fn scenario_validation_includes_offending_key_path() { @@ -250,4 +250,19 @@ mod tests { let err = ScenarioConfig::from_kv_pairs(&value).expect_err("must fail"); assert!(err.contains("$.bad_key")); } + + #[test] + fn parses_flat_toml_table() { + let pairs = + parse_flat_toml_table("houses = 2\nfeeder_kw = 10.5\nseed = 9").expect("toml parse"); + assert!(pairs.iter().any(|(k, v)| k == "houses" && v == "2")); + assert!(pairs.iter().any(|(k, v)| k == "feeder_kw" && v == "10.5")); + } + + #[test] + fn bare_filename_resolves_from_scenarios_dir() { + let cfg = ScenarioConfig::from_path(Path::new("baseline.toml")) + .expect("baseline preset from scenarios dir should load"); + assert!(cfg.houses > 0); + } } diff --git a/tests/scenario_presets_integration.rs b/tests/scenario_presets_integration.rs new file mode 100644 index 0000000..53ce0b7 --- /dev/null +++ b/tests/scenario_presets_integration.rs @@ -0,0 +1,85 @@ +use std::process::Command; + +#[derive(Debug)] +struct Kpis { + rmse_tracking_kw: f64, + curtailment_pct: f64, +} + +#[test] +fn scenario_presets_run_via_cli_and_produce_distinct_dynamics() { + let baseline = run_and_parse_kpis("scenarios/baseline.toml"); + let high_solar = run_and_parse_kpis("scenarios/high_solar.toml"); + let dr_event = run_and_parse_kpis("scenarios/dr_event.toml"); + + assert!( + (baseline.rmse_tracking_kw - high_solar.rmse_tracking_kw).abs() > 1.0, + "expected baseline and high_solar RMSE to differ: baseline={:.3}, high_solar={:.3}", + baseline.rmse_tracking_kw, + high_solar.rmse_tracking_kw + ); + + assert!( + (baseline.curtailment_pct - dr_event.curtailment_pct).abs() > 1.0, + "expected baseline and dr_event curtailment to differ: baseline={:.3}, dr_event={:.3}", + baseline.curtailment_pct, + dr_event.curtailment_pct + ); + + assert!( + (high_solar.rmse_tracking_kw - dr_event.rmse_tracking_kw).abs() > 0.01, + "expected high_solar and dr_event RMSE to differ: high_solar={:.3}, dr_event={:.3}", + high_solar.rmse_tracking_kw, + dr_event.rmse_tracking_kw + ); + + assert!( + (high_solar.curtailment_pct - dr_event.curtailment_pct).abs() > 1.0, + "expected high_solar and dr_event curtailment to differ: high_solar={:.3}, dr_event={:.3}", + high_solar.curtailment_pct, + dr_event.curtailment_pct + ); +} + +fn run_and_parse_kpis(path: &str) -> Kpis { + let output = Command::new(env!("CARGO_BIN_EXE_vpp-sim")) + .args(["--scenario", path]) + .output() + .expect("vpp-sim process should run"); + + assert!( + output.status.success(), + "scenario run failed for {path}: stderr={} ", + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8(output.stdout).expect("stdout should be valid UTF-8"); + parse_kpis(&stdout) +} + +fn parse_kpis(stdout: &str) -> Kpis { + let rmse_tracking_kw = parse_metric(stdout, "RMSE tracking error:", "kW"); + let curtailment_pct = parse_metric(stdout, "Curtailment achieved:", "%"); + + Kpis { + rmse_tracking_kw, + curtailment_pct, + } +} + +fn parse_metric(stdout: &str, label: &str, unit: &str) -> f64 { + let line = stdout + .lines() + .find(|line| line.trim_start().starts_with(label)) + .unwrap_or_else(|| panic!("missing KPI line `{label}` in output: {stdout}")); + + let raw = line + .split_once(':') + .map(|(_, right)| right.trim()) + .unwrap_or_else(|| panic!("invalid KPI format for line `{line}`")); + + let numeric = raw.strip_suffix(unit).unwrap_or(raw).trim(); + numeric + .parse::() + .unwrap_or_else(|_| panic!("failed parsing `{numeric}` from KPI line `{line}`")) +}