From 276aceac486226f92790543bb2245fdfbd3135b2 Mon Sep 17 00:00:00 2001 From: samtalki <10187005+samtalki@users.noreply.github.com> Date: Sun, 14 Jun 2026 18:54:22 -0400 Subject: [PATCH 1/7] Consume PowerIO directly; drop the ParsedCase layer PowerIO.to_powerdata already returns normalized, per-unit, filtered, slack-inferred, cost-rescaled network data, which parser.jl reimplemented as ParsedCase. Build DCNetwork/ACNetwork straight from a PowerIO.Network instead, keeping only PowerDiff's OPF modeling: polynomial cost interpretation, a finite flow-limit fallback when rate_a is 0, default angle-difference bounds, and rejection of storage/HVDC records PowerDiff does not model. - parser.jl: parse_file/parse_matpower return a PowerIO.Network; _network_data builds the network tables; remove ParsedCase/ParsedBus/... and the old normalization helpers. - DCNetwork/ACNetwork/DCOPFProblem/ACOPFProblem/calc_demand_vector take a PowerIO.Network or the network-tables NamedTuple; drop the ParsedCase methods. - IDMapping no longer tracks per-load/shunt ids (loads and shunts are aggregated per bus by to_powerdata). - Read generator costs from PowerIO's raw records: to_powerdata mangles costs declared with ncost>3 (e.g. MATPOWER case14, a quadratic padded to ncost=5). - Finalize PowerIO as a registered dependency: drop the [sources] git pin and set [compat] PowerIO = "0.1". - Migrate the test suite and the IPP experiment off ParsedCase; examples already used the parse_file -> network constructor path. Co-authored-by: Cameron Khanpour <99142483+cameronkhanpour@users.noreply.github.com> Co-Authored-By: Claude Opus 4.8 (1M context) --- Project.toml | 8 +- experiments/ipp_market_planning.jl | 22 +- src/PowerDiff.jl | 1 - src/parser.jl | 377 ++++++++++------------------- src/types/ac_network.jl | 32 +-- src/types/ac_opf_problem.jl | 6 +- src/types/dc_network.jl | 46 ++-- src/types/dc_opf_problem.jl | 4 +- src/types/id_mapping.jl | 47 ++-- test/common.jl | 12 + test/runtests.jl | 21 +- test/test_ac_topology_sens.jl | 16 +- test/test_jvp_vjp.jl | 2 +- test/test_kkt_vjp_jvp.jl | 2 +- test/test_nonbasic.jl | 84 ++----- test/test_parser_parity.jl | 96 ++++---- test/unified/test_interface.jl | 5 +- 17 files changed, 295 insertions(+), 486 deletions(-) diff --git a/Project.toml b/Project.toml index fbf2a94..57e5685 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" julia = "1.9" [extras] 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..a368aee 100644 --- a/src/PowerDiff.jl +++ b/src/PowerDiff.jl @@ -102,7 +102,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/parser.jl b/src/parser.jl index a449c8a..747789b 100644 --- a/src/parser.jl +++ b/src/parser.jl @@ -1,92 +1,12 @@ 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 +# PowerIO is the parser and data layer. It parses MATPOWER/PSSE/etc. and +# `PowerIO.to_powerdata` returns normalized, per-unit, filtered data with the slack +# bus inferred, loads/shunts aggregated per bus, and polynomial costs rescaled. +# PowerDiff consumes that directly. The only logic kept here is OPF-solver modeling: +# polynomial cost interpretation, finite flow limits, default angle-difference +# bounds, and rejection of records PowerDiff does not model (storage, HVDC). """ get_path(library::Symbol) @@ -99,62 +19,49 @@ function get_path(library::Symbol) end """ - parse_file(io::Union{IO,String}; library=nothing, validate=true, filetype="m") - -Parse a MATPOWER v2 `.m` file into a normalized `ParsedCase`. + parse_file(io::Union{IO,String}; library=nothing, filetype="m") -> PowerIO.Network -PowerDiff intentionally supports MATPOWER files only. Convert other formats -before constructing PowerDiff types. +Parse a MATPOWER v2 `.m` file into a `PowerIO.Network`. -PowerIO is the parser and data layer. PowerDiff normalizes the PowerIO `Network` -into its own [`ParsedCase`](@ref). +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, validate=true, filetype="m", kwargs...) +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; validate) + return parse_matpower(resolved) end """ - parse_matpower(io::IO; validate=true) - parse_matpower(file::String; library=nothing, validate=true) + parse_matpower(io::IO) -> PowerIO.Network + parse_matpower(file::String; library=nothing) -> PowerIO.Network -Parse MATPOWER v2 data into a normalized [`ParsedCase`](@ref). +Parse MATPOWER v2 data into a `PowerIO.Network`. """ -function parse_matpower(io::IO; validate=true)::ParsedCase +function parse_matpower(io::IO) try - net = PowerIO.parse_str(read(io, String), "matpower") - return _finish_parse(_parsedcase_from_powerio(net), validate) + 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, validate=true)::ParsedCase +function parse_matpower(file::String; library=nothing) 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) + return PowerIO.parse_file(String(resolved)) 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...) @@ -162,81 +69,112 @@ Compatibility alias for [`parse_matpower`](@ref). """ parse_matpower_struct(file::String; kwargs...) = parse_matpower(file; kwargs...) -""" - _load_powerio_network(path) -> PowerIO.Network +_resolve_case_path(path::AbstractString, ::Nothing) = String(path) +_resolve_case_path(path::AbstractString, library) = joinpath(get_path(library), path) -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)) + _network_data(net::PowerIO.Network) -> NamedTuple -""" - _parsedcase_from_powerio(net) -> ParsedCase +Normalized PowerDiff network tables built from `PowerIO.to_powerdata(net)`. -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`. +`to_powerdata` already does per-unit scaling, status/isolated filtering, slack +inference, per-bus load/shunt aggregation, and `base^(n-i)` cost rescaling, and it +returns dense file-order indices. This re-keys the bus references back to original +bus ids (so [`IDMapping`](@ref)'s sorted ordering is preserved) and applies +PowerDiff's OPF modeling: polynomial cost (rejecting PWL/quartic), a finite flow +limit fallback when `rate_a == 0`, default angle-difference bounds, and rejection of +storage / HVDC records that PowerDiff does not model. -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. +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`. """ -function _parsedcase_from_powerio(net) - isempty(PowerIO.storage(net)) || throw(ArgumentError( - "PowerDiff does not support storage records; remove or convert storage before parsing")) +function _network_data(net) 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)) + # Surface malformed input (e.g. non-finite fields PowerIO leaves as `nothing`) as an + # ArgumentError rather than a downstream MethodError. + pd = try + PowerIO.to_powerdata(net) + catch e + e isa ArgumentError && rethrow() + throw(ArgumentError("PowerDiff: could not normalize the network: " * sprint(showerror, e))) + end + isempty(pd.storage) || throw(ArgumentError( + "PowerDiff does not support storage records; remove or convert storage before parsing")) + 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] # file-order index -> original bus id + vmax = [Float64(b.vmax) for b in pd.bus] # file-order index -> bus vmax + + 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)] + + # to_powerdata's gen `c`/`n` mangles costs declared with ncost > 3 (it keeps the + # 3 highest-degree coefficients with the wrong exponents and drops the rest), so a + # quadratic padded to ncost=5 — e.g. MATPOWER case14 — loses its linear term. Read + # the raw cost coefficients from PowerIO instead, aligned to to_powerdata's kept + # gens by the same keep filter (in service, bus not isolated). Track as a PowerIO + # enhancement: have to_powerdata expose costs losslessly. + kept_bus = Set(Int(b.id) for b in PowerIO.buses(net) if String(b.kind) != "ISOLATED") + raw_costs = [_cost_tuple(g.cost, Float64(pd.baseMVA)) + for g in PowerIO.generators(net) if g.in_service && Int(g.bus) in kept_bus] + length(raw_costs) == length(pd.gen) || + throw(ArgumentError("generator cost alignment mismatch ($(length(raw_costs)) vs $(length(pd.gen)))")) + + 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=raw_costs[j]) + for (j, g) in enumerate(pd.gen)] + + # PowerDiff's OPF needs a reference bus. to_powerdata only reassigns the slack when + # an explicit REF was demoted; if the source marks none at all, promote the bus with + # the largest generator (matching the previous parser). PowerIO issue: infer a + # reference when the file marks none. + if !any(b -> b.bus_type == 3, buses) + slack_bus, best_pmax = 0, -Inf + for g in gens + if g.pmax > best_pmax + best_pmax = g.pmax + slack_bus = g.gen_bus + end + end + slack_bus == 0 || + (buses = [b.bus_i == slack_bus ? (; b..., bus_type=3) : b for b in buses]) 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 + branches = NamedTuple[] + for (l, br) in enumerate(pd.branch) + 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, + vmax[br.f_bus], vmax[br.t_bus]) + push!(branches, (; 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 + all(br.rate_a > 0 for br in branches) || throw(ArgumentError( + "branches must have positive thermal limits after normalization")) -_resolve_case_path(path::AbstractString, ::Nothing) = String(path) -_resolve_case_path(path::AbstractString, library) = joinpath(get_path(library), path) + return (; name=PowerIO.network_name(net), baseMVA=Float64(pd.baseMVA), + bus=buses, gen=gens, branch=branches) +end -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]) +# Convert a PowerIO raw generator cost (model, ncost, coeffs) to PowerDiff's +# (quadratic, linear, constant) tuple, in per-unit. Rescale each coefficient by +# base^(n-i), drop leading zeros (so a quadratic padded to ncost=5 collapses to a +# real quadratic), and reject piecewise-linear and higher-than-quadratic costs. +function _cost_tuple(cost, base::Float64) + cost === nothing && return (0.0, 0.0, 0.0) + Int(cost.model) == 2 || throw(ArgumentError("only polynomial mpc.gencost model 2 is supported")) + n = Int(cost.ncost) 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] + cf = collect(cost.coeffs) + coeffs = [base^(n - i) * Float64(cf[i]) for i in 1:n] while length(coeffs) > 1 && iszero(first(coeffs)) popfirst!(coeffs) end @@ -246,72 +184,21 @@ function _parse_cost_tuple(row::Vector{Float64}, baseMVA::Float64) (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) +# 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 -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 -) - +# 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) @@ -319,23 +206,3 @@ function _normalize_angle_bounds(angmin::Float64, angmax::Float64) 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..4e25ee2 100644 --- a/src/types/dc_network.jl +++ b/src/types/dc_network.jl @@ -157,17 +157,23 @@ const DEFAULT_SHED_COST_MULTIPLIER = 10 # ============================================================================= """ - 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 +296,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 +313,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 +370,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 +460,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..9e09df2 100644 --- a/test/test_parser_parity.jl +++ b/test/test_parser_parity.jl @@ -12,23 +12,25 @@ 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 @test data.branch[1].tap == 1.0 @test data.branch[1].rate_a > 0 @test data.branch[1].angmin ≈ -π / 3 @@ -38,9 +40,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 +54,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 +113,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 +162,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) From 87536b8cdc0a9d5150505cedf04a12fbc1c0387e Mon Sep 17 00:00:00 2001 From: samtalki <10187005+samtalki@users.noreply.github.com> Date: Sun, 14 Jun 2026 18:59:48 -0400 Subject: [PATCH 2/7] Resolve PowerIO from the registry in CI; refresh docs PowerIO is registered and public, so CI no longer needs the private-repo probe. - CI.yml / Documentation.yml / Benchmark.yml: drop the POWERIO_JL_TOKEN env, the PowerIO access probe/skip/configure steps, the availability gates, and JULIA_PKG_USE_CLI_GIT. The test and build (docs) job names are preserved. - docs: drop the removed ParsedCase/Parsed* @docs entries from the API reference and rewrite the PowerIO integration page for the direct to_powerdata path. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/Benchmark.yml | 23 --------- .github/workflows/CI.yml | 73 ----------------------------- .github/workflows/Documentation.yml | 23 --------- docs/powerio-integration.md | 33 ++++++------- docs/src/api.md | 6 --- 5 files changed, 17 insertions(+), 141 deletions(-) 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/docs/powerio-integration.md b/docs/powerio-integration.md index 5ee9af7..9b960aa 100644 --- a/docs/powerio-integration.md +++ b/docs/powerio-integration.md @@ -4,24 +4,25 @@ 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: constant, linear, and quadratic costs; PWL and + higher-order polynomials are rejected. Costs are read from PowerIO's raw generator + records because `to_powerdata` does not preserve coefficients declared with + `ncost > 3`. +- a finite `rate_a` fallback when the source leaves the thermal limit at `0` +- default angle-difference bounds +- a reference bus chosen as the largest generator's bus when the source marks none -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/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 From b60379f3eed51dfe8fcfe3720b27e4410a62719e Mon Sep 17 00:00:00 2001 From: samtalki <10187005+samtalki@users.noreply.github.com> Date: Sun, 14 Jun 2026 19:14:27 -0400 Subject: [PATCH 3/7] Move get_path (and LazyArtifacts) out of parser.jl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_path resolves PowerDiff's bundled PGLib artifact — a data-library concern, not parsing. Pulling it (and the LazyArtifacts dependency it needs for `artifact"..."`) into src/artifacts.jl leaves parser.jl as just the PowerIO entry points and the network-data adapter. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/PowerDiff.jl | 1 + src/artifacts.jl | 12 ++++++++++++ src/parser.jl | 11 ----------- 3 files changed, 13 insertions(+), 11 deletions(-) create mode 100644 src/artifacts.jl diff --git a/src/PowerDiff.jl b/src/PowerDiff.jl index a368aee..21344b4 100644 --- a/src/PowerDiff.jl +++ b/src/PowerDiff.jl @@ -28,6 +28,7 @@ const MOI = JuMP.MOI # ============================================================================= const _SILENCE_WARNINGS = Ref(false) +include("artifacts.jl") include("parser.jl") """ 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 index 747789b..6025399 100644 --- a/src/parser.jl +++ b/src/parser.jl @@ -1,4 +1,3 @@ -using LazyArtifacts import PowerIO # PowerIO is the parser and data layer. It parses MATPOWER/PSSE/etc. and @@ -8,16 +7,6 @@ import PowerIO # polynomial cost interpretation, finite flow limits, default angle-difference # bounds, and rejection of records PowerDiff does not model (storage, HVDC). -""" - 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, filetype="m") -> PowerIO.Network From fd2f2eafee752546727860df9f800a6f2afb4c78 Mon Sep 17 00:00:00 2001 From: samtalki <10187005+samtalki@users.noreply.github.com> Date: Sun, 14 Jun 2026 23:39:34 -0400 Subject: [PATCH 4/7] Drop PowerIO workarounds; consume to_powerdata directly on 0.1.3 PowerIO 0.1.3 makes to_powerdata a complete data layer: source bus ids on bus_i, an inferred reference (type == 3), and correct polynomial costs (ncost > 3 no longer mangled, so a quadratic padded to ncost=5 keeps its linear term). Bump compat to 0.1.3 and remove the three workarounds _network_data carried for the old gaps: - read gen cost straight from to_powerdata's rows (model_poly/n/c, already per-unit and leading-zero collapsed) instead of re-reading raw costs from PowerIO.generators and rescaling; drop the _cost_tuple helper - drop the biggest-pmax reference promotion; the reference now comes from to_powerdata (type == 3) - drop the try/catch around to_powerdata, which now throws ArgumentError on malformed input itself Kept as consumer-side solver prep (PowerIO leaves these to the caller): the rate_a == 0 finite-limit fallback, the +/-60 deg default angle bounds, rejection of storage/HVDC and PWL/higher-than-quadratic costs, and the dense gen.bus/f_bus/t_bus -> source id mapping. Rename src/parser.jl to src/network_data.jl (it builds network tables, it is not a parser) and move `using PowerIO` to the top of PowerDiff.jl. DCNetwork/ACNetwork field values are unchanged: a before/after field dump over pglib case5/14/30 and a non-basic-id case (ids 1,2,3,4,10) is identical. Co-Authored-By: Claude Opus 4.8 (1M context) --- Project.toml | 2 +- src/PowerDiff.jl | 3 +- src/{parser.jl => network_data.jl} | 104 +++++++++++------------------ 3 files changed, 41 insertions(+), 68 deletions(-) rename src/{parser.jl => network_data.jl} (60%) diff --git a/Project.toml b/Project.toml index 57e5685..e9e48ed 100644 --- a/Project.toml +++ b/Project.toml @@ -26,7 +26,7 @@ Ipopt = "1" JuMP = "1" LazyArtifacts = "1" NLPModelsIpopt = "0.11.2" -PowerIO = "0.1" +PowerIO = "0.1.3" julia = "1.9" [extras] diff --git a/src/PowerDiff.jl b/src/PowerDiff.jl index 21344b4..bba5811 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 @@ -29,7 +30,7 @@ const MOI = JuMP.MOI const _SILENCE_WARNINGS = Ref(false) include("artifacts.jl") -include("parser.jl") +include("network_data.jl") """ silence() diff --git a/src/parser.jl b/src/network_data.jl similarity index 60% rename from src/parser.jl rename to src/network_data.jl index 6025399..a99a39f 100644 --- a/src/parser.jl +++ b/src/network_data.jl @@ -1,11 +1,14 @@ -import PowerIO - -# PowerIO is the parser and data layer. It parses MATPOWER/PSSE/etc. and -# `PowerIO.to_powerdata` returns normalized, per-unit, filtered data with the slack -# bus inferred, loads/shunts aggregated per bus, and polynomial costs rescaled. -# PowerDiff consumes that directly. The only logic kept here is OPF-solver modeling: -# polynomial cost interpretation, finite flow limits, default angle-difference -# bounds, and rejection of records PowerDiff does not model (storage, HVDC). +# 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. +# +# This file is the construction front door: thin MATPOWER-only parse wrappers that +# return a `PowerIO.Network`, and `_network_data`, the adapter that turns one into +# the network tables `DCNetwork`/`ACNetwork` consume. The only logic here 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 @@ -64,15 +67,16 @@ _resolve_case_path(path::AbstractString, library) = joinpath(get_path(library), """ _network_data(net::PowerIO.Network) -> NamedTuple -Normalized PowerDiff network tables built from `PowerIO.to_powerdata(net)`. +Build PowerDiff network tables from `PowerIO.to_powerdata(net)`. -`to_powerdata` already does per-unit scaling, status/isolated filtering, slack -inference, per-bus load/shunt aggregation, and `base^(n-i)` cost rescaling, and it -returns dense file-order indices. This re-keys the bus references back to original -bus ids (so [`IDMapping`](@ref)'s sorted ordering is preserved) and applies -PowerDiff's OPF modeling: polynomial cost (rejecting PWL/quartic), a finite flow -limit fallback when `rate_a == 0`, default angle-difference bounds, and rejection of -storage / HVDC records that PowerDiff does not model. +`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`. @@ -80,61 +84,30 @@ constructors expect, with loads/shunts already folded into per-bus `pd/qd/gs/bs` function _network_data(net) isempty(PowerIO.hvdc(net)) || throw(ArgumentError( "PowerDiff does not support HVDC/dcline records; remove or convert dcline before parsing")) - # Surface malformed input (e.g. non-finite fields PowerIO leaves as `nothing`) as an - # ArgumentError rather than a downstream MethodError. - pd = try - PowerIO.to_powerdata(net) - catch e - e isa ArgumentError && rethrow() - throw(ArgumentError("PowerDiff: could not normalize the network: " * sprint(showerror, e))) - end + pd = PowerIO.to_powerdata(net) isempty(pd.storage) || throw(ArgumentError( "PowerDiff does not support storage records; remove or convert storage before parsing")) 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] # file-order index -> original bus id - vmax = [Float64(b.vmax) for b in pd.bus] # file-order index -> bus vmax + orig = [Int(b.bus_i) for b in pd.bus] # dense file-order index -> source bus id + vmax = [Float64(b.vmax) for b in pd.bus] # dense index -> bus vmax 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)] - # to_powerdata's gen `c`/`n` mangles costs declared with ncost > 3 (it keeps the - # 3 highest-degree coefficients with the wrong exponents and drops the rest), so a - # quadratic padded to ncost=5 — e.g. MATPOWER case14 — loses its linear term. Read - # the raw cost coefficients from PowerIO instead, aligned to to_powerdata's kept - # gens by the same keep filter (in service, bus not isolated). Track as a PowerIO - # enhancement: have to_powerdata expose costs losslessly. - kept_bus = Set(Int(b.id) for b in PowerIO.buses(net) if String(b.kind) != "ISOLATED") - raw_costs = [_cost_tuple(g.cost, Float64(pd.baseMVA)) - for g in PowerIO.generators(net) if g.in_service && Int(g.bus) in kept_bus] - length(raw_costs) == length(pd.gen) || - throw(ArgumentError("generator cost alignment mismatch ($(length(raw_costs)) vs $(length(pd.gen)))")) - + # Costs come straight from to_powerdata's gen rows: `c` is already per-unit + # scaled with leading zeros collapsed (ncost > 3 is no longer mangled), so a + # quadratic padded to ncost=5 keeps its linear term. 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=raw_costs[j]) + vg=Float64(g.vg), pmin=Float64(g.pmin), pmax=Float64(g.pmax), cost=_poly_cost(g)) for (j, g) in enumerate(pd.gen)] - # PowerDiff's OPF needs a reference bus. to_powerdata only reassigns the slack when - # an explicit REF was demoted; if the source marks none at all, promote the bus with - # the largest generator (matching the previous parser). PowerIO issue: infer a - # reference when the file marks none. - if !any(b -> b.bus_type == 3, buses) - slack_bus, best_pmax = 0, -Inf - for g in gens - if g.pmax > best_pmax - best_pmax = g.pmax - slack_bus = g.gen_bus - end - end - slack_bus == 0 || - (buses = [b.bus_i == slack_bus ? (; b..., bus_type=3) : b for b in buses]) - end - branches = NamedTuple[] for (l, br) in enumerate(pd.branch) angmin, angmax = _normalize_angle_bounds(Float64(br.angmin), Float64(br.angmax)) @@ -153,17 +126,16 @@ function _network_data(net) bus=buses, gen=gens, branch=branches) end -# Convert a PowerIO raw generator cost (model, ncost, coeffs) to PowerDiff's -# (quadratic, linear, constant) tuple, in per-unit. Rescale each coefficient by -# base^(n-i), drop leading zeros (so a quadratic padded to ncost=5 collapses to a -# real quadratic), and reject piecewise-linear and higher-than-quadratic costs. -function _cost_tuple(cost, base::Float64) - cost === nothing && return (0.0, 0.0, 0.0) - Int(cost.model) == 2 || throw(ArgumentError("only polynomial mpc.gencost model 2 is supported")) - n = Int(cost.ncost) - n >= 1 || throw(ArgumentError("mpc.gencost must declare at least one coefficient")) - cf = collect(cost.coeffs) - coeffs = [base^(n - i) * Float64(cf[i]) for i in 1:n] +# Interpret a PowerIO gen row's polynomial cost as PowerDiff's +# (quadratic, linear, constant) tuple. `g.c` is already per-unit scaled and +# leading-zero collapsed by to_powerdata; reject PWL (model_poly == false) and +# higher-than-quadratic costs. +function _poly_cost(g) + g.model_poly || throw(ArgumentError("only polynomial mpc.gencost (model 2) is supported")) + n = Int(g.n) + coeffs = collect(Float64, g.c) + 1 <= n <= length(coeffs) || throw(ArgumentError("mpc.gencost must declare at least one coefficient")) + coeffs = coeffs[1:n] while length(coeffs) > 1 && iszero(first(coeffs)) popfirst!(coeffs) end From 40be41b518b76e7c87de79f3fbd3bbeab3a76e12 Mon Sep 17 00:00:00 2001 From: samtalki <10187005+samtalki@users.noreply.github.com> Date: Mon, 15 Jun 2026 00:21:08 -0400 Subject: [PATCH 5/7] Fold network_data.jl into the DCNetwork construction path The MATPOWER parse wrappers (parse_file/parse_matpower), the _network_data adapter, and its solver-prep helpers (_poly_cost, _fallback_rate_a, _normalize_angle_bounds) now live in types/dc_network.jl rather than a separate file. dc_network.jl is included before ac_network.jl, so ACNetwork and the OPF problem constructors reuse the shared _network_data. This removes the standalone src/network_data.jl, which was just the old parser.jl renamed. Pure relocation: DCNetwork/ACNetwork field values are unchanged (before/after field dump over pglib case5/14/30 and a non-basic-id case is identical), the test suite passes, and docs build with parse_* docstrings resolving from dc_network.jl. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/PowerDiff.jl | 1 - src/network_data.jl | 169 --------------------------------------- src/types/dc_network.jl | 173 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 173 insertions(+), 170 deletions(-) delete mode 100644 src/network_data.jl diff --git a/src/PowerDiff.jl b/src/PowerDiff.jl index bba5811..703d1aa 100644 --- a/src/PowerDiff.jl +++ b/src/PowerDiff.jl @@ -30,7 +30,6 @@ const MOI = JuMP.MOI const _SILENCE_WARNINGS = Ref(false) include("artifacts.jl") -include("network_data.jl") """ silence() diff --git a/src/network_data.jl b/src/network_data.jl deleted file mode 100644 index a99a39f..0000000 --- a/src/network_data.jl +++ /dev/null @@ -1,169 +0,0 @@ -# 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. -# -# This file is the construction front door: thin MATPOWER-only parse wrappers that -# return a `PowerIO.Network`, and `_network_data`, the adapter that turns one into -# the network tables `DCNetwork`/`ACNetwork` consume. The only logic here 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; kwargs...) - -Compatibility alias for [`parse_matpower`](@ref). -""" -parse_matpower_struct(file::String; kwargs...) = parse_matpower(file; kwargs...) - -_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`. -""" -function _network_data(net) - isempty(PowerIO.hvdc(net)) || throw(ArgumentError( - "PowerDiff does not support HVDC/dcline records; remove or convert dcline before parsing")) - pd = PowerIO.to_powerdata(net) - isempty(pd.storage) || throw(ArgumentError( - "PowerDiff does not support storage records; remove or convert storage before parsing")) - 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 - vmax = [Float64(b.vmax) for b in pd.bus] # dense index -> bus vmax - - 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: `c` is already per-unit - # scaled with leading zeros collapsed (ncost > 3 is no longer mangled), so a - # quadratic padded to ncost=5 keeps its linear term. 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 = NamedTuple[] - for (l, br) in enumerate(pd.branch) - 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, - vmax[br.f_bus], vmax[br.t_bus]) - push!(branches, (; 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 - all(br.rate_a > 0 for br in branches) || throw(ArgumentError( - "branches must have positive thermal limits after normalization")) - - return (; name=PowerIO.network_name(net), baseMVA=Float64(pd.baseMVA), - bus=buses, gen=gens, branch=branches) -end - -# Interpret a PowerIO gen row's polynomial cost as PowerDiff's -# (quadratic, linear, constant) tuple. `g.c` is already per-unit scaled and -# leading-zero collapsed by to_powerdata; reject PWL (model_poly == false) and -# higher-than-quadratic costs. -function _poly_cost(g) - g.model_poly || throw(ArgumentError("only polynomial mpc.gencost (model 2) is supported")) - n = Int(g.n) - coeffs = collect(Float64, g.c) - 1 <= n <= length(coeffs) || throw(ArgumentError("mpc.gencost must declare at least one coefficient")) - coeffs = coeffs[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 - -# 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 diff --git a/src/types/dc_network.jl b/src/types/dc_network.jl index 4e25ee2..304245f 100644 --- a/src/types/dc_network.jl +++ b/src/types/dc_network.jl @@ -152,6 +152,179 @@ 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; kwargs...) + +Compatibility alias for [`parse_matpower`](@ref). +""" +parse_matpower_struct(file::String; kwargs...) = parse_matpower(file; kwargs...) + +_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`. +""" +function _network_data(net) + isempty(PowerIO.hvdc(net)) || throw(ArgumentError( + "PowerDiff does not support HVDC/dcline records; remove or convert dcline before parsing")) + pd = PowerIO.to_powerdata(net) + isempty(pd.storage) || throw(ArgumentError( + "PowerDiff does not support storage records; remove or convert storage before parsing")) + 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 + vmax = [Float64(b.vmax) for b in pd.bus] # dense index -> bus vmax + + 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: `c` is already per-unit + # scaled with leading zeros collapsed (ncost > 3 is no longer mangled), so a + # quadratic padded to ncost=5 keeps its linear term. 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 = NamedTuple[] + for (l, br) in enumerate(pd.branch) + 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, + vmax[br.f_bus], vmax[br.t_bus]) + push!(branches, (; 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 + all(br.rate_a > 0 for br in branches) || throw(ArgumentError( + "branches must have positive thermal limits after normalization")) + + return (; name=PowerIO.network_name(net), baseMVA=Float64(pd.baseMVA), + bus=buses, gen=gens, branch=branches) +end + +# Interpret a PowerIO gen row's polynomial cost as PowerDiff's +# (quadratic, linear, constant) tuple. `g.c` is already per-unit scaled and +# leading-zero collapsed by to_powerdata; reject PWL (model_poly == false) and +# higher-than-quadratic costs. +function _poly_cost(g) + g.model_poly || throw(ArgumentError("only polynomial mpc.gencost (model 2) is supported")) + n = Int(g.n) + coeffs = collect(Float64, g.c) + 1 <= n <= length(coeffs) || throw(ArgumentError("mpc.gencost must declare at least one coefficient")) + coeffs = coeffs[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 + +# 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 # ============================================================================= From 60d5a156195bd5a1f60f11b086cb89c985b62543 Mon Sep 17 00:00:00 2001 From: samtalki <10187005+samtalki@users.noreply.github.com> Date: Mon, 15 Jun 2026 01:10:57 -0400 Subject: [PATCH 6/7] Address code-review findings on the PowerIO adapter Correctness: - _poly_cost: accept generators with no gencost row (gencost is optional in MATPOWER). PowerIO returns model_poly=false, n=0 for them; treat as cost-free instead of throwing, which had broken even power-flow-only construction. PWL (model_poly=false, n>0) is still rejected, and to_powerdata rejects higher-than-quadratic itself. Consistency: - reject storage from the raw network (PowerIO.storage(net)) like HVDC, so both guards see out-of-service records; to_powerdata's filtered pd.storage dropped them, silently accepting a file that declared disabled storage. Efficiency / clarity: - _poly_cost reads to_powerdata's right-aligned (cq, cl, cc) directly instead of collect/slice/popfirst per generator. - build the branch table with a concrete-eltype comprehension + _branch_row helper instead of an abstract Vector{NamedTuple} + push!. - drop the duplicate per-bus vmax array; _branch_row indexes the buses table. - parse_matpower_struct no longer advertises kwargs... it cannot forward. Docs: - fix stale claims that parse_file returns "PowerDiff's typed representation" (it returns a PowerIO.Network) in README, getting-started, index, advanced. - powerio-integration.md: costs come from to_powerdata, not raw records, and the reference bus comes from to_powerdata, not a largest-generator promotion. No behavior change for valid inputs: DCNetwork/ACNetwork field values are identical (before/after field dump over pglib case5/14/30 and a non-basic-id case), and the test suite passes. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 2 +- docs/powerio-integration.md | 9 +++-- docs/src/advanced.md | 2 +- docs/src/getting-started.md | 2 +- docs/src/index.md | 2 +- src/types/dc_network.jl | 70 ++++++++++++++++++------------------- 6 files changed, 42 insertions(+), 45 deletions(-) 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 9b960aa..a2dcb14 100644 --- a/docs/powerio-integration.md +++ b/docs/powerio-integration.md @@ -14,13 +14,12 @@ 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: -- polynomial cost interpretation: constant, linear, and quadratic costs; PWL and - higher-order polynomials are rejected. Costs are read from PowerIO's raw generator - records because `to_powerdata` does not preserve coefficients declared with - `ncost > 3`. +- 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 -- a reference bus chosen as the largest generator's bus when the source marks none PowerDiff rejects networks carrying storage or HVDC/dcline records, which it does not model. 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/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/src/types/dc_network.jl b/src/types/dc_network.jl index 304245f..7c0d052 100644 --- a/src/types/dc_network.jl +++ b/src/types/dc_network.jl @@ -212,11 +212,11 @@ function parse_matpower(file::String; library=nothing) end """ - parse_matpower_struct(file::String; kwargs...) + parse_matpower_struct(file::String; library=nothing) Compatibility alias for [`parse_matpower`](@ref). """ -parse_matpower_struct(file::String; kwargs...) = parse_matpower(file; kwargs...) +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) @@ -239,43 +239,33 @@ 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`. """ 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")) - pd = PowerIO.to_powerdata(net) - isempty(pd.storage) || throw(ArgumentError( + 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 - vmax = [Float64(b.vmax) for b in pd.bus] # dense index -> bus vmax + 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: `c` is already per-unit - # scaled with leading zeros collapsed (ncost > 3 is no longer mangled), so a - # quadratic padded to ncost=5 keeps its linear term. Map dense `gen.bus` to the - # source bus id via `orig`. + # 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 = NamedTuple[] - for (l, br) in enumerate(pd.branch) - 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, - vmax[br.f_bus], vmax[br.t_bus]) - push!(branches, (; 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 + 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")) @@ -283,23 +273,31 @@ function _network_data(net) bus=buses, gen=gens, branch=branches) end -# Interpret a PowerIO gen row's polynomial cost as PowerDiff's -# (quadratic, linear, constant) tuple. `g.c` is already per-unit scaled and -# leading-zero collapsed by to_powerdata; reject PWL (model_poly == false) and -# higher-than-quadratic costs. +# 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) - g.model_poly || throw(ArgumentError("only polynomial mpc.gencost (model 2) is supported")) - n = Int(g.n) - coeffs = collect(Float64, g.c) - 1 <= n <= length(coeffs) || throw(ArgumentError("mpc.gencost must declare at least one coefficient")) - coeffs = coeffs[1:n] - while length(coeffs) > 1 && iszero(first(coeffs)) - popfirst!(coeffs) + 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 - 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]) + 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 From e1a639bcffe9e58b181de1bf2ad32e346ed8d231 Mon Sep 17 00:00:00 2001 From: samtalki <10187005+samtalki@users.noreply.github.com> Date: Mon, 15 Jun 2026 01:37:02 -0400 Subject: [PATCH 7/7] Re-expose shunts as a data.shunt table Dropping the ParsedCase layer folded shunts into per-bus gs/bs (which the network constructors consume) but removed the separate data.shunt records. Add a `shunt` field back to the _network_data tables: one (; index, shunt_bus, gs, bs) record per bus with a nonzero shunt admittance, derived from the per-bus values to_powerdata already aggregates (no raw re-read). DCNetwork/ACNetwork are unchanged (field dump byte-identical); the inline parser test asserts the restored shunt. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/types/dc_network.jl | 10 +++++++++- test/test_parser_parity.jl | 4 ++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/types/dc_network.jl b/src/types/dc_network.jl index 7c0d052..b6cc433 100644 --- a/src/types/dc_network.jl +++ b/src/types/dc_network.jl @@ -237,6 +237,8 @@ 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 @@ -269,8 +271,14 @@ function _network_data(net) 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) + 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 diff --git a/test/test_parser_parity.jl b/test/test_parser_parity.jl index 9e09df2..b85c3fb 100644 --- a/test/test_parser_parity.jl +++ b/test/test_parser_parity.jl @@ -31,6 +31,10 @@ _inline_data() = PowerDiff._network_data(PowerDiff.parse_matpower(IOBuffer(_INLI # 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