Repository: Deltares/Ribasim
Pull request #2110: Add temporal Pump and Outlet.
Author: login: evetion
Created: 2025-02-27T10:53:52Z | Merged: 2025-03-04T16:03:08Z

@SouthEndMusic could you take over this PR?


Diff:
diff --git a/core/src/allocation_init.jl b/core/src/allocation_init.jl
index bbe68e56d..e7c3ca6a2 100644
--- a/core/src/allocation_init.jl
+++ b/core/src/allocation_init.jl
@@ -30,12 +30,19 @@ function get_subnetwork_capacity(
             if is_flow_constraining(id_src.type)
                 node_src = getfield(p, graph[id_src].type)
 
-                capacity_node_src = node_src.max_flow_rate[id_src.idx]
+                max_flow_rate = node_src.max_flow_rate[id_src.idx]
+                capacity_node_src =
+                    max_flow_rate isa AbstractInterpolation ? max_flow_rate(0) :
+                    max_flow_rate
                 capacity_link = min(capacity_link, capacity_node_src)
             end
             if is_flow_constraining(id_dst.type)
                 node_dst = getfield(p, graph[id_dst].type)
-                capacity_node_dst = node_dst.max_flow_rate[id_dst.idx]
+
+                max_flow_rate = node_dst.max_flow_rate[id_dst.idx]
+                capacity_node_dst =
+                    max_flow_rate isa AbstractInterpolation ? max_flow_rate(0) :
+                    max_flow_rate
                 capacity_link = min(capacity_link, capacity_node_dst)
             end
 
diff --git a/core/src/parameter.jl b/core/src/parameter.jl
index 9c0993848..16e042670 100644
--- a/core/src/parameter.jl
+++ b/core/src/parameter.jl
@@ -314,7 +314,7 @@ The parameter update associated with a certain control state for discrete contro
 """
 @kwdef struct ControlStateUpdate{T <: AbstractInterpolation}
     active::ParameterUpdate{Bool}
-    scalar_update::Vector{ParameterUpdate{Float64}} = []
+    scalar_update::Vector{ParameterUpdate{Float64}} = ParameterUpdate{Float64}[]
     itp_update::Vector{ParameterUpdate{T}} = ParameterUpdate{ScalarInterpolation}[]
 end
 
@@ -618,7 +618,8 @@ inflow_link: incoming flow link metadata
 outflow_link: outgoing flow link metadata
     The ID of the source node is always the ID of the Pump node
 active: whether this node is active and thus contributes flow
-flow_rate: target flow rate
+flow_rate_cache: target flow rate
+flow_rate: timeseries for transient flow data if available
 min_flow_rate: The minimal flow rate of the pump
 max_flow_rate: The maximum flow rate of the pump
 min_upstream_level: The upstream level below which the Pump flow goes to zero
@@ -631,11 +632,12 @@ continuous_control_type: one of None, ContinuousControl, PidControl
     inflow_link::Vector{LinkMetadata} = []
     outflow_link::Vector{LinkMetadata} = []
     active::Vector{Bool} = fill(true, length(node_id))
-    flow_rate::Cache = cache(length(node_id))
-    min_flow_rate::Vector{Float64} = zeros(length(node_id))
-    max_flow_rate::Vector{Float64} = fill(Inf, length(node_id))
-    min_upstream_level::Vector{Float64} = fill(-Inf, length(node_id))
-    max_downstream_level::Vector{Float64} = fill(Inf, length(node_id))
+    flow_rate_cache::Cache = cache(length(node_id))
+    flow_rate::Vector{ScalarInterpolation} = ScalarInterpolation[]
+    min_flow_rate::Vector{ScalarInterpolation} = ScalarInterpolation[]
+    max_flow_rate::Vector{ScalarInterpolation} = ScalarInterpolation[]
+    min_upstream_level::Vector{ScalarInterpolation} = ScalarInterpolation[]
+    max_downstream_level::Vector{ScalarInterpolation} = ScalarInterpolation[]
     control_mapping::Dict{Tuple{NodeID, String}, ControlStateUpdate}
     continuous_control_type::Vector{ContinuousControlType.T} =
         fill(ContinuousControlType.None, length(node_id))
@@ -645,6 +647,7 @@ continuous_control_type: one of None, ContinuousControl, PidControl
         inflow_link,
         outflow_link,
         active,
+        flow_rate_cache,
         flow_rate,
         min_flow_rate,
         max_flow_rate,
@@ -659,6 +662,7 @@ continuous_control_type: one of None, ContinuousControl, PidControl
                 inflow_link,
                 outflow_link,
                 active,
+                flow_rate_cache,
                 flow_rate,
                 min_flow_rate,
                 max_flow_rate,
@@ -680,7 +684,8 @@ inflow_link: incoming flow link metadata.
 outflow_link: outgoing flow link metadata.
     The ID of the source node is always the ID of the Outlet node
 active: whether this node is active and thus contributes flow
-flow_rate: target flow rate
+flow_rate_cache: target flow rate
+flow_rate: timeseries for transient flow data if available
 min_flow_rate: The minimal flow rate of the outlet
 max_flow_rate: The maximum flow rate of the outlet
 min_upstream_level: The upstream level below which the Outlet flow goes to zero
@@ -693,11 +698,12 @@ continuous_control_type: one of None, ContinuousControl, PidControl
     inflow_link::Vector{LinkMetadata} = []
     outflow_link::Vector{LinkMetadata} = []
     active::Vector{Bool} = fill(true, length(node_id))
-    flow_rate::Cache = cache(length(node_id))
-    min_flow_rate::Vector{Float64} = zeros(length(node_id))
-    max_flow_rate::Vector{Float64} = fill(Inf, length(node_id))
-    min_upstream_level::Vector{Float64} = fill(-Inf, length(node_id))
-    max_downstream_level::Vector{Float64} = fill(Inf, length(node_id))
+    flow_rate_cache::Cache = cache(length(node_id))
+    flow_rate::Vector{ScalarInterpolation} = ScalarInterpolation[]
+    min_flow_rate::Vector{ScalarInterpolation} = ScalarInterpolation[]
+    max_flow_rate::Vector{ScalarInterpolation} = ScalarInterpolation[]
+    min_upstream_level::Vector{ScalarInterpolation} = ScalarInterpolation[]
+    max_downstream_level::Vector{ScalarInterpolation} = ScalarInterpolation[]
     control_mapping::Dict{Tuple{NodeID, String}, ControlStateUpdate} = Dict()
     continuous_control_type::Vector{ContinuousControlType.T} =
         fill(ContinuousControlType.None, length(node_id))
@@ -707,6 +713,7 @@ continuous_control_type: one of None, ContinuousControl, PidControl
         inflow_link,
         outflow_link,
         active,
+        flow_rate_cache,
         flow_rate,
         min_flow_rate,
         max_flow_rate,
@@ -721,6 +728,7 @@ continuous_control_type: one of None, ContinuousControl, PidControl
                 inflow_link,
                 outflow_link,
                 active,
+                flow_rate_cache,
                 flow_rate,
                 min_flow_rate,
                 max_flow_rate,
diff --git a/core/src/read.jl b/core/src/read.jl
index e39b93039..f5ccaa575 100644
--- a/core/src/read.jl
+++ b/core/src/read.jl
@@ -129,7 +129,7 @@ function parse_static_and_time(
                     hasproperty(row, :control_state) ? row.control_state : missing
                 # Get the parameter values, and turn them into trivial interpolation objects
                 # if this parameter can be transient
-                parameter_values = Any[]
+                parameter_values = []
                 for parameter_name in parameter_names
                     val = getfield(row, parameter_name)
                     # Set default parameter value if no value was given
@@ -706,6 +706,7 @@ end
 
 function Pump(db::DB, config::Config, graph::MetaGraph)::Pump
     static = load_structvector(db, config, PumpStaticV1)
+    time = load_structvector(db, config, PumpTimeV1)
     defaults = (;
         min_flow_rate = 0.0,
         max_flow_rate = Inf,
@@ -713,7 +714,23 @@ function Pump(db::DB, config::Config, graph::MetaGraph)::Pump
         max_downstream_level = Inf,
         active = true,
     )
-    parsed_parameters, valid = parse_static_and_time(db, config, "Pump"; static, defaults)
+    time_interpolatables = [
+        :flow_rate,
+        :min_flow_rate,
+        :max_flow_rate,
+        :min_upstream_level,
+        :max_downstream_level,
+    ]
+
+    parsed_parameters, valid = parse_static_and_time(
+        db,
+        config,
+        "Pump";
+        static,
+        time,
+        defaults,
+        time_interpolatables,
+    )
 
     if !valid
         error("Errors occurred when parsing Pump data.")
@@ -721,16 +738,17 @@ function Pump(db::DB, config::Config, graph::MetaGraph)::Pump
 
     (; node_id) = parsed_parameters
 
-    # If flow rate is set by PID control, it is part of the AD Jacobian computations
-    flow_rate = cache(length(node_id))
-    flow_rate[Float64[]] .= parsed_parameters.flow_rate
+    # If flow rate is set by PidControl or ContinuousControl, it is part of the AD Jacobian computations
+    flow_rate_cache = cache(length(node_id))
+    flow_rate_cache[Float64[]] .= [itp(0) for itp in parsed_parameters.flow_rate]
 
     return Pump(;
         node_id,
         inflow_link = inflow_link.(Ref(graph), node_id),
         outflow_link = outflow_link.(Ref(graph), node_id),
         parsed_parameters.active,
-        flow_rate,
+        flow_rate_cache,
+        parsed_parameters.flow_rate,
         parsed_parameters.min_flow_rate,
         parsed_parameters.max_flow_rate,
         parsed_parameters.min_upstream_level,
@@ -741,6 +759,7 @@ end
 
 function Outlet(db::DB, config::Config, graph::MetaGraph)::Outlet
     static = load_structvector(db, config, OutletStaticV1)
+    time = load_structvector(db, config, OutletTimeV1)
     defaults = (;
         min_flow_rate = 0.0,
         max_flow_rate = Inf,
@@ -748,7 +767,23 @@ function Outlet(db::DB, config::Config, graph::MetaGraph)::Outlet
         max_downstream_level = Inf,
         active = true,
     )
-    parsed_parameters, valid = parse_static_and_time(db, config, "Outlet"; static, defaults)
+    time_interpolatables = [
+        :flow_rate,
+        :min_flow_rate,
+        :max_flow_rate,
+        :min_upstream_level,
+        :max_downstream_level,
+    ]
+
+    parsed_parameters, valid = parse_static_and_time(
+        db,
+        config,
+        "Outlet";
+        static,
+        time,
+        defaults,
+        time_interpolatables,
+    )
 
     if !valid
         error("Errors occurred when parsing Outlet data.")
@@ -761,16 +796,18 @@ function Outlet(db::DB, config::Config, graph::MetaGraph)::Outlet
             eachindex(parsed_parameters.node_id),
         )
 
-    # If flow rate is set by PID control, it is part of the AD Jacobian computations
-    flow_rate = cache(length(node_id))
-    flow_rate[Float64[], length(node_id)] .= parsed_parameters.flow_rate
+    # If flow rate is set by PidControl or ContinuousControl, it is part of the AD Jacobian computations
+    flow_rate_cache = cache(length(node_id))
+    flow_rate_cache[Float64[], length(node_id)] .=
+        [itp(0) for itp in parsed_parameters.flow_rate]
 
     return Outlet(;
         node_id,
         inflow_link = inflow_link.(Ref(graph), node_id),
         outflow_link = outflow_link.(Ref(graph), node_id),
         parsed_parameters.active,
-        flow_rate,
+        flow_rate_cache,
+        parsed_parameters.flow_rate,
         parsed_parameters.min_flow_rate,
         parsed_parameters.max_flow_rate,
         parsed_parameters.control_mapping,
diff --git a/core/src/schema.jl b/core/src/schema.jl
index d4f5f321b..8301a957d 100644
--- a/core/src/schema.jl
+++ b/core/src/schema.jl
@@ -27,9 +27,11 @@
 @schema "ribasim.linearresistance.static" LinearResistanceStatic
 @schema "ribasim.manningresistance.static" ManningResistanceStatic
 @schema "ribasim.outlet.static" OutletStatic
+@schema "ribasim.outlet.time" OutletTime
 @schema "ribasim.pidcontrol.static" PidControlStatic
 @schema "ribasim.pidcontrol.time" PidControlTime
 @schema "ribasim.pump.static" PumpStatic
+@schema "ribasim.pump.time" PumpTime
 @schema "ribasim.tabulatedratingcurve.static" TabulatedRatingCurveStatic
 @schema "ribasim.tabulatedratingcurve.time" TabulatedRatingCurveTime
 @schema "ribasim.userdemand.concentration" UserDemandConcentration
@@ -87,6 +89,16 @@ end
     control_state::Union{Missing, String}
 end
 
+@version PumpTimeV1 begin
+    node_id::Int32
+    time::DateTime
+    flow_rate::Float64
+    min_flow_rate::Union{Missing, Float64}
+    max_flow_rate::Union{Missing, Float64}
+    min_upstream_level::Union{Missing, Float64}
+    max_downstream_level::Union{Missing, Float64}
+end
+
 @version OutletStaticV1 begin
     node_id::Int32
     active::Union{Missing, Bool}
@@ -98,6 +110,16 @@ end
     control_state::Union{Missing, String}
 end
 
+@version OutletTimeV1 begin
+    node_id::Int32
+    time::DateTime
+    flow_rate::Float64
+    min_flow_rate::Union{Missing, Float64}
+    max_flow_rate::Union{Missing, Float64}
+    min_upstream_level::Union{Missing, Float64}
+    max_downstream_level::Union{Missing, Float64}
+end
+
 @version BasinStaticV1 begin
     node_id::Int32
     drainage::Union{Missing, Float64}
@@ -292,7 +314,6 @@ end
     proportional::Float64
     integral::Float64
     derivative::Float64
-    control_state::Union{Missing, String}
 end
 
 @version UserDemandStaticV1 begin
diff --git a/core/src/solve.jl b/core/src/solve.jl
index 1b0d5daa5..68a8b6fcf 100644
--- a/core/src/solve.jl
+++ b/core/src/solve.jl
@@ -24,7 +24,7 @@ function water_balance!(du::Vector, u::Vector, p::Parameters, t::Number)::Nothin
     update_vertical_flux!(du, p)
 
     # Formulate intermediate flows (non continuously controlled)
-    formulate_flows!(du, p, t, current_storage, current_low_storage_factor, current_level)
+    formulate_flows!(du, p, t, current_low_storage_factor, current_level)
 
     # Compute continuous control
     formulate_continuous_control!(du, p, t)
@@ -34,7 +34,6 @@ function water_balance!(du::Vector, u::Vector, p::Parameters, t::Number)::Nothin
         du,
         p,
         t,
-        current_storage,
         current_low_storage_factor,
         current_level;
         continuous_control_type = ContinuousControlType.Continuous,
@@ -48,7 +47,6 @@ function water_balance!(du::Vector, u::Vector, p::Parameters, t::Number)::Nothin
         du,
         p,
         t,
-        current_storage,
         current_low_storage_factor,
         current_level;
         continuous_control_type = ContinuousControlType.PID,
@@ -573,7 +571,8 @@ function formulate_flow!(
         inflow_link,
         outflow_link,
         active,
-        flow_rate,
+        flow_rate_from_cache,
+        flow_rate_itp,
         min_flow_rate,
         max_flow_rate,
         min_upstream_level,
@@ -584,7 +583,8 @@ function formulate_flow!(
         pump.inflow_link,
         pump.outflow_link,
         pump.active,
-        pump.flow_rate[du],
+        pump.flow_rate_cache[du],
+        pump.flow_rate,
         pump.min_flow_rate,
         pump.max_flow_rate,
         pump.min_upstream_level,
@@ -596,6 +596,12 @@ function formulate_flow!(
             continue
         end
 
+        flow_rate = if continuous_control_type == ContinuousControlType.None
+            flow_rate_itp(t)
+        else
+            flow_rate_from_cache
+        end
+
         inflow_id = inflow_link.link[1]
         outflow_id = outflow_link.link[2]
         src_level = get_level(p, inflow_id, t, current_level)
@@ -604,10 +610,10 @@ function formulate_flow!(
         factor = get_low_storage_factor(current_low_storage_factor, inflow_id)
         q = flow_rate * factor
 
-        q *= reduction_factor(src_level - min_upstream_level, 0.02)
-        q *= reduction_factor(max_downstream_level - dst_level, 0.02)
+        q *= reduction_factor(src_level - min_upstream_level(t), 0.02)
+        q *= reduction_factor(max_downstream_level(t) - dst_level, 0.02)
 
-        q = clamp(q, min_flow_rate, max_flow_rate)
+        q = clamp(q, min_flow_rate(t), max_flow_rate(t))
         du_pump[id.idx] = q
     end
     return nothing
@@ -629,7 +635,8 @@ function formulate_flow!(
         inflow_link,
         outflow_link,
         active,
-        flow_rate,
+        flow_rate_from_cache,
+        flow_rate_itp,
         min_flow_rate,
         max_flow_rate,
         continuous_control_type,
@@ -640,7 +647,8 @@ function formulate_flow!(
         outlet.inflow_link,
         outlet.outflow_link,
         outlet.active,
-        outlet.flow_rate[du],
+        outlet.flow_rate_cache[du],
+        outlet.flow_rate,
         outlet.min_flow_rate,
         outlet.max_flow_rate,
         outlet.continuous_control_type,
@@ -652,6 +660,12 @@ function formulate_flow!(
             continue
         end
 
+        flow_rate = if continuous_control_type == ContinuousControlType.None
+            flow_rate_itp(t)
+        else
+            flow_rate_from_cache
+        end
+
         inflow_id = inflow_link.link[1]
         outflow_id = outflow_link.link[2]
         src_level = get_level(p, inflow_id, t, current_level)
@@ -663,10 +677,10 @@ function formulate_flow!(
         # No flow of outlet if source level is lower than target level
         Δlevel = src_level - dst_level
         q *= reduction_factor(Δlevel, 0.02)
-        q *= reduction_factor(src_level - min_upstream_level, 0.02)
-        q *= reduction_factor(max_downstream_level - dst_level, 0.02)
+        q *= reduction_factor(src_level - min_upstream_level(t), 0.02)
+        q *= reduction_factor(max_downstream_level(t) - dst_level, 0.02)
 
-        q = clamp(q, min_flow_rate, max_flow_rate)
+        q = clamp(q, min_flow_rate(t), max_flow_rate(t))
         du_outlet[id.idx] = q
     end
     return nothing
@@ -676,7 +690,6 @@ function formulate_flows!(
     du::AbstractVector,
     p::Parameters,
     t::Number,
-    current_storage::Vector,
     current_low_storage_factor::Vector,
     current_level::Vector;
     continuous_control_type::ContinuousControlType.T = ContinuousControlType.None,
@@ -795,13 +808,21 @@ function limit_flow!(u::Vector, integrator::DEIntegrator, p::Parameters, t::Numb
     # Pump flow is in [min_flow_rate, max_flow_rate] and can be inactive
     for (id, min_flow_rate, max_flow_rate, active) in
         zip(pump.node_id, pump.min_flow_rate, pump.max_flow_rate, pump.active)
-        limit_flow!(u_pump, uprev_pump, id, min_flow_rate, max_flow_rate, active, dt)
+        limit_flow!(u_pump, uprev_pump, id, min_flow_rate(t), max_flow_rate(t), active, dt)
     end
 
     # Outlet flow is in [min_flow_rate, max_flow_rate] and can be inactive
     for (id, min_flow_rate, max_flow_rate, active) in
         zip(outlet.node_id, outlet.min_flow_rate, outlet.max_flow_rate, outlet.active)
-        limit_flow!(u_outlet, uprev_outlet, id, min_flow_rate, max_flow_rate, active, dt)
+        limit_flow!(
+            u_outlet,
+            uprev_outlet,
+            id,
+            min_flow_rate(t),
+            max_flow_rate(t),
+            active,
+            dt,
+        )
     end
 
     # LinearResistance flow is in [-max_flow_rate, max_flow_rate] and can be inactive
diff --git a/core/src/util.jl b/core/src/util.jl
index 816a7be2f..7ab5fd345 100644
--- a/core/src/util.jl
+++ b/core/src/util.jl
@@ -573,7 +573,7 @@ function get_variable_ref(
         if listen
             if node_id.type ∉ conservative_nodetypes
                 errors = true
-                @error "Cannot listen to flow_rate of $node_id, the node type must be one of $conservative_node_types"
+                @error "Cannot listen to flow_rate of $node_id, the node type must be one of $conservative_node_types."
                 Ref(Float64[], 0)
             else
                 # Index in the state vector (inflow)
@@ -582,7 +582,7 @@ function get_variable_ref(
             end
         else
             node = getfield(p, snake_case(node_id))
-            PreallocationRef(node.flow_rate, node_id.idx)
+            PreallocationRef(node.flow_rate_cache, node_id.idx)
         end
     else
         # Placeholder to obtain correct type
@@ -686,15 +686,15 @@ Add a control state to a logic mapping. The references to the targets in memory
 for the parameter values are added later when these references are known
 """
 function add_control_state!(
-    control_mapping,
-    time_interpolatables,
-    parameter_names,
-    parameter_values,
-    node_type,
-    control_state,
-    node_id,
+    control_mapping::Dict{Tuple{NodeID, String}, ControlStateUpdate},
+    time_interpolatables::Vector{Symbol},
+    parameter_names::NTuple{N, Symbol} where {N},
+    parameter_values::Vector,
+    node_type::String,
+    control_state::Union{Missing, String},
+    node_id::NodeID,
 )::Nothing
-    control_state_key = coalesce(control_state, "")
+    ismissing(control_state) && return nothing
 
     # Control state is only added if a control state update can be defined
     add_control_state = false
@@ -709,7 +709,7 @@ function add_control_state!(
         ParameterUpdate(:active, parameter_values[active_idx])
     end
 
-    itp_update = []
+    itp_update = ParameterUpdate{ScalarInterpolation}[]
     scalar_update = ParameterUpdate{Float64}[]
     for (parameter_name, parameter_value) in zip(parameter_names, parameter_values)
         if parameter_name in controllablefields(Symbol(node_type)) &&
@@ -735,7 +735,7 @@ function add_control_state!(
     control_state_update = ControlStateUpdate(; active, scalar_update, itp_update)
 
     if add_control_state
-        control_mapping[(node_id, control_state_key)] = control_state_update
+        control_mapping[(node_id, control_state)] = control_state_update
     end
     return nothing
 end
diff --git a/core/src/validation.jl b/core/src/validation.jl
index e8f671462..413aece6e 100644
--- a/core/src/validation.jl
+++ b/core/src/validation.jl
@@ -145,11 +145,13 @@ sort_by(::StructVector{LinearResistanceStaticV1}) = x -> (x.node_id, x.control_s
 sort_by(::StructVector{ManningResistanceStaticV1}) = x -> (x.node_id, x.control_state)
 
 sort_by(::StructVector{OutletStaticV1}) = x -> (x.node_id, x.control_state)
+sort_by(::StructVector{OutletTimeV1}) = x -> (x.node_id, x.time)
 
 sort_by(::StructVector{PidControlStaticV1}) = x -> (x.node_id, x.control_state)
 sort_by(::StructVector{PidControlTimeV1}) = x -> (x.node_id, x.time)
 
 sort_by(::StructVector{PumpStaticV1}) = x -> (x.node_id, x.control_state)
+sort_by(::StructVector{PumpTimeV1}) = x -> (x.node_id, x.time)
 
 sort_by(::StructVector{TabulatedRatingCurveStaticV1}) =
     x -> (x.node_id, x.control_state, x.level)
@@ -285,9 +287,9 @@ function valid_flow_rates(
     for (key, control_state_update) in pairs(control_mapping)
         id_controlled = key[1]
         push!(ids_controlled, id_controlled)
-        flow_rate_update = only(control_state_update.scalar_update)
+        flow_rate_update = only(control_state_update.itp_update)
         @assert flow_rate_update.name == :flow_rate
-        flow_rate_ = flow_rate_update.value
+        flow_rate_ = minimum(flow_rate_update.value.u)
 
         if flow_rate_ < 0.0
             errors = true
@@ -399,9 +401,9 @@ function valid_min_upstream_level!(
         id_in = inflow_id(graph, id)
         if id_in.type == NodeType.Basin
             basin_bottom_level = basin_bottom(basin, id_in)[2]
-            if min_upstream_level == -Inf
-                node.min_upstream_level[id.idx] = basin_bottom_level
-            elseif min_upstream_level < basin_bottom_level
+            if all(==(-Inf), min_upstream_level.u)
+                min_upstream_level.u .= basin_bottom_level
+            elseif minimum(min_upstream_level.u) < basin_bottom_level
                 @error "Minimum upstream level of $id is lower than bottom of upstream $id_in" min_upstream_level basin_bottom_level
                 errors = true
             end
diff --git a/core/test/control_test.jl b/core/test/control_test.jl
index 8cdbcf37a..eb1aa5cd1 100644
--- a/core/test/control_test.jl
+++ b/core/test/control_test.jl
@@ -14,9 +14,12 @@
 
     # Control input(flow rates)
     pump_control_mapping = p.pump.control_mapping
-    @test only(pump_control_mapping[(NodeID(:Pump, 4, p), "off")].scalar_update).value == 0
-    @test only(pump_control_mapping[(NodeID(:Pump, 4, p), "on")].scalar_update).value ==
-          1.0e-5
+    @test unique(
+        only(pump_control_mapping[(NodeID(:Pump, 4, p), "off")].itp_update).value.u,
+    ) == [0]
+    @test unique(
+        only(pump_control_mapping[(NodeID(:Pump, 4, p), "on")].itp_update).value.u,
+    ) == [1.0e-5]
 
     logic_mapping::Vector{Dict{Vector{Bool}, String}} = [
         Dict(
diff --git a/core/test/equations_test.jl b/core/test/equations_test.jl
index 39d88314a..89b5c4b1c 100644
--- a/core/test/equations_test.jl
+++ b/core/test/equations_test.jl
@@ -182,7 +182,7 @@ end
     (; flow_boundary, pump) = p
 
     q_boundary = flow_boundary.flow_rate[1].u[1]
-    pump_flow_rate = pump.flow_rate[Float64[]]
+    pump_flow_rate = pump.flow_rate_cache[Float64[]]
     q_pump = pump_flow_rate[1]
 
     storage_both = get_storages_and_levels(model).storage
diff --git a/core/test/run_models_test.jl b/core/test/run_models_test.jl
index 7bbfdbfee..866933a95 100644
--- a/core/test/run_models_test.jl
+++ b/core/test/run_models_test.jl
@@ -354,7 +354,8 @@ end
         filter([:from_node_id, :to_node_id] => (from, to) -> from == 2 && to == 3, flow)
 
     t_min_upstream_level =
-        level.t[2] * (outlet.min_upstream_level[1] - level.u[1]) / (level.u[2] - level.u[1])
+        level.t[2] * (outlet.min_upstream_level[1](0.0) - level.u[1]) /
+        (level.u[2] - level.u[1])
 
     # No outlet flow when upstream level is below minimum upstream level
     @test all(@. outlet_flow.flow_rate[t <= t_min_upstream_level] == 0)
diff --git a/core/test/time_test.jl b/core/test/time_test.jl
index 4cd42dd56..913f45e8c 100644
--- a/core/test/time_test.jl
+++ b/core/test/time_test.jl
@@ -106,3 +106,19 @@ end
     test_extrapolation(level_boundary.level[1])
     test_extrapolation(flow_boundary.flow_rate[1])
 end
+
+@testitem "transient_pump_weir" begin
+    using DataFrames: DataFrame
+
+    toml_path =
+        normpath(@__DIR__, "../../generated_testmodels/transient_pump_outlet/ribasim.toml")
+    @test ispath(toml_path)
+
+    model = Ribasim.run(toml_path)
+    storage = Ribasim.get_storages_and_levels(model).storage
+    @test all(isapprox.(storage[1, 2:end], storage[1, end]; rtol = 1e-4))
+
+    t_end = model.integrator.t
+    flow_rate_end = model.integrator.p.pump.flow_rate[1].u[end]
+    @test storage[2, end] ≈ storage[2, 1] + 0.5 * flow_rate_end * t_end
+end
diff --git a/core/test/validation_test.jl b/core/test/validation_test.jl
index ba1bfb734..073cf97f2 100644
--- a/core/test/validation_test.jl
+++ b/core/test/validation_test.jl
@@ -218,6 +218,7 @@ end
 @testitem "Pump/outlet flow rate sign validation" begin
     using Logging
     using Ribasim: NodeID, NodeType, ControlStateUpdate, ParameterUpdate, cache
+    using DataInterpolations: LinearInterpolation
 
     logger = TestLogger()
 
@@ -237,15 +238,20 @@ end
     logger = TestLogger()
 
     with_logger(logger) do
-        flow_rate = cache(1)
-        flow_rate[Float64[]] .= -1
+        flow_rate_cache = cache(1)
+        flow_rate_cache[Float64[]] .= -1
         @test_throws "Invalid Pump flow rate(s)." Ribasim.Pump(;
             node_id = [NodeID(:Pump, 1, 1)],
-            flow_rate,
+            flow_rate_cache,
             control_mapping = Dict(
                 (NodeID(:Pump, 1, 1), "foo") => ControlStateUpdate(;
                     active = ParameterUpdate(:active, true),
-                    scalar_update = [ParameterUpdate(:flow_rate, -1.0)],
+                    itp_update = [
+                        ParameterUpdate(
+                            :flow_rate,
+                            LinearInterpolation([-1.0, -1.0], [0.0, 1.0]),
+                        ),
+                    ],
                 ),
             ),
         )
@@ -392,6 +398,7 @@ end
 
 @testitem "Outlet upstream level validation" begin
     using Ribasim: valid_min_upstream_level!
+    using DataInterpolations: LinearInterpolation
     using Logging
 
     toml_path = normpath(
@@ -407,7 +414,7 @@ end
     parameters = model.integrator.p
 
     (; graph, outlet, basin) = parameters
-    outlet.min_upstream_level[1] = invalid_level
+    outlet.min_upstream_level[1] = LinearInterpolation(fill(invalid_level, 2), zeros(2))
 
     logger = TestLogger()
     with_logger(logger) do
diff --git a/docs/concept/allocation.qmd b/docs/concept/allocation.qmd
index 212e8feba..9d3305165 100644
--- a/docs/concept/allocation.qmd
+++ b/docs/concept/allocation.qmd
@@ -121,7 +121,7 @@ Each link in the subnetwork has an associated capacity. These capacities are col
 The capacities are determined in different ways:
 
 - If an link does not exist in the allocation network, i.e. $(i,j) \notin E_S$ for certain $1 \le i,j\le n'$, then $(C_S)_{i,j} = 0$;
-- The capacity of the link $e \in E_S$ is given by the smallest `max_flow_rate` of the nodes along the equivalent links in the subnetwork. If there are no nodes with a `max_flow_rate`, the link capacity is infinite;
+- The capacity of the link $e \in E_S$ is given by the smallest `max_flow_rate` of the nodes along the equivalent links in the subnetwork. If there are no nodes with a `max_flow_rate`, the link capacity is infinite. If the `max_flow_rate` is time-dependent, only the value at the starttime of the simulation is considered;
 - If the link is a source, the capacity of the link is given by the flow rate of that source;
 
 There are also capacities for special links:
diff --git a/docs/reference/node/outlet.qmd b/docs/reference/node/outlet.qmd
index b2848fd0f..8660e7248 100644
--- a/docs/reference/node/outlet.qmd
+++ b/docs/reference/node/outlet.qmd
@@ -22,6 +22,24 @@ max_flow_rate         | Float64 | $\text{m}^3/\text{s}\$ | (optional)
 min_upstream_level    | Float64 | $\text{m}$             | (optional)
 max_downstream_level  | Float64 | $\text{m}$             | (optional)
 
+## Time
+
+This table is the transient form of the `Outlet` table.
+With this all parameters can be updated over time. In between the given times the
+flow_rate is interpolated linearly, and outside the flow rate is constant given by the
+nearest time value.
+Note that a `node_id` can be either in this table or in the static one, but not both.
+
+column                | type    | unit                   | restriction
+---------             | ------- | ---------------------- | -----------
+node_id               | Int32   | -                      |
+time                  | DateTime| -                      |
+flow_rate             | Float64 | $\text{m}^3/\text{s}\$ | non-negative
+min_flow_rate         | Float64 | $\text{m}^3/\text{s}\$ | (optional, default 0.0)
+max_flow_rate         | Float64 | $\text{m}^3/\text{s}\$ | (optional)
+min_upstream_level    | Float64 | $\text{m}$             | (optional)
+max_downstream_level  | Float64 | $\text{m}$             | (optional)
+
 # Equations
 
 The Outlet is very similar to the Pump, but it has an extra [reduction factor](/reference/node/basin.qmd#sec-reduction-factor) for physical constraints:
diff --git a/docs/reference/node/pump.qmd b/docs/reference/node/pump.qmd
index e28223083..2d615d9d5 100644
--- a/docs/reference/node/pump.qmd
+++ b/docs/reference/node/pump.qmd
@@ -23,6 +23,24 @@ max_flow_rate         | Float64 | $\text{m}^3/\text{s}$ | (optional)
 min_upstream_level    | Float64 | $\text{m}$          | (optional)
 max_downstream_level  | Float64 | $\text{m}$          | (optional)
 
+## Time
+
+This table is the transient form of the `Pump` table.
+With this all parameters can be updated over time. In between the given times the
+flow_rate is interpolated linearly, and outside the flow rate is constant given by the
+nearest time value.
+Note that a `node_id` can be either in this table or in the static one, but not both.
+
+column                | type    | unit         | restriction
+---------             | ------- | ------------ | -----------
+node_id               | Int32   | -            |
+time                  | DateTime| -            |
+flow_rate             | Float64 | $\text{m}^3/\text{s}$ | non-negative
+min_flow_rate         | Float64 | $\text{m}^3/\text{s}$ | (optional, default 0.0)
+max_flow_rate         | Float64 | $\text{m}^3/\text{s}$ | (optional)
+min_upstream_level    | Float64 | $\text{m}$          | (optional)
+max_downstream_level  | Float64 | $\text{m}$          | (optional)
+
 # Equations
 
 The behavior of a Pump is simple if these nodes are not PID controlled.
diff --git a/python/ribasim/ribasim/config.py b/python/ribasim/ribasim/config.py
index 670df5d3c..56f79c73e 100644
--- a/python/ribasim/ribasim/config.py
+++ b/python/ribasim/ribasim/config.py
@@ -43,9 +43,11 @@
     LinearResistanceStaticSchema,
     ManningResistanceStaticSchema,
     OutletStaticSchema,
+    OutletTimeSchema,
     PidControlStaticSchema,
     PidControlTimeSchema,
     PumpStaticSchema,
+    PumpTimeSchema,
     TabulatedRatingCurveStaticSchema,
     TabulatedRatingCurveTimeSchema,
     UserDemandConcentrationSchema,
@@ -352,6 +354,10 @@ class Pump(MultiNodeModel):
         default_factory=TableModel[PumpStaticSchema],
         json_schema_extra={"sort_keys": ["node_id", "control_state"]},
     )
+    time: TableModel[PumpTimeSchema] = Field(
+        default_factory=TableModel[PumpTimeSchema],
+        json_schema_extra={"sort_keys": ["node_id", "time"]},
+    )
 
 
 class TabulatedRatingCurve(MultiNodeModel):
@@ -496,6 +502,10 @@ class Outlet(MultiNodeModel):
         default_factory=TableModel[OutletStaticSchema],
         json_schema_extra={"sort_keys": ["node_id", "control_state"]},
     )
+    time: TableModel[OutletTimeSchema] = Field(
+        default_factory=TableModel[OutletTimeSchema],
+        json_schema_extra={"sort_keys": ["node_id", "time"]},
+    )
 
 
 class LinearResistance(MultiNodeModel):
diff --git a/python/ribasim/ribasim/nodes/outlet.py b/python/ribasim/ribasim/nodes/outlet.py
index 9f38440c9..91de2fdf2 100644
--- a/python/ribasim/ribasim/nodes/outlet.py
+++ b/python/ribasim/ribasim/nodes/outlet.py
@@ -1,10 +1,12 @@
 from ribasim.input_base import TableModel
-from ribasim.schemas import (
-    OutletStaticSchema,
-)
+from ribasim.schemas import OutletStaticSchema, OutletTimeSchema
 
-__all__ = ["Static"]
+__all__ = ["Static", "Time"]
 
 
 class Static(TableModel[OutletStaticSchema]):
     pass
+
+
+class Time(TableModel[OutletTimeSchema]):
+    pass
diff --git a/python/ribasim/ribasim/nodes/pump.py b/python/ribasim/ribasim/nodes/pump.py
index afd8ab355..954febb7d 100644
--- a/python/ribasim/ribasim/nodes/pump.py
+++ b/python/ribasim/ribasim/nodes/pump.py
@@ -1,10 +1,12 @@
 from ribasim.input_base import TableModel
-from ribasim.schemas import (
-    PumpStaticSchema,
-)
+from ribasim.schemas import PumpStaticSchema, PumpTimeSchema
 
-__all__ = ["Static"]
+__all__ = ["Static", "Time"]
 
 
 class Static(TableModel[PumpStaticSchema]):
     pass
+
+
+class Time(TableModel[PumpTimeSchema]):
+    pass
diff --git a/python/ribasim/ribasim/schemas.py b/python/ribasim/ribasim/schemas.py
index 67333edaa..dcb1c3ed9 100644
--- a/python/ribasim/ribasim/schemas.py
+++ b/python/ribasim/ribasim/schemas.py
@@ -478,6 +478,31 @@ class OutletStaticSchema(_BaseSchema):
     )
 
 
+class OutletTimeSchema(_BaseSchema):
+    fid: Index[Int32] = pa.Field(default=1, check_name=True, coerce=True)
+    node_id: Series[Annotated[pd.ArrowDtype, pyarrow.int32()]] = pa.Field(
+        nullable=False, default=0
+    )
+    time: Series[Annotated[pd.ArrowDtype, pyarrow.timestamp("ms")]] = pa.Field(
+        nullable=False
+    )
+    flow_rate: Series[Annotated[pd.ArrowDtype, pyarrow.float64()]] = pa.Field(
+        nullable=False
+    )
+    min_flow_rate: Series[Annotated[pd.ArrowDtype, pyarrow.float64()]] = pa.Field(
+        nullable=True
+    )
+    max_flow_rate: Series[Annotated[pd.ArrowDtype, pyarrow.float64()]] = pa.Field(
+        nullable=True
+    )
+    min_upstream_level: Series[Annotated[pd.ArrowDtype, pyarrow.float64()]] = pa.Field(
+        nullable=True
+    )
+    max_downstream_level: Series[Annotated[pd.ArrowDtype, pyarrow.float64()]] = (
+        pa.Field(nullable=True)
+    )
+
+
 class PidControlStaticSchema(_BaseSchema):
     fid: Index[Int32] = pa.Field(default=1, check_name=True, coerce=True)
     node_id: Series[Annotated[pd.ArrowDtype, pyarrow.int32()]] = pa.Field(
@@ -527,9 +552,6 @@ class PidControlTimeSchema(_BaseSchema):
     derivative: Series[Annotated[pd.ArrowDtype, pyarrow.float64()]] = pa.Field(
         nullable=False
     )
-    control_state: Series[Annotated[pd.ArrowDtype, pyarrow.string()]] = pa.Field(
-        nullable=True
-    )
 
 
 class PumpStaticSchema(_BaseSchema):
@@ -558,6 +580,31 @@ class PumpStaticSchema(_BaseSchema):
     )
 
 
+class PumpTimeSchema(_BaseSchema):
+    fid: Index[Int32] = pa.Field(default=1, check_name=True, coerce=True)
+    node_id: Series[Annotated[pd.ArrowDtype, pyarrow.int32()]] = pa.Field(
+        nullable=False, default=0
+    )
+    time: Series[Annotated[pd.ArrowDtype, pyarrow.timestamp("ms")]] = pa.Field(
+        nullable=False
+    )
+    flow_rate: Series[Annotated[pd.ArrowDtype, pyarrow.float64()]] = pa.Field(
+        nullable=False
+    )
+    min_flow_rate: Series[Annotated[pd.ArrowDtype, pyarrow.float64()]] = pa.Field(
+        nullable=True
+    )
+    max_flow_rate: Series[Annotated[pd.ArrowDtype, pyarrow.float64()]] = pa.Field(
+        nullable=True
+    )
+    min_upstream_level: Series[Annotated[pd.ArrowDtype, pyarrow.float64()]] = pa.Field(
+        nullable=True
+    )
+    max_downstream_level: Series[Annotated[pd.ArrowDtype, pyarrow.float64()]] = (
+        pa.Field(nullable=True)
+    )
+
+
 class TabulatedRatingCurveStaticSchema(_BaseSchema):
     fid: Index[Int32] = pa.Field(default=1, check_name=True, coerce=True)
     node_id: Series[Annotated[pd.ArrowDtype, pyarrow.int32()]] = pa.Field(
diff --git a/python/ribasim_testmodels/ribasim_testmodels/__init__.py b/python/ribasim_testmodels/ribasim_testmodels/__init__.py
index 0f31bb552..673a72a93 100644
--- a/python/ribasim_testmodels/ribasim_testmodels/__init__.py
+++ b/python/ribasim_testmodels/ribasim_testmodels/__init__.py
@@ -63,7 +63,10 @@
     discrete_control_of_pid_control_model,
     pid_control_model,
 )
-from ribasim_testmodels.time import flow_boundary_time_model
+from ribasim_testmodels.time import (
+    flow_boundary_time_model,
+    transient_pump_outlet_model,
+)
 from ribasim_testmodels.trivial import trivial_model
 from ribasim_testmodels.two_basin import two_basin_model
 
@@ -115,6 +118,7 @@
     "tabulated_rating_curve_control_model",
     "tabulated_rating_curve_model",
     "transient_condition_model",
+    "transient_pump_outlet_model",
     "trivial_model",
     "two_basin_model",
     "user_demand_model",
diff --git a/python/ribasim_testmodels/ribasim_testmodels/basic.py b/python/ribasim_testmodels/ribasim_testmodels/basic.py
index 9ed506212..3bb953ca9 100644
--- a/python/ribasim_testmodels/ribasim_testmodels/basic.py
+++ b/python/ribasim_testmodels/ribasim_testmodels/basic.py
@@ -121,7 +121,10 @@ def basic_model() -> Model:
     )
 
     # Setup pump
-    model.pump.add(Node(7, Point(4.0, 1.0)), [pump.Static(flow_rate=[0.5 / 3600])])
+    model.pump.add(
+        Node(7, Point(4.0, 1.0)),
+        [pump.Static(flow_rate=[0.5 / 3600])],
+    )
 
     # Setup flow boundary
     flow_boundary_data: Sequence[TableModel[Any]] = [
diff --git a/python/ribasim_testmodels/ribasim_testmodels/time.py b/python/ribasim_testmodels/ribasim_testmodels/time.py
index 23d2f9688..74ca69ab4 100644
--- a/python/ribasim_testmodels/ribasim_testmodels/time.py
+++ b/python/ribasim_testmodels/ribasim_testmodels/time.py
@@ -2,7 +2,7 @@
 import pandas as pd
 from ribasim.config import Experimental, Node
 from ribasim.model import Model
-from ribasim.nodes import basin, flow_boundary
+from ribasim.nodes import basin, flow_boundary, level_boundary, outlet, pump
 from shapely.geometry import Point
 
 
@@ -50,3 +50,44 @@ def flow_boundary_time_model() -> Model:
     )
 
     return model
+
+
+def transient_pump_outlet_model() -> Model:
+    """Set up a model with time dependent pump and outlet flows."""
+    model = Model(
+        starttime="2020-01-01",
+        endtime="2021-01-01",
+        crs="EPSG:28992",
+        experimental=Experimental(concentration=True),
+    )
+
+    lb = model.level_boundary.add(
+        Node(1, Point(1, 0)), [level_boundary.Static(level=[1.0])]
+    )
+
+    time = ["2020-01-01", "2021-01-01"]
+    flow_rate = [0.0, 10.0]
+
+    out = model.outlet.add(
+        Node(2, Point(2, 0)), [outlet.Time(time=time, flow_rate=flow_rate)]
+    )
+
+    bsn1 = model.basin.add(
+        Node(3, Point(3, 0)),
+        [basin.State(level=[1.0]), basin.Profile(level=[0.0, 2.0], area=100.0)],
+    )
+    bsn2 = model.basin.add(
+        Node(5, Point(5, 0)),
+        [basin.State(level=[1.0]), basin.Profile(level=[0.0, 2.0], area=100.0)],
+    )
+
+    pmp = model.pump.add(
+        Node(4, Point(4, 0)), [pump.Time(time=time, flow_rate=flow_rate)]
+    )
+
+    model.link.add(lb, out)
+    model.link.add(out, bsn1)
+    model.link.add(bsn1, pmp)
+    model.link.add(pmp, bsn2)
+
+    return model
diff --git a/ribasim_qgis/core/nodes.py b/ribasim_qgis/core/nodes.py
index 88363e108..59b9db4d0 100644
--- a/ribasim_qgis/core/nodes.py
+++ b/ribasim_qgis/core/nodes.py
@@ -646,10 +646,34 @@ def attributes(cls) -> list[QgsField]:
             QgsField("flow_rate", QVariant.Double),
             QgsField("min_flow_rate", QVariant.Double),
             QgsField("max_flow_rate", QVariant.Double),
+            QgsField("min_upstream_level", QVariant.Double),
+            QgsField("max_downstream_level", QVariant.Double),
             QgsField("control_state", QVariant.String),
         ]
 
 
+class PumpTime(Input):
+    @classmethod
+    def input_type(cls) -> str:
+        return "Pump / time"
+
+    @classmethod
+    def geometry_type(cls) -> str:
+        return "No Geometry"
+
+    @classmethod
+    def attributes(cls) -> list[QgsField]:
+        return [
+            QgsField("node_id", QVariant.Int),
+            QgsField("time", QVariant.DateTime),
+            QgsField("flow_rate", QVariant.Double),
+            QgsField("min_flow_rate", QVariant.Double),
+            QgsField("max_flow_rate", QVariant.Double),
+            QgsField("min_upstream_level", QVariant.Double),
+            QgsField("max_downstream_level", QVariant.Double),
+        ]
+
+
 class OutletStatic(Input):
     @classmethod
     def input_type(cls) -> str:
@@ -668,10 +692,33 @@ def attributes(cls) -> list[QgsField]:
             QgsField("min_flow_rate", QVariant.Double),
             QgsField("max_flow_rate", QVariant.Double),
             QgsField("min_upstream_level", QVariant.Double),
+            QgsField("max_downstream_level", QVariant.Double),
             QgsField("control_state", QVariant.String),
         ]
 
 
+class OutletTime(Input):
+    @classmethod
+    def input_type(cls) -> str:
+        return "Outlet / time"
+
+    @classmethod
+    def geometry_type(cls) -> str:
+        return "No Geometry"
+
+    @classmethod
+    def attributes(cls) -> list[QgsField]:
+        return [
+            QgsField("node_id", QVariant.Int),
+            QgsField("time", QVariant.DateTime),
+            QgsField("flow_rate", QVariant.Double),
+            QgsField("min_flow_rate", QVariant.Double),
+            QgsField("max_flow_rate", QVariant.Double),
+            QgsField("min_upstream_level", QVariant.Double),
+            QgsField("max_downstream_level", QVariant.Double),
+        ]
+
+
 class FlowBoundaryStatic(Input):
     @classmethod
     def input_type(cls) -> str:


Reviews:
user: login: visr
body: Looks good! Left some comments.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-04T12:44:30Z
user: login: SouthEndMusic
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-04T15:32:01Z

Review comments:
diff_hunk: @@ -646,10 +646,34 @@ def attributes(cls) -> list[QgsField]:
             QgsField("flow_rate", QVariant.Double),
             QgsField("min_flow_rate", QVariant.Double),
             QgsField("max_flow_rate", QVariant.Double),
+            QgsField("min_upstream_level", QVariant.Double),
+            QgsField("max_upstream_level", QVariant.Double),
             QgsField("control_state", QVariant.String),
         ]
 
 
+class PumpTime(Input):
+    @classmethod
+    def input_type(cls) -> str:
+        return "Pump / time"
+
+    @classmethod
+    def geometry_type(cls) -> str:
+        return "No Geometry"
+
+    @classmethod
+    def attributes(cls) -> list[QgsField]:
+        return [
+            QgsField("node_id", QVariant.Int),
+            QgsField("time", QVariant.DateTime),
+            QgsField("flow_rate", QVariant.Double),
+            QgsField("min_flow_rate", QVariant.Double),
+            QgsField("max_flow_rate", QVariant.Double),
+            QgsField("min_upstream_level", QVariant.Double),
+            QgsField("max_upstream_level", QVariant.Double),
path: ribasim_qgis/core/nodes.py
user: login: visr
body: 
```suggestion
            QgsField("max_downstream_level", QVariant.Double),
```
created_at: 2025-03-04T12:17:39Z
author_association: MEMBER
diff_hunk: @@ -668,10 +692,33 @@ def attributes(cls) -> list[QgsField]:
             QgsField("min_flow_rate", QVariant.Double),
             QgsField("max_flow_rate", QVariant.Double),
             QgsField("min_upstream_level", QVariant.Double),
+            QgsField("max_upstream_level", QVariant.Double),
path: ribasim_qgis/core/nodes.py
user: login: visr
body: 
```suggestion
            QgsField("max_downstream_level", QVariant.Double),
```
created_at: 2025-03-04T12:17:52Z
author_association: MEMBER
diff_hunk: @@ -668,10 +692,33 @@ def attributes(cls) -> list[QgsField]:
             QgsField("min_flow_rate", QVariant.Double),
             QgsField("max_flow_rate", QVariant.Double),
             QgsField("min_upstream_level", QVariant.Double),
+            QgsField("max_upstream_level", QVariant.Double),
             QgsField("control_state", QVariant.String),
         ]
 
 
+class OutletTime(Input):
+    @classmethod
+    def input_type(cls) -> str:
+        return "Outlet / time"
+
+    @classmethod
+    def geometry_type(cls) -> str:
+        return "No Geometry"
+
+    @classmethod
+    def attributes(cls) -> list[QgsField]:
+        return [
+            QgsField("node_id", QVariant.Int),
+            QgsField("time", QVariant.DateTime),
+            QgsField("flow_rate", QVariant.Double),
+            QgsField("min_flow_rate", QVariant.Double),
+            QgsField("max_flow_rate", QVariant.Double),
+            QgsField("min_upstream_level", QVariant.Double),
+            QgsField("max_upstream_level", QVariant.Double),
path: ribasim_qgis/core/nodes.py
user: login: visr
body: 
```suggestion
            QgsField("max_downstream_level", QVariant.Double),
```
created_at: 2025-03-04T12:18:07Z
author_association: MEMBER
diff_hunk: @@ -23,6 +23,25 @@ max_flow_rate         | Float64 | $\text{m}^3/\text{s}$ | (optional)
 min_upstream_level    | Float64 | $\text{m}$          | (optional)
 max_downstream_level  | Float64 | $\text{m}$          | (optional)
 
+## Time
+
+This table is the transient form of the `Pump` table.
+With this the flow_rate can be updated over time. In between the given times the
+flow_rate is interpolated linearly, and outside the flow rate is constant given by the
+nearest time value.
+Note that a `node_id` can be either in this table or in the static one, but not both.
+
+column                | type    | unit         | restriction
+---------             | ------- | ------------ | -----------
+node_id               | Int32   | -            |
+time                  | DateTime| -            |
+control_state         | String  | -            | (optional)
path: docs/reference/node/pump.qmd
user: login: visr
body: 
```suggestion
```
created_at: 2025-03-04T12:23:52Z
author_association: MEMBER
diff_hunk: @@ -22,6 +22,25 @@ max_flow_rate         | Float64 | $\text{m}^3/\text{s}\$ | (optional)
 min_upstream_level    | Float64 | $\text{m}$             | (optional)
 max_downstream_level  | Float64 | $\text{m}$             | (optional)
 
+## Time
+
+This table is the transient form of the `Outlet` table.
+With this the flow_rate can be updated over time. In between the given times the
+flow_rate is interpolated linearly, and outside the flow rate is constant given by the
+nearest time value.
+Note that a `node_id` can be either in this table or in the static one, but not both.
+
+column                | type    | unit                   | restriction
+---------             | ------- | ---------------------- | -----------
+node_id               | Int32   | -                      |
+time                  | DateTime| -                      |
+control_state         | String  | -                      | (optional)
path: docs/reference/node/outlet.qmd
user: login: visr
body: 
```suggestion
```
created_at: 2025-03-04T12:24:15Z
author_association: MEMBER
diff_hunk: @@ -399,9 +401,9 @@ function valid_min_upstream_level!(
         id_in = inflow_id(graph, id)
         if id_in.type == NodeType.Basin
             basin_bottom_level = basin_bottom(basin, id_in)[2]
-            if min_upstream_level == -Inf
-                node.min_upstream_level[id.idx] = basin_bottom_level
-            elseif min_upstream_level < basin_bottom_level
+            if unique(min_upstream_level.u) == [-Inf]
path: core/src/validation.jl
user: login: visr
body: 
```suggestion
            if all(==(-Inf), min_upstream_level.u)
```
created_at: 2025-03-04T12:31:37Z
author_association: MEMBER
diff_hunk: @@ -22,6 +22,25 @@ max_flow_rate         | Float64 | $\text{m}^3/\text{s}\$ | (optional)
 min_upstream_level    | Float64 | $\text{m}$             | (optional)
 max_downstream_level  | Float64 | $\text{m}$             | (optional)
 
+## Time
+
+This table is the transient form of the `Outlet` table.
+With this the flow_rate can be updated over time. In between the given times the
path: docs/reference/node/outlet.qmd
user: login: visr
body: All columns can be updated over time.
created_at: 2025-03-04T12:36:56Z
author_association: MEMBER
diff_hunk: @@ -23,6 +23,25 @@ max_flow_rate         | Float64 | $\text{m}^3/\text{s}$ | (optional)
 min_upstream_level    | Float64 | $\text{m}$          | (optional)
 max_downstream_level  | Float64 | $\text{m}$          | (optional)
 
+## Time
+
+This table is the transient form of the `Pump` table.
+With this the flow_rate can be updated over time. In between the given times the
path: docs/reference/node/pump.qmd
user: login: visr
body: All columns can be updated over time.
created_at: 2025-03-04T12:37:01Z
author_association: MEMBER
diff_hunk: @@ -596,6 +596,12 @@ function formulate_flow!(
             continue
         end
 
+        flow_rate = if continuous_control_type == ContinuousControlType.None
+            flow_rate = flow_rate_itp(t)
path: core/src/solve.jl
user: login: visr
body: 
```suggestion
            flow_rate_itp(t)
```
created_at: 2025-03-04T12:38:48Z
author_association: MEMBER
diff_hunk: @@ -604,10 +610,10 @@ function formulate_flow!(
         factor = get_low_storage_factor(current_low_storage_factor, inflow_id)
         q = flow_rate * factor
 
-        q *= reduction_factor(src_level - min_upstream_level, 0.02)
-        q *= reduction_factor(max_downstream_level - dst_level, 0.02)
+        q *= reduction_factor(src_level - min_upstream_level(t), 0.02)
+        q *= reduction_factor(max_downstream_level(t) - dst_level, 0.02)
 
-        q = clamp(q, min_flow_rate, max_flow_rate)
+        q = clamp(q, min_flow_rate(t), max_flow_rate(t))
path: core/src/solve.jl
user: login: visr
body: Lovely
created_at: 2025-03-04T12:39:17Z
author_association: MEMBER
diff_hunk: @@ -663,10 +677,10 @@ function formulate_flow!(
         # No flow of outlet if source level is lower than target level
         Δlevel = src_level - dst_level
         q *= reduction_factor(Δlevel, 0.02)
-        q *= reduction_factor(src_level - min_upstream_level, 0.02)
-        q *= reduction_factor(max_downstream_level - dst_level, 0.02)
+        q *= reduction_factor(src_level - min_upstream_level(t), 0.02)
+        q *= reduction_factor(max_downstream_level(t) - dst_level, 0.02)
 
-        q = clamp(q, min_flow_rate, max_flow_rate)
+        q = clamp(q, min_flow_rate(t), max_flow_rate(t))
path: core/src/solve.jl
user: login: visr
body: Lovely
created_at: 2025-03-04T12:39:41Z
author_association: MEMBER
diff_hunk: @@ -30,12 +30,19 @@ function get_subnetwork_capacity(
             if is_flow_constraining(id_src.type)
                 node_src = getfield(p, graph[id_src].type)
 
-                capacity_node_src = node_src.max_flow_rate[id_src.idx]
+                max_flow_rate = node_src.max_flow_rate[id_src.idx]
+                capacity_node_src =
+                    max_flow_rate isa AbstractInterpolation ? max_flow_rate(0) :
+                    max_flow_rate
                 capacity_link = min(capacity_link, capacity_node_src)
             end
             if is_flow_constraining(id_dst.type)
                 node_dst = getfield(p, graph[id_dst].type)
-                capacity_node_dst = node_dst.max_flow_rate[id_dst.idx]
+
+                max_flow_rate = node_dst.max_flow_rate[id_dst.idx]
+                capacity_node_dst =
+                    max_flow_rate isa AbstractInterpolation ? max_flow_rate(0) :
+                    max_flow_rate
path: core/src/allocation_init.jl
user: login: visr
body: Looking at parameter.jl, the `max_flow_rate` is now always an AbstractInterpolation, right? Perhaps also good to add a note that only the initial value is taken into account for allocation.
created_at: 2025-03-04T12:44:06Z
author_association: MEMBER
diff_hunk: @@ -30,12 +30,19 @@ function get_subnetwork_capacity(
             if is_flow_constraining(id_src.type)
                 node_src = getfield(p, graph[id_src].type)
 
-                capacity_node_src = node_src.max_flow_rate[id_src.idx]
+                max_flow_rate = node_src.max_flow_rate[id_src.idx]
+                capacity_node_src =
+                    max_flow_rate isa AbstractInterpolation ? max_flow_rate(0) :
+                    max_flow_rate
                 capacity_link = min(capacity_link, capacity_node_src)
             end
             if is_flow_constraining(id_dst.type)
                 node_dst = getfield(p, graph[id_dst].type)
-                capacity_node_dst = node_dst.max_flow_rate[id_dst.idx]
+
+                max_flow_rate = node_dst.max_flow_rate[id_dst.idx]
+                capacity_node_dst =
+                    max_flow_rate isa AbstractInterpolation ? max_flow_rate(0) :
+                    max_flow_rate
path: core/src/allocation_init.jl
user: login: SouthEndMusic
body: `max_flow_rate` is still a `Float64` for `LinearResistance`. Did add the note
created_at: 2025-03-04T15:31:59Z
author_association: COLLABORATOR

PR comments:
user: login: SouthEndMusic
created_at: 2025-03-03T13:02:00Z
body: I just realized a nasty complication with this feature: currently in allocation the link capacities are based on the max flow rate of the connected nodes (if they have one). This was assumed to be static, so only inferred at the initialization of the allocation problems. I don't think it's worth investing time in this, because in a while these link capacities will be replaced by linearized connector node functions. So for now I just evaluate the max flow rate at `t = 0` for the link capacities.
author_association: COLLABORATOR
user: login: SouthEndMusic
created_at: 2025-03-03T13:09:22Z
body: It's also a bit inconsistent that `LinearResistance` is the only connector node that has a `max_flow_rate` which cannot be time dependent
author_association: COLLABORATOR
user: login: SouthEndMusic
created_at: 2025-03-03T16:22:40Z
body: We might want to validate somewhere that linear interpolations require at least 2 data points (or should this 'just work' in `DataInterpolations.jl`?).
author_association: COLLABORATOR