Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/InfrastructureOptimizationModels.jl
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,7 @@ export add_sparse_pwl_interpolation_variables!
export JuMPOrFloat
# Constraint helpers
export add_range_constraints!, add_parameterized_upper_bound_range_constraints
export add_parameterized_lower_bound_range_constraints
export add_reserve_bound_range_constraints!, add_commitment_bound_range_constraints!
export add_semicontinuous_range_constraints!, add_semicontinuous_ramp_constraints!
export add_slacked_range_constraints!, fill_slacked_range_constraints!
Expand Down
26 changes: 25 additions & 1 deletion src/common_models/range_constraint.jl
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,19 @@ function add_parameterized_bound_range_constraints(
return
end

# Backwards-compatible wrappers
# Direction-specific wrappers over `add_parameterized_bound_range_constraints`.
"""
Add `array[name, t] >= rhs[name, t]` constraints of type `T`, where `array` is the variable or

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Docstring is identical to the upper_bound version, do they both need it? Could we put just this one docstring on add_parameterized_bound_range_constraints? Or even further: do we need these helpers? A caller could easily pass the LowerBound() or UpperBound() themselves.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see the deleted comment says "backwards compatible", but that's probably from PSI.

@luke-kiernan luke-kiernan Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or even further: do we need these helpers? A caller could easily pass the LowerBound() or UpperBound() themselves.

I share your sentiments...but @jd-lara says they're needed and should have tests. See issue #130 for disucssion: after 2 rounds of back-and-forth, I decided to take his word for it, even if it doesn't make sense to me.

expression `U` and the right-hand side is built from the parameter `P`. The right-hand side
depends on the parameter family:

- generic `P`: `multiplier[name, t] * parameter[name, t]`
- `P <: EventParameter`: `get_max_active_power(device) * parameter[name, t]`

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Musing out loud: these three paths are pretty similar, but we're dispatching at a higher level in common_models/range_constraint such that we have a lot of code that is pretty identical. If instead we had smaller wrappers like _multiplier(param_type, device_type) (device_type so EventParameters can get MAP) and _param(param_type), we could probably shave off a lot of repeated code there.

@luke-kiernan luke-kiernan Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had AI do some of that in moderation. I don't want to go too deep, though: test behavior, not code.

- `P <: TimeSeriesParameter`: `multiplier[name, t] * parameter_column[t]`, and only devices
that own the time series named in `get_time_series_names(model)[P]` are constrained.

Mirror of [`add_parameterized_upper_bound_range_constraints`](@ref).
"""
function add_parameterized_lower_bound_range_constraints(
container::OptimizationContainer,
::Type{T},
Expand Down Expand Up @@ -444,6 +456,18 @@ function add_parameterized_lower_bound_range_constraints(
return
end

"""
Add `array[name, t] <= rhs[name, t]` constraints of type `T`, where `array` is the variable or
expression `U` and the right-hand side is built from the parameter `P`. The right-hand side
depends on the parameter family:

- generic `P`: `multiplier[name, t] * parameter[name, t]`
- `P <: EventParameter`: `get_max_active_power(device) * parameter[name, t]`
- `P <: TimeSeriesParameter`: `multiplier[name, t] * parameter_column[t]`, and only devices
that own the time series named in `get_time_series_names(model)[P]` are constrained.

Mirror of [`add_parameterized_lower_bound_range_constraints`](@ref).
"""
function add_parameterized_upper_bound_range_constraints(
container::OptimizationContainer,
::Type{T},
Expand Down
1 change: 1 addition & 0 deletions test/InfrastructureOptimizationModelsTests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ function run_tests()
include(joinpath(TEST_DIR, "test_jump_utils.jl"))
include(joinpath(TEST_DIR, "test_pwl_methods.jl"))
include(joinpath(TEST_DIR, "test_ramp_constraints.jl"))
include(joinpath(TEST_DIR, "test_parameterized_range_constraints.jl"))
include(joinpath(TEST_DIR, "test_duration_constraints.jl"))
include(joinpath(TEST_DIR, "test_emulation_model_store.jl"))
include(joinpath(TEST_DIR, "test_model_store.jl"))
Expand Down
3 changes: 2 additions & 1 deletion test/mocks/mock_components.jl
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,8 @@ end
get_name(l::MockLoad) = l.name
get_available(l::MockLoad) = l.available
get_bus(l::MockLoad) = l.bus
get_max_active_power(l::MockLoad) = l.max_active_power
IOM.get_max_active_power(l::MockLoad) = l.max_active_power
IOM.get_max_active_power(g::MockThermalGen) = g.active_power_limits.max

# Mock Branch
struct MockBranch <: AbstractMockDevice
Expand Down
22 changes: 21 additions & 1 deletion test/mocks/mock_time_series.jl
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Minimal time series mocks for testing parameter updates.

using Dates

struct MockDeterministic
struct MockDeterministic <: IS.TimeSeriesData
name::String
data::Vector{Float64}
resolution::Dates.Period
Expand All @@ -18,3 +18,23 @@ struct MockSingleTimeSeries
end

get_name(ts::Union{MockDeterministic, MockSingleTimeSeries}) = ts.name

# Mock components are immutable and hold no time series manager, so `IS.has_time_series`
# can't work off the component itself. Builders that filter devices on time series
# ownership (e.g. the parameterized range constraints) consult this registry instead.
const MOCK_TIME_SERIES_REGISTRY = Dict{String, Set{String}}()

function mock_add_time_series!(component, ts_name::AbstractString)
push!(get!(MOCK_TIME_SERIES_REGISTRY, get_name(component), Set{String}()), ts_name)
return
end

mock_clear_time_series!() = empty!(MOCK_TIME_SERIES_REGISTRY)

function IS.has_time_series(
component::AbstractMockDevice,
::Type{MockDeterministic},
ts_name::AbstractString,
)
return ts_name in get(MOCK_TIME_SERIES_REGISTRY, get_name(component), Set{String}())
end
211 changes: 211 additions & 0 deletions test/test_parameterized_range_constraints.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
"""
Unit tests for the parameterized range constraints
(`add_parameterized_lower_bound_range_constraints` /
`add_parameterized_upper_bound_range_constraints`).

The real branching is the parameter family: generic, `EventParameter`, and
`TimeSeriesParameter` dispatch to different `_bound_range_with_parameter!` methods, and the
time series one additionally filters devices, so those carry the bulk of the coverage.
"""

struct TestValueParameter <: IOM.VariableValueParameter end
struct TestTimeSeriesParameter <: IOM.TimeSeriesParameter end
struct TestEventParameter <: IOM.EventParameter end

const PARAM_RANGE_TS_NAME = "max_active_power"
const PARAM_RANGE_MULTIPLIER = 0.5

param_range_value(name, t) = name == "A" ? 1.0 * t : 10.0 + t

function _make_parameterized_range_container(devices, time_steps)
mock_sys = MockSystem(100.0)
settings = IOM.Settings(
mock_sys;
horizon = Dates.Hour(length(time_steps)),
resolution = Dates.Hour(1),
time_series_cache_size = 0,
)
container = IOM.OptimizationContainer(mock_sys, settings, nothing, MockDeterministic)
IOM.set_time_steps!(container, time_steps)
jump_model = IOM.get_jump_model(container)
names = [get_name(d) for d in devices]
D = eltype(devices)
var = IOM.add_variable_container!(container, TestVariableType, D, names, time_steps)
expr =
IOM.add_expression_container!(container, TestExpressionType, D, names, time_steps)
for name in names, t in time_steps
v = JuMP.@variable(jump_model)
var[name, t] = v
# Scaled so a test can tell the expression path from the variable path.
expr[name, t] = 2.0 * v
end
return container
end

"Add a `TestValueParameter` container with `param[name, t] = param_range_value(name, t)`."
function _add_value_parameter!(container, names, time_steps)
param_container = IOM.add_param_container!(
container,
TestValueParameter,
MockThermalGen,
IOM.VariableKey(TestVariableType, MockThermalGen),
names,
time_steps,
)
param_array = IOM.get_parameter_array(param_container)
mult_array = IOM.get_multiplier_array(param_container)
for name in names, t in time_steps
param_array[name, t] = param_range_value(name, t)
mult_array[name, t] = PARAM_RANGE_MULTIPLIER
end
return param_container
end

@testset "Parameterized range constraints" begin
time_steps = 1:3
devices = [make_mock_thermal("A"), make_mock_thermal("B")]
names = ["A", "B"]
model = IOM.DeviceModel(
MockThermalGen,
TestFormulation;
time_series_names = Dict{Type{<:IOM.ParameterType}, String}(
TestTimeSeriesParameter => PARAM_RANGE_TS_NAME,
),
)

@testset "Bounds from a variable value parameter" begin
container = _make_parameterized_range_container(devices, time_steps)
_add_value_parameter!(container, names, time_steps)
for f in (
add_parameterized_lower_bound_range_constraints,
add_parameterized_upper_bound_range_constraints,
)
f(
container,
TestConstraintType,
TestVariableType,
TestValueParameter,
devices,
model,
TestPowerModel,
)
end
con_lb = IOM.get_constraint(container, TestConstraintType, MockThermalGen, "lb")
con_ub = IOM.get_constraint(container, TestConstraintType, MockThermalGen, "ub")
var = IOM.get_variable(container, TestVariableType, MockThermalGen)
# The two directions land in distinct containers under the "lb"/"ub" metas. Sense is
# compile-time dispatch, invariant across names and time steps, so spot-check it
# once; the RHS arithmetic is what varies, so check that elementwise.
@test JuMP.constraint_object(con_lb["A", 1]).set isa MOI.GreaterThan
@test JuMP.constraint_object(con_ub["A", 1]).set isa MOI.LessThan
for name in names, t in time_steps
@test JuMP.normalized_coefficient(con_lb[name, t], var[name, t]) ≈ 1.0
@test JuMP.normalized_rhs(con_lb[name, t]) ≈
PARAM_RANGE_MULTIPLIER * param_range_value(name, t)
end
end

@testset "Exercise expression codepath" begin
container = _make_parameterized_range_container(devices, time_steps)
_add_value_parameter!(container, names, time_steps)
add_parameterized_lower_bound_range_constraints(
container,
TestConstraintType,
TestExpressionType,
TestValueParameter,
devices,
model,
TestPowerModel,
)
con = IOM.get_constraint(container, TestConstraintType, MockThermalGen, "lb")
var = IOM.get_variable(container, TestVariableType, MockThermalGen)
# The expression is 2 * var, so the LHS coefficient must be 2, not 1.
for name in names, t in time_steps
@test JuMP.normalized_coefficient(con[name, t], var[name, t]) ≈ 2.0
end
end

@testset "Time series parameter constrains only devices owning the time series" begin

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This does not check that "B" does not have any constraints. Which is fine I think, like I'm not sure we need to test that. But then 1) this test's name is weird and 2) then it's redundant to the other tests, right?

mock_clear_time_series!()
ts_devices =
[make_mock_thermal("A"), make_mock_thermal("B"), make_mock_thermal("C")]
ts_names = ["A", "C"] # B has no time series
for device in ts_devices
get_name(device) in ts_names &&
mock_add_time_series!(device, PARAM_RANGE_TS_NAME)
end
uuids = Dict(name => "uuid-$name" for name in ts_names)
container = _make_parameterized_range_container(ts_devices, time_steps)
param_container = IOM.add_param_container!(
container,
TestTimeSeriesParameter,
MockThermalGen,
MockDeterministic,
PARAM_RANGE_TS_NAME,
collect(values(uuids)),
ts_names,
(),
time_steps,
)
attributes = IOM.get_attributes(param_container)
param_array = IOM.get_parameter_array(param_container)
mult_array = IOM.get_multiplier_array(param_container)
for name in ts_names
IOM.add_component_name!(attributes, name, uuids[name])
for t in time_steps
param_array[uuids[name], t] = param_range_value(name, t)
mult_array[name, t] = PARAM_RANGE_MULTIPLIER
end
end

add_parameterized_lower_bound_range_constraints(
container,
TestConstraintType,
TestVariableType,
TestTimeSeriesParameter,
ts_devices,
model,
TestPowerModel,
)
con = IOM.get_constraint(container, TestConstraintType, MockThermalGen, "lb")
@test axes(con)[1] == ts_names
# RHS comes from the parameter's column refs rather than the parameter array.
for name in ts_names, t in time_steps
@test JuMP.normalized_rhs(con[name, t]) ≈
PARAM_RANGE_MULTIPLIER * param_range_value(name, t)
end
mock_clear_time_series!()
end

@testset "Event parameter scales by the device's max active power" begin
container = _make_parameterized_range_container(devices, time_steps)
param_container = IOM.add_param_container!(
container,
TestEventParameter,
MockThermalGen,
MockThermalGen,
names,
time_steps,
)
param_array = IOM.get_parameter_array(param_container)
for name in names, t in time_steps
param_array[name, t] = param_range_value(name, t)
end
add_parameterized_lower_bound_range_constraints(
container,
TestConstraintType,
TestVariableType,
TestEventParameter,
devices,
model,
TestPowerModel,
)
con = IOM.get_constraint(container, TestConstraintType, MockThermalGen, "lb")
for (device, name) in zip(devices, names), t in time_steps
# The event path scales by the device's max active power, not the multiplier
# array (which is left as NaN here).
expected = IOM.get_max_active_power(device) * param_range_value(name, t)
@test JuMP.normalized_rhs(con[name, t]) ≈ expected
end
end
end
2 changes: 1 addition & 1 deletion test/verify_mocks.jl
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ load = MockLoad("load1", true, bus, 75.0)
get_name(load)
get_available(load)
get_bus(load)
get_max_active_power(load)
IOM.get_max_active_power(load)

# MockBranch
bus2 = MockBus("bus2", 2, :PQ)
Expand Down
Loading