From f2473a27dbf71af8c99aacd98d058cf4e71a30aa Mon Sep 17 00:00:00 2001 From: Bart de Koning Date: Tue, 5 Sep 2023 17:02:34 +0200 Subject: [PATCH 1/6] `User` node type draft --- core/src/create.jl | 23 +++++++ core/src/solve.jl | 69 +++++++++++++++++++ core/src/validation.jl | 35 +++++++++- docs/contribute/addnode.qmd | 19 +++-- docs/schema/UserStatic.schema.json | 54 +++++++++++++++ docs/schema/UserTime.schema.json | 53 ++++++++++++++ docs/schema/root.schema.json | 6 ++ python/ribasim/ribasim/__init__.py | 2 + python/ribasim/ribasim/geometry/node.py | 2 + python/ribasim/ribasim/model.py | 4 ++ python/ribasim/ribasim/models.py | 24 ++++++- python/ribasim/ribasim/node_types/__init__.py | 2 + python/ribasim/ribasim/node_types/user.py | 46 +++++++++++++ qgis/core/nodes.py | 25 +++++++ 14 files changed, 353 insertions(+), 11 deletions(-) create mode 100644 docs/schema/UserStatic.schema.json create mode 100644 docs/schema/UserTime.schema.json create mode 100644 python/ribasim/ribasim/node_types/user.py diff --git a/core/src/create.jl b/core/src/create.jl index 590f7b600..6334db22f 100644 --- a/core/src/create.jl +++ b/core/src/create.jl @@ -621,6 +621,27 @@ function PidControl(db::DB, config::Config, chunk_size::Int)::PidControl ) end +function User(db::DB, config::Config)::User + static = load_structvector(db, config, UserStaticV1) + time = load_structvector(db, config, UserTimeV1) + parsed_parameters, valid = parse_static_and_time(db, config, "User"; static, time) + + if !valid + error("Errors occurred when parsing User (node type) data.") + end + + allocated = zeros(length(parsed_parameters.return_factor)) + + return User( + parsed_parameters.node_id, + parsed_parameters.demand, + allocated, + parsed_parameters.return_factor, + parsed_parameters.min_level, + parsed_parameters.priority, + ) +end + function Parameters(db::DB, config::Config)::Parameters n_states = length(get_ids(db, "Basin")) + length(get_ids(db, "PidControl")) chunk_size = pickchunksize(n_states) @@ -638,6 +659,7 @@ function Parameters(db::DB, config::Config)::Parameters terminal = Terminal(db, config) discrete_control = DiscreteControl(db, config) pid_control = PidControl(db, config, chunk_size) + user = User(db, config) basin = Basin(db, config, chunk_size) @@ -656,6 +678,7 @@ function Parameters(db::DB, config::Config)::Parameters terminal, discrete_control, pid_control, + user, Dict{Int, Symbol}(), ) for (fieldname, fieldtype) in zip(fieldnames(Parameters), fieldtypes(Parameters)) diff --git a/core/src/solve.jl b/core/src/solve.jl index 634acdab0..47a958222 100644 --- a/core/src/solve.jl +++ b/core/src/solve.jl @@ -403,6 +403,22 @@ struct PidControl{T} <: AbstractParameterNode control_mapping::Dict{Tuple{Int, String}, NamedTuple} end +""" +demand: water flux demand of user over time +allocated: water flux currently allocated to user +return_factor: the factor in [0,1] of how much of the abstracted water is given back to the system +min_level: The level of the source basin below which the user does not abstract +priority: integer > 0, the lower the number the higher the priority of the users demand +""" +struct User + node_id::Vector{Int} + demand::Vector{ScalarInterpolation} + allocated::Vector{Float64} + return_factor::Vector{Float64} + min_level::Vector{Float64} + priority::Vector{Int} +end + # TODO Automatically add all nodetypes here struct Parameters{T, TSparse, C1, C2} starttime::DateTime @@ -419,6 +435,7 @@ struct Parameters{T, TSparse, C1, C2} terminal::Terminal discrete_control::DiscreteControl pid_control::PidControl{T} + user::User lookup::Dict{Int, Symbol} end @@ -719,6 +736,58 @@ function continuous_control!( return nothing end +function formulate!( + user::User, + p::Parameters, + current_level, + storage, + flow, + t::Float64, +)::Nothing + (; connectivity, basin) = p + (; graph_flow) = connectivity + (; node_id, allocated, demand, active, return_factor, min_level) = user + + for (i, id) in enumerate(node_id) + src_id = only(inneighbors(graph_flow, id)) + dst_id = only(outneighbors(graph_flow, id)) + + if !active[i] + flow[src_id, id] = 0.0 + flow[id, dst_id] = 0.0 + continue + end + + # For now allocated = demand + allocated[i] = demand[i](t) + + q = allocated[i] + + # TODO: change to smooth reduction factor + # Smoothly let abstraction go to 0 as the source basin + # dries out + _, basin_idx = id_index(basin.node_id, src_id) + source_storage = storage[basin_idx] + reduction_factor_basin_empty = min(source_storage, 10.0) / 10.0 + q *= reduction_factor_basin_empty + + # TODO: change to smooth reduction factor + # Smoothly let abstraction go to 0 as the source basin + # level reaches its minimum level + source_level = get_level(p, src_id, current_level, t) + Δsource_level = source_level - min_level[i] + reduction_factor_min_level = min(Δsource_level, 0.1) / 0.1 + q *= reduction_factor_min_level + + flow[src_id, id] = q + + # Return flow is immediate + flow[id, dst_id] = q * return_factor[i] + + return nothing + end +end + """ Directed graph: outflow is positive! """ diff --git a/core/src/validation.jl b/core/src/validation.jl index 4f422a5d6..505685ead 100644 --- a/core/src/validation.jl +++ b/core/src/validation.jl @@ -22,6 +22,8 @@ @schema "ribasim.tabulatedratingcurve.static" TabulatedRatingCurveStatic @schema "ribasim.tabulatedratingcurve.time" TabulatedRatingCurveTime @schema "ribasim.outlet.static" OutletStatic +@schema "ribasim.user.static" UserStatic +@schema "ribasim.user.time" UserTime const delimiter = " / " tablename(sv::Type{SchemaVersion{T, N}}) where {T, N} = join(nodetype(sv), delimiter) @@ -46,8 +48,15 @@ end neighbortypes(nodetype::Symbol) = neighbortypes(Val(nodetype)) neighbortypes(::Val{:Pump}) = Set((:Basin, :FractionalFlow, :Terminal, :LevelBoundary)) neighbortypes(::Val{:Outlet}) = Set((:Basin, :FractionalFlow, :Terminal, :LevelBoundary)) -neighbortypes(::Val{:Basin}) = - Set((:LinearResistance, :TabulatedRatingCurve, :ManningResistance, :Pump, :Outlet)) +neighbortypes(::Val{:User}) = Set((:Basin, :FractionalFlow, :Terminal, :LevelBoundary)) +neighbortypes(::Val{:Basin}) = Set(( + :LinearResistance, + :TabulatedRatingCurve, + :ManningResistance, + :Pump, + :Outlet, + :User, +)) neighbortypes(::Val{:Terminal}) = Set{Symbol}() # only endnode neighbortypes(::Val{:FractionalFlow}) = Set((:Basin, :Terminal, :LevelBoundary)) neighbortypes(::Val{:FlowBoundary}) = @@ -93,6 +102,7 @@ n_neighbor_bounds_flow(::Val{:Outlet}) = n_neighbor_bounds(1, 1, 1, typemax(Int) n_neighbor_bounds_flow(::Val{:Terminal}) = n_neighbor_bounds(1, typemax(Int), 0, 0) n_neighbor_bounds_flow(::Val{:PidControl}) = n_neighbor_bounds(0, 0, 0, 0) n_neighbor_bounds_flow(::Val{:DiscreteControl}) = n_neighbor_bounds(0, 0, 0, 0) +n_neighbor_bounds_flow(::Val{:User}) = n_neighbor_bounds(1, 1, 1, 1) n_neighbor_bounds_flow(nodetype) = error("'n_neighbor_bounds_flow' not defined for $nodetype.") @@ -110,7 +120,8 @@ n_neighbor_bounds_control(::Val{:Terminal}) = n_neighbor_bounds(0, 0, 0, 0) n_neighbor_bounds_control(::Val{:PidControl}) = n_neighbor_bounds(0, 1, 1, 1) n_neighbor_bounds_control(::Val{:DiscreteControl}) = n_neighbor_bounds(0, 0, 1, typemax(Int)) -n_neighbor_bounds_control(nodetype) = +n_neighbor_bounds_control(::Val{:User}) = n_neighbor_bounds(0, 0, 0, 0) +n_neghbor_bounds_control(nodetype) = error("'n_neighbor_bounds_control' not defined for $nodetype.") # TODO NodeV1 and EdgeV1 are not yet used @@ -277,6 +288,24 @@ end control_state::Union{Missing, String} end +@version UserStaticV1 begin + node_id::Int + active::Union{Missing, String} + demand::Float64 + return_factor::Float64 + min_level::Float64 + priority::Int +end + +@version UserTimeV1 begin + node_id::Int + time::DateTime + demand::Float64 + return_factor::Float64 + min_level::Float64 + priority::Int +end + function variable_names(s::Any) filter(x -> !(x in (:node_id, :control_state)), fieldnames(s)) end diff --git a/docs/contribute/addnode.qmd b/docs/contribute/addnode.qmd index a0924558c..b3ad1693f 100644 --- a/docs/contribute/addnode.qmd +++ b/docs/contribute/addnode.qmd @@ -47,15 +47,19 @@ Now we define the function that is called in the second bullet above, in `create ```julia function NewNodeType(db::DB, config::Config)::NewNodeType static = load_structvector(db, config, NewNodeTypeStaticV1) - defaults = (; active = true) + defaults = (; foo = 1, bar = false) # Process potential control states in the static data - static_parsed = parse_static(static, db, "Outlet", defaults) + parsed_parameters, valid = parse_static_and_time(db, config, "Outlet"; static, defaults) + + if !valid + error("Errors occurred when parsing NewNodeType data.") + end # Unpack the fields of static as inputs for the NewNodeType constructor return NewNodeType( - static_parsed.node_id, - static_parsed.some_property, - static_parsed.control_mapping) + parsed_parameters.node_id, + parsed_parameters.some_property, + parsed_parameters.control_mapping) end ``` @@ -93,7 +97,7 @@ The current dependency groups are: - Either-neighbor dependencies: examples are `LinearResistance`, `ManningResistance`. If either the in-neighbor or out-neighbor of a node of this group is a basin, the storage of this basin depends on itself. If both the in-neighbor and the out-neighbor are basins, their storages also depend on eachother. - The `PidControl` node is a special case which is discussed in [equations](../core/equations.qmd#sec-PID). -In the methods `formulate_jac!` in `jac.jl` the analytical expressions for the partial derivatives $\frac{\partial Q_{i',j'}}{\partial u_i}$ (and the ones related to PID integral states) are hardcoded. For `NewNodeType` either a new method of `formulate_jac!` has to be introduced, or it has to be added to the list of node types that do not contribute to the Jacobian in the method of `formulate_jac!` whose signature contains `node::AbstractParameterNode`. +Using `jac_prototype` the Jacobian of `water_balance!` is computed automatically using [ForwardDiff.jl](https://juliadiff.org/ForwardDiff.jl/stable/) with memory management provided by [PreallocationTools.jl](https://docs.sciml.ai/PreallocationTools/stable/). These computations make use of `DiffCache` and dual numbers. # Python I/O @@ -102,7 +106,8 @@ In the methods `formulate_jac!` in `jac.jl` the analytical expressions for the p Create a new file `python/ribasim/ribasim/node_types/new_node_type.py` which is structured as follows: ```python -import pandas as pd +from typing import Optional + import pandera as pa from pandera.engines.pandas_engine import PydanticModel from pandera.typing import DataFrame diff --git a/docs/schema/UserStatic.schema.json b/docs/schema/UserStatic.schema.json new file mode 100644 index 000000000..4733934ca --- /dev/null +++ b/docs/schema/UserStatic.schema.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "remarks": { + "format": "default", + "default": "", + "description": "a hack for pandera", + "type": "string" + }, + "priority": { + "format": "default", + "description": "priority", + "type": "integer" + }, + "active": { + "format": "default", + "description": "active", + "type": [ + "string" + ] + }, + "demand": { + "format": "double", + "description": "demand", + "type": "number" + }, + "return_factor": { + "format": "default", + "description": "return_factor", + "type": "list" + }, + "node_id": { + "format": "default", + "description": "node_id", + "type": "integer" + }, + "allocated": { + "format": "double", + "description": "allocated", + "type": "number" + } + }, + "required": [ + "node_id", + "demand", + "allocated", + "return_factor", + "priority" + ], + "$id": "https://deltares.github.io/Ribasim/schema/UserStatic.schema.json", + "title": "UserStatic", + "description": "A UserStatic object based on Ribasim.UserStaticV1", + "type": "object" +} diff --git a/docs/schema/UserTime.schema.json b/docs/schema/UserTime.schema.json new file mode 100644 index 000000000..aa9eb10be --- /dev/null +++ b/docs/schema/UserTime.schema.json @@ -0,0 +1,53 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "remarks": { + "format": "default", + "default": "", + "description": "a hack for pandera", + "type": "string" + }, + "priority": { + "format": "default", + "description": "priority", + "type": "integer" + }, + "time": { + "format": "date-time", + "description": "time", + "type": "string" + }, + "demand": { + "format": "double", + "description": "demand", + "type": "number" + }, + "return_factor": { + "format": "default", + "description": "return_factor", + "type": "list" + }, + "node_id": { + "format": "default", + "description": "node_id", + "type": "integer" + }, + "allocated": { + "format": "double", + "description": "allocated", + "type": "number" + } + }, + "required": [ + "node_id", + "time", + "demand", + "allocated", + "return_factor", + "priority" + ], + "$id": "https://deltares.github.io/Ribasim/schema/UserTime.schema.json", + "title": "UserTime", + "description": "A UserTime object based on Ribasim.UserTimeV1", + "type": "object" +} diff --git a/docs/schema/root.schema.json b/docs/schema/root.schema.json index db2018158..469dcc354 100644 --- a/docs/schema/root.schema.json +++ b/docs/schema/root.schema.json @@ -10,12 +10,18 @@ "FlowBoundaryTime": { "$ref": "FlowBoundaryTime.schema.json" }, + "UserStatic": { + "$ref": "UserStatic.schema.json" + }, "PumpStatic": { "$ref": "PumpStatic.schema.json" }, "LevelBoundaryStatic": { "$ref": "LevelBoundaryStatic.schema.json" }, + "UserTime": { + "$ref": "UserTime.schema.json" + }, "DiscreteControlCondition": { "$ref": "DiscreteControlCondition.schema.json" }, diff --git a/python/ribasim/ribasim/__init__.py b/python/ribasim/ribasim/__init__.py index 7bf297f14..b02abbb88 100644 --- a/python/ribasim/ribasim/__init__.py +++ b/python/ribasim/ribasim/__init__.py @@ -18,6 +18,7 @@ from ribasim.node_types.pump import Pump from ribasim.node_types.tabulated_rating_curve import TabulatedRatingCurve from ribasim.node_types.terminal import Terminal +from ribasim.node_types.user import User __all__ = [ "models", @@ -40,4 +41,5 @@ "Terminal", "DiscreteControl", "PidControl", + "User", ] diff --git a/python/ribasim/ribasim/geometry/node.py b/python/ribasim/ribasim/geometry/node.py index a61f9aa4d..c604b781d 100644 --- a/python/ribasim/ribasim/geometry/node.py +++ b/python/ribasim/ribasim/geometry/node.py @@ -136,6 +136,7 @@ def plot(self, ax=None, zorder=None) -> Any: "FlowBoundary": "h", "DiscreteControl": "*", "PidControl": "x", + "User": "s", "": "o", } @@ -152,6 +153,7 @@ def plot(self, ax=None, zorder=None) -> Any: "FlowBoundary": "m", "DiscreteControl": "k", "PidControl": "k", + "User": "g", "": "k", } diff --git a/python/ribasim/ribasim/model.py b/python/ribasim/ribasim/model.py index aba0d84af..618017e05 100644 --- a/python/ribasim/ribasim/model.py +++ b/python/ribasim/ribasim/model.py @@ -33,6 +33,7 @@ from ribasim.node_types.pump import Pump from ribasim.node_types.tabulated_rating_curve import TabulatedRatingCurve from ribasim.node_types.terminal import Terminal +from ribasim.node_types.user import User from ribasim.types import FilePath @@ -75,6 +76,8 @@ class Model(BaseModel): Discrete control logic. pid_control : Optional[PidControl] PID controller attempting to set the level of a basin to a desired value using a pump/outlet. + user : Optional[User] + User node type with demand and priority. starttime : Union[str, datetime.datetime] Starting time of the simulation. endtime : Union[str, datetime.datetime] @@ -100,6 +103,7 @@ class Model(BaseModel): terminal: Optional[Terminal] discrete_control: Optional[DiscreteControl] pid_control: Optional[PidControl] + user: Optional[User] starttime: datetime.datetime endtime: datetime.datetime solver: Optional[Solver] diff --git a/python/ribasim/ribasim/models.py b/python/ribasim/ribasim/models.py index b9aabdfe2..e4ca81f28 100644 --- a/python/ribasim/ribasim/models.py +++ b/python/ribasim/ribasim/models.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import datetime -from typing import Optional +from typing import Any, Optional from pydantic import BaseModel, Field @@ -31,6 +31,16 @@ class FlowBoundaryTime(BaseModel): node_id: int +class UserStatic(BaseModel): + remarks: Optional[str] = Field("", description="a hack for pandera") + priority: int = Field(..., description="priority") + active: Optional[str] = Field(None, description="active") + demand: float = Field(..., description="demand") + return_factor: Any = Field(..., description="return_factor") + node_id: int = Field(..., description="node_id") + allocated: float = Field(..., description="allocated") + + class PumpStatic(BaseModel): max_flow_rate: Optional[float] = None remarks: str = Field("", description="a hack for pandera") @@ -48,6 +58,16 @@ class LevelBoundaryStatic(BaseModel): level: float +class UserTime(BaseModel): + remarks: Optional[str] = Field("", description="a hack for pandera") + priority: int = Field(..., description="priority") + time: datetime = Field(..., description="time") + demand: float = Field(..., description="demand") + return_factor: Any = Field(..., description="return_factor") + node_id: int = Field(..., description="node_id") + allocated: float = Field(..., description="allocated") + + class DiscreteControlCondition(BaseModel): remarks: str = Field("", description="a hack for pandera") greater_than: float @@ -217,3 +237,5 @@ class Root(BaseModel): BasinProfile: Optional[BasinProfile] = None TerminalStatic: Optional[TerminalStatic] = None BasinStatic: Optional[BasinStatic] = None + UserStatic: Optional[UserStatic] = None + UserTime: Optional[UserTime] = None diff --git a/python/ribasim/ribasim/node_types/__init__.py b/python/ribasim/ribasim/node_types/__init__.py index f1671e7d5..a202a0883 100644 --- a/python/ribasim/ribasim/node_types/__init__.py +++ b/python/ribasim/ribasim/node_types/__init__.py @@ -10,6 +10,7 @@ from ribasim.node_types.pump import Pump from ribasim.node_types.tabulated_rating_curve import TabulatedRatingCurve from ribasim.node_types.terminal import Terminal +from ribasim.node_types.user import User __all__ = [ "Basin", @@ -24,4 +25,5 @@ "Terminal", "DiscreteControl", "PidControl", + "User", ] diff --git a/python/ribasim/ribasim/node_types/user.py b/python/ribasim/ribasim/node_types/user.py new file mode 100644 index 000000000..75e831d62 --- /dev/null +++ b/python/ribasim/ribasim/node_types/user.py @@ -0,0 +1,46 @@ +from typing import Optional + +import pandera as pa +from pandera.engines.pandas_engine import PydanticModel +from pandera.typing import DataFrame + +from ribasim import models +from ribasim.input_base import TableModel + +__all__ = ("User",) + + +class StaticSchema(pa.SchemaModel): + class Config: + """Config with dataframe-level data type.""" + + dtype = PydanticModel(models.UserStatic) + + +class TimeSchema(pa.SchemaModel): + class Config: + """Config with dataframe-level data type.""" + + dtype = PydanticModel(models.UserTime) + + +class User(TableModel): + """ + User node type with demand and priority. + + Parameters + ---------- + static: pandas.DataFrame + table with static data for this node type. + time: pandas.DataFrame + table with static data for this node type (only demand can be transient). + """ + + static: Optional[DataFrame[StaticSchema]] = None + time: Optional[DataFrame[TimeSchema]] = None + + class Config: + validate_assignment = True + + def sort(self): + self.static = self.static.sort_values("node_id", ignore_index=True) diff --git a/qgis/core/nodes.py b/qgis/core/nodes.py index a12cb068a..fcb6dfdfc 100644 --- a/qgis/core/nodes.py +++ b/qgis/core/nodes.py @@ -182,6 +182,7 @@ def renderer(self) -> QgsCategorizedSymbolRenderer: "Terminal": (QColor("purple"), "Terminal", shape.Square), "DiscreteControl": (QColor("black"), "DiscreteControl", shape.Star), "PidControl": (QColor("black"), "PidControl", shape.Cross2), + "User": (QColor("green"), "User", shape.Square), "": ( QColor("white"), "", @@ -509,6 +510,30 @@ class PidControlTime(Input): ] +class UserStatic(Input): + input_type = "User / static" + geometry_type = "No Geometry" + attributes = [ + QgsField("node_id", QVariant.Int), + QgsField("active", QVariant.Bool), + QgsField("demand", QVariant.Double), + QgsField("return_factor", QVariant.Double), + QgsField("priority", QVariant.Int), + ] + + +class UserTime(Input): + input_type = "User / time" + geometry_type = "No Geometry" + attributes = [ + QgsField("node_id", QVariant.Int), + QgsField("time", QVariant.DateTime), + QgsField("demand", QVariant.Double), + QgsField("return_factor", QVariant.Double), + QgsField("priority", QVariant.Int), + ] + + NODES = {cls.input_type: cls for cls in Input.__subclasses__()} NONSPATIALNODETYPES = { cls.nodetype() for cls in Input.__subclasses__() if not cls.is_spatial() From 54273a0c2176ed461c2bb8515d561262e5c7bf65 Mon Sep 17 00:00:00 2001 From: Martijn Visser Date: Thu, 14 Sep 2023 18:53:32 +0200 Subject: [PATCH 2/6] run datamodel-codegen again --- docs/schema/Config.schema.json | 8 +++++ .../DiscreteControlCondition.schema.json | 4 +-- docs/schema/FlowBoundaryStatic.schema.json | 4 +-- docs/schema/LevelBoundaryStatic.schema.json | 4 +-- .../schema/LinearResistanceStatic.schema.json | 4 +-- .../ManningResistanceStatic.schema.json | 4 +-- docs/schema/OutletStatic.schema.json | 16 ++++----- docs/schema/PIDControlStatic.schema.json | 4 +-- docs/schema/PumpStatic.schema.json | 12 +++---- .../TabulatedRatingCurveStatic.schema.json | 4 +-- docs/schema/UserStatic.schema.json | 29 ++++++++------- docs/schema/UserTime.schema.json | 20 ++++------- docs/schema/user.schema.json | 35 +++++++++++++++++++ python/ribasim/ribasim/config.py | 8 +++++ python/ribasim/ribasim/models.py | 34 +++++++++--------- 15 files changed, 117 insertions(+), 73 deletions(-) create mode 100644 docs/schema/user.schema.json diff --git a/docs/schema/Config.schema.json b/docs/schema/Config.schema.json index f324fc81b..2125ba6da 100644 --- a/docs/schema/Config.schema.json +++ b/docs/schema/Config.schema.json @@ -38,6 +38,13 @@ }, "$ref": "https://deltares.github.io/Ribasim/schema/level_boundary.schema.json" }, + "user": { + "default": { + "static": null, + "time": null + }, + "$ref": "https://deltares.github.io/Ribasim/schema/user.schema.json" + }, "pump": { "default": { "static": null @@ -165,6 +172,7 @@ "level_boundary", "pump", "tabulated_rating_curve", + "user", "flow_boundary", "basin", "manning_resistance", diff --git a/docs/schema/DiscreteControlCondition.schema.json b/docs/schema/DiscreteControlCondition.schema.json index 3ce188fe2..00cd84ec3 100644 --- a/docs/schema/DiscreteControlCondition.schema.json +++ b/docs/schema/DiscreteControlCondition.schema.json @@ -27,10 +27,10 @@ "format": "default", "anyOf": [ { - "type": "null" + "type": "number" }, { - "type": "number" + "type": "null" } ] } diff --git a/docs/schema/FlowBoundaryStatic.schema.json b/docs/schema/FlowBoundaryStatic.schema.json index 8300ea9fd..429fe8f20 100644 --- a/docs/schema/FlowBoundaryStatic.schema.json +++ b/docs/schema/FlowBoundaryStatic.schema.json @@ -11,10 +11,10 @@ "format": "default", "anyOf": [ { - "type": "null" + "type": "boolean" }, { - "type": "boolean" + "type": "null" } ] }, diff --git a/docs/schema/LevelBoundaryStatic.schema.json b/docs/schema/LevelBoundaryStatic.schema.json index f635afce0..85ff75b67 100644 --- a/docs/schema/LevelBoundaryStatic.schema.json +++ b/docs/schema/LevelBoundaryStatic.schema.json @@ -11,10 +11,10 @@ "format": "default", "anyOf": [ { - "type": "null" + "type": "boolean" }, { - "type": "boolean" + "type": "null" } ] }, diff --git a/docs/schema/LinearResistanceStatic.schema.json b/docs/schema/LinearResistanceStatic.schema.json index 5a5146ea9..c0261ad6f 100644 --- a/docs/schema/LinearResistanceStatic.schema.json +++ b/docs/schema/LinearResistanceStatic.schema.json @@ -11,10 +11,10 @@ "format": "default", "anyOf": [ { - "type": "null" + "type": "boolean" }, { - "type": "boolean" + "type": "null" } ] }, diff --git a/docs/schema/ManningResistanceStatic.schema.json b/docs/schema/ManningResistanceStatic.schema.json index 22bad56df..f8ec17058 100644 --- a/docs/schema/ManningResistanceStatic.schema.json +++ b/docs/schema/ManningResistanceStatic.schema.json @@ -19,10 +19,10 @@ "format": "default", "anyOf": [ { - "type": "null" + "type": "boolean" }, { - "type": "boolean" + "type": "null" } ] }, diff --git a/docs/schema/OutletStatic.schema.json b/docs/schema/OutletStatic.schema.json index d8feae988..8c24fac8c 100644 --- a/docs/schema/OutletStatic.schema.json +++ b/docs/schema/OutletStatic.schema.json @@ -5,10 +5,10 @@ "format": "default", "anyOf": [ { - "type": "null" + "type": "number" }, { - "type": "number" + "type": "null" } ] }, @@ -22,10 +22,10 @@ "format": "default", "anyOf": [ { - "type": "null" + "type": "boolean" }, { - "type": "boolean" + "type": "null" } ] }, @@ -33,10 +33,10 @@ "format": "default", "anyOf": [ { - "type": "null" + "type": "number" }, { - "type": "number" + "type": "null" } ] }, @@ -63,10 +63,10 @@ "format": "default", "anyOf": [ { - "type": "null" + "type": "number" }, { - "type": "number" + "type": "null" } ] } diff --git a/docs/schema/PIDControlStatic.schema.json b/docs/schema/PIDControlStatic.schema.json index df64576ee..c3226017b 100644 --- a/docs/schema/PIDControlStatic.schema.json +++ b/docs/schema/PIDControlStatic.schema.json @@ -19,10 +19,10 @@ "format": "default", "anyOf": [ { - "type": "null" + "type": "boolean" }, { - "type": "boolean" + "type": "null" } ] }, diff --git a/docs/schema/PumpStatic.schema.json b/docs/schema/PumpStatic.schema.json index afe80e42c..fa15283fa 100644 --- a/docs/schema/PumpStatic.schema.json +++ b/docs/schema/PumpStatic.schema.json @@ -5,10 +5,10 @@ "format": "default", "anyOf": [ { - "type": "null" + "type": "number" }, { - "type": "number" + "type": "null" } ] }, @@ -22,10 +22,10 @@ "format": "default", "anyOf": [ { - "type": "null" + "type": "boolean" }, { - "type": "boolean" + "type": "null" } ] }, @@ -52,10 +52,10 @@ "format": "default", "anyOf": [ { - "type": "null" + "type": "number" }, { - "type": "number" + "type": "null" } ] } diff --git a/docs/schema/TabulatedRatingCurveStatic.schema.json b/docs/schema/TabulatedRatingCurveStatic.schema.json index cb244575b..c073e11c1 100644 --- a/docs/schema/TabulatedRatingCurveStatic.schema.json +++ b/docs/schema/TabulatedRatingCurveStatic.schema.json @@ -11,10 +11,10 @@ "format": "default", "anyOf": [ { - "type": "null" + "type": "boolean" }, { - "type": "boolean" + "type": "null" } ] }, diff --git a/docs/schema/UserStatic.schema.json b/docs/schema/UserStatic.schema.json index 4733934ca..e3e69755f 100644 --- a/docs/schema/UserStatic.schema.json +++ b/docs/schema/UserStatic.schema.json @@ -9,42 +9,41 @@ }, "priority": { "format": "default", - "description": "priority", "type": "integer" }, "active": { "format": "default", - "description": "active", - "type": [ - "string" + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } ] }, "demand": { "format": "double", - "description": "demand", "type": "number" }, "return_factor": { - "format": "default", - "description": "return_factor", - "type": "list" + "format": "double", + "type": "number" + }, + "min_level": { + "format": "double", + "type": "number" }, "node_id": { "format": "default", - "description": "node_id", "type": "integer" - }, - "allocated": { - "format": "double", - "description": "allocated", - "type": "number" } }, "required": [ "node_id", "demand", - "allocated", "return_factor", + "min_level", "priority" ], "$id": "https://deltares.github.io/Ribasim/schema/UserStatic.schema.json", diff --git a/docs/schema/UserTime.schema.json b/docs/schema/UserTime.schema.json index aa9eb10be..0b930a790 100644 --- a/docs/schema/UserTime.schema.json +++ b/docs/schema/UserTime.schema.json @@ -9,41 +9,35 @@ }, "priority": { "format": "default", - "description": "priority", "type": "integer" }, "time": { "format": "date-time", - "description": "time", "type": "string" }, "demand": { "format": "double", - "description": "demand", "type": "number" }, "return_factor": { - "format": "default", - "description": "return_factor", - "type": "list" + "format": "double", + "type": "number" + }, + "min_level": { + "format": "double", + "type": "number" }, "node_id": { "format": "default", - "description": "node_id", "type": "integer" - }, - "allocated": { - "format": "double", - "description": "allocated", - "type": "number" } }, "required": [ "node_id", "time", "demand", - "allocated", "return_factor", + "min_level", "priority" ], "$id": "https://deltares.github.io/Ribasim/schema/UserTime.schema.json", diff --git a/docs/schema/user.schema.json b/docs/schema/user.schema.json new file mode 100644 index 000000000..6fcacadf3 --- /dev/null +++ b/docs/schema/user.schema.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "time": { + "format": "default", + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ], + "default": null + }, + "static": { + "format": "default", + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ], + "default": null + } + }, + "required": [ + ], + "$id": "https://deltares.github.io/Ribasim/schema/user.schema.json", + "title": "user", + "description": "A user object based on Ribasim.config.user", + "type": "object" +} diff --git a/python/ribasim/ribasim/config.py b/python/ribasim/ribasim/config.py index 1f5bc7662..096dcbc75 100644 --- a/python/ribasim/ribasim/config.py +++ b/python/ribasim/ribasim/config.py @@ -23,6 +23,11 @@ class LevelBoundary(BaseModel): static: Optional[str] = None +class User(BaseModel): + time: Optional[str] = None + static: Optional[str] = None + + class Pump(BaseModel): static: Optional[str] = None @@ -111,6 +116,9 @@ class Config(BaseModel): level_boundary: LevelBoundary = Field( default_factory=lambda: LevelBoundary.parse_obj({"static": None, "time": None}) ) + user: User = Field( + default_factory=lambda: User.parse_obj({"static": None, "time": None}) + ) pump: Pump = Field(default_factory=lambda: Pump.parse_obj({"static": None})) discrete_control: DiscreteControl = Field( default_factory=lambda: DiscreteControl.parse_obj( diff --git a/python/ribasim/ribasim/models.py b/python/ribasim/ribasim/models.py index e4ca81f28..f541fe4c4 100644 --- a/python/ribasim/ribasim/models.py +++ b/python/ribasim/ribasim/models.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import datetime -from typing import Any, Optional +from typing import Optional from pydantic import BaseModel, Field @@ -32,13 +32,13 @@ class FlowBoundaryTime(BaseModel): class UserStatic(BaseModel): - remarks: Optional[str] = Field("", description="a hack for pandera") - priority: int = Field(..., description="priority") - active: Optional[str] = Field(None, description="active") - demand: float = Field(..., description="demand") - return_factor: Any = Field(..., description="return_factor") - node_id: int = Field(..., description="node_id") - allocated: float = Field(..., description="allocated") + remarks: str = Field("", description="a hack for pandera") + priority: int + active: Optional[str] = None + demand: float + return_factor: float + min_level: float + node_id: int class PumpStatic(BaseModel): @@ -59,13 +59,13 @@ class LevelBoundaryStatic(BaseModel): class UserTime(BaseModel): - remarks: Optional[str] = Field("", description="a hack for pandera") - priority: int = Field(..., description="priority") - time: datetime = Field(..., description="time") - demand: float = Field(..., description="demand") - return_factor: Any = Field(..., description="return_factor") - node_id: int = Field(..., description="node_id") - allocated: float = Field(..., description="allocated") + remarks: str = Field("", description="a hack for pandera") + priority: int + time: datetime + demand: float + return_factor: float + min_level: float + node_id: int class DiscreteControlCondition(BaseModel): @@ -218,8 +218,10 @@ class Root(BaseModel): DiscreteControlLogic: Optional[DiscreteControlLogic] = None Edge: Optional[Edge] = None FlowBoundaryTime: Optional[FlowBoundaryTime] = None + UserStatic: Optional[UserStatic] = None PumpStatic: Optional[PumpStatic] = None LevelBoundaryStatic: Optional[LevelBoundaryStatic] = None + UserTime: Optional[UserTime] = None DiscreteControlCondition: Optional[DiscreteControlCondition] = None BasinForcing: Optional[BasinForcing] = None FractionalFlowStatic: Optional[FractionalFlowStatic] = None @@ -237,5 +239,3 @@ class Root(BaseModel): BasinProfile: Optional[BasinProfile] = None TerminalStatic: Optional[TerminalStatic] = None BasinStatic: Optional[BasinStatic] = None - UserStatic: Optional[UserStatic] = None - UserTime: Optional[UserTime] = None From e4b5f9f9f39181e4e61ecdd52d610adecec634f0 Mon Sep 17 00:00:00 2001 From: Martijn Visser Date: Fri, 15 Sep 2023 21:50:49 +0200 Subject: [PATCH 3/6] fixes --- core/src/create.jl | 1 + core/src/solve.jl | 26 +++++++++++------------ core/src/validation.jl | 5 +++-- docs/contribute/addnode.qmd | 4 +--- docs/schema/UserStatic.schema.json | 4 ++-- python/ribasim/ribasim/models.py | 2 +- python/ribasim/ribasim/node_types/user.py | 5 ++++- 7 files changed, 25 insertions(+), 22 deletions(-) diff --git a/core/src/create.jl b/core/src/create.jl index 6334db22f..39d89f118 100644 --- a/core/src/create.jl +++ b/core/src/create.jl @@ -634,6 +634,7 @@ function User(db::DB, config::Config)::User return User( parsed_parameters.node_id, + parsed_parameters.active, parsed_parameters.demand, allocated, parsed_parameters.return_factor, diff --git a/core/src/solve.jl b/core/src/solve.jl index 47a958222..9ebcba29a 100644 --- a/core/src/solve.jl +++ b/core/src/solve.jl @@ -405,6 +405,7 @@ end """ demand: water flux demand of user over time +active: whether this node is active and thus demands water allocated: water flux currently allocated to user return_factor: the factor in [0,1] of how much of the abstracted water is given back to the system min_level: The level of the source basin below which the user does not abstract @@ -412,6 +413,7 @@ priority: integer > 0, the lower the number the higher the priority of the users """ struct User node_id::Vector{Int} + active::BitVector demand::Vector{ScalarInterpolation} allocated::Vector{Float64} return_factor::Vector{Float64} @@ -736,12 +738,12 @@ function continuous_control!( return nothing end -function formulate!( +function formulate_flow!( user::User, p::Parameters, - current_level, - storage, - flow, + flow::AbstractMatrix, + current_level::AbstractVector, + storage::AbstractVector, t::Float64, )::Nothing (; connectivity, basin) = p @@ -763,21 +765,17 @@ function formulate!( q = allocated[i] - # TODO: change to smooth reduction factor - # Smoothly let abstraction go to 0 as the source basin - # dries out + # Smoothly let abstraction go to 0 as the source basin dries out _, basin_idx = id_index(basin.node_id, src_id) - source_storage = storage[basin_idx] - reduction_factor_basin_empty = min(source_storage, 10.0) / 10.0 - q *= reduction_factor_basin_empty + factor_basin = reduction_factor(storage[basin_idx], 10.0) + q *= factor_basin - # TODO: change to smooth reduction factor # Smoothly let abstraction go to 0 as the source basin # level reaches its minimum level source_level = get_level(p, src_id, current_level, t) Δsource_level = source_level - min_level[i] - reduction_factor_min_level = min(Δsource_level, 0.1) / 0.1 - q *= reduction_factor_min_level + factor_level = reduction_factor(Δsource_level, 0.1) + q *= factor_level flow[src_id, id] = q @@ -1119,6 +1117,7 @@ function formulate_flows!( flow_boundary, pump, outlet, + user, ) = p formulate_flow!(linear_resistance, p, current_level, flow, t) @@ -1128,6 +1127,7 @@ function formulate_flows!( formulate_flow!(fractional_flow, flow, p) formulate_flow!(pump, p, flow, storage) formulate_flow!(outlet, p, flow, current_level, storage, t) + formulate_flow!(user, p, flow, current_level, storage, t) return nothing end diff --git a/core/src/validation.jl b/core/src/validation.jl index 505685ead..5b63569fb 100644 --- a/core/src/validation.jl +++ b/core/src/validation.jl @@ -121,7 +121,7 @@ n_neighbor_bounds_control(::Val{:PidControl}) = n_neighbor_bounds(0, 1, 1, 1) n_neighbor_bounds_control(::Val{:DiscreteControl}) = n_neighbor_bounds(0, 0, 1, typemax(Int)) n_neighbor_bounds_control(::Val{:User}) = n_neighbor_bounds(0, 0, 0, 0) -n_neghbor_bounds_control(nodetype) = +n_neighbor_bounds_control(nodetype) = error("'n_neighbor_bounds_control' not defined for $nodetype.") # TODO NodeV1 and EdgeV1 are not yet used @@ -290,7 +290,7 @@ end @version UserStaticV1 begin node_id::Int - active::Union{Missing, String} + active::Union{Missing, Bool} demand::Float64 return_factor::Float64 min_level::Float64 @@ -356,6 +356,7 @@ const TimeSchemas = Union{ LevelBoundaryTimeV1, PidControlTimeV1, TabulatedRatingCurveTimeV1, + UserTimeV1, } function sort_by_function(table::StructVector{<:TimeSchemas}) diff --git a/docs/contribute/addnode.qmd b/docs/contribute/addnode.qmd index b3ad1693f..74fb3e62c 100644 --- a/docs/contribute/addnode.qmd +++ b/docs/contribute/addnode.qmd @@ -144,10 +144,8 @@ class NewNodeType(TableModel): class Config: validate_assignment = True - - def sort(self): - self.static = self.static.sort_values("node_id", ignore_index=True) + self.static.sort_values("node_id", ignore_index=True, inplace=True) ``` The `sort` method should implement the same sorting as in `validation.jl`. diff --git a/docs/schema/UserStatic.schema.json b/docs/schema/UserStatic.schema.json index e3e69755f..d0c806834 100644 --- a/docs/schema/UserStatic.schema.json +++ b/docs/schema/UserStatic.schema.json @@ -15,10 +15,10 @@ "format": "default", "anyOf": [ { - "type": "null" + "type": "boolean" }, { - "type": "string" + "type": "null" } ] }, diff --git a/python/ribasim/ribasim/models.py b/python/ribasim/ribasim/models.py index f541fe4c4..4d24afe50 100644 --- a/python/ribasim/ribasim/models.py +++ b/python/ribasim/ribasim/models.py @@ -34,7 +34,7 @@ class FlowBoundaryTime(BaseModel): class UserStatic(BaseModel): remarks: str = Field("", description="a hack for pandera") priority: int - active: Optional[str] = None + active: Optional[bool] = None demand: float return_factor: float min_level: float diff --git a/python/ribasim/ribasim/node_types/user.py b/python/ribasim/ribasim/node_types/user.py index 75e831d62..ef0352603 100644 --- a/python/ribasim/ribasim/node_types/user.py +++ b/python/ribasim/ribasim/node_types/user.py @@ -43,4 +43,7 @@ class Config: validate_assignment = True def sort(self): - self.static = self.static.sort_values("node_id", ignore_index=True) + if self.static is not None: + self.static.sort_values("node_id", ignore_index=True, inplace=True) + if self.time is not None: + self.time.sort_values(["time", "node_id"], ignore_index=True, inplace=True) From 72ec19b49439f9dd91d4871e9519e8da649f80e1 Mon Sep 17 00:00:00 2001 From: Martijn Visser Date: Fri, 15 Sep 2023 22:41:05 +0200 Subject: [PATCH 4/6] use Inf as a more obvious default --- core/src/create.jl | 16 +++++++++++++--- core/src/solve.jl | 2 +- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/core/src/create.jl b/core/src/create.jl index 39d89f118..3c00709a3 100644 --- a/core/src/create.jl +++ b/core/src/create.jl @@ -411,7 +411,7 @@ end function Pump(db::DB, config::Config, chunk_size::Int)::Pump static = load_structvector(db, config, PumpStaticV1) - defaults = (; min_flow_rate = 0.0, max_flow_rate = NaN, active = true) + defaults = (; min_flow_rate = 0.0, max_flow_rate = Inf, active = true) parsed_parameters, valid = parse_static_and_time(db, config, "Pump"; static, defaults) is_pid_controlled = falses(length(parsed_parameters.node_id)) @@ -440,7 +440,7 @@ end function Outlet(db::DB, config::Config, chunk_size::Int)::Outlet static = load_structvector(db, config, OutletStaticV1) defaults = - (; min_flow_rate = 0.0, max_flow_rate = NaN, min_crest_level = NaN, active = true) + (; min_flow_rate = 0.0, max_flow_rate = Inf, min_crest_level = -Inf, active = true) parsed_parameters, valid = parse_static_and_time(db, config, "Outlet"; static, defaults) is_pid_controlled = falses(length(parsed_parameters.node_id)) @@ -624,7 +624,17 @@ end function User(db::DB, config::Config)::User static = load_structvector(db, config, UserStaticV1) time = load_structvector(db, config, UserTimeV1) - parsed_parameters, valid = parse_static_and_time(db, config, "User"; static, time) + defaults = (; min_level = -Inf, active = true) + time_interpolatables = [:demand] + parsed_parameters, valid = parse_static_and_time( + db, + config, + "User"; + static, + time, + time_interpolatables, + defaults, + ) if !valid error("Errors occurred when parsing User (node type) data.") diff --git a/core/src/solve.jl b/core/src/solve.jl index 9ebcba29a..8a21ff918 100644 --- a/core/src/solve.jl +++ b/core/src/solve.jl @@ -1071,7 +1071,7 @@ function formulate_flow!( end # No flow out outlet if source level is lower than minimum crest level - if src_level !== nothing && !isnan(min_crest_level[i]) + if src_level !== nothing q *= reduction_factor(src_level - min_crest_level[i], 0.1) end From 02a0c53b96269d43f72d6b1f19d96e1b5ad13b1a Mon Sep 17 00:00:00 2001 From: Martijn Visser Date: Fri, 15 Sep 2023 22:42:14 +0200 Subject: [PATCH 5/6] add usage docs --- docs/core/usage.qmd | 47 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/docs/core/usage.qmd b/docs/core/usage.qmd index daedbe09d..7a7278cfd 100644 --- a/docs/core/usage.qmd +++ b/docs/core/usage.qmd @@ -150,6 +150,9 @@ name it must have in the GeoPackage if it is stored there. - `Pump / static`: flow rate - Outlet: let water flow with a prescribed flux under the conditions of positive head difference and the upstream level being higher than the minimum crest level - `Outlet / static`: flow rate, minimum crest level +- User: sets water usage demands at a certain priority + - `User / static`: demands + - `User / time`: dynamic demands - Terminal: Water sink without state or properties - `Terminal / static`: - (only node IDs) - DiscreteControl: Set parameters of other nodes based on model state conditions (e.g. basin level) @@ -367,6 +370,46 @@ max_flow_rate | Float64 | $m^3 s^{-1}$ | (optional) min_crest_level | Float64 | $m$ | (optional) control_state | String | - | (optional) +# User + +A user can demand a certain flow from the basin that supplies it. +Currently the user attempts to extract the complete demand from the Basin. +Only if the Basin is almost empty or reaches the minimum level at which the user can extract +water (`min_level`), will it take less than the demand. +In the future water can be allocated to users based on their priority. +Users need an outgoing flow edge along which they can send their return flow, +this can also be to the same basin from which it extracts water. +The amount of return flow is always a fraction of the inflow into the user. +The difference is consumed by the user. + +column | type | unit | restriction +------------- | ------- | ------------ | ----------- +node_id | Int | - | sorted +active | Bool | - | (optional, default true) +demand | Float64 | $m^3 s^{-1}$ | - +return_factor | Float64 | - | between [0 - 1] +min_level | Float64 | $m$ | (optional) +priority | Int | - | - + +## User / time + +This table is the transient form of the `User` table. +The only difference is that a time column is added and activity is assumed to be true. +The table must by sorted by time, and per time it must be sorted by `node_id`. +With this the demand can be updated over time. In between the given times the +demand is interpolated linearly, and outside the demand 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 | Int | - | sorted per time +time | DateTime | - | sorted +demand | Float64 | $m^3 s^{-1}$ | - +return_factor | Float64 | - | between [0 - 1] +min_level | Float64 | $m$ | (optional) +priority | Int | - | - + # LevelBoundary Acts like an infinitely large basin where the level does not change by flow. @@ -378,7 +421,7 @@ column | type | unit | restriction ------------- | ------- | ------------ | ----------- node_id | Int | - | sorted active | Bool | - | (optional, default true) -level | Float64 | $m^3$ | - +level | Float64 | $m$ | - ## LevelBoundary / time @@ -394,7 +437,7 @@ column | type | unit | restriction --------- | ------- | ------------ | ----------- time | DateTime | - | sorted node_id | Int | - | sorted per time -level | Float64 | $m^3 s^{-1}$ | - +level | Float64 | $m$ | - # FlowBoundary From be81c09a7e3a620fd7359863936e0db5f33613cf Mon Sep 17 00:00:00 2001 From: Martijn Visser Date: Fri, 15 Sep 2023 23:52:12 +0200 Subject: [PATCH 6/6] add user test model --- core/src/bmi.jl | 8 +- core/src/solve.jl | 3 +- core/test/run_models.jl | 13 ++ .../ribasim_testmodels/__init__.py | 2 + .../ribasim_testmodels/allocation.py | 124 ++++++++++++++++++ 5 files changed, 146 insertions(+), 4 deletions(-) create mode 100644 python/ribasim_testmodels/ribasim_testmodels/allocation.py diff --git a/core/src/bmi.jl b/core/src/bmi.jl index 7aa8b5497..2667ec9b5 100644 --- a/core/src/bmi.jl +++ b/core/src/bmi.jl @@ -61,9 +61,13 @@ function BMI.initialize(T::Type{Model}, config::Config)::Model end end - # tstops for transient flow_boundary + # tell the solver to stop when new data comes in + # TODO add all time tables here time_flow_boundary = load_structvector(db, config, FlowBoundaryTimeV1) - tstops = get_tstops(time_flow_boundary.time, config.starttime) + tstops_flow_boundary = get_tstops(time_flow_boundary.time, config.starttime) + time_user = load_structvector(db, config, UserTimeV1) + tstops_user = get_tstops(time_user.time, config.starttime) + tstops = sort(unique(vcat(tstops_flow_boundary, tstops_user))) # use state state = load_structvector(db, config, BasinStateV1) diff --git a/core/src/solve.jl b/core/src/solve.jl index 8a21ff918..676f7f9eb 100644 --- a/core/src/solve.jl +++ b/core/src/solve.jl @@ -781,9 +781,8 @@ function formulate_flow!( # Return flow is immediate flow[id, dst_id] = q * return_factor[i] - - return nothing end + return nothing end """ diff --git a/core/test/run_models.jl b/core/test/run_models.jl index ad800deef..b39f146d8 100644 --- a/core/test/run_models.jl +++ b/core/test/run_models.jl @@ -165,6 +165,19 @@ end all(isapprox.(level_basin[timesteps .>= t_maximum_level], level.u[3], atol = 5e-2)) end +@testset "User" begin + toml_path = normpath(@__DIR__, "../../data/user/user.toml") + @test ispath(toml_path) + model = Ribasim.run(toml_path) + + day = 86400.0 + @test only(model.integrator.sol(0day)) == 1000.0 + # constant user withdraws to 0.9m/900m3 + @test only(model.integrator.sol(150day)) ≈ 900 atol = 5 + # dynamic user withdraws to 0.5m/500m3 + @test only(model.integrator.sol(180day)) ≈ 500 atol = 1 +end + @testset "ManningResistance" begin """ Apply the "standard step method" finite difference method to find a diff --git a/python/ribasim_testmodels/ribasim_testmodels/__init__.py b/python/ribasim_testmodels/ribasim_testmodels/__init__.py index 3bcac41c0..2bbc79d0b 100644 --- a/python/ribasim_testmodels/ribasim_testmodels/__init__.py +++ b/python/ribasim_testmodels/ribasim_testmodels/__init__.py @@ -1,5 +1,6 @@ __version__ = "0.1.1" +from ribasim_testmodels.allocation import user_model from ribasim_testmodels.backwater import backwater_model from ribasim_testmodels.basic import ( basic_model, @@ -62,4 +63,5 @@ "discrete_control_of_pid_control_model", "level_boundary_condition_model", "outlet_model", + "user_model", ] diff --git a/python/ribasim_testmodels/ribasim_testmodels/allocation.py b/python/ribasim_testmodels/ribasim_testmodels/allocation.py new file mode 100644 index 000000000..0286c8ccf --- /dev/null +++ b/python/ribasim_testmodels/ribasim_testmodels/allocation.py @@ -0,0 +1,124 @@ +import geopandas as gpd +import numpy as np +import pandas as pd +import ribasim + + +def user_model(): + """Create a user test model with static and dynamic users on the same basin.""" + + # Set up the nodes: + xy = np.array( + [ + (0.0, 0.0), # 1: Basin + (1.0, 0.5), # 2: User + (1.0, -0.5), # 3: User + (2.0, 0.0), # 4: Terminal + ] + ) + node_xy = gpd.points_from_xy(x=xy[:, 0], y=xy[:, 1]) + + node_type = ["Basin", "User", "User", "Terminal"] + + # Make sure the feature id starts at 1: explicitly give an index. + node = ribasim.Node( + static=gpd.GeoDataFrame( + data={"type": node_type}, + index=pd.Index(np.arange(len(xy)) + 1, name="fid"), + geometry=node_xy, + crs="EPSG:28992", + ) + ) + + # Setup the edges: + from_id = np.array([1, 1, 2, 3], dtype=np.int64) + to_id = np.array([2, 3, 4, 4], dtype=np.int64) + lines = ribasim.utils.geometry_from_connectivity(node, from_id, to_id) + edge = ribasim.Edge( + static=gpd.GeoDataFrame( + data={ + "from_node_id": from_id, + "to_node_id": to_id, + "edge_type": len(from_id) * ["flow"], + }, + geometry=lines, + crs="EPSG:28992", + ) + ) + + # Setup the basins: + profile = pd.DataFrame( + data={ + "node_id": 1, + "area": 1000.0, + "level": [0.0, 1.0], + } + ) + + static = pd.DataFrame( + data={ + "node_id": [1], + "drainage": 0.0, + "potential_evaporation": 0.0, + "infiltration": 0.0, + "precipitation": 0.0, + "urban_runoff": 0.0, + } + ) + + state = pd.DataFrame(data={"node_id": [1], "level": 1.0}) + + basin = ribasim.Basin(profile=profile, static=static, state=state) + + # Setup the user + user = ribasim.User( + static=pd.DataFrame( + data={ + "node_id": [2], + "demand": 1e-4, + "return_factor": 0.9, + "min_level": 0.9, + "priority": 1, + } + ), + time=pd.DataFrame( + data={ + "node_id": 3, + "time": [ + "2020-06-01 00:00:00", + "2020-06-01 01:00:00", + "2020-07-01 00:00:00", + "2020-07-01 01:00:00", + ], + "demand": [0.0, 3e-4, 3e-4, 0.0], + "return_factor": 0.4, + "min_level": 0.5, + "priority": 1, + } + ), + ) + + # Setup the terminal: + terminal = ribasim.Terminal( + static=pd.DataFrame( + data={ + "node_id": [4], + } + ) + ) + + solver = ribasim.Solver(algorithm="Tsit5") + + model = ribasim.Model( + modelname="user", + node=node, + edge=edge, + basin=basin, + user=user, + terminal=terminal, + solver=solver, + starttime="2020-01-01 00:00:00", + endtime="2021-01-01 00:00:00", + ) + + return model