From 963e25a570bb27f135040c045c0d9abc9ab9ca84 Mon Sep 17 00:00:00 2001 From: Jackson Hoffart Date: Fri, 20 Feb 2026 14:12:05 +0100 Subject: [PATCH 1/3] feat(scenarios): add TOML schema and built-in presets baseline/ high_solar/dr_event presents --- Cargo.lock | 60 ++++++ Cargo.toml | 1 + README.md | 59 +++++- scenarios/baseline.toml | 9 + scenarios/dr_event.toml | 9 + scenarios/high_solar.toml | 9 + src/cli.rs | 2 +- src/main.rs | 2 +- src/runner.rs | 20 +- src/scenario.rs | 256 ++++++++++++++++---------- tests/scenario_presets_integration.rs | 85 +++++++++ 11 files changed, 393 insertions(+), 119 deletions(-) create mode 100644 scenarios/baseline.toml create mode 100644 scenarios/dr_event.toml create mode 100644 scenarios/high_solar.toml create mode 100644 tests/scenario_presets_integration.rs 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..6c907ab 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,10 @@ cargo run --release -- --preset demo cargo run --release -- --scenario /path/to/scenario.json ``` +```bash +cargo run --release -- --scenario /path/to/scenario.toml +``` + ```bash cargo build --release ./target/release/vpp-sim --preset demo @@ -84,13 +88,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 +127,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..03c408f 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 scenario file path (.toml recommended))".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..8e0b825 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,57 @@ 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() + ) + })?, + "json" => parse_flat_json_object(&raw).map_err(|err| { + format!( + "invalid JSON 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 +85,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 +101,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 +123,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() @@ -125,117 +213,66 @@ 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); - } + let value: serde_json::Value = + serde_json::from_str(raw).map_err(|err| format!("failed to parse JSON: {err}"))?; + let obj = value + .as_object() + .ok_or_else(|| "expected top-level JSON object".to_string())?; - 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}")), - } - } - - skip_ws(bytes, &mut i); - if i != bytes.len() { - return Err(format!("unexpected trailing content at byte {i}")); + let mut pairs = Vec::with_capacity(obj.len()); + for (key, value) in obj { + let as_string = 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 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())?; + + 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 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)); +fn value_to_numeric_string(value: &serde_json::Value, key: &str) -> Result { + if let Some(n) = value.as_u64() { + return Ok(n.to_string()); } - if bytes[*i] != ch { - return Err(format!("expected `{}` at byte {}", ch as char, *i)); + if let Some(n) = value.as_i64() { + return Ok(n.to_string()); } - *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; + if let Some(n) = value.as_f64() { + return Ok(n.to_string()); } - Err("unterminated string".to_string()) + + Err(format!( + "at `$.{key}`: expected numeric value (integer or float)" + )) } -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}")); +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)" + )), } - 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 +287,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}`")) +} From ebc5a9a0ceeae0dd385189fa456d53d30e8b7527 Mon Sep 17 00:00:00 2001 From: Jackson Hoffart Date: Fri, 20 Feb 2026 14:29:03 +0100 Subject: [PATCH 2/3] remove JSON support --- README.md | 4 ---- src/scenario.rs | 37 ------------------------------------- 2 files changed, 41 deletions(-) diff --git a/README.md b/README.md index 6c907ab..0065cd8 100644 --- a/README.md +++ b/README.md @@ -47,10 +47,6 @@ Run methods: cargo run --release -- --preset demo ``` -```bash -cargo run --release -- --scenario /path/to/scenario.json -``` - ```bash cargo run --release -- --scenario /path/to/scenario.toml ``` diff --git a/src/scenario.rs b/src/scenario.rs index 8e0b825..de06990 100644 --- a/src/scenario.rs +++ b/src/scenario.rs @@ -49,12 +49,6 @@ impl ScenarioConfig { resolved_path.display() ) })?, - "json" => parse_flat_json_object(&raw).map_err(|err| { - format!( - "invalid JSON in scenario `{}`: {err}", - resolved_path.display() - ) - })?, _ => { return Err(format!( "unsupported scenario format for `{}` (expected .toml)", @@ -212,21 +206,6 @@ fn parse_f32(value: Option<&str>, path: &str, default: f32) -> Result Result, String> { - let value: serde_json::Value = - serde_json::from_str(raw).map_err(|err| format!("failed to parse JSON: {err}"))?; - let obj = value - .as_object() - .ok_or_else(|| "expected top-level JSON object".to_string())?; - - let mut pairs = Vec::with_capacity(obj.len()); - for (key, value) in obj { - let as_string = value_to_numeric_string(value, key)?; - pairs.push((key.clone(), as_string)); - } - Ok(pairs) -} - fn parse_flat_toml_table(raw: &str) -> Result, String> { let value: toml::Value = raw .parse::() @@ -243,22 +222,6 @@ fn parse_flat_toml_table(raw: &str) -> Result, String> { Ok(pairs) } -fn value_to_numeric_string(value: &serde_json::Value, key: &str) -> Result { - if let Some(n) = value.as_u64() { - return Ok(n.to_string()); - } - if let Some(n) = value.as_i64() { - return Ok(n.to_string()); - } - if let Some(n) = value.as_f64() { - return Ok(n.to_string()); - } - - Err(format!( - "at `$.{key}`: expected numeric value (integer or float)" - )) -} - fn toml_value_to_numeric_string(value: &toml::Value, key: &str) -> Result { match value { toml::Value::Integer(n) => Ok(n.to_string()), From b7f5107899fe01a732c88063aa926e88c4b63c66 Mon Sep 17 00:00:00 2001 From: Jackson Hoffart Date: Fri, 20 Feb 2026 14:35:05 +0100 Subject: [PATCH 3/3] Update src/cli.rs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/cli.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli.rs b/src/cli.rs index 03c408f..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 scenario file path (.toml recommended))".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());