From 4a668734c6aabc4dc448ace4d4670171fc7a134d Mon Sep 17 00:00:00 2001 From: Anna Casavant Date: Fri, 25 Sep 2026 08:53:32 -0600 Subject: [PATCH] Write one field per controlled quantity from PSS/E control blocks Follows the SiennaSchemas and PowerSystems change of the same name. The OpenAPI writers stop reusing one field whose unit depends on a mode and write the field the mode selects, leaving the rest absent. - Transformers: the objective picks one actuator band (tap_ratio_limits or phase_angle_limits) and one target band (controlled_voltage_limits, controlled_reactive_power_flow_limits or controlled_active_power_flow_limits) from a pairing table; no COD means all five absent. pti.jl records whether the RMI/RMA and VMI/VMA columns were present before defaults are filled, and psse.jl carries the flags as RM_PRESENT/VM_PRESENT, so the 0.9-1.1 substitute is written only where it is a real tap or voltage default and never as an MVAr or angle band. Phase-angle bands and impedance-correction angle curves convert from degrees to radians. - LCC lines: MDC maps to control_mode BLOCKED/POWER/CURRENT with one of power_transfer_setpoint or current_transfer_setpoint. - VSC lines: dc_power_setpoint or dc_voltage_setpoint and power_factor_setpoint or ac_voltage_setpoint per terminal. - Switched shunts: voltage_limits for the voltage modes, reactive_power_range_limits as a fraction of the regulated device's range for the reactive modes; MODSW 6 maps to DISCRETE_REACTIVE_FACTS. - Impedance correction: tap_ratio_correction_curve or phase_angle_correction_curve by transformer_control_mode. device_base.jl drops the :dynamic classification, since no field's unit depends on a mode any more. A synthetic v35 fixture covers every presence combination. --- src/openapi/attributes.jl | 15 +- src/openapi/branch.jl | 144 ++++++++------- src/openapi/dc_branch.jl | 56 +++--- src/openapi/device_base.jl | 123 +++---------- src/openapi/shunt.jl | 41 ++++- src/openapi/units.jl | 25 +++ src/pm_io/psse.jl | 29 ++- src/pm_io/pti.jl | 20 +++ ...ynthetic_v35_transformer_band_presence.raw | 55 ++++++ test/test_openapi_attributes.jl | 7 +- test/test_openapi_branch.jl | 134 ++++++++++---- test/test_openapi_dc_shunt.jl | 168 ++++++++++++------ test/test_openapi_generation.jl | 40 +++-- ...test_openapi_transformer_discriminators.jl | 12 +- test/test_parse_psse.jl | 26 +++ test/test_transformer_band_presence.jl | 70 ++++++++ 16 files changed, 646 insertions(+), 319 deletions(-) create mode 100644 test/fixtures/synthetic_v35_transformer_band_presence.raw create mode 100644 test/test_transformer_band_presence.jl diff --git a/src/openapi/attributes.jl b/src/openapi/attributes.jl index aebdd31..576a8fc 100644 --- a/src/openapi/attributes.jl +++ b/src/openapi/attributes.jl @@ -54,11 +54,21 @@ function _impedance_correction_curves(data::Dict) return curves end +"""The curve field a correction table's control mode selects, with the unit PSS/E states +the table's x axis in: a tap ratio is dimensionless, a phase-shift angle is in degrees +(converted to the schema's radians on write). Exhaustive over the enum.""" +function _correction_curve_field(control_mode::AbstractString) + control_mode == "TAP_RATIO" && return (:tap_ratio_correction_curve, "1") + control_mode == "PHASE_SHIFT_ANGLE" && return (:phase_angle_correction_curve, "deg") + throw(IS.DataFormatError("unhandled impedance correction control mode $control_mode")) +end + """ Build and register a new `ImpedanceCorrectionData` for `(table_number, winding)` and attach it to `transformer_id` — the first-sighting path for a (table, winding) pair. Returns the attribute, so later sightings of the same pair associate against it directly -(see [`_attach_impedance_correction!`](@ref)). +(see [`_attach_impedance_correction!`](@ref)). The table's curve lands on the one field its +control mode selects; the other curve stays absent. """ function _new_impedance_correction_attribute!( sys::OpenAPISystem, @@ -71,7 +81,8 @@ function _new_impedance_correction_attribute!( attribute = stage(PO.ImpedanceCorrectionData) set_value!(attribute, :id, next_id!(get_registry(sys))) set_value!(attribute, :table_number, table_number) - set_value!(attribute, :impedance_correction_curve, curve) + field, x_unit = _correction_curve_field(control_mode) + set_value!(attribute, field, curve, x_unit) set_value!(attribute, :transformer_winding, winding) set_value!(attribute, :transformer_control_mode, control_mode) add_supplemental_attribute!(sys, attribute, transformer_id) diff --git a/src/openapi/branch.jl b/src/openapi/branch.jl index 33f14c6..311a3be 100644 --- a/src/openapi/branch.jl +++ b/src/openapi/branch.jl @@ -129,37 +129,37 @@ const TRANSFORMER_CONTROL_OBJECTIVE_NAMES = Dict( 5 => "ASYMMETRIC_ACTIVE_POWER_FLOW", ) -"""COD values whose control objective is a phase-shift (angle) control rather than a tap -(voltage/reactive) control.""" -const _PHASE_SHIFT_OBJECTIVES = ( - "ACTIVE_POWER_FLOW", - "ACTIVE_POWER_FLOW_DISABLED", - "ASYMMETRIC_ACTIVE_POWER_FLOW", - "ASYMMETRIC_ACTIVE_POWER_FLOW_DISABLED", +"""Objective → `(actuator, target)` band fields, one field per physical quantity; the same +pairing PowerSystems validates against. `UNDEFINED` has no control block and no bands.""" +const _CONTROL_BAND_FIELDS = Dict{String, Union{Nothing, NTuple{2, Symbol}}}( + "UNDEFINED" => nothing, + "FIXED" => (:tap_ratio_limits, :controlled_voltage_limits), + "VOLTAGE" => (:tap_ratio_limits, :controlled_voltage_limits), + "VOLTAGE_DISABLED" => (:tap_ratio_limits, :controlled_voltage_limits), + "REACTIVE_POWER_FLOW" => + (:tap_ratio_limits, :controlled_reactive_power_flow_limits), + "REACTIVE_POWER_FLOW_DISABLED" => + (:tap_ratio_limits, :controlled_reactive_power_flow_limits), + "CONTROL_OF_DC_LINE" => (:tap_ratio_limits, :controlled_active_power_flow_limits), + "CONTROL_OF_DC_LINE_DISABLED" => + (:tap_ratio_limits, :controlled_active_power_flow_limits), + "ACTIVE_POWER_FLOW" => (:phase_angle_limits, :controlled_active_power_flow_limits), + "ACTIVE_POWER_FLOW_DISABLED" => + (:phase_angle_limits, :controlled_active_power_flow_limits), + "ASYMMETRIC_ACTIVE_POWER_FLOW" => + (:phase_angle_limits, :controlled_active_power_flow_limits), + "ASYMMETRIC_ACTIVE_POWER_FLOW_DISABLED" => + (:phase_angle_limits, :controlled_active_power_flow_limits), ) +@assert Set(keys(_CONTROL_BAND_FIELDS)) == Set(values(TRANSFORMER_CONTROL_OBJECTIVE_NAMES)) -"""`TransformerCircuit.control_limits`' unit per `control_objective`, read directly off -the schema's `x-units` table (`TransformerCircuit.json`).""" -const _CONTROL_LIMITS_UNIT = Dict( - "UNDEFINED" => "1", "VOLTAGE_DISABLED" => "1", - "REACTIVE_POWER_FLOW_DISABLED" => "1", - "ACTIVE_POWER_FLOW_DISABLED" => "rad", "CONTROL_OF_DC_LINE_DISABLED" => "1", - "ASYMMETRIC_ACTIVE_POWER_FLOW_DISABLED" => "rad", "FIXED" => "1", "VOLTAGE" => "1", - "REACTIVE_POWER_FLOW" => "1", "ACTIVE_POWER_FLOW" => "rad", - "CONTROL_OF_DC_LINE" => "1", - "ASYMMETRIC_ACTIVE_POWER_FLOW" => "rad", -) - -"""`TransformerCircuit.controlled_quantity_limits`' unit per `control_objective`, read -directly off the schema's `x-units` table.""" -const _CONTROLLED_QUANTITY_LIMITS_UNIT = Dict( - "UNDEFINED" => "pu", "VOLTAGE_DISABLED" => "pu", - "REACTIVE_POWER_FLOW_DISABLED" => "MVAr", - "ACTIVE_POWER_FLOW_DISABLED" => "MW", "CONTROL_OF_DC_LINE_DISABLED" => "MW", - "ASYMMETRIC_ACTIVE_POWER_FLOW_DISABLED" => "MW", "FIXED" => "pu", "VOLTAGE" => "pu", - "REACTIVE_POWER_FLOW" => "MVAr", "ACTIVE_POWER_FLOW" => "MW", - "CONTROL_OF_DC_LINE" => "MW", - "ASYMMETRIC_ACTIVE_POWER_FLOW" => "MW", +"""The wire unit of each band, matching `TransformerCircuit.json`'s `x-unit`.""" +const _CONTROL_BAND_UNITS = Dict( + :tap_ratio_limits => "1", + :phase_angle_limits => "rad", + :controlled_voltage_limits => "pu", + :controlled_reactive_power_flow_limits => "MVAr", + :controlled_active_power_flow_limits => "MW", ) function _transformer_control_objective(cod::Real) @@ -170,13 +170,27 @@ function _transformer_control_objective(cod::Real) return TRANSFORMER_CONTROL_OBJECTIVE_NAMES[code] end +"""Warn and swap an inverted `(lo, hi)` band, naming the PSS/E columns it came from.""" +function _ordered_band(lo, hi, lo_col, hi_col, record, suffix) + if lo > hi + @warn "Transformer $record winding $suffix has inverted $lo_col$suffix = $lo > $hi_col$suffix = $hi; normalizing to (min = $hi, max = $lo)." + return hi, lo + end + return lo, hi +end + """ Assign a `TransformerCircuit`'s flat control block from a pm transformer dict `d` for -winding `suffix` (1/2/3). Ported from PSCB's `_transformer_control_fields`: PSS/E's -`RMI`/`RMA`/`VMI`/`VMA` are already expressed in the unit `control_objective` implies, so -every value is a direct passthrough once `_CONTROL_LIMITS_UNIT`/ -`_CONTROLLED_QUANTITY_LIMITS_UNIT` supply that unit. `record` names the site in the -inverted-limits warnings. +winding `suffix` (1/2/3). The objective selects one actuator band and one target band +(`_CONTROL_BAND_FIELDS`); every other band stays absent, and so does every band under +`UNDEFINED`, so a circuit with no control block carries no invented limits. + +PSS/E's `RMI`/`RMA` and `VMI`/`VMA` arrive already in the unit the objective implies, but +`pti.jl` substitutes 0.9/1.1 for a column the file omitted. That substitute is a voltage or +tap-ratio default and nothing else, so `VM_PRESENT`/`RM_PRESENT` decide what may be written: +`controlled_voltage_limits` and `tap_ratio_limits` take the default when the file omitted +them, while an omitted power band or angle band stays absent rather than carrying a +fabricated value. `record` names the site in the inverted-limits warnings. """ function _set_transformer_control_fields!( circuit, @@ -184,40 +198,40 @@ function _set_transformer_control_fields!( suffix::Int, record::AbstractString, ) - cod = get(d, "COD$suffix", -99) - objective = _transformer_control_objective(cod) - phase_shifting = objective in _PHASE_SHIFT_OBJECTIVES - if phase_shifting - rmi_default, rma_default = -180.0, 180.0 - else - rmi_default, rma_default = 0.9, 1.1 - end - rmi = get(d, "RMI$suffix", rmi_default) - rma = get(d, "RMA$suffix", rma_default) - if rmi > rma - @warn "Transformer $record winding $suffix has inverted control limits RMI$suffix = $rmi > RMA$suffix = $rma; normalizing to (min = $rma, max = $rmi)." - rmi, rma = rma, rmi - end - if phase_shifting - rmi, rma = deg2rad(rmi), deg2rad(rma) - end - vmi = get(d, "VMI$suffix", 0.9) - vma = get(d, "VMA$suffix", 1.1) - if vmi > vma - @warn "Transformer $record winding $suffix has inverted controlled-quantity limits VMI$suffix = $vmi > VMA$suffix = $vma; normalizing to (min = $vma, max = $vmi)." - vmi, vma = vma, vmi - end + objective = _transformer_control_objective(get(d, "COD$suffix", -99)) set_value!(circuit, :control_objective, objective) set_value!(circuit, :regulated_bus_number, Int(get(d, "CONT$suffix", 0))) - set_value!( - circuit, - :control_limits, - (min = rmi, max = rma), - _CONTROL_LIMITS_UNIT[objective], - ) - set_value!(circuit, :controlled_quantity_limits, (min = vmi, max = vma), - _CONTROLLED_QUANTITY_LIMITS_UNIT[objective]) set_value!(circuit, :number_of_tap_positions, Int(get(d, "NTP$suffix", 33))) + bands = _CONTROL_BAND_FIELDS[objective] + isnothing(bands) && return + actuator, target = bands + + # A hand-built dict without the flag states its values explicitly. + rm_present = get(d, "RM_PRESENT$suffix", true) + vm_present = get(d, "VM_PRESENT$suffix", true) + + if actuator == :tap_ratio_limits || rm_present + rmi, rma = _ordered_band( + get(d, "RMI$suffix", 0.9), get(d, "RMA$suffix", 1.1), "RMI", "RMA", record, + suffix, + ) + if actuator == :phase_angle_limits + rmi, rma = deg2rad(rmi), deg2rad(rma) + end + set_value!(circuit, actuator, (min = rmi, max = rma), _CONTROL_BAND_UNITS[actuator]) + end + + if target == :controlled_voltage_limits + vmi, vma = + vm_present ? (get(d, "VMI$suffix", 0.9), get(d, "VMA$suffix", 1.1)) : + (0.9, 1.1) + vmi, vma = _ordered_band(vmi, vma, "VMI", "VMA", record, suffix) + set_value!(circuit, target, (min = vmi, max = vma), _CONTROL_BAND_UNITS[target]) + elseif vm_present + vmi, vma = + _ordered_band(d["VMI$suffix"], d["VMA$suffix"], "VMI", "VMA", record, suffix) + set_value!(circuit, target, (min = vmi, max = vma), _CONTROL_BAND_UNITS[target]) + end return end diff --git a/src/openapi/dc_branch.jl b/src/openapi/dc_branch.jl index 67e2528..f08f96b 100644 --- a/src/openapi/dc_branch.jl +++ b/src/openapi/dc_branch.jl @@ -1,6 +1,6 @@ # `data["dcline"]` IS a native PowerModels section — `_make_per_unit!` divides its power # fields (`pf`/`qf`/`p*f`/`q*f`/`p*t`/`q*t`) by `baseMVA`. The LCC-specific fields (`r`, -# `transfer_setpoint`, `scheduled_dc_voltage`, `rectifier_*`, `inverter_*`, and the rest +# `transfer_setpoint` (raw SETVL), `scheduled_dc_voltage`, `rectifier_*`, `inverter_*`, and the rest # of the PSS/E-native block) have no PowerModels counterpart, so `_make_per_unit!` never # touches them: they arrive already in the natural unit the schema's `NATURAL_UNITS` # default expects (ohms, kV, radians, ...). `TwoTerminalGenericHVDCLine`/ @@ -58,18 +58,18 @@ function make_lcc_line!( set_value!(component, :active_power_flow, get(d, "pf", 0.0) * sys_mbase, "MW") set_value!(component, :parameter_units, "NATURAL_UNITS") set_value!(component, :r, d["r"], "ohm") - set_value!(component, :power_mode, Bool(d["power_mode"])) - if d["power_mode"] - transfer_setpoint_unit = "MW" - else - transfer_setpoint_unit = "A" + # `control_mode` (PSS/E MDC) selects which schedule the raw SETVL is: a power in MW + # under POWER, a current in amperes under CURRENT, and nothing under BLOCKED, where the + # line holds no schedule and both setpoints stay absent. Exhaustive over the enum. + control_mode = d["control_mode"] + set_value!(component, :control_mode, control_mode) + if control_mode == "POWER" + set_value!(component, :power_transfer_setpoint, d["transfer_setpoint"], "MW") + elseif control_mode == "CURRENT" + set_value!(component, :current_transfer_setpoint, d["transfer_setpoint"], "A") + elseif control_mode != "BLOCKED" + throw(IS.DataFormatError("DC line $name: unknown LCC control_mode $control_mode")) end - set_value!( - component, - :transfer_setpoint, - d["transfer_setpoint"], - transfer_setpoint_unit, - ) set_value!(component, :dc_voltage_units, "NATURAL_UNITS") set_value!(component, :scheduled_dc_voltage, d["scheduled_dc_voltage"], "kV") set_value!(component, :rectifier_bridges, Int(d["rectifier_bridges"])) @@ -179,16 +179,18 @@ convention as `data["dcline"]`'s native fields, so this maker multiplies them ba `dc_current`("if")/`max_dc_current_from`/`to`/`power_factor_weighting_fraction_from`/`to` are already natural (Amperes / a bare fraction) and pass through unscaled. -`dc_setpoint_from`/`to` is per-unit on `rated_dc_voltage` when the converter controls DC -voltage, or on `sys_mbase` when it controls DC power. +The pm dict's `dc_setpoint_from`/`to` is per-unit on `rated_dc_voltage` when the converter +controls DC voltage, or on `sys_mbase` when it controls DC power; each lands on the one wire +field its mode selects, `dc_voltage_setpoint_*` (pu) or `dc_power_setpoint_*` (MW), and the +other stays absent. Likewise `ac_setpoint_from`/`to` lands on `ac_voltage_setpoint_*` (pu) +under AC voltage control or `power_factor_setpoint_*` otherwise. `setpoint_voltage_units` (decoupled from `voltage_units`, which tags only `voltage_limits_from`/`to`) is set unconditionally to `COMPONENT_BASE`: PSS/E always reports a voltage-controlling side's DC setpoint as p.u. of `rated_dc_voltage` (`psse.jl` pre-divides `DCSET` by `base_voltage`) and a voltage-controlling AC setpoint (`ACSET`) as p.u. of the AC -bus's own base voltage — never kV. The `DC_POWER`/`AC_REACTIVE_POWER` branches have their own -fixed units (`MW`/`1`) and ignore this discriminator, so setting it unconditionally is safe -regardless of which sides actually control voltage. +bus's own base voltage — never kV. It governs only `dc_voltage_setpoint_*` and +`ac_voltage_setpoint_*`; the power and power-factor setpoints carry their own fixed units. `psse.jl` also captures each converter's own AC bus base kV as `base_voltage_from`/ `base_voltage_to`, threaded onto the document as `rated_ac_voltage_from`/ @@ -225,17 +227,19 @@ function make_vscline!( set_value!(component, :setpoint_voltage_units, "COMPONENT_BASE") if d["dc_voltage_control_from"] set_value!(component, :dc_control_from, "DC_VOLTAGE") - set_value!(component, :dc_setpoint_from, d["dc_setpoint_from"], "pu") + set_value!(component, :dc_voltage_setpoint_from, d["dc_setpoint_from"], "pu") else set_value!(component, :dc_control_from, "DC_POWER") - set_value!(component, :dc_setpoint_from, d["dc_setpoint_from"] * sys_mbase, "MW") + set_value!( + component, :dc_power_setpoint_from, d["dc_setpoint_from"] * sys_mbase, "MW", + ) end if d["ac_voltage_control_from"] set_value!(component, :ac_control_from, "AC_VOLTAGE") - set_value!(component, :ac_setpoint_from, d["ac_setpoint_from"], "pu") + set_value!(component, :ac_voltage_setpoint_from, d["ac_setpoint_from"], "pu") else set_value!(component, :ac_control_from, "AC_REACTIVE_POWER") - set_value!(component, :ac_setpoint_from, d["ac_setpoint_from"], "1") + set_value!(component, :power_factor_setpoint_from, d["ac_setpoint_from"], "1") end set_value!(component, :rated_ac_voltage_from, d["base_voltage_from"], "kV") set_value!( @@ -257,17 +261,19 @@ function make_vscline!( set_value!(component, :reactive_power_to, get(d, "qt", 0.0) * sys_mbase, "MVAr") if d["dc_voltage_control_to"] set_value!(component, :dc_control_to, "DC_VOLTAGE") - set_value!(component, :dc_setpoint_to, d["dc_setpoint_to"], "pu") + set_value!(component, :dc_voltage_setpoint_to, d["dc_setpoint_to"], "pu") else set_value!(component, :dc_control_to, "DC_POWER") - set_value!(component, :dc_setpoint_to, d["dc_setpoint_to"] * sys_mbase, "MW") + set_value!( + component, :dc_power_setpoint_to, d["dc_setpoint_to"] * sys_mbase, "MW", + ) end if d["ac_voltage_control_to"] set_value!(component, :ac_control_to, "AC_VOLTAGE") - set_value!(component, :ac_setpoint_to, d["ac_setpoint_to"], "pu") + set_value!(component, :ac_voltage_setpoint_to, d["ac_setpoint_to"], "pu") else set_value!(component, :ac_control_to, "AC_REACTIVE_POWER") - set_value!(component, :ac_setpoint_to, d["ac_setpoint_to"], "1") + set_value!(component, :power_factor_setpoint_to, d["ac_setpoint_to"], "1") end set_value!(component, :rated_ac_voltage_to, d["base_voltage_to"], "kV") set_value!( diff --git a/src/openapi/device_base.jl b/src/openapi/device_base.jl index 3f472e2..104f57b 100644 --- a/src/openapi/device_base.jl +++ b/src/openapi/device_base.jl @@ -47,7 +47,7 @@ # # A field whose Type-level `declared_unit`/`declared_quantity` throws depends on a runtime # discriminator sibling (`parameter_units`, `admittance_units`, `energy_units`, -# `voltage_setpoint_units`, `dc_voltage_units`, `power_mode`, ...). That discriminator is +# `voltage_setpoint_units`, `dc_voltage_units`, `setpoint_voltage_units`, ...). That discriminator is # used for two semantically different things in this schema, and conflating them is a bug: # a single-bucket "instance-dispatched => skip" rule leaves # `EnergyReservoirStorage.storage_capacity` unconverted and unflagged. @@ -61,15 +61,16 @@ # and PowerSystems' own converters confirm it is identical between `ComponentBaseUnit`/ # `NaturalUnit` in every case checked. These are `:skip`. # 2. A **natural-unit choice** among sibling units of the SAME quantity -# (`EnergyReservoirStorage.energy_units`: "MWH" vs "MWMIN", both genuine energy units) -# or, in one case, a **quantity switch** between two physically different quantities -# (`TwoTerminalLCCLine.power_mode` selects `transfer_setpoint`'s unit between MW -# (`ActivePower`) and A (`CurrentFlow`)). Neither is a pu-vs-natural switch, so neither -# is exempt from document-level conversion on that basis. PowerSystems' own converter -# divides `storage_capacity` by device `base_power` exactly like every other -# `:mva`-tagged field regardless of which (implemented) `energy_units` branch is active -# (`export_handwritten.jl`'s `EnergyReservoirStorage` section) — these are -# `:convert_own` (or, for the quantity-switch case, resolved dynamically per component). +# (`EnergyReservoirStorage.energy_units`: "MWH" vs "MWMIN", both genuine energy units). +# This is not a pu-vs-natural switch, so it is not exempt from document-level +# conversion on that basis. PowerSystems' own converter divides `storage_capacity` by +# device `base_power` exactly like every other `:mva`-tagged field regardless of which +# (implemented) `energy_units` branch is active (`export_handwritten.jl`'s +# `EnergyReservoirStorage` section) — these are `:convert_own`. +# +# No field's quantity switches with a modeling enum any more: every such field was split +# into one field per quantity, each with a fixed unit, so the fixed-unit path classifies +# them and no per-component resolution exists. # # `_DEVICEBASE_INSTANCE_DISPATCHED` is the explicit registry every instance-dispatched # `(key, prop)` this package's readers can produce must appear in, classified as one of the @@ -111,25 +112,15 @@ const _DEVICEBASE_INSTANCE_DISPATCHED = Dict{Tuple{String, Symbol}, Symbol}( ("TwoWindingTransformer", :magnetizing_shunt) => :skip, ("ThreeWindingTransformer", :magnetizing_shunt) => :skip, ("FACTSControlDevice", :voltage_setpoint) => :skip, - # control_objective governs both of TransformerCircuit's own control fields. - # `control_limits` resolves to Dimensionless ("1") or Angle ("rad") on EVERY - # control_objective branch (checked against every enum value in the schema, not just - # this fixture's "FIXED") -- never power-family, so a static verdict is correct - # regardless of which branch a future producer hits. - ("TransformerCircuit", :control_limits) => :skip, - # `controlled_quantity_limits` DOES switch schema quantity with control_objective - # (Voltage/pu for VOLTAGE-family objectives, MW/MVAr for ACTIVE_POWER_FLOW/ - # REACTIVE_POWER_FLOW/CONTROL_OF_DC_LINE-family objectives) -- but PowerSystems' own - # to_openapi calls the SAME unscaled `_minmax_po(get_controlled_quantity_limits(circuit))` - # in BOTH the ComponentBaseUnit and NaturalUnit methods (export_handwritten.jl:166-167 and - # :195-196) -- i.e. PSY never scales this field by base_power regardless of document - # convention OR control_objective. A first cut of this registry made it `:dynamic` - # (converting the power-flow-family branches) purely from the schema's declared - # quantity, without checking PSY's actual CU/NU pair -- wrong, and invisible on the - # 14-bus fixture because every circuit there is control_objective = "FIXED" (a - # VOLTAGE-family, already-`:skip` branch either way). Static `:skip`, matching - # `control_limits`. - ("TransformerCircuit", :controlled_quantity_limits) => :skip, + # setpoint_voltage_units always "COMPONENT_BASE" (dc_branch.jl): the voltage setpoints + # are pu of the converter's own rated voltage in both document conventions. + # `TransformerCircuit`'s five control bands each carry a fixed unit and take the + # fixed-unit path: the tap, angle and voltage bands are non-power and skip, the MW and + # MVAr bands convert on the circuit's own base like `active_power_flow`. + ("TwoTerminalVSCLine", :dc_voltage_setpoint_from) => :skip, + ("TwoTerminalVSCLine", :dc_voltage_setpoint_to) => :skip, + ("TwoTerminalVSCLine", :ac_voltage_setpoint_from) => :skip, + ("TwoTerminalVSCLine", :ac_voltage_setpoint_to) => :skip, # admittance_units always "COMPONENT_MVAR" (shunt.jl) -- PowerSystems' own to_openapi # confirms this is fixed-natural, multiplied by the SYSTEM base in both document # conventions (export_handwritten.jl's FixedAdmittance section), not document-unit- @@ -137,7 +128,6 @@ const _DEVICEBASE_INSTANCE_DISPATCHED = Dict{Tuple{String, Symbol}, Symbol}( # `FixedAdmittance.Y` is lowercase `y` now (the JSON key stays `Y`). ("FixedAdmittance", :y) => :skip, ("SwitchedAdmittance", :y_increase) => :skip, - ("SwitchedAdmittance", :admittance_limits) => :skip, # BINIT (PowerSystems.jl#1774) is the same COMPONENT_MVAR-on-system-base quantity as `Y` # and `Y_increase` above -- PSY's own to_openapi scales it by the SYSTEM base in both # document conventions -- so it takes their classification, not a device-base conversion. @@ -164,17 +154,6 @@ const _DEVICEBASE_INSTANCE_DISPATCHED = Dict{Tuple{String, Symbol}, Symbol}( # converter divides storage_capacity by device base_power regardless of which # (implemented) branch is active. ("EnergyReservoirStorage", :storage_capacity) => :convert_own, - # power_mode selects between two DIFFERENT PHYSICAL QUANTITIES (ActivePower vs - # CurrentFlow), not two representations of the same one -- resolved per component from - # the instance-level quantity, not statically here. - # - # SETTLED (design decision, 2026-08-08): Sienna models LCC only as this two-terminal - # HVDC line type -- there is no standalone LCC converter model -- so the field follows - # the two-terminal HVDC family convention, like its own sibling power fields - # (`active_power_flow`, `active_power_limits_from/to`) and the generic type's PSY - # converter. PSY has no `TwoTerminalLCCLine` converter yet (`openapi_type: null`); - # when one is written it must match this convention. - ("TwoTerminalLCCLine", :transfer_setpoint) => :dynamic, ) """Whether `T.prop`'s declared unit is fixed — resolvable from the Type alone, rather than @@ -204,57 +183,13 @@ function _devicebase_instance_dispatched(key::AbstractString, prop::Symbol) error( "COMPONENT_BASE conversion: $key.$prop has an instance-level unit discriminator " * "not accounted for in _DEVICEBASE_INSTANCE_DISPATCHED — classify it as " * - ":convert_own, :skip, or :dynamic (see device_base.jl's header) before " * + ":convert_own or :skip (see device_base.jl's header) before " * "building a COMPONENT_BASE document containing this type", ) end return verdict end -""" -Per-`(key, prop)` map from a `:dynamic` field's resolved instance-level *quantity* to its -verdict — every quantity the field's discriminator can ever produce must be listed -(checked against every enum value in the schema, not just what a given fixture exercises), -or [`_devicebase_dynamic`](@ref) errors naming the unexpected quantity rather than guessing. - - - `TwoTerminalLCCLine.transfer_setpoint` (`power_mode`): `ActivePower` (MW, converts like - every sibling power field) or `CurrentFlow` (A — no power-base conversion is defined for - a current quantity anywhere in this schema). Settled per the registry entry above. - -`TransformerCircuit.controlled_quantity_limits` was the one other candidate for this table -(its schema quantity does switch with `control_objective`) but is `:skip` in -`_DEVICEBASE_INSTANCE_DISPATCHED` instead, not `:dynamic` here — PowerSystems' own -`to_openapi` never scales it regardless of `control_objective` (see that registry entry's -comment), so there is no quantity-dependent verdict to look up. -""" -const _DEVICEBASE_DYNAMIC_QUANTITIES = Dict{Tuple{String, Symbol}, Dict{String, Symbol}}( - ("TwoTerminalLCCLine", :transfer_setpoint) => Dict( - "ActivePower" => :convert_own, - "CurrentFlow" => :skip, - ), -) - -"""Resolve a `:dynamic` verdict for one component `po`, from its own instance-level -quantity, via `_DEVICEBASE_DYNAMIC_QUANTITIES`.""" -function _devicebase_dynamic(key::AbstractString, prop::Symbol, po) - quantities = get(_DEVICEBASE_DYNAMIC_QUANTITIES, (key, prop), nothing) - if quantities === nothing - error( - "COMPONENT_BASE conversion: $key.$prop is registered :dynamic with no entry in " * - "_DEVICEBASE_DYNAMIC_QUANTITIES", - ) - end - quantity = IC.declared_quantity(po, Val(prop)) - verdict = get(quantities, quantity, nothing) - if verdict === nothing - error( - "COMPONENT_BASE conversion: $key.$prop resolved quantity \"$quantity\", not " * - "accounted for in _DEVICEBASE_DYNAMIC_QUANTITIES[($key, :$prop)]", - ) - end - return verdict -end - """A copy of `o` with `power_units` set to `power_units` and every other field held exactly as-is — probes a field's instance dispatch without mutating the real component (see [`_power_units_only_dispatch`](@ref)).""" @@ -283,7 +218,7 @@ under BOTH valid values is the closest still-available signal: every ordinary po field across the 32 power-bearing types resolves this way today (confirmed against the current schema, and unaffected by this change — `power_units` staying required only removes the invalid-sentinel probe, not the underlying dispatch). A field genuinely gated -by another discriminator (`power_mode`, `parameter_units`, `dc_control_from`, ...) instead +by another discriminator (`setpoint_voltage_units`, `parameter_units`, ...) instead must go through the explicit `_DEVICEBASE_INSTANCE_DISPATCHED` registry and its loud error on a miss, never through this fallback — and since that registry is checked first (`_devicebase_classification`'s `!haskey(...) && ...`), this function is only ever reached @@ -308,8 +243,7 @@ Classify `key.prop` (`representative`: any one instance of `key` — every compo given type carries the same run-wide `power_units`, stamped uniformly by [`add_component!`](@ref)) for the COMPONENT_BASE pass: `:convert_own` (divide by the component's own `base_power`), `:convert_system` (divide by the document's system base), -`:dynamic` (resolved per component by [`_devicebase_dynamic`](@ref)), or `:skip`. See this -file's header for the full rule. +or `:skip`. See this file's header for the full rule. """ function _devicebase_classification( representative::T, @@ -411,16 +345,7 @@ function _devicebase_rebuild( kwargs[prop] = getfield(po, prop) continue end - resolved = if classification === :dynamic - _devicebase_dynamic(key, prop, po) - else - classification - end - if resolved === :skip - kwargs[prop] = getfield(po, prop) - continue - end - base = if resolved === :convert_system + base = if classification === :convert_system system_base else _devicebase_own_base(po, key, prop) diff --git a/src/openapi/shunt.jl b/src/openapi/shunt.jl index 9e805ba..54bb48f 100644 --- a/src/openapi/shunt.jl +++ b/src/openapi/shunt.jl @@ -6,7 +6,7 @@ # value — the RAW's own GL/BL and BINIT/Bi. # # Every other field this file writes is outside that rescale and is used exactly as PFFP's -# own psse.jl parser wrote it: `switched_shunt`'s `admittance_limits` (a voltage band, see +# own psse.jl parser wrote it: `switched_shunt`'s VSWLO/VSWHI band (see # `make_switched_admittance!`) and all of `facts`. """Fixed admittance (PSS/E `FIXED SHUNT`).""" @@ -47,8 +47,26 @@ const SWITCHED_ADMITTANCE_CONTROL_MODE_NAMES = Dict( 3 => "DISCRETE_REACTIVE_PLANT", 4 => "DISCRETE_REACTIVE_VSC", 5 => "DISCRETE_ADMITTANCE_REMOTE", + 6 => "DISCRETE_REACTIVE_FACTS", ) +"""The band field a switched-shunt control mode selects for PSS/E's VSWLO/VSWHI pair: +`voltage_limits` under the voltage modes, `reactive_power_range_limits` under the reactive and +remote-admittance modes, and no band under `UNDEFINED`/`FIXED`. Exhaustive over the enum.""" +function _switched_admittance_band_field(control_mode::AbstractString) + if control_mode == "UNDEFINED" || control_mode == "FIXED" + return nothing + elseif control_mode == "DISCRETE_VOLTAGE" || control_mode == "CONTINUOUS_VOLTAGE" + return :voltage_limits + elseif control_mode in ( + "DISCRETE_REACTIVE_PLANT", "DISCRETE_REACTIVE_VSC", + "DISCRETE_ADMITTANCE_REMOTE", "DISCRETE_REACTIVE_FACTS", + ) + return :reactive_power_range_limits + end + throw(IS.DataFormatError("unhandled switched shunt control_mode $control_mode")) +end + function _switched_admittance_control_mode(code::Integer) if !haskey(SWITCHED_ADMITTANCE_CONTROL_MODE_NAMES, code) throw(IS.DataFormatError("unsupported switched shunt MODSW control mode=$code")) @@ -82,11 +100,13 @@ end """ Switched admittance (PSS/E `SWITCHED SHUNT`). -`admittance_limits` mirrors PSCB's own field verbatim: PSS/E's `VSWLO`/`VSWHI` are a -controlled-voltage band, not an admittance band, despite the oracle's field name — a -pre-existing PSCB naming quirk reproduced faithfully, not fixed here. Being voltages, they -are outside `_make_per_unit!`'s admittance rescale and take no `base_power` factor, unlike -`solved_admittance` and `y_increase`. +PSS/E's `VSWLO`/`VSWHI` (the pm dict's `admittance_limits` pair, a name kept from the +oracle) is one band whose meaning follows `MODSW`: a controlled-voltage band under the +voltage modes, written to `voltage_limits` in pu, and under the reactive and +remote-admittance modes a fraction of the regulated device's reactive range, written to +`reactive_power_range_limits` as a dimensionless band. Under `FIXED`/`UNDEFINED` the columns +are inert and no band is written. Neither band is inside `_make_per_unit!`'s admittance +rescale, so no `base_power` factor applies, unlike `solved_admittance` and `y_increase`. The schema dropped `SwitchedAdmittance`'s own fixed `Y` field (the total admittance is now `number_engaged` * `y_increase`, unless `solved_admittance` overrides it): `d["bs"]` @@ -113,10 +133,13 @@ function make_switched_admittance!( set_value!(component, :admittance_units, "COMPONENT_MVAR") set_value!(component, :number_of_steps, d["step_number"]) _set_y_increase!(component, d["y_increment"] * base_power, "MVAr") - admittance_limits = d["admittance_limits"] - set_value!(component, :admittance_limits, - (min = admittance_limits[1], max = admittance_limits[2]), "MVAr") set_value!(component, :control_mode, control_mode) + band = _switched_admittance_band_field(control_mode) + if !isnothing(band) + lo, hi = d["admittance_limits"] + unit = band == :voltage_limits ? "pu" : "1" + set_value!(component, band, (min = lo, max = hi), unit) + end set_value!( component, :regulated_bus_number, diff --git a/src/openapi/units.jl b/src/openapi/units.jl index d804de4..30ba8fb 100644 --- a/src/openapi/units.jl +++ b/src/openapi/units.jl @@ -390,6 +390,31 @@ function set_value!( return end +""" +Assign a curve-valued property whose declared unit is the unit of the curve's x axis +(`ImpedanceCorrectionData`'s correction curves): every x converts from `source_unit` to the +declared unit, the y axis is a dimensionless multiplier and passes through unchanged. +""" +function set_value!( + s::Staged{T}, + prop::Symbol, + value::IC.PiecewiseLinearData, + source_unit::AbstractString, +) where {T} + target, quantity = _declared(s, prop) + points = [ + IC.XYCoords(; + x = _convert(s, prop, Float64(point.x), source_unit, target, quantity), + y = point.y, + ) for point in value.points + ] + s.fields[prop] = IC.PiecewiseLinearData(; + function_type = value.function_type, + points = points, + ) + return +end + """ Reject a unit supplied for something that cannot carry one. diff --git a/src/pm_io/psse.jl b/src/pm_io/psse.jl index 704c08f..51ebc26 100644 --- a/src/pm_io/psse.jl +++ b/src/pm_io/psse.jl @@ -1450,6 +1450,8 @@ function _psse2pm_transformer!(pm_data::Dict, pti_data::Dict, import_all::Bool, _tap_ratio_limits(transformer, 1, tap_scale) sub_data["VMA1"] = transformer["VMA1"] sub_data["VMI1"] = transformer["VMI1"] + sub_data["RM_PRESENT1"] = transformer["RM_PRESENT1"] + sub_data["VM_PRESENT1"] = transformer["VM_PRESENT1"] sub_data["NTP1"] = transformer["NTP1"] if import_all _import_remaining_keys!( @@ -1880,6 +1882,8 @@ function _psse2pm_transformer!(pm_data::Dict, pti_data::Dict, import_all::Bool, _tap_ratio_limits(transformer, i, turns_ratio_scales[i]) sub_data["VMA$i"] = transformer["VMA$i"] sub_data["VMI$i"] = transformer["VMI$i"] + sub_data["RM_PRESENT$i"] = transformer["RM_PRESENT$i"] + sub_data["VM_PRESENT$i"] = transformer["VM_PRESENT$i"] sub_data["NTP$i"] = transformer["NTP$i"] end @@ -1937,6 +1941,21 @@ function _psse2pm_transformer!(pm_data::Dict, pti_data::Dict, import_all::Bool, return end +"""PSS(R)E two-terminal DC `MDC` control-mode codes, in the schema's `LCCControlMode` +spelling: 0 blocked, 1 power, 2 current. Exhaustive on purpose: an unknown code errors +rather than landing in a default mode, the same posture as +`_switched_admittance_control_mode` for switched shunts.""" +function _lcc_control_mode(code::Integer, name) + code == 0 && return "BLOCKED" + code == 1 && return "POWER" + code == 2 && return "CURRENT" + throw( + DataFormatError( + "DC line $name: unsupported two-terminal DC MDC control mode=$code", + ), + ) +end + """ DC voltage base and scheduled flow of a VSC line, taken from the converter that controls DC voltage (TYPE = 1). PSS/E keeps out-of-service lines (MDC = 0, or a converter with @@ -1982,6 +2001,7 @@ PSS(R)E Two-Terminal DC specification. For Voltage Source Converters, "source_id is given by `["IBUS1", "IBUS2", "NAME"]`, where "IBUS1" is "IBUS" of the first converter bus, and "IBUS2" is the "IBUS" of the second converter bus, in the PSS(R)E Voltage Source Converter specification. + """ function _psse2pm_dcline!(pm_data::Dict, pti_data::Dict, import_all::Bool) @info "Parsing PSS(R)E Two-Terminal and VSC DC line data into a PowerModels Dict..." @@ -2022,11 +2042,10 @@ function _psse2pm_dcline!(pm_data::Dict, pti_data::Dict, import_all::Bool) push!(pm_data["connected_buses"], sub_data["t_bus"]) end - if dcline["MDC"] == 1 - sub_data["power_mode"] = true - else - sub_data["power_mode"] = false - end + # `MDC` is three-valued; a blocked line (0) is neither power- nor + # current-controlled, so it is carried as its own mode rather than folded into + # `available = false` alone. + sub_data["control_mode"] = _lcc_control_mode(dcline["MDC"], sub_data["name"]) sub_data["available"] = dcline["MDC"] != 0 sub_data["br_status"] = sub_data["available"] diff --git a/src/pm_io/pti.jl b/src/pm_io/pti.jl index e74c2b4..ecb05de 100644 --- a/src/pm_io/pti.jl +++ b/src/pm_io/pti.jl @@ -2001,6 +2001,26 @@ function _parse_line_element!( @debug "The following fields in $section are missing: $missing_str" end end + _record_control_band_presence!(data, section, dtypes) +end + +# A transformer winding line carries its control bands as RMI/RMA (actuator) and VMI/VMA +# (target). `_populate_defaults!` later replaces every "" with the PSS(R)E default, after +# which a band the file omitted is indistinguishable from one it stated. That distinction +# decides whether a consumer may write the band at all: under a non-voltage COD the default +# 0.9-1.1 is a voltage band and must not be recorded as MVAr or MW. So presence is captured +# here, while "" still means absent, as `RM_PRESENT`/`VM_PRESENT` per winding. +const _CONTROL_BAND_FLAGS = (("RM_PRESENT", "RMI", "RMA"), ("VM_PRESENT", "VMI", "VMA")) + +function _record_control_band_presence!(data::Dict, section::AbstractString, dtypes::Dict) + startswith(section, "TRANSFORMER ") || return + names = Set(first(entry) for entry in dtypes[section]) + for k in 1:3, (flag, lo, hi) in _CONTROL_BAND_FLAGS + lo_k, hi_k = "$lo$k", "$hi$k" + (lo_k in names && hi_k in names) || continue + data["$flag$k"] = data[lo_k] != "" && data[hi_k] != "" + end + return end const _comment_split = r"(?!\B[\'][^\']*)[\/](?![^\']*[\']\B)" diff --git a/test/fixtures/synthetic_v35_transformer_band_presence.raw b/test/fixtures/synthetic_v35_transformer_band_presence.raw new file mode 100644 index 0000000..928c5c1 --- /dev/null +++ b/test/fixtures/synthetic_v35_transformer_band_presence.raw @@ -0,0 +1,55 @@ +@!IC,SBASE,REV,XFRRAT,NXFRAT,BASFRQ +0, 100.00, 35, 0, 1, 60.00 +Synthetic fictional v35 case for transformer control-band presence +T1: COD1=2 (reactive-flow control) with the winding line truncated before RMA1/RMI1/VMA1/VMI1. T2: COD1=1 with all four stated explicitly. +0 / END OF SYSTEM-WIDE DATA, BEGIN BUS DATA +@! I,'NAME ', BASKV, IDE,AREA,ZONE,OWNER, VM, VA, NVHI, NVLO, EVHI, EVLO + 1,'BUSONE ', 200.0000,3, 1, 1, 1,1.00000, 0.0000,1.10000,0.90000,1.10000,0.90000 + 2,'BUSTWO ', 200.0000,2, 1, 1, 1,1.00000, 0.0000,1.10000,0.90000,1.10000,0.90000 + 3,'BUSTHREE ', 138.0000,1, 1, 1, 1,1.00000, 0.0000,1.10000,0.90000,1.10000,0.90000 + 201,'T1_HV ', 100.0000,3, 1, 1, 1,1.00000, 0.0000,1.10000,0.90000,1.10000,0.90000 + 202,'T1_LV ', 20.0000,1, 1, 1, 1,1.00000, 0.0000,1.10000,0.90000,1.10000,0.90000 + 203,'T2_HV ', 100.0000,3, 1, 1, 1,1.00000, 0.0000,1.10000,0.90000,1.10000,0.90000 + 204,'T2_LV ', 25.0000,1, 1, 1, 1,1.00000, 0.0000,1.10000,0.90000,1.10000,0.90000 +0 / END OF BUS DATA, BEGIN LOAD DATA + 3,'1 ', 1, 1, 1, 50.000, 10.000, 0.000, 0.000, 0.000, 0.000, 1, 1, 0, 0.000, 0.000, 0,' V' + 202,'1 ', 1, 1, 1, 5.000, 1.000, 0.000, 0.000, 0.000, 0.000, 1, 1, 0, 0.000, 0.000, 0,' V' + 204,'1 ', 1, 1, 1, 4.000, 0.800, 0.000, 0.000, 0.000, 0.000, 1, 1, 0, 0.000, 0.000, 0,' V' +0 / END OF LOAD DATA, BEGIN FIXED SHUNT DATA +0 / END OF FIXED SHUNT DATA, BEGIN GENERATOR DATA + 1,'1 ', 80.000, 0.000, 80.000, -80.000,1.00000, 0, 0, 100.000, 0.00000E+0, 1.00000E-1, 0.00000E+0, 0.00000E+0,1.00000,1, 100.0, 120.000, 0.000, 0,1,1.0000 + 2,'1 ', 30.000, 0.000, 50.000, -50.000,1.00000, 0, 0, 100.000, 0.00000E+0, 1.00000E-1, 0.00000E+0, 0.00000E+0,1.00000,1, 100.0, 120.000, 0.000, 0,1,1.0000 + 201,'1 ', 10.000, 0.000, 20.000, -20.000,1.00000, 0, 0, 100.000, 0.00000E+0, 1.00000E-1, 0.00000E+0, 0.00000E+0,1.00000,1, 100.0, 120.000, 0.000, 0,1,1.0000 + 203,'1 ', 10.000, 0.000, 20.000, -20.000,1.00000, 0, 0, 100.000, 0.00000E+0, 1.00000E-1, 0.00000E+0, 0.00000E+0,1.00000,1, 100.0, 120.000, 0.000, 0,1,1.0000 +0 / END OF GENERATOR DATA, BEGIN BRANCH DATA + 1, 2,'1 ', 1.00000E-02, 1.00000E-01,0.02000,'BRANCH_1_2 ', 500.00, 500.00, 500.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00000, 0.00000, 0.00000, 0.00000,1,1, 1.00, 1,1.0000 + 2, 3,'1 ', 1.00000E-02, 1.00000E-01,0.02000,'BRANCH_2_3 ', 500.00, 500.00, 500.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00, 0.00000, 0.00000, 0.00000, 0.00000,1,1, 1.00, 1,1.0000 +0 / END OF BRANCH DATA, BEGIN SYSTEM SWITCHING DEVICE DATA +0 / END OF SYSTEM SWITCHING DEVICE DATA, BEGIN TRANSFORMER DATA +201, 202, 0, '1 ', 2, 2, 1, 0.001, 0.002, 2, 'XFMR_T1 ', 1, 1, 1.0, 0, 1.0, 0, 1.0, 0, 1.0, ' ' +0.01, 0.05, 50.0 +105.0, 0.0, 0.0, 40.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 2, 202, 0 +21.0, 0.0 +203, 204, 0, '1 ', 3, 3, 2, 80000.0, 0.005, 2, 'XFMR_T2 ', 1, 1, 1.0, 0, 1.0, 0, 1.0, 0, 1.0, ' ' +800.0, 0.05, 80.0 +1.0, 0.0, 0.0, 64.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1, 204, 0, 1.05, 0.95, 1.04, 0.96, 33, 0, 0.0, 0.0, 0.0 +1.02, 26.0 +0 / END OF TRANSFORMER DATA, BEGIN AREA DATA +0 / END OF AREA DATA, BEGIN TWO-TERMINAL DC DATA +"DCTEST2 ",2, 5.0000, 200.00, 400.00, 0.0000, 0.00000, 0.00000,'I', 0.00, 20, 1.00000 + 1, 1, 15.000, 10.000, 0.0000, 5.0000, 200.00,1.00000,1.00000,1.10000,0.90000,0.00625, 0, 0, 0, 0,'1 ', 0.0000 + 2, 1, 15.000, 10.000, 0.0000, 5.0000, 200.00,1.00000,1.00000,1.10000,0.90000,0.00625, 0, 0, 0, 0,'1 ', 0.0000 +0 / END OF TWO-TERMINAL DC DATA, BEGIN VSC DC LINE DATA +0 / END OF VSC DC LINE DATA, BEGIN IMPEDANCE CORRECTION DATA +0 / END OF IMPEDANCE CORRECTION DATA, BEGIN MULTI-TERMINAL DC DATA +0 / END OF MULTI-TERMINAL DC DATA, BEGIN MULTI-SECTION LINE DATA +0 / END OF MULTI-SECTION LINE DATA, BEGIN ZONE DATA +0 / END OF ZONE DATA, BEGIN INTER-AREA TRANSFER DATA +0 / END OF INTER-AREA TRANSFER DATA, BEGIN OWNER DATA +0 / END OF OWNER DATA, BEGIN FACTS DEVICE DATA +0 / END OF FACTS DEVICE DATA, BEGIN SWITCHED SHUNT DATA +0 / END OF SWITCHED SHUNT DATA, BEGIN GNE DATA +0 / END OF GNE DATA, BEGIN INDUCTION MACHINE DATA +0 / END OF INDUCTION MACHINE DATA, BEGIN SUBSTATION DATA +0 / END OF SUBSTATION DATA +Q diff --git a/test/test_openapi_attributes.jl b/test/test_openapi_attributes.jl index 61e4ce2..f6af107 100644 --- a/test/test_openapi_attributes.jl +++ b/test/test_openapi_attributes.jl @@ -85,11 +85,14 @@ end PFP.get_value(ict, :transformer_winding) == "TR2W_WINDING" ) @test PFP.get_value(ict, :transformer_control_mode) == "PHASE_SHIFT_ANGLE" - curve = PFP.get_value(ict, :impedance_correction_curve) + # A phase-shift table lands on phase_angle_correction_curve, its x axis converted + # from PSS/E's degrees to the schema's radians; the tap-ratio curve stays absent. + @test PFP.get_value(ict, :tap_ratio_correction_curve) === PFP.ABSENT + curve = PFP.get_value(ict, :phase_angle_correction_curve) points = PFP.get_value(curve, :points) @test length(points) == length(x) for (i, point) in enumerate(points) - @test PFP.get_value(point, :x) == x[i] + @test PFP.get_value(point, :x) ≈ deg2rad(x[i]) @test PFP.get_value(point, :y) == y[i] end diff --git a/test/test_openapi_branch.jl b/test/test_openapi_branch.jl index 04c5d9c..228fb57 100644 --- a/test/test_openapi_branch.jl +++ b/test/test_openapi_branch.jl @@ -123,16 +123,20 @@ end @test PFP.get_value(circuit, :rating) ≈ d["rate_a"] * d["base_power"] @test PFP.get_value(circuit, :active_power_flow) == 0.0 @test PFP.get_value(circuit, :reactive_power_flow) == 0.0 - # COD1 = 0 => FIXED; RMI1/RMA1/VMI1/VMA1 are the schema defaults, present verbatim. + # COD1 = 0 => FIXED selects the tap band and the voltage band; the other three bands + # stay absent. RMI1/RMA1/VMI1/VMA1 are the schema defaults, present verbatim. @test PFP.get_value(circuit, :control_objective) == "FIXED" @test _matches_nt( - PFP.get_value(circuit, :control_limits), + PFP.get_value(circuit, :tap_ratio_limits), (min = d["RMI1"], max = d["RMA1"]), ) @test _matches_nt( - PFP.get_value(circuit, :controlled_quantity_limits), + PFP.get_value(circuit, :controlled_voltage_limits), (min = d["VMI1"], max = d["VMA1"]), ) + @test PFP.get_value(circuit, :phase_angle_limits) isa PFP.IC.Absent + @test PFP.get_value(circuit, :controlled_reactive_power_flow_limits) isa PFP.IC.Absent + @test PFP.get_value(circuit, :controlled_active_power_flow_limits) isa PFP.IC.Absent @test PFP.get_value(circuit, :number_of_tap_positions) == Int(d["NTP1"]) transformer = _two_winding_transformer_for(sys, circuit) @@ -307,46 +311,53 @@ end @test !isnothing(_two_winding_transformer_for(sys, circuit)) end -@testset "TransformerCircuit.controlled_quantity_limits passes through UNSCALED under COMPONENT_BASE for a power-flow-family control_objective" begin - # Regression: `controlled_quantity_limits`'s schema quantity DOES switch with - # `control_objective` (pu for VOLTAGE-family objectives, MW/MVAr for ACTIVE_POWER_FLOW/ - # REACTIVE_POWER_FLOW/CONTROL_OF_DC_LINE-family ones), which made a first cut of the - # COMPONENT_BASE registry classify it `:dynamic` (converting the power-flow-family - # branches by the circuit's own base_power). That was wrong: PowerSystems' own - # `to_openapi` calls the SAME unscaled `_minmax_po(get_controlled_quantity_limits(...))` - # in BOTH `ComponentBaseUnit` and `NaturalUnit` (export_handwritten.jl:166-167, :195-196) — - # this field never scales with the document convention, regardless of - # `control_objective`. Invisible on the 14-bus fixture because every circuit there is - # `control_objective = "FIXED"` (already `:skip` either way) — this test uses - # `COD1 = 3` ("ACTIVE_POWER_FLOW", MW-declared) specifically to exercise the branch - # the bug was in. - # - # base_power = 50, sys_mbase = 100 (base_conversion-sensitive, same discipline as the - # storage/generator COMPONENT_BASE tests): active_power_flow/reactive_power_flow DO - # convert (10.0/50.0 = 0.2, 5.0/50.0 = 0.1) so this test also proves the fix did not - # collaterally stop scaling this circuit's other power fields. controlled_quantity_limits - # must come out exactly (50.0, 150.0) -- if it were wrongly divided by base_power = 50 - # it would read (1.0, 3.0) instead, a clearly different and wrong number. - sys = PFP.OpenAPISystem(100.0; power_units = "COMPONENT_BASE") +function _control_test_circuit(d::Dict; power_units = "COMPONENT_BASE") + sys = PFP.OpenAPISystem(100.0; power_units = power_units) reg = PFP.get_registry(sys) from_id = _register_bus!(sys, 1, "b1") to_id = _register_bus!(sys, 2, "b2") - d = Dict{String, Any}( - "tap" => 1.0, "shift" => 0.0, - "COD1" => 3, "RMI1" => -10.0, "RMA1" => 10.0, "VMI1" => 50.0, "VMA1" => 150.0, - ) + merged = merge(Dict{String, Any}("tap" => 1.0, "shift" => 0.0), d) PFP._make_transformer_circuit!( - sys, reg, d, from_id, to_id, "test_xfmr"; + sys, reg, merged, from_id, to_id, "test_xfmr"; tap_key = "tap", angle_key = "shift", control_suffix = 1, available = true, r = 0.01, x = 0.05, rating = 100.0, rating_b = nothing, rating_c = nothing, base_power = 50.0, base_voltage_primary = 100.0, base_voltage_secondary = 100.0, active_power_flow = 10.0, reactive_power_flow = 5.0, ) PFP.apply_device_base_conversion!(sys) - circuit = only(PFP.get_components(sys, "TransformerCircuit")) + return only(PFP.get_components(sys, "TransformerCircuit")) +end + +const _BAND_FIELDS = ( + :tap_ratio_limits, :phase_angle_limits, :controlled_voltage_limits, + :controlled_reactive_power_flow_limits, :controlled_active_power_flow_limits, +) +_absent_bands(circuit) = + Tuple(f for f in _BAND_FIELDS if PFP.get_value(circuit, f) isa PFP.IC.Absent) + +@testset "TransformerCircuit power-flow target band converts by the circuit's own base under COMPONENT_BASE" begin + # COD1 = 3 (ACTIVE_POWER_FLOW) selects the angle band, written in radians, and the + # MW band. The MW band is a power field on the circuit base (50 MVA here, not the + # 100 MVA system base), so under COMPONENT_BASE it divides like `active_power_flow` + # does: 50/50 = 1.0, 150/50 = 3.0. Under the old single polymorphic field this band + # was never converted; the split is what lets it carry a unit of its own. + circuit = _control_test_circuit( + Dict{String, Any}( + "COD1" => 3, "RMI1" => -10.0, "RMA1" => 10.0, "VMI1" => 50.0, + "VMA1" => 150.0, + ), + ) @test PFP.get_value(circuit, :control_objective) == "ACTIVE_POWER_FLOW" - @test PFP.get_value(circuit, :controlled_quantity_limits).min == 50.0 - @test PFP.get_value(circuit, :controlled_quantity_limits).max == 150.0 + @test PFP.get_value(circuit, :phase_angle_limits).min ≈ deg2rad(-10.0) + @test PFP.get_value(circuit, :phase_angle_limits).max ≈ deg2rad(10.0) + @test PFP.get_value(circuit, :controlled_active_power_flow_limits).min == 1.0 + @test PFP.get_value(circuit, :controlled_active_power_flow_limits).max == 3.0 + @test _absent_bands(circuit) == + ( + :tap_ratio_limits, + :controlled_voltage_limits, + :controlled_reactive_power_flow_limits, + ) @test PFP.get_value(circuit, :active_power_flow) ≈ 0.2 @test PFP.get_value(circuit, :reactive_power_flow) ≈ 0.1 @test PFP.get_value(circuit, :base_power) == 50.0 @@ -367,3 +378,62 @@ end @test PFP.get_value(kept, :rating) == 40.0 @test length(PFP.get_components(sys, "Line")) == 2 end + +@testset "TransformerCircuit bands follow the presence flags: defaults only where 0.9-1.1 is a real default" begin + # No COD at all: no control block, every band absent (no invented limits). + uncontrolled = _control_test_circuit(Dict{String, Any}()) + @test PFP.get_value(uncontrolled, :control_objective) == "UNDEFINED" + @test _absent_bands(uncontrolled) == _BAND_FIELDS + + # COD1 = 2 with the target columns omitted: the 0.9/1.1 the pti layer substituted is a + # voltage default, so the MVAr band stays absent rather than carrying it (B1). + q_omitted = _control_test_circuit( + Dict{String, Any}( + "COD1" => 2, "RMI1" => 0.9, "RMA1" => 1.1, "VMI1" => 0.9, "VMA1" => 1.1, + "RM_PRESENT1" => false, "VM_PRESENT1" => false, + ), + ) + @test _matches_nt(PFP.get_value(q_omitted, :tap_ratio_limits), (min = 0.9, max = 1.1)) + @test :controlled_reactive_power_flow_limits in _absent_bands(q_omitted) + + # COD1 = 1 with the target columns omitted: 0.9/1.1 is the right voltage default. + v_omitted = _control_test_circuit( + Dict{String, Any}( + "COD1" => 1, "RMI1" => 0.9, "RMA1" => 1.1, "VMI1" => 0.9, "VMA1" => 1.1, + "RM_PRESENT1" => false, "VM_PRESENT1" => false, + ), + ) + @test _matches_nt( + PFP.get_value(v_omitted, :controlled_voltage_limits), + (min = 0.9, max = 1.1), + ) + @test _matches_nt(PFP.get_value(v_omitted, :tap_ratio_limits), (min = 0.9, max = 1.1)) + + # COD1 = 3 with the actuator columns omitted: 0.9/1.1 degrees is not an angle band. + angle_omitted = _control_test_circuit( + Dict{String, Any}( + "COD1" => 3, "RMI1" => 0.9, "RMA1" => 1.1, "VMI1" => -20.0, "VMA1" => 20.0, + "RM_PRESENT1" => false, "VM_PRESENT1" => true, + ), + ) + @test :phase_angle_limits in _absent_bands(angle_omitted) + @test PFP.get_value(angle_omitted, :controlled_active_power_flow_limits).max == 0.4 + + # A stated band under COD1 = 2 is written in MVAr and converts on the circuit base. + q_stated = _control_test_circuit( + Dict{String, Any}( + "COD1" => 2, "RMI1" => 0.95, "RMA1" => 1.05, "VMI1" => -25.0, "VMA1" => + 25.0, + ), + ) + @test _matches_nt( + PFP.get_value(q_stated, :controlled_reactive_power_flow_limits), + (min = -0.5, max = 0.5), + ) + @test _absent_bands(q_stated) == + ( + :phase_angle_limits, + :controlled_voltage_limits, + :controlled_active_power_flow_limits, + ) +end diff --git a/test/test_openapi_dc_shunt.jl b/test/test_openapi_dc_shunt.jl index ec50aec..4c52d5b 100644 --- a/test/test_openapi_dc_shunt.jl +++ b/test/test_openapi_dc_shunt.jl @@ -8,8 +8,11 @@ @test PFP.get_value(line, :active_power_flow) ≈ d["pf"] * 100.0 @test PFP.get_value(line, :parameter_units) == "NATURAL_UNITS" @test PFP.get_value(line, :r) == d["r"] - @test PFP.get_value(line, :power_mode) == d["power_mode"] - @test PFP.get_value(line, :transfer_setpoint) == d["transfer_setpoint"] + # MDC=1: POWER holds the MW schedule; the current schedule stays absent. + @test d["control_mode"] == "POWER" + @test PFP.get_value(line, :control_mode) == "POWER" + @test PFP.get_value(line, :power_transfer_setpoint) == d["transfer_setpoint"] + @test PFP.get_value(line, :current_transfer_setpoint) === PFP.ABSENT @test PFP.get_value(line, :scheduled_dc_voltage) == d["scheduled_dc_voltage"] @test PFP.get_value(line, :rectifier_bridges) == Int(d["rectifier_bridges"]) @test _matches_nt( @@ -75,7 +78,7 @@ end @test _matches_nt(PFP.get_value(shunt, :y), (real = 100.0, imag = 200.0)) end -@testset "SwitchedAdmittance: control mode mapping, natural Y/Y_increase, admittance_limits passthrough" begin +@testset "SwitchedAdmittance: control mode mapping, natural Y/Y_increase, VSWLO/VSWHI as the mode's band" begin pm = fourteen_bus_pm_data() sys = PFP.build_openapi_system(pm) base = pm.data["baseMVA"] @@ -103,15 +106,77 @@ end # overriding when present. @test PFP.get_value(shunt, :solved_admittance) == 50.0 @test only(y_increase).imag == 100.0 + # MODSW=1 is a voltage mode: VSWLO/VSWHI land on voltage_limits, the reactive band + # stays absent. @test _matches_nt( - PFP.get_value(shunt, :admittance_limits), + PFP.get_value(shunt, :voltage_limits), (min = d["admittance_limits"][1], max = d["admittance_limits"][2]), ) + @test PFP.get_value(shunt, :reactive_power_range_limits) === PFP.ABSENT @test PFP.get_value(shunt, :number_engaged) == d["number_engaged"] end @testset "_switched_admittance_control_mode rejects an unrecognized MODSW code" begin @test_throws IS.DataFormatError PFP._switched_admittance_control_mode(42) + @test PFP._switched_admittance_control_mode(6) == "DISCRETE_REACTIVE_FACTS" +end + +"""A single registered `ACBus`, for the maker-level testset below (kept local so this file +does not depend on the include order of its siblings).""" +function _dc_shunt_bus!(sys::PFP.OpenAPISystem, number::Int, name::AbstractString) + reg = PFP.get_registry(sys) + bus = PFP.stage(PFP.PC.ACBus) + id = PFP.register_bus!(reg, number, name) + PFP.set_value!(bus, :id, id) + PFP.set_value!(bus, :number, number) + PFP.set_value!(bus, :name, name) + PFP.set_value!(bus, :available, true) + PFP.set_value!(bus, :bustype, "PQ") + PFP.set_value!(bus, :base_voltage, 100.0, "kV") + PFP.set_value!(bus, :angle, 0.0, "rad") + PFP.set_value!(bus, :magnitude, 1.0, "pu") + PFP.set_value!(bus, :voltage_limits, (min = 0.9, max = 1.1), "pu") + PFP.add_component!(sys, bus) + return id +end + +@testset "SwitchedAdmittance: a reactive control mode writes reactive_power_range_limits, FIXED writes no band" begin + for (mode, band) in ( + (3, :reactive_power_range_limits), (4, :reactive_power_range_limits), + (5, :reactive_power_range_limits), (6, :reactive_power_range_limits), + (2, :voltage_limits), (0, nothing), + ) + sys = PFP.OpenAPISystem(100.0) + reg = PFP.get_registry(sys) + bus_id = _dc_shunt_bus!(sys, 1, "b1") + d = Dict{String, Any}( + "status" => true, "control_mode" => mode, "step_number" => [2], + "y_increment" => [complex(0.0, 0.5)], "admittance_limits" => (0.2, 0.8), + "bs" => 0.0, "regulated_bus_number" => 0, + ) + PFP.make_switched_admittance!(sys, reg, "sh$mode", d, bus_id) + shunt = only(PFP.get_components(sys, "SwitchedAdmittance")) + for candidate in (:voltage_limits, :reactive_power_range_limits) + if candidate == band + @test _matches_nt(PFP.get_value(shunt, candidate), (min = 0.2, max = 0.8)) + else + @test PFP.get_value(shunt, candidate) === PFP.ABSENT + end + end + end +end + +@testset "TwoTerminalLCCLine: MDC=0 builds as BLOCKED with both setpoints absent" begin + raw = read(joinpath(@__DIR__, "fixtures", "synthetic_v35_two_terminal_dc.raw"), String) + blocked = replace(raw, "\"DCTEST1 \",1," => "\"DCTEST1 \",0,") + path = joinpath(mktempdir(), "blocked.raw") + write(path, blocked) + sys = PFP.build_openapi_system(PFP.PowerModelsData(path)) + line = only(PFP.get_components(sys, "TwoTerminalLCCLine")) + @test PFP.get_value(line, :control_mode) == "BLOCKED" + @test !PFP.get_value(line, :available) + @test PFP.get_value(line, :power_transfer_setpoint) === PFP.ABSENT + @test PFP.get_value(line, :current_transfer_setpoint) === PFP.ABSENT end @testset "FACTSControlDevice: PSS/E MODE 0/1/2 maps to OOS/NML/BYP" begin @@ -265,8 +330,10 @@ end @test PFP.get_value(vsc, :ac_control_from) == "AC_REACTIVE_POWER" @test PFP.get_value(vsc, :active_power_flow) ≈ 0.05 * 100.0 @test PFP.get_value(vsc, :rating) ≈ 1.0 * 100.0 - @test PFP.get_value(vsc, :dc_setpoint_from) ≈ 0.02 * 100.0 - @test PFP.get_value(vsc, :ac_setpoint_from) == 1.0 + @test PFP.get_value(vsc, :dc_power_setpoint_from) ≈ 0.02 * 100.0 + @test PFP.get_value(vsc, :dc_voltage_setpoint_from) === PFP.ABSENT + @test PFP.get_value(vsc, :power_factor_setpoint_from) == 1.0 + @test PFP.get_value(vsc, :ac_voltage_setpoint_from) === PFP.ABSENT @test PFP.get_value(vsc, :dc_current) == 10.0 @test PFP.get_value(vsc, :g) ≈ 1.0 / 0.5 @test PFP.get_value(vsc, :max_dc_current_from) == 100.0 @@ -288,8 +355,8 @@ end end @testset "TwoTerminalVSCLine: make_vscline! stores DC_VOLTAGE/AC_VOLTAGE setpoints as COMPONENT_BASE pu" begin - # SiennaSchemas decouples setpoint_voltage_units (dc_setpoint_from/to, - # ac_setpoint_from/to) from voltage_units (voltage_limits_from/to only), so tagging a + # SiennaSchemas decouples setpoint_voltage_units (dc_voltage_setpoint_from/to, + # ac_voltage_setpoint_from/to) from voltage_units (voltage_limits_from/to only), so tagging a # voltage-controlling setpoint COMPONENT_BASE no longer relabels the untouched # voltage_limits_from/to defaults. make_vscline! sets setpoint_voltage_units = # "COMPONENT_BASE" unconditionally and stores the already-p.u. PSS/E value with unit "pu" — @@ -329,7 +396,8 @@ end ) @test PFP.get_value(vsc_dc, :dc_control_from) == "DC_VOLTAGE" @test PFP.get_value(vsc_dc, :setpoint_voltage_units) == "COMPONENT_BASE" - @test PFP.get_value(vsc_dc, :dc_setpoint_from) == 1.03 + @test PFP.get_value(vsc_dc, :dc_voltage_setpoint_from) == 1.03 + @test PFP.get_value(vsc_dc, :dc_power_setpoint_from) === PFP.ABSENT # make_vscline! has no pm dict source for voltage_units/voltage_limits_from — PSS/E's # VSC record carries no DC-bus voltage bound — so both stay genuinely unset rather than # defaulting to some placeholder range (see "unset properties are absent, not null" in @@ -349,77 +417,65 @@ end ) @test PFP.get_value(vsc_ac, :ac_control_from) == "AC_VOLTAGE" @test PFP.get_value(vsc_ac, :setpoint_voltage_units) == "COMPONENT_BASE" - @test PFP.get_value(vsc_ac, :ac_setpoint_from) == 1.02 + @test PFP.get_value(vsc_ac, :ac_voltage_setpoint_from) == 1.02 + @test PFP.get_value(vsc_ac, :power_factor_setpoint_from) === PFP.ABSENT # See the vsc_dc case above: voltage_units/voltage_limits_to have no pm dict source. @test PFP.get_value(vsc_ac, :voltage_units) === PFP.ABSENT @test PFP.get_value(vsc_ac, :voltage_limits_to) === PFP.ABSENT end -@testset "TwoTerminalVSCLine: dc_setpoint_from/to convert correctly under DC_VOLTAGE and DC_VOLTAGE_DROOP" begin - # psse.jl's own VSC parsing (src/pm_io/psse.jl:2083-2097) documents this - # exactly: "PSY documents dc_setpoint_from/to as p.u. of rated_dc_voltage - # for the DC-voltage-controlling side (TYPE = 1)", computed there as - # `from_bus["DCSET"] / base_voltage`. Hand math: DCSET = 515.0 kV, - # base_voltage (rated_dc_voltage) = 500.0 kV => 515.0 / 500.0 = 1.03 p.u. - # That division already produces the number PSY expects, so passing it - # through set_value! with unit "pu" is an identity conversion: source - # unit "pu" equals the COMPONENT_BASE-branch declared unit "pu", and "pu" - # carries no fixed conversion factor (to_default: null in - # Core/units.json) -- there is nothing left to scale. +@testset "TwoTerminalVSCLine: dc_voltage_setpoint_from/to follow setpoint_voltage_units" begin + # psse.jl documents the DC-voltage-controlling side's setpoint as p.u. of + # rated_dc_voltage, computed as `DCSET / base_voltage`: 515.0 / 500.0 = 1.03 p.u. + # Under COMPONENT_BASE that number is already in the declared unit, so "pu" is an + # identity; under NATURAL_UNITS the field is a literal kV magnitude. The mode is + # carried separately and no longer changes the field's unit. vsc = PFP.stage(PFP.PO.TwoTerminalVSCLine) PFP.set_value!(vsc, :power_units, "NATURAL_UNITS") PFP.set_value!(vsc, :dc_control_from, "DC_VOLTAGE") PFP.set_value!(vsc, :setpoint_voltage_units, "COMPONENT_BASE") - PFP.set_value!(vsc, :dc_setpoint_from, 515.0 / 500.0, "pu") - @test PFP.get_value(vsc, :dc_setpoint_from) == 1.03 + PFP.set_value!(vsc, :dc_voltage_setpoint_from, 515.0 / 500.0, "pu") + @test PFP.get_value(vsc, :dc_voltage_setpoint_from) == 1.03 - # NATURAL_UNITS is the schema's other DC-voltage basis: dc_setpoint_from - # is then a literal kV magnitude. "kV" is both the source and the - # DC_VOLTAGE/NATURAL_UNITS-branch declared unit (to_default 1.0 on both - # sides), so this is also an identity conversion. PFP.set_value!(vsc, :setpoint_voltage_units, "NATURAL_UNITS") - PFP.set_value!(vsc, :dc_setpoint_from, 515.0, "kV") - @test PFP.get_value(vsc, :dc_setpoint_from) == 515.0 + PFP.set_value!(vsc, :dc_voltage_setpoint_from, 515.0, "kV") + @test PFP.get_value(vsc, :dc_voltage_setpoint_from) == 515.0 - # DC_VOLTAGE_DROOP shares the exact same nested setpoint_voltage_units branch as - # DC_VOLTAGE in TwoTerminalVSCLine.json's dc_setpoint_from annotation; - # confirm the emitter's recursive walk produced the same result for it. + # DC_VOLTAGE_DROOP selects the same field. PFP.set_value!(vsc, :dc_control_from, "DC_VOLTAGE_DROOP") PFP.set_value!(vsc, :setpoint_voltage_units, "COMPONENT_BASE") - PFP.set_value!(vsc, :dc_setpoint_from, 1.03, "pu") - @test PFP.get_value(vsc, :dc_setpoint_from) == 1.03 + PFP.set_value!(vsc, :dc_voltage_setpoint_from, 1.03, "pu") + @test PFP.get_value(vsc, :dc_voltage_setpoint_from) == 1.03 - # dc_setpoint_to shares TwoTerminalVSCLine's one setpoint_voltage_units field with - # dc_setpoint_from but has its own dc_control_to discriminator. + # The `to` side shares the one setpoint_voltage_units field. PFP.set_value!(vsc, :dc_control_to, "DC_VOLTAGE") - PFP.set_value!(vsc, :dc_setpoint_to, 1.03, "pu") - @test PFP.get_value(vsc, :dc_setpoint_to) == 1.03 + PFP.set_value!(vsc, :dc_voltage_setpoint_to, 1.03, "pu") + @test PFP.get_value(vsc, :dc_voltage_setpoint_to) == 1.03 + + # The power setpoint has its own fixed unit, untouched by the basis tag. + PFP.set_value!(vsc, :dc_power_setpoint_to, 40.0, "MW") + @test PFP.get_value(vsc, :dc_power_setpoint_to) == 40.0 end -@testset "TwoTerminalVSCLine: ac_setpoint_from/to convert correctly under AC_VOLTAGE" begin - # psse.jl: `sub_data["ac_setpoint_from"] = from_bus["ACSET"]` -- PSS/E's - # ACSET for a VSC converter bus is already per-unit of the AC bus's own - # base voltage (the same PSS/E convention as bus VM), so no scaling - # happens before this value reaches PSY. Passing "pu" here is again an - # identity: source unit "pu" equals the AC_VOLTAGE/COMPONENT_BASE-branch - # declared unit "pu". +@testset "TwoTerminalVSCLine: ac_voltage_setpoint_from/to follow setpoint_voltage_units" begin + # PSS/E's ACSET for a VSC converter bus is already per-unit of the AC bus's own base + # voltage, so "pu" under COMPONENT_BASE is an identity; NATURAL_UNITS is a kV magnitude. vsc = PFP.stage(PFP.PO.TwoTerminalVSCLine) PFP.set_value!(vsc, :power_units, "NATURAL_UNITS") PFP.set_value!(vsc, :ac_control_from, "AC_VOLTAGE") PFP.set_value!(vsc, :setpoint_voltage_units, "COMPONENT_BASE") - PFP.set_value!(vsc, :ac_setpoint_from, 1.02, "pu") - @test PFP.get_value(vsc, :ac_setpoint_from) == 1.02 + PFP.set_value!(vsc, :ac_voltage_setpoint_from, 1.02, "pu") + @test PFP.get_value(vsc, :ac_voltage_setpoint_from) == 1.02 - # NATURAL_UNITS basis: ac_setpoint_from would be a literal kV magnitude - # (e.g. 1.02 p.u. x 138.0 kV bus base = 140.76 kV); identity again since - # source and declared units both resolve to "kV". PFP.set_value!(vsc, :setpoint_voltage_units, "NATURAL_UNITS") - PFP.set_value!(vsc, :ac_setpoint_from, 1.02 * 138.0, "kV") - @test PFP.get_value(vsc, :ac_setpoint_from) == 140.76 + PFP.set_value!(vsc, :ac_voltage_setpoint_from, 1.02 * 138.0, "kV") + @test PFP.get_value(vsc, :ac_voltage_setpoint_from) == 140.76 - # ac_setpoint_to mirrors ac_setpoint_from; setpoint_voltage_units is shared across - # both sides of the component, ac_control_to is independent. PFP.set_value!(vsc, :ac_control_to, "AC_VOLTAGE") - PFP.set_value!(vsc, :ac_setpoint_to, 1.02 * 138.0, "kV") - @test PFP.get_value(vsc, :ac_setpoint_to) == 140.76 + PFP.set_value!(vsc, :ac_voltage_setpoint_to, 1.02 * 138.0, "kV") + @test PFP.get_value(vsc, :ac_voltage_setpoint_to) == 140.76 + + # The power factor has its own fixed unit, untouched by the basis tag. + PFP.set_value!(vsc, :power_factor_setpoint_to, 0.95, "1") + @test PFP.get_value(vsc, :power_factor_setpoint_to) == 0.95 end diff --git a/test/test_openapi_generation.jl b/test/test_openapi_generation.jl index d7d05cc..d1ab024 100644 --- a/test/test_openapi_generation.jl +++ b/test/test_openapi_generation.jl @@ -397,26 +397,28 @@ end # Pins the structural guarantee: an # instance-level-discriminated field with no verdict in # `_DEVICEBASE_INSTANCE_DISPATCHED` must error, not silently fall through unconverted. - # `TwoTerminalVSCLine.dc_setpoint_from` (governed by `dc_control_from`) is real and - # instance-dispatched today but deliberately not registered -- this package's readers - # never reach a document containing it without first hitting the recorded VSC - # voltage-control gap (`_vsc_voltage_control_unsupported`), so it is exactly the kind - # of "not yet classified" field the guard exists for. - vsc = PFP.stage(PFP.PO.TwoTerminalVSCLine) - PFP.set_value!(vsc, :id, 1) - PFP.set_value!(vsc, :name, "vsc") - PFP.set_value!(vsc, :available, true) - PFP.set_value!(vsc, :arc, 0) - PFP.set_value!(vsc, :power_units, "NATURAL_UNITS") - PFP.set_value!(vsc, :active_power_flow, 0.0, "MW") - PFP.set_value!(vsc, :active_power_limits_from, (min = 0.0, max = 0.0), "MW") - PFP.set_value!(vsc, :active_power_limits_to, (min = 0.0, max = 0.0), "MW") - PFP.set_value!(vsc, :rating, 0.0, "MVA") - PFP.set_value!(vsc, :base_power, 100.0, "MVA") + # `InterconnectingConverter.dc_voltage_setpoint` (governed by + # `voltage_setpoint_units`) is real and instance-dispatched but deliberately not + # registered: no PSS/E record produces an InterconnectingConverter, so this package's + # readers never emit one, and it is exactly the kind of "not yet classified" field the + # guard exists for. + ic = PFP.stage(PFP.PO.InterconnectingConverter) + PFP.set_value!(ic, :id, 1) + PFP.set_value!(ic, :name, "ic") + PFP.set_value!(ic, :available, true) + PFP.set_value!(ic, :bus, 0) + PFP.set_value!(ic, :dc_bus, 0) + PFP.set_value!(ic, :power_units, "NATURAL_UNITS") + PFP.set_value!(ic, :active_power, 0.0, "MW") + PFP.set_value!(ic, :active_power_limits, (min = 0.0, max = 0.0), "MW") + PFP.set_value!(ic, :rating, 0.0, "MVA") + PFP.set_value!(ic, :base_power, 100.0, "MVA") + PFP.set_value!(ic, :dc_control, "DC_VOLTAGE") + PFP.set_value!(ic, :ac_control, "AC_REACTIVE_POWER") @test_throws ErrorException PFP._devicebase_classification( - PFP.materialize(vsc), - "TwoTerminalVSCLine", - :dc_setpoint_from, + PFP.materialize(ic), + "InterconnectingConverter", + :dc_voltage_setpoint, ) end diff --git a/test/test_openapi_transformer_discriminators.jl b/test/test_openapi_transformer_discriminators.jl index d66ed28..251db5f 100644 --- a/test/test_openapi_transformer_discriminators.jl +++ b/test/test_openapi_transformer_discriminators.jl @@ -86,7 +86,7 @@ _transformer_discriminator_pm_data() = @test PFP.get_value(circuit, :rating) ≈ d["rate_a"] * d["base_power"] @test PFP.get_value(circuit, :rating) ≈ 20.0 @test _matches_nt( - PFP.get_value(circuit, :control_limits), + PFP.get_value(circuit, :tap_ratio_limits), (min = d["RMI1"], max = d["RMA1"]), ) end @@ -161,7 +161,7 @@ end @test PFP.get_value(circuit, :rating) ≈ 51.2 end -@testset "two-terminal DC line MDC=2 (current-controlled): power_mode=false, hand-derived power_demand" begin +@testset "two-terminal DC line MDC=2 (current-controlled): control_mode=CURRENT, hand-derived power_demand" begin pm = _transformer_discriminator_pm_data() data = pm.data sys_mbase = data["baseMVA"] @@ -174,14 +174,16 @@ end @test power_demand == 80.0 @test d["pf"] ≈ power_demand / sys_mbase # PowerModels' generic per-unit correction @test d["pf"] ≈ 0.8 - @test !d["power_mode"] # MDC != 1 + @test d["control_mode"] == "CURRENT" # MDC=2 @test d["transfer_setpoint"] == 200.0 # raw SETVL, passed through regardless of MDC @test d["scheduled_dc_voltage"] == 400.0 sys = PFP.build_openapi_system(pm) line = only(PFP.get_components(sys, "TwoTerminalLCCLine")) - @test !PFP.get_value(line, :power_mode) - @test PFP.get_value(line, :transfer_setpoint) == d["transfer_setpoint"] + # MDC=2: CURRENT holds the amperes schedule; the MW schedule stays absent. + @test PFP.get_value(line, :control_mode) == "CURRENT" + @test PFP.get_value(line, :current_transfer_setpoint) == d["transfer_setpoint"] + @test PFP.get_value(line, :power_transfer_setpoint) === PFP.ABSENT # Emit-layer scaling is uniform regardless of MDC (dc_branch.jl multiplies `pf` by # sys_mbase either way): 0.8 * 100.0 = 80.0 MW, matching the hand-derived power_demand # above — confirms MDC=2's current-based power_demand survives the full pipeline. diff --git a/test/test_parse_psse.jl b/test/test_parse_psse.jl index 119cf03..fb5fc3d 100644 --- a/test/test_parse_psse.jl +++ b/test/test_parse_psse.jl @@ -95,9 +95,35 @@ end ) blocked_dcline = only(values(pm_blocked["dcline"])) @test blocked_dcline["available"] == false + @test blocked_dcline["control_mode"] == "BLOCKED" @test blocked_dcline["r"] ≈ 5.0 / (200.0^2 / 100.0) end +@testset "Two-terminal DC MDC maps to the three LCCControlMode strings" begin + # MDC=1 (14-bus fixture) and MDC=2 (discriminator fixture) parse to their names; an + # unknown code is rejected rather than defaulted. MDC=0 is covered above. + power = + only(values(parse_file(joinpath(@__DIR__, "modified_14bus_system.raw"))["dcline"])) + @test power["control_mode"] == "POWER" + current = only( + values( + parse_file( + joinpath( + @__DIR__, + "fixtures", + "synthetic_v35_transformer_discriminators.raw", + ), + )["dcline"], + ), + ) + @test current["control_mode"] == "CURRENT" + @test current["available"] + @test_throws PowerFlowFileParser.DataFormatError PowerFlowFileParser._lcc_control_mode( + 42, + "x", + ) +end + @testset "PSSE VSC line captures each converter's own AC bus base_kv" begin file = joinpath(@__DIR__, "fixtures", "synthetic_v35_vsc_line.raw") pm_data = PowerModelsData(file).data diff --git a/test/test_transformer_band_presence.jl b/test/test_transformer_band_presence.jl new file mode 100644 index 0000000..df07578 --- /dev/null +++ b/test/test_transformer_band_presence.jl @@ -0,0 +1,70 @@ +# A PSS(R)E winding line may stop before its control bands. `pti.jl` then fills RMA/RMI/ +# VMA/VMI with the documented defaults, so by the time the pm dict exists a stated 0.9-1.1 +# and a substituted one look the same. `RM_PRESENT`/`VM_PRESENT` is the only record of +# which it was, captured before the substitution. These tests enter through the RAW parse +# path on purpose: a hand-built dict can omit a key that a parsed one never lacks. + +const BAND_PRESENCE_FIXTURE = + joinpath(@__DIR__, "fixtures", "synthetic_v35_transformer_band_presence.raw") + +_branch_between(data, f, t) = + only(v for v in values(data["branch"]) if v["f_bus"] == f && v["t_bus"] == t) + +@testset "Winding line truncated before its bands: flags false, defaults still filled" begin + data = PowerFlowFileParser.parse_file(BAND_PRESENCE_FIXTURE) + t1 = _branch_between(data, 201, 202) + @test t1["COD1"] == 2 + @test t1["RM_PRESENT1"] === false + @test t1["VM_PRESENT1"] === false + # The defaults are substituted regardless; the flag is what says they were not stated. + # (RMA/RMI are re-expressed by `apply_tap_correction!` per CW, so only VMA/VMI are + # compared as raw values here.) + @test haskey(t1, "RMA1") && haskey(t1, "RMI1") + @test t1["VMA1"] == 1.1 && t1["VMI1"] == 0.9 +end + +@testset "Winding line stating its bands: flags true, values as written" begin + data = PowerFlowFileParser.parse_file(BAND_PRESENCE_FIXTURE) + t2 = _branch_between(data, 203, 204) + @test t2["COD1"] == 1 + @test t2["RM_PRESENT1"] === true + @test t2["VM_PRESENT1"] === true + @test t2["VMA1"] == 1.04 && t2["VMI1"] == 0.96 +end + +@testset "Three-winding transformer: one flag pair per winding" begin + data = PowerFlowFileParser.parse_file(joinpath(PSSE_RAW_DIR, "case6_3w.raw")) + @test !isempty(data["3w_transformer"]) + for t in values(data["3w_transformer"]), k in 1:3 + @test t["RM_PRESENT$k"] isa Bool + @test t["VM_PRESENT$k"] isa Bool + end +end + +# The same fixture through the full document build: the presence flags decide which bands +# the circuit carries, so a truncated COD=2 record yields no MVAr band and a stated COD=1 +# record yields its voltage band, with no hand-built dict anywhere in the path. +function _band_circuit(sys, from_number::Int, to_number::Int) + reg = PFP.get_registry(sys) + arc_id = PFP.add_arc!( + sys, PFP.get_bus_id(reg, from_number), PFP.get_bus_id(reg, to_number), + ) + return only( + c for c in PFP.get_components(sys, "TransformerCircuit") if + PFP.get_value(c, :arc) == arc_id + ) +end + +@testset "Document build: truncated COD=2 record carries no MVAr band, stated COD=1 record its voltage band" begin + sys = PFP.build_openapi_system(PFP.PowerModelsData(BAND_PRESENCE_FIXTURE)) + t1 = _band_circuit(sys, 201, 202) + @test PFP.get_value(t1, :control_objective) == "REACTIVE_POWER_FLOW" + @test PFP.get_value(t1, :controlled_reactive_power_flow_limits) isa PFP.IC.Absent + @test PFP.get_value(t1, :controlled_voltage_limits) isa PFP.IC.Absent + @test !(PFP.get_value(t1, :tap_ratio_limits) isa PFP.IC.Absent) + t2 = _band_circuit(sys, 203, 204) + @test PFP.get_value(t2, :control_objective) == "VOLTAGE" + band = PFP.get_value(t2, :controlled_voltage_limits) + @test PFP.get_value(band, :min) == 0.96 && PFP.get_value(band, :max) == 1.04 + @test PFP.get_value(t2, :controlled_reactive_power_flow_limits) isa PFP.IC.Absent +end