Cost expression refactor, parameter broadcasting, and PF headroom slack porting#112
Cost expression refactor, parameter broadcasting, and PF headroom slack porting#112acostarelli wants to merge 20 commits into
Conversation
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR ports PowerSimulations.jl cost-expression refactors, parameter broadcasting fixes, and power-flow-in-the-loop (PFitL) functionality into PowerOperationsModels.jl, including headroom-proportional slack participation and expanded cost-expression reporting.
Changes:
- Refactors device constructors to register cost expressions via a new
add_cost_expressions!bundle and expands exported cost-expression types. - Reworks time-series parameter population/multiplier handling (including a regression fix for parallel-branch multipliers) using IOM fast-path setters.
- Implements PFitL in
PowerFlowsExt(input mapping, data update, solve/aux readback, headroom slack) and adds new regression/coverage tests.
Reviewed changes
Copilot reviewed 24 out of 25 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| test/test_power_flow_in_the_loop.jl | Adds PFitL tests including headroom-proportional slack coverage and baseline PFitL scenarios. |
| test/test_parallel_branch_parameter_multipliers.jl | Adds regression test ensuring per-branch multiplier rows are populated (no NaNs) for parallel branches. |
| test/test_model_decision.jl | Updates expected expression counts and realized output export assertions for the expanded cost-expression set. |
| test/test_device_thermal_generation_constructors.jl | Expands thermal cost tests to validate constituent decomposition and startup-cost behavior. |
| test/test_device_renewable_generation_constructors.jl | Adds nonnegativity test for CurtailmentCostExpression. |
| test/Project.toml | Pins test dependency rev of InfrastructureOptimizationModels to the required branch. |
| src/static_injector_models/thermalgeneration_constructor.jl | Switches from individual add_expressions! calls to add_cost_expressions! for thermals. |
| src/static_injector_models/source_constructor.jl | Switches sources to add_cost_expressions!. |
| src/static_injector_models/renewablegeneration_constructor.jl | Switches renewables to add_cost_expressions!. |
| src/static_injector_models/renewable_generation.jl | Adds curtailment-cost hook in renewable objective building. |
| src/static_injector_models/load_constructor.jl | Switches loads to add_cost_expressions!. |
| src/static_injector_models/hydro_generation.jl | Uses IOM internal setters for faster parameter/multiplier writes in hydro parameter population. |
| src/PowerOperationsModels.jl | Adds explicit imports/exports for cost-expression bindings and includes new objective helper file. |
| src/core/interfaces.jl | Improves add_power_flow_data! fallback behavior and error message when PowerFlows extension isn’t loaded. |
| src/common_models/objective_function.jl | Introduces renewable curtailment-cost expression generation. |
| src/common_models/market_bid_plumbing.jl | Routes VOM costs into VOMCostExpression via updated IOM helper signature. |
| src/common_models/add_to_expression.jl | Refactors fuel-consumption expression construction (dispatch-based methods) and compact-formulation handling. |
| src/common_models/add_parameters.jl | Reworks time-series parameter/multiplier population via IOM fast setters and fixes parallel-branch multiplier axis bug. |
| src/common_models/add_expressions.jl | Adds add_cost_expressions! bundles and fuel-curve predicates for container setup decisions. |
| Project.toml | Pins main dependency rev of InfrastructureOptimizationModels to the required branch. |
| ext/PowerFlowsExt/PowerFlowsExt.jl | Sets up the PowerFlows extension module and includes PFitL implementation files. |
| ext/PowerFlowsExt/pf_input_mapping.jl | Implements PF input key mapping and aux-var container creation for PFitL. |
| ext/PowerFlowsExt/pf_data_update.jl | Implements writing optimization results into PF data/system for PFitL. |
| ext/PowerFlowsExt/pf_headroom.jl | Implements headroom-proportional slack participation factor recomputation. |
| ext/PowerFlowsExt/pf_solve_and_aux.jl | Implements PF solve dispatch and aux-variable readback from PF results. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
Performance Results
|
luke-kiernan
left a comment
There was a problem hiding this comment.
Big picture things:
- Just generally: before adding code to POM, ask yourself "is there device specific handling here?" If the answer is "no," it probably belongs better in IOM. And there may be a helper for it there already.
- All the expression stuff is currently in POM, not in IOM, whether device-specific or generic. That's purely due to copy-pasting from PSI, not due to intentional organization choices. We should probably change that at some point and move the generic implementations into IOM.
- Test coverage. I ran the tests locally and they pass, but with the level of complexity here, I wouldn't be surprised if some amount of the code isn't actually getting run. Lots of different multiple dispatch cases: e.g.
_set_parameter_atwith HDF5 storage.
| reduced_branch_tracker = get_reduced_branch_tracker(network_model) | ||
| all_branch_maps_by_type = PNM.get_all_branch_maps_by_type(net_reduction_data) | ||
|
|
||
| # TODO: Temporary workaround to get the name where we assume all the names are the same across devices. |
There was a problem hiding this comment.
Ick we should open an issue for this
| # rather than a runtime branch. | ||
|
|
||
| # LinearCurve fuel: linear in the dispatch variable. | ||
| function _add_fuel_consumption_term!( |
There was a problem hiding this comment.
Identical to IOM's add_proportional_cost_invariant!, except we skip the final step where we add to the objective function and to the ProductionCostExpression. Refactor?
- Rename solve_powerflow! → solve_power_flow! to match PowerFlows naming
(kiernan).
- Fix aux-var readback guard to compare AuxVarKey entry-type, not the
whole key, against branch_aux_vars / bus_aux_vars; add a regression
test covering an unsupported PowerFlowAuxVariable key.
- Rename _get_variable_if_exists → _get_cost_if_exists so it no longer
reads as an optimization-variable accessor.
- Route LinearCurve fuel cost and renewable curtailment cost through
the new IOM helpers add_cost_term_to_expression! and
add_cost_term_invariant!; route compact-fuel SOS dispatch through
IOM._determine_bin_lhs to drop the duplicated three-way branch.
- Replace Ref(x) with singleton tuples (x,) in broadcast calls for
stack allocation; drop unnecessary Float64(multiplier) casts.
- Tighten devices argument on _add_time_series_parameters! to
Union{Vector{D}, IS.FlattenIteratorWrapper{D}}.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`findlast` returns `nothing` when no `PowerFlowEvaluationData` entry has solved, which previously bubbled up as an opaque `MethodError` from `datas[nothing]`. Raise an explicit error and mark the broader handling as a FIXME pending the PF-failure design discussion. Addresses kiernan's review comment on PR #112. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
luke-kiernan
left a comment
There was a problem hiding this comment.
Looks good. I'm noticing the following sequence of calls in a couple different places
power_units = PSY.get_power_units(var_cost)
proportional_term = PSY.get_proportional_term(value_curve)
prop_term_per_unit = get_proportional_cost_per_system_unit(
proportional_term, power_units, base_power, device_base_power)
rate = prop_term_per_unit * dt # sometimes also * multipler.but that's something we can address later
Replaces the removed PF-specific IOM API (AbstractPowerFlowEvaluationModel, solve_power_flow!, get_power_flow_data, get_power_flow_evaluation_data, reset_power_flow_is_solved!, is_from_power_flow, NetworkModel.power_flow_evaluation, OptimizationContainer.power_flow_evaluation_data) with IOM's generic AbstractEvaluator / AbstractEvaluationData / EvaluationContainer surface. PowerFlowEvaluationData now subtypes IOM.AbstractEvaluationData and implements IOM.evaluate!, IOM.reset!, IOM.is_solved, IOM.get_inner_data. add_power_flow_data! shares the EvaluationContainer instance between the NetworkModel and the OptimizationContainer so IOM's calculate_aux_variables! sees the runtime state POM registers. Adds power_flow_evaluations(ev) helper so call sites stay ergonomic: `evaluations = power_flow_evaluations(ACPowerFlow())`. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
| has_container_key(container, ActivePowerTimeSeriesParameter, C) || | ||
| return PSY.get_max_active_power(component) | ||
| param_container = get_parameter(container, ActivePowerTimeSeriesParameter, C) | ||
| multiplier = get_multiplier_array(param_container) |
There was a problem hiding this comment.
Good catch, yeah check this
| for br in parallel_brs | ||
| sample_line = first(parallel_brs) | ||
| impedance = PSY.get_r(sample_line) + im * PSY.get_x(sample_line) | ||
| first_name = PSY.get_name(sample_line) |
IOM #104 ("Redistribute operation models") de-power-ified IOM, leaving the power-flavoured problem taxonomy to POM. This renames POM's problem-type hierarchy and operations template to the explicit de-power-ified convention: PowerOperationModel -> AbstractPowerOperationProblem DecisionProblem -> AbstractPowerDecisionProblem EmulationProblem -> AbstractPowerEmulationProblem DefaultDecisionProblem -> DefaultPowerDecisionProblem DefaultEmulationProblem -> DefaultPowerEmulationProblem GenericOpProblem -> GenericPowerDecisionProblem GenericEmulationProblem -> GenericPowerEmulationProblem OperationsProblemTemplate -> PowerOperationsProblemTemplate Also: - Add the bare-system ArgumentError constructor stub for DefaultPowerDecisionProblem (a template is required). - Remove dead Simulation* test scaffolding that referenced IOM types removed by #104 (test_utils/run_simulation.jl and two unused mock helpers); mirrors IOM #104's own deletion of run_simulation.jl. POM precompiles and loads cleanly against the merged IOM main. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
luke-kiernan
left a comment
There was a problem hiding this comment.
Mostly small things. The 2 that require attention/discussion: CoPilot's comment on _renewable_offer_max and sharing the EvaluationContainer.
| PrettyTables = "08abe8d2-0d0c-5749-adfa-8a2ac140af0d" | ||
|
|
||
| [sources] | ||
| PowerOperationsModels = {path = ".."} |
There was a problem hiding this comment.
Do we want this POM pin or is this a temporary testing thing? Not sure--would have to check other projects for how they handle the docs environment.
There was a problem hiding this comment.
Testing thing will remove
| ) | ||
| container.power_flow_evaluation_data = Vector{PowerFlowEvaluationData}() | ||
| sizehint!(container.power_flow_evaluation_data, length(evaluators)) | ||
| evaluations = get_evaluations(network_model) |
There was a problem hiding this comment.
# Share the same EvaluationContainer instance between NetworkModel and # OptimizationContainer so IOM.calculate_aux_variables! sees the same # runtime state we register here.
Is this a bugfix or consequence of how we're restructuring things? Are these also shared in PSI?
| has_container_key(container, ActivePowerTimeSeriesParameter, C) || | ||
| return PSY.get_max_active_power(component) | ||
| param_container = get_parameter(container, ActivePowerTimeSeriesParameter, C) | ||
| multiplier = get_multiplier_array(param_container) |
There was a problem hiding this comment.
Good catch, yeah check this
| common single-evaluator case at call sites such as | ||
| `NetworkModel(...; evaluations = power_flow_evaluations(ACPowerFlow()))`. | ||
| """ | ||
| function power_flow_evaluations(ev) |
There was a problem hiding this comment.
typeof(ev)
Switch to a type parameter, ev::T.
| `ProblemTemplate` (the common case). `DecisionModel{<:DefaultPowerDecisionProblem}` | ||
| gets the generic template-driven `build_model!`/`validate_template` methods. | ||
| """ | ||
| abstract type DefaultPowerDecisionProblem <: AbstractPowerDecisionProblem end |
There was a problem hiding this comment.
nitpick on naming clarity: default sounds more specific than generic. So I'd be inclined to switch the order, have Generic as the supertype and Default as the instance.
There was a problem hiding this comment.
Agreed the naming is confusing I'll see how that looks
| network_model.subnetworks = get_subnetworks(get_network_model(model.template)) | ||
| # Initialization does not support PowerFlow evaluation - use empty vector | ||
| network_model.power_flow_evaluation = AbstractPowerFlowEvaluationModel[] | ||
| # Initialization does not run external evaluators |
There was a problem hiding this comment.
# Initialization does not run external evaluators
clarifying question: are there any internal evaluators? I don't think so, which makes the comment a bit deceptive
| return DecisionModel{GenericPowerDecisionProblem}(template, sys, jump_model; kwargs...) | ||
| end | ||
|
|
||
| # Template-driven problems require a template; the bare-system constructor is an error. |
There was a problem hiding this comment.
Confusing comment imo. Just say "default decision problems require a AbstractProblemTemplate subtype."
(Are there other problems that don't require a template? If so, which?)
There was a problem hiding this comment.
Yeah this comment confused me too
|
|
||
| # POM-side implementation of IOM's `validate_time_series!` extension point for | ||
| # emulation problems. Emulation models solve a single step, so horizon == resolution. | ||
| function validate_time_series!(model::EmulationModel{<:DefaultPowerEmulationProblem}) |
There was a problem hiding this comment.
could combine with decision problem to cut down on code repetition
| pf = sin( | ||
| atan(( | ||
| PSY.get_max_reactive_power(d, PSY.SU) / PSY.get_max_active_power(d, PSY.SU) | ||
| )), |
There was a problem hiding this comment.
Has closed form via Pythagorean identities: if r is for reactive and a for active, I'm getting r/sqrt(r^2+a^2)
PSY6/IS4 made physical-quantity getters require an explicit unit-system argument; the bare 1-arg getters no longer exist. Append `, PSY.SU` (system base, matching the optimization's working units) across branch, HVDC, reserve, and transmission-interface getters. Notable fixes: - TwoTerminalVSC PQ-approx registration: broadcasted limit getters need the unit marker wrapped in `Ref(PSY.SU)`, since `SystemBaseUnit()` is a plain singleton struct and is not on Julia's broadcast scalar allow-list (without Ref, broadcast tries to iterate it -> `length(::SystemBaseUnit)` MethodError). - DynamicBranchRating multiplier / branch rating: generalize the parallel-group dispatch from PNM.BranchesParallel to PNM.AbstractBranchesParallel so the new PNM.MixedBranchesParallel is handled (it otherwise fell through to the PSY.ACTransmission method and called get_rating on the group itself), and route the multiplier through get_rating(d). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- _renewable_offer_max: guard against devices missing a time-series entry when the type-level ActivePowerTimeSeriesParameter container exists; fall back to static max_active_power. Add mixed-TS regression test. - electric_loads: compute power factor via closed form q/sqrt(q^2+p^2) (also avoids NaN when p == q == 0). - pf_solve_and_aux: hoist per-parallel-group invariants out of inner loop. - power_flow_evaluations: use a type parameter instead of typeof(ev). - Swap problem-type names: Generic* is now the abstract supertype, Default* the concrete struct. - Extract shared resolution reconciliation into _reconcile_resolution!, shared by DecisionModel/EmulationModel validate_time_series!. - decision_model: throw (not just construct) the no-template ArgumentError; clarify comment. - Clarify EvaluationContainer-sharing and initialization comments. - docs/Project.toml: drop temporary local POM path source. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
luke-kiernan
left a comment
There was a problem hiding this comment.
Second pass: the get_rating name collision here is the big one
| IS.@assert_op T <: POM.BranchFlowAuxVariableType || | ||
| (T == POM.PowerFlowBranchActivePowerLoss) | ||
| if !isapprox(PSY.get_r(br) + im * PSY.get_x(br), impedance) | ||
| if !isapprox(PSY.get_r(br, PSY.SU) + im * PSY.get_x(br, PSY.SU), impedance) |
There was a problem hiding this comment.
Still needed? With updating PSI to be compatible with network reductions, I think it might be fine: see compute_parallel_multiplier in PNM.
| ################################## Rate Limits constraint_infos ############################ | ||
|
|
||
| function get_rating(double_circuit::PNM.BranchesParallel) | ||
| function get_rating(double_circuit::PNM.AbstractBranchesParallel) |
There was a problem hiding this comment.
nitpick: looks near-identical to PNM.get_sum_of_max_rating
| # A zero offer curve in SYSTEM_BASE. Used as the "absent" side when only one of the | ||
| # incremental/decremental curves is meaningful: PSY requires both offer curves to share | ||
| # a unit system, and the default `ZERO_OFFER_CURVE` is NATURAL_UNITS, so we can't leave | ||
| # the other side defaulted when the meaningful side is SYSTEM_BASE. A zero value curve is |
There was a problem hiding this comment.
Should we go change the default in PSY? Could open an issue there to discuss--if most curves are in SU, then imo we should.
| InfrastructureSystems = {rev = "IS4", url = "https://github.com/Sienna-Platform/InfrastructureSystems.jl"} | ||
| PowerSystems = {rev = "psy6", url = "https://github.com/Sienna-Platform/PowerSystems.jl"} | ||
| InfrastructureOptimizationModels = {rev = "main", url = "https://github.com/Sienna-Platform/InfrastructureOptimizationModels.jl"} | ||
| InfrastructureOptimizationModels = {path = "InfrastructureOptimizationModels.jl"} |
There was a problem hiding this comment.
I'd be surprised if the CI passes with this environment: you probably need IOM, PF, and PNM pinned to the correct branches (and not to local paths).
| end | ||
|
|
||
| function get_model(template::OperationsProblemTemplate, ::Type{T}) where {T <: PSY.Device} | ||
| function get_model( |
There was a problem hiding this comment.
(Pre-existing issue.) Where do we use get_model? Between here and main branch of IOM, the only call sites I can find are in test/test_utils/model_checks.jl. Perhaps we need it for the eventual PSI that sits on top of POM and IOM?
|
|
||
| # Predicates for value-curve shape. Used to decide whether a FuelConsumptionExpression | ||
| # container needs JuMP.QuadExpr storage (vs the cheaper GAE). | ||
| _value_curve_is_quadratic(::PSY.LinearCurve) = false |
There was a problem hiding this comment.
Could just write
_value_curve_is_quadratic(::PSY.InputOutputCurve{QuadraticFunctionData}) = true
_value_curve_is_quadratic(::PSY.InputOutputCurve{<:FunctionData}) = falseIS.FunctionData is mostly an internal type, though, so this is perhaps a little bit less-than-kosher.
| function get_rating(series_chain::PNM.BranchesSeries) | ||
| return minimum([get_rating(segment) for segment in series_chain]) | ||
| end | ||
| function get_rating(device::T) where {T <: PSY.ACTransmission} |
There was a problem hiding this comment.
I like the forced-function of having phased-out functions (like single-argument get_rating) error. I'd vote for re-naming this function to _get_rating: internal only.
Consider the following:
using PowerSimulations
from PowerSimulations import get_rating
using PowerSystems
from PowerSystems import get_rating
# set g equal to some generator from sys.
set_units_base_system!(sys, "DEVICE_BASE")
# pre-unitful PSY6: returns rating in device base, via PSY's single arg get_rating
# post unitful PSY6: returns rating in system base, via PSI's single arg get_rating.
r = get_rating(g)Now, we're not exporting PSI's get_rating, nor importing PSY's get_rating. So you'd really have to try to trip upon this. But still, my vote would be to rename, to avoid confusion and bugs.
There was a problem hiding this comment.
I asked Claude to search for similar name collisions: get_base_power is close, but PSI's 1-arg version only accepts OptimizationContainer and ModelStoreParams types.
Sienna-Platform/PowerSimulations.jl#1564
Sienna-Platform/PowerSimulations.jl#1609
Sienna-Platform/PowerSimulations.jl#1566
Requires IOM on Sienna-Platform/InfrastructureOptimizationModels.jl#97