diff --git a/.gitattributes b/.gitattributes index 43db043..6614a95 100644 --- a/.gitattributes +++ b/.gitattributes @@ -7,6 +7,12 @@ tests/data/**/*.m linguist-vendored # Julia appears here only as a validation harness, not as the package language. benchmarks/**/*.jl linguist-vendored +powerio-dist/tools/pmd/*.jl linguist-vendored + +# Vendored distribution fixtures (BMOPF schema + example networks from +# frederikgeth/bmopf-report, OpenDSS test feeders): keep them out of the +# language stats and collapsed in diffs. +tests/data/dist/** linguist-vendored # Vendored case fixtures are byte-exact round-trip references: the `parse → write` # tests (e.g. powerio/tests/roundtrip.rs) compare the writer output to the file diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index e461682..a1e2012 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -56,11 +56,11 @@ jobs: # Deny the workspace pedantic lints on the non-extension crates. The PyO3 # ext crates are linted in the Python workflow with their feature enabled. - name: Clippy - run: cargo clippy --all-targets -p powerio -p powerio-matrix -p powerio-cli -p powerio-capi -- -D warnings - # Bare `cargo test` only covers powerio + powerio-matrix (the default-members); name - # the C ABI crate explicitly so its end-to-end ABI tests run in CI too. + run: cargo clippy --all-targets -p powerio -p powerio-matrix -p powerio-cli -p powerio-capi -p powerio-dist -- -D warnings + # Bare `cargo test` only covers the default-members; name the C ABI crate + # explicitly so its end-to-end ABI tests run in CI too. - name: Run tests - run: cargo test -p powerio -p powerio-matrix -p powerio-cli -p powerio-capi --verbose + run: cargo test -p powerio -p powerio-matrix -p powerio-cli -p powerio-capi -p powerio-dist --verbose # The gridfm Parquet export is behind a cargo feature; exercise it (and its # clippy) explicitly so coverage doesn't depend on CLI feature unification. - name: Clippy + tests (gridfm feature) @@ -127,3 +127,36 @@ jobs: run: | cargo test -p powerio-capi --features arrow --verbose cargo clippy -p powerio-capi --all-targets --features arrow -- -D warnings + + c-abi-dist: + name: C ABI (dist feature) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + - name: Build powerio-capi --features dist + run: cargo build -p powerio-capi --release --features dist + # The cfg-gated pio_dist_* entry points still appear in the source symbol + # grep, so they must be declared in the header (inside #ifdef PIO_DIST); + # parity holds. + - name: Header symbol parity + run: | + grep -oE 'extern "C" fn pio_[a-z_]+' powerio-capi/src/lib.rs \ + | grep -oE 'pio_[a-z_]+' | sort -u > rs_syms + grep -oE 'pio_[a-z_]+ *\(' powerio-capi/include/powerio.h \ + | grep -oE 'pio_[a-z_]+' | sort -u > h_syms + diff rs_syms h_syms + # Compile the smoke test with -DPIO_DIST so it exercises the distribution + # entry points against the dist-featured library. + - name: Compile and run the C smoke test (dist) + run: | + cc -DPIO_DIST -I powerio-capi/include powerio-capi/examples/smoke.c \ + -L target/release -lpowerio_capi -o pio_smoke_dist + LD_LIBRARY_PATH=target/release ./pio_smoke_dist tests/data/case9.m + - name: Tests + clippy (dist feature) + run: | + cargo test -p powerio-capi --features dist --verbose + cargo clippy -p powerio-capi --all-targets --features dist -- -D warnings diff --git a/.gitignore b/.gitignore index 358cc02..a55a12c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,11 +5,12 @@ .DS_Store .claude/ -# Python bindings (maturin / PyO3) +# Python bindings (maturin / PyO3). dist/ and build/ are anchored to the +# repo root: tests/data/dist holds fixtures, not build output. *.so *.pyd -dist/ -build/ +/dist/ +/build/ wheelhouse/ *.egg-info/ __pycache__/ @@ -17,6 +18,9 @@ __pycache__/ .venv/ tests/data/large/ +# Scratch Julia project for the PMD oracle; its Manifest pins local paths. +powerio-dist/tools/pmd/Manifest.toml + # Machine-readable bench output (render_tables.py reads it; numbers are per-machine) benchmarks/results/ diff --git a/Cargo.lock b/Cargo.lock index 1255707..0f8c00d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12,6 +12,7 @@ dependencies = [ "const-random", "getrandom 0.3.4", "once_cell", + "serde", "version_check", "zerocopy", ] @@ -323,7 +324,16 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" dependencies = [ - "bit-vec", + "bit-vec 0.6.3", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec 0.8.0", ] [[package]] @@ -332,6 +342,12 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "1.3.2" @@ -353,6 +369,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + [[package]] name = "bumpalo" version = "3.20.3" @@ -365,6 +387,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "bytemuck" version = "1.25.0" @@ -714,6 +742,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + [[package]] name = "deltae" version = "0.3.2" @@ -761,6 +795,17 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "document-features" version = "0.2.12" @@ -776,6 +821,15 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -807,10 +861,21 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" dependencies = [ - "bit-set", + "bit-set 0.5.3", "regex", ] +[[package]] +name = "fancy-regex" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e1dacd0d2082dfcf1351c4bdd566bbe89a2b263235a2b50058f1e130a47277" +dependencies = [ + "bit-set 0.8.0", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fast-srgb8" version = "1.0.0" @@ -868,6 +933,17 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + [[package]] name = "fnv" version = "1.0.7" @@ -886,6 +962,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "fraction" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872" +dependencies = [ + "lazy_static", + "num", +] + [[package]] name = "futures-core" version = "0.3.32" @@ -938,9 +1024,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1041,6 +1129,88 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "id-arena" version = "2.3.0" @@ -1053,6 +1223,27 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -1146,6 +1337,33 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonschema" +version = "0.46.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a5fe5206f06e589caf25e79fc05ccdf91fca745685fe9fe1a13bbdfb479a631" +dependencies = [ + "ahash", + "bytecount", + "data-encoding", + "email_address", + "fancy-regex 0.18.0", + "fraction", + "getrandom 0.3.4", + "idna", + "itoa", + "num-cmp", + "num-traits", + "percent-encoding", + "referencing", + "regex", + "regex-syntax", + "serde", + "serde_json", + "unicode-general-category", + "uuid-simd", +] + [[package]] name = "kasuari" version = "0.4.12" @@ -1259,6 +1477,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "litrs" version = "1.0.0" @@ -1339,6 +1563,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "micromap" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -1404,6 +1634,20 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -1414,6 +1658,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + [[package]] name = "num-complex" version = "0.4.6" @@ -1449,6 +1699,28 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1504,6 +1776,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "palette" version = "0.7.6" @@ -1585,6 +1863,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pest" version = "2.8.6" @@ -1713,6 +1997,15 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -1738,6 +2031,7 @@ version = "0.1.0" dependencies = [ "arrow", "powerio", + "powerio-dist", "powerio-matrix", "tempfile", ] @@ -1749,6 +2043,7 @@ dependencies = [ "anyhow", "clap", "crossterm", + "powerio-dist", "powerio-matrix", "ratatui", "sprs", @@ -1757,6 +2052,16 @@ dependencies = [ "walkdir", ] +[[package]] +name = "powerio-dist" +version = "0.1.0" +dependencies = [ + "jsonschema", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "powerio-matrix" version = "0.1.0" @@ -1781,6 +2086,7 @@ dependencies = [ name = "powerio-py" version = "0.1.0" dependencies = [ + "powerio-dist", "powerio-matrix", "pyo3", "sprs", @@ -2065,6 +2371,43 @@ dependencies = [ "bitflags 2.13.0", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "referencing" +version = "0.46.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69e4e17ef386c5383591d07623d3de49cbc601156e7582973e6db98d66a57de2" +dependencies = [ + "ahash", + "fluent-uri", + "getrandom 0.3.4", + "hashbrown 0.16.1", + "itoa", + "micromap", + "parking_lot", + "percent-encoding", + "serde_json", +] + [[package]] name = "regex" version = "1.12.3" @@ -2291,6 +2634,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "static_assertions" version = "1.1.0" @@ -2346,6 +2695,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "target-lexicon" version = "0.13.5" @@ -2395,7 +2755,7 @@ dependencies = [ "anyhow", "base64", "bitflags 2.13.0", - "fancy-regex", + "fancy-regex 0.11.0", "filedescriptor", "finl_unicode", "fixedbitset 0.4.2", @@ -2518,6 +2878,16 @@ dependencies = [ "crunchy", ] +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tinytemplate" version = "1.2.1" @@ -2607,6 +2977,12 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -2648,6 +3024,12 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -2666,6 +3048,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "valuable" version = "0.1.1" @@ -2678,6 +3070,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "vtparse" version = "0.6.2" @@ -3065,6 +3463,35 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.8.48" @@ -3085,6 +3512,60 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 83d04bc..3dc9b3f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,9 @@ [workspace] -members = ["powerio", "powerio-matrix", "powerio-cli", "powerio-py", "powerio-capi"] +members = ["powerio", "powerio-matrix", "powerio-cli", "powerio-py", "powerio-capi", "powerio-dist"] # Bare `cargo build`/`test`/`clippy` touch only the library + CLI crates, so the # toolchain never compiles the PyO3 extension (which needs libpython). Build the # binding explicitly with `-p powerio-py`. -default-members = ["powerio", "powerio-matrix", "powerio-cli"] +default-members = ["powerio", "powerio-matrix", "powerio-cli", "powerio-dist"] resolver = "2" # Single source for the release version and shared metadata; the crates inherit @@ -22,6 +22,7 @@ homepage = "https://eigenergy.github.io/powerio/" # them here means the pin moves with the [workspace.package] version. powerio = { path = "powerio", version = "0.1.0" } powerio-matrix = { path = "powerio-matrix", version = "0.1.0" } +powerio-dist = { path = "powerio-dist", version = "0.1.0" } # Cross-crate type identity: `CsMat` (sprs) and the Arrow types cross crate # boundaries, so every crate must build against the same major. sprs = { version = "0.11", default-features = false } diff --git a/README.md b/README.md index 274a4a6..97e5a46 100644 --- a/README.md +++ b/README.md @@ -31,10 +31,13 @@ The following formats are currently supported with read/write functionality: - [egret](https://pypi.org/project/gridx-egret/) `ModelData` JSON - [GridFM](https://github.com/gridfm) `.parquet` +Distribution networks are supported in wire coordinates via [`powerio-dist`](powerio-dist/): +- [OpenDSS](https://www.epri.com/pages/sa/opendss) `.dss` +- [PowerModelsDistribution.jl](https://github.com/lanl-ansi/PowerModelsDistribution.jl) ENGINEERING data JSON +- the draft [IEEE BMOPF task force](https://github.com/frederikgeth/bmopf-report) schema `.json` + Support for the following formats is under development (see the open pull requests): -- [surge](https://github.com/amptimal/surge) `.surge.json` -- [PowerModelsDistribution.jl](https://github.com/lanl-ansi/PowerModelsDistribution.jl) engineering data JSON -- [IEEE BMOPF](https://github.com/frederikgeth/bmopf-report) schema `.json` +- [surge](https://github.com/amptimal/surge) `.surge.json` Other formats are planned; see the GitHub issues. If a format you need is missing, open an issue or a pull request. All are welcome to contribute to this community project. @@ -45,6 +48,7 @@ This repository contains multiple packages. ``` powerio # parser, Network model, source retaining writers, converters powerio-matrix # sparse matrices, DC sensitivity factors, graph views +powerio-dist # multiconductor distribution model, dss/PMD/BMOPF converters powerio-cli # the `powerio` command and ratatui TUI powerio-py # PyO3 extension for the Python `powerio` package powerio-capi # C ABI for C, C++, Julia, and other foreign function interfaces @@ -128,7 +132,11 @@ powerio | egret JSON | partial | full | partial | partial | original text | `partial` means the target lacks fields present in the source. The writer reports -those cases in `Conversion::warnings`. +those cases in `Conversion::warnings`. +The distribution matrix (dss, PMD JSON, BMOPF JSON, per fixture) is generated into +[powerio-dist/docs/conversion-matrix.md](https://github.com/eigenergy/powerio/blob/main/powerio-dist/docs/conversion-matrix.md). +Vendored test data keeps its own licenses, documented next to the fixtures +(see [tests/data/dist/README.md](tests/data/dist/README.md)). GridFM Parquet is not in this table: both read and write are currently lossy. Known limits for every format are documented in diff --git a/docs/languages.md b/docs/languages.md index 313ccce..ceb9848 100644 --- a/docs/languages.md +++ b/docs/languages.md @@ -33,3 +33,19 @@ Verb taxonomy: **Note:** `pio_export_arrow` keeps `export` because it fills Arrow C Data Interface structs with release callbacks. It is not an owned string or handle return like the `to_*` functions. + +## Distribution surface (`powerio-dist`) + +The multiconductor distribution model follows the same taxonomy under its own +handle type; the two families do not mix. Julia bindings are not wired up yet +(tracked on the PowerIO.jl side). + +| Concept | Rust | Python | C ABI | +|---|---|---|---| +| Parse path | `powerio_dist::parse_file(path, from)` | `dist.parse_file(path, from_=None)` | `pio_dist_parse_file` | +| Parse text | `powerio_dist::parse_str(text, format)` | `dist.parse_str(text, format)` | `pio_dist_parse_str` | +| File conversion | `powerio_dist::convert_file(path, to, from)` | `dist.convert_file(path, to, from_=None)` | `pio_dist_convert_file` | +| Target format type | `DistTargetFormat` (`FromStr`, `name()`) | format name strings | format name strings | +| Text conversion | `powerio_dist::convert_str(text, to, format)` | `dist.convert_str(text, to, format)` | `pio_dist_convert_str` | +| Parsed conversion | `net.to_format(to)` | `case.to_format(to)` | `pio_dist_to_format` | +| Parse warnings | `net.warnings` | `case.warnings` | `pio_dist_warnings` | diff --git a/powerio-capi/Cargo.toml b/powerio-capi/Cargo.toml index 7e693a7..f85c42f 100644 --- a/powerio-capi/Cargo.toml +++ b/powerio-capi/Cargo.toml @@ -18,6 +18,9 @@ crate-type = ["cdylib", "staticlib", "rlib"] # Zero-copy raw network export over the Arrow C Data Interface (`pio_export_arrow`). # Off by default so the base ABI pulls in nothing but `powerio`. arrow = ["dep:arrow"] +# Multiconductor distribution formats (OpenDSS, PMD ENGINEERING JSON, BMOPF +# JSON): the pio_dist_* entry points. Off by default for the same reason. +dist = ["dep:powerio-dist"] # gridfm-datakit Parquet reader (`pio_read_gridfm` / `pio_gridfm_scenario_ids`). # Off by default so the base ABI pulls in nothing but `powerio`; enabling it pulls # in powerio-matrix (arrow + parquet) for the reader. @@ -25,6 +28,7 @@ gridfm = ["dep:powerio-matrix", "powerio-matrix/gridfm"] [dependencies] powerio.workspace = true +powerio-dist = { workspace = true, optional = true } # Only the C Data Interface (`ffi`) is needed; the heavy arrow defaults stay off. arrow = { workspace = true, features = ["ffi"], optional = true } # Pulled in only by the `gridfm` feature: the reader lives in powerio-matrix. diff --git a/powerio-capi/cbindgen.toml b/powerio-capi/cbindgen.toml index 6c73340..c68cd5b 100644 --- a/powerio-capi/cbindgen.toml +++ b/powerio-capi/cbindgen.toml @@ -31,6 +31,10 @@ header = """ * * Optional: build with `--features arrow` to get pio_export_arrow, a raw * network export over the Arrow C Data Interface (guarded by PIO_ARROW). + * Build with `--features dist` to get the pio_dist_* entry points (guarded by + * PIO_DIST): multiconductor distribution cases (OpenDSS, PMD ENGINEERING JSON, + * BMOPF JSON) behind their own PioDistNetwork handle, freed with + * pio_dist_network_free; their string outputs are freed with pio_string_free. * * Optional: build with `--features gridfm` to get pio_read_gridfm and * pio_gridfm_scenario_ids, the gridfm-datakit Parquet reader (guarded by PIO_GRIDFM). @@ -52,18 +56,19 @@ struct ArrowSchema; [export] prefix = "" -include = ["PioNetwork"] +include = ["PioNetwork", "PioDistNetwork"] [export.rename] "FFI_ArrowArray" = "struct ArrowArray" "FFI_ArrowSchema" = "struct ArrowSchema" -# Gate the optional Arrow export behind `#ifdef PIO_ARROW` (the `arrow` feature) -# and the optional gridfm reader behind `#ifdef PIO_GRIDFM` (the `gridfm` feature): -# cbindgen emits the cfg-gated symbols inside `#if defined(...)` even on a default -# (no-feature) generation, so the checked-in header always carries both blocks. +# Gate the optional features behind #ifdef (PIO_ARROW for `arrow`, PIO_DIST +# for `dist`, PIO_GRIDFM for `gridfm`): cbindgen emits the cfg-gated symbols +# inside `#if defined(...)` even on a default (no-feature) generation, so the +# checked-in header always carries every block. [defines] "feature = arrow" = "PIO_ARROW" +"feature = dist" = "PIO_DIST" "feature = gridfm" = "PIO_GRIDFM" [parse] diff --git a/powerio-capi/examples/smoke.c b/powerio-capi/examples/smoke.c index 0ae2110..33e49d7 100644 --- a/powerio-capi/examples/smoke.c +++ b/powerio-capi/examples/smoke.c @@ -123,6 +123,51 @@ int main(int argc, char **argv) { printf("parse_str + to_normalized OK\n"); } +#ifdef PIO_DIST + /* Distribution surface: parse an in-memory OpenDSS case, read its parse + * warnings, convert it to BMOPF JSON, and check the byte-exact dss echo. */ + { + const char *dss = + "clear\n" + "new circuit.smoke basekv=12.47 bus1=src\n" + "new line.l1 bus1=src bus2=b2 length=100 units=m\n" + "new load.d1 bus1=b2 kv=12.47 kw=50\n" + "solve\n"; + PioDistNetwork *d = pio_dist_parse_str(dss, "dss", err, sizeof err); + CHECK(d != NULL, err); + + char warn[1024]; + char *w = pio_dist_warnings(d, err, sizeof err); + CHECK(w != NULL, err); + pio_string_free(w); + + char *bmopf = pio_dist_to_format(d, "bmopf", warn, sizeof warn, err, sizeof err); + CHECK(bmopf != NULL, err); + CHECK(strstr(bmopf, "\"bus\"") != NULL, "BMOPF output lost the bus table"); + pio_string_free(bmopf); + + /* Same-format write echoes the retained source byte for byte. */ + char *echo2 = pio_dist_to_format(d, "dss", warn, sizeof warn, err, sizeof err); + CHECK(echo2 != NULL, err); + CHECK(strcmp(echo2, dss) == 0, "dss echo is not byte exact"); + pio_string_free(echo2); + pio_dist_network_free(d); + + /* One-shot string conversion into PMD ENGINEERING JSON; parameter + * order is input, target, source, like pio_dist_convert_file. */ + char *pmd = pio_dist_convert_str(dss, "pmd", "dss", warn, sizeof warn, err, sizeof err); + CHECK(pmd != NULL, err); + CHECK(strstr(pmd, "\"data_model\": \"ENGINEERING\"") != NULL, + "PMD output lost the data_model marker"); + pio_string_free(pmd); + + /* NULL handle is the documented safe default. */ + CHECK(pio_dist_warnings(NULL, err, sizeof err) == NULL, + "NULL dist handle did not return NULL"); + printf("dist surface OK\n"); + } +#endif + #ifdef PIO_ARROW /* Zero-copy Arrow C Data Interface export: pull the bus table, check the row * count, then release the producer-owned buffers. */ diff --git a/powerio-capi/include/powerio.h b/powerio-capi/include/powerio.h index 47ea0a0..3f4afa6 100644 --- a/powerio-capi/include/powerio.h +++ b/powerio-capi/include/powerio.h @@ -18,6 +18,10 @@ * * Optional: build with `--features arrow` to get pio_export_arrow, a raw * network export over the Arrow C Data Interface (guarded by PIO_ARROW). + * Build with `--features dist` to get the pio_dist_* entry points (guarded by + * PIO_DIST): multiconductor distribution cases (OpenDSS, PMD ENGINEERING JSON, + * BMOPF JSON) behind their own PioDistNetwork handle, freed with + * pio_dist_network_free; their string outputs are freed with pio_string_free. * * Optional: build with `--features gridfm` to get pio_read_gridfm and * pio_gridfm_scenario_ids, the gridfm-datakit Parquet reader (guarded by PIO_GRIDFM). @@ -78,6 +82,17 @@ struct ArrowSchema; #define PIO_ARROW_TABLE_SHUNT 4 #endif +#if defined(PIO_DIST) +/** + * Opaque multiconductor distribution network handle: a parsed OpenDSS, PMD + * ENGINEERING JSON, or BMOPF JSON case in wire coordinates. Distinct from + * [`PioNetwork`] (the positive sequence transmission model); none of the + * `pio_n_*`/extractor functions accept it. Only built with the `dist` cargo + * feature. + */ +typedef struct PioDistNetwork PioDistNetwork; +#endif + /** * Opaque parsed network handle. Carries the parsed [`Network`] plus the * [`IndexCore`] derived from it once at parse time, so every indexed query @@ -241,8 +256,7 @@ ptrdiff_t pio_gridfm_scenario_ids(const char *dir, /** * Free a string returned by [`pio_to_matpower`], [`pio_to_format`], - * [`pio_convert_file`], or - * [`pio_to_json`]. + * [`pio_convert_file`], [`pio_to_json`], or any `pio_dist_*` string output. */ void pio_string_free(char *s); @@ -329,6 +343,108 @@ int32_t pio_export_arrow(const PioNetwork *net, size_t errlen); #endif +#if defined(PIO_DIST) +/** + * Parse a distribution case file into a [`PioDistNetwork`] handle. The format + * comes from `from` if non-NULL (`dss`, `pmd`, or `bmopf`), else from the + * file itself: `.dss` is OpenDSS, and `.json` holding the ENGINEERING + * `data_model` key is PMD JSON, otherwise BMOPF JSON. Returns `NULL` on error + * and writes the message into `errbuf`. Free the handle with + * [`pio_dist_network_free`]. + */ +PioDistNetwork *pio_dist_parse_file(const char *path, + const char *from, + char *errbuf, + size_t errlen); +#endif + +#if defined(PIO_DIST) +/** + * Parse in-memory distribution case `text` of the named `format` (`dss`, + * `pmd`, or `bmopf`; required, since there is no path to infer from). An + * OpenDSS `Redirect`/`Compile` in `text` resolves against the current working + * directory. Returns `NULL` on error and writes the message into `errbuf`. + * Free the handle with [`pio_dist_network_free`]. + */ +PioDistNetwork *pio_dist_parse_str(const char *text, + const char *format, + char *errbuf, + size_t errlen); +#endif + +#if defined(PIO_DIST) +/** + * Free a distribution network handle from [`pio_dist_parse_file`] or + * [`pio_dist_parse_str`]. + */ +void pio_dist_network_free(PioDistNetwork *net); +#endif + +#if defined(PIO_DIST) +/** + * Parse warnings retained on the handle: everything the reader could not + * represent or had to assume (the loud half of the fidelity contract). + * Returns them `\n`-joined as an owned C string (free with + * [`pio_string_free`]; empty string when there are none), `NULL` on error. + * Warning text is unbounded, so this is an owned string, never a fixed + * caller buffer. + */ +char *pio_dist_warnings(const PioDistNetwork *net, char *errbuf, size_t errlen); +#endif + +#if defined(PIO_DIST) +/** + * Serialize `net` to distribution format `to` (`dss`, `pmd`, or `bmopf`). + * Writing back to the format the handle was parsed from echoes the source + * text byte for byte; a cross format write reports every fidelity loss in + * `warnbuf` (`\n`-joined). Returns the text as an owned C string (free with + * [`pio_string_free`]), `NULL` on error. + */ +char *pio_dist_to_format(const PioDistNetwork *net, + const char *to, + char *warnbuf, + size_t warnlen, + char *errbuf, + size_t errlen); +#endif + +#if defined(PIO_DIST) +/** + * Convert distribution case `path` to format `to` (optionally forcing the + * source via `from`; see [`pio_dist_parse_file`] for the inference rules). + * Returns the converted text as an owned C string (free with + * [`pio_string_free`]), `NULL` on error. The warnings written `\n`-joined + * into `warnbuf` carry both the parse warnings and the writer's fidelity + * losses (there is no handle to query them from). + */ +char *pio_dist_convert_file(const char *path, + const char *to, + const char *from, + char *warnbuf, + size_t warnlen, + char *errbuf, + size_t errlen); +#endif + +#if defined(PIO_DIST) +/** + * Convert in-memory distribution case `text` of format `from` to format + * `to` (both required; `dss`, `pmd`, or `bmopf`). The parameter order is + * input, target, source, matching [`pio_dist_convert_file`]. Returns the + * converted text as an owned C string (free with [`pio_string_free`]), + * `NULL` on error. The warnings written `\n`-joined into `warnbuf` carry + * both the parse warnings and the writer's fidelity losses (there is no + * handle to query them from). + */ +char *pio_dist_convert_str(const char *text, + const char *to, + const char *from, + char *warnbuf, + size_t warnlen, + char *errbuf, + size_t errlen); +#endif + #ifdef __cplusplus } // extern "C" #endif // __cplusplus diff --git a/powerio-capi/src/lib.rs b/powerio-capi/src/lib.rs index ec51966..fddd910 100644 --- a/powerio-capi/src/lib.rs +++ b/powerio-capi/src/lib.rs @@ -9,6 +9,10 @@ //! buffers (length = the matching `pio_n_*` count); pass `NULL` to skip one. //! //! Naming: every symbol is prefixed `pio_`. The header is `include/powerio.h`. +//! +//! The `dist` cargo feature adds the `pio_dist_*` entry points: multiconductor +//! distribution cases (OpenDSS, PMD ENGINEERING JSON, BMOPF JSON) behind their +//! own opaque [`PioDistNetwork`] handle. #![allow(clippy::missing_safety_doc)] @@ -87,27 +91,19 @@ unsafe fn guard(fallback: R, f: impl FnOnce() -> R) -> R { catch_unwind(AssertUnwindSafe(f)).unwrap_or(fallback) } -/// Box a `Network` into an owned network handle, building its [`IndexCore`] once so -/// every indexed query reuses it. The one constructor for `*mut PioNetwork`. -fn make_network(net: Network) -> *mut PioNetwork { - let core = IndexCore::build(&net); - Box::into_raw(Box::new(PioNetwork { net, core })) -} - -/// Finish a `*mut PioNetwork` entry point: run `f` (producing a `Network` or an -/// error message) under the panic guard, hand back an owned handle, or write the -/// error, `panic_msg` if `f` panicked, into `errbuf` and return NULL. The -/// shared tail of every handle-returning function (`pio_parse_file`, -/// `pio_parse_str`, `pio_to_normalized`, `pio_from_json`). -unsafe fn finish_network( +/// Finish a handle-returning entry point: run `f` (producing the handle +/// payload or an error message) under the panic guard and box the payload +/// into an owned handle, or write the error, `panic_msg` if `f` panicked, +/// into `errbuf` and return NULL. +unsafe fn finish_handle( errbuf: *mut c_char, errlen: usize, panic_msg: &str, - f: impl FnOnce() -> Result, -) -> *mut PioNetwork { + f: impl FnOnce() -> Result, +) -> *mut H { unsafe { match catch_unwind(AssertUnwindSafe(f)) { - Ok(Ok(net)) => make_network(net), + Ok(Ok(h)) => Box::into_raw(Box::new(h)), Ok(Err(msg)) => { copy_to_buf(errbuf, errlen, &msg); std::ptr::null_mut() @@ -120,6 +116,24 @@ unsafe fn finish_network( } } +/// [`finish_handle`] for `*mut PioNetwork` (`pio_parse_file`, `pio_parse_str`, +/// `pio_to_normalized`, `pio_from_json`): builds the [`IndexCore`] once at +/// parse time, under the same panic guard, so every indexed query reuses it. +unsafe fn finish_network( + errbuf: *mut c_char, + errlen: usize, + panic_msg: &str, + f: impl FnOnce() -> Result, +) -> *mut PioNetwork { + unsafe { + finish_handle(errbuf, errlen, panic_msg, || { + let net = f()?; + let core = IndexCore::build(&net); + Ok(PioNetwork { net, core }) + }) + } +} + /// ABI version of this C interface. Bump on any breaking change to an existing /// `pio_*` signature or to the JSON transport schema (new additive symbols don't /// require a bump). A consumer compares [`pio_abi_version`] against the value it @@ -152,7 +166,7 @@ pub extern "C" fn pio_version() -> *const c_char { } fn target_format_from_c(to: *const c_char) -> Result { - let to = unsafe { cstr(to) }.ok_or_else(|| "to is NULL or not UTF-8".to_string())?; + let to = required_cstr(to, "to")?; to.parse::().map_err(|e| e.to_string()) } @@ -166,6 +180,10 @@ fn optional_cstr<'a>(p: *const c_char, name: &str) -> Result, St } } +fn required_cstr<'a>(p: *const c_char, name: &str) -> Result<&'a str, String> { + unsafe { cstr(p) }.ok_or_else(|| format!("{name} is NULL or not UTF-8")) +} + /// Parse `path` (format from extension, or `from` if non-NULL) into a case /// handle. Returns `NULL` on error and writes the message into `errbuf`. #[unsafe(no_mangle)] @@ -177,7 +195,7 @@ pub unsafe extern "C" fn pio_parse_file( ) -> *mut PioNetwork { unsafe { finish_network(errbuf, errlen, "panic while parsing", || { - let path = cstr(path).ok_or_else(|| "path is NULL or not UTF-8".to_string())?; + let path = required_cstr(path, "path")?; let from = optional_cstr(from, "from")?; powerio::parse_file(std::path::Path::new(path), from).map_err(|e| e.to_string()) }) @@ -198,8 +216,8 @@ pub unsafe extern "C" fn pio_parse_str( ) -> *mut PioNetwork { unsafe { finish_network(errbuf, errlen, "panic while parsing", || { - let text = cstr(text).ok_or_else(|| "text is NULL or not UTF-8".to_string())?; - let format = cstr(format).ok_or_else(|| "format is NULL or not UTF-8".to_string())?; + let text = required_cstr(text, "text")?; + let format = required_cstr(format, "format")?; powerio::parse_str(text, format).map_err(|e| e.to_string()) }) } @@ -355,28 +373,20 @@ pub unsafe extern "C" fn pio_to_matpower( } } -/// Serialize `net` to format `to`. -/// -/// Returns the converted text as an owned C string (free with -/// [`pio_string_free`]), `NULL` on error. Fidelity warnings, if any, are written -/// `\n`-joined into `warnbuf`. -#[unsafe(no_mangle)] -pub unsafe extern "C" fn pio_to_format( - net: *const PioNetwork, - to: *const c_char, +/// Finish a converter entry point: run `f` (producing converted text plus +/// fidelity warnings) under the panic guard, write the warnings `\n`-joined +/// into `warnbuf`, and hand back the text as an owned C string; on error write +/// the message into `errbuf` and return NULL. The shared tail of every +/// text-returning converter. +unsafe fn finish_conversion( warnbuf: *mut c_char, warnlen: usize, errbuf: *mut c_char, errlen: usize, + f: impl FnOnce() -> Result<(String, Vec), String>, ) -> *mut c_char { unsafe { - let r = catch_unwind(AssertUnwindSafe(|| { - let c = network_ref(net).ok_or_else(|| "network handle is NULL".to_string())?; - let target = target_format_from_c(to)?; - let conv = c.net.to_format(target); - Ok::<_, String>((conv.text, conv.warnings)) - })); - match r { + match catch_unwind(AssertUnwindSafe(f)) { Ok(Ok((text, warnings))) => { copy_to_buf(warnbuf, warnlen, &warnings.join("\n")); finish_cstring(text, errbuf, errlen) @@ -393,6 +403,30 @@ pub unsafe extern "C" fn pio_to_format( } } +/// Serialize `net` to format `to`. +/// +/// Returns the converted text as an owned C string (free with +/// [`pio_string_free`]), `NULL` on error. Fidelity warnings, if any, are written +/// `\n`-joined into `warnbuf`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn pio_to_format( + net: *const PioNetwork, + to: *const c_char, + warnbuf: *mut c_char, + warnlen: usize, + errbuf: *mut c_char, + errlen: usize, +) -> *mut c_char { + unsafe { + finish_conversion(warnbuf, warnlen, errbuf, errlen, || { + let c = network_ref(net).ok_or_else(|| "network handle is NULL".to_string())?; + let target = target_format_from_c(to)?; + let conv = c.net.to_format(target); + Ok((conv.text, conv.warnings)) + }) + } +} + /// Convert `path` to format `to` (optionally forcing the source via `from`). /// Returns the converted text as an owned C string (free with /// [`pio_string_free`]), `NULL` on error. Fidelity warnings, if any, are written @@ -408,28 +442,14 @@ pub unsafe extern "C" fn pio_convert_file( errlen: usize, ) -> *mut c_char { unsafe { - let r = catch_unwind(AssertUnwindSafe(|| { - let path = cstr(path).ok_or_else(|| "path is NULL or not UTF-8".to_string())?; + finish_conversion(warnbuf, warnlen, errbuf, errlen, || { + let path = required_cstr(path, "path")?; let from = optional_cstr(from, "from")?; let target = target_format_from_c(to)?; let conv = powerio::convert_file(std::path::Path::new(path), target, from) .map_err(|e| e.to_string())?; - Ok::<_, String>((conv.text, conv.warnings)) - })); - match r { - Ok(Ok((text, warnings))) => { - copy_to_buf(warnbuf, warnlen, &warnings.join("\n")); - finish_cstring(text, errbuf, errlen) - } - Ok(Err(msg)) => { - copy_to_buf(errbuf, errlen, &msg); - std::ptr::null_mut() - } - Err(_) => { - copy_to_buf(errbuf, errlen, "panic while converting"); - std::ptr::null_mut() - } - } + Ok((conv.text, conv.warnings)) + }) } } @@ -451,25 +471,13 @@ pub unsafe extern "C" fn pio_read_gridfm( errlen: usize, ) -> *mut PioNetwork { unsafe { - let r = catch_unwind(AssertUnwindSafe(|| { - let dir = cstr(dir).ok_or_else(|| "dir is NULL or not UTF-8".to_string())?; - powerio_matrix::read_gridfm_dataset(std::path::Path::new(dir), scenario) - .map_err(|e| e.to_string()) - })); - match r { - Ok(Ok(read)) => { - copy_to_buf(warnbuf, warnlen, &read.warnings.join("\n")); - make_network(read.network) - } - Ok(Err(msg)) => { - copy_to_buf(errbuf, errlen, &msg); - std::ptr::null_mut() - } - Err(_) => { - copy_to_buf(errbuf, errlen, "panic while reading gridfm dataset"); - std::ptr::null_mut() - } - } + finish_network(errbuf, errlen, "panic while reading gridfm dataset", || { + let dir = required_cstr(dir, "dir")?; + let read = powerio_matrix::read_gridfm_dataset(std::path::Path::new(dir), scenario) + .map_err(|e| e.to_string())?; + copy_to_buf(warnbuf, warnlen, &read.warnings.join("\n")); + Ok(read.network) + }) } } @@ -514,8 +522,7 @@ pub unsafe extern "C" fn pio_gridfm_scenario_ids( } /// Free a string returned by [`pio_to_matpower`], [`pio_to_format`], -/// [`pio_convert_file`], or -/// [`pio_to_json`]. +/// [`pio_convert_file`], [`pio_to_json`], or any `pio_dist_*` string output. #[unsafe(no_mangle)] pub unsafe extern "C" fn pio_string_free(s: *mut c_char) { unsafe { @@ -568,7 +575,7 @@ pub unsafe extern "C" fn pio_from_json( ) -> *mut PioNetwork { unsafe { finish_network(errbuf, errlen, "panic while parsing JSON", || { - let json = cstr(json).ok_or_else(|| "json is NULL or not UTF-8".to_string())?; + let json = required_cstr(json, "json")?; Network::from_json(json).map_err(|e| e.to_string()) }) } @@ -758,6 +765,195 @@ pub unsafe extern "C" fn pio_export_arrow( } } +/// Opaque multiconductor distribution network handle: a parsed OpenDSS, PMD +/// ENGINEERING JSON, or BMOPF JSON case in wire coordinates. Distinct from +/// [`PioNetwork`] (the positive sequence transmission model); none of the +/// `pio_n_*`/extractor functions accept it. Only built with the `dist` cargo +/// feature. +#[cfg(feature = "dist")] +pub struct PioDistNetwork { + net: powerio_dist::DistNetwork, +} + +/// Parse a distribution case file into a [`PioDistNetwork`] handle. The format +/// comes from `from` if non-NULL (`dss`, `pmd`, or `bmopf`), else from the +/// file itself: `.dss` is OpenDSS, and `.json` holding the ENGINEERING +/// `data_model` key is PMD JSON, otherwise BMOPF JSON. Returns `NULL` on error +/// and writes the message into `errbuf`. Free the handle with +/// [`pio_dist_network_free`]. +#[cfg(feature = "dist")] +#[unsafe(no_mangle)] +pub unsafe extern "C" fn pio_dist_parse_file( + path: *const c_char, + from: *const c_char, + errbuf: *mut c_char, + errlen: usize, +) -> *mut PioDistNetwork { + unsafe { + finish_handle(errbuf, errlen, "panic while parsing", || { + let path = required_cstr(path, "path")?; + let from = optional_cstr(from, "from")?; + powerio_dist::parse_file(std::path::Path::new(path), from) + .map(|net| PioDistNetwork { net }) + .map_err(|e| e.to_string()) + }) + } +} + +/// Parse in-memory distribution case `text` of the named `format` (`dss`, +/// `pmd`, or `bmopf`; required, since there is no path to infer from). An +/// OpenDSS `Redirect`/`Compile` in `text` resolves against the current working +/// directory. Returns `NULL` on error and writes the message into `errbuf`. +/// Free the handle with [`pio_dist_network_free`]. +#[cfg(feature = "dist")] +#[unsafe(no_mangle)] +pub unsafe extern "C" fn pio_dist_parse_str( + text: *const c_char, + format: *const c_char, + errbuf: *mut c_char, + errlen: usize, +) -> *mut PioDistNetwork { + unsafe { + finish_handle(errbuf, errlen, "panic while parsing", || { + let text = required_cstr(text, "text")?; + let format = required_cstr(format, "format")?; + powerio_dist::parse_str(text, format) + .map(|net| PioDistNetwork { net }) + .map_err(|e| e.to_string()) + }) + } +} + +/// Free a distribution network handle from [`pio_dist_parse_file`] or +/// [`pio_dist_parse_str`]. +#[cfg(feature = "dist")] +#[unsafe(no_mangle)] +pub unsafe extern "C" fn pio_dist_network_free(net: *mut PioDistNetwork) { + unsafe { + if !net.is_null() { + drop(Box::from_raw(net)); + } + } +} + +/// Parse warnings retained on the handle: everything the reader could not +/// represent or had to assume (the loud half of the fidelity contract). +/// Returns them `\n`-joined as an owned C string (free with +/// [`pio_string_free`]; empty string when there are none), `NULL` on error. +/// Warning text is unbounded, so this is an owned string, never a fixed +/// caller buffer. +#[cfg(feature = "dist")] +#[unsafe(no_mangle)] +pub unsafe extern "C" fn pio_dist_warnings( + net: *const PioDistNetwork, + errbuf: *mut c_char, + errlen: usize, +) -> *mut c_char { + unsafe { + guard(std::ptr::null_mut(), || match net.as_ref() { + Some(c) => finish_cstring(c.net.warnings.join("\n"), errbuf, errlen), + None => { + copy_to_buf(errbuf, errlen, "network handle is NULL"); + std::ptr::null_mut() + } + }) + } +} + +/// Serialize `net` to distribution format `to` (`dss`, `pmd`, or `bmopf`). +/// Writing back to the format the handle was parsed from echoes the source +/// text byte for byte; a cross format write reports every fidelity loss in +/// `warnbuf` (`\n`-joined). Returns the text as an owned C string (free with +/// [`pio_string_free`]), `NULL` on error. +#[cfg(feature = "dist")] +#[unsafe(no_mangle)] +pub unsafe extern "C" fn pio_dist_to_format( + net: *const PioDistNetwork, + to: *const c_char, + warnbuf: *mut c_char, + warnlen: usize, + errbuf: *mut c_char, + errlen: usize, +) -> *mut c_char { + unsafe { + finish_conversion(warnbuf, warnlen, errbuf, errlen, || { + let c = net + .as_ref() + .ok_or_else(|| "network handle is NULL".to_string())?; + let target = dist_target_from_c(to)?; + let conv = c.net.to_format(target); + Ok((conv.text, conv.warnings)) + }) + } +} + +/// Convert distribution case `path` to format `to` (optionally forcing the +/// source via `from`; see [`pio_dist_parse_file`] for the inference rules). +/// Returns the converted text as an owned C string (free with +/// [`pio_string_free`]), `NULL` on error. The warnings written `\n`-joined +/// into `warnbuf` carry both the parse warnings and the writer's fidelity +/// losses (there is no handle to query them from). +#[cfg(feature = "dist")] +#[unsafe(no_mangle)] +pub unsafe extern "C" fn pio_dist_convert_file( + path: *const c_char, + to: *const c_char, + from: *const c_char, + warnbuf: *mut c_char, + warnlen: usize, + errbuf: *mut c_char, + errlen: usize, +) -> *mut c_char { + unsafe { + finish_conversion(warnbuf, warnlen, errbuf, errlen, || { + let path = required_cstr(path, "path")?; + let from = optional_cstr(from, "from")?; + let to = dist_target_from_c(to)?; + let conv = powerio_dist::convert_file(std::path::Path::new(path), to, from) + .map_err(|e| e.to_string())?; + Ok((conv.text, conv.warnings)) + }) + } +} + +/// Convert in-memory distribution case `text` of format `from` to format +/// `to` (both required; `dss`, `pmd`, or `bmopf`). The parameter order is +/// input, target, source, matching [`pio_dist_convert_file`]. Returns the +/// converted text as an owned C string (free with [`pio_string_free`]), +/// `NULL` on error. The warnings written `\n`-joined into `warnbuf` carry +/// both the parse warnings and the writer's fidelity losses (there is no +/// handle to query them from). +#[cfg(feature = "dist")] +#[unsafe(no_mangle)] +pub unsafe extern "C" fn pio_dist_convert_str( + text: *const c_char, + to: *const c_char, + from: *const c_char, + warnbuf: *mut c_char, + warnlen: usize, + errbuf: *mut c_char, + errlen: usize, +) -> *mut c_char { + unsafe { + finish_conversion(warnbuf, warnlen, errbuf, errlen, || { + let text = required_cstr(text, "text")?; + let to = dist_target_from_c(to)?; + let from = required_cstr(from, "from")?; + let conv = powerio_dist::convert_str(text, to, from).map_err(|e| e.to_string())?; + Ok((conv.text, conv.warnings)) + }) + } +} + +#[cfg(feature = "dist")] +fn dist_target_from_c(to: *const c_char) -> Result { + let to = required_cstr(to, "to")?; + // The message comes from the real error so it can't drift from what the + // powerio-dist dispatchers report for the same mistake. + to.parse::() + .map_err(|e| e.to_string()) +} + #[cfg(test)] mod tests { use super::*; @@ -1221,6 +1417,175 @@ mpc.branch = [ unsafe { pio_network_free(c) }; } + #[cfg(feature = "dist")] + mod dist { + use super::*; + use std::ffi::CStr; + + fn fourwire() -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../tests/data/dist/micro/fourwire_linecode.dss") + } + + fn fourwire_cstr() -> CString { + CString::new(fourwire().to_str().unwrap()).unwrap() + } + + #[test] + fn parse_file_convert_and_echo() { + let path = fourwire_cstr(); + let mut err = [0 as c_char; PIO_ERRBUF_MIN]; + let net = unsafe { + pio_dist_parse_file(path.as_ptr(), std::ptr::null(), err.as_mut_ptr(), err.len()) + }; + assert!( + !net.is_null(), + "{}", + unsafe { CStr::from_ptr(err.as_ptr()) }.to_str().unwrap() + ); + + // Cross format write: schema-shaped BMOPF JSON out. + let to = CString::new("bmopf").unwrap(); + let mut warn = [0 as c_char; 4096]; + let s = unsafe { + pio_dist_to_format( + net, + to.as_ptr(), + warn.as_mut_ptr(), + warn.len(), + err.as_mut_ptr(), + err.len(), + ) + }; + assert!(!s.is_null()); + let text = unsafe { CStr::from_ptr(s) }.to_str().unwrap(); + assert!(text.contains("\"bus\"")); + unsafe { pio_string_free(s) }; + + // Same format write echoes the retained source byte for byte. + let to = CString::new("dss").unwrap(); + let s = unsafe { + pio_dist_to_format( + net, + to.as_ptr(), + warn.as_mut_ptr(), + warn.len(), + err.as_mut_ptr(), + err.len(), + ) + }; + assert!(!s.is_null()); + let echoed = unsafe { CStr::from_ptr(s) }.to_str().unwrap(); + let source = std::fs::read_to_string(fourwire()).unwrap(); + assert_eq!(echoed, source); + assert_eq!( + unsafe { CStr::from_ptr(warn.as_ptr()) }.to_str().unwrap(), + "" + ); + unsafe { pio_string_free(s) }; + + unsafe { pio_dist_network_free(net) }; + } + + #[test] + fn convert_str_round_trips_through_pmd() { + let source = std::fs::read_to_string(fourwire()).unwrap(); + let text = CString::new(source).unwrap(); + let from = CString::new("dss").unwrap(); + let to = CString::new("pmd").unwrap(); + let mut warn = [0 as c_char; 4096]; + let mut err = [0 as c_char; PIO_ERRBUF_MIN]; + let s = unsafe { + pio_dist_convert_str( + text.as_ptr(), + to.as_ptr(), + from.as_ptr(), + warn.as_mut_ptr(), + warn.len(), + err.as_mut_ptr(), + err.len(), + ) + }; + assert!( + !s.is_null(), + "{}", + unsafe { CStr::from_ptr(err.as_ptr()) }.to_str().unwrap() + ); + let pmd = unsafe { CStr::from_ptr(s) }.to_str().unwrap(); + assert!(pmd.contains("\"data_model\": \"ENGINEERING\"")); + unsafe { pio_string_free(s) }; + } + + #[test] + fn warnings_report_count_and_text() { + // An unknown length unit draws a parse warning; the handle must + // surface it. + let text = CString::new( + "clear\nnew circuit.w basekv=12.47 bus1=src\nnew line.l1 bus1=src bus2=b2 length=1 units=furlong\n", + ) + .unwrap(); + let fmt = CString::new("dss").unwrap(); + let mut err = [0 as c_char; PIO_ERRBUF_MIN]; + let net = unsafe { + pio_dist_parse_str(text.as_ptr(), fmt.as_ptr(), err.as_mut_ptr(), err.len()) + }; + assert!(!net.is_null()); + let s = unsafe { pio_dist_warnings(net, err.as_mut_ptr(), err.len()) }; + assert!(!s.is_null()); + let msg = unsafe { CStr::from_ptr(s) }.to_str().unwrap(); + assert!( + msg.lines().any(|w| w.contains("furlong")), + "expected the units warning, got: {msg}" + ); + unsafe { pio_string_free(s) }; + assert!( + unsafe { pio_dist_warnings(std::ptr::null(), err.as_mut_ptr(), err.len()) } + .is_null() + ); + unsafe { pio_dist_network_free(net) }; + } + + #[test] + fn convert_file_round_trips_through_bmopf() { + let path = fourwire_cstr(); + let to = CString::new("bmopf-json").unwrap(); + let mut warn = [0 as c_char; 4096]; + let mut err = [0 as c_char; PIO_ERRBUF_MIN]; + let s = unsafe { + pio_dist_convert_file( + path.as_ptr(), + to.as_ptr(), + std::ptr::null(), + warn.as_mut_ptr(), + warn.len(), + err.as_mut_ptr(), + err.len(), + ) + }; + assert!( + !s.is_null(), + "{}", + unsafe { CStr::from_ptr(err.as_ptr()) }.to_str().unwrap() + ); + let text = unsafe { CStr::from_ptr(s) }.to_str().unwrap(); + assert!(text.contains("\"bus\"")); + unsafe { pio_string_free(s) }; + } + + #[test] + fn unknown_format_is_an_error_not_a_crash() { + let text = CString::new("clear\n").unwrap(); + let fmt = CString::new("matpower").unwrap(); + let mut err = [0 as c_char; PIO_ERRBUF_MIN]; + let net = unsafe { + pio_dist_parse_str(text.as_ptr(), fmt.as_ptr(), err.as_mut_ptr(), err.len()) + }; + assert!(net.is_null()); + let msg = unsafe { CStr::from_ptr(err.as_ptr()) }.to_str().unwrap(); + assert!(msg.contains("unknown distribution format")); + } + } + #[cfg(feature = "gridfm")] #[test] fn read_gridfm_round_trips_and_enumerates_scenarios() { diff --git a/powerio-cli/Cargo.toml b/powerio-cli/Cargo.toml index d4c61b2..57bae74 100644 --- a/powerio-cli/Cargo.toml +++ b/powerio-cli/Cargo.toml @@ -21,6 +21,9 @@ doc = false [dependencies] powerio-matrix = { workspace = true, features = ["gridfm"] } +# Unconditional: the CLI always ships the distribution surface (`convert +# --to dss/pmd-json/bmopf-json`); only the C ABI gates it behind a feature. +powerio-dist.workspace = true sprs.workspace = true anyhow = "1" clap = { version = "4", features = ["derive"] } diff --git a/powerio-cli/src/main.rs b/powerio-cli/src/main.rs index b5c1d46..4d893af 100644 --- a/powerio-cli/src/main.rs +++ b/powerio-cli/src/main.rs @@ -126,11 +126,14 @@ enum Command { #[arg(long, default_value_t = 0)] scenario: i64, }, - /// Convert a case file to another format through the neutral hub. + /// Convert a case file to another format. Transmission formats convert + /// through the neutral hub; distribution formats (dss, pmd-json, + /// bmopf-json) through the wire coordinate distribution model. The two + /// families do not mix. Convert { /// Input case file, or a gridfm dataset directory with `--from gridfm`. /// The format is inferred from the extension (`.m`, `.json`, `.raw`, - /// `.aux`) unless `--from` is given. + /// `.aux`, `.dss`) unless `--from` is given. input: PathBuf, /// Target format. #[arg(long, value_enum)] @@ -166,29 +169,50 @@ enum FormatArg { /// Read a gridfm-datakit Parquet dataset directory (read-only). #[value(name = "gridfm")] Gridfm, + #[value(name = "dss", alias = "opendss")] + Dss, + #[value(name = "pmd-json", alias = "pmd", alias = "engineering")] + PmdJson, + #[value(name = "bmopf-json", alias = "bmopf")] + BmopfJson, } impl FormatArg { - /// The write target this format maps to. `gridfm` has no convert-writer (use - /// the `gridfm` subcommand), so it errors here rather than silently misrouting. - fn to_target(self) -> anyhow::Result { + /// The writable transmission hub target: `None` for the distribution + /// formats and for gridfm, which has no convert writer (the `gridfm` + /// subcommand writes datasets). + fn transmission(self) -> Option { use powerio_matrix::TargetFormat; - Ok(match self { - FormatArg::Matpower => TargetFormat::Matpower, - FormatArg::PowerModelsJson => TargetFormat::PowerModelsJson, - FormatArg::EgretJson => TargetFormat::EgretJson, - FormatArg::Psse => TargetFormat::Psse, - FormatArg::PowerWorld => TargetFormat::PowerWorld, - FormatArg::Gridfm => anyhow::bail!( - "`convert` cannot write a gridfm dataset; use the `gridfm` subcommand" - ), - }) + match self { + FormatArg::Matpower => Some(TargetFormat::Matpower), + FormatArg::PowerModelsJson => Some(TargetFormat::PowerModelsJson), + FormatArg::EgretJson => Some(TargetFormat::EgretJson), + FormatArg::Psse => Some(TargetFormat::Psse), + FormatArg::PowerWorld => Some(TargetFormat::PowerWorld), + FormatArg::Gridfm | FormatArg::Dss | FormatArg::PmdJson | FormatArg::BmopfJson => None, + } } - /// The canonical format name. For the five classical formats this is the name - /// `target_format_from_name` accepts, used to force a text reader; `gridfm` is - /// parquet-only and never routes through that hub (the callers guard it first), - /// so its name is for diagnostics only. + /// The distribution target, or `None` outside that family. For every + /// writable format exactly one of this and [`FormatArg::transmission`] + /// is `Some`, so adding one without wiring its family is a compile + /// error; gridfm is read only and returns `None` from both. + fn distribution(self) -> Option { + use powerio_dist::DistTargetFormat; + match self { + FormatArg::Dss => Some(DistTargetFormat::Dss), + FormatArg::PmdJson => Some(DistTargetFormat::PmdJson), + FormatArg::BmopfJson => Some(DistTargetFormat::BmopfJson), + FormatArg::Matpower + | FormatArg::PowerModelsJson + | FormatArg::EgretJson + | FormatArg::Psse + | FormatArg::PowerWorld + | FormatArg::Gridfm => None, + } + } + + /// The canonical name the format dispatchers accept, for forcing a reader. fn name(self) -> &'static str { match self { FormatArg::Matpower => "matpower", @@ -197,6 +221,9 @@ impl FormatArg { FormatArg::Psse => "psse", FormatArg::PowerWorld => "powerworld", FormatArg::Gridfm => "gridfm", + FormatArg::Dss => "dss", + FormatArg::PmdJson => "pmd-json", + FormatArg::BmopfJson => "bmopf-json", } } } @@ -588,41 +615,129 @@ fn run_convert( from: Option, scenario: i64, ) -> anyhow::Result<()> { - let target = to.to_target()?; - // gridfm reads a Parquet dataset directory (the parquet-free `parse_file` - // can't), so it routes through powerio-matrix's reader, surfacing its fidelity - // notes. - let net = if from == Some(FormatArg::Gridfm) { - let read = powerio_matrix::read_gridfm_dataset(input, scenario) - .with_context(|| format!("reading gridfm dataset {}", input.display()))?; - for w in &read.warnings { - eprintln!("fidelity: {w}"); - } - read.network + // gridfm has no convert writer; the dataset writer is the `gridfm` + // subcommand. + if matches!(to, FormatArg::Gridfm) { + anyhow::bail!("`convert` cannot write a gridfm dataset; use the `gridfm` subcommand"); + } + // The two families share no conversion path; say so directly instead of + // letting the wrong family's reader produce a confusing format error. The + // input family comes from --from (gridfm reads into the transmission + // model), or from an unambiguous extension (.json is shared, so it stays + // undecided and the reader sniffs it). + let input_is_dist = from + .map(|f| !matches!(f, FormatArg::Gridfm) && f.transmission().is_none()) + .or_else(|| { + match input + .extension() + .and_then(|e| e.to_str()) + .map(str::to_ascii_lowercase) + .as_deref() + { + Some("m" | "raw" | "aux") => Some(false), + Some("dss") => Some(true), + _ => None, + } + }); + if input_is_dist.is_some_and(|dist| dist != to.transmission().is_none()) { + anyhow::bail!( + "no conversion path between the transmission and distribution format families \ + ({} to `{}`)", + from.map_or_else( + || format!("`{}` input", input.display()), + |f| format!("`{}`", f.name()) + ), + to.name() + ); + } + let (text, warnings) = if let Some(target) = to.transmission() { + // gridfm reads a Parquet dataset directory (the parquet-free + // `parse_file` can't), so it routes through powerio-matrix's reader, + // surfacing its fidelity notes. + let net = if matches!(from, Some(FormatArg::Gridfm)) { + let read = powerio_matrix::read_gridfm_dataset(input, scenario) + .with_context(|| format!("reading gridfm dataset {}", input.display()))?; + for w in &read.warnings { + eprintln!("fidelity: {w}"); + } + read.network + } else { + // A .json input is undecided above. When the transmission reader + // rejects it but the distribution reader accepts it, the input is a + // distribution case aimed at a transmission target: say so instead + // of presenting the transmission parse error as the problem. + read_network(input, from).map_err(|err| { + // The liberal BMOPF reader accepts almost any JSON, so a bare + // parse success is no signal; a typed voltage source is (both + // distribution layouts carry one, transmission JSON does not). + if from.is_none() + && input + .extension() + .is_some_and(|e| e.eq_ignore_ascii_case("json")) + && powerio_dist::parse_file(input, None).is_ok_and(|n| !n.sources.is_empty()) + { + anyhow::anyhow!( + "no conversion path between the transmission and distribution format \ + families (`{}` is a distribution case, `{}` is a transmission format)", + input.display(), + to.name() + ) + } else { + err + } + })? + }; + let conv = powerio_matrix::write_as(&net, target); + (conv.text, conv.warnings) } else { - read_network(input, from)? + let net = powerio_dist::parse_file(input, from.map(FormatArg::name)) + .with_context(|| format!("reading {}", input.display()))?; + for w in &net.warnings { + eprintln!("parse: {w}"); + } + let target = to + .distribution() + .expect("the family check routed a transmission target here"); + let conv = net.to_format(target); + (conv.text, conv.warnings) }; - let conv = powerio_matrix::write_as(&net, target); - for w in &conv.warnings { + for w in &warnings { eprintln!("fidelity: {w}"); } match output { Some(p) if p.as_os_str() != "-" => { - std::fs::write(p, &conv.text).with_context(|| format!("writing {}", p.display()))?; + std::fs::write(p, &text).with_context(|| format!("writing {}", p.display()))?; eprintln!("wrote {}", p.display()); } - _ => print!("{}", conv.text), + _ => print!("{text}"), } Ok(()) } /// Read `input` into the neutral [`powerio_matrix::Network`] through the shared /// format hub, which picks the reader from `from` or the extension (sniffing a -/// `.json` for the egret vs PowerModels shape). +/// `.json` for the egret vs PowerModels shape). Distribution formats are +/// rejected up front: every caller of this function consumes the transmission +/// model, and clap can't express the restriction on the shared `FormatArg`. fn read_network( input: &std::path::Path, from: Option, ) -> anyhow::Result { + if let Some(f) = from { + if matches!(f, FormatArg::Gridfm) { + anyhow::bail!( + "gridfm datasets are read by `convert --from gridfm` or the `gridfm` \ + subcommand, not this command" + ); + } + if f.transmission().is_none() { + anyhow::bail!( + "`{}` is a distribution format; this command reads transmission cases \ + (matpower, powermodels-json, egret-json, psse, powerworld)", + f.name() + ); + } + } powerio_matrix::parse_file(input, from.map(FormatArg::name)) .with_context(|| format!("reading {}", input.display())) } diff --git a/powerio-dist/Cargo.toml b/powerio-dist/Cargo.toml new file mode 100644 index 0000000..eaa5213 --- /dev/null +++ b/powerio-dist/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "powerio-dist" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +description = "Multiconductor distribution network model and lossless converters for OpenDSS, PMD JSON, and BMOPF JSON." +readme = "README.md" +license.workspace = true +repository.workspace = true +homepage.workspace = true +documentation = "https://docs.rs/powerio-dist" +keywords = ["opendss", "distribution", "parser", "lossless"] +categories = ["science", "parsing"] + +[package.metadata.docs.rs] +all-features = true + +[dependencies] +thiserror = "2" +serde.workspace = true +# float_roundtrip: the default float parser can be one ULP off the shortest +# representation its own serializer prints; the round trip contracts need +# parse(print(x)) == x exactly. +serde_json = { workspace = true, features = ["float_roundtrip"] } + +[dev-dependencies] +# Draft 2020-12 validation against the vendored BMOPF schema in tests. +jsonschema = { version = "0.46", default-features = false } + +[lints] +workspace = true diff --git a/powerio-dist/README.md b/powerio-dist/README.md new file mode 100644 index 0000000..2148af5 --- /dev/null +++ b/powerio-dist/README.md @@ -0,0 +1,36 @@ +# powerio-dist + +`powerio-dist` parses multiconductor distribution network cases into a typed +model in wire coordinates and converts between OpenDSS `.dss`, +PowerModelsDistribution ENGINEERING JSON, and the draft BMOPF schema from the +IEEE PES Task Force on Benchmarking Multiconductor OPF +(). + +Writing back to the source format reproduces the file byte for byte; every +cross format conversion reports each field the target cannot represent in its +warnings. The dss reader materializes every OpenDSS class default into an +explicit model value (verified against the OpenDSS source and empirically +against `opendssdirect`) and records which fields were defaulted, so BMOPF +output is always fully explicit. The per fixture conversion matrix is +generated into [docs/conversion-matrix.md](docs/conversion-matrix.md). + +```rust +let net = powerio_dist::parse_file("feeder.dss", None)?; +let pmd = net.to_format(powerio_dist::DistTargetFormat::PmdJson); +for w in &pmd.warnings { + eprintln!("fidelity: {w}"); +} +``` + +The same surface is available from the `powerio` CLI +(`powerio convert feeder.dss --to pmd-json`), the Python package +(`powerio.dist`), and the C ABI (`pio_dist_*`, behind the `dist` cargo +feature of `powerio-capi`). + +Fixtures live in `tests/data/dist/` at the workspace root with provenance +recorded in its README. The oracle harnesses under `tools/` re-solve emitted +`.dss` in OpenDSS and validate emitted PMD JSON against +PowerModelsDistribution; CI runs the schema validation and round trip suites. + +The workspace README covers the CLI, Python package, C ABI, and the +transmission crates: . diff --git a/powerio-dist/docs/conversion-matrix.md b/powerio-dist/docs/conversion-matrix.md new file mode 100644 index 0000000..1326819 --- /dev/null +++ b/powerio-dist/docs/conversion-matrix.md @@ -0,0 +1,22 @@ +# Conversion matrix + +Generated by `cargo test -p powerio-dist --test matrix -- --ignored write_conversion_matrix`. Rows are fixtures (tests/data/dist, provenance in its README); columns are conversion targets. `echo` is the byte exact diagonal; `ok` is a canonical write that reparses to the common projection of the model; `ok (n warn)` names the count of fidelity losses the conversion reports, each one listed in the conversion's warnings. + +| fixture | source | → dss | → BMOPF | → PMD | +|---|---|---|---|---| +| IEEE 13 | dss | echo | ok (143 warn) | ok (44 warn) | +| IEEE 34 | dss | echo | ok (374 warn) | ok (218 warn) | +| IEEE 123 | dss | echo | ok (543 warn) | ok (266 warn) | +| single phase transformer | dss | echo | ok (10 warn) | ok (4 warn) | +| center tap transformer | dss | echo | ok (23 warn) | ok (12 warn) | +| wye delta transformer | dss | echo | ok (10 warn) | ok (4 warn) | +| delta wye transformer | dss | echo | ok (10 warn) | ok (4 warn) | +| switch states | dss | echo | ok (12 warn) | ok (4 warn) | +| four wire linecode | dss | echo | ok (25 warn) | ok (12 warn) | +| constructor defaults | dss | echo | ok (9 warn) | ok (2 warn) | +| ten conductor linecode | dss | echo | ok (25 warn) | ok (12 warn) | +| BMOPF IEEE 13 example | BMOPF | ok (1 warn) | echo | ok | +| BMOPF ENWL example | BMOPF | ok (1035 warn) | echo | ok (1019 warn) | +| PMD IEEE 13 | PMD | ok (9 warn) | ok (152 warn) | echo | +| PMD four wire | PMD | ok (1 warn) | ok (8 warn) | echo | + diff --git a/powerio-dist/src/bmopf/mod.rs b/powerio-dist/src/bmopf/mod.rs new file mode 100644 index 0000000..3cef47d --- /dev/null +++ b/powerio-dist/src/bmopf/mod.rs @@ -0,0 +1,13 @@ +//! The draft BMOPF task force JSON schema (frederikgeth/bmopf-report). +//! +//! Everything is explicit SI: volts, watts, vars, ohms, siemens, meters, +//! radians, string bus ids and terminal names. The schema sets +//! `additionalProperties: false` on every element, so the strict writer +//! drops what the schema cannot carry and says so per field; the dropped +//! data stays in the model's `extras`, never in the emitted JSON. + +mod read; +mod write; + +pub use read::{parse_bmopf_file, parse_bmopf_str}; +pub use write::write_bmopf_json; diff --git a/powerio-dist/src/bmopf/read.rs b/powerio-dist/src/bmopf/read.rs new file mode 100644 index 0000000..d382d3e --- /dev/null +++ b/powerio-dist/src/bmopf/read.rs @@ -0,0 +1,597 @@ +//! BMOPF JSON into the canonical [`DistNetwork`]. +//! +//! The format is fully explicit, so the reader materializes nothing and +//! `defaulted` stays empty. Reading is liberal where writing is strict: +//! fields outside the schema land in the element's `extras` with a warning +//! instead of failing the parse. Transformer subtypes become windings; the +//! subtype rides in the transformer's extras (`bmopf_subtype`) so writing +//! back reproduces the same grouping for shapes the windings alone do not +//! pin down (center tap reads as two windings). + +use std::path::Path; +use std::sync::Arc; + +use serde_json::{Map, Value}; + +use crate::error::{Error, Result}; +use crate::model::{ + Configuration, DistBus, DistGenerator, DistLine, DistLineCode, DistLoad, DistNetwork, + DistShunt, DistSourceFormat, DistSwitch, DistTransformer, Extras, Mat, UntypedObject, + VoltageSource, Winding, WindingConn, +}; + +pub fn parse_bmopf_file(path: impl AsRef) -> Result { + let path = path.as_ref(); + let text = std::fs::read_to_string(path).map_err(|source| Error::Io { + path: path.display().to_string(), + source, + })?; + parse_bmopf_str(&text) +} + +pub fn parse_bmopf_str(text: &str) -> Result { + let doc: Value = serde_json::from_str(text).map_err(|e| Error::Json { + format: "BMOPF", + message: e.to_string(), + })?; + let Value::Object(doc) = doc else { + return Err(Error::Json { + format: "BMOPF", + message: "top level is not an object".into(), + }); + }; + let mut net = DistNetwork { + source: Some(Arc::new(text.to_string())), + source_format: Some(DistSourceFormat::BmopfJson), + base_frequency: 60.0, + ..DistNetwork::default() + }; + let mut rd = Reader { net: &mut net }; + rd.document(&doc); + Ok(net) +} + +struct Reader<'a> { + net: &'a mut DistNetwork, +} + +fn f(v: &Value) -> f64 { + v.as_f64().unwrap_or(f64::NAN) +} + +fn floats(v: Option<&Value>) -> Option> { + v?.as_array().map(|a| a.iter().map(f).collect()) +} + +fn strings(v: Option<&Value>) -> Vec { + v.and_then(Value::as_array) + .map(|a| { + a.iter() + .map(|s| s.as_str().unwrap_or_default().to_string()) + .collect() + }) + .unwrap_or_default() +} + +fn string(v: Option<&Value>) -> String { + v.and_then(Value::as_str).unwrap_or_default().to_string() +} + +/// Case insensitive on the recognized values (the dss reader's tolerance); +/// a present but unrecognized string warns and reads as WYE. +fn config(v: Option<&Value>, what: &str, warnings: &mut Vec) -> Configuration { + let Some(s) = v.and_then(Value::as_str) else { + return Configuration::Wye; + }; + match s.to_ascii_uppercase().as_str() { + "WYE" => Configuration::Wye, + "DELTA" => Configuration::Delta, + "SINGLE_PHASE" => Configuration::SinglePhase, + _ => { + warnings.push(format!( + "{what}: configuration `{s}` is not WYE, DELTA, or SINGLE_PHASE; read as WYE" + )); + Configuration::Wye + } + } +} + +/// Parses the `_i_j` tail of a `prefix_i_j` matrix key (1 based). None +/// when the key is not a well formed entry for this prefix. +fn matrix_indices(key: &str, prefix: &str) -> Option<(usize, usize)> { + let rest = key.strip_prefix(prefix)?.strip_prefix('_')?; + let (i, j) = rest.split_once('_')?; + let (i, j) = (i.parse::().ok()?, j.parse::().ok()?); + (i >= 1 && j >= 1).then_some((i, j)) +} + +/// Collects `prefix_i_j` keys into a square matrix; `n` is the largest +/// index seen. Returns None when no key carries the prefix. +fn flat_matrix(o: &Map, prefix: &str) -> Option { + let mut entries: Vec<(usize, usize, f64)> = Vec::new(); + let mut n = 0; + for (k, v) in o { + let Some((i, j)) = matrix_indices(k, prefix) else { + continue; + }; + entries.push((i - 1, j - 1, f(v))); + n = n.max(i).max(j); + } + if n == 0 { + return None; + } + let mut m = vec![vec![0.0; n]; n]; + for (i, j, v) in entries { + m[i][j] = v; + } + Some(m) +} + +/// Grows `m` to `n` by `n`, preserving the existing entries. +fn pad_to(m: Mat, n: usize) -> Mat { + if m.len() >= n { + return m; + } + let mut out = vec![vec![0.0; n]; n]; + for (i, row) in m.into_iter().enumerate() { + for (j, v) in row.into_iter().enumerate() { + out[i][j] = v; + } + } + out +} + +/// Element fields outside `known` go to extras with a warning. +fn take_extras( + o: &Map, + known: &[&str], + what: &str, + warnings: &mut Vec, + matrix_prefixes: &[&str], +) -> Extras { + let mut extras = Extras::new(); + for (k, v) in o { + if known.contains(&k.as_str()) { + continue; + } + if matrix_prefixes + .iter() + .any(|p| matrix_indices(k, p).is_some()) + { + continue; + } + warnings.push(format!( + "{what}: `{k}` is outside the schema; kept in extras" + )); + extras.insert(k.clone(), v.clone()); + } + extras +} + +impl Reader<'_> { + fn document(&mut self, doc: &Map) { + if let Some(name) = doc.get("name").and_then(Value::as_str) { + self.net.name = Some(name.to_string()); + } + for (key, value) in doc { + let Value::Object(items) = value else { + continue; + }; + match key.as_str() { + "bus" => self.buses(items), + "linecode" => self.linecodes(items), + "line" => self.lines(items), + "switch" => self.switches(items), + "load" => self.loads(items), + "generator" => self.generators(items), + "shunt" => self.shunts(items), + "voltage_source" => self.sources(items), + "transformer" => self.transformers(items), + "name" => {} + other => { + self.net.warnings.push(format!( + "top level `{other}` is outside the schema; kept untyped" + )); + for (name, v) in items { + self.net.untyped.push(UntypedObject { + class: other.to_string(), + name: name.clone(), + props: vec![(None, v.to_string())], + }); + } + } + } + } + } + + fn buses(&mut self, items: &Map) { + for (id, v) in items { + let Value::Object(o) = v else { continue }; + let known = [ + "terminal_names", + "perfectly_grounded_terminals", + "v_min", + "v_max", + "vpn_min", + "vpn_max", + "vpp_min", + "vpp_max", + "vsym_min", + "vsym_max", + ]; + self.net.buses.push(DistBus { + id: id.clone(), + terminals: strings(o.get("terminal_names")), + grounded: strings(o.get("perfectly_grounded_terminals")), + v_min: o.get("v_min").map(f), + v_max: o.get("v_max").map(f), + vpn_min: floats(o.get("vpn_min")), + vpn_max: floats(o.get("vpn_max")), + vpp_min: floats(o.get("vpp_min")), + vpp_max: floats(o.get("vpp_max")), + vsym_min: floats(o.get("vsym_min")), + vsym_max: floats(o.get("vsym_max")), + extras: take_extras(o, &known, &format!("bus {id}"), &mut self.net.warnings, &[]), + }); + } + } + + fn linecodes(&mut self, items: &Map) { + for (name, v) in items { + let Value::Object(o) = v else { continue }; + let mats = [ + flat_matrix(o, "R_series"), + flat_matrix(o, "X_series"), + flat_matrix(o, "G_from"), + flat_matrix(o, "B_from"), + flat_matrix(o, "G_to"), + flat_matrix(o, "B_to"), + ]; + // Conductor count is the widest matrix present; absent matrices + // read as zero, smaller ones pad without losing entries. + let n = mats.iter().flatten().map(Vec::len).max().unwrap_or(0); + if mats.iter().flatten().any(|m| m.len() < n) { + self.net.warnings.push(format!( + "linecode {name}: matrix sizes disagree; smaller ones padded \ + with zeros to {n}x{n}" + )); + } + let [r, x, gf, bf, gt, bt] = mats.map(|m| pad_to(m.unwrap_or_default(), n)); + let code = DistLineCode { + name: name.clone(), + n_conductors: n, + r_series: r, + x_series: x, + g_from: gf, + b_from: bf, + g_to: gt, + b_to: bt, + i_max: floats(o.get("i_max")), + s_max: floats(o.get("s_max")), + extras: take_extras( + o, + &["i_max", "s_max"], + &format!("linecode {name}"), + &mut self.net.warnings, + &["R_series", "X_series", "G_from", "G_to", "B_from", "B_to"], + ), + }; + self.net.linecodes.push(code); + } + } + + fn lines(&mut self, items: &Map) { + for (name, v) in items { + let Value::Object(o) = v else { continue }; + let known = [ + "length", + "linecode", + "bus_from", + "bus_to", + "terminal_map_from", + "terminal_map_to", + ]; + self.net.lines.push(DistLine { + name: name.clone(), + bus_from: string(o.get("bus_from")), + bus_to: string(o.get("bus_to")), + terminal_map_from: strings(o.get("terminal_map_from")), + terminal_map_to: strings(o.get("terminal_map_to")), + linecode: string(o.get("linecode")), + length: o.get("length").map_or(f64::NAN, f), + extras: take_extras( + o, + &known, + &format!("line {name}"), + &mut self.net.warnings, + &[], + ), + }); + } + } + + fn switches(&mut self, items: &Map) { + for (name, v) in items { + let Value::Object(o) = v else { continue }; + let known = [ + "bus_from", + "bus_to", + "terminal_map_from", + "terminal_map_to", + "open_switch", + "i_max", + ]; + self.net.switches.push(DistSwitch { + name: name.clone(), + bus_from: string(o.get("bus_from")), + bus_to: string(o.get("bus_to")), + terminal_map_from: strings(o.get("terminal_map_from")), + terminal_map_to: strings(o.get("terminal_map_to")), + open: o + .get("open_switch") + .and_then(Value::as_bool) + .unwrap_or(false), + i_max: floats(o.get("i_max")), + extras: take_extras( + o, + &known, + &format!("switch {name}"), + &mut self.net.warnings, + &[], + ), + }); + } + } + + fn loads(&mut self, items: &Map) { + for (name, v) in items { + let Value::Object(o) = v else { continue }; + let known = ["p_nom", "q_nom", "bus", "configuration", "terminal_map"]; + self.net.loads.push(DistLoad { + name: name.clone(), + bus: string(o.get("bus")), + terminal_map: strings(o.get("terminal_map")), + configuration: config( + o.get("configuration"), + &format!("load {name}"), + &mut self.net.warnings, + ), + p_nom: floats(o.get("p_nom")).unwrap_or_default(), + q_nom: floats(o.get("q_nom")).unwrap_or_default(), + extras: take_extras( + o, + &known, + &format!("load {name}"), + &mut self.net.warnings, + &[], + ), + }); + } + } + + fn generators(&mut self, items: &Map) { + for (name, v) in items { + let Value::Object(o) = v else { continue }; + let known = [ + "p_min", + "p_max", + "q_min", + "q_max", + "cost", + "bus", + "configuration", + "terminal_map", + ]; + let p_min = floats(o.get("p_min")); + let p_max = floats(o.get("p_max")); + let q_min = floats(o.get("q_min")); + let q_max = floats(o.get("q_max")); + // Pinned bounds are a fixed dispatch; surface them as the + // setpoint too so a power flow oriented target has one. + let pinned = |lo: &Option>, hi: &Option>| match (lo, hi) { + (Some(a), Some(b)) if a == b => a.clone(), + _ => Vec::new(), + }; + self.net.generators.push(DistGenerator { + name: name.clone(), + bus: string(o.get("bus")), + terminal_map: strings(o.get("terminal_map")), + configuration: config( + o.get("configuration"), + &format!("generator {name}"), + &mut self.net.warnings, + ), + p_nom: pinned(&p_min, &p_max), + q_nom: pinned(&q_min, &q_max), + p_min, + p_max, + q_min, + q_max, + cost: o.get("cost").map(f), + extras: take_extras( + o, + &known, + &format!("generator {name}"), + &mut self.net.warnings, + &[], + ), + }); + } + } + + fn shunts(&mut self, items: &Map) { + for (name, v) in items { + let Value::Object(o) = v else { continue }; + let g = flat_matrix(o, "G").unwrap_or_default(); + let b = flat_matrix(o, "B").unwrap_or_default(); + let n = g.len().max(b.len()); + if g.len() != b.len() { + self.net.warnings.push(format!( + "shunt {name}: G is {gx}x{gx} but B is {bx}x{bx}; the smaller \ + padded with zeros to {n}x{n}", + gx = g.len(), + bx = b.len(), + )); + } + self.net.shunts.push(DistShunt { + name: name.clone(), + bus: string(o.get("bus")), + terminal_map: strings(o.get("terminal_map")), + g: pad_to(g, n), + b: pad_to(b, n), + extras: take_extras( + o, + &["bus", "terminal_map"], + &format!("shunt {name}"), + &mut self.net.warnings, + &["G", "B"], + ), + }); + } + } + + fn sources(&mut self, items: &Map) { + for (name, v) in items { + let Value::Object(o) = v else { continue }; + let known = ["v_magnitude", "v_angle", "bus", "terminal_map"]; + self.net.sources.push(VoltageSource { + name: name.clone(), + bus: string(o.get("bus")), + terminal_map: strings(o.get("terminal_map")), + v_magnitude: floats(o.get("v_magnitude")).unwrap_or_default(), + v_angle: floats(o.get("v_angle")).unwrap_or_default(), + extras: take_extras( + o, + &known, + &format!("voltage source {name}"), + &mut self.net.warnings, + &[], + ), + }); + } + } + + fn transformers(&mut self, subtypes: &Map) { + for (subtype, group) in subtypes { + let Value::Object(items) = group else { + continue; + }; + for (name, v) in items { + let Value::Object(o) = v else { continue }; + let t = self.transformer(subtype, name, o); + self.net.transformers.push(t); + } + } + } + + fn transformer( + &mut self, + subtype: &str, + name: &str, + o: &Map, + ) -> DistTransformer { + let known = [ + "bus_from", + "bus_to", + "terminal_map_from", + "terminal_map_to", + "s_rating", + "v_ref_from", + "v_ref_to", + "r_series", + "x_series", + "r_series_from", + "r_series_to", + "x_series_from", + "x_series_to", + ]; + if !matches!( + subtype, + "single_phase" | "center_tap" | "wye_delta" | "delta_wye" + ) { + self.net.warnings.push(format!( + "transformer {name}: subtype `{subtype}` is outside the schema; \ + read as a single phase pair" + )); + } + let s = o.get("s_rating").map_or(f64::NAN, f); + let v_from = o.get("v_ref_from").map_or(f64::NAN, f); + let v_to = o.get("v_ref_to").map_or(f64::NAN, f); + let positive = |v: f64| v.is_finite() && v > 0.0; + if !positive(s) || !positive(v_from) || !positive(v_to) { + self.net.warnings.push(format!( + "transformer {name}: s_rating or v_ref missing or nonpositive; \ + impedances read as zero" + )); + } + let three_phase = matches!(subtype, "wye_delta" | "delta_wye"); + let phases = if three_phase { 3 } else { 1 }; + + let pct = |x_ohm: f64, v: f64| { + if s > 0.0 && v > 0.0 { + x_ohm / (v * v / s) * 100.0 + } else { + 0.0 + } + }; + let (r_from_pct, r_to_pct, xsc) = if three_phase { + let wye_v = if subtype == "wye_delta" { v_from } else { v_to }; + // The schema puts one series impedance on the wye side; the + // model splits resistance evenly across the windings. + let r = pct(o.get("r_series").map_or(0.0, f), wye_v); + let x = pct(o.get("x_series").map_or(0.0, f), wye_v); + (r / 2.0, r / 2.0, x) + } else { + let r_from = pct(o.get("r_series_from").map_or(0.0, f), v_from); + let r_to = pct(o.get("r_series_to").map_or(0.0, f), v_to); + let x = pct(o.get("x_series_from").map_or(0.0, f), v_from) + + pct(o.get("x_series_to").map_or(0.0, f), v_to); + (r_from, r_to, x) + }; + + let conn = |delta: bool| { + if delta { + WindingConn::Delta + } else { + WindingConn::Wye + } + }; + let windings = vec![ + Winding { + bus: string(o.get("bus_from")), + terminal_map: strings(o.get("terminal_map_from")), + conn: conn(subtype == "delta_wye"), + v_ref: v_from, + s_rating: s, + r_pct: r_from_pct, + tap: 1.0, + }, + Winding { + bus: string(o.get("bus_to")), + terminal_map: strings(o.get("terminal_map_to")), + conn: conn(subtype == "wye_delta"), + v_ref: v_to, + s_rating: s, + r_pct: r_to_pct, + tap: 1.0, + }, + ]; + let mut extras = take_extras( + o, + &known, + &format!("transformer {name}"), + &mut self.net.warnings, + &[], + ); + // Windings alone cannot tell single_phase from center_tap back + // apart; record the subtype for the writer. + extras.insert("bmopf_subtype".into(), subtype.into()); + DistTransformer { + name: name.to_string(), + windings, + xsc_pct: vec![xsc], + phases, + extras, + } + } +} diff --git a/powerio-dist/src/bmopf/write.rs b/powerio-dist/src/bmopf/write.rs new file mode 100644 index 0000000..08a215f --- /dev/null +++ b/powerio-dist/src/bmopf/write.rs @@ -0,0 +1,673 @@ +//! [`DistNetwork`] into strict BMOPF JSON. +//! +//! Output is schema valid wherever the schema permits the data; the one +//! deliberate exception is linecodes and shunts wider than 9 conductors, +//! whose matrix keys (`R_series_10_10`) the draft schema's single digit +//! key patterns reject. The writer emits them anyway: the data is valid, +//! the pattern is the limitation, and the conversion warns. +//! +//! Numbers serialize through serde_json (shortest round trip form). +//! Nonfinite values cannot appear in JSON; they emit as 0 with a warning +//! naming the element and field. + +use serde_json::{Map, Value, json}; + +use crate::convert::Conversion; +use crate::model::{Configuration, DistNetwork, DistTransformer, Mat, Winding, WindingConn}; + +/// Writes the strict BMOPF document. Every field the schema cannot carry +/// is reported in the warnings. +/// +/// # Panics +/// +/// Never in practice: the document is maps, strings, and finite numbers, +/// which always serialize. +pub fn write_bmopf_json(net: &DistNetwork) -> Conversion { + let mut w = Writer { + warnings: Vec::new(), + }; + let doc = w.document(net); + Conversion { + text: serde_json::to_string_pretty(&doc).expect("maps and finite numbers") + "\n", + warnings: w.warnings, + } +} + +struct Writer { + warnings: Vec, +} + +impl Writer { + fn warn(&mut self, msg: impl Into) { + self.warnings.push(msg.into()); + } + + /// Finite number guard (the jnum pattern): JSON has no Inf/NaN. + fn num(&mut self, v: f64, what: &str) -> Value { + if v.is_finite() { + json!(v) + } else { + self.warn(format!("{what}: nonfinite value emitted as 0")); + json!(0.0) + } + } + + fn nums(&mut self, vs: &[f64], what: &str) -> Value { + Value::Array(vs.iter().map(|&v| self.num(v, what)).collect()) + } + + fn extras_dropped(&mut self, extras: &crate::model::Extras, what: &str) { + for key in extras.keys() { + if key == "bmopf_subtype" { + continue; // reader bookkeeping, not source data + } + self.warn(format!( + "{what}: `{key}` has no place in the BMOPF schema; dropped from the output" + )); + } + } + + fn document(&mut self, net: &DistNetwork) -> Value { + let mut doc = Map::new(); + if let Some(name) = &net.name { + doc.insert("name".into(), json!(name)); + } + + let mut buses = Map::new(); + for b in &net.buses { + let mut o = Map::new(); + o.insert("terminal_names".into(), json!(b.terminals)); + if !b.grounded.is_empty() { + o.insert("perfectly_grounded_terminals".into(), json!(b.grounded)); + } + if let Some(v) = b.v_min { + o.insert("v_min".into(), self.num(v, "bus v_min")); + } + if let Some(v) = b.v_max { + o.insert("v_max".into(), self.num(v, "bus v_max")); + } + for (key, bound) in [ + ("vpn_min", &b.vpn_min), + ("vpn_max", &b.vpn_max), + ("vpp_min", &b.vpp_min), + ("vpp_max", &b.vpp_max), + ("vsym_min", &b.vsym_min), + ("vsym_max", &b.vsym_max), + ] { + if let Some(v) = bound { + o.insert(key.into(), self.nums(v, &format!("bus {key}"))); + } + } + // Coordinates and other extras have no bus fields in the schema. + self.extras_dropped(&b.extras, &format!("bus {}", b.id)); + buses.insert(b.id.clone(), Value::Object(o)); + } + doc.insert("bus".into(), Value::Object(buses)); + + if !net.linecodes.is_empty() { + let mut codes = Map::new(); + for c in &net.linecodes { + let mut o = Map::new(); + let n = c.n_conductors; + if n > 9 { + self.warn(format!( + "linecode {}: {n} conductors produce double digit matrix keys, \ + which the draft schema's `^R_series_\\d_\\d` patterns reject; \ + emitted anyway", + c.name + )); + } + // The schema requires R_series_1_1 and X_series_1_1; an + // empty matrix would drop them and invalidate the output. + let dim = c.r_series.len().max(c.x_series.len()).max(1); + if c.r_series.is_empty() && c.x_series.is_empty() { + self.warn(format!( + "linecode {}: no series matrix; emitted as 1 conductor \ + zero impedance", + c.name + )); + } else if c.r_series.is_empty() || c.x_series.is_empty() { + self.warn(format!( + "linecode {}: R_series and X_series sizes disagree; the \ + empty one emitted as zeros", + c.name + )); + } + self.required_matrix(&mut o, "R_series", &c.r_series, dim, &c.name); + self.required_matrix(&mut o, "X_series", &c.x_series, dim, &c.name); + self.flat_matrix(&mut o, "G_from", &c.g_from, &c.name); + self.flat_matrix(&mut o, "G_to", &c.g_to, &c.name); + self.flat_matrix(&mut o, "B_from", &c.b_from, &c.name); + self.flat_matrix(&mut o, "B_to", &c.b_to, &c.name); + if let Some(i_max) = &c.i_max { + o.insert("i_max".into(), self.nums(i_max, "linecode i_max")); + } + if let Some(s_max) = &c.s_max { + o.insert("s_max".into(), self.nums(s_max, "linecode s_max")); + } + self.extras_dropped(&c.extras, &format!("linecode {}", c.name)); + codes.insert(c.name.clone(), Value::Object(o)); + } + doc.insert("linecode".into(), Value::Object(codes)); + } + + self.branches(net, &mut doc); + self.injections(net, &mut doc); + + let transformers = self.transformers(net); + if !transformers.is_empty() { + doc.insert("transformer".into(), Value::Object(transformers)); + } + + for u in &net.untyped { + self.warn(format!( + "{} {}: class is not represented in BMOPF; dropped from the output", + u.class, u.name + )); + } + Value::Object(doc) + } + + /// Lines and switches. + fn branches(&mut self, net: &DistNetwork, doc: &mut Map) { + if !net.lines.is_empty() { + let mut lines = Map::new(); + for l in &net.lines { + let mut o = Map::new(); + o.insert("length".into(), self.num(l.length, "line length")); + o.insert("linecode".into(), json!(l.linecode)); + o.insert("bus_from".into(), json!(l.bus_from)); + o.insert("bus_to".into(), json!(l.bus_to)); + o.insert("terminal_map_from".into(), json!(l.terminal_map_from)); + o.insert("terminal_map_to".into(), json!(l.terminal_map_to)); + self.extras_dropped(&l.extras, &format!("line {}", l.name)); + lines.insert(l.name.clone(), Value::Object(o)); + } + doc.insert("line".into(), Value::Object(lines)); + } + if !net.switches.is_empty() { + let mut switches = Map::new(); + for s in &net.switches { + let mut o = Map::new(); + o.insert("bus_from".into(), json!(s.bus_from)); + o.insert("bus_to".into(), json!(s.bus_to)); + o.insert("terminal_map_from".into(), json!(s.terminal_map_from)); + o.insert("terminal_map_to".into(), json!(s.terminal_map_to)); + o.insert("open_switch".into(), json!(s.open)); + if let Some(i_max) = &s.i_max { + o.insert("i_max".into(), self.nums(i_max, "switch i_max")); + } + self.extras_dropped(&s.extras, &format!("switch {}", s.name)); + switches.insert(s.name.clone(), Value::Object(o)); + } + doc.insert("switch".into(), Value::Object(switches)); + } + } + + /// Loads, generators, shunts, and the voltage sources. + fn injections(&mut self, net: &DistNetwork, doc: &mut Map) { + if !net.loads.is_empty() { + let mut loads = Map::new(); + for l in &net.loads { + let mut o = Map::new(); + o.insert("configuration".into(), json!(config_str(l.configuration))); + o.insert("p_nom".into(), self.nums(&l.p_nom, "load p_nom")); + o.insert("q_nom".into(), self.nums(&l.q_nom, "load q_nom")); + o.insert("bus".into(), json!(l.bus)); + o.insert("terminal_map".into(), json!(l.terminal_map)); + self.extras_dropped(&l.extras, &format!("load {}", l.name)); + loads.insert(l.name.clone(), Value::Object(o)); + } + doc.insert("load".into(), Value::Object(loads)); + } + if !net.generators.is_empty() { + let mut gens = Map::new(); + for g in &net.generators { + gens.insert(g.name.clone(), self.generator(g)); + } + doc.insert("generator".into(), Value::Object(gens)); + } + if !net.shunts.is_empty() { + let mut shunts = Map::new(); + for s in &net.shunts { + let mut o = Map::new(); + o.insert("bus".into(), json!(s.bus)); + o.insert("terminal_map".into(), json!(s.terminal_map)); + // The schema requires G_1_1 and B_1_1. + let dim = s.g.len().max(s.b.len()).max(1); + if s.g.is_empty() && s.b.is_empty() { + self.warn(format!( + "shunt {}: no admittance matrix; emitted as 1 conductor \ + zero admittance", + s.name + )); + } else if s.g.is_empty() || s.b.is_empty() { + self.warn(format!( + "shunt {}: G and B sizes disagree; the empty one emitted \ + as zeros", + s.name + )); + } + self.required_matrix(&mut o, "G", &s.g, dim, &s.name); + self.required_matrix(&mut o, "B", &s.b, dim, &s.name); + self.extras_dropped(&s.extras, &format!("shunt {}", s.name)); + shunts.insert(s.name.clone(), Value::Object(o)); + } + doc.insert("shunt".into(), Value::Object(shunts)); + } + let mut sources = Map::new(); + if net.sources.is_empty() { + self.warn("network has no voltage source; BMOPF requires exactly one"); + } + for (i, vs) in net.sources.iter().enumerate() { + if i > 0 { + self.warn(format!( + "voltage source {}: the BMOPF formulation expects exactly one source; \ + this network has {}", + vs.name, + net.sources.len() + )); + } + let mut o = Map::new(); + o.insert( + "v_magnitude".into(), + self.nums(&vs.v_magnitude, "voltage_source v_magnitude"), + ); + o.insert( + "v_angle".into(), + self.nums(&vs.v_angle, "voltage_source v_angle"), + ); + o.insert("bus".into(), json!(vs.bus)); + o.insert("terminal_map".into(), json!(vs.terminal_map)); + self.extras_dropped(&vs.extras, &format!("voltage source {}", vs.name)); + sources.insert(vs.name.clone(), Value::Object(o)); + } + doc.insert("voltage_source".into(), Value::Object(sources)); + } + + fn generator(&mut self, g: &crate::model::DistGenerator) -> Value { + let mut o = Map::new(); + // BMOPF generators carry bounds and cost, no dispatch setpoint: a + // fixed injection becomes pinned bounds. Explicit source bounds win + // over the setpoint, which then has nowhere to go. + let what = format!("generator {}", g.name); + for (key_lo, key_hi, lo, hi, nom) in [ + ("p_min", "p_max", &g.p_min, &g.p_max, &g.p_nom), + ("q_min", "q_max", &g.q_min, &g.q_max, &g.q_nom), + ] { + if lo.is_some() || hi.is_some() { + // Pinned bounds ARE the setpoint; only a setpoint that + // differs from the bounds has nowhere to go. + let pinned = lo.as_deref() == Some(nom) && hi.as_deref() == Some(nom); + if !nom.is_empty() && !nom.iter().all(|&v| v == 0.0) && !pinned { + self.warn(format!( + "{what}: explicit {key_lo}/{key_hi} bounds win over the setpoint, \ + which has no BMOPF field" + )); + } + if let Some(v) = lo { + o.insert(key_lo.into(), self.nums(v, key_lo)); + } + if let Some(v) = hi { + o.insert(key_hi.into(), self.nums(v, key_hi)); + } + } else if !nom.is_empty() { + // A fixed injection becomes pinned bounds. + o.insert(key_lo.into(), self.nums(nom, key_lo)); + o.insert(key_hi.into(), self.nums(nom, key_hi)); + } + } + let cost = g.cost.unwrap_or_else(|| { + self.warnings.push(format!( + "{what}: no generation cost in the source; emitted cost 0" + )); + 0.0 + }); + o.insert("cost".into(), self.num(cost, "generator cost")); + o.insert("bus".into(), json!(g.bus)); + o.insert("configuration".into(), json!(config_str(g.configuration))); + o.insert("terminal_map".into(), json!(g.terminal_map)); + if g.configuration == Configuration::Delta { + self.warn(format!( + "{what}: the BMOPF formulation covers WYE generators; DELTA emitted as written" + )); + } + self.extras_dropped(&g.extras, &what); + Value::Object(o) + } + + /// Transformers keyed by subtype; wye-wye three phase units decompose + /// into one single_phase entry per phase, the convention the public + /// example networks use. + fn transformers(&mut self, net: &DistNetwork) -> Map { + let mut by_subtype: Map = Map::new(); + let insert = |sub: &str, name: String, v: Value, map: &mut Map| { + map.entry(sub.to_string()) + .or_insert_with(|| Value::Object(Map::new())) + .as_object_mut() + .expect("subtype maps are objects") + .insert(name, v); + }; + for t in &net.transformers { + self.extras_dropped(&t.extras, &format!("transformer {}", t.name)); + match classify(t) { + Kind::SinglePhase => { + let v = self.two_winding(t, &t.windings[0], &t.windings[1], 1.0); + insert("single_phase", t.name.clone(), v, &mut by_subtype); + } + Kind::SinglePhaseShape(sub) => { + let v = self.two_winding(t, &t.windings[0], &t.windings[1], 1.0); + insert(sub, t.name.clone(), v, &mut by_subtype); + } + Kind::CenterTap => { + let v = self.center_tap(t); + insert("center_tap", t.name.clone(), v, &mut by_subtype); + } + Kind::WyeDelta => { + let v = self.three_phase(t, 0); + insert("wye_delta", t.name.clone(), v, &mut by_subtype); + } + Kind::DeltaWye => { + let v = self.three_phase(t, 1); + insert("delta_wye", t.name.clone(), v, &mut by_subtype); + } + Kind::WyeWye3 => { + for (k, v) in self.decompose_wye_wye(t) { + insert("single_phase", k, v, &mut by_subtype); + } + } + Kind::Unsupported(why) => { + self.warn(format!( + "transformer {}: {why}; not representable in the four BMOPF \ + subtypes, dropped from the output", + t.name + )); + } + } + } + by_subtype + } + + /// Shared single_phase / center_tap shape. `to_scale` rescales the to + /// side ratings (used by the wye-wye decomposition). + fn two_winding( + &mut self, + t: &DistTransformer, + from: &Winding, + to: &Winding, + s_scale: f64, + ) -> Value { + let s = from.s_rating * s_scale; + let zb_from = from.v_ref * from.v_ref / s; + let zb_to = to.v_ref * to.v_ref / s; + let mut o = Map::new(); + o.insert("bus_from".into(), json!(from.bus)); + o.insert("bus_to".into(), json!(to.bus)); + o.insert("s_rating".into(), self.num(s, "transformer s_rating")); + o.insert( + "v_ref_from".into(), + self.num(from.v_ref, "transformer v_ref_from"), + ); + o.insert( + "v_ref_to".into(), + self.num(to.v_ref, "transformer v_ref_to"), + ); + o.insert( + "r_series_from".into(), + self.num(from.r_pct / 100.0 * zb_from, "transformer r_series_from"), + ); + o.insert( + "r_series_to".into(), + self.num(to.r_pct / 100.0 * zb_to, "transformer r_series_to"), + ); + // The whole leakage reactance rides on the from side, the + // convention the public example uses. + o.insert( + "x_series_from".into(), + self.num(t.xsc_pct[0] / 100.0 * zb_from, "transformer x_series_from"), + ); + o.insert("x_series_to".into(), json!(0.0)); + o.insert("terminal_map_from".into(), json!(from.terminal_map)); + o.insert("terminal_map_to".into(), json!(to.terminal_map)); + self.taps_dropped(t); + o.into() + } + + fn center_tap(&mut self, t: &DistTransformer) -> Value { + // The split secondary collapses to one to side winding: voltage is + // the full 240 V across the outer terminals, the center tap is the + // shared terminal, listed last. + let from = &t.windings[0]; + let (w2, w3) = (&t.windings[1], &t.windings[2]); + let common = w2 + .terminal_map + .iter() + .find(|term| w3.terminal_map.contains(term)) + .cloned() + .unwrap_or_default(); + let mut hots: Vec = Vec::new(); + for term in w2.terminal_map.iter().chain(&w3.terminal_map) { + if *term != common && !hots.contains(term) { + hots.push(term.clone()); + } + } + // Percent resistance does not transfer to the doubled voltage: + // each winding's impedance base is its own v^2/s (PMD eng2math + // builds zbase per winding from vnom^2/snom). Convert each half to + // ohms on its own base, sum the series path, and express the total + // on the base two_winding gives the combined winding, + // v_new^2/from.s_rating. Equal ratings make the s factors exactly + // 1, leaving the plain v^2 weighting. The leakage reactance needs + // no such move: two_winding applies xsc_pct at the from side, + // whose base the collapse does not touch. + let v_new = w2.v_ref + w3.v_ref; + let r_pct_new = (w2.r_pct * w2.v_ref * w2.v_ref * (from.s_rating / w2.s_rating) + + w3.r_pct * w3.v_ref * w3.v_ref * (from.s_rating / w3.s_rating)) + / (v_new * v_new); + let to = Winding { + bus: w2.bus.clone(), + terminal_map: { + let mut m = hots; + m.push(common); + m + }, + conn: WindingConn::Wye, + v_ref: v_new, + s_rating: from.s_rating, + r_pct: r_pct_new, + tap: 1.0, + }; + self.warn(format!( + "transformer {}: center tap secondary collapsed to one winding; the \ + xht/xlt impedance split is not representable and was dropped", + t.name + )); + if w2.s_rating.to_bits() != from.s_rating.to_bits() + || w3.s_rating.to_bits() != from.s_rating.to_bits() + { + self.warn(format!( + "transformer {}: center tap half winding s_ratings ({}, {}) differ \ + from the primary's {}; the collapsed winding keeps the primary \ + rating, the half ratings only survive in the resistance conversion", + t.name, w2.s_rating, w3.s_rating, from.s_rating + )); + } + self.two_winding(t, from, &to, 1.0) + } + + /// `wye_delta` / `delta_wye`: one series impedance in ohms on the wye + /// side. `wye_idx` names which winding is the wye one. + fn three_phase(&mut self, t: &DistTransformer, wye_idx: usize) -> Value { + let from = &t.windings[0]; + let to = &t.windings[1]; + let wye = &t.windings[wye_idx]; + let s = from.s_rating; + let zb_wye = wye.v_ref * wye.v_ref / s; + let mut o = Map::new(); + o.insert("bus_from".into(), json!(from.bus)); + o.insert("bus_to".into(), json!(to.bus)); + o.insert("s_rating".into(), self.num(s, "transformer s_rating")); + o.insert( + "v_ref_from".into(), + self.num(from.v_ref, "transformer v_ref_from"), + ); + o.insert( + "v_ref_to".into(), + self.num(to.v_ref, "transformer v_ref_to"), + ); + o.insert( + "r_series".into(), + self.num( + (from.r_pct + to.r_pct) / 100.0 * zb_wye, + "transformer r_series", + ), + ); + o.insert( + "x_series".into(), + self.num(t.xsc_pct[0] / 100.0 * zb_wye, "transformer x_series"), + ); + o.insert("terminal_map_from".into(), json!(from.terminal_map)); + o.insert("terminal_map_to".into(), json!(to.terminal_map)); + self.taps_dropped(t); + o.into() + } + + /// A three phase wye-wye unit becomes one single_phase entry per phase + /// (`name_1`..), each at line to neutral voltage and a third of the + /// rating. That keeps the impedance base v^2/s, so the percent values + /// carry over unchanged. The public IEEE13 example records the line to + /// line voltage on its decomposed units instead; both are self + /// consistent, they differ in the v_ref convention. + fn decompose_wye_wye(&mut self, t: &DistTransformer) -> Vec<(String, Value)> { + let mut out = Vec::new(); + let (from, to) = (&t.windings[0], &t.windings[1]); + let sqrt3 = 3f64.sqrt(); + for k in 0..t.phases { + let per = |w: &Winding| { + let neutral = w.terminal_map.last().cloned().unwrap_or_default(); + Winding { + bus: w.bus.clone(), + terminal_map: vec![w.terminal_map[k].clone(), neutral], + conn: WindingConn::Wye, + v_ref: w.v_ref / sqrt3, + s_rating: w.s_rating / 3.0, + r_pct: w.r_pct, + tap: w.tap, + } + }; + let f = per(from); + let to_1 = per(to); + let mut t1 = t.clone(); + t1.windings = vec![f.clone(), to_1.clone()]; + let v = self.two_winding(&t1, &f, &to_1, 1.0); + out.push((format!("{}_{}", t.name, k + 1), v)); + } + self.warn(format!( + "transformer {}: three phase wye-wye decomposed into {} single_phase units", + t.name, t.phases + )); + out + } + + fn taps_dropped(&mut self, t: &DistTransformer) { + for w in &t.windings { + if (w.tap - 1.0).abs() > 1e-12 { + self.warn(format!( + "transformer {}: off nominal tap {} has no BMOPF field; dropped", + t.name, w.tap + )); + } + } + } + + /// Emits a matrix whose `_1_1` entry the schema requires; an empty one + /// becomes `dim` by `dim` zeros so the required key exists. + fn required_matrix( + &mut self, + o: &mut Map, + prefix: &str, + m: &Mat, + dim: usize, + name: &str, + ) { + if m.is_empty() { + self.flat_matrix(o, prefix, &vec![vec![0.0; dim]; dim], name); + } else { + self.flat_matrix(o, prefix, m, name); + } + } + + fn flat_matrix(&mut self, o: &mut Map, prefix: &str, m: &Mat, name: &str) { + for (i, row) in m.iter().enumerate() { + for (j, &v) in row.iter().enumerate() { + o.insert( + format!("{prefix}_{}_{}", i + 1, j + 1), + self.num(v, &format!("{name} {prefix}")), + ); + } + } + } +} + +enum Kind { + SinglePhase, + /// Two windings already in the shared single_phase/center_tap shape, + /// emitted under the named subtype. + SinglePhaseShape(&'static str), + CenterTap, + WyeDelta, + DeltaWye, + WyeWye3, + Unsupported(String), +} + +fn classify(t: &DistTransformer) -> Kind { + // A network read from BMOPF records its subtype; trust it so writing + // back reproduces the grouping (center tap reads as two windings). + // An unknown or shape mismatched subtype falls through to the shape + // based classification below. + if let Some(sub) = t.extras.get("bmopf_subtype").and_then(|v| v.as_str()) + && t.windings.len() == 2 + { + match sub { + "single_phase" => return Kind::SinglePhase, + "center_tap" => return Kind::SinglePhaseShape("center_tap"), + "wye_delta" => return Kind::WyeDelta, + "delta_wye" => return Kind::DeltaWye, + _ => {} + } + } + let conns: Vec = t.windings.iter().map(|w| w.conn).collect(); + match (t.phases, conns.as_slice()) { + (1, [WindingConn::Wye, WindingConn::Wye]) => Kind::SinglePhase, + (1, [WindingConn::Wye, WindingConn::Wye, WindingConn::Wye]) => Kind::CenterTap, + (3, [WindingConn::Wye, WindingConn::Delta]) => Kind::WyeDelta, + (3, [WindingConn::Delta, WindingConn::Wye]) => Kind::DeltaWye, + // The decomposition indexes terminal_map[phase] and takes the last + // entry as the neutral; anything else is not safely decomposable. + (3, [WindingConn::Wye, WindingConn::Wye]) + if t.windings + .iter() + .all(|w| w.terminal_map.len() == t.phases + 1) => + { + Kind::WyeWye3 + } + (3, [WindingConn::Wye, WindingConn::Wye]) => Kind::Unsupported( + "three phase wye-wye whose terminal maps do not list each phase plus a neutral".into(), + ), + _ => Kind::Unsupported(format!( + "{} phase with {} windings ({:?})", + t.phases, + t.windings.len(), + conns + )), + } +} + +fn config_str(c: Configuration) -> &'static str { + match c { + Configuration::Wye => "WYE", + Configuration::Delta => "DELTA", + Configuration::SinglePhase => "SINGLE_PHASE", + } +} diff --git a/powerio-dist/src/convert.rs b/powerio-dist/src/convert.rs new file mode 100644 index 0000000..af5be1b --- /dev/null +++ b/powerio-dist/src/convert.rs @@ -0,0 +1,231 @@ +//! Cross format conversion output and the format dispatcher. + +use crate::model::{DistNetwork, DistSourceFormat}; + +/// Text in the target format plus every fidelity loss the writer took. +/// Nothing drops silently: a field the target cannot represent appears +/// here as a warning naming the element and field. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct Conversion { + pub text: String, + pub warnings: Vec, +} + +/// A writable distribution format. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum DistTargetFormat { + Dss, + BmopfJson, + PmdJson, +} + +/// Resolves common names and file extensions to a target format. +pub fn dist_target_from_name(name: &str) -> Option { + match name.to_ascii_lowercase().as_str() { + "dss" | "opendss" => Some(DistTargetFormat::Dss), + "bmopf" | "bmopf-json" | "bmopf_json" => Some(DistTargetFormat::BmopfJson), + "pmd" | "pmd-json" | "pmd_json" | "engineering" => Some(DistTargetFormat::PmdJson), + _ => None, + } +} + +impl std::str::FromStr for DistTargetFormat { + type Err = crate::Error; + + /// [`dist_target_from_name`] as a `Result`, matching the transmission + /// hub's `TargetFormat: FromStr`. + fn from_str(s: &str) -> crate::Result { + dist_target_from_name(s).ok_or_else(|| crate::Error::UnknownFormat(s.to_string())) + } +} + +impl DistTargetFormat { + /// The canonical format name (`dss`, `pmd-json`, `bmopf-json`), accepted + /// back by [`dist_target_from_name`]. + pub fn name(self) -> &'static str { + match self { + DistTargetFormat::Dss => "dss", + DistTargetFormat::PmdJson => "pmd-json", + DistTargetFormat::BmopfJson => "bmopf-json", + } + } +} + +fn read(path: &std::path::Path) -> crate::Result { + std::fs::read_to_string(path).map_err(|source| crate::Error::Io { + path: path.display().to_string(), + source, + }) +} + +/// PMD ENGINEERING JSON carries a top level `data_model` key; the BMOPF +/// layout has none. Deserializing into an [`IgnoredAny`](serde::de::IgnoredAny) +/// field finds the key at the top level only (a nested or quoted occurrence +/// doesn't count) without building the value tree. +fn is_pmd_json(text: &str) -> bool { + #[derive(serde::Deserialize)] + struct Shape { + data_model: Option, + } + serde_json::from_str::(text).is_ok_and(|s| s.data_model.is_some()) +} + +/// Parses `text` in the named format (see [`dist_target_from_name`]). +pub fn parse_str(text: &str, format: &str) -> crate::Result { + match format.parse::()? { + DistTargetFormat::Dss => Ok(crate::dss::parse_dss_str(text)), + DistTargetFormat::BmopfJson => crate::bmopf::parse_bmopf_str(text), + DistTargetFormat::PmdJson => crate::pmd::parse_pmd_str(text), + } +} + +/// Parses `path`, taking the format from `from` when given, the `.dss` +/// extension otherwise, and for `.json` the presence of the top level PMD +/// ENGINEERING `data_model` key against the BMOPF layout. +pub fn parse_file( + path: impl AsRef, + from: Option<&str>, +) -> crate::Result { + let path = path.as_ref(); + // Dss goes through the path-based parser (Redirect/Compile resolve + // against the file's directory); the JSON readers take text. + let format = if let Some(from) = from { + from.parse::()? + } else { + let ext = path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + match ext.as_str() { + "dss" => DistTargetFormat::Dss, + "json" => { + let text = read(path)?; + return if is_pmd_json(&text) { + crate::pmd::parse_pmd_str(&text) + } else { + crate::bmopf::parse_bmopf_str(&text) + }; + } + other => return Err(crate::Error::UnknownFormat(other.to_string())), + } + }; + match format { + DistTargetFormat::Dss => crate::dss::parse_dss_file(path), + DistTargetFormat::BmopfJson => crate::bmopf::parse_bmopf_str(&read(path)?), + DistTargetFormat::PmdJson => crate::pmd::parse_pmd_str(&read(path)?), + } +} + +/// Prepend the reader's parse warnings to the writer's fidelity warnings: the +/// one-shot converters return no handle to query, so this is the only place +/// the loud half of the parse can surface. +fn convert(net: &DistNetwork, target: DistTargetFormat) -> Conversion { + let conv = net.to_format(target); + let mut warnings = net.warnings.clone(); + warnings.extend(conv.warnings); + Conversion { + text: conv.text, + warnings, + } +} + +/// Parses `text` as `format` and writes it as `to` in one call. The warnings +/// carry both the parse warnings and the writer's fidelity losses. +pub fn convert_str(text: &str, to: DistTargetFormat, format: &str) -> crate::Result { + Ok(convert(&parse_str(text, format)?, to)) +} + +/// Parses `path` (format from `from` or the file itself) and writes it as +/// `to` in one call. The warnings carry both the parse warnings and the +/// writer's fidelity losses. +pub fn convert_file( + path: impl AsRef, + to: DistTargetFormat, + from: Option<&str>, +) -> crate::Result { + Ok(convert(&parse_file(path, from)?, to)) +} + +impl DistTargetFormat { + fn matches(self, source: DistSourceFormat) -> bool { + matches!( + (self, source), + (DistTargetFormat::Dss, DistSourceFormat::Dss) + | (DistTargetFormat::BmopfJson, DistSourceFormat::BmopfJson) + | (DistTargetFormat::PmdJson, DistSourceFormat::PmdJson) + ) + } +} + +impl DistNetwork { + /// Writes the network in `format`. + /// + /// Writing back to the source format echoes the retained source text + /// byte for byte; every cross format write regenerates from the typed + /// model and reports each fidelity loss in the warnings. The returned + /// warnings hold only the writer's losses: parse warnings stay on + /// [`DistNetwork::warnings`] (the one-shot [`convert_str`]/[`convert_file`] + /// merge the two). After mutating a parsed model, set `source = None` + /// (and `source_format`), or the echo tier returns the original text + /// and silently discards the edits. + pub fn to_format(&self, format: DistTargetFormat) -> Conversion { + if let (Some(source), Some(source_format)) = (&self.source, self.source_format) { + if format.matches(source_format) { + return Conversion { + text: source.as_ref().clone(), + warnings: Vec::new(), + }; + } + } + match format { + DistTargetFormat::Dss => crate::dss::write_dss(self), + DistTargetFormat::BmopfJson => crate::bmopf::write_bmopf_json(self), + DistTargetFormat::PmdJson => crate::pmd::write_pmd_json(self), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sniff_requires_top_level_data_model() { + assert!(is_pmd_json(r#"{"data_model": "ENGINEERING"}"#)); + // Nested or quoted occurrences are not the marker. + assert!(!is_pmd_json(r#"{"bus": {"data_model": {}}}"#)); + assert!(!is_pmd_json(r#"{"name": "data_model"}"#)); + assert!(!is_pmd_json("{not json")); + } + + #[test] + fn unknown_format_names_fail_before_any_work() { + assert!(matches!( + parse_str("", "matpower"), + Err(crate::Error::UnknownFormat(_)) + )); + assert!(matches!( + "matpower".parse::(), + Err(crate::Error::UnknownFormat(_)) + )); + assert!(matches!( + parse_file("missing.dss", Some("matpower")), + Err(crate::Error::UnknownFormat(_)) + )); + } + + #[test] + fn one_shot_convert_carries_parse_warnings() { + let dss = "clear\nnew circuit.w basekv=12.47 bus1=src\n\ + new line.l1 bus1=src bus2=b2 length=1 units=furlong\n"; + let conv = convert_str(dss, DistTargetFormat::BmopfJson, "dss").unwrap(); + assert!( + conv.warnings.iter().any(|w| w.contains("furlong")), + "parse warnings must surface through the one-shot converter: {:?}", + conv.warnings + ); + } +} diff --git a/powerio-dist/src/dss/defaults.rs b/powerio-dist/src/dss/defaults.rs new file mode 100644 index 0000000..b2e9450 --- /dev/null +++ b/powerio-dist/src/dss/defaults.rs @@ -0,0 +1,100 @@ +//! OpenDSS constructor defaults for the phase A classes. +//! +//! Values come from the object constructors in epri-dev/OpenDSS-C and are +//! verified empirically against the engine (opendssdirect via +//! `tools/verify_defaults.py`; rerun it when bumping the engine). The reader +//! materializes these into explicit model values and records each +//! materialization in `DistNetwork::defaulted`. +//! +//! The generator note: the constructor sets kW=1000, PF=0.88, kvar=60 +//! (`generator.cpp`, member init), while the property display strings claim +//! kW=100, PF=0.80; the engine reports the constructor values, so those are +//! the defaults here. + +pub mod line { + //! Sequence impedances in ohm per unit length, capacitance in nF per + //! unit length, with `units = none` (factor 1). + pub const R1: f64 = 0.058; + pub const X1: f64 = 0.1206; + pub const R0: f64 = 0.1784; + pub const X0: f64 = 0.4047; + pub const C1_NF: f64 = 3.4; + pub const C0_NF: f64 = 1.6; + pub const LENGTH: f64 = 1.0; + pub const PHASES: usize = 3; + pub const NORMAMPS: f64 = 400.0; + pub const EMERGAMPS: f64 = 600.0; +} + +pub mod linecode { + pub const NPHASES: usize = 3; +} + +pub mod load { + pub const PHASES: usize = 3; + pub const KV: f64 = 12.47; + pub const KW: f64 = 10.0; + pub const PF: f64 = 0.88; + /// Constant power. + pub const MODEL: i64 = 1; +} + +pub mod transformer { + pub const PHASES: usize = 3; + pub const WINDINGS: usize = 2; + pub const KV: f64 = 12.47; + pub const KVA: f64 = 1000.0; + pub const TAP: f64 = 1.0; + pub const PCT_R: f64 = 0.2; + pub const XHL: f64 = 7.0; + pub const XHT: f64 = 35.0; + pub const XLT: f64 = 30.0; +} + +pub mod vsource { + pub const BASEKV: f64 = 115.0; + pub const PU: f64 = 1.0; + pub const ANGLE_DEG: f64 = 0.0; + pub const PHASES: usize = 3; + pub const BUS1: &str = "sourcebus"; +} + +pub mod capacitor { + pub const PHASES: usize = 3; + pub const KVAR: f64 = 1200.0; + pub const KV: f64 = 12.47; +} + +pub mod generator { + pub const PHASES: usize = 3; + pub const KV: f64 = 12.47; + pub const KW: f64 = 1000.0; + pub const KVAR: f64 = 60.0; + /// Constructor PFNominal; kw/pf writes resync kvar from it. + pub const PF: f64 = 0.88; +} + +/// Base frequency when no `Set DefaultBaseFrequency` appears. +pub const BASE_FREQUENCY: f64 = 60.0; + +/// `GetUnitsCode` + `To_Meters` from Shared/LineUnits.cpp. The engine +/// matches on the first two characters; `no*` and anything unrecognized +/// are UNITS_NONE, which has no conversion factor. +pub fn unit_to_meters(code: &str) -> Option { + let two: String = code + .chars() + .take(2) + .map(|c| c.to_ascii_lowercase()) + .collect(); + Some(match two.as_str() { + "mi" => 1609.344, + "kf" => 304.8, + "km" => 1000.0, + "m" | "me" => 1.0, + "ft" => 0.3048, + "in" => 0.0254, + "cm" => 0.01, + "mm" => 0.001, + _ => return None, + }) +} diff --git a/powerio-dist/src/dss/lex.rs b/powerio-dist/src/dss/lex.rs new file mode 100644 index 0000000..5ba2ca8 --- /dev/null +++ b/powerio-dist/src/dss/lex.rs @@ -0,0 +1,490 @@ +//! Tokenizer matching OpenDSS's TParser (Parser/ParserDel.cpp). +//! +//! A command line is a sequence of parameters, positional or `name=value`. +//! Delimiters are `,` and `=` plus space and tab; a token opening with one of +//! `( " ' [ {` runs to the matching closer and keeps delimiters inside; +//! `!` and `//` start a comment that eats the rest of the line. A token +//! beginning with `@` is replaced by the named parser variable, keeping any +//! `.node` suffix. Quoted tokens parse as RPN when read as numbers; vector +//! values re-tokenize their content with `|` terminating a matrix row. + +use std::collections::BTreeMap; + +use super::rpn::{self, RpnCalc}; + +/// Parser variables (`var @x=...`), looked up case insensitively with the +/// leading `@` included in the key. +pub type VarMap = BTreeMap; + +const BEGIN_QUOTE: &[u8] = b"(\"'[{"; +const END_QUOTE: &[u8] = b")\"']}"; + +/// What ended the last token. +#[derive(Clone, Copy, PartialEq, Debug)] +enum Delim { + Whitespace, + Char(u8), + Comment, +} + +/// One parameter from a command line. +#[derive(Clone, Debug, PartialEq)] +pub struct Param { + /// Property name to the left of `=`; `None` for a positional value. + pub name: Option, + pub value: Value, +} + +/// A raw value token. `quoted` records that the token came from a quote pair, +/// which switches numeric interpretation to RPN. +#[derive(Clone, Debug, PartialEq, Default)] +pub struct Value { + pub text: String, + pub quoted: bool, +} + +/// A `bus1=name.1.2.0` bus reference: name plus ordered node numbers. +#[derive(Clone, Debug, PartialEq)] +pub struct BusSpec { + pub name: String, + /// Node numbers as written; `0` is ground. Unparseable nodes become -1, + /// matching the reference parser's error marker. + pub nodes: Vec, +} + +#[derive(Debug, thiserror::Error, PartialEq)] +pub enum ValueError { + #[error("`{0}` is not a number")] + NotANumber(String), + #[error("bad RPN token `{token}` in `{expr}`")] + BadRpn { expr: String, token: String }, +} + +pub struct Scanner<'a> { + buf: &'a [u8], + pos: usize, + last_delim: Delim, + /// Extra delimiter, the matrix row terminator `|` during vector parsing. + row_term: bool, + vars: Option<&'a VarMap>, +} + +impl<'a> Scanner<'a> { + pub fn new(line: &'a str, vars: Option<&'a VarMap>) -> Self { + let mut s = Scanner { + buf: line.as_bytes(), + pos: 0, + last_delim: Delim::Whitespace, + row_term: false, + vars, + }; + s.skip_whitespace(); + s + } + + fn skip_whitespace(&mut self) { + while self.pos < self.buf.len() && matches!(self.buf[self.pos], b' ' | b'\t') { + self.pos += 1; + } + } + + fn is_delim_char(&self, b: u8) -> bool { + b == b',' || b == b'=' || (self.row_term && b == b'|') + } + + fn at_comment(&self) -> bool { + match self.buf.get(self.pos) { + Some(b'!') => true, + Some(b'/') => self.buf.get(self.pos + 1) == Some(&b'/'), + _ => false, + } + } + + /// TParser::GetToken. Returns `None` at end of line; an empty token can + /// occur mid stream (e.g. between consecutive commas), as in the + /// reference. + fn get_token(&mut self) -> Option<(String, bool)> { + if self.pos >= self.buf.len() { + return None; + } + self.last_delim = Delim::Whitespace; + let mut quoted = false; + let text; + + let open = self.buf[self.pos]; + if let Some(qi) = BEGIN_QUOTE.iter().position(|&q| q == open) { + let close = END_QUOTE[qi]; + self.pos += 1; + let start = self.pos; + while self.pos < self.buf.len() && self.buf[self.pos] != close { + self.pos += 1; + } + text = String::from_utf8_lossy(&self.buf[start..self.pos]).into_owned(); + if self.pos < self.buf.len() { + self.pos += 1; // past the closer + } + quoted = true; + } else { + let start = self.pos; + while self.pos < self.buf.len() { + if self.at_comment() { + self.last_delim = Delim::Comment; + break; + } + let b = self.buf[self.pos]; + if self.is_delim_char(b) { + self.last_delim = Delim::Char(b); + break; + } + if matches!(b, b' ' | b'\t') { + self.last_delim = Delim::Whitespace; + break; + } + self.pos += 1; + } + text = String::from_utf8_lossy(&self.buf[start..self.pos]).into_owned(); + } + + if self.last_delim == Delim::Comment { + self.pos = self.buf.len(); + return Some((text, quoted)); + } + + // Move past one terminating delimiter, eating whitespace around it, + // so `a = b` and `a=b` scan identically. + if self.last_delim == Delim::Whitespace { + self.skip_whitespace(); + } + if self.pos < self.buf.len() { + if self.at_comment() { + self.pos = self.buf.len(); + return Some((text, quoted)); + } + let b = self.buf[self.pos]; + if self.is_delim_char(b) { + self.last_delim = Delim::Char(b); + self.pos += 1; + } + } + self.skip_whitespace(); + Some((text, quoted)) + } + + /// TParser::CheckforVar: a token starting with `@` is replaced by its + /// variable value, keeping a `.node.node` suffix (`^` also cuts the + /// name). A value stored as `{...}` unwraps and becomes a quoted token. + fn substitute(&self, token: String, quoted: bool) -> (String, bool) { + if token.len() < 2 || !token.starts_with('@') { + return (token, quoted); + } + let Some(vars) = self.vars else { + return (token, quoted); + }; + let cut = token.find(['.', '^']).unwrap_or(token.len()); + let (name, suffix) = token.split_at(cut); + let key = name.to_ascii_lowercase(); + let Some(value) = vars.get(&key) else { + return (token, quoted); + }; + if let Some(inner) = value.strip_prefix('{').and_then(|v| v.strip_suffix('}')) { + (format!("{inner}{suffix}"), true) + } else { + (format!("{value}{suffix}"), quoted) + } + } + + /// TParser::GetNextParam: one positional or `name=value` parameter. + /// Variable substitution applies to the value, never the name. + pub fn next_param(&mut self) -> Option { + let (tok, quoted) = self.get_token()?; + let (name, raw) = if self.last_delim == Delim::Char(b'=') { + (Some(tok), self.get_token().unwrap_or_default()) + } else { + (None, (tok, quoted)) + }; + let (text, quoted) = self.substitute(raw.0, raw.1); + Some(Param { + name, + value: Value { text, quoted }, + }) + } + + /// Remaining unscanned text, trimmed; the argument tail for commands that + /// take free text. + pub fn remainder(&self) -> &str { + std::str::from_utf8(&self.buf[self.pos.min(self.buf.len())..]) + .unwrap_or_default() + .trim() + } +} + +impl Value { + pub fn new(text: impl Into) -> Self { + Value { + text: text.into(), + quoted: false, + } + } + + /// TParser::MakeDouble_: quoted tokens evaluate as RPN, bare tokens must + /// be plain numbers. An empty value is 0, as in the reference. + pub fn to_f64(&self, vars: Option<&VarMap>) -> Result { + if self.text.is_empty() { + return Ok(0.0); + } + if self.quoted { + return self.eval_rpn(vars); + } + rpn::parse_number(&self.text).ok_or_else(|| ValueError::NotANumber(self.text.clone())) + } + + /// TParser::MakeInteger_: parse as a double and round. + pub fn to_i64(&self, vars: Option<&VarMap>) -> Result { + self.to_f64(vars).map(|v| v.round() as i64) + } + + fn eval_rpn(&self, vars: Option<&VarMap>) -> Result { + let mut calc = RpnCalc::new(); + let mut scan = Scanner::new(&self.text, vars); + while let Some((tok, _)) = scan.get_token() { + if tok.is_empty() { + continue; + } + let (tok, _) = scan.substitute(tok, false); + if !calc.apply(&tok) { + return Err(ValueError::BadRpn { + expr: self.text.clone(), + token: tok, + }); + } + } + Ok(calc.x()) + } + + /// TParser::ParseAsVector over the whole value: numbers separated by + /// whitespace or commas. `|` row terminators split a matrix value into + /// rows; a plain vector is one row. + pub fn to_rows(&self, vars: Option<&VarMap>) -> Result>, ValueError> { + let mut rows = Vec::new(); + let mut row = Vec::new(); + let mut scan = Scanner::new(&self.text, vars); + scan.row_term = true; + while let Some((tok, quoted)) = scan.get_token() { + if !tok.is_empty() { + let (text, quoted) = scan.substitute(tok, quoted); + row.push(Value { text, quoted }.to_f64(vars)?); + } + if scan.last_delim == Delim::Char(b'|') { + rows.push(std::mem::take(&mut row)); + } + } + if !row.is_empty() || rows.is_empty() { + rows.push(row); + } + Ok(rows) + } + + /// A flat numeric vector (kVs, taps, ZIPV, ...). + pub fn to_vector(&self, vars: Option<&VarMap>) -> Result, ValueError> { + Ok(self.to_rows(vars)?.into_iter().flatten().collect()) + } + + /// A list of string items (`buses=(b1, b2)`, `conns=(wye delta)`). + pub fn to_string_list(&self, vars: Option<&VarMap>) -> Vec { + let mut out = Vec::new(); + let mut scan = Scanner::new(&self.text, vars); + while let Some((tok, quoted)) = scan.get_token() { + if !tok.is_empty() { + out.push(scan.substitute(tok, quoted).0); + } + } + out + } + + /// TParser::ParseAsBusName: `name.1.2.0` into name and node list. + pub fn to_bus_spec(&self) -> BusSpec { + let text = self.text.trim(); + match text.split_once('.') { + None => BusSpec { + name: text.to_string(), + nodes: Vec::new(), + }, + Some((name, rest)) => BusSpec { + name: name.trim().to_string(), + nodes: rest + .split('.') + .map(|n| n.trim().parse::().unwrap_or(-1)) + .collect(), + }, + } + } + + /// OpenDSS boolean: leading `y`/`t`/`1` is true, anything else false. + pub fn to_bool(&self) -> bool { + matches!( + self.text.bytes().next().map(|b| b.to_ascii_lowercase()), + Some(b'y' | b't' | b'1') + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn params(line: &str) -> Vec<(Option, String, bool)> { + let mut scan = Scanner::new(line, None); + let mut out = Vec::new(); + while let Some(p) = scan.next_param() { + out.push((p.name, p.value.text, p.value.quoted)); + } + out + } + + #[test] + fn positional_and_named() { + let p = params("Line.l1 bus1=a bus2=b 0.3"); + assert_eq!(p[0], (None, "Line.l1".into(), false)); + assert_eq!(p[1], (Some("bus1".into()), "a".into(), false)); + assert_eq!(p[2], (Some("bus2".into()), "b".into(), false)); + assert_eq!(p[3], (None, "0.3".into(), false)); + } + + #[test] + fn spaces_around_equals() { + assert_eq!(params("a = b"), params("a=b")); + assert_eq!(params("a =b"), params("a= b")); + } + + #[test] + fn comma_separates() { + let p = params("conns=(wye, delta)"); + assert_eq!(p[0], (Some("conns".into()), "wye, delta".into(), true)); + } + + #[test] + fn quote_pairs() { + for (open, close) in [('(', ')'), ('"', '"'), ('\'', '\''), ('[', ']'), ('{', '}')] { + let line = format!("x={open}1 2 3{close}"); + let p = params(&line); + assert_eq!(p[0], (Some("x".into()), "1 2 3".into(), true), "{open}"); + } + } + + #[test] + fn comments_stop_the_line() { + assert_eq!(params("a=1 ! trailing").len(), 1); + assert_eq!(params("a=1 // trailing").len(), 1); + assert_eq!(params("a=1!glued").len(), 1); + assert!(params("! whole line").first().unwrap().1.is_empty()); + } + + #[test] + fn slash_alone_is_not_a_comment() { + let p = params("x=a/b"); + assert_eq!(p[0], (Some("x".into()), "a/b".into(), false)); + } + + #[test] + fn rpn_value() { + let v = Value { + text: "8 1000 /".into(), + quoted: true, + }; + assert_eq!(v.to_f64(None), Ok(0.008)); + let bare = Value::new("3.5"); + assert_eq!(bare.to_f64(None), Ok(3.5)); + let bad = Value::new("abc"); + assert!(bad.to_f64(None).is_err()); + } + + #[test] + fn quoted_single_number_is_rpn() { + let v = Value { + text: "42".into(), + quoted: true, + }; + assert_eq!(v.to_f64(None), Ok(42.0)); + } + + #[test] + fn matrix_rows() { + let v = Value { + text: "0.088 | 0.031 0.090 | 0.030 0.031 0.088".into(), + quoted: true, + }; + let rows = v.to_rows(None).unwrap(); + assert_eq!(rows.len(), 3); + assert_eq!(rows[0], vec![0.088]); + assert_eq!(rows[2], vec![0.030, 0.031, 0.088]); + } + + #[test] + fn vector_with_commas() { + let v = Value { + text: "7.2, 0.24".into(), + quoted: true, + }; + assert_eq!(v.to_vector(None).unwrap(), vec![7.2, 0.24]); + } + + #[test] + fn rpn_inside_vector() { + let v = Value { + text: "1 \"8 1000 /\"".into(), + quoted: true, + }; + assert_eq!(v.to_vector(None).unwrap(), vec![1.0, 0.008]); + } + + #[test] + fn bus_dotting() { + let b = Value::new("632.1.2.3.0").to_bus_spec(); + assert_eq!(b.name, "632"); + assert_eq!(b.nodes, vec![1, 2, 3, 0]); + let plain = Value::new("sourcebus").to_bus_spec(); + assert_eq!(plain.name, "sourcebus"); + assert!(plain.nodes.is_empty()); + let bad = Value::new("b.1.x").to_bus_spec(); + assert_eq!(bad.nodes, vec![1, -1]); + } + + #[test] + fn var_substitution() { + let mut vars = VarMap::new(); + vars.insert("@kv".into(), "12.47".into()); + vars.insert("@bus".into(), "632".into()); + vars.insert("@expr".into(), "{2 3 *}".into()); + let mut scan = Scanner::new("kv=@kv bus1=@bus.1.2 x=@expr y=@undef", Some(&vars)); + let p1 = scan.next_param().unwrap(); + assert_eq!(p1.value.text, "12.47"); + let p2 = scan.next_param().unwrap(); + assert_eq!(p2.value.text, "632.1.2"); + let p3 = scan.next_param().unwrap(); + assert_eq!(p3.value.text, "2 3 *"); + assert!(p3.value.quoted); + assert_eq!(p3.value.to_f64(Some(&vars)), Ok(6.0)); + let p4 = scan.next_param().unwrap(); + assert_eq!(p4.value.text, "@undef"); + } + + #[test] + fn string_list() { + let v = Value { + text: "b1, b2".into(), + quoted: true, + }; + assert_eq!(v.to_string_list(None), vec!["b1", "b2"]); + } + + #[test] + fn booleans() { + assert!(Value::new("yes").to_bool()); + assert!(Value::new("Y").to_bool()); + assert!(Value::new("true").to_bool()); + assert!(Value::new("1").to_bool()); + assert!(!Value::new("no").to_bool()); + assert!(!Value::new("false").to_bool()); + assert!(!Value::new("").to_bool()); + } +} diff --git a/powerio-dist/src/dss/mod.rs b/powerio-dist/src/dss/mod.rs new file mode 100644 index 0000000..4351f7e --- /dev/null +++ b/powerio-dist/src/dss/mod.rs @@ -0,0 +1,19 @@ +//! OpenDSS `.dss` support: tokenizer, RPN, class tables, raw object layer. +//! +//! The semantics mirror the OpenDSS reference implementation +//! (epri-dev/OpenDSS-C): TParser tokenization, executive command dispatch +//! with prefix abbreviation, property resolution in class definition order, +//! and the TRPNCalc expression calculator. + +pub mod defaults; +pub mod lex; +pub mod prop; +pub mod raw; +pub mod read; +mod rpn; +mod write; + +pub use lex::{BusSpec, Param, Scanner, Value, VarMap}; +pub use raw::{BusCoord, RawCommand, RawDss, RawObject, RawProp, parse_raw_file, parse_raw_with}; +pub use read::{network_from_raw, parse_dss_file, parse_dss_str}; +pub use write::write_dss; diff --git a/powerio-dist/src/dss/prop.rs b/powerio-dist/src/dss/prop.rs new file mode 100644 index 0000000..5c9bdbd --- /dev/null +++ b/powerio-dist/src/dss/prop.rs @@ -0,0 +1,498 @@ +//! OpenDSS class and property name tables, in definition order. +//! +//! Names and order come from each class's DefineProperties in the OpenDSS +//! source (epri-dev/OpenDSS-C): the order fixes both positional property +//! assignment and abbreviation resolution. Lookup is case insensitive, exact +//! match first, then the first name in definition order with the query as a +//! prefix (THashList::FindAbbrev). Every class list ends with the inherited +//! properties: PD elements add normamps..repair, PC elements add spectrum, +//! and every circuit element adds basefreq, enabled, like. + +/// One OpenDSS object class: canonical name plus ordered property names. +pub struct DssClass { + pub name: &'static str, + pub props: &'static [&'static str], +} + +macro_rules! class { + ($ident:ident, $name:literal, [$($p:literal),* $(,)?]) => { + pub static $ident: DssClass = DssClass { name: $name, props: &[$($p),*] }; + }; +} + +class!( + LINE, + "line", + [ + "bus1", + "bus2", + "linecode", + "length", + "phases", + "r1", + "x1", + "r0", + "x0", + "c1", + "c0", + "rmatrix", + "xmatrix", + "cmatrix", + "switch", + "rg", + "xg", + "rho", + "geometry", + "units", + "spacing", + "wires", + "earthmodel", + "cncables", + "tscables", + "b1", + "b0", + "seasons", + "ratings", + "linetype", + // inherited + "normamps", + "emergamps", + "faultrate", + "pctperm", + "repair", + "basefreq", + "enabled", + "like", + ] +); + +class!( + LINECODE, + "linecode", + [ + "nphases", + "r1", + "x1", + "r0", + "x0", + "c1", + "c0", + "units", + "rmatrix", + "xmatrix", + "cmatrix", + "basefreq", + "normamps", + "emergamps", + "faultrate", + "pctperm", + "repair", + "kron", + "rg", + "xg", + "rho", + "neutral", + "b1", + "b0", + "seasons", + "ratings", + "linetype", + // inherited + "like", + ] +); + +class!( + LOAD, + "load", + [ + "phases", + "bus1", + "kv", + "kw", + "pf", + "model", + "yearly", + "daily", + "duty", + "growth", + "conn", + "kvar", + "rneut", + "xneut", + "status", + "class", + "vminpu", + "vmaxpu", + "vminnorm", + "vminemerg", + "xfkva", + "allocationfactor", + "kva", + "%mean", + "%stddev", + "cvrwatts", + "cvrvars", + "kwh", + "kwhdays", + "cfactor", + "cvrcurve", + "numcust", + "zipv", + "%seriesrl", + "relweight", + "vlowpu", + "puxharm", + "xrharm", + // inherited + "spectrum", + "basefreq", + "enabled", + "like", + ] +); + +class!( + TRANSFORMER, + "transformer", + [ + "phases", + "windings", + "wdg", + "bus", + "conn", + "kv", + "kva", + "tap", + "%r", + "rneut", + "xneut", + "buses", + "conns", + "kvs", + "kvas", + "taps", + "xhl", + "xht", + "xlt", + "xscarray", + "thermal", + "n", + "m", + "flrise", + "hsrise", + "%loadloss", + "%noloadloss", + "normhkva", + "emerghkva", + "sub", + "maxtap", + "mintap", + "numtaps", + "subname", + "%imag", + "ppm_antifloat", + "%rs", + "bank", + "xfmrcode", + "xrconst", + "x12", + "x13", + "x23", + "leadlag", + "wdgcurrents", + "core", + "rdcohms", + "seasons", + "ratings", + // inherited + "normamps", + "emergamps", + "faultrate", + "pctperm", + "repair", + "basefreq", + "enabled", + "like", + ] +); + +class!( + VSOURCE, + "vsource", + [ + "bus1", + "basekv", + "pu", + "angle", + "frequency", + "phases", + "mvasc3", + "mvasc1", + "x1r1", + "x0r0", + "isc3", + "isc1", + "r1", + "x1", + "r0", + "x0", + "scantype", + "sequence", + "bus2", + "z1", + "z0", + "z2", + "puz1", + "puz0", + "puz2", + "basemva", + "yearly", + "daily", + "duty", + "model", + "puzideal", + // inherited + "spectrum", + "basefreq", + "enabled", + "like", + ] +); + +class!( + CAPACITOR, + "capacitor", + [ + "bus1", + "bus2", + "phases", + "kvar", + "kv", + "conn", + "cmatrix", + "cuf", + "r", + "xl", + "harm", + "numsteps", + "states", + // inherited + "normamps", + "emergamps", + "faultrate", + "pctperm", + "repair", + "basefreq", + "enabled", + "like", + ] +); + +class!( + GENERATOR, + "generator", + [ + "phases", + "bus1", + "kv", + "kw", + "pf", + "kvar", + "model", + "vminpu", + "vmaxpu", + "yearly", + "daily", + "duty", + "dispmode", + "dispvalue", + "conn", + "rneut", + "xneut", + "status", + "class", + "vpu", + "maxkvar", + "minkvar", + "pvfactor", + "forceon", + "kva", + "mva", + "xd", + "xdp", + "xdpp", + "h", + "d", + "usermodel", + "userdata", + "shaftmodel", + "shaftdata", + "dutystart", + "debugtrace", + "balanced", + "xrdp", + "usefuel", + "fuelkwh", + "%fuel", + "%reserve", + "refuel", + "dynamiceq", + "dynout", + // inherited + "spectrum", + "basefreq", + "enabled", + "like", + ] +); + +class!( + SWTCONTROL, + "swtcontrol", + [ + "switchedobj", + "switchedterm", + "action", + "lock", + "delay", + "normal", + "state", + "reset", + // inherited + "basefreq", + "enabled", + "like", + ] +); + +class!( + REGCONTROL, + "regcontrol", + [ + "transformer", + "winding", + "vreg", + "band", + "ptratio", + "ctprim", + "r", + "x", + "bus", + "delay", + "reversible", + "revvreg", + "revband", + "revr", + "revx", + "tapdelay", + "debugtrace", + "maxtapchange", + "inversetime", + "tapwinding", + "vlimit", + "ptphase", + "revthreshold", + "revdelay", + "revneutral", + "eventlog", + "remoteptratio", + "tapnum", + "reset", + "ldc_z", + "rev_z", + "cogen", + // inherited + "basefreq", + "enabled", + "like", + ] +); + +/// The Phase A classes with property tables. Anything else parses into the +/// raw layer untyped. +static CLASSES: &[&DssClass] = &[ + &LINE, + &LINECODE, + &LOAD, + &TRANSFORMER, + &VSOURCE, + &CAPACITOR, + &GENERATOR, + &SWTCONTROL, + ®CONTROL, +]; + +/// Case insensitive exact class name lookup (`circuit` is handled by the +/// command layer, not here). +pub fn class_by_name(name: &str) -> Option<&'static DssClass> { + CLASSES + .iter() + .find(|c| c.name.eq_ignore_ascii_case(name)) + .copied() +} + +impl DssClass { + /// Property lookup: exact case insensitive match first, then the first + /// property in definition order that starts with the query. + pub fn prop_index(&self, query: &str) -> Option { + let q = query.to_ascii_lowercase(); + self.props + .iter() + .position(|p| *p == q) + .or_else(|| self.props.iter().position(|p| p.starts_with(&q))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exact_beats_prefix() { + // "r1" is exact even though "r0" or "rmatrix" share the prefix. + assert_eq!(LINE.prop_index("r1"), Some(5)); + assert_eq!(LINE.prop_index("R1"), Some(5)); + } + + #[test] + fn first_prefix_match_in_definition_order() { + // "r" has no exact match; the first r* property in order is r1. + assert_eq!(LINE.prop_index("r"), Some(5)); + // "rm" picks rmatrix, not rg or rho. + assert_eq!(LINE.prop_index("rm"), Some(11)); + // "norm" picks normamps from the inherited tail. + assert_eq!(LINE.prop_index("norm"), Some(30)); + } + + #[test] + fn percent_properties() { + assert_eq!(TRANSFORMER.prop_index("%R"), Some(8)); + assert_eq!(TRANSFORMER.prop_index("%Rs"), Some(36)); + assert_eq!(TRANSFORMER.prop_index("%loadloss"), Some(25)); + } + + #[test] + fn load_positions_match_the_engine() { + // Load.cpp DefineProperties: RelWeight 35, Vlowpu 36, puXharm 37, + // XRharm 38 (1-based); a missing slot would shift every later + // positional assignment. + assert_eq!(LOAD.prop_index("relweight"), Some(34)); + assert_eq!(LOAD.prop_index("vlowpu"), Some(35)); + assert_eq!(LOAD.prop_index("puxharm"), Some(36)); + assert_eq!(LOAD.prop_index("xrharm"), Some(37)); + assert_eq!(LOAD.props.len(), 38 + 4); // 38 own + spectrum, basefreq, enabled, like + } + + #[test] + fn class_lookup() { + assert!(class_by_name("Line").is_some()); + assert!(class_by_name("LINECODE").is_some()); + assert!(class_by_name("reactor").is_none()); + } + + #[test] + fn unknown_property() { + assert_eq!(LINE.prop_index("zzz"), None); + } +} diff --git a/powerio-dist/src/dss/raw.rs b/powerio-dist/src/dss/raw.rs new file mode 100644 index 0000000..97b8e94 --- /dev/null +++ b/powerio-dist/src/dss/raw.rs @@ -0,0 +1,1153 @@ +//! Script execution and the raw object layer. +//! +//! A `.dss` file is a command script. This layer splits it into command +//! lines (handling block comments), resolves command verbs with the same +//! exact-then-prefix rule OpenDSS uses, follows `Redirect`/`Compile` +//! includes, and accumulates `New`/`Edit`/`~` property assignments into raw +//! objects with property names resolved against the class tables. Values +//! stay untyped [`Value`] tokens; interpretation happens in the readers. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use super::lex::{Scanner, Value, VarMap}; +use super::prop::{self, DssClass}; +use crate::error::{Error, Result}; + +/// The OpenDSS executive command list, in definition order +/// (Executive/ExecCommands.cpp). Order fixes abbreviation resolution: a verb +/// matches exactly first, then the first command here with the verb as a +/// prefix. Only a handful execute in this layer; the rest are preserved as +/// [`RawCommand`]s. +static COMMANDS: &[&str] = &[ + "new", + "edit", + "more", + "m", + "~", + "select", + "save", + "show", + "solve", + "enable", + "disable", + "plot", + "reset", + "compile", + "set", + "dump", + "open", + "close", + "//", + "redirect", + "help", + "quit", + "?", + "next", + "panel", + "sample", + "clear", + "about", + "calcvoltagebases", + "setkvbase", + "buildy", + "get", + "init", + "export", + "fileedit", + "voltages", + "currents", + "powers", + "seqvoltages", + "seqcurrents", + "seqpowers", + "losses", + "phaselosses", + "cktlosses", + "allocateloads", + "formedit", + "totals", + "capacity", + "classes", + "userclasses", + "zsc", + "zsc10", + "zscrefresh", + "ysc", + "puvoltages", + "varvalues", + "varnames", + "buscoords", + "makebuslist", + "makeposseq", + "reduce", + "interpolate", + "alignfile", + "top", + "rotate", + "vdiff", + "summary", + "distribute", + "di_plot", + "comparecases", + "yearlycurves", + "cd", + "visualize", + "closedi", + "doscmd", + "estimate", + "reconductor", + "_initsnap", + "_solvenocontrol", + "_samplecontrols", + "_docontrolactions", + "_showcontrolqueue", + "_solvedirect", + "_solvepflow", + "addbusmarker", + "uuids", + "setloadandgenkv", + "cvrtloadshapes", + "nodediff", + "rephase", + "setbusxy", + "updatestorage", + "obfuscate", + "latlongcoords", + "batchedit", + "pstcalc", + "variable", + "reprocessbuses", + "clearbusmarkers", + "relcalc", + "var", + "cleanup", + "finishtimestep", + "nodelist", + "newactor", + "clearall", + "wait", + "solveall", + "calcincmatrix", + "calcincmatrix_o", + "tear_circuit", + "connect", + "disconnect", + "refine_buslevels", + "remove", + "abort", + "calclaplacian", + "clone", + "fncspublish", + "exportoverloads", + "exportvviolations", + "zsc012", + "aggregateprofiles", + "allpceatbus", + "allpdeatbus", + "totalpowers", + "comhelp", + "gis", + "giscoords", + "readefieldhdf", +]; + +fn command_index(verb: &str) -> Option { + let v = verb.to_ascii_lowercase(); + COMMANDS + .iter() + .position(|c| *c == v) + .or_else(|| COMMANDS.iter().position(|c| c.starts_with(&v))) +} + +/// One property assignment as applied to an object, in application order. +#[derive(Clone, Debug, PartialEq)] +pub struct RawProp { + /// Canonical property name when resolved against the class table; + /// the name as written when the class or property is unknown; `None` + /// for a positional value on an unknown class. + pub name: Option, + pub value: Value, +} + +/// An accumulated object: every `New`/`Edit`/`~`/`like` assignment that +/// touched it, in order. Values are raw tokens. +#[derive(Clone, Debug)] +pub struct RawObject { + /// Canonical lowercase class name (`line`, `load`, ...), known or not. + pub class: String, + /// Object name as written; lookup is case insensitive. + pub name: String, + pub props: Vec, + /// Prop-count checkpoints at edit boundaries. Every object command line + /// (`New`/`Edit`/`~`/`More`/property reference) is one engine Edit, and + /// the class Edit ends in RecalcElementData; readers with end-of-edit + /// side effects (Load) segment `props` on these. `like=` splices the + /// source's checkpoints too: MakeLike copies the source's recalced + /// state, so its boundaries must replay. + pub edits: Vec, +} + +impl RawObject { + /// The last assignment to a canonical property name, if any. + pub fn get(&self, name: &str) -> Option<&Value> { + self.props + .iter() + .rev() + .find(|p| p.name.as_deref() == Some(name)) + .map(|p| &p.value) + } + + /// Edit boundary checkpoints, closed over the full prop list: a + /// trailing segment without a recorded boundary counts as one more + /// edit, so callers always see `props.len()` last. + pub fn edit_bounds(&self) -> impl Iterator + '_ { + let tail = + (self.edits.last().copied() != Some(self.props.len())).then_some(self.props.len()); + self.edits.iter().copied().chain(tail) + } +} + +/// A command this layer does not execute, preserved verbatim. +#[derive(Clone, Debug, PartialEq)] +pub struct RawCommand { + /// Canonical verb when recognized, the first token as written otherwise. + pub verb: String, + /// Everything after the verb, trimmed. + pub args: String, +} + +/// Bus coordinates from a `BusCoords` file. +#[derive(Clone, Debug, PartialEq)] +pub struct BusCoord { + pub bus: String, + pub x: f64, + pub y: f64, +} + +/// The executed script: objects, options, and preserved commands. +#[derive(Debug, Default)] +pub struct RawDss { + pub circuit_name: Option, + pub objects: Vec, + /// `Set option=value` assignments in order. + pub options: Vec<(String, Value)>, + /// Commands preserved without execution (solve, calcvoltagebases, ...). + pub commands: Vec, + pub buscoords: Vec, + pub vars: VarMap, + pub warnings: Vec, + index: BTreeMap<(String, String), usize>, + active: Option, +} + +impl RawDss { + pub fn find(&self, class: &str, name: &str) -> Option<&RawObject> { + self.index + .get(&(class.to_ascii_lowercase(), name.to_ascii_lowercase())) + .map(|&i| &self.objects[i]) + } + + pub fn of_class<'a>(&'a self, class: &'a str) -> impl Iterator { + self.objects.iter().filter(move |o| o.class == class) + } + + fn warn(&mut self, msg: impl Into) { + self.warnings.push(msg.into()); + } + + fn clear(&mut self) { + *self = RawDss::default(); + } +} + +/// Supplies included file text, so tests can run without a filesystem. +pub trait Loader { + fn load(&mut self, path: &Path) -> std::io::Result; +} + +impl Loader for F +where + F: FnMut(&Path) -> std::io::Result, +{ + fn load(&mut self, path: &Path) -> std::io::Result { + self(path) + } +} + +/// Redirect nesting limit; OpenDSS recurses unbounded, this bounds cycles. +const MAX_REDIRECT_DEPTH: usize = 64; + +struct Executor<'l, L: Loader> { + raw: RawDss, + loader: &'l mut L, + /// Directory stack for relative include resolution; starts with the + /// root file's directory, so its depth is the redirect nesting level. + dirs: Vec, +} + +/// Splits script text into command lines, dropping block comments. A block +/// comment starts on a line whose first character is `/` followed by `*` +/// and ends on the first line containing `*/`; both boundary lines are +/// consumed whole, matching the OpenDSS executive. +fn command_lines(text: &str) -> impl Iterator { + let mut in_block = false; + text.lines().enumerate().filter_map(move |(i, line)| { + if in_block { + if line.contains("*/") { + in_block = false; + } + return None; + } + if line.starts_with("/*") { + in_block = true; + if line.contains("*/") { + in_block = false; + } + return None; + } + Some((i + 1, line)) + }) +} + +impl Executor<'_, L> { + fn run_script(&mut self, text: &str, file: &str) { + for (line_no, line) in command_lines(text) { + self.run_command(line, file, line_no); + } + } + + fn run_command(&mut self, line: &str, file: &str, line_no: usize) { + // The scanner substitutes against a snapshot of the var table so the + // live table stays free for mutation: `var` inserts into it directly + // and redirected files both see and extend it. The snapshot only + // diverges for a self referencing `var` line, which OpenDSS scripts + // do not write. + let vars = self.raw.vars.clone(); + let mut scan = Scanner::new(line, Some(&vars)); + let ctx = |msg: String| format!("{file}:{line_no}: {msg}"); + match scan.next_param() { + None => {} + Some(first) if first.value.text.is_empty() && first.name.is_none() => {} + Some(first) => { + if let Some(name) = first.name { + // First parameter is name=value: a property reference + // like `Transformer.Reg1.Taps=[...]`. + self.edit_property_reference(&name, first.value, &mut scan, &ctx); + } else { + self.dispatch(first.value.text, &mut scan, &ctx); + } + } + } + } + + fn dispatch(&mut self, verb: String, scan: &mut Scanner, ctx: &dyn Fn(String) -> String) { + match command_index(&verb).map(|i| COMMANDS[i]) { + Some("new") => self.do_new(scan, ctx), + Some("edit") => self.do_edit(scan, ctx), + Some("more" | "m" | "~") => self.do_more(scan, ctx), + Some("select") => self.do_select(scan, ctx), + Some("set") => self.do_set(scan), + Some("redirect") => self.do_redirect(scan, false, ctx), + Some("compile") => self.do_redirect(scan, true, ctx), + Some("buscoords") => self.do_buscoords(scan, ctx), + Some("var") => self.do_var(scan), + Some("clear" | "clearall") => self.raw.clear(), + Some("//") => {} + Some(canonical) => { + self.raw.commands.push(RawCommand { + verb: canonical.to_string(), + args: scan.remainder().to_string(), + }); + } + None => { + self.raw.warn(ctx(format!( + "unknown command `{verb}`; line preserved verbatim" + ))); + self.raw.commands.push(RawCommand { + verb, + args: scan.remainder().to_string(), + }); + } + } + } + + /// `var @name=value ...` defines parser variables. TParserVar::Add + /// stores every value brace wrapped unless it begins with `@`; + /// CheckforVar unwraps the braces into a quoted token, so a definition + /// like `var @z=(8 1000 /)` still evaluates as RPN where it is used. + fn do_var(&mut self, scan: &mut Scanner) { + while let Some(p) = scan.next_param() { + if p.value.text.is_empty() && p.name.is_none() { + break; + } + if let Some(name) = p.name { + let stored = if p.value.text.starts_with('@') { + p.value.text + } else { + format!("{{{}}}", p.value.text) + }; + self.raw.vars.insert(name.to_ascii_lowercase(), stored); + } + } + } + + /// A leading `name=value` parameter is a property reference + /// (ExecCommands ProcessCommand): `Class.Name.Prop=value`, + /// `Name.Prop=value` with the class omitted, or `Prop=value` on the + /// active object. ParseObjName cuts the object part at the second dot; + /// SetObject resolves an omitted class to the last referenced one, + /// which here is the active object's class. + fn edit_property_reference( + &mut self, + spec: &str, + value: Value, + scan: &mut Scanner, + ctx: &dyn Fn(String) -> String, + ) { + let (object, prop) = match spec.split_once('.') { + None => (None, spec), + Some((first, rest)) => match rest.split_once('.') { + None => (Some((None, first)), rest), + Some((name, prop)) => (Some((Some(first), name)), prop), + }, + }; + let active_or = |raw: &mut RawDss| { + let active = raw.active; + if active.is_none() { + raw.warn(ctx(format!("`{spec}=` with no active object"))); + } + active + }; + let idx = match object { + None => match active_or(&mut self.raw) { + Some(idx) => idx, + None => return, + }, + Some((class, name)) => { + let class = match class { + Some(c) => c.to_ascii_lowercase(), + None => match active_or(&mut self.raw) { + Some(idx) => self.raw.objects[idx].class.clone(), + None => return, + }, + }; + if let Some(idx) = self + .raw + .index + .get(&(class.clone(), name.to_ascii_lowercase())) + .copied() + { + idx + } else { + self.raw.warn(ctx(format!( + "property reference to unknown object `{class}.{name}`" + ))); + return; + } + } + }; + self.raw.active = Some(idx); + let table = prop_table(&self.raw.objects[idx].class); + let name = match table { + Some(c) => { + if let Some(i) = c.prop_index(prop) { + c.props[i].to_string() + } else { + self.raw.warn(ctx(format!( + "unknown property `{prop}` on {}; kept as written", + c.name + ))); + prop.to_ascii_lowercase() + } + } + None => prop.to_ascii_lowercase(), + }; + let mut props = vec![RawProp { + name: Some(name), + value, + }]; + props.extend(collect_props_for( + table, + scan, + Some(prop), + &mut self.raw.warnings, + ctx, + )); + self.apply_props(idx, props, ctx); + } + + fn do_new(&mut self, scan: &mut Scanner, ctx: &dyn Fn(String) -> String) { + let Some((class, name)) = self.object_spec(scan, ctx) else { + return; + }; + if class.eq_ignore_ascii_case("circuit") { + // A new circuit brings its Vsource named "source"; the line's + // remaining properties edit that source. Its defaults (bus1 = + // sourcebus etc.) stay implicit here so the reader can tell + // written values from materialized defaults. + self.raw.circuit_name = Some(name); + let idx = self.make_object("vsource", "source".into()); + self.consume_and_apply(idx, scan, ctx); + return; + } + let key = (class.to_ascii_lowercase(), name.to_ascii_lowercase()); + let idx = match self.raw.index.get(&key) { + Some(&existing) => { + self.raw.warn(ctx(format!( + "duplicate `New {class}.{name}`; editing the existing object" + ))); + existing + } + None => self.make_object(&class, name), + }; + self.consume_and_apply(idx, scan, ctx); + } + + fn do_edit(&mut self, scan: &mut Scanner, ctx: &dyn Fn(String) -> String) { + let Some((class, name)) = self.object_spec(scan, ctx) else { + return; + }; + let key = (class.to_ascii_lowercase(), name.to_ascii_lowercase()); + let Some(&idx) = self.raw.index.get(&key) else { + self.raw + .warn(ctx(format!("`Edit {class}.{name}` on an unknown object"))); + return; + }; + self.consume_and_apply(idx, scan, ctx); + } + + fn do_more(&mut self, scan: &mut Scanner, ctx: &dyn Fn(String) -> String) { + let Some(idx) = self.raw.active else { + self.raw.warn(ctx("`~` with no active object".into())); + return; + }; + self.consume_and_apply(idx, scan, ctx); + } + + fn do_select(&mut self, scan: &mut Scanner, ctx: &dyn Fn(String) -> String) { + let Some((class, name)) = self.object_spec(scan, ctx) else { + return; + }; + let key = (class.to_ascii_lowercase(), name.to_ascii_lowercase()); + match self.raw.index.get(&key) { + Some(&idx) => self.raw.active = Some(idx), + None => self + .raw + .warn(ctx(format!("`Select {class}.{name}` on an unknown object"))), + } + } + + fn do_set(&mut self, scan: &mut Scanner) { + while let Some(p) = scan.next_param() { + if p.value.text.is_empty() && p.name.is_none() { + break; + } + let name = p.name.unwrap_or_default().to_ascii_lowercase(); + self.raw.options.push((name, p.value)); + } + } + + /// Resolves a file argument relative to the current file's directory. + /// Backslash separators (the format's DOS heritage) become `/`. + fn resolve(&self, file_arg: &str) -> PathBuf { + let rel = file_arg.replace('\\', "/"); + self.dirs + .last() + .map_or_else(|| PathBuf::from(&rel), |d| d.join(&rel)) + } + + fn do_redirect(&mut self, scan: &mut Scanner, compile: bool, ctx: &dyn Fn(String) -> String) { + let Some(p) = scan.next_param() else { + self.raw.warn(ctx("redirect with no file".into())); + return; + }; + let path = self.resolve(&p.value.text); + if self.dirs.len() > MAX_REDIRECT_DEPTH { + self.raw + .warn(ctx(format!("redirect depth limit at {}", path.display()))); + return; + } + match self.loader.load(&path) { + Ok(text) => { + let dir = path.parent().map(Path::to_path_buf).unwrap_or_default(); + self.dirs.push(dir.clone()); + self.run_script(&text, &path.display().to_string()); + self.dirs.pop(); + // The engine keeps one current directory: Redirect restores + // the caller's on return (SetCurrentDir(SaveDir)), Compile + // pins it to the compiled file's OWN directory — ExecHelper + // DoRedirect sets CurrDir once from the file path (~:300) + // and compile exit reapplies it via SetDataPath (~:361) — + // even when the compiled script itself compiled deeper. The + // caller's later relative paths follow the compiled file. + if compile && let Some(top) = self.dirs.last_mut() { + *top = dir; + } + } + Err(e) => { + let verb = if compile { "compile" } else { "redirect" }; + self.raw + .warn(ctx(format!("{verb} {}: {e}", path.display()))); + } + } + } + + fn do_buscoords(&mut self, scan: &mut Scanner, ctx: &dyn Fn(String) -> String) { + let Some(p) = scan.next_param() else { + self.raw.warn(ctx("buscoords with no file".into())); + return; + }; + let path = self.resolve(&p.value.text); + match self.loader.load(&path) { + Ok(text) => { + for (line_no, line) in text.lines().enumerate() { + let mut s = Scanner::new(line, None); + let Some(bus) = s.next_param() else { continue }; + if bus.value.text.is_empty() { + continue; + } + let x = s.next_param().map(|p| p.value).unwrap_or_default(); + let y = s.next_param().map(|p| p.value).unwrap_or_default(); + match (x.to_f64(None), y.to_f64(None)) { + (Ok(x), Ok(y)) => self.raw.buscoords.push(BusCoord { + bus: bus.value.text, + x, + y, + }), + _ => self.raw.warn(ctx(format!( + "buscoords {}:{}: unparseable coordinates", + path.display(), + line_no + 1 + ))), + } + } + } + Err(e) => self + .raw + .warn(ctx(format!("buscoords {}: {e}", path.display()))), + } + } + + /// Reads `Class.Name` (or `object=Class.Name`) from the next parameter. + fn object_spec( + &mut self, + scan: &mut Scanner, + ctx: &dyn Fn(String) -> String, + ) -> Option<(String, String)> { + let p = scan.next_param()?; + if let Some(name) = &p.name { + if !name.eq_ignore_ascii_case("object") { + self.raw + .warn(ctx(format!("expected Class.Name, got `{name}=`"))); + return None; + } + } + let spec = p.value.text; + match spec.split_once('.') { + Some((class, name)) if !class.is_empty() && !name.is_empty() => { + Some((class.to_string(), name.to_string())) + } + _ => { + self.raw + .warn(ctx(format!("malformed object spec `{spec}`"))); + None + } + } + } + + fn make_object(&mut self, class: &str, name: String) -> usize { + let class_lc = class.to_ascii_lowercase(); + let idx = self.raw.objects.len(); + self.raw + .index + .insert((class_lc.clone(), name.to_ascii_lowercase()), idx); + self.raw.objects.push(RawObject { + class: class_lc, + name, + props: Vec::new(), + edits: Vec::new(), + }); + idx + } + + fn consume_and_apply( + &mut self, + idx: usize, + scan: &mut Scanner, + ctx: &dyn Fn(String) -> String, + ) { + let props = collect_props_for( + prop_table(&self.raw.objects[idx].class), + scan, + None, + &mut self.raw.warnings, + ctx, + ); + self.apply_props(idx, props, ctx); + } + + fn apply_props(&mut self, idx: usize, props: Vec, ctx: &dyn Fn(String) -> String) { + self.raw.active = Some(idx); + for p in props { + // `like=` splices the source object's accumulated props, + // checkpoints included: MakeLike copies the source's recalced + // state (Load.cpp ~810-815 takes kWBase, kvarBase, LoadSpecType, + // AND PFNominal), which equals replaying the source's writes + // with its own edit boundaries. + if p.name.as_deref() == Some("like") { + let class = self.raw.objects[idx].class.clone(); + let key = (class.clone(), p.value.text.to_ascii_lowercase()); + match self.raw.index.get(&key).copied() { + Some(src) => { + let base = self.raw.objects[idx].props.len(); + let cloned = self.raw.objects[src].props.clone(); + let bounds: Vec = self.raw.objects[src] + .edit_bounds() + .map(|e| base + e) + .collect(); + self.raw.objects[idx].props.extend(cloned); + self.raw.objects[idx].edits.extend(bounds); + } + None => self.raw.warn(ctx(format!( + "like={} names an unknown {class}", + p.value.text + ))), + } + continue; + } + self.raw.objects[idx].props.push(p); + } + // This command line was one engine Edit; it ends in + // RecalcElementData, so record the boundary. + let end = self.raw.objects[idx].props.len(); + self.raw.objects[idx].edits.push(end); + } +} + +fn prop_table(class: &str) -> Option<&'static DssClass> { + prop::class_by_name(class) +} + +/// Reads the remaining parameters of an object command, resolving names +/// (with abbreviation) and positional order against the class table. The +/// positional pointer continues from the last named property, as in the +/// reference. `after` seeds the pointer for property reference lines. +fn collect_props_for( + class: Option<&'static DssClass>, + scan: &mut Scanner, + after: Option<&str>, + warnings: &mut Vec, + ctx: &dyn Fn(String) -> String, +) -> Vec { + let mut out = Vec::new(); + let mut pointer: Option = class.zip(after).and_then(|(c, name)| c.prop_index(name)); + while let Some(p) = scan.next_param() { + if p.value.text.is_empty() && p.name.is_none() { + break; + } + let name = match (&p.name, class) { + (Some(written), Some(c)) => { + if let Some(i) = c.prop_index(written) { + pointer = Some(i); + Some(c.props[i].to_string()) + } else { + // Getcommand yields 0 for an unknown name, so the next + // positional lands on property 1 (the class Edit loops: + // `ParamPointer = CommandList.Getcommand(ParamName)`). + pointer = None; + warnings.push(ctx(format!( + "unknown property `{written}` on {}; kept as written", + c.name + ))); + Some(written.to_ascii_lowercase()) + } + } + (Some(written), None) => Some(written.to_ascii_lowercase()), + (None, Some(c)) => { + let next = pointer.map_or(0, |i| i + 1); + pointer = Some(next); + if let Some(canon) = c.props.get(next) { + Some((*canon).to_string()) + } else { + warnings.push(ctx(format!( + "positional value `{}` beyond the last {} property", + p.value.text, c.name + ))); + None + } + } + (None, None) => None, + }; + out.push(RawProp { + name, + value: p.value, + }); + } + out +} + +/// Parses `.dss` text. `path` anchors relative includes; pass the file's +/// path when the text came from a file, anything descriptive otherwise. +pub fn parse_raw_with(text: &str, path: &str, loader: &mut impl Loader) -> RawDss { + let mut exec = Executor { + raw: RawDss::default(), + loader, + dirs: vec![ + Path::new(path) + .parent() + .map(Path::to_path_buf) + .unwrap_or_default(), + ], + }; + exec.run_script(text, path); + exec.raw +} + +/// Parses a `.dss` file from disk, following its includes. +pub fn parse_raw_file(path: impl AsRef) -> Result { + let path = path.as_ref(); + let text = std::fs::read_to_string(path).map_err(|source| Error::Io { + path: path.display().to_string(), + source, + })?; + Ok(parse_raw_with( + &text, + &path.display().to_string(), + &mut |p: &Path| std::fs::read_to_string(p), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn no_files(_: &Path) -> std::io::Result { + Err(std::io::Error::new(std::io::ErrorKind::NotFound, "test")) + } + + fn parse(text: &str) -> RawDss { + parse_raw_with(text, "test.dss", &mut no_files) + } + + #[test] + fn new_object_with_positional_and_named() { + let raw = parse("New Line.l1 b1 b2 lc 0.3 phases=2 r1=0.1"); + let l = raw.find("line", "l1").unwrap(); + assert_eq!(l.get("bus1").unwrap().text, "b1"); + assert_eq!(l.get("bus2").unwrap().text, "b2"); + assert_eq!(l.get("linecode").unwrap().text, "lc"); + assert_eq!(l.get("length").unwrap().text, "0.3"); + assert_eq!(l.get("phases").unwrap().text, "2"); + assert_eq!(l.get("r1").unwrap().text, "0.1"); + assert!(raw.warnings.is_empty()); + } + + #[test] + fn positional_continues_after_named() { + // After r1=0.1 (index 5), the next positional is x1 (index 6). + let raw = parse("New Line.l1 r1=0.1 0.2"); + let l = raw.find("line", "l1").unwrap(); + assert_eq!(l.get("x1").unwrap().text, "0.2"); + } + + #[test] + fn unknown_property_resets_the_positional_pointer() { + // `ParamPointer = Getcommand("bogus")` is 0 in the engine, so the + // next positional gets property 1 (bus1), not the one after r1. + let raw = parse("New Line.l1 r1=0.1 bogus=2 0.5"); + let l = raw.find("line", "l1").unwrap(); + assert_eq!(l.get("bus1").unwrap().text, "0.5"); + assert!(l.get("x1").is_none()); + assert_eq!(raw.warnings.len(), 1); + } + + #[test] + fn tilde_continues_the_active_object() { + let raw = parse("New Load.ld bus1=b1\n~ kW=15 kvar=3\nMore pf=0.9"); + let ld = raw.find("load", "ld").unwrap(); + assert_eq!(ld.get("kw").unwrap().text, "15"); + assert_eq!(ld.get("kvar").unwrap().text, "3"); + assert_eq!(ld.get("pf").unwrap().text, "0.9"); + } + + #[test] + fn abbreviated_property_names() { + let raw = parse("New Line.l1 ph=3 len=2 rm=(1 | 0 1)"); + let l = raw.find("line", "l1").unwrap(); + assert_eq!(l.get("phases").unwrap().text, "3"); + assert_eq!(l.get("length").unwrap().text, "2"); + assert!(l.get("rmatrix").unwrap().quoted); + } + + #[test] + fn new_circuit_creates_the_source() { + let raw = parse("New Circuit.test basekv=115 pu=1.05\n~ angle=30"); + assert_eq!(raw.circuit_name.as_deref(), Some("test")); + let vs = raw.find("vsource", "source").unwrap(); + assert_eq!(vs.get("basekv").unwrap().text, "115"); + assert_eq!(vs.get("angle").unwrap().text, "30"); + // bus1 was not written; the default (sourcebus) is the reader's to + // materialize, so the raw layer must not invent it. + assert!(vs.get("bus1").is_none()); + } + + #[test] + fn edit_and_property_reference() { + let raw = parse("New Line.l1 length=1\nEdit Line.l1 length=2\nLine.l1.Length=3 phases=2"); + let l = raw.find("line", "l1").unwrap(); + assert_eq!(l.get("length").unwrap().text, "3"); + assert_eq!(l.get("phases").unwrap().text, "2"); + } + + #[test] + fn property_reference_resolves_abbreviations() { + let raw = parse("New Line.l1 bus1=a\nLine.l1.Len=2.5"); + let l = raw.find("line", "l1").unwrap(); + assert_eq!(l.get("length").unwrap().text, "2.5"); + assert!(raw.warnings.is_empty()); + } + + #[test] + fn bare_property_edits_the_active_object() { + let raw = parse("New Line.l1 bus1=a bus2=b\nlength=2.5"); + let l = raw.find("line", "l1").unwrap(); + assert_eq!(l.get("length").unwrap().text, "2.5"); + assert!(raw.warnings.is_empty()); + } + + #[test] + fn classless_reference_uses_the_active_class() { + // SetObject with no dot in the spec looks the name up in the last + // referenced class, line here via the active object. + let raw = parse("New Line.l1 bus1=a\nNew Line.l2 bus1=b\nl1.length=7 phases=2"); + let l1 = raw.find("line", "l1").unwrap(); + assert_eq!(l1.get("length").unwrap().text, "7"); + assert_eq!(l1.get("phases").unwrap().text, "2"); + assert!(raw.find("line", "l2").unwrap().get("length").is_none()); + assert!(raw.warnings.is_empty()); + } + + #[test] + fn like_splices_source_props() { + let raw = parse("New Load.a kW=10 pf=0.9\nNew Load.b like=a kW=20"); + let b = raw.find("load", "b").unwrap(); + assert_eq!(b.get("kw").unwrap().text, "20"); + assert_eq!(b.get("pf").unwrap().text, "0.9"); + } + + #[test] + fn unknown_class_is_preserved_raw() { + let raw = parse("New Reactor.r1 bus1=b1 x=3"); + let r = raw.find("reactor", "r1").unwrap(); + assert_eq!(r.get("bus1").unwrap().text, "b1"); + assert_eq!(r.get("x").unwrap().text, "3"); + } + + #[test] + fn set_options_accumulate() { + let raw = parse("Set VoltageBases=[115, 12.47]\nset mode=snapshot"); + assert_eq!(raw.options[0].0, "voltagebases"); + assert_eq!( + raw.options[0].1.to_vector(None).unwrap(), + vec![115.0, 12.47] + ); + assert_eq!(raw.options[1].0, "mode"); + } + + #[test] + fn unexecuted_commands_are_preserved() { + let raw = parse("Solve\ncalcv\nShow Voltages LN"); + let verbs: Vec<&str> = raw.commands.iter().map(|c| c.verb.as_str()).collect(); + assert_eq!(verbs, vec!["solve", "calcvoltagebases", "show"]); + assert_eq!(raw.commands[2].args, "Voltages LN"); + } + + #[test] + fn clear_resets() { + let raw = parse("New Line.l1 length=1\nClear\nNew Line.l2 length=2"); + assert!(raw.find("line", "l1").is_none()); + assert!(raw.find("line", "l2").is_some()); + } + + #[test] + fn block_comments_skip_lines() { + let raw = parse("/* comment\nNew Line.l1 length=1\n*/\nNew Line.l2 length=2"); + assert!(raw.find("line", "l1").is_none()); + assert!(raw.find("line", "l2").is_some()); + } + + #[test] + fn one_line_block_comment() { + let raw = parse("/* x */\nNew Line.l2 length=2"); + assert!(raw.find("line", "l2").is_some()); + } + + #[test] + fn redirect_includes_a_file() { + let mut files = BTreeMap::from([( + PathBuf::from("sub/codes.dss"), + "New Linecode.lc1 nphases=3".to_string(), + )]); + let mut loader = move |p: &Path| { + files + .remove(p) + .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "missing")) + }; + let raw = parse_raw_with( + "Redirect sub/codes.dss\nNew Line.l1 linecode=lc1", + "test.dss", + &mut loader, + ); + assert!(raw.find("linecode", "lc1").is_some()); + assert!(raw.warnings.is_empty()); + } + + #[test] + fn missing_redirect_warns() { + let raw = parse("Redirect nope.dss"); + assert_eq!(raw.warnings.len(), 1); + assert!(raw.warnings[0].contains("nope.dss")); + } + + #[test] + fn compile_moves_the_directory_redirect_restores_it() { + // After `Compile sub/feeder.dss`, the caller's relative paths + // resolve against sub/; after a Redirect they resolve against the + // caller's own directory again. Both directories carry a lines.dss + // so the wrong resolution shows up as the wrong object. + let root = std::env::temp_dir().join(format!("powerio-dist-raw-{}", std::process::id())); + let sub = root.join("sub"); + std::fs::create_dir_all(&sub).unwrap(); + std::fs::write(sub.join("feeder.dss"), "New Linecode.lc1 nphases=3").unwrap(); + std::fs::write(sub.join("lines.dss"), "New Line.fromsub bus1=a").unwrap(); + std::fs::write(root.join("lines.dss"), "New Line.fromroot bus1=a").unwrap(); + std::fs::write( + root.join("compile.dss"), + "Compile sub/feeder.dss\nRedirect lines.dss", + ) + .unwrap(); + std::fs::write( + root.join("redirect.dss"), + "Redirect sub/feeder.dss\nRedirect lines.dss", + ) + .unwrap(); + + let compiled = parse_raw_file(root.join("compile.dss")).unwrap(); + assert_eq!(compiled.warnings, Vec::::new()); + assert!(compiled.find("line", "fromsub").is_some()); + + let redirected = parse_raw_file(root.join("redirect.dss")).unwrap(); + assert_eq!(redirected.warnings, Vec::::new()); + assert!(redirected.find("line", "fromroot").is_some()); + + std::fs::remove_dir_all(&root).unwrap(); + } + + #[test] + fn compile_inside_compile_pins_the_compiled_files_directory() { + // ExecHelper DoRedirect sets CurrDir from the file path once at + // entry and compile exit reapplies it (SetDataPath → ChDir), so a + // Compile that itself compiles deeper still leaves the caller in + // the directly compiled file's directory, not the innermost one. + // probe.dss exists in both sub/ and sub/inner/; the engine resolves + // sub/probe.dss. + let root = + std::env::temp_dir().join(format!("powerio-dist-rawnest-{}", std::process::id())); + let sub = root.join("sub"); + let inner = sub.join("inner"); + std::fs::create_dir_all(&inner).unwrap(); + std::fs::write( + root.join("main.dss"), + "Compile sub/a.dss\nRedirect probe.dss", + ) + .unwrap(); + std::fs::write(sub.join("a.dss"), "Compile inner/b.dss").unwrap(); + std::fs::write(inner.join("b.dss"), "New Linecode.lc1 nphases=1").unwrap(); + std::fs::write(sub.join("probe.dss"), "New Line.fromsub bus1=a").unwrap(); + std::fs::write(inner.join("probe.dss"), "New Line.frominner bus1=a").unwrap(); + + let raw = parse_raw_file(root.join("main.dss")).unwrap(); + assert_eq!(raw.warnings, Vec::::new()); + assert!(raw.find("linecode", "lc1").is_some()); + assert!(raw.find("line", "fromsub").is_some()); + assert!(raw.find("line", "frominner").is_none()); + + std::fs::remove_dir_all(&root).unwrap(); + } + + #[test] + fn edit_boundaries_are_recorded() { + // One checkpoint per command line; like= splices the source's + // boundaries (offset) before the splicing edit's own. + let raw = parse("New Load.a kW=10 pf=0.9\n~ kvar=5\nNew Load.b like=a kw=20"); + let a = raw.find("load", "a").unwrap(); + assert_eq!(a.edits, vec![2, 3]); + let b = raw.find("load", "b").unwrap(); + assert_eq!(b.props.len(), 4); + assert_eq!(b.edits, vec![2, 3, 4]); + assert_eq!(b.edit_bounds().collect::>(), vec![2, 3, 4]); + } + + #[test] + fn var_definition_and_use() { + let raw = parse("var @kv=12.47\nNew Load.ld kv=@kv"); + let ld = raw.find("load", "ld").unwrap(); + assert_eq!(ld.get("kv").unwrap().text, "12.47"); + } + + #[test] + fn quoted_var_value_stays_rpn() { + // The braces TParserVar::Add wraps around the stored value come + // back off as a quoted token, so the substituted expression still + // evaluates as RPN. + let raw = parse("var @z=(8 1000 /)\nNew Load.ld kW=@z"); + let v = raw.find("load", "ld").unwrap().get("kw").unwrap(); + assert!(v.quoted); + assert_eq!(v.to_f64(None), Ok(0.008)); + } + + #[test] + fn vars_cross_redirect_boundaries() { + // A var defined in the parent substitutes inside the include, and a + // var defined in the include survives back in the parent. + let mut loader = |p: &Path| { + if p == Path::new("inc.dss") { + Ok("New Load.inner kv=@kv\nvar @kw=42".to_string()) + } else { + Err(std::io::Error::new(std::io::ErrorKind::NotFound, "missing")) + } + }; + let raw = parse_raw_with( + "var @kv=12.47\nRedirect inc.dss\nNew Load.outer kW=@kw", + "test.dss", + &mut loader, + ); + assert_eq!(raw.warnings, Vec::::new()); + assert_eq!( + raw.find("load", "inner").unwrap().get("kv").unwrap().text, + "12.47" + ); + assert_eq!( + raw.find("load", "outer").unwrap().get("kw").unwrap().text, + "42" + ); + } + + #[test] + fn duplicate_new_warns_and_edits() { + let raw = parse("New Line.l1 length=1\nNew Line.l1 length=2"); + assert_eq!(raw.warnings.len(), 1); + assert_eq!( + raw.find("line", "l1").unwrap().get("length").unwrap().text, + "2" + ); + } + + #[test] + fn rpn_value_via_props() { + let raw = parse("New Load.ld kW=(8 1000 /)"); + let v = raw.find("load", "ld").unwrap().get("kw").unwrap().clone(); + assert_eq!(v.to_f64(None), Ok(0.008)); + } +} diff --git a/powerio-dist/src/dss/read.rs b/powerio-dist/src/dss/read.rs new file mode 100644 index 0000000..42d577b --- /dev/null +++ b/powerio-dist/src/dss/read.rs @@ -0,0 +1,1792 @@ +//! `.dss` raw objects into the canonical [`DistNetwork`]. +//! +//! Every OpenDSS default materializes into an explicit model value, recorded +//! in [`DistNetwork::defaulted`] under the `"class.name"` key. Specified +//! properties the typed fields do not capture go into the element's `extras` +//! verbatim (string values), so a later writer can reproduce them. Bus specs +//! resolve with the engine's fill rule: phase conductors default to nodes +//! `1..=phases`, every remaining conductor to ground (node 0), and the +//! written dot list overrides from the left. Ground connections become an +//! explicit perfectly grounded neutral terminal on the bus, named +//! `max(4, highest node + 1)` to match PowerModelsDistribution and the +//! public BMOPF examples. + +use std::collections::BTreeMap; +use std::path::Path; +use std::sync::Arc; + +use super::defaults as dd; +use super::lex::{BusSpec, Value, VarMap}; +use super::raw::{RawDss, RawObject, parse_raw_with}; +use crate::error::{Error, Result}; +use crate::model::{ + Configuration, DistBus, DistGenerator, DistLine, DistLineCode, DistLoad, DistNetwork, + DistShunt, DistSourceFormat, DistSwitch, DistTransformer, Extras, Mat, UntypedObject, + VoltageSource, Winding, WindingConn, square_from_rows, +}; + +/// Parses a `.dss` file, following includes, into the canonical model. +pub fn parse_dss_file(path: impl AsRef) -> Result { + let path = path.as_ref(); + let text = std::fs::read_to_string(path).map_err(|source| Error::Io { + path: path.display().to_string(), + source, + })?; + let raw = parse_raw_with(&text, &path.display().to_string(), &mut |p: &Path| { + std::fs::read_to_string(p) + }); + Ok(network_from_raw(&raw, Arc::new(text))) +} + +/// Parses `.dss` text; `Redirect`/`Compile` resolve relative to the working +/// directory. +pub fn parse_dss_str(text: &str) -> DistNetwork { + let raw = parse_raw_with(text, "", &mut |p: &Path| std::fs::read_to_string(p)); + network_from_raw(&raw, Arc::new(text.to_string())) +} + +/// Lowers an executed raw script into the typed model. +pub fn network_from_raw(raw: &RawDss, source: Arc) -> DistNetwork { + let mut rd = Reader { + net: DistNetwork { + name: raw.circuit_name.clone(), + base_frequency: dd::BASE_FREQUENCY, + source: Some(source), + source_format: Some(DistSourceFormat::Dss), + warnings: raw.warnings.clone(), + ..DistNetwork::default() + }, + buses: BTreeMap::new(), + bus_order: Vec::new(), + linecode_units: BTreeMap::new(), + vars: &raw.vars, + }; + + for (name, value) in &raw.options { + // Set option names resolve by first match in the engine's option + // table order (Command.cpp Getcommand → HashList FindAbbrev), so + // `Set defaultb=50` is DefaultBaseFrequency but anything shorter + // ("default", "d") binds DefaultDaily; the bound sits at the unique + // resolution point. + if name.len() >= "defaultb".len() && "defaultbasefrequency".starts_with(name.as_str()) { + if let Ok(f) = value.to_f64(Some(rd.vars)) { + rd.net.base_frequency = f; + } + } + rd.net.options.push((name.clone(), value.text.clone())); + } + for cmd in &raw.commands { + rd.net.commands.push((cmd.verb.clone(), cmd.args.clone())); + } + + // Linecodes first: lines reference them. Then everything else in script + // order per class. + for obj in raw.of_class("linecode") { + let lc = rd.linecode(obj); + rd.net.linecodes.push(lc); + } + for obj in raw.of_class("vsource") { + let vs = rd.vsource(obj); + rd.net.sources.push(vs); + } + for obj in raw.of_class("line") { + rd.line(obj); + } + for obj in raw.of_class("transformer") { + let t = rd.transformer(obj); + rd.net.transformers.push(t); + } + for obj in raw.of_class("load") { + let l = rd.load(obj); + rd.net.loads.push(l); + } + for obj in raw.of_class("capacitor") { + rd.capacitor(obj); + } + for obj in raw.of_class("generator") { + let g = rd.generator(obj); + rd.net.generators.push(g); + } + for obj in raw.of_class("swtcontrol") { + rd.swtcontrol(obj); + } + for obj in raw.of_class("regcontrol") { + rd.regcontrol(obj); + } + for obj in &raw.objects { + if !matches!( + obj.class.as_str(), + "linecode" + | "vsource" + | "line" + | "transformer" + | "load" + | "capacitor" + | "generator" + | "swtcontrol" + | "regcontrol" + ) { + rd.net.untyped.push(UntypedObject::from(obj)); + } + } + + // A dangling linecode reference would otherwise surface only at write + // time; the engine refuses it at parse time. + let known: std::collections::BTreeSet = rd + .net + .linecodes + .iter() + .map(|c| c.name.to_ascii_lowercase()) + .collect(); + let missing: Vec = rd + .net + .lines + .iter() + .filter(|l| !known.contains(&l.linecode.to_ascii_lowercase())) + .map(|l| { + format!( + "line {} references unknown linecode `{}`", + l.name, l.linecode + ) + }) + .collect(); + rd.net.warnings.extend(missing); + + finish_buses(rd, raw) +} + +/// Materializes the accumulated bus states, ground markers, and coordinates. +/// +/// Element processing records ground connections (node 0) verbatim; here +/// each grounded bus gains an explicit perfectly grounded neutral terminal +/// named `max(4, highest node + 1)`, the number PowerModelsDistribution +/// and the public BMOPF examples give the materialized neutral, and every +/// element terminal map is rewritten from "0" to it. +fn finish_buses(mut rd: Reader, raw: &RawDss) -> DistNetwork { + let mut coords: BTreeMap = BTreeMap::new(); + for c in &raw.buscoords { + coords.insert(c.bus.to_ascii_lowercase(), (c.x, c.y)); + } + let buses = std::mem::take(&mut rd.bus_order); + let states = std::mem::take(&mut rd.buses); + let mut net = rd.net; + let mut neutral_names: BTreeMap = BTreeMap::new(); + for id in buses { + let st = &states[&id]; + let mut terminals: Vec = st.nodes.iter().copied().filter(|&n| n != 0).collect(); + terminals.sort_unstable(); + let mut bus = DistBus { + id: st.display.clone(), + terminals: terminals.iter().map(ToString::to_string).collect(), + ..DistBus::default() + }; + if st.nodes.contains(&0) { + let neutral = terminals.last().map_or(4, |&n| n.max(3) + 1); + bus.terminals.push(neutral.to_string()); + bus.grounded.push(neutral.to_string()); + neutral_names.insert(id.clone(), neutral.to_string()); + } + if let Some((x, y)) = coords.get(&id) { + bus.extras.insert("x".into(), (*x).into()); + bus.extras.insert("y".into(), (*y).into()); + } + net.buses.push(bus); + } + + let rewrite = |bus: &str, map: &mut [String]| { + if let Some(neutral) = neutral_names.get(&bus.to_ascii_lowercase()) { + for t in map.iter_mut().filter(|t| *t == "0") { + t.clone_from(neutral); + } + } + }; + for l in &mut net.lines { + rewrite(&l.bus_from, &mut l.terminal_map_from); + rewrite(&l.bus_to, &mut l.terminal_map_to); + } + for s in &mut net.switches { + rewrite(&s.bus_from, &mut s.terminal_map_from); + rewrite(&s.bus_to, &mut s.terminal_map_to); + } + for l in &mut net.loads { + rewrite(&l.bus, &mut l.terminal_map); + } + for g in &mut net.generators { + rewrite(&g.bus, &mut g.terminal_map); + } + for s in &mut net.shunts { + rewrite(&s.bus, &mut s.terminal_map); + } + for v in &mut net.sources { + rewrite(&v.bus, &mut v.terminal_map); + } + for t in &mut net.transformers { + for w in &mut t.windings { + rewrite(&w.bus, &mut w.terminal_map); + } + } + net +} + +impl From<&RawObject> for UntypedObject { + fn from(obj: &RawObject) -> Self { + UntypedObject { + class: obj.class.clone(), + name: obj.name.clone(), + props: obj + .props + .iter() + .map(|p| (p.name.clone(), p.value.text.clone())) + .collect(), + } + } +} + +struct BusState { + display: String, + nodes: std::collections::BTreeSet, +} + +struct Reader<'a> { + net: DistNetwork, + buses: BTreeMap, + bus_order: Vec, + /// Linecode name (lowercase) → meters per its length unit, `None` when + /// the linecode has no units. Lines need it: `ConvertLineUnits` couples + /// the two sides' units. + linecode_units: BTreeMap>, + vars: &'a VarMap, +} + +/// Last-wins view of an object's resolved properties, plus the set of names +/// actually written (for provenance and extras). +struct Props<'a> { + by_name: BTreeMap<&'a str, &'a Value>, + consumed: std::cell::RefCell>, +} + +impl<'a> Props<'a> { + fn new(obj: &'a RawObject) -> Self { + let mut by_name = BTreeMap::new(); + for p in &obj.props { + if let Some(n) = &p.name { + by_name.insert(n.as_str(), &p.value); + } + } + Props { + by_name, + consumed: std::cell::RefCell::new(Vec::new()), + } + } + + fn get(&self, name: &'a str) -> Option<&'a Value> { + self.consumed.borrow_mut().push(name); + self.by_name.get(name).copied() + } + + /// Specified properties the typed fields did not consume, for extras. + fn leftovers(&self) -> Vec<(&str, &Value)> { + let consumed = self.consumed.borrow(); + self.by_name + .iter() + .filter(|(k, _)| !consumed.contains(*k) && **k != "like") + .map(|(k, v)| (*k, *v)) + .collect() + } +} + +impl Reader<'_> { + fn warn(&mut self, msg: impl Into) { + self.net.warnings.push(msg.into()); + } + + fn defaulted(&mut self, class: &str, name: &str, field: &'static str) { + let fields = self + .net + .defaulted + .entry(format!("{class}.{name}")) + .or_default(); + if !fields.contains(&field) { + fields.push(field); + } + } + + fn f64_prop(&mut self, p: Option<&Value>) -> Option { + p.and_then(|v| v.to_f64(Some(self.vars)).ok()) + } + + fn usize_prop(&mut self, p: Option<&Value>) -> Option { + p.and_then(|v| v.to_i64(Some(self.vars)).ok()) + .map(|i| usize::try_from(i).unwrap_or(0)) + } + + /// Meters per source length unit, or `None` when no conversion applies: + /// the property is missing, `none`, or a code `GetUnitsCode` + /// (Shared/LineUnits.cpp) does not recognize — the engine maps unknown + /// codes to UNITS_NONE. Unknown codes warn. + fn units_code(&mut self, units: Option<&str>, class: &str, name: &str) -> Option { + let u = units?; + if let Some(f) = dd::unit_to_meters(u) { + return Some(f); + } + if !u.to_ascii_lowercase().starts_with("no") { + self.net.warnings.push(format!( + "{class} {name}: unknown units `{u}`; treated as none" + )); + } + None + } + + /// Extras value for a written numeric token: the literal text when it + /// is already a plain number, otherwise the evaluated value — RPN or + /// `@var` text is no use to the dss writer, which needs an argument the + /// engine can read back. + fn stash_numeric(&self, v: &Value) -> serde_json::Value { + if v.text.parse::().is_ok() { + v.text.clone().into() + } else { + match v.to_f64(Some(self.vars)) { + Ok(n) => n.into(), + Err(_) => v.text.clone().into(), + } + } + } + + /// `kv` and `phases` for the dss writer: the written token (evaluated + /// when not a plain number), the materialized default otherwise. + fn stash_kv_and_phases(&self, props: &Props, extras: &mut Extras, kv: f64, phases: usize) { + let kv_value = match props.by_name.get("kv") { + Some(written) => self.stash_numeric(written), + None => kv.into(), + }; + extras.insert("kv".into(), kv_value); + let phases_value = match props.by_name.get("phases") { + Some(written) => self.stash_numeric(written), + None => (phases as u64).into(), + }; + extras.insert("phases".into(), phases_value); + // A 1 phase delta types as SinglePhase, indistinguishable from a wye + // spot load without the written token; the writer reads this stash to + // re-emit conn=delta. + if let Some(written) = props.by_name.get("conn") { + extras.insert("conn".into(), written.text.clone().into()); + } + } + + /// The property's value, or the class default recorded with provenance. + fn f64_or( + &mut self, + props: &Props, + key: &'static str, + class: &str, + name: &str, + default: f64, + ) -> f64 { + if let Some(v) = self.f64_prop(props.get(key)) { + v + } else { + self.defaulted(class, name, key); + default + } + } + + fn usize_or( + &mut self, + props: &Props, + key: &'static str, + class: &str, + name: &str, + default: usize, + ) -> usize { + if let Some(v) = self.usize_prop(props.get(key)) { + v + } else { + self.defaulted(class, name, key); + default + } + } + + /// Registers a bus connection and returns the terminal names for the + /// element. `phases` conductors default to nodes 1..=phases; conductors + /// beyond that default to ground. `keep` limits how many conductors the + /// terminal map lists (delta maps exclude the unused trailing conductor). + fn terminals( + &mut self, + spec: &BusSpec, + phases: usize, + nconds: usize, + keep: usize, + ) -> Vec { + let mut nodes: Vec = (1..=i32::try_from(nconds).unwrap_or(i32::MAX)).collect(); + for n in nodes.iter_mut().skip(phases) { + *n = 0; + } + for (i, &n) in spec.nodes.iter().enumerate().take(nconds) { + nodes[i] = n.max(0); // parser marks bad nodes -1; treat as ground + } + let key = spec.name.to_ascii_lowercase(); + let state = self.buses.entry(key.clone()).or_insert_with(|| { + self.bus_order.push(key.clone()); + BusState { + display: spec.name.clone(), + nodes: std::collections::BTreeSet::new(), + } + }); + for &n in nodes.iter().take(keep) { + state.nodes.insert(n); + } + nodes.truncate(keep); + nodes.iter().map(ToString::to_string).collect() + } + + // ----- linecode ------------------------------------------------------ + + fn linecode(&mut self, obj: &RawObject) -> DistLineCode { + let props = Props::new(obj); + let n = self.usize_or( + &props, + "nphases", + "linecode", + &obj.name, + dd::linecode::NPHASES, + ); + let units = props.get("units").map(|v| v.text.clone()); + let units_m = self.units_code(units.as_deref(), "linecode", &obj.name); + let per_meter = units_m.unwrap_or(1.0); + self.linecode_units + .insert(obj.name.to_ascii_lowercase(), units_m); + + let freq = self + .f64_prop(props.get("basefreq")) + .unwrap_or(self.net.base_frequency); + + let z = self.impedance_matrices( + &props, + n, + "linecode", + &obj.name, + dd::line::R1, + dd::line::X1, + dd::line::R0, + dd::line::X0, + dd::line::C1_NF, + dd::line::C0_NF, + ); + if z.all_default { + self.defaulted("linecode", &obj.name, "rmatrix"); + } + + // Half the total line charging susceptance at each end; OpenDSS + // carries one C matrix for the whole pi section. + let b_half = scale_mat( + &z.c_nf, + std::f64::consts::TAU * freq * 1e-9 / per_meter / 2.0, + ); + let zero = vec![vec![0.0; n]; n]; + + // i_max carries the emergency rating: PMD's cm_ub and the public + // BMOPF examples both use emergamps. normamps stays in extras. + let amps = self.f64_or( + &props, + "emergamps", + "linecode", + &obj.name, + dd::line::EMERGAMPS, + ); + let i_max = Some(vec![amps; n]); + + let mut extras = extras_from_leftovers(&props); + if let Some(u) = units { + extras.insert("units".into(), u.into()); + } + for (key, text) in z.malformed { + extras.insert(key.to_string(), text.into()); + } + DistLineCode { + name: obj.name.clone(), + n_conductors: n, + r_series: scale_mat(&z.r, 1.0 / per_meter), + x_series: scale_mat(&z.x, 1.0 / per_meter), + g_from: zero.clone(), + b_from: b_half.clone(), + g_to: zero, + b_to: b_half, + i_max, + s_max: None, + extras, + } + } + + /// R, X (ohm per unit length) and C (nF per unit length) matrices from + /// either explicit matrices or sequence values. + #[allow(clippy::too_many_arguments)] + fn impedance_matrices( + &mut self, + props: &Props, + n: usize, + class: &str, + name: &str, + r1d: f64, + x1d: f64, + r0d: f64, + x0d: f64, + c1d: f64, + c0d: f64, + ) -> SeriesImpedance { + let mut malformed: Vec<(&'static str, String)> = Vec::new(); + let mut rows = |key: &'static str| -> Option { + let v = props.get(key)?; + let parsed = v + .to_rows(Some(self.vars)) + .ok() + .and_then(|rows| square_from_rows(&rows, n)); + if parsed.is_none() { + malformed.push((key, v.text.clone())); + } + parsed + }; + let rm = rows("rmatrix"); + let xm = rows("xmatrix"); + let cm = rows("cmatrix"); + // The engine rejects the whole script on a bad matrix; the liberal + // reader falls back to the sequence values but says so and keeps + // the text. A written property is never reported as defaulted. + for (key, _) in &malformed { + self.warn(format!( + "{class} {name}: `{key}` does not parse as a {n}x{n} matrix; \ + sequence values apply and the text is kept in extras" + )); + } + let any_written = [ + "rmatrix", "xmatrix", "cmatrix", "r1", "x1", "r0", "x0", "c1", "c0", "b1", "b0", + ] + .iter() + .any(|k| props.by_name.contains_key(*k)); + + let seq = |props: &Props, k1: &'static str, k0: &'static str, d1: f64, d0: f64| { + let v1 = props + .get(k1) + .and_then(|v| v.to_f64(Some(self.vars)).ok()) + .unwrap_or(d1); + let v0 = props + .get(k0) + .and_then(|v| v.to_f64(Some(self.vars)).ok()) + .unwrap_or(d0); + // Symmetric component to phase: diag (2 z1 + z0)/3, off + // diagonal (z0 - z1)/3. + let s = (2.0 * v1 + v0) / 3.0; + let m = (v0 - v1) / 3.0; + let mut mat = vec![vec![m; n]; n]; + for (i, row) in mat.iter_mut().enumerate() { + row[i] = s; + } + mat + }; + + SeriesImpedance { + r: rm.unwrap_or_else(|| seq(props, "r1", "r0", r1d, r0d)), + x: xm.unwrap_or_else(|| seq(props, "x1", "x0", x1d, x0d)), + c_nf: cm.unwrap_or_else(|| seq(props, "c1", "c0", c1d, c0d)), + all_default: !any_written, + malformed, + } + } + + // ----- vsource ------------------------------------------------------- + + fn vsource(&mut self, obj: &RawObject) -> VoltageSource { + let props = Props::new(obj); + let phases = self.usize_or(&props, "phases", "vsource", &obj.name, dd::vsource::PHASES); + let basekv = self.f64_or(&props, "basekv", "vsource", &obj.name, dd::vsource::BASEKV); + let pu = self.f64_or(&props, "pu", "vsource", &obj.name, dd::vsource::PU); + let angle_deg = self.f64_or( + &props, + "angle", + "vsource", + &obj.name, + dd::vsource::ANGLE_DEG, + ); + let spec = if let Some(v) = props.get("bus1") { + v.to_bus_spec() + } else { + self.defaulted("vsource", &obj.name, "bus1"); + Value::new(dd::vsource::BUS1).to_bus_spec() + }; + let map = self.terminals(&spec, phases, phases + 1, phases + 1); + + // VSource.cpp ~995-1003: one phase takes basekv outright, otherwise + // the per phase magnitude is basekv / (2 sin(pi/n)) — the chord of + // the n-gon, which is sqrt(3) only at n = 3. Angles space at + // -360/n degrees (positive sequence, ~1272), wrapped to (-180, 180] + // in radians, matching the reference conversion. + let v_ln = if phases == 1 { + basekv * 1e3 * pu + } else { + basekv * 1e3 * pu / (2.0 * (std::f64::consts::PI / phases as f64).sin()) + }; + let mut v_magnitude = vec![v_ln; phases]; + let mut v_angle: Vec = (0..phases) + .map(|k| { + let deg = angle_deg - 360.0 / phases as f64 * k as f64; + let a = deg.to_radians(); + // rem_euclid yields [0, tau); shifting puts the result in + // [-pi, pi), and the reference maps the open end to +pi. + let shifted = (a + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU); + if shifted <= 0.0 { + std::f64::consts::PI + } else { + shifted - std::f64::consts::PI + } + }) + .collect(); + // The neutral conductor rides at ground. + v_magnitude.push(0.0); + v_angle.push(0.0); + + // The raw base voltage rides in extras: the magnitudes fold in pu, + // and downstream writers need the unscaled base. + let mut extras = extras_from_leftovers(&props); + extras.insert("basekv".into(), basekv.into()); + extras.insert("angle".into(), angle_deg.into()); + if (pu - 1.0).abs() > 0.0 { + extras.insert("pu".into(), pu.into()); + } + VoltageSource { + name: obj.name.clone(), + bus: spec.name, + terminal_map: map, + v_magnitude, + v_angle, + extras, + } + } + + // ----- line / switch ------------------------------------------------- + + fn line(&mut self, obj: &RawObject) { + let props = Props::new(obj); + let phases = self + .usize_prop(props.get("phases")) + .unwrap_or(dd::line::PHASES); + let spec1 = bus_spec(props.get("bus1"), ""); + let spec2 = bus_spec(props.get("bus2"), ""); + // A line has no neutral conductor of its own: nconds == phases. + let map_from = self.terminals(&spec1, phases, phases, phases); + let map_to = self.terminals(&spec2, phases, phases, phases); + + let is_switch = props.get("switch").is_some_and(super::lex::Value::to_bool); + if is_switch { + let amps = self.f64_or(&props, "emergamps", "line", &obj.name, dd::line::EMERGAMPS); + let i_max = Some(vec![amps; phases]); + let mut extras = extras_from_leftovers(&props); + // OpenDSS replaces a switch line's impedance with fixed dummy + // values; record anything written so nothing drops silently. + for k in ["linecode", "length", "r1", "x1", "rmatrix", "xmatrix"] { + if let Some(v) = props.by_name.get(k) { + extras.insert(k.to_string(), v.text.clone().into()); + self.warn(format!( + "line {}: `{k}` is ignored by OpenDSS on switch=yes; kept in extras", + obj.name + )); + } + } + self.net.switches.push(DistSwitch { + name: obj.name.clone(), + bus_from: spec1.name, + bus_to: spec2.name, + terminal_map_from: map_from, + terminal_map_to: map_to, + open: false, + i_max, + extras, + }); + return; + } + + let length_units = props.get("units").map(|v| v.text.clone()); + let line_units_m = self.units_code(length_units.as_deref(), "line", &obj.name); + let length = self.f64_or(&props, "length", "line", &obj.name, dd::line::LENGTH); + + // ConvertLineUnits (Shared/LineUnits.cpp ~166) is 1.0 when either + // side is UNITS_NONE, and the engine scales the linecode matrices + // by Len / FUnitsConvert (Line.cpp ~1177). A unitless line length + // is therefore in the linecode's units, and a unitless linecode is + // per line length unit, so the raw length preserves the Z·length + // product. + let mut malformed: Vec<(&'static str, String)> = Vec::new(); + let (linecode, length_factor) = if let Some(code) = props.get("linecode") { + let lc_units_m = self + .linecode_units + .get(&code.text.to_ascii_lowercase()) + .copied() + .flatten(); + let factor = match (lc_units_m, line_units_m) { + (Some(_), Some(lf)) => lf, + (Some(lcf), None) => lcf, + (None, _) => 1.0, + }; + (code.text.clone(), factor) + } else { + let factor = line_units_m.unwrap_or(1.0); + let (code, bad) = self.synthesize_linecode(&props, phases, factor, &obj.name); + malformed = bad; + (code, factor) + }; + + let mut extras = extras_from_leftovers(&props); + if let Some(u) = length_units { + extras.insert("units".into(), u.into()); + } + for (key, text) in malformed { + extras.insert(key.to_string(), text.into()); + } + self.net.lines.push(DistLine { + name: obj.name.clone(), + bus_from: spec1.name, + bus_to: spec2.name, + terminal_map_from: map_from, + terminal_map_to: map_to, + linecode, + length: length * length_factor, + extras, + }); + } + + /// A line without `linecode=` carries inline or default impedance; + /// materialize it as a linecode named `_line_` in the line's own + /// length units. Malformed matrix texts return for the line's extras. + fn synthesize_linecode( + &mut self, + props: &Props, + phases: usize, + length_factor: f64, + line_name: &str, + ) -> (String, Vec<(&'static str, String)>) { + let z = self.impedance_matrices( + props, + phases, + "line", + line_name, + dd::line::R1, + dd::line::X1, + dd::line::R0, + dd::line::X0, + dd::line::C1_NF, + dd::line::C0_NF, + ); + if z.all_default { + self.defaulted("line", line_name, "r1"); + self.defaulted("line", line_name, "x1"); + } + let b_half = scale_mat( + &z.c_nf, + std::f64::consts::TAU * self.net.base_frequency * 1e-9 / length_factor / 2.0, + ); + let zero = vec![vec![0.0; phases]; phases]; + let amps = self.f64_or(props, "emergamps", "line", line_name, dd::line::EMERGAMPS); + let i_max = Some(vec![amps; phases]); + let name = format!("_line_{line_name}"); + self.net.linecodes.push(DistLineCode { + name: name.clone(), + n_conductors: phases, + r_series: scale_mat(&z.r, 1.0 / length_factor), + x_series: scale_mat(&z.x, 1.0 / length_factor), + g_from: zero.clone(), + b_from: b_half.clone(), + g_to: zero, + b_to: b_half, + i_max, + s_max: None, + extras: Extras::new(), + }); + (name, z.malformed) + } + + // ----- load ---------------------------------------------------------- + + /// Final (kWBase, kvarBase, PFNominal, LoadSpecType) after the last + /// edit boundary, with write provenance for kw and pf. + /// + /// Load.cpp runs RecalcElementData at the end of EVERY Edit (~773), so + /// kw/kvar/pf fold per edit, not flat. Within an edit, kw (case 4, + /// ~691) sets LoadSpecType 0 (kW + PF), kvar (case 12, ~753) sets 1 + /// (kW + kvar), and pf (case 5, ~699) updates PFNominal without + /// touching the spec. The boundary recalc (~1342) rederives kvar from + /// kW and PF under spec 0, and PFNominal from kW and kvar under spec 1 + /// (~1352-1360). like= splices the source's boundaries in the raw + /// layer, matching MakeLike's copy of the recalced state. + fn load_power(&mut self, obj: &RawObject) -> LoadPower { + let mut s = LoadPower { + kw: dd::load::KW, + // Constructor kvarBase is 5.0, never observable: spec 1 + // requires a kvar write and the first spec 0 boundary + // overwrites the seed. + kvar: 0.0, + pf: dd::load::PF, + spec_kvar: false, // LoadSpecType: false = 0, true = 1 + kw_written: false, + pf_written: false, + }; + let mut start = 0; + for end in obj.edit_bounds() { + for p in &obj.props[start..end] { + let Some(key @ ("kw" | "kvar" | "pf")) = p.name.as_deref() else { + continue; + }; + let Some(v) = self.f64_prop(Some(&p.value)) else { + continue; + }; + match key { + "kw" => { + s.kw = v; + s.spec_kvar = false; + s.kw_written = true; + } + "kvar" => { + s.kvar = v; + s.spec_kvar = true; + } + _ => { + s.pf = v; + s.pf_written = true; + } + } + } + start = end; + // RecalcElementData at the edit boundary. + if s.spec_kvar { + let kva = s.kw.hypot(s.kvar); + if kva > 0.0 { + s.pf = s.kw / kva; + // Mixed signs make PF negative (Sign(kWBase*kvarBase)). + if s.kw * s.kvar < 0.0 { + s.pf = -s.pf; + } + } + } else { + s.kvar = s.kw * (1.0 / (s.pf * s.pf) - 1.0).sqrt(); + if s.pf < 0.0 { + s.kvar = -s.kvar; + } + } + } + s + } + + fn load(&mut self, obj: &RawObject) -> DistLoad { + let props = Props::new(obj); + let phases = self.usize_or(&props, "phases", "load", &obj.name, dd::load::PHASES); + let conn_delta = props.get("conn").is_some_and(|v| { + v.text.to_ascii_lowercase().starts_with('d') || v.text.eq_ignore_ascii_case("ll") + }); + let kv = self.f64_or(&props, "kv", "load", &obj.name, dd::load::KV); + let LoadPower { + kw, + kvar: q_total, + pf, + spec_kvar, + kw_written, + pf_written, + } = self.load_power(obj); + if !kw_written { + self.defaulted("load", &obj.name, "kw"); + } + // Mark the walked properties consumed so they stay out of extras. + let _ = (props.get("kw"), props.get("kvar"), props.get("pf")); + // When the final spec is 0, q derives from the power factor; the + // source pf rides in extras so the dss writer can emit pf= and let + // the engine do its own trigonometry — transcendental rounding + // across implementations would otherwise leak into regenerated + // cases. Under spec 1 the writer emits kvar=. + let mut pf_source: Option = None; + if !spec_kvar { + if !pf_written { + self.defaulted("load", &obj.name, "pf"); + } + pf_source = Some(pf); + } + let model = self + .usize_prop(props.get("model")) + .map_or(dd::load::MODEL, |m| i64::try_from(m).unwrap_or(i64::MAX)); + if model != 1 { + self.warn(format!( + "load {}: model={model} is not constant power; downstream formats treat it as constant power", + obj.name + )); + } + + let spec = bus_spec(props.get("bus1"), ""); + let nconds = if conn_delta && phases == 3 { + phases + } else { + phases + 1 + }; + let map = self.terminals(&spec, phases, nconds, nconds); + + let configuration = if phases == 1 { + Configuration::SinglePhase + } else if conn_delta { + Configuration::Delta + } else { + Configuration::Wye + }; + + // kv is the load's own base and model its dss load model code; + // both ride in extras for the writers (the kv default materializes + // here like every other constructor default), while the typed + // fields hold explicit power per phase. phases rides too: a 2 + // phase delta load also has 3 conductors, so the terminal map + // alone cannot reconstruct `phases=`. + let mut extras = extras_from_leftovers(&props); + self.stash_kv_and_phases(&props, &mut extras, kv, phases); + if let Some(pf) = pf_source { + extras.insert("pf".into(), pf.into()); + } + if model != 1 { + extras.insert("model".into(), model.into()); + } + DistLoad { + name: obj.name.clone(), + bus: spec.name, + terminal_map: map, + configuration, + p_nom: vec![kw * 1e3 / phases as f64; phases], + q_nom: vec![q_total * 1e3 / phases as f64; phases], + extras, + } + } + + // ----- transformer --------------------------------------------------- + + fn transformer(&mut self, obj: &RawObject) -> DistTransformer { + // Order matters: wdg= switches the winding under edit, windings= + // reallocates. Walk assignments sequentially. + let mut phases = dd::transformer::PHASES; + let mut n_windings = dd::transformer::WINDINGS; + let mut windings = vec![WindingRaw::default(); n_windings]; + let mut active = 0usize; + let mut xhl = dd::transformer::XHL; + let mut xht = dd::transformer::XHT; + let mut xlt = dd::transformer::XLT; + let mut xhl_specified = false; + let mut extras = Extras::new(); + let conn_is_delta = + |t: &str| t.to_ascii_lowercase().starts_with('d') || t.eq_ignore_ascii_case("ll"); + for p in &obj.props { + let Some(name) = &p.name else { continue }; + let v = &p.value; + match name.as_str() { + "phases" => { + phases = self.usize_prop(Some(v)).unwrap_or(phases); + } + "windings" => { + n_windings = self.usize_prop(Some(v)).unwrap_or(n_windings).max(1); + windings = vec![WindingRaw::default(); n_windings]; + active = 0; + } + "wdg" => { + let k = self.usize_prop(Some(v)).unwrap_or(1).max(1); + grow(&mut windings, k, &mut n_windings); + active = k - 1; + } + "bus" => windings[active].bus = Some(v.to_bus_spec()), + "conn" => windings[active].conn_delta = conn_is_delta(&v.text), + "kv" | "kva" | "tap" | "%r" => { + let parsed = self.f64_prop(Some(v)); + let w = &mut windings[active]; + match name.as_str() { + "kv" => { + w.kv = parsed.unwrap_or(w.kv); + w.kv_specified = true; + } + "kva" => { + w.kva = parsed.unwrap_or(w.kva); + w.kva_specified = true; + } + "tap" => w.tap = parsed.unwrap_or(w.tap), + _ => w.r_pct = parsed.unwrap_or(w.r_pct), + } + } + "buses" | "conns" => { + let items = v.to_string_list(Some(self.vars)); + grow(&mut windings, items.len(), &mut n_windings); + apply_winding_strings(&mut windings, name, &items); + } + "kvs" | "kvas" | "taps" | "%rs" => match v.to_vector(Some(self.vars)) { + Ok(items) => { + grow(&mut windings, items.len(), &mut n_windings); + apply_winding_numbers(&mut windings, name, &items); + } + Err(e) => self.warn(format!("transformer {}: {name}: {e}", obj.name)), + }, + "%loadloss" => { + // The engine splits load loss across the first two + // windings: %R each = %loadloss / 2 (Transformer.cpp, + // property 26). The written value also rides in extras + // for the canonical echo. + if let Some(ll) = self.f64_prop(Some(v)) { + for w in windings.iter_mut().take(2) { + w.r_pct = ll / 2.0; + } + } + extras.insert("%loadloss".to_string(), v.text.clone().into()); + } + "xhl" | "x12" => { + xhl = self.f64_prop(Some(v)).unwrap_or(xhl); + xhl_specified = true; + } + "xht" | "x13" => xht = self.f64_prop(Some(v)).unwrap_or(xht), + "xlt" | "x23" => xlt = self.f64_prop(Some(v)).unwrap_or(xlt), + other => { + extras.insert(other.to_string(), v.text.clone().into()); + } + } + } + + if !xhl_specified { + self.defaulted("transformer", &obj.name, "xhl"); + } + let out = self.finish_windings(&windings, phases, &obj.name); + + let xsc_pct = if n_windings >= 3 { + vec![xhl, xht, xlt] + } else { + vec![xhl] + }; + DistTransformer { + name: obj.name.clone(), + windings: out, + xsc_pct, + phases, + extras, + } + } + + /// Resolves winding bus specs, terminal maps, and SI ratings, recording + /// provenance for defaulted kv/kva. + fn finish_windings( + &mut self, + windings: &[WindingRaw], + phases: usize, + name: &str, + ) -> Vec { + let mut out = Vec::with_capacity(windings.len()); + for (i, w) in windings.iter().enumerate() { + if !w.kv_specified { + self.defaulted("transformer", name, "kv"); + } + if !w.kva_specified { + self.defaulted("transformer", name, "kva"); + } + let spec = w + .bus + .clone() + .unwrap_or_else(|| Value::new(format!("{name}_w{}", i + 1)).to_bus_spec()); + // Each winding terminal has phases + 1 conductors; wye keeps the + // neutral in the map, delta leaves the unused conductor out. + let keep = if w.conn_delta { phases } else { phases + 1 }; + let map = self.terminals(&spec, phases, phases + 1, keep); + out.push(Winding { + bus: spec.name, + terminal_map: map, + conn: if w.conn_delta { + WindingConn::Delta + } else { + WindingConn::Wye + }, + v_ref: w.kv * 1e3, + s_rating: w.kva * 1e3, + r_pct: w.r_pct, + tap: w.tap, + }); + } + out + } + + // ----- capacitor → shunt --------------------------------------------- + + fn capacitor(&mut self, obj: &RawObject) { + let props = Props::new(obj); + if props.by_name.contains_key("bus2") { + self.warn(format!( + "capacitor {}: series capacitors (bus2) are not typed yet; kept untyped", + obj.name + )); + self.net.untyped.push(UntypedObject::from(obj)); + return; + } + let phases = self.usize_or( + &props, + "phases", + "capacitor", + &obj.name, + dd::capacitor::PHASES, + ); + // InterpretConnection (Capacitor.cpp ~180): `d*` and `ll` are delta. + let conn_delta = props.get("conn").is_some_and(|v| { + v.text.to_ascii_lowercase().starts_with('d') || v.text.eq_ignore_ascii_case("ll") + }); + if conn_delta { + self.warn(format!( + "capacitor {}: delta connection is not typed yet; kept untyped", + obj.name + )); + self.net.untyped.push(UntypedObject::from(obj)); + return; + } + let kvar_first = props + .get("kvar") + .and_then(|v| v.to_vector(Some(self.vars)).ok()) + .and_then(|v| v.first().copied()); + let kvar = if let Some(q) = kvar_first { + q + } else { + self.defaulted("capacitor", &obj.name, "kvar"); + dd::capacitor::KVAR + }; + let kv = self.f64_or(&props, "kv", "capacitor", &obj.name, dd::capacitor::KV); + // Capacitor.cpp ~620-630: a wye bank's kv is line to line for 2 or + // 3 phases, line to neutral otherwise. + let v_phase = if phases == 2 || phases == 3 { + kv * 1e3 / 3f64.sqrt() + } else { + kv * 1e3 + }; + let b_phase = kvar * 1e3 / phases as f64 / (v_phase * v_phase); + + let spec = bus_spec(props.get("bus1"), ""); + // The default return (bus2) is the same bus's ground; register the + // ground connection but keep the map and matrices phase only, the + // shape a shunt-to-ground admittance has downstream. + let map = self.terminals(&spec, phases, phases + 1, phases); + let n = map.len(); + let mut b = vec![vec![0.0; n]; n]; + for (i, row) in b.iter_mut().enumerate().take(phases) { + row[i] = b_phase; + } + // The written pair regenerates verbatim in the dss writer; the b + // matrix is the model truth either way. + let mut extras = extras_from_leftovers(&props); + self.stash_kv_and_phases(&props, &mut extras, kv, phases); + extras.insert("kvar".into(), kvar.into()); + self.net.shunts.push(DistShunt { + name: obj.name.clone(), + bus: spec.name, + terminal_map: map, + g: vec![vec![0.0; n]; n], + b, + extras, + }); + } + + // ----- generator ----------------------------------------------------- + + fn generator(&mut self, obj: &RawObject) -> DistGenerator { + let props = Props::new(obj); + let phases = self.usize_or( + &props, + "phases", + "generator", + &obj.name, + dd::generator::PHASES, + ); + // InterpretConnection (generator.cpp ~299): `d*` and `ll` are delta. + let conn_delta = props.get("conn").is_some_and(|v| { + v.text.to_ascii_lowercase().starts_with('d') || v.text.eq_ignore_ascii_case("ll") + }); + // generator.cpp: kw and pf writes (props 4-5, side effect ~588) + // call SyncUpPowerQuantities (~3879), rederiving kvar from kW and + // PF; a kvar write (Set_Presentkvar, ~3857) stores kvar and + // rederives PF from kW and kvar. The state carries across writes + // in source order, seeded by the constructor values. Verified + // asymmetry with Load: the generator resyncs eagerly AT each write + // and has no end-of-edit recalc, so a flat fold over all writes is + // correct here while loads need the per edit boundary walk above. + let mut kw = dd::generator::KW; + let mut kvar = dd::generator::KVAR; + let mut pf = dd::generator::PF; + let (mut kw_written, mut q_written) = (false, false); + for p in &obj.props { + let Some(key @ ("kw" | "kvar" | "pf")) = p.name.as_deref() else { + continue; + }; + let Some(v) = self.f64_prop(Some(&p.value)) else { + continue; + }; + match key { + "kw" | "pf" => { + if key == "kw" { + kw = v; + kw_written = true; + } else { + pf = v; + q_written = true; + } + if pf != 0.0 { + kvar = kw * (pf.acos().tan()).copysign(pf); + } + } + _ => { + kvar = v; + q_written = true; + let kva = kw.hypot(kvar); + pf = if kva == 0.0 { 1.0 } else { kw / kva }; + if kw * kvar < 0.0 { + pf = -pf; + } + } + } + } + if !kw_written { + self.defaulted("generator", &obj.name, "kw"); + } + if !q_written { + self.defaulted("generator", &obj.name, "kvar"); + } + // Mark the walked properties consumed so they stay out of extras. + let _ = (props.get("kw"), props.get("kvar"), props.get("pf")); + let kv = self.f64_or(&props, "kv", "generator", &obj.name, dd::generator::KV); + let maxkvar = self.f64_prop(props.get("maxkvar")); + let minkvar = self.f64_prop(props.get("minkvar")); + + let spec = bus_spec(props.get("bus1"), ""); + let nconds = if conn_delta && phases == 3 { + phases + } else { + phases + 1 + }; + let map = self.terminals(&spec, phases, nconds, nconds); + + let per_phase = |total_kw: f64| vec![total_kw * 1e3 / phases as f64; phases]; + let mut extras = extras_from_leftovers(&props); + self.stash_kv_and_phases(&props, &mut extras, kv, phases); + DistGenerator { + name: obj.name.clone(), + bus: spec.name, + terminal_map: map, + configuration: if phases == 1 { + Configuration::SinglePhase + } else if conn_delta { + Configuration::Delta + } else { + Configuration::Wye + }, + p_nom: per_phase(kw), + q_nom: per_phase(kvar), + p_min: None, + p_max: None, + q_min: minkvar.map(per_phase), + q_max: maxkvar.map(per_phase), + cost: None, + extras, + } + } + + // ----- controls ------------------------------------------------------ + + fn swtcontrol(&mut self, obj: &RawObject) { + let props = Props::new(obj); + let Some(target) = props.get("switchedobj").map(|v| v.text.clone()) else { + self.warn(format!("swtcontrol {}: no SwitchedObj; ignored", obj.name)); + return; + }; + // Element references compare class names case insensitively, like + // every dss identifier. + let line_name = match target.split_once('.') { + Some((class, rest)) if class.eq_ignore_ascii_case("line") => rest, + _ => target.as_str(), + }; + // The present state follows the last `action`/`state` assignment in + // source order; `normal` applies only when neither was written. + let mut open = None; + for p in &obj.props { + match p.name.as_deref() { + Some("action" | "state") => { + open = Some(p.value.text.to_ascii_lowercase().starts_with('o')); + } + Some("normal") if open.is_none() => { + open = Some(p.value.text.to_ascii_lowercase().starts_with('o')); + } + _ => {} + } + } + let open = open.unwrap_or(false); + match self + .net + .switches + .iter_mut() + .find(|s| s.name.eq_ignore_ascii_case(line_name)) + { + Some(sw) => sw.open = open, + None => self.warn(format!( + "swtcontrol {}: switched object `{target}` is not a switch line", + obj.name + )), + } + } + + fn regcontrol(&mut self, obj: &RawObject) { + let props = Props::new(obj); + let target = props + .get("transformer") + .map_or_else(String::new, |v| v.text.clone()); + self.warn(format!( + "regcontrol {}: voltage regulation is ignored; transformer `{target}` keeps its written taps", + obj.name + )); + self.net.untyped.push(UntypedObject::from(obj)); + } +} + +/// Every entry times `k`. +fn scale_mat(m: &Mat, k: f64) -> Mat { + m.iter() + .map(|row| row.iter().map(|v| v * k).collect()) + .collect() +} + +fn bus_spec(v: Option<&Value>, fallback: &str) -> BusSpec { + v.map_or_else( + || Value::new(fallback).to_bus_spec(), + super::lex::Value::to_bus_spec, + ) +} + +fn extras_from_leftovers(props: &Props) -> Extras { + let mut extras = Extras::new(); + for (k, v) in props.leftovers() { + extras.insert(k.to_string(), v.text.clone().into()); + } + extras +} + +/// `buses=(...)` / `conns=(...)` applied across windings. +fn apply_winding_strings(windings: &mut [WindingRaw], name: &str, items: &[String]) { + let conn_is_delta = + |t: &str| t.to_ascii_lowercase().starts_with('d') || t.eq_ignore_ascii_case("ll"); + for (i, item) in items.iter().enumerate() { + let w = &mut windings[i]; + if name == "buses" { + w.bus = Some(Value::new(item.clone()).to_bus_spec()); + } else { + w.conn_delta = conn_is_delta(item); + } + } +} + +/// A numeric transformer array (`kvs=(...)`, RPN entries included) applied +/// across windings. +fn apply_winding_numbers(windings: &mut [WindingRaw], name: &str, items: &[f64]) { + for (i, &item) in items.iter().enumerate() { + let w = &mut windings[i]; + match name { + "kvs" => { + w.kv = item; + w.kv_specified = true; + } + "kvas" => { + w.kva = item; + w.kva_specified = true; + } + "taps" => w.tap = item, + _ => w.r_pct = item, + } + } +} + +/// A load's power state after the last edit boundary: the engine's +/// (kWBase, kvarBase, PFNominal, LoadSpecType), plus which of kw/pf were +/// ever written (for default provenance). +struct LoadPower { + kw: f64, + kvar: f64, + pf: f64, + /// LoadSpecType: false = 0 (kW + PF), true = 1 (kW + kvar). + spec_kvar: bool, + kw_written: bool, + pf_written: bool, +} + +/// Series impedance of a linecode or inline line, per source length unit. +struct SeriesImpedance { + r: Mat, + x: Mat, + c_nf: Mat, + /// No matrix or sequence property was written at all. + all_default: bool, + /// Matrix properties written but unparseable as n x n, with their raw + /// text (the engine rejects the whole script; the reader keeps them + /// in extras). + malformed: Vec<(&'static str, String)>, +} + +#[derive(Clone)] +struct WindingRaw { + bus: Option, + conn_delta: bool, + kv: f64, + kva: f64, + tap: f64, + r_pct: f64, + kv_specified: bool, + kva_specified: bool, +} + +impl Default for WindingRaw { + fn default() -> Self { + WindingRaw { + bus: None, + conn_delta: false, + kv: dd::transformer::KV, + kva: dd::transformer::KVA, + tap: dd::transformer::TAP, + r_pct: dd::transformer::PCT_R, + kv_specified: false, + kva_specified: false, + } + } +} + +/// Grows the winding list to at least `n`, tracking the winding count. +fn grow(windings: &mut Vec, n: usize, count: &mut usize) { + if n > windings.len() { + windings.resize(n, WindingRaw::default()); + *count = n; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn has_warning(net: &DistNetwork, needle: &str) -> bool { + net.warnings.iter().any(|w| w.contains(needle)) + } + + #[test] + fn vsource_magnitude_is_the_polygon_chord() { + // VSource.cpp ~999-1002: one phase takes basekv outright, n > 1 + // divides by 2 sin(pi/n); sqrt(3) is the n = 3 special case. + let net = parse_dss_str( + "New Circuit.c basekv=12.47 pu=1.05 phases=2 bus1=src.1.2\n\ + New Vsource.aux basekv=12.47 phases=4 bus1=b2\n\ + New Vsource.solo basekv=2.4 phases=1 bus1=b3.1", + ); + let two = &net.sources[0]; + assert!((two.v_magnitude[0] - 12.47e3 * 1.05 / 2.0).abs() < 1e-9); + // Spacing is -360/n degrees: the second phase of a 2 phase source + // wraps to +pi. + assert!((two.v_angle[1] - std::f64::consts::PI).abs() < 1e-12); + let four = &net.sources[1]; + let chord = 2.0 * (std::f64::consts::PI / 4.0).sin(); + assert!((four.v_magnitude[0] - 12.47e3 / chord).abs() < 1e-9); + let solo = &net.sources[2]; + assert!((solo.v_magnitude[0] - 2.4e3).abs() < 1e-9); + } + + #[test] + fn vsource_defaults_are_recorded() { + let net = parse_dss_str("New Circuit.c1"); + let fields = net.defaulted.get("vsource.source").expect("entry"); + for key in ["phases", "pu", "angle", "basekv", "bus1"] { + assert!(fields.contains(&key), "missing {key}"); + } + } + + /// One single phase linecode + line; (r per meter, length meters). + fn r_and_length(lc_tail: &str, line_tail: &str) -> (f64, f64) { + let net = parse_dss_str(&format!( + "New Circuit.c\n\ + New Linecode.lc nphases=1 rmatrix=(0.5){lc_tail}\n\ + New Line.l1 bus1=a.1 bus2=b.1 phases=1 linecode=lc{line_tail}" + )); + let line = net.lines.iter().find(|l| l.name == "l1").unwrap(); + let code = net.linecode(&line.linecode).unwrap(); + (code.r_series[0][0], line.length) + } + + #[test] + fn unitless_line_length_is_in_linecode_units() { + // ConvertLineUnits is 1.0 when the line has no units, so the + // engine reads `length=2` against a km linecode as 2 km: + // 0.5 ohm/km * 2 km = 1 ohm total. + let (r, len) = r_and_length(" units=km", " length=2"); + assert!((len - 2000.0).abs() < 1e-9); + assert!((r * len - 1.0).abs() < 1e-12); + } + + #[test] + fn unitless_linecode_is_per_line_unit() { + // The mirror case: a unitless linecode is per line length unit, + // so the raw length carries and the total is again 1 ohm. + let (r, len) = r_and_length("", " length=2 units=km"); + assert!((len - 2.0).abs() < 1e-12); + assert!((r * len - 1.0).abs() < 1e-12); + } + + #[test] + fn written_units_on_both_sides_convert() { + // 0.5 ohm/km over 500 m = 0.25 ohm. + let (r, len) = r_and_length(" units=km", " length=500 units=m"); + assert!((len - 500.0).abs() < 1e-9); + assert!((r * len - 0.25).abs() < 1e-12); + } + + #[test] + fn no_units_anywhere_takes_the_raw_product() { + let (r, len) = r_and_length("", " length=2"); + assert!((len - 2.0).abs() < 1e-12); + assert!((r * len - 1.0).abs() < 1e-12); + } + + #[test] + fn two_phase_wye_capacitor_kv_is_line_to_line() { + // Capacitor.cpp ~621-630: PhasekV = kv/sqrt(3) for 2 AND 3 phase + // wye banks, kv outright otherwise. + let net = parse_dss_str( + "New Circuit.c\n\ + New Capacitor.c2 bus1=b.1.2 phases=2 kv=12.47 kvar=600\n\ + New Capacitor.c1 bus1=b.3 phases=1 kv=7.2 kvar=300", + ); + let c2 = net.shunts.iter().find(|s| s.name == "c2").unwrap(); + let v2 = 12.47e3 / 3f64.sqrt(); + assert!((c2.b[0][0] * v2 * v2 / 300e3 - 1.0).abs() < 1e-12); + let c1 = net.shunts.iter().find(|s| s.name == "c1").unwrap(); + let v1 = 7.2e3; + assert!((c1.b[0][0] * v1 * v1 / 300e3 - 1.0).abs() < 1e-12); + } + + #[test] + fn ll_connection_means_delta() { + // InterpretConnection maps `ll` to delta for every class. + let net = parse_dss_str( + "New Circuit.c\n\ + New Generator.g bus1=b.1.2.3 phases=3 conn=ll kw=90 kvar=30 kv=4.16\n\ + New Capacitor.cap bus1=b.1.2.3 phases=3 conn=ll kvar=600 kv=4.16", + ); + assert_eq!(net.generators[0].configuration, Configuration::Delta); + // Delta capacitors stay untyped, same as conn=delta. + assert!(net.shunts.is_empty()); + assert!( + net.untyped + .iter() + .any(|u| u.class.eq_ignore_ascii_case("capacitor") && u.name == "cap") + ); + } + + #[test] + fn load_kw_after_kvar_reverts_to_pf() { + // Load.cpp: kw flips LoadSpecType back to 0 (kW + PF), so the + // earlier kvar is discarded and q comes from the default pf 0.88. + let net = + parse_dss_str("New Circuit.c\nNew Load.l bus1=b.1 phases=1 kv=2.4 kvar=20 kw=100"); + let l = &net.loads[0]; + let q: f64 = l.q_nom.iter().sum(); + assert!((q - 100e3 * 0.88f64.acos().tan()).abs() < 1e-6); + assert_eq!( + l.extras.get("pf").and_then(serde_json::Value::as_f64), + Some(0.88) + ); + assert!( + net.defaulted + .get("load.l") + .is_some_and(|f| f.contains(&"pf")) + ); + } + + #[test] + fn load_like_replays_the_sources_recalced_pf() { + // Load.a ends its New under spec 1: recalc derives + // PFNominal = 10/sqrt(10² + 20²) = 0.4472 (kw still the constructor + // 10). MakeLike copies that recalced state, so b's kw=100 flips to + // spec 0 and the end-of-edit recalc lands kvar = + // 100·tan(acos(0.4472)) = 200, not the 53.97 a flat walk against + // pf 0.88 would give. Confirmed against opendssdirect. + let net = parse_dss_str( + "New Circuit.c\n\ + New Load.a bus1=b.1 phases=1 kv=2.4 kvar=20\n\ + New Load.b like=a kw=100", + ); + let b = net.loads.iter().find(|l| l.name == "b").unwrap(); + let q: f64 = b.q_nom.iter().sum(); + assert!((q - 200e3).abs() < 1e-6); + // Final spec is 0: the writer emits pf=, the recalced 0.4472. + let pf = b.extras.get("pf").and_then(serde_json::Value::as_f64); + assert!((pf.unwrap() - 0.447_213_595_499_957_9).abs() < 1e-12); + // The source itself keeps its written kvar. + let a = net.loads.iter().find(|l| l.name == "a").unwrap(); + let qa: f64 = a.q_nom.iter().sum(); + assert!((qa - 20e3).abs() < 1e-9); + } + + #[test] + fn load_tilde_continuation_recalcs_at_each_edit() { + // Same numbers via `~`: the New line's recalc fixes pf at 0.4472, + // the continuation's kw=100 reverts to spec 0 and its own recalc + // gives kvar = 200. A flat last-write walk would say 53.97. + let net = parse_dss_str( + "New Circuit.c\n\ + New Load.l bus1=b.1 phases=1 kv=2.4 kvar=20\n\ + ~ kw=100", + ); + let q: f64 = net.loads[0].q_nom.iter().sum(); + assert!((q - 200e3).abs() < 1e-6); + } + + #[test] + fn load_pf_between_kvar_and_kw_applies() { + // pf (case 5) updates PFNominal without touching the spec; the + // later kw sets spec 0, so the single recalc uses pf 0.95: + // q = 100·tan(acos(0.95)) = 32.868. Confirmed against + // opendssdirect. + let net = parse_dss_str( + "New Circuit.c\nNew Load.l bus1=b.1 phases=1 kv=2.4 kvar=20 pf=0.95 kw=100", + ); + let l = &net.loads[0]; + let q: f64 = l.q_nom.iter().sum(); + assert!((q - 100e3 * 0.95f64.acos().tan()).abs() < 1e-6); + assert_eq!( + l.extras.get("pf").and_then(serde_json::Value::as_f64), + Some(0.95) + ); + assert!( + !net.defaulted + .get("load.l") + .is_some_and(|f| f.contains(&"pf")) + ); + } + + #[test] + fn load_kvar_after_kw_stays() { + let net = + parse_dss_str("New Circuit.c\nNew Load.l bus1=b.1 phases=1 kv=2.4 kw=100 kvar=20"); + let l = &net.loads[0]; + let q: f64 = l.q_nom.iter().sum(); + assert!((q - 20e3).abs() < 1e-9); + // The writer must emit kvar=, not pf=. + assert!(!l.extras.contains_key("pf")); + } + + #[test] + fn generator_kw_after_kvar_resyncs_q() { + // Set_Presentkvar rederives PF from kW and kvar; the later kw + // write resyncs kvar from that PF. Constructor kW is 1000, so + // kvar=20 kw=100 scales q to 100 * 20/1000 = 2 kvar. + let net = + parse_dss_str("New Circuit.c\nNew Generator.g bus1=b.1 phases=1 kv=2.4 kvar=20 kw=100"); + let q: f64 = net.generators[0].q_nom.iter().sum(); + assert!((q - 2e3).abs() < 1e-6); + } + + #[test] + fn generator_kvar_after_kw_stays() { + let net = + parse_dss_str("New Circuit.c\nNew Generator.g bus1=b.1 phases=1 kv=2.4 kw=100 kvar=20"); + let q: f64 = net.generators[0].q_nom.iter().sum(); + assert!((q - 20e3).abs() < 1e-9); + } + + #[test] + fn generator_pf_after_kvar_wins() { + // pf calls SyncUpPowerQuantities: kvar = kW tan(acos(pf)) with the + // constructor kW 1000. + let net = parse_dss_str( + "New Circuit.c\nNew Generator.g bus1=b.1.2.3 phases=3 kv=4.16 kvar=20 pf=0.9", + ); + let q: f64 = net.generators[0].q_nom.iter().sum(); + assert!((q - 1000e3 * 0.9f64.acos().tan()).abs() < 1e-3); + } + + #[test] + fn malformed_matrix_warns_and_keeps_text() { + // The engine rejects a bad rmatrix outright; the reader keeps + // going on sequence values but must not call the property + // defaulted, and the text must survive in extras. + let net = parse_dss_str( + "New Circuit.c\n\ + New Linecode.bad nphases=2 rmatrix=(1 2 3) units=m\n\ + New Line.l2 bus1=a.1.2 bus2=b.1.2 phases=2 rmatrix=(bogus) length=10", + ); + assert!(has_warning(&net, "linecode bad") && has_warning(&net, "rmatrix")); + assert!( + !net.defaulted + .get("linecode.bad") + .is_some_and(|f| f.contains(&"rmatrix")) + ); + let code = net.linecode("bad").unwrap(); + assert!( + code.extras + .get("rmatrix") + .and_then(serde_json::Value::as_str) + .is_some_and(|s| s.contains("1 2 3")) + ); + // Sequence defaults filled in: diag (2 r1 + r0) / 3. + let diag = (2.0 * dd::line::R1 + dd::line::R0) / 3.0; + assert!((code.r_series[0][0] - diag).abs() < 1e-12); + // The inline line path lands the text on the line's extras. + assert!(has_warning(&net, "line l2")); + let l2 = net.lines.iter().find(|l| l.name == "l2").unwrap(); + assert!( + l2.extras + .get("rmatrix") + .and_then(serde_json::Value::as_str) + .is_some_and(|s| s.contains("bogus")) + ); + } + + #[test] + fn switchedobj_class_prefix_is_case_insensitive() { + let net = parse_dss_str( + "New Circuit.c\n\ + New Line.sw1 bus1=a.1 bus2=b.1 phases=1 switch=y\n\ + New SwtControl.s1 SwitchedObj=LINE.sw1 Action=open", + ); + assert!(net.switches[0].open); + } + + #[test] + fn phases_token_rides_in_extras() { + // A 2 phase delta load has 3 conductors, indistinguishable from a + // 3 phase delta by terminal map alone. + let net = parse_dss_str( + "New Circuit.c\n\ + New Load.l bus1=b.1.2 phases=2 conn=delta kw=50 kvar=10 kv=4.8\n\ + New Generator.g bus1=b.1.2.3 kw=10 kvar=2 kv=4.16\n\ + New Capacitor.cap bus1=b.1.2.3 phases=3 kvar=600 kv=4.16", + ); + let l = &net.loads[0]; + assert_eq!(l.terminal_map.len(), 3); + assert_eq!( + l.extras.get("phases").and_then(serde_json::Value::as_str), + Some("2") + ); + // An unwritten phases= materializes the class default. + assert_eq!( + net.generators[0] + .extras + .get("phases") + .and_then(serde_json::Value::as_u64), + Some(3) + ); + assert_eq!( + net.shunts[0] + .extras + .get("phases") + .and_then(serde_json::Value::as_str), + Some("3") + ); + } + + #[test] + fn rpn_kv_token_stashes_the_evaluated_value() { + // The writer needs a number; RPN text would not read back. + let net = parse_dss_str("New Circuit.c\nNew Load.l bus1=b.1 phases=1 kw=10 kv={4.8 2 /}"); + assert_eq!( + net.loads[0] + .extras + .get("kv") + .and_then(serde_json::Value::as_f64), + Some(2.4) + ); + } +} diff --git a/powerio-dist/src/dss/rpn.rs b/powerio-dist/src/dss/rpn.rs new file mode 100644 index 0000000..849f631 --- /dev/null +++ b/powerio-dist/src/dss/rpn.rs @@ -0,0 +1,150 @@ +//! RPN expression evaluator matching OpenDSS's TRPNCalc (Parser/RPN.cpp). +//! +//! OpenDSS evaluates any quoted token that is not a plain number as an RPN +//! expression: `(8 1000 /)` is 8/1000, `(1 2 +)` is 3. The calculator is a +//! ten register HP style stack; entering a number rolls the stack up, binary +//! operators combine X and Y and roll down. Trig works in degrees. The roll +//! operations shift rather than rotate, mirroring the reference exactly. + +const STACK: usize = 10; + +pub(crate) struct RpnCalc { + /// s[0] is the X register, s[1] Y, s[2] Z. + s: [f64; STACK], +} + +impl RpnCalc { + pub(crate) fn new() -> Self { + RpnCalc { s: [0.0; STACK] } + } + + pub(crate) fn x(&self) -> f64 { + self.s[0] + } + + fn roll_up(&mut self) { + for i in (1..STACK).rev() { + self.s[i] = self.s[i - 1]; + } + } + + fn roll_dn(&mut self) { + for i in 1..STACK { + self.s[i - 1] = self.s[i]; + } + } + + fn enter(&mut self, v: f64) { + self.roll_up(); + self.s[0] = v; + } + + fn binary(&mut self, f: impl Fn(f64, f64) -> f64) { + // Matches the reference: result lands in Y, then the stack rolls down. + self.s[1] = f(self.s[1], self.s[0]); + self.roll_dn(); + } + + /// Applies one RPN token. Returns false for an unrecognized op. + pub(crate) fn apply(&mut self, token: &str) -> bool { + if let Some(v) = parse_number(token) { + self.enter(v); + return true; + } + let d = std::f64::consts::PI / 180.0; + match token.to_ascii_lowercase().as_str() { + "+" => self.binary(|y, x| y + x), + "-" => self.binary(|y, x| y - x), + "*" => self.binary(|y, x| y * x), + "/" => self.binary(|y, x| y / x), + "^" => self.binary(f64::powf), + "atan2" => self.binary(move |y, x| y.atan2(x) / d), + "sqrt" => self.s[0] = self.s[0].sqrt(), + "sqr" => self.s[0] = self.s[0] * self.s[0], + "sin" => self.s[0] = (self.s[0] * d).sin(), + "cos" => self.s[0] = (self.s[0] * d).cos(), + "tan" => self.s[0] = (self.s[0] * d).tan(), + "asin" => self.s[0] = self.s[0].asin() / d, + "acos" => self.s[0] = self.s[0].acos() / d, + "atan" => self.s[0] = self.s[0].atan() / d, + "ln" => self.s[0] = self.s[0].ln(), + "exp" => self.s[0] = self.s[0].exp(), + "log10" => self.s[0] = self.s[0].log10(), + "inv" => self.s[0] = 1.0 / self.s[0], + "pi" => self.enter(std::f64::consts::PI), + "swap" => self.s.swap(0, 1), + "rollup" => self.roll_up(), + "rolldn" => self.roll_dn(), + _ => return false, + } + true + } +} + +/// Number parsing with Pascal `val` semantics: the whole token must be a +/// decimal or scientific float; `inf`/`nan` spellings are not numbers. +pub(crate) fn parse_number(token: &str) -> Option { + if token.is_empty() + || !token + .bytes() + .all(|b| b.is_ascii_digit() || matches!(b, b'.' | b'+' | b'-' | b'e' | b'E')) + { + return None; + } + token.parse::().ok().filter(|v| v.is_finite()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn eval(tokens: &[&str]) -> f64 { + let mut c = RpnCalc::new(); + for t in tokens { + assert!(c.apply(t), "bad RPN token {t}"); + } + c.x() + } + + #[test] + #[allow(clippy::float_cmp)] + fn arithmetic() { + assert_eq!(eval(&["8", "1000", "/"]), 0.008); + assert_eq!(eval(&["1", "2", "+"]), 3.0); + assert_eq!(eval(&["10", "4", "-"]), 6.0); + assert_eq!(eval(&["2", "3", "^"]), 8.0); + } + + #[test] + fn degrees_trig() { + assert!((eval(&["30", "sin"]) - 0.5).abs() < 1e-12); + assert!((eval(&["60", "cos"]) - 0.5).abs() < 1e-12); + assert!((eval(&["1", "1", "atan2"]) - 45.0).abs() < 1e-12); + } + + #[test] + #[allow(clippy::float_cmp)] + fn stack_ops() { + assert_eq!(eval(&["2", "5", "swap", "-"]), 3.0); + assert!((eval(&["pi"]) - std::f64::consts::PI).abs() < 1e-15); + assert_eq!(eval(&["9", "sqrt"]), 3.0); + assert_eq!(eval(&["4", "inv"]), 0.25); + } + + #[test] + fn unknown_op() { + let mut c = RpnCalc::new(); + assert!(!c.apply("bogus")); + } + + #[test] + fn number_syntax() { + assert_eq!(parse_number("1.5e3"), Some(1500.0)); + assert_eq!(parse_number(".5"), Some(0.5)); + assert_eq!(parse_number("-2"), Some(-2.0)); + assert_eq!(parse_number("inf"), None); + assert_eq!(parse_number("nan"), None); + assert_eq!(parse_number("1.2.3"), None); + assert_eq!(parse_number(""), None); + } +} diff --git a/powerio-dist/src/dss/write.rs b/powerio-dist/src/dss/write.rs new file mode 100644 index 0000000..ca1cab5 --- /dev/null +++ b/powerio-dist/src/dss/write.rs @@ -0,0 +1,1798 @@ +//! [`DistNetwork`] into OpenDSS `.dss` text. +//! +//! The canonical writer regenerates a solvable case from the typed model: +//! a `Clear`/`Set DefaultBaseFrequency` header, the circuit with its +//! source, linecodes in meters, elements with explicit bus dots (a +//! terminal in the bus's perfectly grounded set emits as node 0, the exact +//! inverse of the reader's materialization), the source `Set` options the +//! writer does not derive itself, `Set VoltageBases`, `Calcvoltagebases`, +//! and `Solve`. Element extras whose keys appear in the class property +//! tables emit verbatim; everything else is reported. +//! +//! Floats print through Rust's shortest round trip formatting; OpenDSS +//! reads the full precision back. + +use std::collections::BTreeMap; +use std::fmt::Write as _; + +use crate::convert::Conversion; +use crate::model::{Configuration, DistBus, DistNetwork, Extras, Mat, WindingConn}; + +use super::{lex, prop}; + +/// Writes canonical `.dss` text from the model. +pub fn write_dss(net: &DistNetwork) -> Conversion { + let mut w = DssWriter { + out: String::new(), + warnings: Vec::new(), + grounded: net + .buses + .iter() + .map(|b| (b.id.to_ascii_lowercase(), b.grounded.clone())) + .collect(), + terminals: net + .buses + .iter() + .map(|b| (b.id.to_ascii_lowercase(), b.terminals.clone())) + .collect(), + kv_estimate: estimate_bus_kv(net), + }; + w.network(net); + Conversion { + text: w.out, + warnings: w.warnings, + } +} + +struct DssWriter { + out: String, + warnings: Vec, + /// Bus id (lowercase) → perfectly grounded terminal names. + grounded: BTreeMap>, + /// Bus id (lowercase) → ordered terminal names. + terminals: BTreeMap>, + /// Bus id (lowercase) → phase to neutral voltage estimate, volts. + kv_estimate: BTreeMap, +} + +/// Phase to neutral voltage per bus, propagated from the sources through +/// lines and switches (same level) and transformers (winding ratios). The +/// estimate feeds load/capacitor `kv` and `Set VoltageBases` when the +/// source format did not carry them. +/// +/// The seed is not the model voltage directly: it is the basekv the writer +/// will emit (the stashed token when the source carried one), run through +/// the reader's basekv → per phase formula. A reparse then reproduces the +/// same floats bit for bit; seeding from `v_magnitude` is not a fixed +/// point of the sqrt round trip and `Set VoltageBases` would drift one ulp +/// per write. Transformer ratios use `(v_ref / 1e3) * 1e3`, the value a +/// reparse of the emitted `kvs=` rebuilds, for the same reason. +fn estimate_bus_kv(net: &DistNetwork) -> BTreeMap { + let mut kv: BTreeMap = BTreeMap::new(); + for vs in &net.sources { + let phases = source_phases(net, vs); + let basekv = extras_f64(&vs.extras, "basekv").unwrap_or_else(|| source_basekv(vs, phases)); + let pu = extras_f64(&vs.extras, "pu").unwrap_or(1.0); + let vln = basekv * 1e3 * pu / source_chord(phases); + if vln > 0.0 { + kv.insert(vs.bus.to_ascii_lowercase(), vln); + } + } + for _ in 0..net.buses.len() { + let mut changed = false; + for l in &net.lines { + let (f, t) = ( + l.bus_from.to_ascii_lowercase(), + l.bus_to.to_ascii_lowercase(), + ); + match (kv.get(&f).copied(), kv.get(&t).copied()) { + (Some(v), None) => { + kv.insert(t, v); + changed = true; + } + (None, Some(v)) => { + kv.insert(f, v); + changed = true; + } + _ => {} + } + } + for s in &net.switches { + let (f, t) = ( + s.bus_from.to_ascii_lowercase(), + s.bus_to.to_ascii_lowercase(), + ); + match (kv.get(&f).copied(), kv.get(&t).copied()) { + (Some(v), None) => { + kv.insert(t, v); + changed = true; + } + (None, Some(v)) => { + kv.insert(f, v); + changed = true; + } + _ => {} + } + } + for t in &net.transformers { + // Propagate by winding voltage ratio from any known winding bus. + let known: Option<(usize, f64)> = t + .windings + .iter() + .enumerate() + .find_map(|(i, w)| kv.get(&w.bus.to_ascii_lowercase()).map(|v| (i, *v))); + if let Some((i, v_known)) = known { + let v_ref_known = (t.windings[i].v_ref / 1e3) * 1e3; + if v_ref_known > 0.0 { + for (j, w) in t.windings.iter().enumerate() { + if j != i && !kv.contains_key(&w.bus.to_ascii_lowercase()) { + kv.insert( + w.bus.to_ascii_lowercase(), + v_known * ((w.v_ref / 1e3) * 1e3) / v_ref_known, + ); + changed = true; + } + } + } + } + } + if !changed { + break; + } + } + kv +} + +/// A float in the shortest form Rust round trips. +fn num(v: f64) -> String { + format!("{v}") +} + +/// VSource.cpp's per phase magnitude divisor: the chord of the n-gon +/// (1 for a single phase source, sqrt(3) at n = 3). Division by the +/// 1 phase chord is exact, so one expression serves both reader branches. +fn source_chord(phases: usize) -> f64 { + if phases <= 1 { + 1.0 + } else { + 2.0 * (std::f64::consts::PI / phases as f64).sin() + } +} + +/// The basekv a source without a stashed token emits: the model magnitude +/// through the inverse of the reader's chord formula. +fn source_basekv(vs: &crate::model::VoltageSource, phases: usize) -> f64 { + vs.v_magnitude.iter().copied().fold(0.0_f64, f64::max) * source_chord(phases) / 1e3 +} + +/// An extra as a number: the reader stashes written tokens as strings and +/// materialized defaults as numbers. +fn extras_f64(extras: &Extras, key: &str) -> Option { + let v = extras.get(key)?; + v.as_f64() + .or_else(|| v.as_str().and_then(|s| s.parse().ok())) +} + +fn extras_usize(extras: &Extras, key: &str) -> Option { + let v = extras.get(key)?; + v.as_u64() + .and_then(|u| usize::try_from(u).ok()) + .or_else(|| v.as_str().and_then(|s| s.parse().ok())) + .or_else(|| { + v.as_f64() + .filter(|f| f.fract() == 0.0 && *f >= 0.0) + .map(|f| f as usize) + }) +} + +/// Whether the dss tokenizer would split this name: its delimiters, quote +/// pair characters, comment openers, and (in bus ids) the node dot. +fn name_breaks_dss(name: &str, is_bus_id: bool) -> bool { + name.contains("//") + || name.chars().any(|c| { + matches!( + c, + ' ' | '\t' | ',' | '=' | '!' | '"' | '\'' | '(' | ')' | '[' | ']' | '{' | '}' + ) || (is_bus_id && c == '.') + }) +} + +/// A `key=value` value as dss text. A value the lexer scans back as one +/// bare token emits bare; anything else wraps in the first quote pair +/// whose closer is absent from the value. The lexer honors all five pairs, +/// and its quoted scan runs to the closer without checking delimiters or +/// comment openers, so the wrapper protects spaces, commas, `=`, `!`, and +/// `//`. The choice depends only on the value: the reader strips the +/// wrapper, so the next write sees the bare value and picks the same form. +/// `false` means nothing reparses to the value — every closer appears in +/// it and bare scanning splits it — and the caller must warn. +fn dss_value_out(value: &str) -> (String, bool) { + // An empty value is never bare representable: `key=` makes the lexer + // eat the next token as the value. `()` strips back to the empty string. + if value.is_empty() { + return ("()".to_string(), true); + } + let mut scan = lex::Scanner::new(value, None); + let bare = scan.next_param().is_some_and(|p| { + p.name.is_none() && !p.value.quoted && p.value.text == value && scan.next_param().is_none() + }); + if bare { + return (value.to_string(), true); + } + for (open, close) in [('(', ')'), ('[', ']'), ('{', '}'), ('"', '"'), ('\'', '\'')] { + if !value.contains(close) { + return (format!("{open}{value}{close}"), true); + } + } + (value.to_string(), false) +} + +/// Emitted source `phases=`: the stashed token when the source carried +/// one, otherwise the terminal map entries outside the bus's grounded +/// set. The engine counts conductors, not energized phases, so a phase +/// at v_magnitude 0 keeps its place on the dot list; the emission site +/// warns about the disagreement. +fn source_phases(net: &DistNetwork, vs: &crate::model::VoltageSource) -> usize { + if let Some(p) = extras_usize(&vs.extras, "phases") { + return p.max(1); + } + let grounded = net + .buses + .iter() + .find(|b| b.id.eq_ignore_ascii_case(&vs.bus)) + .map(|b| b.grounded.as_slice()) + .unwrap_or_default(); + vs.terminal_map + .iter() + .filter(|t| !grounded.contains(t)) + .count() + .max(1) +} + +/// First row (self, mutual) of a series matrix extra, without consuming it. +fn seq_parts(extras: &Extras, key: &str) -> Option<(f64, f64)> { + let row = extras.get(key)?.as_array()?.first()?.as_array()?; + let self_v = row.first()?.as_f64()?; + let mutual = row + .get(1) + .and_then(serde_json::Value::as_f64) + .unwrap_or(0.0); + Some((self_v, mutual)) +} + +impl DssWriter { + fn warn(&mut self, msg: impl Into) { + self.warnings.push(msg.into()); + } + + /// The engine's bus fill rule gives every conductor the dot list does + /// not cover a default — nodes 1..=phases for the phase conductors, + /// ground for the rest — so a map shorter than the class's conductor + /// count comes back from a reparse one grounded neutral longer. The + /// first write of such a model is not a fixed point; the second is. + fn warn_short_map(&mut self, class: &str, name: &str, map_len: usize, nconds: usize) { + if map_len < nconds { + self.warn(format!( + "{class} {name}: terminal map lists {map_len} of {nconds} conductors; \ + dss materializes a grounded neutral terminal and the reparsed model \ + gains one" + )); + } + } + + /// A numeric source extra. A present token that does not parse warns; + /// the derived value substitutes and the extra is consumed either way. + fn source_extra_f64(&mut self, vs: &crate::model::VoltageSource, key: &str) -> Option { + let v = vs.extras.get(key)?; + let parsed = v + .as_f64() + .or_else(|| v.as_str().and_then(|s| s.parse().ok())); + if parsed.is_none() { + self.warn(format!( + "vsource {}: {key} extra `{v}` does not parse as a number; \ + using the derived value", + vs.name + )); + } + parsed + } + + fn line_out(&mut self, s: &str) { + self.out.push_str(s); + self.out.push('\n'); + } + + fn check_name(&mut self, class: &str, name: &str) { + if name_breaks_dss(name, false) { + self.warn(format!( + "{class} `{name}`: name contains characters dss cannot represent; \ + output will not reparse identically" + )); + } + } + + /// `bus.1.2.0` syntax: terminals in the bus's perfectly grounded set + /// emit as node 0, the inverse of the reader's neutral naming. dss + /// nodes are positional integers, so a non numeric terminal name emits + /// as its 1 based position on the bus (the element map position when + /// the bus does not list it), reported, keeping the conductor structure + /// intact across the trip. + fn bus_ref(&mut self, bus: &str, map: &[String]) -> String { + let key = bus.to_ascii_lowercase(); + if name_breaks_dss(bus, true) { + self.warn(format!( + "bus `{bus}`: id contains characters dss cannot represent; \ + output will not reparse identically" + )); + } + let grounded = self.grounded.get(&key).cloned(); + let terminals = self.terminals.get(&key).cloned().unwrap_or_default(); + let nodes: Vec = map + .iter() + .enumerate() + .map(|(i, t)| { + if grounded.as_ref().is_some_and(|g| g.contains(t)) { + "0".to_string() + } else if t.parse::().is_ok() { + t.clone() + } else { + let pos = terminals.iter().position(|x| x == t).unwrap_or(i) + 1; + self.warn(format!( + "bus {bus}: terminal `{t}` is not a dss node number; \ + emitted as node {pos}, its position on the bus" + )); + pos.to_string() + } + }) + .collect(); + if nodes.is_empty() { + bus.to_string() + } else { + format!("{bus}.{}", nodes.join(".")) + } + } + + /// Extras whose keys are dss properties of `class` emit as written; + /// the rest are reported per key. + fn extras_tail(&mut self, class: &str, name: &str, extras: &Extras) -> String { + let table = prop::class_by_name(class); + let mut tail = String::new(); + for (key, value) in extras { + if matches!(key.as_str(), "bmopf_subtype") || key.starts_with("pmd_") { + continue; // converter bookkeeping + } + let known = table.is_some_and(|t| t.props.contains(&key.as_str())); + let text = value + .as_str() + .map(ToString::to_string) + .or_else(|| value.as_f64().map(num)) + .or_else(|| value.as_i64().map(|v| v.to_string())); + match (known, text) { + (true, Some(text)) => { + let (out, representable) = dss_value_out(&text); + if !representable { + self.warn(format!( + "{class} {name}: extra `{key}` value `{text}` contains every \ + dss quote closer and splits when scanned bare; emitted as \ + written and a reparse will not see the same value" + )); + } + let _ = write!(tail, " {key}={out}"); + } + _ => self.warn(format!( + "{class} {name}: extra `{key}` is not a dss property; dropped from the output" + )), + } + } + tail + } + + /// Lower triangle matrix text. Rows shorter than the triangle pad + /// with 0 instead of panicking, and the padding is reported. + fn matrix_arg(&mut self, m: &Mat, what: &str) -> String { + let mut short = false; + let rows: Vec = m + .iter() + .enumerate() + .map(|(i, row)| { + let take = row.len().min(i + 1); + let mut vals: Vec = row[..take].iter().map(|v| num(*v)).collect(); + if take < i + 1 { + short = true; + vals.resize(i + 1, "0".to_string()); + } + vals.join(" ") + }) + .collect(); + if short { + self.warn(format!( + "{what}: matrix rows are shorter than the lower triangle; \ + missing entries emitted as 0" + )); + } + format!("({})", rows.join(" | ")) + } + + /// Consumes an rs/xs extras pair only when both first rows parse; a + /// half present or unusable pair stays in extras and is reported. + fn take_seq_pair( + &mut self, + extras: &mut Extras, + r_key: &str, + x_key: &str, + what: &str, + ) -> Option<((f64, f64), (f64, f64))> { + let r = seq_parts(extras, r_key); + let x = seq_parts(extras, x_key); + if let (Some(r), Some(x)) = (r, x) { + extras.remove(r_key); + extras.remove(x_key); + return Some((r, x)); + } + if extras.contains_key(r_key) || extras.contains_key(x_key) { + let state = |key: &str, parsed: bool| { + if !extras.contains_key(key) { + format!("`{key}` is missing") + } else if parsed { + format!("`{key}` is usable") + } else { + format!("`{key}` is not a numeric matrix") + } + }; + self.warn(format!( + "{what}: series impedance extras unusable ({}, {}); left in extras", + state(r_key, r.is_some()), + state(x_key, x.is_some()), + )); + } + None + } + + /// Emitted `phases=`: the reader's stash when present, otherwise + /// inferred from the terminal map shape. A delta map with 3 conductors + /// is 2 or 3 phase; without the stash the 3 phase reading wins, loudly. + fn element_phases( + &mut self, + extras: &Extras, + terminal_map: &[String], + configuration: Configuration, + class: &str, + name: &str, + ) -> usize { + if let Some(p) = extras_usize(extras, "phases") { + return p.max(1); + } + match configuration { + Configuration::Delta => match terminal_map.len() { + 2 => 1, + 3 => { + self.warn(format!( + "{class} {name}: a delta terminal map with 3 conductors is 2 or 3 \ + phase and no phases record disambiguates; emitted phases=3" + )); + 3 + } + n => { + self.warn(format!( + "{class} {name}: a delta terminal map with {n} conductors has no \ + dss phases mapping; emitted phases={}", + n.max(1) + )); + n.max(1) + } + }, + Configuration::Wye => terminal_map.len().saturating_sub(1).max(1), + _ => 1, + } + } + + fn network(&mut self, net: &DistNetwork) { + self.line_out("Clear"); + self.line_out(&format!( + "Set DefaultBaseFrequency={}", + num(net.base_frequency) + )); + self.out.push('\n'); + + self.sources(net); + self.linecodes(net); + self.lines(net); + self.switches(net); + self.transformers(net); + self.loads(net); + self.shunts(net); + self.generators(net); + + for u in &net.untyped { + self.warn(format!( + "{} {}: untyped object is not regenerated in canonical dss output", + u.class, u.name + )); + } + for b in &net.buses { + self.bus_extras(b); + } + + self.out.push('\n'); + // Source options re-emit in stored order, except the keys this + // writer derives itself (the DefaultBaseFrequency header, the + // VoltageBases tail). Commands do not re-emit: their position in + // the script matters and the canonical element order does not + // preserve it, so each drop is reported instead. + for (key, value) in &net.options { + if key.is_empty() { + self.warn(format!( + "option `{value}` has no name; not regenerated in canonical dss output" + )); + continue; + } + // The engine resolves Set names by first match in option table + // order (Command.cpp Getcommand → HashList FindAbbrev). Every + // prefix of "voltagebases" binds Voltagebases (it precedes the + // other v options), but prefixes of "defaultbasefrequency" + // shorter than "defaultb" bind DefaultDaily, so the frequency + // skip is bounded at the engine's unique resolution point. + // Calcvoltagebases is a command, never a Set option, so it does + // not belong here. + let key_lc = key.to_ascii_lowercase(); + if "voltagebases".starts_with(&key_lc) + || (key_lc.len() >= "defaultb".len() && "defaultbasefrequency".starts_with(&key_lc)) + { + continue; + } + let (text, representable) = dss_value_out(value); + if !representable { + self.warn(format!( + "option `{key}`: value `{value}` contains every dss quote closer \ + and splits when scanned bare; emitted as written and a reparse \ + will not see the same value" + )); + } + self.line_out(&format!("Set {key}={text}")); + } + for (verb, args) in &net.commands { + if verb.eq_ignore_ascii_case("calcvoltagebases") || verb.eq_ignore_ascii_case("solve") { + continue; // the tail emits these + } + let shown = if args.is_empty() { + verb.clone() + } else { + format!("{verb} {args}") + }; + self.warn(format!( + "command `{shown}` is not regenerated in canonical dss output" + )); + } + let mut bases: Vec = self + .kv_estimate + .values() + .map(|v| v * 3f64.sqrt() / 1e3) + .collect(); + bases.sort_by(f64::total_cmp); + bases.dedup_by(|a, b| (*a - *b).abs() < 1e-9); + if !bases.is_empty() { + let list: Vec = bases.iter().map(|v| num(*v)).collect(); + self.line_out(&format!("Set VoltageBases=[{}]", list.join(", "))); + self.line_out("Calcvoltagebases"); + } + self.line_out("Solve"); + } + + fn bus_extras(&mut self, b: &DistBus) { + for key in b.extras.keys() { + if key == "x" || key == "y" { + continue; // coordinates have no command in canonical output yet + } + self.warnings.push(format!( + "bus {}: extra `{key}` is not regenerated in canonical dss output", + b.id + )); + } + for (field, present) in [ + ("v_min", b.v_min.is_some()), + ("v_max", b.v_max.is_some()), + ("vpn_min", b.vpn_min.is_some()), + ("vpn_max", b.vpn_max.is_some()), + ("vpp_min", b.vpp_min.is_some()), + ("vpp_max", b.vpp_max.is_some()), + ("vsym_min", b.vsym_min.is_some()), + ("vsym_max", b.vsym_max.is_some()), + ] { + if present { + self.warnings.push(format!( + "bus {}: `{field}` voltage bounds have no dss expression; dropped", + b.id + )); + } + } + } + + fn sources(&mut self, net: &DistNetwork) { + for (i, vs) in net.sources.iter().enumerate() { + let phases = source_phases(net, vs); + let energized = vs.v_magnitude.iter().filter(|&&v| v > 0.0).count(); + if energized > 0 && energized != phases { + self.warn(format!( + "vsource {}: emitted phases={phases} but {energized} v_magnitude \ + entries are positive; a reparse energizes all {phases}", + vs.name + )); + } + self.warn_short_map("vsource", &vs.name, vs.terminal_map.len(), phases + 1); + let basekv = self + .source_extra_f64(vs, "basekv") + .unwrap_or_else(|| source_basekv(vs, phases)); + let pu = self.source_extra_f64(vs, "pu").unwrap_or(1.0); + let angle = self + .source_extra_f64(vs, "angle") + .unwrap_or_else(|| vs.v_angle.first().copied().unwrap_or(0.0).to_degrees()); + let head = if i == 0 { + let name = net.name.clone().unwrap_or_else(|| "converted".into()); + self.check_name("circuit", &name); + format!("New Circuit.{name}") + } else { + self.check_name("vsource", &vs.name); + format!("New Vsource.{}", vs.name) + }; + let mut s = format!( + "{head} basekv={} pu={} angle={} phases={phases} bus1={}", + num(basekv), + num(pu), + num(angle), + self.bus_ref(&vs.bus, &vs.terminal_map), + ); + let mut extras = vs.extras.clone(); + extras.remove("basekv"); + extras.remove("pu"); + extras.remove("angle"); + extras.remove("phases"); // the head already prints phases= + // A source that came through the ENGINEERING model carries its + // Thevenin impedance as rs/xs matrices; sequence values + // reconstruct exactly (z1 = self - mutual, z0 = self + 2 mutual). + let what = format!("vsource {}", vs.name); + if let Some(((rs, rm), (xs, xm))) = self.take_seq_pair(&mut extras, "rs", "xs", &what) { + // Lowercase keys in sorted order: a reparse keeps these in + // extras and the next write emits them from there verbatim. + let _ = write!( + s, + " z0=({}, {}) z1=({}, {})", + num(rs + 2.0 * rm), + num(xs + 2.0 * xm), + num(rs - rm), + num(xs - xm) + ); + } + s.push_str(&self.extras_tail("vsource", &vs.name, &extras)); + self.line_out(&s); + } + self.out.push('\n'); + } + + fn linecodes(&mut self, net: &DistNetwork) { + let omega_nf = std::f64::consts::TAU * net.base_frequency * 1e-9; + for c in &net.linecodes { + self.check_name("linecode", &c.name); + let n = c.n_conductors; + let what = format!("linecode {}", c.name); + let mut s = format!("New Linecode.{} nphases={n} units=m", c.name); + let rm = self.matrix_arg(&c.r_series, &what); + let _ = write!(s, " rmatrix={rm}"); + let xm = self.matrix_arg(&c.x_series, &what); + let _ = write!(s, " xmatrix={xm}"); + // cmatrix in nF per meter: each half is omega C / 2, so + // C_nF = 2 b / (omega 1e-9). + let c_nf: Mat = c + .b_from + .iter() + .map(|row| row.iter().map(|b| 2.0 * b / omega_nf).collect()) + .collect(); + let cm = self.matrix_arg(&c_nf, &what); + let _ = write!(s, " cmatrix={cm}"); + match c.i_max.as_deref() { + Some([amps, ..]) => { + let _ = write!(s, " emergamps={}", num(*amps)); + } + Some([]) => self.warn(format!( + "linecode {}: i_max is empty; emergamps not emitted", + c.name + )), + None => {} + } + if !c.g_from.iter().flatten().all(|&g| g == 0.0) { + self.warn(format!( + "linecode {}: shunt conductance has no dss linecode field; dropped", + c.name + )); + } + let mut extras = c.extras.clone(); + extras.remove("units"); // canonical output is in meters + s.push_str(&self.extras_tail("linecode", &c.name, &extras)); + self.line_out(&s); + } + self.out.push('\n'); + } + + fn lines(&mut self, net: &DistNetwork) { + for l in &net.lines { + self.check_name("line", &l.name); + let phases = l.terminal_map_from.len(); + let mut s = format!( + "New Line.{} bus1={} bus2={} phases={phases} linecode={} length={} units=m", + l.name, + self.bus_ref(&l.bus_from, &l.terminal_map_from), + self.bus_ref(&l.bus_to, &l.terminal_map_to), + l.linecode, + num(l.length), + ); + let mut extras = l.extras.clone(); + extras.remove("units"); // canonical output is in meters + s.push_str(&self.extras_tail("line", &l.name, &extras)); + self.line_out(&s); + } + self.out.push('\n'); + } + + fn switches(&mut self, net: &DistNetwork) { + for sw in &net.switches { + self.check_name("line", &sw.name); + let phases = sw.terminal_map_from.len(); + let mut s = format!( + "New Line.{} bus1={} bus2={} phases={phases} switch=y", + sw.name, + self.bus_ref(&sw.bus_from, &sw.terminal_map_from), + self.bus_ref(&sw.bus_to, &sw.terminal_map_to), + ); + match sw.i_max.as_deref() { + Some([amps, ..]) => { + let _ = write!(s, " emergamps={}", num(*amps)); + } + Some([]) => self.warn(format!( + "line {}: i_max is empty; emergamps not emitted", + sw.name + )), + None => {} + } + // A switch that came through the ENGINEERING model carries its + // total series matrices; sequence overrides reproduce them over + // the forced 0.001 length (the engine's switch dummy values + // would otherwise apply). + let mut extras = sw.extras.clone(); + let what = format!("line {}", sw.name); + if let Some(((rs, rm), (xs, xm))) = + self.take_seq_pair(&mut extras, "pmd_rs", "pmd_xs", &what) + { + let _ = write!( + s, + " c0=0 c1=0 r0={} r1={} x0={} x1={}", + num((rs + 2.0 * rm) / 0.001), + num((rs - rm) / 0.001), + num((xs + 2.0 * xm) / 0.001), + num((xs - xm) / 0.001) + ); + } + s.push_str(&self.extras_tail("line", &sw.name, &extras)); + self.line_out(&s); + self.line_out(&format!( + "New SwtControl.{}_state SwitchedObj=Line.{} Action={}", + sw.name, + sw.name, + if sw.open { "open" } else { "close" }, + )); + } + self.out.push('\n'); + } + + fn transformers(&mut self, net: &DistNetwork) { + for t in &net.transformers { + self.check_name("transformer", &t.name); + let nw = t.windings.len(); + let buses: Vec = t + .windings + .iter() + .map(|w| self.bus_ref(&w.bus, &w.terminal_map)) + .collect(); + let conns: Vec<&str> = t + .windings + .iter() + .map(|w| match w.conn { + WindingConn::Wye => "wye", + WindingConn::Delta => "delta", + }) + .collect(); + let kvs: Vec = t.windings.iter().map(|w| num(w.v_ref / 1e3)).collect(); + let kvas: Vec = t.windings.iter().map(|w| num(w.s_rating / 1e3)).collect(); + let rs: Vec = t.windings.iter().map(|w| num(w.r_pct)).collect(); + let taps: Vec = t.windings.iter().map(|w| num(w.tap)).collect(); + let mut s = format!( + "New Transformer.{} phases={} windings={nw} buses=({}) conns=({}) kvs=({}) kvas=({}) %Rs=({}) taps=({})", + t.name, + t.phases, + buses.join(", "), + conns.join(", "), + kvs.join(", "), + kvas.join(", "), + rs.join(", "), + taps.join(", "), + ); + if let Some(xhl) = t.xsc_pct.first() { + let _ = write!(s, " xhl={}", num(*xhl)); + if t.xsc_pct.len() >= 3 { + let _ = write!(s, " xht={} xlt={}", num(t.xsc_pct[1]), num(t.xsc_pct[2])); + } + } else { + self.warn(format!( + "transformer {}: xsc_pct is empty; emitted xhl=0", + t.name + )); + s.push_str(" xhl=0"); + } + s.push_str(&self.extras_tail("transformer", &t.name, &t.extras)); + self.line_out(&s); + } + self.out.push('\n'); + } + + fn loads(&mut self, net: &DistNetwork) { + for l in &net.loads { + self.check_name("load", &l.name); + let phases = + self.element_phases(&l.extras, &l.terminal_map, l.configuration, "load", &l.name); + let conn = element_conn(&l.extras, l.configuration); + // The reader's nconds: a 3 phase delta has no neutral conductor, + // every other connection carries phases + 1. + let nconds = if conn == "delta" && phases == 3 { + phases + } else { + phases + 1 + }; + self.warn_short_map("load", &l.name, l.terminal_map.len(), nconds); + let kw: f64 = l.p_nom.iter().sum::() / 1e3; + let kvar: f64 = l.q_nom.iter().sum::() / 1e3; + let kv = self.element_kv(&l.extras, &l.bus, phases, l.configuration, &l.name, "load"); + let mut extras = l.extras.clone(); + extras.remove("kv"); + extras.remove("phases"); + extras.remove("conn"); + // q that came from a power factor goes back as pf=, so the + // engine recomputes its own kvar bit for bit. + let reactive = match extras.remove("pf").and_then(|v| v.as_f64()) { + Some(pf) => format!("pf={}", num(pf)), + None => format!("kvar={}", num(kvar)), + }; + let mut s = format!( + "New Load.{} bus1={} phases={phases} conn={conn} kv={} kw={} {reactive}", + l.name, + self.bus_ref(&l.bus, &l.terminal_map), + num(kv), + num(kw), + ); + s.push_str(&self.extras_tail("load", &l.name, &extras)); + self.line_out(&s); + } + self.out.push('\n'); + } + + /// `kv` for a load or capacitor: the recorded value when the source + /// carried one, otherwise the propagated bus estimate. + fn element_kv( + &mut self, + extras: &Extras, + bus: &str, + phases: usize, + configuration: Configuration, + name: &str, + class: &str, + ) -> f64 { + if let Some(v) = extras.get("kv") { + match v + .as_f64() + .or_else(|| v.as_str().and_then(|s| s.parse().ok())) + { + Some(kv) => return kv, + None => self.warn(format!( + "{class} {name}: kv extra `{v}` does not parse as a number; \ + using the bus voltage estimate" + )), + } + } + if let Some(vln) = self.kv_estimate.get(&bus.to_ascii_lowercase()).copied() { + // OpenDSS convention: line to line for 2 and 3 phase, line to + // neutral for single phase. + let v = if phases >= 2 || configuration == Configuration::Delta { + vln * 3f64.sqrt() + } else { + vln + }; + v / 1e3 + } else { + self.warn(format!( + "{class} {name}: no kv in the source and no bus voltage estimate; \ + emitted 12.47" + )); + 12.47 + } + } + + fn shunts(&mut self, net: &DistNetwork) { + for sh in &net.shunts { + self.check_name("capacitor", &sh.name); + let phases = extras_usize(&sh.extras, "phases").unwrap_or(sh.terminal_map.len()); + let b_phase = (0..phases.min(sh.b.len())) + .map(|i| sh.b[i][i]) + .fold(0.0_f64, f64::max); + if b_phase <= 0.0 { + self.warn(format!( + "shunt {}: no positive susceptance; dropped from the output", + sh.name + )); + continue; + } + let off_diag = + sh.b.iter() + .enumerate() + .any(|(i, row)| row.iter().enumerate().any(|(j, &v)| i != j && v != 0.0)); + if off_diag { + self.warn(format!( + "shunt {}: off diagonal susceptance has no capacitor expression; \ + only the diagonal is regenerated", + sh.name + )); + } + // Any (kv, kvar) pair with kvar = b v^2 reproduces the same + // admittance; the recorded pair (when the source carried one) + // emits verbatim, keeping the text stable across round trips. + let kv = self.element_kv( + &sh.extras, + &sh.bus, + phases, + Configuration::Wye, + &sh.name, + "capacitor", + ); + let kvar = extras_f64(&sh.extras, "kvar").unwrap_or_else(|| { + // The reader's wye capacitor convention: line to line kv + // for 2 and 3 phase, line to neutral for single phase. + let v_phase = if matches!(phases, 2 | 3) { + kv * 1e3 / 3f64.sqrt() + } else { + kv * 1e3 + }; + b_phase * v_phase * v_phase * phases as f64 / 1e3 + }); + let mut extras = sh.extras.clone(); + extras.remove("kv"); + extras.remove("kvar"); + extras.remove("phases"); + extras.remove("conn"); + let mut s = format!( + "New Capacitor.{} bus1={} phases={phases} conn=wye kv={} kvar={}", + sh.name, + self.bus_ref(&sh.bus, &sh.terminal_map), + num(kv), + num(kvar), + ); + s.push_str(&self.extras_tail("capacitor", &sh.name, &extras)); + self.line_out(&s); + } + self.out.push('\n'); + } + + fn generators(&mut self, net: &DistNetwork) { + for g in &net.generators { + self.check_name("generator", &g.name); + let phases = self.element_phases( + &g.extras, + &g.terminal_map, + g.configuration, + "generator", + &g.name, + ); + let conn = element_conn(&g.extras, g.configuration); + let nconds = if conn == "delta" && phases == 3 { + phases + } else { + phases + 1 + }; + self.warn_short_map("generator", &g.name, g.terminal_map.len(), nconds); + let kw: f64 = g.p_nom.iter().sum::() / 1e3; + let kvar: f64 = g.q_nom.iter().sum::() / 1e3; + let kv = self.element_kv( + &g.extras, + &g.bus, + phases, + g.configuration, + &g.name, + "generator", + ); + let mut s = format!( + "New Generator.{} bus1={} phases={phases} conn={conn} kv={} kw={} kvar={}", + g.name, + self.bus_ref(&g.bus, &g.terminal_map), + num(kv), + num(kw), + num(kvar), + ); + if let Some(q) = &g.q_max { + let _ = write!(s, " maxkvar={}", num(q.iter().sum::() / 1e3)); + } + if let Some(q) = &g.q_min { + let _ = write!(s, " minkvar={}", num(q.iter().sum::() / 1e3)); + } + if g.cost.is_some() { + self.warn(format!( + "generator {}: generation cost has no dss field; dropped", + g.name + )); + } + let mut extras = g.extras.clone(); + extras.remove("kv"); + extras.remove("phases"); + extras.remove("conn"); + s.push_str(&self.extras_tail("generator", &g.name, &extras)); + self.line_out(&s); + } + } +} + +/// Emitted `conn=`: delta for a typed delta, and for a single phase +/// element whose stashed conn token was delta (the reader types 1 phase +/// delta as `SinglePhase`, which would otherwise re-emit as wye). +fn element_conn(extras: &Extras, configuration: Configuration) -> &'static str { + let stash_delta = extras + .get("conn") + .and_then(|v| v.as_str()) + .is_some_and(|t| t.to_ascii_lowercase().starts_with('d') || t.eq_ignore_ascii_case("ll")); + match configuration { + Configuration::Delta => "delta", + Configuration::SinglePhase if stash_delta => "delta", + _ => "wye", + } +} + +#[cfg(test)] +mod tests { + use super::super::read::parse_dss_str; + use super::*; + use crate::model::{ + DistGenerator, DistLine, DistLineCode, DistLoad, DistShunt, DistSwitch, DistTransformer, + VoltageSource, Winding, + }; + + fn strings(v: &[&str]) -> Vec { + v.iter().map(ToString::to_string).collect() + } + + fn bus(id: &str, terminals: &[&str], grounded: &[&str]) -> DistBus { + DistBus { + id: id.into(), + terminals: strings(terminals), + grounded: strings(grounded), + ..DistBus::default() + } + } + + fn three_phase_source(vln: f64) -> (DistBus, VoltageSource) { + let third = 2.0 * std::f64::consts::FRAC_PI_3; + ( + bus("sb", &["1", "2", "3", "4"], &["4"]), + VoltageSource { + name: "source".into(), + bus: "sb".into(), + terminal_map: strings(&["1", "2", "3", "4"]), + v_magnitude: vec![vln, vln, vln, 0.0], + v_angle: vec![0.0, -third, third, 0.0], + extras: Extras::new(), + }, + ) + } + + fn load_on(bus: &str, map: &[&str], configuration: Configuration) -> DistLoad { + let phases = map.len(); + DistLoad { + name: "ld".into(), + bus: bus.into(), + terminal_map: strings(map), + configuration, + p_nom: vec![1e3; phases], + q_nom: vec![0.0; phases], + extras: Extras::from([("kv".to_string(), serde_json::json!("0.4"))]), + } + } + + fn roundtrip(net: &DistNetwork) -> (String, String) { + let first = write_dss(net); + let second = write_dss(&parse_dss_str(&first.text)); + (first.text, second.text) + } + + #[test] + fn voltage_bases_survive_the_sqrt_round_trip() { + // basekv = vln*sqrt(3)/1e3 then vln' = basekv*1e3/sqrt(3) is not a + // float fixed point for this PMD shaped value; the second write must + // reuse the stashed basekv instead of re-deriving the entry. + let vln = 9_336.235_056_420_312_f64; + let basekv = vln * 3f64.sqrt() / 1e3; + assert!( + (basekv * 1e3 / 3f64.sqrt()).to_bits() != vln.to_bits(), + "test value no longer reproduces the drift" + ); + let (b, vs) = three_phase_source(vln); + let net = DistNetwork { + name: Some("t".into()), + base_frequency: 60.0, + buses: vec![b], + sources: vec![vs], + ..DistNetwork::default() + }; + let (first, second) = roundtrip(&net); + assert!(first.contains("Set VoltageBases="), "{first}"); + assert_eq!(first, second); + } + + #[test] + fn load_phases_prefer_the_reader_stash() { + let (b, vs) = three_phase_source(2400.0); + let mut load = load_on("sb", &["1", "2", "3"], Configuration::Delta); + load.extras.insert("phases".into(), serde_json::json!("2")); + let net = DistNetwork { + base_frequency: 60.0, + buses: vec![b], + sources: vec![vs], + loads: vec![load], + ..DistNetwork::default() + }; + let out = write_dss(&net); + let line = out.text.lines().find(|l| l.contains("Load.ld")).unwrap(); + assert!(line.contains("phases=2 conn=delta"), "{line}"); + // The stash must not double emit through the extras tail. + assert_eq!(line.matches("phases=").count(), 1, "{line}"); + assert!(!out.warnings.iter().any(|w| w.contains("2 or 3 phase"))); + } + + #[test] + fn ambiguous_delta_keeps_three_phases_loudly() { + let (b, vs) = three_phase_source(2400.0); + let net = DistNetwork { + base_frequency: 60.0, + buses: vec![b], + sources: vec![vs], + loads: vec![load_on("sb", &["1", "2", "3"], Configuration::Delta)], + ..DistNetwork::default() + }; + let out = write_dss(&net); + let line = out.text.lines().find(|l| l.contains("Load.ld")).unwrap(); + assert!(line.contains("phases=3 conn=delta"), "{line}"); + assert!( + out.warnings.iter().any(|w| w.contains("2 or 3 phase")), + "{:?}", + out.warnings + ); + } + + #[test] + fn single_phase_delta_emits_conn_delta() { + let (b, vs) = three_phase_source(2400.0); + // Two conductor delta typed as Delta: phases=1 conn=delta. + let two_wire = load_on("sb", &["1", "2"], Configuration::Delta); + // The reader types 1 phase delta as SinglePhase; the stashed conn + // token carries the delta. + let mut stashed = load_on("sb", &["1", "2"], Configuration::SinglePhase); + stashed.name = "ld2".into(); + stashed + .extras + .insert("conn".into(), serde_json::json!("delta")); + let net = DistNetwork { + base_frequency: 60.0, + buses: vec![b], + sources: vec![vs], + loads: vec![two_wire, stashed], + ..DistNetwork::default() + }; + let out = write_dss(&net); + let l1 = out.text.lines().find(|l| l.contains("Load.ld ")).unwrap(); + assert!(l1.contains("phases=1 conn=delta"), "{l1}"); + let l2 = out.text.lines().find(|l| l.contains("Load.ld2 ")).unwrap(); + assert!(l2.contains("phases=1 conn=delta"), "{l2}"); + assert_eq!(l2.matches("conn=").count(), 1, "{l2}"); + } + + #[test] + fn unrepresentable_names_are_reported() { + let (b, vs) = three_phase_source(2400.0); + let mut load = load_on("sb", &["1", "2", "3", "4"], Configuration::Wye); + load.name = "load 1".into(); + let net = DistNetwork { + name: Some("my circuit".into()), + base_frequency: 60.0, + buses: vec![b, bus("a=b", &["1"], &[])], + sources: vec![vs], + loads: vec![load], + ..DistNetwork::default() + }; + let out = write_dss(&net); + let hits = |needle: &str| { + out.warnings + .iter() + .any(|w| w.contains(needle) && w.contains("cannot represent")) + }; + assert!(hits("load 1"), "{:?}", out.warnings); + assert!(hits("my circuit"), "{:?}", out.warnings); + // The bad bus id warns at its bus_ref emission site. + let mut net2 = net.clone(); + net2.lines.push(DistLine { + name: "l1".into(), + bus_from: "sb".into(), + bus_to: "a=b".into(), + terminal_map_from: strings(&["1"]), + terminal_map_to: strings(&["1"]), + linecode: "lc".into(), + length: 1.0, + extras: Extras::new(), + }); + let out2 = write_dss(&net2); + assert!( + out2.warnings + .iter() + .any(|w| w.contains("a=b") && w.contains("cannot represent")), + "{:?}", + out2.warnings + ); + } + + #[test] + fn unparseable_kv_extra_warns_instead_of_silently_substituting() { + let (b, vs) = three_phase_source(2400.0); + let mut load = load_on("sb", &["1", "2", "3", "4"], Configuration::Wye); + load.extras.insert("kv".into(), serde_json::json!("@kv")); + let net = DistNetwork { + base_frequency: 60.0, + buses: vec![b], + sources: vec![vs], + loads: vec![load], + ..DistNetwork::default() + }; + let out = write_dss(&net); + assert!( + out.warnings + .iter() + .any(|w| w.contains("@kv") && w.contains("does not parse")), + "{:?}", + out.warnings + ); + // The estimate substitutes: 2400*sqrt(3)/1e3 line to line. + let line = out.text.lines().find(|l| l.contains("Load.ld")).unwrap(); + assert!( + line.contains(&format!("kv={}", num(2400.0 * 3f64.sqrt() / 1e3))), + "{line}" + ); + } + + #[test] + fn options_reemit_and_commands_warn() { + let src = "Clear\n\ + New Circuit.c1 basekv=12.47 pu=1 angle=0 phases=3 bus1=sb\n\ + Set mode=snapshot\n\ + Set controlmode=OFF\n\ + Disable Line.l1\n\ + Set VoltageBases=[12.47]\n\ + Calcvoltagebases\n\ + Solve\n"; + let out = write_dss(&parse_dss_str(src)); + assert!(out.text.contains("Set mode=snapshot"), "{}", out.text); + assert!(out.text.contains("Set controlmode=OFF"), "{}", out.text); + // The writer derives these; the stored options must not double them. + assert_eq!(out.text.matches("Set VoltageBases").count(), 1); + assert_eq!(out.text.matches("Calcvoltagebases").count(), 1); + assert_eq!(out.text.matches("DefaultBaseFrequency").count(), 1); + assert!(!out.text.to_lowercase().contains("disable")); + assert!( + out.warnings + .iter() + .any(|w| w.contains("disable Line.l1") && w.contains("not regenerated")), + "{:?}", + out.warnings + ); + // Solve and Calcvoltagebases re-derive; no warning claims they drop. + assert!(!out.warnings.iter().any(|w| w.contains("`solve`"))); + let again = write_dss(&parse_dss_str(&out.text)); + assert_eq!(out.text, again.text); + } + + #[test] + fn non_numeric_terminal_positionalizes() { + let mut load = load_on("b1", &["a", "n"], Configuration::Wye); + load.extras.insert("kv".into(), serde_json::json!("0.23")); + let net = DistNetwork { + base_frequency: 60.0, + buses: vec![bus("b1", &["a", "n"], &["n"])], + loads: vec![load], + ..DistNetwork::default() + }; + let (first, second) = roundtrip(&net); + let line = first.lines().find(|l| l.contains("Load.ld")).unwrap(); + assert!(line.contains("bus1=b1.1.0"), "{line}"); + let out = write_dss(&net); + assert!( + out.warnings + .iter() + .any(|w| w.contains("`a`") && w.contains("position")), + "{:?}", + out.warnings + ); + assert_eq!(first, second); + } + + #[test] + fn half_present_thevenin_pair_stays_and_warns() { + let (b, mut vs) = three_phase_source(2400.0); + vs.extras + .insert("rs".into(), serde_json::json!([[1.0, 0.1], [0.1, 1.0]])); + let net = DistNetwork { + base_frequency: 60.0, + buses: vec![b], + sources: vec![vs], + ..DistNetwork::default() + }; + let out = write_dss(&net); + assert!(!out.text.contains("z1="), "{}", out.text); + assert!( + out.warnings.iter().any(|w| w.contains("`xs` is missing")), + "{:?}", + out.warnings + ); + } + + #[test] + fn unusable_switch_sequence_extras_warn() { + let (b, vs) = three_phase_source(2400.0); + let sw = DistSwitch { + name: "sw1".into(), + bus_from: "sb".into(), + bus_to: "b2".into(), + terminal_map_from: strings(&["1", "2", "3"]), + terminal_map_to: strings(&["1", "2", "3"]), + open: false, + i_max: Some(Vec::new()), + extras: Extras::from([("pmd_rs".to_string(), serde_json::json!("oops"))]), + }; + let net = DistNetwork { + base_frequency: 60.0, + buses: vec![b, bus("b2", &["1", "2", "3"], &[])], + sources: vec![vs], + switches: vec![sw], + ..DistNetwork::default() + }; + let out = write_dss(&net); + assert!(!out.text.contains("r0="), "{}", out.text); + assert!( + out.warnings + .iter() + .any(|w| w.contains("pmd_rs") && w.contains("not a numeric matrix")), + "{:?}", + out.warnings + ); + assert!( + out.warnings.iter().any(|w| w.contains("i_max is empty")), + "{:?}", + out.warnings + ); + } + + #[test] + fn degenerate_shapes_warn_instead_of_panicking() { + let (b, vs) = three_phase_source(2400.0); + let lc = DistLineCode { + name: "lc1".into(), + n_conductors: 2, + r_series: vec![vec![1.0], vec![0.5]], // second row short + x_series: vec![vec![1.0, 0.0], vec![0.0, 1.0]], + g_from: vec![vec![0.0; 2]; 2], + b_from: vec![vec![0.0; 2]; 2], + g_to: vec![vec![0.0; 2]; 2], + b_to: vec![vec![0.0; 2]; 2], + i_max: Some(Vec::new()), + s_max: None, + extras: Extras::new(), + }; + let t = DistTransformer { + name: "t1".into(), + windings: vec![ + Winding { + bus: "sb".into(), + terminal_map: strings(&["1", "2"]), + conn: WindingConn::Wye, + v_ref: 2400.0, + s_rating: 25e3, + r_pct: 0.5, + tap: 1.0, + }, + Winding { + bus: "b2".into(), + terminal_map: strings(&["1", "2"]), + conn: WindingConn::Wye, + v_ref: 240.0, + s_rating: 25e3, + r_pct: 0.5, + tap: 1.0, + }, + ], + xsc_pct: Vec::new(), + phases: 1, + extras: Extras::new(), + }; + let net = DistNetwork { + base_frequency: 60.0, + buses: vec![b, bus("b2", &["1", "2"], &[])], + sources: vec![vs], + linecodes: vec![lc], + transformers: vec![t], + ..DistNetwork::default() + }; + let out = write_dss(&net); // must not panic + assert!(out.text.contains("rmatrix=(1 | 0.5 0)"), "{}", out.text); + assert!(out.text.contains("xhl=0"), "{}", out.text); + let has = |needle: &str| out.warnings.iter().any(|w| w.contains(needle)); + assert!(has("shorter than the lower triangle"), "{:?}", out.warnings); + assert!(has("xsc_pct is empty"), "{:?}", out.warnings); + assert!(has("i_max is empty"), "{:?}", out.warnings); + } + + #[test] + fn two_phase_capacitor_kvar_uses_line_to_line_kv() { + // The reader treats wye capacitor kv as line to line for 2 and 3 + // phase; the kvar fallback must invert with the same convention. + let (b, vs) = three_phase_source(2400.0); + let b_phase = 1e-3; + let sh = DistShunt { + name: "c1".into(), + bus: "sb".into(), + terminal_map: strings(&["1", "2"]), + g: vec![vec![0.0; 2]; 2], + b: vec![vec![b_phase, 0.0], vec![0.0, b_phase]], + extras: Extras::new(), + }; + let net = DistNetwork { + base_frequency: 60.0, + buses: vec![b], + sources: vec![vs], + shunts: vec![sh], + ..DistNetwork::default() + }; + let out = write_dss(&net); + let kv = 2400.0 * 3f64.sqrt() / 1e3; + let v_phase = kv * 1e3 / 3f64.sqrt(); + let expected = b_phase * v_phase * v_phase * 2.0 / 1e3; + let line = out + .text + .lines() + .find(|l| l.contains("Capacitor.c1")) + .unwrap(); + assert!(line.contains(&format!("kvar={}", num(expected))), "{line}"); + } + + #[test] + fn option_values_choose_a_wrapper_the_lexer_undoes() { + let src = "Clear\n\ + New Circuit.c1 basekv=12.47 pu=1 angle=0 phases=3 bus1=sb\n\ + Set foo=[a!b]\n\ + Set bar=[(abc]\n\ + Set baz=(x ] y)\n\ + Set qux=[a ) b]\n\ + Solve\n"; + let net = parse_dss_str(src); + let first = write_dss(&net); + for line in [ + "Set foo=(a!b)", + "Set bar=((abc)", + "Set baz=(x ] y)", + "Set qux=[a ) b]", + ] { + assert!( + first.text.contains(line), + "{line} missing in {}", + first.text + ); + } + assert!( + !first + .warnings + .iter() + .any(|w| w.contains("emitted as written")), + "{:?}", + first.warnings + ); + // The reader strips the wrapper back off... + let reparsed = parse_dss_str(&first.text); + let opt = |k: &str| { + reparsed + .options + .iter() + .find(|(name, _)| name == k) + .map(|(_, v)| v.as_str()) + }; + assert_eq!(opt("foo"), Some("a!b")); + assert_eq!(opt("bar"), Some("(abc")); + assert_eq!(opt("baz"), Some("x ] y")); + assert_eq!(opt("qux"), Some("a ) b")); + // ...and the second write picks the same wrapper from the bare value. + let second = write_dss(&reparsed); + assert_eq!(first.text, second.text); + } + + #[test] + fn extras_tail_values_wrap_like_options() { + let (b, vs) = three_phase_source(2400.0); + let mut load = load_on("sb", &["1", "2", "3", "4"], Configuration::Wye); + load.extras + .insert("daily".into(), serde_json::json!("a ) b")); + let net = DistNetwork { + base_frequency: 60.0, + buses: vec![b], + sources: vec![vs], + loads: vec![load], + ..DistNetwork::default() + }; + let (first, second) = roundtrip(&net); + // A paren wrapper would close at the `)` and land `b)` on the next + // positional property (duty); brackets survive. + assert!(first.contains("daily=[a ) b]"), "{first}"); + assert_eq!(first, second); + let back = parse_dss_str(&first); + assert_eq!( + back.loads[0] + .extras + .get("daily") + .and_then(serde_json::Value::as_str), + Some("a ) b") + ); + } + + #[test] + fn unrepresentable_values_emit_as_written_and_warn() { + // Every quote closer appears, and the spaces split a bare scan: no + // emitted form reparses to this value. + let bad = "a )]}\"' b"; + let (b, vs) = three_phase_source(2400.0); + let mut load = load_on("sb", &["1", "2", "3", "4"], Configuration::Wye); + load.extras.insert("daily".into(), serde_json::json!(bad)); + let mut net = DistNetwork { + base_frequency: 60.0, + buses: vec![b], + sources: vec![vs], + loads: vec![load], + ..DistNetwork::default() + }; + net.options.push(("foo".into(), bad.into())); + let out = write_dss(&net); + assert!(out.text.contains(&format!("Set foo={bad}")), "{}", out.text); + assert!(out.text.contains(&format!("daily={bad}")), "{}", out.text); + let warned = |needle: &str| { + out.warnings + .iter() + .any(|w| w.contains(needle) && w.contains("emitted as written")) + }; + assert!(warned("option `foo`"), "{:?}", out.warnings); + assert!(warned("`daily`"), "{:?}", out.warnings); + } + + #[test] + fn empty_extras_values_wrap_instead_of_eating_the_next_token() { + let dss = "clear\nnew circuit.c basekv=12.47 bus1=sb\n\ + new load.ld bus1=sb.1 phases=1 kv=7.2 kw=10 daily=() duty=sh\nsolve\n"; + let net = parse_dss_str(dss); + let load = &net.loads[0]; + assert_eq!(load.extras.get("daily").and_then(|v| v.as_str()), Some("")); + let w1 = write_dss(&net).text; + let again = parse_dss_str(&w1); + let load2 = &again.loads[0]; + assert_eq!(load2.extras.get("daily").and_then(|v| v.as_str()), Some("")); + assert_eq!( + load2.extras.get("duty").and_then(|v| v.as_str()), + Some("sh") + ); + assert_eq!(w1, write_dss(&again).text); + } + + #[test] + fn sub_unique_option_prefixes_re_emit_instead_of_vanishing() { + // "ca" is CapkVAR and "default" is DefaultDaily in the engine's + // option table; neither may be skipped as a derived key, and + // `Set default=2.5` must not change the base frequency. + let dss = "clear\nnew circuit.c basekv=12.47 bus1=sb\n\ + Set ca=600\nSet default=2.5\nsolve\n"; + let net = parse_dss_str(dss); + assert!((net.base_frequency - 60.0).abs() < 1e-12); + let out = write_dss(&net).text; + assert!(out.contains("Set ca=600"), "{out}"); + assert!(out.contains("Set default=2.5"), "{out}"); + } + + #[test] + fn abbreviated_derived_options_skip_and_set_the_frequency() { + // The engine resolves Set names by unique prefix, so volt= IS + // Voltagebases and defaultb= IS DefaultBaseFrequency. + let src = "Clear\n\ + New Circuit.c1 basekv=12.47 pu=1 angle=0 phases=3 bus1=sb\n\ + Set volt=[115, 132]\n\ + Set defaultb=50\n\ + Solve\n"; + let net = parse_dss_str(src); + assert!((net.base_frequency - 50.0).abs() < 1e-12); + let out = write_dss(&net); + assert!( + out.text.contains("Set DefaultBaseFrequency=50"), + "{}", + out.text + ); + assert_eq!( + out.text + .to_lowercase() + .matches("defaultbasefrequency") + .count(), + 1, + "{}", + out.text + ); + assert_eq!( + out.text.matches("Set VoltageBases").count(), + 1, + "{}", + out.text + ); + assert!(!out.text.contains("Set volt="), "{}", out.text); + assert!(!out.text.contains("Set defaultb="), "{}", out.text); + let second = write_dss(&parse_dss_str(&out.text)); + assert_eq!(out.text, second.text); + } + + #[test] + fn non_numeric_source_extras_warn_before_falling_back() { + let (b, mut vs) = three_phase_source(2400.0); + vs.extras + .insert("basekv".into(), serde_json::json!("@base")); + vs.extras.insert("pu".into(), serde_json::json!("unity")); + vs.extras.insert("angle".into(), serde_json::json!([0.0])); + let net = DistNetwork { + base_frequency: 60.0, + buses: vec![b], + sources: vec![vs], + ..DistNetwork::default() + }; + let out = write_dss(&net); + for key in ["basekv", "pu", "angle"] { + assert!( + out.warnings + .iter() + .any(|w| w.contains(&format!("{key} extra")) && w.contains("does not parse")), + "{key}: {:?}", + out.warnings + ); + } + // The derived values substitute. + let line = out.text.lines().find(|l| l.contains("Circuit.")).unwrap(); + assert!(line.contains("pu=1 angle=0"), "{line}"); + } + + #[test] + fn de_energized_source_phase_keeps_its_conductor() { + let (b, mut vs) = three_phase_source(2400.0); + vs.v_magnitude[2] = 0.0; // de-energized, but still a phase conductor + let net = DistNetwork { + name: Some("t".into()), + base_frequency: 60.0, + buses: vec![b], + sources: vec![vs], + ..DistNetwork::default() + }; + let (first, second) = roundtrip(&net); + let line = first.lines().find(|l| l.contains("Circuit.")).unwrap(); + // phases=2 against the 4 node dot list would drop a node on reparse. + assert!(line.contains("phases=3"), "{line}"); + assert!(line.contains("bus1=sb.1.2.3.0"), "{line}"); + assert_eq!(first, second); + let out = write_dss(&net); + assert!( + out.warnings + .iter() + .any(|w| w.contains("phases=3") && w.contains("positive")), + "{:?}", + out.warnings + ); + } + + #[test] + fn source_phases_stash_wins_and_does_not_double_emit() { + let (b, mut vs) = three_phase_source(2400.0); + vs.extras.insert("phases".into(), serde_json::json!("3")); + let net = DistNetwork { + base_frequency: 60.0, + buses: vec![b], + sources: vec![vs], + ..DistNetwork::default() + }; + let out = write_dss(&net); + let line = out.text.lines().find(|l| l.contains("Circuit.")).unwrap(); + assert!(line.contains("phases=3"), "{line}"); + assert_eq!(line.matches("phases=").count(), 1, "{line}"); + } + + #[test] + fn foreign_maps_without_a_neutral_warn_and_converge_at_write2() { + // A vsource/wye load map with no grounded terminal: the engine's + // nconds fill extends the reparsed bus with a grounded neutral, so + // write1 is not a fixed point. The writer must say so. + let third = 2.0 * std::f64::consts::FRAC_PI_3; + let vs = VoltageSource { + name: "source".into(), + bus: "sb".into(), + terminal_map: strings(&["1", "2", "3"]), + v_magnitude: vec![2400.0; 3], + v_angle: vec![0.0, -third, third], + extras: Extras::new(), + }; + let load = load_on("sb", &["1"], Configuration::Wye); + let net = DistNetwork { + name: Some("t".into()), + base_frequency: 60.0, + buses: vec![bus("sb", &["1", "2", "3"], &[])], + sources: vec![vs], + loads: vec![load], + ..DistNetwork::default() + }; + let first = write_dss(&net); + let hits = |warnings: &[String], name: &str| { + warnings + .iter() + .any(|w| w.contains(name) && w.contains("materializes a grounded neutral")) + }; + assert!( + hits(&first.warnings, "vsource source"), + "{:?}", + first.warnings + ); + assert!(hits(&first.warnings, "load ld"), "{:?}", first.warnings); + let second = write_dss(&parse_dss_str(&first.text)); + assert_ne!(first.text, second.text); + assert!(!hits(&second.warnings, "vsource"), "{:?}", second.warnings); + assert!(!hits(&second.warnings, "load"), "{:?}", second.warnings); + let third_write = write_dss(&parse_dss_str(&second.text)); + assert_eq!(second.text, third_write.text); + } + + #[test] + fn generator_phases_and_conn_match_the_load_rules() { + let (b, vs) = three_phase_source(2400.0); + let g = DistGenerator { + name: "g1".into(), + bus: "sb".into(), + terminal_map: strings(&["1", "2", "3"]), + configuration: Configuration::Delta, + p_nom: vec![1e3; 3], + q_nom: vec![0.0; 3], + p_min: None, + p_max: None, + q_min: None, + q_max: None, + cost: None, + extras: Extras::from([ + ("kv".to_string(), serde_json::json!("4.16")), + ("phases".to_string(), serde_json::json!("2")), + ]), + }; + let net = DistNetwork { + base_frequency: 60.0, + buses: vec![b], + sources: vec![vs], + generators: vec![g], + ..DistNetwork::default() + }; + let out = write_dss(&net); + let line = out + .text + .lines() + .find(|l| l.contains("Generator.g1")) + .unwrap(); + assert!(line.contains("phases=2 conn=delta"), "{line}"); + assert_eq!(line.matches("phases=").count(), 1, "{line}"); + } +} diff --git a/powerio-dist/src/error.rs b/powerio-dist/src/error.rs new file mode 100644 index 0000000..e38c895 --- /dev/null +++ b/powerio-dist/src/error.rs @@ -0,0 +1,23 @@ +use thiserror::Error; + +pub type Result = std::result::Result; + +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum Error { + #[error("io error reading {path}: {source}")] + Io { + path: String, + #[source] + source: std::io::Error, + }, + + #[error("malformed {format} JSON: {message}")] + Json { + format: &'static str, + message: String, + }, + + #[error("unknown distribution format `{0}` (expected dss, bmopf, or pmd)")] + UnknownFormat(String), +} diff --git a/powerio-dist/src/lib.rs b/powerio-dist/src/lib.rs new file mode 100644 index 0000000..9ba512e --- /dev/null +++ b/powerio-dist/src/lib.rs @@ -0,0 +1,65 @@ +//! `powerio-dist`: a multiconductor distribution network model and lossless +//! converters between OpenDSS `.dss`, PowerModelsDistribution ENGINEERING +//! JSON ("PMD JSON"), and the draft JSON schema of the IEEE PES Task Force on +//! Benchmarking Multiconductor OPF ("BMOPF JSON", +//! ). +//! +//! The canonical model is a network in wire coordinates: string bus ids, +//! ordered string terminal names per bus, explicit grounding, terminal maps +//! on every element, SI units and radians internally (BMOPF semantics, the +//! most explicit of the three formats). The transmission model in the +//! `powerio` crate is positive sequence and stays separate; the two crates +//! share conventions, not types. +//! +//! ```no_run +//! let net = powerio_dist::parse_file("feeder.dss", None)?; +//! for w in &net.warnings { +//! eprintln!("parse: {w}"); +//! } +//! let conv = net.to_format(powerio_dist::DistTargetFormat::PmdJson); +//! # Ok::<(), powerio_dist::Error>(()) +//! ``` +//! +//! # Fidelity contract +//! +//! The contract matches `powerio`. Writing back to the source format +//! reproduces the file byte for byte via retained source text. Every cross +//! format conversion regenerates from the typed model and reports each field +//! the target cannot represent in [`Conversion::warnings`]; nothing drops +//! silently. The dss reader materializes every OpenDSS class default into an +//! explicit model value and records which fields were defaulted +//! ([`DistNetwork::defaulted`]), so BMOPF output is always fully explicit. +//! The per fixture results live in `docs/conversion-matrix.md`. +//! +//! # Float formatting +//! +//! Canonical output formats every number as its shortest round trip +//! representation: Rust's `Display` for `.dss`, serde_json (ryu) for both +//! JSON formats. The readers parse with serde_json's `float_roundtrip` +//! feature, so a parse of canonical output recovers the exact bit pattern +//! and canonical writes are idempotent. JSON cannot carry `Inf`/`NaN`: the +//! PMD writer emits `null` (PMD restores the value from the field name +//! suffix), and the BMOPF writer emits `0` with a warning, since the schema +//! requires numbers. The byte exact echo tier is unaffected; it never +//! reformats. + +pub mod bmopf; +pub mod convert; +pub mod dss; +pub mod error; +pub mod model; +pub mod pmd; + +pub use bmopf::{parse_bmopf_file, parse_bmopf_str, write_bmopf_json}; +pub use convert::{ + Conversion, DistTargetFormat, convert_file, convert_str, dist_target_from_name, parse_file, + parse_str, +}; +pub use dss::{parse_dss_file, parse_dss_str, write_dss}; +pub use error::{Error, Result}; +pub use model::{ + Configuration, DistBus, DistGenerator, DistLine, DistLineCode, DistLoad, DistNetwork, + DistShunt, DistSourceFormat, DistSwitch, DistTransformer, Extras, Mat, UntypedObject, + VoltageSource, Winding, WindingConn, +}; +pub use pmd::{parse_pmd_file, parse_pmd_str, write_pmd_json}; diff --git a/powerio-dist/src/model.rs b/powerio-dist/src/model.rs new file mode 100644 index 0000000..c6f4e0b --- /dev/null +++ b/powerio-dist/src/model.rs @@ -0,0 +1,343 @@ +//! The canonical multiconductor network model. +//! +//! Wire coordinates with BMOPF semantics: string bus ids, ordered string +//! terminal names per bus, explicit grounding on buses, terminal maps on +//! every element, SI units (V, W, var, ohm, S, meters) and radians. Terminal +//! names are the OpenDSS node numbers as strings; implicit ground +//! connections materialize as an explicit perfectly grounded neutral +//! terminal on the bus (named 4 on a three phase bus), the convention +//! PowerModelsDistribution and the public BMOPF examples share. +//! +//! Transformer impedances stay in the per unit form the source formats use +//! (`r_pct`, `xsc_pct` as percent of the winding base); the BMOPF writer +//! converts to ohms on the wye side at emission. Everything an element +//! carries beyond the typed fields lives in its `extras` map. + +use std::collections::BTreeMap; +use std::sync::Arc; + +pub type Extras = BTreeMap; + +/// A square matrix in conductor order, row major. +pub type Mat = Vec>; + +/// Where the network came from; fixes the echo tier target. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum DistSourceFormat { + Dss, + BmopfJson, + PmdJson, +} + +impl DistSourceFormat { + /// The canonical format name (`dss`, `pmd-json`, `bmopf-json`), accepted + /// back by [`crate::dist_target_from_name`]. + pub fn name(self) -> &'static str { + match self { + DistSourceFormat::Dss => "dss", + DistSourceFormat::PmdJson => "pmd-json", + DistSourceFormat::BmopfJson => "bmopf-json", + } + } +} + +#[derive(Clone, Debug, PartialEq, Default)] +pub struct DistBus { + pub id: String, + /// Ordered terminal names; OpenDSS node numbers as strings. + pub terminals: Vec, + /// Terminals tied to ground with zero impedance. + pub grounded: Vec, + /// Voltage magnitude bounds, volts: the scalar pair plus the phase to + /// neutral, phase to phase, and symmetrical component families (the + /// four BMOPF bound families). + pub v_min: Option, + pub v_max: Option, + pub vpn_min: Option>, + pub vpn_max: Option>, + pub vpp_min: Option>, + pub vpp_max: Option>, + pub vsym_min: Option>, + pub vsym_max: Option>, + pub extras: Extras, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct DistLineCode { + pub name: String, + pub n_conductors: usize, + /// Series impedance, ohm per meter. + pub r_series: Mat, + pub x_series: Mat, + /// Shunt admittance halves at each end, S per meter. + pub g_from: Mat, + pub b_from: Mat, + pub g_to: Mat, + pub b_to: Mat, + /// Ampacity per conductor. + pub i_max: Option>, + pub s_max: Option>, + pub extras: Extras, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct DistLine { + pub name: String, + pub bus_from: String, + pub bus_to: String, + pub terminal_map_from: Vec, + pub terminal_map_to: Vec, + pub linecode: String, + /// Meters. + pub length: f64, + pub extras: Extras, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct DistSwitch { + pub name: String, + pub bus_from: String, + pub bus_to: String, + pub terminal_map_from: Vec, + pub terminal_map_to: Vec, + pub open: bool, + pub i_max: Option>, + pub extras: Extras, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum Configuration { + Wye, + Delta, + SinglePhase, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct DistLoad { + pub name: String, + pub bus: String, + pub terminal_map: Vec, + pub configuration: Configuration, + /// Watts per phase. + pub p_nom: Vec, + /// Vars per phase. + pub q_nom: Vec, + pub extras: Extras, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct DistGenerator { + pub name: String, + pub bus: String, + pub terminal_map: Vec, + pub configuration: Configuration, + /// Setpoint, watts per phase. + pub p_nom: Vec, + pub q_nom: Vec, + pub p_min: Option>, + pub p_max: Option>, + pub q_min: Option>, + pub q_max: Option>, + /// $/kWh; no OpenDSS equivalent, so it is None until a format supplies it. + pub cost: Option, + pub extras: Extras, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct DistShunt { + pub name: String, + pub bus: String, + pub terminal_map: Vec, + /// Total siemens in conductor order. + pub g: Mat, + pub b: Mat, + pub extras: Extras, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum WindingConn { + Wye, + Delta, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Winding { + pub bus: String, + pub terminal_map: Vec, + pub conn: WindingConn, + /// Rated winding voltage, volts (line to line for 2 and 3 phase). + pub v_ref: f64, + /// Volt amperes. + pub s_rating: f64, + /// Winding resistance, percent of the winding base. + pub r_pct: f64, + pub tap: f64, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct DistTransformer { + pub name: String, + pub windings: Vec, + /// Short circuit reactances between winding pairs, percent: + /// `[xhl]` for two windings, `[xhl, xht, xlt]` for three. + pub xsc_pct: Vec, + pub phases: usize, + pub extras: Extras, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct VoltageSource { + pub name: String, + pub bus: String, + pub terminal_map: Vec, + /// Volts per terminal (0.0 on grounded terminals). + pub v_magnitude: Vec, + /// Radians per terminal. + pub v_angle: Vec, + pub extras: Extras, +} + +/// An object the reader recognized but does not type: preserved by class, +/// name, and raw property text so conversions can warn precisely. +#[derive(Clone, Debug, PartialEq)] +pub struct UntypedObject { + pub class: String, + pub name: String, + pub props: Vec<(Option, String)>, +} + +/// A multiconductor distribution network. +/// +/// `source` retains the original text for the byte exact echo tier; +/// `defaulted` records, per element (`"class.name"` key), the fields the +/// reader materialized from format defaults rather than the source text. +#[derive(Clone, Debug)] +pub struct DistNetwork { + pub name: Option, + /// Hz. + pub base_frequency: f64, + pub buses: Vec, + pub linecodes: Vec, + pub lines: Vec, + pub switches: Vec, + pub transformers: Vec, + pub loads: Vec, + pub generators: Vec, + pub shunts: Vec, + /// BMOPF allows exactly one; the model allows any number and the BMOPF + /// writer warns beyond the first. + pub sources: Vec, + pub untyped: Vec, + /// Source commands and options the typed model does not interpret + /// (`solve`, `set mode=...`), in order, as (verb, args). + pub commands: Vec<(String, String)>, + pub options: Vec<(String, String)>, + pub defaulted: BTreeMap>, + pub warnings: Vec, + pub source: Option>, + pub source_format: Option, + pub extras: Extras, +} + +impl Default for DistNetwork { + /// An empty network at the OpenDSS default frequency. A derived 0 Hz + /// default would put NaN into every capacitance the dss writer converts + /// through omega. + fn default() -> Self { + DistNetwork { + name: None, + base_frequency: crate::dss::defaults::BASE_FREQUENCY, + buses: Vec::new(), + linecodes: Vec::new(), + lines: Vec::new(), + switches: Vec::new(), + transformers: Vec::new(), + loads: Vec::new(), + generators: Vec::new(), + shunts: Vec::new(), + sources: Vec::new(), + untyped: Vec::new(), + commands: Vec::new(), + options: Vec::new(), + defaulted: BTreeMap::new(), + warnings: Vec::new(), + source: None, + source_format: None, + extras: Extras::new(), + } + } +} + +impl DistNetwork { + /// Case insensitive, matching the source formats' name semantics. + pub fn bus(&self, id: &str) -> Option<&DistBus> { + self.buses.iter().find(|b| b.id.eq_ignore_ascii_case(id)) + } + + /// Case insensitive, matching the source formats' name semantics. + pub fn linecode(&self, name: &str) -> Option<&DistLineCode> { + self.linecodes + .iter() + .find(|c| c.name.eq_ignore_ascii_case(name)) + } +} + +/// Builds an `n`x`n` matrix from lower triangle rows (the OpenDSS matrix +/// entry convention) or full rows; symmetric completion for the triangle. +pub(crate) fn square_from_rows(rows: &[Vec], n: usize) -> Option { + let mut m = vec![vec![0.0; n]; n]; + if rows.len() != n { + return None; + } + let lower = rows.iter().enumerate().all(|(i, r)| r.len() == i + 1); + let full = rows.iter().all(|r| r.len() == n); + if lower { + for (i, row) in rows.iter().enumerate() { + for (j, &v) in row.iter().enumerate() { + m[i][j] = v; + m[j][i] = v; + } + } + } else if full { + for (i, row) in rows.iter().enumerate() { + m[i].clone_from_slice(&row[..n]); + } + } else { + return None; + } + Some(m) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[allow(clippy::float_cmp)] + fn lower_triangle_completes_symmetrically() { + let rows = vec![vec![1.0], vec![0.5, 2.0], vec![0.3, 0.4, 3.0]]; + let m = square_from_rows(&rows, 3).unwrap(); + assert_eq!(m[0][1], 0.5); + assert_eq!(m[1][0], 0.5); + assert_eq!(m[2][2], 3.0); + assert_eq!(m[0][2], 0.3); + } + + #[test] + #[allow(clippy::float_cmp)] + fn full_rows_pass_through() { + let rows = vec![vec![1.0, 9.0], vec![8.0, 2.0]]; + let m = square_from_rows(&rows, 2).unwrap(); + assert_eq!(m[0][1], 9.0); + assert_eq!(m[1][0], 8.0); + } + + #[test] + fn wrong_shape_is_rejected() { + assert!(square_from_rows(&[vec![1.0], vec![2.0]], 2).is_none()); + assert!(square_from_rows(&[vec![1.0, 2.0]], 2).is_none()); + } +} diff --git a/powerio-dist/src/pmd/mod.rs b/powerio-dist/src/pmd/mod.rs new file mode 100644 index 0000000..752fb4c --- /dev/null +++ b/powerio-dist/src/pmd/mod.rs @@ -0,0 +1,15 @@ +//! The PowerModelsDistribution ENGINEERING model as JSON ("PMD JSON"). +//! +//! The byte conventions follow PMD's own `print_file`/`parse_file` pair: +//! matrices as arrays of arrays read back via `hcat` (inner arrays are +//! columns), `Inf`/`NaN` as `null` restored by field suffix (`_ub`/`max` +//! to +Inf, `_lb`/`min` to -Inf, anything else NaN), enums as uppercase +//! strings, kV and kW scales with angles in degrees, meters for lengths, +//! per unit transformer impedances, and integer terminals with grounding +//! as `grounded` plus `rg`/`xg` on the bus. + +mod read; +mod write; + +pub use read::{parse_pmd_file, parse_pmd_str}; +pub use write::write_pmd_json; diff --git a/powerio-dist/src/pmd/read.rs b/powerio-dist/src/pmd/read.rs new file mode 100644 index 0000000..6ae081f --- /dev/null +++ b/powerio-dist/src/pmd/read.rs @@ -0,0 +1,830 @@ +//! PMD ENGINEERING JSON into the canonical [`DistNetwork`]. +//! +//! The reader applies PMD's own import corrections: `null` becomes +Inf +//! under a `_ub`/`max` suffix, -Inf under `_lb`/`min`, NaN elsewhere, and +//! arrays of arrays rebuild as matrices with the inner arrays as columns. +//! Integer terminals become the model's string names; per unit transformer +//! impedances become the model's percent fields; kV, kW, and degrees scale +//! to volts, watts, and radians. Fields the model does not type ride in +//! `extras` so the PMD writer can reproduce them. + +use std::path::Path; +use std::sync::Arc; + +use serde_json::{Map, Value}; + +use crate::error::{Error, Result}; +use crate::model::{ + Configuration, DistBus, DistGenerator, DistLine, DistLineCode, DistLoad, DistNetwork, + DistShunt, DistSourceFormat, DistSwitch, DistTransformer, Extras, Mat, UntypedObject, + VoltageSource, Winding, WindingConn, +}; + +pub fn parse_pmd_file(path: impl AsRef) -> Result { + let path = path.as_ref(); + let text = std::fs::read_to_string(path).map_err(|source| Error::Io { + path: path.display().to_string(), + source, + })?; + parse_pmd_str(&text) +} + +pub fn parse_pmd_str(text: &str) -> Result { + let doc: Value = serde_json::from_str(text).map_err(|e| Error::Json { + format: "PMD", + message: e.to_string(), + })?; + let Value::Object(doc) = doc else { + return Err(Error::Json { + format: "PMD", + message: "top level is not an object".into(), + }); + }; + let mut net = DistNetwork { + source: Some(Arc::new(text.to_string())), + source_format: Some(DistSourceFormat::PmdJson), + base_frequency: 60.0, + ..DistNetwork::default() + }; + let mut rd = Reader { net: &mut net }; + rd.document(&doc); + Ok(net) +} + +struct Reader<'a> { + net: &'a mut DistNetwork, +} + +/// PMD's null restoration: the field suffix picks the value. +fn restore(key: &str, v: &Value) -> f64 { + if v.is_null() { + if key.ends_with("_ub") || key.ends_with("max") { + f64::INFINITY + } else if key.ends_with("_lb") || key.ends_with("min") { + f64::NEG_INFINITY + } else { + f64::NAN + } + } else { + v.as_f64().unwrap_or(f64::NAN) + } +} + +fn floats(key: &str, v: Option<&Value>) -> Option> { + v?.as_array() + .map(|a| a.iter().map(|x| restore(key, x)).collect()) +} + +/// Arrays of arrays rebuild with the inner arrays as columns (`hcat`). +fn matrix(key: &str, v: Option<&Value>) -> Option { + let cols = v?.as_array()?; + let n = cols.len(); + let mut m = vec![vec![0.0; n]; n]; + for (j, col) in cols.iter().enumerate() { + let col = col.as_array()?; + for (i, x) in col.iter().enumerate().take(n) { + m[i][j] = restore(key, x); + } + } + Some(m) +} + +fn ints_as_strings(v: Option<&Value>) -> Vec { + v.and_then(Value::as_array) + .map(|a| { + a.iter() + .map(|x| { + x.as_i64().map_or_else( + || x.as_str().unwrap_or_default().to_string(), + |i| i.to_string(), + ) + }) + .collect() + }) + .unwrap_or_default() +} + +fn string(v: Option<&Value>) -> String { + v.and_then(Value::as_str).unwrap_or_default().to_string() +} + +/// Grows `m` to `n` by `n`, preserving the existing entries. +fn pad_to(m: Mat, n: usize) -> Mat { + if m.len() >= n { + return m; + } + let mut out = vec![vec![0.0; n]; n]; + for (i, row) in m.into_iter().enumerate() { + for (j, v) in row.into_iter().enumerate() { + out[i][j] = v; + } + } + out +} + +/// Keeps fields outside `known` in extras verbatim (no warning: the +/// ENGINEERING model legitimately carries fields the hub does not type, +/// and the PMD writer reproduces the typed ones). +fn take_extras(o: &Map, known: &[&str]) -> Extras { + o.iter() + // The inner `name` duplicates the element's key. + .filter(|(k, _)| !known.contains(&k.as_str()) && k.as_str() != "name") + .map(|(k, v)| (k.clone(), v.clone())) + .collect() +} + +/// The model has no status field; a non ENABLED status rides in extras so +/// the PMD writer reproduces it instead of silently re-enabling. +fn stash_status( + o: &Map, + extras: &mut Extras, + what: &str, + warnings: &mut Vec, +) { + if let Some(s) = o.get("status").and_then(Value::as_str) + && s != "ENABLED" + { + extras.insert("pmd_status".into(), Value::String(s.to_string())); + warnings.push(format!( + "{what}: status {s} kept in extras; other formats emit the element enabled" + )); + } +} + +/// A linecode from an object carrying the linecode matrix fields: a +/// `linecode` entry, or a line with inline impedance (the dss2eng output +/// for rmatrix defined lines). Extras hold only the raw `b_fr`/`b_to` +/// stash; the caller merges anything else. +fn linecode_from( + name: &str, + o: &Map, + base_frequency: f64, + warnings: &mut Vec, +) -> DistLineCode { + let mats = [ + matrix("rs", o.get("rs")), + matrix("xs", o.get("xs")), + matrix("g_fr", o.get("g_fr")), + matrix("g_to", o.get("g_to")), + matrix("b_fr", o.get("b_fr")), + matrix("b_to", o.get("b_to")), + ]; + // Conductor count is the widest matrix present; absent matrices read + // as zero, smaller ones pad without losing entries. + let n = mats.iter().flatten().map(Vec::len).max().unwrap_or(0); + if mats.iter().flatten().any(|m| m.len() < n) { + warnings.push(format!( + "linecode {name}: matrix sizes disagree; smaller ones padded \ + with zeros to {n}x{n}" + )); + } + let [r, x, gf, gt, bf, bt] = mats.map(|m| pad_to(m.unwrap_or_default(), n)); + // b_fr/b_to numbers are cmatrix halves in nF per meter; the model + // holds siemens per meter. + let omega = std::f64::consts::TAU * base_frequency * 1e-9; + let to_b = |m: Mat| -> Mat { + m.into_iter() + .map(|row| row.into_iter().map(|v| v * omega).collect()) + .collect() + }; + DistLineCode { + name: name.to_string(), + n_conductors: n, + x_series: x, + g_from: gf, + g_to: gt, + b_from: to_b(bf), + b_to: to_b(bt), + r_series: r, + i_max: floats("cm_ub", o.get("cm_ub")).filter(|v| v.iter().all(|x| x.is_finite())), + s_max: floats("sm_ub", o.get("sm_ub")).filter(|v| v.iter().all(|x| x.is_finite())), + extras: { + // The raw arrays make writing back bit exact across the + // capacitance to susceptance basis change. + let mut extras = Extras::new(); + if let Some(b) = o.get("b_fr") { + extras.insert("pmd_b_fr".into(), b.clone()); + } + if let Some(b) = o.get("b_to") { + extras.insert("pmd_b_to".into(), b.clone()); + } + extras + }, + } +} + +/// `Winding.tap` is a scalar; the first phase tap represents each winding +/// (the raw per phase arrays ride in extras). The flag reports whether any +/// winding's phases disagree, which exact comparison detects: a copied +/// default differs by zero bits. +#[allow(clippy::float_cmp)] +fn representative_taps(tm_set: Option<&Value>) -> (Vec, bool) { + let mut firsts = Vec::new(); + let mut differ = false; + for w in tm_set + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default() + { + let taps: Vec = w + .as_array() + .map(|p| p.iter().map(|v| restore("tm_set", v)).collect()) + .unwrap_or_default(); + let first = taps.first().copied().unwrap_or(1.0); + differ |= taps.iter().any(|&t| t != first); + firsts.push(first); + } + (firsts, differ) +} + +struct WindingNums<'a> { + rw: &'a [f64], + xsc: &'a [f64], + sm_nom: &'a [f64], + vm_nom: &'a [f64], + tm_set: &'a [f64], +} + +/// Windings from the parallel per winding arrays; undoes the lag +/// connection's barrel roll (`polarity` -1 on a wye winding under a delta +/// primary) so the model holds the source case's order. The flag reports +/// whether any winding was unrolled. +fn build_windings( + buses: &[String], + configs: &[WindingConn], + polarity: &[i64], + o: &Map, + nums: &WindingNums, +) -> (Vec, usize, bool) { + let _ = nums.xsc; + let mut windings = Vec::with_capacity(buses.len()); + let mut phases = 1; + let mut unrolled = false; + for (w, bus) in buses.iter().enumerate() { + let mut map = ints_as_strings( + o.get("connections") + .and_then(Value::as_array) + .and_then(|a| a.get(w)), + ); + let conn = configs.get(w).copied().unwrap_or(WindingConn::Wye); + if polarity.get(w) == Some(&-1) + && conn == WindingConn::Wye + && configs.first() == Some(&WindingConn::Delta) + && map.len() > 1 + { + let phases_part = map.len() - 1; + map[..phases_part].rotate_right(1); + unrolled = true; + } + if conn == WindingConn::Wye { + phases = phases.max(map.len().saturating_sub(1)); + } else { + phases = phases.max(map.len()); + } + windings.push(Winding { + bus: bus.clone(), + terminal_map: map, + conn, + v_ref: nums.vm_nom.get(w).copied().unwrap_or(f64::NAN) * 1e3, + s_rating: nums.sm_nom.get(w).copied().unwrap_or(f64::NAN) * 1e3, + r_pct: nums.rw.get(w).copied().unwrap_or(0.0) * 100.0, + tap: nums.tm_set.get(w).copied().unwrap_or(1.0), + }); + } + (windings, phases, unrolled) +} + +/// The known sections process in a fixed order, not the document's +/// (serde_json maps iterate sorted, which puts "line" before "linecode"): +/// `lines` consults the already materialized linecodes for the inline +/// impedance `{name}_z` collision check, so "linecode" must come first. +/// The other sections do not consult each other; unknown sections follow +/// in document order. +const SECTIONS: &[&str] = &[ + "bus", + "linecode", + "line", + "switch", + "load", + "generator", + "shunt", + "voltage_source", + "transformer", +]; + +impl Reader<'_> { + fn document(&mut self, doc: &Map) { + if let Some(name) = doc.get("name").and_then(Value::as_str) { + self.net.name = Some(name.to_string()); + } + if let Some(settings) = doc.get("settings").and_then(Value::as_object) { + if let Some(f) = settings.get("base_frequency").and_then(Value::as_f64) { + self.net.base_frequency = f; + } + self.net + .extras + .insert("pmd_settings".into(), Value::Object(settings.clone())); + } + for key in ["data_model", "files", "conductor_ids", "per_unit"] { + if let Some(v) = doc.get(key) { + self.net.extras.insert(format!("pmd_{key}"), v.clone()); + } + } + + for &key in SECTIONS { + let Some(Value::Object(items)) = doc.get(key) else { + continue; + }; + match key { + "bus" => self.buses(items), + "linecode" => self.linecodes(items), + "line" => self.lines(items), + "switch" => self.switches(items), + "load" => self.loads(items), + "generator" => self.generators(items), + "shunt" => self.shunts(items), + "voltage_source" => self.sources(items), + "transformer" => self.transformers(items), + _ => unreachable!(), + } + } + for (key, value) in doc { + if SECTIONS.contains(&key.as_str()) || key == "settings" || key == "name" { + continue; + } + let Value::Object(items) = value else { + continue; + }; + self.net.warnings.push(format!( + "ENGINEERING `{key}` components are not typed; kept untyped" + )); + for (name, v) in items { + self.net.untyped.push(UntypedObject { + class: key.clone(), + name: name.clone(), + props: vec![(None, v.to_string())], + }); + } + } + } + + fn buses(&mut self, items: &Map) { + for (id, v) in items { + let Value::Object(o) = v else { continue }; + let mut extras = take_extras( + o, + &["terminals", "grounded", "rg", "xg", "status", "lat", "lon"], + ); + if let Some(x) = o.get("lon") { + extras.insert("x".into(), x.clone()); + } + if let Some(y) = o.get("lat") { + extras.insert("y".into(), y.clone()); + } + let rg = floats("rg", o.get("rg")).unwrap_or_default(); + let xg = floats("xg", o.get("xg")).unwrap_or_default(); + if rg.iter().any(|&r| r != 0.0) || xg.iter().any(|&x| x != 0.0) { + self.net.warnings.push(format!( + "bus {id}: nonzero grounding impedance is not typed; kept in extras" + )); + extras.insert("rg".into(), o.get("rg").cloned().unwrap_or(Value::Null)); + extras.insert("xg".into(), o.get("xg").cloned().unwrap_or(Value::Null)); + } + stash_status(o, &mut extras, &format!("bus {id}"), &mut self.net.warnings); + self.net.buses.push(DistBus { + id: id.clone(), + terminals: ints_as_strings(o.get("terminals")), + grounded: ints_as_strings(o.get("grounded")), + extras, + ..DistBus::default() + }); + } + } + + fn linecodes(&mut self, items: &Map) { + for (name, v) in items { + let Value::Object(o) = v else { continue }; + let mut lc = linecode_from(name, o, self.net.base_frequency, &mut self.net.warnings); + let mut extras = take_extras( + o, + &["rs", "xs", "g_fr", "g_to", "b_fr", "b_to", "cm_ub", "sm_ub"], + ); + extras.append(&mut lc.extras); + lc.extras = extras; + self.net.linecodes.push(lc); + } + } + + fn lines(&mut self, items: &Map) { + for (name, v) in items { + let Value::Object(o) = v else { continue }; + let mut known = vec![ + "f_bus", + "t_bus", + "f_connections", + "t_connections", + "linecode", + "length", + "status", + "source_id", + ]; + let mut linecode = string(o.get("linecode")); + let mut extras; + // Inline impedance (the dss2eng output for rmatrix defined + // lines): materialize a linecode so the matrices survive, and + // mark the line so the PMD writer re-inlines them. + if linecode.is_empty() && o.get("rs").is_some() { + known.extend(["rs", "xs", "g_fr", "g_to", "b_fr", "b_to", "cm_ub"]); + extras = take_extras(o, &known); + let mut lc_name = format!("{name}_z"); + let mut k = 2; + while self.net.linecode(&lc_name).is_some() { + lc_name = format!("{name}_z{k}"); + k += 1; + } + let lc = + linecode_from(&lc_name, o, self.net.base_frequency, &mut self.net.warnings); + self.net.linecodes.push(lc); + self.net.warnings.push(format!( + "line {name}: inline impedance materialized as linecode {lc_name}; the PMD writer re-inlines it" + )); + extras.insert("pmd_inline".into(), Value::Bool(true)); + linecode = lc_name; + } else { + extras = take_extras(o, &known); + } + stash_status( + o, + &mut extras, + &format!("line {name}"), + &mut self.net.warnings, + ); + self.net.lines.push(DistLine { + name: name.clone(), + bus_from: string(o.get("f_bus")), + bus_to: string(o.get("t_bus")), + terminal_map_from: ints_as_strings(o.get("f_connections")), + terminal_map_to: ints_as_strings(o.get("t_connections")), + linecode, + length: o.get("length").map_or(f64::NAN, |v| restore("length", v)), + extras, + }); + } + } + + fn switches(&mut self, items: &Map) { + for (name, v) in items { + let Value::Object(o) = v else { continue }; + let mut extras = take_extras( + o, + &[ + "f_bus", + "t_bus", + "f_connections", + "t_connections", + "state", + "cm_ub", + "status", + "source_id", + "dispatchable", + "rs", + "xs", + "g_fr", + "g_to", + "b_fr", + "b_to", + ], + ); + // The series matrices ride along raw so a dss regeneration can + // override the engine's switch dummy impedance with the real + // one, and the PMD writer can reproduce them. + for key in ["rs", "xs"] { + if let Some(m) = o.get(key) { + extras.insert(format!("pmd_{key}"), m.clone()); + } + } + stash_status( + o, + &mut extras, + &format!("switch {name}"), + &mut self.net.warnings, + ); + self.net.switches.push(DistSwitch { + name: name.clone(), + bus_from: string(o.get("f_bus")), + bus_to: string(o.get("t_bus")), + terminal_map_from: ints_as_strings(o.get("f_connections")), + terminal_map_to: ints_as_strings(o.get("t_connections")), + open: o.get("state").and_then(Value::as_str) == Some("OPEN"), + i_max: floats("cm_ub", o.get("cm_ub")), + extras, + }); + } + } + + fn loads(&mut self, items: &Map) { + for (name, v) in items { + let Value::Object(o) = v else { continue }; + let connections = ints_as_strings(o.get("connections")); + let configuration = match o.get("configuration").and_then(Value::as_str) { + Some("DELTA") if connections.len() > 2 => Configuration::Delta, + _ if connections.len() <= 2 => Configuration::SinglePhase, + Some("DELTA") => Configuration::Delta, + _ => Configuration::Wye, + }; + let scale = |key: &str| { + floats(key, o.get(key)) + .unwrap_or_default() + .iter() + .map(|v| v * 1e3) + .collect::>() + }; + let mut extras = take_extras( + o, + &[ + "bus", + "connections", + "configuration", + "pd_nom", + "qd_nom", + "status", + "source_id", + "dispatchable", + "vm_nom", + "model", + ], + ); + if let Some(kv) = o.get("vm_nom") { + extras.insert("kv".into(), kv.clone()); + } + if let Some(model) = o.get("model").and_then(Value::as_str) { + let dss_model = match model { + "IMPEDANCE" => 2, + "CURRENT" => 5, + "ZIPV" => 8, + _ => 1, + }; + if dss_model != 1 { + extras.insert("model".into(), dss_model.into()); + } + } + stash_status( + o, + &mut extras, + &format!("load {name}"), + &mut self.net.warnings, + ); + self.net.loads.push(DistLoad { + name: name.clone(), + bus: string(o.get("bus")), + terminal_map: connections, + configuration, + p_nom: scale("pd_nom"), + q_nom: scale("qd_nom"), + extras, + }); + } + } + + fn generators(&mut self, items: &Map) { + for (name, v) in items { + let Value::Object(o) = v else { continue }; + let scale = |key: &str| { + floats(key, o.get(key)).map(|v| v.iter().map(|x| x * 1e3).collect::>()) + }; + let mut extras = take_extras( + o, + &[ + "bus", + "connections", + "configuration", + "pg", + "qg", + "pg_lb", + "pg_ub", + "qg_lb", + "qg_ub", + "status", + "source_id", + ], + ); + stash_status( + o, + &mut extras, + &format!("generator {name}"), + &mut self.net.warnings, + ); + self.net.generators.push(DistGenerator { + name: name.clone(), + bus: string(o.get("bus")), + terminal_map: ints_as_strings(o.get("connections")), + configuration: match o.get("configuration").and_then(Value::as_str) { + Some("DELTA") => Configuration::Delta, + _ => Configuration::Wye, + }, + p_nom: scale("pg").unwrap_or_default(), + q_nom: scale("qg").unwrap_or_default(), + p_min: scale("pg_lb").filter(|v| v.iter().all(|x| x.is_finite())), + p_max: scale("pg_ub").filter(|v| v.iter().all(|x| x.is_finite())), + q_min: scale("qg_lb").filter(|v| v.iter().all(|x| x.is_finite())), + q_max: scale("qg_ub").filter(|v| v.iter().all(|x| x.is_finite())), + cost: None, + extras, + }); + } + } + + fn shunts(&mut self, items: &Map) { + for (name, v) in items { + let Value::Object(o) = v else { continue }; + let g = matrix("gs", o.get("gs")).unwrap_or_default(); + let b = matrix("bs", o.get("bs")).unwrap_or_default(); + let mut extras = take_extras( + o, + &["bus", "connections", "gs", "bs", "status", "source_id"], + ); + stash_status( + o, + &mut extras, + &format!("shunt {name}"), + &mut self.net.warnings, + ); + self.net.shunts.push(DistShunt { + name: name.clone(), + bus: string(o.get("bus")), + terminal_map: ints_as_strings(o.get("connections")), + g, + b, + extras, + }); + } + } + + fn sources(&mut self, items: &Map) { + for (name, v) in items { + let Value::Object(o) = v else { continue }; + let mut extras = take_extras( + o, + &["bus", "connections", "vm", "va", "status", "source_id"], + ); + stash_status( + o, + &mut extras, + &format!("voltage source {name}"), + &mut self.net.warnings, + ); + self.net.sources.push(VoltageSource { + name: name.clone(), + bus: string(o.get("bus")), + terminal_map: ints_as_strings(o.get("connections")), + v_magnitude: floats("vm", o.get("vm")) + .unwrap_or_default() + .iter() + .map(|v| v * 1e3) + .collect(), + v_angle: floats("va", o.get("va")) + .unwrap_or_default() + .iter() + .map(|a| a.to_radians()) + .collect(), + extras, + }); + } + } + + fn transformers(&mut self, items: &Map) { + for (name, v) in items { + let Value::Object(o) = v else { continue }; + let t = self.transformer(name, o); + self.net.transformers.push(t); + } + } + + /// The writer recomputes polarity from the lag convention; when the + /// file disagrees (a euro/lead or reversed winding), the raw arrays + /// ride in extras and the writer emits them verbatim. + fn stash_polarity( + &mut self, + name: &str, + o: &Map, + windings: &[Winding], + polarity: &[i64], + unrolled: bool, + extras: &mut Extras, + ) { + let file_polarity: Vec = (0..windings.len()) + .map(|w| polarity.get(w).copied().unwrap_or(1)) + .collect(); + if file_polarity == super::write::lag_polarity(windings) { + return; + } + extras.insert( + "pmd_polarity".into(), + o.get("polarity") + .cloned() + .unwrap_or_else(|| file_polarity.clone().into()), + ); + if unrolled && let Some(c) = o.get("connections") { + extras.insert("pmd_connections".into(), c.clone()); + } + self.net.warnings.push(format!( + "transformer {name}: polarity {file_polarity:?} is not the lag convention; kept in extras (other formats assume lag)" + )); + } + + fn transformer(&mut self, name: &str, o: &Map) -> DistTransformer { + let buses = ints_as_strings(o.get("bus")); + let configs: Vec = o + .get("configuration") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .map(|c| { + if c.as_str() == Some("DELTA") { + WindingConn::Delta + } else { + WindingConn::Wye + } + }) + .collect() + }) + .unwrap_or_default(); + let polarity: Vec = o + .get("polarity") + .and_then(Value::as_array) + .map(|a| a.iter().map(|p| p.as_i64().unwrap_or(1)).collect()) + .unwrap_or_default(); + let rw = floats("rw", o.get("rw")).unwrap_or_default(); + let xsc = floats("xsc", o.get("xsc")).unwrap_or_default(); + let sm_nom = floats("sm_nom", o.get("sm_nom")).unwrap_or_default(); + let vm_nom = floats("vm_nom", o.get("vm_nom")).unwrap_or_default(); + let (tm_set, taps_differ) = representative_taps(o.get("tm_set")); + if taps_differ { + self.net.warnings.push(format!( + "transformer {name}: per phase taps differ; the winding tap keeps the first phase (full arrays in extras)" + )); + } + + let (windings, phases, unrolled) = build_windings( + &buses, + &configs, + &polarity, + o, + &WindingNums { + rw: &rw, + xsc: &xsc, + sm_nom: &sm_nom, + vm_nom: &vm_nom, + tm_set: &tm_set, + }, + ); + + if o.get("controls").is_some() { + self.net.warnings.push(format!( + "transformer {name}: regulator controls are not typed; kept in extras" + )); + } + let mut extras = take_extras( + o, + &[ + "bus", + "connections", + "configuration", + "polarity", + "rw", + "xsc", + "sm_nom", + "vm_nom", + "tm_set", + "tm_fix", + "tm_lb", + "tm_ub", + "tm_step", + "status", + "source_id", + "noloadloss", + "cmag", + "sm_ub", + ], + ); + for key in ["tm_set", "tm_lb", "tm_ub", "tm_fix", "tm_step"] { + if let Some(v) = o.get(key) { + extras.insert(format!("pmd_{key}"), v.clone()); + } + } + self.stash_polarity(name, o, &windings, &polarity, unrolled, &mut extras); + stash_status( + o, + &mut extras, + &format!("transformer {name}"), + &mut self.net.warnings, + ); + DistTransformer { + name: name.to_string(), + windings, + xsc_pct: xsc.iter().map(|x| x * 100.0).collect(), + phases, + extras, + } + } +} diff --git a/powerio-dist/src/pmd/write.rs b/powerio-dist/src/pmd/write.rs new file mode 100644 index 0000000..179a257 --- /dev/null +++ b/powerio-dist/src/pmd/write.rs @@ -0,0 +1,853 @@ +//! [`DistNetwork`] into PMD ENGINEERING JSON. +//! +//! The output reproduces what PMD's own dss2eng emits for the same network +//! wherever the model carries the data: terminal integers, `ENABLED` +//! status, `source_id`, the materialized grounded neutral with zero +//! `rg`/`xg`, linecode `cm_ub` from the emergency rating, transformer +//! `tm_*` tap fields, the delta wye barrel roll with `polarity` -1 on the +//! lagging wye winding, and the voltage source Thevenin matrices computed +//! from the short circuit data when the source format carried it. The +//! reader's `pmd_*` stashes (status, settings, files, grounding and switch +//! impedance, tap arrays, polarity, inline line impedance) win over the +//! recomputed defaults, so PMD in, PMD out does not alter fields. + +use std::collections::BTreeSet; + +use serde_json::{Map, Value, json}; + +use crate::convert::Conversion; +use crate::model::{ + Configuration, DistLineCode, DistNetwork, DistTransformer, Extras, Mat, VoltageSource, Winding, + WindingConn, +}; + +/// Writes the ENGINEERING document. +/// +/// # Panics +/// +/// Never in practice: the document is maps, strings, finite numbers, and +/// nulls, which always serialize. +pub fn write_pmd_json(net: &DistNetwork) -> Conversion { + let mut w = Writer { + warnings: Vec::new(), + }; + let doc = w.document(net); + Conversion { + text: serde_json::to_string_pretty(&doc).expect("maps and finite numbers") + "\n", + warnings: w.warnings, + } +} + +struct Writer { + warnings: Vec, +} + +/// Terminal names as PMD integer connections; non numeric names count from +/// 90 upward (PMD requires ints; the warning names the rename). +fn conns(map: &[String], warnings: &mut Vec, what: &str) -> Vec { + map.iter() + .enumerate() + .map(|(k, t)| { + t.parse::().unwrap_or_else(|_| { + let fallback = 90 + i64::try_from(k).unwrap_or(0); + warnings.push(format!( + "{what}: terminal `{t}` is not numeric; emitted as {fallback}" + )); + fallback + }) + }) + .collect() +} + +/// A matrix as PMD serializes it: array of columns (`hcat` rebuilds it). +fn matrix(m: &Mat) -> Value { + let n = m.len(); + let cols: Vec = (0..n) + .map(|j| Value::Array((0..n).map(|i| json!(m[i][j])).collect())) + .collect(); + Value::Array(cols) +} + +fn zero_matrix(n: usize) -> Mat { + vec![vec![0.0; n]; n] +} + +fn scale(m: &Mat, k: f64) -> Mat { + m.iter() + .map(|row| row.iter().map(|v| v * k).collect()) + .collect() +} + +impl Writer { + fn warn(&mut self, msg: impl Into) { + self.warnings.push(msg.into()); + } + + /// Reports extras the ENGINEERING model has no field for. `consumed` + /// names keys a field already represents; `pmd_*` bookkeeping and the + /// BMOPF subtype marker pass silently. + fn extras_dropped(&mut self, extras: &crate::model::Extras, consumed: &[&str], what: &str) { + for key in extras.keys() { + if consumed.contains(&key.as_str()) || key.starts_with("pmd_") || key == "bmopf_subtype" + { + continue; + } + self.warn(format!( + "{what}: `{key}` has no ENGINEERING field; dropped from the output" + )); + } + } + + fn extras_f64(extras: &Extras, key: &str) -> Option { + extras.get(key).and_then(|v| { + v.as_f64() + .or_else(|| v.as_str().and_then(|s| s.parse().ok())) + }) + } + + /// The element status: the reader's stash when the source carried a non + /// ENABLED status, `ENABLED` otherwise. + fn status(extras: &Extras) -> Value { + extras + .get("pmd_status") + .cloned() + .unwrap_or_else(|| json!("ENABLED")) + } + + fn document(&mut self, net: &DistNetwork) -> Value { + let mut doc = Map::new(); + doc.insert("data_model".into(), json!("ENGINEERING")); + doc.insert( + "name".into(), + json!(net.name.clone().unwrap_or_default().to_lowercase()), + ); + doc.insert( + "files".into(), + net.extras + .get("pmd_files") + .cloned() + .unwrap_or_else(|| json!([])), + ); + + // The reader's stash wins; synthesis covers dss/bmopf sourced + // models. + let settings = net + .extras + .get("pmd_settings") + .cloned() + .unwrap_or_else(|| synthesized_settings(net)); + doc.insert("settings".into(), settings); + + let max_conductor = net + .buses + .iter() + .flat_map(|b| &b.terminals) + .filter_map(|t| t.parse::().ok()) + .max() + .unwrap_or(4) + .max(4); + doc.insert( + "conductor_ids".into(), + Value::Array((1..=max_conductor).map(|i| json!(i)).collect()), + ); + + let mut buses = Map::new(); + for b in &net.buses { + let mut o = Map::new(); + o.insert( + "terminals".into(), + json!(conns( + &b.terminals, + &mut self.warnings, + &format!("bus {}", b.id) + )), + ); + let grounded = conns(&b.grounded, &mut self.warnings, &format!("bus {}", b.id)); + // Nonzero grounding impedance rides in extras (the reader's + // stash); zero vectors are the materialized default. + for key in ["rg", "xg"] { + let v = b + .extras + .get(key) + .cloned() + .unwrap_or_else(|| json!(vec![0.0; grounded.len()])); + o.insert(key.into(), v); + } + o.insert("grounded".into(), json!(grounded)); + o.insert("status".into(), Self::status(&b.extras)); + if let Some(x) = Self::extras_f64(&b.extras, "x") { + o.insert("lon".into(), json!(x)); + } + if let Some(y) = Self::extras_f64(&b.extras, "y") { + o.insert("lat".into(), json!(y)); + } + // Voltage bound families have no ENGINEERING fields in volts; + // they drop loudly (PMD bounds are per unit). + for (key, present) in [ + ("v_min", b.v_min.is_some()), + ("v_max", b.v_max.is_some()), + ("vpn_min", b.vpn_min.is_some()), + ("vpn_max", b.vpn_max.is_some()), + ("vpp_min", b.vpp_min.is_some()), + ("vpp_max", b.vpp_max.is_some()), + ("vsym_min", b.vsym_min.is_some()), + ("vsym_max", b.vsym_max.is_some()), + ] { + if present { + self.warn(format!( + "bus {}: `{key}` volt bounds have no ENGINEERING field; dropped", + b.id + )); + } + } + buses.insert(b.id.to_lowercase(), Value::Object(o)); + } + doc.insert("bus".into(), Value::Object(buses)); + + Self::linecodes(net, &mut doc); + self.branches(net, &mut doc); + self.injections(net, &mut doc); + self.transformers(net, &mut doc); + + for u in &net.untyped { + self.warn(format!( + "{} {}: class is not converted to ENGINEERING; dropped from the output", + u.class, u.name + )); + } + Value::Object(doc) + } + + fn linecodes(net: &DistNetwork, doc: &mut Map) { + // Linecodes the reader materialized from inline line impedance + // re-inline on the line; they are skipped here unless a line + // without the marker also references them. + let inlined = inlined_codes(net); + let mut codes = Map::new(); + for c in &net.linecodes { + if inlined.contains(&c.name.to_lowercase()) { + continue; + } + let mut o = Map::new(); + insert_impedance_matrices(&mut o, c, net.base_frequency); + if let Some(i_max) = &c.i_max { + o.insert("cm_ub".into(), json!(i_max)); + } + if let Some(s_max) = &c.s_max { + o.insert("sm_ub".into(), json!(s_max)); + } + codes.insert(c.name.to_lowercase(), Value::Object(o)); + } + if !codes.is_empty() { + doc.insert("linecode".into(), Value::Object(codes)); + } + } + + fn branches(&mut self, net: &DistNetwork, doc: &mut Map) { + if !net.lines.is_empty() { + let mut lines = Map::new(); + for l in &net.lines { + let mut o = Map::new(); + o.insert("f_bus".into(), json!(l.bus_from.to_lowercase())); + o.insert("t_bus".into(), json!(l.bus_to.to_lowercase())); + let what = format!("line {}", l.name); + o.insert( + "f_connections".into(), + json!(conns(&l.terminal_map_from, &mut self.warnings, &what)), + ); + o.insert( + "t_connections".into(), + json!(conns(&l.terminal_map_to, &mut self.warnings, &what)), + ); + o.insert("length".into(), json!(l.length)); + // A line the reader materialized a linecode for re-inlines + // its impedance, the dss2eng shape for rmatrix defined + // lines: matrices on the line, no linecode key. + let inline = l.extras.get("pmd_inline").and_then(Value::as_bool) == Some(true); + match net.linecode(&l.linecode) { + Some(c) if inline => { + insert_impedance_matrices(&mut o, c, net.base_frequency); + if let Some(i_max) = &c.i_max { + o.insert("cm_ub".into(), json!(i_max)); + } + } + _ => { + if inline { + self.warn(format!( + "{what}: linecode `{}` is missing; emitted the reference instead of inline impedance", + l.linecode + )); + } + o.insert("linecode".into(), json!(l.linecode.to_lowercase())); + } + } + o.insert("status".into(), Self::status(&l.extras)); + o.insert( + "source_id".into(), + json!(format!("line.{}", l.name.to_lowercase())), + ); + self.extras_dropped(&l.extras, &["units"], &what); + lines.insert(l.name.to_lowercase(), Value::Object(o)); + } + doc.insert("line".into(), Value::Object(lines)); + } + + if !net.switches.is_empty() { + let mut switches = Map::new(); + for s in &net.switches { + let mut o = Map::new(); + let n = s.terminal_map_from.len(); + let what = format!("switch {}", s.name); + o.insert("f_bus".into(), json!(s.bus_from.to_lowercase())); + o.insert("t_bus".into(), json!(s.bus_to.to_lowercase())); + o.insert( + "f_connections".into(), + json!(conns(&s.terminal_map_from, &mut self.warnings, &what)), + ); + o.insert( + "t_connections".into(), + json!(conns(&s.terminal_map_to, &mut self.warnings, &what)), + ); + // The reader's stash carries the source's series matrices; + // otherwise PMD models a dss switch as a tiny series + // resistance, 1e-4 ohm/m over the forced 0.001 m length + // (the product form keeps the value bit identical). + let rs = s.extras.get("pmd_rs").cloned().unwrap_or_else(|| { + let mut rs = zero_matrix(n); + for (i, row) in rs.iter_mut().enumerate() { + row[i] = 1e-4 * 0.001; + } + matrix(&rs) + }); + o.insert("rs".into(), rs); + let xs = s + .extras + .get("pmd_xs") + .cloned() + .unwrap_or_else(|| matrix(&zero_matrix(n))); + o.insert("xs".into(), xs); + o.insert("g_fr".into(), matrix(&zero_matrix(n))); + o.insert("g_to".into(), matrix(&zero_matrix(n))); + o.insert("b_fr".into(), matrix(&zero_matrix(n))); + o.insert("b_to".into(), matrix(&zero_matrix(n))); + if let Some(i_max) = &s.i_max { + o.insert("cm_ub".into(), json!(i_max)); + } + o.insert( + "state".into(), + json!(if s.open { "OPEN" } else { "CLOSED" }), + ); + o.insert("dispatchable".into(), json!("YES")); + o.insert("status".into(), Self::status(&s.extras)); + o.insert( + "source_id".into(), + json!(format!("line.{}", s.name.to_lowercase())), + ); + self.extras_dropped(&s.extras, &[], &what); + switches.insert(s.name.to_lowercase(), Value::Object(o)); + } + doc.insert("switch".into(), Value::Object(switches)); + } + } + + fn loads(&mut self, net: &DistNetwork, doc: &mut Map) { + if !net.loads.is_empty() { + let mut loads = Map::new(); + for l in &net.loads { + let mut o = Map::new(); + let what = format!("load {}", l.name); + let connections = conns(&l.terminal_map, &mut self.warnings, &what); + // PMD types a two terminal load WYE when the return is the + // bus's grounded neutral and DELTA otherwise. + let configuration = match l.configuration { + Configuration::Delta => "DELTA", + Configuration::Wye => "WYE", + Configuration::SinglePhase => { + let grounded_return = l + .terminal_map + .last() + .zip(net.bus(&l.bus)) + .is_some_and(|(t, b)| b.grounded.contains(t)); + if grounded_return { "WYE" } else { "DELTA" } + } + }; + o.insert("configuration".into(), json!(configuration)); + o.insert("connections".into(), json!(connections)); + o.insert( + "pd_nom".into(), + json!(l.p_nom.iter().map(|p| p / 1e3).collect::>()), + ); + o.insert( + "qd_nom".into(), + json!(l.q_nom.iter().map(|q| q / 1e3).collect::>()), + ); + o.insert("bus".into(), json!(l.bus.to_lowercase())); + if let Some(kv) = Self::extras_f64(&l.extras, "kv") { + o.insert("vm_nom".into(), json!(kv)); + } + let model = match Self::extras_f64(&l.extras, "model").map(|m| m as i64) { + Some(2) => "IMPEDANCE", + Some(5) => "CURRENT", + Some(8) => "ZIPV", + _ => "POWER", + }; + o.insert("model".into(), json!(model)); + o.insert("dispatchable".into(), json!("NO")); + o.insert("status".into(), Self::status(&l.extras)); + o.insert( + "source_id".into(), + json!(format!("load.{}", l.name.to_lowercase())), + ); + self.extras_dropped(&l.extras, &["kv", "model", "pf"], &what); + loads.insert(l.name.to_lowercase(), Value::Object(o)); + } + doc.insert("load".into(), Value::Object(loads)); + } + } + + fn generators(&mut self, net: &DistNetwork, doc: &mut Map) { + if !net.generators.is_empty() { + let mut gens = Map::new(); + for g in &net.generators { + let mut o = Map::new(); + let what = format!("generator {}", g.name); + o.insert("bus".into(), json!(g.bus.to_lowercase())); + o.insert( + "connections".into(), + json!(conns(&g.terminal_map, &mut self.warnings, &what)), + ); + o.insert( + "configuration".into(), + json!(match g.configuration { + Configuration::Delta => "DELTA", + _ => "WYE", + }), + ); + let kw = |w: &[f64]| w.iter().map(|v| v / 1e3).collect::>(); + o.insert("pg".into(), json!(kw(&g.p_nom))); + o.insert("qg".into(), json!(kw(&g.q_nom))); + if let Some(b) = &g.q_min { + o.insert("qg_lb".into(), json!(kw(b))); + } + if let Some(b) = &g.q_max { + o.insert("qg_ub".into(), json!(kw(b))); + } + if let Some(b) = &g.p_min { + o.insert("pg_lb".into(), json!(kw(b))); + } + if let Some(b) = &g.p_max { + o.insert("pg_ub".into(), json!(kw(b))); + } + if g.cost.is_some() { + self.warn(format!( + "{what}: generation cost has no ENGINEERING field; dropped" + )); + } + o.insert("control_mode".into(), json!("FREQUENCYDROOP")); + o.insert("status".into(), Self::status(&g.extras)); + o.insert( + "source_id".into(), + json!(format!("generator.{}", g.name.to_lowercase())), + ); + self.extras_dropped(&g.extras, &["kv"], &what); + gens.insert(g.name.to_lowercase(), Value::Object(o)); + } + doc.insert("generator".into(), Value::Object(gens)); + } + } + + fn injections(&mut self, net: &DistNetwork, doc: &mut Map) { + self.loads(net, doc); + self.generators(net, doc); + if !net.shunts.is_empty() { + let mut shunts = Map::new(); + for s in &net.shunts { + let mut o = Map::new(); + let what = format!("shunt {}", s.name); + o.insert("bus".into(), json!(s.bus.to_lowercase())); + o.insert( + "connections".into(), + json!(conns(&s.terminal_map, &mut self.warnings, &what)), + ); + o.insert("gs".into(), matrix(&s.g)); + o.insert("bs".into(), matrix(&s.b)); + o.insert("configuration".into(), json!("WYE")); + o.insert("model".into(), json!("CAPACITOR")); + o.insert("dispatchable".into(), json!("NO")); + o.insert("status".into(), Self::status(&s.extras)); + o.insert( + "source_id".into(), + json!(format!("capacitor.{}", s.name.to_lowercase())), + ); + self.extras_dropped(&s.extras, &["kv", "kvar"], &what); + shunts.insert(s.name.to_lowercase(), Value::Object(o)); + } + doc.insert("shunt".into(), Value::Object(shunts)); + } + + let mut sources = Map::new(); + for vs in &net.sources { + sources.insert(vs.name.to_lowercase(), self.voltage_source(vs)); + } + doc.insert("voltage_source".into(), Value::Object(sources)); + } + + fn voltage_source(&mut self, vs: &VoltageSource) -> Value { + let mut o = Map::new(); + let what = format!("voltage source {}", vs.name); + let connections = conns(&vs.terminal_map, &mut self.warnings, &what); + let n = connections.len(); + o.insert("bus".into(), json!(vs.bus.to_lowercase())); + o.insert("connections".into(), json!(connections)); + o.insert("configuration".into(), json!("WYE")); + o.insert( + "vm".into(), + json!(vs.v_magnitude.iter().map(|v| v / 1e3).collect::>()), + ); + o.insert( + "va".into(), + json!( + vs.v_angle + .iter() + .map(|a| a.to_degrees()) + .collect::>() + ), + ); + // The Thevenin matrices: verbatim when the source carried them + // (an ENGINEERING round trip), recomputed with the engine's + // formulas from short circuit data otherwise. + if let (Some(rs), Some(xs)) = (vs.extras.get("rs"), vs.extras.get("xs")) { + o.insert("rs".into(), rs.clone()); + o.insert("xs".into(), xs.clone()); + } else { + let (rs, xs) = thevenin(vs, n); + if rs.iter().flatten().all(|&v| v == 0.0) { + self.warn(format!( + "{what}: no short circuit data; emitted an ideal source (zero rs/xs)" + )); + } + o.insert("rs".into(), matrix(&rs)); + o.insert("xs".into(), matrix(&xs)); + } + o.insert("status".into(), Self::status(&vs.extras)); + o.insert( + "source_id".into(), + json!(format!("vsource.{}", vs.name.to_lowercase())), + ); + // The short circuit form (basekv/pu/angle/MVAsc/X-R ratios) is + // represented by vm/va and the Thevenin matrices. + self.extras_dropped( + &vs.extras, + &[ + "basekv", + "basemva", + "pu", + "angle", + "mvasc1", + "mvasc3", + "x1r1", + "x0r0", + "rs", + "xs", + "isc1", + "isc3", + "configuration", + ], + &what, + ); + Value::Object(o) + } + + fn transformers(&mut self, net: &DistNetwork, doc: &mut Map) { + if net.transformers.is_empty() { + return; + } + let mut out = Map::new(); + for t in &net.transformers { + out.insert(t.name.to_lowercase(), self.transformer(t)); + } + doc.insert("transformer".into(), Value::Object(out)); + } + + fn transformer(&mut self, t: &DistTransformer) -> Value { + let mut o = Map::new(); + let what = format!("transformer {}", t.name); + let phases = t.phases; + + // The reader's stash carries a source polarity/connections pair the + // lag convention does not reproduce (euro/lead, reversed windings); + // emit it verbatim. Otherwise apply the ANSI lag convention the + // reference dss2eng uses: barrel roll the wye phase conductors + // under a delta primary and reverse the winding polarity. + let stashed = t.extras.contains_key("pmd_polarity"); + let mut buses = Vec::new(); + let mut connections: Vec = Vec::new(); + for (w_idx, w) in t.windings.iter().enumerate() { + buses.push(json!(w.bus.to_lowercase())); + let mut c = conns(&w.terminal_map, &mut self.warnings, &what); + if !stashed + && w_idx > 0 + && t.windings[0].conn == WindingConn::Delta + && w.conn == WindingConn::Wye + && c.len() > 1 + { + let phases_part = c.len() - 1; + c[..phases_part].rotate_left(1); + } + connections.push(json!(c)); + } + o.insert("bus".into(), Value::Array(buses)); + o.insert( + "connections".into(), + t.extras + .get("pmd_connections") + .cloned() + .unwrap_or(Value::Array(connections)), + ); + o.insert( + "polarity".into(), + t.extras + .get("pmd_polarity") + .cloned() + .unwrap_or_else(|| json!(lag_polarity(&t.windings))), + ); + o.insert( + "configuration".into(), + Value::Array( + t.windings + .iter() + .map(|w| { + json!(match w.conn { + WindingConn::Wye => "WYE", + WindingConn::Delta => "DELTA", + }) + }) + .collect(), + ), + ); + o.insert( + "rw".into(), + json!( + t.windings + .iter() + .map(|w| w.r_pct / 100.0) + .collect::>() + ), + ); + o.insert( + "xsc".into(), + json!(t.xsc_pct.iter().map(|x| x / 100.0).collect::>()), + ); + o.insert( + "sm_nom".into(), + json!( + t.windings + .iter() + .map(|w| w.s_rating / 1e3) + .collect::>() + ), + ); + o.insert( + "vm_nom".into(), + json!(t.windings.iter().map(|w| w.v_ref / 1e3).collect::>()), + ); + let sm_ub = + Self::extras_f64(&t.extras, "emerghkva").unwrap_or(t.windings[0].s_rating / 1e3 * 1.5); + o.insert("sm_ub".into(), json!(sm_ub)); + insert_tap_fields(&mut o, t, phases); + if let Some(controls) = t.extras.get("controls") { + o.insert("controls".into(), controls.clone()); + } + let noloadloss = Self::extras_f64(&t.extras, "%noloadloss").unwrap_or(0.0) / 100.0; + let cmag = Self::extras_f64(&t.extras, "%imag").unwrap_or(0.0) / 100.0; + o.insert("noloadloss".into(), json!(noloadloss)); + o.insert("cmag".into(), json!(cmag)); + o.insert("status".into(), Self::status(&t.extras)); + o.insert( + "source_id".into(), + json!(format!("transformer.{}", t.name.to_lowercase())), + ); + self.extras_dropped( + &t.extras, + &["controls", "%loadloss", "%noloadloss", "%imag", "emerghkva"], + &what, + ); + Value::Object(o) + } +} + +/// The per winding per phase tap arrays. The reader's `pmd_tm_*` stashes +/// win (per phase taps, custom bounds, regulator fix flags); the defaults +/// for the rest are the engine's bounds (0.9..1.1) and step (1/32). +fn insert_tap_fields(o: &mut Map, t: &DistTransformer, phases: usize) { + let nw = t.windings.len(); + let mut insert = |key: &str, default: fn(&DistTransformer, usize, usize) -> Value| { + let v = t + .extras + .get(&format!("pmd_{key}")) + .cloned() + .unwrap_or_else(|| default(t, nw, phases)); + o.insert(key.into(), v); + }; + insert("tm_set", |t, _, phases| { + Value::Array( + t.windings + .iter() + .map(|w| json!(vec![w.tap; phases])) + .collect(), + ) + }); + insert("tm_fix", |_, nw, phases| { + Value::Array((0..nw).map(|_| json!(vec![true; phases])).collect()) + }); + insert("tm_lb", |_, nw, phases| { + Value::Array((0..nw).map(|_| json!(vec![0.9; phases])).collect()) + }); + insert("tm_ub", |_, nw, phases| { + Value::Array((0..nw).map(|_| json!(vec![1.1; phases])).collect()) + }); + insert("tm_step", |_, nw, phases| { + Value::Array((0..nw).map(|_| json!(vec![1.0 / 32.0; phases])).collect()) + }); +} + +/// The ENGINEERING settings for a model without the reader's stash (dss or +/// bmopf sourced), following the dss2eng conventions: the per bus vbase is +/// the source's nominal line to neutral kV without the pu factor folded +/// in, and sbase is basemva in kVA (default 100 MVA). +fn synthesized_settings(net: &DistNetwork) -> Value { + let mut settings = Map::new(); + settings.insert("base_frequency".into(), json!(net.base_frequency)); + settings.insert("power_scale_factor".into(), json!(1000.0)); + settings.insert("voltage_scale_factor".into(), json!(1000.0)); + let sbase = net + .sources + .first() + .and_then(|vs| Writer::extras_f64(&vs.extras, "basemva")) + .map_or(100_000.0, |mva| mva * 1e3); + settings.insert("sbase_default".into(), json!(sbase)); + let mut vbases = Map::new(); + for vs in &net.sources { + let phases = count_phases(vs).max(1) as f64; + let vln_kv = Writer::extras_f64(&vs.extras, "basekv").map_or_else( + || { + let pu = Writer::extras_f64(&vs.extras, "pu").unwrap_or(1.0); + vs.v_magnitude.first().copied().unwrap_or(0.0) / 1e3 / pu + }, + |kv| kv / phases.sqrt(), + ); + vbases.insert(vs.bus.to_lowercase(), json!(vln_kv)); + } + settings.insert("vbases_default".into(), Value::Object(vbases)); + Value::Object(settings) +} + +/// The polarity vector the ANSI lag convention produces for these windings: +/// -1 with a barrel roll on each wye winding under a delta primary, -1 on +/// the reversed second half of a center tap secondary, 1 elsewhere. The +/// reader compares the source against this to decide whether the file's +/// polarity needs an extras stash. +pub(super) fn lag_polarity(windings: &[Winding]) -> Vec { + let nw = windings.len(); + let mut polarity = vec![1i64; nw]; + for (w_idx, w) in windings.iter().enumerate().skip(1) { + if windings[0].conn == WindingConn::Delta + && w.conn == WindingConn::Wye + && w.terminal_map.len() > 1 + { + polarity[w_idx] = -1; + } + // Center tap: the second half winding is reversed. + if w_idx == 2 && nw == 3 && windings[1].terminal_map.last() == w.terminal_map.first() { + polarity[w_idx] = -1; + } + } + polarity +} + +/// Names (lowercased) of linecodes that re-inline on their lines: every +/// referencing line carries the reader's `pmd_inline` marker. +fn inlined_codes(net: &DistNetwork) -> BTreeSet { + let mut inlined = BTreeSet::new(); + for c in &net.linecodes { + let mut refs = net + .lines + .iter() + .filter(|l| l.linecode.eq_ignore_ascii_case(&c.name)) + .peekable(); + if refs.peek().is_some() + && refs.all(|l| l.extras.get("pmd_inline").and_then(Value::as_bool) == Some(true)) + { + inlined.insert(c.name.to_lowercase()); + } + } + inlined +} + +/// The six ENGINEERING impedance matrices of a linecode, emitted onto a +/// `linecode` entry or re-inlined onto a line. The b_fr/b_to numbers are +/// the dss cmatrix halves in nanofarads per meter (the susceptance follows +/// as 2 pi f C); the model holds true siemens per meter, so divide the +/// omega back out — or emit the reader's raw stash, which is bit exact. +fn insert_impedance_matrices(o: &mut Map, c: &DistLineCode, base_frequency: f64) { + o.insert("rs".into(), matrix(&c.r_series)); + o.insert("xs".into(), matrix(&c.x_series)); + o.insert("g_fr".into(), matrix(&c.g_from)); + o.insert("g_to".into(), matrix(&c.g_to)); + if let (Some(fr), Some(to)) = (c.extras.get("pmd_b_fr"), c.extras.get("pmd_b_to")) { + o.insert("b_fr".into(), fr.clone()); + o.insert("b_to".into(), to.clone()); + } else { + let to_nf = 1.0 / (std::f64::consts::TAU * base_frequency * 1e-9); + o.insert("b_fr".into(), matrix(&scale(&c.b_from, to_nf))); + o.insert("b_to".into(), matrix(&scale(&c.b_to, to_nf))); + } +} + +/// The engine's Thevenin computation from MVAsc3/MVAsc1 and the X/R ratios +/// (the same math the reference dss2eng inherits): sequence impedances from +/// the short circuit levels, then self/mutual phase values filled over all +/// conductors including the neutral. +fn thevenin(vs: &VoltageSource, n_cond: usize) -> (Mat, Mat) { + let get = |key: &str| Writer::extras_f64(&vs.extras, key); + let basekv = get("basekv").unwrap_or_else(|| { + // Reconstruct from the magnitude when basekv was defaulted. + vs.v_magnitude.first().copied().unwrap_or(0.0) / 1e3 * (count_phases(vs) as f64).sqrt() + }); + let phases = count_phases(vs); + if basekv <= 0.0 || phases == 0 { + return (zero_matrix(n_cond), zero_matrix(n_cond)); + } + let mvasc3 = get("mvasc3").unwrap_or(2000.0); + let mvasc1 = get("mvasc1").unwrap_or(2100.0); + let x1r1 = get("x1r1").unwrap_or(4.0); + let x0r0 = get("x0r0").unwrap_or(3.0); + let factor = if phases == 1 { 1.0 } else { 3f64.sqrt() }; + + let isc1 = mvasc1 * 1e3 / (basekv * factor); + let x1 = basekv * basekv / mvasc3 / (1.0 + 1.0 / (x1r1 * x1r1)).sqrt(); + let r1 = x1 / x1r1; + let a = 1.0 + x0r0 * x0r0; + let b = 4.0 * (r1 + x1 * x0r0); + let c = 4.0 * (r1 * r1 + x1 * x1) - (3.0 * basekv * 1000.0 / factor / isc1).powi(2); + let disc = (b * b - 4.0 * a * c).max(0.0).sqrt(); + let r0 = ((-b + disc) / (2.0 * a)).max((-b - disc) / (2.0 * a)); + let x0 = r0 * x0r0; + + let r_self = (2.0 * r1 + r0) / 3.0; + let x_self = (2.0 * x1 + x0) / 3.0; + let r_mutual = (r0 - r1) / 3.0; + let x_mutual = (x0 - x1) / 3.0; + + let mut r_mat = vec![vec![r_mutual; n_cond]; n_cond]; + let mut x_mat = vec![vec![x_mutual; n_cond]; n_cond]; + for i in 0..n_cond { + r_mat[i][i] = r_self; + x_mat[i][i] = x_self; + } + (r_mat, x_mat) +} + +fn count_phases(vs: &VoltageSource) -> usize { + vs.v_magnitude.iter().filter(|&&v| v > 0.0).count() +} diff --git a/powerio-dist/tests/bmopf.rs b/powerio-dist/tests/bmopf.rs new file mode 100644 index 0000000..764cd22 --- /dev/null +++ b/powerio-dist/tests/bmopf.rs @@ -0,0 +1,503 @@ +//! BMOPF reader/writer against the vendored draft schema and the two +//! public example networks from frederikgeth/bmopf-report. + +use std::path::PathBuf; +use std::sync::Arc; + +use powerio_dist::dss::{parse_dss_file, parse_dss_str}; +use powerio_dist::{ + Configuration, DistNetwork, DistTransformer, Extras, Winding, WindingConn, parse_bmopf_file, + parse_bmopf_str, write_bmopf_json, +}; + +fn fixture(rel: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../tests/data/dist") + .join(rel) +} + +fn schema_validator() -> jsonschema::Validator { + let schema: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(fixture("bmopf/draft_bmopf_schema.json")).unwrap(), + ) + .unwrap(); + jsonschema::validator_for(&schema).expect("vendored schema compiles") +} + +fn errors(validator: &jsonschema::Validator, text: &str) -> Vec { + let doc: serde_json::Value = serde_json::from_str(text).unwrap(); + validator + .iter_errors(&doc) + .map(|e| format!("{}: {e}", e.instance_path())) + .collect() +} + +#[test] +fn vendored_examples_validate() { + let v = schema_validator(); + for example in ["bmopf/example_ieee13.json", "bmopf/example_enwl_n1_f2.json"] { + let text = std::fs::read_to_string(fixture(example)).unwrap(); + assert_eq!(errors(&v, &text), Vec::::new(), "{example}"); + } +} + +#[test] +fn parse_the_public_examples() { + let net = parse_bmopf_file(fixture("bmopf/example_ieee13.json")).unwrap(); + assert_eq!(net.buses.len(), 16); + assert_eq!(net.switches.len(), 1); + assert_eq!(net.shunts.len(), 2); + assert_eq!(net.transformers.len(), 7); + assert_eq!(net.sources.len(), 1); + assert!(net.warnings.is_empty(), "{:?}", net.warnings); + + let b611 = net.bus("611").unwrap(); + assert_eq!(b611.terminals, vec!["3", "4"]); + assert_eq!(b611.grounded, vec!["4"]); + + let enwl = parse_bmopf_file(fixture("bmopf/example_enwl_n1_f2.json")).unwrap(); + assert_eq!(enwl.buses.len(), 506); + assert_eq!(enwl.generators.len(), 7); + let g = &enwl.generators[0]; + assert_eq!(g.cost, Some(0.001)); + assert!(g.p_max.is_some()); + // ENWL buses carry phase to neutral bounds. + assert!(enwl.buses.iter().any(|b| b.vpn_min.is_some())); +} + +#[test] +fn written_output_validates_and_round_trips() { + let v = schema_validator(); + let net = parse_bmopf_file(fixture("bmopf/example_ieee13.json")).unwrap(); + let out = write_bmopf_json(&net); + assert_eq!(errors(&v, &out.text), Vec::::new()); + // Nothing in the example exceeds the schema, so nothing should drop. + assert_eq!(out.warnings, Vec::::new()); + + // Canonical idempotence at the model level: parse(write(parse(x))) + // equals parse(x) up to the retained source text. + let again = parse_bmopf_str(&out.text).unwrap(); + assert_model_eq(&net, &again); + + // And byte idempotence of the canonical form. + let out2 = write_bmopf_json(&again); + assert_eq!(out.text, out2.text); +} + +#[test] +fn enwl_round_trips() { + let v = schema_validator(); + let net = parse_bmopf_file(fixture("bmopf/example_enwl_n1_f2.json")).unwrap(); + let out = write_bmopf_json(&net); + assert_eq!(errors(&v, &out.text), Vec::::new()); + let again = parse_bmopf_str(&out.text).unwrap(); + assert_model_eq(&net, &again); +} + +/// Model equality minus the retained source (which differs by format). +fn assert_model_eq(a: &DistNetwork, b: &DistNetwork) { + let strip = |n: &DistNetwork| { + let mut n = n.clone(); + n.source = Some(Arc::new(String::new())); + n + }; + let (a, b) = (strip(a), strip(b)); + assert_eq!(a.buses, b.buses); + assert_eq!(a.linecodes, b.linecodes); + assert_eq!(a.lines, b.lines); + assert_eq!(a.switches, b.switches); + assert_eq!(a.loads, b.loads); + assert_eq!(a.generators, b.generators); + assert_eq!(a.shunts, b.shunts); + assert_eq!(a.sources, b.sources); + assert_eq!(a.transformers, b.transformers); +} + +#[test] +fn dss_fixtures_emit_valid_bmopf() { + let v = schema_validator(); + for case in [ + "opendss/ieee13/IEEE13Nodeckt.dss", + "opendss/ieee34/ieee34Mod1.dss", + "opendss/ieee123/IEEE123Master.dss", + "micro/xfmr_single_phase.dss", + "micro/xfmr_center_tap.dss", + "micro/xfmr_wye_delta.dss", + "micro/xfmr_delta_wye.dss", + "micro/switch.dss", + "micro/fourwire_linecode.dss", + "micro/defaults_degenerate.dss", + ] { + let net = parse_dss_file(fixture(case)).unwrap(); + let out = write_bmopf_json(&net); + assert_eq!(errors(&v, &out.text), Vec::::new(), "{case}"); + } +} + +#[test] +fn ieee13_conversion_warnings_name_every_loss() { + let net = parse_dss_file(fixture("opendss/ieee13/IEEE13Nodeckt.dss")).unwrap(); + let out = write_bmopf_json(&net); + // The wye-wye XFM1 decomposes; regulators and coordinates drop loudly. + assert!( + out.warnings + .iter() + .any(|w| w.contains("XFM1") && w.contains("single_phase")) + ); + assert!(out.warnings.iter().any(|w| w.contains("regcontrol"))); + // No silent extras: every warning leads with a `class name:` element + // identifier ("load 671: ...", "voltage source source: ..."). + for w in &out.warnings { + let Some((head, _)) = w.split_once(": ") else { + panic!("warning has no `class name:` prefix: {w}"); + }; + assert!( + head.split_whitespace().count() >= 2, + "warning does not name its element: {w}" + ); + } +} + +#[test] +fn ten_conductor_linecode_is_valid_data_the_schema_rejects() { + let v = schema_validator(); + let net = parse_dss_file(fixture("micro/linecode_10x10.dss")).unwrap(); + let out = write_bmopf_json(&net); + // The writer says what is about to happen... + assert!( + out.warnings + .iter() + .any(|w| w.contains("double digit matrix keys")) + ); + // ...and the draft schema indeed rejects the document: the single + // digit key patterns (`^R_series_\d_\d`) do not match `R_series_10_10`, + // so additionalProperties: false refuses the key. The fix is + // `^R_series_\d+_\d+$`. + let errs = errors(&v, &out.text); + assert!(!errs.is_empty()); + assert!(errs.iter().any(|e| e.contains("linecode"))); +} + +#[test] +fn negative_validation_cases() { + let v = schema_validator(); + let base: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(fixture("bmopf/example_ieee13.json")).unwrap(), + ) + .unwrap(); + let mutate = |f: &dyn Fn(&mut serde_json::Value)| { + let mut doc = base.clone(); + f(&mut doc); + doc + }; + let cases: Vec<(&str, serde_json::Value)> = vec![ + ( + "missing voltage_source", + mutate(&|d| { + d.as_object_mut().unwrap().remove("voltage_source"); + }), + ), + ( + "missing terminal_map on a line", + mutate(&|d| { + d["line"]["632633"] + .as_object_mut() + .unwrap() + .remove("terminal_map_from"); + }), + ), + ( + "unknown field on a bus", + mutate(&|d| { + d["bus"]["632"]["color"] = "blue".into(); + }), + ), + ( + "lowercase configuration enum", + mutate(&|d| { + let loads = d["load"].as_object_mut().unwrap(); + let first = loads.keys().next().unwrap().clone(); + loads[&first]["configuration"] = "wye".into(); + }), + ), + ( + "wrong type for length", + mutate(&|d| { + d["line"]["632633"]["length"] = "152.4".into(); + }), + ), + ( + // linecode i_max items are nonnegative; switch i_max has no + // item constraint in the draft, an asymmetry worth feedback. + "negative linecode i_max", + mutate(&|d| { + let codes = d["linecode"].as_object_mut().unwrap(); + let first = codes.keys().next().unwrap().clone(); + codes[&first]["i_max"] = serde_json::json!([-600.0, 600.0, 600.0]); + }), + ), + ( + "integer terminal names", + mutate(&|d| { + d["bus"]["632"]["terminal_names"] = serde_json::json!([1, 2, 3]); + }), + ), + ]; + for (what, doc) in cases { + let text = serde_json::to_string(&doc).unwrap(); + assert!(!errors(&v, &text).is_empty(), "schema accepted: {what}"); + } +} + +/// A bus plus one source, the minimum the schema requires; element +/// snippets splice in after the source. +fn doc_with(extra: &str) -> String { + format!( + r#"{{ + "bus": {{"a": {{"terminal_names": ["1", "2"]}}}}, + "voltage_source": {{"src": {{"v_magnitude": [240.0], "v_angle": [0.0], + "bus": "a", "terminal_map": ["1"]}}}}{extra} + }}"# + ) +} + +#[test] +fn shunt_size_mismatch_pads_the_smaller_matrix() { + let text = doc_with( + r#", "shunt": {"c1": {"bus": "a", "terminal_map": ["1", "2"], + "G_1_1": 0.5, + "B_1_1": 1.0, "B_1_2": -1.0, "B_2_1": -1.0, "B_2_2": 1.0}}"#, + ); + let net = parse_bmopf_str(&text).unwrap(); + let s = &net.shunts[0]; + // G grew to B's size; its entry survived the padding. + assert_eq!(s.g, vec![vec![0.5, 0.0], vec![0.0, 0.0]]); + assert_eq!(s.b, vec![vec![1.0, -1.0], vec![-1.0, 1.0]]); + assert!( + net.warnings + .iter() + .any(|w| w.contains("shunt c1") && w.contains("padded")), + "{:?}", + net.warnings + ); + // The padded form writes back schema valid. + let out = write_bmopf_json(&net); + assert_eq!(errors(&schema_validator(), &out.text), Vec::::new()); +} + +#[test] +fn center_tap_collapse_converts_resistance_through_ohms() { + // Each 120 V half carries %R=1.2 on 25 kVA: 0.012 * 120^2/25000 = + // 0.006912 ohm, so the series path across the outer terminals is + // 0.013824 ohm. Percent does not transfer to the 240 V base (zb + // scales 4x), so the collapse converts through ohms. + let net = parse_dss_file(fixture("micro/xfmr_center_tap.dss")).unwrap(); + let out = write_bmopf_json(&net); + let doc: serde_json::Value = serde_json::from_str(&out.text).unwrap(); + let t = &doc["transformer"]["center_tap"]["t1"]; + assert_eq!(t["v_ref_to"], 240.0); + let r_to = t["r_series_to"].as_f64().unwrap(); + assert!((r_to - 0.013_824).abs() < 1e-12, "r_series_to = {r_to}"); + // The primary is untouched by the collapse: %R=0.6 on 7.2 kV/25 kVA. + let r_from = t["r_series_from"].as_f64().unwrap(); + assert!((r_from - 12.4416).abs() < 1e-9, "r_series_from = {r_from}"); +} + +#[test] +fn center_tap_collapse_uses_each_half_windings_own_s_rating() { + // Legal OpenDSS: the two 120 V halves carry different kva ratings, so + // each half's impedance base is its own v^2/s. The series path across + // the outer terminals is the sum of the per half ohms. + let net = parse_dss_str( + "Clear\n\ + New Circuit.ct basekv=7.2 pu=1.0 phases=1 bus1=src.1\n\ + New Transformer.t1 phases=1 windings=3 buses=(src.1.0, lv.1.0, lv.0.2) \ + kvs=(7.2 0.12 0.12) kvas=(25 50 25) %Rs=(1 2 4) xhl=2.04 xht=2.04 xlt=1.36\n", + ); + let out = write_bmopf_json(&net); + let doc: serde_json::Value = serde_json::from_str(&out.text).unwrap(); + let t = &doc["transformer"]["center_tap"]["t1"]; + assert_eq!(t["v_ref_to"], 240.0); + let expected = 0.02 * 120.0 * 120.0 / 50e3 + 0.04 * 120.0 * 120.0 / 25e3; + let r_to = t["r_series_to"].as_f64().unwrap(); + assert!((r_to - expected).abs() < 1e-12, "r_series_to = {r_to}"); + // The collapsed winding keeps one s_rating; the half ratings drop loudly. + assert!( + out.warnings + .iter() + .any(|w| w.contains("transformer t1") && w.contains("s_rating")), + "{:?}", + out.warnings + ); +} + +#[test] +fn x_only_linecode_sizes_from_x_and_keeps_required_keys() { + let text = doc_with( + r#", "linecode": {"lc": { + "X_series_1_1": 0.4, "X_series_1_2": 0.1, + "X_series_2_1": 0.1, "X_series_2_2": 0.4}}"#, + ); + let net = parse_bmopf_str(&text).unwrap(); + let lc = net.linecode("lc").unwrap(); + assert_eq!(lc.n_conductors, 2); + assert_eq!(lc.r_series, vec![vec![0.0; 2]; 2]); + assert!((lc.x_series[0][1] - 0.1).abs() < 1e-15); + // The output carries the schema required R_series_1_1 (zero). + let out = write_bmopf_json(&net); + assert_eq!(errors(&schema_validator(), &out.text), Vec::::new()); + let doc: serde_json::Value = serde_json::from_str(&out.text).unwrap(); + assert_eq!(doc["linecode"]["lc"]["R_series_1_1"], 0.0); +} + +#[test] +fn matrixless_linecode_and_shunt_emit_required_zero_matrices_loudly() { + let text = doc_with( + r#", "linecode": {"bare": {"i_max": [400.0]}}, + "shunt": {"empty": {"bus": "a", "terminal_map": ["1"]}}"#, + ); + let net = parse_bmopf_str(&text).unwrap(); + assert_eq!(net.linecode("bare").unwrap().n_conductors, 0); + let out = write_bmopf_json(&net); + assert_eq!(errors(&schema_validator(), &out.text), Vec::::new()); + let doc: serde_json::Value = serde_json::from_str(&out.text).unwrap(); + assert_eq!(doc["linecode"]["bare"]["R_series_1_1"], 0.0); + assert_eq!(doc["linecode"]["bare"]["X_series_1_1"], 0.0); + assert_eq!(doc["shunt"]["empty"]["G_1_1"], 0.0); + assert_eq!(doc["shunt"]["empty"]["B_1_1"], 0.0); + assert!( + out.warnings + .iter() + .any(|w| w.contains("linecode bare") && w.contains("no series matrix")) + ); + assert!( + out.warnings + .iter() + .any(|w| w.contains("shunt empty") && w.contains("no admittance matrix")) + ); +} + +#[test] +fn malformed_matrix_keys_land_in_extras_with_warnings() { + let text = doc_with( + r#", "linecode": {"lc": {"R_series_1_1": 0.2, "X_series_1_1": 0.4, + "X_series_note": "an aside"}}, + "shunt": {"c1": {"bus": "a", "terminal_map": ["1"], + "G_1_1": 0.5, "B_1_1": 1.0, "B_total": 5.0, "G_0_1": 9.0}}"#, + ); + let net = parse_bmopf_str(&text).unwrap(); + let lc = net.linecode("lc").unwrap(); + assert_eq!(lc.n_conductors, 1); + assert!(lc.extras.contains_key("X_series_note")); + let s = &net.shunts[0]; + // Only well formed `_i_j` keys (1 based) count as matrix entries. + assert_eq!(s.g, vec![vec![0.5]]); + assert_eq!(s.b, vec![vec![1.0]]); + assert!(s.extras.contains_key("B_total")); + assert!(s.extras.contains_key("G_0_1")); + for key in ["X_series_note", "B_total", "G_0_1"] { + assert!( + net.warnings.iter().any(|w| w.contains(&format!("`{key}`"))), + "no warning for {key}: {:?}", + net.warnings + ); + } + // Writing drops them, again loudly. + let out = write_bmopf_json(&net); + assert!( + out.warnings + .iter() + .any(|w| w.contains("shunt c1") && w.contains("`B_total`")) + ); +} + +#[test] +fn unrecognized_configuration_and_subtype_warn() { + let text = doc_with( + r#", "load": { + "l1": {"p_nom": [1000.0], "q_nom": [0.0], "bus": "a", + "configuration": "delta", "terminal_map": ["1", "2"]}, + "l2": {"p_nom": [1000.0], "q_nom": [0.0], "bus": "a", + "configuration": "zigzag", "terminal_map": ["1", "2"]}}, + "transformer": {"open_delta": {"t1": {"bus_from": "a", "bus_to": "a", + "terminal_map_from": ["1", "2"], "terminal_map_to": ["1", "2"], + "s_rating": 5000.0, "v_ref_from": 240.0, "v_ref_to": 240.0}}}"#, + ); + let net = parse_bmopf_str(&text).unwrap(); + // A recognized value in the wrong case is tolerated without a warning. + assert_eq!(net.loads[0].configuration, Configuration::Delta); + assert!(!net.warnings.iter().any(|w| w.contains("load l1"))); + // A truly unknown one coerces to WYE, loudly. + assert_eq!(net.loads[1].configuration, Configuration::Wye); + assert!( + net.warnings + .iter() + .any(|w| w.contains("load l2") && w.contains("zigzag")) + ); + // An unknown transformer subtype group reads, with a warning. + assert_eq!(net.transformers.len(), 1); + assert!( + net.warnings + .iter() + .any(|w| w.contains("transformer t1") && w.contains("open_delta")) + ); +} + +#[test] +fn missing_voltage_source_warns() { + let net = parse_bmopf_str(r#"{"bus": {"a": {"terminal_names": ["1"]}}}"#).unwrap(); + let out = write_bmopf_json(&net); + assert!(out.warnings.iter().any(|w| w.contains("no voltage source"))); + // Still schema valid: the required key exists, empty. + assert_eq!(errors(&schema_validator(), &out.text), Vec::::new()); +} + +#[test] +fn three_wire_wye_wye_is_unsupported_not_a_panic() { + // Terminal maps without a trailing neutral cannot decompose per phase; + // a map shorter than the phase count used to index out of bounds. + let mut net = parse_bmopf_str(&doc_with("")).unwrap(); + let winding = |map: &[&str]| Winding { + bus: "a".into(), + terminal_map: map.iter().map(ToString::to_string).collect(), + conn: WindingConn::Wye, + v_ref: 4160.0, + s_rating: 500_000.0, + r_pct: 0.5, + tap: 1.0, + }; + net.transformers.push(DistTransformer { + name: "t3w".into(), + windings: vec![winding(&["1", "2", "3"]), winding(&["1", "2"])], + xsc_pct: vec![2.0], + phases: 3, + extras: Extras::new(), + }); + let out = write_bmopf_json(&net); + assert!(!out.text.contains("t3w")); + assert!( + out.warnings + .iter() + .any(|w| w.contains("transformer t3w") && w.contains("dropped")), + "{:?}", + out.warnings + ); +} + +#[test] +fn reader_is_liberal_where_the_writer_is_strict() { + // An out of schema field parses with a warning and lands in extras; + // writing drops it with a warning. Nothing is silent in either + // direction. + let text = r#"{ + "bus": {"a": {"terminal_names": ["1"], "note": "hand edited"}}, + "voltage_source": {"src": {"v_magnitude": [240.0], "v_angle": [0.0], + "bus": "a", "terminal_map": ["1"]}} + }"#; + let net = parse_bmopf_str(text).unwrap(); + assert!(net.warnings.iter().any(|w| w.contains("note"))); + assert!(net.buses[0].extras.contains_key("note")); + let out = write_bmopf_json(&net); + assert!(out.warnings.iter().any(|w| w.contains("note"))); + assert!(!out.text.contains("hand edited")); +} diff --git a/powerio-dist/tests/dss_reader.rs b/powerio-dist/tests/dss_reader.rs new file mode 100644 index 0000000..4117b15 --- /dev/null +++ b/powerio-dist/tests/dss_reader.rs @@ -0,0 +1,291 @@ +//! Typed model from the vendored fixtures, checked against the OpenDSS +//! engine's own bus and node sets (dumped with opendssdirect 0.9.4 via +//! `dss.Circuit.AllBusNames()` and `dss.Bus.Nodes()` per bus after a +//! Redirect; tools/solve_dss.py documents the staging to reuse when the +//! engine changes). + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use powerio_dist::dss::parse_dss_file; +use powerio_dist::{Configuration, DistNetwork, WindingConn}; + +fn fixture(rel: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../tests/data/dist") + .join(rel) +} + +fn parse(rel: &str) -> DistNetwork { + parse_dss_file(fixture(rel)).expect("fixture parses") +} + +/// Bus id (lowercased) → phase terminal names, excluding the materialized +/// grounded neutral, matching what the engine reports as the bus's nodes. +fn phase_terminals(net: &DistNetwork) -> BTreeMap> { + net.buses + .iter() + .map(|b| { + ( + b.id.to_ascii_lowercase(), + b.terminals + .iter() + .filter(|t| !b.grounded.contains(t)) + .cloned() + .collect(), + ) + }) + .collect() +} + +#[test] +fn ieee13_matches_the_engine_bus_map() { + let net = parse("opendss/ieee13/IEEE13Nodeckt.dss"); + // dss.Circuit.AllBusNames() + dss.Bus.Nodes() on the same fixture. + let expected: BTreeMap> = [ + ("611", vec!["3"]), + ("632", vec!["1", "2", "3"]), + ("633", vec!["1", "2", "3"]), + ("634", vec!["1", "2", "3"]), + ("645", vec!["2", "3"]), + ("646", vec!["2", "3"]), + ("650", vec!["1", "2", "3"]), + ("652", vec!["1"]), + ("670", vec!["1", "2", "3"]), + ("671", vec!["1", "2", "3"]), + ("675", vec!["1", "2", "3"]), + ("680", vec!["1", "2", "3"]), + ("684", vec!["1", "3"]), + ("692", vec!["1", "2", "3"]), + ("rg60", vec!["1", "2", "3"]), + ("sourcebus", vec!["1", "2", "3"]), + ] + .into_iter() + .map(|(k, v)| (k.to_string(), v.into_iter().map(String::from).collect())) + .collect(); + assert_eq!(phase_terminals(&net), expected); + + assert_eq!(net.name.as_deref(), Some("IEEE13Nodeckt")); + assert_eq!(net.sources.len(), 1); + assert_eq!(net.transformers.len(), 5); + assert_eq!(net.loads.len(), 15); + assert_eq!(net.switches.len(), 1); + assert_eq!(net.shunts.len(), 2); + assert_eq!(net.lines.len(), 11); // 12 line objects minus the switch + + // Source: 115 kV, pu=1.0001, 30 degrees. + let vs = &net.sources[0]; + assert_eq!(vs.bus, "SourceBus"); + let vln = 115_000.0 / 3f64.sqrt() * 1.0001; + assert!((vs.v_magnitude[0] - vln).abs() < 1e-6); + assert!((vs.v_angle[0] - 30f64.to_radians()).abs() < 1e-12); + assert!((vs.v_angle[1] - (-90f64).to_radians()).abs() < 1e-12); + + // Line 650632: mtx601 (ohm per mile), 2000 ft. r11 = 0.3465/1609.344 + // ohm/m; length = 2000*0.3048 m. Product must match the engine. + let line = net.lines.iter().find(|l| l.name == "650632").unwrap(); + assert!((line.length - 2000.0 * 0.3048).abs() < 1e-9); + let code = net.linecode(&line.linecode).unwrap(); + let r11_total = code.r_series[0][0] * line.length; + assert!((r11_total - 0.3465 * 2000.0 / 5280.0).abs() < 1e-9); + + // The switch line 671692 carries its ampacity. + let sw = &net.switches[0]; + assert_eq!(sw.name, "671692"); + assert!(!sw.open); + + // Bus coordinates landed as extras. + let b = net.bus("611").unwrap(); + assert!(b.extras.contains_key("x")); + + // Load 671 is 3 phase delta: 1155 kW total, 660 kvar. + let l671 = net.loads.iter().find(|l| l.name == "671").unwrap(); + assert_eq!(l671.configuration, Configuration::Delta); + assert_eq!(l671.terminal_map, vec!["1", "2", "3"]); + let p: f64 = l671.p_nom.iter().sum(); + assert!((p - 1_155_000.0).abs() < 1e-6); + + // Load 611 is single phase wye on node 3 with grounded return. + let l611 = net.loads.iter().find(|l| l.name == "611").unwrap(); + assert_eq!(l611.configuration, Configuration::SinglePhase); + assert_eq!(l611.terminal_map, vec!["3", "4"]); + let b611 = net.bus("611").unwrap(); + assert_eq!(b611.grounded, vec!["4"]); + + // Substation transformer: delta primary, wye secondary. + let sub = net + .transformers + .iter() + .find(|t| t.name.eq_ignore_ascii_case("sub")) + .unwrap(); + assert_eq!(sub.windings.len(), 2); + assert_eq!(sub.windings[0].conn, WindingConn::Delta); + assert_eq!(sub.windings[1].conn, WindingConn::Wye); + assert!((sub.windings[0].v_ref - 115_000.0).abs() < 1e-9); + assert!((sub.windings[1].v_ref - 4160.0).abs() < 1e-9); +} + +#[test] +fn ieee34_and_ieee123_bus_counts_match_the_engine() { + let net34 = parse("opendss/ieee34/ieee34Mod1.dss"); + assert_eq!(net34.buses.len(), 37); + let t34 = phase_terminals(&net34); + assert_eq!(t34["810"], vec!["2"]); + assert_eq!(t34["864"], vec!["1"]); + assert_eq!(t34["890"], vec!["1", "2", "3"]); + + let net123 = parse("opendss/ieee123/IEEE123Master.dss"); + assert_eq!(net123.buses.len(), 132); + let t123 = phase_terminals(&net123); + assert_eq!(t123["25r"], vec!["1", "3"]); + assert_eq!(t123["36"], vec!["1", "2"]); + assert_eq!(t123["94_open"], vec!["1"]); + assert_eq!(net123.loads.len(), 91); +} + +#[test] +fn defaults_materialize_with_provenance() { + let net = parse("micro/defaults_degenerate.dss"); + + // New Line.l_default bus1=sourcebus bus2=b2: every electrical value is + // the constructor default, materialized and recorded. + let line = net.lines.iter().find(|l| l.name == "l_default").unwrap(); + assert!((line.length - 1.0).abs() < 1e-12); + let code = net.linecode(&line.linecode).unwrap(); + // Sequence defaults: diag (2*0.058 + 0.1784)/3, off diag (0.1784-0.058)/3. + assert!((code.r_series[0][0] - 0.098_133_333_333_333_33).abs() < 1e-12); + assert!((code.r_series[0][1] - 0.040_133_333_333_333_33).abs() < 1e-12); + assert!((code.x_series[0][0] - 0.2153).abs() < 1e-12); + let d = &net.defaulted["line.l_default"]; + assert!(d.contains(&"length") && d.contains(&"r1")); + + // New Load.ld_default bus1=b2: kv, kw, pf all defaulted. + let load = net.loads.iter().find(|l| l.name == "ld_default").unwrap(); + let p: f64 = load.p_nom.iter().sum(); + let q: f64 = load.q_nom.iter().sum(); + assert!((p - 10_000.0).abs() < 1e-9); + // q = kw * tan(acos(0.88)) + assert!((q - 10_000.0 * 0.88f64.acos().tan()).abs() < 1e-6); + let d = &net.defaulted["load.ld_default"]; + assert!(d.contains(&"kv") && d.contains(&"kw") && d.contains(&"pf")); + + // New Transformer.t_default buses=(b2, b3): 12.47 kV / 1000 kVA wye-wye. + let t = net + .transformers + .iter() + .find(|t| t.name == "t_default") + .unwrap(); + assert_eq!(t.windings.len(), 2); + assert!((t.windings[0].v_ref - 12_470.0).abs() < 1e-9); + assert!((t.windings[0].s_rating - 1_000_000.0).abs() < 1e-9); + assert_eq!(t.windings[0].conn, WindingConn::Wye); + assert!((t.xsc_pct[0] - 7.0).abs() < 1e-12); + let d = &net.defaulted["transformer.t_default"]; + assert!(d.contains(&"kv") && d.contains(&"kva") && d.contains(&"xhl")); + + // The default circuit source. + let vs = &net.sources[0]; + assert!((vs.v_magnitude[0] - 115_000.0 / 3f64.sqrt()).abs() < 1e-9); + assert_eq!(vs.bus, "sourcebus"); +} + +#[test] +fn micro_transformers_type_correctly() { + let net = parse("micro/xfmr_center_tap.dss"); + let t = net.transformers.iter().find(|t| t.name == "t1").unwrap(); + assert_eq!(t.windings.len(), 3); + assert_eq!(t.phases, 1); + assert!((t.windings[0].v_ref - 7200.0).abs() < 1e-9); + assert!((t.windings[1].v_ref - 120.0).abs() < 1e-9); + // Winding 2 is secondary.1.0, winding 3 is secondary.0.2 (reversed). + assert_eq!(t.windings[1].terminal_map, vec!["1", "4"]); + assert_eq!(t.windings[2].terminal_map, vec!["4", "2"]); + assert_eq!(t.xsc_pct.len(), 3); + + let net = parse("micro/xfmr_wye_delta.dss"); + let t = net.transformers.iter().find(|t| t.name == "t1").unwrap(); + assert_eq!(t.windings[0].conn, WindingConn::Wye); + assert_eq!(t.windings[1].conn, WindingConn::Delta); + // Delta side lists only the phase conductors. + assert_eq!(t.windings[1].terminal_map, vec!["1", "2", "3"]); + // Wye side default neutral is grounded. + assert_eq!(t.windings[0].terminal_map, vec!["1", "2", "3", "4"]); +} + +#[test] +fn switch_states_follow_swtcontrol() { + let net = parse("micro/switch.dss"); + let closed = net.switches.iter().find(|s| s.name == "sw_closed").unwrap(); + let open = net.switches.iter().find(|s| s.name == "sw_open").unwrap(); + assert!(!closed.open); + assert!(open.open); +} + +#[test] +fn swtcontrol_last_action_or_state_wins() { + use powerio_dist::parse_dss_str; + let base = "New Circuit.c basekv=12.47\nNew Line.sw bus1=sourcebus bus2=b2 switch=y\n"; + // The later `state` overrides the earlier `action`. + let net = parse_dss_str(&format!( + "{base}New SwtControl.s1 SwitchedObj=Line.sw action=close state=open" + )); + assert!(net.switches[0].open); + // Source order reversed: `action` wins. + let net = parse_dss_str(&format!( + "{base}New SwtControl.s1 SwitchedObj=Line.sw state=open action=close" + )); + assert!(!net.switches[0].open); + // `normal` applies only when neither action nor state is written. + let net = parse_dss_str(&format!( + "{base}New SwtControl.s1 SwitchedObj=Line.sw normal=open" + )); + assert!(net.switches[0].open); + let net = parse_dss_str(&format!( + "{base}New SwtControl.s1 SwitchedObj=Line.sw normal=open action=close" + )); + assert!(!net.switches[0].open); +} + +#[test] +#[allow(clippy::float_cmp)] +fn four_wire_line_keeps_the_neutral() { + let net = parse("micro/fourwire_linecode.dss"); + let line = net.lines.iter().find(|l| l.name == "l1").unwrap(); + assert_eq!(line.terminal_map_from, vec!["1", "2", "3", "4"]); + assert_eq!(line.terminal_map_to, vec!["1", "2", "3", "4"]); + let code = net.linecode("lc4").unwrap(); + assert_eq!(code.n_conductors, 4); + // km units: 0.211 ohm/km = 2.11e-4 ohm/m on the diagonal. + assert!((code.r_series[0][0] - 0.211e-3).abs() < 1e-12); + assert_eq!(code.i_max.as_ref().unwrap()[0], 240.0); + // The load on phase 1 returns through terminal 4, not ground. + let la = net.loads.iter().find(|l| l.name == "la").unwrap(); + assert_eq!(la.terminal_map, vec!["1", "4"]); +} + +#[test] +fn ten_conductor_linecode_types() { + let net = parse("micro/linecode_10x10.dss"); + let code = net.linecode("lc10").unwrap(); + assert_eq!(code.n_conductors, 10); + assert_eq!(code.r_series.len(), 10); + assert!((code.r_series[9][9] - 0.25e-3).abs() < 1e-12); + let line = net.lines.iter().find(|l| l.name == "l10").unwrap(); + assert_eq!(line.terminal_map_to.len(), 10); +} + +#[test] +fn regcontrol_warns_and_keeps_taps() { + let net = parse("opendss/ieee13/IEEE13Nodeckt.dss"); + assert!( + net.warnings + .iter() + .any(|w| w.contains("regcontrol") && w.contains("Reg1")) + ); + let reg1 = net + .transformers + .iter() + .find(|t| t.name.eq_ignore_ascii_case("reg1")) + .unwrap(); + assert_eq!(reg1.phases, 1); +} diff --git a/powerio-dist/tests/matrix.rs b/powerio-dist/tests/matrix.rs new file mode 100644 index 0000000..71c95bd --- /dev/null +++ b/powerio-dist/tests/matrix.rs @@ -0,0 +1,556 @@ +//! The 3x3 conversion harness: diagonal byte identity via the retained +//! source, canonical writer idempotence, and off diagonal round trips with +//! the lossy transforms named per cell. `cargo test --test matrix -- +//! --ignored write_conversion_matrix` regenerates docs/conversion-matrix.md. + +use std::fmt::Write as _; +use std::path::PathBuf; +use std::sync::Arc; + +use powerio_dist::{ + DistNetwork, DistTargetFormat, Result, parse_bmopf_str, parse_dss_file, parse_pmd_str, +}; + +fn fixture(rel: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../tests/data/dist") + .join(rel) +} + +#[derive(Clone, Copy, PartialEq)] +enum Fmt { + Dss, + Bmopf, + Pmd, +} + +impl Fmt { + fn target(self) -> DistTargetFormat { + match self { + Fmt::Dss => DistTargetFormat::Dss, + Fmt::Bmopf => DistTargetFormat::BmopfJson, + Fmt::Pmd => DistTargetFormat::PmdJson, + } + } + + fn parse(self, text: &str) -> Result { + match self { + Fmt::Dss => { + // Unique path per call: the harness tests run in parallel + // threads and must not race on a shared temp file. + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let dir = std::env::temp_dir().join("powerio-dist-matrix"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(format!( + "roundtrip-{}.dss", + COUNTER.fetch_add(1, Ordering::Relaxed) + )); + std::fs::write(&path, text).unwrap(); + let parsed = powerio_dist::dss::parse_dss_file(&path); + let _ = std::fs::remove_file(&path); + parsed + } + Fmt::Bmopf => parse_bmopf_str(text), + Fmt::Pmd => parse_pmd_str(text), + } + } + + fn name(self) -> &'static str { + match self { + Fmt::Dss => "dss", + Fmt::Bmopf => "BMOPF", + Fmt::Pmd => "PMD", + } + } +} + +struct Case { + label: &'static str, + rel: &'static str, + fmt: Fmt, + /// Transformer shapes BMOPF restates (wye-wye decomposition, center tap + /// collapse), making the D→B→D transformer list structurally different. + bmopf_restates_transformers: bool, + /// dss expresses perfect grounding as node 0, so a grounded terminal's + /// name does not survive a trip through dss. Only the public BMOPF + /// IEEE 13 example grounds phase terminals (its three wire buses mark + /// the highest terminal grounded); everywhere else the grounded + /// terminal is the materialized neutral, which dss regenerates as the + /// same name. + dss_renames_grounded: bool, +} + +const CASES: &[Case] = &[ + Case { + label: "IEEE 13", + rel: "opendss/ieee13/IEEE13Nodeckt.dss", + fmt: Fmt::Dss, + bmopf_restates_transformers: true, + dss_renames_grounded: false, + }, + Case { + label: "IEEE 34", + rel: "opendss/ieee34/ieee34Mod1.dss", + fmt: Fmt::Dss, + bmopf_restates_transformers: true, + dss_renames_grounded: false, + }, + Case { + label: "IEEE 123", + rel: "opendss/ieee123/IEEE123Master.dss", + fmt: Fmt::Dss, + bmopf_restates_transformers: true, + dss_renames_grounded: false, + }, + Case { + label: "single phase transformer", + rel: "micro/xfmr_single_phase.dss", + fmt: Fmt::Dss, + bmopf_restates_transformers: false, + dss_renames_grounded: false, + }, + Case { + label: "center tap transformer", + rel: "micro/xfmr_center_tap.dss", + fmt: Fmt::Dss, + bmopf_restates_transformers: true, + dss_renames_grounded: false, + }, + Case { + label: "wye delta transformer", + rel: "micro/xfmr_wye_delta.dss", + fmt: Fmt::Dss, + bmopf_restates_transformers: false, + dss_renames_grounded: false, + }, + Case { + label: "delta wye transformer", + rel: "micro/xfmr_delta_wye.dss", + fmt: Fmt::Dss, + bmopf_restates_transformers: false, + dss_renames_grounded: false, + }, + Case { + label: "switch states", + rel: "micro/switch.dss", + fmt: Fmt::Dss, + bmopf_restates_transformers: false, + dss_renames_grounded: false, + }, + Case { + label: "four wire linecode", + rel: "micro/fourwire_linecode.dss", + fmt: Fmt::Dss, + bmopf_restates_transformers: false, + dss_renames_grounded: false, + }, + Case { + label: "constructor defaults", + rel: "micro/defaults_degenerate.dss", + fmt: Fmt::Dss, + bmopf_restates_transformers: true, + dss_renames_grounded: false, + }, + Case { + label: "ten conductor linecode", + rel: "micro/linecode_10x10.dss", + fmt: Fmt::Dss, + bmopf_restates_transformers: false, + dss_renames_grounded: false, + }, + Case { + label: "BMOPF IEEE 13 example", + rel: "bmopf/example_ieee13.json", + fmt: Fmt::Bmopf, + bmopf_restates_transformers: false, + dss_renames_grounded: true, + }, + Case { + label: "BMOPF ENWL example", + rel: "bmopf/example_enwl_n1_f2.json", + fmt: Fmt::Bmopf, + bmopf_restates_transformers: false, + dss_renames_grounded: false, + }, + Case { + label: "PMD IEEE 13", + rel: "pmd/ieee13.json", + fmt: Fmt::Pmd, + bmopf_restates_transformers: true, + dss_renames_grounded: false, + }, + Case { + label: "PMD four wire", + rel: "pmd/fourwire_linecode.json", + fmt: Fmt::Pmd, + bmopf_restates_transformers: false, + dss_renames_grounded: false, + }, +]; + +fn parse_case(case: &Case) -> DistNetwork { + let path = fixture(case.rel); + match case.fmt { + Fmt::Dss => parse_dss_file(&path).unwrap(), + Fmt::Bmopf => powerio_dist::parse_bmopf_file(&path).unwrap(), + Fmt::Pmd => powerio_dist::parse_pmd_file(&path).unwrap(), + } +} + +/// The model fields every format carries; the per cell comparisons run on +/// this projection, with transformer carve outs where BMOPF restates them. +fn assert_projection_eq(a: &DistNetwork, b: &DistNetwork, what: &str, transformers: bool) { + // JSON formats key elements by name, so order is not preserved across + // a round trip; compare per name. + fn by_name<'a, T>(items: &'a [T], name: impl Fn(&'a T) -> &'a str) -> Vec<(&'a str, &'a T)> { + let mut v: Vec<(&str, &T)> = items.iter().map(|t| (name(t), t)).collect(); + v.sort_by_key(|(n, _)| n.to_ascii_lowercase()); + v + } + assert_eq!(a.buses.len(), b.buses.len(), "{what}: bus count"); + let buses_a = by_name(&a.buses, |b| &b.id); + let buses_b = by_name(&b.buses, |b| &b.id); + for ((_, x), (_, y)) in buses_a.iter().zip(&buses_b) { + assert!(x.id.eq_ignore_ascii_case(&y.id), "{what}: bus set"); + assert_eq!(x.terminals, y.terminals, "{what}: bus {} terminals", x.id); + assert_eq!(x.grounded, y.grounded, "{what}: bus {} grounding", x.id); + } + assert_eq!(a.switches.len(), b.switches.len(), "{what}: switches"); + for ((_, x), (_, y)) in by_name(&a.switches, |s| &s.name) + .iter() + .zip(&by_name(&b.switches, |s| &s.name)) + { + assert_eq!(x.open, y.open, "{what}: switch {}", x.name); + } + // Scale changes (kW to W and back) cost at most one rounding per + // direction; powers compare to 2 ULP relative, everything structural + // exactly. + let close = |x: f64, y: f64| (x - y).abs() <= 4.0 * f64::EPSILON * x.abs().max(y.abs()); + assert_eq!(a.loads.len(), b.loads.len(), "{what}: loads"); + for ((_, x), (_, y)) in by_name(&a.loads, |l| &l.name) + .iter() + .zip(&by_name(&b.loads, |l| &l.name)) + { + for (p, q) in x.p_nom.iter().zip(&y.p_nom) { + assert!(close(*p, *q), "{what}: load {} p {p} vs {q}", x.name); + } + for (p, q) in x.q_nom.iter().zip(&y.q_nom) { + assert!(close(*p, *q), "{what}: load {} q {p} vs {q}", x.name); + } + assert_eq!( + x.terminal_map, y.terminal_map, + "{what}: load {} map", + x.name + ); + } + assert_eq!(a.lines.len(), b.lines.len(), "{what}: lines"); + for ((_, x), (_, y)) in by_name(&a.lines, |l| &l.name) + .iter() + .zip(&by_name(&b.lines, |l| &l.name)) + { + assert!( + x.name.eq_ignore_ascii_case(&y.name), + "{what}: line set ({} vs {})", + x.name, + y.name + ); + assert!( + x.bus_from.eq_ignore_ascii_case(&y.bus_from) + && x.bus_to.eq_ignore_ascii_case(&y.bus_to), + "{what}: line {} endpoints", + x.name + ); + assert_eq!( + x.length.to_bits(), + y.length.to_bits(), + "{what}: line {} length", + x.name + ); + assert_eq!( + x.terminal_map_from, y.terminal_map_from, + "{what}: line {} from map", + x.name + ); + assert_eq!( + x.terminal_map_to, y.terminal_map_to, + "{what}: line {} to map", + x.name + ); + } + if transformers { + assert_eq!( + a.transformers.len(), + b.transformers.len(), + "{what}: transformers" + ); + for ((_, x), (_, y)) in by_name(&a.transformers, |t| &t.name) + .iter() + .zip(&by_name(&b.transformers, |t| &t.name)) + { + assert_eq!( + x.windings.len(), + y.windings.len(), + "{what}: xfmr {}", + x.name + ); + for (wx, wy) in x.windings.iter().zip(&y.windings) { + assert_eq!(wx.conn, wy.conn, "{what}: xfmr {} conn", x.name); + assert!( + (wx.v_ref - wy.v_ref).abs() <= 1e-9 * wx.v_ref.abs().max(1.0), + "{what}: xfmr {} v_ref {} vs {}", + x.name, + wx.v_ref, + wy.v_ref + ); + } + } + } +} + +/// Linecode matrices compare to within one ULP scale relative error: a +/// basis change (the PMD capacitance form, the dss per length form) costs +/// at most one rounding per direction. +fn assert_linecodes_close(a: &DistNetwork, b: &DistNetwork, what: &str) { + assert_eq!(a.linecodes.len(), b.linecodes.len(), "{what}: linecodes"); + let close = |x: f64, y: f64| (x - y).abs() <= 1e-12 * x.abs().max(y.abs()).max(1e-300); + let mut xs: Vec<_> = a.linecodes.iter().collect(); + let mut ys: Vec<_> = b.linecodes.iter().collect(); + xs.sort_by_key(|c| c.name.to_ascii_lowercase()); + ys.sort_by_key(|c| c.name.to_ascii_lowercase()); + for (x, y) in xs.iter().zip(&ys) { + assert!( + x.name.eq_ignore_ascii_case(&y.name), + "{what}: linecode set ({} vs {})", + x.name, + y.name + ); + assert_eq!( + x.n_conductors, y.n_conductors, + "{what}: linecode {} size", + x.name + ); + let mats = [ + ("r", &x.r_series, &y.r_series), + ("x", &x.x_series, &y.x_series), + ("b", &x.b_from, &y.b_from), + ]; + for (label, mx, my) in mats { + assert_eq!(mx.len(), my.len(), "{what}: linecode {} {label}", x.name); + for (rx, ry) in mx.iter().zip(my) { + assert_eq!(rx.len(), ry.len(), "{what}: linecode {} {label}", x.name); + for (vx, vy) in rx.iter().zip(ry) { + assert!( + close(*vx, *vy), + "{what}: linecode {} {label} {vx} vs {vy}", + x.name + ); + } + } + } + } +} + +/// Replaces every grounded terminal name with "G", on buses and in the +/// terminal maps of the elements referencing them. +fn normalize_grounded(net: &DistNetwork) -> DistNetwork { + let mut net = net.clone(); + let grounded: std::collections::BTreeMap> = net + .buses + .iter() + .map(|b| (b.id.to_ascii_lowercase(), b.grounded.clone())) + .collect(); + let fix = |bus: &str, map: &mut Vec| { + if let Some(g) = grounded.get(&bus.to_ascii_lowercase()) { + for t in map.iter_mut() { + if g.contains(t) { + *t = "G".to_string(); + } + } + } + }; + for b in &mut net.buses { + let g = b.grounded.clone(); + for t in b.terminals.iter_mut().chain(b.grounded.iter_mut()) { + if g.contains(t) { + *t = "G".to_string(); + } + } + } + for l in &mut net.lines { + fix(&l.bus_from.clone(), &mut l.terminal_map_from); + fix(&l.bus_to.clone(), &mut l.terminal_map_to); + } + for s in &mut net.switches { + fix(&s.bus_from.clone(), &mut s.terminal_map_from); + fix(&s.bus_to.clone(), &mut s.terminal_map_to); + } + for l in &mut net.loads { + fix(&l.bus.clone(), &mut l.terminal_map); + } + for t in &mut net.transformers { + for w in &mut t.windings { + fix(&w.bus.clone(), &mut w.terminal_map); + } + } + net +} + +#[test] +fn diagonal_byte_identity() { + for case in CASES { + let net = parse_case(case); + let original = std::fs::read_to_string(fixture(case.rel)).unwrap(); + let echoed = net.to_format(case.fmt.target()); + assert_eq!(echoed.text, original, "{}: diagonal echo", case.label); + assert!(echoed.warnings.is_empty(), "{}: echo warns", case.label); + } +} + +#[test] +fn canonical_writers_are_idempotent() { + for case in CASES { + let net = parse_case(case); + for target in [Fmt::Dss, Fmt::Bmopf, Fmt::Pmd] { + let first = match target { + Fmt::Dss => powerio_dist::write_dss(&net), + Fmt::Bmopf => powerio_dist::write_bmopf_json(&net), + Fmt::Pmd => powerio_dist::write_pmd_json(&net), + }; + let reparsed = match target.parse(&first.text) { + Ok(n) => n, + Err(e) => panic!("{} → {}: reparse failed: {e}", case.label, target.name()), + }; + let second = match target { + Fmt::Dss => powerio_dist::write_dss(&reparsed), + Fmt::Bmopf => powerio_dist::write_bmopf_json(&reparsed), + Fmt::Pmd => powerio_dist::write_pmd_json(&reparsed), + }; + assert_eq!( + first.text, + second.text, + "{} → {}: canonical output is not idempotent", + case.label, + target.name() + ); + } + } +} + +#[test] +fn off_diagonal_round_trips() { + for case in CASES { + let net = parse_case(case); + for target in [Fmt::Dss, Fmt::Bmopf, Fmt::Pmd] { + if target == case.fmt { + continue; + } + let what = format!("{} → {} → back", case.label, target.name()); + let out = net.to_format(target.target()); + let back = target + .parse(&out.text) + .unwrap_or_else(|e| panic!("{what}: {e}")); + let transformers = !(target == Fmt::Bmopf && case.bmopf_restates_transformers); + if target == Fmt::Dss && case.dss_renames_grounded { + // Grounded phase terminals fold into node 0 on the way + // through dss; compare the networks with each bus's grounded + // terminals normalized to one token. + let (a, b) = (normalize_grounded(&net), normalize_grounded(&back)); + assert_projection_eq(&a, &b, &what, transformers); + assert_linecodes_close(&a, &b, &what); + } else { + assert_projection_eq(&net, &back, &what, transformers); + assert_linecodes_close(&net, &back, &what); + } + } + } +} + +/// Regenerates docs/conversion-matrix.md; the table records every cell of +/// the matrix with its outcome. +#[test] +#[ignore = "writes docs/conversion-matrix.md; run on demand"] +fn write_conversion_matrix() { + let mut md = String::new(); + md.push_str("# Conversion matrix\n\n"); + md.push_str( + "Generated by `cargo test -p powerio-dist --test matrix -- --ignored \ + write_conversion_matrix`. Rows are fixtures (tests/data/dist, provenance in its \ + README); columns are conversion targets. `echo` is the byte exact diagonal; `ok` is \ + a canonical write that reparses to the common projection of the model; `ok (n warn)` \ + names the count of fidelity losses the conversion reports, each one listed in the \ + conversion's warnings.\n\n", + ); + md.push_str("| fixture | source | → dss | → BMOPF | → PMD |\n"); + md.push_str("|---|---|---|---|---|\n"); + for case in CASES { + let net = parse_case(case); + let mut cells = Vec::new(); + for target in [Fmt::Dss, Fmt::Bmopf, Fmt::Pmd] { + if target == case.fmt { + cells.push("echo".to_string()); + continue; + } + let out = net.to_format(target.target()); + match target.parse(&out.text) { + Ok(_) => { + if out.warnings.is_empty() { + cells.push("ok".to_string()); + } else { + cells.push(format!("ok ({} warn)", out.warnings.len())); + } + } + Err(e) => cells.push(format!("FAIL: {e}")), + } + } + let _ = writeln!( + md, + "| {} | {} | {} | {} | {} |", + case.label, + case.fmt.name(), + cells[0], + cells[1], + cells[2] + ); + } + md.push('\n'); + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("docs/conversion-matrix.md"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, md).unwrap(); +} + +/// Writes every fixture's canonical dss output under target/physics so +/// tools/physics_check.py can re-solve them against the originals. +#[test] +#[ignore = "writes target/physics; run before tools/physics_check.py"] +fn emit_for_physics_check() { + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../target/physics"); + std::fs::create_dir_all(&dir).unwrap(); + for case in CASES { + let net = parse_case(case); + let stem = case + .rel + .replace('/', "_") + .replace(".dss", "") + .replace(".json", ""); + // The canonical dss regeneration (echo bypassed on purpose). + let dss = powerio_dist::write_dss(&net); + std::fs::write(dir.join(format!("{stem}.canonical.dss")), &dss.text).unwrap(); + if case.fmt == Fmt::Dss { + // Through each JSON format and back to dss. + for (suffix, text) in [ + ("via_bmopf", powerio_dist::write_bmopf_json(&net).text), + ("via_pmd", powerio_dist::write_pmd_json(&net).text), + ] { + let mid: DistNetwork = if suffix == "via_bmopf" { + parse_bmopf_str(&text).unwrap() + } else { + parse_pmd_str(&text).unwrap() + }; + let out = powerio_dist::write_dss(&mid); + std::fs::write(dir.join(format!("{stem}.{suffix}.dss")), &out.text).unwrap(); + } + } + } + let _ = Arc::new(()); +} diff --git a/powerio-dist/tests/pmd.rs b/powerio-dist/tests/pmd.rs new file mode 100644 index 0000000..975ecac --- /dev/null +++ b/powerio-dist/tests/pmd.rs @@ -0,0 +1,585 @@ +//! PMD ENGINEERING JSON reader/writer against reference JSON generated by +//! PowerModelsDistribution itself (tests/data/dist/pmd, provenance in the +//! fixture README). + +use std::collections::BTreeSet; +use std::path::PathBuf; +use std::sync::Arc; + +use powerio_dist::dss::parse_dss_file; +use powerio_dist::{DistNetwork, parse_pmd_file, parse_pmd_str, write_bmopf_json, write_pmd_json}; + +fn fixture(rel: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../tests/data/dist") + .join(rel) +} + +#[test] +#[allow(clippy::float_cmp)] +fn parse_the_reference_engineering_json() { + let net = parse_pmd_file(fixture("pmd/ieee13.json")).unwrap(); + assert_eq!(net.name.as_deref(), Some("ieee13nodeckt")); + assert_eq!(net.buses.len(), 16); + assert_eq!(net.lines.len(), 11); + assert_eq!(net.switches.len(), 1); + assert_eq!(net.loads.len(), 15); + assert_eq!(net.shunts.len(), 2); + assert_eq!(net.transformers.len(), 3); // sub, xfm1, reg1 (banked) + assert_eq!(net.sources.len(), 1); + + let b611 = net.bus("611").unwrap(); + assert_eq!(b611.terminals, vec!["3", "4"]); + assert_eq!(b611.grounded, vec!["4"]); + + // kW scale restores to watts; degrees to radians. + let l611 = net.loads.iter().find(|l| l.name == "611").unwrap(); + assert!((l611.p_nom[0] - 170_000.0).abs() < 1e-9); + let vs = &net.sources[0]; + assert!((vs.v_magnitude[0] - 66_401.920_484_902_64).abs() < 1e-6); + assert!((vs.v_angle[0] - 30f64.to_radians()).abs() < 1e-9); + assert_eq!(vs.v_magnitude.len(), 4); + assert_eq!(vs.v_magnitude[3], 0.0); +} + +/// The PMD oracle agreement: PMD's own parse of IEEE13 and ours agree on +/// the network content. +#[test] +fn dss_parse_agrees_with_the_pmd_parse() { + let ours = parse_dss_file(fixture("opendss/ieee13/IEEE13Nodeckt.dss")).unwrap(); + let pmd = parse_pmd_file(fixture("pmd/ieee13.json")).unwrap(); + + let bus_set = |n: &DistNetwork| -> BTreeSet { + n.buses.iter().map(|b| b.id.to_lowercase()).collect() + }; + assert_eq!(bus_set(&ours), bus_set(&pmd)); + + // Terminals and grounding agree bus by bus. + for b in &ours.buses { + let other = pmd.bus(&b.id).unwrap(); + assert_eq!(b.terminals, other.terminals, "bus {}", b.id); + assert_eq!(b.grounded, other.grounded, "bus {}", b.id); + } + + // Linecode series impedance agrees on mtx601 to the mile rounding: + // PMD converts with 1609.3 m per mile, the engine with the exact + // 1609.344, a 2.7e-5 relative difference carried into every per meter + // value. powerio-dist uses the engine's factor. + let a = ours.linecode("mtx601").unwrap(); + let b = pmd.linecode("mtx601").unwrap(); + let close = |x: f64, y: f64| (x - y).abs() <= 5e-5 * x.abs().max(y.abs()).max(1e-30); + for i in 0..3 { + for j in 0..3 { + assert!(close(a.r_series[i][j], b.r_series[i][j])); + assert!(close(a.x_series[i][j], b.x_series[i][j])); + assert!(close(a.b_from[i][j], b.b_from[i][j])); + } + } + assert_eq!(a.i_max, b.i_max); + + // Loads agree in power, configuration, and connection. + for l in &ours.loads { + let other = pmd + .loads + .iter() + .find(|o| o.name.eq_ignore_ascii_case(&l.name)) + .unwrap(); + for (p, q) in l.p_nom.iter().zip(&other.p_nom) { + assert!((p - q).abs() < 1e-9, "load {}", l.name); + } + assert_eq!(l.terminal_map, other.terminal_map, "load {}", l.name); + } + + // The switch state and ampacity agree. + assert_eq!(ours.switches[0].open, pmd.switches[0].open); + assert_eq!(ours.switches[0].i_max, pmd.switches[0].i_max); + + // Source magnitude and angles agree. + let (vs_a, vs_b) = (&ours.sources[0], &pmd.sources[0]); + for (m, o) in vs_a.v_magnitude.iter().zip(&vs_b.v_magnitude) { + assert!((m - o).abs() < 1e-6); + } + for (m, o) in vs_a.v_angle.iter().zip(&vs_b.v_angle) { + assert!((m - o).abs() < 1e-9); + } +} + +#[test] +fn four_wire_reference_agrees() { + let ours = parse_dss_file(fixture("micro/fourwire_linecode.dss")).unwrap(); + let pmd = parse_pmd_file(fixture("pmd/fourwire_linecode.json")).unwrap(); + let a = ours.linecode("lc4").unwrap(); + let b = pmd.linecode("lc4").unwrap(); + assert_eq!(a.n_conductors, b.n_conductors); + for i in 0..4 { + for j in 0..4 { + assert!((a.r_series[i][j] - b.r_series[i][j]).abs() < 1e-15); + } + } + let la = ours.loads.iter().find(|l| l.name == "la").unwrap(); + let lb = pmd.loads.iter().find(|l| l.name == "la").unwrap(); + assert_eq!(la.terminal_map, lb.terminal_map); + assert!((la.p_nom[0] - lb.p_nom[0]).abs() < 1e-9); +} + +#[test] +fn canonical_output_is_idempotent() { + let net = parse_pmd_file(fixture("pmd/ieee13.json")).unwrap(); + let once = write_pmd_json(&net); + let again = parse_pmd_str(&once.text).unwrap(); + let twice = write_pmd_json(&again); + assert_eq!(once.text, twice.text); +} + +/// Model equality after a P round trip, minus the retained source. +#[test] +fn pmd_round_trips_to_model_equality() { + let net = parse_pmd_file(fixture("pmd/ieee13.json")).unwrap(); + let out = write_pmd_json(&net); + let again = parse_pmd_str(&out.text).unwrap(); + let strip = |n: &DistNetwork| { + let mut n = n.clone(); + n.source = Some(Arc::new(String::new())); + n.extras.clear(); // pmd_settings/pmd_files bookkeeping differs in formatting only + n.warnings.clear(); + n + }; + let (a, b) = (strip(&net), strip(&again)); + assert_eq!(a.buses, b.buses); + assert_eq!(a.lines, b.lines); + assert_eq!(a.switches, b.switches); + assert_eq!(a.loads, b.loads); + assert_eq!(a.shunts, b.shunts); + assert_eq!(a.sources, b.sources); + assert_eq!(a.linecodes, b.linecodes); + assert_eq!(a.transformers, b.transformers); +} + +#[test] +fn dss_to_pmd_reproduces_the_reference_essentials() { + let net = parse_dss_file(fixture("opendss/ieee13/IEEE13Nodeckt.dss")).unwrap(); + let out = write_pmd_json(&net); + let emitted: serde_json::Value = serde_json::from_str(&out.text).unwrap(); + let reference: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(fixture("pmd/ieee13.json")).unwrap()) + .unwrap(); + + assert_eq!(emitted["data_model"], reference["data_model"]); + // Bus terminals, grounding, and coordinates match the reference. + for (id, bus) in reference["bus"].as_object().unwrap() { + let ours = &emitted["bus"][id]; + assert_eq!(ours["terminals"], bus["terminals"], "bus {id}"); + assert_eq!(ours["grounded"], bus["grounded"], "bus {id}"); + assert_eq!(ours["lat"], bus["lat"], "bus {id}"); + assert_eq!(ours["status"], bus["status"], "bus {id}"); + } + // Loads match in power, model enum, and nominal voltage. + for (id, load) in reference["load"].as_object().unwrap() { + let ours = &emitted["load"][id]; + assert_eq!(ours["pd_nom"], load["pd_nom"], "load {id}"); + assert_eq!(ours["qd_nom"], load["qd_nom"], "load {id}"); + assert_eq!(ours["model"], load["model"], "load {id}"); + assert_eq!(ours["vm_nom"], load["vm_nom"], "load {id}"); + assert_eq!(ours["configuration"], load["configuration"], "load {id}"); + assert_eq!(ours["connections"], load["connections"], "load {id}"); + } + // The switch reproduces PMD's tiny series resistance convention. + assert_eq!( + emitted["switch"]["671692"]["rs"], + reference["switch"]["671692"]["rs"] + ); + assert_eq!( + emitted["switch"]["671692"]["state"], + reference["switch"]["671692"]["state"] + ); + // The voltage source Thevenin matrices match the engine's computation. + let ours_rs = emitted["voltage_source"]["source"]["rs"] + .as_array() + .unwrap(); + let ref_rs = reference["voltage_source"]["source"]["rs"] + .as_array() + .unwrap(); + for (a, b) in ours_rs.iter().zip(ref_rs) { + for (x, y) in a.as_array().unwrap().iter().zip(b.as_array().unwrap()) { + assert!((x.as_f64().unwrap() - y.as_f64().unwrap()).abs() < 1e-9); + } + } + // Transformer per unit impedances and taps match (sub: delta primary). + for id in ["sub", "xfm1"] { + let ours = &emitted["transformer"][id]; + let want = &reference["transformer"][id]; + assert_eq!(ours["rw"], want["rw"], "{id}"); + assert_eq!(ours["xsc"], want["xsc"], "{id}"); + assert_eq!(ours["vm_nom"], want["vm_nom"], "{id}"); + assert_eq!(ours["sm_nom"], want["sm_nom"], "{id}"); + assert_eq!(ours["polarity"], want["polarity"], "{id}"); + assert_eq!(ours["connections"], want["connections"], "{id}"); + assert_eq!(ours["tm_step"], want["tm_step"], "{id}"); + } +} + +fn rewrite(text: &str) -> serde_json::Value { + let net = parse_pmd_str(text).unwrap(); + serde_json::from_str(&write_pmd_json(&net).text).unwrap() +} + +/// A non ENABLED status survives the round trip on every component class +/// instead of silently re-enabling. +#[test] +fn disabled_status_round_trips() { + let text = r#"{ + "data_model": "ENGINEERING", + "bus": { + "b1": {"terminals": [1, 2, 3, 4], "grounded": [4], "rg": [0.0], "xg": [0.0], "status": "DISABLED"}, + "b2": {"terminals": [1, 2, 3, 4], "grounded": [4], "rg": [0.0], "xg": [0.0], "status": "ENABLED"} + }, + "linecode": {"lc": {"rs": [[0.1]], "xs": [[0.1]], "g_fr": [[0.0]], "g_to": [[0.0]], "b_fr": [[0.0]], "b_to": [[0.0]]}}, + "line": {"ln": {"f_bus": "b1", "t_bus": "b2", "f_connections": [1], "t_connections": [1], + "linecode": "lc", "length": 10.0, "status": "DISABLED"}}, + "switch": {"sw": {"f_bus": "b1", "t_bus": "b2", "f_connections": [1], "t_connections": [1], + "state": "CLOSED", "status": "DISABLED"}}, + "load": {"ld": {"bus": "b1", "connections": [1, 4], "configuration": "WYE", + "pd_nom": [5.0], "qd_nom": [1.0], "status": "DISABLED"}}, + "generator": {"gn": {"bus": "b1", "connections": [1, 2, 3, 4], "configuration": "WYE", + "pg": [10.0, 10.0, 10.0], "qg": [0.0, 0.0, 0.0], "status": "DISABLED"}}, + "shunt": {"sh": {"bus": "b1", "connections": [1, 4], "gs": [[0.0]], "bs": [[1.0]], "status": "DISABLED"}}, + "voltage_source": {"src": {"bus": "b1", "connections": [1, 2, 3, 4], + "vm": [7.2, 7.2, 7.2, 0.0], "va": [0.0, -120.0, 120.0, 0.0], "status": "DISABLED"}}, + "transformer": {"tr": {"bus": ["b1", "b2"], "connections": [[1, 2, 3, 4], [1, 2, 3, 4]], + "configuration": ["WYE", "WYE"], "polarity": [1, 1], "rw": [0.01, 0.01], "xsc": [0.05], + "sm_nom": [500.0, 500.0], "vm_nom": [12.47, 4.16], + "tm_set": [[1.0, 1.0, 1.0], [1.0, 1.0, 1.0]], "status": "DISABLED"}} + }"#; + let net = parse_pmd_str(text).unwrap(); + assert!( + net.warnings + .iter() + .any(|w| w.contains("status DISABLED kept in extras")) + ); + let out = rewrite(text); + for (class, name) in [ + ("bus", "b1"), + ("line", "ln"), + ("switch", "sw"), + ("load", "ld"), + ("generator", "gn"), + ("shunt", "sh"), + ("voltage_source", "src"), + ("transformer", "tr"), + ] { + assert_eq!(out[class][name]["status"], "DISABLED", "{class} {name}"); + } + // Untouched elements stay enabled. + assert_eq!(out["bus"]["b2"]["status"], "ENABLED"); +} + +/// A euro (lead) Dy transformer: polarity [1, 1] with unrolled secondary +/// connections must come back verbatim, not flipped to the ANSI lag roll. +#[test] +fn euro_lead_transformer_round_trips() { + let text = r#"{ + "data_model": "ENGINEERING", + "transformer": {"tr": {"bus": ["b1", "b2"], + "connections": [[1, 2, 3], [1, 2, 3, 4]], + "configuration": ["DELTA", "WYE"], "polarity": [1, 1], + "rw": [0.01, 0.01], "xsc": [0.05], + "sm_nom": [500.0, 500.0], "vm_nom": [12.47, 4.16], + "tm_set": [[1.0, 1.0, 1.0], [1.0, 1.0, 1.0]], "status": "ENABLED"}} + }"#; + let net = parse_pmd_str(text).unwrap(); + // No undo: the file is not in the lag convention, so the model holds + // the connections as written and the raw polarity rides in extras. + let t = &net.transformers[0]; + assert_eq!(t.windings[1].terminal_map, vec!["1", "2", "3", "4"]); + assert!(t.extras.contains_key("pmd_polarity")); + + let out = rewrite(text); + assert_eq!( + out["transformer"]["tr"]["polarity"], + serde_json::json!([1, 1]) + ); + assert_eq!( + out["transformer"]["tr"]["connections"], + serde_json::json!([[1, 2, 3], [1, 2, 3, 4]]) + ); +} + +/// The ANSI lag Dy transformer (the dss2eng output: polarity -1 with the +/// barrel rolled secondary) reproduces its input through the model. +#[test] +fn ansi_lag_transformer_round_trips() { + let text = r#"{ + "data_model": "ENGINEERING", + "transformer": {"tr": {"bus": ["b1", "b2"], + "connections": [[1, 2, 3], [2, 3, 1, 4]], + "configuration": ["DELTA", "WYE"], "polarity": [1, -1], + "rw": [0.01, 0.01], "xsc": [0.05], + "sm_nom": [500.0, 500.0], "vm_nom": [12.47, 4.16], + "tm_set": [[1.0, 1.0, 1.0], [1.0, 1.0, 1.0]], "status": "ENABLED"}} + }"#; + let net = parse_pmd_str(text).unwrap(); + // The roll is undone in the model (the dss source order) and nothing + // needs a stash: the writer's lag convention reproduces the file. + let t = &net.transformers[0]; + assert_eq!(t.windings[1].terminal_map, vec!["1", "2", "3", "4"]); + assert!(!t.extras.contains_key("pmd_polarity")); + + let out = rewrite(text); + assert_eq!( + out["transformer"]["tr"]["polarity"], + serde_json::json!([1, -1]) + ); + assert_eq!( + out["transformer"]["tr"]["connections"], + serde_json::json!([[1, 2, 3], [2, 3, 1, 4]]) + ); +} + +/// A line with inline impedance (the dss2eng output for rmatrix defined +/// lines) keeps its matrices: the reader materializes a linecode and the +/// writer re-inlines it without a linecode key. +#[test] +fn inline_line_impedance_round_trips() { + let text = r#"{ + "data_model": "ENGINEERING", + "bus": { + "b1": {"terminals": [1, 2], "grounded": [], "rg": [], "xg": [], "status": "ENABLED"}, + "b2": {"terminals": [1, 2], "grounded": [], "rg": [], "xg": [], "status": "ENABLED"} + }, + "line": {"ln1": {"f_bus": "b1", "t_bus": "b2", + "f_connections": [1, 2], "t_connections": [1, 2], "length": 304.8, + "rs": [[0.1, 0.02], [0.02, 0.1]], "xs": [[0.2, 0.05], [0.05, 0.2]], + "g_fr": [[0.0, 0.0], [0.0, 0.0]], "g_to": [[0.0, 0.0], [0.0, 0.0]], + "b_fr": [[1.7, -0.4], [-0.4, 1.7]], "b_to": [[1.7, -0.4], [-0.4, 1.7]], + "cm_ub": [400.0, 400.0], "status": "ENABLED"}} + }"#; + let net = parse_pmd_str(text).unwrap(); + let l = &net.lines[0]; + assert_eq!(l.linecode, "ln1_z"); + assert_eq!(l.extras.get("pmd_inline"), Some(&serde_json::json!(true))); + let c = net.linecode("ln1_z").unwrap(); + assert!((c.r_series[0][1] - 0.02).abs() < 1e-15); + assert_eq!(c.i_max.as_deref(), Some(&[400.0, 400.0][..])); + assert!(net.warnings.iter().any(|w| w.contains("materialized"))); + + let input: serde_json::Value = serde_json::from_str(text).unwrap(); + let out = rewrite(text); + let line = &out["line"]["ln1"]; + for key in ["rs", "xs", "g_fr", "g_to", "b_fr", "b_to", "cm_ub"] { + assert_eq!(line[key], input["line"]["ln1"][key], "{key}"); + } + assert!(line.get("linecode").is_none()); + // The materialized linecode does not leak into the linecode section. + assert!(out.get("linecode").is_none()); +} + +/// Per phase taps and custom bounds survive: the raw tm_* arrays ride in +/// extras and the writer prefers them over the engine defaults. +#[test] +#[allow(clippy::float_cmp)] +fn per_phase_taps_round_trip() { + let text = r#"{ + "data_model": "ENGINEERING", + "transformer": {"reg": {"bus": ["b1", "b2"], + "connections": [[1, 2, 3, 4], [1, 2, 3, 4]], + "configuration": ["WYE", "WYE"], "polarity": [1, 1], + "rw": [0.005, 0.005], "xsc": [0.01], + "sm_nom": [1666.0, 1666.0], "vm_nom": [2.4, 2.4], + "tm_set": [[1.0, 1.0, 1.0], [1.05625, 1.04375, 1.05]], + "tm_lb": [[0.85, 0.85, 0.85], [0.85, 0.85, 0.85]], + "tm_ub": [[1.15, 1.15, 1.15], [1.15, 1.15, 1.15]], + "tm_fix": [[true, true, true], [false, false, false]], + "tm_step": [[0.0625, 0.0625, 0.0625], [0.0625, 0.0625, 0.0625]], + "status": "ENABLED"}} + }"#; + let net = parse_pmd_str(text).unwrap(); + assert_eq!(net.transformers[0].windings[1].tap, 1.05625); + assert!(net.warnings.iter().any(|w| w.contains("per phase taps"))); + + let input: serde_json::Value = serde_json::from_str(text).unwrap(); + let out = rewrite(text); + for key in ["tm_set", "tm_lb", "tm_ub", "tm_fix", "tm_step"] { + assert_eq!( + out["transformer"]["reg"][key], input["transformer"]["reg"][key], + "{key}" + ); + } +} + +/// The settings object and files array come back verbatim from the +/// reader's stash instead of being resynthesized. +#[test] +fn settings_and_files_round_trip() { + let text = r#"{ + "data_model": "ENGINEERING", + "files": ["a.dss", "b.dss"], + "settings": {"base_frequency": 50.0, "power_scale_factor": 1000.0, + "voltage_scale_factor": 1000.0, "sbase_default": 500.0, + "vbases_default": {"slack": 7.2}}, + "bus": {"slack": {"terminals": [1], "grounded": [], "rg": [], "xg": [], "status": "ENABLED"}} + }"#; + let input: serde_json::Value = serde_json::from_str(text).unwrap(); + let out = rewrite(text); + assert_eq!(out["settings"], input["settings"]); + assert_eq!(out["files"], input["files"]); +} + +/// dss sourced settings synthesize with the dss2eng conventions: the vbase +/// is basekv over sqrt(phases) without the pu factor, sbase the basemva +/// default — matching the reference export bit for bit. +#[test] +fn dss_settings_match_the_reference() { + let net = parse_dss_file(fixture("opendss/ieee13/IEEE13Nodeckt.dss")).unwrap(); + let out: serde_json::Value = serde_json::from_str(&write_pmd_json(&net).text).unwrap(); + let reference: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(fixture("pmd/ieee13.json")).unwrap()) + .unwrap(); + // IEEE13 sets pu=1.0001; the vbase must not fold it in. + assert_eq!( + out["settings"]["vbases_default"], + reference["settings"]["vbases_default"] + ); + assert_eq!( + out["settings"]["sbase_default"], + reference["settings"]["sbase_default"] + ); +} + +/// Nonzero bus grounding impedance comes back from the extras stash +/// instead of zero vectors. +#[test] +fn grounding_impedance_round_trips() { + let text = r#"{ + "data_model": "ENGINEERING", + "bus": {"b1": {"terminals": [1, 2, 3, 4], "grounded": [4], + "rg": [5.0], "xg": [12.0], "status": "ENABLED"}} + }"#; + let out = rewrite(text); + assert_eq!(out["bus"]["b1"]["rg"], serde_json::json!([5.0])); + assert_eq!(out["bus"]["b1"]["xg"], serde_json::json!([12.0])); +} + +/// A switch's real series matrices win over the engine's 1e-7 dummy, and +/// a linecode's sm_ub survives through s_max. +#[test] +fn switch_impedance_and_sm_ub_round_trip() { + let text = r#"{ + "data_model": "ENGINEERING", + "linecode": {"lc": {"rs": [[0.1]], "xs": [[0.1]], + "g_fr": [[0.0]], "g_to": [[0.0]], "b_fr": [[0.0]], "b_to": [[0.0]], + "cm_ub": [400.0], "sm_ub": [600.0]}}, + "switch": {"sw": {"f_bus": "b1", "t_bus": "b2", + "f_connections": [1, 2], "t_connections": [1, 2], "state": "CLOSED", + "rs": [[0.03, 0.01], [0.01, 0.03]], "xs": [[0.04, 0.02], [0.02, 0.04]], + "status": "ENABLED"}} + }"#; + let input: serde_json::Value = serde_json::from_str(text).unwrap(); + let out = rewrite(text); + assert_eq!(out["switch"]["sw"]["rs"], input["switch"]["sw"]["rs"]); + assert_eq!(out["switch"]["sw"]["xs"], input["switch"]["sw"]["xs"]); + assert_eq!( + out["linecode"]["lc"]["sm_ub"], + input["linecode"]["lc"]["sm_ub"] + ); +} + +/// An inline impedance line whose `{name}_z` collides with a code in the +/// document's own linecode section. The fixed section order processes +/// "linecode" before "line" (serde_json's sorted maps would visit "line" +/// first and miss the collision), so the materialized inline code takes +/// the `_z2` suffix instead of duplicating the name. +#[test] +fn inline_linecode_collision_with_document_linecode() { + let text = r#"{ + "data_model": "ENGINEERING", + "linecode": {"foo_z": {"rs": [[0.5]], "xs": [[0.9]]}}, + "line": { + "bar": {"f_bus": "b2", "t_bus": "b3", "f_connections": [1], "t_connections": [1], + "linecode": "foo_z", "length": 1.0, "status": "ENABLED"}, + "foo": {"f_bus": "b1", "t_bus": "b2", "f_connections": [1], "t_connections": [1], + "length": 1.0, "rs": [[0.111]], "xs": [[0.222]], "status": "ENABLED"} + } + }"#; + let net = parse_pmd_str(text).unwrap(); + + let names: BTreeSet<&str> = net.linecodes.iter().map(|c| c.name.as_str()).collect(); + assert_eq!(net.linecodes.len(), 2); + assert!(names.contains("foo_z") && names.contains("foo_z2")); + + let foo = net.lines.iter().find(|l| l.name == "foo").unwrap(); + assert_eq!(foo.linecode, "foo_z2"); + assert!((net.linecode("foo_z2").unwrap().r_series[0][0] - 0.111).abs() < 1e-15); + let bar = net.lines.iter().find(|l| l.name == "bar").unwrap(); + assert_eq!(bar.linecode, "foo_z"); + assert!((net.linecode("foo_z").unwrap().r_series[0][0] - 0.5).abs() < 1e-15); + + // The BMOPF projection keys linecodes by name; line foo must carry its + // own 0.111, not the document code's 0.5. + let out = write_bmopf_json(&net); + let doc: serde_json::Value = serde_json::from_str(&out.text).unwrap(); + let code = doc["line"]["foo"]["linecode"].as_str().unwrap(); + assert_eq!(code, "foo_z2"); + assert_eq!( + doc["linecode"][code]["R_series_1_1"], + serde_json::json!(0.111) + ); +} + +/// A linecode carrying only a 10x10 xs sizes from the widest matrix, not +/// rs alone: n_conductors is 10, rs reads as zero padding, and the BMOPF +/// emission fires the double digit schema warning. Present matrices that +/// disagree in size pad with a warning naming the code. +#[test] +#[allow(clippy::float_cmp)] +fn linecode_sized_from_widest_matrix() { + let xs: Vec> = (0..10) + .map(|i| (0..10).map(|j| if i == j { 0.9 } else { 0.1 }).collect()) + .collect(); + let text = serde_json::json!({ + "data_model": "ENGINEERING", + "linecode": { + "wide": {"xs": xs}, + "ragged": {"rs": [[0.1]], "xs": [[0.2, 0.0], [0.0, 0.2]]} + } + }) + .to_string(); + let net = parse_pmd_str(&text).unwrap(); + + let c = net.linecode("wide").unwrap(); + assert_eq!(c.n_conductors, 10); + assert_eq!(c.r_series, vec![vec![0.0; 10]; 10]); + assert_eq!(c.x_series[9][9], 0.9); + + let r = net.linecode("ragged").unwrap(); + assert_eq!(r.n_conductors, 2); + assert_eq!(r.r_series, vec![vec![0.1, 0.0], vec![0.0, 0.0]]); + assert!( + net.warnings + .iter() + .any(|w| w.contains("linecode ragged: matrix sizes disagree")) + ); + + let out = write_bmopf_json(&net); + assert!( + out.warnings + .iter() + .any(|w| w.contains("10 conductors produce double digit matrix keys")) + ); +} + +#[test] +fn null_suffix_restoration() { + let text = r#"{ + "data_model": "ENGINEERING", + "bus": {"a": {"terminals": [1], "grounded": [], "rg": [], "xg": [], "status": "ENABLED"}}, + "linecode": {"c": {"rs": [[1.0]], "xs": [[1.0]], + "g_fr": [[0.0]], "g_to": [[0.0]], "b_fr": [[0.0]], "b_to": [[0.0]], + "cm_ub": [null]}}, + "voltage_source": {"src": {"bus": "a", "connections": [1], + "vm": [0.24], "va": [0.0], "status": "ENABLED"}} + }"#; + let net = parse_pmd_str(text).unwrap(); + // cm_ub null restores to +Inf under the `_ub` suffix; an infinite + // ampacity bound means no bound, so i_max is None. + assert!(net.linecodes[0].i_max.is_none()); +} diff --git a/powerio-dist/tests/raw_fixtures.rs b/powerio-dist/tests/raw_fixtures.rs new file mode 100644 index 0000000..e82edd5 --- /dev/null +++ b/powerio-dist/tests/raw_fixtures.rs @@ -0,0 +1,96 @@ +//! Raw layer over the vendored fixtures: redirects resolve, every object +//! materializes, and nothing warns unexpectedly. + +use std::path::PathBuf; + +use powerio_dist::dss::{RawDss, parse_raw_file}; + +fn fixture(rel: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../tests/data/dist") + .join(rel) +} + +fn parse(rel: &str) -> RawDss { + parse_raw_file(fixture(rel)).expect("fixture readable") +} + +fn count(raw: &RawDss, class: &str) -> usize { + raw.of_class(class).count() +} + +#[test] +fn ieee13() { + let raw = parse("opendss/ieee13/IEEE13Nodeckt.dss"); + assert_eq!(raw.warnings, Vec::::new()); + assert_eq!(raw.circuit_name.as_deref(), Some("IEEE13Nodeckt")); + assert_eq!(count(&raw, "vsource"), 1); + assert_eq!(count(&raw, "line"), 12); + assert_eq!(count(&raw, "load"), 15); + assert_eq!(count(&raw, "transformer"), 5); + assert_eq!(count(&raw, "capacitor"), 2); + assert_eq!(count(&raw, "regcontrol"), 3); + // 7 mtx codes inline plus 29 from IEEELineCodes.DSS, which is a stub + // redirecting to the shared file one directory up; reaching those 29 + // proves nested redirects resolve relative to the including file. + assert_eq!(count(&raw, "linecode"), 36); + assert_eq!(raw.buscoords.len(), 16); + + let switch = raw.find("line", "671692").expect("switch line"); + assert_eq!(switch.get("switch").unwrap().text, "y"); + let xfm1 = raw.find("transformer", "XFM1").expect("XFM1"); + assert!(xfm1.get("xhl").is_some()); +} + +#[test] +fn ieee34() { + let raw = parse("opendss/ieee34/ieee34Mod1.dss"); + assert_eq!(raw.warnings, Vec::::new()); + assert_eq!(count(&raw, "line"), 32); + assert_eq!(count(&raw, "load"), 68); + assert_eq!(count(&raw, "transformer"), 8); + assert_eq!(count(&raw, "capacitor"), 2); + assert_eq!(count(&raw, "regcontrol"), 6); +} + +#[test] +fn ieee123() { + let raw = parse("opendss/ieee123/IEEE123Master.dss"); + assert_eq!(raw.warnings, Vec::::new()); + assert_eq!(count(&raw, "line"), 126); + // Loads come from the redirected IEEE123Loads.DSS. + assert_eq!(count(&raw, "load"), 91); + assert_eq!(count(&raw, "linecode"), 29); + // Regulator transformers and controls come from IEEE123Regulators.DSS. + assert!(count(&raw, "transformer") >= 2); + assert!(count(&raw, "regcontrol") >= 1); +} + +#[test] +fn micro_cases_parse_without_warnings() { + for case in [ + "micro/xfmr_single_phase.dss", + "micro/xfmr_center_tap.dss", + "micro/xfmr_wye_delta.dss", + "micro/xfmr_delta_wye.dss", + "micro/switch.dss", + "micro/fourwire_linecode.dss", + "micro/defaults_degenerate.dss", + "micro/linecode_10x10.dss", + ] { + let raw = parse(case); + assert_eq!(raw.warnings, Vec::::new(), "{case}"); + assert_eq!(count(&raw, "vsource"), 1, "{case}"); + } +} + +#[test] +#[allow(clippy::float_cmp)] +fn ten_conductor_matrix() { + let raw = parse("micro/linecode_10x10.dss"); + let lc = raw.find("linecode", "lc10").expect("lc10"); + let rows = lc.get("rmatrix").unwrap().to_rows(None).unwrap(); + assert_eq!(rows.len(), 10); + assert_eq!(rows[9].len(), 10); + assert_eq!(rows[9][9], 0.25); +} diff --git a/powerio-dist/tools/physics_check.py b/powerio-dist/tools/physics_check.py new file mode 100644 index 0000000..f447f2f --- /dev/null +++ b/powerio-dist/tools/physics_check.py @@ -0,0 +1,166 @@ +"""Re-solve emitted .dss cases against their originals. + +Usage: + cargo test -p powerio-dist --test matrix -- --ignored emit_for_physics_check + powerio-dist/tools/physics_check.py + +For every dss sourced fixture the harness writes three regenerated cases +under target/physics (canonical, via BMOPF, via PMD). This script solves +each against the original and reports the maximum per node voltage +deviation in per unit of the original node magnitude (nodes below 1 volt +are compared absolutely, in volts). The conversion contract bound is 1e-8. +""" + +import glob +import os +import sys + +ORIGINALS = { + "opendss_ieee13_IEEE13Nodeckt": "tests/data/dist/opendss/ieee13/IEEE13Nodeckt.dss", + "opendss_ieee34_ieee34Mod1": "tests/data/dist/opendss/ieee34/ieee34Mod1.dss", + "opendss_ieee123_IEEE123Master": "tests/data/dist/opendss/ieee123/IEEE123Master.dss", + "micro_xfmr_single_phase": "tests/data/dist/micro/xfmr_single_phase.dss", + "micro_xfmr_center_tap": "tests/data/dist/micro/xfmr_center_tap.dss", + "micro_xfmr_wye_delta": "tests/data/dist/micro/xfmr_wye_delta.dss", + "micro_xfmr_delta_wye": "tests/data/dist/micro/xfmr_delta_wye.dss", + "micro_switch": "tests/data/dist/micro/switch.dss", + "micro_fourwire_linecode": "tests/data/dist/micro/fourwire_linecode.dss", + "micro_defaults_degenerate": "tests/data/dist/micro/defaults_degenerate.dss", + "micro_linecode_10x10": "tests/data/dist/micro/linecode_10x10.dss", +} + + +def solve(path): + import opendssdirect as dss + + # The converter drops voltage regulator controls by documented policy + # (RegControl becomes a fixed tap transformer). The cases run their own + # Solve while loading, so control actions must be off before that: + # inject the option right after the circuit line on both sides. + text = open(path, encoding="utf-8", errors="replace").read() + lines = text.splitlines() + injected = False + for i, line in enumerate(lines): + head = line.lower().lstrip() + # Both circuit spellings appear in the vendored masters: the writer + # emits "New Circuit.x", ieee34/ieee123 use "New object=circuit.x". + if head.startswith("new circuit") or head.startswith("new object=circuit"): + # Tight solver tolerance: the default 1e-4 pu would swamp the + # 1e-8 conversion bound with convergence noise. + lines.insert(i + 1, "Set Controlmode=OFF") + lines.insert(i + 2, "Set tolerance=0.0000000001") + injected = True + break + if not injected: + raise SystemExit(f"{path}: no circuit definition found to stage") + staged = os.path.join(os.path.dirname(os.path.abspath(path)), "_staged_" + os.path.basename(path)) + with open(staged, "w") as f: + f.write("\n".join(lines) + "\n") + + try: + dss.Text.Command("Clear") + dss.Text.Command(f'Redirect "{os.path.abspath(staged)}"') + dss.Text.Command("Set Controlmode=OFF") + dss.Text.Command("Solve") + finally: + os.unlink(staged) + if not dss.Solution.Converged(): + return None + volts = {} + for bus in dss.Circuit.AllBusNames(): + dss.Circuit.SetActiveBus(bus) + nodes = dss.Bus.Nodes() + raw = dss.Bus.Voltages() + for k, node in enumerate(nodes): + volts[f"{bus}.{node}"] = complex(raw[2 * k], raw[2 * k + 1]) + return volts + + +def compare(base, emitted): + # Deviation in per unit of the bus's own voltage scale (the largest + # node magnitude at the bus), so near zero neutral nodes compare + # against the working voltage, not their own tiny magnitude. + bus_base = {} + for node, v0 in base.items(): + bus = node.rsplit(".", 1)[0] + bus_base[bus] = max(bus_base.get(bus, 0.0), abs(v0)) + worst = 0.0 + worst_node = "" + for node, v0 in base.items(): + v1 = emitted.get(node) + if v1 is None: + return None, f"missing node {node}" + base_v = max(bus_base[node.rsplit(".", 1)[0]], 1.0) + dev = abs(v1 - v0) / base_v + if dev > worst: + worst, worst_node = dev, node + return worst, worst_node + + +# Cells whose deviation has a documented cause. Bounds above 1e-8 carry +# the reason; "loss" cells are format losses every conversion reports in +# its warnings (constant power only loads in BMOPF, the center tap +# collapse, an unsupported transformer shape, no vminpu field in the +# ENGINEERING model). The engine seeding entries cover OpenDSS treating +# written properties differently from untouched defaults (an untouched +# load seeds VBase 7200 V; writing kv=12.47 computes 12470/sqrt(3)), +# amplified near vminpu boundaries. +DOCUMENTED = { + ("micro_defaults_degenerate", "canonical"): (1e-6, "engine seeding asymmetry"), + ("micro_defaults_degenerate", "via_pmd"): (1e-6, "engine seeding asymmetry"), + ("micro_defaults_degenerate", "via_bmopf"): (1e-2, "BMOPF: constant power loads only"), + ("opendss_ieee13_IEEE13Nodeckt", "via_bmopf"): (1e-1, "BMOPF: constant power loads only"), + ("opendss_ieee34_ieee34Mod1", "via_bmopf"): (1e-1, "BMOPF: constant power loads only"), + ("opendss_ieee34_ieee34Mod1", "via_pmd"): (1e-1, "no vminpu field in ENGINEERING"), + ("opendss_ieee123_IEEE123Master", "via_bmopf"): (None, "transformer shape outside the four BMOPF subtypes"), + ("opendss_ieee123_IEEE123Master", "via_pmd"): (1e-2, "regulator bank restatement"), + ("micro_xfmr_center_tap", "via_bmopf"): (2e-1, "BMOPF: center tap collapses to two windings"), + ("micro_xfmr_single_phase", "via_pmd"): (1e-6, "engine Z1/Z0 vs MVAsc input path"), + # PMD models a dss switch as a 1e-7 ohm series element while the engine's + # switch dummy works out near 1e-3 ohm over the forced length. + ("micro_switch", "via_pmd"): (1e-5, "ENGINEERING switch impedance convention"), + ("micro_xfmr_center_tap", "via_pmd"): (1e-6, "engine Z1/Z0 vs MVAsc input path"), +} + + +def main(): + failures = 0 + for stem, original in ORIGINALS.items(): + emitted_paths = sorted(glob.glob(f"target/physics/{stem}.*.dss")) + if not emitted_paths: + # An empty glob must fail, or the gate silently checks nothing + # (forgotten emit step, renamed fixture). + print(f"{stem}: NO EMITTED CASES under target/physics (run the emit test first)") + failures += 1 + continue + base = solve(original) + if base is None: + print(f"{stem}: ORIGINAL DID NOT CONVERGE") + failures += 1 + continue + for emitted_path in emitted_paths: + kind = emitted_path.rsplit(".", 2)[-2] + bound, reason = DOCUMENTED.get((stem, kind), (1e-8, None)) + emitted = solve(emitted_path) + if emitted is None: + print(f"{stem} [{kind}]: DID NOT CONVERGE") + failures += 1 + continue + worst, where = compare(base, emitted) + if worst is None: + if bound is None: + print(f"{stem} [{kind}]: {where} (documented: {reason})") + else: + print(f"{stem} [{kind}]: {where}") + failures += 1 + elif bound is not None and worst <= bound: + note = f" (documented: {reason})" if reason else "" + print(f"{stem} [{kind}]: max deviation {worst:.3e} at {where} ok{note}") + else: + print(f"{stem} [{kind}]: max deviation {worst:.3e} at {where} FAIL") + failures += 1 + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/powerio-dist/tools/pmd/Project.toml b/powerio-dist/tools/pmd/Project.toml new file mode 100644 index 0000000..b731186 --- /dev/null +++ b/powerio-dist/tools/pmd/Project.toml @@ -0,0 +1,5 @@ +name = "PmdOracle" + +[deps] +JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" +PowerModelsDistribution = "d7431456-977f-11e9-2de3-97ff7677985e" diff --git a/powerio-dist/tools/pmd/pmdtool.jl b/powerio-dist/tools/pmd/pmdtool.jl new file mode 100644 index 0000000..17ea49a --- /dev/null +++ b/powerio-dist/tools/pmd/pmdtool.jl @@ -0,0 +1,48 @@ +# PMD oracle for powerio-dist. +# +# Usage: +# julia pmdtool.jl dss2json input.dss output.json # ENGINEERING model JSON +# julia pmdtool.jl check input.json # parse_file must accept it +# +# Set PIO_PMD_PATH to develop a local PowerModelsDistribution clone instead of +# the registered release. First run resolves the project; later runs reuse it. + +import Pkg +Pkg.activate(@__DIR__; io = devnull) + +loaded = try + @eval using PowerModelsDistribution, JSON + true +catch + false +end +if !loaded + pmd_path = get(ENV, "PIO_PMD_PATH", "") + if isempty(pmd_path) + Pkg.add("PowerModelsDistribution") + else + Pkg.develop(path = pmd_path) + end + Pkg.add("JSON") + Pkg.instantiate() + @eval using PowerModelsDistribution, JSON +end + +function main(argv) + if length(argv) == 3 && argv[1] == "dss2json" + eng = parse_file(argv[2]; kron_reduce = false) + open(argv[3], "w") do io + print_file(io, eng) + end + println("wrote $(argv[3])") + return 0 + elseif length(argv) == 2 && argv[1] == "check" + data = parse_file(argv[2]) + println("parsed: data_model=$(data["data_model"]) components=$(length(keys(data)))") + return 0 + end + println(stderr, "usage: julia pmdtool.jl dss2json in.dss out.json | check in.json") + return 2 +end + +exit(main(ARGS)) diff --git a/powerio-dist/tools/solve_dss.py b/powerio-dist/tools/solve_dss.py new file mode 100644 index 0000000..410c176 --- /dev/null +++ b/powerio-dist/tools/solve_dss.py @@ -0,0 +1,49 @@ +"""Solve a .dss case with the OpenDSS engine and print node voltages as JSON. + +Usage: solve_dss.py case.dss + +Output: {"converged": bool, "voltages": {".": [re, im]}, ...} with +voltages in volts. Run it under an interpreter that has opendssdirect +installed (the repo's .venv works). Nothing in the test suite shells out to +this script; it exists for hand checks and for regenerating the engine bus +maps pinned in tests/dss_reader.rs (print AllBusNames + Bus.Nodes per bus +with the same staging as physics_check.py). +""" + +import json +import sys + + +def solve(path): + import opendssdirect as dss + + dss.Text.Command("Clear") + dss.Text.Command(f'Redirect "{path}"') + dss.Text.Command("Solve") + + volts = {} + for bus in dss.Circuit.AllBusNames(): + dss.Circuit.SetActiveBus(bus) + nodes = dss.Bus.Nodes() + raw = dss.Bus.Voltages() # interleaved re, im per node + for k, node in enumerate(nodes): + volts[f"{bus}.{node}"] = [raw[2 * k], raw[2 * k + 1]] + + return { + "case": path, + "converged": bool(dss.Solution.Converged()), + "iterations": dss.Solution.Iterations(), + "voltages": volts, + } + + +def main(): + if len(sys.argv) != 2: + print(__doc__, file=sys.stderr) + return 2 + print(json.dumps(solve(sys.argv[1]), indent=1, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/powerio-dist/tools/verify_defaults.py b/powerio-dist/tools/verify_defaults.py new file mode 100644 index 0000000..778bf5a --- /dev/null +++ b/powerio-dist/tools/verify_defaults.py @@ -0,0 +1,45 @@ +"""Empirically dump OpenDSS constructor defaults for the phase A classes. + +Usage: verify_defaults.py + +Creates one bare object per class in a throwaway circuit and prints every +property value the engine reports. The Rust defaults table +(powerio-dist/src/dss/defaults.rs) is checked against this output; rerun it +when bumping the engine version. +""" + +import sys + + +CASES = [ + ("Vsource", "source", None), # the circuit's own source, all defaults + ("Line", "l_def", "bus1=a bus2=b"), + ("Linecode", "lc_def", ""), + ("Load", "ld_def", "bus1=a"), + ("Transformer", "t_def", "buses=(a, b)"), + ("Capacitor", "c_def", "bus1=a"), + ("Generator", "g_def", "bus1=a"), +] + + +def main(): + import opendssdirect as dss + + dss.Text.Command("Clear") + dss.Text.Command("New Circuit.defaults_probe") + for cls, name, props in CASES: + if props is not None: + dss.Text.Command(f"New {cls}.{name} {props}") + full = f"{cls}.{name}" + dss.Circuit.SetActiveElement(full) + # Properties API works for general (non circuit) elements too. + dss.Text.Command(f"? {full}.name") + print(f"== {full}") + for prop in dss.Element.AllPropertyNames(): + dss.Text.Command(f"? {full}.{prop}") + print(f" {prop} = {dss.Text.Result()}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/powerio-py/Cargo.toml b/powerio-py/Cargo.toml index 6a9363c..fa183c0 100644 --- a/powerio-py/Cargo.toml +++ b/powerio-py/Cargo.toml @@ -26,6 +26,9 @@ gridfm = ["powerio-matrix/gridfm"] [dependencies] powerio-matrix.workspace = true +# Unconditional: the wheel always ships the distribution surface (powerio.dist); +# only the C ABI gates it behind a feature. +powerio-dist.workspace = true pyo3 = { version = "0.27", features = ["abi3-py39"] } # Must track powerio-matrix's sprs so `CsMat` is the same type across the # boundary. No numpy: the matrix methods hand back COO triplets as plain Python diff --git a/powerio-py/src/lib.rs b/powerio-py/src/lib.rs index 674e994..8efac8b 100644 --- a/powerio-py/src/lib.rs +++ b/powerio-py/src/lib.rs @@ -634,6 +634,131 @@ fn convert_str(text: &str, to: &str, format: Option<&str>) -> PyResult<(String, Ok((conv.text, conv.warnings)) } +fn dist_to_pyerr(e: powerio_dist::Error) -> PyErr { + use powerio_dist::Error as E; + let msg = e.to_string(); + match e { + // OSError(errno, strerror, filename) lets CPython pick the precise + // subclass (FileNotFoundError etc.) while keeping the path on + // e.filename, which a bare io::Error conversion would drop. + E::Io { path, source } => match source.raw_os_error() { + Some(errno) => pyo3::exceptions::PyOSError::new_err((errno, source.to_string(), path)), + None => PowerIOError::new_err(msg), + }, + E::UnknownFormat(_) => PyValueError::new_err(msg), + E::Json { .. } => PowerIOParseError::new_err(msg), + _ => PowerIOError::new_err(msg), + } +} + +/// Low-level handle around a parsed multiconductor distribution network in +/// wire coordinates (OpenDSS, PMD ENGINEERING JSON, BMOPF JSON). The +/// user-facing `powerio.dist.DistCase` wraps it. +#[pyclass(name = "_DistCase", frozen)] +struct PyDistCase { + net: powerio_dist::DistNetwork, +} + +#[pymethods] +impl PyDistCase { + /// Format the case was parsed from (`dss`, `pmd-json`, `bmopf-json`). + fn source_format(&self) -> Option<&'static str> { + self.net.source_format.map(|f| f.name()) + } + + /// Parse warnings: everything the reader could not represent or had to + /// assume. + fn warnings(&self) -> Vec { + self.net.warnings.clone() + } + + fn n_buses(&self) -> usize { + self.net.buses.len() + } + + fn n_lines(&self) -> usize { + self.net.lines.len() + } + + fn n_transformers(&self) -> usize { + self.net.transformers.len() + } + + fn n_loads(&self) -> usize { + self.net.loads.len() + } + + fn n_generators(&self) -> usize { + self.net.generators.len() + } + + /// Serialize to `to` (`dss`, `pmd-json`, `bmopf-json`). Returns + /// `(text, warnings)`. Writing back to the source format echoes the + /// retained source byte for byte. + fn to_format(&self, to: &str) -> PyResult<(String, Vec)> { + let target = to + .parse::() + .map_err(dist_to_pyerr)?; + let conv = self.net.to_format(target); + Ok((conv.text, conv.warnings)) + } + + fn __repr__(&self) -> String { + format!( + "DistCase(n_buses={}, n_lines={}, n_transformers={}, n_loads={})", + self.net.buses.len(), + self.net.lines.len(), + self.net.transformers.len(), + self.net.loads.len() + ) + } +} + +/// Parse a distribution case file. The format comes from `from_` when given, +/// else from the file itself (`.dss`, or `.json` sniffed for the PMD +/// ENGINEERING `data_model` key against the BMOPF layout). +#[pyfunction] +#[pyo3(signature = (path, from_=None))] +fn dist_parse_file(path: &str, from_: Option<&str>) -> PyResult { + powerio_dist::parse_file(std::path::Path::new(path), from_) + .map(|net| PyDistCase { net }) + .map_err(dist_to_pyerr) +} + +/// Parse an in-memory distribution case of the named `format` (`dss`, +/// `pmd-json`, `bmopf-json`). +#[pyfunction] +fn dist_parse_str(text: &str, format: &str) -> PyResult { + powerio_dist::parse_str(text, format) + .map(|net| PyDistCase { net }) + .map_err(dist_to_pyerr) +} + +/// Convert a distribution case file to `to`. Returns `(text, warnings)`; the +/// warnings carry both the parse warnings and the writer's fidelity losses. +#[pyfunction] +#[pyo3(signature = (path, to, from_=None))] +fn dist_convert_file(path: &str, to: &str, from_: Option<&str>) -> PyResult<(String, Vec)> { + let to = to + .parse::() + .map_err(dist_to_pyerr)?; + let conv = + powerio_dist::convert_file(std::path::Path::new(path), to, from_).map_err(dist_to_pyerr)?; + Ok((conv.text, conv.warnings)) +} + +/// Convert an in-memory distribution case of the named `format` to `to`. +/// Returns `(text, warnings)`; the warnings carry both the parse warnings and +/// the writer's fidelity losses. +#[pyfunction] +fn dist_convert_str(text: &str, to: &str, format: &str) -> PyResult<(String, Vec)> { + let to = to + .parse::() + .map_err(dist_to_pyerr)?; + let conv = powerio_dist::convert_str(text, to, format).map_err(dist_to_pyerr)?; + Ok((conv.text, conv.warnings)) +} + /// Build a `{dir, files}` dict from an outputs directory and its written files. /// Shared by the DC OPF and gridfm write paths. Paths go through [`path_to_str`] /// (so a non-UTF8 path raises instead of being mangled). @@ -749,6 +874,11 @@ fn _powerio(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(from_json, m)?)?; m.add_function(wrap_pyfunction!(convert_file, m)?)?; m.add_function(wrap_pyfunction!(convert_str, m)?)?; + m.add_class::()?; + m.add_function(wrap_pyfunction!(dist_parse_file, m)?)?; + m.add_function(wrap_pyfunction!(dist_parse_str, m)?)?; + m.add_function(wrap_pyfunction!(dist_convert_file, m)?)?; + m.add_function(wrap_pyfunction!(dist_convert_str, m)?)?; // Whether the gridfm Parquet surface (arrow/parquet) was compiled in, so the // pure-Python layer can raise an ImportError instead of an AttributeError. m.add("_has_gridfm", cfg!(feature = "gridfm"))?; diff --git a/python/powerio/__init__.py b/python/powerio/__init__.py index 665ab0f..4d44226 100644 --- a/python/powerio/__init__.py +++ b/python/powerio/__init__.py @@ -59,6 +59,7 @@ "read_gridfm", "read_gridfm_scenarios", "GridfmRead", + "dist", "__version__", ] @@ -518,3 +519,6 @@ def read_gridfm_scenarios(dir: Any) -> "list[GridfmRead]": GridfmRead(Network(case), scen, warnings) for case, scen, warnings in _powerio.read_gridfm_scenarios(str(dir)) ] + + +from . import dist # noqa: E402 (needs Conversion defined above) diff --git a/python/powerio/__init__.pyi b/python/powerio/__init__.pyi index e914462..0dc8980 100644 --- a/python/powerio/__init__.pyi +++ b/python/powerio/__init__.pyi @@ -191,6 +191,8 @@ class Conversion(NamedTuple): # "psse"/"raw"). Kept as `str` so aliases type-check; the binding validates it. Format = str +from . import dist as dist + def parse_file(path: Any, from_: Optional[Format] = ...) -> Network: ... def parse_str(text: str, format: Format = ...) -> Network: ... def from_json(text: str) -> Network: ... diff --git a/python/powerio/_powerio.pyi b/python/powerio/_powerio.pyi index e7617a7..9f9fdf7 100644 --- a/python/powerio/_powerio.pyi +++ b/python/powerio/_powerio.pyi @@ -6,7 +6,7 @@ matrix methods return COO triplets as plain Python lists; the pure-Python ``powerio.Network`` wrapper turns them into scipy/networkx objects. """ -from typing import Any, Optional, Tuple +from typing import Any, Literal, Optional, Tuple __version__: str _has_gridfm: bool @@ -90,6 +90,17 @@ class PyCase: ) -> dict: ... def __repr__(self) -> str: ... +class _DistCase: + def source_format(self) -> Optional[str]: ... + def warnings(self) -> list[str]: ... + def n_buses(self) -> int: ... + def n_lines(self) -> int: ... + def n_transformers(self) -> int: ... + def n_loads(self) -> int: ... + def n_generators(self) -> int: ... + def to_format(self, to: str) -> Tuple[str, list[str]]: ... + def __repr__(self) -> str: ... + def parse_file(path: str, from_: Optional[str] = ...) -> PyCase: ... def parse_str(text: str, format: Optional[str] = ...) -> PyCase: ... def from_json(text: str) -> PyCase: ... @@ -99,6 +110,12 @@ def convert_file( def convert_str( text: str, to: str, format: Optional[str] = ... ) -> Tuple[str, list[str]]: ... +def dist_parse_file(path: str, from_: Optional[str] = ...) -> _DistCase: ... +def dist_parse_str(text: str, format: str) -> _DistCase: ... +def dist_convert_file( + path: str, to: str, from_: Optional[str] = ... +) -> Tuple[str, list[str]]: ... +def dist_convert_str(text: str, to: str, format: str) -> Tuple[str, list[str]]: ... # Only present when the extension was compiled with the `gridfm` cargo feature # (the released wheel is); without it, access raises AttributeError. def write_gridfm_batch( diff --git a/python/powerio/dist.py b/python/powerio/dist.py new file mode 100644 index 0000000..afb1dd7 --- /dev/null +++ b/python/powerio/dist.py @@ -0,0 +1,123 @@ +"""Multiconductor distribution cases in wire coordinates. + +Three formats, lossless three way conversion: OpenDSS ``.dss``, +PowerModelsDistribution ENGINEERING JSON (``pmd-json``), and the draft BMOPF +task force JSON (``bmopf-json``). The fidelity contract matches the +transmission surface: writing back to the source format echoes the retained +source text byte for byte, and every cross format write reports each loss in +the :class:`~powerio.Conversion` warnings instead of dropping it silently. + + import powerio.dist as dist + + case = dist.parse_file("feeder.dss") + for w in case.warnings: + print("parse:", w) + conv = case.to_format("pmd-json") +""" + +from __future__ import annotations + +from typing import Any, Optional + +from . import Conversion, _powerio + +__all__ = [ + "DistCase", + "parse_file", + "parse_str", + "convert_file", + "convert_str", +] + + +class DistCase: + """A parsed multiconductor distribution case. + + Buses carry named terminals, lines carry conductor impedance matrices, and + transformers carry per winding connections; nothing is collapsed to + positive sequence. Distinct from :class:`powerio.Network` (the + transmission model); the matrix builders do not accept it. + """ + + def __init__(self, inner) -> None: + self._inner = inner + + @property + def source_format(self) -> Optional[str]: + """Format the case was parsed from: ``dss``, ``pmd-json``, or ``bmopf-json``.""" + return self._inner.source_format() + + @property + def warnings(self) -> "list[str]": + """Parse warnings: everything the reader could not represent or had to assume.""" + return self._inner.warnings() + + @property + def n_buses(self) -> int: + return self._inner.n_buses() + + @property + def n_lines(self) -> int: + return self._inner.n_lines() + + @property + def n_transformers(self) -> int: + return self._inner.n_transformers() + + @property + def n_loads(self) -> int: + return self._inner.n_loads() + + @property + def n_generators(self) -> int: + return self._inner.n_generators() + + def to_format(self, to: str) -> Conversion: + """Serialize to ``to`` (``dss``, ``pmd-json``, ``bmopf-json``). + + Writing back to the source format echoes the retained source text byte + for byte; a cross format write regenerates from the typed model and + reports every fidelity loss in the warnings. + """ + text, warnings = self._inner.to_format(to) + return Conversion(text, warnings) + + def __repr__(self) -> str: + return self._inner.__repr__() + + +def parse_file(path: Any, from_: Optional[str] = None) -> DistCase: + """Parse a distribution case file. + + The format comes from ``from_`` when given, else from the file itself: + ``.dss`` is OpenDSS, and ``.json`` holding the ENGINEERING ``data_model`` + key is PMD JSON, otherwise BMOPF JSON. + """ + return DistCase(_powerio.dist_parse_file(str(path), from_)) + + +def parse_str(text: str, format: str) -> DistCase: + """Parse an in-memory distribution case of the named ``format``.""" + return DistCase(_powerio.dist_parse_str(text, format)) + + +def convert_file(path: Any, to: str, from_: Optional[str] = None) -> Conversion: + """Convert a distribution case file to ``to`` in one call. + + The warnings carry both the parse warnings and the writer's fidelity + losses (there is no :class:`DistCase` to query them from). + """ + text, warnings = _powerio.dist_convert_file(str(path), to, from_) + return Conversion(text, warnings) + + +def convert_str(text: str, to: str, format: str) -> Conversion: + """Convert an in-memory distribution case of the named ``format`` to ``to``. + + The signature matches :func:`powerio.convert_str`: input, target, source, + except ``format`` is required (there is no extension to infer from and no + default). The warnings carry both the parse warnings and the writer's + fidelity losses (there is no :class:`DistCase` to query them from). + """ + text, warnings = _powerio.dist_convert_str(text, to, format) + return Conversion(text, warnings) diff --git a/python/tests/test_dist.py b/python/tests/test_dist.py new file mode 100644 index 0000000..c81614e --- /dev/null +++ b/python/tests/test_dist.py @@ -0,0 +1,107 @@ +"""The powerio.dist surface: parse, echo, convert, warnings, errors.""" + +import json +from pathlib import Path + +import pytest + +import powerio +from powerio import dist + +DATA = Path(__file__).resolve().parents[2] / "tests" / "data" / "dist" +FOURWIRE = DATA / "micro" / "fourwire_linecode.dss" + + +def test_parse_file_counts_and_source_format(): + case = dist.parse_file(FOURWIRE) + assert case.source_format == "dss" + assert case.n_buses > 0 + assert case.n_lines > 0 + assert isinstance(case.warnings, list) + + +def test_same_format_write_echoes_source(): + case = dist.parse_file(FOURWIRE) + conv = case.to_format("dss") + assert conv.text == FOURWIRE.read_text() + assert conv.warnings == [] + + +def test_cross_format_writes(): + case = dist.parse_file(FOURWIRE) + pmd = case.to_format("pmd-json") + assert json.loads(pmd.text)["data_model"] == "ENGINEERING" + bmopf = case.to_format("bmopf-json") + assert "bus" in json.loads(bmopf.text) + + +def test_json_sniffing_round_trip(tmp_path): + case = dist.parse_file(FOURWIRE) + for fmt in ("pmd-json", "bmopf-json"): + text = case.to_format(fmt).text + p = tmp_path / f"case_{fmt}.json" + p.write_text(text) + again = dist.parse_file(p) + assert again.source_format == fmt + assert again.n_buses == case.n_buses + + +def test_convert_str_and_convert_file(): + text = FOURWIRE.read_text() + via_str = dist.convert_str(text, "pmd-json", "dss") + via_file = dist.convert_file(FOURWIRE, "pmd-json") + assert via_str.text == via_file.text + assert isinstance(via_str, powerio.Conversion) + + +def test_parse_warnings_surface(): + case = dist.parse_str( + "clear\n" + "new circuit.w basekv=12.47 bus1=src\n" + "new line.l1 bus1=src bus2=b2 length=1 units=furlong\n", + "dss", + ) + assert any("furlong" in w for w in case.warnings) + + +def test_unknown_format_raises_value_error(): + with pytest.raises(ValueError, match="unknown distribution format"): + dist.parse_str("clear\n", "matpower") + case = dist.parse_file(FOURWIRE) + with pytest.raises(ValueError, match="unknown distribution format"): + case.to_format("matpower") + + +def test_malformed_json_raises_parse_error(): + with pytest.raises(powerio.PowerIOParseError): + dist.parse_str("{not json", "bmopf-json") + + +def test_missing_file_raises_precise_oserror(): + # Io errors map to the precise OSError subclass with the path attached. + with pytest.raises(FileNotFoundError) as exc: + dist.parse_file(DATA / "does_not_exist.dss") + assert exc.value.filename and "does_not_exist.dss" in str(exc.value.filename) + + +def test_one_shot_convert_carries_parse_warnings(): + conv = dist.convert_str( + "clear\n" + "new circuit.w basekv=12.47 bus1=src\n" + "new line.l1 bus1=src bus2=b2 length=1 units=furlong\n", + "bmopf-json", + "dss", + ) + assert any("furlong" in w for w in conv.warnings) + + +def test_bmopf_containing_data_model_string_routes_to_bmopf(tmp_path): + # The sniff keys on a TOP LEVEL data_model key; a nested occurrence is + # not the marker. + case = dist.parse_file(FOURWIRE) + text = case.to_format("bmopf-json").text + doc = json.loads(text) + doc["bus"]["data_model"] = doc["bus"][next(iter(doc["bus"]))] + p = tmp_path / "nested_marker.json" + p.write_text(json.dumps(doc)) + assert dist.parse_file(p).source_format == "bmopf-json" diff --git a/tests/data/dist/README.md b/tests/data/dist/README.md new file mode 100644 index 0000000..bd38392 --- /dev/null +++ b/tests/data/dist/README.md @@ -0,0 +1,74 @@ +# Distribution network fixtures + +Vendored upstream cases for `powerio-dist`. Per CONTRIBUTING.md, fixture bytes +are pinned exactly as committed; do not reformat or re-encode them. + +## bmopf/ + +Draft BMOPF schema and example networks from the IEEE PES Task Force on +Benchmarking Multiconductor OPF. + +- Source: , commit + `f93bca69c59e47d08a727145277406ed3f11aa3f`, directory + `draft_schema_and_networks/`. +- `draft_bmopf_schema.json` sha256 + `b28d712e32a467ad0b339c600f51562aa049574c86cd4323ab18c4fb2e45d089` +- `example_ieee13.json` sha256 + `dec886d0fcde8bb82ef3d4567d04c08eced87a84d30a041385cac97a936dd757` +- `example_enwl_n1_f2.json` sha256 + `c635a3a2a2783b3e0e8249e65ef17f217a464955977e2223ae8f7d39b6519d6c` + +## opendss/ + +IEEE 13, 34, and 123 bus test feeders from the official OpenDSS distribution, +vendored via the dss-extensions mirror of the EPRI test case tree. The +feeders are the IEEE PES Distribution Test Feeder Working Group cases as +distributed with OpenDSS; they are vendored unchanged under the distribution +license in `opendss/License.txt`, with no relicensing. + +- Source: , commit + `3b208397160213cae4a9e2d0a7d1aa3528ce26e1`, directory + `Version8/Distrib/IEEETestCases/`. +- `ieee13/`: `IEEE13Nodeckt.dss`, `IEEELineCodes.DSS`, `IEEE13Node_BusXY.csv` + (from `13Bus/`). +- `ieee34/`: `ieee34Mod1.dss`, `IEEELineCodes.DSS` (from `34Bus/`; the + upstream Run wrapper is not vendored, it references a coordinates csv and + show/plot commands outside the converter's scope). +- `ieee123/`: `IEEE123Master.dss`, `IEEE123Loads.DSS`, + `IEEE123Regulators.DSS`, `IEEELineCodes.DSS` (from `123Bus/`). +- `IEEELineCodes.DSS` at this directory's root is the shared linecode file + the per-feeder 30 byte stubs redirect to (`redirect ../IEEELineCodes.DSS`), + mirroring the upstream layout. + +## micro/ + +Original cases written for this crate (no upstream source). Each isolates one +construct: the four BMOPF transformer subtypes (`xfmr_single_phase`, +`xfmr_center_tap`, `xfmr_wye_delta`, `xfmr_delta_wye`), switch state with +SwtControl (`switch`), an explicit four wire linecode (`fourwire_linecode`), +OpenDSS constructor defaults (`defaults_degenerate`), and a ten conductor +linecode with double digit matrix indices (`linecode_10x10`). All eight solve +in OpenDSS (opendssdirect 0.9.4); `powerio-dist/tools/solve_dss.py` reproduces +the reference solutions. + +## pmd/ + +ENGINEERING model JSON generated from the fixtures above with +PowerModelsDistribution v0.16.0 (lanl-ansi/PowerModelsDistribution.jl, +commit 87dc18b0) via the committed oracle: + + julia powerio-dist/tools/pmd/pmdtool.jl dss2json \ + tests/data/dist/opendss/ieee13/IEEE13Nodeckt.dss \ + tests/data/dist/pmd/ieee13.json + +`fourwire_linecode.json` comes from `micro/fourwire_linecode.dss` the same +way. PMD's `parse_file` ran with `kron_reduce=false`; `print_file` wrote the +dict. Regenerate with the same command when bumping the PMD version. + +## Licensing + +Each directory carries its own license file next to the data it covers: +`bmopf/License.md`, `opendss/License.txt` (the BSD 3 clause notice retained +from the upstream distribution), `micro/License.md` (CC BY 4.0), and +`pmd/License.md` (derivatives carry their sources' licenses). The repository +code license does not apply to vendored data. diff --git a/tests/data/dist/bmopf/License.md b/tests/data/dist/bmopf/License.md new file mode 100644 index 0000000..a822ffe --- /dev/null +++ b/tests/data/dist/bmopf/License.md @@ -0,0 +1,21 @@ +# License + +The schema and example networks in this directory are vendored byte exact +from at the commit pinned in +`../README.md`. That repository carries no license file at the pinned +commit; this directory tracks whatever license the IEEE PES Task Force on +Benchmarking Multiconductor OPF publishes for it, and the files here are +vendored for interoperability testing with the task force's knowledge +(see the review thread on eigenergy/powerio#82). + +Underlying data lineage: + +- `example_enwl_n1_f2.json` derives from the four wire low voltage network + dataset: Heidarihaei, Rahmatollah; Geth, Frederik; & Claeys, Sander + (2024), v1, CSIRO Data Collection, , + released under the Creative Commons Attribution 4.0 International + license. The derivative carries the same license. +- `example_ieee13.json` derives from the IEEE 13 node test feeder of the + IEEE PES Distribution Test Feeder Working Group, as distributed with + OpenDSS (see `../opendss/License.txt` for the distribution license of + the `.dss` source). The task force has noted it may replace this example. diff --git a/tests/data/dist/bmopf/draft_bmopf_schema.json b/tests/data/dist/bmopf/draft_bmopf_schema.json new file mode 100644 index 0000000..ef8d8e7 --- /dev/null +++ b/tests/data/dist/bmopf/draft_bmopf_schema.json @@ -0,0 +1,545 @@ +{ + "$schema":"https://json-schema.org/draft/2020-12/schema", + "$id":"https://github.com/frederikgeth/OpenDSSToPMDJSON", + "title":"**Draft** BMOPF OPF test case schema", + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "Test case name" + }, + "bus": { + "type": "object", + "description": "Collection of buses", + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "properties": { + "terminal_names": { + "type": "array", + "items": { + "type":"string" + }, + "description": "Ordered array of terminal names for this bus" + }, + "perfectly_grounded_terminals": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of terminal names which are perfectly grounded" + }, + "v_min": { + "$ref": "#/$defs/nonnegative_number", + "description": "Minimum per-phase voltage magnitude at bus [V]" + }, + "v_max": { + "$ref": "#/$defs/nonnegative_number", + "description": "Maximum per-phase voltage magnitude at bus [V]" + }, + "vpn_min": { + "type": "array", + "items": { + "$ref": "#/$defs/nonnegative_number" + }, + "description": "Minimum phase-neutral voltage(s) [V]" + }, + "vpn_max": { + "type": "array", + "items": { + "$ref": "#/$defs/nonnegative_number" + }, + "description": "Maximum phase-neutral voltage(s) [V]" + }, + "vpp_min": { + "type": "array", + "items": { + "$ref": "#/$defs/nonnegative_number" + }, + "description": "Minimum phase-phase voltage magnitude [V]" + }, + "vpp_max": { + "type": "array", + "items": { + "$ref": "#/$defs/nonnegative_number" + }, + "description": "Maximum phase-phase voltage magnitude [V]" + }, + "vsym_min": { + "type": "array", + "items": { + "$ref": "#/$defs/nonnegative_number" + }, + "description": "Minimum symmetric component voltage values [V]" + }, + "vsym_max": { + "type": "array", + "items": { + "$ref": "#/$defs/nonnegative_number" + }, + "description": "Maxmimum symmetric component voltage values [V]" + } + }, + "required": ["terminal_names"] + } + }, + "line": { + "type": "object", + "description": "Line objects", + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "properties": { + "length": { + "$ref": "#/$defs/nonnegative_number", + "description": "Line length [m]" + }, + "linecode": { + "type": "string", + "description": "Linecode of line" + }, + "terminal_map_to": { + "$ref": "#/$defs/terminal_map_type" + }, + "terminal_map_from": { + "$ref": "#/$defs/terminal_map_type" + }, + "bus_from": { + "type": "string", + "description": "'from' bus for the element" + }, + "bus_to": { + "type": "string", + "description": "'to' bus for the element" + } + }, + "required": [ + "length", + "linecode", + "bus_from", + "bus_to", + "terminal_map_from", + "terminal_map_to" + ] + } + }, + "voltage_source": { + "type": "object", + "description": "Voltage source element", + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "properties": { + "v_magnitude": { + "type": "array", + "description": "Voltage magnitude of each terminal of the voltage source [V]", + "items": { + "$ref": "#/$defs/nonnegative_number" + } + }, + "v_angle": { + "type": "array", + "description": "Voltage angle of each terminal of the source [radians]", + "items": { + "type": "number" + } + }, + "terminal_map": { + "$ref": "#/$defs/terminal_map_type" + }, + "bus": { + "$ref": "#/$defs/bus_type" + } + }, + "required": [ + "v_angle", + "v_magnitude", + "bus", + "terminal_map" + ] + } + }, + "shunt": { + "type": "object", + "description": "Passive shunt elements (e.g., for capacitors, grounding impedance)", + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "properties":{ + "bus": { + "$ref": "#/$defs/bus_type" + }, + "terminal_map": { + "$ref": "#/$defs/terminal_map_type" + } + }, + "patternProperties": { + "^G_\\d_\\d": { + "type": "number", + "description": "Array elements of the conductance [S]" + }, + "^B_\\d_\\d": { + "type": "number", + "description": "Array elements of the susceptance [S]" + } + }, + "required": [ + "bus", + "terminal_map", + "G_1_1", + "B_1_1" + ] + } + }, + "load": { + "type": "object", + "description": "Load elements", + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "properties": { + "p_nom": { + "type": "array", + "items": { + "type": "number", + "description": "Nominal load active power [W]" + } + }, + "q_nom": { + "type": "array", + "items": { + "type": "number", + "description": "Nominal load reactive power [var]" + } + }, + "bus": { + "$ref": "#/$defs/bus_type" + }, + "configuration": { + "$ref": "#/$defs/configuration_type" + }, + "terminal_map": { + "$ref": "#/$defs/terminal_map_type" + } + }, + "required": [ + "p_nom", + "q_nom", + "bus", + "configuration", + "terminal_map" + ] + } + }, + "generator": { + "type": "object", + "description": "Generator elements", + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "properties": { + "p_min": { + "type": "array", + "items": { + "type": "number", + "description": "Minimum active power value [W]" + } + }, + "p_max": { + "type": "array", + "items": { + "type": "number", + "description": "Maximum active power value [W]" + } + }, + "q_min": { + "type": "array", + "items": { + "type": "number", + "description": "Minimum reactive power value [var]" + } + }, + "q_max": { + "type": "array", + "items": { + "type": "number", + "description": "Maximum reactive power value [var]" + } + }, + "cost": { + "type": "number", + "description": "Generating cost if active power [$/kWh]" + }, + "bus": { + "$ref": "#/$defs/bus_type" + }, + "configuration": { + "$ref": "#/$defs/configuration_type" + }, + "terminal_map": { + "$ref": "#/$defs/terminal_map_type" + } + }, + "required": [ + "cost", + "bus", + "configuration", + "terminal_map" + ] + } + }, + "linecode": { + "type": "object", + "description": "Linecodes", + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "properties": { + "i_max": { + "type": "array", + "description": "Array of the maximum current that can pass into each conductor at either end of a line [A]", + "items": { + "$ref": "#/$defs/nonnegative_number" + } + }, + "s_max": { + "type": "array", + "description": "Array of the maximum apparent power that can pass into each conductor at either end of a line [VA]", + "items": { + "$ref": "#/$defs/nonnegative_number" + } + } + }, + "patternProperties":{ + "^R_series_\\d_\\d": { + "type": "number", + "description": "Element of the series resistance matrix [Ohm/m]" + }, + "^X_series_\\d_\\d": { + "type": "number", + "description": "Element of the series reactance matrix [Ohm/m]" + }, + "^G_from_\\d_\\d": { + "type": "number", + "description": "Element of the shunt conductance matrix [S/m]" + }, + "^G_to_\\d_\\d": { + "type": "number", + "description": "Element of the shunt conductance matrix [S/m]" + }, + "^B_from_\\d_\\d": { + "type": "number", + "description": "Element of the shunt susceptance matrix [S/m]" + }, + "^B_to_\\d_\\d": { + "type": "number", + "description": "Element of the shunt susceptance matrix [S/m]" + } + }, + "required": [ + "R_series_1_1", + "X_series_1_1" + ] + } + }, + "switch": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "properties": { + "bus_from": { + "type": "string", + "description": "'from' bus for the element" + }, + "bus_to": { + "type": "string", + "description": "'to' bus for the element" + }, + "terminal_map_to": { + "$ref": "#/$defs/terminal_map_type" + }, + "terminal_map_from": { + "$ref": "#/$defs/terminal_map_type" + }, + "open_switch": { + "type": "boolean", + "description": "Indicator of switch state, true if switch is open (nonconducting)" + }, + "i_max": { + "type": "array", + "description": "Maximum permitted current passing through each conductor of the switch [A]" + } + }, + "required": [ + "bus_from", + "bus_to", + "open_switch", + "terminal_map_from", + "terminal_map_to" + ] + } + }, + "transformer": { + "type": "object", + "properties": { + "single_phase": { + "$ref": "#/$defs/single_phase_or_center_tap_transformer", + "description": "Single phase transfomrer object" + }, + "center_tap": { + "$ref": "#/$defs/single_phase_or_center_tap_transformer", + "description": "Center tap transfomrer object, with a single-phase winding on the 'from' side and split winding on the 'to' side." + }, + "wye_delta": { + "$ref": "#/$defs/three_phase_transformer", + "description": "Wye-to-Delta transformer object, with series impedance defined on the wye-side" + }, + "delta_wye": { + "$ref": "#/$defs/three_phase_transformer", + "description": "Delta-to-Wye transformer object, with series impedance defined on the wye-side" + } + } + } + }, + "required": [ + "bus", + "voltage_source" + ], + "$defs": { + "configuration_type": { + "type": "string", + "description": "Element configuration, as WYE, DELTA, or SINGLE_PHASE", + "enum": ["WYE", "DELTA", "SINGLE_PHASE"] + }, + "terminal_map_type":{ + "type": "array", + "description": "Mapping of terminals of an element to the corresponding terminals of its corresponding bus", + "items": { + "type": "string" + } + }, + "bus_type":{ + "type": "string", + "description": "bus the element is connected to" + }, + "nonnegative_number":{ + "type": "number", + "minimum": 0, + "description": "Non-negative number" + }, + "single_phase_or_center_tap_transformer":{ + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "properties": { + "s_rating": { + "$ref": "#/$defs/nonnegative_number", + "description": "Transformer power base [VA]" + }, + "r_series_from": { + "$ref": "#/$defs/nonnegative_number", + "description": "Transformer series resistance on the 'from' side [Ohm]" + }, + "x_series_from": { + "$ref": "#/$defs/nonnegative_number", + "description": "Transformer series reactance on the 'from' side [Ohm]" + }, + "r_series_to": { + "$ref": "#/$defs/nonnegative_number", + "description": "Transformer series resistance on the 'to' side [Ohm]" + }, + "x_series_to": { + "$ref": "#/$defs/nonnegative_number", + "description": "Transformer series reactance on the 'to' side [Ohm]" + }, + "bus_from": { + "type": "string", + "description": "'from' bus for the element" + }, + "bus_to": { + "type": "string", + "description": "'to' bus for the element" + }, + "terminal_map_to": { + "$ref": "#/$defs/terminal_map_type" + }, + "terminal_map_from": { + "$ref": "#/$defs/terminal_map_type" + }, + "v_ref_to": { + "$ref": "#/$defs/nonnegative_number", + "description": "Nominal voltage of the 'to'-side winding [V]" + }, + "v_ref_from": { + "$ref": "#/$defs/nonnegative_number", + "description": "Nominal voltage of the 'from'-side winding [V]" + } + }, + "required": [ + "bus_from", + "bus_to", + "terminal_map_from", + "terminal_map_to", + "s_rating", + "v_ref_from", + "v_ref_to" + ] + } + }, + "three_phase_transformer": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "properties": { + "s_rating": { + "$ref": "#/$defs/nonnegative_number", + "description": "Transformer power base [VA]" + }, + "r_series": { + "$ref": "#/$defs/nonnegative_number", + "description": "Series resistance on the wye-connected winding [Ohm]" + }, + "x_series": { + "$ref": "#/$defs/nonnegative_number", + "description": "Series reactance on the wye-conencted winding [Ohm]" + }, + "bus_from": { + "type": "string", + "description": "'from' bus for the element" + }, + "bus_to": { + "type": "string", + "description": "'to' bus for the element" + }, + "terminal_map_to": { + "$ref": "#/$defs/terminal_map_type" + }, + "terminal_map_from": { + "$ref": "#/$defs/terminal_map_type" + }, + "v_ref_to": { + "$ref": "#/$defs/nonnegative_number", + "description": "Nominal phase-to-phase voltage of the 'to'-side winding [V]" + }, + "v_ref_from": { + "$ref": "#/$defs/nonnegative_number", + "description": "Nominal phase-to-phase voltage of the 'from'-side winding [V]" + } + }, + "required": [ + "bus_from", + "bus_to", + "terminal_map_from", + "terminal_map_to", + "s_rating", + "v_ref_from", + "v_ref_to" + ] + } + } + } +} \ No newline at end of file diff --git a/tests/data/dist/bmopf/example_enwl_n1_f2.json b/tests/data/dist/bmopf/example_enwl_n1_f2.json new file mode 100644 index 0000000..ae0e62e --- /dev/null +++ b/tests/data/dist/bmopf/example_enwl_n1_f2.json @@ -0,0 +1,22062 @@ +{ + "bus": { + "306": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "407": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "1": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "54": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "101": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "371": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "41": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "464": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "65": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "475": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "447": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "362": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "335": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "505": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "491": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "326": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "299": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "168": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "159": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "403": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "228": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "332": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "190": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "227": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "270": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "476": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "223": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "453": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "467": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "88": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "297": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "26": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "289": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "250": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "230": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "77": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "24": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "449": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "394": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "258": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "328": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "387": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "416": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "204": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "160": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "23": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "450": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "149": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "359": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "184": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "59": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "43": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "302": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "253": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "122": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "175": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "415": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "39": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "143": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "112": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "372": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "34": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "501": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "293": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "421": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "137": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "55": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "323": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "17": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "243": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "318": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "9": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "172": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "333": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "363": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "192": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "292": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "20": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "350": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "12": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "252": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "357": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "417": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "341": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "462": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "426": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "14": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "167": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "127": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "123": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "96": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "456": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "177": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "254": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "300": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "257": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "19": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "179": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "242": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "396": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "495": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "260": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "239": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "35": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "423": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "317": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "197": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "131": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "488": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "401": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "316": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "463": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "365": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "276": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "458": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "494": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "263": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "21": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "83": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "244": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "45": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "295": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "139": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "181": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "386": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "368": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "436": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "440": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "85": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "413": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "30": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "3": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "309": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "105": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "400": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "81": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "480": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "482": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "296": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "392": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "75": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "27": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "503": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "50": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "460": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "162": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "63": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "303": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "92": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "422": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "208": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "214": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "120": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "224": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "87": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "117": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "255": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "499": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "178": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "89": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "496": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "176": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "275": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "182": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "225": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "195": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "286": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "249": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "485": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "442": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "161": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "202": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "346": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "389": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "459": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "465": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "146": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "142": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "219": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "256": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "291": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "203": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "80": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "308": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "360": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "432": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "113": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "110": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "418": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "492": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "445": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "322": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "431": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "269": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "157": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "57": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "165": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "231": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "384": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "327": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "173": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "284": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "200": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "171": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "233": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "428": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "345": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "273": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "502": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "478": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "319": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "312": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "130": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "61": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "247": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "15": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "67": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "108": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "344": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "500": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "100": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "457": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "385": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "46": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "251": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "444": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "170": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "151": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "248": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "68": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "56": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "147": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "454": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "452": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "76": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "186": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "438": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "342": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "180": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "135": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "262": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "48": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "355": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "103": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "393": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "408": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "109": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "32": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "320": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "405": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "217": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "334": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "264": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "2": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "183": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "155": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "53": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "51": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "106": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "435": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "489": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "111": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "141": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "287": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "93": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "213": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "278": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "10": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "340": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "474": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "356": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "265": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "215": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "305": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "443": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "424": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "154": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "358": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "321": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "49": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "218": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "5": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "196": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "62": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "90": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "234": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "446": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "404": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "205": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "237": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "201": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "311": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "390": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "315": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "448": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "461": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "298": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "366": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "419": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "164": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "380": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "86": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "126": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "152": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "71": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "226": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "37": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "399": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "469": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "245": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "487": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "266": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "268": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "6": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "441": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "125": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "98": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "473": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "272": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "379": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "174": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "471": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "493": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "187": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "7": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "361": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "261": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "194": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "140": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "486": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "397": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "337": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "107": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "102": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "69": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "354": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "282": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "97": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "4": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "221": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "235": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "212": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "210": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "369": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "13": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "136": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "211": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "134": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "240": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "133": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "329": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "148": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "373": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "193": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "118": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "283": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "246": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "466": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "38": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "375": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "188": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "378": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "425": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "116": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "199": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "307": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "sourcebus": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "411": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "66": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "376": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "241": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "301": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "468": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "455": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "18": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "132": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "29": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "477": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "470": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "78": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "388": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "382": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "367": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "74": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "402": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "119": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "236": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "42": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "33": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "28": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "381": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "52": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "439": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "347": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "121": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "451": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "497": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "504": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "290": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "115": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "395": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "409": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "351": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "314": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "163": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "339": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "481": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "281": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "58": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "25": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "114": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "374": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "166": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "31": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "274": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "370": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "206": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "279": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "364": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "280": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "313": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "484": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "44": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "412": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "479": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "429": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "169": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "189": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "94": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "430": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "150": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "352": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "259": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "288": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "129": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "99": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "207": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "47": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "330": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "73": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "437": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "82": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "285": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "310": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "406": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "79": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "433": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "377": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "216": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "84": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "325": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "104": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "124": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "238": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "410": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "185": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "267": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "70": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "427": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "209": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "349": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "391": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "191": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "304": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "8": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "338": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "198": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "64": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "222": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "343": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "91": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "60": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "158": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "156": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "498": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "229": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "348": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "420": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "144": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "220": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "22": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "11": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "271": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "383": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "434": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "483": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "324": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "277": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "398": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "490": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "16": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "331": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "40": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "72": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "472": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "128": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "145": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "36": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "138": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "336": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "414": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "95": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "294": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "353": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "232": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + }, + "153": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ], + "vpn_min": [ + 207.0, + 207.0, + 207.0 + ], + "vpn_max": [ + 253.00000000000003, + 253.00000000000003, + 253.00000000000003 + ] + } + }, + "load": { + "load24": { + "p_nom": [ + 1956.0 + ], + "q_nom": [ + 642.9061097298564 + ], + "bus": "440", + "terminal_map": [ + "2", + "4" + ], + "configuration": "SINGLE_PHASE" + }, + "load26": { + "p_nom": [ + 336.0 + ], + "q_nom": [ + 110.43785934009804 + ], + "bus": "455", + "terminal_map": [ + "2", + "4" + ], + "configuration": "SINGLE_PHASE" + }, + "load2": { + "p_nom": [ + 306.0 + ], + "q_nom": [ + 100.57733618473213 + ], + "bus": "181", + "terminal_map": [ + "1", + "4" + ], + "configuration": "SINGLE_PHASE" + }, + "load11": { + "p_nom": [ + 330.0 + ], + "q_nom": [ + 108.46575470902486 + ], + "bus": "279", + "terminal_map": [ + "1", + "4" + ], + "configuration": "SINGLE_PHASE" + }, + "load3": { + "p_nom": [ + 2940.0 + ], + "q_nom": [ + 966.3312692258578 + ], + "bus": "196", + "terminal_map": [ + "3", + "4" + ], + "configuration": "SINGLE_PHASE" + }, + "load8": { + "p_nom": [ + 294.00000000000006 + ], + "q_nom": [ + 96.6331269225858 + ], + "bus": "240", + "terminal_map": [ + "1", + "4" + ], + "configuration": "SINGLE_PHASE" + }, + "load31": { + "p_nom": [ + 282.0 + ], + "q_nom": [ + 92.68891766043943 + ], + "bus": "479", + "terminal_map": [ + "3", + "4" + ], + "configuration": "SINGLE_PHASE" + }, + "load13": { + "p_nom": [ + 246.0 + ], + "q_nom": [ + 80.85628987400034 + ], + "bus": "317", + "terminal_map": [ + "2", + "4" + ], + "configuration": "SINGLE_PHASE" + }, + "load21": { + "p_nom": [ + 336.0 + ], + "q_nom": [ + 110.43785934009804 + ], + "bus": "364", + "terminal_map": [ + "1", + "4" + ], + "configuration": "SINGLE_PHASE" + }, + "load12": { + "p_nom": [ + 330.0 + ], + "q_nom": [ + 108.46575470902486 + ], + "bus": "284", + "terminal_map": [ + "1", + "4" + ], + "configuration": "SINGLE_PHASE" + }, + "load22": { + "p_nom": [ + 966.0 + ], + "q_nom": [ + 317.5088456027819 + ], + "bus": "399", + "terminal_map": [ + "2", + "4" + ], + "configuration": "SINGLE_PHASE" + }, + "load29": { + "p_nom": [ + 348.00000000000006 + ], + "q_nom": [ + 114.38206860224442 + ], + "bus": "469", + "terminal_map": [ + "1", + "4" + ], + "configuration": "SINGLE_PHASE" + }, + "load10": { + "p_nom": [ + 270.0 + ], + "q_nom": [ + 88.74470839829307 + ], + "bus": "278", + "terminal_map": [ + "1", + "4" + ], + "configuration": "SINGLE_PHASE" + }, + "load28": { + "p_nom": [ + 276.0 + ], + "q_nom": [ + 90.71681302936625 + ], + "bus": "461", + "terminal_map": [ + "2", + "4" + ], + "configuration": "SINGLE_PHASE" + }, + "load7": { + "p_nom": [ + 2058.0000000000005 + ], + "q_nom": [ + 676.4318884581005 + ], + "bus": "232", + "terminal_map": [ + "3", + "4" + ], + "configuration": "SINGLE_PHASE" + }, + "load15": { + "p_nom": [ + 828.0000000000001 + ], + "q_nom": [ + 272.1504390880988 + ], + "bus": "327", + "terminal_map": [ + "1", + "4" + ], + "configuration": "SINGLE_PHASE" + }, + "load27": { + "p_nom": [ + 270.0 + ], + "q_nom": [ + 88.74470839829307 + ], + "bus": "456", + "terminal_map": [ + "2", + "4" + ], + "configuration": "SINGLE_PHASE" + }, + "load18": { + "p_nom": [ + 324.0 + ], + "q_nom": [ + 106.49365007795168 + ], + "bus": "356", + "terminal_map": [ + "2", + "4" + ], + "configuration": "SINGLE_PHASE" + }, + "load5": { + "p_nom": [ + 1332.0 + ], + "q_nom": [ + 437.8072280982458 + ], + "bus": "222", + "terminal_map": [ + "2", + "4" + ], + "configuration": "SINGLE_PHASE" + }, + "load1": { + "p_nom": [ + 1968.0 + ], + "q_nom": [ + 646.8503189920027 + ], + "bus": "115", + "terminal_map": [ + "1", + "4" + ], + "configuration": "SINGLE_PHASE" + }, + "load4": { + "p_nom": [ + 324.0 + ], + "q_nom": [ + 106.49365007795168 + ], + "bus": "219", + "terminal_map": [ + "1", + "4" + ], + "configuration": "SINGLE_PHASE" + }, + "load14": { + "p_nom": [ + 2040.0 + ], + "q_nom": [ + 670.515574564881 + ], + "bus": "326", + "terminal_map": [ + "1", + "4" + ], + "configuration": "SINGLE_PHASE" + }, + "load20": { + "p_nom": [ + 264.0 + ], + "q_nom": [ + 86.77260376721989 + ], + "bus": "362", + "terminal_map": [ + "3", + "4" + ], + "configuration": "SINGLE_PHASE" + }, + "load23": { + "p_nom": [ + 246.0 + ], + "q_nom": [ + 80.85628987400034 + ], + "bus": "415", + "terminal_map": [ + "3", + "4" + ], + "configuration": "SINGLE_PHASE" + }, + "load19": { + "p_nom": [ + 1944.0 + ], + "q_nom": [ + 638.96190046771 + ], + "bus": "361", + "terminal_map": [ + "3", + "4" + ], + "configuration": "SINGLE_PHASE" + }, + "load17": { + "p_nom": [ + 288.00000000000006 + ], + "q_nom": [ + 94.66102229151262 + ], + "bus": "345", + "terminal_map": [ + "3", + "4" + ], + "configuration": "SINGLE_PHASE" + }, + "load6": { + "p_nom": [ + 3054.0000000000005 + ], + "q_nom": [ + 1003.8012572162482 + ], + "bus": "223", + "terminal_map": [ + "2", + "4" + ], + "configuration": "SINGLE_PHASE" + }, + "load9": { + "p_nom": [ + 312.0 + ], + "q_nom": [ + 102.54944081580531 + ], + "bus": "271", + "terminal_map": [ + "2", + "4" + ], + "configuration": "SINGLE_PHASE" + }, + "load16": { + "p_nom": [ + 113.99999999999999 + ], + "q_nom": [ + 37.46998799039041 + ], + "bus": "332", + "terminal_map": [ + "3", + "4" + ], + "configuration": "SINGLE_PHASE" + }, + "load25": { + "p_nom": [ + 294.00000000000006 + ], + "q_nom": [ + 96.6331269225858 + ], + "bus": "443", + "terminal_map": [ + "3", + "4" + ], + "configuration": "SINGLE_PHASE" + }, + "load30": { + "p_nom": [ + 264.0 + ], + "q_nom": [ + 86.77260376721989 + ], + "bus": "477", + "terminal_map": [ + "2", + "4" + ], + "configuration": "SINGLE_PHASE" + } + }, + "linecode": { + "lc8": { + "i_max": [ + 419.0, + 419.0, + 419.0, + 419.0 + ], + "G_from_1_1": 0.0, + "G_to_1_1": 0.0, + "B_from_1_1": 0.0, + "B_to_1_1": 0.0, + "R_series_1_1": 0.0002586466621944817, + "X_series_1_1": 0.0001160280674700721, + "G_from_1_2": 0.0, + "G_to_1_2": 0.0, + "B_from_1_2": 0.0, + "B_to_1_2": 0.0, + "R_series_1_2": 0.00015199066219448172, + "X_series_1_2": 5.543006747007207e-05, + "G_from_1_3": 0.0, + "G_to_1_3": 0.0, + "B_from_1_3": 0.0, + "B_to_1_3": 0.0, + "R_series_1_3": 0.00015199066219448172, + "X_series_1_3": 3.3463067470072106e-05, + "G_from_1_4": 0.0, + "G_to_1_4": 0.0, + "B_from_1_4": 0.0, + "B_to_1_4": 0.0, + "R_series_1_4": 0.00015199066219448172, + "X_series_1_4": 5.543006747007207e-05, + "G_from_2_1": 0.0, + "G_to_2_1": 0.0, + "B_from_2_1": 0.0, + "B_to_2_1": 0.0, + "R_series_2_1": 0.00015199066219448172, + "X_series_2_1": 5.543006747007207e-05, + "G_from_2_2": 0.0, + "G_to_2_2": 0.0, + "B_from_2_2": 0.0, + "B_to_2_2": 0.0, + "R_series_2_2": 0.0002586466621944817, + "X_series_2_2": 0.0001160280674700721, + "G_from_2_3": 0.0, + "G_to_2_3": 0.0, + "B_from_2_3": 0.0, + "B_to_2_3": 0.0, + "R_series_2_3": 0.00015199066219448172, + "X_series_2_3": 5.543006747007207e-05, + "G_from_2_4": 0.0, + "G_to_2_4": 0.0, + "B_from_2_4": 0.0, + "B_to_2_4": 0.0, + "R_series_2_4": 0.00015199066219448172, + "X_series_2_4": 3.3463067470072106e-05, + "G_from_3_1": 0.0, + "G_to_3_1": 0.0, + "B_from_3_1": 0.0, + "B_to_3_1": 0.0, + "R_series_3_1": 0.00015199066219448172, + "X_series_3_1": 3.3463067470072106e-05, + "G_from_3_2": 0.0, + "G_to_3_2": 0.0, + "B_from_3_2": 0.0, + "B_to_3_2": 0.0, + "R_series_3_2": 0.00015199066219448172, + "X_series_3_2": 5.543006747007207e-05, + "G_from_3_3": 0.0, + "G_to_3_3": 0.0, + "B_from_3_3": 0.0, + "B_to_3_3": 0.0, + "R_series_3_3": 0.0002586466621944817, + "X_series_3_3": 0.0001160280674700721, + "G_from_3_4": 0.0, + "G_to_3_4": 0.0, + "B_from_3_4": 0.0, + "B_to_3_4": 0.0, + "R_series_3_4": 0.00015199066219448172, + "X_series_3_4": 5.543006747007207e-05, + "G_from_4_1": 0.0, + "G_to_4_1": 0.0, + "B_from_4_1": 0.0, + "B_to_4_1": 0.0, + "R_series_4_1": 0.00015199066219448172, + "X_series_4_1": 5.543006747007207e-05, + "G_from_4_2": 0.0, + "G_to_4_2": 0.0, + "B_from_4_2": 0.0, + "B_to_4_2": 0.0, + "R_series_4_2": 0.00015199066219448172, + "X_series_4_2": 3.3463067470072106e-05, + "G_from_4_3": 0.0, + "G_to_4_3": 0.0, + "B_from_4_3": 0.0, + "B_to_4_3": 0.0, + "R_series_4_3": 0.00015199066219448172, + "X_series_4_3": 5.543006747007207e-05, + "G_from_4_4": 0.0, + "G_to_4_4": 0.0, + "B_from_4_4": 0.0, + "B_to_4_4": 0.0, + "R_series_4_4": 0.0002586466621944817, + "X_series_4_4": 0.0001160280674700721 + }, + "lc3": { + "i_max": [ + 129.0, + 129.0, + 129.0, + 129.0 + ], + "G_from_1_1": 0.0, + "G_to_1_1": 0.0, + "B_from_1_1": 0.0, + "B_to_1_1": 0.0, + "R_series_1_1": 0.0007710898110209621, + "X_series_1_1": 0.0007078895114426767, + "G_from_1_2": 0.0, + "G_to_1_2": 0.0, + "B_from_1_2": 0.0, + "B_to_1_2": 0.0, + "R_series_1_2": 0.0002465698110209621, + "X_series_1_2": 0.0006383435114426766, + "G_from_1_3": 0.0, + "G_to_1_3": 0.0, + "B_from_1_3": 0.0, + "B_to_1_3": 0.0, + "R_series_1_3": 0.0002460118110209621, + "X_series_1_3": 0.0006141895114426766, + "G_from_1_4": 0.0, + "G_to_1_4": 0.0, + "B_from_1_4": 0.0, + "B_to_1_4": 0.0, + "R_series_1_4": 0.0002465668110209621, + "X_series_1_4": 0.0006383375114426767, + "G_from_2_1": 0.0, + "G_to_2_1": 0.0, + "B_from_2_1": 0.0, + "B_to_2_1": 0.0, + "R_series_2_1": 0.0002465698110209621, + "X_series_2_1": 0.0006383435114426766, + "G_from_2_2": 0.0, + "G_to_2_2": 0.0, + "B_from_2_2": 0.0, + "B_to_2_2": 0.0, + "R_series_2_2": 0.0007711468110209622, + "X_series_2_2": 0.0007080435114426766, + "G_from_2_3": 0.0, + "G_to_2_3": 0.0, + "B_from_2_3": 0.0, + "B_to_2_3": 0.0, + "R_series_2_3": 0.00024662181102096207, + "X_series_2_3": 0.0006384825114426766, + "G_from_2_4": 0.0, + "G_to_2_4": 0.0, + "B_from_2_4": 0.0, + "B_to_2_4": 0.0, + "R_series_2_4": 0.00024601281102096213, + "X_series_2_4": 0.0006141955114426767, + "G_from_3_1": 0.0, + "G_to_3_1": 0.0, + "B_from_3_1": 0.0, + "B_to_3_1": 0.0, + "R_series_3_1": 0.0002460118110209621, + "X_series_3_1": 0.0006141895114426766, + "G_from_3_2": 0.0, + "G_to_3_2": 0.0, + "B_from_3_2": 0.0, + "B_to_3_2": 0.0, + "R_series_3_2": 0.00024662181102096207, + "X_series_3_2": 0.0006384825114426766, + "G_from_3_3": 0.0, + "G_to_3_3": 0.0, + "B_from_3_3": 0.0, + "B_to_3_3": 0.0, + "R_series_3_3": 0.0007711938110209621, + "X_series_3_3": 0.0007081705114426767, + "G_from_3_4": 0.0, + "G_to_3_4": 0.0, + "B_from_3_4": 0.0, + "B_to_3_4": 0.0, + "R_series_3_4": 0.00024661881102096213, + "X_series_3_4": 0.0006384705114426767, + "G_from_4_1": 0.0, + "G_to_4_1": 0.0, + "B_from_4_1": 0.0, + "B_to_4_1": 0.0, + "R_series_4_1": 0.0002465668110209621, + "X_series_4_1": 0.0006383375114426767, + "G_from_4_2": 0.0, + "G_to_4_2": 0.0, + "B_from_4_2": 0.0, + "B_to_4_2": 0.0, + "R_series_4_2": 0.00024601281102096213, + "X_series_4_2": 0.0006141955114426767, + "G_from_4_3": 0.0, + "G_to_4_3": 0.0, + "B_from_4_3": 0.0, + "B_to_4_3": 0.0, + "R_series_4_3": 0.00024661881102096213, + "X_series_4_3": 0.0006384705114426767, + "G_from_4_4": 0.0, + "G_to_4_4": 0.0, + "B_from_4_4": 0.0, + "B_to_4_4": 0.0, + "R_series_4_4": 0.0007711408110209621, + "X_series_4_4": 0.0007080275114426766 + }, + "lc1": { + "i_max": [ + 75.0, + 75.0, + 75.0, + 75.0 + ], + "G_from_1_1": 0.0, + "G_to_1_1": 0.0, + "B_from_1_1": 0.0, + "B_to_1_1": 0.0, + "R_series_1_1": 0.0013480250575502448, + "X_series_1_1": 0.0007814251531485135, + "G_from_1_2": 0.0, + "G_to_1_2": 0.0, + "B_from_1_2": 0.0, + "B_to_1_2": 0.0, + "R_series_1_2": 0.00019622205755024492, + "X_series_1_2": 0.0007013971531485135, + "G_from_1_3": 0.0, + "G_to_1_3": 0.0, + "B_from_1_3": 0.0, + "B_to_1_3": 0.0, + "R_series_1_3": 0.0001960530575502449, + "X_series_1_3": 0.0006774311531485134, + "G_from_1_4": 0.0, + "G_to_1_4": 0.0, + "B_from_1_4": 0.0, + "B_to_1_4": 0.0, + "R_series_1_4": 0.0001962290575502449, + "X_series_1_4": 0.0007014261531485134, + "G_from_2_1": 0.0, + "G_to_2_1": 0.0, + "B_from_2_1": 0.0, + "B_to_2_1": 0.0, + "R_series_2_1": 0.00019622205755024492, + "X_series_2_1": 0.0007013971531485135, + "G_from_2_2": 0.0, + "G_to_2_2": 0.0, + "B_from_2_2": 0.0, + "B_to_2_2": 0.0, + "R_series_2_2": 0.0013480120575502447, + "X_series_2_2": 0.0007813811531485134, + "G_from_2_3": 0.0, + "G_to_2_3": 0.0, + "B_from_2_3": 0.0, + "B_to_2_3": 0.0, + "R_series_2_3": 0.0001962160575502449, + "X_series_2_3": 0.0007013701531485135, + "G_from_2_4": 0.0, + "G_to_2_4": 0.0, + "B_from_2_4": 0.0, + "B_to_2_4": 0.0, + "R_series_2_4": 0.0001960530575502449, + "X_series_2_4": 0.0006774341531485135, + "G_from_3_1": 0.0, + "G_to_3_1": 0.0, + "B_from_3_1": 0.0, + "B_to_3_1": 0.0, + "R_series_3_1": 0.0001960530575502449, + "X_series_3_1": 0.0006774311531485134, + "G_from_3_2": 0.0, + "G_to_3_2": 0.0, + "B_from_3_2": 0.0, + "B_to_3_2": 0.0, + "R_series_3_2": 0.0001962160575502449, + "X_series_3_2": 0.0007013701531485135, + "G_from_3_3": 0.0, + "G_to_3_3": 0.0, + "B_from_3_3": 0.0, + "B_to_3_3": 0.0, + "R_series_3_3": 0.0013480130575502449, + "X_series_3_3": 0.0007813721531485135, + "G_from_3_4": 0.0, + "G_to_3_4": 0.0, + "B_from_3_4": 0.0, + "B_to_3_4": 0.0, + "R_series_3_4": 0.0001962230575502449, + "X_series_3_4": 0.0007014021531485135, + "G_from_4_1": 0.0, + "G_to_4_1": 0.0, + "B_from_4_1": 0.0, + "B_to_4_1": 0.0, + "R_series_4_1": 0.0001962290575502449, + "X_series_4_1": 0.0007014261531485134, + "G_from_4_2": 0.0, + "G_to_4_2": 0.0, + "B_from_4_2": 0.0, + "B_to_4_2": 0.0, + "R_series_4_2": 0.0001960530575502449, + "X_series_4_2": 0.0006774341531485135, + "G_from_4_3": 0.0, + "G_to_4_3": 0.0, + "B_from_4_3": 0.0, + "B_to_4_3": 0.0, + "R_series_4_3": 0.0001962230575502449, + "X_series_4_3": 0.0007014021531485135, + "G_from_4_4": 0.0, + "G_to_4_4": 0.0, + "B_from_4_4": 0.0, + "B_to_4_4": 0.0, + "R_series_4_4": 0.0013480270575502449, + "X_series_4_4": 0.0007814381531485135 + }, + "lc6": { + "i_max": [ + 58.0, + 58.0, + 58.0, + 58.0 + ], + "G_from_1_1": 0.0, + "G_to_1_1": 0.0, + "B_from_1_1": 0.0, + "B_to_1_1": 0.0, + "R_series_1_1": 0.002275110332819354, + "X_series_1_1": 0.001046570816522671, + "G_from_1_2": 0.0, + "G_to_1_2": 0.0, + "B_from_1_2": 0.0, + "B_to_1_2": 0.0, + "R_series_1_2": 0.0011225363328193543, + "X_series_1_2": 0.000962102816522671, + "G_from_1_3": 0.0, + "G_to_1_3": 0.0, + "B_from_1_3": 0.0, + "B_to_1_3": 0.0, + "R_series_1_3": 0.0011217103328193543, + "X_series_1_3": 0.000932128816522671, + "G_from_1_4": 0.0, + "G_to_1_4": 0.0, + "B_from_1_4": 0.0, + "B_to_1_4": 0.0, + "R_series_1_4": 0.0011225373328193542, + "X_series_1_4": 0.000962103816522671, + "G_from_2_1": 0.0, + "G_to_2_1": 0.0, + "B_from_2_1": 0.0, + "B_to_2_1": 0.0, + "R_series_2_1": 0.0011225363328193543, + "X_series_2_1": 0.000962102816522671, + "G_from_2_2": 0.0, + "G_to_2_2": 0.0, + "B_from_2_2": 0.0, + "B_to_2_2": 0.0, + "R_series_2_2": 0.002275110332819354, + "X_series_2_2": 0.001046571816522671, + "G_from_2_3": 0.0, + "G_to_2_3": 0.0, + "B_from_2_3": 0.0, + "B_to_2_3": 0.0, + "R_series_2_3": 0.0011225363328193543, + "X_series_2_3": 0.000962102816522671, + "G_from_2_4": 0.0, + "G_to_2_4": 0.0, + "B_from_2_4": 0.0, + "B_to_2_4": 0.0, + "R_series_2_4": 0.0011217103328193543, + "X_series_2_4": 0.000932128816522671, + "G_from_3_1": 0.0, + "G_to_3_1": 0.0, + "B_from_3_1": 0.0, + "B_to_3_1": 0.0, + "R_series_3_1": 0.0011217103328193543, + "X_series_3_1": 0.000932128816522671, + "G_from_3_2": 0.0, + "G_to_3_2": 0.0, + "B_from_3_2": 0.0, + "B_to_3_2": 0.0, + "R_series_3_2": 0.0011225363328193543, + "X_series_3_2": 0.000962102816522671, + "G_from_3_3": 0.0, + "G_to_3_3": 0.0, + "B_from_3_3": 0.0, + "B_to_3_3": 0.0, + "R_series_3_3": 0.0022751113328193543, + "X_series_3_3": 0.001046573816522671, + "G_from_3_4": 0.0, + "G_to_3_4": 0.0, + "B_from_3_4": 0.0, + "B_to_3_4": 0.0, + "R_series_3_4": 0.0011225373328193542, + "X_series_3_4": 0.000962102816522671, + "G_from_4_1": 0.0, + "G_to_4_1": 0.0, + "B_from_4_1": 0.0, + "B_to_4_1": 0.0, + "R_series_4_1": 0.0011225373328193542, + "X_series_4_1": 0.000962103816522671, + "G_from_4_2": 0.0, + "G_to_4_2": 0.0, + "B_from_4_2": 0.0, + "B_to_4_2": 0.0, + "R_series_4_2": 0.0011217103328193543, + "X_series_4_2": 0.000932128816522671, + "G_from_4_3": 0.0, + "G_to_4_3": 0.0, + "B_from_4_3": 0.0, + "B_to_4_3": 0.0, + "R_series_4_3": 0.0011225373328193542, + "X_series_4_3": 0.000962102816522671, + "G_from_4_4": 0.0, + "G_to_4_4": 0.0, + "B_from_4_4": 0.0, + "B_to_4_4": 0.0, + "R_series_4_4": 0.0022751113328193543, + "X_series_4_4": 0.0010465768165226709 + }, + "lc2": { + "i_max": [ + 107.0, + 107.0, + 107.0, + 107.0 + ], + "G_from_1_1": 0.0, + "G_to_1_1": 0.0, + "B_from_1_1": 0.0, + "B_to_1_1": 0.0, + "R_series_1_1": 0.0009592895406852649, + "X_series_1_1": 0.000739751735790135, + "G_from_1_2": 0.0, + "G_to_1_2": 0.0, + "B_from_1_2": 0.0, + "B_to_1_2": 0.0, + "R_series_1_2": 0.00023168654068526475, + "X_series_1_2": 0.000667380735790135, + "G_from_1_3": 0.0, + "G_to_1_3": 0.0, + "B_from_1_3": 0.0, + "B_to_1_3": 0.0, + "R_series_1_3": 0.00023129854068526474, + "X_series_1_3": 0.000643018735790135, + "G_from_1_4": 0.0, + "G_to_1_4": 0.0, + "B_from_1_4": 0.0, + "B_to_1_4": 0.0, + "R_series_1_4": 0.00023169454068526474, + "X_series_1_4": 0.0006674057357901349, + "G_from_2_1": 0.0, + "G_to_2_1": 0.0, + "B_from_2_1": 0.0, + "B_to_2_1": 0.0, + "R_series_2_1": 0.00023168654068526475, + "X_series_2_1": 0.000667380735790135, + "G_from_2_2": 0.0, + "G_to_2_2": 0.0, + "B_from_2_2": 0.0, + "B_to_2_2": 0.0, + "R_series_2_2": 0.0009592795406852647, + "X_series_2_2": 0.0007397187357901349, + "G_from_2_3": 0.0, + "G_to_2_3": 0.0, + "B_from_2_3": 0.0, + "B_to_2_3": 0.0, + "R_series_2_3": 0.00023168654068526475, + "X_series_2_3": 0.0006673797357901349, + "G_from_2_4": 0.0, + "G_to_2_4": 0.0, + "B_from_2_4": 0.0, + "B_to_2_4": 0.0, + "R_series_2_4": 0.00023129654068526477, + "X_series_2_4": 0.000643011735790135, + "G_from_3_1": 0.0, + "G_to_3_1": 0.0, + "B_from_3_1": 0.0, + "B_to_3_1": 0.0, + "R_series_3_1": 0.00023129854068526474, + "X_series_3_1": 0.000643018735790135, + "G_from_3_2": 0.0, + "G_to_3_2": 0.0, + "B_from_3_2": 0.0, + "B_to_3_2": 0.0, + "R_series_3_2": 0.00023168654068526475, + "X_series_3_2": 0.0006673797357901349, + "G_from_3_3": 0.0, + "G_to_3_3": 0.0, + "B_from_3_3": 0.0, + "B_to_3_3": 0.0, + "R_series_3_3": 0.0009592905406852648, + "X_series_3_3": 0.0007397647357901349, + "G_from_3_4": 0.0, + "G_to_3_4": 0.0, + "B_from_3_4": 0.0, + "B_to_3_4": 0.0, + "R_series_3_4": 0.00023169554068526473, + "X_series_3_4": 0.0006674047357901349, + "G_from_4_1": 0.0, + "G_to_4_1": 0.0, + "B_from_4_1": 0.0, + "B_to_4_1": 0.0, + "R_series_4_1": 0.00023169454068526474, + "X_series_4_1": 0.0006674057357901349, + "G_from_4_2": 0.0, + "G_to_4_2": 0.0, + "B_from_4_2": 0.0, + "B_to_4_2": 0.0, + "R_series_4_2": 0.00023129654068526477, + "X_series_4_2": 0.000643011735790135, + "G_from_4_3": 0.0, + "G_to_4_3": 0.0, + "B_from_4_3": 0.0, + "B_to_4_3": 0.0, + "R_series_4_3": 0.00023169554068526473, + "X_series_4_3": 0.0006674047357901349, + "G_from_4_4": 0.0, + "G_to_4_4": 0.0, + "B_from_4_4": 0.0, + "B_to_4_4": 0.0, + "R_series_4_4": 0.0009592965406852648, + "X_series_4_4": 0.000739778735790135 + }, + "lc4": { + "i_max": [ + 167.0, + 167.0, + 167.0, + 167.0 + ], + "G_from_1_1": 0.0, + "G_to_1_1": 0.0, + "B_from_1_1": 0.0, + "B_to_1_1": 0.0, + "R_series_1_1": 0.0005643923515458874, + "X_series_1_1": 0.0005883153159279138, + "G_from_1_2": 0.0, + "G_to_1_2": 0.0, + "B_from_1_2": 0.0, + "B_to_1_2": 0.0, + "R_series_1_2": 0.0002945514767230663, + "X_series_1_2": 0.0005209253296470171, + "G_from_1_3": 0.0, + "G_to_1_3": 0.0, + "B_from_1_3": 0.0, + "B_to_1_3": 0.0, + "R_series_1_3": 0.0002933874767230663, + "X_series_1_3": 0.0004969653296470171, + "G_from_1_4": 0.0, + "G_to_1_4": 0.0, + "B_from_1_4": 0.0, + "B_to_1_4": 0.0, + "R_series_1_4": 0.0002945233515458874, + "X_series_1_4": 0.0005208813159279138, + "G_from_2_1": 0.0, + "G_to_2_1": 0.0, + "B_from_2_1": 0.0, + "B_to_2_1": 0.0, + "R_series_2_1": 0.0002945514767230663, + "X_series_2_1": 0.0005209253296470171, + "G_from_2_2": 0.0, + "G_to_2_2": 0.0, + "B_from_2_2": 0.0, + "B_to_2_2": 0.0, + "R_series_2_2": 0.0005644766019371148, + "X_series_2_2": 0.0005884633433307894, + "G_from_2_3": 0.0, + "G_to_2_3": 0.0, + "B_from_2_3": 0.0, + "B_to_2_3": 0.0, + "R_series_2_3": 0.0002946046019371148, + "X_series_2_3": 0.0005210123433307894, + "G_from_2_4": 0.0, + "G_to_2_4": 0.0, + "B_from_2_4": 0.0, + "B_to_2_4": 0.0, + "R_series_2_4": 0.0002933894767230663, + "X_series_2_4": 0.0004969663296470171, + "G_from_3_1": 0.0, + "G_to_3_1": 0.0, + "B_from_3_1": 0.0, + "B_to_3_1": 0.0, + "R_series_3_1": 0.0002933874767230663, + "X_series_3_1": 0.0004969653296470171, + "G_from_3_2": 0.0, + "G_to_3_2": 0.0, + "B_from_3_2": 0.0, + "B_to_3_2": 0.0, + "R_series_3_2": 0.0002946046019371148, + "X_series_3_2": 0.0005210123433307894, + "G_from_3_3": 0.0, + "G_to_3_3": 0.0, + "B_from_3_3": 0.0, + "B_to_3_3": 0.0, + "R_series_3_3": 0.0005644996019371148, + "X_series_3_3": 0.0005885053433307894, + "G_from_3_4": 0.0, + "G_to_3_4": 0.0, + "B_from_3_4": 0.0, + "B_to_3_4": 0.0, + "R_series_3_4": 0.0002945764767230663, + "X_series_3_4": 0.0005209683296470172, + "G_from_4_1": 0.0, + "G_to_4_1": 0.0, + "B_from_4_1": 0.0, + "B_to_4_1": 0.0, + "R_series_4_1": 0.0002945233515458874, + "X_series_4_1": 0.0005208813159279138, + "G_from_4_2": 0.0, + "G_to_4_2": 0.0, + "B_from_4_2": 0.0, + "B_to_4_2": 0.0, + "R_series_4_2": 0.0002933894767230663, + "X_series_4_2": 0.0004969663296470171, + "G_from_4_3": 0.0, + "G_to_4_3": 0.0, + "B_from_4_3": 0.0, + "B_to_4_3": 0.0, + "R_series_4_3": 0.0002945764767230663, + "X_series_4_3": 0.0005209683296470172, + "G_from_4_4": 0.0, + "G_to_4_4": 0.0, + "B_from_4_4": 0.0, + "B_to_4_4": 0.0, + "R_series_4_4": 0.0005644203515458874, + "X_series_4_4": 0.0005883693159279138 + } + }, + "generator": { + "4": { + "p_min": [ + 0.0, + 0.0, + 0.0 + ], + "p_max": [ + 4000.0, + 4000.0, + 4000.0 + ], + "cost": 0.001, + "bus": "461", + "terminal_map": [ + "1", + "2", + "3", + "4" + ], + "configuration": "WYE" + }, + "1": { + "p_min": [ + 0.0, + 0.0, + 0.0 + ], + "p_max": [ + 4000.0, + 4000.0, + 4000.0 + ], + "cost": 0.001, + "bus": "181", + "terminal_map": [ + "1", + "2", + "3", + "4" + ], + "configuration": "WYE" + }, + "5": { + "p_min": [ + 0.0, + 0.0, + 0.0 + ], + "p_max": [ + 4000.0, + 4000.0, + 4000.0 + ], + "cost": 0.001, + "bus": "361", + "terminal_map": [ + "1", + "2", + "3", + "4" + ], + "configuration": "WYE" + }, + "2": { + "p_min": [ + 0.0, + 0.0, + 0.0 + ], + "p_max": [ + 4000.0, + 4000.0, + 4000.0 + ], + "cost": 0.001, + "bus": "317", + "terminal_map": [ + "1", + "2", + "3", + "4" + ], + "configuration": "WYE" + }, + "6": { + "p_min": [ + 0.0, + 0.0, + 0.0 + ], + "p_max": [ + 4000.0, + 4000.0, + 4000.0 + ], + "cost": 0.001, + "bus": "345", + "terminal_map": [ + "1", + "2", + "3", + "4" + ], + "configuration": "WYE" + }, + "7": { + "p_min": [ + 0.0, + 0.0, + 0.0 + ], + "p_max": [ + 4000.0, + 4000.0, + 4000.0 + ], + "cost": 0.001, + "bus": "477", + "terminal_map": [ + "1", + "2", + "3", + "4" + ], + "configuration": "WYE" + }, + "3": { + "p_min": [ + 0.0, + 0.0, + 0.0 + ], + "p_max": [ + 4000.0, + 4000.0, + 4000.0 + ], + "cost": 0.001, + "bus": "469", + "terminal_map": [ + "1", + "2", + "3", + "4" + ], + "configuration": "WYE" + } + }, + "line": { + "line68": { + "length": 0.642, + "linecode": "lc4", + "bus_from": "67", + "bus_to": "69", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line48": { + "length": 0.47164, + "linecode": "lc3", + "bus_from": "48", + "bus_to": "49", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line120": { + "length": 0.11322, + "linecode": "lc2", + "bus_from": "114", + "bus_to": "121", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line330": { + "length": 0.8244, + "linecode": "lc3", + "bus_from": "323", + "bus_to": "331", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line75": { + "length": 6.4225, + "linecode": "lc4", + "bus_from": "73", + "bus_to": "76", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line348": { + "length": 0.45839, + "linecode": "lc3", + "bus_from": "343", + "bus_to": "349", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line179": { + "length": 0.3844, + "linecode": "lc6", + "bus_from": "174", + "bus_to": "180", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "_virtual.line167+line172+line159+line200+line146+line155+line178+line192+line185+line151+line163": { + "length": 18.50579, + "linecode": "lc3", + "bus_from": "201", + "bus_to": "143", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line140": { + "length": 0.11332, + "linecode": "lc2", + "bus_from": "137", + "bus_to": "141", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line191": { + "length": 4.6277, + "linecode": "lc8", + "bus_from": "185", + "bus_to": "192", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line448": { + "length": 1.9092, + "linecode": "lc3", + "bus_from": "444", + "bus_to": "449", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line391": { + "length": 0.21019, + "linecode": "lc6", + "bus_from": "387", + "bus_to": "392", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line38": { + "length": 7.942, + "linecode": "lc3", + "bus_from": "38", + "bus_to": "39", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line415": { + "length": 0.35116, + "linecode": "lc6", + "bus_from": "411", + "bus_to": "416", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "_virtual.line392+line398": { + "length": 10.1249, + "linecode": "lc6", + "bus_from": "399", + "bus_to": "388", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line276": { + "length": 0.40594, + "linecode": "lc6", + "bus_from": "266", + "bus_to": "277", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "_virtual.line329+line335+line353+line313+line297+line305+line265+line321+line341+line288+line276+line254+line243+line347": { + "length": 10.724600000000002, + "linecode": "lc6", + "bus_from": "234", + "bus_to": "354", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line443": { + "length": 1.9396, + "linecode": "lc3", + "bus_from": "437", + "bus_to": "444", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line90": { + "length": 0.60701, + "linecode": "lc4", + "bus_from": "89", + "bus_to": "91", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line329": { + "length": 0.15988, + "linecode": "lc6", + "bus_from": "322", + "bus_to": "330", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line300": { + "length": 0.13647, + "linecode": "lc6", + "bus_from": "293", + "bus_to": "301", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line117": { + "length": 0.1651, + "linecode": "lc3", + "bus_from": "111", + "bus_to": "118", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line374": { + "length": 0.54603, + "linecode": "lc3", + "bus_from": "369", + "bus_to": "375", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "_virtual.line402+line399+line393": { + "length": 12.2146, + "linecode": "lc3", + "bus_from": "388", + "bus_to": "403", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line382": { + "length": 3.4195, + "linecode": "lc3", + "bus_from": "378", + "bus_to": "383", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line470": { + "length": 1.5627, + "linecode": "lc3", + "bus_from": "470", + "bus_to": "471", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line93": { + "length": 1.0592, + "linecode": "lc8", + "bus_from": "90", + "bus_to": "94", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line131": { + "length": 0.11385, + "linecode": "lc2", + "bus_from": "127", + "bus_to": "132", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line396": { + "length": 0.9712, + "linecode": "lc3", + "bus_from": "391", + "bus_to": "397", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line156": { + "length": 1.0863, + "linecode": "lc3", + "bus_from": "153", + "bus_to": "157", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line502": { + "length": 0.153, + "linecode": "lc6", + "bus_from": "502", + "bus_to": "503", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line207": { + "length": 0.31369, + "linecode": "lc8", + "bus_from": "200", + "bus_to": "208", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line234": { + "length": 0.20951, + "linecode": "lc6", + "bus_from": "224", + "bus_to": "235", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line137": { + "length": 7.6821, + "linecode": "lc8", + "bus_from": "133", + "bus_to": "138", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line397": { + "length": 0.44931, + "linecode": "lc6", + "bus_from": "392", + "bus_to": "398", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line368": { + "length": 0.64086, + "linecode": "lc3", + "bus_from": "363", + "bus_to": "369", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line84": { + "length": 0.391, + "linecode": "lc8", + "bus_from": "83", + "bus_to": "85", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line466": { + "length": 0.51779, + "linecode": "lc6", + "bus_from": "465", + "bus_to": "467", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line226": { + "length": 0.34979, + "linecode": "lc6", + "bus_from": "215", + "bus_to": "227", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line115": { + "length": 3.71, + "linecode": "lc8", + "bus_from": "109", + "bus_to": "116", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line399": { + "length": 2.6562, + "linecode": "lc3", + "bus_from": "394", + "bus_to": "400", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line469": { + "length": 2.2639, + "linecode": "lc3", + "bus_from": "468", + "bus_to": "470", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "_virtual.line215+line249+line259+line281+line238+line269+line291+line227": { + "length": 2.8610599999999997, + "linecode": "lc6", + "bus_from": "292", + "bus_to": "207", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line311": { + "length": 0.45283, + "linecode": "lc8", + "bus_from": "304", + "bus_to": "312", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line481": { + "length": 0.15473, + "linecode": "lc6", + "bus_from": "481", + "bus_to": "482", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line360": { + "length": 1.0967, + "linecode": "lc6", + "bus_from": "354", + "bus_to": "361", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line471": { + "length": 2.6474, + "linecode": "lc3", + "bus_from": "471", + "bus_to": "472", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line190": { + "length": 1.4826, + "linecode": "lc3", + "bus_from": "184", + "bus_to": "191", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line283": { + "length": 5.0187, + "linecode": "lc6", + "bus_from": "273", + "bus_to": "284", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line183": { + "length": 0.8107, + "linecode": "lc3", + "bus_from": "177", + "bus_to": "184", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line229": { + "length": 2.803, + "linecode": "lc3", + "bus_from": "217", + "bus_to": "230", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line242": { + "length": 0.21836, + "linecode": "lc3", + "bus_from": "233", + "bus_to": "243", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line42": { + "length": 0.45549, + "linecode": "lc3", + "bus_from": "42", + "bus_to": "43", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line354": { + "length": 0.40447, + "linecode": "lc3", + "bus_from": "349", + "bus_to": "355", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line429": { + "length": 3.6289, + "linecode": "lc3", + "bus_from": "425", + "bus_to": "430", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line56": { + "length": 0.23492, + "linecode": "lc3", + "bus_from": "56", + "bus_to": "57", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "_virtual.line308+line282+line300+line292+line316+line271+line261": { + "length": 7.668329999999999, + "linecode": "lc6", + "bus_from": "317", + "bus_to": "251", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "_virtual.line188+line195": { + "length": 12.5884, + "linecode": "lc6", + "bus_from": "196", + "bus_to": "183", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line298": { + "length": 0.71512, + "linecode": "lc3", + "bus_from": "290", + "bus_to": "299", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line428": { + "length": 0.12143, + "linecode": "lc6", + "bus_from": "424", + "bus_to": "429", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line492": { + "length": 0.29785, + "linecode": "lc6", + "bus_from": "492", + "bus_to": "493", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line377": { + "length": 2.4825, + "linecode": "lc3", + "bus_from": "373", + "bus_to": "378", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line416": { + "length": 2.446, + "linecode": "lc3", + "bus_from": "412", + "bus_to": "417", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line272": { + "length": 8.2289, + "linecode": "lc6", + "bus_from": "263", + "bus_to": "273", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line332": { + "length": 0.19393, + "linecode": "lc6", + "bus_from": "325", + "bus_to": "333", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line214": { + "length": 0.26681, + "linecode": "lc6", + "bus_from": "206", + "bus_to": "215", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line497": { + "length": 0.10794, + "linecode": "lc6", + "bus_from": "497", + "bus_to": "498", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line249": { + "length": 0.26417, + "linecode": "lc6", + "bus_from": "239", + "bus_to": "250", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line2": { + "length": 0.14701, + "linecode": "lc3", + "bus_from": "2", + "bus_to": "3", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line83": { + "length": 0.40328, + "linecode": "lc4", + "bus_from": "82", + "bus_to": "84", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line319": { + "length": 0.86868, + "linecode": "lc3", + "bus_from": "311", + "bus_to": "320", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line247": { + "length": 0.73831, + "linecode": "lc3", + "bus_from": "237", + "bus_to": "248", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line168": { + "length": 7.3715, + "linecode": "lc6", + "bus_from": "165", + "bus_to": "169", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line33": { + "length": 0.27631, + "linecode": "lc3", + "bus_from": "33", + "bus_to": "34", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line13": { + "length": 7.3529, + "linecode": "lc3", + "bus_from": "13", + "bus_to": "14", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line157": { + "length": 0.35328, + "linecode": "lc3", + "bus_from": "154", + "bus_to": "158", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line206": { + "length": 1.582, + "linecode": "lc3", + "bus_from": "199", + "bus_to": "207", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line213": { + "length": 1.2534, + "linecode": "lc3", + "bus_from": "205", + "bus_to": "214", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "_virtual.line5+line2+line7+line3+line8+line14+line10+line13+line9+line4+line6+line1+line12+line0+line11+line15": { + "length": 38.167005, + "linecode": "lc3", + "bus_from": "16", + "bus_to": "sourcebus", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line440": { + "length": 0.10977, + "linecode": "lc8", + "bus_from": "434", + "bus_to": "441", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line47": { + "length": 0.54623, + "linecode": "lc3", + "bus_from": "47", + "bus_to": "48", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line202": { + "length": 0.41155, + "linecode": "lc6", + "bus_from": "195", + "bus_to": "203", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "_virtual.line369+line396+line375+line364+line390+line380+line385": { + "length": 2.01402, + "linecode": "lc3", + "bus_from": "358", + "bus_to": "397", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line212": { + "length": 0.23119, + "linecode": "lc6", + "bus_from": "204", + "bus_to": "213", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line496": { + "length": 0.044385, + "linecode": "lc6", + "bus_from": "496", + "bus_to": "497", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line122": { + "length": 0.24502, + "linecode": "lc4", + "bus_from": "117", + "bus_to": "123", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line271": { + "length": 0.29, + "linecode": "lc6", + "bus_from": "262", + "bus_to": "272", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line350": { + "length": 13.1234, + "linecode": "lc3", + "bus_from": "344", + "bus_to": "351", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line423": { + "length": 0.10107, + "linecode": "lc6", + "bus_from": "420", + "bus_to": "424", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line479": { + "length": 0.10141, + "linecode": "lc6", + "bus_from": "478", + "bus_to": "480", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line308": { + "length": 0.80025, + "linecode": "lc6", + "bus_from": "301", + "bus_to": "309", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "_virtual.line414+line409": { + "length": 6.4894099999999995, + "linecode": "lc6", + "bus_from": "406", + "bus_to": "415", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line220": { + "length": 3.3509, + "linecode": "lc3", + "bus_from": "210", + "bus_to": "221", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line232": { + "length": 0.21513, + "linecode": "lc3", + "bus_from": "221", + "bus_to": "233", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line336": { + "length": 6.1793, + "linecode": "lc3", + "bus_from": "331", + "bus_to": "337", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line295": { + "length": 0.64896, + "linecode": "lc8", + "bus_from": "287", + "bus_to": "296", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line364": { + "length": 0.19818, + "linecode": "lc3", + "bus_from": "358", + "bus_to": "365", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line155": { + "length": 1.0219, + "linecode": "lc3", + "bus_from": "152", + "bus_to": "156", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line250": { + "length": 6.7186, + "linecode": "lc6", + "bus_from": "241", + "bus_to": "251", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line378": { + "length": 0.055946, + "linecode": "lc8", + "bus_from": "374", + "bus_to": "379", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line10": { + "length": 0.39461, + "linecode": "lc3", + "bus_from": "10", + "bus_to": "11", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line32": { + "length": 0.30245, + "linecode": "lc3", + "bus_from": "32", + "bus_to": "33", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line158": { + "length": 0.24535, + "linecode": "lc8", + "bus_from": "155", + "bus_to": "159", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line395": { + "length": 1.351, + "linecode": "lc3", + "bus_from": "390", + "bus_to": "396", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line21": { + "length": 0.10668, + "linecode": "lc3", + "bus_from": "20", + "bus_to": "22", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line256": { + "length": 0.21197, + "linecode": "lc6", + "bus_from": "247", + "bus_to": "257", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line373": { + "length": 0.2452, + "linecode": "lc8", + "bus_from": "368", + "bus_to": "374", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line409": { + "length": 0.86161, + "linecode": "lc6", + "bus_from": "406", + "bus_to": "410", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line7": { + "length": 0.40028, + "linecode": "lc3", + "bus_from": "7", + "bus_to": "8", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line74": { + "length": 0.64295, + "linecode": "lc4", + "bus_from": "73", + "bus_to": "75", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line109": { + "length": 6.2731, + "linecode": "lc4", + "bus_from": "104", + "bus_to": "110", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line89": { + "length": 0.9651, + "linecode": "lc8", + "bus_from": "87", + "bus_to": "90", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line142": { + "length": 0.32023, + "linecode": "lc4", + "bus_from": "139", + "bus_to": "143", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line59": { + "length": 0.30895, + "linecode": "lc3", + "bus_from": "59", + "bus_to": "60", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line500": { + "length": 0.068622, + "linecode": "lc6", + "bus_from": "500", + "bus_to": "501", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line71": { + "length": 0.68171, + "linecode": "lc4", + "bus_from": "70", + "bus_to": "72", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line102": { + "length": 3.9741, + "linecode": "lc8", + "bus_from": "98", + "bus_to": "103", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line454": { + "length": 5.158, + "linecode": "lc6", + "bus_from": "452", + "bus_to": "455", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line489": { + "length": 0.30787, + "linecode": "lc6", + "bus_from": "489", + "bus_to": "490", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line91": { + "length": 4.2035, + "linecode": "lc4", + "bus_from": "89", + "bus_to": "92", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line151": { + "length": 0.9003, + "linecode": "lc3", + "bus_from": "147", + "bus_to": "152", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line243": { + "length": 2.4762, + "linecode": "lc6", + "bus_from": "234", + "bus_to": "244", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "_virtual.line253+line287+line275+line232+line312+line296+line242+line264+line304": { + "length": 2.3862900000000002, + "linecode": "lc3", + "bus_from": "221", + "bus_to": "313", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line266": { + "length": 0.75412, + "linecode": "lc6", + "bus_from": "257", + "bus_to": "267", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line343": { + "length": 7.4447, + "linecode": "lc3", + "bus_from": "338", + "bus_to": "344", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line248": { + "length": 0.37136, + "linecode": "lc6", + "bus_from": "238", + "bus_to": "249", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line103": { + "length": 3.9957, + "linecode": "lc4", + "bus_from": "100", + "bus_to": "104", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line108": { + "length": 6.2021, + "linecode": "lc8", + "bus_from": "103", + "bus_to": "109", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line176": { + "length": 0.41015, + "linecode": "lc3", + "bus_from": "171", + "bus_to": "177", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line225": { + "length": 1.4148, + "linecode": "lc3", + "bus_from": "214", + "bus_to": "226", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line236": { + "length": 1.0463, + "linecode": "lc3", + "bus_from": "226", + "bus_to": "237", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line376": { + "length": 0.51751, + "linecode": "lc6", + "bus_from": "371", + "bus_to": "377", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line386": { + "length": 0.2452, + "linecode": "lc6", + "bus_from": "382", + "bus_to": "387", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "_virtual.line466+line464+line468+line462+line459": { + "length": 11.26687, + "linecode": "lc6", + "bus_from": "469", + "bus_to": "457", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line483": { + "length": 0.18588, + "linecode": "lc6", + "bus_from": "483", + "bus_to": "484", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line49": { + "length": 0.30936, + "linecode": "lc3", + "bus_from": "49", + "bus_to": "50", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line143": { + "length": 0.10465, + "linecode": "lc3", + "bus_from": "140", + "bus_to": "144", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "_virtual.line407+line241+line145+line191+line166+line440+line252+line121+line199+line274+line433+line328+line446+line340+line295+line177+line378+line286+line303+line150+line132+line158+line184+line207+line171+line373+line137+line162+line417+line334+line115+line141+line263+line311+line388+line383+line127+line230+line346+line359+line421+line412+line394+line320+line154+line400+line403+line367+line426+line352+line217": { + "length": 92.859073, + "linecode": "lc8", + "bus_from": "109", + "bus_to": "447", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line342": { + "length": 0.53828, + "linecode": "lc3", + "bus_from": "337", + "bus_to": "343", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line400": { + "length": 0.090139, + "linecode": "lc8", + "bus_from": "395", + "bus_to": "401", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line403": { + "length": 0.14013, + "linecode": "lc8", + "bus_from": "401", + "bus_to": "404", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line301": { + "length": 0.28104, + "linecode": "lc6", + "bus_from": "294", + "bus_to": "302", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line462": { + "length": 0.45846, + "linecode": "lc6", + "bus_from": "460", + "bus_to": "463", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line398": { + "length": 4.0569, + "linecode": "lc6", + "bus_from": "393", + "bus_to": "399", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line458": { + "length": 2.5823, + "linecode": "lc3", + "bus_from": "457", + "bus_to": "459", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line217": { + "length": 0.25139, + "linecode": "lc8", + "bus_from": "208", + "bus_to": "218", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line251": { + "length": 4.2833, + "linecode": "lc3", + "bus_from": "241", + "bus_to": "252", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line370": { + "length": 2.1311, + "linecode": "lc6", + "bus_from": "366", + "bus_to": "371", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line474": { + "length": 0.2158, + "linecode": "lc6", + "bus_from": "472", + "bus_to": "475", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line245": { + "length": 0.32758, + "linecode": "lc6", + "bus_from": "235", + "bus_to": "246", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line365": { + "length": 2.8695, + "linecode": "lc3", + "bus_from": "358", + "bus_to": "366", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "_virtual.line136+line144+line131+line120+line107+line126+line113+line140+line149": { + "length": 2.35305, + "linecode": "lc2", + "bus_from": "150", + "bus_to": "103", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "_virtual.line302+line310+line285+line294": { + "length": 15.5397, + "linecode": "lc3", + "bus_from": "311", + "bus_to": "274", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line73": { + "length": 0.85425, + "linecode": "lc8", + "bus_from": "71", + "bus_to": "74", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line261": { + "length": 1.207, + "linecode": "lc6", + "bus_from": "251", + "bus_to": "262", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line166": { + "length": 0.39623, + "linecode": "lc8", + "bus_from": "163", + "bus_to": "167", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line64": { + "length": 0.601, + "linecode": "lc3", + "bus_from": "64", + "bus_to": "65", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "_virtual.line432+line439": { + "length": 6.337, + "linecode": "lc6", + "bus_from": "440", + "bus_to": "426", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line310": { + "length": 1.1717, + "linecode": "lc3", + "bus_from": "303", + "bus_to": "311", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line372": { + "length": 2.0093, + "linecode": "lc3", + "bus_from": "367", + "bus_to": "373", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line390": { + "length": 0.14958, + "linecode": "lc3", + "bus_from": "386", + "bus_to": "391", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line315": { + "length": 0.14001, + "linecode": "lc6", + "bus_from": "308", + "bus_to": "316", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line385": { + "length": 0.17831, + "linecode": "lc3", + "bus_from": "381", + "bus_to": "386", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line419": { + "length": 0.17651, + "linecode": "lc6", + "bus_from": "416", + "bus_to": "420", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line456": { + "length": 1.2361, + "linecode": "lc3", + "bus_from": "453", + "bus_to": "457", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line233": { + "length": 3.6388, + "linecode": "lc3", + "bus_from": "221", + "bus_to": "234", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line188": { + "length": 7.4458, + "linecode": "lc6", + "bus_from": "183", + "bus_to": "189", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line338": { + "length": 0.36749, + "linecode": "lc6", + "bus_from": "333", + "bus_to": "339", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line504": { + "length": 0.3396, + "linecode": "lc6", + "bus_from": "504", + "bus_to": "505", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line107": { + "length": 1.4417, + "linecode": "lc2", + "bus_from": "103", + "bus_to": "108", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line223": { + "length": 0.21543, + "linecode": "lc6", + "bus_from": "212", + "bus_to": "224", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line239": { + "length": 5.2959, + "linecode": "lc6", + "bus_from": "229", + "bus_to": "240", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line264": { + "length": 0.29955, + "linecode": "lc3", + "bus_from": "254", + "bus_to": "265", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "_virtual.line476+line472": { + "length": 11.892199999999999, + "linecode": "lc6", + "bus_from": "477", + "bus_to": "471", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line240": { + "length": 11.1833, + "linecode": "lc3", + "bus_from": "230", + "bus_to": "241", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line284": { + "length": 9.7001, + "linecode": "lc6", + "bus_from": "274", + "bus_to": "285", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line132": { + "length": 2.2731, + "linecode": "lc8", + "bus_from": "128", + "bus_to": "133", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line195": { + "length": 5.1426, + "linecode": "lc6", + "bus_from": "189", + "bus_to": "196", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line45": { + "length": 0.48601, + "linecode": "lc3", + "bus_from": "45", + "bus_to": "46", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "_virtual.line135+line106+line119+line112+line101+line125+line130": { + "length": 2.7314200000000004, + "linecode": "lc8", + "bus_from": "98", + "bus_to": "136", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line36": { + "length": 0.16313, + "linecode": "lc3", + "bus_from": "36", + "bus_to": "37", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line114": { + "length": 18.6333, + "linecode": "lc1", + "bus_from": "109", + "bus_to": "115", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line485": { + "length": 0.26042, + "linecode": "lc6", + "bus_from": "485", + "bus_to": "486", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line490": { + "length": 0.25303, + "linecode": "lc6", + "bus_from": "490", + "bus_to": "491", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line228": { + "length": 7.733, + "linecode": "lc6", + "bus_from": "217", + "bus_to": "229", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "_virtual.line124+line100+line111+line92+line118+line105+line96": { + "length": 1.6324400000000001, + "linecode": "lc8", + "bus_from": "125", + "bus_to": "90", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line141": { + "length": 1.5452, + "linecode": "lc8", + "bus_from": "138", + "bus_to": "142", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line159": { + "length": 0.816, + "linecode": "lc3", + "bus_from": "156", + "bus_to": "160", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line255": { + "length": 1.3444, + "linecode": "lc6", + "bus_from": "246", + "bus_to": "256", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line383": { + "length": 0.072402, + "linecode": "lc8", + "bus_from": "379", + "bus_to": "384", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line326": { + "length": 5.3493, + "linecode": "lc2", + "bus_from": "319", + "bus_to": "327", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "_virtual.line260+line270": { + "length": 5.9651000000000005, + "linecode": "lc6", + "bus_from": "251", + "bus_to": "271", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line260": { + "length": 1.136, + "linecode": "lc6", + "bus_from": "251", + "bus_to": "261", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line381": { + "length": 0.26011, + "linecode": "lc6", + "bus_from": "377", + "bus_to": "382", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line387": { + "length": 2.8291, + "linecode": "lc3", + "bus_from": "383", + "bus_to": "388", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "_virtual.line147+line164+line156+line152+line160": { + "length": 11.992090000000001, + "linecode": "lc3", + "bus_from": "143", + "bus_to": "165", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line40": { + "length": 0.52269, + "linecode": "lc3", + "bus_from": "40", + "bus_to": "41", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line52": { + "length": 0.65323, + "linecode": "lc3", + "bus_from": "52", + "bus_to": "53", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line124": { + "length": 0.57446, + "linecode": "lc8", + "bus_from": "119", + "bus_to": "125", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line65": { + "length": 0.47332, + "linecode": "lc8", + "bus_from": "65", + "bus_to": "66", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line172": { + "length": 0.90468, + "linecode": "lc3", + "bus_from": "168", + "bus_to": "173", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line238": { + "length": 0.27049, + "linecode": "lc6", + "bus_from": "228", + "bus_to": "239", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line80": { + "length": 0.57213, + "linecode": "lc4", + "bus_from": "79", + "bus_to": "81", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line262": { + "length": 8.1431, + "linecode": "lc3", + "bus_from": "252", + "bus_to": "263", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line125": { + "length": 0.16323, + "linecode": "lc8", + "bus_from": "120", + "bus_to": "126", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line285": { + "length": 6.2438, + "linecode": "lc3", + "bus_from": "274", + "bus_to": "286", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line153": { + "length": 5.0734, + "linecode": "lc3", + "bus_from": "149", + "bus_to": "154", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line379": { + "length": 0.73814, + "linecode": "lc3", + "bus_from": "375", + "bus_to": "380", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line366": { + "length": 1.244, + "linecode": "lc3", + "bus_from": "359", + "bus_to": "367", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line422": { + "length": 2.3991, + "linecode": "lc6", + "bus_from": "419", + "bus_to": "423", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line138": { + "length": 0.17913, + "linecode": "lc4", + "bus_from": "134", + "bus_to": "139", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "_virtual.line349+line355": { + "length": 12.4233, + "linecode": "lc6", + "bus_from": "344", + "bus_to": "356", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line352": { + "length": 0.44263, + "linecode": "lc8", + "bus_from": "347", + "bus_to": "353", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line253": { + "length": 0.29969, + "linecode": "lc3", + "bus_from": "243", + "bus_to": "254", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line60": { + "length": 0.34812, + "linecode": "lc3", + "bus_from": "60", + "bus_to": "61", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line478": { + "length": 5.2894, + "linecode": "lc6", + "bus_from": "476", + "bus_to": "479", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line5": { + "length": 0.10826, + "linecode": "lc3", + "bus_from": "5", + "bus_to": "6", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line31": { + "length": 0.18524, + "linecode": "lc3", + "bus_from": "30", + "bus_to": "32", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line441": { + "length": 0.066468, + "linecode": "lc6", + "bus_from": "435", + "bus_to": "442", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line9": { + "length": 0.37317, + "linecode": "lc3", + "bus_from": "9", + "bus_to": "10", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line55": { + "length": 0.22588, + "linecode": "lc3", + "bus_from": "55", + "bus_to": "56", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line252": { + "length": 0.36513, + "linecode": "lc8", + "bus_from": "242", + "bus_to": "253", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line194": { + "length": 0.19228, + "linecode": "lc6", + "bus_from": "188", + "bus_to": "195", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line199": { + "length": 4.134, + "linecode": "lc8", + "bus_from": "192", + "bus_to": "200", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "_virtual.line272+line283": { + "length": 13.247599999999998, + "linecode": "lc6", + "bus_from": "263", + "bus_to": "284", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line39": { + "length": 1.8421, + "linecode": "lc3", + "bus_from": "39", + "bus_to": "40", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line433": { + "length": 0.083024, + "linecode": "lc8", + "bus_from": "427", + "bus_to": "434", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line292": { + "length": 0.14132, + "linecode": "lc6", + "bus_from": "283", + "bus_to": "293", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line134": { + "length": 0.16026, + "linecode": "lc3", + "bus_from": "130", + "bus_to": "135", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line291": { + "length": 1.1696, + "linecode": "lc6", + "bus_from": "282", + "bus_to": "292", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line218": { + "length": 5.133, + "linecode": "lc6", + "bus_from": "209", + "bus_to": "219", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line316": { + "length": 4.8023, + "linecode": "lc6", + "bus_from": "309", + "bus_to": "317", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line328": { + "length": 0.56602, + "linecode": "lc8", + "bus_from": "321", + "bus_to": "329", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line380": { + "length": 0.17156, + "linecode": "lc3", + "bus_from": "376", + "bus_to": "381", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line355": { + "length": 5.6049, + "linecode": "lc6", + "bus_from": "350", + "bus_to": "356", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line446": { + "length": 1.6719, + "linecode": "lc8", + "bus_from": "441", + "bus_to": "447", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "_virtual.line239+line228": { + "length": 13.0289, + "linecode": "lc6", + "bus_from": "240", + "bus_to": "217", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line14": { + "length": 11.2962, + "linecode": "lc3", + "bus_from": "14", + "bus_to": "15", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line110": { + "length": 0.14339, + "linecode": "lc3", + "bus_from": "105", + "bus_to": "111", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line97": { + "length": 5.4917, + "linecode": "lc8", + "bus_from": "94", + "bus_to": "98", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line177": { + "length": 9.9495, + "linecode": "lc8", + "bus_from": "172", + "bus_to": "178", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line257": { + "length": 1.0723, + "linecode": "lc3", + "bus_from": "248", + "bus_to": "258", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line442": { + "length": 4.8319, + "linecode": "lc6", + "bus_from": "436", + "bus_to": "443", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line418": { + "length": 0.30959, + "linecode": "lc6", + "bus_from": "414", + "bus_to": "419", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line453": { + "length": 0.164, + "linecode": "lc6", + "bus_from": "451", + "bus_to": "454", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "_virtual.line93+line97": { + "length": 6.5508999999999995, + "linecode": "lc8", + "bus_from": "98", + "bus_to": "90", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line294": { + "length": 5.0736, + "linecode": "lc3", + "bus_from": "286", + "bus_to": "295", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line473": { + "length": 0.772, + "linecode": "lc3", + "bus_from": "472", + "bus_to": "474", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line192": { + "length": 1.6496, + "linecode": "lc3", + "bus_from": "186", + "bus_to": "193", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line275": { + "length": 0.16085, + "linecode": "lc3", + "bus_from": "265", + "bus_to": "276", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line129": { + "length": 0.18768, + "linecode": "lc3", + "bus_from": "124", + "bus_to": "130", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line82": { + "length": 0.4081, + "linecode": "lc8", + "bus_from": "80", + "bus_to": "83", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line501": { + "length": 0.098387, + "linecode": "lc6", + "bus_from": "501", + "bus_to": "502", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line254": { + "length": 0.22672, + "linecode": "lc6", + "bus_from": "244", + "bus_to": "255", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line118": { + "length": 0.16744, + "linecode": "lc8", + "bus_from": "112", + "bus_to": "119", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line149": { + "length": 0.11411, + "linecode": "lc2", + "bus_from": "145", + "bus_to": "150", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line334": { + "length": 2.1841, + "linecode": "lc8", + "bus_from": "329", + "bus_to": "335", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line491": { + "length": 0.289, + "linecode": "lc6", + "bus_from": "491", + "bus_to": "492", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line51": { + "length": 0.35569, + "linecode": "lc3", + "bus_from": "51", + "bus_to": "52", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line116": { + "length": 6.8138, + "linecode": "lc4", + "bus_from": "110", + "bus_to": "117", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "_virtual.line448+line429+line436+line452+line443+line456": { + "length": 13.475500000000002, + "linecode": "lc3", + "bus_from": "425", + "bus_to": "457", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "_virtual.line430+line444+line449+line437": { + "length": 6.76409, + "linecode": "lc3", + "bus_from": "450", + "bus_to": "425", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "_virtual.line81+line83+line85": { + "length": 5.475879999999999, + "linecode": "lc4", + "bus_from": "86", + "bus_to": "79", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line362": { + "length": 0.43584, + "linecode": "lc3", + "bus_from": "355", + "bus_to": "363", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line119": { + "length": 0.19026, + "linecode": "lc8", + "bus_from": "113", + "bus_to": "120", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line12": { + "length": 0.43804, + "linecode": "lc3", + "bus_from": "12", + "bus_to": "13", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line127": { + "length": 2.0533, + "linecode": "lc8", + "bus_from": "122", + "bus_to": "128", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line61": { + "length": 0.3491, + "linecode": "lc3", + "bus_from": "61", + "bus_to": "62", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line244": { + "length": 6.2937, + "linecode": "lc3", + "bus_from": "234", + "bus_to": "245", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line486": { + "length": 0.21893, + "linecode": "lc6", + "bus_from": "486", + "bus_to": "487", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line495": { + "length": 0.074007, + "linecode": "lc6", + "bus_from": "495", + "bus_to": "496", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line165": { + "length": 0.16586, + "linecode": "lc3", + "bus_from": "162", + "bus_to": "166", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line488": { + "length": 3.9787, + "linecode": "lc6", + "bus_from": "488", + "bus_to": "489", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line363": { + "length": 5.499, + "linecode": "lc6", + "bus_from": "357", + "bus_to": "364", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "_virtual.line370+line386+line397+line376+line451+line408+line418+line391+line434+line441+line413+line447+line427+line422+line401+line381+line404": { + "length": 10.305898000000001, + "linecode": "lc6", + "bus_from": "366", + "bus_to": "452", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line353": { + "length": 0.522, + "linecode": "lc6", + "bus_from": "348", + "bus_to": "354", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line421": { + "length": 0.09434, + "linecode": "lc8", + "bus_from": "418", + "bus_to": "422", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "_virtual.line35+line25+line31+line23+line27+line33+line17+line32+line19+line38+line29+line21+line39+line36+line37+line34": { + "length": 22.791524, + "linecode": "lc3", + "bus_from": "16", + "bus_to": "40", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line410": { + "length": 0.85918, + "linecode": "lc6", + "bus_from": "406", + "bus_to": "411", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line167": { + "length": 0.8392, + "linecode": "lc3", + "bus_from": "164", + "bus_to": "168", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line455": { + "length": 5.158, + "linecode": "lc6", + "bus_from": "452", + "bus_to": "456", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line412": { + "length": 0.79546, + "linecode": "lc8", + "bus_from": "408", + "bus_to": "413", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line196": { + "length": 7.7733, + "linecode": "lc6", + "bus_from": "190", + "bus_to": "197", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line450": { + "length": 0.14863, + "linecode": "lc6", + "bus_from": "446", + "bus_to": "451", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line17": { + "length": 3.9213, + "linecode": "lc3", + "bus_from": "16", + "bus_to": "18", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line246": { + "length": 0.21197, + "linecode": "lc6", + "bus_from": "236", + "bus_to": "247", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line154": { + "length": 0.25111, + "linecode": "lc8", + "bus_from": "151", + "bus_to": "155", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line265": { + "length": 0.32487, + "linecode": "lc6", + "bus_from": "255", + "bus_to": "266", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line289": { + "length": 0.58992, + "linecode": "lc3", + "bus_from": "280", + "bus_to": "290", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line358": { + "length": 0.65613, + "linecode": "lc3", + "bus_from": "352", + "bus_to": "359", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line476": { + "length": 5.4074, + "linecode": "lc6", + "bus_from": "473", + "bus_to": "477", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line203": { + "length": 0.57905, + "linecode": "lc6", + "bus_from": "197", + "bus_to": "204", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line367": { + "length": 7.6605, + "linecode": "lc8", + "bus_from": "360", + "bus_to": "368", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line88": { + "length": 4.9754, + "linecode": "lc4", + "bus_from": "86", + "bus_to": "89", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line113": { + "length": 0.11424, + "linecode": "lc2", + "bus_from": "108", + "bus_to": "114", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line163": { + "length": 0.68062, + "linecode": "lc3", + "bus_from": "160", + "bus_to": "164", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line426": { + "length": 0.098838, + "linecode": "lc8", + "bus_from": "422", + "bus_to": "427", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line407": { + "length": 0.34266, + "linecode": "lc8", + "bus_from": "404", + "bus_to": "408", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "_virtual.line174+line180": { + "length": 14.7847, + "linecode": "lc6", + "bus_from": "170", + "bus_to": "181", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "_virtual.line193+line173+line186+line201+line179+line210+line168": { + "length": 10.89, + "linecode": "lc6", + "bus_from": "211", + "bus_to": "165", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line325": { + "length": 5.3493, + "linecode": "lc2", + "bus_from": "319", + "bus_to": "326", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line106": { + "length": 0.22539, + "linecode": "lc8", + "bus_from": "102", + "bus_to": "107", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line8": { + "length": 0.298, + "linecode": "lc3", + "bus_from": "8", + "bus_to": "9", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line145": { + "length": 0.32006, + "linecode": "lc8", + "bus_from": "142", + "bus_to": "146", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line193": { + "length": 0.33179, + "linecode": "lc6", + "bus_from": "187", + "bus_to": "194", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line435": { + "length": 0.618, + "linecode": "lc6", + "bus_from": "429", + "bus_to": "436", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line480": { + "length": 0.24452, + "linecode": "lc6", + "bus_from": "480", + "bus_to": "481", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line72": { + "length": 2.604, + "linecode": "lc4", + "bus_from": "70", + "bus_to": "73", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line314": { + "length": 0.8433, + "linecode": "lc3", + "bus_from": "307", + "bus_to": "315", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line420": { + "length": 2.1229, + "linecode": "lc3", + "bus_from": "417", + "bus_to": "421", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line121": { + "length": 3.578, + "linecode": "lc8", + "bus_from": "116", + "bus_to": "122", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "_virtual.line229+line240": { + "length": 13.9863, + "linecode": "lc3", + "bus_from": "217", + "bus_to": "241", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line15": { + "length": 3.6224, + "linecode": "lc3", + "bus_from": "15", + "bus_to": "16", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line299": { + "length": 0.2702, + "linecode": "lc6", + "bus_from": "291", + "bus_to": "300", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line130": { + "length": 0.24887, + "linecode": "lc8", + "bus_from": "126", + "bus_to": "131", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line26": { + "length": 0.29149, + "linecode": "lc3", + "bus_from": "25", + "bus_to": "27", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line81": { + "length": 4.0611, + "linecode": "lc4", + "bus_from": "79", + "bus_to": "82", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line327": { + "length": 0.67887, + "linecode": "lc3", + "bus_from": "320", + "bus_to": "328", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "_virtual.line279+line225+line236+line330+line257+line298+line247+line197+line267+line213+line314+line289+line306+line204+line322": { + "length": 19.75733, + "linecode": "lc3", + "bus_from": "331", + "bus_to": "190", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line27": { + "length": 0.18296, + "linecode": "lc3", + "bus_from": "26", + "bus_to": "28", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line477": { + "length": 0.11709, + "linecode": "lc6", + "bus_from": "475", + "bus_to": "478", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line208": { + "length": 7.8473, + "linecode": "lc6", + "bus_from": "201", + "bus_to": "209", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line178": { + "length": 0.57899, + "linecode": "lc3", + "bus_from": "173", + "bus_to": "179", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line339": { + "length": 0.8471, + "linecode": "lc3", + "bus_from": "334", + "bus_to": "340", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line135": { + "length": 0.71943, + "linecode": "lc8", + "bus_from": "131", + "bus_to": "136", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line277": { + "length": 4.4991, + "linecode": "lc6", + "bus_from": "267", + "bus_to": "278", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "_virtual.line54+line60+line48+line51+line46+line44+line63+line59+line55+line47+line64+line61+line50+line42+line52+line56+line43+line49+line57+line53+line62+line45+line41+line58": { + "length": 16.34776, + "linecode": "lc3", + "bus_from": "65", + "bus_to": "40", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line309": { + "length": 0.19873, + "linecode": "lc6", + "bus_from": "302", + "bus_to": "310", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line337": { + "length": 1.019, + "linecode": "lc3", + "bus_from": "331", + "bus_to": "338", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line180": { + "length": 7.5749, + "linecode": "lc6", + "bus_from": "175", + "bus_to": "181", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line175": { + "length": 10.1945, + "linecode": "lc3", + "bus_from": "170", + "bus_to": "176", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line18": { + "length": 0.09014, + "linecode": "lc3", + "bus_from": "17", + "bus_to": "19", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "_virtual.line460+line431+line445+line438+line453+line450+line457": { + "length": 7.355171999999999, + "linecode": "lc6", + "bus_from": "461", + "bus_to": "426", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line384": { + "length": 7.4321, + "linecode": "lc3", + "bus_from": "380", + "bus_to": "385", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line22": { + "length": 0.076118, + "linecode": "lc3", + "bus_from": "21", + "bus_to": "23", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line445": { + "length": 0.078262, + "linecode": "lc6", + "bus_from": "439", + "bus_to": "446", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line313": { + "length": 0.34976, + "linecode": "lc6", + "bus_from": "306", + "bus_to": "314", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line307": { + "length": 0.22062, + "linecode": "lc6", + "bus_from": "300", + "bus_to": "308", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line494": { + "length": 5.1036, + "linecode": "lc6", + "bus_from": "494", + "bus_to": "495", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line86": { + "length": 0.36271, + "linecode": "lc8", + "bus_from": "85", + "bus_to": "87", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line447": { + "length": 0.14863, + "linecode": "lc6", + "bus_from": "442", + "bus_to": "448", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line430": { + "length": 0.48941, + "linecode": "lc3", + "bus_from": "425", + "bus_to": "431", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line431": { + "length": 1.079, + "linecode": "lc6", + "bus_from": "426", + "bus_to": "432", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line29": { + "length": 0.22495, + "linecode": "lc3", + "bus_from": "28", + "bus_to": "30", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line230": { + "length": 0.32995, + "linecode": "lc8", + "bus_from": "218", + "bus_to": "231", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line221": { + "length": 5.1057, + "linecode": "lc6", + "bus_from": "211", + "bus_to": "222", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line468": { + "length": 5.3108, + "linecode": "lc6", + "bus_from": "467", + "bus_to": "469", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line258": { + "length": 0.40266, + "linecode": "lc6", + "bus_from": "249", + "bus_to": "259", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line463": { + "length": 1.6957, + "linecode": "lc3", + "bus_from": "462", + "bus_to": "464", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "_virtual.line211+line181+line194+line202+line223+line245+line187+line234+line255": { + "length": 3.45026, + "linecode": "lc6", + "bus_from": "176", + "bus_to": "256", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line345": { + "length": 1.234, + "linecode": "lc3", + "bus_from": "340", + "bus_to": "346", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line402": { + "length": 5.9147, + "linecode": "lc3", + "bus_from": "400", + "bus_to": "403", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line43": { + "length": 0.38293, + "linecode": "lc3", + "bus_from": "43", + "bus_to": "44", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line161": { + "length": 0.24444, + "linecode": "lc3", + "bus_from": "158", + "bus_to": "162", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line231": { + "length": 4.1663, + "linecode": "lc6", + "bus_from": "220", + "bus_to": "232", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line438": { + "length": 0.16006, + "linecode": "lc6", + "bus_from": "432", + "bus_to": "439", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line444": { + "length": 0.72958, + "linecode": "lc3", + "bus_from": "438", + "bus_to": "445", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line472": { + "length": 6.4848, + "linecode": "lc6", + "bus_from": "471", + "bus_to": "473", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line62": { + "length": 0.24452, + "linecode": "lc3", + "bus_from": "62", + "bus_to": "63", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "_virtual.line478+line475": { + "length": 11.8228, + "linecode": "lc6", + "bus_from": "479", + "bus_to": "472", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line99": { + "length": 2.4912, + "linecode": "lc4", + "bus_from": "96", + "bus_to": "100", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line111": { + "length": 0.115, + "linecode": "lc8", + "bus_from": "106", + "bus_to": "112", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line425": { + "length": 5.2133, + "linecode": "lc6", + "bus_from": "421", + "bus_to": "426", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line268": { + "length": 0.46672, + "linecode": "lc6", + "bus_from": "259", + "bus_to": "269", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line79": { + "length": 0.56945, + "linecode": "lc8", + "bus_from": "77", + "bus_to": "80", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "_virtual.line104+line176+line110+line161+line117+line143+line198+line157+line153+line123+line129+line148+line139+line170+line190+line183+line134+line165": { + "length": 12.785968, + "linecode": "lc3", + "bus_from": "100", + "bus_to": "199", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line322": { + "length": 0.86875, + "linecode": "lc3", + "bus_from": "315", + "bus_to": "323", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line66": { + "length": 4.28, + "linecode": "lc4", + "bus_from": "65", + "bus_to": "67", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line205": { + "length": 0.49573, + "linecode": "lc6", + "bus_from": "199", + "bus_to": "206", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line101": { + "length": 0.97602, + "linecode": "lc8", + "bus_from": "98", + "bus_to": "102", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line317": { + "length": 0.24537, + "linecode": "lc6", + "bus_from": "310", + "bus_to": "318", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line186": { + "length": 0.19807, + "linecode": "lc6", + "bus_from": "180", + "bus_to": "187", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line197": { + "length": 5.7798, + "linecode": "lc3", + "bus_from": "190", + "bus_to": "198", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "_virtual.line420+line411+line416+line406": { + "length": 14.664499999999999, + "linecode": "lc3", + "bus_from": "403", + "bus_to": "421", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line432": { + "length": 1.08, + "linecode": "lc6", + "bus_from": "426", + "bus_to": "433", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line37": { + "length": 0.39768, + "linecode": "lc3", + "bus_from": "37", + "bus_to": "38", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line50": { + "length": 0.3128, + "linecode": "lc3", + "bus_from": "50", + "bus_to": "51", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line160": { + "length": 2.8635, + "linecode": "lc3", + "bus_from": "157", + "bus_to": "161", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line279": { + "length": 0.85234, + "linecode": "lc3", + "bus_from": "268", + "bus_to": "280", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line340": { + "length": 12.7317, + "linecode": "lc8", + "bus_from": "335", + "bus_to": "341", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line404": { + "length": 0.77086, + "linecode": "lc6", + "bus_from": "402", + "bus_to": "405", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "_virtual.line327+line345+line333+line319+line351+line339+line382+line358+line377+line366+line372+line387": { + "length": 18.40222, + "linecode": "lc3", + "bus_from": "388", + "bus_to": "311", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line461": { + "length": 1.5639, + "linecode": "lc3", + "bus_from": "459", + "bus_to": "462", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line290": { + "length": 0.28618, + "linecode": "lc6", + "bus_from": "281", + "bus_to": "291", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line375": { + "length": 0.21225, + "linecode": "lc3", + "bus_from": "370", + "bus_to": "376", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line487": { + "length": 0.17918, + "linecode": "lc6", + "bus_from": "487", + "bus_to": "488", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "_virtual.line248+line205+line290+line226+line237+line307+line280+line323+line299+line268+line315+line214+line258+line331": { + "length": 18.12726, + "linecode": "lc6", + "bus_from": "332", + "bus_to": "199", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line452": { + "length": 2.0202, + "linecode": "lc3", + "bus_from": "449", + "bus_to": "453", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line304": { + "length": 0.29557, + "linecode": "lc3", + "bus_from": "297", + "bus_to": "305", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line434": { + "length": 0.17923, + "linecode": "lc6", + "bus_from": "428", + "bus_to": "435", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line460": { + "length": 5.1151, + "linecode": "lc6", + "bus_from": "458", + "bus_to": "461", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line98": { + "length": 0.534, + "linecode": "lc4", + "bus_from": "96", + "bus_to": "99", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line4": { + "length": 0.091935, + "linecode": "lc3", + "bus_from": "4", + "bus_to": "5", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line184": { + "length": 13.337, + "linecode": "lc8", + "bus_from": "178", + "bus_to": "185", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line323": { + "length": 0.49368, + "linecode": "lc6", + "bus_from": "316", + "bus_to": "324", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line16": { + "length": 0.31933, + "linecode": "lc3", + "bus_from": "16", + "bus_to": "17", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line401": { + "length": 1.0331, + "linecode": "lc6", + "bus_from": "398", + "bus_to": "402", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line475": { + "length": 6.5334, + "linecode": "lc6", + "bus_from": "472", + "bus_to": "476", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line171": { + "length": 2.8593, + "linecode": "lc8", + "bus_from": "167", + "bus_to": "172", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line335": { + "length": 0.21537, + "linecode": "lc6", + "bus_from": "330", + "bus_to": "336", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line35": { + "length": 0.068154, + "linecode": "lc3", + "bus_from": "35", + "bus_to": "36", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line54": { + "length": 0.16388, + "linecode": "lc3", + "bus_from": "54", + "bus_to": "55", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "_virtual.line219+line231": { + "length": 11.899799999999999, + "linecode": "lc6", + "bus_from": "210", + "bus_to": "232", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line282": { + "length": 0.29099, + "linecode": "lc6", + "bus_from": "272", + "bus_to": "283", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line92": { + "length": 0.24449, + "linecode": "lc8", + "bus_from": "90", + "bus_to": "93", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line216": { + "length": 1.9147, + "linecode": "lc3", + "bus_from": "207", + "bus_to": "217", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line222": { + "length": 5.1057, + "linecode": "lc6", + "bus_from": "211", + "bus_to": "223", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line408": { + "length": 0.36628, + "linecode": "lc6", + "bus_from": "405", + "bus_to": "409", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line44": { + "length": 0.42216, + "linecode": "lc3", + "bus_from": "44", + "bus_to": "45", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line63": { + "length": 0.326, + "linecode": "lc3", + "bus_from": "63", + "bus_to": "64", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line85": { + "length": 1.0115, + "linecode": "lc4", + "bus_from": "84", + "bus_to": "86", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line189": { + "length": 4.0245, + "linecode": "lc3", + "bus_from": "183", + "bus_to": "190", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line344": { + "length": 2.66, + "linecode": "lc6", + "bus_from": "339", + "bus_to": "345", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line164": { + "length": 6.5871, + "linecode": "lc3", + "bus_from": "161", + "bus_to": "165", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line312": { + "length": 0.34972, + "linecode": "lc3", + "bus_from": "305", + "bus_to": "313", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line219": { + "length": 7.7335, + "linecode": "lc6", + "bus_from": "210", + "bus_to": "220", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line94": { + "length": 0.56542, + "linecode": "lc4", + "bus_from": "92", + "bus_to": "95", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line287": { + "length": 0.21946, + "linecode": "lc3", + "bus_from": "276", + "bus_to": "288", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line25": { + "length": 0.15048, + "linecode": "lc3", + "bus_from": "24", + "bus_to": "26", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line297": { + "length": 0.27461, + "linecode": "lc6", + "bus_from": "289", + "bus_to": "298", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line210": { + "length": 1.7807, + "linecode": "lc6", + "bus_from": "202", + "bus_to": "211", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line305": { + "length": 0.22638, + "linecode": "lc6", + "bus_from": "298", + "bus_to": "306", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line123": { + "length": 0.18768, + "linecode": "lc3", + "bus_from": "118", + "bus_to": "124", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line320": { + "length": 0.41448, + "linecode": "lc8", + "bus_from": "312", + "bus_to": "321", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line144": { + "length": 0.11378, + "linecode": "lc2", + "bus_from": "141", + "bus_to": "145", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line411": { + "length": 8.3989, + "linecode": "lc3", + "bus_from": "407", + "bus_to": "412", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line280": { + "length": 2.4414, + "linecode": "lc6", + "bus_from": "269", + "bus_to": "281", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line457": { + "length": 0.61012, + "linecode": "lc6", + "bus_from": "454", + "bus_to": "458", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line493": { + "length": 0.44073, + "linecode": "lc6", + "bus_from": "493", + "bus_to": "494", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line288": { + "length": 1.2446, + "linecode": "lc6", + "bus_from": "277", + "bus_to": "289", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line204": { + "length": 1.9267, + "linecode": "lc3", + "bus_from": "198", + "bus_to": "205", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line293": { + "length": 0.31421, + "linecode": "lc6", + "bus_from": "285", + "bus_to": "294", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line405": { + "length": 5.2004, + "linecode": "lc6", + "bus_from": "403", + "bus_to": "406", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "_virtual.line67+line84+line76+line65+line89+line73+line86+line82+line79+line70": { + "length": 6.6126499999999995, + "linecode": "lc8", + "bus_from": "65", + "bus_to": "90", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line227": { + "length": 0.2594, + "linecode": "lc6", + "bus_from": "216", + "bus_to": "228", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line347": { + "length": 0.21465, + "linecode": "lc6", + "bus_from": "342", + "bus_to": "348", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line174": { + "length": 7.2098, + "linecode": "lc6", + "bus_from": "170", + "bus_to": "175", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line241": { + "length": 0.29165, + "linecode": "lc8", + "bus_from": "231", + "bus_to": "242", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line392": { + "length": 6.068, + "linecode": "lc6", + "bus_from": "388", + "bus_to": "393", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line406": { + "length": 1.6967, + "linecode": "lc3", + "bus_from": "403", + "bus_to": "407", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line237": { + "length": 6.1969, + "linecode": "lc6", + "bus_from": "227", + "bus_to": "238", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line270": { + "length": 4.8291, + "linecode": "lc6", + "bus_from": "261", + "bus_to": "271", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line24": { + "length": 0.138, + "linecode": "lc3", + "bus_from": "23", + "bus_to": "25", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line6": { + "length": 10.9021, + "linecode": "lc3", + "bus_from": "6", + "bus_to": "7", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line427": { + "length": 0.35512, + "linecode": "lc6", + "bus_from": "423", + "bus_to": "428", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line281": { + "length": 0.31208, + "linecode": "lc6", + "bus_from": "270", + "bus_to": "282", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line28": { + "length": 5.9092, + "linecode": "lc3", + "bus_from": "27", + "bus_to": "29", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line439": { + "length": 5.257, + "linecode": "lc6", + "bus_from": "433", + "bus_to": "440", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line369": { + "length": 0.13294, + "linecode": "lc3", + "bus_from": "365", + "bus_to": "370", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line209": { + "length": 12.6298, + "linecode": "lc3", + "bus_from": "201", + "bus_to": "210", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line76": { + "length": 0.79964, + "linecode": "lc8", + "bus_from": "74", + "bus_to": "77", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line200": { + "length": 2.9151, + "linecode": "lc3", + "bus_from": "193", + "bus_to": "201", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line201": { + "length": 0.33984, + "linecode": "lc6", + "bus_from": "194", + "bus_to": "202", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line286": { + "length": 0.5602, + "linecode": "lc8", + "bus_from": "275", + "bus_to": "287", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line303": { + "length": 0.3727, + "linecode": "lc8", + "bus_from": "296", + "bus_to": "304", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "_virtual.line356+line363": { + "length": 12.2431, + "linecode": "lc6", + "bus_from": "364", + "bus_to": "351", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line413": { + "length": 0.2531, + "linecode": "lc6", + "bus_from": "409", + "bus_to": "414", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line95": { + "length": 5.4418, + "linecode": "lc4", + "bus_from": "92", + "bus_to": "96", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line324": { + "length": 0.23314, + "linecode": "lc6", + "bus_from": "318", + "bus_to": "325", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line126": { + "length": 0.11428, + "linecode": "lc2", + "bus_from": "121", + "bus_to": "127", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line0": { + "length": 1.0, + "linecode": "lc3", + "bus_from": "sourcebus", + "bus_to": "1", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line11": { + "length": 0.35891, + "linecode": "lc3", + "bus_from": "11", + "bus_to": "12", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line139": { + "length": 0.098858, + "linecode": "lc3", + "bus_from": "135", + "bus_to": "140", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line148": { + "length": 1.1303, + "linecode": "lc3", + "bus_from": "144", + "bus_to": "149", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line224": { + "length": 0.15902, + "linecode": "lc6", + "bus_from": "213", + "bus_to": "225", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line273": { + "length": 7.6317, + "linecode": "lc3", + "bus_from": "263", + "bus_to": "274", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line162": { + "length": 0.32177, + "linecode": "lc8", + "bus_from": "159", + "bus_to": "163", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line341": { + "length": 0.18832, + "linecode": "lc6", + "bus_from": "336", + "bus_to": "342", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line67": { + "length": 0.72048, + "linecode": "lc8", + "bus_from": "66", + "bus_to": "68", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line133": { + "length": 0.12572, + "linecode": "lc4", + "bus_from": "129", + "bus_to": "134", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line465": { + "length": 1.2335, + "linecode": "lc3", + "bus_from": "464", + "bus_to": "466", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line169": { + "length": 2.1281, + "linecode": "lc3", + "bus_from": "165", + "bus_to": "170", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line182": { + "length": 3.0002, + "linecode": "lc3", + "bus_from": "176", + "bus_to": "183", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line263": { + "length": 0.43115, + "linecode": "lc8", + "bus_from": "253", + "bus_to": "264", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "_virtual.line344+line338+line301+line309+line317+line324+line332+line293+line284": { + "length": 14.194010000000002, + "linecode": "lc6", + "bus_from": "345", + "bus_to": "274", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line278": { + "length": 4.4991, + "linecode": "lc6", + "bus_from": "267", + "bus_to": "279", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line321": { + "length": 3.8953, + "linecode": "lc6", + "bus_from": "314", + "bus_to": "322", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line302": { + "length": 3.0506, + "linecode": "lc3", + "bus_from": "295", + "bus_to": "303", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line389": { + "length": 0.35124, + "linecode": "lc3", + "bus_from": "385", + "bus_to": "390", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line306": { + "length": 1.05, + "linecode": "lc3", + "bus_from": "299", + "bus_to": "307", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "_virtual.line251+line262": { + "length": 12.426400000000001, + "linecode": "lc3", + "bus_from": "263", + "bus_to": "241", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line346": { + "length": 0.43233, + "linecode": "lc8", + "bus_from": "341", + "bus_to": "347", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line356": { + "length": 6.7441, + "linecode": "lc6", + "bus_from": "351", + "bus_to": "357", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line104": { + "length": 0.12802, + "linecode": "lc3", + "bus_from": "100", + "bus_to": "105", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line3": { + "length": 0.11539, + "linecode": "lc3", + "bus_from": "3", + "bus_to": "4", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line269": { + "length": 0.24444, + "linecode": "lc6", + "bus_from": "260", + "bus_to": "270", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line482": { + "length": 0.16227, + "linecode": "lc6", + "bus_from": "482", + "bus_to": "483", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line77": { + "length": 0.61537, + "linecode": "lc4", + "bus_from": "76", + "bus_to": "78", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line146": { + "length": 1.2044, + "linecode": "lc3", + "bus_from": "143", + "bus_to": "147", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line424": { + "length": 2.5729, + "linecode": "lc3", + "bus_from": "421", + "bus_to": "425", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line267": { + "length": 0.78189, + "linecode": "lc3", + "bus_from": "258", + "bus_to": "268", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line128": { + "length": 0.19025, + "linecode": "lc4", + "bus_from": "123", + "bus_to": "129", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line503": { + "length": 0.38204, + "linecode": "lc6", + "bus_from": "503", + "bus_to": "504", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line41": { + "length": 2.3487, + "linecode": "lc3", + "bus_from": "40", + "bus_to": "42", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line1": { + "length": 1.2678, + "linecode": "lc3", + "bus_from": "1", + "bus_to": "2", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line58": { + "length": 0.28481, + "linecode": "lc3", + "bus_from": "58", + "bus_to": "59", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line30": { + "length": 0.615, + "linecode": "lc3", + "bus_from": "29", + "bus_to": "31", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line296": { + "length": 0.32796, + "linecode": "lc3", + "bus_from": "288", + "bus_to": "297", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "_virtual.line415+line435+line410+line442+line419+line428+line423": { + "length": 7.0592500000000005, + "linecode": "lc6", + "bus_from": "406", + "bus_to": "443", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line437": { + "length": 4.3441, + "linecode": "lc3", + "bus_from": "431", + "bus_to": "438", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line498": { + "length": 0.13695, + "linecode": "lc6", + "bus_from": "498", + "bus_to": "499", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line181": { + "length": 0.27554, + "linecode": "lc6", + "bus_from": "176", + "bus_to": "182", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line147": { + "length": 0.25849, + "linecode": "lc3", + "bus_from": "143", + "bus_to": "148", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line371": { + "length": 1.4344, + "linecode": "lc3", + "bus_from": "366", + "bus_to": "372", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line87": { + "length": 0.575, + "linecode": "lc4", + "bus_from": "86", + "bus_to": "88", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "_virtual.line203+line212+line256+line235+line196+line224+line266+line246": { + "length": 10.1129, + "linecode": "lc6", + "bus_from": "190", + "bus_to": "267", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line105": { + "length": 0.16744, + "linecode": "lc8", + "bus_from": "101", + "bus_to": "106", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line46": { + "length": 0.7176, + "linecode": "lc3", + "bus_from": "46", + "bus_to": "47", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "_virtual.line470+line465+line461+line467+line458+line469+line463": { + "length": 12.645999999999999, + "linecode": "lc3", + "bus_from": "471", + "bus_to": "457", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line449": { + "length": 1.201, + "linecode": "lc3", + "bus_from": "445", + "bus_to": "450", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line100": { + "length": 0.138, + "linecode": "lc8", + "bus_from": "97", + "bus_to": "101", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line112": { + "length": 0.20822, + "linecode": "lc8", + "bus_from": "107", + "bus_to": "113", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line274": { + "length": 0.63545, + "linecode": "lc8", + "bus_from": "264", + "bus_to": "275", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line436": { + "length": 2.7415, + "linecode": "lc3", + "bus_from": "430", + "bus_to": "437", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line70": { + "length": 1.0686, + "linecode": "lc8", + "bus_from": "68", + "bus_to": "71", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line187": { + "length": 0.20951, + "linecode": "lc6", + "bus_from": "182", + "bus_to": "188", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line331": { + "length": 5.7252, + "linecode": "lc6", + "bus_from": "324", + "bus_to": "332", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line136": { + "length": 0.11455, + "linecode": "lc2", + "bus_from": "132", + "bus_to": "137", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "_virtual.line498+line491+line474+line480+line494+line500+line481+line489+line496+line486+line495+line499+line479+line488+line504+line482+line483+line487+line477+line493+line492+line503+line502+line501+line484+line490+line485+line497": { + "length": 14.284731000000003, + "linecode": "lc6", + "bus_from": "505", + "bus_to": "472", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line259": { + "length": 0.1516, + "linecode": "lc6", + "bus_from": "250", + "bus_to": "260", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line414": { + "length": 5.6278, + "linecode": "lc6", + "bus_from": "410", + "bus_to": "415", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line451": { + "length": 0.611, + "linecode": "lc6", + "bus_from": "448", + "bus_to": "452", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line78": { + "length": 0.71391, + "linecode": "lc4", + "bus_from": "76", + "bus_to": "79", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "_virtual.line337+line343": { + "length": 8.4637, + "linecode": "lc3", + "bus_from": "344", + "bus_to": "331", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line361": { + "length": 1.0967, + "linecode": "lc6", + "bus_from": "354", + "bus_to": "362", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line53": { + "length": 5.6025, + "linecode": "lc3", + "bus_from": "53", + "bus_to": "54", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "_virtual.line354+line368+line336+line384+line348+line374+line379+line362+line342+line389+line395": { + "length": 19.07565, + "linecode": "lc3", + "bus_from": "396", + "bus_to": "331", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line150": { + "length": 0.27351, + "linecode": "lc8", + "bus_from": "146", + "bus_to": "151", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line318": { + "length": 7.9319, + "linecode": "lc2", + "bus_from": "311", + "bus_to": "319", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line69": { + "length": 5.9972, + "linecode": "lc4", + "bus_from": "67", + "bus_to": "70", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line484": { + "length": 0.23497, + "linecode": "lc6", + "bus_from": "484", + "bus_to": "485", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line20": { + "length": 0.095854, + "linecode": "lc3", + "bus_from": "19", + "bus_to": "21", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line170": { + "length": 0.3027, + "linecode": "lc3", + "bus_from": "166", + "bus_to": "171", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line459": { + "length": 4.7429, + "linecode": "lc6", + "bus_from": "457", + "bus_to": "460", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line417": { + "length": 1.7136, + "linecode": "lc8", + "bus_from": "413", + "bus_to": "418", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line333": { + "length": 1.3548, + "linecode": "lc3", + "bus_from": "328", + "bus_to": "334", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line351": { + "length": 0.77824, + "linecode": "lc3", + "bus_from": "346", + "bus_to": "352", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line19": { + "length": 6.5359, + "linecode": "lc3", + "bus_from": "18", + "bus_to": "20", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line388": { + "length": 0.073246, + "linecode": "lc8", + "bus_from": "384", + "bus_to": "389", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line185": { + "length": 6.995, + "linecode": "lc3", + "bus_from": "179", + "bus_to": "186", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line499": { + "length": 0.13382, + "linecode": "lc6", + "bus_from": "499", + "bus_to": "500", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line152": { + "length": 1.1967, + "linecode": "lc3", + "bus_from": "148", + "bus_to": "153", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line359": { + "length": 0.54267, + "linecode": "lc8", + "bus_from": "353", + "bus_to": "360", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line34": { + "length": 0.29119, + "linecode": "lc3", + "bus_from": "34", + "bus_to": "35", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "_virtual.line103+line128+line133+line109+line122+line116+line138+line142": { + "length": 18.14295, + "linecode": "lc4", + "bus_from": "143", + "bus_to": "100", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line211": { + "length": 0.26446, + "linecode": "lc6", + "bus_from": "203", + "bus_to": "212", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line215": { + "length": 0.18928, + "linecode": "lc6", + "bus_from": "207", + "bus_to": "216", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line173": { + "length": 0.4837, + "linecode": "lc6", + "bus_from": "169", + "bus_to": "174", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line467": { + "length": 1.744, + "linecode": "lc3", + "bus_from": "466", + "bus_to": "468", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line23": { + "length": 0.201, + "linecode": "lc3", + "bus_from": "22", + "bus_to": "24", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line57": { + "length": 0.19624, + "linecode": "lc3", + "bus_from": "57", + "bus_to": "58", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line198": { + "length": 1.6369, + "linecode": "lc3", + "bus_from": "191", + "bus_to": "199", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line393": { + "length": 3.6437, + "linecode": "lc3", + "bus_from": "388", + "bus_to": "394", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line349": { + "length": 6.8184, + "linecode": "lc6", + "bus_from": "344", + "bus_to": "350", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line394": { + "length": 0.087658, + "linecode": "lc8", + "bus_from": "389", + "bus_to": "395", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line464": { + "length": 0.23692, + "linecode": "lc6", + "bus_from": "463", + "bus_to": "465", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "_virtual.line26+line18+line22+line20+line16+line30+line28+line24": { + "length": 7.535132, + "linecode": "lc3", + "bus_from": "31", + "bus_to": "16", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line357": { + "length": 3.0093, + "linecode": "lc3", + "bus_from": "351", + "bus_to": "358", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "_virtual.line218+line208": { + "length": 12.9803, + "linecode": "lc6", + "bus_from": "201", + "bus_to": "219", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line235": { + "length": 0.19228, + "linecode": "lc6", + "bus_from": "225", + "bus_to": "236", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + }, + "line96": { + "length": 0.22561, + "linecode": "lc8", + "bus_from": "93", + "bus_to": "97", + "terminal_map_from": [ + "1", + "2", + "3", + "4" + ], + "terminal_map_to": [ + "1", + "2", + "3", + "4" + ] + } + }, + "voltage_source": { + "source": { + "v_magnitude": [ + 240.0, + 240.0, + 240.0, + 0.0 + ], + "v_angle": [ + 0, + -2.0943951023931953, + -4.1887902047863905, + 0 + ], + "bus": "sourcebus", + "terminal_map": [ + "1", + "2", + "3", + "4" + ] + } + }, + "shunt": { + "4": { + "bus": "479", + "terminal_map": [ + "4" + ], + "G_1_1": 0.1, + "B_1_1": 0.0 + }, + "1": { + "bus": "440", + "terminal_map": [ + "4" + ], + "G_1_1": 0.1, + "B_1_1": 0.0 + }, + "12": { + "bus": "362", + "terminal_map": [ + "4" + ], + "G_1_1": 0.1, + "B_1_1": 0.0 + }, + "2": { + "bus": "181", + "terminal_map": [ + "4" + ], + "G_1_1": 0.1, + "B_1_1": 0.0 + }, + "6": { + "bus": "399", + "terminal_map": [ + "4" + ], + "G_1_1": 0.1, + "B_1_1": 0.0 + }, + "11": { + "bus": "219", + "terminal_map": [ + "4" + ], + "G_1_1": 0.1, + "B_1_1": 0.0 + }, + "13": { + "bus": "361", + "terminal_map": [ + "4" + ], + "G_1_1": 0.1, + "B_1_1": 0.0 + }, + "5": { + "bus": "364", + "terminal_map": [ + "4" + ], + "G_1_1": 0.1, + "B_1_1": 0.0 + }, + "15": { + "bus": "332", + "terminal_map": [ + "4" + ], + "G_1_1": 0.1, + "B_1_1": 0.0 + }, + "16": { + "bus": "477", + "terminal_map": [ + "4" + ], + "G_1_1": 0.1, + "B_1_1": 0.0 + }, + "14": { + "bus": "223", + "terminal_map": [ + "4" + ], + "G_1_1": 0.1, + "B_1_1": 0.0 + }, + "7": { + "bus": "278", + "terminal_map": [ + "4" + ], + "G_1_1": 0.1, + "B_1_1": 0.0 + }, + "8": { + "bus": "232", + "terminal_map": [ + "4" + ], + "G_1_1": 0.1, + "B_1_1": 0.0 + }, + "10": { + "bus": "222", + "terminal_map": [ + "4" + ], + "G_1_1": 0.1, + "B_1_1": 0.0 + }, + "9": { + "bus": "456", + "terminal_map": [ + "4" + ], + "G_1_1": 0.1, + "B_1_1": 0.0 + }, + "3": { + "bus": "196", + "terminal_map": [ + "4" + ], + "G_1_1": 0.1, + "B_1_1": 0.0 + } + } +} \ No newline at end of file diff --git a/tests/data/dist/bmopf/example_ieee13.json b/tests/data/dist/bmopf/example_ieee13.json new file mode 100644 index 0000000..c941406 --- /dev/null +++ b/tests/data/dist/bmopf/example_ieee13.json @@ -0,0 +1,1068 @@ +{ + "bus": { + "671": { + "terminal_names": [ + "1", + "2", + "3" + ], + "perfectly_grounded_terminals": [ + "3" + ] + }, + "680": { + "terminal_names": [ + "1", + "2", + "3" + ], + "perfectly_grounded_terminals": [ + "3" + ] + }, + "634": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ] + }, + "652": { + "terminal_names": [ + "1", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ] + }, + "675": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ] + }, + "650": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ] + }, + "rg60": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ] + }, + "611": { + "terminal_names": [ + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ] + }, + "645": { + "terminal_names": [ + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ] + }, + "632": { + "terminal_names": [ + "1", + "2", + "3" + ], + "perfectly_grounded_terminals": [ + "3" + ] + }, + "633": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ] + }, + "684": { + "terminal_names": [ + "1", + "3" + ], + "perfectly_grounded_terminals": [ + "3" + ] + }, + "sourcebus": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ] + }, + "692": { + "terminal_names": [ + "1", + "2", + "3" + ], + "perfectly_grounded_terminals": [ + "3" + ] + }, + "670": { + "terminal_names": [ + "1", + "2", + "3", + "4" + ], + "perfectly_grounded_terminals": [ + "4" + ] + }, + "646": { + "terminal_names": [ + "2", + "3" + ], + "perfectly_grounded_terminals": [ + "3" + ] + } + }, + "load": { + "634a": { + "configuration": "SINGLE_PHASE", + "p_nom": [ + 160000.0 + ], + "q_nom": [ + 110000.0 + ], + "bus": "634", + "terminal_map": [ + "1", + "4" + ] + }, + "675b": { + "configuration": "SINGLE_PHASE", + "p_nom": [ + 68000.0 + ], + "q_nom": [ + 60000.0 + ], + "bus": "675", + "terminal_map": [ + "2", + "4" + ] + }, + "671": { + "configuration": "DELTA", + "p_nom": [ + 385000.0, + 385000.0, + 385000.0 + ], + "q_nom": [ + 220000.0, + 220000.0, + 220000.0 + ], + "bus": "671", + "terminal_map": [ + "1", + "2", + "3" + ] + }, + "675a": { + "configuration": "SINGLE_PHASE", + "p_nom": [ + 485000.0 + ], + "q_nom": [ + 190000.0 + ], + "bus": "675", + "terminal_map": [ + "1", + "4" + ] + }, + "652": { + "configuration": "SINGLE_PHASE", + "p_nom": [ + 128000.0 + ], + "q_nom": [ + 86000.0 + ], + "bus": "652", + "terminal_map": [ + "1", + "4" + ] + }, + "670c": { + "configuration": "SINGLE_PHASE", + "p_nom": [ + 117000.0 + ], + "q_nom": [ + 68000.0 + ], + "bus": "670", + "terminal_map": [ + "3", + "4" + ] + }, + "611": { + "configuration": "SINGLE_PHASE", + "p_nom": [ + 170000.0 + ], + "q_nom": [ + 80000.0 + ], + "bus": "611", + "terminal_map": [ + "3", + "4" + ] + }, + "645": { + "configuration": "SINGLE_PHASE", + "p_nom": [ + 170000.0 + ], + "q_nom": [ + 125000.0 + ], + "bus": "645", + "terminal_map": [ + "2", + "4" + ] + }, + "634c": { + "configuration": "SINGLE_PHASE", + "p_nom": [ + 120000.0 + ], + "q_nom": [ + 90000.0 + ], + "bus": "634", + "terminal_map": [ + "3", + "4" + ] + }, + "670b": { + "configuration": "SINGLE_PHASE", + "p_nom": [ + 66000.0 + ], + "q_nom": [ + 38000.0 + ], + "bus": "670", + "terminal_map": [ + "2", + "4" + ] + }, + "634b": { + "configuration": "SINGLE_PHASE", + "p_nom": [ + 120000.0 + ], + "q_nom": [ + 90000.0 + ], + "bus": "634", + "terminal_map": [ + "2", + "4" + ] + }, + "675c": { + "configuration": "SINGLE_PHASE", + "p_nom": [ + 290000.0 + ], + "q_nom": [ + 212000.0 + ], + "bus": "675", + "terminal_map": [ + "3", + "4" + ] + }, + "692": { + "configuration": "SINGLE_PHASE", + "p_nom": [ + 170000.0 + ], + "q_nom": [ + 151000.0 + ], + "bus": "692", + "terminal_map": [ + "3", + "1" + ] + }, + "670a": { + "configuration": "SINGLE_PHASE", + "p_nom": [ + 17000.0 + ], + "q_nom": [ + 10000.0 + ], + "bus": "670", + "terminal_map": [ + "1", + "4" + ] + }, + "646": { + "configuration": "SINGLE_PHASE", + "p_nom": [ + 230000.0 + ], + "q_nom": [ + 132000.0 + ], + "bus": "646", + "terminal_map": [ + "2", + "3" + ] + } + }, + "linecode": { + "mtx604b": { + "i_max": [ + 600.0, + 600.0 + ], + "G_from_1_1": 0.0, + "G_to_1_1": 0.0, + "B_from_1_1": 0.0008699434536755112, + "B_to_1_1": 0.0008699434536755112, + "R_series_1_1": 0.0008225936742683155, + "X_series_1_1": 0.0008431616230659293, + "G_from_1_2": 0.0, + "G_to_1_2": 0.0, + "B_from_1_2": -0.00018641645435903812, + "B_to_1_2": -0.00018641645435903812, + "R_series_1_2": 0.00012837879823525758, + "X_series_1_2": 0.00028527931398744796, + "G_from_2_1": 0.0, + "G_to_2_1": 0.0, + "B_from_2_1": -0.00018641645435903812, + "B_to_2_1": -0.00018641645435903812, + "R_series_2_1": 0.00012837879823525758, + "X_series_2_1": 0.00028527931398744796, + "G_from_2_2": 0.0, + "G_to_2_2": 0.0, + "B_from_2_2": 0.0008699434536755112, + "B_to_2_2": 0.0008699434536755112, + "R_series_2_2": 0.0008225936742683155, + "X_series_2_2": 0.0008431616230659293 + }, + "mtx603": { + "i_max": [ + 600.0, + 600.0 + ], + "G_from_1_1": 0.0, + "G_to_1_1": 0.0, + "B_from_1_1": 0.0008699434536755112, + "B_to_1_1": 0.0008699434536755112, + "R_series_1_1": 0.0008225936742683155, + "X_series_1_1": 0.0008431616230659293, + "G_from_1_2": 0.0, + "G_to_1_2": 0.0, + "B_from_1_2": -0.00018641645435903812, + "B_to_1_2": -0.00018641645435903812, + "R_series_1_2": 0.00012837879823525758, + "X_series_1_2": 0.00028527931398744796, + "G_from_2_1": 0.0, + "G_to_2_1": 0.0, + "B_from_2_1": -0.00018641645435903812, + "B_to_2_1": -0.00018641645435903812, + "R_series_2_1": 0.00012837879823525758, + "X_series_2_1": 0.00028527931398744796, + "G_from_2_2": 0.0, + "G_to_2_2": 0.0, + "B_from_2_2": 0.0008699434536755112, + "B_to_2_2": 0.0008699434536755112, + "R_series_2_2": 0.0008260734480830174, + "X_series_2_2": 0.0008370720188902007 + }, + "mtx604": { + "i_max": [ + 600.0, + 600.0 + ], + "G_from_1_1": 0.0, + "G_to_1_1": 0.0, + "B_from_1_1": 0.0008699434536755112, + "B_to_1_1": 0.0008699434536755112, + "R_series_1_1": 0.0008225936742683155, + "X_series_1_1": 0.0008431616230659293, + "G_from_1_2": 0.0, + "G_to_1_2": 0.0, + "B_from_1_2": -0.00018641645435903812, + "B_to_1_2": -0.00018641645435903812, + "R_series_1_2": 0.00012837879823525758, + "X_series_1_2": 0.00028527931398744796, + "G_from_2_1": 0.0, + "G_to_2_1": 0.0, + "B_from_2_1": -0.00018641645435903812, + "B_to_2_1": -0.00018641645435903812, + "R_series_2_1": 0.00012837879823525758, + "X_series_2_1": 0.00028527931398744796, + "G_from_2_2": 0.0, + "G_to_2_2": 0.0, + "B_from_2_2": 0.0008699434536755112, + "B_to_2_2": 0.0008699434536755112, + "R_series_2_2": 0.0008260734480830174, + "X_series_2_2": 0.0008370720188902007 + }, + "mtx602": { + "i_max": [ + 600.0, + 600.0, + 600.0 + ], + "G_from_1_1": 0.0, + "G_to_1_1": 0.0, + "B_from_1_1": 0.0008699434536755112, + "B_to_1_1": 0.0008699434536755112, + "R_series_1_1": 0.00046765674516870695, + "X_series_1_1": 0.0007341079972658921, + "G_from_1_2": 0.0, + "G_to_1_2": 0.0, + "B_from_1_2": -0.00018641645435903812, + "B_to_1_2": -0.00018641645435903812, + "R_series_1_2": 9.81793326290934e-05, + "X_series_1_2": 0.00026322003355496175, + "G_from_1_3": 0.0, + "G_to_1_3": 0.0, + "B_from_1_3": -0.00018641645435903812, + "B_to_1_3": -0.00018641645435903812, + "R_series_1_3": 9.693655626669981e-05, + "X_series_1_3": 0.0003117504505064314, + "G_from_2_1": 0.0, + "G_to_2_1": 0.0, + "B_from_2_1": -0.00018641645435903812, + "B_to_2_1": -0.00018641645435903812, + "R_series_2_1": 9.81793326290934e-05, + "X_series_2_1": 0.00026322003355496175, + "G_from_2_2": 0.0, + "G_to_2_2": 0.0, + "B_from_2_2": 0.0008699434536755112, + "B_to_2_2": 0.0008699434536755112, + "R_series_2_2": 0.0004644876654446033, + "X_series_2_2": 0.0007446094575281177, + "G_from_2_3": 0.0, + "G_to_2_3": 0.0, + "B_from_2_3": -0.00018641645435903812, + "B_to_2_3": -0.00018641645435903812, + "R_series_2_3": 9.538308581370783e-05, + "X_series_2_3": 0.00023917231094264588, + "G_from_3_1": 0.0, + "G_to_3_1": 0.0, + "B_from_3_1": -0.00018641645435903812, + "B_to_3_1": -0.00018641645435903812, + "R_series_3_1": 9.693655626669981e-05, + "X_series_3_1": 0.0003117504505064314, + "G_from_3_2": 0.0, + "G_to_3_2": 0.0, + "B_from_3_2": -0.00018641645435903812, + "B_to_3_2": -0.00018641645435903812, + "R_series_3_2": 9.538308581370783e-05, + "X_series_3_2": 0.00023917231094264588, + "G_from_3_3": 0.0, + "G_to_3_3": 0.0, + "B_from_3_3": 0.0008699434536755112, + "B_to_3_3": 0.0008699434536755112, + "R_series_3_3": 0.0004620642515379358, + "X_series_3_3": 0.0007526253650655565 + }, + "mtx606": { + "i_max": [ + 600.0, + 600.0, + 600.0 + ], + "G_from_1_1": 0.0, + "G_to_1_1": 0.0, + "B_from_1_1": 0.11929037469707326, + "B_to_1_1": 0.11929037469707326, + "R_series_1_1": 0.0004919660722053067, + "X_series_1_1": 0.0002723867520039769, + "G_from_1_2": 0.0, + "G_to_1_2": 0.0, + "B_from_1_2": 0.0, + "B_to_1_2": 0.0, + "R_series_1_2": 0.00019789722239483006, + "X_series_1_2": 1.7202386130615796e-05, + "G_from_1_3": 0.0, + "G_to_1_3": 0.0, + "B_from_1_3": 0.0, + "B_to_1_3": 0.0, + "R_series_1_3": 0.00017613247996023116, + "X_series_1_3": -1.1446218852917417e-05, + "G_from_2_1": 0.0, + "G_to_2_1": 0.0, + "B_from_2_1": 0.0, + "B_to_2_1": 0.0, + "R_series_2_1": 0.00019789722239483006, + "X_series_2_1": 1.7202386130615796e-05, + "G_from_2_2": 0.0, + "G_to_2_2": 0.0, + "B_from_2_2": 0.11929037469707326, + "B_to_2_2": 0.11929037469707326, + "R_series_2_2": 0.0004857074504442926, + "X_series_2_2": 0.0002465028273162245, + "G_from_2_3": 0.0, + "G_to_2_3": 0.0, + "B_from_2_3": 0.0, + "B_to_2_3": 0.0, + "R_series_2_3": 0.00019789722239483006, + "X_series_2_3": 1.7202386130615796e-05, + "G_from_3_1": 0.0, + "G_to_3_1": 0.0, + "B_from_3_1": 0.0, + "B_to_3_1": 0.0, + "R_series_3_1": 0.00017613247996023116, + "X_series_3_1": -1.1446218852917417e-05, + "G_from_3_2": 0.0, + "G_to_3_2": 0.0, + "B_from_3_2": 0.0, + "B_to_3_2": 0.0, + "R_series_3_2": 0.00019789722239483006, + "X_series_3_2": 1.7202386130615796e-05, + "G_from_3_3": 0.0, + "G_to_3_3": 0.0, + "B_from_3_3": 0.11929037469707326, + "B_to_3_3": 0.11929037469707326, + "R_series_3_3": 0.0004919660722053067, + "X_series_3_3": 0.0002723867520039769 + }, + "mtx607": { + "i_max": [ + 600.0 + ], + "G_from_1_1": 0.0, + "G_to_1_1": 0.0, + "B_from_1_1": 0.07332380538122166, + "B_to_1_1": 0.07332380538122166, + "R_series_1_1": 0.0008342136332566954, + "X_series_1_1": 0.00031839930404523704 + }, + "mtx605": { + "i_max": [ + 600.0 + ], + "G_from_1_1": 0.0, + "G_to_1_1": 0.0, + "B_from_1_1": 0.0010563599080345492, + "B_to_1_1": 0.0010563599080345492, + "R_series_1_1": 0.0008259491704467781, + "X_series_1_1": 0.0008373205741626794 + }, + "mtx601": { + "i_max": [ + 600.0, + 600.0, + 600.0 + ], + "G_from_1_1": 0.0, + "G_to_1_1": 0.0, + "B_from_1_1": 0.0008699434536755112, + "B_to_1_1": 0.0008699434536755112, + "R_series_1_1": 0.000215311004784689, + "X_series_1_1": 0.0006325110296402163, + "G_from_1_2": 0.0, + "G_to_1_2": 0.0, + "B_from_1_2": -0.00018641645435903812, + "B_to_1_2": -0.00018641645435903812, + "R_series_1_2": 9.693655626669981e-05, + "X_series_1_2": 0.0003117504505064314, + "G_from_1_3": 0.0, + "G_to_1_3": 0.0, + "B_from_1_3": -0.00018641645435903812, + "B_to_1_3": -0.00018641645435903812, + "R_series_1_3": 9.81793326290934e-05, + "X_series_1_3": 0.00026322003355496175, + "G_from_2_1": 0.0, + "G_to_2_1": 0.0, + "B_from_2_1": -0.00018641645435903812, + "B_to_2_1": -0.00018641645435903812, + "R_series_2_1": 9.693655626669981e-05, + "X_series_2_1": 0.0003117504505064314, + "G_from_2_2": 0.0, + "G_to_2_2": 0.0, + "B_from_2_2": 0.0008699434536755112, + "B_to_2_2": 0.0008699434536755112, + "R_series_2_2": 0.00020971851115391787, + "X_series_2_2": 0.0006510905362580005, + "G_from_2_3": 0.0, + "G_to_2_3": 0.0, + "B_from_2_3": -0.00018641645435903812, + "B_to_2_3": -0.00018641645435903812, + "R_series_2_3": 9.538308581370783e-05, + "X_series_2_3": 0.00023917231094264588, + "G_from_3_1": 0.0, + "G_to_3_1": 0.0, + "B_from_3_1": -0.00018641645435903812, + "B_to_3_1": -0.00018641645435903812, + "R_series_3_1": 9.81793326290934e-05, + "X_series_3_1": 0.00026322003355496175, + "G_from_3_2": 0.0, + "G_to_3_2": 0.0, + "B_from_3_2": -0.00018641645435903812, + "B_to_3_2": -0.00018641645435903812, + "R_series_3_2": 9.538308581370783e-05, + "X_series_3_2": 0.00023917231094264588, + "G_from_3_3": 0.0, + "G_to_3_3": 0.0, + "B_from_3_3": 0.0008699434536755112, + "B_to_3_3": 0.0008699434536755112, + "R_series_3_3": 0.00021214192506058534, + "X_series_3_3": 0.0006430124899024421 + } + }, + "line": { + "632670": { + "length": 203.3016, + "linecode": "mtx601", + "bus_from": "632", + "bus_to": "670", + "terminal_map_from": [ + "1", + "2", + "3" + ], + "terminal_map_to": [ + "1", + "2", + "3" + ] + }, + "632645": { + "length": 152.4, + "linecode": "mtx603", + "bus_from": "632", + "bus_to": "645", + "terminal_map_from": [ + "3", + "2" + ], + "terminal_map_to": [ + "3", + "2" + ] + }, + "684611": { + "length": 91.44, + "linecode": "mtx605", + "bus_from": "684", + "bus_to": "611", + "terminal_map_from": [ + "3" + ], + "terminal_map_to": [ + "3" + ] + }, + "692675": { + "length": 152.4, + "linecode": "mtx606", + "bus_from": "692", + "bus_to": "675", + "terminal_map_from": [ + "1", + "2", + "3" + ], + "terminal_map_to": [ + "1", + "2", + "3" + ] + }, + "671684": { + "length": 91.44, + "linecode": "mtx604", + "bus_from": "671", + "bus_to": "684", + "terminal_map_from": [ + "1", + "3" + ], + "terminal_map_to": [ + "1", + "3" + ] + }, + "645646": { + "length": 91.44, + "linecode": "mtx603", + "bus_from": "645", + "bus_to": "646", + "terminal_map_from": [ + "3", + "2" + ], + "terminal_map_to": [ + "3", + "2" + ] + }, + "650632": { + "length": 609.6, + "linecode": "mtx601", + "bus_from": "rg60", + "bus_to": "632", + "terminal_map_from": [ + "1", + "2", + "3" + ], + "terminal_map_to": [ + "1", + "2", + "3" + ] + }, + "671680": { + "length": 304.8, + "linecode": "mtx601", + "bus_from": "671", + "bus_to": "680", + "terminal_map_from": [ + "1", + "2", + "3" + ], + "terminal_map_to": [ + "1", + "2", + "3" + ] + }, + "632633": { + "length": 152.4, + "linecode": "mtx602", + "bus_from": "632", + "bus_to": "633", + "terminal_map_from": [ + "1", + "2", + "3" + ], + "terminal_map_to": [ + "1", + "2", + "3" + ] + }, + "684652": { + "length": 243.84, + "linecode": "mtx607", + "bus_from": "684", + "bus_to": "652", + "terminal_map_from": [ + "1" + ], + "terminal_map_to": [ + "1" + ] + }, + "670671": { + "length": 406.2984, + "linecode": "mtx601", + "bus_from": "670", + "bus_to": "671", + "terminal_map_from": [ + "1", + "2", + "3" + ], + "terminal_map_to": [ + "1", + "2", + "3" + ] + } + }, + "voltage_source": { + "source": { + "v_magnitude": [ + 66401.92048490264, + 66401.92048490264, + 66401.92048490264, + 0.0 + ], + "v_angle": [ + 0.5235987755982988, + -1.5707963267948966, + 2.6179938779914944, + 0.0 + ], + "bus": "sourcebus", + "terminal_map": [ + "1", + "2", + "3", + "4" + ] + } + }, + "shunt": { + "cap1": { + "bus": "675", + "terminal_map": [ + "1", + "2", + "3" + ], + "G_1_1": 0.0, + "B_1_1": 0.03467085798816568, + "G_1_2": 0.0, + "B_1_2": 0.0, + "G_1_3": 0.0, + "B_1_3": 0.0, + "G_2_1": 0.0, + "B_2_1": 0.0, + "G_2_2": 0.0, + "B_2_2": 0.03467085798816568, + "G_2_3": 0.0, + "B_2_3": 0.0, + "G_3_1": 0.0, + "B_3_1": 0.0, + "G_3_2": 0.0, + "B_3_2": 0.0, + "G_3_3": 0.0, + "B_3_3": 0.03467085798816568 + }, + "cap2": { + "bus": "611", + "terminal_map": [ + "3" + ], + "G_1_1": 0.0, + "B_1_1": 0.017361111111111112 + } + }, + "switch": { + "671692": { + "bus_from": "671", + "bus_to": "692", + "i_max": [ + 600.0, + 600.0, + 600.0 + ], + "open_switch": false, + "terminal_map_from": [ + "1", + "2", + "3" + ], + "terminal_map_to": [ + "1", + "2", + "3" + ] + } + }, + "transformer": { + "single_phase": { + "xfm1_a": { + "bus_from": "633", + "bus_to": "634", + "s_rating": 166666.66666666666, + "x_series_from": 0.0006922240000000001, + "x_series_to": 0.0, + "r_series_from": 0.0001903616, + "r_series_to": 2.5344e-06, + "v_ref_from": 4160.0, + "v_ref_to": 480.0, + "terminal_map_from": [ + "1", + "4" + ], + "terminal_map_to": [ + "1", + "4" + ] + }, + "xfm1_b": { + "bus_from": "633", + "bus_to": "634", + "s_rating": 166666.66666666666, + "x_series_from": 0.0006922240000000001, + "x_series_to": 0.0, + "r_series_from": 0.0001903616, + "r_series_to": 2.5344e-06, + "v_ref_from": 4160.0, + "v_ref_to": 480.0, + "terminal_map_from": [ + "2", + "4" + ], + "terminal_map_to": [ + "2", + "4" + ] + }, + "xfm1_c": { + "bus_from": "633", + "bus_to": "634", + "s_rating": 166666.66666666666, + "x_series_from": 0.0006922240000000001, + "x_series_to": 0.0, + "r_series_from": 0.0001903616, + "r_series_to": 2.5344e-06, + "v_ref_from": 4160.0, + "v_ref_to": 480.0, + "terminal_map_from": [ + "3", + "4" + ], + "terminal_map_to": [ + "3", + "4" + ] + }, + "reg1_a": { + "bus_from": "650", + "bus_to": "rg60", + "s_rating": 1666000.0, + "v_ref_from": 2400.0, + "v_ref_to": 2400.0, + "x_series_from": 3.4573829531812724e-07, + "x_series_to": 0.0, + "r_series_from": 0.0, + "r_series_to": 0.0, + "terminal_map_from": [ + "1", + "4" + ], + "terminal_map_to": [ + "1", + "4" + ] + }, + "reg1_b": { + "bus_from": "650", + "bus_to": "rg60", + "s_rating": 1666000.0, + "v_ref_from": 2400.0, + "v_ref_to": 2400.0, + "x_series_from": 3.4573829531812724e-07, + "x_series_to": 0.0, + "r_series_from": 0.0, + "r_series_to": 0.0, + "terminal_map_from": [ + "2", + "4" + ], + "terminal_map_to": [ + "2", + "4" + ] + }, + "reg1_c": { + "bus_from": "650", + "bus_to": "rg60", + "s_rating": 1666000.0, + "v_ref_from": 2400.0, + "v_ref_to": 2400.0, + "x_series_from": 3.4573829531812724e-07, + "x_series_to": 0.0, + "r_series_from": 0.0, + "r_series_to": 0.0, + "terminal_map_from": [ + "3", + "4" + ], + "terminal_map_to": [ + "3", + "4" + ] + } + }, + "delta_wye": { + "sub": { + "bus_from": "sourcebus", + "bus_to": "650", + "s_rating": 5000000.0, + "x_series": 0.00021160000000000002, + "r_series": 0.00013225000000000002, + "v_ref_from": 115000.0, + "v_ref_to": 4160.0, + "terminal_map_from": [ + "1", + "2", + "3" + ], + "terminal_map_to": [ + "2", + "3", + "1", + "4" + ] + } + }, + "wye_delta": {}, + "center_tap": {} + } +} \ No newline at end of file diff --git a/tests/data/dist/micro/License.md b/tests/data/dist/micro/License.md new file mode 100644 index 0000000..0ed2132 --- /dev/null +++ b/tests/data/dist/micro/License.md @@ -0,0 +1,9 @@ +# License + +The eight `.dss` cases in this directory are original works written for +powerio-dist (no upstream source). They are released under the Creative +Commons Attribution 4.0 International license +(). + +Attribution: "micro distribution test cases, eigenergy powerio contributors, +". diff --git a/tests/data/dist/micro/defaults_degenerate.dss b/tests/data/dist/micro/defaults_degenerate.dss new file mode 100644 index 0000000..e192200 --- /dev/null +++ b/tests/data/dist/micro/defaults_degenerate.dss @@ -0,0 +1,18 @@ +! Degenerate circuit exercising OpenDSS class defaults: every element below +! relies on constructor defaults for its electrical parameters. A converter +! must materialize those defaults explicitly (line r1=0.058 ohm/kft etc., +! load kv=12.47 kw=10 pf=0.88, transformer 12.47/12.47 kV 1000 kVA xhl=7, +! vsource basekv=115 pu=1). +Clear +Set DefaultBaseFrequency=60 + +New Circuit.defaults_degenerate + +New Line.l_default bus1=sourcebus bus2=b2 +New Load.ld_default bus1=b2 +New Transformer.t_default buses=(b2, b3) +New Load.ld2 bus1=b3 kw=20 + +Set VoltageBases=[115, 12.47] +Calcvoltagebases +Solve diff --git a/tests/data/dist/micro/fourwire_linecode.dss b/tests/data/dist/micro/fourwire_linecode.dss new file mode 100644 index 0000000..efd24ee --- /dev/null +++ b/tests/data/dist/micro/fourwire_linecode.dss @@ -0,0 +1,23 @@ +! Four wire line with an explicit neutral conductor (no Kron reduction). +! The neutral is grounded at the source bus (node 0) and carried as node 4 +! on the line; the wye loads return through it. +Clear +Set DefaultBaseFrequency=60 + +New Circuit.fourwire basekv=0.416 pu=1.0 phases=3 bus1=sourcebus MVAsc3=2000 MVAsc1=2100 + +New Linecode.lc4 nphases=4 basefreq=60 units=km +~ rmatrix = (0.211 | 0.049 0.211 | 0.049 0.049 0.211 | 0.049 0.049 0.049 0.211) +~ xmatrix = (0.747 | 0.673 0.747 | 0.651 0.673 0.747 | 0.673 0.651 0.673 0.747) +~ cmatrix = (10.0 | 0.0 10.0 | 0.0 0.0 10.0 | 0.0 0.0 0.0 10.0) +~ normamps=185 emergamps=240 + +New Line.l1 bus1=sourcebus.1.2.3.0 bus2=loadbus.1.2.3.4 phases=4 linecode=lc4 length=0.4 units=km + +New Load.la bus1=loadbus.1.4 phases=1 conn=wye kv=0.24 kw=8 pf=0.95 model=1 vminpu=0.8 vmaxpu=1.2 +New Load.lb bus1=loadbus.2.4 phases=1 conn=wye kv=0.24 kw=6 pf=0.95 model=1 vminpu=0.8 vmaxpu=1.2 +New Load.lc bus1=loadbus.3.4 phases=1 conn=wye kv=0.24 kw=10 pf=0.95 model=1 vminpu=0.8 vmaxpu=1.2 + +Set VoltageBases=[0.416] +Calcvoltagebases +Solve diff --git a/tests/data/dist/micro/linecode_10x10.dss b/tests/data/dist/micro/linecode_10x10.dss new file mode 100644 index 0000000..7295a6e --- /dev/null +++ b/tests/data/dist/micro/linecode_10x10.dss @@ -0,0 +1,24 @@ +! Ten conductor linecode (10x10 matrices). Exercises double digit conductor +! indices end to end; the draft BMOPF schema's flat matrix key pattern +! (R_series__) must accept multi digit indices to represent this case. +Clear +Set DefaultBaseFrequency=60 + +New Circuit.linecode_10x10 basekv=0.416 pu=1.0 phases=3 bus1=sourcebus MVAsc3=2000 MVAsc1=2100 + +New Linecode.lc10 nphases=10 basefreq=60 units=km +~ rmatrix = (0.25 | 0.05 0.25 | 0.05 0.05 0.25 | 0.05 0.05 0.05 0.25 | 0.05 0.05 0.05 0.05 0.25 | 0.05 0.05 0.05 0.05 0.05 0.25 | 0.05 0.05 0.05 0.05 0.05 0.05 0.25 | 0.05 0.05 0.05 0.05 0.05 0.05 0.05 0.25 | 0.05 0.05 0.05 0.05 0.05 0.05 0.05 0.05 0.25 | 0.05 0.05 0.05 0.05 0.05 0.05 0.05 0.05 0.05 0.25) +~ xmatrix = (0.75 | 0.30 0.75 | 0.30 0.30 0.75 | 0.30 0.30 0.30 0.75 | 0.30 0.30 0.30 0.30 0.75 | 0.30 0.30 0.30 0.30 0.30 0.75 | 0.30 0.30 0.30 0.30 0.30 0.30 0.75 | 0.30 0.30 0.30 0.30 0.30 0.30 0.30 0.75 | 0.30 0.30 0.30 0.30 0.30 0.30 0.30 0.30 0.75 | 0.30 0.30 0.30 0.30 0.30 0.30 0.30 0.30 0.30 0.75) +~ cmatrix = (10.0 | 0.0 10.0 | 0.0 0.0 10.0 | 0.0 0.0 0.0 10.0 | 0.0 0.0 0.0 0.0 10.0 | 0.0 0.0 0.0 0.0 0.0 10.0 | 0.0 0.0 0.0 0.0 0.0 0.0 10.0 | 0.0 0.0 0.0 0.0 0.0 0.0 0.0 10.0 | 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 10.0 | 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 10.0) + +! Parallel service: three triplets of conductors plus one spare, all between +! the same pair of buses. Source side bundles each triplet onto phases 1 2 3. +New Line.l10 bus1=sourcebus.1.2.3.1.2.3.1.2.3.0 bus2=loadbus.1.2.3.4.5.6.7.8.9.10 phases=10 linecode=lc10 length=0.2 units=km + +New Load.la bus1=loadbus.1.10 phases=1 conn=wye kv=0.24 kw=5 pf=0.95 model=1 vminpu=0.8 vmaxpu=1.2 +New Load.lb bus1=loadbus.5.10 phases=1 conn=wye kv=0.24 kw=5 pf=0.95 model=1 vminpu=0.8 vmaxpu=1.2 +New Load.lc bus1=loadbus.9.10 phases=1 conn=wye kv=0.24 kw=5 pf=0.95 model=1 vminpu=0.8 vmaxpu=1.2 + +Set VoltageBases=[0.416] +Calcvoltagebases +Solve diff --git a/tests/data/dist/micro/switch.dss b/tests/data/dist/micro/switch.dss new file mode 100644 index 0000000..57102dd --- /dev/null +++ b/tests/data/dist/micro/switch.dss @@ -0,0 +1,24 @@ +! Switch handling: a closed switch line in the main path, an open switch +! feeding a stub, and a SwtControl holding each switch in its state. +Clear +Set DefaultBaseFrequency=60 + +New Circuit.switch_case basekv=12.47 pu=1.0 phases=3 bus1=sourcebus MVAsc3=2000 MVAsc1=2100 + +New Linecode.lc3 nphases=3 basefreq=60 units=km +~ rmatrix = (0.31 | 0.06 0.30 | 0.06 0.06 0.31) +~ xmatrix = (0.69 | 0.30 0.71 | 0.28 0.30 0.69) +~ cmatrix = (10.2 | -3.1 10.4 | -2.9 -3.1 10.2) + +New Line.feeder bus1=sourcebus bus2=mid phases=3 linecode=lc3 length=1.2 units=km +New Line.sw_closed bus1=mid bus2=loadbus phases=3 switch=y +New Line.sw_open bus1=mid bus2=stub phases=3 switch=y + +New SwtControl.sc_closed SwitchedObj=Line.sw_closed SwitchedTerm=1 Normal=closed Action=close +New SwtControl.sc_open SwitchedObj=Line.sw_open SwitchedTerm=1 Normal=open Action=open + +New Load.l1 bus1=loadbus phases=3 conn=wye kv=12.47 kw=500 pf=0.95 model=1 vminpu=0.8 vmaxpu=1.2 + +Set VoltageBases=[12.47] +Calcvoltagebases +Solve diff --git a/tests/data/dist/micro/xfmr_center_tap.dss b/tests/data/dist/micro/xfmr_center_tap.dss new file mode 100644 index 0000000..804edbf --- /dev/null +++ b/tests/data/dist/micro/xfmr_center_tap.dss @@ -0,0 +1,21 @@ +! Center tapped single phase service transformer, 7.2 kV primary to 120/240 V +! split phase secondary. Three windings; winding 3 is reversed (bus nodes .0.2) +! so the two halves stack to 240 V across nodes 1 and 2. +Clear +Set DefaultBaseFrequency=60 + +New Circuit.xfmr_center_tap basekv=7.2 pu=1.0 phases=1 bus1=sourcebus.1 MVAsc3=2000 MVAsc1=2100 + +New Transformer.t1 phases=1 windings=3 +~ wdg=1 bus=sourcebus.1 kv=7.2 kva=25 conn=wye %R=0.6 +~ wdg=2 bus=secondary.1.0 kv=0.12 kva=25 conn=wye %R=1.2 +~ wdg=3 bus=secondary.0.2 kv=0.12 kva=25 conn=wye %R=1.2 +~ xhl=2.04 xht=2.04 xlt=1.36 + +New Load.l120a bus1=secondary.1.0 phases=1 conn=wye kv=0.12 kw=5 pf=0.95 model=1 vminpu=0.8 vmaxpu=1.2 +New Load.l120b bus1=secondary.2.0 phases=1 conn=wye kv=0.12 kw=4 pf=0.95 model=1 vminpu=0.8 vmaxpu=1.2 +New Load.l240 bus1=secondary.1.2 phases=1 conn=wye kv=0.24 kw=6 pf=0.95 model=1 vminpu=0.8 vmaxpu=1.2 + +Set VoltageBases=[7.2, 0.24] +Calcvoltagebases +Solve diff --git a/tests/data/dist/micro/xfmr_delta_wye.dss b/tests/data/dist/micro/xfmr_delta_wye.dss new file mode 100644 index 0000000..c5c968d --- /dev/null +++ b/tests/data/dist/micro/xfmr_delta_wye.dss @@ -0,0 +1,14 @@ +! Three phase delta-wye step down transformer, 12.47 kV delta primary to +! 208 V grounded wye secondary, with a wye connected load. +Clear +Set DefaultBaseFrequency=60 + +New Circuit.xfmr_delta_wye basekv=12.47 pu=1.0 phases=3 bus1=sourcebus MVAsc3=2000 MVAsc1=2100 + +New Transformer.t1 phases=3 windings=2 buses=(sourcebus, secondary) conns=(delta, wye) kvs=(12.47, 0.208) kvas=(300, 300) xhl=5.75 %Rs=(0.5, 0.5) + +New Load.l1 bus1=secondary phases=3 conn=wye kv=0.208 kw=200 pf=0.9 model=1 vminpu=0.8 vmaxpu=1.2 + +Set VoltageBases=[12.47, 0.208] +Calcvoltagebases +Solve diff --git a/tests/data/dist/micro/xfmr_single_phase.dss b/tests/data/dist/micro/xfmr_single_phase.dss new file mode 100644 index 0000000..90aef02 --- /dev/null +++ b/tests/data/dist/micro/xfmr_single_phase.dss @@ -0,0 +1,13 @@ +! Single phase wye-wye distribution transformer, 7.2 kV primary to 240 V secondary. +Clear +Set DefaultBaseFrequency=60 + +New Circuit.xfmr_single_phase basekv=7.2 pu=1.0 phases=1 bus1=sourcebus.1 MVAsc3=2000 MVAsc1=2100 + +New Transformer.t1 phases=1 windings=2 buses=(sourcebus.1, secondary.1) conns=(wye, wye) kvs=(7.2, 0.24) kvas=(25, 25) xhl=2.04 %Rs=(0.6, 0.6) + +New Load.l1 bus1=secondary.1 phases=1 conn=wye kv=0.24 kw=15 pf=0.95 model=1 vminpu=0.8 vmaxpu=1.2 + +Set VoltageBases=[7.2, 0.24] +Calcvoltagebases +Solve diff --git a/tests/data/dist/micro/xfmr_wye_delta.dss b/tests/data/dist/micro/xfmr_wye_delta.dss new file mode 100644 index 0000000..f49e38f --- /dev/null +++ b/tests/data/dist/micro/xfmr_wye_delta.dss @@ -0,0 +1,14 @@ +! Three phase wye-delta step down transformer, 12.47 kV wye primary to 480 V +! delta secondary, with a delta connected load. +Clear +Set DefaultBaseFrequency=60 + +New Circuit.xfmr_wye_delta basekv=12.47 pu=1.0 phases=3 bus1=sourcebus MVAsc3=2000 MVAsc1=2100 + +New Transformer.t1 phases=3 windings=2 buses=(sourcebus, secondary) conns=(wye, delta) kvs=(12.47, 0.48) kvas=(500, 500) xhl=5.75 %Rs=(0.5, 0.5) + +New Load.l1 bus1=secondary phases=3 conn=delta kv=0.48 kw=300 pf=0.9 model=1 vminpu=0.8 vmaxpu=1.2 + +Set VoltageBases=[12.47, 0.48] +Calcvoltagebases +Solve diff --git a/tests/data/dist/opendss/IEEELineCodes.DSS b/tests/data/dist/opendss/IEEELineCodes.DSS new file mode 100644 index 0000000..eba8d90 --- /dev/null +++ b/tests/data/dist/opendss/IEEELineCodes.DSS @@ -0,0 +1,213 @@ +! this file was corrected 9/16/2010 to match the values in Kersting's files + + + +! These line codes are used in the 123-bus circuit + +New linecode.1 nphases=3 BaseFreq=60 units=kft +!!!~ rmatrix = (0.088205 | 0.0312137 0.0901946 | 0.0306264 0.0316143 0.0889665 ) +!!!~ xmatrix = (0.20744 | 0.0935314 0.200783 | 0.0760312 0.0855879 0.204877 ) +!!!~ cmatrix = (2.90301 | -0.679335 3.15896 | -0.22313 -0.481416 2.8965 ) +~ rmatrix = [0.086666667 | 0.029545455 0.088371212 | 0.02907197 0.029924242 0.087405303] +~ xmatrix = [0.204166667 | 0.095018939 0.198522727 | 0.072897727 0.080227273 0.201723485] +~ cmatrix = [2.851710072 | -0.920293787 3.004631862 | -0.350755566 -0.585011253 2.71134756] + +New linecode.2 nphases=3 BaseFreq=60 units=kft +!!!~ rmatrix = (0.0901946 | 0.0316143 0.0889665 | 0.0312137 0.0306264 0.088205 ) +!!!~ xmatrix = (0.200783 | 0.0855879 0.204877 | 0.0935314 0.0760312 0.20744 ) +!!!~ cmatrix = (3.15896 | -0.481416 2.8965 | -0.679335 -0.22313 2.90301 ) +~ rmatrix = [0.088371212 | 0.02992424 0.087405303 | 0.029545455 0.02907197 0.086666667] +~ xmatrix = [0.198522727 | 0.080227273 0.201723485 | 0.095018939 0.072897727 0.204166667] +~ cmatrix = [3.004631862 | -0.585011253 2.71134756 | -0.920293787 -0.350755566 2.851710072] + +New linecode.3 nphases=3 BaseFreq=60 units=kft +!!!~ rmatrix = (0.0889665 | 0.0306264 0.088205 | 0.0316143 0.0312137 0.0901946 ) +!!!~ xmatrix = (0.204877 | 0.0760312 0.20744 | 0.0855879 0.0935314 0.200783 ) +!!!~ cmatrix = (2.8965 | -0.22313 2.90301 | -0.481416 -0.679335 3.15896 ) + +~ rmatrix = [0.087405303 | 0.02907197 0.086666667 | 0.029924242 0.029545455 0.088371212] +~ xmatrix = [0.201723485 | 0.072897727 0.204166667 | 0.080227273 0.095018939 0.198522727] +~ cmatrix = [2.71134756 | -0.350755566 2.851710072 | -0.585011253 -0.920293787 3.004631862] + +New linecode.4 nphases=3 BaseFreq=60 units=kft +!!!~ rmatrix = (0.0889665 | 0.0316143 0.0901946 | 0.0306264 0.0312137 0.088205 ) +!!!~ xmatrix = (0.204877 | 0.0855879 0.200783 | 0.0760312 0.0935314 0.20744 ) +!!!~ cmatrix = (2.8965 | -0.481416 3.15896 | -0.22313 -0.679335 2.90301 ) +~ rmatrix = [0.087405303 | 0.029924242 0.088371212 | 0.02907197 0.029545455 0.086666667] +~ xmatrix = [0.201723485 | 0.080227273 0.198522727 | 0.072897727 0.095018939 0.204166667] +~ cmatrix = [2.71134756 | -0.585011253 3.004631862 | -0.350755566 -0.920293787 2.851710072] + +New linecode.5 nphases=3 BaseFreq=60 units=kft +!!!~ rmatrix = (0.0901946 | 0.0312137 0.088205 | 0.0316143 0.0306264 0.0889665 ) +!!!~ xmatrix = (0.200783 | 0.0935314 0.20744 | 0.0855879 0.0760312 0.204877 ) +!!!~ cmatrix = (3.15896 | -0.679335 2.90301 | -0.481416 -0.22313 2.8965 ) + +~ rmatrix = [0.088371212 | 0.029545455 0.086666667 | 0.029924242 0.02907197 0.087405303] +~ xmatrix = [0.198522727 | 0.095018939 0.204166667 | 0.080227273 0.072897727 0.201723485] +~ cmatrix = [3.004631862 | -0.920293787 2.851710072 | -0.585011253 -0.350755566 2.71134756] + +New linecode.6 nphases=3 BaseFreq=60 units=kft +!!!~ rmatrix = (0.088205 | 0.0306264 0.0889665 | 0.0312137 0.0316143 0.0901946 ) +!!!~ xmatrix = (0.20744 | 0.0760312 0.204877 | 0.0935314 0.0855879 0.200783 ) +!!!~ cmatrix = (2.90301 | -0.22313 2.8965 | -0.679335 -0.481416 3.15896 ) +~ rmatrix = [0.086666667 | 0.02907197 0.087405303 | 0.029545455 0.029924242 0.088371212] +~ xmatrix = [0.204166667 | 0.072897727 0.201723485 | 0.095018939 0.080227273 0.198522727] +~ cmatrix = [2.851710072 | -0.350755566 2.71134756 | -0.920293787 -0.585011253 3.004631862] +New linecode.7 nphases=2 BaseFreq=60 units=kft +!!!~ rmatrix = (0.088205 | 0.0306264 0.0889665 ) +!!!~ xmatrix = (0.20744 | 0.0760312 0.204877 ) +!!!~ cmatrix = (2.75692 | -0.326659 2.82313 ) +~ rmatrix = [0.086666667 | 0.02907197 0.087405303] +~ xmatrix = [0.204166667 | 0.072897727 0.201723485] +~ cmatrix = [2.569829596 | -0.52995137 2.597460011] +New linecode.8 nphases=2 BaseFreq=60 units=kft +!!!~ rmatrix = (0.088205 | 0.0306264 0.0889665 ) +!!!~ xmatrix = (0.20744 | 0.0760312 0.204877 ) +!!!~ cmatrix = (2.75692 | -0.326659 2.82313 ) +~ rmatrix = [0.086666667 | 0.02907197 0.087405303] +~ xmatrix = [0.204166667 | 0.072897727 0.201723485] +~ cmatrix = [2.569829596 | -0.52995137 2.597460011] +New linecode.9 nphases=1 BaseFreq=60 units=kft +!!!~ rmatrix = (0.254428 ) +!!!~ xmatrix = (0.259546 ) +!!!~ cmatrix = (2.50575 ) +~ rmatrix = [0.251742424] +~ xmatrix = [0.255208333] +~ cmatrix = [2.270366128] +New linecode.10 nphases=1 BaseFreq=60 units=kft +!!!~ rmatrix = (0.254428 ) +!!!~ xmatrix = (0.259546 ) +!!!~ cmatrix = (2.50575 ) +~ rmatrix = [0.251742424] +~ xmatrix = [0.255208333] +~ cmatrix = [2.270366128] +New linecode.11 nphases=1 BaseFreq=60 units=kft +!!!~ rmatrix = (0.254428 ) +!!!~ xmatrix = (0.259546 ) +!!!~ cmatrix = (2.50575 ) +~ rmatrix = [0.251742424] +~ xmatrix = [0.255208333] +~ cmatrix = [2.270366128] +New linecode.12 nphases=3 BaseFreq=60 units=kft +!!!~ rmatrix = (0.291814 | 0.101656 0.294012 | 0.096494 0.101656 0.291814 ) +!!!~ xmatrix = (0.141848 | 0.0517936 0.13483 | 0.0401881 0.0517936 0.141848 ) +!!!~ cmatrix = (53.4924 | 0 53.4924 | 0 0 53.4924 ) +~ rmatrix = [0.288049242 | 0.09844697 0.29032197 | 0.093257576 0.09844697 0.288049242] +~ xmatrix = [0.142443182 | 0.052556818 0.135643939 | 0.040852273 0.052556818 0.142443182] +~ cmatrix = [33.77150149 | 0 33.77150149 | 0 0 33.77150149] + +! These line codes are used in the 34-node test feeder + +New linecode.300 nphases=3 basefreq=60 units=kft ! ohms per 1000ft Corrected 11/30/05 +~ rmatrix = [0.253181818 | 0.039791667 0.250719697 | 0.040340909 0.039128788 0.251780303] !ABC ORDER +~ xmatrix = [0.252708333 | 0.109450758 0.256988636 | 0.094981061 0.086950758 0.255132576] +~ CMATRIX = [2.680150309 | -0.769281006 2.5610381 | -0.499507676 -0.312072984 2.455590387] +New linecode.301 nphases=3 basefreq=60 units=kft +~ rmatrix = [0.365530303 | 0.04407197 0.36282197 | 0.04467803 0.043333333 0.363996212] +~ xmatrix = [0.267329545 | 0.122007576 0.270473485 | 0.107784091 0.099204545 0.269109848] +~ cmatrix = [2.572492163 | -0.72160598 2.464381882 | -0.472329395 -0.298961096 2.368881119] +New linecode.302 nphases=1 basefreq=60 units=kft +~ rmatrix = (0.530208 ) +~ xmatrix = (0.281345 ) +~ cmatrix = (2.12257 ) +New linecode.303 nphases=1 basefreq=60 units=kft +~ rmatrix = (0.530208 ) +~ xmatrix = (0.281345 ) +~ cmatrix = (2.12257 ) +New linecode.304 nphases=1 basefreq=60 units=kft +~ rmatrix = (0.363958 ) +~ xmatrix = (0.269167 ) +~ cmatrix = (2.1922 ) + + +! This may be for the 4-node test feeder, but is not actually referenced. +! instead, the 4Bus*.dss files all use the wiredata and linegeometry inputs +! to calculate these matrices from physical data. + +New linecode.400 nphases=3 BaseFreq=60 +~ rmatrix = (0.088205 | 0.0312137 0.0901946 | 0.0306264 0.0316143 0.0889665 ) +~ xmatrix = (0.20744 | 0.0935314 0.200783 | 0.0760312 0.0855879 0.204877 ) +~ cmatrix = (2.90301 | -0.679335 3.15896 | -0.22313 -0.481416 2.8965 ) + +! These are for the 13-node test feeder + +New linecode.601 nphases=3 BaseFreq=60 +!!!~ rmatrix = (0.0674673 | 0.0312137 0.0654777 | 0.0316143 0.0306264 0.0662392 ) +!!!~ xmatrix = (0.195204 | 0.0935314 0.201861 | 0.0855879 0.0760312 0.199298 ) +!!!~ cmatrix = (3.32591 | -0.743055 3.04217 | -0.525237 -0.238111 3.03116 ) +~ rmatrix = [0.065625 | 0.029545455 0.063920455 | 0.029924242 0.02907197 0.064659091] +~ xmatrix = [0.192784091 | 0.095018939 0.19844697 | 0.080227273 0.072897727 0.195984848] +~ cmatrix = [3.164838036 | -1.002632425 2.993981593 | -0.632736516 -0.372608713 2.832670203] +New linecode.602 nphases=3 BaseFreq=60 +!!!~ rmatrix = (0.144361 | 0.0316143 0.143133 | 0.0312137 0.0306264 0.142372 ) +!!!~ xmatrix = (0.226028 | 0.0855879 0.230122 | 0.0935314 0.0760312 0.232686 ) +!!!~ cmatrix = (3.01091 | -0.443561 2.77543 | -0.624494 -0.209615 2.77847 ) +~ rmatrix = [0.142537879 | 0.029924242 0.14157197 | 0.029545455 0.02907197 0.140833333] +~ xmatrix = [0.22375 | 0.080227273 0.226950758 | 0.095018939 0.072897727 0.229393939] +~ cmatrix = [2.863013423 | -0.543414918 2.602031589 | -0.8492585 -0.330962141 2.725162768] +New linecode.603 nphases=2 BaseFreq=60 +!!!~ rmatrix = (0.254472 | 0.0417943 0.253371 ) +!!!~ xmatrix = (0.259467 | 0.0912376 0.261431 ) +!!!~ cmatrix = (2.54676 | -0.28882 2.49502 ) +~ rmatrix = [0.251780303 | 0.039128788 0.250719697] +~ xmatrix = [0.255132576 | 0.086950758 0.256988636] +~ cmatrix = [2.366017603 | -0.452083836 2.343963508] +New linecode.604 nphases=2 BaseFreq=60 +!!!~ rmatrix = (0.253371 | 0.0417943 0.254472 ) +!!!~ xmatrix = (0.261431 | 0.0912376 0.259467 ) +!!!~ cmatrix = (2.49502 | -0.28882 2.54676 ) +~ rmatrix = [0.250719697 | 0.039128788 0.251780303] +~ xmatrix = [0.256988636 | 0.086950758 0.255132576] +~ cmatrix = [2.343963508 | -0.452083836 2.366017603] +New linecode.605 nphases=1 BaseFreq=60 +!!!~ rmatrix = (0.254428 ) +!!!~ xmatrix = (0.259546 ) +!!!~ cmatrix = (2.50575 ) +~ rmatrix = [0.251742424] +~ xmatrix = [0.255208333] +~ cmatrix = [2.270366128] +New linecode.606 nphases=3 BaseFreq=60 +!!!~ rmatrix = (0.152193 | 0.0611362 0.15035 | 0.0546992 0.0611362 0.152193 ) +!!!~ xmatrix = (0.0825685 | 0.00548281 0.0745027 | -0.00339824 0.00548281 0.0825685 ) +!!!~ cmatrix = (72.7203 | 0 72.7203 | 0 0 72.7203 ) +~ rmatrix = [0.151174242 | 0.060454545 0.149450758 | 0.053958333 0.060454545 0.151174242] +~ xmatrix = [0.084526515 | 0.006212121 0.076534091 | -0.002708333 0.006212121 0.084526515] +~ cmatrix = [48.67459408 | 0 48.67459408 | 0 0 48.67459408] +New linecode.607 nphases=1 BaseFreq=60 +!!!~ rmatrix = (0.255799 ) +!!!~ xmatrix = (0.092284 ) +!!!~ cmatrix = (50.7067 ) +~ rmatrix = [0.254261364] +~ xmatrix = [0.097045455] +~ cmatrix = [44.70661522] + +! These are for the 37-node test feeder, all underground + +New linecode.721 nphases=3 BaseFreq=60 +!!!~ rmatrix = (0.0554906 | 0.0127467 0.0501597 | 0.00640446 0.0127467 0.0554906 ) +!!!~ xmatrix = (0.0372331 | -0.00704588 0.0358645 | -0.00796424 -0.00704588 0.0372331 ) +!!!~ cmatrix = (124.851 | 0 124.851 | 0 0 124.851 ) +~ rmatrix = [0.055416667 | 0.012746212 0.050113636 | 0.006382576 0.012746212 0.055416667] +~ xmatrix = [0.037367424 | -0.006969697 0.035984848 | -0.007897727 -0.006969697 0.037367424] +~ cmatrix = [80.27484728 | 0 80.27484728 | 0 0 80.27484728] +New linecode.722 nphases=3 BaseFreq=60 +!!!~ rmatrix = (0.0902251 | 0.0309584 0.0851482 | 0.0234946 0.0309584 0.0902251 ) +!!!~ xmatrix = (0.055991 | -0.00646552 0.0504025 | -0.0117669 -0.00646552 0.055991 ) +!!!~ cmatrix = (93.4896 | 0 93.4896 | 0 0 93.4896 ) +~ rmatrix = [0.089981061 | 0.030852273 0.085 | 0.023371212 0.030852273 0.089981061] +~ xmatrix = [0.056306818 | -0.006174242 0.050719697 | -0.011496212 -0.006174242 0.056306818] +~ cmatrix = [64.2184109 | 0 64.2184109 | 0 0 64.2184109] +New linecode.723 nphases=3 BaseFreq=60 +!!!~ rmatrix = (0.247572 | 0.0947678 0.249104 | 0.0893782 0.0947678 0.247572 ) +!!!~ xmatrix = (0.126339 | 0.0390337 0.118816 | 0.0279344 0.0390337 0.126339 ) +!!!~ cmatrix = (58.108 | 0 58.108 | 0 0 58.108 ) +~ rmatrix = [0.245 | 0.092253788 0.246628788 | 0.086837121 0.092253788 0.245] +~ xmatrix = [0.127140152 | 0.039981061 0.119810606 | 0.028806818 0.039981061 0.127140152] +~ cmatrix = [37.5977112 | 0 37.5977112 | 0 0 37.5977112] +New linecode.724 nphases=3 BaseFreq=60 +!!!~ rmatrix = (0.399883 | 0.101765 0.402011 | 0.0965199 0.101765 0.399883 ) +!!!~ xmatrix = (0.146325 | 0.0510963 0.139305 | 0.0395402 0.0510963 0.146325 ) +!!!~ cmatrix = (46.9685 | 0 46.9685 | 0 0 46.9685 ) +~ rmatrix = [0.396818182 | 0.098560606 0.399015152 | 0.093295455 0.098560606 0.396818182] +~ xmatrix = [0.146931818 | 0.051856061 0.140113636 | 0.040208333 0.051856061 0.146931818] +~ cmatrix = [30.26701029 | 0 30.26701029 | 0 0 30.26701029] diff --git a/tests/data/dist/opendss/License.txt b/tests/data/dist/opendss/License.txt new file mode 100644 index 0000000..8234eed --- /dev/null +++ b/tests/data/dist/opendss/License.txt @@ -0,0 +1,29 @@ +* Copyright (c) 2018-2024, DSS-Extensions contributors +* Copyright (c) 2008-2024, Electric Power Research Institute, Inc. +* All rights reserved. +* +* Redistribution and use in source and binary forms, with or without +* modification, are permitted provided that the following conditions are met: +* * Redistributions of source code must retain the above copyright +* notice, this list of conditions and the following disclaimer. +* * Redistributions in binary form must reproduce the above copyright +* notice, this list of conditions and the following disclaimer in the +* documentation and/or other materials provided with the distribution. +* * Neither the name of the Electric Power Research Institute, Inc., nor +* the names of its contributors may be used to endorse or promote +* products derived from this software without specific prior written +* permission. +* +* THIS SOFTWARE IS PROVIDED BY Electric Power Research Institute, Inc., "AS IS" +* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +* DISCLAIMED. IN NO EVENT SHALL Electric Power Research Institute, Inc., OR ANY OTHER +* ENTITY CONTRIBUTING TO OR INVOLVED IN THE PROVISION OF THE SOFTWARE, BE +* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +* POSSIBILITY OF SUCH DAMAGE. + diff --git a/tests/data/dist/opendss/ieee123/IEEE123Loads.DSS b/tests/data/dist/opendss/ieee123/IEEE123Loads.DSS new file mode 100644 index 0000000..4529741 --- /dev/null +++ b/tests/data/dist/opendss/ieee123/IEEE123Loads.DSS @@ -0,0 +1,100 @@ +! +! LOAD DEFINITIONS +! +! Note that 1-phase loads have a voltage rating = to actual voltage across terminals +! This could be either 2.4kV for Wye connectoin or 4.16 kV for Delta or Line-Line connection. +! 3-phase loads are rated Line-Line (as are 2-phase loads, but there are none in this case). +! Only the balanced 3-phase loads are declared as 3-phase; unbalanced 3-phase loads are declared +! as three 1-phase loads. + +New Load.S1a Bus1=1.1 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0 +New Load.S2b Bus1=2.2 Phases=1 Conn=Wye Model=1 kV=2.4 kW=20.0 kvar=10.0 +New Load.S4c Bus1=4.3 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0 +New Load.S5c Bus1=5.3 Phases=1 Conn=Wye Model=5 kV=2.4 kW=20.0 kvar=10.0 +New Load.S6c Bus1=6.3 Phases=1 Conn=Wye Model=2 kV=2.4 kW=40.0 kvar=20.0 +New Load.S7a Bus1=7.1 Phases=1 Conn=Wye Model=1 kV=2.4 kW=20.0 kvar=10.0 +New Load.S9a Bus1=9.1 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0 +New Load.S10a Bus1=10.1 Phases=1 Conn=Wye Model=5 kV=2.4 kW=20.0 kvar=10.0 +New Load.S11a Bus1=11.1 Phases=1 Conn=Wye Model=2 kV=2.4 kW=40.0 kvar=20.0 +New Load.S12b Bus1=12.2 Phases=1 Conn=Wye Model=1 kV=2.4 kW=20.0 kvar=10.0 +New Load.S16c Bus1=16.3 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0 +New Load.S17c Bus1=17.3 Phases=1 Conn=Wye Model=1 kV=2.4 kW=20.0 kvar=10.0 +New Load.S19a Bus1=19.1 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0 +New Load.S20a Bus1=20.1 Phases=1 Conn=Wye Model=5 kV=2.4 kW=40.0 kvar=20.0 +New Load.S22b Bus1=22.2 Phases=1 Conn=Wye Model=2 kV=2.4 kW=40.0 kvar=20.0 +New Load.S24c Bus1=24.3 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0 +New Load.S28a Bus1=28.1 Phases=1 Conn=Wye Model=5 kV=2.4 kW=40.0 kvar=20.0 +New Load.S29a Bus1=29.1 Phases=1 Conn=Wye Model=2 kV=2.4 kW=40.0 kvar=20.0 +New Load.S30c Bus1=30.3 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0 +New Load.S31c Bus1=31.3 Phases=1 Conn=Wye Model=1 kV=2.4 kW=20.0 kvar=10.0 +New Load.S32c Bus1=32.3 Phases=1 Conn=Wye Model=1 kV=2.4 kW=20.0 kvar=10.0 +New Load.S33a Bus1=33.1 Phases=1 Conn=Wye Model=5 kV=2.4 kW=40.0 kvar=20.0 +New Load.S34c Bus1=34.3 Phases=1 Conn=Wye Model=2 kV=2.4 kW=40.0 kvar=20.0 +New Load.S35a Bus1=35.1.2 Phases=1 Conn=Delta Model=1 kV=4.160 kW=40.0 kvar=20.0 +New Load.S37a Bus1=37.1 Phases=1 Conn=Wye Model=2 kV=2.4 kW=40.0 kvar=20.0 +New Load.S38b Bus1=38.2 Phases=1 Conn=Wye Model=5 kV=2.4 kW=20.0 kvar=10.0 +New Load.S39b Bus1=39.2 Phases=1 Conn=Wye Model=1 kV=2.4 kW=20.0 kvar=10.0 +New Load.S41c Bus1=41.3 Phases=1 Conn=Wye Model=1 kV=2.4 kW=20.0 kvar=10.0 +New Load.S42a Bus1=42.1 Phases=1 Conn=Wye Model=1 kV=2.4 kW=20.0 kvar=10.0 +New Load.S43b Bus1=43.2 Phases=1 Conn=Wye Model=2 kV=2.4 kW=40.0 kvar=20.0 +New Load.S45a Bus1=45.1 Phases=1 Conn=Wye Model=5 kV=2.4 kW=20.0 kvar=10.0 +New Load.S46a Bus1=46.1 Phases=1 Conn=Wye Model=1 kV=2.4 kW=20.0 kvar=10.0 +New Load.S47 Bus1=47 Phases=3 Conn=Wye Model=5 kV=4.160 kW=105.0 kvar=75.0 +New Load.S48 Bus1=48 Phases=3 Conn=Wye Model=2 kV=4.160 kW=210.0 kVAR=150.0 +New Load.S49a Bus1=49.1 Phases=1 Conn=Wye Model=1 kV=2.4 kW=35.0 kvar=25.0 +New Load.S49b Bus1=49.2 Phases=1 Conn=Wye Model=1 kV=2.4 kW=70.0 kvar=50.0 +New Load.S49c Bus1=49.3 Phases=1 Conn=Wye Model=1 kV=2.4 kW=35.0 kvar=20.0 ! used to be 25 in on-line document +New Load.S50c Bus1=50.3 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0 +New Load.S51a Bus1=51.1 Phases=1 Conn=Wye Model=1 kV=2.4 kW=20.0 kvar=10.0 +New Load.S52a Bus1=52.1 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0 +New Load.S53a Bus1=53.1 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0 +New Load.S55a Bus1=55.1 Phases=1 Conn=Wye Model=2 kV=2.4 kW=20.0 kvar=10.0 +New Load.S56b Bus1=56.2 Phases=1 Conn=Wye Model=1 kV=2.4 kW=20.0 kvar=10.0 +New Load.S58b Bus1=58.2 Phases=1 Conn=Wye Model=5 kV=2.4 kW=20.0 kvar=10.0 +New Load.S59b Bus1=59.2 Phases=1 Conn=Wye Model=1 kV=2.4 kW=20.0 kvar=10.0 +New Load.S60a Bus1=60.1 Phases=1 Conn=Wye Model=1 kV=2.4 kW=20.0 kvar=10.0 +New Load.S62c Bus1=62.3 Phases=1 Conn=Wye Model=2 kV=2.4 kW=40.0 kvar=20.0 +New Load.S63a Bus1=63.1 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0 +New Load.S64b Bus1=64.2 Phases=1 Conn=Wye Model=5 kV=2.4 kW=75.0 kvar=35.0 +New Load.S65a Bus1=65.1.2 Phases=1 Conn=Delta Model=2 kV=4.160 kW=35.0 kvar=25.0 +New Load.S65b Bus1=65.2.3 Phases=1 Conn=Delta Model=2 kV=4.160 kW=35.0 kvar=25.0 +New Load.S65c Bus1=65.3.1 Phases=1 Conn=Delta Model=2 kV=4.160 kW=70.0 kvar=50.0 +New Load.S66c Bus1=66.3 Phases=1 Conn=Wye Model=1 kV=2.4 kW=75.0 kvar=35.0 +New Load.S68a Bus1=68.1 Phases=1 Conn=Wye Model=1 kV=2.4 kW=20.0 kvar=10.0 +New Load.S69a Bus1=69.1 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0 +New Load.S70a Bus1=70.1 Phases=1 Conn=Wye Model=1 kV=2.4 kW=20.0 kvar=10.0 +New Load.S71a Bus1=71.1 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0 +New Load.S73c Bus1=73.3 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0 +New Load.S74c Bus1=74.3 Phases=1 Conn=Wye Model=2 kV=2.4 kW=40.0 kvar=20.0 +New Load.S75c Bus1=75.3 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0 +New Load.S76a Bus1=76.1.2 Phases=1 Conn=Delta Model=5 kV=4.160 kW=105.0 kvar=80.0 +New Load.S76b Bus1=76.2.3 Phases=1 Conn=Delta Model=5 kV=4.160 kW=70.0 kvar=50.0 +New Load.S76c Bus1=76.3.1 Phases=1 Conn=Delta Model=5 kV=4.160 kW=70.0 kvar=50.0 +New Load.S77b Bus1=77.2 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0 +New Load.S79a Bus1=79.1 Phases=1 Conn=Wye Model=2 kV=2.4 kW=40.0 kvar=20.0 +New Load.S80b Bus1=80.2 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0 +New Load.S82a Bus1=82.1 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0 +New Load.S83c Bus1=83.3 Phases=1 Conn=Wye Model=1 kV=2.4 kW=20.0 kvar=10.0 +New Load.S84c Bus1=84.3 Phases=1 Conn=Wye Model=1 kV=2.4 kW=20.0 kvar=10.0 +New Load.S85c Bus1=85.3 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0 +New Load.S86b Bus1=86.2 Phases=1 Conn=Wye Model=1 kV=2.4 kW=20.0 kvar=10.0 +New Load.S87b Bus1=87.2 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0 +New Load.S88a Bus1=88.1 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0 +New Load.S90b Bus1=90.2 Phases=1 Conn=Wye Model=5 kV=2.4 kW=40.0 kvar=20.0 +New Load.S92c Bus1=92.3 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0 +New Load.S94a Bus1=94.1 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0 +New Load.S95b Bus1=95.2 Phases=1 Conn=Wye Model=1 kV=2.4 kW=20.0 kvar=10.0 +New Load.S96b Bus1=96.2 Phases=1 Conn=Wye Model=1 kV=2.4 kW=20.0 kvar=10.0 +New Load.S98a Bus1=98.1 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0 +New Load.S99b Bus1=99.2 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0 +New Load.S100c Bus1=100.3 Phases=1 Conn=Wye Model=2 kV=2.4 kW=40.0 kvar=20.0 +New Load.S102c Bus1=102.3 Phases=1 Conn=Wye Model=1 kV=2.4 kW=20.0 kvar=10.0 +New Load.S103c Bus1=103.3 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0 +New Load.S104c Bus1=104.3 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0 +New Load.S106b Bus1=106.2 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0 +New Load.S107b Bus1=107.2 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0 +New Load.S109a Bus1=109.1 Phases=1 Conn=Wye Model=1 kV=2.4 kW=40.0 kvar=20.0 +New Load.S111a Bus1=111.1 Phases=1 Conn=Wye Model=1 kV=2.4 kW=20.0 kvar=10.0 +New Load.S112a Bus1=112.1 Phases=1 Conn=Wye Model=5 kV=2.4 kW=20.0 kvar=10.0 +New Load.S113a Bus1=113.1 Phases=1 Conn=Wye Model=2 kV=2.4 kW=40.0 kvar=20.0 +New Load.S114a Bus1=114.1 Phases=1 Conn=Wye Model=1 kV=2.4 kW=20.0 kvar=10.0 diff --git a/tests/data/dist/opendss/ieee123/IEEE123Master.dss b/tests/data/dist/opendss/ieee123/IEEE123Master.dss new file mode 100644 index 0000000..1f234a8 --- /dev/null +++ b/tests/data/dist/opendss/ieee123/IEEE123Master.dss @@ -0,0 +1,222 @@ +! Annotated Master file for the IEEE 123-bus test case. +! +! This file is meant to be invoked from the Compile command in the "Run_IEEE123Bus.DSS" file. +! +! Note: DSS commands, property names, etc., are NOT case sensitive. Capitalize as you please. +! You should always do a "Clear" before making a new circuit: + +Clear +Set DefaultBaseFrequency=60 + +! INSTANTIATE A NEW CIRCUIT AND DEFINE A STIFF 4160V SOURCE +! The new circuit is called "ieee123" +! This creates a Vsource object connected to "sourcebus". This is now the active circuit element, so +! you can simply continue to edit its property value. +! The basekV is redefined to 4.16 kV. The bus name is changed to "150" to match one of the buses in the test feeder. +! The source is set for 1.0 per unit and the Short circuit impedance is set to a small value (0.0001 ohms) +! The ~ is just shorthad for "more" for the New or Edit commands + +New object=circuit.ieee123 +~ basekv=4.16 Bus1=150 pu=1.00 R1=0 X1=0.0001 R0=0 X0=0.0001 + +! 3-PHASE GANGED REGULATOR AT HEAD OF FEEDER (KERSTING ASSUMES NO IMPEDANCE IN THE REGULATOR) +! the first line defines the 3-phase transformer to be controlled by the regulator control. +! The 2nd line defines the properties of the regulator control according to the test case + +new transformer.reg1a phases=3 windings=2 buses=[150 150r] conns=[wye wye] kvs=[4.16 4.16] kvas=[5000 5000] XHL=.001 %LoadLoss=0.00001 ppm=0.0 +new regcontrol.creg1a transformer=reg1a winding=2 vreg=120 band=2 ptratio=20 ctprim=700 R=3 X=7.5 + +! REDIRECT INPUT STREAM TO FILE CONTAINING DEFINITIONS OF LINECODES +! This file defines the line impedances is a similar manner to the description in the test case. + +Redirect IEEELineCodes.DSS + +! LINE DEFINITIONS +! Lines are defined by referring to a "linecode" that contains the impedances per unit length +! So the only properties required are the LineCode name and Length. Units are assumed to match the definition +! since no units property is defined in either the Linecodes file or this file. +! Note that it is not necessary to explicitly specify the node connections for the 3-phase lines +! unless they are not ".1.2.3". However, they are spelled out here for clarity. +! The DSS assumes .1.2.3.0.0 ... for connections of 3 or more phases. +! Likewise, .1 is not necessary for 1-phase lines connected to phase 1. However, if it is connected +! to any other phase, it must be specified. For completeness, everything is spelled out here. +! +! Note that it is recommended that the "units=" property be used here and in the Linecode definition as well +! to avoid confusion in the future + +! *** Original *** New Line.L115 Phases=3 Bus1=149.1.2.3 Bus2=1.1.2.3 LineCode=1 Length=0.4 +! Since the default is 3-phase, the definition of this line can be simpler: + +New Line.L115 Bus1=149 Bus2=1 LineCode=1 Length=0.4 units=kft + +New Line.L1 Phases=1 Bus1=1.2 Bus2=2.2 LineCode=10 Length=0.175 units=kft +New Line.L2 Phases=1 Bus1=1.3 Bus2=3.3 LineCode=11 Length=0.25 units=kft +New Line.L3 Phases=3 Bus1=1.1.2.3 Bus2=7.1.2.3 LineCode=1 Length=0.3 units=kft +New Line.L4 Phases=1 Bus1=3.3 Bus2=4.3 LineCode=11 Length=0.2 units=kft +New Line.L5 Phases=1 Bus1=3.3 Bus2=5.3 LineCode=11 Length=0.325 units=kft +New Line.L6 Phases=1 Bus1=5.3 Bus2=6.3 LineCode=11 Length=0.25 units=kft +New Line.L7 Phases=3 Bus1=7.1.2.3 Bus2=8.1.2.3 LineCode=1 Length=0.2 units=kft +New Line.L8 Phases=1 Bus1=8.2 Bus2=12.2 LineCode=10 Length=0.225 units=kft +New Line.L9 Phases=1 Bus1=8.1 Bus2=9.1 LineCode=9 Length=0.225 units=kft +New Line.L10 Phases=3 Bus1=8.1.2.3 Bus2=13.1.2.3 LineCode=1 Length=0.3 units=kft +New Line.L11 Phases=1 Bus1=9r.1 Bus2=14.1 LineCode=9 Length=0.425 units=kft +New Line.L12 Phases=1 Bus1=13.3 Bus2=34.3 LineCode=11 Length=0.15 units=kft +New Line.L13 Phases=3 Bus1=13.1.2.3 Bus2=18.1.2.3 LineCode=2 Length=0.825 units=kft +New Line.L14 Phases=1 Bus1=14.1 Bus2=11.1 LineCode=9 Length=0.25 units=kft +New Line.L15 Phases=1 Bus1=14.1 Bus2=10.1 LineCode=9 Length=0.25 units=kft +New Line.L16 Phases=1 Bus1=15.3 Bus2=16.3 LineCode=11 Length=0.375 units=kft +New Line.L17 Phases=1 Bus1=15.3 Bus2=17.3 LineCode=11 Length=0.35 units=kft +New Line.L18 Phases=1 Bus1=18.1 Bus2=19.1 LineCode=9 Length=0.25 units=kft +New Line.L19 Phases=3 Bus1=18.1.2.3 Bus2=21.1.2.3 LineCode=2 Length=0.3 units=kft +New Line.L20 Phases=1 Bus1=19.1 Bus2=20.1 LineCode=9 Length=0.325 units=kft +New Line.L21 Phases=1 Bus1=21.2 Bus2=22.2 LineCode=10 Length=0.525 units=kft +New Line.L22 Phases=3 Bus1=21.1.2.3 Bus2=23.1.2.3 LineCode=2 Length=0.25 units=kft +New Line.L23 Phases=1 Bus1=23.3 Bus2=24.3 LineCode=11 Length=0.55 units=kft +New Line.L24 Phases=3 Bus1=23.1.2.3 Bus2=25.1.2.3 LineCode=2 Length=0.275 units=kft +New Line.L25 Phases=2 Bus1=25r.1.3 Bus2=26.1.3 LineCode=7 Length=0.35 units=kft +New Line.L26 Phases=3 Bus1=25.1.2.3 Bus2=28.1.2.3 LineCode=2 Length=0.2 units=kft +New Line.L27 Phases=2 Bus1=26.1.3 Bus2=27.1.3 LineCode=7 Length=0.275 units=kft +New Line.L28 Phases=1 Bus1=26.3 Bus2=31.3 LineCode=11 Length=0.225 units=kft +New Line.L29 Phases=1 Bus1=27.1 Bus2=33.1 LineCode=9 Length=0.5 units=kft +New Line.L30 Phases=3 Bus1=28.1.2.3 Bus2=29.1.2.3 LineCode=2 Length=0.3 units=kft +New Line.L31 Phases=3 Bus1=29.1.2.3 Bus2=30.1.2.3 LineCode=2 Length=0.35 units=kft +New Line.L32 Phases=3 Bus1=30.1.2.3 Bus2=250.1.2.3 LineCode=2 Length=0.2 units=kft +New Line.L33 Phases=1 Bus1=31.3 Bus2=32.3 LineCode=11 Length=0.3 units=kft +New Line.L34 Phases=1 Bus1=34.3 Bus2=15.3 LineCode=11 Length=0.1 units=kft +New Line.L35 Phases=2 Bus1=35.1.2 Bus2=36.1.2 LineCode=8 Length=0.65 units=kft +New Line.L36 Phases=3 Bus1=35.1.2.3 Bus2=40.1.2.3 LineCode=1 Length=0.25 units=kft +New Line.L37 Phases=1 Bus1=36.1 Bus2=37.1 LineCode=9 Length=0.3 units=kft +New Line.L38 Phases=1 Bus1=36.2 Bus2=38.2 LineCode=10 Length=0.25 units=kft +New Line.L39 Phases=1 Bus1=38.2 Bus2=39.2 LineCode=10 Length=0.325 units=kft +New Line.L40 Phases=1 Bus1=40.3 Bus2=41.3 LineCode=11 Length=0.325 units=kft +New Line.L41 Phases=3 Bus1=40.1.2.3 Bus2=42.1.2.3 LineCode=1 Length=0.25 units=kft +New Line.L42 Phases=1 Bus1=42.2 Bus2=43.2 LineCode=10 Length=0.5 units=kft +New Line.L43 Phases=3 Bus1=42.1.2.3 Bus2=44.1.2.3 LineCode=1 Length=0.2 units=kft +New Line.L44 Phases=1 Bus1=44.1 Bus2=45.1 LineCode=9 Length=0.2 units=kft +New Line.L45 Phases=3 Bus1=44.1.2.3 Bus2=47.1.2.3 LineCode=1 Length=0.25 units=kft +New Line.L46 Phases=1 Bus1=45.1 Bus2=46.1 LineCode=9 Length=0.3 units=kft +New Line.L47 Phases=3 Bus1=47.1.2.3 Bus2=48.1.2.3 LineCode=4 Length=0.15 units=kft +New Line.L48 Phases=3 Bus1=47.1.2.3 Bus2=49.1.2.3 LineCode=4 Length=0.25 units=kft +New Line.L49 Phases=3 Bus1=49.1.2.3 Bus2=50.1.2.3 LineCode=4 Length=0.25 units=kft +New Line.L50 Phases=3 Bus1=50.1.2.3 Bus2=51.1.2.3 LineCode=4 Length=0.25 units=kft +New Line.L51 Phases=3 Bus1=51.1.2.3 Bus2=151.1.2.3 LineCode=4 Length=0.5 units=kft +New Line.L52 Phases=3 Bus1=52.1.2.3 Bus2=53.1.2.3 LineCode=1 Length=0.2 units=kft +New Line.L53 Phases=3 Bus1=53.1.2.3 Bus2=54.1.2.3 LineCode=1 Length=0.125 units=kft +New Line.L54 Phases=3 Bus1=54.1.2.3 Bus2=55.1.2.3 LineCode=1 Length=0.275 units=kft +New Line.L55 Phases=3 Bus1=54.1.2.3 Bus2=57.1.2.3 LineCode=3 Length=0.35 units=kft +New Line.L56 Phases=3 Bus1=55.1.2.3 Bus2=56.1.2.3 LineCode=1 Length=0.275 units=kft +New Line.L57 Phases=1 Bus1=57.2 Bus2=58.2 LineCode=10 Length=0.25 units=kft +New Line.L58 Phases=3 Bus1=57.1.2.3 Bus2=60.1.2.3 LineCode=3 Length=0.75 units=kft +New Line.L59 Phases=1 Bus1=58.2 Bus2=59.2 LineCode=10 Length=0.25 units=kft +New Line.L60 Phases=3 Bus1=60.1.2.3 Bus2=61.1.2.3 LineCode=5 Length=0.55 units=kft +New Line.L61 Phases=3 Bus1=60.1.2.3 Bus2=62.1.2.3 LineCode=12 Length=0.25 units=kft +New Line.L62 Phases=3 Bus1=62.1.2.3 Bus2=63.1.2.3 LineCode=12 Length=0.175 units=kft +New Line.L63 Phases=3 Bus1=63.1.2.3 Bus2=64.1.2.3 LineCode=12 Length=0.35 units=kft +New Line.L64 Phases=3 Bus1=64.1.2.3 Bus2=65.1.2.3 LineCode=12 Length=0.425 units=kft +New Line.L65 Phases=3 Bus1=65.1.2.3 Bus2=66.1.2.3 LineCode=12 Length=0.325 units=kft +New Line.L66 Phases=1 Bus1=67.1 Bus2=68.1 LineCode=9 Length=0.2 units=kft +New Line.L67 Phases=3 Bus1=67.1.2.3 Bus2=72.1.2.3 LineCode=3 Length=0.275 units=kft +New Line.L68 Phases=3 Bus1=67.1.2.3 Bus2=97.1.2.3 LineCode=3 Length=0.25 units=kft +New Line.L69 Phases=1 Bus1=68.1 Bus2=69.1 LineCode=9 Length=0.275 units=kft +New Line.L70 Phases=1 Bus1=69.1 Bus2=70.1 LineCode=9 Length=0.325 units=kft +New Line.L71 Phases=1 Bus1=70.1 Bus2=71.1 LineCode=9 Length=0.275 units=kft +New Line.L72 Phases=1 Bus1=72.3 Bus2=73.3 LineCode=11 Length=0.275 units=kft +New Line.L73 Phases=3 Bus1=72.1.2.3 Bus2=76.1.2.3 LineCode=3 Length=0.2 units=kft +New Line.L74 Phases=1 Bus1=73.3 Bus2=74.3 LineCode=11 Length=0.35 units=kft +New Line.L75 Phases=1 Bus1=74.3 Bus2=75.3 LineCode=11 Length=0.4 units=kft +New Line.L76 Phases=3 Bus1=76.1.2.3 Bus2=77.1.2.3 LineCode=6 Length=0.4 units=kft +New Line.L77 Phases=3 Bus1=76.1.2.3 Bus2=86.1.2.3 LineCode=3 Length=0.7 units=kft +New Line.L78 Phases=3 Bus1=77.1.2.3 Bus2=78.1.2.3 LineCode=6 Length=0.1 units=kft +New Line.L79 Phases=3 Bus1=78.1.2.3 Bus2=79.1.2.3 LineCode=6 Length=0.225 units=kft +New Line.L80 Phases=3 Bus1=78.1.2.3 Bus2=80.1.2.3 LineCode=6 Length=0.475 units=kft +New Line.L81 Phases=3 Bus1=80.1.2.3 Bus2=81.1.2.3 LineCode=6 Length=0.175 units=kft +New Line.L82 Phases=3 Bus1=81.1.2.3 Bus2=82.1.2.3 LineCode=6 Length=0.25 units=kft +New Line.L83 Phases=1 Bus1=81.3 Bus2=84.3 LineCode=11 Length=0.675 units=kft +New Line.L84 Phases=3 Bus1=82.1.2.3 Bus2=83.1.2.3 LineCode=6 Length=0.25 units=kft +New Line.L85 Phases=1 Bus1=84.3 Bus2=85.3 LineCode=11 Length=0.475 units=kft +New Line.L86 Phases=3 Bus1=86.1.2.3 Bus2=87.1.2.3 LineCode=6 Length=0.45 units=kft +New Line.L87 Phases=1 Bus1=87.1 Bus2=88.1 LineCode=9 Length=0.175 units=kft +New Line.L88 Phases=3 Bus1=87.1.2.3 Bus2=89.1.2.3 LineCode=6 Length=0.275 units=kft +New Line.L89 Phases=1 Bus1=89.2 Bus2=90.2 LineCode=10 Length=0.25 units=kft +New Line.L90 Phases=3 Bus1=89.1.2.3 Bus2=91.1.2.3 LineCode=6 Length=0.225 units=kft +New Line.L91 Phases=1 Bus1=91.3 Bus2=92.3 LineCode=11 Length=0.3 units=kft +New Line.L92 Phases=3 Bus1=91.1.2.3 Bus2=93.1.2.3 LineCode=6 Length=0.225 units=kft +New Line.L93 Phases=1 Bus1=93.1 Bus2=94.1 LineCode=9 Length=0.275 units=kft +New Line.L94 Phases=3 Bus1=93.1.2.3 Bus2=95.1.2.3 LineCode=6 Length=0.3 units=kft +New Line.L95 Phases=1 Bus1=95.2 Bus2=96.2 LineCode=10 Length=0.2 units=kft +New Line.L96 Phases=3 Bus1=97.1.2.3 Bus2=98.1.2.3 LineCode=3 Length=0.275 units=kft +New Line.L97 Phases=3 Bus1=98.1.2.3 Bus2=99.1.2.3 LineCode=3 Length=0.55 units=kft +New Line.L98 Phases=3 Bus1=99.1.2.3 Bus2=100.1.2.3 LineCode=3 Length=0.3 units=kft +New Line.L99 Phases=3 Bus1=100.1.2.3 Bus2=450.1.2.3 LineCode=3 Length=0.8 units=kft +New Line.L118 Phases=3 Bus1=197.1.2.3 Bus2=101.1.2.3 LineCode=3 Length=0.25 units=kft +New Line.L100 Phases=1 Bus1=101.3 Bus2=102.3 LineCode=11 Length=0.225 units=kft +New Line.L101 Phases=3 Bus1=101.1.2.3 Bus2=105.1.2.3 LineCode=3 Length=0.275 units=kft +New Line.L102 Phases=1 Bus1=102.3 Bus2=103.3 LineCode=11 Length=0.325 units=kft +New Line.L103 Phases=1 Bus1=103.3 Bus2=104.3 LineCode=11 Length=0.7 units=kft +New Line.L104 Phases=1 Bus1=105.2 Bus2=106.2 LineCode=10 Length=0.225 units=kft +New Line.L105 Phases=3 Bus1=105.1.2.3 Bus2=108.1.2.3 LineCode=3 Length=0.325 units=kft +New Line.L106 Phases=1 Bus1=106.2 Bus2=107.2 LineCode=10 Length=0.575 units=kft +New Line.L107 Phases=1 Bus1=108.1 Bus2=109.1 LineCode=9 Length=0.45 units=kft +New Line.L108 Phases=3 Bus1=108.1.2.3 Bus2=300.1.2.3 LineCode=3 Length=1 units=kft +New Line.L109 Phases=1 Bus1=109.1 Bus2=110.1 LineCode=9 Length=0.3 units=kft +New Line.L110 Phases=1 Bus1=110.1 Bus2=111.1 LineCode=9 Length=0.575 units=kft +New Line.L111 Phases=1 Bus1=110.1 Bus2=112.1 LineCode=9 Length=0.125 units=kft +New Line.L112 Phases=1 Bus1=112.1 Bus2=113.1 LineCode=9 Length=0.525 units=kft +New Line.L113 Phases=1 Bus1=113.1 Bus2=114.1 LineCode=9 Length=0.325 units=kft +New Line.L114 Phases=3 Bus1=135.1.2.3 Bus2=35.1.2.3 LineCode=4 Length=0.375 units=kft +New Line.L116 Phases=3 Bus1=152.1.2.3 Bus2=52.1.2.3 LineCode=1 Length=0.4 units=kft +New Line.L117 Phases=3 Bus1=160r.1.2.3 Bus2=67.1.2.3 LineCode=6 Length=0.35 units=kft + + +! NORMALLY CLOSED SWITCHES ARE DEFINED AS SHORT LINES +! Could also be defned by setting the Switch=Yes property + +New Line.Sw1 phases=3 Bus1=150r Bus2=149 switch=yes r1=1e-3 r0=1e-3 x1=0.000 x0=0.000 c1=0.000 c0=0.000 Length=0.001 +New Line.Sw2 phases=3 Bus1=13 Bus2=152 switch=yes r1=1e-3 r0=1e-3 x1=0.000 x0=0.000 c1=0.000 c0=0.000 Length=0.001 +New Line.Sw3 phases=3 Bus1=18 Bus2=135 switch=yes r1=1e-3 r0=1e-3 x1=0.000 x0=0.000 c1=0.000 c0=0.000 Length=0.001 +New Line.Sw4 phases=3 Bus1=60 Bus2=160 switch=yes r1=1e-3 r0=1e-3 x1=0.000 x0=0.000 c1=0.000 c0=0.000 Length=0.001 +New Line.Sw5 phases=3 Bus1=97 Bus2=197 switch=yes r1=1e-3 r0=1e-3 x1=0.000 x0=0.000 c1=0.000 c0=0.000 Length=0.001 +New Line.Sw6 phases=3 Bus1=61 Bus2=61s switch=yes r1=1e-3 r0=1e-3 x1=0.000 x0=0.000 c1=0.000 c0=0.000 Length=0.001 + +! NORMALLY OPEN SWITCHES; DEFINED AS SHORT LINE TO OPEN BUS SO WE CAN SEE OPEN POINT VOLTAGES. +! COULD ALSO BE DEFINED AS DISABLED OR THE TERMINCAL COULD BE OPENED AFTER BEING DEFINED + +New Line.Sw7 phases=3 Bus1=151 Bus2=300_OPEN switch=yes r1=1e-3 r0=1e-3 x1=0.000 x0=0.000 c1=0.000 c0=0.000 Length=0.001 +New Line.Sw8 phases=1 Bus1=54.1 Bus2=94_OPEN.1 switch=yes r1=1e-3 r0=1e-3 x1=0.000 x0=0.000 c1=0.000 c0=0.000 Length=0.001 + +! LOAD TRANSFORMER AT 61s/610 +! This is a 150 kVA Delta-Delta stepdown from 4160V to 480V. + +New Transformer.XFM1 Phases=3 Windings=2 Xhl=2.72 +~ wdg=1 bus=61s conn=Delta kv=4.16 kva=150 %r=0.635 +~ wdg=2 bus=610 conn=Delta kv=0.48 kva=150 %r=0.635 + +! CAPACITORS +! Capacitors are 2-terminal devices. The 2nd terminal (Bus2=...) defaults to all phases +! connected to ground (Node 0). Thus, it need not be specified if a Y-connected or L-N connected +! capacitor is desired + +New Capacitor.C83 Bus1=83 Phases=3 kVAR=600 kV=4.16 +New Capacitor.C88a Bus1=88.1 Phases=1 kVAR=50 kV=2.402 +New Capacitor.C90b Bus1=90.2 Phases=1 kVAR=50 kV=2.402 +New Capacitor.C92c Bus1=92.3 Phases=1 kVAR=50 kV=2.402 + + +!REGULATORS - REDIRECT TO DEFINITIONS FILE +! This file contains definitions for the remainder of regulators on the feeder: + +Redirect IEEE123Regulators.DSS + +! SPOT LOADS -- REDIRECT INPUT STREAM TO LOAD DEFINITIONS FILE + +Redirect IEEE123Loads.DSS + +! All devices in the test feeder are now defined. +! +! Many of the voltages are reported in per unit, so it is important to establish the base voltages at each bus so +! that we can compare with the result with greater ease. +! We will let the DSS compute the voltage bases by doing a zero-load power flow. +! There are only two voltage bases in the problem: 4160V and 480V. These must be expressed in kV + +Set VoltageBases = [4.16, 0.48] ! ARRAY OF VOLTAGES IN KV +CalcVoltageBases ! PERFORMS ZERO LOAD POWER FLOW TO ESTIMATE VOLTAGE BASES diff --git a/tests/data/dist/opendss/ieee123/IEEE123Regulators.DSS b/tests/data/dist/opendss/ieee123/IEEE123Regulators.DSS new file mode 100644 index 0000000..f695011 --- /dev/null +++ b/tests/data/dist/opendss/ieee123/IEEE123Regulators.DSS @@ -0,0 +1,18 @@ +!DEFINE TRANSFORMERS FOR REGULATORS +! Have to assume basically zero impedance regulators to match the test case +new transformer.reg2a phases=1 windings=2 bank=reg2 buses=[9.1 9r.1] conns=[wye wye] kvs=[2.402 2.402] kvas=[2000 2000] XHL=.01 %LoadLoss=0.00001 ppm=0.0 +new transformer.reg3a phases=1 windings=2 bank=reg3 buses=[25.1 25r.1] conns=[wye wye] kvs=[2.402 2.402] kvas=[2000 2000] XHL=.01 %LoadLoss=0.00001 ppm=0.0 +new transformer.reg4a phases=1 windings=2 bank=reg4 buses=[160.1 160r.1] conns=[wye wye] kvs=[2.402 2.402] kvas=[2000 2000] XHL=.01 %LoadLoss=0.00001 ppm=0.0 +new transformer.reg3c like=reg3a bank=reg3 buses=[25.3 25r.3] ppm=0.0 +new transformer.reg4b like=reg4a bank=reg4 buses=[160.2 160r.2] ppm=0.0 +new transformer.reg4c like=reg4a bank=reg4 buses=[160.3 160r.3] ppm=0.0 + +! POINT REGULATOR CONTROLS TO REGULATOR TRANSFORMER AND SET PARAMETERS +new regcontrol.creg2a transformer=reg2a winding=2 vreg=120 band=2 ptratio=20 ctprim=50 R=0.4 X=0.4 +new regcontrol.creg3a transformer=reg3a winding=2 vreg=120 band=1 ptratio=20 ctprim=50 R=0.4 X=0.4 +new regcontrol.creg3c like=creg3a transformer=reg3c +new regcontrol.creg4a transformer=reg4a winding=2 vreg=124 band=2 ptratio=20 ctprim=300 R=0.6 X=1.3 +new regcontrol.creg4b like=creg4a transformer=reg4b R=1.4 X=2.6 +new regcontrol.creg4c like=creg4a transformer=reg4c R=0.2 X=1.4 + +! NOTE: WHEN LIKE= IS USED, IT IS NECESSARY TO SPECIFY ONLY THOSE PROPERTIES THAT ARE DIFFERENT \ No newline at end of file diff --git a/tests/data/dist/opendss/ieee123/IEEELineCodes.DSS b/tests/data/dist/opendss/ieee123/IEEELineCodes.DSS new file mode 100644 index 0000000..519a228 --- /dev/null +++ b/tests/data/dist/opendss/ieee123/IEEELineCodes.DSS @@ -0,0 +1 @@ +redirect ../IEEELineCodes.DSS diff --git a/tests/data/dist/opendss/ieee13/IEEE13Node_BusXY.csv b/tests/data/dist/opendss/ieee13/IEEE13Node_BusXY.csv new file mode 100644 index 0000000..26cd6f4 --- /dev/null +++ b/tests/data/dist/opendss/ieee13/IEEE13Node_BusXY.csv @@ -0,0 +1,18 @@ +SourceBus, 200, 400 +650, 200, 350 +RG60, 200, 300 +646, 0, 250 +645, 100, 250 +632, 200, 250 +633, 350, 250 +634, 400, 250 +670, 200, 200 +611, 0, 100 +684, 100, 100 +671, 200, 100 +692, 250, 100 +675, 400, 100 +652, 100, 0 +680, 200, 0 + + diff --git a/tests/data/dist/opendss/ieee13/IEEE13Nodeckt.dss b/tests/data/dist/opendss/ieee13/IEEE13Nodeckt.dss new file mode 100644 index 0000000..f02bc85 --- /dev/null +++ b/tests/data/dist/opendss/ieee13/IEEE13Nodeckt.dss @@ -0,0 +1,172 @@ +Clear +Set DefaultBaseFrequency=60 + +! +! This script is based on a script developed by Tennessee Tech Univ students +! Tyler Patton, Jon Wood, and David Woods, April 2009 +! + +new circuit.IEEE13Nodeckt +~ basekv=115 pu=1.0001 phases=3 bus1=SourceBus +~ Angle=30 ! advance angle 30 deg so result agree with published angle +~ MVAsc3=20000 MVASC1=21000 ! stiffen the source to approximate inf source + + + +!SUB TRANSFORMER DEFINITION +! Although this data was given, it does not appear to be used in the test case results +! The published test case starts at 1.0 per unit at Bus 650. To make this happen, we will change the impedance +! on the transformer to something tiny by dividing by 1000 using the DSS in-line RPN math +New Transformer.Sub Phases=3 Windings=2 XHL=(8 1000 /) +~ wdg=1 bus=SourceBus conn=delta kv=115 kva=5000 %r=(.5 1000 /) +~ wdg=2 bus=650 conn=wye kv=4.16 kva=5000 %r=(.5 1000 /) + +! FEEDER 1-PHASE VOLTAGE REGULATORS +! Define low-impedance 2-wdg transformer + +New Transformer.Reg1 phases=1 bank=reg1 XHL=0.01 kVAs=[1666 1666] +~ Buses=[650.1 RG60.1] kVs=[2.4 2.4] %LoadLoss=0.01 +new regcontrol.Reg1 transformer=Reg1 winding=2 vreg=122 band=2 ptratio=20 ctprim=700 R=3 X=9 + +New Transformer.Reg2 phases=1 bank=reg1 XHL=0.01 kVAs=[1666 1666] +~ Buses=[650.2 RG60.2] kVs=[2.4 2.4] %LoadLoss=0.01 +new regcontrol.Reg2 transformer=Reg2 winding=2 vreg=122 band=2 ptratio=20 ctprim=700 R=3 X=9 + +New Transformer.Reg3 phases=1 bank=reg1 XHL=0.01 kVAs=[1666 1666] +~ Buses=[650.3 RG60.3] kVs=[2.4 2.4] %LoadLoss=0.01 +new regcontrol.Reg3 transformer=Reg3 winding=2 vreg=122 band=2 ptratio=20 ctprim=700 R=3 X=9 + + +!TRANSFORMER DEFINITION +New Transformer.XFM1 Phases=3 Windings=2 XHL=2 +~ wdg=1 bus=633 conn=Wye kv=4.16 kva=500 %r=.55 +~ wdg=2 bus=634 conn=Wye kv=0.480 kva=500 %r=.55 + + +!LINE CODES +redirect IEEELineCodes.DSS + +// these are local matrix line codes +// corrected 9-14-2011 +New linecode.mtx601 nphases=3 BaseFreq=60 +~ rmatrix = (0.3465 | 0.1560 0.3375 | 0.1580 0.1535 0.3414 ) +~ xmatrix = (1.0179 | 0.5017 1.0478 | 0.4236 0.3849 1.0348 ) +~ units=mi +New linecode.mtx602 nphases=3 BaseFreq=60 +~ rmatrix = (0.7526 | 0.1580 0.7475 | 0.1560 0.1535 0.7436 ) +~ xmatrix = (1.1814 | 0.4236 1.1983 | 0.5017 0.3849 1.2112 ) +~ units=mi +New linecode.mtx603 nphases=2 BaseFreq=60 +~ rmatrix = (1.3238 | 0.2066 1.3294 ) +~ xmatrix = (1.3569 | 0.4591 1.3471 ) +~ units=mi +New linecode.mtx604 nphases=2 BaseFreq=60 +~ rmatrix = (1.3238 | 0.2066 1.3294 ) +~ xmatrix = (1.3569 | 0.4591 1.3471 ) +~ units=mi +New linecode.mtx605 nphases=1 BaseFreq=60 +~ rmatrix = (1.3292 ) +~ xmatrix = (1.3475 ) +~ units=mi + +// *********** Original 606 Linecode ********************* +// +// You have to use this to match Kersting's results: +// +// New linecode.mtx606 nphases=3 BaseFreq=60 +// ~ rmatrix = (0.7982 | 0.3192 0.7891 | 0.2849 0.3192 0.7982 ) +// ~ xmatrix = (0.4463 | 0.0328 0.4041 | -0.0143 0.0328 0.4463 ) +// ~ Cmatrix = [257 | 0 257 | 0 0 257] ! <--- This is too low by 1.5 +// ~ units=mi +// +// Corrected mtx606 Feb 3 2016 by RDugan +// +// The new LineCode.606 is computed using the following CN cable definition and +// LineGeometry definition: +// +// New CNDATA.250_1/3 k=13 DiaStrand=0.064 Rstrand=2.816666667 epsR=2.3 +// ~ InsLayer=0.220 DiaIns=1.06 DiaCable=1.16 Rac=0.076705 GMRac=0.20568 diam=0.573 +// ~ Runits=kft Radunits=in GMRunits=in +// +// New LineGeometry.606 nconds=3 nphases=3 units=ft +// ~ cond=1 cncable=250_1/3 x=-0.5 h= -4 +// ~ cond=2 cncable=250_1/3 x=0 h= -4 +// ~ cond=3 cncable=250_1/3 x=0.5 h= -4 + +New Linecode.mtx606 nphases=3 Units=mi +~ Rmatrix=[0.791721 |0.318476 0.781649 |0.28345 0.318476 0.791721 ] +~ Xmatrix=[0.438352 |0.0276838 0.396697 |-0.0184204 0.0276838 0.438352 ] +~ Cmatrix=[383.948 |0 383.948 |0 0 383.948 ] +New linecode.mtx607 nphases=1 BaseFreq=60 +~ rmatrix = (1.3425 ) +~ xmatrix = (0.5124 ) +~ cmatrix = [236] +~ units=mi + + +!LOAD DEFINITIONS +New Load.671 Bus1=671.1.2.3 Phases=3 Conn=Delta Model=1 kV=4.16 kW=1155 kvar=660 +New Load.634a Bus1=634.1 Phases=1 Conn=Wye Model=1 kV=0.277 kW=160 kvar=110 +New Load.634b Bus1=634.2 Phases=1 Conn=Wye Model=1 kV=0.277 kW=120 kvar=90 +New Load.634c Bus1=634.3 Phases=1 Conn=Wye Model=1 kV=0.277 kW=120 kvar=90 +New Load.645 Bus1=645.2 Phases=1 Conn=Wye Model=1 kV=2.4 kW=170 kvar=125 +New Load.646 Bus1=646.2.3 Phases=1 Conn=Delta Model=2 kV=4.16 kW=230 kvar=132 +New Load.692 Bus1=692.3.1 Phases=1 Conn=Delta Model=5 kV=4.16 kW=170 kvar=151 +New Load.675a Bus1=675.1 Phases=1 Conn=Wye Model=1 kV=2.4 kW=485 kvar=190 +New Load.675b Bus1=675.2 Phases=1 Conn=Wye Model=1 kV=2.4 kW=68 kvar=60 +New Load.675c Bus1=675.3 Phases=1 Conn=Wye Model=1 kV=2.4 kW=290 kvar=212 +New Load.611 Bus1=611.3 Phases=1 Conn=Wye Model=5 kV=2.4 kW=170 kvar=80 +New Load.652 Bus1=652.1 Phases=1 Conn=Wye Model=2 kV=2.4 kW=128 kvar=86 +New Load.670a Bus1=670.1 Phases=1 Conn=Wye Model=1 kV=2.4 kW=17 kvar=10 +New Load.670b Bus1=670.2 Phases=1 Conn=Wye Model=1 kV=2.4 kW=66 kvar=38 +New Load.670c Bus1=670.3 Phases=1 Conn=Wye Model=1 kV=2.4 kW=117 kvar=68 + +!CAPACITOR DEFINITIONS +New Capacitor.Cap1 Bus1=675 phases=3 kVAR=600 kV=4.16 +New Capacitor.Cap2 Bus1=611.3 phases=1 kVAR=100 kV=2.4 + +!Bus 670 is the concentrated point load of the distributed load on line 632 to 671 located at 1/3 the distance from node 632 + +!LINE DEFINITIONS +New Line.650632 Phases=3 Bus1=RG60.1.2.3 Bus2=632.1.2.3 LineCode=mtx601 Length=2000 units=ft +New Line.632670 Phases=3 Bus1=632.1.2.3 Bus2=670.1.2.3 LineCode=mtx601 Length=667 units=ft +New Line.670671 Phases=3 Bus1=670.1.2.3 Bus2=671.1.2.3 LineCode=mtx601 Length=1333 units=ft +New Line.671680 Phases=3 Bus1=671.1.2.3 Bus2=680.1.2.3 LineCode=mtx601 Length=1000 units=ft +New Line.632633 Phases=3 Bus1=632.1.2.3 Bus2=633.1.2.3 LineCode=mtx602 Length=500 units=ft +New Line.632645 Phases=2 Bus1=632.3.2 Bus2=645.3.2 LineCode=mtx603 Length=500 units=ft +New Line.645646 Phases=2 Bus1=645.3.2 Bus2=646.3.2 LineCode=mtx603 Length=300 units=ft +New Line.692675 Phases=3 Bus1=692.1.2.3 Bus2=675.1.2.3 LineCode=mtx606 Length=500 units=ft +New Line.671684 Phases=2 Bus1=671.1.3 Bus2=684.1.3 LineCode=mtx604 Length=300 units=ft +New Line.684611 Phases=1 Bus1=684.3 Bus2=611.3 LineCode=mtx605 Length=300 units=ft +New Line.684652 Phases=1 Bus1=684.1 Bus2=652.1 LineCode=mtx607 Length=800 units=ft + + +!SWITCH DEFINITIONS +New Line.671692 Phases=3 Bus1=671 Bus2=692 Switch=y r1=1e-4 r0=1e-4 x1=0.000 x0=0.000 c1=0.000 c0=0.000 + +Set Voltagebases=[115, 4.16, .48] +calcv +Solve +BusCoords IEEE13Node_BusXY.csv + +!--------------------------------------------------------------------------------------------------------------------------------------------------- +!----------------Show some Results ----------------------------------------------------------------------------------------------------------------- +!--------------------------------------------------------------------------------------------------------------------------------------------------- + + +// Show Voltages LN Nodes +// Show Currents Elem +// Show Powers kVA Elem +// Show Losses +// Show Taps + +!--------------------------------------------------------------------------------------------------------------------------------------------------- +!--------------------------------------------------------------------------------------------------------------------------------------------------- +! Alternate Solution Script +! To force the taps to be same as published results, set the transformer taps manually and disable the controls +!--------------------------------------------------------------------------------------------------------------------------------------------------- +// Transformer.Reg1.Taps=[1.0 1.0625] +// Transformer.Reg2.Taps=[1.0 1.0500] +// Transformer.Reg3.Taps=[1.0 1.06875] +// Set Controlmode=OFF +// Solve diff --git a/tests/data/dist/opendss/ieee13/IEEELineCodes.DSS b/tests/data/dist/opendss/ieee13/IEEELineCodes.DSS new file mode 100644 index 0000000..519a228 --- /dev/null +++ b/tests/data/dist/opendss/ieee13/IEEELineCodes.DSS @@ -0,0 +1 @@ +redirect ../IEEELineCodes.DSS diff --git a/tests/data/dist/opendss/ieee34/IEEELineCodes.DSS b/tests/data/dist/opendss/ieee34/IEEELineCodes.DSS new file mode 100644 index 0000000..519a228 --- /dev/null +++ b/tests/data/dist/opendss/ieee34/IEEELineCodes.DSS @@ -0,0 +1 @@ +redirect ../IEEELineCodes.DSS diff --git a/tests/data/dist/opendss/ieee34/ieee34Mod1.dss b/tests/data/dist/opendss/ieee34/ieee34Mod1.dss new file mode 100644 index 0000000..2e4c352 --- /dev/null +++ b/tests/data/dist/opendss/ieee34/ieee34Mod1.dss @@ -0,0 +1,246 @@ +! Standard (Mod 1) model of IEEE 34 Bus Test Feeder + +! Note: Mod 2 better accounts for distributed load. + +Clear +Set DefaultBaseFrequency=60 + +New object=circuit.ieee34-1 +~ basekv=69 pu=1.05 angle=30 mvasc3=200000 !stiffen up a bit over DSS default + +! Substation Transformer -- Modification: Make source very stiff by defining a tiny leakage Z +New Transformer.SubXF Phases=3 Windings=2 Xhl=0.01 ! normally 8 +~ wdg=1 bus=sourcebus conn=Delta kv=69 kva=25000 %r=0.0005 !reduce %r, too +~ wdg=2 bus=800 conn=wye kv=24.9 kva=25000 %r=0.0005 + +! import line codes with phase impedance matrices +Redirect IEEELineCodes.DSS ! revised according to Later test feeder doc + +! Lines +New Line.L1 Phases=3 Bus1=800.1.2.3 Bus2=802.1.2.3 LineCode=300 Length=2.58 units=kft +New Line.L2 Phases=3 Bus1=802.1.2.3 Bus2=806.1.2.3 LineCode=300 Length=1.73 units=kft +New Line.L3 Phases=3 Bus1=806.1.2.3 Bus2=808.1.2.3 LineCode=300 Length=32.23 units=kft +New Line.L4 Phases=1 Bus1=808.2 Bus2=810.2 LineCode=303 Length=5.804 units=kft +New Line.L5 Phases=3 Bus1=808.1.2.3 Bus2=812.1.2.3 LineCode=300 Length=37.5 units=kft +New Line.L6 Phases=3 Bus1=812.1.2.3 Bus2=814.1.2.3 LineCode=300 Length=29.73 units=kft +New Line.L7 Phases=3 Bus1=814r.1.2.3 Bus2=850.1.2.3 LineCode=301 Length=0.01 units=kft +New Line.L8 Phases=1 Bus1=816.1 Bus2=818.1 LineCode=302 Length=1.71 units=kft +New Line.L9 Phases=3 Bus1=816.1.2.3 Bus2=824.1.2.3 LineCode=301 Length=10.21 units=kft +New Line.L10 Phases=1 Bus1=818.1 Bus2=820.1 LineCode=302 Length=48.15 units=kft +New Line.L11 Phases=1 Bus1=820.1 Bus2=822.1 LineCode=302 Length=13.74 units=kft +New Line.L12 Phases=1 Bus1=824.2 Bus2=826.2 LineCode=303 Length=3.03 units=kft +New Line.L13 Phases=3 Bus1=824.1.2.3 Bus2=828.1.2.3 LineCode=301 Length=0.84 units=kft +New Line.L14 Phases=3 Bus1=828.1.2.3 Bus2=830.1.2.3 LineCode=301 Length=20.44 units=kft +New Line.L15 Phases=3 Bus1=830.1.2.3 Bus2=854.1.2.3 LineCode=301 Length=0.52 units=kft +New Line.L16 Phases=3 Bus1=832.1.2.3 Bus2=858.1.2.3 LineCode=301 Length=4.9 units=kft +New Line.L17 Phases=3 Bus1=834.1.2.3 Bus2=860.1.2.3 LineCode=301 Length=2.02 units=kft +New Line.L18 Phases=3 Bus1=834.1.2.3 Bus2=842.1.2.3 LineCode=301 Length=0.28 units=kft +New Line.L19 Phases=3 Bus1=836.1.2.3 Bus2=840.1.2.3 LineCode=301 Length=0.86 units=kft +New Line.L20 Phases=3 Bus1=836.1.2.3 Bus2=862.1.2.3 LineCode=301 Length=0.28 units=kft +New Line.L21 Phases=3 Bus1=842.1.2.3 Bus2=844.1.2.3 LineCode=301 Length=1.35 units=kft +New Line.L22 Phases=3 Bus1=844.1.2.3 Bus2=846.1.2.3 LineCode=301 Length=3.64 units=kft +New Line.L23 Phases=3 Bus1=846.1.2.3 Bus2=848.1.2.3 LineCode=301 Length=0.53 units=kft +New Line.L24 Phases=3 Bus1=850.1.2.3 Bus2=816.1.2.3 LineCode=301 Length=0.31 units=kft +New Line.L25 Phases=3 Bus1=852r.1.2.3 Bus2=832.1.2.3 LineCode=301 Length=0.01 units=kft + +! 24.9/4.16 kV Transformer +New Transformer.XFM1 Phases=3 Windings=2 Xhl=4.08 +~ wdg=1 bus=832 conn=wye kv=24.9 kva=500 %r=0.95 +~ wdg=2 bus=888 conn=Wye kv=4.16 kva=500 %r=0.95 + +New Line.L26 Phases=1 Bus1=854.2 Bus2=856.2 LineCode=303 Length=23.33 units=kft +New Line.L27 Phases=3 Bus1=854.1.2.3 Bus2=852.1.2.3 LineCode=301 Length=36.83 units=kft +! 9-17-10 858-864 changed to phase A per error report +New Line.L28 Phases=1 Bus1=858.1 Bus2=864.1 LineCode=303 Length=1.62 units=kft +New Line.L29 Phases=3 Bus1=858.1.2.3 Bus2=834.1.2.3 LineCode=301 Length=5.83 units=kft +New Line.L30 Phases=3 Bus1=860.1.2.3 Bus2=836.1.2.3 LineCode=301 Length=2.68 units=kft +New Line.L31 Phases=1 Bus1=862.2 Bus2=838.2 LineCode=304 Length=4.86 units=kft +New Line.L32 Phases=3 Bus1=888.1.2.3 Bus2=890.1.2.3 LineCode=300 Length=10.56 units=kft + +! Capacitors +New Capacitor.C844 Bus1=844 Phases=3 kVAR=300 kV=24.9 +New Capacitor.C848 Bus1=848 Phases=3 kVAR=450 kV=24.9 + +! Regulators - three independent phases +! Regulator 1 +new transformer.reg1a phases=1 windings=2 bank=reg1 buses=(814.1 814r.1) conns='wye wye' kvs="14.376 14.376" kvas="20000 20000" XHL=1 +new regcontrol.creg1a transformer=reg1a winding=2 vreg=122 band=2 ptratio=120 ctprim=100 R=2.7 X=1.6 +new transformer.reg1b phases=1 windings=2 bank=reg1 buses=(814.2 814r.2) conns='wye wye' kvs="14.376 14.376" kvas="20000 20000" XHL=1 +new regcontrol.creg1b transformer=reg1b winding=2 vreg=122 band=2 ptratio=120 ctprim=100 R=2.7 X=1.6 +new transformer.reg1c phases=1 windings=2 bank=reg1 buses=(814.3 814r.3) conns='wye wye' kvs="14.376 14.376" kvas="20000 20000" XHL=1 +new regcontrol.creg1c transformer=reg1c winding=2 vreg=122 band=2 ptratio=120 ctprim=100 R=2.7 X=1.6 + +! Regulator 2 +new transformer.reg2a phases=1 windings=2 bank=reg2 buses=(852.1 852r.1) conns='wye wye' kvs="14.376 14.376" kvas="20000 20000" XHL=1 +new regcontrol.creg2a transformer=reg2a winding=2 vreg=124 band=2 ptratio=120 ctprim=100 R=2.5 X=1.5 +new transformer.reg2b phases=1 windings=2 bank=reg2 buses=(852.2 852r.2) conns='wye wye' kvs="14.376 14.376" kvas="20000 20000" XHL=1 +new regcontrol.creg2b transformer=reg2b winding=2 vreg=124 band=2 ptratio=120 ctprim=100 R=2.5 X=1.5 +new transformer.reg2c phases=1 windings=2 bank=reg2 buses=(852.3 852r.3) conns='wye wye' kvs="14.376 14.376" kvas="20000 20000" XHL=1 +new regcontrol.creg2c transformer=reg2c winding=2 vreg=124 band=2 ptratio=120 ctprim=100 R=2.5 X=1.5 + +! spot loads +New Load.S860 Bus1=860 Phases=3 Conn=Wye Model=1 kV= 24.900 kW= 60.0 kVAR= 48.0 +New Load.S840 Bus1=840 Phases=3 Conn=Wye Model=5 kV= 24.900 kW= 27.0 kVAR= 21.0 +New Load.S844 Bus1=844 Phases=3 Conn=Wye Model=2 kV= 24.900 kW= 405.0 kVAR= 315.0 + +New Load.S848 Bus1=848 Phases=3 Conn=Delta Model=1 kV= 24.900 kW= 60.0 kVAR= 48.0 +New Load.S830a Bus1=830.1.2 Phases=1 Conn=Delta Model=2 kV= 24.900 kW= 10.0 kVAR= 5.0 +New Load.S830b Bus1=830.2.3 Phases=1 Conn=Delta Model=2 kV= 24.900 kW= 10.0 kVAR= 5.0 +New Load.S830c Bus1=830.3.1 Phases=1 Conn=Delta Model=2 kV= 24.900 kW= 25.0 kVAR= 10.0 +New Load.S890 Bus1=890 Phases=3 Conn=Delta Model=5 kV= 4.160 kW= 450.0 kVAR= 225.0 + +! distributed loads +New Load.D802_806sb Bus1=802.2 Phases=1 Conn=Wye Model=1 kV= 14.376 kW= 15.0 kVAR= 7.5 +New Load.D802_806rb Bus1=806.2 Phases=1 Conn=Wye Model=1 kV= 14.376 kW= 15.0 kVAR= 7.5 +New Load.D802_806sc Bus1=802.3 Phases=1 Conn=Wye Model=1 kV= 14.376 kW= 12.5 kVAR= 7.0 +New Load.D802_806rc Bus1=806.3 Phases=1 Conn=Wye Model=1 kV= 14.376 kW= 12.5 kVAR= 7.0 + +New Load.D808_810sb Bus1=808.2 Phases=1 Conn=Wye Model=4 kV= 14.376 kW= 8.0 kVAR= 4.0 +New Load.D808_810rb Bus1=810.2 Phases=1 Conn=Wye Model=4 kV= 14.376 kW= 8.0 kVAR= 4.0 + +New Load.D818_820sa Bus1=818.1 Phases=1 Conn=Wye Model=2 kV= 14.376 kW= 17.0 kVAR= 8.5 +New Load.D818_820ra Bus1=820.1 Phases=1 Conn=Wye Model=2 kV= 14.376 kW= 17.0 kVAR= 8.5 + +New Load.D820_822sa Bus1=820.1 Phases=1 Conn=Wye Model=1 kV= 14.376 kW= 67.5 kVAR= 35.0 +New Load.D820_822ra Bus1=822.1 Phases=1 Conn=Wye Model=1 kV= 14.376 kW= 67.5 kVAR= 35.0 + +New Load.D816_824sb Bus1=816.2.3 Phases=1 Conn=Delta Model=5 kV= 24.900 kW= 2.5 kVAR= 1.0 +New Load.D816_824rb Bus1=824.2.3 Phases=1 Conn=Delta Model=5 kV= 24.900 kW= 2.5 kVAR= 1.0 + +New Load.D824_826sb Bus1=824.2 Phases=1 Conn=Wye Model=5 kV= 14.376 kW= 20.0 kVAR= 10.0 +New Load.D824_826rb Bus1=826.2 Phases=1 Conn=Wye Model=5 kV= 14.376 kW= 20.0 kVAR= 10.0 +New Load.D824_828sc Bus1=824.3 Phases=1 Conn=Wye Model=1 kV= 14.376 kW= 2.0 kVAR= 1.0 +New Load.D824_828rc Bus1=828.3 Phases=1 Conn=Wye Model=1 kV= 14.376 kW= 2.0 kVAR= 1.0 + +New Load.D828_830sa Bus1=828.1 Phases=1 Conn=Wye Model=1 kV= 14.376 kW= 3.5 kVAR= 1.5 +New Load.D828_830ra Bus1=830.1 Phases=1 Conn=Wye Model=1 kV= 14.376 kW= 3.5 kVAR= 1.5 + +New Load.D854_856sb Bus1=854.2 Phases=1 Conn=Wye Model=1 kV= 14.376 kW= 2.0 kVAR= 1.0 +New Load.D854_856rb Bus1=856.2 Phases=1 Conn=Wye Model=1 kV= 14.376 kW= 2.0 kVAR= 1.0 + +New Load.D832_858sa Bus1=832.1 Phases=1 Conn=Delta Model=2 kV= 24.900 kW= 3.5 kVAR= 1.5 +New Load.D832_858ra Bus1=858.1 Phases=1 Conn=Delta Model=2 kV= 24.900 kW= 3.5 kVAR= 1.5 +New Load.D832_858sb Bus1=832.2 Phases=1 Conn=Delta Model=2 kV= 24.900 kW= 1.0 kVAR= 0.5 +New Load.D832_858rb Bus1=858.2 Phases=1 Conn=Delta Model=2 kV= 24.900 kW= 1.0 kVAR= 0.5 +New Load.D832_858sc Bus1=832.3 Phases=1 Conn=Delta Model=2 kV= 24.900 kW= 3.0 kVAR= 1.5 +New Load.D832_858rc Bus1=858.3 Phases=1 Conn=Delta Model=2 kV= 24.900 kW= 3.0 kVAR= 1.5 + +! 9-17-10 858-864 changed to phase A per error report +New Load.D858_864sb Bus1=858.1 Phases=1 Conn=Wye Model=1 kV= 14.376 kW= 1.0 kVAR= 0.5 +New Load.D858_864rb Bus1=864.1 Phases=1 Conn=Wye Model=1 kV= 14.376 kW= 1.0 kVAR= 0.5 + +New Load.D858_834sa Bus1=858.1.2 Phases=1 Conn=Delta Model=1 kV= 24.900 kW= 2.0 kVAR= 1.0 +New Load.D858_834ra Bus1=834.1.2 Phases=1 Conn=Delta Model=1 kV= 24.900 kW= 2.0 kVAR= 1.0 +New Load.D858_834sb Bus1=858.2.3 Phases=1 Conn=Delta Model=1 kV= 24.900 kW= 7.5 kVAR= 4.0 +New Load.D858_834rb Bus1=834.2.3 Phases=1 Conn=Delta Model=1 kV= 24.900 kW= 7.5 kVAR= 4.0 +New Load.D858_834sc Bus1=858.3.1 Phases=1 Conn=Delta Model=1 kV= 24.900 kW= 6.5 kVAR= 3.5 +New Load.D858_834rc Bus1=834.3.1 Phases=1 Conn=Delta Model=1 kV= 24.900 kW= 6.5 kVAR= 3.5 + +New Load.D834_860sa Bus1=834.1.2 Phases=1 Conn=Delta Model=2 kV= 24.900 kW= 8.0 kVAR= 4.0 +New Load.D834_860ra Bus1=860.1.2 Phases=1 Conn=Delta Model=2 kV= 24.900 kW= 8.0 kVAR= 4.0 +New Load.D834_860sb Bus1=834.2.3 Phases=1 Conn=Delta Model=2 kV= 24.900 kW= 10.0 kVAR= 5.0 +New Load.D834_860rb Bus1=860.2.3 Phases=1 Conn=Delta Model=2 kV= 24.900 kW= 10.0 kVAR= 5.0 +New Load.D834_860sc Bus1=834.3.1 Phases=1 Conn=Delta Model=2 kV= 24.900 kW= 55.0 kVAR= 27.5 +New Load.D834_860rc Bus1=860.3.1 Phases=1 Conn=Delta Model=2 kV= 24.900 kW= 55.0 kVAR= 27.5 + +New Load.D860_836sa Bus1=860.1.2 Phases=1 Conn=Delta Model=1 kV= 24.900 kW= 15.0 kVAR= 7.5 +New Load.D860_836ra Bus1=836.1.2 Phases=1 Conn=Delta Model=1 kV= 24.900 kW= 15.0 kVAR= 7.5 +New Load.D860_836sb Bus1=860.2.3 Phases=1 Conn=Delta Model=1 kV= 24.900 kW= 5.0 kVAR= 3.0 +New Load.D860_836rb Bus1=836.2.3 Phases=1 Conn=Delta Model=1 kV= 24.900 kW= 5.0 kVAR= 3.0 +New Load.D860_836sc Bus1=860.3.1 Phases=1 Conn=Delta Model=1 kV= 24.900 kW= 21.0 kVAR= 11.0 +New Load.D860_836rc Bus1=836.3.1 Phases=1 Conn=Delta Model=1 kV= 24.900 kW= 21.0 kVAR= 11.0 + +New Load.D836_840sa Bus1=836.1.2 Phases=1 Conn=Delta Model=5 kV= 24.900 kW= 9.0 kVAR= 4.5 +New Load.D836_840ra Bus1=840.1.2 Phases=1 Conn=Delta Model=5 kV= 24.900 kW= 9.0 kVAR= 4.5 +New Load.D836_840sb Bus1=836.2.3 Phases=1 Conn=Delta Model=5 kV= 24.900 kW= 11.0 kVAR= 5.5 +New Load.D836_840rb Bus1=840.2.3 Phases=1 Conn=Delta Model=5 kV= 24.900 kW= 11.0 kVAR= 5.5 + +New Load.D862_838sb Bus1=862.2 Phases=1 Conn=Wye Model=1 kV= 14.376 kW= 14.0 kVAR= 7.0 +New Load.D862_838rb Bus1=838.2 Phases=1 Conn=Wye Model=1 kV= 14.376 kW= 14.0 kVAR= 7.0 + +New Load.D842_844sa Bus1=842.1 Phases=1 Conn=Wye Model=1 kV= 14.376 kW= 4.5 kVAR= 2.5 +New Load.D842_844ra Bus1=844.1 Phases=1 Conn=Wye Model=1 kV= 14.376 kW= 4.5 kVAR= 2.5 + +New Load.D844_846sb Bus1=844.2 Phases=1 Conn=Wye Model=1 kV= 14.376 kW= 12.5 kVAR= 6.0 +New Load.D844_846rb Bus1=846.2 Phases=1 Conn=Wye Model=1 kV= 14.376 kW= 12.5 kVAR= 6.0 +New Load.D844_846sc Bus1=844.3 Phases=1 Conn=Wye Model=1 kV= 14.376 kW= 10.0 kVAR= 5.5 +New Load.D844_846rc Bus1=846.3 Phases=1 Conn=Wye Model=1 kV= 14.376 kW= 10.0 kVAR= 5.5 + +New Load.D846_848sb Bus1=846.2 Phases=1 Conn=Wye Model=1 kV= 14.376 kW= 11.5 kVAR= 5.5 +New Load.D846_848rb Bus1=848.2 Phases=1 Conn=Wye Model=1 kV= 14.376 kW= 11.5 kVAR= 5.5 + +! Script to revise Vminpu property on all loads to allow voltage to sag to 85% without switching +! to constant Z model +Load.s860.vminpu=.85 +Load.s840.vminpu=.85 +Load.s844.vminpu=.85 +Load.s848.vminpu=.85 +Load.s830a.vminpu=.85 +Load.s830b.vminpu=.85 +Load.s830c.vminpu=.85 +Load.s890.vminpu=.85 +Load.d802_806sb.vminpu=.85 +Load.d802_806rb.vminpu=.85 +Load.d802_806sc.vminpu=.85 +Load.d802_806rc.vminpu=.85 +Load.d808_810sb.vminpu=.85 +Load.d808_810rb.vminpu=.85 +Load.d818_820sa.vminpu=.85 +Load.d818_820ra.vminpu=.85 +Load.d820_822sa.vminpu=.85 +Load.d820_822ra.vminpu=.85 +Load.d816_824sb.vminpu=.85 +Load.d816_824rb.vminpu=.85 +Load.d824_826sb.vminpu=.85 +Load.d824_826rb.vminpu=.85 +Load.d824_828sc.vminpu=.85 +Load.d824_828rc.vminpu=.85 +Load.d828_830sa.vminpu=.85 +Load.d828_830ra.vminpu=.85 +Load.d854_856sb.vminpu=.85 +Load.d854_856rb.vminpu=.85 +Load.d832_858sa.vminpu=.85 +Load.d832_858ra.vminpu=.85 +Load.d832_858sb.vminpu=.85 +Load.d832_858rb.vminpu=.85 +Load.d832_858sc.vminpu=.85 +Load.d832_858rc.vminpu=.85 +Load.d858_864sb.vminpu=.85 +Load.d858_864rb.vminpu=.85 +Load.d858_834sa.vminpu=.85 +Load.d858_834ra.vminpu=.85 +Load.d858_834sb.vminpu=.85 +Load.d858_834rb.vminpu=.85 +Load.d858_834sc.vminpu=.85 +Load.d858_834rc.vminpu=.85 +Load.d834_860sa.vminpu=.85 +Load.d834_860ra.vminpu=.85 +Load.d834_860sb.vminpu=.85 +Load.d834_860rb.vminpu=.85 +Load.d834_860sc.vminpu=.85 +Load.d834_860rc.vminpu=.85 +Load.d860_836sa.vminpu=.85 +Load.d860_836ra.vminpu=.85 +Load.d860_836sb.vminpu=.85 +Load.d860_836rb.vminpu=.85 +Load.d860_836sc.vminpu=.85 +Load.d860_836rc.vminpu=.85 +Load.d836_840sa.vminpu=.85 +Load.d836_840ra.vminpu=.85 +Load.d836_840sb.vminpu=.85 +Load.d836_840rb.vminpu=.85 +Load.d862_838sb.vminpu=.85 +Load.d862_838rb.vminpu=.85 +Load.d842_844sa.vminpu=.85 +Load.d842_844ra.vminpu=.85 +Load.d844_846sb.vminpu=.85 +Load.d844_846rb.vminpu=.85 +Load.d844_846sc.vminpu=.85 +Load.d844_846rc.vminpu=.85 +Load.d846_848sb.vminpu=.85 +Load.d846_848rb.vminpu=.85 + + +! let the DSS estimate voltage bases automatically +Set VoltageBases = "69,24.9,4.16, .48" +CalcVoltageBases diff --git a/tests/data/dist/pmd/License.md b/tests/data/dist/pmd/License.md new file mode 100644 index 0000000..b28b56b --- /dev/null +++ b/tests/data/dist/pmd/License.md @@ -0,0 +1,11 @@ +# License + +The JSON files in this directory are derivative works: ENGINEERING model +exports generated with PowerModelsDistribution from the `.dss` cases in +`../opendss/` and `../micro/` (the generation commands are recorded in +`../README.md`). Each file carries the license of its source case: + +- `ieee13.json` derives from `../opendss/ieee13/`, distributed under the + BSD 3 clause license in `../opendss/License.txt`. +- `fourwire_linecode.json` derives from `../micro/fourwire_linecode.dss`, + CC BY 4.0 per `../micro/License.md`. diff --git a/tests/data/dist/pmd/fourwire_linecode.json b/tests/data/dist/pmd/fourwire_linecode.json new file mode 100644 index 0000000..5028f29 --- /dev/null +++ b/tests/data/dist/pmd/fourwire_linecode.json @@ -0,0 +1,379 @@ +{ + "bus": { + "loadbus": { + "grounded": [], + "rg": [], + "status": "ENABLED", + "terminals": [ + 1, + 2, + 3, + 4 + ], + "xg": [] + }, + "sourcebus": { + "grounded": [ + 4 + ], + "rg": [ + 0.0 + ], + "status": "ENABLED", + "terminals": [ + 1, + 2, + 3, + 4 + ], + "xg": [ + 0.0 + ] + } + }, + "conductor_ids": [ + 1, + 2, + 3, + 4 + ], + "data_model": "ENGINEERING", + "files": [ + "tests/data/dist/micro/fourwire_linecode.dss" + ], + "line": { + "l1": { + "f_bus": "sourcebus", + "f_connections": [ + 1, + 2, + 3, + 4 + ], + "length": 400.0, + "linecode": "lc4", + "source_id": "line.l1", + "status": "ENABLED", + "t_bus": "loadbus", + "t_connections": [ + 1, + 2, + 3, + 4 + ] + } + }, + "linecode": { + "lc4": { + "b_fr": [ + [ + 0.005, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.005, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.005, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.005 + ] + ], + "b_to": [ + [ + 0.005, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.005, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.005, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.005 + ] + ], + "cm_ub": [ + 240.0, + 240.0, + 240.0, + 240.0 + ], + "g_fr": [ + [ + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0 + ] + ], + "g_to": [ + [ + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0 + ] + ], + "rs": [ + [ + 0.000211, + 4.9000000000000005e-5, + 4.9000000000000005e-5, + 4.9000000000000005e-5 + ], + [ + 4.9000000000000005e-5, + 0.000211, + 4.9000000000000005e-5, + 4.9000000000000005e-5 + ], + [ + 4.9000000000000005e-5, + 4.9000000000000005e-5, + 0.000211, + 4.9000000000000005e-5 + ], + [ + 4.9000000000000005e-5, + 4.9000000000000005e-5, + 4.9000000000000005e-5, + 0.000211 + ] + ], + "xs": [ + [ + 0.000747, + 0.0006730000000000001, + 0.000651, + 0.0006730000000000001 + ], + [ + 0.0006730000000000001, + 0.000747, + 0.0006730000000000001, + 0.000651 + ], + [ + 0.000651, + 0.0006730000000000001, + 0.000747, + 0.0006730000000000001 + ], + [ + 0.0006730000000000001, + 0.000651, + 0.0006730000000000001, + 0.000747 + ] + ] + } + }, + "load": { + "la": { + "bus": "loadbus", + "configuration": "DELTA", + "connections": [ + 1, + 4 + ], + "dispatchable": "NO", + "model": "POWER", + "pd_nom": [ + 8.0 + ], + "qd_nom": [ + 2.6294728414309056 + ], + "source_id": "load.la", + "status": "ENABLED", + "vm_nom": 0.24 + }, + "lb": { + "bus": "loadbus", + "configuration": "DELTA", + "connections": [ + 2, + 4 + ], + "dispatchable": "NO", + "model": "POWER", + "pd_nom": [ + 6.0 + ], + "qd_nom": [ + 1.9721046310731793 + ], + "source_id": "load.lb", + "status": "ENABLED", + "vm_nom": 0.24 + }, + "lc": { + "bus": "loadbus", + "configuration": "DELTA", + "connections": [ + 3, + 4 + ], + "dispatchable": "NO", + "model": "POWER", + "pd_nom": [ + 10.0 + ], + "qd_nom": [ + 3.286841051788632 + ], + "source_id": "load.lc", + "status": "ENABLED", + "vm_nom": 0.24 + } + }, + "name": "fourwire", + "settings": { + "base_frequency": 60.0, + "power_scale_factor": 1000.0, + "sbase_default": 100000.0, + "vbases_default": { + "sourcebus": 0.24017771198288432 + }, + "voltage_scale_factor": 1000.0 + }, + "voltage_source": { + "source": { + "bus": "sourcebus", + "configuration": "WYE", + "connections": [ + 1, + 2, + 3, + 4 + ], + "rs": [ + [ + 2.182476921015706e-5, + 8.386466470132402e-7, + 8.386466470132402e-7, + 8.386466470132402e-7 + ], + [ + 8.386466470132402e-7, + 2.182476921015706e-5, + 8.386466470132402e-7, + 8.386466470132402e-7 + ], + [ + 8.386466470132402e-7, + 8.386466470132402e-7, + 2.182476921015706e-5, + 8.386466470132402e-7 + ], + [ + 8.386466470132402e-7, + 8.386466470132402e-7, + 8.386466470132402e-7, + 2.182476921015706e-5 + ] + ], + "source_id": "vsource.source", + "status": "ENABLED", + "va": [ + 0.0, + -119.99999999999999, + 120.00000000000001, + 0.0 + ], + "vm": [ + 0.24017771198288432, + 0.24017771198288432, + 0.24017771198288432, + 0.0 + ], + "xs": [ + [ + 7.94650560059004e-5, + -4.479434246674887e-6, + -4.479434246674887e-6, + -4.479434246674887e-6 + ], + [ + -4.479434246674887e-6, + 7.94650560059004e-5, + -4.479434246674887e-6, + -4.479434246674887e-6 + ], + [ + -4.479434246674887e-6, + -4.479434246674887e-6, + 7.94650560059004e-5, + -4.479434246674887e-6 + ], + [ + -4.479434246674887e-6, + -4.479434246674887e-6, + -4.479434246674887e-6, + 7.94650560059004e-5 + ] + ] + } + } +} \ No newline at end of file diff --git a/tests/data/dist/pmd/ieee13.json b/tests/data/dist/pmd/ieee13.json new file mode 100644 index 0000000..0005e27 --- /dev/null +++ b/tests/data/dist/pmd/ieee13.json @@ -0,0 +1,4379 @@ +{ + "bus": { + "611": { + "grounded": [ + 4 + ], + "lat": 100.0, + "lon": 0.0, + "rg": [ + 0.0 + ], + "status": "ENABLED", + "terminals": [ + 3, + 4 + ], + "xg": [ + 0.0 + ] + }, + "632": { + "grounded": [], + "lat": 250.0, + "lon": 200.0, + "rg": [], + "status": "ENABLED", + "terminals": [ + 1, + 2, + 3 + ], + "xg": [] + }, + "633": { + "grounded": [ + 4 + ], + "lat": 250.0, + "lon": 350.0, + "rg": [ + 0.0 + ], + "status": "ENABLED", + "terminals": [ + 1, + 2, + 3, + 4 + ], + "xg": [ + 0.0 + ] + }, + "634": { + "grounded": [ + 4 + ], + "lat": 250.0, + "lon": 400.0, + "rg": [ + 0.0 + ], + "status": "ENABLED", + "terminals": [ + 1, + 2, + 3, + 4 + ], + "xg": [ + 0.0 + ] + }, + "645": { + "grounded": [ + 4 + ], + "lat": 250.0, + "lon": 100.0, + "rg": [ + 0.0 + ], + "status": "ENABLED", + "terminals": [ + 2, + 3, + 4 + ], + "xg": [ + 0.0 + ] + }, + "646": { + "grounded": [], + "lat": 250.0, + "lon": 0.0, + "rg": [], + "status": "ENABLED", + "terminals": [ + 2, + 3 + ], + "xg": [] + }, + "650": { + "grounded": [ + 4 + ], + "lat": 350.0, + "lon": 200.0, + "rg": [ + 0.0 + ], + "status": "ENABLED", + "terminals": [ + 1, + 2, + 3, + 4 + ], + "xg": [ + 0.0 + ] + }, + "652": { + "grounded": [ + 4 + ], + "lat": 0.0, + "lon": 100.0, + "rg": [ + 0.0 + ], + "status": "ENABLED", + "terminals": [ + 1, + 4 + ], + "xg": [ + 0.0 + ] + }, + "670": { + "grounded": [ + 4 + ], + "lat": 200.0, + "lon": 200.0, + "rg": [ + 0.0 + ], + "status": "ENABLED", + "terminals": [ + 1, + 2, + 3, + 4 + ], + "xg": [ + 0.0 + ] + }, + "671": { + "grounded": [], + "lat": 100.0, + "lon": 200.0, + "rg": [], + "status": "ENABLED", + "terminals": [ + 1, + 2, + 3 + ], + "xg": [] + }, + "675": { + "grounded": [ + 4 + ], + "lat": 100.0, + "lon": 400.0, + "rg": [ + 0.0 + ], + "status": "ENABLED", + "terminals": [ + 1, + 2, + 3, + 4 + ], + "xg": [ + 0.0 + ] + }, + "680": { + "grounded": [], + "lat": 0.0, + "lon": 200.0, + "rg": [], + "status": "ENABLED", + "terminals": [ + 1, + 2, + 3 + ], + "xg": [] + }, + "684": { + "grounded": [], + "lat": 100.0, + "lon": 100.0, + "rg": [], + "status": "ENABLED", + "terminals": [ + 1, + 3 + ], + "xg": [] + }, + "692": { + "grounded": [], + "lat": 100.0, + "lon": 250.0, + "rg": [], + "status": "ENABLED", + "terminals": [ + 1, + 2, + 3 + ], + "xg": [] + }, + "rg60": { + "grounded": [ + 4 + ], + "lat": 300.0, + "lon": 200.0, + "rg": [ + 0.0 + ], + "status": "ENABLED", + "terminals": [ + 1, + 2, + 3, + 4 + ], + "xg": [ + 0.0 + ] + }, + "sourcebus": { + "grounded": [ + 4 + ], + "lat": 400.0, + "lon": 200.0, + "rg": [ + 0.0 + ], + "status": "ENABLED", + "terminals": [ + 1, + 2, + 3, + 4 + ], + "xg": [ + 0.0 + ] + } + }, + "conductor_ids": [ + 1, + 2, + 3, + 4 + ], + "data_model": "ENGINEERING", + "files": [ + "tests/data/dist/opendss/ieee13/IEEE13Nodeckt.dss", + "tests/data/dist/opendss/ieee13/IEEELineCodes.DSS", + "tests/data/dist/opendss/ieee13/../IEEELineCodes.DSS" + ], + "line": { + "632633": { + "f_bus": "632", + "f_connections": [ + 1, + 2, + 3 + ], + "length": 152.4, + "linecode": "mtx602", + "source_id": "line.632633", + "status": "ENABLED", + "t_bus": "633", + "t_connections": [ + 1, + 2, + 3 + ] + }, + "632645": { + "f_bus": "632", + "f_connections": [ + 3, + 2 + ], + "length": 152.4, + "linecode": "mtx603", + "source_id": "line.632645", + "status": "ENABLED", + "t_bus": "645", + "t_connections": [ + 3, + 2 + ] + }, + "632670": { + "f_bus": "632", + "f_connections": [ + 1, + 2, + 3 + ], + "length": 203.3016, + "linecode": "mtx601", + "source_id": "line.632670", + "status": "ENABLED", + "t_bus": "670", + "t_connections": [ + 1, + 2, + 3 + ] + }, + "645646": { + "f_bus": "645", + "f_connections": [ + 3, + 2 + ], + "length": 91.44, + "linecode": "mtx603", + "source_id": "line.645646", + "status": "ENABLED", + "t_bus": "646", + "t_connections": [ + 3, + 2 + ] + }, + "650632": { + "f_bus": "rg60", + "f_connections": [ + 1, + 2, + 3 + ], + "length": 609.6, + "linecode": "mtx601", + "source_id": "line.650632", + "status": "ENABLED", + "t_bus": "632", + "t_connections": [ + 1, + 2, + 3 + ] + }, + "670671": { + "f_bus": "670", + "f_connections": [ + 1, + 2, + 3 + ], + "length": 406.2984, + "linecode": "mtx601", + "source_id": "line.670671", + "status": "ENABLED", + "t_bus": "671", + "t_connections": [ + 1, + 2, + 3 + ] + }, + "671680": { + "f_bus": "671", + "f_connections": [ + 1, + 2, + 3 + ], + "length": 304.8, + "linecode": "mtx601", + "source_id": "line.671680", + "status": "ENABLED", + "t_bus": "680", + "t_connections": [ + 1, + 2, + 3 + ] + }, + "671684": { + "f_bus": "671", + "f_connections": [ + 1, + 3 + ], + "length": 91.44, + "linecode": "mtx604", + "source_id": "line.671684", + "status": "ENABLED", + "t_bus": "684", + "t_connections": [ + 1, + 3 + ] + }, + "684611": { + "f_bus": "684", + "f_connections": [ + 3 + ], + "length": 91.44, + "linecode": "mtx605", + "source_id": "line.684611", + "status": "ENABLED", + "t_bus": "611", + "t_connections": [ + 3 + ] + }, + "684652": { + "f_bus": "684", + "f_connections": [ + 1 + ], + "length": 243.84, + "linecode": "mtx607", + "source_id": "line.684652", + "status": "ENABLED", + "t_bus": "652", + "t_connections": [ + 1 + ] + }, + "692675": { + "f_bus": "692", + "f_connections": [ + 1, + 2, + 3 + ], + "length": 152.4, + "linecode": "mtx606", + "source_id": "line.692675", + "status": "ENABLED", + "t_bus": "675", + "t_connections": [ + 1, + 2, + 3 + ] + } + }, + "linecode": { + "1": { + "b_fr": [ + [ + 0.004678002086614173, + -0.0015096682857611548, + -0.0005753864271653543 + ], + [ + -0.0015096682857611548, + 0.004928858041338582, + -0.0009596641289370078 + ], + [ + -0.0005753864271653543, + -0.0009596641289370078, + 0.004447748622047244 + ] + ], + "b_to": [ + [ + 0.004678002086614173, + -0.0015096682857611548, + -0.0005753864271653543 + ], + [ + -0.0015096682857611548, + 0.004928858041338582, + -0.0009596641289370078 + ], + [ + -0.0005753864271653543, + -0.0009596641289370078, + 0.004447748622047244 + ] + ], + "cm_ub": [ + 600.0, + 600.0, + 600.0 + ], + "g_fr": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ] + ], + "g_to": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ] + ], + "rs": [ + [ + 0.0002843394586614173, + 9.693390748031496e-5, + 9.538047900262466e-5 + ], + [ + 9.693390748031496e-5, + 0.00028993179790026246, + 9.817664698162729e-5 + ], + [ + 9.538047900262466e-5, + 9.817664698162729e-5, + 0.00028676280511811023 + ] + ], + "xs": [ + [ + 0.0006698381463254593, + 0.00031174192585301837, + 0.0002391657709973753 + ], + [ + 0.00031174192585301837, + 0.0006513212828083989, + 0.0002632128379265092 + ], + [ + 0.0002391657709973753, + 0.0002632128379265092, + 0.0006618224573490814 + ] + ] + }, + "10": { + "b_fr": [ + [ + 0.0037243538845144358 + ] + ], + "b_to": [ + [ + 0.0037243538845144358 + ] + ], + "cm_ub": [ + 600.0 + ], + "g_fr": [ + [ + 0.0 + ] + ], + "g_to": [ + [ + 0.0 + ] + ], + "rs": [ + [ + 0.0008259265879265092 + ] + ], + "xs": [ + [ + 0.0008372976804461943 + ] + ] + }, + "11": { + "b_fr": [ + [ + 0.0037243538845144358 + ] + ], + "b_to": [ + [ + 0.0037243538845144358 + ] + ], + "cm_ub": [ + 600.0 + ], + "g_fr": [ + [ + 0.0 + ] + ], + "g_to": [ + [ + 0.0 + ] + ], + "rs": [ + [ + 0.0008259265879265092 + ] + ], + "xs": [ + [ + 0.0008372976804461943 + ] + ] + }, + "12": { + "b_fr": [ + [ + 0.05539944470144356, + 0.0, + 0.0 + ], + [ + 0.0, + 0.05539944470144356, + 0.0 + ], + [ + 0.0, + 0.0, + 0.05539944470144356 + ] + ], + "b_to": [ + [ + 0.05539944470144356, + 0.0, + 0.0 + ], + [ + 0.0, + 0.05539944470144356, + 0.0 + ], + [ + 0.0, + 0.0, + 0.05539944470144356 + ] + ], + "cm_ub": [ + 600.0, + 600.0, + 600.0 + ], + "g_fr": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ] + ], + "g_to": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ] + ], + "rs": [ + [ + 0.0009450434448818897, + 0.0003229887467191601, + 0.00030596317585301837 + ], + [ + 0.0003229887467191601, + 0.0009524999015748031, + 0.0003229887467191601 + ], + [ + 0.00030596317585301837, + 0.0003229887467191601, + 0.0009450434448818897 + ] + ], + "xs": [ + [ + 0.0004673332742782152, + 0.00017243050524934383, + 0.00013402976706036745 + ], + [ + 0.00017243050524934383, + 0.00044502604658792645, + 0.00017243050524934383 + ], + [ + 0.00013402976706036745, + 0.00017243050524934383, + 0.0004673332742782152 + ] + ] + }, + "2": { + "b_fr": [ + [ + 0.004928858041338582, + -0.0009596641289370078, + -0.0015096682857611548 + ], + [ + -0.0009596641289370078, + 0.004447748622047244, + -0.0005753864271653543 + ], + [ + -0.0015096682857611548, + -0.0005753864271653543, + 0.004678002086614173 + ] + ], + "b_to": [ + [ + 0.004928858041338582, + -0.0009596641289370078, + -0.0015096682857611548 + ], + [ + -0.0009596641289370078, + 0.004447748622047244, + -0.0005753864271653543 + ], + [ + -0.0015096682857611548, + -0.0005753864271653543, + 0.004678002086614173 + ] + ], + "cm_ub": [ + 600.0, + 600.0, + 600.0 + ], + "g_fr": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ] + ], + "g_to": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ] + ], + "rs": [ + [ + 0.00028993179790026246, + 9.81766404199475e-5, + 9.693390748031496e-5 + ], + [ + 9.81766404199475e-5, + 0.00028676280511811023, + 9.538047900262466e-5 + ], + [ + 9.693390748031496e-5, + 9.538047900262466e-5, + 0.0002843394586614173 + ] + ], + "xs": [ + [ + 0.0006513212828083989, + 0.0002632128379265092, + 0.00031174192585301837 + ], + [ + 0.0002632128379265092, + 0.0006618224573490814, + 0.0002391657709973753 + ], + [ + 0.00031174192585301837, + 0.0002391657709973753, + 0.0006698381463254593 + ] + ] + }, + "3": { + "b_fr": [ + [ + 0.004447748622047244, + -0.0005753864271653543, + -0.0009596641289370078 + ], + [ + -0.0005753864271653543, + 0.004678002086614173, + -0.0015096682857611548 + ], + [ + -0.0009596641289370078, + -0.0015096682857611548, + 0.004928858041338582 + ] + ], + "b_to": [ + [ + 0.004447748622047244, + -0.0005753864271653543, + -0.0009596641289370078 + ], + [ + -0.0005753864271653543, + 0.004678002086614173, + -0.0015096682857611548 + ], + [ + -0.0009596641289370078, + -0.0015096682857611548, + 0.004928858041338582 + ] + ], + "cm_ub": [ + 600.0, + 600.0, + 600.0 + ], + "g_fr": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ] + ], + "g_to": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ] + ], + "rs": [ + [ + 0.00028676280511811023, + 9.538047900262466e-5, + 9.817664698162729e-5 + ], + [ + 9.538047900262466e-5, + 0.0002843394586614173, + 9.693390748031496e-5 + ], + [ + 9.817664698162729e-5, + 9.693390748031496e-5, + 0.00028993179790026246 + ] + ], + "xs": [ + [ + 0.0006618224573490814, + 0.0002391657709973753, + 0.0002632128379265092 + ], + [ + 0.0002391657709973753, + 0.0006698381463254593, + 0.00031174192585301837 + ], + [ + 0.0002632128379265092, + 0.00031174192585301837, + 0.0006513212828083989 + ] + ] + }, + "300": { + "b_fr": [ + [ + 0.004396572029199475, + -0.001261943907480315, + -0.0008194023556430446 + ], + [ + -0.001261943907480315, + 0.004201177985564305, + -0.0005119307480314961 + ], + [ + -0.0008194023556430446, + -0.0005119307480314961, + 0.004028199453740157 + ] + ], + "b_to": [ + [ + 0.004396572029199475, + -0.001261943907480315, + -0.0008194023556430446 + ], + [ + -0.001261943907480315, + 0.004201177985564305, + -0.0005119307480314961 + ], + [ + -0.0008194023556430446, + -0.0005119307480314961, + 0.004028199453740157 + ] + ], + "cm_ub": [ + 600.0, + 600.0, + 600.0 + ], + "g_fr": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ] + ], + "g_to": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ] + ], + "rs": [ + [ + 0.0008306490091863517, + 0.00013055008858267717, + 0.00013235206364829396 + ], + [ + 0.00013055008858267717, + 0.0008225711843832021, + 0.00012837528871391076 + ], + [ + 0.00013235206364829396, + 0.00012837528871391076, + 0.0008260508628608923 + ] + ], + "xs": [ + [ + 0.0008290955807086615, + 0.00035909041338582677, + 0.00031161765419947506 + ], + [ + 0.00035909041338582677, + 0.0008431385695538057, + 0.0002852715157480315 + ], + [ + 0.00031161765419947506, + 0.0002852715157480315, + 0.0008370491338582677 + ] + ] + }, + "301": { + "b_fr": [ + [ + 0.004219967458989502, + -0.0011837368438320209, + -0.0007748185613517059 + ], + [ + -0.0011837368438320209, + 0.004042621197506562, + -0.000490421745406824 + ], + [ + -0.0007748185613517059, + -0.000490421745406824, + 0.003885959840879265 + ] + ], + "b_to": [ + [ + 0.004219967458989502, + -0.0011837368438320209, + -0.0007748185613517059 + ], + [ + -0.0011837368438320209, + 0.004042621197506562, + -0.000490421745406824 + ], + [ + -0.0007748185613517059, + -0.000490421745406824, + 0.003885959840879265 + ] + ], + "cm_ub": [ + 600.0, + 600.0, + 600.0 + ], + "g_fr": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ] + ], + "g_to": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ] + ], + "rs": [ + [ + 0.001199246400918635, + 0.00014459307742782152, + 0.00014658146325459317 + ], + [ + 0.00014459307742782152, + 0.0011903607939632546, + 0.0001421697276902887 + ], + [ + 0.00014658146325459317, + 0.0001421697276902887, + 0.0011942132939632545 + ] + ], + "xs": [ + [ + 0.000877065436351706, + 0.00040028732283464567, + 0.00035362234580052493 + ], + [ + 0.00040028732283464567, + 0.0008873802001312336, + 0.00032547422900262466 + ], + [ + 0.00035362234580052493, + 0.00032547422900262466, + 0.0008829063254593175 + ] + ] + }, + "302": { + "b_fr": [ + [ + 0.0034819061679790026 + ] + ], + "b_to": [ + [ + 0.0034819061679790026 + ] + ], + "cm_ub": [ + 600.0 + ], + "g_fr": [ + [ + 0.0 + ] + ], + "g_to": [ + [ + 0.0 + ] + ], + "rs": [ + [ + 0.001739527559055118 + ] + ], + "xs": [ + [ + 0.0009230479002624672 + ] + ] + }, + "303": { + "b_fr": [ + [ + 0.0034819061679790026 + ] + ], + "b_to": [ + [ + 0.0034819061679790026 + ] + ], + "cm_ub": [ + 600.0 + ], + "g_fr": [ + [ + 0.0 + ] + ], + "g_to": [ + [ + 0.0 + ] + ], + "rs": [ + [ + 0.001739527559055118 + ] + ], + "xs": [ + [ + 0.0009230479002624672 + ] + ] + }, + "304": { + "b_fr": [ + [ + 0.0035961286089238845 + ] + ], + "b_to": [ + [ + 0.0035961286089238845 + ] + ], + "cm_ub": [ + 600.0 + ], + "g_fr": [ + [ + 0.0 + ] + ], + "g_to": [ + [ + 0.0 + ] + ], + "rs": [ + [ + 0.0011940879265091864 + ] + ], + "xs": [ + [ + 0.0008830938320209973 + ] + ] + }, + "4": { + "b_fr": [ + [ + 0.004447748622047244, + -0.0009596641289370078, + -0.0005753864271653543 + ], + [ + -0.0009596641289370078, + 0.004928858041338582, + -0.0015096682857611548 + ], + [ + -0.0005753864271653543, + -0.0015096682857611548, + 0.004678002086614173 + ] + ], + "b_to": [ + [ + 0.004447748622047244, + -0.0009596641289370078, + -0.0005753864271653543 + ], + [ + -0.0009596641289370078, + 0.004928858041338582, + -0.0015096682857611548 + ], + [ + -0.0005753864271653543, + -0.0015096682857611548, + 0.004678002086614173 + ] + ], + "cm_ub": [ + 600.0, + 600.0, + 600.0 + ], + "g_fr": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ] + ], + "g_to": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ] + ], + "rs": [ + [ + 0.00028676280511811023, + 9.817664698162729e-5, + 9.538047900262466e-5 + ], + [ + 9.817664698162729e-5, + 0.00028993179790026246, + 9.693390748031496e-5 + ], + [ + 9.538047900262466e-5, + 9.693390748031496e-5, + 0.0002843394586614173 + ] + ], + "xs": [ + [ + 0.0006618224573490814, + 0.0002632128379265092, + 0.0002391657709973753 + ], + [ + 0.0002632128379265092, + 0.0006513212828083989, + 0.00031174192585301837 + ], + [ + 0.0002391657709973753, + 0.00031174192585301837, + 0.0006698381463254593 + ] + ] + }, + "400": { + "b_fr": [ + [ + 1.451505, + -0.3396675, + -0.111565 + ], + [ + -0.3396675, + 1.57948, + -0.240708 + ], + [ + -0.111565, + -0.240708, + 1.44825 + ] + ], + "b_to": [ + [ + 1.451505, + -0.3396675, + -0.111565 + ], + [ + -0.3396675, + 1.57948, + -0.240708 + ], + [ + -0.111565, + -0.240708, + 1.44825 + ] + ], + "cm_ub": [ + 600.0, + 600.0, + 600.0 + ], + "g_fr": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ] + ], + "g_to": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ] + ], + "rs": [ + [ + 0.088205, + 0.0312137, + 0.0306264 + ], + [ + 0.0312137, + 0.0901946, + 0.0316143 + ], + [ + 0.0306264, + 0.0316143, + 0.0889665 + ] + ], + "xs": [ + [ + 0.20744, + 0.0935314, + 0.0760312 + ], + [ + 0.0935314, + 0.200783, + 0.0855879 + ], + [ + 0.0760312, + 0.0855879, + 0.204877 + ] + ] + }, + "5": { + "b_fr": [ + [ + 0.004928858041338582, + -0.0015096682857611548, + -0.0009596641289370078 + ], + [ + -0.0015096682857611548, + 0.004678002086614173, + -0.0005753864271653543 + ], + [ + -0.0009596641289370078, + -0.0005753864271653543, + 0.004447748622047244 + ] + ], + "b_to": [ + [ + 0.004928858041338582, + -0.0015096682857611548, + -0.0009596641289370078 + ], + [ + -0.0015096682857611548, + 0.004678002086614173, + -0.0005753864271653543 + ], + [ + -0.0009596641289370078, + -0.0005753864271653543, + 0.004447748622047244 + ] + ], + "cm_ub": [ + 600.0, + 600.0, + 600.0 + ], + "g_fr": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ] + ], + "g_to": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ] + ], + "rs": [ + [ + 0.00028993179790026246, + 9.693390748031496e-5, + 9.817664698162729e-5 + ], + [ + 9.693390748031496e-5, + 0.0002843394586614173, + 9.538047900262466e-5 + ], + [ + 9.817664698162729e-5, + 9.538047900262466e-5, + 0.00028676280511811023 + ] + ], + "xs": [ + [ + 0.0006513212828083989, + 0.00031174192585301837, + 0.0002632128379265092 + ], + [ + 0.00031174192585301837, + 0.0006698381463254593, + 0.0002391657709973753 + ], + [ + 0.0002632128379265092, + 0.0002391657709973753, + 0.0006618224573490814 + ] + ] + }, + "6": { + "b_fr": [ + [ + 0.004678002086614173, + -0.0005753864271653543, + -0.0015096682857611548 + ], + [ + -0.0005753864271653543, + 0.004447748622047244, + -0.0009596641289370078 + ], + [ + -0.0015096682857611548, + -0.0009596641289370078, + 0.004928858041338582 + ] + ], + "b_to": [ + [ + 0.004678002086614173, + -0.0005753864271653543, + -0.0015096682857611548 + ], + [ + -0.0005753864271653543, + 0.004447748622047244, + -0.0009596641289370078 + ], + [ + -0.0015096682857611548, + -0.0009596641289370078, + 0.004928858041338582 + ] + ], + "cm_ub": [ + 600.0, + 600.0, + 600.0 + ], + "g_fr": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ] + ], + "g_to": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ] + ], + "rs": [ + [ + 0.0002843394586614173, + 9.538047900262466e-5, + 9.693390748031496e-5 + ], + [ + 9.538047900262466e-5, + 0.00028676280511811023, + 9.817664698162729e-5 + ], + [ + 9.693390748031496e-5, + 9.817664698162729e-5, + 0.00028993179790026246 + ] + ], + "xs": [ + [ + 0.0006698381463254593, + 0.0002391657709973753, + 0.00031174192585301837 + ], + [ + 0.0002391657709973753, + 0.0006618224573490814, + 0.0002632128379265092 + ], + [ + 0.00031174192585301837, + 0.0002632128379265092, + 0.0006513212828083989 + ] + ] + }, + "601": { + "b_fr": [ + [ + 1.582419018, + -0.5013162125, + -0.316368258 + ], + [ + -0.5013162125, + 1.4969907965, + -0.1863043565 + ], + [ + -0.316368258, + -0.1863043565, + 1.4163351015 + ] + ], + "b_to": [ + [ + 1.582419018, + -0.5013162125, + -0.316368258 + ], + [ + -0.5013162125, + 1.4969907965, + -0.1863043565 + ], + [ + -0.316368258, + -0.1863043565, + 1.4163351015 + ] + ], + "cm_ub": [ + 600.0, + 600.0, + 600.0 + ], + "g_fr": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ] + ], + "g_to": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ] + ], + "rs": [ + [ + 0.065625, + 0.029545455, + 0.029924242 + ], + [ + 0.029545455, + 0.063920455, + 0.02907197 + ], + [ + 0.029924242, + 0.02907197, + 0.064659091 + ] + ], + "xs": [ + [ + 0.192784091, + 0.095018939, + 0.080227273 + ], + [ + 0.095018939, + 0.19844697, + 0.072897727 + ], + [ + 0.080227273, + 0.072897727, + 0.195984848 + ] + ] + }, + "602": { + "b_fr": [ + [ + 1.4315067115, + -0.271707459, + -0.42462925 + ], + [ + -0.271707459, + 1.3010157945, + -0.1654810705 + ], + [ + -0.42462925, + -0.1654810705, + 1.362581384 + ] + ], + "b_to": [ + [ + 1.4315067115, + -0.271707459, + -0.42462925 + ], + [ + -0.271707459, + 1.3010157945, + -0.1654810705 + ], + [ + -0.42462925, + -0.1654810705, + 1.362581384 + ] + ], + "cm_ub": [ + 600.0, + 600.0, + 600.0 + ], + "g_fr": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ] + ], + "g_to": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ] + ], + "rs": [ + [ + 0.142537879, + 0.029924242, + 0.029545455 + ], + [ + 0.029924242, + 0.14157197, + 0.02907197 + ], + [ + 0.029545455, + 0.02907197, + 0.140833333 + ] + ], + "xs": [ + [ + 0.22375, + 0.080227273, + 0.095018939 + ], + [ + 0.080227273, + 0.226950758, + 0.072897727 + ], + [ + 0.095018939, + 0.072897727, + 0.229393939 + ] + ] + }, + "603": { + "b_fr": [ + [ + 1.1830088015, + -0.226041918 + ], + [ + -0.226041918, + 1.171981754 + ] + ], + "b_to": [ + [ + 1.1830088015, + -0.226041918 + ], + [ + -0.226041918, + 1.171981754 + ] + ], + "cm_ub": [ + 600.0, + 600.0 + ], + "g_fr": [ + [ + 0.0, + 0.0 + ], + [ + 0.0, + 0.0 + ] + ], + "g_to": [ + [ + 0.0, + 0.0 + ], + [ + 0.0, + 0.0 + ] + ], + "rs": [ + [ + 0.251780303, + 0.039128788 + ], + [ + 0.039128788, + 0.250719697 + ] + ], + "xs": [ + [ + 0.255132576, + 0.086950758 + ], + [ + 0.086950758, + 0.256988636 + ] + ] + }, + "604": { + "b_fr": [ + [ + 1.171981754, + -0.226041918 + ], + [ + -0.226041918, + 1.1830088015 + ] + ], + "b_to": [ + [ + 1.171981754, + -0.226041918 + ], + [ + -0.226041918, + 1.1830088015 + ] + ], + "cm_ub": [ + 600.0, + 600.0 + ], + "g_fr": [ + [ + 0.0, + 0.0 + ], + [ + 0.0, + 0.0 + ] + ], + "g_to": [ + [ + 0.0, + 0.0 + ], + [ + 0.0, + 0.0 + ] + ], + "rs": [ + [ + 0.250719697, + 0.039128788 + ], + [ + 0.039128788, + 0.251780303 + ] + ], + "xs": [ + [ + 0.256988636, + 0.086950758 + ], + [ + 0.086950758, + 0.255132576 + ] + ] + }, + "605": { + "b_fr": [ + [ + 1.135183064 + ] + ], + "b_to": [ + [ + 1.135183064 + ] + ], + "cm_ub": [ + 600.0 + ], + "g_fr": [ + [ + 0.0 + ] + ], + "g_to": [ + [ + 0.0 + ] + ], + "rs": [ + [ + 0.251742424 + ] + ], + "xs": [ + [ + 0.255208333 + ] + ] + }, + "606": { + "b_fr": [ + [ + 24.33729704, + 0.0, + 0.0 + ], + [ + 0.0, + 24.33729704, + 0.0 + ], + [ + 0.0, + 0.0, + 24.33729704 + ] + ], + "b_to": [ + [ + 24.33729704, + 0.0, + 0.0 + ], + [ + 0.0, + 24.33729704, + 0.0 + ], + [ + 0.0, + 0.0, + 24.33729704 + ] + ], + "cm_ub": [ + 600.0, + 600.0, + 600.0 + ], + "g_fr": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ] + ], + "g_to": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ] + ], + "rs": [ + [ + 0.151174242, + 0.060454545, + 0.053958333 + ], + [ + 0.060454545, + 0.149450758, + 0.060454545 + ], + [ + 0.053958333, + 0.060454545, + 0.151174242 + ] + ], + "xs": [ + [ + 0.084526515, + 0.006212121, + -0.002708333 + ], + [ + 0.006212121, + 0.076534091, + 0.006212121 + ], + [ + -0.002708333, + 0.006212121, + 0.084526515 + ] + ] + }, + "607": { + "b_fr": [ + [ + 22.35330761 + ] + ], + "b_to": [ + [ + 22.35330761 + ] + ], + "cm_ub": [ + 600.0 + ], + "g_fr": [ + [ + 0.0 + ] + ], + "g_to": [ + [ + 0.0 + ] + ], + "rs": [ + [ + 0.254261364 + ] + ], + "xs": [ + [ + 0.097045455 + ] + ] + }, + "7": { + "b_fr": [ + [ + 0.004215599730971129, + -0.0008693427985564305 + ], + [ + -0.0008693427985564305, + 0.004260925214895013 + ] + ], + "b_to": [ + [ + 0.004215599730971129, + -0.0008693427985564305 + ], + [ + -0.0008693427985564305, + 0.004260925214895013 + ] + ], + "cm_ub": [ + 600.0, + 600.0 + ], + "g_fr": [ + [ + 0.0, + 0.0 + ], + [ + 0.0, + 0.0 + ] + ], + "g_to": [ + [ + 0.0, + 0.0 + ], + [ + 0.0, + 0.0 + ] + ], + "rs": [ + [ + 0.0002843394586614173, + 9.538047900262466e-5 + ], + [ + 9.538047900262466e-5, + 0.00028676280511811023 + ] + ], + "xs": [ + [ + 0.0006698381463254593, + 0.0002391657709973753 + ], + [ + 0.0002391657709973753, + 0.0006618224573490814 + ] + ] + }, + "721": { + "b_fr": [ + [ + 40.13742364, + 0.0, + 0.0 + ], + [ + 0.0, + 40.13742364, + 0.0 + ], + [ + 0.0, + 0.0, + 40.13742364 + ] + ], + "b_to": [ + [ + 40.13742364, + 0.0, + 0.0 + ], + [ + 0.0, + 40.13742364, + 0.0 + ], + [ + 0.0, + 0.0, + 40.13742364 + ] + ], + "cm_ub": [ + 600.0, + 600.0, + 600.0 + ], + "g_fr": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ] + ], + "g_to": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ] + ], + "rs": [ + [ + 0.055416667, + 0.012746212, + 0.006382576 + ], + [ + 0.012746212, + 0.050113636, + 0.012746212 + ], + [ + 0.006382576, + 0.012746212, + 0.055416667 + ] + ], + "xs": [ + [ + 0.037367424, + -0.006969697, + -0.007897727 + ], + [ + -0.006969697, + 0.035984848, + -0.006969697 + ], + [ + -0.007897727, + -0.006969697, + 0.037367424 + ] + ] + }, + "722": { + "b_fr": [ + [ + 32.10920545, + 0.0, + 0.0 + ], + [ + 0.0, + 32.10920545, + 0.0 + ], + [ + 0.0, + 0.0, + 32.10920545 + ] + ], + "b_to": [ + [ + 32.10920545, + 0.0, + 0.0 + ], + [ + 0.0, + 32.10920545, + 0.0 + ], + [ + 0.0, + 0.0, + 32.10920545 + ] + ], + "cm_ub": [ + 600.0, + 600.0, + 600.0 + ], + "g_fr": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ] + ], + "g_to": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ] + ], + "rs": [ + [ + 0.089981061, + 0.030852273, + 0.023371212 + ], + [ + 0.030852273, + 0.085, + 0.030852273 + ], + [ + 0.023371212, + 0.030852273, + 0.089981061 + ] + ], + "xs": [ + [ + 0.056306818, + -0.006174242, + -0.011496212 + ], + [ + -0.006174242, + 0.050719697, + -0.006174242 + ], + [ + -0.011496212, + -0.006174242, + 0.056306818 + ] + ] + }, + "723": { + "b_fr": [ + [ + 18.7988556, + 0.0, + 0.0 + ], + [ + 0.0, + 18.7988556, + 0.0 + ], + [ + 0.0, + 0.0, + 18.7988556 + ] + ], + "b_to": [ + [ + 18.7988556, + 0.0, + 0.0 + ], + [ + 0.0, + 18.7988556, + 0.0 + ], + [ + 0.0, + 0.0, + 18.7988556 + ] + ], + "cm_ub": [ + 600.0, + 600.0, + 600.0 + ], + "g_fr": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ] + ], + "g_to": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ] + ], + "rs": [ + [ + 0.245, + 0.092253788, + 0.086837121 + ], + [ + 0.092253788, + 0.246628788, + 0.092253788 + ], + [ + 0.086837121, + 0.092253788, + 0.245 + ] + ], + "xs": [ + [ + 0.127140152, + 0.039981061, + 0.028806818 + ], + [ + 0.039981061, + 0.119810606, + 0.039981061 + ], + [ + 0.028806818, + 0.039981061, + 0.127140152 + ] + ] + }, + "724": { + "b_fr": [ + [ + 15.133505145, + 0.0, + 0.0 + ], + [ + 0.0, + 15.133505145, + 0.0 + ], + [ + 0.0, + 0.0, + 15.133505145 + ] + ], + "b_to": [ + [ + 15.133505145, + 0.0, + 0.0 + ], + [ + 0.0, + 15.133505145, + 0.0 + ], + [ + 0.0, + 0.0, + 15.133505145 + ] + ], + "cm_ub": [ + 600.0, + 600.0, + 600.0 + ], + "g_fr": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ] + ], + "g_to": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ] + ], + "rs": [ + [ + 0.396818182, + 0.098560606, + 0.093295455 + ], + [ + 0.098560606, + 0.399015152, + 0.098560606 + ], + [ + 0.093295455, + 0.098560606, + 0.396818182 + ] + ], + "xs": [ + [ + 0.146931818, + 0.051856061, + 0.040208333 + ], + [ + 0.051856061, + 0.140113636, + 0.051856061 + ], + [ + 0.040208333, + 0.051856061, + 0.146931818 + ] + ] + }, + "8": { + "b_fr": [ + [ + 0.004215599730971129, + -0.0008693427985564305 + ], + [ + -0.0008693427985564305, + 0.004260925214895013 + ] + ], + "b_to": [ + [ + 0.004215599730971129, + -0.0008693427985564305 + ], + [ + -0.0008693427985564305, + 0.004260925214895013 + ] + ], + "cm_ub": [ + 600.0, + 600.0 + ], + "g_fr": [ + [ + 0.0, + 0.0 + ], + [ + 0.0, + 0.0 + ] + ], + "g_to": [ + [ + 0.0, + 0.0 + ], + [ + 0.0, + 0.0 + ] + ], + "rs": [ + [ + 0.0002843394586614173, + 9.538047900262466e-5 + ], + [ + 9.538047900262466e-5, + 0.00028676280511811023 + ] + ], + "xs": [ + [ + 0.0006698381463254593, + 0.0002391657709973753 + ], + [ + 0.0002391657709973753, + 0.0006618224573490814 + ] + ] + }, + "9": { + "b_fr": [ + [ + 0.0037243538845144358 + ] + ], + "b_to": [ + [ + 0.0037243538845144358 + ] + ], + "cm_ub": [ + 600.0 + ], + "g_fr": [ + [ + 0.0 + ] + ], + "g_to": [ + [ + 0.0 + ] + ], + "rs": [ + [ + 0.0008259265879265092 + ] + ], + "xs": [ + [ + 0.0008372976804461943 + ] + ] + }, + "mtx601": { + "b_fr": [ + [ + 0.0008699434536755112, + -0.00018641645435903812, + -0.00018641645435903812 + ], + [ + -0.00018641645435903812, + 0.0008699434536755112, + -0.00018641645435903812 + ], + [ + -0.00018641645435903812, + -0.00018641645435903812, + 0.0008699434536755112 + ] + ], + "b_to": [ + [ + 0.0008699434536755112, + -0.00018641645435903812, + -0.00018641645435903812 + ], + [ + -0.00018641645435903812, + 0.0008699434536755112, + -0.00018641645435903812 + ], + [ + -0.00018641645435903812, + -0.00018641645435903812, + 0.0008699434536755112 + ] + ], + "cm_ub": [ + 600.0, + 600.0, + 600.0 + ], + "g_fr": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ] + ], + "g_to": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ] + ], + "rs": [ + [ + 0.000215311004784689, + 9.693655626669981e-5, + 9.81793326290934e-5 + ], + [ + 9.693655626669981e-5, + 0.00020971851115391787, + 9.538308581370783e-5 + ], + [ + 9.81793326290934e-5, + 9.538308581370783e-5, + 0.00021214192506058534 + ] + ], + "xs": [ + [ + 0.0006325110296402163, + 0.0003117504505064314, + 0.00026322003355496175 + ], + [ + 0.0003117504505064314, + 0.0006510905362580005, + 0.00023917231094264588 + ], + [ + 0.00026322003355496175, + 0.00023917231094264588, + 0.0006430124899024421 + ] + ] + }, + "mtx602": { + "b_fr": [ + [ + 0.0008699434536755112, + -0.00018641645435903812, + -0.00018641645435903812 + ], + [ + -0.00018641645435903812, + 0.0008699434536755112, + -0.00018641645435903812 + ], + [ + -0.00018641645435903812, + -0.00018641645435903812, + 0.0008699434536755112 + ] + ], + "b_to": [ + [ + 0.0008699434536755112, + -0.00018641645435903812, + -0.00018641645435903812 + ], + [ + -0.00018641645435903812, + 0.0008699434536755112, + -0.00018641645435903812 + ], + [ + -0.00018641645435903812, + -0.00018641645435903812, + 0.0008699434536755112 + ] + ], + "cm_ub": [ + 600.0, + 600.0, + 600.0 + ], + "g_fr": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ] + ], + "g_to": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ] + ], + "rs": [ + [ + 0.00046765674516870695, + 9.81793326290934e-5, + 9.693655626669981e-5 + ], + [ + 9.81793326290934e-5, + 0.0004644876654446033, + 9.538308581370783e-5 + ], + [ + 9.693655626669981e-5, + 9.538308581370783e-5, + 0.0004620642515379358 + ] + ], + "xs": [ + [ + 0.0007341079972658921, + 0.00026322003355496175, + 0.0003117504505064314 + ], + [ + 0.00026322003355496175, + 0.0007446094575281177, + 0.00023917231094264588 + ], + [ + 0.0003117504505064314, + 0.00023917231094264588, + 0.0007526253650655565 + ] + ] + }, + "mtx603": { + "b_fr": [ + [ + 0.0008699434536755112, + -0.00018641645435903812 + ], + [ + -0.00018641645435903812, + 0.0008699434536755112 + ] + ], + "b_to": [ + [ + 0.0008699434536755112, + -0.00018641645435903812 + ], + [ + -0.00018641645435903812, + 0.0008699434536755112 + ] + ], + "cm_ub": [ + 600.0, + 600.0 + ], + "g_fr": [ + [ + 0.0, + 0.0 + ], + [ + 0.0, + 0.0 + ] + ], + "g_to": [ + [ + 0.0, + 0.0 + ], + [ + 0.0, + 0.0 + ] + ], + "rs": [ + [ + 0.0008225936742683155, + 0.00012837879823525758 + ], + [ + 0.00012837879823525758, + 0.0008260734480830174 + ] + ], + "xs": [ + [ + 0.0008431616230659293, + 0.00028527931398744796 + ], + [ + 0.00028527931398744796, + 0.0008370720188902007 + ] + ] + }, + "mtx604": { + "b_fr": [ + [ + 0.0008699434536755112, + -0.00018641645435903812 + ], + [ + -0.00018641645435903812, + 0.0008699434536755112 + ] + ], + "b_to": [ + [ + 0.0008699434536755112, + -0.00018641645435903812 + ], + [ + -0.00018641645435903812, + 0.0008699434536755112 + ] + ], + "cm_ub": [ + 600.0, + 600.0 + ], + "g_fr": [ + [ + 0.0, + 0.0 + ], + [ + 0.0, + 0.0 + ] + ], + "g_to": [ + [ + 0.0, + 0.0 + ], + [ + 0.0, + 0.0 + ] + ], + "rs": [ + [ + 0.0008225936742683155, + 0.00012837879823525758 + ], + [ + 0.00012837879823525758, + 0.0008260734480830174 + ] + ], + "xs": [ + [ + 0.0008431616230659293, + 0.00028527931398744796 + ], + [ + 0.00028527931398744796, + 0.0008370720188902007 + ] + ] + }, + "mtx605": { + "b_fr": [ + [ + 0.0010563599080345492 + ] + ], + "b_to": [ + [ + 0.0010563599080345492 + ] + ], + "cm_ub": [ + 600.0 + ], + "g_fr": [ + [ + 0.0 + ] + ], + "g_to": [ + [ + 0.0 + ] + ], + "rs": [ + [ + 0.0008259491704467781 + ] + ], + "xs": [ + [ + 0.0008373205741626794 + ] + ] + }, + "mtx606": { + "b_fr": [ + [ + 0.11929037469707326, + 0.0, + 0.0 + ], + [ + 0.0, + 0.11929037469707326, + 0.0 + ], + [ + 0.0, + 0.0, + 0.11929037469707326 + ] + ], + "b_to": [ + [ + 0.11929037469707326, + 0.0, + 0.0 + ], + [ + 0.0, + 0.11929037469707326, + 0.0 + ], + [ + 0.0, + 0.0, + 0.11929037469707326 + ] + ], + "cm_ub": [ + 600.0, + 600.0, + 600.0 + ], + "g_fr": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ] + ], + "g_to": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ] + ], + "rs": [ + [ + 0.0004919660722053067, + 0.00019789722239483006, + 0.00017613247996023116 + ], + [ + 0.00019789722239483006, + 0.0004857074504442926, + 0.00019789722239483006 + ], + [ + 0.00017613247996023116, + 0.00019789722239483006, + 0.0004919660722053067 + ] + ], + "xs": [ + [ + 0.0002723867520039769, + 1.7202386130615796e-5, + -1.1446218852917417e-5 + ], + [ + 1.7202386130615796e-5, + 0.0002465028273162245, + 1.7202386130615796e-5 + ], + [ + -1.1446218852917417e-5, + 1.7202386130615796e-5, + 0.0002723867520039769 + ] + ] + }, + "mtx607": { + "b_fr": [ + [ + 0.07332380538122166 + ] + ], + "b_to": [ + [ + 0.07332380538122166 + ] + ], + "cm_ub": [ + 600.0 + ], + "g_fr": [ + [ + 0.0 + ] + ], + "g_to": [ + [ + 0.0 + ] + ], + "rs": [ + [ + 0.0008342136332566954 + ] + ], + "xs": [ + [ + 0.00031839930404523704 + ] + ] + } + }, + "load": { + "611": { + "bus": "611", + "configuration": "WYE", + "connections": [ + 3, + 4 + ], + "dispatchable": "NO", + "model": "CURRENT", + "pd_nom": [ + 170.0 + ], + "qd_nom": [ + 80.0 + ], + "source_id": "load.611", + "status": "ENABLED", + "vm_nom": 2.4 + }, + "634a": { + "bus": "634", + "configuration": "WYE", + "connections": [ + 1, + 4 + ], + "dispatchable": "NO", + "model": "POWER", + "pd_nom": [ + 160.0 + ], + "qd_nom": [ + 110.0 + ], + "source_id": "load.634a", + "status": "ENABLED", + "vm_nom": 0.277 + }, + "634b": { + "bus": "634", + "configuration": "WYE", + "connections": [ + 2, + 4 + ], + "dispatchable": "NO", + "model": "POWER", + "pd_nom": [ + 120.0 + ], + "qd_nom": [ + 90.0 + ], + "source_id": "load.634b", + "status": "ENABLED", + "vm_nom": 0.277 + }, + "634c": { + "bus": "634", + "configuration": "WYE", + "connections": [ + 3, + 4 + ], + "dispatchable": "NO", + "model": "POWER", + "pd_nom": [ + 120.0 + ], + "qd_nom": [ + 90.0 + ], + "source_id": "load.634c", + "status": "ENABLED", + "vm_nom": 0.277 + }, + "645": { + "bus": "645", + "configuration": "WYE", + "connections": [ + 2, + 4 + ], + "dispatchable": "NO", + "model": "POWER", + "pd_nom": [ + 170.0 + ], + "qd_nom": [ + 125.0 + ], + "source_id": "load.645", + "status": "ENABLED", + "vm_nom": 2.4 + }, + "646": { + "bus": "646", + "configuration": "DELTA", + "connections": [ + 2, + 3 + ], + "dispatchable": "NO", + "model": "IMPEDANCE", + "pd_nom": [ + 230.0 + ], + "qd_nom": [ + 132.0 + ], + "source_id": "load.646", + "status": "ENABLED", + "vm_nom": 4.16 + }, + "652": { + "bus": "652", + "configuration": "WYE", + "connections": [ + 1, + 4 + ], + "dispatchable": "NO", + "model": "IMPEDANCE", + "pd_nom": [ + 128.0 + ], + "qd_nom": [ + 86.0 + ], + "source_id": "load.652", + "status": "ENABLED", + "vm_nom": 2.4 + }, + "670a": { + "bus": "670", + "configuration": "WYE", + "connections": [ + 1, + 4 + ], + "dispatchable": "NO", + "model": "POWER", + "pd_nom": [ + 17.0 + ], + "qd_nom": [ + 10.0 + ], + "source_id": "load.670a", + "status": "ENABLED", + "vm_nom": 2.4 + }, + "670b": { + "bus": "670", + "configuration": "WYE", + "connections": [ + 2, + 4 + ], + "dispatchable": "NO", + "model": "POWER", + "pd_nom": [ + 66.0 + ], + "qd_nom": [ + 38.0 + ], + "source_id": "load.670b", + "status": "ENABLED", + "vm_nom": 2.4 + }, + "670c": { + "bus": "670", + "configuration": "WYE", + "connections": [ + 3, + 4 + ], + "dispatchable": "NO", + "model": "POWER", + "pd_nom": [ + 117.0 + ], + "qd_nom": [ + 68.0 + ], + "source_id": "load.670c", + "status": "ENABLED", + "vm_nom": 2.4 + }, + "671": { + "bus": "671", + "configuration": "DELTA", + "connections": [ + 1, + 2, + 3 + ], + "dispatchable": "NO", + "model": "POWER", + "pd_nom": [ + 385.0, + 385.0, + 385.0 + ], + "qd_nom": [ + 220.0, + 220.0, + 220.0 + ], + "source_id": "load.671", + "status": "ENABLED", + "vm_nom": 4.16 + }, + "675a": { + "bus": "675", + "configuration": "WYE", + "connections": [ + 1, + 4 + ], + "dispatchable": "NO", + "model": "POWER", + "pd_nom": [ + 485.0 + ], + "qd_nom": [ + 190.0 + ], + "source_id": "load.675a", + "status": "ENABLED", + "vm_nom": 2.4 + }, + "675b": { + "bus": "675", + "configuration": "WYE", + "connections": [ + 2, + 4 + ], + "dispatchable": "NO", + "model": "POWER", + "pd_nom": [ + 68.0 + ], + "qd_nom": [ + 60.0 + ], + "source_id": "load.675b", + "status": "ENABLED", + "vm_nom": 2.4 + }, + "675c": { + "bus": "675", + "configuration": "WYE", + "connections": [ + 3, + 4 + ], + "dispatchable": "NO", + "model": "POWER", + "pd_nom": [ + 290.0 + ], + "qd_nom": [ + 212.0 + ], + "source_id": "load.675c", + "status": "ENABLED", + "vm_nom": 2.4 + }, + "692": { + "bus": "692", + "configuration": "DELTA", + "connections": [ + 3, + 1 + ], + "dispatchable": "NO", + "model": "CURRENT", + "pd_nom": [ + 170.0 + ], + "qd_nom": [ + 151.0 + ], + "source_id": "load.692", + "status": "ENABLED", + "vm_nom": 4.16 + } + }, + "name": "ieee13nodeckt", + "settings": { + "base_frequency": 60.0, + "power_scale_factor": 1000.0, + "sbase_default": 100000.0, + "vbases_default": { + "sourcebus": 66.39528095680697 + }, + "voltage_scale_factor": 1000.0 + }, + "shunt": { + "cap1": { + "bs": [ + [ + 0.03467085798816568, + 0.0, + 0.0 + ], + [ + 0.0, + 0.03467085798816568, + 0.0 + ], + [ + 0.0, + 0.0, + 0.03467085798816568 + ] + ], + "bus": "675", + "configuration": "WYE", + "connections": [ + 1, + 2, + 3 + ], + "dispatchable": "NO", + "gs": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ] + ], + "model": "CAPACITOR", + "source_id": "capacitor.cap1", + "status": "ENABLED" + }, + "cap2": { + "bs": [ + [ + 0.017361111111111112 + ] + ], + "bus": "611", + "configuration": "WYE", + "connections": [ + 3 + ], + "dispatchable": "NO", + "gs": [ + [ + 0.0 + ] + ], + "model": "CAPACITOR", + "source_id": "capacitor.cap2", + "status": "ENABLED" + } + }, + "switch": { + "671692": { + "b_fr": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ] + ], + "b_to": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ] + ], + "cm_ub": [ + 600.0, + 600.0, + 600.0 + ], + "dispatchable": "YES", + "f_bus": "671", + "f_connections": [ + 1, + 2, + 3 + ], + "g_fr": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ] + ], + "g_to": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ] + ], + "rs": [ + [ + 1.0000000000000001e-7, + 0.0, + 0.0 + ], + [ + 0.0, + 1.0000000000000001e-7, + 0.0 + ], + [ + 0.0, + 0.0, + 1.0000000000000001e-7 + ] + ], + "source_id": "line.671692", + "state": "CLOSED", + "status": "ENABLED", + "t_bus": "692", + "t_connections": [ + 1, + 2, + 3 + ], + "xs": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ] + ] + } + }, + "transformer": { + "reg1": { + "bus": [ + "650", + "rg60" + ], + "cmag": 0.0, + "configuration": [ + "WYE", + "WYE" + ], + "connections": [ + [ + 1, + 2, + 3, + 4 + ], + [ + 1, + 2, + 3, + 4 + ] + ], + "controls": { + "band": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 2.0, + 2.0, + 2.0 + ] + ], + "ctprim": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 700.0, + 700.0, + 700.0 + ] + ], + "ptratio": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 20.0, + 20.0, + 20.0 + ] + ], + "r": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 3.0, + 3.0, + 3.0 + ] + ], + "vreg": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 122.0, + 122.0, + 122.0 + ] + ], + "x": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 9.0, + 9.0, + 9.0 + ] + ] + }, + "noloadloss": 0.0, + "polarity": [ + 1, + 1 + ], + "rw": [ + 5.0e-5, + 5.0e-5 + ], + "sm_nom": [ + 1666.0, + 1666.0 + ], + "sm_ub": 2499.0, + "source_id": "transformer.reg1", + "status": "ENABLED", + "tm_fix": [ + [ + true, + true, + true + ], + [ + false, + false, + false + ] + ], + "tm_lb": [ + [ + 0.9, + 0.9, + 0.9 + ], + [ + 0.9, + 0.9, + 0.9 + ] + ], + "tm_set": [ + [ + 1.0, + 1.0, + 1.0 + ], + [ + 1.0, + 1.0, + 1.0 + ] + ], + "tm_step": [ + [ + 0.03125, + 0.03125, + 0.03125 + ], + [ + 0.03125, + 0.03125, + 0.03125 + ] + ], + "tm_ub": [ + [ + 1.1, + 1.1, + 1.1 + ], + [ + 1.1, + 1.1, + 1.1 + ] + ], + "vm_nom": [ + 2.4, + 2.4 + ], + "xsc": [ + 0.0001 + ] + }, + "sub": { + "bus": [ + "sourcebus", + "650" + ], + "cmag": 0.0, + "configuration": [ + "DELTA", + "WYE" + ], + "connections": [ + [ + 1, + 2, + 3 + ], + [ + 2, + 3, + 1, + 4 + ] + ], + "name": "sub", + "noloadloss": 0.0, + "polarity": [ + 1, + -1 + ], + "rw": [ + 5.0e-6, + 5.0e-6 + ], + "sm_nom": [ + 5000.0, + 5000.0 + ], + "sm_ub": 7500.0, + "source_id": "transformer.sub", + "status": "ENABLED", + "tm_fix": [ + [ + true, + true, + true + ], + [ + true, + true, + true + ] + ], + "tm_lb": [ + [ + 0.9, + 0.9, + 0.9 + ], + [ + 0.9, + 0.9, + 0.9 + ] + ], + "tm_set": [ + [ + 1.0, + 1.0, + 1.0 + ], + [ + 1.0, + 1.0, + 1.0 + ] + ], + "tm_step": [ + [ + 0.03125, + 0.03125, + 0.03125 + ], + [ + 0.03125, + 0.03125, + 0.03125 + ] + ], + "tm_ub": [ + [ + 1.1, + 1.1, + 1.1 + ], + [ + 1.1, + 1.1, + 1.1 + ] + ], + "vm_nom": [ + 115.0, + 4.16 + ], + "xsc": [ + 8.0e-5 + ] + }, + "xfm1": { + "bus": [ + "633", + "634" + ], + "cmag": 0.0, + "configuration": [ + "WYE", + "WYE" + ], + "connections": [ + [ + 1, + 2, + 3, + 4 + ], + [ + 1, + 2, + 3, + 4 + ] + ], + "name": "xfm1", + "noloadloss": 0.0, + "polarity": [ + 1, + 1 + ], + "rw": [ + 0.0055000000000000005, + 0.0055000000000000005 + ], + "sm_nom": [ + 500.0, + 500.0 + ], + "sm_ub": 750.0, + "source_id": "transformer.xfm1", + "status": "ENABLED", + "tm_fix": [ + [ + true, + true, + true + ], + [ + true, + true, + true + ] + ], + "tm_lb": [ + [ + 0.9, + 0.9, + 0.9 + ], + [ + 0.9, + 0.9, + 0.9 + ] + ], + "tm_set": [ + [ + 1.0, + 1.0, + 1.0 + ], + [ + 1.0, + 1.0, + 1.0 + ] + ], + "tm_step": [ + [ + 0.03125, + 0.03125, + 0.03125 + ], + [ + 0.03125, + 0.03125, + 0.03125 + ] + ], + "tm_ub": [ + [ + 1.1, + 1.1, + 1.1 + ], + [ + 1.1, + 1.1, + 1.1 + ] + ], + "vm_nom": [ + 4.16, + 0.48 + ], + "xsc": [ + 0.02 + ] + } + }, + "voltage_source": { + "source": { + "bus": "sourcebus", + "configuration": "WYE", + "connections": [ + 1, + 2, + 3, + 4 + ], + "rs": [ + [ + 0.16678564904096196, + 0.006408966985686826, + 0.006408966985686826, + 0.006408966985686826 + ], + [ + 0.006408966985686826, + 0.16678564904096196, + 0.006408966985686826, + 0.006408966985686826 + ], + [ + 0.006408966985686826, + 0.006408966985686826, + 0.16678564904096196, + 0.006408966985686826 + ], + [ + 0.006408966985686826, + 0.006408966985686826, + 0.006408966985686826, + 0.16678564904096196 + ] + ], + "source_id": "vsource.source", + "status": "ENABLED", + "va": [ + 29.999999999999996, + -90.0, + 150.0, + 0.0 + ], + "vm": [ + 66.40192048490265, + 66.40192048490265, + 66.40192048490265, + 0.0 + ], + "xs": [ + [ + 0.6072747351597361, + -0.034231993061364596, + -0.034231993061364596, + -0.034231993061364596 + ], + [ + -0.034231993061364596, + 0.6072747351597361, + -0.034231993061364596, + -0.034231993061364596 + ], + [ + -0.034231993061364596, + -0.034231993061364596, + 0.6072747351597361, + -0.034231993061364596 + ], + [ + -0.034231993061364596, + -0.034231993061364596, + -0.034231993061364596, + 0.6072747351597361 + ] + ] + } + } +} \ No newline at end of file