powerio 0.7.1

Compiler infrastructure for power systems: parse, convert, validate, and lower grid models
Documentation
//! Schema lock for the `powerio-json` snapshot.
//!
//! The C ABI and the Julia bridge ride on this transport, and `PIO_ABI_VERSION`
//! ties the snapshot schema to the ABI version (`powerio-capi/include/powerio.h`).
//! So an accidental serde rename, retype, or removed default is a forced C ABI
//! break, not a quiet change. These tests pin three things:
//!   1. a committed v4-vintage snapshot keeps parsing under the current `from_json`
//!      (a rename/retype of any present field breaks parsing the frozen bytes),
//!   2. generator `caps` stays a name-keyed object on the wire (a length-exact
//!      array would force a v5 the day `GEN_EXTRA_KEYS` grows), and
//!   3. `deny_unknown_fields` stays off and `caps` keeps its `serde(default)`, so a
//!      newer snapshot's extra key and an older snapshot that predates `caps` both
//!      parse.

use std::path::Path;

use powerio::{
    Branch, Bus, BusId, BusType, CoordinateSpace, CoordsKind, GenCaps, Generator, GeoMeta,
    Location, Network, SourceFormat,
};

/// A v4-vintage snapshot, written by `powerio convert case30.m --to powerio-json`.
/// Regenerate ONLY on a deliberate schema change, and then bump `PIO_ABI_VERSION`.
fn golden_v4() -> String {
    let path =
        Path::new(env!("CARGO_MANIFEST_DIR")).join("../tests/data/powerio-json/case30_v4.json");
    std::fs::read_to_string(&path).expect("the committed v4 golden snapshot must exist")
}

#[test]
fn golden_v4_snapshot_still_parses() {
    let text = golden_v4();
    let net = Network::from_json(&text).expect("the v4 golden must still parse");
    assert_eq!(net.buses.len(), 30);
    assert_eq!(net.branches.len(), 41);
    assert_eq!(net.generators.len(), 6);
    assert_eq!(net.loads.len(), 20);
    assert_eq!(net.shunts.len(), 2);
    assert_eq!(net.base_mva.to_bits(), 100.0_f64.to_bits());
    assert!(
        net.generators.iter().all(|g| g.cost.is_some()),
        "case30 gen costs must survive the round trip"
    );

    // The freeze-critical wire form: caps is a name-keyed object, never an array.
    assert!(
        text.contains(r#""caps":{"#) && !text.contains(r#""caps":["#),
        "generator caps must serialize as an object"
    );

    // Re-serialize and read back: the schema round-trips without drift, and the
    // CURRENT serializer (not just the frozen golden bytes) still emits caps as a
    // name-keyed object, so a writer-side wire-form regression fails here too.
    let again = net.to_json().unwrap();
    assert!(
        again.contains(r#""caps":{"#) && !again.contains(r#""caps":["#),
        "the live serializer must still emit caps as an object"
    );
    let back = Network::from_json(&again).unwrap();
    assert_eq!(back.buses.len(), net.buses.len());
    assert_eq!(back.generators.len(), net.generators.len());
}

#[test]
fn snapshot_ignores_unknown_fields_and_defaults_omitted_caps() {
    // Build a minimal valid net, drop it to JSON, then mutate the JSON so it looks
    // like a snapshot from a different schema vintage and confirm it still parses.
    let net = small_net();
    let mut v: serde_json::Value = serde_json::from_str(&net.to_json().unwrap()).unwrap();

    // (a) an unknown future top-level field is ignored (deny_unknown_fields off).
    v["future_field_v5"] = serde_json::json!("ignored");
    // (b) a generator that omits caps entirely still parses (caps defaults empty).
    v["generators"][0].as_object_mut().unwrap().remove("caps");

    let text = serde_json::to_string(&v).unwrap();
    let parsed =
        Network::from_json(&text).expect("an unknown field and an omitted caps must still parse");
    assert_eq!(parsed.generators.len(), 1);
    assert!(
        !parsed.generators[0].has_caps(),
        "an omitted caps field defaults to the empty set"
    );
}

fn small_net() -> Network {
    let bus = |id, kind| Bus::new(BusId(id), kind, 230.0);
    // Length-agnostic: GEN_EXTRA_KEYS is pub(crate), so the integration crate
    // can't write `[None; GEN_EXTRA_KEYS.len()]`; `GenCaps::default()` tracks the
    // array length so this test still compiles when a capability column is added.
    let mut caps: GenCaps = GenCaps::default();
    caps[8] = Some(1.5); // ramp_30
    let mut g = Generator::new(BusId(1));
    g.pg = 10.0;
    g.pmax = 100.0;
    g.qmax = 50.0;
    g.qmin = -50.0;
    g.mbase = 100.0;
    g.caps = caps;
    let branch = Branch::new(BusId(1), BusId(2), 0.01, 0.1);
    let mut net = Network::new("schema_lock", 100.0);
    net.buses = vec![bus(1, BusType::Ref), bus(2, BusType::Pq)];
    net.branches = vec![branch];
    net.generators = vec![g];
    net.source_format = SourceFormat::InMemory;
    net
}

#[test]
fn uid_survives_snapshot_roundtrip_and_stays_off_the_wire_when_absent() {
    let mut net = small_net();
    net.generators[0].uid = Some("gen-a".to_owned());

    let v: serde_json::Value = serde_json::from_str(&net.to_json().unwrap()).unwrap();
    assert_eq!(v["generators"][0]["uid"], serde_json::json!("gen-a"));
    // A row without a uid omits the key instead of writing null, so snapshots
    // from parsers that never set it are byte-identical to the pre-uid vintage.
    assert!(v["buses"][0].get("uid").is_none());

    let parsed = Network::from_json(&serde_json::to_string(&v).unwrap()).unwrap();
    assert_eq!(parsed.generators[0].uid.as_deref(), Some("gen-a"));
    assert_eq!(parsed.buses[0].uid, None);
}

#[test]
fn geo_fields_roundtrip_and_stay_off_the_wire_when_absent() {
    let net = small_net();
    let text = net.to_json().unwrap();
    assert!(!text.contains(r#""geo""#));
    assert!(!text.contains(r#""location""#));
    let parsed = Network::from_json(&text).unwrap();
    assert_eq!(parsed.to_json().unwrap(), text);

    let v: serde_json::Value = serde_json::from_str(&text).unwrap();
    assert!(v.get("geo").is_none());
    assert!(v["buses"][0].get("location").is_none());

    let mut with_geo = net;
    with_geo.geo = Some(GeoMeta {
        space: CoordinateSpace::Geographic { crs: None },
        kind: Some(CoordsKind::Source),
    });
    with_geo.buses[0].location = Some(Location {
        x: -80.0,
        y: 35.0,
        kind: None,
    });

    let v: serde_json::Value = serde_json::from_str(&with_geo.to_json().unwrap()).unwrap();
    assert_eq!(
        v["geo"],
        serde_json::json!({"space": "geographic", "kind": "source"})
    );
    assert_eq!(v["buses"][0]["location"]["x"], serde_json::json!(-80.0));
    assert_eq!(v["buses"][0]["location"]["y"], serde_json::json!(35.0));

    let parsed = Network::from_json(&serde_json::to_string(&v).unwrap()).unwrap();
    assert_eq!(parsed.geo, with_geo.geo);
    assert_eq!(parsed.buses[0].location, with_geo.buses[0].location);
}