diff --git a/.github/workflows/cross-package-test.yml b/.github/workflows/cross-package-test.yml index 1ca646c..f8a96ed 100644 --- a/.github/workflows/cross-package-test.yml +++ b/.github/workflows/cross-package-test.yml @@ -12,7 +12,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - julia-version: [1] + julia-version: ['1'] os: [ubuntu-latest] package_name: [PowerSystemCaseBuilder] continue-on-error: true diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index c73aab8..272b90c 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -17,7 +17,7 @@ jobs: with: # `[sources]` is honored from 1.11 on; on 1.10 the docs env silently ignored the # OpenAPI pins and fell through to the registry. - version: '1.12' + version: '1' - name: Install dependencies run: julia --project=docs/ -e 'using Pkg; Pkg.develop(PackageSpec(path=pwd())); Pkg.instantiate()' - name: Build and deploy diff --git a/Project.toml b/Project.toml index 3317e82..57ee5b7 100644 --- a/Project.toml +++ b/Project.toml @@ -19,12 +19,7 @@ PowerOpenAPIModels = "0730f07c-cff6-4c3b-a9df-c546153be50a" PowerOperationsOpenAPIModels = "a372b6d7-45a2-44c2-8199-6a724b72e8ff" Unicode = "4ec0a83e-493e-50e2-b9ac-8f72acf5a8f5" [sources] -# LOCAL PATH CO-DEV PINS (temporary): PowerOpenAPIModels split InfrastructureCore/ -# InfrastructureTimeSeries out on local disk only; committed git pins below still point at -# `jd/schemas-0.1.0`, which predates the split. Revert to git-url pins (with subdirs matching -# the new package names) once the split is pushed. InfrastructureSystems must also point at -# the local IS4 checkout (not the remote branch) because that checkout already carries this -# same split (uncommitted) and the remote IS4 branch does not yet. +# Branch pins until the OpenAPI packages are registered and the psy6-line branches merge. InfrastructureSystems = {url = "https://github.com/Sienna-Platform/InfrastructureSystems.jl.git", rev = "IS4"} InfrastructureCoreOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "InfrastructureCoreOpenAPIModels.jl"} InfrastructureTimeSeriesOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "InfrastructureTimeSeriesOpenAPIModels.jl"} @@ -42,7 +37,7 @@ InfrastructureSystems = "^3.2" InfrastructureTimeSeriesOpenAPIModels = "0.1" JSON = "1" LinearAlgebra = "1" -OpenAPI = "0.2" +OpenAPI = "1" PowerCoreOpenAPIModels = "0.1" PowerDynamicsOpenAPIModels = "0.1" PowerInvestmentsOpenAPIModels = "0.1" diff --git a/src/PowerFlowFileParser.jl b/src/PowerFlowFileParser.jl index 893214c..d79074a 100644 --- a/src/PowerFlowFileParser.jl +++ b/src/PowerFlowFileParser.jl @@ -28,6 +28,8 @@ const PC = PowerCoreOpenAPIModels const PD = PowerOpenAPIModels const PO = PowerOperationsOpenAPIModels +import OpenAPI.Runtime: Absent, ABSENT + import InfrastructureSystems const IS = InfrastructureSystems diff --git a/src/openapi/attributes.jl b/src/openapi/attributes.jl index 09f34a9..aebdd31 100644 --- a/src/openapi/attributes.jl +++ b/src/openapi/attributes.jl @@ -40,6 +40,7 @@ function _impedance_correction_curves(data::Dict) continue end curve = IC.PiecewiseLinearData(; + function_type = "PIECEWISE_LINEAR", points = [IC.XYCoords(; x = x[i], y = y[i]) for i in eachindex(x)], ) control_mode = @@ -67,7 +68,7 @@ function _new_impedance_correction_attribute!( transformer_id::Int, ) curve, control_mode = curves[table_number] - attribute = PO.ImpedanceCorrectionData() + 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) @@ -89,7 +90,7 @@ shared attribute and multiple associations — see the file header. """ function _attach_impedance_correction!( sys::OpenAPISystem, - cache::Dict{Tuple{Int, String}, PO.ImpedanceCorrectionData}, + cache::Dict{Tuple{Int, String}, Staged{PO.ImpedanceCorrectionData}}, curves::Dict{Int, Tuple{IC.PiecewiseLinearData, String}}, d::Dict, table_key::AbstractString, @@ -139,7 +140,7 @@ function read_substations!(sys::OpenAPISystem, data::Dict; kwargs...) bus_ids = unique(get_bus_id(reg, Int(node["bus"])) for node in nodes) name = string(d["name"]) - attribute = PO.Substation() + attribute = stage(PO.Substation) set_value!(attribute, :id, register!(reg, "Substation", name)) set_value!(attribute, :name, name) set_value!(attribute, :number, number) @@ -170,7 +171,7 @@ function read_impedance_corrections!(sys::OpenAPISystem, data::Dict; kwargs...) bus_lookup = _pm_bus_lookup(sys) _get_branch_name = get(kwargs, :branch_name_formatter, _get_pm_branch_name) _get_3w_name = get(kwargs, :xfrm_3w_name_formatter, _get_pm_3w_name) - cache = Dict{Tuple{Int, String}, PO.ImpedanceCorrectionData}() + cache = Dict{Tuple{Int, String}, Staged{PO.ImpedanceCorrectionData}}() for (_, d) in _sorted_pm_entries(get(data, "branch", Dict{String, Any}())) if !haskey(d, "correction_table") diff --git a/src/openapi/branch.jl b/src/openapi/branch.jl index 5524d8a..33f14c6 100644 --- a/src/openapi/branch.jl +++ b/src/openapi/branch.jl @@ -252,7 +252,7 @@ function _make_transformer_circuit!( reactive_power_flow::Real, ) arc_id = add_arc!(sys, from_id, to_id) - circuit = PO.TransformerCircuit() + circuit = stage(PO.TransformerCircuit) set_value!(circuit, :id, next_id!(reg)) set_value!(circuit, :available, available) set_value!(circuit, :arc, arc_id) @@ -293,7 +293,7 @@ function make_line!( available = _branch_available(d["br_status"] == 1, from_isolated, to_isolated) arc_id = add_arc!(sys, from_id, to_id) - line = PO.Line() + line = stage(PO.Line) set_value!(line, :id, register!(reg, "Line", name)) set_value!(line, :name, name) set_value!(line, :available, available) @@ -339,7 +339,7 @@ function make_switch_from_zero_impedance_branch!( end @warn "Branch $name has zero impedance and available = $available; converting to a DiscreteControlledACBranch of type SWITCH with available = $available and branch_status = $status" - component = PO.DiscreteControlledACBranch() + component = stage(PO.DiscreteControlledACBranch) set_value!(component, :id, register!(reg, "DiscreteControlledACBranch", name)) set_value!(component, :name, name) set_value!(component, :available, available) @@ -423,7 +423,7 @@ function make_transformer_2w!( reactive_power_flow = get(d, "qf", 0.0) * base_power, ) - component = PO.TwoWindingTransformer() + component = stage(PO.TwoWindingTransformer) set_value!(component, :id, register!(reg, "TwoWindingTransformer", name)) set_value!(component, :name, name) set_value!(component, :circuit, circuit_id) @@ -491,7 +491,7 @@ function make_3w_transformer!( reactive_power_flow = d["reactive_power_flow_tertiary"], ) - component = PO.ThreeWindingTransformer() + component = stage(PO.ThreeWindingTransformer) set_value!(component, :id, register!(reg, "ThreeWindingTransformer", name)) set_value!(component, :name, name) set_value!(component, :primary_circuit, primary_circuit) diff --git a/src/openapi/container.jl b/src/openapi/container.jl index 6da348a..b228b79 100644 --- a/src/openapi/container.jl +++ b/src/openapi/container.jl @@ -93,16 +93,16 @@ why each component states the convention it was written in. uses_per_unit(sys::OpenAPISystem) = sys.power_units == "COMPONENT_BASE" """ -Add `component` to the document, first stamping this run's `power_units` onto it when +Materialize `staged` into the document, first stamping this run's `power_units` onto it when its PO type declares the field — the per-component wire-contract requirement every power-bearing type carries (a component with none, e.g. a pure topology row, is untouched). """ -function add_component!(sys::OpenAPISystem, component::T) where {T <: OpenAPI.APIModel} +function add_component!(sys::OpenAPISystem, staged::Staged{T}) where {T <: IC.APIModel} if hasfield(T, :power_units) - setproperty!(component, :power_units, sys.power_units) + set_value!(staged, :power_units, sys.power_units) end - PD.add_component!(get_document(sys), component) + PD.add_component!(get_document(sys), materialize(staged)) return end @@ -116,10 +116,10 @@ caller need not know it. """ function add_supplemental_attribute!( sys::OpenAPISystem, - attribute::OpenAPI.APIModel, + attribute::Staged, component_id::Int, ) - PD.add_supplemental_attribute!(get_document(sys), attribute, component_id) + PD.add_supplemental_attribute!(get_document(sys), materialize(attribute), component_id) return end @@ -139,17 +139,17 @@ disagree. """ function add_supplemental_attribute_association!( sys::OpenAPISystem, - attribute::OpenAPI.APIModel, + attribute::Staged{T}, component_id::Int, component_type::AbstractString, -) +) where {T} push!( get_document(sys).supplemental_attribute_associations, IC.SupplementalAttributeAssociation(; component_id = component_id, component_type = String(component_type), attribute_id = get_value(attribute, :id), - attribute_type = string(nameof(typeof(attribute))), + attribute_type = string(nameof(T)), ), ) return diff --git a/src/openapi/cost.jl b/src/openapi/cost.jl index 74457b1..706dd70 100644 --- a/src/openapi/cost.jl +++ b/src/openapi/cost.jl @@ -2,6 +2,20 @@ # models already describe a $/hr curve directly (MATPOWER Table B-4), so unlike PTDP's # table-driven cost.jl there is no fuel-price/heat-rate separation here: a MATPOWER-shaped # generator cost becomes a `CostCurve`, never a `FuelCurve`. +# +# The schema also gained a level of oneOf wrapping since this file was last written: +# `ThermalGenerationCost`/`HydroGenerationCost.variable_operation_cost` is now +# `ProductionVariableCostCurve` (`Union{CostCurve, FuelCurve}`), and +# `ThermalGenerationCost`/`StorageCost.start_up` is now `ThermalGenerationCostStartUp`/ +# `StorageCostStartUp` (`Union{Float64, StartUpStages}`/`Union{Float64, +# StorageCostStartUp2}`) — both plain one-field oneOf wrappers with an auto-generated +# single-arg constructor, so a bare `CostCurve`/`Float64` needs an explicit `T(value)` +# wrap here (this file builds these with a literal kwarg call, not through +# `units.jl`'s `set_value!`, whose `_coerce` would otherwise do this automatically). +# `RenewableGenerationCost`/`LoadCost.variable_operation_cost` stayed plain `CostCurve` — +# not every owner picked up the wrapper, so check each type rather than assuming. +# `CostCurve` itself also gained a `variable_cost_type` discriminator ("COST"), the +# `ProductionVariableCostCurve` counterpart to `FuelCurve`'s own "FUEL". """The schema's declared `vom_cost` default for `CostCurve`/`FuelCurve`: a zero linear input-output curve (Core/common.json defs.CostCurve.properties.vom_cost.default). @@ -9,8 +23,10 @@ linear input-output curve (Core/common.json defs.CostCurve.properties.vom_cost.d the generated `CostCurve`/`FuelCurve` constructors default it to `nothing`.""" function _zero_vom_cost() return PC.InputOutputCurve(; + curve_type = "INPUT_OUTPUT", function_data = PC.InputOutputCurveFunctionData( IC.LinearFunctionData(; + function_type = "LINEAR", proportional_term = 0.0, constant_term = 0.0, ), @@ -23,11 +39,14 @@ end has no cost data to read from a PowerModels dict.""" function _zero_cost_curve() return PC.CostCurve(; - power_units = "NATURAL_UNITS", + power_units = IC.UnitSystem("NATURAL_UNITS"), + variable_cost_type = "COST", value_curve = PC.ValueCurve( PC.InputOutputCurve(; + curve_type = "INPUT_OUTPUT", function_data = PC.InputOutputCurveFunctionData( IC.LinearFunctionData(; + function_type = "LINEAR", proportional_term = 0.0, constant_term = 0.0, ), @@ -54,7 +73,8 @@ function _piecewise_linear_cost(cost_component::Vector{Float64}) first_slope = (second_y - first_y) / (second_x - first_x) fixed = max(0.0, first_y - first_slope * first_x) shifted = [IC.XYCoords(; x = x, y = y - fixed) for (x, y) in points] - return IC.PiecewiseLinearData(; points = shifted), fixed + return IC.PiecewiseLinearData(; function_type = "PIECEWISE_LINEAR", points = shifted), + fixed end """ @@ -83,6 +103,7 @@ function _polynomial_cost(gen_name::AbstractString, cost_component::Vector{Float quadratic_term, proportional_term, constant_term = (get(coeffs, deg, 0.0) for deg in quadratic_degrees) return IC.QuadraticFunctionData(; + function_type = "QUADRATIC", quadratic_term = quadratic_term, proportional_term = proportional_term, constant_term = constant_term, @@ -101,9 +122,10 @@ function make_thermal_cost(gen_name::AbstractString, pm_gen::Dict, sys_mbase::Fl if !haskey(pm_gen, "model") @warn "Generator cost data not included for Generator: $gen_name" return PC.ThermalGenerationCost(; - variable_operation_cost = _zero_cost_curve(), + cost_type = "THERMAL", + variable_operation_cost = PC.ProductionVariableCostCurve(_zero_cost_curve()), fixed = 0.0, - start_up = 0.0, + start_up = PC.ThermalGenerationCostStartUp(0.0), shut_down = 0.0, ) end @@ -118,29 +140,45 @@ function make_thermal_cost(gen_name::AbstractString, pm_gen::Dict, sys_mbase::Fl throw(IS.DataFormatError("$gen_name: unsupported generator cost model=$model")) end return PC.ThermalGenerationCost(; - variable_operation_cost = PC.CostCurve(; - power_units = "COMPONENT_BASE", - value_curve = PC.ValueCurve( - PC.InputOutputCurve(; - function_data = PC.InputOutputCurveFunctionData(function_data), + cost_type = "THERMAL", + variable_operation_cost = PC.ProductionVariableCostCurve( + PC.CostCurve(; + power_units = IC.UnitSystem("COMPONENT_BASE"), + variable_cost_type = "COST", + value_curve = PC.ValueCurve( + PC.InputOutputCurve(; + curve_type = "INPUT_OUTPUT", + function_data = PC.InputOutputCurveFunctionData(function_data), + ), ), + vom_cost = _zero_vom_cost(), ), - vom_cost = _zero_vom_cost(), ), fixed = fixed, - start_up = pm_gen["startup"], + start_up = PC.ThermalGenerationCostStartUp(pm_gen["startup"]), shut_down = pm_gen["shutdown"], ) end """Curtailment cost for a hydro generator: PSCB never derives one from pm data.""" make_hydro_cost() = - PC.HydroGenerationCost(; variable_operation_cost = _zero_cost_curve(), fixed = 0.0) + PC.HydroGenerationCost(; + cost_type = "HYDRO_GEN", + variable_operation_cost = PC.ProductionVariableCostCurve(_zero_cost_curve()), + fixed = 0.0, + ) """Operating cost for a renewable generator: PSCB never derives one from pm data.""" make_renewable_cost() = - PC.RenewableGenerationCost(; variable_operation_cost = _zero_cost_curve()) + PC.RenewableGenerationCost(; + cost_type = "RENEWABLE", + variable_operation_cost = _zero_cost_curve(), + ) """Operating cost for an interruptible load: PSCB never derives one from pm data.""" make_load_cost() = - PC.LoadCost(; variable_operation_cost = _zero_cost_curve(), fixed = 0.0) + PC.LoadCost(; + cost_type = "LOAD", + variable_operation_cost = _zero_cost_curve(), + fixed = 0.0, + ) diff --git a/src/openapi/dc_branch.jl b/src/openapi/dc_branch.jl index 59d155f..67e2528 100644 --- a/src/openapi/dc_branch.jl +++ b/src/openapi/dc_branch.jl @@ -10,21 +10,35 @@ # `data["vscline"]` and `data["interarea_transfer"]` are NOT native PowerModels sections; # see the per-maker docstrings below for how PFFP's own `psse.jl` pre-scales their fields. -"""A linear `TwoTerminalLoss` curve from a pm dict's `loss0`/`loss1` fields, shared by -`TwoTerminalLCCLine` and `TwoTerminalGenericHVDCLine`.""" -function _two_terminal_loss(d::Dict) - return PC.TwoTerminalLoss( - PC.InputOutputCurve(; - function_data = PC.InputOutputCurveFunctionData( - IC.LinearFunctionData(; - proportional_term = d["loss1"], - constant_term = d["loss0"], +"""A linear `LossCurve` with fixed `"NATURAL_UNITS"` power units, shared by every +converter/two-terminal loss curve site: loss values arrive unscaled regardless of the +run's own `power_units` convention (see this file's header), the same fixed-natural +pattern `cost.jl`'s `_zero_cost_curve` uses.""" +function _loss_curve(proportional_term::Float64, constant_term::Float64) + return PC.LossCurve(; + power_units = IC.UnitSystem("NATURAL_UNITS"), + value_curve = PC.LossValueCurve( + PC.InputOutputCurve(; + curve_type = "INPUT_OUTPUT", + function_data = PC.InputOutputCurveFunctionData( + IC.LinearFunctionData(; + function_type = "LINEAR", + proportional_term = proportional_term, + constant_term = constant_term, + ), ), ), ), ) end +"""A linear `LossCurve` from a pm dict's `loss0`/`loss1` fields, shared by +`TwoTerminalLCCLine` and `TwoTerminalGenericHVDCLine`. `TwoTerminalLoss` no longer exists +(the two per-owner loss wrappers collapsed into this one shared `LossCurve`).""" +function _two_terminal_loss(d::Dict) + return _loss_curve(d["loss1"], d["loss0"]) +end + """Two-terminal LCC HVDC line (PSS/E).""" function make_lcc_line!( sys::OpenAPISystem, @@ -36,7 +50,7 @@ function make_lcc_line!( sys_mbase::Float64, ) arc_id = add_arc!(sys, from_id, to_id) - component = PO.TwoTerminalLCCLine() + component = stage(PO.TwoTerminalLCCLine) set_value!(component, :id, register!(reg, "TwoTerminalLCCLine", name)) set_value!(component, :name, name) set_value!(component, :available, Bool(d["available"])) @@ -107,7 +121,7 @@ function make_generic_hvdc_line!( sys_mbase::Float64, ) arc_id = add_arc!(sys, from_id, to_id) - component = PO.TwoTerminalGenericHVDCLine() + component = stage(PO.TwoTerminalGenericHVDCLine) set_value!(component, :id, register!(reg, "TwoTerminalGenericHVDCLine", name)) set_value!(component, :name, name) set_value!(component, :available, d["br_status"] == 1) @@ -190,7 +204,7 @@ function make_vscline!( sys_mbase::Float64, ) arc_id = add_arc!(sys, from_id, to_id) - component = PO.TwoTerminalVSCLine() + component = stage(PO.TwoTerminalVSCLine) set_value!(component, :id, register!(reg, "TwoTerminalVSCLine", name)) set_value!(component, :name, name) set_value!(component, :available, Bool(d["available"])) @@ -227,13 +241,9 @@ function make_vscline!( set_value!( component, :converter_loss_from, - PC.InputOutputCurve(; - function_data = PC.InputOutputCurveFunctionData( - IC.LinearFunctionData(; - proportional_term = IS.get_proportional_term(d["converter_loss_from"]), - constant_term = IS.get_constant_term(d["converter_loss_from"]), - ), - ), + _loss_curve( + IS.get_proportional_term(d["converter_loss_from"]), + IS.get_constant_term(d["converter_loss_from"]), ), ) set_value!(component, :max_dc_current_from, d["max_dc_current_from"], "A") @@ -242,7 +252,7 @@ function make_vscline!( (min = d["qminf"] * sys_mbase, max = d["qmaxf"] * sys_mbase), "MVAr") set_value!(component, :power_factor_weighting_fraction_from, d["power_factor_weighting_fraction_from"], "1") - set_value!(component, :remote_bus_control_from, _psse_remote_bus(d, "REMOT_FROM")) + _set_nullable!(component, :remote_bus_control_from, _psse_remote_bus(d, "REMOT_FROM")) set_value!(component, :rmpct_from, get(get(d, "ext", Dict()), "RMPCT_FROM", 100.0), "1") set_value!(component, :reactive_power_to, get(d, "qt", 0.0) * sys_mbase, "MVAr") if d["dc_voltage_control_to"] @@ -263,13 +273,9 @@ function make_vscline!( set_value!( component, :converter_loss_to, - PC.InputOutputCurve(; - function_data = PC.InputOutputCurveFunctionData( - IC.LinearFunctionData(; - proportional_term = IS.get_proportional_term(d["converter_loss_to"]), - constant_term = IS.get_constant_term(d["converter_loss_to"]), - ), - ), + _loss_curve( + IS.get_proportional_term(d["converter_loss_to"]), + IS.get_constant_term(d["converter_loss_to"]), ), ) set_value!(component, :max_dc_current_to, d["max_dc_current_to"], "A") @@ -278,7 +284,7 @@ function make_vscline!( (min = d["qmint"] * sys_mbase, max = d["qmaxt"] * sys_mbase), "MVAr") set_value!(component, :power_factor_weighting_fraction_to, d["power_factor_weighting_fraction_to"], "1") - set_value!(component, :remote_bus_control_to, _psse_remote_bus(d, "REMOT_TO")) + _set_nullable!(component, :remote_bus_control_to, _psse_remote_bus(d, "REMOT_TO")) set_value!(component, :rmpct_to, get(get(d, "ext", Dict()), "RMPCT_TO", 100.0), "1") set_value!(component, :rated_dc_voltage, d["rated_dc_voltage"], "kV") set_value!(component, :base_power, sys_mbase, "MVA") @@ -287,6 +293,25 @@ function make_vscline!( return end +""" +Assign a field whose declared type keeps `Nothing` as a real schema value (not merely +the optional-field placeholder every generated field also carries): units.jl's +`_concrete_field_type` always strips both `Absent` and `Nothing` before computing the +single concrete type `_coerce` builds, on the assumption that `Nothing` is only ever that +placeholder, so `_coerce` cannot construct a bare `nothing` for a field like +`remote_bus_control_from`/`_to`, whose schema spells "regulates its own terminal bus" as an +explicit `null` rather than an absent field. Only these two fields need this today; a +non-`nothing` value still goes through the normal `set_value!` enforcement. +""" +function _set_nullable!(s::Staged, prop::Symbol, value) + if isnothing(value) + s.fields[prop] = nothing + else + set_value!(s, prop, value) + end + return +end + """PSS/E encodes "no remote regulated bus" as `REMOT = 0`; the schema's `remote_bus_control_*` is nullable with a valid range `>= 1` and spells local-terminal-bus regulation as `nothing`.""" @@ -396,7 +421,7 @@ function read_area_interchanges!(sys::OpenAPISystem, data::Dict; kwargs...) end name = "$(area_from_name)_$(area_to_name)_$(transfer_id)" - component = PO.AreaInterchange() + component = stage(PO.AreaInterchange) set_value!(component, :id, register!(reg, "AreaInterchange", name)) set_value!(component, :name, name) set_value!(component, :available, true) diff --git a/src/openapi/device_base.jl b/src/openapi/device_base.jl index a13d0db..3f472e2 100644 --- a/src/openapi/device_base.jl +++ b/src/openapi/device_base.jl @@ -134,13 +134,15 @@ const _DEVICEBASE_INSTANCE_DISPATCHED = Dict{Tuple{String, Symbol}, Symbol}( # confirms this is fixed-natural, multiplied by the SYSTEM base in both document # conventions (export_handwritten.jl's FixedAdmittance section), not document-unit- # system-governed at all (same shape as Area/LoadZone's peak fields). - ("FixedAdmittance", :Y) => :skip, - ("SwitchedAdmittance", :Y) => :skip, - ("SwitchedAdmittance", :Y_increase) => :skip, + # `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. + # `solved_admittance` replaced SwitchedAdmittance's old fixed `Y` field entirely, sharing + # `y_increase`'s admittance_units-discriminated unit, hence the same `:skip`. ("SwitchedAdmittance", :solved_admittance) => :skip, # parameter_units/dc_voltage_units/admittance_units always "NATURAL_UNITS" for the # PSS/E-native LCC/VSC fields (dc_branch.jl) -- fixed ohm/kV/S regardless of the @@ -255,44 +257,50 @@ 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)). `nothing` passes the generated constructor's -own enum validation (unlike an invalid string, which would raise `OpenAPI.ValidationException` -there instead of letting the probe reach the declared-quantity method it targets).""" +(see [`_power_units_only_dispatch`](@ref)).""" function _with_power_units( o::T, - power_units::Union{Nothing, AbstractString}, -) where {T <: OpenAPI.APIModel} + power_units::AbstractString, +) where {T <: IC.APIModel} return T(; ( - f => (f === :power_units ? power_units : getfield(o, f)) for f in fieldnames(T) + f => (f === :power_units ? IC.UnitSystem(power_units) : getfield(o, f)) + for f in fieldnames(T) )..., ) end """ -Whether `prop`'s instance-level dispatch actually branches on `power_units`, rather than -depending only on some other, unaccounted-for discriminator that happens to resolve -today regardless of what `power_units` holds. `representative`'s own fields (other than -`power_units`) are already whatever the real reader that built it set them to; poisoning -`power_units` alone to `nothing` and checking whether resolution fails BECAUSE of it (the -schema's generated error always names the deciding field, e.g. `"...no unit declared for -power_units=..."`) is the only test that distinguishes the two without requiring every one -of this schema's other discriminators to be listed here. Every ordinary power-family field -across the 32 power-bearing types resolves this way (confirmed against the current -schema); a field genuinely gated by another discriminator (`power_mode`, -`parameter_units`, `dc_control_from`, ...) instead must go through the explicit -`_DEVICEBASE_INSTANCE_DISPATCHED` registry and its loud error on a miss, never through -this fallback. +Whether `prop`'s instance-level dispatch resolves consistently across both of `power_units`' +valid values, `representative`'s own fields (other than `power_units`) held exactly as the +real reader that built it set them. + +Every generated component now declares `power_units` a required, enum-validated field (no +`nothing`/`ABSENT` "unset" state), unlike the pre-1.0 runtime, so this can no longer poison +it with an out-of-domain sentinel and read the schema's own error message naming the +deciding field — the technique the previous runtime supported. Resolving successfully +under BOTH valid values is the closest still-available signal: every ordinary power-family +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 +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 +for a pair already confirmed NOT to need one of those siblings, so a resolution failure +here means a real gap the registry has not been taught about yet, not a false positive. """ function _power_units_only_dispatch(representative::T, prop::Symbol) where {T} - poisoned = _with_power_units(representative, nothing) - try - IC.declared_quantity(poisoned, Val(prop)) - catch e - e isa ErrorException || rethrow() - return occursin("power_units=", e.msg) + for power_units in ("NATURAL_UNITS", "COMPONENT_BASE") + variant = _with_power_units(representative, power_units) + try + IC.declared_quantity(variant, Val(prop)) + catch e + e isa ErrorException || rethrow() + return false + end end - return false + return true end """ @@ -337,14 +345,26 @@ function _devicebase_classification( end _devicebase_scale(::Nothing, ::Float64) = nothing +_devicebase_scale(::Absent, ::Float64) = ABSENT _devicebase_scale(x::Real, base::Float64) = Float64(x) / base """A compound field (`MinMax`/`UpDown`/`FromTo`/`FromToToFrom`, ...): every one of these PO -types holds only `Real`/`Nothing` leaves, so scaling every field generically is exact — -mirrors PowerSystems' own per-shape `_minmax_po_scaled`/`_updown_po_scaled_optional`/... -without needing one method per compound type here.""" -function _devicebase_scale(x::T, base::Float64) where {T <: OpenAPI.APIModel} - return T(; (f => _devicebase_scale(getfield(x, f), base) for f in fieldnames(T))...) +types holds only `Real`/`Nothing`/`Absent` leaves (plus the `additional_properties` every +generated struct carries, held as-is rather than scaled), so scaling every field +generically is exact — mirrors PowerSystems' own per-shape `_minmax_po_scaled`/ +`_updown_po_scaled_optional`/... without needing one method per compound type here.""" +function _devicebase_scale(x::T, base::Float64) where {T <: IC.APIModel} + return T(; + ( + f => ( + if f === :additional_properties + getfield(x, f) + else + _devicebase_scale(getfield(x, f), base) + end + ) for f in fieldnames(T) + )..., + ) end """Own device base for `po`, naming `key`/`prop` in the error when the type has neither a @@ -361,6 +381,55 @@ function _devicebase_own_base(po, key::AbstractString, prop::Symbol) return Float64(po.base_power) end +""" +Every field-level verdict for `T.prop` (`key`'s type), classified once from `representative` +— every component of a given type shares this run's `power_units`, stamped uniformly by +[`add_component!`](@ref), so one classification pass per type is enough. +""" +function _devicebase_classifications(representative::T, key::AbstractString) where {T} + classifications = Dict{Symbol, Symbol}() + for prop in fieldnames(T) + classifications[prop] = _devicebase_classification(representative, key, prop) + end + return classifications +end + +""" +`po` rebuilt with every `:skip`-classified field held as-is and every convertible field +scaled by its resolved base — components are immutable, so a converted component replaces +rather than mutates the original.""" +function _devicebase_rebuild( + po::T, + key::AbstractString, + classifications::Dict{Symbol, Symbol}, + system_base::Float64, +) where {T} + kwargs = Dict{Symbol, Any}() + for prop in fieldnames(T) + classification = classifications[prop] + if classification === :skip + 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 + system_base + else + _devicebase_own_base(po, key, prop) + end + kwargs[prop] = _devicebase_scale(getfield(po, prop), base) + end + return T(; kwargs...) +end + """ apply_device_base_conversion!(sys::OpenAPISystem) @@ -377,24 +446,9 @@ function apply_device_base_conversion!(sys::OpenAPISystem) for key in component_type_names(sys) components = get_components(sys, key) isempty(components) && continue - T = eltype(components) - for prop in fieldnames(T) - classification = _devicebase_classification(first(components), key, prop) - classification === :skip && continue - for po in components - resolved = if classification === :dynamic - _devicebase_dynamic(key, prop, po) - else - classification - end - resolved === :skip && continue - base = if resolved === :convert_system - system_base - else - _devicebase_own_base(po, key, prop) - end - setproperty!(po, prop, _devicebase_scale(getproperty(po, prop), base)) - end + classifications = _devicebase_classifications(first(components), key) + for (ix, po) in enumerate(components) + components[ix] = _devicebase_rebuild(po, key, classifications, system_base) end end return sys diff --git a/src/openapi/generation.jl b/src/openapi/generation.jl index d78decd..fcc62c2 100644 --- a/src/openapi/generation.jl +++ b/src/openapi/generation.jl @@ -189,6 +189,26 @@ function _gen_base_and_limits(pm_gen::Dict, gen_name::AbstractString, sys_mbase: ramp_limits end +"""Thermal `:status` enum from a pm dict's `"gen_status"` flag: zero is `"OFFLINE"`, +nonzero is `"ONLINE"`. `Bool <: Integer`, so this one method also covers PSS/E's +`_determine_injector_status`, which sometimes stores a `Bool` rather than Matpower's `Int` +column (the previous `Bool(...)` coercion this replaces).""" +function _thermal_status(gen_name::AbstractString, gen_status::Integer) + if iszero(gen_status) + return "OFFLINE" + else + return "ONLINE" + end +end + +function _thermal_status(gen_name::AbstractString, gen_status) + throw( + IS.DataFormatError( + "generator $gen_name has non-integer gen_status: $gen_status", + ), + ) +end + """Thermal generator; the cost branch lives in `make_thermal_cost` (cost.jl).""" function make_thermal_generator!( sys::OpenAPISystem, @@ -203,12 +223,15 @@ function make_thermal_generator!( _is_likely_motor_load(pm_gen, gen_name) extras = _generator_ext(pm_gen) - component = PO.ThermalStandard() + component = stage(PO.ThermalStandard) set_value!(component, :id, register!(reg, "ThermalStandard", gen_name)) set_value!(component, :name, gen_name) set_value!(component, :available, Bool(pm_gen["gen_status"])) - set_value!(component, :status, Bool(pm_gen["gen_status"])) + set_value!(component, :status, _thermal_status(gen_name, pm_gen["gen_status"])) set_value!(component, :bus, bus_id) + # operation_cost is a required oneOf field `_shadow` can't placeholder (see `stage`'s + # docstring); stage it before any power-family field below. + set_value!(component, :operation_cost, make_thermal_cost(gen_name, pm_gen, sys_mbase)) set_value!(component, :active_power, _natural_value(pm_gen["pg"] * base_conversion, mbase), "MW") @@ -221,7 +244,6 @@ function make_thermal_generator!( _natural_value(reactive_power_limits, mbase), "MVAr") set_optional_value!(component, :ramp_limits, _natural_value(ramp_limits, mbase), "MW/min") - set_value!(component, :operation_cost, make_thermal_cost(gen_name, pm_gen, sys_mbase)) set_value!(component, :base_power, mbase, "MVA") set_value!(component, :prime_mover_type, prime_mover_type(get(pm_gen, "type", "OT"))) set_value!(component, :fuel, thermal_fuel(get(pm_gen, "fuel", "OTHER"))) @@ -246,25 +268,28 @@ function _make_hydro_dispatch_body!( mbase, base_conversion, active_power_limits, reactive_power_limits, rating, ramp_limits = _gen_base_and_limits(pm_gen, gen_name, sys_mbase) - component = PO.HydroDispatch() + component = stage(PO.HydroDispatch) set_value!(component, :id, register!(reg, "HydroDispatch", gen_name)) set_value!(component, :name, gen_name) set_value!(component, :available, Bool(pm_gen["gen_status"])) set_value!(component, :bus, bus_id) + # operation_cost and prime_mover_type are both required enum/oneOf fields `_shadow` + # can't placeholder (see `stage`'s docstring); stage both before any power-family + # field below. + set_value!(component, :operation_cost, make_hydro_cost()) + set_value!(component, :prime_mover_type, prime_mover_type(get(pm_gen, "type", "OT"))) set_value!(component, :active_power, _natural_value(pm_gen["pg"] * base_conversion, mbase), "MW") set_value!(component, :reactive_power, _natural_value(pm_gen["qg"] * base_conversion, mbase), "MVAr") set_value!(component, :rating, _natural_value(rating, mbase), "MVA") - set_value!(component, :prime_mover_type, prime_mover_type(get(pm_gen, "type", "OT"))) set_value!(component, :active_power_limits, _natural_value(active_power_limits, mbase), "MW") set_value!(component, :reactive_power_limits, _natural_value(reactive_power_limits, mbase), "MVAr") set_optional_value!(component, :ramp_limits, _natural_value(ramp_limits, mbase), "MW/min") - set_value!(component, :operation_cost, make_hydro_cost()) set_value!(component, :base_power, mbase, "MVA") add_component!(sys, component) return @@ -322,22 +347,25 @@ function make_renewable_dispatch!( # second multiply is the double-application the docstring names. rating = rating * base_conversion - component = PO.RenewableDispatch() + component = stage(PO.RenewableDispatch) set_value!(component, :id, register!(reg, "RenewableDispatch", gen_name)) set_value!(component, :name, gen_name) set_value!(component, :available, Bool(pm_gen["gen_status"])) set_value!(component, :bus, bus_id) + # operation_cost and prime_mover_type are both required enum/oneOf fields `_shadow` + # can't placeholder (see `stage`'s docstring); stage both before any power-family + # field below. + set_value!(component, :operation_cost, make_renewable_cost()) + set_value!(component, :prime_mover_type, prime_mover_type(get(pm_gen, "type", "OT"))) set_value!(component, :active_power, _natural_value(pm_gen["pg"] * base_conversion, mbase), "MW") set_value!(component, :reactive_power, _natural_value(pm_gen["qg"] * base_conversion, mbase), "MVAr") set_value!(component, :rating, _natural_value(rating, mbase), "MVA") - set_value!(component, :prime_mover_type, prime_mover_type(get(pm_gen, "type", "OT"))) set_value!(component, :reactive_power_limits, _natural_value(reactive_power_limits, mbase), "MVAr") set_value!(component, :power_factor, 1.0, "1") - set_value!(component, :operation_cost, make_renewable_cost()) set_value!(component, :base_power, mbase, "MVA") add_component!(sys, component) return @@ -356,11 +384,14 @@ function make_renewable_nondispatch!( mbase = _device_base_power(pm_gen, gen_name, sys_mbase) base_conversion = sys_mbase / mbase - component = PO.RenewableNonDispatch() + component = stage(PO.RenewableNonDispatch) set_value!(component, :id, register!(reg, "RenewableNonDispatch", gen_name)) set_value!(component, :name, gen_name) set_value!(component, :available, Bool(pm_gen["gen_status"])) set_value!(component, :bus, bus_id) + # prime_mover_type is a required enum field `_shadow` can't placeholder (see `stage`'s + # docstring); stage it before any power-family field below. + set_value!(component, :prime_mover_type, prime_mover_type(get(pm_gen, "type", "OT"))) set_value!(component, :active_power, _natural_value(pm_gen["pg"] * base_conversion, mbase), "MW") @@ -368,7 +399,6 @@ function make_renewable_nondispatch!( _natural_value(pm_gen["qg"] * base_conversion, mbase), "MVAr") set_value!(component, :rating, _natural_value(Float64(pm_gen["pmax"]) * base_conversion, mbase), "MVA") - set_value!(component, :prime_mover_type, prime_mover_type(get(pm_gen, "type", "OT"))) set_value!(component, :power_factor, 1.0, "1") set_value!(component, :base_power, mbase, "MVA") add_component!(sys, component) @@ -392,7 +422,7 @@ function make_synchronous_condenser!( rating = max(abs(pm_gen["qmax"]), abs(pm_gen["qmin"])) * base_conversion extras = _generator_ext(pm_gen) - component = PO.SynchronousCondenser() + component = stage(PO.SynchronousCondenser) set_value!(component, :id, register!(reg, "SynchronousCondenser", gen_name)) set_value!(component, :name, gen_name) set_value!(component, :available, Bool(pm_gen["gen_status"])) @@ -431,11 +461,26 @@ function make_storage!( # per-unit value, not a true MVA base — see docstring. thermal_rating = Float64(d["thermal_rating"]) - component = PO.EnergyReservoirStorage() + component = stage(PO.EnergyReservoirStorage) set_value!(component, :id, register!(reg, "EnergyReservoirStorage", storage_name)) set_value!(component, :name, storage_name) set_value!(component, :available, Bool(d["status"])) set_value!(component, :bus, bus_id) + # See make_thermal_generator! — operation_cost (a required oneOf) must be staged + # before any field below needing a shadow (storage_capacity's declared unit depends + # on this component's own energy_units). + # fixed/shut_down are schema-required now (PSCB never derives them from pm data + # either, hence zero); start_up wraps in PC.StorageCostStartUp (see cost.jl's header). + set_value!( + component, + :operation_cost, + PC.StorageCost(; + cost_type = "STORAGE", + fixed = 0.0, + shut_down = 0.0, + start_up = PC.StorageCostStartUp(0.0), + ), + ) set_value!(component, :prime_mover_type, "BA") set_value!(component, :storage_technology_type, "OTHER_CHEM") set_value!(component, :storage_capacity, _natural_value(energy_rating, thermal_rating), @@ -473,7 +518,6 @@ function make_storage!( "MVAr", ) set_value!(component, :base_power, thermal_rating, "MVA") - set_value!(component, :operation_cost, PC.StorageCost(; start_up = 0.0)) add_component!(sys, component) return end diff --git a/src/openapi/load.jl b/src/openapi/load.jl index 0b05ac1..a419454 100644 --- a/src/openapi/load.jl +++ b/src/openapi/load.jl @@ -80,7 +80,7 @@ function _make_standard_load!( name::AbstractString, base_power::Float64, ) - load = PO.StandardLoad() + load = stage(PO.StandardLoad) _set_load_identity!(load, reg, "StandardLoad", name, bus_id, d["status"], base_power) set_value!(load, :conformity, _conformity_string(Int(d["conformity"]))) _set_zip_fields!(load, d, base_power) @@ -96,7 +96,7 @@ function _make_interruptible_standardload!( name::AbstractString, base_power::Float64, ) - load = PO.InterruptibleStandardLoad() + load = stage(PO.InterruptibleStandardLoad) _set_load_identity!(load, reg, "InterruptibleStandardLoad", name, bus_id, d["status"], base_power) set_value!(load, :operation_cost, make_load_cost()) @@ -114,7 +114,7 @@ function _make_power_load!( name::AbstractString, base_power::Float64, ) - load = PO.PowerLoad() + load = stage(PO.PowerLoad) _set_load_identity!(load, reg, "PowerLoad", name, bus_id, d["status"], base_power) set_value!(load, :active_power, d["pd"] * base_power, "MW") set_value!(load, :reactive_power, d["qd"] * base_power, "MVAr") @@ -143,15 +143,17 @@ function _make_dgen_renewable!( name = string(load_name, "_dgen") active_power = dgen["pg"] * base_power reactive_power = dgen["qg"] * base_power - component = PO.RenewableNonDispatch() + component = stage(PO.RenewableNonDispatch) set_value!(component, :id, register!(reg, "RenewableNonDispatch", name)) set_value!(component, :name, name) set_value!(component, :available, Bool(dgen["status"])) set_value!(component, :bus, bus_id) + # prime_mover_type is a required enum field `_shadow` can't placeholder (see `stage`'s + # docstring); stage it before active_power below. + set_value!(component, :prime_mover_type, "OT") set_value!(component, :active_power, active_power, "MW") set_value!(component, :reactive_power, reactive_power, "MVAr") set_value!(component, :rating, hypot(dgen["pg"], dgen["qg"]) * base_power, "MVA") - set_value!(component, :prime_mover_type, "OT") set_value!(component, :power_factor, 1.0, "1") set_value!(component, :base_power, base_power, "MVA") add_component!(sys, component) diff --git a/src/openapi/shunt.jl b/src/openapi/shunt.jl index c93a6ab..9e805ba 100644 --- a/src/openapi/shunt.jl +++ b/src/openapi/shunt.jl @@ -19,7 +19,7 @@ function make_fixed_admittance!( ) base_power = get_base_power(sys) - component = PO.FixedAdmittance() + component = stage(PO.FixedAdmittance) set_value!(component, :id, register!(reg, "FixedAdmittance", name)) set_value!(component, :name, name) set_value!(component, :available, Bool(d["status"])) @@ -28,7 +28,7 @@ function make_fixed_admittance!( set_value!(component, :admittance_units, "COMPONENT_MVAR") set_value!( component, - :Y, + :y, (real = d["gs"] * base_power, imag = d["bs"] * base_power), "MVAr", ) @@ -57,25 +57,25 @@ function _switched_admittance_control_mode(code::Integer) end """ -Assign `Y_increase`: an array of complex admittances sharing `admittance_units`'s +Assign `y_increase`: an array of complex admittances sharing `admittance_units`'s discriminated unit. `units.jl`'s generic compound-value path builds ONE compound object per call and cannot construct a `Vector` of them; this is the only array-of-compound shape any reader needs, so it reuses `units.jl`'s private `_declared`/`_convert` here rather than extending `set_value!` for a single call site. """ function _set_y_increase!( - component, + component::Staged, values::Vector{ComplexF64}, source_unit::AbstractString, ) - target, quantity = _declared(component, :Y_increase) + target, quantity = _declared(component, :y_increase) converted = [ IC.ComplexNumber(; - real = _convert(component, :Y_increase, real(v), source_unit, target, quantity), - imag = _convert(component, :Y_increase, imag(v), source_unit, target, quantity), + real = _convert(component, :y_increase, real(v), source_unit, target, quantity), + imag = _convert(component, :y_increase, imag(v), source_unit, target, quantity), ) for v in values ] - setproperty!(component, :Y_increase, converted) + component.fields[:y_increase] = converted return end @@ -86,7 +86,14 @@ Switched admittance (PSS/E `SWITCHED SHUNT`). 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 -`Y` and `Y_increase`. +`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"]` +(PSS/E BINIT, the solved-case total) now lands on `solved_admittance` instead, and +`d["gs"]` (always `0.0` for a switched shunt) has no remaining target. `initial_status` +(already per-block Vector{Int} from `psse.jl`) is `number_engaged` now, not a bare status +flag. """ function make_switched_admittance!( sys::OpenAPISystem, @@ -98,18 +105,12 @@ function make_switched_admittance!( control_mode = _switched_admittance_control_mode(Int(d["control_mode"])) base_power = get_base_power(sys) - component = PO.SwitchedAdmittance() + component = stage(PO.SwitchedAdmittance) set_value!(component, :id, register!(reg, "SwitchedAdmittance", name)) set_value!(component, :name, name) set_value!(component, :available, Bool(d["status"])) set_value!(component, :bus, bus_id) set_value!(component, :admittance_units, "COMPONENT_MVAR") - set_value!( - component, - :Y, - (real = d["gs"] * base_power, imag = d["bs"] * base_power), - "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"] @@ -170,7 +171,7 @@ function make_facts!( end control_mode = _facts_control_mode(Int(d["control_mode"])) - component = PO.FACTSControlDevice() + component = stage(PO.FACTSControlDevice) set_value!(component, :id, register!(reg, "FACTSControlDevice", name)) set_value!(component, :name, name) set_value!(component, :available, Bool(d["available"])) diff --git a/src/openapi/switch_breaker.jl b/src/openapi/switch_breaker.jl index e3f1da1..e9ee3d8 100644 --- a/src/openapi/switch_breaker.jl +++ b/src/openapi/switch_breaker.jl @@ -58,7 +58,7 @@ function make_switch_breaker!( arc_id = add_arc!(sys, from_id, to_id) state = Int(d["state"]) - component = PO.DiscreteControlledACBranch() + component = stage(PO.DiscreteControlledACBranch) set_value!(component, :id, register!(reg, "DiscreteControlledACBranch", name)) set_value!(component, :name, name) set_value!(component, :available, Bool(state)) diff --git a/src/openapi/topology.jl b/src/openapi/topology.jl index 60eae1b..8830675 100644 --- a/src/openapi/topology.jl +++ b/src/openapi/topology.jl @@ -108,7 +108,7 @@ function _ensure_area!(sys::OpenAPISystem, data::Dict, name::AbstractString) if has_id(reg, "Area", name) return get_id(reg, "Area", name) end - area = PO.Area() + area = stage(PC.Area) id = register!(reg, "Area", name) set_value!(area, :id, id) set_value!(area, :name, name) @@ -155,7 +155,7 @@ function read_loadzones!(sys::OpenAPISystem, data::Dict; kwargs...) for zone in zones name = _get_name(zone) active, reactive = get(peaks, zone, (0.0, 0.0)) - load_zone = PO.LoadZone() + load_zone = stage(PC.LoadZone) set_value!(load_zone, :id, register!(reg, "LoadZone", name)) set_value!(load_zone, :name, name) set_value!(load_zone, :peak_active_power, active, "MW") @@ -190,7 +190,7 @@ function read_bus!(sys::OpenAPISystem, data::Dict; kwargs...) area_id = _ensure_area!(sys, data, _get_area_name(d["area"])) zone_id = get_id(reg, "LoadZone", _get_zone_name(d["zone"])) - bus = PO.ACBus() + bus = stage(PC.ACBus) set_value!(bus, :id, register_bus!(reg, number, name)) set_value!(bus, :number, number) set_value!(bus, :name, name) @@ -202,13 +202,15 @@ function read_bus!(sys::OpenAPISystem, data::Dict; kwargs...) set_value!(bus, :angle, d["va"], "rad") set_value!(bus, :magnitude, d["vm"], "pu") set_value!(bus, :voltage_limits, (min = d["vmin"], max = d["vmax"]), "pu") - add_component!(sys, bus) # PSS/E's extended-bus-number slack convention (ISW), mapped onto "area_slack" - # while parsing; overrides whatever bus_type said, matching the oracle. + # while parsing; overrides whatever bus_type said, matching the oracle. Must be + # staged before add_component! materializes the bus — components are immutable + # once built, so a later set_value! would no longer reach the stored copy. if get(d, "area_slack", false) set_value!(bus, :bustype, "SLACK") end + add_component!(sys, bus) end return end @@ -221,7 +223,7 @@ Parallel circuits (PSS/E allows several `CKT`s on one bus pair) share one arc. function add_arc!(sys::OpenAPISystem, from_id::Int, to_id::Int) id, created = arc_id!(get_registry(sys), from_id, to_id) if created - arc = PO.Arc() + arc = stage(PC.Arc) set_value!(arc, :id, id) set_value!(arc, :from_id, from_id) set_value!(arc, :to_id, to_id) diff --git a/src/openapi/units.jl b/src/openapi/units.jl index a8ad8c2..d804de4 100644 --- a/src/openapi/units.jl +++ b/src/openapi/units.jl @@ -1,58 +1,208 @@ -# Unit-checked assignment onto generated OpenAPI components. -# -# Components are built empty and populated one property at a time, mirroring how -# OpenAPI.jl itself deserializes them (`from_json(T(), json)` then per-property). -# Every numeric assignment names the unit of the incoming value; the declared unit -# comes from the schema annotations generated into PowerOpenAPIModels. -# -# The two arities are the enforcement. A property that declares a unit can only be -# written by the 4-argument form, and one that does not can only be written by the -# 3-argument form, so the check cannot be skipped by choosing the shorter call. -# -# Assignment goes through `setproperty!` rather than `setfield!`, which runs the -# generated `validate_property` and so keeps enum and range checks in force. -# -# A property annotated `x-unit-base` is per-unit on a sibling property rather than on -# a scalar factor. Assigning one converts through that sibling's own declared unit, -# so the sibling must be assigned first. +""" +Unit-checked assignment onto generated OpenAPI components. + +Generated component types are immutable `Base.@kwdef struct`s with no common mutable +state, so a builder cannot allocate one empty and mutate it field by field the way the +pre-1.0 OpenAPI.jl runtime allowed. [`stage`](@ref) opens a mutable scratch dict typed to +the target type instead; `set_value!`/`get_value` read and write that dict, resolving unit +and compound-type information from `fieldtype` and the generated unit metadata; +[`materialize`](@ref) (called from `add_component!`/`add_supplemental_attribute!` in +`container.jl`) builds the real immutable struct once, from every field accumulated so far. + +The two `set_value!` arities are the enforcement, as before: a property that declares a +unit can only be written by the 4-argument form, and one that does not can only be written +by the 3-argument form, so the check cannot be skipped by choosing the shorter call. +A property annotated `x-unit-base` (`ACBus.magnitude`/`voltage_limits`, `Source. +internal_voltage`, ...) is per-unit on a SIBLING PROPERTY'S OWN VALUE rather than a fixed +scalar factor; assigning or reading one converts through that sibling's own declared unit, +so the sibling must be staged first. """ -Constructor for a compound property, e.g. `MinMax` for `ACBus.voltage_limits`. -Generated property types are `Union{Nothing, T}`; drop the `Nothing` arm. """ -function _compound_type(o::OpenAPI.APIModel, prop::Symbol) - ftype = OpenAPI.property_type(typeof(o), prop) - concrete = filter(t -> t !== Nothing, Base.uniontypes(ftype)) +Mutable staging for a to-be-immutable OpenAPI model type `T`. + +`stage(T)` opens one of these with an empty field dict; `set_value!`/`get_value` accumulate +into it; `materialize` builds the real `T` in one kwarg call once every field the caller +means to set has been staged. Fields are stored already coerced to the exact concrete type +`T` declares (see [`_coerce`](@ref)), so materialization is a plain, conversion-free +`T(; fields...)`. +""" +struct Staged{T} + fields::Dict{Symbol, Any} +end + +"""Open a staging area for `T`. Replaces the old empty-construct `T()`.""" +stage(::Type{T}) where {T} = Staged{T}(Dict{Symbol, Any}()) + +"""Build the real, immutable `T` from every field staged so far.""" +materialize(s::Staged{T}) where {T} = T(; s.fields...) + +""" +A value already of the target type is a no-op; otherwise call the target type's own +constructor on it. + +This is how a raw `String` such as `"ONLINE"` becomes the validating enum wrapper a +generated field actually declares (`OperationalStates`, `UnitSystem`, ...): there is no +generic `convert` fallback for these types, and assignment must run the same validation the +old mutable `setproperty!` path did. +""" +_coerce(::Type{T}, value::T) where {T} = value +_coerce(::Type{T}, value) where {T} = T(value) + +"""Whether `Absent` is one of `u`'s member types.""" +_has_absent(u::Union) = Absent in Base.uniontypes(u) +_has_absent(::Type) = false + +"""The member types of `t`; a non-`Union` type is its own sole member.""" +_concrete_types(u::Union) = Base.uniontypes(u) +_concrete_types(t::Type) = (t,) + +""" +The single concrete type a field can actually hold, with the `Absent`/`Nothing` arms the +generator adds to every optional field stripped off. + +Used both to build a compound value (`MinMax`, `UpDown`, `FromTo`, ...) and to know what +[`_coerce`](@ref) should convert a plain value into. +""" +function _concrete_field_type(::Type{T}, prop::Symbol) where {T} + ftype = fieldtype(T, prop) + concrete = filter(t -> t !== Nothing && t !== Absent, collect(_concrete_types(ftype))) if length(concrete) != 1 throw( IS.DataFormatError( - "$(nameof(typeof(o))).$prop is not a single compound type: $ftype", + "$(nameof(T)).$prop is not a single concrete type: $ftype", ), ) end return only(concrete) end +"""A placeholder value for a required field this object has not staged yet. + +Only used to complete a [`_shadow`](@ref) instance so the generated per-instance +`declared_unit`/`declared_quantity` methods have something to dispatch on; the discriminated +field they actually read is always staged first (by convention, before its dependent +fields), so a placeholder is never the value such a method consults. +""" +_placeholder(::Type{T}) where {T <: Integer} = zero(T) +_placeholder(::Type{T}) where {T <: AbstractFloat} = zero(T) +_placeholder(::Type{Bool}) = false +_placeholder(::Type{String}) = "" +_placeholder(::Type{Dict{K, V}}) where {K, V} = Dict{K, V}() +_placeholder(::Type{Vector{T}}) where {T} = T[] + +""" +A placeholder for a required oneOf-wrapper field (`FunctionData`, `*OperationCost`, ...): +the first declared variant, itself placeholder-built recursively. + +A shadow only needs *some* valid instance to satisfy the outer struct's required kwarg — +the generated `declared_unit`/`declared_quantity` methods it stands in for never read a +oneOf field's own contents, only a plain sibling discriminator's — so which variant is +picked is immaterial. `EnumAPIModel` gets no such case: unlike a oneOf member, an enum's +inner constructor validates against a fixed string whitelist this package cannot enumerate, +so a required enum field still falls through to the generic fallback below. +""" +function _placeholder(::Type{T}) where {T <: IC.OneOfAPIModel} + variant = first(Base.uniontypes(fieldtype(T, :value))) + return T(_placeholder(variant)) +end + +""" +Recursive fallback: a required compound "shape" type (`MinMax`, `UpDown`, `FromTo`, ...) is +plain numbers with no validation, so a zeroed instance is always constructible. A field +named for one of the [`_DEFAULT_BASIS`](@ref) discriminators (`power_units`, ...) uses that +same default, whatever struct it turns up nested in — a oneOf variant's own basis field +(`CostCurve.power_units`, say) is exactly as placeholder-able as the top-level one +`_default_bases!` defaults. A required field with no such shape and no case above (an enum +wrapper outside that known set) means a caller staged a discriminated numeric field before +the enum field its shadow needs — a genuine ordering bug, so this fails loudly rather than +guessing a value. """ -A power-bearing component's own `power_units` governs the declared unit of every -power-family field on it (schema instance dispatch), so it must hold a real value -before any such field is assigned — but a freshly constructed component has it unset, -and every reader in this package always computes and assigns natural-unit values -first (`add_component!` restamps `power_units` to the run's actual convention once the -component is complete; see `device_base.jl`). Default it to `"NATURAL_UNITS"` here, -the first time it is needed, rather than requiring every construction site to stamp it. +function _placeholder(::Type{T}) where {T} + kwargs = Dict{Symbol, Any}() + for name in fieldnames(T) + name === :additional_properties && continue + ftype = fieldtype(T, name) + _has_absent(ftype) && continue + concrete = _concrete_field_type(T, name) + kwargs[name] = if haskey(_DEFAULT_BASIS, name) + _coerce(concrete, _DEFAULT_BASIS[name]) + else + _placeholder(concrete) + end + end + return T(; kwargs...) +end + +""" +A throw-away, fully valid `T` built from this object's fields staged so far, standing in for +the real (not-yet-complete) component so the generated per-instance `declared_unit`/ +`declared_quantity` methods — which resolve a discriminated field's unit by reading a sibling +basis field via `getproperty` — have a real `T` to dispatch on. Every field not yet staged +gets a [`_placeholder`](@ref). """ -function _default_power_units!(o::OpenAPI.APIModel) - T = typeof(o) - if hasfield(T, :power_units) && getproperty(o, :power_units) === nothing - setproperty!(o, :power_units, "NATURAL_UNITS") +function _shadow(s::Staged{T}) where {T} + kwargs = Dict{Symbol, Any}() + for name in fieldnames(T) + name === :additional_properties && continue + if haskey(s.fields, name) + kwargs[name] = s.fields[name] + else + ftype = fieldtype(T, name) + _has_absent(ftype) && continue + kwargs[name] = _placeholder(_concrete_field_type(T, name)) + end + end + return T(; kwargs...) +end + +""" +Basis-selector fields this package leaves optional, defaulted the first time a +declared-unit lookup needs one — mirroring `add_component!`'s later restamp of `power_units` +to the run's real convention. The default per field matches the literal source-unit label +every reader in this package already passes for that field's dependents: `power_units` +defaults to `"NATURAL_UNITS"` because readers always pass natural-unit labels (`"MW"`, ...) +and `add_component!` restamps it to the run's real convention regardless; `parameter_units` +and `admittance_units` default to `"COMPONENT_BASE"` because the impedance and admittance +columns readers pass are always already per unit (`"pu"`); `energy_units` (which names the +energy unit directly, `"MWH"`/`"MWMIN"`, rather than choosing a natural-vs-per-unit basis) +defaults to `"MWH"` because readers always pass `"MWh"`. +""" +const _DEFAULT_BASIS = Dict{Symbol, String}( + :power_units => "NATURAL_UNITS", + :energy_units => "MWH", + :parameter_units => "COMPONENT_BASE", + :admittance_units => "COMPONENT_BASE", +) + +function _default_bases!(s::Staged{T}) where {T} + for (name, default) in _DEFAULT_BASIS + if hasfield(T, name) && !haskey(s.fields, name) + s.fields[name] = _coerce(_concrete_field_type(T, name), default) + end end return end -function _declared(o::OpenAPI.APIModel, prop::Symbol) - T = typeof(o) +""" +Constructor for a compound property, e.g. `MinMax` for `ACBus.voltage_limits`. +""" +_compound_type(::Type{T}, prop::Symbol) where {T} = _concrete_field_type(T, prop) + +""" +Whether `T.prop`'s declared unit needs a real instance to resolve. + +The generator never emits a type-level `declared_unit`/`declared_quantity` method for a +discriminated property (`Line.r` on `parameter_units`, every power-family field on +`power_units`, ...) — only an instance-level one that reads the sibling discriminator via +`getproperty`. Calling the type-level form for one of these therefore raises `MethodError`, +not the schema's own `error()` call (`ErrorException`, raised by a genuinely fixed +property's instance-level method when an unexpected discriminator value reaches it). Both +mean "needs a shadow" here; PFFP's own `device_base.jl` (`_has_fixed_declared_unit`, unchanged +by this migration) already draws this same distinction for the same reason. +""" +function _declared(s::Staged{T}, prop::Symbol) where {T} if !IC.has_declared_unit(T, Val(prop)) throw( IS.DataFormatError( @@ -60,16 +210,19 @@ function _declared(o::OpenAPI.APIModel, prop::Symbol) ), ) end - _default_power_units!(o) - # Instance dispatch: for discriminated properties both the unit and the - # quantity depend on a sibling field. - return IC.declared_unit(o, Val(prop)), IC.declared_quantity(o, Val(prop)) + try + return IC.declared_unit(T, Val(prop)), IC.declared_quantity(T, Val(prop)) + catch e + (e isa ErrorException || e isa MethodError) || rethrow() + end + _default_bases!(s) + shadow = _shadow(s) + return IC.declared_unit(shadow, Val(prop)), IC.declared_quantity(shadow, Val(prop)) end -function _reject_declared(o::OpenAPI.APIModel, prop::Symbol) - T = typeof(o) +function _reject_declared(s::Staged{T}, prop::Symbol) where {T} if IC.has_declared_unit(T, Val(prop)) - unit = IC.declared_unit(o, Val(prop)) + unit = declared_unit_label(s, prop) throw( IS.DataFormatError( "$(nameof(T)).$prop declares unit \"$unit\"; use the 4-argument set_value!", @@ -79,18 +232,28 @@ function _reject_declared(o::OpenAPI.APIModel, prop::Symbol) return end +"""Best-effort unit label for an error message: resolves through a shadow instance for a +discriminated property (mirroring `_declared`), falling back to `"?"` only if that also +fails (e.g. a required sibling discriminator has no default and is not yet staged).""" +function declared_unit_label(s::Staged{T}, prop::Symbol) where {T} + return try + first(_declared(s, prop)) + catch + "?" + end +end + function _convert_by_factor( - o::OpenAPI.APIModel, + ::Type{T}, prop::Symbol, value::Float64, source_unit::AbstractString, target::AbstractString, quantity::AbstractString, -) +) where {T} if source_unit == target return value end - T = typeof(o) if !IC.has_conversion_factor(quantity, source_unit) throw( IS.DataFormatError( @@ -112,9 +275,7 @@ function _convert_by_factor( end """Whether the sibling property holding a per-unit value's base has been assigned.""" -function _base_is_set(o::OpenAPI.APIModel, base_prop::Symbol) - return !isnothing(getproperty(o, base_prop)) -end +_base_is_set(s::Staged, base_prop::Symbol) = haskey(s.fields, base_prop) """ The assigned base, rejected unless it is positive. @@ -124,12 +285,12 @@ A zero or negative voltage or power base is physically meaningless, and PSS/E wr `NaN` that survives every later check and first fails inside `JSON.print`, after the output file has been truncated. """ -function _checked_base(o::OpenAPI.APIModel, prop::Symbol, base_prop::Symbol) - base = getproperty(o, base_prop) +function _checked_base(s::Staged{T}, prop::Symbol, base_prop::Symbol) where {T} + base = get_value(s, base_prop) if base <= 0 throw( IS.DataFormatError( - "$(nameof(typeof(o))).$prop is per-unit on $base_prop, which is $base; " * + "$(nameof(T)).$prop is per-unit on $base_prop, which is $base; " * "the base must be positive", ), ) @@ -142,20 +303,19 @@ Convert into a per-unit property whose base lives in a sibling property. `x-unit-base` names that sibling. The base carries its own declared unit, so the incoming value is first brought into that unit by the factor path and then divided. -The base must already be assigned, which makes assignment order significant here -and nowhere else. +The base must already be staged, which makes staging order significant here and +nowhere else. """ function _convert_onto_base( - o::OpenAPI.APIModel, + s::Staged{T}, prop::Symbol, value::Float64, source_unit::AbstractString, target::AbstractString, quantity::AbstractString, -) - T = typeof(o) +) where {T} base_prop = IC.unit_base(T, Val(prop)) - if !_base_is_set(o, base_prop) + if !_base_is_set(s, base_prop) throw( IS.DataFormatError( "$(nameof(T)).$prop is $quantity in \"$target\" on $base_prop, which is " * @@ -163,139 +323,163 @@ function _convert_onto_base( ), ) end - base_unit, base_quantity = _declared(o, base_prop) - natural = _convert_by_factor(o, prop, value, source_unit, base_unit, base_quantity) - return natural / _checked_base(o, prop, base_prop) + base_unit, base_quantity = _declared(s, base_prop) + natural = _convert_by_factor(T, prop, value, source_unit, base_unit, base_quantity) + return natural / _checked_base(s, prop, base_prop) end function _convert( - o::OpenAPI.APIModel, + s::Staged{T}, prop::Symbol, value::Float64, source_unit::AbstractString, target::AbstractString, quantity::AbstractString, -) +) where {T} if source_unit == target return value end - if IC.has_unit_base(typeof(o), Val(prop)) - return _convert_onto_base(o, prop, value, source_unit, target, quantity) + if IC.has_unit_base(T, Val(prop)) + return _convert_onto_base(s, prop, value, source_unit, target, quantity) end - return _convert_by_factor(o, prop, value, source_unit, target, quantity) + return _convert_by_factor(T, prop, value, source_unit, target, quantity) end """Convert `value` from `source_unit` into the unit `prop` declares.""" function convert_to_declared( - o::OpenAPI.APIModel, + s::Staged{T}, prop::Symbol, value::Real, source_unit::AbstractString, -) - target, quantity = _declared(o, prop) - return _convert(o, prop, Float64(value), source_unit, target, quantity) +) where {T} + target, quantity = _declared(s, prop) + return _convert(s, prop, Float64(value), source_unit, target, quantity) end """Assign a numeric property, converting from `source_unit` to the declared unit.""" function set_value!( - o::OpenAPI.APIModel, + s::Staged{T}, prop::Symbol, value::Real, source_unit::AbstractString, -) - setproperty!(o, prop, convert_to_declared(o, prop, value, source_unit)) +) where {T} + converted = convert_to_declared(s, prop, value, source_unit) + s.fields[prop] = _coerce(_concrete_field_type(T, prop), converted) return end """ Assign a compound property such as `MinMax`, `UpDown`, `FromTo` or `InOut`. -The schemas annotate these at the object level rather than per member, so one -unit applies to every field of the tuple. +The schemas annotate these at the object level rather than per member, so one unit applies to +every field of the tuple. """ function set_value!( - o::OpenAPI.APIModel, + s::Staged{T}, prop::Symbol, value::NamedTuple, source_unit::AbstractString, -) - target, quantity = _declared(o, prop) +) where {T} + target, quantity = _declared(s, prop) converted = map( - v -> _convert(o, prop, Float64(v), source_unit, target, quantity), + v -> _convert(s, prop, Float64(v), source_unit, target, quantity), values(value), ) - ctor = _compound_type(o, prop) - setproperty!(o, prop, ctor(; NamedTuple{keys(value)}(converted)...)) + ctor = _compound_type(T, prop) + s.fields[prop] = ctor(; NamedTuple{keys(value)}(converted)...) return end """ Reject a unit supplied for something that cannot carry one. -Either the property declares no unit, or the value is neither a number nor a -compound tuple. Both are caller mistakes worth naming precisely rather than -surfacing as a MethodError. +Either the property declares no unit, or the value is neither a number nor a compound tuple. +Both are caller mistakes worth naming precisely rather than surfacing as a MethodError. """ function set_value!( - o::OpenAPI.APIModel, + s::Staged{T}, prop::Symbol, value, source_unit::AbstractString, -) - _reject_declared(o, prop) +) where {T} + _reject_declared(s, prop) throw( IS.DataFormatError( - "$(nameof(typeof(o))).$prop: a unit applies only to a number or a compound " * + "$(nameof(T)).$prop: a unit applies only to a number or a compound " * "tuple, got $(typeof(value))", ), ) end """Assign a property that declares no unit: names, ids, flags, enum strings.""" -function set_value!(o::OpenAPI.APIModel, prop::Symbol, value) - _reject_declared(o, prop) - setproperty!(o, prop, value) +function set_value!(s::Staged{T}, prop::Symbol, value) where {T} + _reject_declared(s, prop) + s.fields[prop] = _coerce(_concrete_field_type(T, prop), value) return end """Assign `prop` only when `value` is present; the schemas leave these fields optional and the pm dict does not always carry one.""" function set_optional_value!( - o::OpenAPI.APIModel, + s::Staged{T}, prop::Symbol, value, source_unit::AbstractString, -) +) where {T} if !isnothing(value) - set_value!(o, prop, value, source_unit) + set_value!(s, prop, value, source_unit) end return end -"""Return the stored value of `prop`.""" -get_value(o::OpenAPI.APIModel, prop::Symbol) = getproperty(o, prop) +""" +The plain value inside an enum wrapper (`OperationalStates`, `PrimeMovers`, `UnitSystem`, +...); anything else is returned unchanged. + +`get_value` reads through this rather than returning the wrapper: every comparison in this +package and its tests is against the schema's bare string constants (`"ONLINE"`, `"FIXED"`, +...), and an `EnumAPIModel` does not compare equal to the string it wraps. `OneOfAPIModel` +(a oneOf wrapper over concrete struct variants, not a string) is deliberately excluded — a +caller reading one of those wants the concrete variant, not a string. +""" +_unwrap(value::IC.EnumAPIModel) = value.value +_unwrap(value) = value + +"""Return the staged value of `prop`.""" +get_value(s::Staged, prop::Symbol) = _unwrap(s.fields[prop]) + +"""Return the stored value of `prop` on an already-materialized component.""" +get_value(o::IC.APIModel, prop::Symbol) = _unwrap(getproperty(o, prop)) + +_declared_read(s::Staged, prop::Symbol) = _declared(s, prop) -"""Return the value of `prop` expressed in `unit`.""" -function get_value(o::OpenAPI.APIModel, prop::Symbol, unit::AbstractString) - source, quantity = _declared(o, prop) - value = getproperty(o, prop) +_has_unit_base(::Staged{T}, prop::Symbol) where {T} = IC.has_unit_base(T, Val(prop)) + +_unit_base_of(::Staged{T}, prop::Symbol) where {T} = IC.unit_base(T, Val(prop)) + +_declared_type(::Staged{T}) where {T} = T + +"""Return the value of `prop` expressed in `unit`. Works on a still-staged object (mid-build +reads, e.g. after a sibling field it depends on).""" +function get_value(o, prop::Symbol, unit::AbstractString) + source, quantity = _declared_read(o, prop) + value = get_value(o, prop) if source == unit return value end - T = typeof(o) - if IC.has_unit_base(T, Val(prop)) - base_prop = IC.unit_base(T, Val(prop)) + if _has_unit_base(o, prop) + base_prop = _unit_base_of(o, prop) if !_base_is_set(o, base_prop) throw( IS.DataFormatError( - "$(nameof(T)).$prop is $quantity in \"$source\" on $base_prop, " * - "which is unset; assign $base_prop first", + "$(nameof(_declared_type(o))).$prop is $quantity in \"$source\" on " * + "$base_prop, which is unset; assign $base_prop first", ), ) end - base_unit, base_quantity = _declared(o, base_prop) + base_unit, base_quantity = _declared_read(o, base_prop) return _convert_by_factor( - o, + _declared_type(o), prop, value * _checked_base(o, prop, base_prop), base_unit, @@ -307,7 +491,8 @@ function get_value(o::OpenAPI.APIModel, prop::Symbol, unit::AbstractString) !IC.has_conversion_factor(quantity, source) throw( IS.DataFormatError( - "$(nameof(T)).$prop is $quantity in \"$source\"; cannot express in \"$unit\"", + "$(nameof(_declared_type(o))).$prop is $quantity in \"$source\"; " * + "cannot express in \"$unit\"", ), ) end diff --git a/test/Project.toml b/test/Project.toml index c016ebb..08e6aac 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -16,8 +16,7 @@ PowerOperationsOpenAPIModels = "a372b6d7-45a2-44c2-8199-6a724b72e8ff" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [sources] PowerFlowFileParser = {path = ".."} -# LOCAL PATH CO-DEV PINS (temporary): see ../Project.toml for the split rationale; revert to -# git-url pins once the split is pushed. +# Branch pins until the OpenAPI packages are registered and the psy6-line branches merge. InfrastructureSystems = {url = "https://github.com/Sienna-Platform/InfrastructureSystems.jl.git", rev = "IS4"} InfrastructureCoreOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "InfrastructureCoreOpenAPIModels.jl"} InfrastructureTimeSeriesOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "InfrastructureTimeSeriesOpenAPIModels.jl"} diff --git a/test/test_openapi_branch.jl b/test/test_openapi_branch.jl index 8e7b95e..f096c27 100644 --- a/test/test_openapi_branch.jl +++ b/test/test_openapi_branch.jl @@ -28,7 +28,7 @@ end `_make_transformer_circuit!` maker directly rather than through `build_openapi_system`.""" function _register_bus!(sys::PFP.OpenAPISystem, number::Int, name::AbstractString) reg = PFP.get_registry(sys) - bus = PFP.PO.ACBus() + 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) diff --git a/test/test_openapi_container.jl b/test/test_openapi_container.jl index b000c3f..0d75c98 100644 --- a/test/test_openapi_container.jl +++ b/test/test_openapi_container.jl @@ -1,7 +1,9 @@ function _bus(id::Int, name::AbstractString) - bus = PFP.PO.ACBus() + bus = PFP.stage(PFP.PC.ACBus) PFP.set_value!(bus, :id, id) PFP.set_value!(bus, :name, name) + PFP.set_value!(bus, :available, true) + PFP.set_value!(bus, :number, id) return bus end @@ -19,9 +21,10 @@ end PFP.add_component!(sys, _bus(1, "Abel")) PFP.add_component!(sys, _bus(2, "Adams")) - area = PFP.PO.Area() + area = PFP.stage(PFP.PC.Area) PFP.set_value!(area, :id, 3) PFP.set_value!(area, :name, "1") + PFP.set_value!(area, :base_power, 100.0, "MVA") PFP.add_component!(sys, area) @test PFP.component_type_names(sys) == ["ACBus", "Area"] @@ -31,10 +34,21 @@ end @testset "component_type_names is sorted for deterministic output" begin sys = PFP.OpenAPISystem(100.0) - line = PFP.PO.Line() + + line = PFP.stage(PFP.PO.Line) PFP.set_value!(line, :id, 1) PFP.set_value!(line, :name, "L1") + PFP.set_value!(line, :available, true) + PFP.set_value!(line, :arc, 0) + PFP.set_value!(line, :active_power_flow, 0.0, "MW") + PFP.set_value!(line, :reactive_power_flow, 0.0, "MVAr") + PFP.set_value!(line, :base_power, 100.0, "MVA") + PFP.set_value!(line, :r, 0.01, "pu") + PFP.set_value!(line, :x, 0.1, "pu") + PFP.set_value!(line, :rating, 1.0, "MVA") + PFP.set_value!(line, :angle_limits, (min = -0.5, max = 0.5), "rad") PFP.add_component!(sys, line) + PFP.add_component!(sys, _bus(2, "Abel")) @test PFP.component_type_names(sys) == ["ACBus", "Line"] end @@ -43,7 +57,7 @@ end sys = PFP.OpenAPISystem(100.0) PFP.add_component!(sys, _bus(1, "Abel")) PFP.add_component!(sys, _bus(2, "Adams")) - @test eltype(PFP.get_components(sys, "ACBus")) == PFP.PO.ACBus + @test eltype(PFP.get_components(sys, "ACBus")) == PFP.PC.ACBus end @testset "get_components on an absent type is empty, not an error" begin @@ -62,7 +76,7 @@ end @testset "add_supplemental_attribute! records the attribute and its link" begin sys = PFP.OpenAPISystem(100.0) PFP.add_component!(sys, _bus(1, "Abel")) - geo = PFP.IC.GeographicInfo() + geo = PFP.stage(PFP.IC.GeographicInfo) PFP.set_value!(geo, :id, 2) PFP.set_value!(geo, :geo_json, Dict{String, Any}("type" => "Point")) PFP.add_supplemental_attribute!(sys, geo, 1) @@ -71,7 +85,8 @@ end @test PFP.get_value(assoc, :attribute_id) == 2 @test PFP.get_value(assoc, :component_id) == 1 @test PFP.get_value(assoc, :component_type) == "ACBus" - @test only(PFP.get_supplemental_attributes(sys, "GeographicInfo")) === geo + materialized_geo = only(PFP.get_supplemental_attributes(sys, "GeographicInfo")) + @test PFP.get_value(materialized_geo, :id) == 2 end @testset "add_service_association! records a membership row" begin diff --git a/test/test_openapi_dc_shunt.jl b/test/test_openapi_dc_shunt.jl index 7337f01..ec50aec 100644 --- a/test/test_openapi_dc_shunt.jl +++ b/test/test_openapi_dc_shunt.jl @@ -67,12 +67,12 @@ end @test PFP.get_value(shunt, :available) == d["status"] @test PFP.get_value(shunt, :admittance_units) == "COMPONENT_MVAR" @test _matches_nt( - PFP.get_value(shunt, :Y), + PFP.get_value(shunt, :y), (real = d["gs"] * base, imag = d["bs"] * base), ) # The fixture's bus-111 FIXED SHUNT record declares GL/BL as 100.000/200.000, so # COMPONENT_MVAR must read back as the RAW's own MW/MVAr, not the pm dict's 1.0/2.0 pu. - @test _matches_nt(PFP.get_value(shunt, :Y), (real = 100.0, imag = 200.0)) + @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 @@ -87,12 +87,8 @@ end PFP.get_value(s, :bus) == PFP.get_bus_id(PFP.get_registry(sys), 101) ) @test PFP.get_value(shunt, :control_mode) == "DISCRETE_VOLTAGE" - @test _matches_nt( - PFP.get_value(shunt, :Y), - (real = d["gs"] * base, imag = d["bs"] * base), - ) @test PFP.get_value(shunt, :number_of_steps) == d["step_number"] - y_increase = PFP.get_value(shunt, :Y_increase) + y_increase = PFP.get_value(shunt, :y_increase) @test length(y_increase) == length(d["y_increment"]) @test all( y_increase[i].real == real(d["y_increment"][i]) * base && @@ -101,9 +97,10 @@ end ) # The fixture's bus-101 SWITCHED SHUNT record declares BINIT = 50.00 and B1 = 100.00, # so both must read back in the RAW's own MVAr rather than the pm dict's 0.5/1.0 pu. - # BINIT is the SOLVED admittance, not a fixed base: it lands in `solved_admittance` and - # `Y` stays zero, since a PSS/E switched shunt has no fixed base term (#1774). - @test _matches_nt(PFP.get_value(shunt, :Y), (real = 0.0, imag = 0.0)) + # BINIT is the SOLVED admittance: a PSS/E switched shunt has no fixed base term + # (#1774), so SwitchedAdmittance dropped the fixed `Y` field entirely — total + # admittance is `number_engaged` steps of `y_increase`, with `solved_admittance` + # overriding when present. @test PFP.get_value(shunt, :solved_admittance) == 50.0 @test only(y_increase).imag == 100.0 @test _matches_nt( @@ -333,8 +330,12 @@ 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, :voltage_units) == "NATURAL_UNITS" - @test _matches_nt(PFP.get_value(vsc_dc, :voltage_limits_from), (min = 0.0, max = 999.9)) + # 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 + # test_openapi_serialize.jl). + @test PFP.get_value(vsc_dc, :voltage_units) === PFP.ABSENT + @test PFP.get_value(vsc_dc, :voltage_limits_from) === PFP.ABSENT d_ac = _synthetic_vscline_dict() d_ac["ac_voltage_control_from"] = true @@ -349,8 +350,9 @@ 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, :voltage_units) == "NATURAL_UNITS" - @test _matches_nt(PFP.get_value(vsc_ac, :voltage_limits_to), (min = 0.0, max = 999.9)) + # 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 @@ -364,7 +366,8 @@ end # 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. - vsc = PFP.PO.TwoTerminalVSCLine() + 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") @@ -400,7 +403,8 @@ end # 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". - vsc = PFP.PO.TwoTerminalVSCLine() + 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") diff --git a/test/test_openapi_generation.jl b/test/test_openapi_generation.jl index 331321a..c131775 100644 --- a/test/test_openapi_generation.jl +++ b/test/test_openapi_generation.jl @@ -15,7 +15,7 @@ end maker directly rather than through `build_openapi_system`.""" function _register_test_bus!(sys::PFP.OpenAPISystem) reg = PFP.get_registry(sys) - bus = PFP.PO.ACBus() + bus = PFP.stage(PFP.PC.ACBus) id = PFP.register_bus!(reg, 1, "b1") PFP.set_value!(bus, :id, id) PFP.set_value!(bus, :number, 1) @@ -61,7 +61,11 @@ end @test PFP.get_value(gen, :active_power_limits).max ≈ d["pmax"] * base_conversion * mbase @test PFP.get_value(gen, :base_power) == mbase - @test PFP.get_value(gen, :status) == d["gen_status"] + if iszero(d["gen_status"]) + @test PFP.get_value(gen, :status) == "OFFLINE" + else + @test PFP.get_value(gen, :status) == "ONLINE" + end @test PFP.get_value(gen, :prime_mover_type) == "OT" @test PFP.get_value(gen, :fuel) == "OTHER" bus = _bus_component(sys, d["gen_bus"]) @@ -107,6 +111,22 @@ end @test PFP.get_value(gen, :ramp_limits).down ≈ d["pmax"] end +@testset "an out-of-service Matpower generator maps to OFFLINE, an in-service one to ONLINE" begin + base_d = Dict{String, Any}( + "mbase" => 100.0, "pg" => 0.0, "qg" => 0.0, "pmax" => 10.0, "pmin" => 0.0, + "qmax" => 5.0, "qmin" => -5.0, + ) + for (gen_status, expected) in ((0, "OFFLINE"), (1, "ONLINE")) + sys = PFP.OpenAPISystem(100.0) + reg = PFP.get_registry(sys) + bus = _register_test_bus!(sys) + d = merge(base_d, Dict{String, Any}("gen_status" => gen_status)) + PFP.make_thermal_generator!(sys, reg, bus, d, "g1", 100.0) + gen = only(PFP.get_components(sys, "ThermalStandard")) + @test PFP.get_value(gen, :status) == expected + end +end + @testset "every 14-bus generator's real POLYNOMIAL cost (model=2) matches the hand-derived coefficients" begin # Every gen has cost=[100.0, 0.0], ncost=2, mbase == sys_mbase == 100: PowerModels' # own per-unit correction scaled the synthetic PSS/E default (proportional_term=1.0, @@ -119,10 +139,12 @@ end # PowerTableDataParser's own test convention. cost = PFP.get_value(gen, :operation_cost).value @test cost.fixed == 0.0 - @test cost.start_up == 0.0 + @test cost.start_up.value == 0.0 @test cost.shut_down == 0.0 - variable = cost.variable_operation_cost - @test variable.power_units == "COMPONENT_BASE" + # variable_operation_cost is a `ProductionVariableCostCurve` oneOf wrapper + # (`Union{CostCurve, FuelCurve}`) now, one `.value` deep from the CostCurve itself. + variable = cost.variable_operation_cost.value + @test variable.power_units.value == "COMPONENT_BASE" function_data = variable.value_curve.value.function_data.value @test function_data.quadratic_term == 0.0 @test function_data.proportional_term == 1.0 @@ -143,7 +165,7 @@ end c1, c0 = d["cost"] expected_proportional = c1 / sys_mbase cost = PFP.get_value(gen, :operation_cost).value - fd = cost.variable_operation_cost.value_curve.value.function_data.value + fd = cost.variable_operation_cost.value.value_curve.value.function_data.value @test fd.proportional_term ≈ expected_proportional @test fd.constant_term == 0.0 @test cost.fixed == 0.0 @@ -169,7 +191,7 @@ end cost = PFP.get_value(gen, :operation_cost).value @test cost.fixed ≈ fixed - fd = cost.variable_operation_cost.value_curve.value.function_data.value + fd = cost.variable_operation_cost.value.value_curve.value.function_data.value @test fd.function_type == "PIECEWISE_LINEAR" @test length(fd.points) == length(points) for (p, (x, y)) in zip(fd.points, points) @@ -190,10 +212,13 @@ end 100.0, ) @test cost.fixed == 0.0 - @test cost.start_up == 0.0 + @test cost.start_up.value == 0.0 @test cost.shut_down == 0.0 - @test cost.variable_operation_cost.power_units == "NATURAL_UNITS" - fd = cost.variable_operation_cost.value_curve.value.function_data.value + # variable_operation_cost is a `ProductionVariableCostCurve` oneOf wrapper + # (`Union{CostCurve, FuelCurve}`) now, one `.value` deep from the CostCurve itself. + cost_curve = cost.variable_operation_cost.value + @test cost_curve.power_units.value == "NATURAL_UNITS" + fd = cost_curve.value_curve.value.function_data.value @test fd.function_type == "LINEAR" @test fd.proportional_term == 0.0 @test fd.constant_term == 0.0 @@ -372,8 +397,19 @@ end # 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") @test_throws ErrorException PFP._devicebase_classification( - PFP.PO.TwoTerminalVSCLine(), + PFP.materialize(vsc), "TwoTerminalVSCLine", :dc_setpoint_from, ) diff --git a/test/test_openapi_serialize.jl b/test/test_openapi_serialize.jl index 0a50880..b7017f0 100644 --- a/test/test_openapi_serialize.jl +++ b/test/test_openapi_serialize.jl @@ -2,7 +2,7 @@ function _serialize_test_system(; power_units::AbstractString = "NATURAL_UNITS") sys = PFP.OpenAPISystem(100.0; power_units = power_units) reg = PFP.get_registry(sys) - bus = PFP.PO.ACBus() + bus = PFP.stage(PFP.PC.ACBus) PFP.set_value!(bus, :id, PFP.register_bus!(reg, 101, "Abel")) PFP.set_value!(bus, :number, 101) PFP.set_value!(bus, :name, "Abel") @@ -11,13 +11,13 @@ function _serialize_test_system(; power_units::AbstractString = "NATURAL_UNITS") PFP.set_value!(bus, :base_voltage, 138.0, "kV") PFP.add_component!(sys, bus) - area = PFP.PO.Area() + area = PFP.stage(PFP.PC.Area) PFP.set_value!(area, :id, PFP.register!(reg, "Area", "1")) PFP.set_value!(area, :name, "1") PFP.set_value!(area, :base_power, PFP.get_base_power(sys), "MVA") PFP.add_component!(sys, area) - geo = PFP.IC.GeographicInfo() + geo = PFP.stage(PFP.IC.GeographicInfo) PFP.set_value!(geo, :id, PFP.next_id!(reg)) PFP.set_value!(geo, :geo_json, Dict{String, Any}("type" => "Point")) PFP.add_supplemental_attribute!(sys, geo, PFP.get_value(bus, :id)) @@ -127,15 +127,21 @@ end @test read(a, String) == read(b, String) end -@testset "every emitted component satisfies check_required" begin +# OpenAPI.jl 1.x dropped the mutable-model runtime `check_required` came from; a +# materialized component is a `Base.@kwdef struct` whose non-defaulted (i.e. +# schema-required) fields Julia itself refuses to leave unassigned, so there is no longer +# a separate required-field check to run after the fact. The equivalent guarantee under +# the new runtime is that `_encode` — which schema-validates the full object, not just +# field presence — succeeds on every emitted component. +@testset "every emitted component round-trips through _encode" begin sys = _serialize_test_system() for type_name in PFP.component_type_names(sys) for component in PFP.get_components(sys, type_name) - @test OpenAPI.check_required(component) + @test OpenAPI.Runtime._encode(component) isa JSON.Object end end for association in PFP.get_document(sys).supplemental_attribute_associations - @test OpenAPI.check_required(association) + @test OpenAPI.Runtime._encode(association) isa JSON.Object end end diff --git a/test/test_openapi_switch.jl b/test/test_openapi_switch.jl index 3fa6d8b..014259c 100644 --- a/test/test_openapi_switch.jl +++ b/test/test_openapi_switch.jl @@ -26,7 +26,12 @@ @test PFP.get_value(switch, :active_power_flow) == 0.0 @test PFP.get_value(switch, :reactive_power_flow) == 0.0 @test PFP.get_value(switch, :base_power) == 100.0 # sys_mbase - @test PFP.get_value(switch, :normal_branch_status) == "CLOSED" # schema default, unset + # OpenAPI.jl 1.x dropped the mutable-model runtime that used to auto-populate an + # unset field from the JSON schema's own `default`; a `@kwdef` struct's field default + # is uniformly `ABSENT`, so a field read_switch_breaker! never stages (there is no pm + # dict source for a "normal" as-designed status, only the current one) now stays + # genuinely absent rather than reading back the schema's documented "CLOSED" default. + @test PFP.get_value(switch, :normal_branch_status) === PFP.ABSENT breaker = only( c for c in PFP.get_components(sys, "DiscreteControlledACBranch") if diff --git a/test/test_openapi_topology.jl b/test/test_openapi_topology.jl index f8bedab..29cfc7c 100644 --- a/test/test_openapi_topology.jl +++ b/test/test_openapi_topology.jl @@ -87,11 +87,15 @@ end @test PFP.get_value(zone, :base_power) == 100.0 end -@testset "Area gets a zero peak, matching the oracle's asymmetry with LoadZone" begin +@testset "Area leaves peak absent, matching the oracle's asymmetry with LoadZone" begin + # Unlike LoadZone, `_ensure_area!` never sums bus loads onto an Area, and the new + # immutable model no longer defaults an unset optional field to zero (see "unset + # properties are absent, not null" in test_openapi_serialize.jl) — so the field + # this component never staged stays genuinely `Absent`, not a phantom 0.0. sys = PFP.build_openapi_system(fourteen_bus_pm_data()) area = only(PFP.get_components(sys, "Area")) - @test PFP.get_value(area, :peak_active_power) == 0.0 - @test PFP.get_value(area, :peak_reactive_power) == 0.0 + @test PFP.get_value(area, :peak_active_power) === PFP.ABSENT + @test PFP.get_value(area, :peak_reactive_power) === PFP.ABSENT @test PFP.get_value(area, :base_power) == 100.0 end diff --git a/test/test_openapi_units.jl b/test/test_openapi_units.jl index e580904..3f54d4e 100644 --- a/test/test_openapi_units.jl +++ b/test/test_openapi_units.jl @@ -1,73 +1,91 @@ @testset "set_value! stores a matching unit unchanged" begin - bus = PFP.PO.ACBus() + bus = PFP.stage(PFP.PC.ACBus) PFP.set_value!(bus, :base_voltage, 138.0, "kV") - @test bus.base_voltage == 138.0 + @test PFP.get_value(bus, :base_voltage) == 138.0 end @testset "set_value! assigns units that have no conversion factor" begin # to_default is 0.0 for Angle and null for pu bases, so no factor exists. # Assignment must still work when source and target already agree. - bus = PFP.PO.ACBus() + bus = PFP.stage(PFP.PC.ACBus) PFP.set_value!(bus, :angle, 0.3, "rad") - @test bus.angle == 0.3 + @test PFP.get_value(bus, :angle) == 0.3 - line = PFP.PO.Line() + line = PFP.stage(PFP.PO.Line) PFP.set_value!(line, :r, 0.003, "pu") - @test line.r == 0.003 + @test PFP.get_value(line, :r) == 0.003 end @testset "set_value! converts within a quantity" begin - storage = PFP.PO.EnergyReservoirStorage() + storage = PFP.stage(PFP.PO.EnergyReservoirStorage) + # operation_cost, power_units, prime_mover_type and storage_technology_type are all + # required enum/oneOf fields: `_shadow` cannot placeholder an enum wrapper (it + # constructs positionally, not by keyword), so each must be staged before + # storage_capacity, whose declared unit depends on a shadow (see generation.jl's + # make_storage! for the same ordering requirement). + PFP.set_value!( + storage, + :operation_cost, + PFP.PC.StorageCost(; + cost_type = "STORAGE", + fixed = 0.0, + shut_down = 0.0, + start_up = PFP.PC.StorageCostStartUp(0.0), + ), + ) + PFP.set_value!(storage, :power_units, "NATURAL_UNITS") + PFP.set_value!(storage, :prime_mover_type, "BA") + PFP.set_value!(storage, :storage_technology_type, "OTHER_CHEM") PFP.set_value!(storage, :storage_capacity, 3600.0, "MJ") - @test storage.storage_capacity ≈ 1.0 + @test PFP.get_value(storage, :storage_capacity) ≈ 1.0 - converter = PFP.PO.InterconnectingConverter() + converter = PFP.stage(PFP.PO.InterconnectingConverter) PFP.set_value!(converter, :dc_current, 1.5, "kA") - @test converter.dc_current ≈ 1500.0 + @test PFP.get_value(converter, :dc_current) ≈ 1500.0 end @testset "set_value! rejects a cross-quantity conversion" begin - bus = PFP.PO.ACBus() + bus = PFP.stage(PFP.PC.ACBus) @test_throws IS.DataFormatError PFP.set_value!(bus, :base_voltage, 138.0, "MW") - gen = PFP.PO.ThermalStandard() + gen = PFP.stage(PFP.PO.ThermalStandard) @test_throws IS.DataFormatError PFP.set_value!(gen, :base_power, 100.0, "MWh") end @testset "set_value! converts degrees to radians" begin - bus = PFP.PO.ACBus() + bus = PFP.stage(PFP.PC.ACBus) PFP.set_value!(bus, :angle, 180.0, "deg") - @test bus.angle ≈ pi + @test PFP.get_value(bus, :angle) ≈ pi PFP.set_value!(bus, :angle, 0.0, "deg") - @test bus.angle == 0.0 + @test PFP.get_value(bus, :angle) == 0.0 end @testset "a pu property converts from the unit its base is declared in" begin - bus = PFP.PO.ACBus() + bus = PFP.stage(PFP.PC.ACBus) PFP.set_value!(bus, :base_voltage, 138.0, "kV") PFP.set_value!(bus, :magnitude, 141.45, "kV") - @test bus.magnitude ≈ 1.025 + @test PFP.get_value(bus, :magnitude) ≈ 1.025 PFP.set_value!(bus, :magnitude, 1.0, "pu") - @test bus.magnitude == 1.0 + @test PFP.get_value(bus, :magnitude) == 1.0 end @testset "a compound pu property converts every member onto the base" begin - bus = PFP.PO.ACBus() + bus = PFP.stage(PFP.PC.ACBus) PFP.set_value!(bus, :base_voltage, 138.0, "kV") PFP.set_value!(bus, :voltage_limits, (min = 131.1, max = 144.9), "kV") - @test bus.voltage_limits.min ≈ 0.95 - @test bus.voltage_limits.max ≈ 1.05 + @test PFP.get_value(bus, :voltage_limits).min ≈ 0.95 + @test PFP.get_value(bus, :voltage_limits).max ≈ 1.05 end @testset "converting onto an unset base names the base property" begin - bus = PFP.PO.ACBus() + bus = PFP.stage(PFP.PC.ACBus) @test_throws IS.DataFormatError PFP.set_value!(bus, :magnitude, 141.45, "kV") end @testset "a non-positive base is rejected rather than stored as Inf or NaN" begin # PSS/E writes BASKV = 0.0 for buses with no specified base. - zero_base = PFP.PO.ACBus() + zero_base = PFP.stage(PFP.PC.ACBus) PFP.set_value!(zero_base, :base_voltage, 0.0, "kV") @test_throws IS.DataFormatError PFP.set_value!(zero_base, :magnitude, 138.0, "kV") @test_throws IS.DataFormatError PFP.set_value!(zero_base, :magnitude, 0.0, "kV") @@ -78,26 +96,26 @@ end "kV", ) - negative_base = PFP.PO.ACBus() + negative_base = PFP.stage(PFP.PC.ACBus) PFP.set_value!(negative_base, :base_voltage, -138.0, "kV") @test_throws IS.DataFormatError PFP.set_value!(negative_base, :magnitude, 138.0, "kV") end @testset "get_value rejects a non-positive base" begin - bus = PFP.PO.ACBus() + bus = PFP.stage(PFP.PC.ACBus) PFP.set_value!(bus, :base_voltage, 0.0, "kV") PFP.set_value!(bus, :magnitude, 1.0, "pu") @test_throws IS.DataFormatError PFP.get_value(bus, :magnitude, "kV") end @testset "get_value onto an unset base names the base property" begin - bus = PFP.PO.ACBus() + bus = PFP.stage(PFP.PC.ACBus) PFP.set_value!(bus, :magnitude, 1.025, "pu") @test_throws IS.DataFormatError PFP.get_value(bus, :magnitude, "kV") end @testset "get_value expresses a pu value in the base's unit" begin - bus = PFP.PO.ACBus() + bus = PFP.stage(PFP.PC.ACBus) PFP.set_value!(bus, :base_voltage, 138.0, "kV") PFP.set_value!(bus, :magnitude, 1.025, "pu") @test PFP.get_value(bus, :magnitude) == 1.025 @@ -105,97 +123,130 @@ end end @testset "the base mechanism is not ACBus-specific" begin - source = PFP.PO.Source() + source = PFP.stage(PFP.PO.Source) PFP.set_value!(source, :base_voltage, 230.0, "kV") PFP.set_value!(source, :internal_voltage, 234.6, "kV") - @test source.internal_voltage ≈ 1.02 + @test PFP.get_value(source, :internal_voltage) ≈ 1.02 end @testset "set_value! rejects units absent from the vocabulary" begin - gen = PFP.PO.ThermalStandard() + gen = PFP.stage(PFP.PO.ThermalStandard) @test_throws IS.DataFormatError PFP.set_value!(gen, :base_power, 100_000.0, "kW") end @testset "arity enforces the unit rule in both directions" begin - bus = PFP.PO.ACBus() + bus = PFP.stage(PFP.PC.ACBus) @test_throws IS.DataFormatError PFP.set_value!(bus, :name, "Abel", "kV") @test_throws IS.DataFormatError PFP.set_value!(bus, :base_voltage, 138.0) PFP.set_value!(bus, :name, "Abel") PFP.set_value!(bus, :available, true) PFP.set_value!(bus, :number, 101) - @test bus.name == "Abel" - @test bus.available - @test bus.number == 101 + @test PFP.get_value(bus, :name) == "Abel" + @test PFP.get_value(bus, :available) + @test PFP.get_value(bus, :number) == 101 end @testset "set_value! runs the generated property validation" begin - bus = PFP.PO.ACBus() + bus = PFP.stage(PFP.PC.ACBus) @test_throws Exception PFP.set_value!(bus, :bustype, "Ref") PFP.set_value!(bus, :bustype, "REF") - @test bus.bustype == "REF" + @test PFP.get_value(bus, :bustype) == "REF" end @testset "compound properties take the unit at object level" begin - bus = PFP.PO.ACBus() + bus = PFP.stage(PFP.PC.ACBus) PFP.set_value!(bus, :voltage_limits, (min = 0.95, max = 1.05), "pu") - @test bus.voltage_limits.min == 0.95 - @test bus.voltage_limits.max == 1.05 - - gen = PFP.PO.ThermalStandard() + @test PFP.get_value(bus, :voltage_limits).min == 0.95 + @test PFP.get_value(bus, :voltage_limits).max == 1.05 + + gen = PFP.stage(PFP.PO.ThermalStandard) + # operation_cost, power_units and status are all required enum/oneOf fields: + # `_shadow` cannot placeholder an enum wrapper (it constructs positionally, not by + # keyword), so each must be staged before active_power_limits/ramp_limits below (see + # generation.jl's make_thermal_generator! for the same ordering requirement). + PFP.set_value!( + gen, + :operation_cost, + PFP.PC.ThermalGenerationCost(; + fixed = 0.0, + shut_down = 0.0, + start_up = PFP.PC.ThermalGenerationCostStartUp(0.0), + variable_operation_cost = PFP.PC.ProductionVariableCostCurve( + PFP._zero_cost_curve(), + ), + ), + ) + PFP.set_value!(gen, :power_units, "NATURAL_UNITS") + PFP.set_value!(gen, :status, "ONLINE") PFP.set_value!(gen, :active_power_limits, (min = 15.2, max = 76.0), "MW") - @test gen.active_power_limits.min == 15.2 - @test gen.active_power_limits.max == 76.0 + @test PFP.get_value(gen, :active_power_limits).min == 15.2 + @test PFP.get_value(gen, :active_power_limits).max == 76.0 PFP.set_value!(gen, :ramp_limits, (up = 3.0, down = 3.0), "MW/min") - @test gen.ramp_limits.up == 3.0 - @test gen.ramp_limits.down == 3.0 + @test PFP.get_value(gen, :ramp_limits).up == 3.0 + @test PFP.get_value(gen, :ramp_limits).down == 3.0 - line = PFP.PO.Line() + line = PFP.stage(PFP.PO.Line) PFP.set_value!(line, :b, (from = 0.0225, to = 0.0225), "pu") - @test line.b.from == 0.0225 + @test PFP.get_value(line, :b).from == 0.0225 end @testset "compound properties convert every member" begin - line = PFP.PO.Line() + line = PFP.stage(PFP.PO.Line) PFP.set_value!(line, :angle_limits, (min = -30.0, max = 30.0), "deg") - @test line.angle_limits.min ≈ -pi / 6 - @test line.angle_limits.max ≈ pi / 6 + @test PFP.get_value(line, :angle_limits).min ≈ -pi / 6 + @test PFP.get_value(line, :angle_limits).max ≈ pi / 6 end @testset "discriminated units are read off the instance" begin - line = PFP.PO.TwoTerminalLCCLine() + line = PFP.stage(PFP.PO.TwoTerminalLCCLine) PFP.set_value!(line, :parameter_units, "NATURAL_UNITS") PFP.set_value!(line, :r, 5.0, "ohm") - @test line.r == 5.0 + @test PFP.get_value(line, :r) == 5.0 - other = PFP.PO.TwoTerminalLCCLine() + other = PFP.stage(PFP.PO.TwoTerminalLCCLine) PFP.set_value!(other, :parameter_units, "COMPONENT_BASE") @test_throws IS.DataFormatError PFP.set_value!(other, :r, 5.0, "ohm") PFP.set_value!(other, :r, 0.01, "pu") - @test other.r == 0.01 + @test PFP.get_value(other, :r) == 0.01 end @testset "get_value returns the stored value and converts on request" begin - bus = PFP.PO.ACBus() + bus = PFP.stage(PFP.PC.ACBus) PFP.set_value!(bus, :base_voltage, 138.0, "kV") @test PFP.get_value(bus, :base_voltage) == 138.0 @test PFP.get_value(bus, :base_voltage, "kV") == 138.0 @test_throws IS.DataFormatError PFP.get_value(bus, :base_voltage, "MW") - storage = PFP.PO.EnergyReservoirStorage() + storage = PFP.stage(PFP.PO.EnergyReservoirStorage) + # See "set_value! converts within a quantity" above for why these must be staged + # before storage_capacity. + PFP.set_value!( + storage, + :operation_cost, + PFP.PC.StorageCost(; + cost_type = "STORAGE", + fixed = 0.0, + shut_down = 0.0, + start_up = PFP.PC.StorageCostStartUp(0.0), + ), + ) + PFP.set_value!(storage, :power_units, "NATURAL_UNITS") + PFP.set_value!(storage, :prime_mover_type, "BA") + PFP.set_value!(storage, :storage_technology_type, "OTHER_CHEM") PFP.set_value!(storage, :storage_capacity, 1.0, "MWh") @test PFP.get_value(storage, :storage_capacity, "MJ") ≈ 3600.0 end @testset "operational time is minutes, and the vocabulary has no hour" begin - gen = PFP.PO.ThermalStandard() + gen = PFP.stage(PFP.PO.ThermalStandard) PFP.set_value!(gen, :time_limits, (up = 120.0, down = 60.0), "min") - @test gen.time_limits.up == 120.0 - @test gen.time_limits.down == 60.0 + @test PFP.get_value(gen, :time_limits).up == 120.0 + @test PFP.get_value(gen, :time_limits).down == 60.0 - reserve = PFP.PO.OnlineReserve() + reserve = PFP.stage(PFP.PO.OnlineReserve) PFP.set_value!(reserve, :time_frame, 60.0, "min") - @test reserve.time_frame == 60.0 + @test PFP.get_value(reserve, :time_frame) == 60.0 @test_throws IS.DataFormatError PFP.set_value!(reserve, :sustained_time, 1.0, "h") end