Skip to content

Commit 7a7375f

Browse files
verheemvisr
andauthored
A Boolean for allocation controlled (#2912)
fixes #2889 example of an outlet that switches between distinct allocation states <img width="596" height="396" alt="Screenshot 2026-02-26 at 09 50 06" src="https://github.com/user-attachments/assets/3e1e19fd-56ac-4f25-88b8-dea4064ec606" /> --------- Co-authored-by: Martijn Visser <mgvisser@gmail.com>
1 parent cfadad0 commit 7a7375f

File tree

26 files changed

+330
-180
lines changed

26 files changed

+330
-180
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,3 +172,5 @@ debug/*
172172

173173
.DS_Store
174174
lock.pid
175+
176+
CLAUDE.md

core/src/allocation_init.jl

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -47,21 +47,9 @@ function add_flow!(
4747
allocation_model::AllocationModel,
4848
p_independent::ParametersIndependent,
4949
)::Nothing
50-
(; problem, subnetwork_id, scaling) = allocation_model
50+
(; problem, subnetwork_id, scaling, flow_links_subnetwork) = allocation_model
5151
(; graph) = p_independent
5252

53-
node_ids_subnetwork = graph[].node_ids[subnetwork_id]
54-
flow_links_subnetwork = Vector{Tuple{NodeID, NodeID}}()
55-
56-
# Sort link metadata for deterministic problem generation
57-
for link_metadata in sort!(collect(values(graph.edge_data)))
58-
(; type, link) = link_metadata
59-
if (type == LinkType.flow) &&
60-
((link[1] node_ids_subnetwork) || (link[2] node_ids_subnetwork))
61-
push!(flow_links_subnetwork, link)
62-
end
63-
end
64-
6553
# Define decision variables: flow over flow links (scaling.flow * m^3/s)
6654
problem[:flow] = JuMP.@variable(
6755
problem,
@@ -73,6 +61,7 @@ function add_flow!(
7361
return nothing
7462
end
7563

64+
7665
"""
7766
Add flow conservation constraints for conservative nodes.
7867
Ensures that inflow equals outflow for nodes that conserve mass (pumps, outlets,
@@ -1004,6 +993,20 @@ function AllocationModel(
1004993
end
1005994
end
1006995

996+
(; graph) = p_independent
997+
998+
node_ids_subnetwork = graph[].node_ids[subnetwork_id]
999+
flow_links_subnetwork = Vector{Tuple{NodeID, NodeID}}()
1000+
1001+
# Sort link metadata for deterministic problem generation
1002+
for link_metadata in sort!(collect(values(graph.edge_data)))
1003+
(; type, link) = link_metadata
1004+
if (type == LinkType.flow) &&
1005+
((link[1] node_ids_subnetwork) || (link[2] node_ids_subnetwork))
1006+
push!(flow_links_subnetwork, link)
1007+
end
1008+
end
1009+
10071010
allocation_model = AllocationModel(;
10081011
subnetwork_id,
10091012
node_ids_in_subnetwork,
@@ -1012,6 +1015,7 @@ function AllocationModel(
10121015
scaling,
10131016
has_demand_priority,
10141017
secondary_network_demand,
1018+
flow_links_subnetwork,
10151019
)
10161020

10171021
# Volume and flow

core/src/allocation_optim.jl

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1039,7 +1039,7 @@ function save_flows!(
10391039
return nothing
10401040
end
10411041

1042-
# Set the flow rate of allocation controlled pumps and outlets to
1042+
# Set the flow rate of allocation controlled pumps, outlets and rating curves to
10431043
# their flow determined by allocation
10441044
function apply_control_from_allocation!(
10451045
node::Union{Pump, Outlet, TabulatedRatingCurve},
@@ -1100,14 +1100,38 @@ function delete_control_constraints!(
11001100
return nothing
11011101
end
11021102

1103+
function update_flow_variable_bounds!(
1104+
allocation_model::AllocationModel,
1105+
p_independent::ParametersIndependent,
1106+
)::Nothing
1107+
(; problem, scaling, flow_links_subnetwork) = allocation_model
1108+
flow = problem[:flow]
1109+
for flow_link in flow_links_subnetwork
1110+
flow_var = flow[flow_link]
1111+
JuMP.is_fixed(flow_var) && continue
1112+
JuMP.set_lower_bound(
1113+
flow_var,
1114+
flow_capacity_lower_bound(flow_link, p_independent) / scaling.flow,
1115+
)
1116+
JuMP.set_upper_bound(
1117+
flow_var,
1118+
flow_capacity_upper_bound(flow_link, p_independent) / scaling.flow,
1119+
)
1120+
end
1121+
return nothing
1122+
end
1123+
11031124
function update_control_states!(
11041125
allocation_model::AllocationModel,
11051126
p_independent::ParametersIndependent,
11061127
)::Nothing
11071128
delete_control_constraints!(allocation_model, :pump)
11081129
delete_control_constraints!(allocation_model, :outlet)
1130+
delete_control_constraints!(allocation_model, :tabulated_rating_curve_constraint)
11091131
add_pump!(allocation_model, p_independent)
11101132
add_outlet!(allocation_model, p_independent)
1133+
add_tabulated_rating_curve!(allocation_model, p_independent)
1134+
update_flow_variable_bounds!(allocation_model, p_independent)
11111135
return nothing
11121136
end
11131137

core/src/allocation_util.jl

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,3 +502,10 @@ function get_primary_network(allocation_models::Vector{AllocationModel})::Alloca
502502
end
503503
error("Queries primary network while no primary network found in allocation models.")
504504
end
505+
506+
function delete_flow!(
507+
allocation_model::AllocationModel
508+
)::Nothing
509+
(; problem) = allocation_model
510+
return JuMP.delete(problem, problem[:flow])
511+
end

core/src/callback.jl

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -685,22 +685,19 @@ function set_new_control_state!(
685685
for target_node_id in discrete_control.controlled_nodes[discrete_control_id.idx]
686686
set_control_params!(p, target_node_id, control_state_new)
687687

688-
if control_state_new == "Ribasim.allocation"
689-
if target_node_id.type == NodeType.Pump
690-
pump.allocation_controlled[target_node_id.idx] = true
691-
elseif target_node_id.type == NodeType.Outlet
692-
outlet.allocation_controlled[target_node_id.idx] = true
693-
elseif target_node_id.type == NodeType.TabulatedRatingCurve
694-
tabulated_rating_curve.allocation_controlled[target_node_id.idx] = true
695-
end
696-
else
697-
if target_node_id.type == NodeType.Pump
698-
pump.allocation_controlled[target_node_id.idx] = false
699-
elseif target_node_id.type == NodeType.Outlet
700-
outlet.allocation_controlled[target_node_id.idx] = false
701-
elseif target_node_id.type == NodeType.TabulatedRatingCurve
702-
tabulated_rating_curve.allocation_controlled[target_node_id.idx] = false
703-
end
688+
# Update allocation_controlled based on the new control state
689+
if target_node_id.type == NodeType.Pump
690+
control_state_update = pump.control_mapping[(target_node_id, control_state_new)]
691+
pump.allocation_controlled[target_node_id.idx] =
692+
control_state_update.allocation_controlled
693+
elseif target_node_id.type == NodeType.Outlet
694+
control_state_update = outlet.control_mapping[(target_node_id, control_state_new)]
695+
outlet.allocation_controlled[target_node_id.idx] =
696+
control_state_update.allocation_controlled
697+
elseif target_node_id.type == NodeType.TabulatedRatingCurve
698+
control_state_update = tabulated_rating_curve.control_mapping[(target_node_id, control_state_new)]
699+
tabulated_rating_curve.allocation_controlled[target_node_id.idx] =
700+
control_state_update.allocation_controlled
704701
end
705702
end
706703

core/src/parameter.jl

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,7 @@ scaling: The flow and storage scaling factors to make the optimization problem m
259259
sources::OrderedDict{Int32, NodeID} = OrderedDict()
260260
secondary_network_demand::OrderedDict{Tuple{NodeID, NodeID}, Vector{Float64}} =
261261
OrderedDict()
262+
flow_links_subnetwork::Vector{Tuple{NodeID, NodeID}} = Vector{Tuple{NodeID, NodeID}}()
262263
scaling::ScalingFactors = ScalingFactors()
263264
temporary_constraints::Vector{JuMP.ConstraintRef} = JuMP.ConstraintRef[]
264265
route_priority_expression::JuMP.AffExpr = JuMP.AffExpr()
@@ -370,6 +371,7 @@ The parameter update associated with a certain control state for discrete contro
370371
itp_update_linear::Vector{ParameterUpdate{ScalarLinearInterpolation}} =
371372
ParameterUpdate{ScalarLinearInterpolation}[]
372373
itp_update_lookup::Vector{ParameterUpdate{IndexLookup}} = ParameterUpdate{IndexLookup}[]
374+
allocation_controlled::Bool = false
373375
end
374376

375377
"""

core/src/read.jl

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,8 @@ parse_control_states!(::BasinForcing, args...) = nothing
329329

330330
function initialize_control_mapping!(node::AbstractParameterNode, static::StructVector)
331331
isempty(static) && return
332+
has_alloc_controlled = hasproperty(first(static), :allocation_controlled)
333+
node_has_alloc_controlled = hasfield(typeof(node), :allocation_controlled)
332334
static_groups = IterTools.groupby(row -> row.node_id, static)
333335
static_group, static_idx = iterate(static_groups)
334336
for node_id in node.node_id
@@ -338,8 +340,23 @@ function initialize_control_mapping!(node::AbstractParameterNode, static::Struct
338340
IterTools.groupby(row -> coalesce(row.control_state, nothing), static_group)
339341
first_row = first(control_state_group)
340342
control_state = first_row.control_state
343+
344+
# Read allocation_controlled from static data if available
345+
alloc_controlled = if has_alloc_controlled
346+
coalesce(first_row.allocation_controlled, false)
347+
else
348+
false
349+
end
350+
351+
# Set the node's allocation_controlled flag if any row has it set,
352+
# so we always start with allocation enabled for all allocation controlled nodes
353+
if alloc_controlled && node_has_alloc_controlled
354+
node.allocation_controlled[node_id.idx] = true
355+
end
356+
341357
ismissing(control_state) && continue
342-
node.control_mapping[(node_id, control_state)] = ControlStateUpdate()
358+
node.control_mapping[(node_id, control_state)] =
359+
ControlStateUpdate(; allocation_controlled = alloc_controlled)
343360
end
344361
if static_idx[1]
345362
static_group, static_idx = iterate(static_groups, static_idx)

core/src/schema.jl

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ module Schema
8989
min_upstream_level::Union{Missing, Float64}
9090
max_downstream_level::Union{Missing, Float64}
9191
control_state::Union{Missing, String}
92+
allocation_controlled::Union{Missing, Bool}
9293
end
9394

9495
struct Time <: Table
@@ -115,6 +116,7 @@ module Schema
115116
min_upstream_level::Union{Missing, Float64}
116117
max_downstream_level::Union{Missing, Float64}
117118
control_state::Union{Missing, String}
119+
allocation_controlled::Union{Missing, Bool}
118120
end
119121

120122
struct Time <: Table
@@ -215,6 +217,7 @@ module Schema
215217
flow_rate::Float64
216218
max_downstream_level::Union{Missing, Float64}
217219
control_state::Union{Missing, String}
220+
allocation_controlled::Union{Missing, Bool}
218221
end
219222

220223
struct Time <: Table

core/src/util.jl

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -411,9 +411,6 @@ function set_control_type!(node::AbstractParameterNode, graph::MetaGraph)::Nothi
411411
else
412412
ContinuousControlType.None
413413
end
414-
415-
node.allocation_controlled[node_id.idx] =
416-
(node_id, "Ribasim.allocation") in keys(control_mapping)
417414
end
418415

419416
errors && error("Errors encountered when parsing control type of $(typeof(node)).")

core/src/validation.jl

Lines changed: 0 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,3 @@
1-
# Reserved control states that can be used by Control nodes
2-
# These are control states that start with "Ribasim." and are reserved for special behavior
3-
const RESERVED_CONTROL_STATES = Set(["Ribasim.allocation"])
4-
51
# Allowed types for downstream (to_node_id) nodes given the type of the upstream (from_node_id) node
62
neighbortypes(nodetype::Symbol) = neighbortypes(Val(config.snake_case(nodetype)))
73
neighbortypes(::Val{:pump}) = OrderedSet((:basin, :terminal, :level_boundary, :junction))
@@ -553,25 +549,6 @@ function valid_link_types(db::DB)::Bool
553549
return !errors
554550
end
555551

556-
"""
557-
Validate that all control states starting with "Ribasim." are reserved and supported.
558-
"""
559-
function valid_reserved_control_states(control_states::Set{String})::Bool
560-
errors = false
561-
562-
for control_state in control_states
563-
# Check if control state starts with "Ribasim." prefix
564-
if startswith(control_state, "Ribasim.")
565-
# If it does, it must be in the reserved set
566-
if control_state RESERVED_CONTROL_STATES
567-
errors = true
568-
@error "Unknown reserved control state '$control_state'. Control states starting with 'Ribasim.' must be one of: $(join(RESERVED_CONTROL_STATES, ", "))"
569-
end
570-
end
571-
end
572-
573-
return !errors
574-
end
575552

576553
"""
577554
Check:
@@ -586,9 +563,6 @@ function valid_discrete_control(p::ParametersIndependent, config::Config)::Bool
586563
t_end = seconds_since(config.endtime, config.starttime)
587564
errors = false
588565

589-
# Collect all control states to validate reserved ones
590-
all_control_states = Set{String}()
591-
592566
for (id, compound_variables) in zip(node_id, discrete_control.compound_variables)
593567

594568
# The number of conditions of this DiscreteControl node
@@ -605,7 +579,6 @@ function valid_discrete_control(p::ParametersIndependent, config::Config)::Bool
605579

606580
for (truth_state, control_state) in logic_mapping[id.idx]
607581
push!(control_states_discrete_control, control_state)
608-
push!(all_control_states, control_state)
609582

610583
if length(truth_state) != n_conditions
611584
push!(truth_states_wrong_length, truth_state)
@@ -683,11 +656,6 @@ function valid_discrete_control(p::ParametersIndependent, config::Config)::Bool
683656
end
684657
end
685658

686-
# Validate reserved control states
687-
if !valid_reserved_control_states(all_control_states)
688-
errors = true
689-
end
690-
691659
return !errors
692660
end
693661

0 commit comments

Comments
 (0)