diff --git a/.github/workflows/Benchmark.yml b/.github/workflows/Benchmark.yml index 5e52ccc..05525c3 100644 --- a/.github/workflows/Benchmark.yml +++ b/.github/workflows/Benchmark.yml @@ -7,8 +7,6 @@ on: permissions: contents: read pull-requests: write -env: - JULIA_PKG_USE_CLI_GIT: true concurrency: group: benchmark-${{ github.event.pull_request.number }} @@ -17,29 +15,8 @@ concurrency: jobs: benchmark: runs-on: ubuntu-latest - env: - POWERIO_JL_TOKEN: ${{ secrets.POWERIO_JL_REPO_TOKEN }} steps: - - name: Probe PowerIO.jl access - id: powerio-jl - env: - GIT_TERMINAL_PROMPT: 0 - run: | - if [ -n "${POWERIO_JL_TOKEN}" ]; then - echo "available=true" >> "$GITHUB_OUTPUT" - elif git ls-remote https://github.com/eigenergy/PowerIO.jl.git >/dev/null 2>&1; then - echo "available=true" >> "$GITHUB_OUTPUT" - else - echo "available=false" >> "$GITHUB_OUTPUT" - fi - - name: Skip notice (PowerIO.jl unavailable) - if: steps.powerio-jl.outputs.available != 'true' - run: echo "PowerIO.jl is not publicly readable and POWERIO_JL_REPO_TOKEN is not set; skipping benchmarks until PowerIO.jl is public or the token is configured." - - name: Configure PowerIO.jl access - if: env.POWERIO_JL_TOKEN != '' - run: git config --global url."https://x-access-token:${POWERIO_JL_TOKEN}@github.com/eigenergy/".insteadOf "https://github.com/eigenergy/" - uses: MilesCranmer/AirspeedVelocity.jl@315c11b51ceee8ebd6063d70cff6ae499a040d28 - if: steps.powerio-jl.outputs.available == 'true' with: julia-version: '1' mode: 'time,memory' diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index f5dddfe..f6dcb13 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -8,44 +8,18 @@ on: - cron: '0 4 * * 0' env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - JULIA_PKG_USE_CLI_GIT: true jobs: # Runs on every PR and push — single fast job test: runs-on: ubuntu-latest - env: - # PowerIO.jl is private until release; surface the secret as env so steps can - # skip cleanly when CI does not have cross-repo read access. - POWERIO_JL_TOKEN: ${{ secrets.POWERIO_JL_REPO_TOKEN }} steps: - uses: actions/checkout@v6 - uses: julia-actions/setup-julia@v3 with: version: '1' - - name: Probe PowerIO.jl access - id: powerio-jl - env: - GIT_TERMINAL_PROMPT: 0 - run: | - if [ -n "${POWERIO_JL_TOKEN}" ]; then - echo "available=true" >> "$GITHUB_OUTPUT" - elif git ls-remote https://github.com/eigenergy/PowerIO.jl.git >/dev/null 2>&1; then - echo "available=true" >> "$GITHUB_OUTPUT" - else - echo "available=false" >> "$GITHUB_OUTPUT" - fi - - name: Skip notice (PowerIO.jl unavailable) - if: steps.powerio-jl.outputs.available != 'true' - run: echo "PowerIO.jl is not publicly readable and POWERIO_JL_REPO_TOKEN is not set; skipping PowerDiff tests until PowerIO.jl is public or the token is configured." - - name: Configure PowerIO.jl access - if: env.POWERIO_JL_TOKEN != '' - run: git config --global url."https://x-access-token:${POWERIO_JL_TOKEN}@github.com/eigenergy/".insteadOf "https://github.com/eigenergy/" - uses: julia-actions/cache@v3 - if: steps.powerio-jl.outputs.available == 'true' - uses: julia-actions/julia-buildpkg@v1 - if: steps.powerio-jl.outputs.available == 'true' - uses: julia-actions/julia-runtest@v1 - if: steps.powerio-jl.outputs.available == 'true' # Full matrix — only on main push and scheduled runs test-full: @@ -62,37 +36,14 @@ jobs: - julia-version: 'nightly' os: ubuntu-latest continue-on-error: ${{ matrix.julia-version == 'nightly' }} - env: - POWERIO_JL_TOKEN: ${{ secrets.POWERIO_JL_REPO_TOKEN }} steps: - uses: actions/checkout@v6 - uses: julia-actions/setup-julia@v3 with: version: ${{ matrix.julia-version }} - - name: Probe PowerIO.jl access - id: powerio-jl - env: - GIT_TERMINAL_PROMPT: 0 - run: | - if [ -n "${POWERIO_JL_TOKEN}" ]; then - echo "available=true" >> "$GITHUB_OUTPUT" - elif git ls-remote https://github.com/eigenergy/PowerIO.jl.git >/dev/null 2>&1; then - echo "available=true" >> "$GITHUB_OUTPUT" - else - echo "available=false" >> "$GITHUB_OUTPUT" - fi - - name: Skip notice (PowerIO.jl unavailable) - if: steps.powerio-jl.outputs.available != 'true' - run: echo "PowerIO.jl is not publicly readable and POWERIO_JL_REPO_TOKEN is not set; skipping PowerDiff tests until PowerIO.jl is public or the token is configured." - - name: Configure PowerIO.jl access - if: env.POWERIO_JL_TOKEN != '' - run: git config --global url."https://x-access-token:${POWERIO_JL_TOKEN}@github.com/eigenergy/".insteadOf "https://github.com/eigenergy/" - uses: julia-actions/cache@v3 - if: steps.powerio-jl.outputs.available == 'true' - uses: julia-actions/julia-buildpkg@v1 - if: steps.powerio-jl.outputs.available == 'true' - uses: julia-actions/julia-runtest@v1 - if: steps.powerio-jl.outputs.available == 'true' # APF extension — only on main push and scheduled runs. # Pinned to Julia 1.12 because upstream AcceleratedDCPowerFlows restricted @@ -101,37 +52,13 @@ jobs: if: github.event_name != 'pull_request' name: APF Extension (Julia 1.12) runs-on: ubuntu-latest - env: - POWERIO_JL_TOKEN: ${{ secrets.POWERIO_JL_REPO_TOKEN }} steps: - uses: actions/checkout@v6 - uses: julia-actions/setup-julia@v3 with: version: '1.12' - - name: Probe PowerIO.jl access - id: powerio-jl - env: - GIT_TERMINAL_PROMPT: 0 - run: | - if [ -n "${POWERIO_JL_TOKEN}" ]; then - echo "available=true" >> "$GITHUB_OUTPUT" - elif git ls-remote https://github.com/eigenergy/PowerIO.jl.git >/dev/null 2>&1; then - echo "available=true" >> "$GITHUB_OUTPUT" - else - echo "available=false" >> "$GITHUB_OUTPUT" - fi - - name: Skip notice (PowerIO.jl unavailable) - if: steps.powerio-jl.outputs.available != 'true' - run: echo "PowerIO.jl is not publicly readable and POWERIO_JL_REPO_TOKEN is not set; skipping PowerDiff tests until PowerIO.jl is public or the token is configured." - - name: Configure PowerIO.jl access - if: env.POWERIO_JL_TOKEN != '' - run: git config --global url."https://x-access-token:${POWERIO_JL_TOKEN}@github.com/eigenergy/".insteadOf "https://github.com/eigenergy/" - uses: julia-actions/cache@v3 - if: steps.powerio-jl.outputs.available == 'true' - name: Install APF (unregistered) - if: steps.powerio-jl.outputs.available == 'true' run: julia --project=. -e 'using Pkg; Pkg.add(url="https://github.com/mtanneau/AcceleratedDCPowerFlows.jl.git")' - uses: julia-actions/julia-buildpkg@v1 - if: steps.powerio-jl.outputs.available == 'true' - uses: julia-actions/julia-runtest@v1 - if: steps.powerio-jl.outputs.available == 'true' diff --git a/.github/workflows/Documentation.yml b/.github/workflows/Documentation.yml index 146011e..6ee4fec 100644 --- a/.github/workflows/Documentation.yml +++ b/.github/workflows/Documentation.yml @@ -6,7 +6,6 @@ on: pull_request: env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - JULIA_PKG_USE_CLI_GIT: true jobs: build: permissions: @@ -14,36 +13,14 @@ jobs: contents: write statuses: write runs-on: ubuntu-latest - env: - POWERIO_JL_TOKEN: ${{ secrets.POWERIO_JL_REPO_TOKEN }} steps: - uses: actions/checkout@v6 - uses: julia-actions/setup-julia@v3 with: version: '1' - - name: Probe PowerIO.jl access - id: powerio-jl - env: - GIT_TERMINAL_PROMPT: 0 - run: | - if [ -n "${POWERIO_JL_TOKEN}" ]; then - echo "available=true" >> "$GITHUB_OUTPUT" - elif git ls-remote https://github.com/eigenergy/PowerIO.jl.git >/dev/null 2>&1; then - echo "available=true" >> "$GITHUB_OUTPUT" - else - echo "available=false" >> "$GITHUB_OUTPUT" - fi - - name: Skip notice (PowerIO.jl unavailable) - if: steps.powerio-jl.outputs.available != 'true' - run: echo "PowerIO.jl is not publicly readable and POWERIO_JL_REPO_TOKEN is not set; skipping docs until PowerIO.jl is public or the token is configured." - - name: Configure PowerIO.jl access - if: env.POWERIO_JL_TOKEN != '' - run: git config --global url."https://x-access-token:${POWERIO_JL_TOKEN}@github.com/eigenergy/".insteadOf "https://github.com/eigenergy/" - name: Install dependencies - if: steps.powerio-jl.outputs.available == 'true' run: julia --project=docs/ -e 'using Pkg; Pkg.develop(PackageSpec(path=pwd())); Pkg.instantiate()' - name: Build and deploy - if: steps.powerio-jl.outputs.available == 'true' run: julia --project=docs/ docs/make.jl env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/Project.toml b/Project.toml index fbf2a94..e9e48ed 100644 --- a/Project.toml +++ b/Project.toml @@ -13,12 +13,6 @@ NLPModelsIpopt = "f4238b75-b362-5c4c-b852-0801c9a21d71" PowerIO = "05ed8b54-f668-4096-9d0d-e8c3dd9dc169" SparseArrays = "2f01184e-e22b-5df5-ae63-d93ebab69eaf" -# PowerIO is not yet in the General registry, so it is pinned to the PowerIO.jl repo -# here. Once PowerIO registers, drop this [sources] block and add a [compat] entry. -# The PowerIO branch named in `rev` must be pushed before this branch's CI runs. -[sources] -PowerIO = {url = "https://github.com/eigenergy/PowerIO.jl.git", rev = "main"} - [weakdeps] AcceleratedDCPowerFlows = "c32744f1-403b-4af7-9195-1da907387c09" @@ -32,7 +26,7 @@ Ipopt = "1" JuMP = "1" LazyArtifacts = "1" NLPModelsIpopt = "0.11.2" -PowerIO = "0.0.1, 0.1" +PowerIO = "0.1.3" julia = "1.9" [extras] diff --git a/README.md b/README.md index 4089dcc..ae3782a 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Pkg.add(url="https://github.com/grid-opt-alg-lab/PowerDiff.jl.git") ```julia using PowerDiff -# Load a MATPOWER v2 case into PowerDiff's typed representation +# Parse a MATPOWER v2 case into a PowerIO.Network net = parse_file("case14.m") dc_net = DCNetwork(net) d = calc_demand_vector(net) diff --git a/docs/powerio-integration.md b/docs/powerio-integration.md index 5ee9af7..a2dcb14 100644 --- a/docs/powerio-integration.md +++ b/docs/powerio-integration.md @@ -4,24 +4,24 @@ PowerIO is PowerDiff's parser and data layer. PowerDiff does not expose a parser backend switch. `PowerDiff.parse_file(path)` resolves the path, requires a MATPOWER `.m` file, and -calls `PowerIO.parse_file(path)`. `PowerDiff.parse_file(io; filetype="m")` reads -the stream and calls `PowerIO.parse_str(text, "matpower")`. +returns a `PowerIO.Network` via `PowerIO.parse_file`. `PowerDiff.parse_file(io)` +reads the stream and calls `PowerIO.parse_str(text, "matpower")`. Pass the result to +[`DCNetwork`](@ref) or [`ACNetwork`](@ref). -PowerIO returns a raw, lossless `Network`: MW/MVAr, degrees, original bus ids, raw -bus types, loads and shunts as first class records, and out of service elements -retained. PowerDiff then maps that `Network` into its own `ParsedCase` and keeps -the normalization it already owns: +The network constructors build directly from `PowerIO.to_powerdata(net)`, which +already returns normalized data: per-unit scaling by `base_mva`, degree-to-radian +conversion, out-of-service and isolated-element filtering, bus-type inference, +per-bus load/shunt aggregation, and polynomial cost rescaling. PowerDiff layers on +only the OPF modeling it owns: -- per unit scaling by `base_mva` -- degree to radian conversion -- bus type inference and slack selection -- out of service and isolated element filtering -- tap `0` to `1` -- angle bound normalization -- generator cost rescaling and padding -- `rate_a` fallback +- polynomial cost interpretation: it reads the constant, linear, and quadratic + coefficients straight from `to_powerdata`'s generator rows (already per-unit and + right-aligned). PWL costs are rejected; higher-order polynomials are rejected by + `to_powerdata` itself. A generator with no cost record is treated as cost-free. +- a finite `rate_a` fallback when the source leaves the thermal limit at `0` +- default angle-difference bounds -PowerDiff rejects PowerIO networks carrying storage or HVDC/dcline records because -the current `ParsedCase` model has no fields for them. +PowerDiff rejects networks carrying storage or HVDC/dcline records, which it does +not model. The parser tests assert path and IO parity through this single PowerIO path. diff --git a/docs/src/advanced.md b/docs/src/advanced.md index b63129d..3545400 100644 --- a/docs/src/advanced.md +++ b/docs/src/advanced.md @@ -41,7 +41,7 @@ Stores the DC network topology and parameters. | `tau` | `Float64` | Regularization parameter | | `id_map` | `IDMapping` | Bidirectional element ID mapping (original ↔ sequential) | -Construct from typed MATPOWER data with `DCNetwork(parse_file("case14.m"))`, or +Construct from a parsed MATPOWER network with `DCNetwork(parse_file("case14.m"))`, or with explicit parameters: `DCNetwork(n, m, k, A, G_inc, b; ...)`. ### ACNetwork diff --git a/docs/src/api.md b/docs/src/api.md index 27d7a35..ea947a3 100644 --- a/docs/src/api.md +++ b/docs/src/api.md @@ -3,12 +3,6 @@ ## MATPOWER Parser ```@docs -ParsedCase -ParsedBus -ParsedGen -ParsedBranch -ParsedLoad -ParsedShunt parse_file parse_matpower parse_matpower_struct diff --git a/docs/src/getting-started.md b/docs/src/getting-started.md index 14dbd4e..21d9f05 100644 --- a/docs/src/getting-started.md +++ b/docs/src/getting-started.md @@ -7,7 +7,7 @@ This guide walks through the main workflows: DC power flow, DC OPF with LMP anal ```julia using PowerDiff -# Load a MATPOWER v2 case into PowerDiff's typed representation +# Parse a MATPOWER v2 case into a PowerIO.Network net = parse_file("case14.m") ``` diff --git a/docs/src/index.md b/docs/src/index.md index 6e9243f..517acf0 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -25,7 +25,7 @@ Pkg.add(url="https://github.com/grid-opt-alg-lab/PowerDiff.jl.git") ```julia using PowerDiff -# Load a MATPOWER v2 case into PowerDiff's typed representation +# Parse a MATPOWER v2 case into a PowerIO.Network net = parse_file("case14.m") dc_net = DCNetwork(net) d = calc_demand_vector(net) diff --git a/experiments/ipp_market_planning.jl b/experiments/ipp_market_planning.jl index d2ea7a1..d439ccb 100644 --- a/experiments/ipp_market_planning.jl +++ b/experiments/ipp_market_planning.jl @@ -571,16 +571,12 @@ function run_tier2(; outdir::String=@__DIR__, println("="^65) case_path = joinpath(dirname(pathof(PM)), "..", "test", "data", "matpower", "case14.m") - raw = PM.parse_file(case_path) - PM.make_basic_network!(raw) # populates rate_a defaults + net = DCNetwork(parse_file(case_path)) # case14 ships with very loose flow limits (max loading ~15% on the binding - # line at default rate_a). Scale by 0.10 to bring 2 lines to the bound and + # line at default rate_a). Scale fmax by 0.10 to bring 2 lines to the bound and # produce meaningful LMP variation. This is the "stressed" scenario IPPs # actually care about — peak loading, outages, etc. - for (_, br) in raw["branch"] - br["rate_a"] *= fmax_scale - end - net = DCNetwork(raw) + net.fmax .*= fmax_scale # Break generator degeneracy. Without this the KKT system is singular at # the optimum (multiple gens at upper bound), Tikhonov regularization kicks # in, and the matrix free VJP returns essentially zero gradient. @@ -728,9 +724,8 @@ const RTS_LOAD_CSV = expanduser("~/Datasets/RTS-GMLC/RTS_Data/timeseries_data_fi function load_rts_gmlc() isfile(RTS_PATH) || error("RTS_GMLC.m not at $RTS_PATH") raw = PM.parse_file(RTS_PATH) - if !isempty(raw["dcline"]) - empty!(raw["dcline"]) # PowerModels DC line workaround - end + # PowerDiff does not model HVDC/dclines; drop them (RTS-GMLC ships DC lines). + isempty(raw["dcline"]) || empty!(raw["dcline"]) PM.make_basic_network!(raw) # populates rate_a defaults & sequential IDs return raw end @@ -765,11 +760,10 @@ function run_tier4(; outdir::String=@__DIR__, println("="^65) raw = load_rts_gmlc() + # Bridge the (dcline-free) PowerModels dict into PowerDiff via PowerIO. + net = DCNetwork(PowerDiff.PowerIO.from_powermodels(raw)) # Tighten flow limits so congestion is meaningful (RTS-GMLC ships generous limits) - for (_, br) in raw["branch"] - br["rate_a"] *= 0.5 - end - net = DCNetwork(raw) + net.fmax .*= 0.5 # Break gen degeneracy so the KKT system is non singular at the optimum. for i in eachindex(net.gmax) if net.gmax[i] > 0.01 diff --git a/src/PowerDiff.jl b/src/PowerDiff.jl index d3bb1f4..703d1aa 100644 --- a/src/PowerDiff.jl +++ b/src/PowerDiff.jl @@ -20,6 +20,7 @@ using JuMP using Ipopt using ExaModels using NLPModelsIpopt +using PowerIO const MOI = JuMP.MOI @@ -28,7 +29,7 @@ const MOI = JuMP.MOI # ============================================================================= const _SILENCE_WARNINGS = Ref(false) -include("parser.jl") +include("artifacts.jl") """ silence() @@ -102,7 +103,6 @@ export Sensitivity, silence export operand_symbols, parameter_symbols export jvp, vjp, jvp!, vjp!, dict_to_vec, vec_to_dict, kkt_dims export parse_file, parse_matpower, parse_matpower_struct, get_path -export ParsedCase, ParsedBus, ParsedGen, ParsedBranch, ParsedLoad, ParsedShunt # DC Power Flow Types export DCNetwork, DCPowerFlowState diff --git a/src/artifacts.jl b/src/artifacts.jl new file mode 100644 index 0000000..4d32077 --- /dev/null +++ b/src/artifacts.jl @@ -0,0 +1,12 @@ +using LazyArtifacts + +""" + get_path(library::Symbol) + +Resolve an artifact-backed case library bundled with PowerDiff. Currently only +`:pglib` (PGLib-OPF) is available. +""" +function get_path(library::Symbol) + library == :pglib && return joinpath(artifact"PGLib_opf", "pglib-opf-23.07") + throw(ArgumentError("unsupported library $library")) +end diff --git a/src/parser.jl b/src/parser.jl deleted file mode 100644 index a449c8a..0000000 --- a/src/parser.jl +++ /dev/null @@ -1,341 +0,0 @@ -using LazyArtifacts -import PowerIO - -"""Normalized MATPOWER bus record.""" -struct ParsedBus - bus_i::Int - bus_type::Int - pd::Float64 - qd::Float64 - gs::Float64 - bs::Float64 - area::Int - vm::Float64 - va::Float64 - base_kv::Float64 - zone::Int - vmax::Float64 - vmin::Float64 -end - -"""Normalized MATPOWER generator record with quadratic cost coefficients.""" -struct ParsedGen - index::Int - gen_bus::Int - pg::Float64 - qg::Float64 - qmax::Float64 - qmin::Float64 - vg::Float64 - mbase::Float64 - gen_status::Int - pmax::Float64 - pmin::Float64 - cost::NTuple{3,Float64} -end - -"""Normalized MATPOWER pi-model branch record.""" -struct ParsedBranch - index::Int - f_bus::Int - t_bus::Int - br_r::Float64 - br_x::Float64 - br_b::Float64 - rate_a::Float64 - rate_b::Float64 - rate_c::Float64 - tap::Float64 - shift::Float64 - br_status::Int - angmin::Float64 - angmax::Float64 -end - -"""Normalized active and reactive load record.""" -struct ParsedLoad - index::Int - load_bus::Int - pd::Float64 - qd::Float64 - status::Int -end - -"""Normalized bus shunt record.""" -struct ParsedShunt - index::Int - shunt_bus::Int - gs::Float64 - bs::Float64 - status::Int -end - -""" - ParsedCase - -Normalized MATPOWER network data used by PowerDiff constructors. Power quantities -are stored in per-unit values. Constructing `ParsedCase` programmatically assumes -the supplied values are already normalized. -""" -struct ParsedCase - name::String - source_version::String - baseMVA::Float64 - bus::Vector{ParsedBus} - gen::Vector{ParsedGen} - branch::Vector{ParsedBranch} - load::Vector{ParsedLoad} - shunt::Vector{ParsedShunt} -end - -""" - get_path(library::Symbol) - -Resolve an artifact-backed library path owned by PowerDiff. -""" -function get_path(library::Symbol) - library == :pglib && return joinpath(artifact"PGLib_opf", "pglib-opf-23.07") - throw(ArgumentError("unsupported library $library")) -end - -""" - parse_file(io::Union{IO,String}; library=nothing, validate=true, filetype="m") - -Parse a MATPOWER v2 `.m` file into a normalized `ParsedCase`. - -PowerDiff intentionally supports MATPOWER files only. Convert other formats -before constructing PowerDiff types. - -PowerIO is the parser and data layer. PowerDiff normalizes the PowerIO `Network` -into its own [`ParsedCase`](@ref). -""" -function parse_file(io::Union{IO,String}; library=nothing, validate=true, filetype="m", kwargs...) - isempty(kwargs) || throw(ArgumentError( - "unsupported parse_file keyword(s): $(join(string.(keys(kwargs)), ", "))")) - resolved = io isa String ? _resolve_case_path(io, library) : io - resolved_type = resolved isa String ? lowercase(splitext(resolved)[2]) : ".$(lowercase(filetype))" - resolved_type == ".m" || throw(ArgumentError( - "unsupported network file type $resolved_type; PowerDiff supports MATPOWER v2 .m files only")) - return parse_matpower(resolved; validate) -end - -""" - parse_matpower(io::IO; validate=true) - parse_matpower(file::String; library=nothing, validate=true) - -Parse MATPOWER v2 data into a normalized [`ParsedCase`](@ref). -""" -function parse_matpower(io::IO; validate=true)::ParsedCase - try - net = PowerIO.parse_str(read(io, String), "matpower") - return _finish_parse(_parsedcase_from_powerio(net), validate) - catch e - e isa ArgumentError && rethrow() - throw(ArgumentError("PowerDiff.parse_matpower: " * sprint(showerror, e))) - end -end - -function parse_matpower(file::String; library=nothing, validate=true)::ParsedCase - resolved = _resolve_case_path(file, library) - isfile(resolved) || throw(ArgumentError("invalid MATPOWER file $resolved")) - try - return _finish_parse(_parsedcase_from_powerio(_load_powerio_network(resolved)), validate) - catch e - e isa ArgumentError && rethrow() - throw(ArgumentError("PowerDiff.parse_matpower: " * sprint(showerror, e))) - end -end - -# The parser builds a raw ParsedCase from PowerIO's Network, then this applies -# PowerDiff's normalization and validation. -function _finish_parse(parsed::ParsedCase, validate::Bool)::ParsedCase - validate || return parsed - parsed = _normalize_parsed_case(parsed) - _validate_parsed_case(parsed) - return parsed -end - -""" - parse_matpower_struct(file::String; kwargs...) - -Compatibility alias for [`parse_matpower`](@ref). -""" -parse_matpower_struct(file::String; kwargs...) = parse_matpower(file; kwargs...) - -""" - _load_powerio_network(path) -> PowerIO.Network - -Parse `path` with the PowerIO Rust core. PowerIO infers the format from the -extension and returns a raw, lossless network (MW/MVAr, degrees, raw bus types, -out of service elements retained), which [`_parsedcase_from_powerio`](@ref) then -normalizes. -""" -_load_powerio_network(path::AbstractString) = PowerIO.parse_file(String(path)) - -""" - _parsedcase_from_powerio(net) -> ParsedCase - -Adapter from a PowerIO `Network` to a normalized PowerDiff [`ParsedCase`](@ref). -PowerIO emits raw, lossless data, so this reuses PowerDiff's normalization -(`_normalize_buses`, `_parse_cost_tuple`, `_normalize_angle_bounds`) before the -shared `_finish_parse` tail (`_normalize_parsed_case` + `_validate_parsed_case`) -runs in `parse_matpower`. - -PowerIO keeps loads and shunts as first class records, so the adapter builds `ParsedLoad` / -`ParsedShunt` straight from those vectors (no `_build_bus_injections`), and leaves -bus injections zeroed. It still calls `_normalize_buses`, because PowerIO carries -the raw file bus type and PowerDiff infers PV/slack itself. -""" -function _parsedcase_from_powerio(net) - isempty(PowerIO.storage(net)) || throw(ArgumentError( - "PowerDiff does not support storage records; remove or convert storage before parsing")) - isempty(PowerIO.hvdc(net)) || throw(ArgumentError( - "PowerDiff does not support HVDC/dcline records; remove or convert dcline before parsing")) - base = PowerIO.base_mva(net) - buses = [ParsedBus(b.id, PowerIO.bus_type_code(String(b.kind)), 0.0, 0.0, 0.0, 0.0, - b.area, b.vm, deg2rad(b.va), b.base_kv, b.zone, b.vmax, b.vmin) - for b in PowerIO.buses(net)] - gens = [ParsedGen(i, g.bus, g.pg / base, g.qg / base, g.qmax / base, g.qmin / base, - g.vg, g.mbase, g.in_service ? 1 : 0, g.pmax / base, g.pmin / base, - _parse_cost_tuple(_powerio_cost_row(g.cost), base)) - for (i, g) in enumerate(PowerIO.generators(net))] - branches = ParsedBranch[] - for (i, br) in enumerate(PowerIO.branches(net)) - angmin, angmax = _normalize_angle_bounds(deg2rad(br.angmin), deg2rad(br.angmax)) - push!(branches, ParsedBranch( - i, br.from, br.to, br.r, br.x, br.b, br.rate_a / base, br.rate_b / base, - br.rate_c / base, br.tap, deg2rad(br.shift), br.in_service ? 1 : 0, angmin, angmax)) - end - loads = [ParsedLoad(i, l.bus, l.p / base, l.q / base, l.in_service ? 1 : 0) - for (i, l) in enumerate(PowerIO.loads(net))] - shunts = [ParsedShunt(i, s.bus, s.g / base, s.b / base, s.in_service ? 1 : 0) - for (i, s) in enumerate(PowerIO.shunts(net))] - buses = _normalize_buses(buses, gens) - return ParsedCase(PowerIO.network_name(net), "2", base, buses, gens, branches, loads, shunts) -end - -# Rebuild a MATPOWER `gencost` numeric row, `[model, startup, shutdown, ncost, coeffs...]`, -# from PowerIO's GenCost so `_parse_cost_tuple` applies the same `base_mva^(n-i)` rescale and -# 3-tuple padding as the native path (PowerIO's own `quadratic()` does not rescale). PowerIO -# leaves `cost` as `nothing` for a generator with no cost row, which yields a zero cost tuple -# through `_parse_cost_tuple`'s normal path. -function _powerio_cost_row(cost) - cost === nothing && return [2.0, 0.0, 0.0, 1.0, 0.0] - return Float64[Float64(cost.model), Float64(cost.startup), Float64(cost.shutdown), - Float64(cost.ncost), (Float64(c) for c in cost.coeffs)...] -end - -_resolve_case_path(path::AbstractString, ::Nothing) = String(path) -_resolve_case_path(path::AbstractString, library) = joinpath(get_path(library), path) - -function _parse_cost_tuple(row::Vector{Float64}, baseMVA::Float64) - length(row) >= 5 || throw(ArgumentError("mpc.gencost row is incomplete")) - all(isfinite, row) || throw(ArgumentError("mpc.gencost contains a non-finite value")) - model = Int(row[1]) - model == 2 || throw(ArgumentError("only polynomial mpc.gencost model 2 is supported")) - n = Int(row[4]) - n >= 1 || throw(ArgumentError("mpc.gencost must declare at least one coefficient")) - length(row) >= 4 + n || throw(ArgumentError("mpc.gencost row declares $n coefficients but contains $(length(row) - 4)")) - coeffs = [baseMVA^(n - i) * row[4 + i] for i in 1:n] - while length(coeffs) > 1 && iszero(first(coeffs)) - popfirst!(coeffs) - end - length(coeffs) <= 3 || throw(ArgumentError("only constant, linear, and quadratic generator costs are supported")) - return length(coeffs) == 3 ? (coeffs[1], coeffs[2], coeffs[3]) : - length(coeffs) == 2 ? (0.0, coeffs[1], coeffs[2]) : - (0.0, 0.0, coeffs[1]) -end - -function _normalize_parsed_case(data::ParsedCase)::ParsedCase - active_bus_ids = Set(bus.bus_i for bus in data.bus if bus.bus_type != 4) - buses = [bus for bus in data.bus if bus.bus_i in active_bus_ids] - gens = [gen for gen in data.gen if gen.gen_status != 0 && gen.gen_bus in active_bus_ids] - buses = _normalize_buses(buses, gens) - bus_by_id = Dict(bus.bus_i => bus for bus in buses) - branches = ParsedBranch[] - for branch in data.branch - branch.br_status != 0 || continue - branch.f_bus in active_bus_ids || continue - branch.t_bus in active_bus_ids || continue - tap = iszero(branch.tap) ? 1.0 : branch.tap - rate_a = branch.rate_a > 0 ? branch.rate_a : _fallback_rate_a(branch, bus_by_id) - push!(branches, ParsedBranch( - branch.index, branch.f_bus, branch.t_bus, branch.br_r, branch.br_x, - branch.br_b, rate_a, branch.rate_b, branch.rate_c, tap, branch.shift, - branch.br_status, branch.angmin, branch.angmax - )) - end - loads = [load for load in data.load if load.status != 0 && load.load_bus in active_bus_ids] - shunts = [shunt for shunt in data.shunt if shunt.status != 0 && shunt.shunt_bus in active_bus_ids] - return ParsedCase(data.name, data.source_version, data.baseMVA, buses, gens, branches, loads, shunts) -end - -function _fallback_rate_a(branch::ParsedBranch, bus_by_id::Dict{Int,ParsedBus}) - theta_max = max(abs(branch.angmin), abs(branch.angmax)) - fr_vmax = bus_by_id[branch.f_bus].vmax - to_vmax = bus_by_id[branch.t_bus].vmax - zmag = hypot(branch.br_r, branch.br_x) - ymag = iszero(zmag) ? 0.0 : inv(zmag) - cmax = sqrt(fr_vmax^2 + to_vmax^2 - 2fr_vmax * to_vmax * cos(theta_max)) - return ymag * max(fr_vmax, to_vmax) * cmax -end - -function _normalize_buses(buses::Vector{ParsedBus}, gens::Vector{ParsedGen}) - normalized = copy(buses) - has_active_gen = Dict(bus.bus_i => false for bus in buses) - biggest_gen_bus = nothing - biggest_gen_pmax = -Inf - for gen in gens - has_active_gen[gen.gen_bus] = true - if gen.pmax > biggest_gen_pmax - biggest_gen_pmax = gen.pmax - biggest_gen_bus = gen.gen_bus - end - end - slack_found = false - for i in eachindex(normalized) - bus = normalized[i] - has_gen = get(has_active_gen, bus.bus_i, false) - bus_type = has_gen ? (bus.bus_type == 3 ? 3 : 2) : 1 - slack_found |= bus_type == 3 - normalized[i] = _with_bus_type(bus, bus_type) - end - if !slack_found && !isnothing(biggest_gen_bus) - idx = findfirst(bus -> bus.bus_i == biggest_gen_bus, normalized) - normalized[idx] = _with_bus_type(normalized[idx], 3) - end - return normalized -end - -_with_bus_type(bus::ParsedBus, bus_type::Int) = ParsedBus( - bus.bus_i, bus_type, bus.pd, bus.qd, bus.gs, bus.bs, bus.area, - bus.vm, bus.va, bus.base_kv, bus.zone, bus.vmax, bus.vmin -) - -function _normalize_angle_bounds(angmin::Float64, angmax::Float64) - pad = deg2rad(60.0) - angmin <= -pi / 2 && (angmin = -pad) - angmax >= pi / 2 && (angmax = pad) - iszero(angmin) && iszero(angmax) && return (-pad, pad) - return angmin, angmax -end - -function _validate_parsed_case(data::ParsedCase) - isempty(data.bus) && throw(ArgumentError("MATPOWER file is missing mpc.bus")) - isempty(data.gen) && throw(ArgumentError("MATPOWER file has no active generators")) - isempty(data.branch) && throw(ArgumentError("MATPOWER file has no active branches")) - _require_unique(getfield.(data.bus, :bus_i), "bus") - _require_unique(getfield.(data.gen, :index), "generator") - _require_unique(getfield.(data.branch, :index), "branch") - bus_ids = Set(bus.bus_i for bus in data.bus) - all(gen.gen_bus in bus_ids for gen in data.gen) || throw(ArgumentError("generator references an inactive or missing bus")) - all(branch.f_bus in bus_ids && branch.t_bus in bus_ids for branch in data.branch) || - throw(ArgumentError("branch references an inactive or missing bus")) - all(branch.rate_a > 0 for branch in data.branch) || - throw(ArgumentError("branches must have positive thermal limits after normalization")) - return data -end - -function _require_unique(ids, label) - length(Set(ids)) == length(ids) || throw(ArgumentError("duplicate $label IDs are not supported")) -end diff --git a/src/types/ac_network.jl b/src/types/ac_network.jl index c02e6f7..4c55e5e 100644 --- a/src/types/ac_network.jl +++ b/src/types/ac_network.jl @@ -204,10 +204,16 @@ end Reject the removed dictionary API with a migration hint. """ function ACNetwork(net::Dict{String,<:Any}; idx_slack::Union{Nothing,Int}=nothing) - throw(ArgumentError("dictionary constructors were removed; parse a MATPOWER file with PowerDiff.parse_file or construct ParsedCase")) + throw(ArgumentError("dictionary constructors were removed; parse a MATPOWER file with PowerDiff.parse_file")) end -function ACNetwork(data::ParsedCase; idx_slack::Union{Nothing,Int}=nothing) +ACNetwork(net::PowerIO.Network; idx_slack::Union{Nothing,Int}=nothing) = + ACNetwork(_network_data(net); idx_slack=idx_slack) + +# Build from PowerDiff network tables (see `_network_data`). The `PowerIO.Network` +# method runs PowerDiff's modeling deltas; this assumes the tables are already +# normalized, so programmatic callers can supply ready values directly. +function ACNetwork(data::NamedTuple; idx_slack::Union{Nothing,Int}=nothing) id_map = IDMapping(data) n_bus = length(id_map.bus_ids) n_branch = length(id_map.branch_ids) @@ -272,17 +278,15 @@ function ACNetwork(data::ParsedCase; idx_slack::Union{Nothing,Int}=nothing) qd = zeros(n_bus) gs = zeros(n_bus) bs = zeros(n_bus) - for load in data.load - i = id_map.bus_to_idx[load.load_bus] - pd[i] += load.pd - qd[i] += load.qd - end - for shunt in data.shunt - i = id_map.bus_to_idx[shunt.shunt_bus] - gs[i] += shunt.gs - bs[i] += shunt.bs - g_shunt[i] += shunt.gs - b_shunt[i] += shunt.bs + # to_powerdata aggregates loads/shunts into per-bus values (per-unit). + for bus in data.bus + i = id_map.bus_to_idx[bus.bus_i] + pd[i] += bus.pd + qd[i] += bus.qd + gs[i] += bus.gs + bs[i] += bus.bs + g_shunt[i] += bus.gs + b_shunt[i] += bus.bs end pg = zeros(n_bus) @@ -381,7 +385,7 @@ function ACNetwork(Y::AbstractMatrix{<:Complex}; idx_slack::Int=1) sw, is_switchable, idx_slack, vm_min, vm_max, - IDMapping(n, m, 0, 0), + IDMapping(n, m, 0), [edge[1] for edge in edges], [edge[2] for edge in edges], zeros(m), zeros(m), zeros(m), zeros(m), zeros(m), zeros(m), zeros(m), ones(m), zeros(m), ones(m), fill(-π, m), fill(π, m), fill(Inf, m), diff --git a/src/types/ac_opf_problem.jl b/src/types/ac_opf_problem.jl index c1937f3..14e3d15 100644 --- a/src/types/ac_opf_problem.jl +++ b/src/types/ac_opf_problem.jl @@ -729,7 +729,5 @@ function ACOPFProblem(pm_data::Dict; kwargs...) throw(ArgumentError("dictionary constructors were removed; parse a MATPOWER file with PowerDiff.parse_file")) end -function ACOPFProblem(data::ParsedCase; kwargs...) - network = ACNetwork(data) - return ACOPFProblem(network; kwargs...) -end +ACOPFProblem(net::PowerIO.Network; kwargs...) = ACOPFProblem(ACNetwork(net); kwargs...) +ACOPFProblem(data::NamedTuple; kwargs...) = ACOPFProblem(ACNetwork(data); kwargs...) diff --git a/src/types/dc_network.jl b/src/types/dc_network.jl index c536297..b6cc433 100644 --- a/src/types/dc_network.jl +++ b/src/types/dc_network.jl @@ -152,22 +152,207 @@ const DEFAULT_TAU = 1e-2 # constraints prevent delivery. const DEFAULT_SHED_COST_MULTIPLIER = 10 +# ============================================================================= +# MATPOWER input and PowerIO -> network-table construction +# ============================================================================= +# +# PowerIO is the parser and data layer. `PowerIO.parse_*` reads MATPOWER/PSSE/etc. +# and `PowerIO.to_powerdata` returns normalized, per-unit, status/isolated-filtered +# data with the reference bus inferred (`type == 3`), source bus ids on `bus_i`, +# loads/shunts aggregated per bus, and polynomial costs collapsed and rescaled. +# These thin MATPOWER-only wrappers return a `PowerIO.Network`, and `_network_data` +# turns one into the network tables the DCNetwork and ACNetwork constructors +# consume. The only logic beyond re-keying to source bus ids is the OPF-solver +# modeling PowerIO leaves to the consumer: polynomial cost interpretation, finite +# flow limits, default angle-difference bounds, and rejection of records PowerDiff +# does not model. + +""" + parse_file(io::Union{IO,String}; library=nothing, filetype="m") -> PowerIO.Network + +Parse a MATPOWER v2 `.m` file into a `PowerIO.Network`. + +PowerDiff intentionally supports MATPOWER files only. Convert other formats before +constructing PowerDiff types. Pass the result to [`DCNetwork`](@ref) / [`ACNetwork`](@ref). +""" +function parse_file(io::Union{IO,String}; library=nothing, filetype="m", kwargs...) + isempty(kwargs) || throw(ArgumentError( + "unsupported parse_file keyword(s): $(join(string.(keys(kwargs)), ", "))")) + resolved = io isa String ? _resolve_case_path(io, library) : io + resolved_type = resolved isa String ? lowercase(splitext(resolved)[2]) : ".$(lowercase(filetype))" + resolved_type == ".m" || throw(ArgumentError( + "unsupported network file type $resolved_type; PowerDiff supports MATPOWER v2 .m files only")) + return parse_matpower(resolved) +end + +""" + parse_matpower(io::IO) -> PowerIO.Network + parse_matpower(file::String; library=nothing) -> PowerIO.Network + +Parse MATPOWER v2 data into a `PowerIO.Network`. +""" +function parse_matpower(io::IO) + try + return PowerIO.parse_str(read(io, String), "matpower") + catch e + e isa ArgumentError && rethrow() + throw(ArgumentError("PowerDiff.parse_matpower: " * sprint(showerror, e))) + end +end + +function parse_matpower(file::String; library=nothing) + resolved = _resolve_case_path(file, library) + isfile(resolved) || throw(ArgumentError("invalid MATPOWER file $resolved")) + try + return PowerIO.parse_file(String(resolved)) + catch e + e isa ArgumentError && rethrow() + throw(ArgumentError("PowerDiff.parse_matpower: " * sprint(showerror, e))) + end +end + +""" + parse_matpower_struct(file::String; library=nothing) + +Compatibility alias for [`parse_matpower`](@ref). +""" +parse_matpower_struct(file::String; library=nothing) = parse_matpower(file; library=library) + +_resolve_case_path(path::AbstractString, ::Nothing) = String(path) +_resolve_case_path(path::AbstractString, library) = joinpath(get_path(library), path) + +""" + _network_data(net::PowerIO.Network) -> NamedTuple + +Build PowerDiff network tables from `PowerIO.to_powerdata(net)`. + +`to_powerdata` does per-unit scaling, status/isolated filtering, per-bus +load/shunt aggregation, reference-bus inference (`type == 3`), source bus ids on +`bus_i`, and polynomial cost collapse/rescaling, returning dense file-order rows. +This adapter keys bus references back to source bus ids (so [`IDMapping`](@ref)'s +sorted ordering is preserved) and applies the OPF modeling PowerIO leaves to the +consumer: polynomial cost interpretation (rejecting PWL and higher-than-quadratic), +a finite flow-limit fallback when `rate_a == 0`, default angle-difference bounds, +and rejection of storage / HVDC records that PowerDiff does not model. + +The returned `bus`/`gen`/`branch` rows mirror the field names the network +constructors expect, with loads/shunts already folded into per-bus `pd/qd/gs/bs`. +`shunt` re-exposes those bus shunts as a table (one `(; index, shunt_bus, gs, bs)` +record per bus with a nonzero shunt admittance) for callers that want shunt records. +""" +function _network_data(net) + # Reject records PowerDiff does not model. Both guards read the raw network so + # they stay consistent: to_powerdata's filtered output drops out-of-service + # records, which would silently accept a file that declares them. + isempty(PowerIO.hvdc(net)) || throw(ArgumentError( + "PowerDiff does not support HVDC/dcline records; remove or convert dcline before parsing")) + isempty(PowerIO.storage(net)) || throw(ArgumentError( + "PowerDiff does not support storage records; remove or convert storage before parsing")) + pd = PowerIO.to_powerdata(net) + isempty(pd.bus) && throw(ArgumentError("MATPOWER file is missing mpc.bus")) + isempty(pd.gen) && throw(ArgumentError("MATPOWER file has no active generators")) + isempty(pd.branch) && throw(ArgumentError("MATPOWER file has no active branches")) + + orig = [Int(b.bus_i) for b in pd.bus] # dense file-order index -> source bus id + + buses = [(; bus_i=orig[i], bus_type=Int(b.type), + pd=Float64(b.pd), qd=Float64(b.qd), gs=Float64(b.gs), bs=Float64(b.bs), + vm=Float64(b.vm), va=Float64(b.va), vmin=Float64(b.vmin), vmax=Float64(b.vmax)) + for (i, b) in enumerate(pd.bus)] + + # Costs come straight from to_powerdata's gen rows (already per-unit and + # right-aligned). Map dense `gen.bus` to the source bus id via `orig`. + gens = [(; index=j, gen_bus=orig[g.bus], + pg=Float64(g.pg), qg=Float64(g.qg), qmin=Float64(g.qmin), qmax=Float64(g.qmax), + vg=Float64(g.vg), pmin=Float64(g.pmin), pmax=Float64(g.pmax), cost=_poly_cost(g)) + for (j, g) in enumerate(pd.gen)] + + branches = [_branch_row(l, br, orig, buses) for (l, br) in enumerate(pd.branch)] + all(br.rate_a > 0 for br in branches) || throw(ArgumentError( + "branches must have positive thermal limits after normalization")) + + # to_powerdata folds shunts into per-bus gs/bs (which the constructors consume). + # Re-expose them as a table, one record per bus with a nonzero shunt admittance, + # for callers that want shunt records back. + shunt_buses = [b for b in buses if b.gs != 0.0 || b.bs != 0.0] + shunts = [(; index=i, shunt_bus=b.bus_i, gs=b.gs, bs=b.bs) for (i, b) in enumerate(shunt_buses)] + + return (; name=PowerIO.network_name(net), baseMVA=Float64(pd.baseMVA), + bus=buses, gen=gens, branch=branches, shunt=shunts) +end + +# Build one PowerDiff branch row from a to_powerdata branch: map dense f_bus/t_bus to +# source ids, default the angle window, and synthesize a finite rate_a when MATPOWER +# leaves it at 0 (unlimited), using the endpoint buses' vmax limits. +function _branch_row(l, br, orig, buses) + angmin, angmax = _normalize_angle_bounds(Float64(br.angmin), Float64(br.angmax)) + rate_a = br.rate_a > 0 ? Float64(br.rate_a) : + _fallback_rate_a(Float64(br.br_r), Float64(br.br_x), angmin, angmax, + buses[br.f_bus].vmax, buses[br.t_bus].vmax) + return (; index=l, f_bus=orig[br.f_bus], t_bus=orig[br.t_bus], + br_r=Float64(br.br_r), br_x=Float64(br.br_x), br_b=Float64(br.b_fr + br.b_to), + rate_a=rate_a, rate_b=Float64(br.rate_b), rate_c=Float64(br.rate_c), + tap=Float64(br.tap), shift=Float64(br.shift), angmin=angmin, angmax=angmax) +end + +# Interpret a PowerIO gen row's polynomial cost as PowerDiff's (quadratic, linear, +# constant) tuple. to_powerdata returns polynomial (model 2) costs as a right-aligned, +# per-unit (cq, cl, cc) triple and rejects higher-than-quadratic itself. A generator +# with no gencost row comes back as `model_poly == false` with `n == 0` (cost-free); +# piecewise-linear (model 1) is `model_poly == false` with `n > 0` and is unsupported. +function _poly_cost(g) + if !g.model_poly + Int(g.n) == 0 && return (0.0, 0.0, 0.0) + throw(ArgumentError("only polynomial mpc.gencost (model 2) is supported")) + end + return (Float64(g.c[1]), Float64(g.c[2]), Float64(g.c[3])) +end + +# PowerDiff's OPF needs a finite thermal limit on every branch. When MATPOWER leaves +# rate_a == 0 (unlimited), synthesize one from the bus voltage limits and the branch +# impedance / angle window, matching the previous native parser. +function _fallback_rate_a(r::Float64, x::Float64, angmin::Float64, angmax::Float64, + fr_vmax::Float64, to_vmax::Float64) + theta_max = max(abs(angmin), abs(angmax)) + zmag = hypot(r, x) + ymag = iszero(zmag) ? 0.0 : inv(zmag) + cmax = sqrt(fr_vmax^2 + to_vmax^2 - 2fr_vmax * to_vmax * cos(theta_max)) + return ymag * max(fr_vmax, to_vmax) * cmax +end + +# Default angle-difference bounds (radians in, radians out). MATPOWER angmin == angmax +# == 0 means unbounded; treat ±90°-or-wider and the zero case as a ±60° window, the +# MATPOWER/PowerModels convention. PowerIO's `to_powerdata` already converts to radians. +function _normalize_angle_bounds(angmin::Float64, angmax::Float64) + pad = deg2rad(60.0) + angmin <= -pi / 2 && (angmin = -pad) + angmax >= pi / 2 && (angmax = pad) + iszero(angmin) && iszero(angmax) && return (-pad, pad) + return angmin, angmax +end + # ============================================================================= # DCNetwork Constructors # ============================================================================= """ - DCNetwork(data::ParsedCase; tau=DEFAULT_TAU, ref_bus=nothing) + DCNetwork(net::PowerIO.Network; tau=DEFAULT_TAU, ref_bus=nothing) -Construct a DCNetwork from normalized typed MATPOWER data. +Construct a DCNetwork from a parsed PowerIO network. # Example ```julia -data = parse_file("case14.m") -dc_net = DCNetwork(data) +net = parse_file("case14.m") +dc_net = DCNetwork(net) ``` """ -function DCNetwork(data::ParsedCase; tau::Float64=DEFAULT_TAU, ref_bus::Union{Nothing,Int}=nothing) +DCNetwork(net::PowerIO.Network; tau::Float64=DEFAULT_TAU, ref_bus::Union{Nothing,Int}=nothing) = + DCNetwork(_network_data(net); tau=tau, ref_bus=ref_bus) + +# Build from PowerDiff network tables (see `_network_data`). The `PowerIO.Network` +# method runs PowerDiff's modeling deltas; this assumes the tables are already +# normalized, so programmatic callers can supply ready values directly. +function DCNetwork(data::NamedTuple; tau::Float64=DEFAULT_TAU, ref_bus::Union{Nothing,Int}=nothing) id_map = IDMapping(data) n = length(id_map.bus_ids) @@ -290,7 +475,7 @@ function DCNetwork( Float64.(c_shed), Float64.(demand), Float64.(pg_init), ref_bus, tau, - IDMapping(n, m, k, 0) + IDMapping(n, m, k) ) end @@ -307,15 +492,15 @@ function calc_demand_vector(network::DCNetwork) return copy(network.demand) end -calc_demand_vector(data::ParsedCase) = calc_demand_vector(data, IDMapping(data)) +calc_demand_vector(net::PowerIO.Network) = calc_demand_vector(_network_data(net)) +calc_demand_vector(data::NamedTuple) = calc_demand_vector(data, IDMapping(data)) -function calc_demand_vector(data::ParsedCase, id_map::IDMapping) - # Index by the sorted IDMapping, matching every other DCNetwork(::ParsedCase) path. - # Keying off enumerate(data.bus) (file order) misaligns loads when bus IDs are unsorted. +function calc_demand_vector(data::NamedTuple, id_map::IDMapping) + # to_powerdata already aggregates loads into per-bus demand (per-unit). Index by + # the sorted IDMapping so demand aligns even when original bus IDs are unsorted. d = zeros(length(id_map.bus_ids)) - for load in data.load - load.status != 0 || continue - d[id_map.bus_to_idx[load.load_bus]] += load.pd + for bus in data.bus + d[id_map.bus_to_idx[bus.bus_i]] += bus.pd end return d end @@ -364,13 +549,11 @@ end """ Aggregate generation to bus-level vector. """ -function _calc_generation_vector(data::ParsedCase, id_map::IDMapping) +function _calc_generation_vector(data::NamedTuple, id_map::IDMapping) n = length(id_map.bus_ids) g = zeros(n) for gen in data.gen - gen.gen_status != 0 || continue - bus_idx = id_map.bus_to_idx[gen.gen_bus] - g[bus_idx] += gen.pg + g[id_map.bus_to_idx[gen.gen_bus]] += gen.pg end return g end @@ -456,14 +639,14 @@ function DCPowerFlowState(net::DCNetwork, d::AbstractVector{<:Real}) end """ - DCPowerFlowState(data::ParsedCase; g=nothing, d=nothing) + DCPowerFlowState(net::PowerIO.Network; g=nothing, d=nothing) -Construct DCPowerFlowState from typed MATPOWER data. +Construct DCPowerFlowState from a parsed PowerIO network. If `d` is not provided, extracts demand from the network. If `g` is not provided, aggregates generation from gen data to buses. """ -function DCPowerFlowState(data::ParsedCase; g::Union{Nothing,AbstractVector}=nothing, d::Union{Nothing,AbstractVector}=nothing) - net = DCNetwork(data) +function DCPowerFlowState(net::PowerIO.Network; g::Union{Nothing,AbstractVector}=nothing, d::Union{Nothing,AbstractVector}=nothing) + net = DCNetwork(net) if isnothing(d) d = net.demand diff --git a/src/types/dc_opf_problem.jl b/src/types/dc_opf_problem.jl index d4fa245..cb43110 100644 --- a/src/types/dc_opf_problem.jl +++ b/src/types/dc_opf_problem.jl @@ -294,7 +294,9 @@ function DCOPFProblem(network::DCNetwork; d::Union{Nothing,AbstractVector}=nothi return DCOPFProblem(network, d; kwargs...) end -function DCOPFProblem(data::ParsedCase; d::Union{Nothing,AbstractVector}=nothing, tau::Float64=DEFAULT_TAU, kwargs...) +DCOPFProblem(net::PowerIO.Network; kwargs...) = DCOPFProblem(_network_data(net); kwargs...) + +function DCOPFProblem(data::NamedTuple; d::Union{Nothing,AbstractVector}=nothing, tau::Float64=DEFAULT_TAU, kwargs...) network = DCNetwork(data; tau=tau) if isnothing(d) d = calc_demand_vector(network) diff --git a/src/types/id_mapping.jl b/src/types/id_mapping.jl index 81f8791..eb42784 100644 --- a/src/types/id_mapping.jl +++ b/src/types/id_mapping.jl @@ -7,76 +7,63 @@ IDMapping Bidirectional mapping between original network element IDs and sequential -1-based indices used for internal computation. +1-based indices used for internal computation. Loads and shunts are aggregated +per bus, so only bus, branch, and generator IDs are tracked. """ struct IDMapping bus_ids::Vector{Int} branch_ids::Vector{Int} gen_ids::Vector{Int} - load_ids::Vector{Int} - shunt_ids::Vector{Int} bus_to_idx::Dict{Int,Int} branch_to_idx::Dict{Int,Int} gen_to_idx::Dict{Int,Int} - load_to_idx::Dict{Int,Int} - shunt_to_idx::Dict{Int,Int} - function IDMapping(bus_ids, branch_ids, gen_ids, load_ids, shunt_ids, - bus_to_idx, branch_to_idx, gen_to_idx, load_to_idx, shunt_to_idx) + function IDMapping(bus_ids, branch_ids, gen_ids, + bus_to_idx, branch_to_idx, gen_to_idx) for (ids, mapping, label) in ( (bus_ids, bus_to_idx, "bus"), (branch_ids, branch_to_idx, "branch"), (gen_ids, gen_to_idx, "generator"), - (load_ids, load_to_idx, "load"), - (shunt_ids, shunt_to_idx, "shunt"), ) issorted(ids) || throw(ArgumentError("$label IDs must be sorted")) length(ids) == length(mapping) || throw(ArgumentError( "$label ID count must match mapping size")) end - new(bus_ids, branch_ids, gen_ids, load_ids, shunt_ids, - bus_to_idx, branch_to_idx, gen_to_idx, load_to_idx, shunt_to_idx) + new(bus_ids, branch_ids, gen_ids, bus_to_idx, branch_to_idx, gen_to_idx) end end """ - IDMapping(data::ParsedCase) + IDMapping(data::NamedTuple) -Construct an ID mapping from normalized typed network data. +Construct an ID mapping from PowerDiff network tables (see `_network_data`). """ -function IDMapping(data::ParsedCase) +function IDMapping(data::NamedTuple) isempty(data.bus) && throw(ArgumentError("Network has no buses")) - bus_ids = sort(getfield.(data.bus, :bus_i)) - branch_ids = sort(getfield.(data.branch, :index)) - gen_ids = sort(getfield.(data.gen, :index)) - load_ids = sort(getfield.(data.load, :index)) - shunt_ids = sort(getfield.(data.shunt, :index)) + bus_ids = sort([b.bus_i for b in data.bus]) + branch_ids = sort([br.index for br in data.branch]) + gen_ids = sort([g.index for g in data.gen]) return IDMapping( - bus_ids, branch_ids, gen_ids, load_ids, shunt_ids, + bus_ids, branch_ids, gen_ids, Dict(id => i for (i, id) in enumerate(bus_ids)), Dict(id => i for (i, id) in enumerate(branch_ids)), Dict(id => i for (i, id) in enumerate(gen_ids)), - Dict(id => i for (i, id) in enumerate(load_ids)), - Dict(id => i for (i, id) in enumerate(shunt_ids)), ) end """ - IDMapping(n::Int, m::Int, k::Int, n_load::Int; n_shunt::Int=0) + IDMapping(n::Int, m::Int, k::Int) Create identity mappings for direct programmatic constructors. """ -function IDMapping(n::Int, m::Int, k::Int, n_load::Int; n_shunt::Int=0) +function IDMapping(n::Int, m::Int, k::Int) return IDMapping( - collect(1:n), collect(1:m), collect(1:k), collect(1:n_load), collect(1:n_shunt), - Dict(i => i for i in 1:n), Dict(i => i for i in 1:m), - Dict(i => i for i in 1:k), Dict(i => i for i in 1:n_load), - Dict(i => i for i in 1:n_shunt), + collect(1:n), collect(1:m), collect(1:k), + Dict(i => i for i in 1:n), Dict(i => i for i in 1:m), Dict(i => i for i in 1:k), ) end function Base.show(io::IO, mapping::IDMapping) print(io, "IDMapping($(length(mapping.bus_ids)) buses, ", - "$(length(mapping.branch_ids)) branches, $(length(mapping.gen_ids)) gens, ", - "$(length(mapping.load_ids)) loads, $(length(mapping.shunt_ids)) shunts)") + "$(length(mapping.branch_ids)) branches, $(length(mapping.gen_ids)) gens)") end diff --git a/test/common.jl b/test/common.jl index e73eb0e..f0ec70f 100644 --- a/test/common.jl +++ b/test/common.jl @@ -51,6 +51,18 @@ using JuMP: MOI const PM_DATA_DIR = joinpath(dirname(pathof(PowerModels)), "..", "test", "data", "matpower") const PD_PGLIB_DIR = PowerDiff.get_path(:pglib) +# Build PowerDiff network tables (the NamedTuple that DCNetwork/ACNetwork consume, +# see PowerDiff._network_data) directly, for programmatic test networks. Values are +# taken as-is — already normalized — like the removed hand-built ParsedCase path. +pd_bus(bus_i, bus_type; pd=0.0, qd=0.0, gs=0.0, bs=0.0, vm=1.0, va=0.0, vmin=0.9, vmax=1.1) = + (; bus_i, bus_type, pd, qd, gs, bs, vm, va, vmin, vmax) +pd_gen(index, gen_bus; pg=0.0, qg=0.0, qmin=0.0, qmax=0.0, vg=1.0, pmin=0.0, pmax=0.0, cost=(0.0, 0.0, 0.0)) = + (; index, gen_bus, pg, qg, qmin, qmax, vg, pmin, pmax, cost) +pd_branch(index, f_bus, t_bus; br_r, br_x, br_b=0.0, rate_a=Inf, rate_b=0.0, rate_c=0.0, + tap=1.0, shift=0.0, angmin=-pi / 3, angmax=pi / 3) = + (; index, f_bus, t_bus, br_r, br_x, br_b, rate_a, rate_b, rate_c, tap, shift, angmin, angmax) +pd_case(bus, gen, branch; name="case", baseMVA=100.0) = (; name, baseMVA, bus, gen, branch) + """ load_test_case(case_name::String) diff --git a/test/runtests.jl b/test/runtests.jl index 984a357..162dc57 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -43,10 +43,11 @@ include("common.jl") @test_skip false else dc_net = DCNetwork(net) + nd = PowerDiff._network_data(net) - @test dc_net.n == length(net.bus) - @test dc_net.m == length(net.branch) - @test dc_net.k == length(net.gen) + @test dc_net.n == length(nd.bus) + @test dc_net.m == length(nd.branch) + @test dc_net.k == length(nd.gen) @test size(dc_net.A) == (dc_net.m, dc_net.n) @test size(dc_net.G_inc) == (dc_net.n, dc_net.k) @test length(dc_net.b) == dc_net.m @@ -56,16 +57,12 @@ include("common.jl") end end -# Regression: calc_demand_vector(::ParsedCase) must index by the sorted IDMapping, -# not by file order, so loads land on the right bus when bus IDs are unsorted. +# Regression: calc_demand_vector(::NamedTuple) must index by the sorted IDMapping, +# not by file order, so demand lands on the right bus when bus IDs are unsorted. @testset "calc_demand_vector aligns with sorted IDMapping" begin - # Only bus_i varies; the other fields are identical across the three buses. - buses = [PowerDiff.ParsedBus(id, 1, 0.0, 0.0, 0.0, 0.0, 1, 1.0, 0.0, 100.0, 1, 1.1, 0.9) - for id in (10, 2, 5)] - loads = [PowerDiff.ParsedLoad(1, 10, 1.0, 0.0, 1), - PowerDiff.ParsedLoad(2, 5, 3.0, 0.0, 1)] - data = PowerDiff.ParsedCase("unsorted", "2", 100.0, buses, - PowerDiff.ParsedGen[], PowerDiff.ParsedBranch[], loads, PowerDiff.ParsedShunt[]) + # Per-bus demand (loads already aggregated into bus pd); only bus_i and pd vary. + buses = [pd_bus(10, 1; pd=1.0), pd_bus(2, 1; pd=0.0), pd_bus(5, 1; pd=3.0)] + data = pd_case(buses, NamedTuple[], NamedTuple[]; name="unsorted") d = calc_demand_vector(data) id_map = PowerDiff.IDMapping(data) diff --git a/test/test_ac_topology_sens.jl b/test/test_ac_topology_sens.jl index 9b04a63..1e3c938 100644 --- a/test/test_ac_topology_sens.jl +++ b/test/test_ac_topology_sens.jl @@ -120,19 +120,19 @@ end @testset "Transformer, phase shift, and parallel-line finite differences" begin buses = [ - ParsedBus(1, 3, 0.0, 0.0, 0.0, 0.0, 1, 1.0, 0.0, 230.0, 1, 1.1, 0.9), - ParsedBus(2, 1, 0.0, 0.0, 0.0, 0.0, 1, 1.0, 0.0, 230.0, 1, 1.1, 0.9), - ParsedBus(3, 1, 0.0, 0.0, 0.0, 0.0, 1, 1.0, 0.0, 230.0, 1, 1.1, 0.9), + pd_bus(1, 3; vmax=1.1, vmin=0.9), + pd_bus(2, 1; vmax=1.1, vmin=0.9), + pd_bus(3, 1; vmax=1.1, vmin=0.9), ] gens = [ - ParsedGen(1, 1, 0.5, 0.0, 1.0, -1.0, 1.0, 100.0, 1, 2.0, 0.0, (1.0, 1.0, 0.0)), + pd_gen(1, 1; pg=0.5, qmax=1.0, qmin=-1.0, vg=1.0, pmax=2.0, pmin=0.0, cost=(1.0, 1.0, 0.0)), ] branches = [ - ParsedBranch(1, 1, 2, 0.01, 0.10, 0.02, 2.0, 2.0, 2.0, 1.05, 0.12, 1, -π / 3, π / 3), - ParsedBranch(2, 1, 2, 0.02, 0.20, 0.01, 2.0, 2.0, 2.0, 1.00, 0.00, 1, -π / 3, π / 3), - ParsedBranch(3, 2, 3, 0.01, 0.15, 0.03, 2.0, 2.0, 2.0, 0.97, -0.08, 1, -π / 3, π / 3), + pd_branch(1, 1, 2; br_r=0.01, br_x=0.10, br_b=0.02, rate_a=2.0, rate_b=2.0, rate_c=2.0, tap=1.05, shift=0.12, angmin=-π / 3, angmax=π / 3), + pd_branch(2, 1, 2; br_r=0.02, br_x=0.20, br_b=0.01, rate_a=2.0, rate_b=2.0, rate_c=2.0, tap=1.00, shift=0.00, angmin=-π / 3, angmax=π / 3), + pd_branch(3, 2, 3; br_r=0.01, br_x=0.15, br_b=0.03, rate_a=2.0, rate_b=2.0, rate_c=2.0, tap=0.97, shift=-0.08, angmin=-π / 3, angmax=π / 3), ] - net = ACNetwork(ParsedCase("topology_fd", "2", 100.0, buses, gens, branches, ParsedLoad[], ParsedShunt[])) + net = ACNetwork(pd_case(buses, gens, branches; name="topology_fd")) state = ACPowerFlowState(net, [1.01 + 0.02im, 0.98 - 0.04im, 1.02 + 0.01im]) non_slack = [i for i in 1:state.n if i != state.idx_slack] injections = state.v .* conj.(state.Y * state.v) diff --git a/test/test_jvp_vjp.jl b/test/test_jvp_vjp.jl index c61c4ee..8b001cc 100644 --- a/test/test_jvp_vjp.jl +++ b/test/test_jvp_vjp.jl @@ -22,7 +22,7 @@ # round-trip dict_to_vec/vec_to_dict, and error handling for invalid IDs. @testset "JVP / VJP" begin - raw = PowerDiff.parse_file(joinpath(PM_DATA_DIR, "case5.m")) + raw = PowerDiff._network_data(PowerDiff.parse_file(joinpath(PM_DATA_DIR, "case5.m"))) basic = _make_basic_case(raw) # ================================================================= diff --git a/test/test_kkt_vjp_jvp.jl b/test/test_kkt_vjp_jvp.jl index 9a10b3c..5cb82cf 100644 --- a/test/test_kkt_vjp_jvp.jl +++ b/test/test_kkt_vjp_jvp.jl @@ -20,7 +20,7 @@ # match the materialized Sensitivity matrix path. @testset "KKT VJP/JVP" begin - raw = PowerDiff.parse_file(joinpath(PM_DATA_DIR, "case5.m")) + raw = PowerDiff._network_data(PowerDiff.parse_file(joinpath(PM_DATA_DIR, "case5.m"))) basic = _make_basic_case(raw) # ================================================================= diff --git a/test/test_nonbasic.jl b/test/test_nonbasic.jl index cb3ba54..ed2c0f5 100644 --- a/test/test_nonbasic.jl +++ b/test/test_nonbasic.jl @@ -21,38 +21,17 @@ # arbitrary element IDs. Uses case5.m with bus IDs [1,2,3,4,10] — bus 10 # maps to sequential index 5 via IDMapping. -function _make_basic_case(data::ParsedCase) - bus_map = Dict(id => i for (i, id) in enumerate(sort([bus.bus_i for bus in data.bus]))) - buses = [ - ParsedBus(bus_map[bus.bus_i], bus.bus_type, bus.pd, bus.qd, bus.gs, bus.bs, - bus.area, bus.vm, bus.va, bus.base_kv, bus.zone, bus.vmax, bus.vmin) - for bus in data.bus - ] - gens = [ - ParsedGen(gen.index, bus_map[gen.gen_bus], gen.pg, gen.qg, gen.qmax, gen.qmin, - gen.vg, gen.mbase, gen.gen_status, gen.pmax, gen.pmin, gen.cost) - for gen in data.gen - ] - branches = [ - ParsedBranch(branch.index, bus_map[branch.f_bus], bus_map[branch.t_bus], - branch.br_r, branch.br_x, branch.br_b, branch.rate_a, branch.rate_b, - branch.rate_c, branch.tap, branch.shift, branch.br_status, - branch.angmin, branch.angmax) - for branch in data.branch - ] - loads = [ - ParsedLoad(load.index, bus_map[load.load_bus], load.pd, load.qd, load.status) - for load in data.load - ] - shunts = [ - ParsedShunt(shunt.index, bus_map[shunt.shunt_bus], shunt.gs, shunt.bs, shunt.status) - for shunt in data.shunt - ] - return ParsedCase(data.name, data.source_version, data.baseMVA, buses, gens, branches, loads, shunts) +# Renumber bus ids to a dense 1..n space, operating on PowerDiff network tables. +function _make_basic_case(data) + bus_map = Dict(id => i for (i, id) in enumerate(sort([b.bus_i for b in data.bus]))) + buses = [(; b..., bus_i=bus_map[b.bus_i]) for b in data.bus] + gens = [(; g..., gen_bus=bus_map[g.gen_bus]) for g in data.gen] + branches = [(; br..., f_bus=bus_map[br.f_bus], t_bus=bus_map[br.t_bus]) for br in data.branch] + return (; data.name, data.baseMVA, bus=buses, gen=gens, branch=branches) end @testset "Non-Basic Network Support" begin - raw = PowerDiff.parse_file(joinpath(PM_DATA_DIR, "case5.m")) + raw = PowerDiff._network_data(PowerDiff.parse_file(joinpath(PM_DATA_DIR, "case5.m"))) basic = _make_basic_case(raw) # ================================================================= @@ -398,19 +377,6 @@ end @test dc_prog.demand == zeros(n) end - # ================================================================= - # IDMapping shunt support - # ================================================================= - @testset "IDMapping shunt fields" begin - dc_nb = DCNetwork(raw) - id_map = dc_nb.id_map - - @test isa(id_map.shunt_ids, Vector{Int}) - @test isa(id_map.shunt_to_idx, Dict{Int,Int}) - # shunt_ids should be sorted - @test issorted(id_map.shunt_ids) - end - # ================================================================= # calc_demand_vector from DCNetwork # ================================================================= @@ -472,43 +438,33 @@ end @testset "IDMapping constructor validation" begin # Unsorted bus_ids should throw @test_throws ArgumentError IDMapping( - [3, 1, 2], [1, 2], [1], [1], Int[], - Dict(3=>1, 1=>2, 2=>3), Dict(1=>1, 2=>2), Dict(1=>1), Dict(1=>1), Dict{Int,Int}()) + [3, 1, 2], [1, 2], [1], + Dict(3=>1, 1=>2, 2=>3), Dict(1=>1, 2=>2), Dict(1=>1)) # Unsorted branch_ids should throw @test_throws ArgumentError IDMapping( - [1, 2, 3], [2, 1], [1], [1], Int[], - Dict(1=>1, 2=>2, 3=>3), Dict(2=>1, 1=>2), Dict(1=>1), Dict(1=>1), Dict{Int,Int}()) + [1, 2, 3], [2, 1], [1], + Dict(1=>1, 2=>2, 3=>3), Dict(2=>1, 1=>2), Dict(1=>1)) # Unsorted gen_ids should throw @test_throws ArgumentError IDMapping( - [1, 2], [1], [3, 1], [1], Int[], - Dict(1=>1, 2=>2), Dict(1=>1), Dict(3=>1, 1=>2), Dict(1=>1), Dict{Int,Int}()) - - # Unsorted load_ids should throw - @test_throws ArgumentError IDMapping( - [1, 2], [1], [1], [5, 2], Int[], - Dict(1=>1, 2=>2), Dict(1=>1), Dict(1=>1), Dict(5=>1, 2=>2), Dict{Int,Int}()) - - # Unsorted shunt_ids should throw - @test_throws ArgumentError IDMapping( - [1, 2], [1], [1], [1], [3, 1], - Dict(1=>1, 2=>2), Dict(1=>1), Dict(1=>1), Dict(1=>1), Dict(3=>1, 1=>2)) + [1, 2], [1], [3, 1], + Dict(1=>1, 2=>2), Dict(1=>1), Dict(3=>1, 1=>2)) # Length mismatch: bus_ids vs bus_to_idx @test_throws ArgumentError IDMapping( - [1, 2, 3], [1], [1], [1], Int[], - Dict(1=>1, 2=>2), Dict(1=>1), Dict(1=>1), Dict(1=>1), Dict{Int,Int}()) + [1, 2, 3], [1], [1], + Dict(1=>1, 2=>2), Dict(1=>1), Dict(1=>1)) # Length mismatch: branch_ids vs branch_to_idx @test_throws ArgumentError IDMapping( - [1, 2], [1, 2], [1], [1], Int[], - Dict(1=>1, 2=>2), Dict(1=>1), Dict(1=>1), Dict(1=>1), Dict{Int,Int}()) + [1, 2], [1, 2], [1], + Dict(1=>1, 2=>2), Dict(1=>1), Dict(1=>1)) # Valid construction should work id_map = IDMapping( - [1, 5, 10], [1, 2], [1], [1], Int[], - Dict(1=>1, 5=>2, 10=>3), Dict(1=>1, 2=>2), Dict(1=>1), Dict(1=>1), Dict{Int,Int}()) + [1, 5, 10], [1, 2], [1], + Dict(1=>1, 5=>2, 10=>3), Dict(1=>1, 2=>2), Dict(1=>1)) @test id_map.bus_ids == [1, 5, 10] @test id_map.bus_to_idx[10] == 3 end diff --git a/test/test_parser_parity.jl b/test/test_parser_parity.jl index f040ce2..b85c3fb 100644 --- a/test/test_parser_parity.jl +++ b/test/test_parser_parity.jl @@ -12,23 +12,29 @@ mpc.areas = [1 1]; mpc.bus_name = ['one'; 'two']; """ +# `parse_file`/`parse_matpower` return a PowerIO.Network; `_network_data` applies +# PowerDiff's normalization (per-unit via PowerIO, cost right-align, rate_a fallback, +# angle defaults, storage/HVDC rejection) into the tables the constructors consume. +_inline_data() = PowerDiff._network_data(PowerDiff.parse_matpower(IOBuffer(_INLINE_CASE))) + @testset "MATPOWER Parser Semantics" begin @testset "Inline arrays and normalization" begin - data = PowerDiff.parse_matpower(IOBuffer(_INLINE_CASE)) + data = _inline_data() - @test data isa ParsedCase + @test data isa NamedTuple @test data.name == "case_inline" - @test data.source_version == "2" @test data.baseMVA == 100.0 @test length(data.bus) == 2 @test length(data.gen) == 1 @test length(data.branch) == 1 - @test length(data.load) == 1 - @test length(data.shunt) == 1 @test data.bus[1].bus_type == 3 - @test data.bus[1].pd == 0.0 - @test data.load[1].pd == 0.5 - @test data.shunt[1].gs == 0.01 + # Loads and shunts are aggregated into per-bus values. + @test data.bus[1].pd == 0.5 + @test data.bus[1].gs == 0.01 + # Shunts are also re-exposed as a per-bus table (bus 1: Gs=1, Bs=-2 -> 0.01, -0.02 pu). + @test length(data.shunt) == 1 + @test data.shunt[1].shunt_bus == 1 + @test data.shunt[1].gs ≈ 0.01 && data.shunt[1].bs ≈ -0.02 @test data.branch[1].tap == 1.0 @test data.branch[1].rate_a > 0 @test data.branch[1].angmin ≈ -π / 3 @@ -38,9 +44,10 @@ mpc.bus_name = ['one'; 'two']; @testset "Multiline arrays and artifact path" begin parsed = PowerDiff.parse_file("pglib_opf_case14_ieee.m"; library=:pglib) - @test parsed isa ParsedCase - @test length(parsed.bus) == 14 - @test length(parsed.branch) == 20 + @test parsed isa PowerIO.Network + nd = PowerDiff._network_data(parsed) + @test length(nd.bus) == 14 + @test length(nd.branch) == 20 @test PowerDiff.get_path(:pglib) == PD_PGLIB_DIR end @@ -51,67 +58,57 @@ mpc.bus_name = ['one'; 'two']; @test_throws ArgumentError PowerDiff.parse_file(IOBuffer(_INLINE_CASE); unsupported=true) @test_throws ArgumentError PowerDiff.get_path(:unknown) + # Modeling-level rejections happen when the network tables are built. unsupported = replace(_INLINE_CASE, "mpc.areas = [1 1];" => "mpc.storage = [1 1];") - @test_throws ArgumentError PowerDiff.parse_matpower(IOBuffer(unsupported)) + @test_throws ArgumentError PowerDiff._network_data(PowerDiff.parse_matpower(IOBuffer(unsupported))) invalid = replace(_INLINE_CASE, "0.01 0.1" => "NaN 0.1") - @test_throws ArgumentError PowerDiff.parse_matpower(IOBuffer(invalid)) + @test_throws ArgumentError PowerDiff._network_data(PowerDiff.parse_matpower(IOBuffer(invalid))) pwl = replace(_INLINE_CASE, "2 0 0 3 0.01 2 3" => "1 0 0 3 0.01 2 3") - @test_throws ArgumentError PowerDiff.parse_matpower(IOBuffer(pwl)) + @test_throws ArgumentError PowerDiff._network_data(PowerDiff.parse_matpower(IOBuffer(pwl))) quartic = replace(_INLINE_CASE, "2 0 0 3 0.01 2 3" => "2 0 0 4 1 0.01 2 3") - @test_throws ArgumentError PowerDiff.parse_matpower(IOBuffer(quartic)) + @test_throws ArgumentError PowerDiff._network_data(PowerDiff.parse_matpower(IOBuffer(quartic))) end @testset "Parser contract" begin - @test PowerDiff.parse_file(IOBuffer(_INLINE_CASE)) isa ParsedCase + @test PowerDiff.parse_file(IOBuffer(_INLINE_CASE)) isa PowerIO.Network @test_throws ArgumentError PowerDiff.parse_file(IOBuffer(_INLINE_CASE); backend=:native) end end -# Field-for-field equality of two ParsedCase values; floats compared with ≈, ints with ==. -function _assert_parsedcase_equal(a::ParsedCase, b::ParsedCase, label) +# Field-for-field equality of two PowerDiff network tables; floats with ≈, ints with ==. +function _assert_netdata_equal(a, b, label) @testset "$label" begin @test a.baseMVA ≈ b.baseMVA @test length(a.bus) == length(b.bus) @test length(a.gen) == length(b.gen) @test length(a.branch) == length(b.branch) - @test length(a.load) == length(b.load) - @test length(a.shunt) == length(b.shunt) for (x, y) in zip(a.bus, b.bus) @test x.bus_i == y.bus_i @test x.bus_type == y.bus_type - @test x.area == y.area && x.zone == y.zone - @test x.vm ≈ y.vm && x.va ≈ y.va && x.base_kv ≈ y.base_kv - @test x.vmax ≈ y.vmax && x.vmin ≈ y.vmin + @test x.pd ≈ y.pd && x.qd ≈ y.qd && x.gs ≈ y.gs && x.bs ≈ y.bs + @test x.vm ≈ y.vm && x.va ≈ y.va && x.vmin ≈ y.vmin && x.vmax ≈ y.vmax end for (x, y) in zip(a.gen, b.gen) - @test x.gen_bus == y.gen_bus && x.gen_status == y.gen_status - @test x.pg ≈ y.pg && x.qg ≈ y.qg && x.vg ≈ y.vg && x.mbase ≈ y.mbase - @test x.pmax ≈ y.pmax && x.pmin ≈ y.pmin && x.qmax ≈ y.qmax && x.qmin ≈ y.qmin + @test x.gen_bus == y.gen_bus + @test x.pg ≈ y.pg && x.qg ≈ y.qg + @test x.pmin ≈ y.pmin && x.pmax ≈ y.pmax && x.qmin ≈ y.qmin && x.qmax ≈ y.qmax @test all(x.cost .≈ y.cost) end for (x, y) in zip(a.branch, b.branch) - @test x.f_bus == y.f_bus && x.t_bus == y.t_bus && x.br_status == y.br_status + @test x.f_bus == y.f_bus && x.t_bus == y.t_bus @test x.br_r ≈ y.br_r && x.br_x ≈ y.br_x && x.br_b ≈ y.br_b - @test x.rate_a ≈ y.rate_a && x.rate_b ≈ y.rate_b && x.rate_c ≈ y.rate_c + @test x.rate_a ≈ y.rate_a @test x.tap ≈ y.tap && x.shift ≈ y.shift && x.angmin ≈ y.angmin && x.angmax ≈ y.angmax end - for (x, y) in zip(a.load, b.load) - @test x.load_bus == y.load_bus && x.status == y.status - @test x.pd ≈ y.pd && x.qd ≈ y.qd - end - for (x, y) in zip(a.shunt, b.shunt) - @test x.shunt_bus == y.shunt_bus && x.status == y.status - @test x.gs ≈ y.gs && x.bs ≈ y.bs - end end end @testset "PowerIO parser path and IO parity" begin - # PowerIO is the only parser/data layer. Path parsing and IO parsing must - # land on the same PowerDiff ParsedCase after normalization. + # PowerIO is the only parser/data layer. Path parsing and IO parsing must land on + # the same PowerDiff network tables after normalization. if !PowerIO.library_available() @info "libpowerio_capi not found (set POWERIO_CAPI to a local build); skipping parser parity" @test_skip false @@ -120,28 +117,29 @@ end ["pglib_opf_case5_pjm.m", "pglib_opf_case14_ieee.m", "pglib_opf_case30_ieee.m"]) @test !isempty(cases) for c in cases - path_case = PowerDiff.parse_file(c; library=:pglib) - io_case = PowerDiff.parse_file(IOBuffer(read(joinpath(PD_PGLIB_DIR, c), String))) - _assert_parsedcase_equal(path_case, io_case, c) + path_data = PowerDiff._network_data(PowerDiff.parse_file(c; library=:pglib)) + io_data = PowerDiff._network_data( + PowerDiff.parse_file(IOBuffer(read(joinpath(PD_PGLIB_DIR, c), String)))) + _assert_netdata_equal(path_data, io_data, c) end end end @testset "Typed AC Pi Model" begin buses = [ - ParsedBus(1, 3, 0.0, 0.0, 0.0, 0.0, 1, 1.0, 0.0, 230.0, 1, 1.1, 0.9), - ParsedBus(2, 1, 0.0, 0.0, 0.0, 0.0, 1, 1.0, 0.0, 230.0, 1, 1.1, 0.9), - ParsedBus(3, 1, 0.0, 0.0, 0.0, 0.0, 1, 1.0, 0.0, 230.0, 1, 1.1, 0.9), + pd_bus(1, 3; vmax=1.1, vmin=0.9), + pd_bus(2, 1; vmax=1.1, vmin=0.9), + pd_bus(3, 1; vmax=1.1, vmin=0.9), ] gens = [ - ParsedGen(1, 1, 0.5, 0.0, 1.0, -1.0, 1.0, 100.0, 1, 2.0, 0.0, (1.0, 1.0, 0.0)), + pd_gen(1, 1; pg=0.5, qmax=1.0, qmin=-1.0, vg=1.0, pmax=2.0, pmin=0.0, cost=(1.0, 1.0, 0.0)), ] branches = [ - ParsedBranch(1, 1, 2, 0.01, 0.10, 0.02, 2.0, 2.0, 2.0, 1.05, 0.12, 1, -π / 3, π / 3), - ParsedBranch(2, 1, 2, 0.02, 0.20, 0.01, 2.0, 2.0, 2.0, 1.00, 0.00, 1, -π / 3, π / 3), - ParsedBranch(3, 2, 3, 0.01, 0.15, 0.03, 2.0, 2.0, 2.0, 0.97, -0.08, 1, -π / 3, π / 3), + pd_branch(1, 1, 2; br_r=0.01, br_x=0.10, br_b=0.02, rate_a=2.0, rate_b=2.0, rate_c=2.0, tap=1.05, shift=0.12, angmin=-π / 3, angmax=π / 3), + pd_branch(2, 1, 2; br_r=0.02, br_x=0.20, br_b=0.01, rate_a=2.0, rate_b=2.0, rate_c=2.0, tap=1.00, shift=0.00, angmin=-π / 3, angmax=π / 3), + pd_branch(3, 2, 3; br_r=0.01, br_x=0.15, br_b=0.03, rate_a=2.0, rate_b=2.0, rate_c=2.0, tap=0.97, shift=-0.08, angmin=-π / 3, angmax=π / 3), ] - data = ParsedCase("pi_model", "2", 100.0, buses, gens, branches, ParsedLoad[], ParsedShunt[]) + data = pd_case(buses, gens, branches; name="pi_model") net = ACNetwork(data) v = [1.01 + 0.02im, 0.98 - 0.04im, 1.02 + 0.01im] @@ -168,8 +166,8 @@ end @test branch_power(net, v) ≈ v[net.f_bus] .* conj.(expected_current) end -@testset "ParsedCase Status Filtering" begin - parsed = PowerDiff.parse_matpower(IOBuffer(_INLINE_CASE)) +@testset "Status Filtering" begin + parsed = _inline_data() @test length(parsed.gen) == 1 @test length(parsed.branch) == 1 end diff --git a/test/unified/test_interface.jl b/test/unified/test_interface.jl index 1504379..b06d49e 100644 --- a/test/unified/test_interface.jl +++ b/test/unified/test_interface.jl @@ -141,9 +141,10 @@ using Test @testset "ACNetwork" begin ac_net = ACNetwork(net_data) + nd = PowerDiff._network_data(net_data) @test ac_net isa AbstractPowerNetwork - @test ac_net.n == length(net_data.bus) - @test ac_net.m == length(net_data.branch) + @test ac_net.n == length(nd.bus) + @test ac_net.m == length(nd.branch) # Admittance matrix reconstruction Y = admittance_matrix(ac_net)