Add HVDCTwoTerminalVSC two-terminal HVDC formulation#119
Conversation
Add two new formulations for PSY.TwoTerminalVSCLine: HVDCTwoTerminalVSC (NLP) and HVDCTwoTerminalVSCBin2 (SOS2 MILP). Both model independent active/reactive power control per terminal under a PQ capability circle, quadratic+linear converter losses, and explicit cable resistance via v_f - v_t = (1/g)*I. Reactive variables/constraints are added only on full AC networks. Refactor shared converter-loss helpers (loss expression builder, abs-value decomposition, linear-loss detection) out of mt_hvdc_models into src/common_models/quadratic_converter_loss.jl so the multi-terminal and two-terminal VSC paths reuse the same primitives. Rename the ConverterPositiveCurrent / NegativeCurrent / CurrentDirection variable types to generic PositiveCurrent / NegativeCurrent / CurrentDirection. Point Project.toml at the InfrastructureOptimizationModels ac/hvdc-vsc branch (which carries the per-device-bounds API used by these formulations) and restore runtests.jl to run the full test list.
|
Performance Results
|
There was a problem hiding this comment.
Pull request overview
This PR introduces a new two-terminal VSC HVDC formulation (HVDCTwoTerminalVSC NLP and HVDCTwoTerminalVSCBin2 MILP approximation) and associated modeling infrastructure, plus a refactor to share quadratic-loss and absolute-value current decomposition utilities across HVDC components.
Changes:
- Add two-terminal VSC HVDC device formulation (variables, constraints, constructor integration) including Bin2 approximations.
- Factor shared quadratic converter-loss + |I| decomposition helpers into a common module and reuse them in MT HVDC converter code.
- Add/extend tests to build/solve the new VSC formulations and update a logger-dependent warning test.
Reviewed changes
Copilot reviewed 13 out of 14 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
src/core/formulations.jl |
Adds AbstractTwoTerminalVSCFormulation, HVDCTwoTerminalVSC, HVDCTwoTerminalVSCBin2. |
src/core/variables.jl |
Introduces new VSC-related variables and renames current decomposition variable types. |
src/core/constraints.jl |
Adds constraint types/docs for VSC power balance, cable Ohm’s law, and PQ capability. |
src/common_models/quadratic_converter_loss.jl |
New shared helpers for quadratic/two-term loss expressions and abs-value decomposition. |
src/twoterminal_hvdc_models/TwoTerminalDC_branches.jl |
Implements VSC variable traits, core constraints (Ohm’s law, power balance), and PQ capability constraints. |
src/ac_transmission_models/branch_constructor.jl |
Wires TwoTerminalVSCLine into the build pipeline (variables, expressions, constraints, approximations). |
src/network_models/pm_translator.jl |
Skips PM translation for LCC/VSC two-terminal HVDC branches via a trait. |
src/mt_hvdc_models/HVDCsystems.jl |
Updates MT HVDC converter code to use shared loss/decomposition helpers and renamed variables. |
src/mt_hvdc_models/hvdcsystems_constructor.jl |
Updates MT HVDC converter constructor to use renamed decomposition variables and formatting tweaks. |
src/PowerOperationsModels.jl |
Includes the new common helper file and exports new formulations/variables (and renamed variables). |
test/test_device_hvdc.jl |
Adds VSC formulation tests and adjusts a warning assertion to read the build log file. |
test/runtests.jl |
Updates commented-out entries in the disabled test file list. |
test/Project.toml |
Pins InfrastructureOptimizationModels to a feature branch for tests. |
Project.toml |
Pins InfrastructureOptimizationModels to a feature branch and removes its [compat] entry. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| _maybe_add_reactive_power_variables!(container, devices, device_model, network_model) | ||
|
|
||
| if _use_linear_loss(F, device_model) | ||
| ll_devices = _devices_with_linear_loss(devices) | ||
| if isempty(ll_devices) | ||
| @warn "use_linear_loss is enabled but every TwoTerminalVSCLine has zero proportional loss terms; no linear-loss variables/constraints will be added." | ||
| else | ||
| _add_abs_value_decomposition_variables!(container, ll_devices, device_model) | ||
| end | ||
| end | ||
|
|
||
| add_to_expression!( | ||
| container, ActivePowerBalance, FlowActivePowerFromToVariable, | ||
| devices, device_model, network_model, | ||
| ) | ||
| add_to_expression!( | ||
| container, ActivePowerBalance, FlowActivePowerToFromVariable, | ||
| devices, device_model, network_model, | ||
| ) |
There was a problem hiding this comment.
@acostarelli Copilot is trolling here. the _maybe_add method also adds them to the expression. However, I would make the method in common files so it can be used with generic formulations like ThermalStandard or others. We do not need to refactor the old code to this, but future methods should try to use this.
| g = PSY.get_g(d) | ||
| # If g == 0 we treat the cable as lossless (v_f == v_t). | ||
| # Otherwise R = 1/g. | ||
| for t in time_steps | ||
| cons[name, t] = if iszero(g) | ||
| JuMP.@constraint(jump_model, v_f[name, t] == v_t[name, t]) | ||
| else | ||
| JuMP.@constraint( | ||
| jump_model, | ||
| v_f[name, t] - v_t[name, t] == (1.0 / g) * i_var[name, t], | ||
| ) |
There was a problem hiding this comment.
This is correct. If g = 0, then there is an infinite impedance and hence cannot be current flowing through the cable. Good catch here.
| # Bin2 variant: inscribed octagon in the rating circle (s/sqrt(2) half-diagonal). | ||
| # Eight linear constraints per terminal: ±p ± q ≤ s and ±p ≤ s, ±q ≤ s. | ||
| function add_constraints!( | ||
| container::OptimizationContainer, | ||
| ::Type{HVDCVSCReactiveCapabilityConstraint}, | ||
| devices::Union{ | ||
| Vector{PSY.TwoTerminalVSCLine}, | ||
| IS.FlattenIteratorWrapper{PSY.TwoTerminalVSCLine}, | ||
| }, | ||
| ::DeviceModel{PSY.TwoTerminalVSCLine, HVDCTwoTerminalVSCBin2}, | ||
| ::NetworkModel{<:AbstractPowerModel}, | ||
| ) | ||
| time_steps = get_time_steps(container) | ||
| names = [PSY.get_name(d) for d in devices] | ||
| jump_model = get_jump_model(container) | ||
|
|
||
| p_ft = get_variable(container, FlowActivePowerFromToVariable, PSY.TwoTerminalVSCLine) | ||
| p_tf = get_variable(container, FlowActivePowerToFromVariable, PSY.TwoTerminalVSCLine) | ||
| q_f = get_variable(container, HVDCReactivePowerFromVariable, PSY.TwoTerminalVSCLine) | ||
| q_t = get_variable(container, HVDCReactivePowerToVariable, PSY.TwoTerminalVSCLine) | ||
|
|
||
| cons = Dict{String, Any}() | ||
| for tag in ("from_pp", "from_pn", "from_np", "from_nn", | ||
| "to_pp", "to_pn", "to_np", "to_nn") | ||
| cons[tag] = add_constraints_container!( | ||
| container, HVDCVSCReactiveCapabilityConstraint, PSY.TwoTerminalVSCLine, | ||
| names, time_steps; meta = tag, | ||
| ) | ||
| end | ||
|
|
||
| inv_sqrt2 = 1.0 / sqrt(2.0) | ||
| for d in devices | ||
| name = PSY.get_name(d) | ||
| s_f = PSY.get_rating_from(d) * inv_sqrt2 | ||
| s_t = PSY.get_rating_to(d) * inv_sqrt2 | ||
| for t in time_steps | ||
| # Octagon at the from terminal: project (p, q) onto 4 diagonal lines. | ||
| cons["from_pp"][name, t] = | ||
| JuMP.@constraint(jump_model, p_ft[name, t] + q_f[name, t] <= 2.0 * s_f) | ||
| cons["from_pn"][name, t] = | ||
| JuMP.@constraint(jump_model, p_ft[name, t] - q_f[name, t] <= 2.0 * s_f) | ||
| cons["from_np"][name, t] = | ||
| JuMP.@constraint(jump_model, -p_ft[name, t] + q_f[name, t] <= 2.0 * s_f) | ||
| cons["from_nn"][name, t] = | ||
| JuMP.@constraint(jump_model, -p_ft[name, t] - q_f[name, t] <= 2.0 * s_f) | ||
| cons["to_pp"][name, t] = | ||
| JuMP.@constraint(jump_model, p_tf[name, t] + q_t[name, t] <= 2.0 * s_t) | ||
| cons["to_pn"][name, t] = | ||
| JuMP.@constraint(jump_model, p_tf[name, t] - q_t[name, t] <= 2.0 * s_t) | ||
| cons["to_np"][name, t] = | ||
| JuMP.@constraint(jump_model, -p_tf[name, t] + q_t[name, t] <= 2.0 * s_t) | ||
| cons["to_nn"][name, t] = | ||
| JuMP.@constraint(jump_model, -p_tf[name, t] - q_t[name, t] <= 2.0 * s_t) |
- Rename Bin2 → MIP for VSC + IPC formulations and update tests
- Make HVDCReactivePowerVariable parametric on From/To with const aliases
- Wire HVDCReactivePower{From,To}Variable into ReactivePowerBalance on AC
- Fix g==0 cable Ohm's law to force I==0 (open circuit, not v_f == v_t)
- Add axis-aligned half-spaces to the MIP PQ-capability octagon
- Default use_linear_loss=true for MIP and false for NLP via
get_default_attributes; warn when NLP runs with use_linear_loss=true
- Collapse abs-value decomposition into a single ModelConstructStage helper
- Skip zero-coefficient terms in the quadratic converter loss expression
- Fix docstring sign convention on HVDCVSCConverterPowerConstraint
- Drop brittle log-file-regex linear-loss warning test
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Add HVDCTwoTerminalVSCLP formulation that owns the octagonal linear
outer-approximation of the per-terminal PQ disk; document the octagon
geometry and its tightness tradeoffs vs the PWL path.
- Refactor HVDCTwoTerminalVSC (NLP) and HVDCTwoTerminalVSCMIP (PWL) to
share a single PQ-capability add_constraints! method that writes
`p_sq + q_sq <= s^2` over IOM._add_quadratic_approx! expressions.
- Collapse the HVDCReactivePowerVariable{From}/{To} add_to_expression
methods into one parametric method via a new `_vsc_arc_bus` helper;
collapse `_vsc_v_from_bounds`/`_vsc_v_to_bounds` similarly.
- Dispatch `_quadratic_converter_loss_expr` on i_sq_t (QuadExpr vs
AffExpr) and accumulate via JuMP.add_to_expression!; degrade to
AffExpr when a == 0 even on the QuadExpr branch.
- Drop the explicit-type-check MINLP warnings on both VSC and MT HVDC
paths; leave brief comments in their place.
- Document the use_linear_loss defaults on all four
get_default_attributes methods.
- Reorder construct_device! to add the abs-value decomposition variables
before the constraints that consume them (PositiveCurrent /
NegativeCurrent were previously requested before they were added).
- Replace `filter(_has_linear_loss, devices)` with a comprehension so
it works on FlattenIteratorWrapper as well as Vector.
- Omit the VSCMIP/VSCLP on ACPPowerModel build tests: HiGHS cannot
handle the ACP network's trigonometric branch constraints.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
| """ | ||
| struct HVDCToDCVoltage <: VariableType end | ||
|
|
||
| abstract type From end |
There was a problem hiding this comment.
This looks wierd. Probably an hallucination
|
|
||
| For `HVDCTwoTerminalVSCMIP` it is replaced by an inscribed polygon to stay MILP. | ||
| """ | ||
| struct HVDCVSCReactiveCapabilityConstraint <: ConstraintType end |
There was a problem hiding this comment.
This name is off. If this is a constraint for p^2+q^2, then this is constraint on ApparentPower, so it should be HVDCVSCApparentPowerLimitConstraint.
However a more important comment is that I don't understand why Claude is talking here about MIP or LP. This a circle constraint on apparent power, but we could skip this and only model the box constraints separately Pmin <= P <= Pmax and Qmin <= Q <= Qmax.
There was a problem hiding this comment.
I instructed it to make three formulations:
- NLP that uses the exact p^2+q^2
- MIP that approximates p^2 and q^2
- LP that builds an octagon (instead of a box)
| # solver in that case. | ||
| use_ll = get_attribute(model, "use_linear_loss") | ||
| if use_ll | ||
| _add_abs_value_decomposition!( |
There was a problem hiding this comment.
Is this creating the variables in the ModelConstructor? I think we should try to create the variables in the ArgumentConstructor. Is fine if we have two auxiliary methods for constraints and variables separately.
rodrigomha
left a comment
There was a problem hiding this comment.
@acostarelli I think these comments are good starting point. Let me know when you address them and I will re-review.
Overall, is in a good shape. I have not reviewed the tests yet.
I think we should not use parametric types for now for variables or others, so let's revert that to multiple From/To structs specifically.
- Drop parametric From/To types in favor of two concrete reactive-power variable structs (HVDCReactivePowerFromVariable / ToVariable); unroll the side-parametric _vsc_* accessors into two named methods each. - Rename HVDCTwoTerminalVSCMIP -> HVDCTwoTerminalVSCMILP (forward-looking for future MINLP support) and HVDCVSCReactiveCapabilityConstraint -> HVDCVSCApparentPowerLimitConstraint with a docstring covering NLP/MILP/LP paths accurately. - Split _add_abs_value_decomposition! into separate variables (ArgumentStage) and constraints (ModelStage) helpers; move the variable-creation call to ArgumentConstructStage in both the VSC and MT converter constructors. - Move _maybe_add_reactive_power_variables! / _constraints! to a new common_models/network_conditional.jl as generic helpers parameterized by variable types and constraint type, addressing issue #120. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
| Two-terminal VSC: add `HVDCReactivePowerFromVariable` to `ReactivePowerBalance` | ||
| at the from-terminal AC bus. | ||
| """ | ||
| function add_to_expression!( |
There was a problem hiding this comment.
We can still combine these two add_to_expression!s and dispatch on the variable type.
There was a problem hiding this comment.
I think is fine having two methods. However, since the methods are super close I think you could reuse a single method with Union for both From and To, and having a helper method that retrieves the from or to bus depending on the variable. Proposed approach:
function add_to_expression!(
container::OptimizationContainer,
::Type{T},
::Type{U},
devices::IS.FlattenIteratorWrapper{V},
::DeviceModel{V, W},
network_model::NetworkModel{X},
) where {
T <: ReactivePowerBalance,
U <: Union{HVDCReactivePowerFromVariable, HVDCReactivePowerToVariable}
V <: PSY.TwoTerminalVSCLine,
W <: AbstractTwoTerminalVSCFormulation,
X <: ACPPowerModel,
}
var = get_variable(container, HVDCReactivePowerToVariable, V)
nodal_expr = get_expression(container, T, PSY.ACBus)
network_reduction = get_network_reduction(network_model)
time_steps = get_time_steps(container)
for d in devices
name = PSY.get_name(d)
bus = _get_bus_for_expression(d, U) # TODO: implement this helper method
bus_no = PNM.get_mapped_bus_number(network_reduction, bus)
for t in time_steps
add_proportional_to_jump_expression!(
nodal_expr[bus_no, t],
var[name, t],
-1.0,
)
end
end
return
endAlso, I think we should just add the ReactivePowerVariable positive to the expression. This is mostly a convention for VSC endings, that they can inject/consume reactive power freely, so it's fine to have them positive (contrary to the LCC model that has to consume reactive power).
| (prefix = "from", p_var = p_ft, q_var = q_f, rating_getter = _vsc_rating_from), | ||
| (prefix = "to", p_var = p_tf, q_var = q_t, rating_getter = _vsc_rating_to), | ||
| ) | ||
| for d in devices |
There was a problem hiding this comment.
Can we reorganize this loop and use JuMP vectorized jump.@constraint macros to build this faster?
There was a problem hiding this comment.
You can ask Claude to try this, but this is okay as it is. Note that we are adding a container for each tag, so it can be a pain to implement that.
| # Their intersection is a regular octagon. Eight linear constraints per | ||
| # terminal per timestep (four diagonal + four axis-aligned). | ||
| # | ||
| # Tightness: the octagon is loose by at most ≈8.2% in area (octagon-to-disk |
There was a problem hiding this comment.
Are you sure it's an outer approximation?
|
Still some work to be done here, but is getting there. |
Drop the HVDCTwoTerminalVSCMILP formulation: the PWL surrogate for the
loss model is already MILP-grade, so a separate MILP-on-disk path didn't
buy anything we can solve. The LP path now carries a `use_octagon`
attribute (default `true`) that toggles the four diagonal half-planes on
top of the always-on axis-aligned box, so users can pick either the
octagon (≤8.2% loose linear outer-approximation of the disk) or just the
box without adding new formulations.
Refactors driven by the review feedback:
- Rename MIPQuadraticLossConverter -> MILPQuadraticLossConverter
(forward-looking for potential MINLP variants).
- Delete _maybe_add_pq_sq_approx! and the per-formulation
_uses_pq_sq_approx trait; the p_sq/q_sq IOM expressions are now
registered by _register_pq_sq_expressions!, resolved by dispatch on
(formulation, network) so only HVDCTwoTerminalVSC on an AC network
registers them.
- Drop the VSC PSY wrappers (_vsc_v_limits_*, _vsc_rating_*,
_vsc_v_bounds_*, _vsc_i_bounds) and call PSY.get_voltage_limits_* /
get_rating_* directly via broadcast.
- Consolidate _vsc_shared_i_max into _linear_loss_i_max with method
overloads for TwoTerminalVSCLine and InterconnectingConverter.
- Collapse the From/To HVDCReactivePower* add_to_expression! pair into
one method dispatching on the variable type via _vsc_q_terminal_bus,
and flip the coefficient from -1.0 to +1.0 (VSC injects/consumes Q
freely, unlike LCC which is strictly a consumer).
- Replace the runtime `if N <: AbstractActivePowerModel` guards in
network_conditional.jl with explicit no-op methods dispatched on
NetworkModel{<:AbstractActivePowerModel}.
- Combine the two _quadratic_converter_loss_expr methods via a
JuMP-type-dispatched _loss_seed helper.
- Drop the i_max_getter::Function argument from
_add_abs_value_decomposition_constraints!; the getter is now picked
by dispatch on the device type.
- Switch the four VSC add_constraints! signatures to the parameterized
Union{Vector{U}, IS.FlattenIteratorWrapper{U}} where {U <: ...} form
used elsewhere in the file.
Tests:
- Delete the HVDCTwoTerminalVSCMILP testsets and rename the
MILPQuadraticLossConverter testset accordingly.
- Switch the MIP-vs-NLP agreement test to LP-vs-NLP isapprox at 5%
tolerance (on DCP the PQ disk is inactive, so the LP and NLP differ
only by the i² loss surrogate; the SOS2 PWL can be slightly above the
exact i², so a strict ordering doesn't hold).
- Add a new testset that asserts use_octagon=true on the LP path gives
an objective ≥ the box-only case, pinning the new attribute's
semantics.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
| return expr | ||
| end | ||
|
|
||
| function _quadratic_converter_loss_expr( |
There was a problem hiding this comment.
What do you think of this setup with the helper? @rodrigomha
| prefix = spec.prefix | ||
| p_var, q_var = spec.p_var, spec.q_var | ||
| for t in time_steps | ||
| cons[prefix * "_p_ub"][name, t] = |
There was a problem hiding this comment.
If we didn't this prefix system, could we simplify this using the vectorized constraints?
There was a problem hiding this comment.
This is good for future testing but not really needed for this PR, so we can keep it as it is for now, but an issue could be open if you want to explore the idea
| ``I^2`` terms exact. Requires an NLP-capable solver (e.g. Ipopt). | ||
| """ | ||
| struct HVDCTwoTerminalVSC <: AbstractTwoTerminalVSCFormulation end | ||
| struct HVDCTwoTerminalVSCNLP <: AbstractTwoTerminalVSCFormulation end |
There was a problem hiding this comment.
@rodrigomha What do you think of this naming?
There was a problem hiding this comment.
It's okay I think. I'm thinking that maybe NLPHVDCTwoTerminalVSC and LPHVDCTwoTerminalVSC is better, but I don't know lol. This approach is more consistent with the Converter convention using MILPQuadraticLossConverter.
Any ideas @jd-lara
| # converter formulations. | ||
| _quad_config(::Type{HVDCTwoTerminalVSCNLP}) = IOM.NoQuadApproxConfig() | ||
| _quad_config(::Type{HVDCTwoTerminalVSCLP}) = | ||
| IOM.SolverSOS2QuadConfig(DEFAULT_INTERPOLATION_LENGTH) |
There was a problem hiding this comment.
@acostarelli Can you open an issue to figure out how the user can configure the interpolation and model option (BIN2 or other) for the approximation for the future? We don't need to solve this in this PR, but we need to be aware of that.
rodrigomha
left a comment
There was a problem hiding this comment.
Okay. I'm happy with this. We can merge this @jd-lara after we decide if we are okay with the naming convention
we can open an issue to review that |

No description provided.