From 509612249590bf005e42a14be8a610c589ee360f Mon Sep 17 00:00:00 2001 From: Huite Bootsma Date: Tue, 17 Oct 2023 18:39:40 +0200 Subject: [PATCH 01/37] Read some interpolation tables into the Model struct --- core/src/Ribasim.jl | 1 + core/src/bmi.jl | 6 +- core/src/export.jl | 58 +++++++++++++++++++ core/src/lib.jl | 4 +- core/src/validation.jl | 9 +++ docs/schema/Config.schema.json | 13 ++++- docs/schema/LevelExporterStatic.schema.json | 42 ++++++++++++++ docs/schema/level_exporter.schema.json | 23 ++++++++ docs/schema/root.schema.json | 3 + python/ribasim/ribasim/__init__.py | 2 + python/ribasim/ribasim/config.py | 13 ++++- python/ribasim/ribasim/level_exporter.py | 17 ++++++ python/ribasim/ribasim/model.py | 2 + python/ribasim/ribasim/models.py | 10 ++++ .../ribasim_testmodels/trivial.py | 19 ++++++ 15 files changed, 213 insertions(+), 9 deletions(-) create mode 100644 core/src/export.jl create mode 100644 docs/schema/LevelExporterStatic.schema.json create mode 100644 docs/schema/level_exporter.schema.json create mode 100644 python/ribasim/ribasim/level_exporter.py diff --git a/core/src/Ribasim.jl b/core/src/Ribasim.jl index 1acfde3f4..930c4b9af 100644 --- a/core/src/Ribasim.jl +++ b/core/src/Ribasim.jl @@ -50,6 +50,7 @@ include("validation.jl") include("solve.jl") include("config.jl") using .config +include("export.jl") include("utils.jl") include("lib.jl") include("io.jl") diff --git a/core/src/bmi.jl b/core/src/bmi.jl index 13ce7a70e..c2c775f53 100644 --- a/core/src/bmi.jl +++ b/core/src/bmi.jl @@ -28,7 +28,7 @@ function BMI.initialize(T::Type{Model}, config::Config)::Model # All data from the GeoPackage that we need during runtime is copied into memory, # so we can directly close it again. db = SQLite.DB(gpkg_path) - local parameters, state, n, tstops + local parameters, state, n, tstops, level_exporters try parameters = Parameters(db, config) @@ -82,6 +82,8 @@ function BMI.initialize(T::Type{Model}, config::Config)::Model # use state state = load_structvector(db, config, BasinStateV1) n = length(get_ids(db, "Basin")) + + level_exporters = create_level_exporters(db, config, basin) finally # always close the GeoPackage, also in case of an error close(db) @@ -150,7 +152,7 @@ function BMI.initialize(T::Type{Model}, config::Config)::Model set_initial_discrete_controlled_parameters!(integrator, storage) - return Model(integrator, config, saved_flow) + return Model(integrator, config, saved_flow, level_exporters) end """ diff --git a/core/src/export.jl b/core/src/export.jl new file mode 100644 index 000000000..d974313c4 --- /dev/null +++ b/core/src/export.jl @@ -0,0 +1,58 @@ +# This module exports a water level: +# +# * the water level of the original hydrodynamic model before lumping. +# * a differently aggregated water level, used for e.g. coupling to MODFLOW. +# +# The second is arguably easier to interpret. + +""" +basin_level: a view on Ribasim's basin level. +level: the interpolated water level +tables: the interpolator callables + +All members of this struct have length n_elem. +""" +struct LevelExporter + basin_index::Vector{Int} + interpolations::Vector{ScalarInterpolation} + level::Vector{Float64} +end + +function LevelExporter(tables, node_to_basin::Dict{Int, Int})::LevelExporter + basin_ids = Int[] + interpolations = ScalarInterpolation[] + + for group in IterTools.groupby(row -> row.element_id, tables) + node_id = first(getproperty.(group, :node_id)) + basin_level = getproperty.(group, :basin_level) + element_level = getproperty.(group, :level) + # Ensure it doesn't extrapolate before the first value. + new_interp = LinearInterpolation([element_level[1], element_level...], [prevfloat(basin_level[1]), basin_level...]) + push!(basin_ids, node_to_basin[node_id]) + push!(interpolations, new_interp) + end + + return LevelExporter(basin_ids, interpolations, fill(NaN, length(basin_ids))) +end + +function create_level_exporters(db::DB, config::Config, basin::Basin)::Dict{String, LevelExporter} + node_to_basin = Dict(node_id => index for (index, node_id) in enumerate(basin.node_id)) + tables = load_structvector(db, config, LevelExporterStaticV1) + level_exporters = Dict{String, LevelExporter}() + if length(tables) > 0 + for group in IterTools.groupby(row -> row.name, tables) + name = first(getproperty.(group, :name)) + level_exporters[name] = LevelExporter(group, node_to_basin) + end + end + return level_exporters +end + +""" +Compute a new water level for each external element. +""" +function update!(exporter::LevelExporter, basin_level) + for (i, (index, interp)) in enumerate(zip(exporter.basin_index, exporter.interpolations)) + exporter.level[i] = interp(basin_level[index]) + end +end diff --git a/core/src/lib.jl b/core/src/lib.jl index ab33f6f44..74457cfb3 100644 --- a/core/src/lib.jl +++ b/core/src/lib.jl @@ -12,12 +12,14 @@ struct Model{T} integrator::T config::Config saved_flow::SavedValues{Float64, Vector{Float64}} + level_exporters::Dict{String, LevelExporter} function Model( integrator::T, config, saved_flow, + level_exporters, ) where {T <: SciMLBase.AbstractODEIntegrator} - new{T}(integrator, config, saved_flow) + new{T}(integrator, config, saved_flow, level_exporters) end end diff --git a/core/src/validation.jl b/core/src/validation.jl index 1cc358867..016b1279b 100644 --- a/core/src/validation.jl +++ b/core/src/validation.jl @@ -24,6 +24,7 @@ @schema "ribasim.outlet.static" OutletStatic @schema "ribasim.user.static" UserStatic @schema "ribasim.user.time" UserTime +@schema "ribasim.levelexporter.static" LevelExporterStatic const delimiter = " / " tablename(sv::Type{SchemaVersion{T, N}}) where {T, N} = join(nodetype(sv), delimiter) @@ -308,6 +309,14 @@ end priority::Int end +@version LevelExporterStaticV1 begin + name::String + element_id::Int + node_id::Int + basin_level::Float64 + level::Float64 +end + function variable_names(s::Any) filter(x -> !(x in (:node_id, :control_state)), fieldnames(s)) end diff --git a/docs/schema/Config.schema.json b/docs/schema/Config.schema.json index 0663dad60..b0f4f11de 100644 --- a/docs/schema/Config.schema.json +++ b/docs/schema/Config.schema.json @@ -75,6 +75,12 @@ "timing": false } }, + "fractional_flow": { + "$ref": "https://deltares.github.io/Ribasim/schema/fractional_flow.schema.json", + "default": { + "static": null + } + }, "terminal": { "$ref": "https://deltares.github.io/Ribasim/schema/terminal.schema.json", "default": { @@ -156,8 +162,8 @@ "static": null } }, - "fractional_flow": { - "$ref": "https://deltares.github.io/Ribasim/schema/fractional_flow.schema.json", + "level_exporter": { + "$ref": "https://deltares.github.io/Ribasim/schema/level_exporter.schema.json", "default": { "static": null } @@ -174,6 +180,7 @@ "output", "solver", "logging", + "fractional_flow", "terminal", "pid_control", "level_boundary", @@ -186,6 +193,6 @@ "discrete_control", "outlet", "linear_resistance", - "fractional_flow" + "level_exporter" ] } diff --git a/docs/schema/LevelExporterStatic.schema.json b/docs/schema/LevelExporterStatic.schema.json new file mode 100644 index 000000000..6ccf04a89 --- /dev/null +++ b/docs/schema/LevelExporterStatic.schema.json @@ -0,0 +1,42 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://deltares.github.io/Ribasim/schema/LevelExporterStatic.schema.json", + "title": "LevelExporterStatic", + "description": "A LevelExporterStatic object based on Ribasim.LevelExporterStaticV1", + "type": "object", + "properties": { + "name": { + "format": "default", + "type": "string" + }, + "element_id": { + "format": "default", + "type": "integer" + }, + "node_id": { + "format": "default", + "type": "integer" + }, + "basin_level": { + "format": "double", + "type": "number" + }, + "level": { + "format": "double", + "type": "number" + }, + "remarks": { + "description": "a hack for pandera", + "type": "string", + "format": "default", + "default": "" + } + }, + "required": [ + "name", + "element_id", + "node_id", + "basin_level", + "level" + ] +} diff --git a/docs/schema/level_exporter.schema.json b/docs/schema/level_exporter.schema.json new file mode 100644 index 000000000..aa4e52f2d --- /dev/null +++ b/docs/schema/level_exporter.schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://deltares.github.io/Ribasim/schema/level_exporter.schema.json", + "title": "level_exporter", + "description": "A level_exporter object based on Ribasim.config.level_exporter", + "type": "object", + "properties": { + "static": { + "format": "default", + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ], + "default": null + } + }, + "required": [ + ] +} diff --git a/docs/schema/root.schema.json b/docs/schema/root.schema.json index ad7a55499..4a066feba 100644 --- a/docs/schema/root.schema.json +++ b/docs/schema/root.schema.json @@ -37,6 +37,9 @@ "LevelBoundaryTime": { "$ref": "LevelBoundaryTime.schema.json" }, + "LevelExporterStatic": { + "$ref": "LevelExporterStatic.schema.json" + }, "LinearResistanceStatic": { "$ref": "LinearResistanceStatic.schema.json" }, diff --git a/python/ribasim/ribasim/__init__.py b/python/ribasim/ribasim/__init__.py index c9ff969e3..40a52ba2d 100644 --- a/python/ribasim/ribasim/__init__.py +++ b/python/ribasim/ribasim/__init__.py @@ -5,6 +5,7 @@ from ribasim.config import Config, Logging, Solver from ribasim.geometry.edge import Edge from ribasim.geometry.node import Node +from ribasim.level_exporter import LevelExporter from ribasim.model import Model from ribasim.node_types.basin import Basin from ribasim.node_types.discrete_control import DiscreteControl @@ -42,4 +43,5 @@ "DiscreteControl", "PidControl", "User", + "LevelExporter", ] diff --git a/python/ribasim/ribasim/config.py b/python/ribasim/ribasim/config.py index cc12c951d..dff1827e4 100644 --- a/python/ribasim/ribasim/config.py +++ b/python/ribasim/ribasim/config.py @@ -38,6 +38,10 @@ class Logging(BaseModel): timing: bool = False +class FractionalFlow(BaseModel): + static: Optional[str] = None + + class Terminal(BaseModel): static: Optional[str] = None @@ -95,7 +99,7 @@ class LinearResistance(BaseModel): static: Optional[str] = None -class FractionalFlow(BaseModel): +class LevelExporter(BaseModel): static: Optional[str] = None @@ -142,6 +146,9 @@ class Config(BaseModel): {"verbosity": {"level": 0}, "timing": False} ) ) + fractional_flow: FractionalFlow = Field( + default_factory=lambda: FractionalFlow.parse_obj({"static": None}) + ) terminal: Terminal = Field( default_factory=lambda: Terminal.parse_obj({"static": None}) ) @@ -180,6 +187,6 @@ class Config(BaseModel): linear_resistance: LinearResistance = Field( default_factory=lambda: LinearResistance.parse_obj({"static": None}) ) - fractional_flow: FractionalFlow = Field( - default_factory=lambda: FractionalFlow.parse_obj({"static": None}) + level_exporter: LevelExporter = Field( + default_factory=lambda: LevelExporter.parse_obj({"static": None}) ) diff --git a/python/ribasim/ribasim/level_exporter.py b/python/ribasim/ribasim/level_exporter.py new file mode 100644 index 000000000..3cee3c54f --- /dev/null +++ b/python/ribasim/ribasim/level_exporter.py @@ -0,0 +1,17 @@ +from pandera.typing import DataFrame + +from ribasim.input_base import TableModel +from ribasim.schemas import LevelExporterStaticSchema # type: ignore + + +class LevelExporter(TableModel): + """The level exporter export Ribasim water levels.""" + + static: DataFrame[LevelExporterStaticSchema] + + def sort(self): + self.static.sort_values( + ["name", "element_id", "node_id", "basin_level"], + ignore_index=True, + inplace=True, + ) diff --git a/python/ribasim/ribasim/model.py b/python/ribasim/ribasim/model.py index 2faf0d502..1fa31fa01 100644 --- a/python/ribasim/ribasim/model.py +++ b/python/ribasim/ribasim/model.py @@ -21,6 +21,7 @@ # Do not import from ribasim namespace: will create import errors. # E.g. not: from ribasim import Basin from ribasim.input_base import TableModel +from ribasim.level_exporter import LevelExporter from ribasim.node_types.basin import Basin from ribasim.node_types.discrete_control import DiscreteControl from ribasim.node_types.flow_boundary import FlowBoundary @@ -104,6 +105,7 @@ class Model(BaseModel): discrete_control: Optional[DiscreteControl] pid_control: Optional[PidControl] user: Optional[User] + level_exporter: Optional[LevelExporter] starttime: datetime.datetime endtime: datetime.datetime solver: Optional[Solver] diff --git a/python/ribasim/ribasim/models.py b/python/ribasim/ribasim/models.py index eba64fd86..c1feb8886 100644 --- a/python/ribasim/ribasim/models.py +++ b/python/ribasim/ribasim/models.py @@ -103,6 +103,15 @@ class LevelBoundaryTime(BaseModel): remarks: str = Field("", description="a hack for pandera") +class LevelExporterStatic(BaseModel): + name: str + element_id: int + node_id: int + basin_level: float + level: float + remarks: str = Field("", description="a hack for pandera") + + class LinearResistanceStatic(BaseModel): node_id: int active: Optional[bool] = None @@ -229,6 +238,7 @@ class Root(BaseModel): FractionalFlowStatic: Optional[FractionalFlowStatic] = None LevelBoundaryStatic: Optional[LevelBoundaryStatic] = None LevelBoundaryTime: Optional[LevelBoundaryTime] = None + LevelExporterStatic: Optional[LevelExporterStatic] = None LinearResistanceStatic: Optional[LinearResistanceStatic] = None ManningResistanceStatic: Optional[ManningResistanceStatic] = None Node: Optional[Node] = None diff --git a/python/ribasim_testmodels/ribasim_testmodels/trivial.py b/python/ribasim_testmodels/ribasim_testmodels/trivial.py index bd9c554a4..950b00835 100644 --- a/python/ribasim_testmodels/ribasim_testmodels/trivial.py +++ b/python/ribasim_testmodels/ribasim_testmodels/trivial.py @@ -96,6 +96,24 @@ def trivial_model() -> ribasim.Model: ) ) + # Create a level exporter from one basin to three elements. Scale one to one, but: + # + # 1. start at -1.0 + # 2. start at 0.0 + # 3. start at 1.0 + # + level_exporter = ribasim.LevelExporter( + static=pd.DataFrame( + data={ + "name": "primary-system", + "element_id": [1, 1, 2, 2, 3, 3], + "node_id": [1, 1, 1, 1, 1, 1], + "basin_level": [0.0, 1.0, 0.0, 1.0, 0.0, 1.0], + "level": [-1.0, 0.0, 0.0, 1.0, 1.0, 2.0], + } + ) + ) + model = ribasim.Model( modelname="trivial", node=node, @@ -103,6 +121,7 @@ def trivial_model() -> ribasim.Model: basin=basin, terminal=terminal, tabulated_rating_curve=rating_curve, + level_exporter=level_exporter, starttime="2020-01-01 00:00:00", endtime="2021-01-01 00:00:00", ) From 7d6e552761fd07da64de2004496a6917c8ad0b6c Mon Sep 17 00:00:00 2001 From: Huite Bootsma Date: Wed, 15 Nov 2023 11:34:01 +0100 Subject: [PATCH 02/37] format, process comments --- core/src/export.jl | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/core/src/export.jl b/core/src/export.jl index d974313c4..3d729d384 100644 --- a/core/src/export.jl +++ b/core/src/export.jl @@ -27,7 +27,10 @@ function LevelExporter(tables, node_to_basin::Dict{Int, Int})::LevelExporter basin_level = getproperty.(group, :basin_level) element_level = getproperty.(group, :level) # Ensure it doesn't extrapolate before the first value. - new_interp = LinearInterpolation([element_level[1], element_level...], [prevfloat(basin_level[1]), basin_level...]) + new_interp = LinearInterpolation( + [element_level[1], element_level...], + [prevfloat(basin_level[1]), basin_level...], + ) push!(basin_ids, node_to_basin[node_id]) push!(interpolations, new_interp) end @@ -35,11 +38,15 @@ function LevelExporter(tables, node_to_basin::Dict{Int, Int})::LevelExporter return LevelExporter(basin_ids, interpolations, fill(NaN, length(basin_ids))) end -function create_level_exporters(db::DB, config::Config, basin::Basin)::Dict{String, LevelExporter} +function create_level_exporters( + db::DB, + config::Config, + basin::Basin, +)::Dict{String, LevelExporter} node_to_basin = Dict(node_id => index for (index, node_id) in enumerate(basin.node_id)) tables = load_structvector(db, config, LevelExporterStaticV1) level_exporters = Dict{String, LevelExporter}() - if length(tables) > 0 + if !isempty(tables) > 0 for group in IterTools.groupby(row -> row.name, tables) name = first(getproperty.(group, :name)) level_exporters[name] = LevelExporter(group, node_to_basin) @@ -51,8 +58,9 @@ end """ Compute a new water level for each external element. """ -function update!(exporter::LevelExporter, basin_level) - for (i, (index, interp)) in enumerate(zip(exporter.basin_index, exporter.interpolations)) +function update!(exporter::LevelExporter, basin_level)::Nothing + for (i, (index, interp)) in + enumerate(zip(exporter.basin_index, exporter.interpolations)) exporter.level[i] = interp(basin_level[index]) end end From 4e3a42b11eeb95bfacb16863de5863980e68474e Mon Sep 17 00:00:00 2001 From: Huite Bootsma Date: Wed, 15 Nov 2023 11:43:00 +0100 Subject: [PATCH 03/37] Update LevelExporter to new Python API --- python/ribasim/ribasim/__init__.py | 1 + python/ribasim/ribasim/config.py | 10 ++++++++++ python/ribasim/ribasim/level_exporter.py | 17 ----------------- python/ribasim/ribasim/model.py | 4 ++++ 4 files changed, 15 insertions(+), 17 deletions(-) delete mode 100644 python/ribasim/ribasim/level_exporter.py diff --git a/python/ribasim/ribasim/__init__.py b/python/ribasim/ribasim/__init__.py index 44eab21cf..bb8f4d85c 100644 --- a/python/ribasim/ribasim/__init__.py +++ b/python/ribasim/ribasim/__init__.py @@ -9,6 +9,7 @@ FlowBoundary, FractionalFlow, LevelBoundary, + LevelExporter, LinearResistance, Logging, ManningResistance, diff --git a/python/ribasim/ribasim/config.py b/python/ribasim/ribasim/config.py index 35a30c331..cb99f14c9 100644 --- a/python/ribasim/ribasim/config.py +++ b/python/ribasim/ribasim/config.py @@ -22,6 +22,7 @@ FractionalFlowStaticSchema, LevelBoundaryStaticSchema, LevelBoundaryTimeSchema, + LevelExporterStaticSchema, LinearResistanceStaticSchema, ManningResistanceStaticSchema, OutletStaticSchema, @@ -203,3 +204,12 @@ class FractionalFlow(NodeModel): static: TableModel[FractionalFlowStaticSchema] = Field( default_factory=TableModel[FractionalFlowStaticSchema] ) + + +class LevelExporter(NodeModel): + static: TableModel[LevelExporterStaticSchema] = Field( + default_factory=TableModel[LevelExporterStaticSchema] + ) + _sort_keys: Dict[str, List[str]] = { + "static": ["name", "element_id", "node_id", "basin_level"], + } diff --git a/python/ribasim/ribasim/level_exporter.py b/python/ribasim/ribasim/level_exporter.py deleted file mode 100644 index 3cee3c54f..000000000 --- a/python/ribasim/ribasim/level_exporter.py +++ /dev/null @@ -1,17 +0,0 @@ -from pandera.typing import DataFrame - -from ribasim.input_base import TableModel -from ribasim.schemas import LevelExporterStaticSchema # type: ignore - - -class LevelExporter(TableModel): - """The level exporter export Ribasim water levels.""" - - static: DataFrame[LevelExporterStaticSchema] - - def sort(self): - self.static.sort_values( - ["name", "element_id", "node_id", "basin_level"], - ignore_index=True, - inplace=True, - ) diff --git a/python/ribasim/ribasim/model.py b/python/ribasim/ribasim/model.py index 0e9de75b9..4ae2d3aa1 100644 --- a/python/ribasim/ribasim/model.py +++ b/python/ribasim/ribasim/model.py @@ -17,6 +17,7 @@ FlowBoundary, FractionalFlow, LevelBoundary, + LevelExporter, LinearResistance, Logging, ManningResistance, @@ -100,6 +101,8 @@ class Model(FileModel): Split flows into fractions. level_boundary : Optional[LevelBoundary] Boundary condition specifying the water level. + level_exporter : Optional[LevelExporter] + Translates basin water levels to spatially distributed water levels. flow_boundary : Optional[FlowBoundary] Boundary conditions specifying the flow. linear_resistance: Optional[LinearResistance] @@ -147,6 +150,7 @@ class Model(FileModel): basin: Basin = Field(default_factory=Basin) fractional_flow: FractionalFlow = Field(default_factory=FractionalFlow) level_boundary: LevelBoundary = Field(default_factory=LevelBoundary) + level_exporter: LevelExporter = Field(default_factory=LevelExporter) flow_boundary: FlowBoundary = Field(default_factory=FlowBoundary) linear_resistance: LinearResistance = Field(default_factory=LinearResistance) manning_resistance: ManningResistance = Field(default_factory=ManningResistance) From 2c5a384ab071addb690dee3c1a48147e62e14a47 Mon Sep 17 00:00:00 2001 From: Huite Bootsma Date: Fri, 17 Nov 2023 14:54:18 +0100 Subject: [PATCH 04/37] Make LevelExporter part of Basin --- docs/schema/LevelExporterStatic.schema.json | 6 +-- docs/schema/root.schema.json | 4 +- python/ribasim/ribasim/__init__.py | 2 - python/ribasim/ribasim/config.py | 15 +++----- python/ribasim/ribasim/model.py | 4 +- python/ribasim/ribasim/models.py | 20 +++++----- .../ribasim_testmodels/trivial.py | 37 +++++++++---------- ribasim_qgis/core/nodes.py | 12 ++++++ 8 files changed, 50 insertions(+), 50 deletions(-) diff --git a/docs/schema/LevelExporterStatic.schema.json b/docs/schema/LevelExporterStatic.schema.json index 6ccf04a89..39c21f552 100644 --- a/docs/schema/LevelExporterStatic.schema.json +++ b/docs/schema/LevelExporterStatic.schema.json @@ -1,8 +1,8 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://deltares.github.io/Ribasim/schema/LevelExporterStatic.schema.json", - "title": "LevelExporterStatic", - "description": "A LevelExporterStatic object based on Ribasim.LevelExporterStaticV1", + "$id": "https://deltares.github.io/Ribasim/schema/BasinLevelExporter.schema.json", + "title": "BasinLevelExporter", + "description": "A BasinLevelExporter object based on Ribasim.BasinLevelExporterV1", "type": "object", "properties": { "name": { diff --git a/docs/schema/root.schema.json b/docs/schema/root.schema.json index 4a066feba..8309f959e 100644 --- a/docs/schema/root.schema.json +++ b/docs/schema/root.schema.json @@ -37,8 +37,8 @@ "LevelBoundaryTime": { "$ref": "LevelBoundaryTime.schema.json" }, - "LevelExporterStatic": { - "$ref": "LevelExporterStatic.schema.json" + "BasinLevelExporter": { + "$ref": "BasinLevelExporter.schema.json" }, "LinearResistanceStatic": { "$ref": "LinearResistanceStatic.schema.json" diff --git a/python/ribasim/ribasim/__init__.py b/python/ribasim/ribasim/__init__.py index bb8f4d85c..1183c1bf5 100644 --- a/python/ribasim/ribasim/__init__.py +++ b/python/ribasim/ribasim/__init__.py @@ -9,7 +9,6 @@ FlowBoundary, FractionalFlow, LevelBoundary, - LevelExporter, LinearResistance, Logging, ManningResistance, @@ -50,5 +49,4 @@ "DiscreteControl", "PidControl", "User", - "LevelExporter", ] diff --git a/python/ribasim/ribasim/config.py b/python/ribasim/ribasim/config.py index cb99f14c9..45c0cbbb8 100644 --- a/python/ribasim/ribasim/config.py +++ b/python/ribasim/ribasim/config.py @@ -11,6 +11,7 @@ # These schemas are autogenerated from ribasim.schemas import ( # type: ignore + BasinExporterSchema, BasinProfileSchema, BasinStateSchema, BasinStaticSchema, @@ -22,7 +23,6 @@ FractionalFlowStaticSchema, LevelBoundaryStaticSchema, LevelBoundaryTimeSchema, - LevelExporterStaticSchema, LinearResistanceStaticSchema, ManningResistanceStaticSchema, OutletStaticSchema, @@ -166,10 +166,14 @@ class Basin(NodeModel): time: TableModel[BasinTimeSchema] = Field( default_factory=TableModel[BasinTimeSchema] ) + exporter: TableModel[BasinExporterSchema] = Field( + default_factory=TableModel[BasinExporterSchema] + ) _sort_keys: Dict[str, List[str]] = { "profile": ["node_id", "level"], "time": ["time", "node_id"], + "exporter": ["name", "element_id", "node_id", "basin_level"], } @@ -204,12 +208,3 @@ class FractionalFlow(NodeModel): static: TableModel[FractionalFlowStaticSchema] = Field( default_factory=TableModel[FractionalFlowStaticSchema] ) - - -class LevelExporter(NodeModel): - static: TableModel[LevelExporterStaticSchema] = Field( - default_factory=TableModel[LevelExporterStaticSchema] - ) - _sort_keys: Dict[str, List[str]] = { - "static": ["name", "element_id", "node_id", "basin_level"], - } diff --git a/python/ribasim/ribasim/model.py b/python/ribasim/ribasim/model.py index 4ae2d3aa1..4ba4820a6 100644 --- a/python/ribasim/ribasim/model.py +++ b/python/ribasim/ribasim/model.py @@ -17,7 +17,6 @@ FlowBoundary, FractionalFlow, LevelBoundary, - LevelExporter, LinearResistance, Logging, ManningResistance, @@ -101,7 +100,7 @@ class Model(FileModel): Split flows into fractions. level_boundary : Optional[LevelBoundary] Boundary condition specifying the water level. - level_exporter : Optional[LevelExporter] + exporter : Optional[LevelExporter] Translates basin water levels to spatially distributed water levels. flow_boundary : Optional[FlowBoundary] Boundary conditions specifying the flow. @@ -150,7 +149,6 @@ class Model(FileModel): basin: Basin = Field(default_factory=Basin) fractional_flow: FractionalFlow = Field(default_factory=FractionalFlow) level_boundary: LevelBoundary = Field(default_factory=LevelBoundary) - level_exporter: LevelExporter = Field(default_factory=LevelExporter) flow_boundary: FlowBoundary = Field(default_factory=FlowBoundary) linear_resistance: LinearResistance = Field(default_factory=LinearResistance) manning_resistance: ManningResistance = Field(default_factory=ManningResistance) diff --git a/python/ribasim/ribasim/models.py b/python/ribasim/ribasim/models.py index a7790f5b8..77d78844d 100644 --- a/python/ribasim/ribasim/models.py +++ b/python/ribasim/ribasim/models.py @@ -43,6 +43,15 @@ class BasinTime(BaseModel): remarks: str = Field("", description="a hack for pandera") +class BasinExporter(BaseModel): + name: str + element_id: int + node_id: int + basin_level: float + level: float + remarks: str = Field("", description="a hack for pandera") + + class DiscreteControlCondition(BaseModel): node_id: int listen_feature_id: int @@ -104,15 +113,6 @@ class LevelBoundaryTime(BaseModel): remarks: str = Field("", description="a hack for pandera") -class LevelExporterStatic(BaseModel): - name: str - element_id: int - node_id: int - basin_level: float - level: float - remarks: str = Field("", description="a hack for pandera") - - class LinearResistanceStatic(BaseModel): node_id: int active: Optional[bool] = None @@ -232,6 +232,7 @@ class Root(BaseModel): BasinState: Optional[BasinState] = None BasinStatic: Optional[BasinStatic] = None BasinTime: Optional[BasinTime] = None + BasinExporter: Optional[BasinExporter] = None DiscreteControlCondition: Optional[DiscreteControlCondition] = None DiscreteControlLogic: Optional[DiscreteControlLogic] = None Edge: Optional[Edge] = None @@ -240,7 +241,6 @@ class Root(BaseModel): FractionalFlowStatic: Optional[FractionalFlowStatic] = None LevelBoundaryStatic: Optional[LevelBoundaryStatic] = None LevelBoundaryTime: Optional[LevelBoundaryTime] = None - LevelExporterStatic: Optional[LevelExporterStatic] = None LinearResistanceStatic: Optional[LinearResistanceStatic] = None ManningResistanceStatic: Optional[ManningResistanceStatic] = None Node: Optional[Node] = None diff --git a/python/ribasim_testmodels/ribasim_testmodels/trivial.py b/python/ribasim_testmodels/ribasim_testmodels/trivial.py index 15ddfd6db..e8008f3dc 100644 --- a/python/ribasim_testmodels/ribasim_testmodels/trivial.py +++ b/python/ribasim_testmodels/ribasim_testmodels/trivial.py @@ -72,7 +72,23 @@ def trivial_model() -> ribasim.Model: "urban_runoff": [0.0], } ) - basin = ribasim.Basin(profile=profile, static=static) + + # Create a level exporter from one basin to three elements. Scale one to one, but: + # + # 1. start at -1.0 + # 2. start at 0.0 + # 3. start at 1.0 + # + exporter = pd.DataFrame( + data={ + "name": "primary-system", + "element_id": [1, 1, 2, 2, 3, 3], + "node_id": [1, 1, 1, 1, 1, 1], + "basin_level": [0.0, 1.0, 0.0, 1.0, 0.0, 1.0], + "level": [-1.0, 0.0, 0.0, 1.0, 1.0, 2.0], + } + ) + basin = ribasim.Basin(profile=profile, static=static, exporter=exporter) # Set up a rating curve node: # Discharge: lose 1% of storage volume per day at storage = 1000.0. @@ -96,24 +112,6 @@ def trivial_model() -> ribasim.Model: ) ) - # Create a level exporter from one basin to three elements. Scale one to one, but: - # - # 1. start at -1.0 - # 2. start at 0.0 - # 3. start at 1.0 - # - level_exporter = ribasim.LevelExporter( - static=pd.DataFrame( - data={ - "name": "primary-system", - "element_id": [1, 1, 2, 2, 3, 3], - "node_id": [1, 1, 1, 1, 1, 1], - "basin_level": [0.0, 1.0, 0.0, 1.0, 0.0, 1.0], - "level": [-1.0, 0.0, 0.0, 1.0, 1.0, 2.0], - } - ) - ) - model = ribasim.Model( database=ribasim.Database( node=node, @@ -122,7 +120,6 @@ def trivial_model() -> ribasim.Model: basin=basin, terminal=terminal, tabulated_rating_curve=rating_curve, - level_exporter=level_exporter, starttime="2020-01-01 00:00:00", endtime="2021-01-01 00:00:00", ) diff --git a/ribasim_qgis/core/nodes.py b/ribasim_qgis/core/nodes.py index 28cd7c976..b137253f8 100644 --- a/ribasim_qgis/core/nodes.py +++ b/ribasim_qgis/core/nodes.py @@ -336,6 +336,18 @@ class BasinTime(Input): ] +class BasinExporter(Input): + input_type = "Basin / exporter" + geometry_type = "No Geometry" + attributes = [ + QgsField("name", QVariant.String), + QgsField("element_id", QVariant.Int), + QgsField("node_id", QVariant.Int), + QgsField("basin_level", QVariant.Double), + QgsField("level", QVariant.Double), + ] + + class BasinState(Input): input_type = "Basin / state" geometry_type = "No Geometry" From 0cab52af67814fc0d76ca5ddba87ab77b3c07f40 Mon Sep 17 00:00:00 2001 From: Huite Bootsma Date: Fri, 17 Nov 2023 16:45:01 +0100 Subject: [PATCH 05/37] Move LevelExporter into Parameters Place logic similarly to other structures, not a separate file. --- core/src/Ribasim.jl | 1 - core/src/bmi.jl | 20 +++++++++++-- core/src/create.jl | 41 ++++++++++++++++++++++++++ core/src/export.jl | 66 ------------------------------------------ core/src/lib.jl | 4 +-- core/src/solve.jl | 10 +++++++ core/src/validation.jl | 18 ++++++------ 7 files changed, 79 insertions(+), 81 deletions(-) delete mode 100644 core/src/export.jl diff --git a/core/src/Ribasim.jl b/core/src/Ribasim.jl index 83d5538cc..7f68deec4 100644 --- a/core/src/Ribasim.jl +++ b/core/src/Ribasim.jl @@ -64,7 +64,6 @@ include("solve.jl") include("allocation.jl") include("config.jl") using .config -include("export.jl") include("utils.jl") include("lib.jl") include("io.jl") diff --git a/core/src/bmi.jl b/core/src/bmi.jl index 2616a53de..e31a06fa4 100644 --- a/core/src/bmi.jl +++ b/core/src/bmi.jl @@ -72,7 +72,6 @@ function BMI.initialize(T::Type{Model}, config::Config)::Model state = load_structvector(db, config, BasinStateV1) n = length(get_ids(db, "Basin")) - level_exporters = create_level_exporters(db, config, basin) finally # always close the database, also in case of an error close(db) @@ -141,7 +140,7 @@ function BMI.initialize(T::Type{Model}, config::Config)::Model set_initial_discrete_controlled_parameters!(integrator, storage) - return Model(integrator, config, saved_flow, level_exporters) + return Model(integrator, config, saved_flow) end """ @@ -234,6 +233,11 @@ function create_callbacks( save_flow_cb = SavingCallback(save_flow, saved_flow; saveat, save_start = false) push!(callbacks, save_flow_cb) + # interpolate the levels + tstops = get_tstops(basin.time.time, starttime) + export_cb = PresetTimeCallback(tstops, update_exporter_levels!) + push!(callbacks, export_cb) + n_conditions = length(discrete_control.node_id) if n_conditions > 0 discrete_control_cb = VectorContinuousCallback( @@ -535,6 +539,18 @@ function update_tabulated_rating_curve!(integrator)::Nothing return nothing end +function update_exporter_levels!(integrator)::Nothing + parameters = integrator.p + basin_level = parameters.basin.current_level + + for exporter in values(parameters.level_exporters) + for (i, (index, interp)) in + enumerate(zip(exporter.basin_index, exporter.interpolations)) + exporter.level[i] = interp(basin_level[index]) + end + end +end + function BMI.update(model::Model)::Model step!(model.integrator) return model diff --git a/core/src/create.jl b/core/src/create.jl index e5a50ed88..3ecce80ce 100644 --- a/core/src/create.jl +++ b/core/src/create.jl @@ -798,6 +798,44 @@ function User(db::DB, config::Config)::User ) end +function LevelExporter(tables, node_to_basin::Dict{Int, Int})::LevelExporter + basin_ids = Int[] + interpolations = ScalarInterpolation[] + + for group in IterTools.groupby(row -> row.element_id, tables) + node_id = first(getproperty.(group, :node_id)) + basin_level = getproperty.(group, :basin_level) + element_level = getproperty.(group, :level) + # Ensure it doesn't extrapolate before the first value. + new_interp = LinearInterpolation( + [element_level[1], element_level...], + [prevfloat(basin_level[1]), basin_level...], + ) + push!(basin_ids, node_to_basin[node_id]) + push!(interpolations, new_interp) + end + + return LevelExporter(basin_ids, interpolations, fill(NaN, length(basin_ids))) +end + +function create_level_exporters( + db::DB, + config::Config, + basin::Basin, +)::Dict{String, LevelExporter} + # TODO validate + node_to_basin = Dict(node_id => index for (index, node_id) in enumerate(basin.node_id)) + tables = load_structvector(db, config, BasinLevelExporterV1) + level_exporters = Dict{String, LevelExporter}() + if !isempty(tables) > 0 + for group in IterTools.groupby(row -> row.name, tables) + name = first(getproperty.(group, :name)) + level_exporters[name] = LevelExporter(group, node_to_basin) + end + end + return level_exporters +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) @@ -819,6 +857,8 @@ function Parameters(db::DB, config::Config)::Parameters basin = Basin(db, config, chunk_size) + level_exporters = create_level_exporters(db, config, basin) + # Set is_pid_controlled to true for those pumps and outlets that are PID controlled for id in pid_control.node_id id_controlled = only(outneighbors(connectivity.graph_control, id)) @@ -848,6 +888,7 @@ function Parameters(db::DB, config::Config)::Parameters pid_control, user, Dict{Int, Symbol}(), + level_exporters, ) for (fieldname, fieldtype) in zip(fieldnames(Parameters), fieldtypes(Parameters)) if fieldtype <: AbstractParameterNode diff --git a/core/src/export.jl b/core/src/export.jl deleted file mode 100644 index 3d729d384..000000000 --- a/core/src/export.jl +++ /dev/null @@ -1,66 +0,0 @@ -# This module exports a water level: -# -# * the water level of the original hydrodynamic model before lumping. -# * a differently aggregated water level, used for e.g. coupling to MODFLOW. -# -# The second is arguably easier to interpret. - -""" -basin_level: a view on Ribasim's basin level. -level: the interpolated water level -tables: the interpolator callables - -All members of this struct have length n_elem. -""" -struct LevelExporter - basin_index::Vector{Int} - interpolations::Vector{ScalarInterpolation} - level::Vector{Float64} -end - -function LevelExporter(tables, node_to_basin::Dict{Int, Int})::LevelExporter - basin_ids = Int[] - interpolations = ScalarInterpolation[] - - for group in IterTools.groupby(row -> row.element_id, tables) - node_id = first(getproperty.(group, :node_id)) - basin_level = getproperty.(group, :basin_level) - element_level = getproperty.(group, :level) - # Ensure it doesn't extrapolate before the first value. - new_interp = LinearInterpolation( - [element_level[1], element_level...], - [prevfloat(basin_level[1]), basin_level...], - ) - push!(basin_ids, node_to_basin[node_id]) - push!(interpolations, new_interp) - end - - return LevelExporter(basin_ids, interpolations, fill(NaN, length(basin_ids))) -end - -function create_level_exporters( - db::DB, - config::Config, - basin::Basin, -)::Dict{String, LevelExporter} - node_to_basin = Dict(node_id => index for (index, node_id) in enumerate(basin.node_id)) - tables = load_structvector(db, config, LevelExporterStaticV1) - level_exporters = Dict{String, LevelExporter}() - if !isempty(tables) > 0 - for group in IterTools.groupby(row -> row.name, tables) - name = first(getproperty.(group, :name)) - level_exporters[name] = LevelExporter(group, node_to_basin) - end - end - return level_exporters -end - -""" -Compute a new water level for each external element. -""" -function update!(exporter::LevelExporter, basin_level)::Nothing - for (i, (index, interp)) in - enumerate(zip(exporter.basin_index, exporter.interpolations)) - exporter.level[i] = interp(basin_level[index]) - end -end diff --git a/core/src/lib.jl b/core/src/lib.jl index e61cf028d..f07979eb1 100644 --- a/core/src/lib.jl +++ b/core/src/lib.jl @@ -12,14 +12,12 @@ struct Model{T} integrator::T config::Config saved_flow::SavedValues{Float64, Vector{Float64}} - level_exporters::Dict{String, LevelExporter} function Model( integrator::T, config, saved_flow, - level_exporters, ) where {T <: SciMLBase.AbstractODEIntegrator} - new{T}(integrator, config, saved_flow, level_exporters) + new{T}(integrator, config, saved_flow) end end diff --git a/core/src/solve.jl b/core/src/solve.jl index effa8b62a..b2f2efb5c 100644 --- a/core/src/solve.jl +++ b/core/src/solve.jl @@ -465,6 +465,15 @@ struct User <: AbstractParameterNode } end +""" +LevelExporter linearly interpolates basin levels. +""" +struct LevelExporter + basin_index::Vector{Int} + interpolations::Vector{ScalarInterpolation} + level::Vector{Float64} +end + # TODO Automatically add all nodetypes here struct Parameters{T, TSparse, C1, C2} starttime::DateTime @@ -483,6 +492,7 @@ struct Parameters{T, TSparse, C1, C2} pid_control::PidControl{T} user::User lookup::Dict{Int, Symbol} + level_exporters::Dict{String, LevelExporter} end """ diff --git a/core/src/validation.jl b/core/src/validation.jl index 7588963e2..9ba524db1 100644 --- a/core/src/validation.jl +++ b/core/src/validation.jl @@ -8,6 +8,7 @@ @schema "ribasim.basin.time" BasinTime @schema "ribasim.basin.profile" BasinProfile @schema "ribasim.basin.state" BasinState +@schema "ribasim.basin.levelexporter" BasinLevelExporter @schema "ribasim.terminal.static" TerminalStatic @schema "ribasim.fractionalflow.static" FractionalFlowStatic @schema "ribasim.flowboundary.static" FlowBoundaryStatic @@ -24,7 +25,6 @@ @schema "ribasim.outlet.static" OutletStatic @schema "ribasim.user.static" UserStatic @schema "ribasim.user.time" UserTime -@schema "ribasim.levelexporter.static" LevelExporterStatic const delimiter = " / " tablename(sv::Type{SchemaVersion{T, N}}) where {T, N} = tablename(sv()) @@ -203,6 +203,14 @@ end level::Float64 end +@version BasinLevelExporterV1 begin + name::String + element_id::Int + node_id::Int + basin_level::Float64 + level::Float64 +end + @version FractionalFlowStaticV1 begin node_id::Int fraction::Float64 @@ -323,14 +331,6 @@ end priority::Int end -@version LevelExporterStaticV1 begin - name::String - element_id::Int - node_id::Int - basin_level::Float64 - level::Float64 -end - function variable_names(s::Any) filter(x -> !(x in (:node_id, :control_state)), fieldnames(s)) end From 256c1e0cf1fc9fc4b23cadc79ab0b522b31fd07d Mon Sep 17 00:00:00 2001 From: Huite Bootsma Date: Fri, 17 Nov 2023 17:57:43 +0100 Subject: [PATCH 06/37] Get level interpolation running (currently at preset daily times) --- core/src/bmi.jl | 5 ++--- core/src/create.jl | 2 +- core/src/validation.jl | 4 ++-- ...lExporterStatic.schema.json => BasinExporter.schema.json} | 4 ++-- docs/schema/root.schema.json | 4 ++-- 5 files changed, 9 insertions(+), 10 deletions(-) rename docs/schema/{LevelExporterStatic.schema.json => BasinExporter.schema.json} (88%) diff --git a/core/src/bmi.jl b/core/src/bmi.jl index e31a06fa4..8e2d5812d 100644 --- a/core/src/bmi.jl +++ b/core/src/bmi.jl @@ -234,8 +234,7 @@ function create_callbacks( push!(callbacks, save_flow_cb) # interpolate the levels - tstops = get_tstops(basin.time.time, starttime) - export_cb = PresetTimeCallback(tstops, update_exporter_levels!) + export_cb = PeriodicCallback(update_exporter_levels!, 86400.0; final_affect = true) push!(callbacks, export_cb) n_conditions = length(discrete_control.node_id) @@ -541,7 +540,7 @@ end function update_exporter_levels!(integrator)::Nothing parameters = integrator.p - basin_level = parameters.basin.current_level + basin_level = get_tmp(parameters.basin.current_level, 0) for exporter in values(parameters.level_exporters) for (i, (index, interp)) in diff --git a/core/src/create.jl b/core/src/create.jl index 3ecce80ce..c5d6bd22b 100644 --- a/core/src/create.jl +++ b/core/src/create.jl @@ -825,7 +825,7 @@ function create_level_exporters( )::Dict{String, LevelExporter} # TODO validate node_to_basin = Dict(node_id => index for (index, node_id) in enumerate(basin.node_id)) - tables = load_structvector(db, config, BasinLevelExporterV1) + tables = load_structvector(db, config, BasinExporterV1) level_exporters = Dict{String, LevelExporter}() if !isempty(tables) > 0 for group in IterTools.groupby(row -> row.name, tables) diff --git a/core/src/validation.jl b/core/src/validation.jl index 9ba524db1..dfd5ae16f 100644 --- a/core/src/validation.jl +++ b/core/src/validation.jl @@ -8,7 +8,7 @@ @schema "ribasim.basin.time" BasinTime @schema "ribasim.basin.profile" BasinProfile @schema "ribasim.basin.state" BasinState -@schema "ribasim.basin.levelexporter" BasinLevelExporter +@schema "ribasim.basin.exporter" BasinExporter @schema "ribasim.terminal.static" TerminalStatic @schema "ribasim.fractionalflow.static" FractionalFlowStatic @schema "ribasim.flowboundary.static" FlowBoundaryStatic @@ -203,7 +203,7 @@ end level::Float64 end -@version BasinLevelExporterV1 begin +@version BasinExporterV1 begin name::String element_id::Int node_id::Int diff --git a/docs/schema/LevelExporterStatic.schema.json b/docs/schema/BasinExporter.schema.json similarity index 88% rename from docs/schema/LevelExporterStatic.schema.json rename to docs/schema/BasinExporter.schema.json index 39c21f552..a33d53743 100644 --- a/docs/schema/LevelExporterStatic.schema.json +++ b/docs/schema/BasinExporter.schema.json @@ -1,8 +1,8 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://deltares.github.io/Ribasim/schema/BasinLevelExporter.schema.json", - "title": "BasinLevelExporter", - "description": "A BasinLevelExporter object based on Ribasim.BasinLevelExporterV1", + "title": "BasinExporter", + "description": "A BasinExporter object based on Ribasim.BasinExporterV1", "type": "object", "properties": { "name": { diff --git a/docs/schema/root.schema.json b/docs/schema/root.schema.json index 617bcae56..c0d006864 100644 --- a/docs/schema/root.schema.json +++ b/docs/schema/root.schema.json @@ -37,8 +37,8 @@ "levelboundarytime": { "$ref": "levelboundarytime.schema.json" }, - "basinlevelexporter": { - "$ref": "basinlevelexporter.schema.json" + "basinexporter": { + "$ref": "basinexporter.schema.json" }, "linearresistancestatic": { "$ref": "linearresistancestatic.schema.json" From 6e988678ae6c7884c57201b1ff3960219fb69df4 Mon Sep 17 00:00:00 2001 From: Huite Bootsma Date: Fri, 17 Nov 2023 18:54:13 +0100 Subject: [PATCH 07/37] Write exported levels to output arrow --- core/src/bmi.jl | 68 +++++++++++++++++++++----------- core/src/config.jl | 1 + core/src/io.jl | 21 +++++++++- core/src/lib.jl | 12 ++++-- core/test/run_models.jl | 6 +-- python/ribasim/ribasim/config.py | 1 + 6 files changed, 78 insertions(+), 31 deletions(-) diff --git a/core/src/bmi.jl b/core/src/bmi.jl index 8e2d5812d..2a0788b59 100644 --- a/core/src/bmi.jl +++ b/core/src/bmi.jl @@ -106,7 +106,7 @@ function BMI.initialize(T::Type{Model}, config::Config)::Model end @debug "Setup ODEProblem." - callback, saved_flow = create_callbacks(parameters, config; config.solver.saveat) + callback, saved = create_callbacks(parameters, config; config.solver.saveat) @debug "Created callbacks." # Initialize the integrator, providing all solver options as described in @@ -140,7 +140,7 @@ function BMI.initialize(T::Type{Model}, config::Config)::Model set_initial_discrete_controlled_parameters!(integrator, storage) - return Model(integrator, config, saved_flow) + return Model(integrator, config, saved) end """ @@ -173,6 +173,11 @@ function BMI.finalize(model::Model)::Model path = results_path(config, results.allocation) write_arrow(path, table, compress) + # exported levels + table = exported_levels_table(model) + path = results_path(config, results.exported_levels) + write_arrow(path, table, compress) + @debug "Wrote results." return model end @@ -207,7 +212,7 @@ function create_callbacks( parameters::Parameters, config::Config; saveat, -)::Tuple{CallbackSet, SavedValues{Float64, Vector{Float64}}} +)::Tuple{CallbackSet, SavedResults} (; starttime, basin, tabulated_rating_curve, discrete_control) = parameters callbacks = SciMLBase.DECallback[] @@ -234,9 +239,17 @@ function create_callbacks( push!(callbacks, save_flow_cb) # interpolate the levels - export_cb = PeriodicCallback(update_exporter_levels!, 86400.0; final_affect = true) + saved_exported_levels = SavedValues(Float64, Vector{Float64}) + export_cb = SavingCallback( + save_exported_levels, + saved_exported_levels; + saveat, + save_start = false, + ) push!(callbacks, export_cb) + saved = SavedResults(saved_flow, saved_exported_levels) + n_conditions = length(discrete_control.node_id) if n_conditions > 0 discrete_control_cb = VectorContinuousCallback( @@ -249,7 +262,7 @@ function create_callbacks( end callback = CallbackSet(callbacks...) - return callback, saved_flow + return callback, saved end """ @@ -485,6 +498,27 @@ function save_flow(u, t, integrator) copy(nonzeros(get_tmp(integrator.p.connectivity.flow, u))) end +function update_exporter_levels!(integrator)::Nothing + parameters = integrator.p + basin_level = get_tmp(parameters.basin.current_level, 0) + + for exporter in values(parameters.level_exporters) + for (i, (index, interp)) in + enumerate(zip(exporter.basin_index, exporter.interpolations)) + exporter.level[i] = interp(basin_level[index]) + end + end +end + +"""Interpolate the levels and save them to SavedValues""" +function save_exported_levels(u, t, integrator) + update_exporter_levels!(integrator) + # TODO: multiple systems. Although at the point, shouldn't we + # just write to disk instead of using SavedValues? + exporter = first(values(integrator.p.level_exporters)) + copy(exporter.level) +end + "Load updates from 'Basin / time' into the parameters" function update_basin(integrator)::Nothing (; basin) = integrator.p @@ -538,18 +572,6 @@ function update_tabulated_rating_curve!(integrator)::Nothing return nothing end -function update_exporter_levels!(integrator)::Nothing - parameters = integrator.p - basin_level = get_tmp(parameters.basin.current_level, 0) - - for exporter in values(parameters.level_exporters) - for (i, (index, interp)) in - enumerate(zip(exporter.basin_index, exporter.interpolations)) - exporter.level[i] = interp(basin_level[index]) - end - end -end - function BMI.update(model::Model)::Model step!(model.integrator) return model @@ -616,10 +638,10 @@ function run(config::Config)::Model ) end - with_logger(logger) do - model = Model(config) - solve!(model) - BMI.finalize(model) - return model - end + #with_logger(logger) do + model = Model(config) + solve!(model) + BMI.finalize(model) + return model + #end end diff --git a/core/src/config.jl b/core/src/config.jl index 5fd8692c3..cf3067510 100644 --- a/core/src/config.jl +++ b/core/src/config.jl @@ -113,6 +113,7 @@ end flow::String = "results/flow.arrow" control::String = "results/control.arrow" allocation::String = "results/allocation.arrow" + exported_levels::String = "results/exported_levels.arrow" outstate::Union{String, Nothing} = nothing compression::Compression = "zstd" compression_level::Int = 6 diff --git a/core/src/io.jl b/core/src/io.jl index 66d315cc0..4f5be008b 100644 --- a/core/src/io.jl +++ b/core/src/io.jl @@ -182,8 +182,8 @@ end "Create a flow result table from the saved data" function flow_table(model::Model)::NamedTuple - (; config, saved_flow, integrator) = model - (; t, saveval) = saved_flow + (; config, saved, integrator) = model + (; t, saveval) = saved.flow (; connectivity) = integrator.p I, J, _ = findnz(get_tmp(connectivity.flow, integrator.u)) @@ -227,6 +227,23 @@ function allocation_table(model::Model)::NamedTuple ) end +function exported_levels_table(model::Model)::NamedTuple + (; config, saved, integrator) = model + (; t, saveval) = saved.exported_levels + + name, exporter = first(integrator.p.level_exporters) + nelem = length(exporter.basin_index) + unique_elem_id = collect(1:nelem) + ntsteps = length(t) + + time = repeat(datetime_since.(t, config.starttime); inner = nelem) + elem_id = repeat(unique_elem_id; outer = ntsteps) + levels = FlatVector(saveval) + names = fill(name, length(time)) + + return (; time, names, elem_id, levels) +end + "Write a result table to disk as an Arrow file" function write_arrow( path::AbstractString, diff --git a/core/src/lib.jl b/core/src/lib.jl index f07979eb1..7870ed5bb 100644 --- a/core/src/lib.jl +++ b/core/src/lib.jl @@ -8,16 +8,22 @@ The Model struct is an initialized model, combined with the [`Config`](@ref) use The Basic Model Interface ([BMI](https://github.com/Deltares/BasicModelInterface.jl)) is implemented on the Model. A Model can be created from the path to a TOML configuration file, or a Config object. """ + +struct SavedResults + flow::SavedValues{Float64, Vector{Float64}} + exported_levels::SavedValues{Float64, Vector{Float64}} +end + struct Model{T} integrator::T config::Config - saved_flow::SavedValues{Float64, Vector{Float64}} + saved::SavedResults function Model( integrator::T, config, - saved_flow, + saved, ) where {T <: SciMLBase.AbstractODEIntegrator} - new{T}(integrator, config, saved_flow) + new{T}(integrator, config, saved) end end diff --git a/core/test/run_models.jl b/core/test/run_models.jl index 293a623f1..b4434468b 100644 --- a/core/test/run_models.jl +++ b/core/test/run_models.jl @@ -170,7 +170,7 @@ end (; level) = level_boundary level = level[1] - timesteps = model.saved_flow.t + timesteps = model.saved.flow.t flow = DataFrame(Ribasim.flow_table(model)) outlet_flow = filter([:from_node_id, :to_node_id] => (from, to) -> from === 2 && to === 3, flow) @@ -274,6 +274,6 @@ end # https://www.hec.usace.army.mil/confluence/rasdocs/ras1dtechref/latest/theoretical-basis-for-one-dimensional-and-two-dimensional-hydrodynamic-calculations/1d-steady-flow-water-surface-profiles/friction-loss-evaluation @test all(isapprox.(h_expected, h_actual; atol = 0.02)) # Test for conservation of mass, flow at the beginning == flow at the end - @test model.saved_flow.saveval[end][2] ≈ 5.0 atol = 0.001 skip = Sys.isapple() - @test model.saved_flow.saveval[end][end - 1] ≈ 5.0 atol = 0.001 skip = Sys.isapple() + @test model.saved.flow.saveval[end][2] ≈ 5.0 atol = 0.001 skip = Sys.isapple() + @test model.saved.flow.saveval[end][end - 1] ≈ 5.0 atol = 0.001 skip = Sys.isapple() end diff --git a/python/ribasim/ribasim/config.py b/python/ribasim/ribasim/config.py index b745e5cb9..8394351a9 100644 --- a/python/ribasim/ribasim/config.py +++ b/python/ribasim/ribasim/config.py @@ -47,6 +47,7 @@ class Results(BaseModel): basin: Path = Path("results/basin.arrow") flow: Path = Path("results/flow.arrow") control: Path = Path("results/control.arrow") + exported_levels: Path = Path("results/exported_levels.arrow") outstate: str | None = None compression: Compression = Compression.zstd compression_level: int = 6 From 17f2b6ec7abc1faa95f8421b7274ede641a540f5 Mon Sep 17 00:00:00 2001 From: Huite Bootsma Date: Fri, 17 Nov 2023 19:20:57 +0100 Subject: [PATCH 08/37] Allow outputting of multiple systems for exported_levels --- core/src/bmi.jl | 5 +---- core/src/io.jl | 25 +++++++++++++++---------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/core/src/bmi.jl b/core/src/bmi.jl index 2a0788b59..15d3a9fa3 100644 --- a/core/src/bmi.jl +++ b/core/src/bmi.jl @@ -513,10 +513,7 @@ end """Interpolate the levels and save them to SavedValues""" function save_exported_levels(u, t, integrator) update_exporter_levels!(integrator) - # TODO: multiple systems. Although at the point, shouldn't we - # just write to disk instead of using SavedValues? - exporter = first(values(integrator.p.level_exporters)) - copy(exporter.level) + return vcat([exporter.level for exporter in values(integrator.p.level_exporters)]...) end "Load updates from 'Basin / time' into the parameters" diff --git a/core/src/io.jl b/core/src/io.jl index 4f5be008b..00e7ce4cb 100644 --- a/core/src/io.jl +++ b/core/src/io.jl @@ -231,17 +231,22 @@ function exported_levels_table(model::Model)::NamedTuple (; config, saved, integrator) = model (; t, saveval) = saved.exported_levels - name, exporter = first(integrator.p.level_exporters) - nelem = length(exporter.basin_index) - unique_elem_id = collect(1:nelem) - ntsteps = length(t) - - time = repeat(datetime_since.(t, config.starttime); inner = nelem) - elem_id = repeat(unique_elem_id; outer = ntsteps) - levels = FlatVector(saveval) - names = fill(name, length(time)) + # The level exporter may contain multiple named systems, but the + # saved levels are flat. + time = DateTime[] + name = String[] + element_id = Int[] + for (unique_name, exporter) in integrator.p.level_exporters + nelem = length(exporter.basin_index) + unique_elem_id = collect(1:nelem) + ntsteps = length(t) + append!(time, repeat(datetime_since.(t, config.starttime); inner = nelem)) + append!(element_id, repeat(unique_elem_id; outer = ntsteps)) + append!(name, fill(unique_name, length(time))) + end - return (; time, names, elem_id, levels) + level = FlatVector(saveval) + return (; time, name, element_id, level) end "Write a result table to disk as an Arrow file" From f465a152814eb8018d1527b505b338f62b8eb944 Mon Sep 17 00:00:00 2001 From: Huite Bootsma Date: Mon, 20 Nov 2023 10:03:45 +0100 Subject: [PATCH 09/37] lower case basin exporter in model root --- python/ribasim/ribasim/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/ribasim/ribasim/models.py b/python/ribasim/ribasim/models.py index b2129d3fa..7a08f4da7 100644 --- a/python/ribasim/ribasim/models.py +++ b/python/ribasim/ribasim/models.py @@ -233,7 +233,7 @@ class Root(BaseModel): basinstate: BasinState | None = None basinstatic: BasinStatic | None = None basintime: BasinTime | None = None - Basinexporter: BasinExporter | None = None + basinexporter: BasinExporter | None = None discretecontrolcondition: DiscreteControlCondition | None = None discretecontrollogic: DiscreteControlLogic | None = None edge: Edge | None = None From 4e348a017778c8b9c2f827136f438dcd5bc77e77 Mon Sep 17 00:00:00 2001 From: Huite Bootsma Date: Mon, 20 Nov 2023 10:04:07 +0100 Subject: [PATCH 10/37] Re-enable logging --- core/src/bmi.jl | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/core/src/bmi.jl b/core/src/bmi.jl index 15d3a9fa3..c33555c47 100644 --- a/core/src/bmi.jl +++ b/core/src/bmi.jl @@ -635,10 +635,10 @@ function run(config::Config)::Model ) end - #with_logger(logger) do - model = Model(config) - solve!(model) - BMI.finalize(model) - return model - #end + with_logger(logger) do + model = Model(config) + solve!(model) + BMI.finalize(model) + return model + end end From 3e30179679631173078fab2da2fc4e9f7251a903 Mon Sep 17 00:00:00 2001 From: Huite Bootsma Date: Mon, 20 Nov 2023 11:24:20 +0100 Subject: [PATCH 11/37] Set debug to info for more readable terminal output while running tests --- python/ribasim_testmodels/ribasim_testmodels/basic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/ribasim_testmodels/ribasim_testmodels/basic.py b/python/ribasim_testmodels/ribasim_testmodels/basic.py index eceea3ce1..46ffbaa88 100644 --- a/python/ribasim_testmodels/ribasim_testmodels/basic.py +++ b/python/ribasim_testmodels/ribasim_testmodels/basic.py @@ -186,7 +186,7 @@ def basic_model() -> ribasim.Model: ) ) # Setup logging - logging = ribasim.Logging(verbosity="debug") + logging = ribasim.Logging(verbosity="info") # Setup a model: model = ribasim.Model( From 9456cd1b86c6da4930651dbe1a382a315487c830 Mon Sep 17 00:00:00 2001 From: Huite Bootsma Date: Mon, 20 Nov 2023 11:29:16 +0100 Subject: [PATCH 12/37] Add validation for LevelExporter --- core/src/create.jl | 41 +++++++++++++++++++++++++++++++---------- core/src/validation.jl | 36 ++++++++++++++++++++++++++++++++++++ core/test/validation.jl | 21 +++++++++++++++++++++ 3 files changed, 88 insertions(+), 10 deletions(-) diff --git a/core/src/create.jl b/core/src/create.jl index c5d6bd22b..fd2de16da 100644 --- a/core/src/create.jl +++ b/core/src/create.jl @@ -798,24 +798,46 @@ function User(db::DB, config::Config)::User ) end -function LevelExporter(tables, node_to_basin::Dict{Int, Int})::LevelExporter +function LevelExporter(tables, name, node_to_basin::Dict{Int, Int})::LevelExporter basin_ids = Int[] interpolations = ScalarInterpolation[] + errors = String[] for group in IterTools.groupby(row -> row.element_id, tables) + element_id = first(getproperty.(group, :element_id)) node_id = first(getproperty.(group, :node_id)) basin_level = getproperty.(group, :basin_level) element_level = getproperty.(group, :level) - # Ensure it doesn't extrapolate before the first value. - new_interp = LinearInterpolation( - [element_level[1], element_level...], - [prevfloat(basin_level[1]), basin_level...], + + group_errors = valid_level_exporter( + element_id, + node_id, + node_to_basin, + basin_level, + element_level, ) - push!(basin_ids, node_to_basin[node_id]) - push!(interpolations, new_interp) + + if isempty(group_errors) + # Ensure it doesn't extrapolate before the first value. + new_interp = LinearInterpolation( + [element_level[1], element_level...], + [prevfloat(basin_level[1]), basin_level...], + ) + push!(basin_ids, node_to_basin[node_id]) + push!(interpolations, new_interp) + else + append!(errors, group_errors) + end end - return LevelExporter(basin_ids, interpolations, fill(NaN, length(basin_ids))) + if isempty(errors) + return LevelExporter(basin_ids, interpolations, fill(NaN, length(basin_ids))) + else + foreach(x -> @error(x), errors) + error( + "Errors occurred while parsing BasinExporter data for group with name: $(name).", + ) + end end function create_level_exporters( @@ -823,14 +845,13 @@ function create_level_exporters( config::Config, basin::Basin, )::Dict{String, LevelExporter} - # TODO validate node_to_basin = Dict(node_id => index for (index, node_id) in enumerate(basin.node_id)) tables = load_structvector(db, config, BasinExporterV1) level_exporters = Dict{String, LevelExporter}() if !isempty(tables) > 0 for group in IterTools.groupby(row -> row.name, tables) name = first(getproperty.(group, :name)) - level_exporters[name] = LevelExporter(group, node_to_basin) + level_exporters[name] = LevelExporter(group, name, node_to_basin) end end return level_exporters diff --git a/core/src/validation.jl b/core/src/validation.jl index dfd5ae16f..6d6a7a873 100644 --- a/core/src/validation.jl +++ b/core/src/validation.jl @@ -371,6 +371,7 @@ sort_by_id_level(row) = (row.node_id, row.level) sort_by_id_state_level(row) = (row.node_id, row.control_state, row.level) sort_by_priority(row) = (row.node_id, row.priority) sort_by_priority_time(row) = (row.node_id, row.priority, row.time) +sort_by_exporter(row) = (row.name, row.element_id, row.node_id, row.basin_level) # get the right sort by function given the Schema, with sort_by_id as the default sort_by_function(table::StructVector{<:Legolas.AbstractRecord}) = sort_by_id @@ -380,6 +381,7 @@ sort_by_function(table::StructVector{TabulatedRatingCurveStaticV1}) = sort_by_id sort_by_function(table::StructVector{BasinProfileV1}) = sort_by_id_level sort_by_function(table::StructVector{UserStaticV1}) = sort_by_priority sort_by_function(table::StructVector{UserTimeV1}) = sort_by_priority_time +sort_by_function(table::StructVector{BasinExporterV1}) = sort_by_exporter const TimeSchemas = Union{ BasinTimeV1, @@ -619,3 +621,37 @@ function valid_fractional_flow( end return !errors end + +function valid_level_exporter( + element_id::Int, + node_id::Int, + node_to_basin::Dict{Int, Int}, + basin_level::Vector{Float64}, + element_level::Vector{Float64}, +) + # The Schema ensures that the entries are sorted properly, so we do not need to validate the order here. + errors = String[] + + if !(node_id in keys(node_to_basin)) + push!( + errors, + "The node_id of the BasinExporter does not refer to a basin: node_id $(node_id) for element_id $(element_id).", + ) + end + + if !allunique(basin_level) + push!( + errors, + "BasinExporter element_id $(element_id) has repeated basin levels, this cannot be interpolated.", + ) + end + + if !allunique(element_level) + push!( + errors, + "BasinExporter element_id $(element_id) has repeated element levels, this cannot be interpolated.", + ) + end + + return errors +end diff --git a/core/test/validation.jl b/core/test/validation.jl index a193cb816..dbc3f7371 100644 --- a/core/test/validation.jl +++ b/core/test/validation.jl @@ -291,3 +291,24 @@ end @test logger.logs[2].message == "Invalid edge type 'bar' for edge #2 from node #2 to node #3." end + +@testset "Level exporter validation" begin + node_to_basin = Dict(9 => 1) + errors = Ribasim.valid_level_exporter(1, 10, node_to_basin, [-1.0, 0.0], [-1.0, 0.0]) + @test length(errors) == 1 + @test errors[1] == + "The node_id of the BasinExporter does not refer to a basin: node_id 10 for element_id 1." + + errors = Ribasim.valid_level_exporter( + 1, + 9, + node_to_basin, + [-1.0, 0.0, 0.0], + [-1.0, 0.0, 0.0], + ) + @test length(errors) == 2 + @test errors[1] == + "BasinExporter element_id 1 has repeated basin levels, this cannot be interpolated." + @test errors[2] == + "BasinExporter element_id 1 has repeated element levels, this cannot be interpolated." +end From db4b6d966f0ce18281272aa0cebd819af2f59708 Mon Sep 17 00:00:00 2001 From: Huite Bootsma Date: Mon, 20 Nov 2023 13:48:23 +0100 Subject: [PATCH 13/37] Rename exporter to subgrid; Add validation; Make subgrids optional via results --- core/src/bmi.jl | 30 +++++++----- core/src/config.jl | 2 +- core/src/create.jl | 47 ++++++++++--------- core/src/io.jl | 14 +++--- core/src/lib.jl | 2 +- core/src/solve.jl | 8 ++-- core/src/validation.jl | 29 +++++++----- core/test/run_models.jl | 4 ++ core/test/validation.jl | 10 ++-- docs/schema/BasinExporter.schema.json | 12 ++--- docs/schema/Config.schema.json | 9 +--- docs/schema/level_exporter.schema.json | 23 --------- python/ribasim/ribasim/config.py | 10 ++-- python/ribasim/ribasim/models.py | 8 ++-- .../ribasim_testmodels/basic.py | 2 +- .../ribasim_testmodels/trivial.py | 13 +++-- ribasim_qgis/core/nodes.py | 8 ++-- 17 files changed, 109 insertions(+), 122 deletions(-) delete mode 100644 docs/schema/level_exporter.schema.json diff --git a/core/src/bmi.jl b/core/src/bmi.jl index c33555c47..3c1a529dd 100644 --- a/core/src/bmi.jl +++ b/core/src/bmi.jl @@ -174,9 +174,11 @@ function BMI.finalize(model::Model)::Model write_arrow(path, table, compress) # exported levels - table = exported_levels_table(model) - path = results_path(config, results.exported_levels) - write_arrow(path, table, compress) + if !isnothing(results.subgrid_levels) + table = subgrid_levels_table(model) + path = results_path(config, results.subgrid_levels) + write_arrow(path, table, compress) + end @debug "Wrote results." return model @@ -239,16 +241,16 @@ function create_callbacks( push!(callbacks, save_flow_cb) # interpolate the levels - saved_exported_levels = SavedValues(Float64, Vector{Float64}) + saved_subgrid_levels = SavedValues(Float64, Vector{Float64}) export_cb = SavingCallback( - save_exported_levels, - saved_exported_levels; + save_subgrid_levels, + saved_subgrid_levels; saveat, save_start = false, ) push!(callbacks, export_cb) - saved = SavedResults(saved_flow, saved_exported_levels) + saved = SavedResults(saved_flow, saved_subgrid_levels) n_conditions = length(discrete_control.node_id) if n_conditions > 0 @@ -498,22 +500,24 @@ function save_flow(u, t, integrator) copy(nonzeros(get_tmp(integrator.p.connectivity.flow, u))) end -function update_exporter_levels!(integrator)::Nothing +function update_subgrid_levels!(integrator)::Nothing parameters = integrator.p basin_level = get_tmp(parameters.basin.current_level, 0) - for exporter in values(parameters.level_exporters) + for exporter in values(parameters.subgrid_exporters) for (i, (index, interp)) in enumerate(zip(exporter.basin_index, exporter.interpolations)) - exporter.level[i] = interp(basin_level[index]) + exporter.subgrid_level[i] = interp(basin_level[index]) end end end """Interpolate the levels and save them to SavedValues""" -function save_exported_levels(u, t, integrator) - update_exporter_levels!(integrator) - return vcat([exporter.level for exporter in values(integrator.p.level_exporters)]...) +function save_subgrid_levels(u, t, integrator) + update_subgrid_levels!(integrator) + return vcat( + [exporter.subgrid_level for exporter in values(integrator.p.subgrid_exporters)]..., + ) end "Load updates from 'Basin / time' into the parameters" diff --git a/core/src/config.jl b/core/src/config.jl index cf3067510..01c6615cf 100644 --- a/core/src/config.jl +++ b/core/src/config.jl @@ -113,7 +113,7 @@ end flow::String = "results/flow.arrow" control::String = "results/control.arrow" allocation::String = "results/allocation.arrow" - exported_levels::String = "results/exported_levels.arrow" + subgrid_levels::Union{String, Nothing} = nothing outstate::Union{String, Nothing} = nothing compression::Compression = "zstd" compression_level::Int = 6 diff --git a/core/src/create.jl b/core/src/create.jl index fd2de16da..a478cb486 100644 --- a/core/src/create.jl +++ b/core/src/create.jl @@ -798,29 +798,29 @@ function User(db::DB, config::Config)::User ) end -function LevelExporter(tables, name, node_to_basin::Dict{Int, Int})::LevelExporter +function SubgridExporter(tables, name, node_to_basin::Dict{Int, Int})::SubgridExporter basin_ids = Int[] interpolations = ScalarInterpolation[] errors = String[] - for group in IterTools.groupby(row -> row.element_id, tables) - element_id = first(getproperty.(group, :element_id)) + for group in IterTools.groupby(row -> row.subgrid_id, tables) + subgrid_id = first(getproperty.(group, :subgrid_id)) node_id = first(getproperty.(group, :node_id)) basin_level = getproperty.(group, :basin_level) - element_level = getproperty.(group, :level) + subgrid_level = getproperty.(group, :subgrid_level) - group_errors = valid_level_exporter( - element_id, + group_errors = valid_subgrid_exporter( + subgrid_id, node_id, node_to_basin, basin_level, - element_level, + subgrid_level, ) if isempty(group_errors) # Ensure it doesn't extrapolate before the first value. new_interp = LinearInterpolation( - [element_level[1], element_level...], + [subgrid_level[1], subgrid_level...], [prevfloat(basin_level[1]), basin_level...], ) push!(basin_ids, node_to_basin[node_id]) @@ -831,30 +831,33 @@ function LevelExporter(tables, name, node_to_basin::Dict{Int, Int})::LevelExport end if isempty(errors) - return LevelExporter(basin_ids, interpolations, fill(NaN, length(basin_ids))) + return SubgridExporter(basin_ids, interpolations, fill(NaN, length(basin_ids))) else foreach(x -> @error(x), errors) error( - "Errors occurred while parsing BasinExporter data for group with name: $(name).", + "Errors occurred while parsing BasinSubgrid data for group with name: $(name).", ) end end -function create_level_exporters( +function create_subgrid_exporters( db::DB, config::Config, basin::Basin, -)::Dict{String, LevelExporter} - node_to_basin = Dict(node_id => index for (index, node_id) in enumerate(basin.node_id)) - tables = load_structvector(db, config, BasinExporterV1) - level_exporters = Dict{String, LevelExporter}() - if !isempty(tables) > 0 - for group in IterTools.groupby(row -> row.name, tables) - name = first(getproperty.(group, :name)) - level_exporters[name] = LevelExporter(group, name, node_to_basin) +)::Dict{String, SubgridExporter} + subgrid_exporters = Dict{String, SubgridExporter}() + if !isnothing(config.results.subgrid_levels) + node_to_basin = + Dict(node_id => index for (index, node_id) in enumerate(basin.node_id)) + tables = load_structvector(db, config, BasinSubgridV1) + if !isempty(tables) + for group in IterTools.groupby(row -> row.name, tables) + name = first(getproperty.(group, :name)) + subgrid_exporters[name] = SubgridExporter(group, name, node_to_basin) + end end end - return level_exporters + return subgrid_exporters end function Parameters(db::DB, config::Config)::Parameters @@ -878,7 +881,7 @@ function Parameters(db::DB, config::Config)::Parameters basin = Basin(db, config, chunk_size) - level_exporters = create_level_exporters(db, config, basin) + subgrid_exporters = create_subgrid_exporters(db, config, basin) # Set is_pid_controlled to true for those pumps and outlets that are PID controlled for id in pid_control.node_id @@ -909,7 +912,7 @@ function Parameters(db::DB, config::Config)::Parameters pid_control, user, Dict{Int, Symbol}(), - level_exporters, + subgrid_exporters, ) for (fieldname, fieldtype) in zip(fieldnames(Parameters), fieldtypes(Parameters)) if fieldtype <: AbstractParameterNode diff --git a/core/src/io.jl b/core/src/io.jl index 00e7ce4cb..dc44f67f6 100644 --- a/core/src/io.jl +++ b/core/src/io.jl @@ -227,26 +227,26 @@ function allocation_table(model::Model)::NamedTuple ) end -function exported_levels_table(model::Model)::NamedTuple +function subgrid_levels_table(model::Model)::NamedTuple (; config, saved, integrator) = model - (; t, saveval) = saved.exported_levels + (; t, saveval) = saved.subgrid_levels # The level exporter may contain multiple named systems, but the # saved levels are flat. time = DateTime[] name = String[] - element_id = Int[] - for (unique_name, exporter) in integrator.p.level_exporters + subgrid_id = Int[] + for (unique_name, exporter) in integrator.p.subgrid_exporters nelem = length(exporter.basin_index) unique_elem_id = collect(1:nelem) ntsteps = length(t) append!(time, repeat(datetime_since.(t, config.starttime); inner = nelem)) - append!(element_id, repeat(unique_elem_id; outer = ntsteps)) + append!(subgrid_id, repeat(unique_elem_id; outer = ntsteps)) append!(name, fill(unique_name, length(time))) end - level = FlatVector(saveval) - return (; time, name, element_id, level) + subgrid_level = FlatVector(saveval) + return (; time, name, subgrid_id, subgrid_level) end "Write a result table to disk as an Arrow file" diff --git a/core/src/lib.jl b/core/src/lib.jl index 7870ed5bb..170349f99 100644 --- a/core/src/lib.jl +++ b/core/src/lib.jl @@ -11,7 +11,7 @@ A Model can be created from the path to a TOML configuration file, or a Config o struct SavedResults flow::SavedValues{Float64, Vector{Float64}} - exported_levels::SavedValues{Float64, Vector{Float64}} + subgrid_levels::SavedValues{Float64, Vector{Float64}} end struct Model{T} diff --git a/core/src/solve.jl b/core/src/solve.jl index b2f2efb5c..b53b3d85c 100644 --- a/core/src/solve.jl +++ b/core/src/solve.jl @@ -466,12 +466,12 @@ struct User <: AbstractParameterNode end """ -LevelExporter linearly interpolates basin levels. +SubgridExporter linearly interpolates basin levels. """ -struct LevelExporter +struct SubgridExporter basin_index::Vector{Int} interpolations::Vector{ScalarInterpolation} - level::Vector{Float64} + subgrid_level::Vector{Float64} end # TODO Automatically add all nodetypes here @@ -492,7 +492,7 @@ struct Parameters{T, TSparse, C1, C2} pid_control::PidControl{T} user::User lookup::Dict{Int, Symbol} - level_exporters::Dict{String, LevelExporter} + subgrid_exporters::Dict{String, SubgridExporter} end """ diff --git a/core/src/validation.jl b/core/src/validation.jl index 6d6a7a873..c66fcf920 100644 --- a/core/src/validation.jl +++ b/core/src/validation.jl @@ -8,7 +8,7 @@ @schema "ribasim.basin.time" BasinTime @schema "ribasim.basin.profile" BasinProfile @schema "ribasim.basin.state" BasinState -@schema "ribasim.basin.exporter" BasinExporter +@schema "ribasim.basin.subgrid" BasinSubgrid @schema "ribasim.terminal.static" TerminalStatic @schema "ribasim.fractionalflow.static" FractionalFlowStatic @schema "ribasim.flowboundary.static" FlowBoundaryStatic @@ -203,12 +203,12 @@ end level::Float64 end -@version BasinExporterV1 begin +@version BasinSubgridV1 begin name::String - element_id::Int + subgrid_id::Int node_id::Int basin_level::Float64 - level::Float64 + subgrid_level::Float64 end @version FractionalFlowStaticV1 begin @@ -371,7 +371,7 @@ sort_by_id_level(row) = (row.node_id, row.level) sort_by_id_state_level(row) = (row.node_id, row.control_state, row.level) sort_by_priority(row) = (row.node_id, row.priority) sort_by_priority_time(row) = (row.node_id, row.priority, row.time) -sort_by_exporter(row) = (row.name, row.element_id, row.node_id, row.basin_level) +sort_by_exporter(row) = (row.name, row.subgrid_id, row.node_id, row.basin_level) # get the right sort by function given the Schema, with sort_by_id as the default sort_by_function(table::StructVector{<:Legolas.AbstractRecord}) = sort_by_id @@ -381,7 +381,7 @@ sort_by_function(table::StructVector{TabulatedRatingCurveStaticV1}) = sort_by_id sort_by_function(table::StructVector{BasinProfileV1}) = sort_by_id_level sort_by_function(table::StructVector{UserStaticV1}) = sort_by_priority sort_by_function(table::StructVector{UserTimeV1}) = sort_by_priority_time -sort_by_function(table::StructVector{BasinExporterV1}) = sort_by_exporter +sort_by_function(table::StructVector{BasinSubgridV1}) = sort_by_exporter const TimeSchemas = Union{ BasinTimeV1, @@ -622,12 +622,15 @@ function valid_fractional_flow( return !errors end -function valid_level_exporter( - element_id::Int, +""" +Validate the entries for a single subgrid element. +""" +function valid_subgrid_exporter( + subgrid_id::Int, node_id::Int, node_to_basin::Dict{Int, Int}, basin_level::Vector{Float64}, - element_level::Vector{Float64}, + subgrid_level::Vector{Float64}, ) # The Schema ensures that the entries are sorted properly, so we do not need to validate the order here. errors = String[] @@ -635,21 +638,21 @@ function valid_level_exporter( if !(node_id in keys(node_to_basin)) push!( errors, - "The node_id of the BasinExporter does not refer to a basin: node_id $(node_id) for element_id $(element_id).", + "The node_id of the BasinSubgrid does not refer to a basin: node_id $(node_id) for subgrid_id $(subgrid_id).", ) end if !allunique(basin_level) push!( errors, - "BasinExporter element_id $(element_id) has repeated basin levels, this cannot be interpolated.", + "BasinSubgrid subgrid_id $(subgrid_id) has repeated basin levels, this cannot be interpolated.", ) end - if !allunique(element_level) + if !allunique(subgrid_level) push!( errors, - "BasinExporter element_id $(element_id) has repeated element levels, this cannot be interpolated.", + "BasinSubgrid subgrid_id $(subgrid_id) has repeated element levels, this cannot be interpolated.", ) end diff --git a/core/test/run_models.jl b/core/test/run_models.jl index b4434468b..6a472390e 100644 --- a/core/test/run_models.jl +++ b/core/test/run_models.jl @@ -14,6 +14,10 @@ using DataFrames: DataFrame model = Ribasim.run(toml_path) @test model isa Ribasim.Model @test successful_retcode(model) + + # The exporter interpolates 1:1 for three subgrid elements, but shifted by 1.0 meter. + subgrid_exporter = model.integrator.p.subgrid_exporters["primary-system"] + @test all(diff(subgrid_exporter.subgrid_level) .≈ 1.0) end @testset "bucket model" begin diff --git a/core/test/validation.jl b/core/test/validation.jl index dbc3f7371..c2b836cd4 100644 --- a/core/test/validation.jl +++ b/core/test/validation.jl @@ -294,12 +294,12 @@ end @testset "Level exporter validation" begin node_to_basin = Dict(9 => 1) - errors = Ribasim.valid_level_exporter(1, 10, node_to_basin, [-1.0, 0.0], [-1.0, 0.0]) + errors = Ribasim.valid_subgrid_exporter(1, 10, node_to_basin, [-1.0, 0.0], [-1.0, 0.0]) @test length(errors) == 1 @test errors[1] == - "The node_id of the BasinExporter does not refer to a basin: node_id 10 for element_id 1." + "The node_id of the BasinSubgrid does not refer to a basin: node_id 10 for subgrid_id 1." - errors = Ribasim.valid_level_exporter( + errors = Ribasim.valid_subgrid_exporter( 1, 9, node_to_basin, @@ -308,7 +308,7 @@ end ) @test length(errors) == 2 @test errors[1] == - "BasinExporter element_id 1 has repeated basin levels, this cannot be interpolated." + "BasinSubgrid subgrid_id 1 has repeated basin levels, this cannot be interpolated." @test errors[2] == - "BasinExporter element_id 1 has repeated element levels, this cannot be interpolated." + "BasinSubgrid subgrid_id 1 has repeated element levels, this cannot be interpolated." end diff --git a/docs/schema/BasinExporter.schema.json b/docs/schema/BasinExporter.schema.json index a33d53743..6df716697 100644 --- a/docs/schema/BasinExporter.schema.json +++ b/docs/schema/BasinExporter.schema.json @@ -1,15 +1,15 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://deltares.github.io/Ribasim/schema/BasinLevelExporter.schema.json", - "title": "BasinExporter", - "description": "A BasinExporter object based on Ribasim.BasinExporterV1", + "$id": "https://deltares.github.io/Ribasim/schema/BasinSubgridExporter.schema.json", + "title": "BasinSubgrid", + "description": "A BasinSubgrid object based on Ribasim.BasinSubgridV1", "type": "object", "properties": { "name": { "format": "default", "type": "string" }, - "element_id": { + "subgrid_id": { "format": "default", "type": "integer" }, @@ -21,7 +21,7 @@ "format": "double", "type": "number" }, - "level": { + "subgrid_level": { "format": "double", "type": "number" }, @@ -34,7 +34,7 @@ }, "required": [ "name", - "element_id", + "subgrid_id", "node_id", "basin_level", "level" diff --git a/docs/schema/Config.schema.json b/docs/schema/Config.schema.json index f40e940c2..342e4149a 100644 --- a/docs/schema/Config.schema.json +++ b/docs/schema/Config.schema.json @@ -158,12 +158,6 @@ "default": { "static": null } - }, - "level_exporter": { - "$ref": "https://deltares.github.io/Ribasim/schema/level_exporter.schema.json", - "default": { - "static": null - } } }, "required": [ @@ -188,7 +182,6 @@ "manning_resistance", "discrete_control", "outlet", - "linear_resistance", - "level_exporter" + "linear_resistance" ] } diff --git a/docs/schema/level_exporter.schema.json b/docs/schema/level_exporter.schema.json deleted file mode 100644 index aa4e52f2d..000000000 --- a/docs/schema/level_exporter.schema.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://deltares.github.io/Ribasim/schema/level_exporter.schema.json", - "title": "level_exporter", - "description": "A level_exporter object based on Ribasim.config.level_exporter", - "type": "object", - "properties": { - "static": { - "format": "default", - "anyOf": [ - { - "type": "null" - }, - { - "type": "string" - } - ], - "default": null - } - }, - "required": [ - ] -} diff --git a/python/ribasim/ribasim/config.py b/python/ribasim/ribasim/config.py index 8394351a9..510b0bf7f 100644 --- a/python/ribasim/ribasim/config.py +++ b/python/ribasim/ribasim/config.py @@ -7,10 +7,10 @@ # These schemas are autogenerated from ribasim.schemas import ( # type: ignore - BasinExporterSchema, BasinProfileSchema, BasinStateSchema, BasinStaticSchema, + BasinSubgridSchema, BasinTimeSchema, DiscreteControlConditionSchema, DiscreteControlLogicSchema, @@ -47,7 +47,7 @@ class Results(BaseModel): basin: Path = Path("results/basin.arrow") flow: Path = Path("results/flow.arrow") control: Path = Path("results/control.arrow") - exported_levels: Path = Path("results/exported_levels.arrow") + subgrid_levels: str | None = None outstate: str | None = None compression: Compression = Compression.zstd compression_level: int = 6 @@ -163,14 +163,14 @@ class Basin(NodeModel): time: TableModel[BasinTimeSchema] = Field( default_factory=TableModel[BasinTimeSchema] ) - exporter: TableModel[BasinExporterSchema] = Field( - default_factory=TableModel[BasinExporterSchema] + subgrid: TableModel[BasinSubgridSchema] = Field( + default_factory=TableModel[BasinSubgridSchema] ) _sort_keys: dict[str, list[str]] = { "profile": ["node_id", "level"], "time": ["time", "node_id"], - "exporter": ["name", "element_id", "node_id", "basin_level"], + "exporter": ["name", "subgrid_id", "node_id", "basin_level"], } diff --git a/python/ribasim/ribasim/models.py b/python/ribasim/ribasim/models.py index 7a08f4da7..6758d5ea8 100644 --- a/python/ribasim/ribasim/models.py +++ b/python/ribasim/ribasim/models.py @@ -44,12 +44,12 @@ class BasinTime(BaseModel): remarks: str = Field("", description="a hack for pandera") -class BasinExporter(BaseModel): +class BasinSubgrid(BaseModel): name: str - element_id: int + subgrid_id: int node_id: int basin_level: float - level: float + subgrid_level: float remarks: str = Field("", description="a hack for pandera") @@ -233,7 +233,7 @@ class Root(BaseModel): basinstate: BasinState | None = None basinstatic: BasinStatic | None = None basintime: BasinTime | None = None - basinexporter: BasinExporter | None = None + basinsubgrid: BasinSubgrid | None = None discretecontrolcondition: DiscreteControlCondition | None = None discretecontrollogic: DiscreteControlLogic | None = None edge: Edge | None = None diff --git a/python/ribasim_testmodels/ribasim_testmodels/basic.py b/python/ribasim_testmodels/ribasim_testmodels/basic.py index 46ffbaa88..eceea3ce1 100644 --- a/python/ribasim_testmodels/ribasim_testmodels/basic.py +++ b/python/ribasim_testmodels/ribasim_testmodels/basic.py @@ -186,7 +186,7 @@ def basic_model() -> ribasim.Model: ) ) # Setup logging - logging = ribasim.Logging(verbosity="info") + logging = ribasim.Logging(verbosity="debug") # Setup a model: model = ribasim.Model( diff --git a/python/ribasim_testmodels/ribasim_testmodels/trivial.py b/python/ribasim_testmodels/ribasim_testmodels/trivial.py index 5f66fdc8c..2d7dfb400 100644 --- a/python/ribasim_testmodels/ribasim_testmodels/trivial.py +++ b/python/ribasim_testmodels/ribasim_testmodels/trivial.py @@ -73,22 +73,22 @@ def trivial_model() -> ribasim.Model: } ) - # Create a level exporter from one basin to three elements. Scale one to one, but: + # Create a subgrid level interpolation from one basin to three elements. Scale one to one, but: # # 1. start at -1.0 # 2. start at 0.0 # 3. start at 1.0 # - exporter = pd.DataFrame( + subgrid = pd.DataFrame( data={ "name": "primary-system", - "element_id": [1, 1, 2, 2, 3, 3], + "subgrid_id": [1, 1, 2, 2, 3, 3], "node_id": [1, 1, 1, 1, 1, 1], "basin_level": [0.0, 1.0, 0.0, 1.0, 0.0, 1.0], - "level": [-1.0, 0.0, 0.0, 1.0, 1.0, 2.0], + "subgrid_level": [-1.0, 0.0, 0.0, 1.0, 1.0, 2.0], } ) - basin = ribasim.Basin(profile=profile, static=static, exporter=exporter) + basin = ribasim.Basin(profile=profile, static=static, subgrid=subgrid) # Set up a rating curve node: # Discharge: lose 1% of storage volume per day at storage = 1000.0. @@ -112,6 +112,8 @@ def trivial_model() -> ribasim.Model: ) ) + results = ribasim.Results(subgrid_levels="results/subgrid_levels.arrow") + model = ribasim.Model( network=ribasim.Network( node=node, @@ -122,5 +124,6 @@ def trivial_model() -> ribasim.Model: tabulated_rating_curve=rating_curve, starttime="2020-01-01 00:00:00", endtime="2021-01-01 00:00:00", + results=results, ) return model diff --git a/ribasim_qgis/core/nodes.py b/ribasim_qgis/core/nodes.py index 7d0e54355..875b8f330 100644 --- a/ribasim_qgis/core/nodes.py +++ b/ribasim_qgis/core/nodes.py @@ -336,15 +336,15 @@ class BasinTime(Input): ] -class BasinExporter(Input): - input_type = "Basin / exporter" +class BasinSubgrid(Input): + input_type = "Basin / subgrid" geometry_type = "No Geometry" attributes = [ QgsField("name", QVariant.String), - QgsField("element_id", QVariant.Int), + QgsField("subgrid_id", QVariant.Int), QgsField("node_id", QVariant.Int), QgsField("basin_level", QVariant.Double), - QgsField("level", QVariant.Double), + QgsField("subgrid_level", QVariant.Double), ] From 263d5a6c0d09c2333f2a737b8677a1f70dc49cfa Mon Sep 17 00:00:00 2001 From: Huite Bootsma Date: Mon, 20 Nov 2023 13:58:33 +0100 Subject: [PATCH 14/37] Run codegen --- docs/schema/BasinSubgrid.schema.json | 42 ++++++++++++++++++++++++++++ docs/schema/Config.schema.json | 11 +++++++- docs/schema/Results.schema.json | 12 ++++++++ docs/schema/basin.schema.json | 12 ++++++++ docs/schema/root.schema.json | 6 ++-- python/ribasim/ribasim/models.py | 20 ++++++------- 6 files changed, 89 insertions(+), 14 deletions(-) create mode 100644 docs/schema/BasinSubgrid.schema.json diff --git a/docs/schema/BasinSubgrid.schema.json b/docs/schema/BasinSubgrid.schema.json new file mode 100644 index 000000000..42dd0ac9d --- /dev/null +++ b/docs/schema/BasinSubgrid.schema.json @@ -0,0 +1,42 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://deltares.github.io/Ribasim/schema/BasinSubgrid.schema.json", + "title": "BasinSubgrid", + "description": "A BasinSubgrid object based on Ribasim.BasinSubgridV1", + "type": "object", + "properties": { + "name": { + "format": "default", + "type": "string" + }, + "subgrid_id": { + "format": "default", + "type": "integer" + }, + "node_id": { + "format": "default", + "type": "integer" + }, + "basin_level": { + "format": "double", + "type": "number" + }, + "subgrid_level": { + "format": "double", + "type": "number" + }, + "remarks": { + "description": "a hack for pandera", + "type": "string", + "format": "default", + "default": "" + } + }, + "required": [ + "name", + "subgrid_id", + "node_id", + "basin_level", + "subgrid_level" + ] +} diff --git a/docs/schema/Config.schema.json b/docs/schema/Config.schema.json index 342e4149a..dadea6996 100644 --- a/docs/schema/Config.schema.json +++ b/docs/schema/Config.schema.json @@ -73,6 +73,7 @@ "flow": "results/flow.arrow", "control": "results/control.arrow", "allocation": "results/allocation.arrow", + "subgrid_levels": null, "outstate": null, "compression": "zstd", "compression_level": 6 @@ -131,6 +132,7 @@ "profile": null, "state": null, "static": null, + "subgrid": null, "time": null } }, @@ -158,6 +160,12 @@ "default": { "static": null } + }, + "fractional_flow": { + "$ref": "https://deltares.github.io/Ribasim/schema/fractional_flow.schema.json", + "default": { + "static": null + } } }, "required": [ @@ -182,6 +190,7 @@ "manning_resistance", "discrete_control", "outlet", - "linear_resistance" + "linear_resistance", + "fractional_flow" ] } diff --git a/docs/schema/Results.schema.json b/docs/schema/Results.schema.json index 99f4ea9f9..c2ceb7dbf 100644 --- a/docs/schema/Results.schema.json +++ b/docs/schema/Results.schema.json @@ -25,6 +25,18 @@ "type": "string", "default": "results/allocation.arrow" }, + "subgrid_levels": { + "format": "default", + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ], + "default": null + }, "outstate": { "format": "default", "anyOf": [ diff --git a/docs/schema/basin.schema.json b/docs/schema/basin.schema.json index 743b5ca8b..ce36b64ec 100644 --- a/docs/schema/basin.schema.json +++ b/docs/schema/basin.schema.json @@ -41,6 +41,18 @@ ], "default": null }, + "subgrid": { + "format": "default", + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ], + "default": null + }, "time": { "format": "default", "anyOf": [ diff --git a/docs/schema/root.schema.json b/docs/schema/root.schema.json index c0d006864..e9089cc68 100644 --- a/docs/schema/root.schema.json +++ b/docs/schema/root.schema.json @@ -10,6 +10,9 @@ "basinstatic": { "$ref": "basinstatic.schema.json" }, + "basinsubgrid": { + "$ref": "basinsubgrid.schema.json" + }, "basintime": { "$ref": "basintime.schema.json" }, @@ -37,9 +40,6 @@ "levelboundarytime": { "$ref": "levelboundarytime.schema.json" }, - "basinexporter": { - "$ref": "basinexporter.schema.json" - }, "linearresistancestatic": { "$ref": "linearresistancestatic.schema.json" }, diff --git a/python/ribasim/ribasim/models.py b/python/ribasim/ribasim/models.py index 6758d5ea8..acdb00954 100644 --- a/python/ribasim/ribasim/models.py +++ b/python/ribasim/ribasim/models.py @@ -33,6 +33,15 @@ class BasinStatic(BaseModel): remarks: str = Field("", description="a hack for pandera") +class BasinSubgrid(BaseModel): + name: str + subgrid_id: int + node_id: int + basin_level: float + subgrid_level: float + remarks: str = Field("", description="a hack for pandera") + + class BasinTime(BaseModel): node_id: int time: datetime @@ -44,15 +53,6 @@ class BasinTime(BaseModel): remarks: str = Field("", description="a hack for pandera") -class BasinSubgrid(BaseModel): - name: str - subgrid_id: int - node_id: int - basin_level: float - subgrid_level: float - remarks: str = Field("", description="a hack for pandera") - - class DiscreteControlCondition(BaseModel): node_id: int listen_feature_id: int @@ -232,8 +232,8 @@ class Root(BaseModel): basinprofile: BasinProfile | None = None basinstate: BasinState | None = None basinstatic: BasinStatic | None = None - basintime: BasinTime | None = None basinsubgrid: BasinSubgrid | None = None + basintime: BasinTime | None = None discretecontrolcondition: DiscreteControlCondition | None = None discretecontrollogic: DiscreteControlLogic | None = None edge: Edge | None = None From 96a8d25e901d4aa088ddd63b3ad064d71b826a54 Mon Sep 17 00:00:00 2001 From: Huite Bootsma Date: Mon, 20 Nov 2023 14:39:45 +0100 Subject: [PATCH 15/37] Update docs: usage.qmd --- docs/core/usage.qmd | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/docs/core/usage.qmd b/docs/core/usage.qmd index 5e688fc30..136ea4d32 100644 --- a/docs/core/usage.qmd +++ b/docs/core/usage.qmd @@ -141,6 +141,7 @@ name it must have in the database if it is stored there. - `Basin / profile`: geometries of the basins - `Basin / time`: time series of the forcing values - `Basin / state`: used as initial condition of the basins + - `Basin / subgrid`: used to interpolate basin levels to a more detailed spatial representation - FractionalFlow: connect two of these from a Basin to get a fixed ratio bifurcation - `FractionalFlow / static`: fractions - LevelBoundary: stores water at a given level unaffected by flow, like an infinitely large basin @@ -275,6 +276,47 @@ Internally this get converted to two functions, $A(S)$ and $h(S)$, by integratin The minimum area cannot be zero to avoid numerical issues. The maximum area is used to convert the precipitation flux into an inflow. +## Basin / subgrid + +The subgrid table defines a piecewise linear interpolation from a basin water level to +a subgrid element water level. Many subgrid elements may be associated with a single +basin, each with distinct interpolation functions. This functionality can be used to +translate a single lumped basin level to a more spatially detailed representation (e.g +comparable to the output of a hydrodynamic simulation). + +column | type | unit | restriction +------------- | ------- | ----- | ------------------------ +name | String | - | +subgrid_id | Int | - | sorted +node_id | Int | - | constant per subgrid_id +basin_level | Float64 | $m$ | per subgrid_id: increasing +subgrid_level | Float64 | $m$ | per subgrid_id: increasing + +The name column is used to distinguish between spatially distinct water bodies that may +exist within a single basin (e.g. perennial versus ephemeral streams). When no +distinction is required, all rows should given the same name. + +name | subgrid_id | node_id | basin_level | subgrid_level +----- | ---------- | ------- | ----------- | ------------- +first | 1 | 9 | 0.0 | 0.0 +first | 1 | 9 | 1.0 | 1.0 +first | 1 | 9 | 2.0 | 2.0 +first | 2 | 9 | 0.0 | 0.5 +first | 2 | 9 | 1.0 | 1.5 +first | 2 | 9 | 2.0 | 2.5 + +The example shows the table for two subgrid elements. Both subgrid elements use the +water level of basin with `node_id` 9 to interpolate to their respective water levels. +The first element has a one to one connection with the water level; the second also +has a one to one connection, but is offset by half a meter. A basin water level of 0.3 +would be translated to a water level of 0.3 for the first subgrid element, and 0.8 for +the second. Water levels beyond the last `basin_level` are linearly extrapolated. + +Note that the interpolation to subgrid water level is not constrained by any water +balance within Ribasim. Generally, to create physically meaningful subgrid water levels, +the subgrid table must be parametrized properly such that the spatially integrated water +volume of the subgrid elements agrees with the total storage volume of the basin. + ## Basin results The basin table contains results of the storage and level of each basin at every solver From b3b376a2f4de144cd873b4ff794c673a76725a3f Mon Sep 17 00:00:00 2001 From: Huite Date: Tue, 21 Nov 2023 10:18:04 +0100 Subject: [PATCH 16/37] Update docs/core/usage.qmd Co-authored-by: Hofer-Julian <30049909+Hofer-Julian@users.noreply.github.com> --- docs/core/usage.qmd | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/core/usage.qmd b/docs/core/usage.qmd index 136ea4d32..2aeb15251 100644 --- a/docs/core/usage.qmd +++ b/docs/core/usage.qmd @@ -294,7 +294,7 @@ subgrid_level | Float64 | $m$ | per subgrid_id: increasing The name column is used to distinguish between spatially distinct water bodies that may exist within a single basin (e.g. perennial versus ephemeral streams). When no -distinction is required, all rows should given the same name. +distinction is required, all rows should be given the same name. name | subgrid_id | node_id | basin_level | subgrid_level ----- | ---------- | ------- | ----------- | ------------- From 33826520fdfa037e34893b546d0c5935b8e10e61 Mon Sep 17 00:00:00 2001 From: Huite Bootsma Date: Tue, 21 Nov 2023 10:56:49 +0100 Subject: [PATCH 17/37] Enable extrapolation on subgrid level interpolation --- core/src/create.jl | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/core/src/create.jl b/core/src/create.jl index b34cc338c..10af4d061 100644 --- a/core/src/create.jl +++ b/core/src/create.jl @@ -834,7 +834,8 @@ function SubgridExporter(tables, name, node_to_basin::Dict{Int, Int})::SubgridEx # Ensure it doesn't extrapolate before the first value. new_interp = LinearInterpolation( [subgrid_level[1], subgrid_level...], - [prevfloat(basin_level[1]), basin_level...], + [prevfloat(basin_level[1]), basin_level...]; + extrapolate = true, ) push!(basin_ids, node_to_basin[node_id]) push!(interpolations, new_interp) From 332aa57cb4896d96d9da10efb114905983c37d49 Mon Sep 17 00:00:00 2001 From: Huite Bootsma Date: Wed, 22 Nov 2023 14:46:49 +0100 Subject: [PATCH 18/37] subgrid levels not optional in expectation of PR 815 --- core/src/bmi.jl | 8 +++----- core/src/config.jl | 2 +- core/src/create.jl | 15 ++++++--------- python/ribasim/ribasim/config.py | 2 +- .../ribasim_testmodels/trivial.py | 4 +--- 5 files changed, 12 insertions(+), 19 deletions(-) diff --git a/core/src/bmi.jl b/core/src/bmi.jl index 3c1a529dd..44d485972 100644 --- a/core/src/bmi.jl +++ b/core/src/bmi.jl @@ -174,11 +174,9 @@ function BMI.finalize(model::Model)::Model write_arrow(path, table, compress) # exported levels - if !isnothing(results.subgrid_levels) - table = subgrid_levels_table(model) - path = results_path(config, results.subgrid_levels) - write_arrow(path, table, compress) - end + table = subgrid_levels_table(model) + path = results_path(config, results.subgrid_levels) + write_arrow(path, table, compress) @debug "Wrote results." return model diff --git a/core/src/config.jl b/core/src/config.jl index 094b2711f..212549dfb 100644 --- a/core/src/config.jl +++ b/core/src/config.jl @@ -113,7 +113,7 @@ end flow::String = "results/flow.arrow" control::String = "results/control.arrow" allocation::String = "results/allocation.arrow" - subgrid_levels::Union{String, Nothing} = nothing + subgrid_levels::String = "results/subgrid_levels.arrow" outstate::Union{String, Nothing} = nothing compression::Compression = "zstd" compression_level::Int = 6 diff --git a/core/src/create.jl b/core/src/create.jl index 10af4d061..b3d3252f8 100644 --- a/core/src/create.jl +++ b/core/src/create.jl @@ -860,15 +860,12 @@ function create_subgrid_exporters( basin::Basin, )::Dict{String, SubgridExporter} subgrid_exporters = Dict{String, SubgridExporter}() - if !isnothing(config.results.subgrid_levels) - node_to_basin = - Dict(node_id => index for (index, node_id) in enumerate(basin.node_id)) - tables = load_structvector(db, config, BasinSubgridV1) - if !isempty(tables) - for group in IterTools.groupby(row -> row.name, tables) - name = first(getproperty.(group, :name)) - subgrid_exporters[name] = SubgridExporter(group, name, node_to_basin) - end + node_to_basin = Dict(node_id => index for (index, node_id) in enumerate(basin.node_id)) + tables = load_structvector(db, config, BasinSubgridV1) + if !isempty(tables) + for group in IterTools.groupby(row -> row.name, tables) + name = first(getproperty.(group, :name)) + subgrid_exporters[name] = SubgridExporter(group, name, node_to_basin) end end return subgrid_exporters diff --git a/python/ribasim/ribasim/config.py b/python/ribasim/ribasim/config.py index 50f587340..e4471ae8c 100644 --- a/python/ribasim/ribasim/config.py +++ b/python/ribasim/ribasim/config.py @@ -48,7 +48,7 @@ class Results(BaseModel): basin: Path = Path("results/basin.arrow") flow: Path = Path("results/flow.arrow") control: Path = Path("results/control.arrow") - subgrid_levels: str | None = None + subgrid_levels: Path = Path("results/subgrid_levels.arrow") outstate: str | None = None compression: Compression = Compression.zstd compression_level: int = 6 diff --git a/python/ribasim_testmodels/ribasim_testmodels/trivial.py b/python/ribasim_testmodels/ribasim_testmodels/trivial.py index 2d7dfb400..030c650c9 100644 --- a/python/ribasim_testmodels/ribasim_testmodels/trivial.py +++ b/python/ribasim_testmodels/ribasim_testmodels/trivial.py @@ -112,8 +112,6 @@ def trivial_model() -> ribasim.Model: ) ) - results = ribasim.Results(subgrid_levels="results/subgrid_levels.arrow") - model = ribasim.Model( network=ribasim.Network( node=node, @@ -124,6 +122,6 @@ def trivial_model() -> ribasim.Model: tabulated_rating_curve=rating_curve, starttime="2020-01-01 00:00:00", endtime="2021-01-01 00:00:00", - results=results, + results=ribasim.Results(), ) return model From d5d21b4742673b7854acd1727335901e6df9120d Mon Sep 17 00:00:00 2001 From: Huite Bootsma Date: Wed, 22 Nov 2023 15:08:25 +0100 Subject: [PATCH 19/37] testset -> testitem for new test --- core/test/validation_test.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/test/validation_test.jl b/core/test/validation_test.jl index 2b59b6359..d7f1d4a5e 100644 --- a/core/test/validation_test.jl +++ b/core/test/validation_test.jl @@ -304,7 +304,7 @@ end "Invalid edge type 'bar' for edge #2 from node #2 to node #3." end -@testset "Level exporter validation" begin +@testitem "Level exporter validation" begin node_to_basin = Dict(9 => 1) errors = Ribasim.valid_subgrid_exporter(1, 10, node_to_basin, [-1.0, 0.0], [-1.0, 0.0]) @test length(errors) == 1 From 0dd96705a83876d9d7d605b9e6a9f14bf2bc7782 Mon Sep 17 00:00:00 2001 From: Martijn Visser Date: Thu, 23 Nov 2023 11:13:31 +0100 Subject: [PATCH 20/37] fixes --- core/src/lib.jl | 11 ++++--- docs/schema/BasinExporter.schema.json | 42 --------------------------- docs/schema/Config.schema.json | 2 +- docs/schema/Results.schema.json | 12 ++------ python/ribasim/ribasim/config.py | 2 +- 5 files changed, 10 insertions(+), 59 deletions(-) delete mode 100644 docs/schema/BasinExporter.schema.json diff --git a/core/src/lib.jl b/core/src/lib.jl index 170349f99..6446cd484 100644 --- a/core/src/lib.jl +++ b/core/src/lib.jl @@ -1,3 +1,8 @@ +struct SavedResults + flow::SavedValues{Float64, Vector{Float64}} + subgrid_levels::SavedValues{Float64, Vector{Float64}} +end + """ Model(config_path::AbstractString) Model(config::Config) @@ -8,12 +13,6 @@ The Model struct is an initialized model, combined with the [`Config`](@ref) use The Basic Model Interface ([BMI](https://github.com/Deltares/BasicModelInterface.jl)) is implemented on the Model. A Model can be created from the path to a TOML configuration file, or a Config object. """ - -struct SavedResults - flow::SavedValues{Float64, Vector{Float64}} - subgrid_levels::SavedValues{Float64, Vector{Float64}} -end - struct Model{T} integrator::T config::Config diff --git a/docs/schema/BasinExporter.schema.json b/docs/schema/BasinExporter.schema.json deleted file mode 100644 index 6df716697..000000000 --- a/docs/schema/BasinExporter.schema.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://deltares.github.io/Ribasim/schema/BasinSubgridExporter.schema.json", - "title": "BasinSubgrid", - "description": "A BasinSubgrid object based on Ribasim.BasinSubgridV1", - "type": "object", - "properties": { - "name": { - "format": "default", - "type": "string" - }, - "subgrid_id": { - "format": "default", - "type": "integer" - }, - "node_id": { - "format": "default", - "type": "integer" - }, - "basin_level": { - "format": "double", - "type": "number" - }, - "subgrid_level": { - "format": "double", - "type": "number" - }, - "remarks": { - "description": "a hack for pandera", - "type": "string", - "format": "default", - "default": "" - } - }, - "required": [ - "name", - "subgrid_id", - "node_id", - "basin_level", - "level" - ] -} diff --git a/docs/schema/Config.schema.json b/docs/schema/Config.schema.json index 6af515867..8f5e5245a 100644 --- a/docs/schema/Config.schema.json +++ b/docs/schema/Config.schema.json @@ -74,7 +74,7 @@ "flow": "results/flow.arrow", "control": "results/control.arrow", "allocation": "results/allocation.arrow", - "subgrid_levels": null, + "subgrid_levels": "results/subgrid_levels.arrow", "outstate": null, "compression": "zstd", "compression_level": 6 diff --git a/docs/schema/Results.schema.json b/docs/schema/Results.schema.json index c2ceb7dbf..349c1411f 100644 --- a/docs/schema/Results.schema.json +++ b/docs/schema/Results.schema.json @@ -27,15 +27,8 @@ }, "subgrid_levels": { "format": "default", - "anyOf": [ - { - "type": "null" - }, - { - "type": "string" - } - ], - "default": null + "type": "string", + "default": "results/subgrid_levels.arrow" }, "outstate": { "format": "default", @@ -65,6 +58,7 @@ "flow", "control", "allocation", + "subgrid_levels", "compression", "compression_level" ] diff --git a/python/ribasim/ribasim/config.py b/python/ribasim/ribasim/config.py index e4471ae8c..36ba4629d 100644 --- a/python/ribasim/ribasim/config.py +++ b/python/ribasim/ribasim/config.py @@ -171,7 +171,7 @@ class Basin(NodeModel): _sort_keys: dict[str, list[str]] = { "profile": ["node_id", "level"], "time": ["time", "node_id"], - "exporter": ["name", "subgrid_id", "node_id", "basin_level"], + "subgrid": ["name", "subgrid_id", "node_id", "basin_level"], } From cdb0ea86551496f220a5651d64a375ea9edcdc69 Mon Sep 17 00:00:00 2001 From: Martijn Visser Date: Thu, 23 Nov 2023 11:22:03 +0100 Subject: [PATCH 21/37] sentence per line --- docs/core/usage.qmd | 32 +++++++++++++------------------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/docs/core/usage.qmd b/docs/core/usage.qmd index 2aeb15251..b89fb9a52 100644 --- a/docs/core/usage.qmd +++ b/docs/core/usage.qmd @@ -278,11 +278,9 @@ The maximum area is used to convert the precipitation flux into an inflow. ## Basin / subgrid -The subgrid table defines a piecewise linear interpolation from a basin water level to -a subgrid element water level. Many subgrid elements may be associated with a single -basin, each with distinct interpolation functions. This functionality can be used to -translate a single lumped basin level to a more spatially detailed representation (e.g -comparable to the output of a hydrodynamic simulation). +The subgrid table defines a piecewise linear interpolation from a basin water level to a subgrid element water level. +Many subgrid elements may be associated with a single basin, each with distinct interpolation functions. +This functionality can be used to translate a single lumped basin level to a more spatially detailed representation (e.g comparable to the output of a hydrodynamic simulation). column | type | unit | restriction ------------- | ------- | ----- | ------------------------ @@ -292,9 +290,8 @@ node_id | Int | - | constant per subgrid_id basin_level | Float64 | $m$ | per subgrid_id: increasing subgrid_level | Float64 | $m$ | per subgrid_id: increasing -The name column is used to distinguish between spatially distinct water bodies that may -exist within a single basin (e.g. perennial versus ephemeral streams). When no -distinction is required, all rows should be given the same name. +The name column is used to distinguish between spatially distinct water bodies that may exist within a single basin (e.g. perennial versus ephemeral streams). +When no distinction is required, all rows should be given the same name. name | subgrid_id | node_id | basin_level | subgrid_level ----- | ---------- | ------- | ----------- | ------------- @@ -305,17 +302,14 @@ first | 2 | 9 | 0.0 | 0.5 first | 2 | 9 | 1.0 | 1.5 first | 2 | 9 | 2.0 | 2.5 -The example shows the table for two subgrid elements. Both subgrid elements use the -water level of basin with `node_id` 9 to interpolate to their respective water levels. -The first element has a one to one connection with the water level; the second also -has a one to one connection, but is offset by half a meter. A basin water level of 0.3 -would be translated to a water level of 0.3 for the first subgrid element, and 0.8 for -the second. Water levels beyond the last `basin_level` are linearly extrapolated. - -Note that the interpolation to subgrid water level is not constrained by any water -balance within Ribasim. Generally, to create physically meaningful subgrid water levels, -the subgrid table must be parametrized properly such that the spatially integrated water -volume of the subgrid elements agrees with the total storage volume of the basin. +The example shows the table for two subgrid elements. +Both subgrid elements use the water level of basin with `node_id` 9 to interpolate to their respective water levels. +The first element has a one to one connection with the water level; the second also has a one to one connection, but is offset by half a meter. +A basin water level of 0.3 would be translated to a water level of 0.3 for the first subgrid element, and 0.8 for the second. +Water levels beyond the last `basin_level` are linearly extrapolated. + +Note that the interpolation to subgrid water level is not constrained by any water balance within Ribasim. +Generally, to create physically meaningful subgrid water levels, the subgrid table must be parametrized properly such that the spatially integrated water volume of the subgrid elements agrees with the total storage volume of the basin. ## Basin results From 50e5246ce0a3682314e72bd006f1afa8789d7f16 Mon Sep 17 00:00:00 2001 From: Martijn Visser Date: Thu, 23 Nov 2023 11:32:39 +0100 Subject: [PATCH 22/37] Clarify name --- docs/core/usage.qmd | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/core/usage.qmd b/docs/core/usage.qmd index b89fb9a52..6fdbd9c36 100644 --- a/docs/core/usage.qmd +++ b/docs/core/usage.qmd @@ -284,7 +284,7 @@ This functionality can be used to translate a single lumped basin level to a mor column | type | unit | restriction ------------- | ------- | ----- | ------------------------ -name | String | - | +name | String | - | (optional, does not have to be unique) subgrid_id | Int | - | sorted node_id | Int | - | constant per subgrid_id basin_level | Float64 | $m$ | per subgrid_id: increasing @@ -293,17 +293,17 @@ subgrid_level | Float64 | $m$ | per subgrid_id: increasing The name column is used to distinguish between spatially distinct water bodies that may exist within a single basin (e.g. perennial versus ephemeral streams). When no distinction is required, all rows should be given the same name. -name | subgrid_id | node_id | basin_level | subgrid_level ------ | ---------- | ------- | ----------- | ------------- -first | 1 | 9 | 0.0 | 0.0 -first | 1 | 9 | 1.0 | 1.0 -first | 1 | 9 | 2.0 | 2.0 -first | 2 | 9 | 0.0 | 0.5 -first | 2 | 9 | 1.0 | 1.5 -first | 2 | 9 | 2.0 | 2.5 +name | subgrid_id | node_id | basin_level | subgrid_level +-------- | ---------- | ------- | ----------- | ------------- +"first" | 1 | 9 | 0.0 | 0.0 +"first" | 1 | 9 | 1.0 | 1.0 +"first" | 1 | 9 | 2.0 | 2.0 +"second" | 2 | 9 | 0.0 | 0.5 +"second" | 2 | 9 | 1.0 | 1.5 +"second" | 2 | 9 | 2.0 | 2.5 The example shows the table for two subgrid elements. -Both subgrid elements use the water level of basin with `node_id` 9 to interpolate to their respective water levels. +Both subgrid elements use the water level of the basin with `node_id` 9 to interpolate to their respective water levels. The first element has a one to one connection with the water level; the second also has a one to one connection, but is offset by half a meter. A basin water level of 0.3 would be translated to a water level of 0.3 for the first subgrid element, and 0.8 for the second. Water levels beyond the last `basin_level` are linearly extrapolated. From f9a5980d18de03918be408011995b340405fefc3 Mon Sep 17 00:00:00 2001 From: Martijn Visser Date: Thu, 23 Nov 2023 13:24:15 +0100 Subject: [PATCH 23/37] rename to Basin / subgrid_level --- core/src/bmi.jl | 22 ++++++++----------- core/src/config.jl | 2 +- core/src/create.jl | 4 ++-- core/src/io.jl | 4 ++-- core/src/lib.jl | 2 +- core/src/validation.jl | 20 +++++++++-------- core/test/validation_test.jl | 6 ++--- docs/core/usage.qmd | 6 ++--- ...ema.json => BasinSubgridLevel.schema.json} | 6 ++--- docs/schema/Config.schema.json | 4 ++-- docs/schema/Results.schema.json | 6 ++--- docs/schema/basin.schema.json | 2 +- docs/schema/root.schema.json | 4 ++-- python/ribasim/ribasim/config.py | 10 ++++----- python/ribasim/ribasim/models.py | 4 ++-- .../ribasim_testmodels/trivial.py | 4 ++-- ribasim_qgis/core/nodes.py | 4 ++-- 17 files changed, 54 insertions(+), 56 deletions(-) rename docs/schema/{BasinSubgrid.schema.json => BasinSubgridLevel.schema.json} (87%) diff --git a/core/src/bmi.jl b/core/src/bmi.jl index 44d485972..1c26eb907 100644 --- a/core/src/bmi.jl +++ b/core/src/bmi.jl @@ -174,8 +174,8 @@ function BMI.finalize(model::Model)::Model write_arrow(path, table, compress) # exported levels - table = subgrid_levels_table(model) - path = results_path(config, results.subgrid_levels) + table = subgrid_level_table(model) + path = results_path(config, results.subgrid_level) write_arrow(path, table, compress) @debug "Wrote results." @@ -239,16 +239,12 @@ function create_callbacks( push!(callbacks, save_flow_cb) # interpolate the levels - saved_subgrid_levels = SavedValues(Float64, Vector{Float64}) - export_cb = SavingCallback( - save_subgrid_levels, - saved_subgrid_levels; - saveat, - save_start = false, - ) + saved_subgrid_level = SavedValues(Float64, Vector{Float64}) + export_cb = + SavingCallback(save_subgrid_level, saved_subgrid_level; saveat, save_start = false) push!(callbacks, export_cb) - saved = SavedResults(saved_flow, saved_subgrid_levels) + saved = SavedResults(saved_flow, saved_subgrid_level) n_conditions = length(discrete_control.node_id) if n_conditions > 0 @@ -498,7 +494,7 @@ function save_flow(u, t, integrator) copy(nonzeros(get_tmp(integrator.p.connectivity.flow, u))) end -function update_subgrid_levels!(integrator)::Nothing +function update_subgrid_level!(integrator)::Nothing parameters = integrator.p basin_level = get_tmp(parameters.basin.current_level, 0) @@ -511,8 +507,8 @@ function update_subgrid_levels!(integrator)::Nothing end """Interpolate the levels and save them to SavedValues""" -function save_subgrid_levels(u, t, integrator) - update_subgrid_levels!(integrator) +function save_subgrid_level(u, t, integrator) + update_subgrid_level!(integrator) return vcat( [exporter.subgrid_level for exporter in values(integrator.p.subgrid_exporters)]..., ) diff --git a/core/src/config.jl b/core/src/config.jl index 212549dfb..2d6c51705 100644 --- a/core/src/config.jl +++ b/core/src/config.jl @@ -113,7 +113,7 @@ end flow::String = "results/flow.arrow" control::String = "results/control.arrow" allocation::String = "results/allocation.arrow" - subgrid_levels::String = "results/subgrid_levels.arrow" + subgrid_level::String = "results/subgrid_level.arrow" outstate::Union{String, Nothing} = nothing compression::Compression = "zstd" compression_level::Int = 6 diff --git a/core/src/create.jl b/core/src/create.jl index b3d3252f8..b1f5425f3 100644 --- a/core/src/create.jl +++ b/core/src/create.jl @@ -849,7 +849,7 @@ function SubgridExporter(tables, name, node_to_basin::Dict{Int, Int})::SubgridEx else foreach(x -> @error(x), errors) error( - "Errors occurred while parsing BasinSubgrid data for group with name: $(name).", + "Errors occurred while parsing Basin / subgrid_level data for group with name: $(name).", ) end end @@ -861,7 +861,7 @@ function create_subgrid_exporters( )::Dict{String, SubgridExporter} subgrid_exporters = Dict{String, SubgridExporter}() node_to_basin = Dict(node_id => index for (index, node_id) in enumerate(basin.node_id)) - tables = load_structvector(db, config, BasinSubgridV1) + tables = load_structvector(db, config, BasinSubgridLevelV1) if !isempty(tables) for group in IterTools.groupby(row -> row.name, tables) name = first(getproperty.(group, :name)) diff --git a/core/src/io.jl b/core/src/io.jl index dc44f67f6..21f53814b 100644 --- a/core/src/io.jl +++ b/core/src/io.jl @@ -227,9 +227,9 @@ function allocation_table(model::Model)::NamedTuple ) end -function subgrid_levels_table(model::Model)::NamedTuple +function subgrid_level_table(model::Model)::NamedTuple (; config, saved, integrator) = model - (; t, saveval) = saved.subgrid_levels + (; t, saveval) = saved.subgrid_level # The level exporter may contain multiple named systems, but the # saved levels are flat. diff --git a/core/src/lib.jl b/core/src/lib.jl index 6446cd484..6d29a5276 100644 --- a/core/src/lib.jl +++ b/core/src/lib.jl @@ -1,6 +1,6 @@ struct SavedResults flow::SavedValues{Float64, Vector{Float64}} - subgrid_levels::SavedValues{Float64, Vector{Float64}} + subgrid_level::SavedValues{Float64, Vector{Float64}} end """ diff --git a/core/src/validation.jl b/core/src/validation.jl index c66fcf920..0aee97b87 100644 --- a/core/src/validation.jl +++ b/core/src/validation.jl @@ -1,5 +1,7 @@ # These schemas define the name of database tables and the configuration file structure # The identifier is parsed as ribasim.nodetype.kind, no capitals or underscores are allowed. +# If the kind consists of multiple words, these can also be split with extra dots, +# such that from the "subgrid.level" schema we get "subgrid_level". @schema "ribasim.node" Node @schema "ribasim.edge" Edge @schema "ribasim.discretecontrol.condition" DiscreteControlCondition @@ -8,7 +10,7 @@ @schema "ribasim.basin.time" BasinTime @schema "ribasim.basin.profile" BasinProfile @schema "ribasim.basin.state" BasinState -@schema "ribasim.basin.subgrid" BasinSubgrid +@schema "ribasim.basin.subgrid.level" BasinSubgridLevel @schema "ribasim.terminal.static" TerminalStatic @schema "ribasim.fractionalflow.static" FractionalFlowStatic @schema "ribasim.flowboundary.static" FlowBoundaryStatic @@ -31,7 +33,7 @@ tablename(sv::Type{SchemaVersion{T, N}}) where {T, N} = tablename(sv()) tablename(sv::SchemaVersion{T, N}) where {T, N} = join(filter(!isnothing, nodetype(sv)), delimiter) isnode(sv::Type{SchemaVersion{T, N}}) where {T, N} = isnode(sv()) -isnode(::SchemaVersion{T, N}) where {T, N} = length(split(string(T), ".")) == 3 +isnode(::SchemaVersion{T, N}) where {T, N} = length(split(string(T), '.'; limit = 3)) == 3 nodetype(sv::Type{SchemaVersion{T, N}}) where {T, N} = nodetype(sv()) """ @@ -44,9 +46,9 @@ function nodetype( # so we parse the related record Ribasim.BasinTimeV1 # to derive BasinTime from it. record = Legolas.record_type(sv) - node = last(split(string(Symbol(record)), ".")) + node = last(split(string(Symbol(record)), '.'; limit = 3)) - elements = split(string(T), ".") + elements = split(string(T), '.'; limit = 3) if isnode(sv) n = elements[2] k = Symbol(elements[3]) @@ -203,7 +205,7 @@ end level::Float64 end -@version BasinSubgridV1 begin +@version BasinSubgridLevelV1 begin name::String subgrid_id::Int node_id::Int @@ -381,7 +383,7 @@ sort_by_function(table::StructVector{TabulatedRatingCurveStaticV1}) = sort_by_id sort_by_function(table::StructVector{BasinProfileV1}) = sort_by_id_level sort_by_function(table::StructVector{UserStaticV1}) = sort_by_priority sort_by_function(table::StructVector{UserTimeV1}) = sort_by_priority_time -sort_by_function(table::StructVector{BasinSubgridV1}) = sort_by_exporter +sort_by_function(table::StructVector{BasinSubgridLevelV1}) = sort_by_exporter const TimeSchemas = Union{ BasinTimeV1, @@ -638,21 +640,21 @@ function valid_subgrid_exporter( if !(node_id in keys(node_to_basin)) push!( errors, - "The node_id of the BasinSubgrid does not refer to a basin: node_id $(node_id) for subgrid_id $(subgrid_id).", + "The node_id of the Basin / subgrid_level does not refer to a basin: node_id $(node_id) for subgrid_id $(subgrid_id).", ) end if !allunique(basin_level) push!( errors, - "BasinSubgrid subgrid_id $(subgrid_id) has repeated basin levels, this cannot be interpolated.", + "Basin / subgrid_level subgrid_id $(subgrid_id) has repeated basin levels, this cannot be interpolated.", ) end if !allunique(subgrid_level) push!( errors, - "BasinSubgrid subgrid_id $(subgrid_id) has repeated element levels, this cannot be interpolated.", + "Basin / subgrid_level subgrid_id $(subgrid_id) has repeated element levels, this cannot be interpolated.", ) end diff --git a/core/test/validation_test.jl b/core/test/validation_test.jl index d7f1d4a5e..41f769b69 100644 --- a/core/test/validation_test.jl +++ b/core/test/validation_test.jl @@ -309,7 +309,7 @@ end errors = Ribasim.valid_subgrid_exporter(1, 10, node_to_basin, [-1.0, 0.0], [-1.0, 0.0]) @test length(errors) == 1 @test errors[1] == - "The node_id of the BasinSubgrid does not refer to a basin: node_id 10 for subgrid_id 1." + "The node_id of the Basin / subgrid_level does not refer to a basin: node_id 10 for subgrid_id 1." errors = Ribasim.valid_subgrid_exporter( 1, @@ -320,7 +320,7 @@ end ) @test length(errors) == 2 @test errors[1] == - "BasinSubgrid subgrid_id 1 has repeated basin levels, this cannot be interpolated." + "Basin / subgrid_level subgrid_id 1 has repeated basin levels, this cannot be interpolated." @test errors[2] == - "BasinSubgrid subgrid_id 1 has repeated element levels, this cannot be interpolated." + "Basin / subgrid_level subgrid_id 1 has repeated element levels, this cannot be interpolated." end diff --git a/docs/core/usage.qmd b/docs/core/usage.qmd index 6fdbd9c36..100453f9f 100644 --- a/docs/core/usage.qmd +++ b/docs/core/usage.qmd @@ -141,7 +141,7 @@ name it must have in the database if it is stored there. - `Basin / profile`: geometries of the basins - `Basin / time`: time series of the forcing values - `Basin / state`: used as initial condition of the basins - - `Basin / subgrid`: used to interpolate basin levels to a more detailed spatial representation + - `Basin / subgrid_level`: used to interpolate basin levels to a more detailed spatial representation - FractionalFlow: connect two of these from a Basin to get a fixed ratio bifurcation - `FractionalFlow / static`: fractions - LevelBoundary: stores water at a given level unaffected by flow, like an infinitely large basin @@ -276,9 +276,9 @@ Internally this get converted to two functions, $A(S)$ and $h(S)$, by integratin The minimum area cannot be zero to avoid numerical issues. The maximum area is used to convert the precipitation flux into an inflow. -## Basin / subgrid +## Basin / subgrid_level -The subgrid table defines a piecewise linear interpolation from a basin water level to a subgrid element water level. +The subgrid_level table defines a piecewise linear interpolation from a basin water level to a subgrid element water level. Many subgrid elements may be associated with a single basin, each with distinct interpolation functions. This functionality can be used to translate a single lumped basin level to a more spatially detailed representation (e.g comparable to the output of a hydrodynamic simulation). diff --git a/docs/schema/BasinSubgrid.schema.json b/docs/schema/BasinSubgridLevel.schema.json similarity index 87% rename from docs/schema/BasinSubgrid.schema.json rename to docs/schema/BasinSubgridLevel.schema.json index 42dd0ac9d..1360c677f 100644 --- a/docs/schema/BasinSubgrid.schema.json +++ b/docs/schema/BasinSubgridLevel.schema.json @@ -1,8 +1,8 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://deltares.github.io/Ribasim/schema/BasinSubgrid.schema.json", - "title": "BasinSubgrid", - "description": "A BasinSubgrid object based on Ribasim.BasinSubgridV1", + "$id": "https://deltares.github.io/Ribasim/schema/BasinSubgridLevel.schema.json", + "title": "BasinSubgridLevel", + "description": "A BasinSubgridLevel object based on Ribasim.BasinSubgridLevelV1", "type": "object", "properties": { "name": { diff --git a/docs/schema/Config.schema.json b/docs/schema/Config.schema.json index 8f5e5245a..5858aba4d 100644 --- a/docs/schema/Config.schema.json +++ b/docs/schema/Config.schema.json @@ -74,7 +74,7 @@ "flow": "results/flow.arrow", "control": "results/control.arrow", "allocation": "results/allocation.arrow", - "subgrid_levels": "results/subgrid_levels.arrow", + "subgrid_level": "results/subgrid_level.arrow", "outstate": null, "compression": "zstd", "compression_level": 6 @@ -133,7 +133,7 @@ "profile": null, "state": null, "static": null, - "subgrid": null, + "subgrid.level": null, "time": null } }, diff --git a/docs/schema/Results.schema.json b/docs/schema/Results.schema.json index 349c1411f..3c58d600b 100644 --- a/docs/schema/Results.schema.json +++ b/docs/schema/Results.schema.json @@ -25,10 +25,10 @@ "type": "string", "default": "results/allocation.arrow" }, - "subgrid_levels": { + "subgrid_level": { "format": "default", "type": "string", - "default": "results/subgrid_levels.arrow" + "default": "results/subgrid_level.arrow" }, "outstate": { "format": "default", @@ -58,7 +58,7 @@ "flow", "control", "allocation", - "subgrid_levels", + "subgrid_level", "compression", "compression_level" ] diff --git a/docs/schema/basin.schema.json b/docs/schema/basin.schema.json index ce36b64ec..4ae482343 100644 --- a/docs/schema/basin.schema.json +++ b/docs/schema/basin.schema.json @@ -41,7 +41,7 @@ ], "default": null }, - "subgrid": { + "subgrid.level": { "format": "default", "anyOf": [ { diff --git a/docs/schema/root.schema.json b/docs/schema/root.schema.json index e9089cc68..a2ca1d63e 100644 --- a/docs/schema/root.schema.json +++ b/docs/schema/root.schema.json @@ -10,8 +10,8 @@ "basinstatic": { "$ref": "basinstatic.schema.json" }, - "basinsubgrid": { - "$ref": "basinsubgrid.schema.json" + "basinsubgridlevel": { + "$ref": "basinsubgridlevel.schema.json" }, "basintime": { "$ref": "basintime.schema.json" diff --git a/python/ribasim/ribasim/config.py b/python/ribasim/ribasim/config.py index 36ba4629d..e179d6494 100644 --- a/python/ribasim/ribasim/config.py +++ b/python/ribasim/ribasim/config.py @@ -10,7 +10,7 @@ BasinProfileSchema, BasinStateSchema, BasinStaticSchema, - BasinSubgridSchema, + BasinSubgridLevelSchema, BasinTimeSchema, DiscreteControlConditionSchema, DiscreteControlLogicSchema, @@ -48,7 +48,7 @@ class Results(BaseModel): basin: Path = Path("results/basin.arrow") flow: Path = Path("results/flow.arrow") control: Path = Path("results/control.arrow") - subgrid_levels: Path = Path("results/subgrid_levels.arrow") + subgrid_level: Path = Path("results/subgrid_level.arrow") outstate: str | None = None compression: Compression = Compression.zstd compression_level: int = 6 @@ -164,14 +164,14 @@ class Basin(NodeModel): time: TableModel[BasinTimeSchema] = Field( default_factory=TableModel[BasinTimeSchema] ) - subgrid: TableModel[BasinSubgridSchema] = Field( - default_factory=TableModel[BasinSubgridSchema] + subgrid_level: TableModel[BasinSubgridLevelSchema] = Field( + default_factory=TableModel[BasinSubgridLevelSchema] ) _sort_keys: dict[str, list[str]] = { "profile": ["node_id", "level"], "time": ["time", "node_id"], - "subgrid": ["name", "subgrid_id", "node_id", "basin_level"], + "subgrid_level": ["name", "subgrid_id", "node_id", "basin_level"], } diff --git a/python/ribasim/ribasim/models.py b/python/ribasim/ribasim/models.py index acdb00954..754665166 100644 --- a/python/ribasim/ribasim/models.py +++ b/python/ribasim/ribasim/models.py @@ -33,7 +33,7 @@ class BasinStatic(BaseModel): remarks: str = Field("", description="a hack for pandera") -class BasinSubgrid(BaseModel): +class BasinSubgridLevel(BaseModel): name: str subgrid_id: int node_id: int @@ -232,7 +232,7 @@ class Root(BaseModel): basinprofile: BasinProfile | None = None basinstate: BasinState | None = None basinstatic: BasinStatic | None = None - basinsubgrid: BasinSubgrid | None = None + basinsubgridlevel: BasinSubgridLevel | None = None basintime: BasinTime | None = None discretecontrolcondition: DiscreteControlCondition | None = None discretecontrollogic: DiscreteControlLogic | None = None diff --git a/python/ribasim_testmodels/ribasim_testmodels/trivial.py b/python/ribasim_testmodels/ribasim_testmodels/trivial.py index 030c650c9..85c23e85e 100644 --- a/python/ribasim_testmodels/ribasim_testmodels/trivial.py +++ b/python/ribasim_testmodels/ribasim_testmodels/trivial.py @@ -79,7 +79,7 @@ def trivial_model() -> ribasim.Model: # 2. start at 0.0 # 3. start at 1.0 # - subgrid = pd.DataFrame( + subgrid_level = pd.DataFrame( data={ "name": "primary-system", "subgrid_id": [1, 1, 2, 2, 3, 3], @@ -88,7 +88,7 @@ def trivial_model() -> ribasim.Model: "subgrid_level": [-1.0, 0.0, 0.0, 1.0, 1.0, 2.0], } ) - basin = ribasim.Basin(profile=profile, static=static, subgrid=subgrid) + basin = ribasim.Basin(profile=profile, static=static, subgrid_level=subgrid_level) # Set up a rating curve node: # Discharge: lose 1% of storage volume per day at storage = 1000.0. diff --git a/ribasim_qgis/core/nodes.py b/ribasim_qgis/core/nodes.py index 875b8f330..1808f7770 100644 --- a/ribasim_qgis/core/nodes.py +++ b/ribasim_qgis/core/nodes.py @@ -336,8 +336,8 @@ class BasinTime(Input): ] -class BasinSubgrid(Input): - input_type = "Basin / subgrid" +class BasinSubgridLevel(Input): + input_type = "Basin / subgrid_level" geometry_type = "No Geometry" attributes = [ QgsField("name", QVariant.String), From be499336ecb764db09fdba6a78c8627098ad1cf4 Mon Sep 17 00:00:00 2001 From: Martijn Visser Date: Thu, 23 Nov 2023 15:24:49 +0100 Subject: [PATCH 24/37] no need for isempty --- core/src/bmi.jl | 2 +- core/src/create.jl | 8 +++----- core/src/solve.jl | 4 +--- 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/core/src/bmi.jl b/core/src/bmi.jl index 1c26eb907..a295f0793 100644 --- a/core/src/bmi.jl +++ b/core/src/bmi.jl @@ -506,7 +506,7 @@ function update_subgrid_level!(integrator)::Nothing end end -"""Interpolate the levels and save them to SavedValues""" +"Interpolate the levels and save them to SavedValues" function save_subgrid_level(u, t, integrator) update_subgrid_level!(integrator) return vcat( diff --git a/core/src/create.jl b/core/src/create.jl index b1f5425f3..e802964db 100644 --- a/core/src/create.jl +++ b/core/src/create.jl @@ -862,11 +862,9 @@ function create_subgrid_exporters( subgrid_exporters = Dict{String, SubgridExporter}() node_to_basin = Dict(node_id => index for (index, node_id) in enumerate(basin.node_id)) tables = load_structvector(db, config, BasinSubgridLevelV1) - if !isempty(tables) - for group in IterTools.groupby(row -> row.name, tables) - name = first(getproperty.(group, :name)) - subgrid_exporters[name] = SubgridExporter(group, name, node_to_basin) - end + for group in IterTools.groupby(row -> row.name, tables) + name = first(getproperty.(group, :name)) + subgrid_exporters[name] = SubgridExporter(group, name, node_to_basin) end return subgrid_exporters end diff --git a/core/src/solve.jl b/core/src/solve.jl index 0cbb6c6b1..c039a7bcc 100644 --- a/core/src/solve.jl +++ b/core/src/solve.jl @@ -467,9 +467,7 @@ struct User <: AbstractParameterNode } end -""" -SubgridExporter linearly interpolates basin levels. -""" +"SubgridExporter linearly interpolates basin levels." struct SubgridExporter basin_index::Vector{Int} interpolations::Vector{ScalarInterpolation} From 547cfb0ca9bb9612cf01e178ca686a8648d39173 Mon Sep 17 00:00:00 2001 From: Huite Bootsma Date: Fri, 24 Nov 2023 10:25:12 +0100 Subject: [PATCH 25/37] Expose subgrid_levels via BMI --- core/src/bmi.jl | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/core/src/bmi.jl b/core/src/bmi.jl index a295f0793..2341610f3 100644 --- a/core/src/bmi.jl +++ b/core/src/bmi.jl @@ -587,16 +587,25 @@ function BMI.update_until(model::Model, time)::Model end function BMI.get_value_ptr(model::Model, name::AbstractString) - if name == "volume" - model.integrator.u.storage - elseif name == "level" - get_tmp(model.integrator.p.basin.current_level, 0) - elseif name == "infiltration" - model.integrator.p.basin.infiltration - elseif name == "drainage" - model.integrator.p.basin.drainage + if occursin("/", name) + variable, part = split(name, "/") + if variable == "subgrid_level" + model.integrator.p.subgrid_exporters[part] + else + error("Unknown variable $variable in $name") + end else - error("Unknown variable $name") + if name == "volume" + model.integrator.u.storage + elseif name == "level" + get_tmp(model.integrator.p.basin.current_level, 0) + elseif name == "infiltration" + model.integrator.p.basin.infiltration + elseif name == "drainage" + model.integrator.p.basin.drainage + else + error("Unknown variable $name") + end end end From 36bdd2e1489ea3bf6b06598256faaacc89250c68 Mon Sep 17 00:00:00 2001 From: Huite Bootsma Date: Fri, 24 Nov 2023 12:12:55 +0100 Subject: [PATCH 26/37] Simplify & make naming consistent Name struct Subgrid Becomes Basin / subgrid in Geopackage Remove name column from subgrid table Remove nested subgrids (unique subgrid_ids are sufficient) --- core/src/bmi.jl | 57 +++++++------------ core/src/create.jl | 37 +++--------- core/src/io.jl | 21 +++---- core/src/solve.jl | 8 +-- core/src/validation.jl | 11 ++-- core/test/run_models_test.jl | 4 +- core/test/validation_test.jl | 12 +--- docs/core/usage.qmd | 25 ++++---- ...l.schema.json => BasinSubgrid.schema.json} | 11 +--- docs/schema/Config.schema.json | 2 +- docs/schema/basin.schema.json | 2 +- docs/schema/root.schema.json | 4 +- python/ribasim/ribasim/config.py | 8 +-- python/ribasim/ribasim/models.py | 5 +- .../ribasim_testmodels/trivial.py | 5 +- ribasim_qgis/core/nodes.py | 3 +- 16 files changed, 79 insertions(+), 136 deletions(-) rename docs/schema/{BasinSubgridLevel.schema.json => BasinSubgrid.schema.json} (77%) diff --git a/core/src/bmi.jl b/core/src/bmi.jl index 2341610f3..efd477f78 100644 --- a/core/src/bmi.jl +++ b/core/src/bmi.jl @@ -495,23 +495,17 @@ function save_flow(u, t, integrator) end function update_subgrid_level!(integrator)::Nothing - parameters = integrator.p - basin_level = get_tmp(parameters.basin.current_level, 0) - - for exporter in values(parameters.subgrid_exporters) - for (i, (index, interp)) in - enumerate(zip(exporter.basin_index, exporter.interpolations)) - exporter.subgrid_level[i] = interp(basin_level[index]) - end + basin_level = get_tmp(integrator.p.basin.current_level, 0) + subgrid = integrator.p.subgrid + for (i, (index, interp)) in enumerate(zip(subgrid.basin_index, subgrid.interpolations)) + subgrid.level[i] = interp(basin_level[index]) end end "Interpolate the levels and save them to SavedValues" function save_subgrid_level(u, t, integrator) update_subgrid_level!(integrator) - return vcat( - [exporter.subgrid_level for exporter in values(integrator.p.subgrid_exporters)]..., - ) + return integrator.p.subgrid.level end "Load updates from 'Basin / time' into the parameters" @@ -587,25 +581,18 @@ function BMI.update_until(model::Model, time)::Model end function BMI.get_value_ptr(model::Model, name::AbstractString) - if occursin("/", name) - variable, part = split(name, "/") - if variable == "subgrid_level" - model.integrator.p.subgrid_exporters[part] - else - error("Unknown variable $variable in $name") - end + if name == "volume" + model.integrator.u.storage + elseif name == "level" + get_tmp(model.integrator.p.basin.current_level, 0) + elseif name == "infiltration" + model.integrator.p.basin.infiltration + elseif name == "drainage" + model.integrator.p.basin.drainage + elseif name == "subgrid_level" + model.integrator.p.subgrid.level else - if name == "volume" - model.integrator.u.storage - elseif name == "level" - get_tmp(model.integrator.p.basin.current_level, 0) - elseif name == "infiltration" - model.integrator.p.basin.infiltration - elseif name == "drainage" - model.integrator.p.basin.drainage - else - error("Unknown variable $name") - end + error("Unknown variable $name") end end @@ -642,10 +629,10 @@ function run(config::Config)::Model ) end - with_logger(logger) do - model = Model(config) - solve!(model) - BMI.finalize(model) - return model - end + # with_logger(logger) do + model = Model(config) + solve!(model) + BMI.finalize(model) + return model + # end end diff --git a/core/src/create.jl b/core/src/create.jl index e802964db..9edf1e176 100644 --- a/core/src/create.jl +++ b/core/src/create.jl @@ -811,10 +811,12 @@ function User(db::DB, config::Config)::User ) end -function SubgridExporter(tables, name, node_to_basin::Dict{Int, Int})::SubgridExporter +function Subgrid(db::DB, config::Config, basin::Basin)::Subgrid + node_to_basin = Dict(node_id => index for (index, node_id) in enumerate(basin.node_id)) + tables = load_structvector(db, config, BasinSubgridV1) + basin_ids = Int[] interpolations = ScalarInterpolation[] - errors = String[] for group in IterTools.groupby(row -> row.subgrid_id, tables) subgrid_id = first(getproperty.(group, :subgrid_id)) @@ -822,13 +824,8 @@ function SubgridExporter(tables, name, node_to_basin::Dict{Int, Int})::SubgridEx basin_level = getproperty.(group, :basin_level) subgrid_level = getproperty.(group, :subgrid_level) - group_errors = valid_subgrid_exporter( - subgrid_id, - node_id, - node_to_basin, - basin_level, - subgrid_level, - ) + group_errors = + valid_subgrid(subgrid_id, node_id, node_to_basin, basin_level, subgrid_level) if isempty(group_errors) # Ensure it doesn't extrapolate before the first value. @@ -845,7 +842,7 @@ function SubgridExporter(tables, name, node_to_basin::Dict{Int, Int})::SubgridEx end if isempty(errors) - return SubgridExporter(basin_ids, interpolations, fill(NaN, length(basin_ids))) + return Subgrid(basin_ids, interpolations, fill(NaN, length(basin_ids))) else foreach(x -> @error(x), errors) error( @@ -854,21 +851,6 @@ function SubgridExporter(tables, name, node_to_basin::Dict{Int, Int})::SubgridEx end end -function create_subgrid_exporters( - db::DB, - config::Config, - basin::Basin, -)::Dict{String, SubgridExporter} - subgrid_exporters = Dict{String, SubgridExporter}() - node_to_basin = Dict(node_id => index for (index, node_id) in enumerate(basin.node_id)) - tables = load_structvector(db, config, BasinSubgridLevelV1) - for group in IterTools.groupby(row -> row.name, tables) - name = first(getproperty.(group, :name)) - subgrid_exporters[name] = SubgridExporter(group, name, node_to_basin) - end - return subgrid_exporters -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) @@ -889,8 +871,7 @@ function Parameters(db::DB, config::Config)::Parameters user = User(db, config) basin = Basin(db, config, chunk_size) - - subgrid_exporters = create_subgrid_exporters(db, config, basin) + subgrid_level = Subgrid(db, config, basin) # Set is_pid_controlled to true for those pumps and outlets that are PID controlled for id in pid_control.node_id @@ -921,7 +902,7 @@ function Parameters(db::DB, config::Config)::Parameters pid_control, user, Dict{Int, Symbol}(), - subgrid_exporters, + subgrid_level, ) for (fieldname, fieldtype) in zip(fieldnames(Parameters), fieldtypes(Parameters)) if fieldtype <: AbstractParameterNode diff --git a/core/src/io.jl b/core/src/io.jl index 21f53814b..fe21aedd5 100644 --- a/core/src/io.jl +++ b/core/src/io.jl @@ -230,23 +230,16 @@ end function subgrid_level_table(model::Model)::NamedTuple (; config, saved, integrator) = model (; t, saveval) = saved.subgrid_level + subgrid = integrator.p.subgrid - # The level exporter may contain multiple named systems, but the - # saved levels are flat. - time = DateTime[] - name = String[] - subgrid_id = Int[] - for (unique_name, exporter) in integrator.p.subgrid_exporters - nelem = length(exporter.basin_index) - unique_elem_id = collect(1:nelem) - ntsteps = length(t) - append!(time, repeat(datetime_since.(t, config.starttime); inner = nelem)) - append!(subgrid_id, repeat(unique_elem_id; outer = ntsteps)) - append!(name, fill(unique_name, length(time))) - end + nelem = length(subgrid.basin_index) + ntsteps = length(t) + unique_elem_id = collect(1:nelem) + time = repeat(datetime_since.(t, config.starttime); inner = nelem) + subgrid_id = repeat(unique_elem_id; outer = ntsteps) subgrid_level = FlatVector(saveval) - return (; time, name, subgrid_id, subgrid_level) + return (; time, subgrid_id, subgrid_level) end "Write a result table to disk as an Arrow file" diff --git a/core/src/solve.jl b/core/src/solve.jl index c039a7bcc..eb9ce947a 100644 --- a/core/src/solve.jl +++ b/core/src/solve.jl @@ -467,11 +467,11 @@ struct User <: AbstractParameterNode } end -"SubgridExporter linearly interpolates basin levels." -struct SubgridExporter +"Subgrid linearly interpolates basin levels." +struct Subgrid basin_index::Vector{Int} interpolations::Vector{ScalarInterpolation} - subgrid_level::Vector{Float64} + level::Vector{Float64} end # TODO Automatically add all nodetypes here @@ -492,7 +492,7 @@ struct Parameters{T, TSparse, C1, C2} pid_control::PidControl{T} user::User lookup::Dict{Int, Symbol} - subgrid_exporters::Dict{String, SubgridExporter} + subgrid::Subgrid end """ diff --git a/core/src/validation.jl b/core/src/validation.jl index 0aee97b87..bb30d6c66 100644 --- a/core/src/validation.jl +++ b/core/src/validation.jl @@ -10,7 +10,7 @@ @schema "ribasim.basin.time" BasinTime @schema "ribasim.basin.profile" BasinProfile @schema "ribasim.basin.state" BasinState -@schema "ribasim.basin.subgrid.level" BasinSubgridLevel +@schema "ribasim.basin.subgrid" BasinSubgrid @schema "ribasim.terminal.static" TerminalStatic @schema "ribasim.fractionalflow.static" FractionalFlowStatic @schema "ribasim.flowboundary.static" FlowBoundaryStatic @@ -205,8 +205,7 @@ end level::Float64 end -@version BasinSubgridLevelV1 begin - name::String +@version BasinSubgridV1 begin subgrid_id::Int node_id::Int basin_level::Float64 @@ -373,7 +372,7 @@ sort_by_id_level(row) = (row.node_id, row.level) sort_by_id_state_level(row) = (row.node_id, row.control_state, row.level) sort_by_priority(row) = (row.node_id, row.priority) sort_by_priority_time(row) = (row.node_id, row.priority, row.time) -sort_by_exporter(row) = (row.name, row.subgrid_id, row.node_id, row.basin_level) +sort_by_subgrid_level(row) = (row.subgrid_id, row.node_id, row.basin_level) # get the right sort by function given the Schema, with sort_by_id as the default sort_by_function(table::StructVector{<:Legolas.AbstractRecord}) = sort_by_id @@ -383,7 +382,7 @@ sort_by_function(table::StructVector{TabulatedRatingCurveStaticV1}) = sort_by_id sort_by_function(table::StructVector{BasinProfileV1}) = sort_by_id_level sort_by_function(table::StructVector{UserStaticV1}) = sort_by_priority sort_by_function(table::StructVector{UserTimeV1}) = sort_by_priority_time -sort_by_function(table::StructVector{BasinSubgridLevelV1}) = sort_by_exporter +sort_by_function(table::StructVector{BasinSubgridV1}) = sort_by_subgrid_level const TimeSchemas = Union{ BasinTimeV1, @@ -627,7 +626,7 @@ end """ Validate the entries for a single subgrid element. """ -function valid_subgrid_exporter( +function valid_subgrid( subgrid_id::Int, node_id::Int, node_to_basin::Dict{Int, Int}, diff --git a/core/test/run_models_test.jl b/core/test/run_models_test.jl index 62c37fb02..b78e549ae 100644 --- a/core/test/run_models_test.jl +++ b/core/test/run_models_test.jl @@ -8,8 +8,8 @@ @test successful_retcode(model) # The exporter interpolates 1:1 for three subgrid elements, but shifted by 1.0 meter. - subgrid_exporter = model.integrator.p.subgrid_exporters["primary-system"] - @test all(diff(subgrid_exporter.subgrid_level) .≈ 1.0) + subgrid = model.integrator.p.subgrid + @test all(diff(subgrid.level) .≈ 1.0) end @testitem "bucket model" begin diff --git a/core/test/validation_test.jl b/core/test/validation_test.jl index 41f769b69..2c7cc4d6a 100644 --- a/core/test/validation_test.jl +++ b/core/test/validation_test.jl @@ -304,20 +304,14 @@ end "Invalid edge type 'bar' for edge #2 from node #2 to node #3." end -@testitem "Level exporter validation" begin +@testitem "Subgrid validation" begin node_to_basin = Dict(9 => 1) - errors = Ribasim.valid_subgrid_exporter(1, 10, node_to_basin, [-1.0, 0.0], [-1.0, 0.0]) + errors = Ribasim.valid_subgrid(1, 10, node_to_basin, [-1.0, 0.0], [-1.0, 0.0]) @test length(errors) == 1 @test errors[1] == "The node_id of the Basin / subgrid_level does not refer to a basin: node_id 10 for subgrid_id 1." - errors = Ribasim.valid_subgrid_exporter( - 1, - 9, - node_to_basin, - [-1.0, 0.0, 0.0], - [-1.0, 0.0, 0.0], - ) + errors = Ribasim.valid_subgrid(1, 9, node_to_basin, [-1.0, 0.0, 0.0], [-1.0, 0.0, 0.0]) @test length(errors) == 2 @test errors[1] == "Basin / subgrid_level subgrid_id 1 has repeated basin levels, this cannot be interpolated." diff --git a/docs/core/usage.qmd b/docs/core/usage.qmd index 100453f9f..1bb865947 100644 --- a/docs/core/usage.qmd +++ b/docs/core/usage.qmd @@ -141,7 +141,7 @@ name it must have in the database if it is stored there. - `Basin / profile`: geometries of the basins - `Basin / time`: time series of the forcing values - `Basin / state`: used as initial condition of the basins - - `Basin / subgrid_level`: used to interpolate basin levels to a more detailed spatial representation + - `Basin / subgrid`: used to interpolate basin levels to a more detailed spatial representation - FractionalFlow: connect two of these from a Basin to get a fixed ratio bifurcation - `FractionalFlow / static`: fractions - LevelBoundary: stores water at a given level unaffected by flow, like an infinitely large basin @@ -276,7 +276,7 @@ Internally this get converted to two functions, $A(S)$ and $h(S)$, by integratin The minimum area cannot be zero to avoid numerical issues. The maximum area is used to convert the precipitation flux into an inflow. -## Basin / subgrid_level +## Basin / subgrid The subgrid_level table defines a piecewise linear interpolation from a basin water level to a subgrid element water level. Many subgrid elements may be associated with a single basin, each with distinct interpolation functions. @@ -284,25 +284,22 @@ This functionality can be used to translate a single lumped basin level to a mor column | type | unit | restriction ------------- | ------- | ----- | ------------------------ -name | String | - | (optional, does not have to be unique) subgrid_id | Int | - | sorted node_id | Int | - | constant per subgrid_id basin_level | Float64 | $m$ | per subgrid_id: increasing subgrid_level | Float64 | $m$ | per subgrid_id: increasing -The name column is used to distinguish between spatially distinct water bodies that may exist within a single basin (e.g. perennial versus ephemeral streams). -When no distinction is required, all rows should be given the same name. +The table below shows example input for two subgrid elements: -name | subgrid_id | node_id | basin_level | subgrid_level --------- | ---------- | ------- | ----------- | ------------- -"first" | 1 | 9 | 0.0 | 0.0 -"first" | 1 | 9 | 1.0 | 1.0 -"first" | 1 | 9 | 2.0 | 2.0 -"second" | 2 | 9 | 0.0 | 0.5 -"second" | 2 | 9 | 1.0 | 1.5 -"second" | 2 | 9 | 2.0 | 2.5 +subgrid_id | node_id | basin_level | subgrid_level +---------- | ------- | ----------- | ------------- + 1 | 9 | 0.0 | 0.0 + 1 | 9 | 1.0 | 1.0 + 1 | 9 | 2.0 | 2.0 + 2 | 9 | 0.0 | 0.5 + 2 | 9 | 1.0 | 1.5 + 2 | 9 | 2.0 | 2.5 -The example shows the table for two subgrid elements. Both subgrid elements use the water level of the basin with `node_id` 9 to interpolate to their respective water levels. The first element has a one to one connection with the water level; the second also has a one to one connection, but is offset by half a meter. A basin water level of 0.3 would be translated to a water level of 0.3 for the first subgrid element, and 0.8 for the second. diff --git a/docs/schema/BasinSubgridLevel.schema.json b/docs/schema/BasinSubgrid.schema.json similarity index 77% rename from docs/schema/BasinSubgridLevel.schema.json rename to docs/schema/BasinSubgrid.schema.json index 1360c677f..244ab8189 100644 --- a/docs/schema/BasinSubgridLevel.schema.json +++ b/docs/schema/BasinSubgrid.schema.json @@ -1,14 +1,10 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://deltares.github.io/Ribasim/schema/BasinSubgridLevel.schema.json", - "title": "BasinSubgridLevel", - "description": "A BasinSubgridLevel object based on Ribasim.BasinSubgridLevelV1", + "$id": "https://deltares.github.io/Ribasim/schema/BasinSubgrid.schema.json", + "title": "BasinSubgrid", + "description": "A BasinSubgrid object based on Ribasim.BasinSubgridV1", "type": "object", "properties": { - "name": { - "format": "default", - "type": "string" - }, "subgrid_id": { "format": "default", "type": "integer" @@ -33,7 +29,6 @@ } }, "required": [ - "name", "subgrid_id", "node_id", "basin_level", diff --git a/docs/schema/Config.schema.json b/docs/schema/Config.schema.json index 5858aba4d..16a429ebb 100644 --- a/docs/schema/Config.schema.json +++ b/docs/schema/Config.schema.json @@ -133,7 +133,7 @@ "profile": null, "state": null, "static": null, - "subgrid.level": null, + "subgrid": null, "time": null } }, diff --git a/docs/schema/basin.schema.json b/docs/schema/basin.schema.json index 4ae482343..ce36b64ec 100644 --- a/docs/schema/basin.schema.json +++ b/docs/schema/basin.schema.json @@ -41,7 +41,7 @@ ], "default": null }, - "subgrid.level": { + "subgrid": { "format": "default", "anyOf": [ { diff --git a/docs/schema/root.schema.json b/docs/schema/root.schema.json index a2ca1d63e..e9089cc68 100644 --- a/docs/schema/root.schema.json +++ b/docs/schema/root.schema.json @@ -10,8 +10,8 @@ "basinstatic": { "$ref": "basinstatic.schema.json" }, - "basinsubgridlevel": { - "$ref": "basinsubgridlevel.schema.json" + "basinsubgrid": { + "$ref": "basinsubgrid.schema.json" }, "basintime": { "$ref": "basintime.schema.json" diff --git a/python/ribasim/ribasim/config.py b/python/ribasim/ribasim/config.py index e179d6494..15add7d16 100644 --- a/python/ribasim/ribasim/config.py +++ b/python/ribasim/ribasim/config.py @@ -10,7 +10,7 @@ BasinProfileSchema, BasinStateSchema, BasinStaticSchema, - BasinSubgridLevelSchema, + BasinSubgridSchema, BasinTimeSchema, DiscreteControlConditionSchema, DiscreteControlLogicSchema, @@ -164,14 +164,14 @@ class Basin(NodeModel): time: TableModel[BasinTimeSchema] = Field( default_factory=TableModel[BasinTimeSchema] ) - subgrid_level: TableModel[BasinSubgridLevelSchema] = Field( - default_factory=TableModel[BasinSubgridLevelSchema] + subgrid: TableModel[BasinSubgridSchema] = Field( + default_factory=TableModel[BasinSubgridSchema] ) _sort_keys: dict[str, list[str]] = { "profile": ["node_id", "level"], "time": ["time", "node_id"], - "subgrid_level": ["name", "subgrid_id", "node_id", "basin_level"], + "subgrid_level": ["subgrid_id", "node_id", "basin_level"], } diff --git a/python/ribasim/ribasim/models.py b/python/ribasim/ribasim/models.py index 754665166..38de5e958 100644 --- a/python/ribasim/ribasim/models.py +++ b/python/ribasim/ribasim/models.py @@ -33,8 +33,7 @@ class BasinStatic(BaseModel): remarks: str = Field("", description="a hack for pandera") -class BasinSubgridLevel(BaseModel): - name: str +class BasinSubgrid(BaseModel): subgrid_id: int node_id: int basin_level: float @@ -232,7 +231,7 @@ class Root(BaseModel): basinprofile: BasinProfile | None = None basinstate: BasinState | None = None basinstatic: BasinStatic | None = None - basinsubgridlevel: BasinSubgridLevel | None = None + basinsubgrid: BasinSubgrid | None = None basintime: BasinTime | None = None discretecontrolcondition: DiscreteControlCondition | None = None discretecontrollogic: DiscreteControlLogic | None = None diff --git a/python/ribasim_testmodels/ribasim_testmodels/trivial.py b/python/ribasim_testmodels/ribasim_testmodels/trivial.py index 85c23e85e..743cbfcc1 100644 --- a/python/ribasim_testmodels/ribasim_testmodels/trivial.py +++ b/python/ribasim_testmodels/ribasim_testmodels/trivial.py @@ -79,16 +79,15 @@ def trivial_model() -> ribasim.Model: # 2. start at 0.0 # 3. start at 1.0 # - subgrid_level = pd.DataFrame( + subgrid = pd.DataFrame( data={ - "name": "primary-system", "subgrid_id": [1, 1, 2, 2, 3, 3], "node_id": [1, 1, 1, 1, 1, 1], "basin_level": [0.0, 1.0, 0.0, 1.0, 0.0, 1.0], "subgrid_level": [-1.0, 0.0, 0.0, 1.0, 1.0, 2.0], } ) - basin = ribasim.Basin(profile=profile, static=static, subgrid_level=subgrid_level) + basin = ribasim.Basin(profile=profile, static=static, subgrid=subgrid) # Set up a rating curve node: # Discharge: lose 1% of storage volume per day at storage = 1000.0. diff --git a/ribasim_qgis/core/nodes.py b/ribasim_qgis/core/nodes.py index 1808f7770..7c3290d3b 100644 --- a/ribasim_qgis/core/nodes.py +++ b/ribasim_qgis/core/nodes.py @@ -337,10 +337,9 @@ class BasinTime(Input): class BasinSubgridLevel(Input): - input_type = "Basin / subgrid_level" + input_type = "Basin / subgrid" geometry_type = "No Geometry" attributes = [ - QgsField("name", QVariant.String), QgsField("subgrid_id", QVariant.Int), QgsField("node_id", QVariant.Int), QgsField("basin_level", QVariant.Double), From b8d99e48891abba34e889eb6f1e87567a78d4144 Mon Sep 17 00:00:00 2001 From: Huite Bootsma Date: Fri, 24 Nov 2023 12:15:45 +0100 Subject: [PATCH 27/37] Add subgrid_levels to RESULTS_FILENAME --- core/src/bmi.jl | 2 +- core/src/consts.jl | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/core/src/bmi.jl b/core/src/bmi.jl index 67ccb2483..994e4ae8e 100644 --- a/core/src/bmi.jl +++ b/core/src/bmi.jl @@ -175,7 +175,7 @@ function BMI.finalize(model::Model)::Model # exported levels table = subgrid_level_table(model) - path = results_path(config, results.subgrid_level) + path = results_path(config, RESULTS_FILENAME.subgrid_levels) write_arrow(path, table, compress) @debug "Wrote results." diff --git a/core/src/consts.jl b/core/src/consts.jl index e138e85fc..50126b04f 100644 --- a/core/src/consts.jl +++ b/core/src/consts.jl @@ -3,4 +3,5 @@ const RESULTS_FILENAME = ( flow = "flow.arrow", control = "control.arrow", allocation = "allocation.arrow", + subgrid_levels = "subgrid_levels.arrow", ) From ac58be546022b56758d0837e31f47495e70078ba Mon Sep 17 00:00:00 2001 From: Huite Bootsma Date: Fri, 24 Nov 2023 12:38:42 +0100 Subject: [PATCH 28/37] Make subgrid levels computation option via Results config --- core/src/bmi.jl | 24 ++++++++++++------- core/src/config.jl | 1 + python/ribasim/ribasim/config.py | 1 + .../ribasim_testmodels/trivial.py | 2 +- 4 files changed, 18 insertions(+), 10 deletions(-) diff --git a/core/src/bmi.jl b/core/src/bmi.jl index 994e4ae8e..2dfddfe02 100644 --- a/core/src/bmi.jl +++ b/core/src/bmi.jl @@ -240,9 +240,15 @@ function create_callbacks( # interpolate the levels saved_subgrid_level = SavedValues(Float64, Vector{Float64}) - export_cb = - SavingCallback(save_subgrid_level, saved_subgrid_level; saveat, save_start = false) - push!(callbacks, export_cb) + if config.results.subgrid + export_cb = SavingCallback( + save_subgrid_level, + saved_subgrid_level; + saveat, + save_start = false, + ) + push!(callbacks, export_cb) + end saved = SavedResults(saved_flow, saved_subgrid_level) @@ -629,10 +635,10 @@ function run(config::Config)::Model ) end - # with_logger(logger) do - model = Model(config) - solve!(model) - BMI.finalize(model) - return model - # end + with_logger(logger) do + model = Model(config) + solve!(model) + BMI.finalize(model) + return model + end end diff --git a/core/src/config.jl b/core/src/config.jl index 41e4b7d0d..3b2483861 100644 --- a/core/src/config.jl +++ b/core/src/config.jl @@ -112,6 +112,7 @@ end outstate::Union{String, Nothing} = nothing compression::Compression = "zstd" compression_level::Int = 6 + subgrid::Bool = false end @option struct Logging <: TableOption diff --git a/python/ribasim/ribasim/config.py b/python/ribasim/ribasim/config.py index 49680727d..6e8ffbe0c 100644 --- a/python/ribasim/ribasim/config.py +++ b/python/ribasim/ribasim/config.py @@ -47,6 +47,7 @@ class Results(BaseModel): outstate: str | None = None compression: Compression = Compression.zstd compression_level: int = 6 + subgrid: bool = False class Solver(BaseModel): diff --git a/python/ribasim_testmodels/ribasim_testmodels/trivial.py b/python/ribasim_testmodels/ribasim_testmodels/trivial.py index 743cbfcc1..22131df7f 100644 --- a/python/ribasim_testmodels/ribasim_testmodels/trivial.py +++ b/python/ribasim_testmodels/ribasim_testmodels/trivial.py @@ -121,6 +121,6 @@ def trivial_model() -> ribasim.Model: tabulated_rating_curve=rating_curve, starttime="2020-01-01 00:00:00", endtime="2021-01-01 00:00:00", - results=ribasim.Results(), + results=ribasim.Results(subgrid=True), ) return model From 359814c5538e89f542f4c71c4fda3aef5eb799ef Mon Sep 17 00:00:00 2001 From: Martijn Visser Date: Fri, 24 Nov 2023 19:20:42 +0100 Subject: [PATCH 29/37] update nodes.py --- ribasim_qgis/core/nodes.py | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/ribasim_qgis/core/nodes.py b/ribasim_qgis/core/nodes.py index ce438c466..140f83677 100644 --- a/ribasim_qgis/core/nodes.py +++ b/ribasim_qgis/core/nodes.py @@ -391,14 +391,22 @@ def attributes(cls) -> list[QgsField]: class BasinSubgridLevel(Input): - input_type = "Basin / subgrid" - geometry_type = "No Geometry" - attributes = [ - QgsField("subgrid_id", QVariant.Int), - QgsField("node_id", QVariant.Int), - QgsField("basin_level", QVariant.Double), - QgsField("subgrid_level", QVariant.Double), - ] + @classmethod + def input_type(cls) -> str: + return "Basin / subgrid" + + @classmethod + def geometry_type(cls) -> str: + return "No Geometry" + + @classmethod + def attributes(cls) -> list[QgsField]: + return [ + QgsField("subgrid_id", QVariant.Int), + QgsField("node_id", QVariant.Int), + QgsField("basin_level", QVariant.Double), + QgsField("subgrid_level", QVariant.Double), + ] class BasinState(Input): From 232d3c067a81127e9018961ddacc2822fd951f08 Mon Sep 17 00:00:00 2001 From: Martijn Visser Date: Fri, 24 Nov 2023 19:27:50 +0100 Subject: [PATCH 30/37] avoid splatting Also use `nextfloat(-Inf)`, we already use that elsewhere, and perhaps `prevfloat`'s small dx can cause floating point issues. --- core/src/create.jl | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/core/src/create.jl b/core/src/create.jl index 1cf79dd00..e20bd6b82 100644 --- a/core/src/create.jl +++ b/core/src/create.jl @@ -803,11 +803,9 @@ function Subgrid(db::DB, config::Config, basin::Basin)::Subgrid if isempty(group_errors) # Ensure it doesn't extrapolate before the first value. - new_interp = LinearInterpolation( - [subgrid_level[1], subgrid_level...], - [prevfloat(basin_level[1]), basin_level...]; - extrapolate = true, - ) + pushfirst!(subgrid_level, first(subgrid_level)) + pushfirst!(basin_level, nextfloat(-Inf)) + new_interp = LinearInterpolation(subgrid_level, basin_level; extrapolate = true) push!(basin_ids, node_to_basin[node_id]) push!(interpolations, new_interp) else From 6236211b98d85ad91eb1bafdebea9753e974c1f4 Mon Sep 17 00:00:00 2001 From: Martijn Visser Date: Fri, 24 Nov 2023 19:29:20 +0100 Subject: [PATCH 31/37] Write out return type --- core/src/io.jl | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/core/src/io.jl b/core/src/io.jl index 3856190f2..657eaae71 100644 --- a/core/src/io.jl +++ b/core/src/io.jl @@ -252,7 +252,13 @@ function allocation_table( ) end -function subgrid_level_table(model::Model)::NamedTuple +function subgrid_level_table( + model::Model, +)::@NamedTuple{ + time::Vector{DateTime}, + subgrid_id::Vector{Int}, + subgrid_level::Vector{Float64}, +} (; config, saved, integrator) = model (; t, saveval) = saved.subgrid_level subgrid = integrator.p.subgrid From 55021b459ae402bd5e77db6c7e2e58d7453ab91f Mon Sep 17 00:00:00 2001 From: Martijn Visser Date: Fri, 24 Nov 2023 19:55:16 +0100 Subject: [PATCH 32/37] log errors directly More consistently with the other validation code. --- core/src/create.jl | 22 +++++++----------- core/src/validation.jl | 31 +++++++++----------------- core/test/validation_test.jl | 43 +++++++++++++++++++++++++++--------- 3 files changed, 52 insertions(+), 44 deletions(-) diff --git a/core/src/create.jl b/core/src/create.jl index e20bd6b82..81ec95fe7 100644 --- a/core/src/create.jl +++ b/core/src/create.jl @@ -791,36 +791,30 @@ function Subgrid(db::DB, config::Config, basin::Basin)::Subgrid basin_ids = Int[] interpolations = ScalarInterpolation[] - errors = String[] + has_error = false for group in IterTools.groupby(row -> row.subgrid_id, tables) subgrid_id = first(getproperty.(group, :subgrid_id)) - node_id = first(getproperty.(group, :node_id)) + node_id = NodeID(first(getproperty.(group, :node_id))) basin_level = getproperty.(group, :basin_level) subgrid_level = getproperty.(group, :subgrid_level) - group_errors = + is_valid = valid_subgrid(subgrid_id, node_id, node_to_basin, basin_level, subgrid_level) - if isempty(group_errors) + if !is_valid + has_error = true # Ensure it doesn't extrapolate before the first value. pushfirst!(subgrid_level, first(subgrid_level)) pushfirst!(basin_level, nextfloat(-Inf)) new_interp = LinearInterpolation(subgrid_level, basin_level; extrapolate = true) push!(basin_ids, node_to_basin[node_id]) push!(interpolations, new_interp) - else - append!(errors, group_errors) end end - if isempty(errors) - return Subgrid(basin_ids, interpolations, fill(NaN, length(basin_ids))) - else - foreach(x -> @error(x), errors) - error( - "Errors occurred while parsing Basin / subgrid_level data for group with name: $(name).", - ) - end + has_error && error("Invalid Basin / subgrid table.") + + return Subgrid(basin_ids, interpolations, fill(NaN, length(basin_ids))) end function Parameters(db::DB, config::Config)::Parameters diff --git a/core/src/validation.jl b/core/src/validation.jl index 68c925c29..576d407f6 100644 --- a/core/src/validation.jl +++ b/core/src/validation.jl @@ -1,7 +1,5 @@ # These schemas define the name of database tables and the configuration file structure # The identifier is parsed as ribasim.nodetype.kind, no capitals or underscores are allowed. -# If the kind consists of multiple words, these can also be split with extra dots, -# such that from the "subgrid.level" schema we get "subgrid_level". @schema "ribasim.node" Node @schema "ribasim.edge" Edge @schema "ribasim.discretecontrol.condition" DiscreteControlCondition @@ -637,34 +635,27 @@ Validate the entries for a single subgrid element. """ function valid_subgrid( subgrid_id::Int, - node_id::Int, - node_to_basin::Dict{Int, Int}, + node_id::NodeID, + node_to_basin::Dict{NodeID, Int}, basin_level::Vector{Float64}, subgrid_level::Vector{Float64}, -) - # The Schema ensures that the entries are sorted properly, so we do not need to validate the order here. - errors = String[] +)::Bool + errors = false if !(node_id in keys(node_to_basin)) - push!( - errors, - "The node_id of the Basin / subgrid_level does not refer to a basin: node_id $(node_id) for subgrid_id $(subgrid_id).", - ) + errors = true + @error "The node_id of the Basin / subgrid_level does not refer to a basin." node_id subgrid_id end if !allunique(basin_level) - push!( - errors, - "Basin / subgrid_level subgrid_id $(subgrid_id) has repeated basin levels, this cannot be interpolated.", - ) + errors = true + @error "Basin / subgrid_level subgrid_id $(subgrid_id) has repeated basin levels, this cannot be interpolated." end if !allunique(subgrid_level) - push!( - errors, - "Basin / subgrid_level subgrid_id $(subgrid_id) has repeated element levels, this cannot be interpolated.", - ) + errors = true + @error "Basin / subgrid_level subgrid_id $(subgrid_id) has repeated element levels, this cannot be interpolated." end - return errors + return !errors end diff --git a/core/test/validation_test.jl b/core/test/validation_test.jl index 62716713e..76d652c9a 100644 --- a/core/test/validation_test.jl +++ b/core/test/validation_test.jl @@ -346,16 +346,39 @@ end end @testitem "Subgrid validation" begin - node_to_basin = Dict(9 => 1) - errors = Ribasim.valid_subgrid(1, 10, node_to_basin, [-1.0, 0.0], [-1.0, 0.0]) - @test length(errors) == 1 - @test errors[1] == - "The node_id of the Basin / subgrid_level does not refer to a basin: node_id 10 for subgrid_id 1." - - errors = Ribasim.valid_subgrid(1, 9, node_to_basin, [-1.0, 0.0, 0.0], [-1.0, 0.0, 0.0]) - @test length(errors) == 2 - @test errors[1] == + using Ribasim: valid_subgrid, NodeID + using Logging + + node_to_basin = Dict(NodeID(9) => 1) + + logger = TestLogger() + with_logger(logger) do + @test !valid_subgrid(1, NodeID(10), node_to_basin, [-1.0, 0.0], [-1.0, 0.0]) + end + + @test length(logger.logs) == 1 + @test logger.logs[1].level == Error + @test logger.logs[1].message == + "The node_id of the Basin / subgrid_level does not refer to a basin." + @test logger.logs[1].kwargs[:node_id] == NodeID(10) + @test logger.logs[1].kwargs[:subgrid_id] == 1 + + logger = TestLogger() + with_logger(logger) do + @test !valid_subgrid( + 1, + NodeID(9), + node_to_basin, + [-1.0, 0.0, 0.0], + [-1.0, 0.0, 0.0], + ) + end + + @test length(logger.logs) == 2 + @test logger.logs[1].level == Error + @test logger.logs[1].message == "Basin / subgrid_level subgrid_id 1 has repeated basin levels, this cannot be interpolated." - @test errors[2] == + @test logger.logs[2].level == Error + @test logger.logs[2].message == "Basin / subgrid_level subgrid_id 1 has repeated element levels, this cannot be interpolated." end From 126859482f5c332d9fca62b9cc258649db6f6338 Mon Sep 17 00:00:00 2001 From: Martijn Visser Date: Fri, 24 Nov 2023 20:09:41 +0100 Subject: [PATCH 33/37] remove redundant sorting key --- core/src/validation.jl | 2 +- docs/core/usage.qmd | 4 ++-- python/ribasim/ribasim/config.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/core/src/validation.jl b/core/src/validation.jl index 576d407f6..739a6f359 100644 --- a/core/src/validation.jl +++ b/core/src/validation.jl @@ -370,7 +370,7 @@ sort_by_id_level(row) = (row.node_id, row.level) sort_by_id_state_level(row) = (row.node_id, row.control_state, row.level) sort_by_priority(row) = (row.node_id, row.priority) sort_by_priority_time(row) = (row.node_id, row.priority, row.time) -sort_by_subgrid_level(row) = (row.subgrid_id, row.node_id, row.basin_level) +sort_by_subgrid_level(row) = (row.subgrid_id, row.basin_level) # get the right sort by function given the Schema, with sort_by_id as the default sort_by_function(table::StructVector{<:Legolas.AbstractRecord}) = sort_by_id diff --git a/docs/core/usage.qmd b/docs/core/usage.qmd index 1bb865947..d87b28db5 100644 --- a/docs/core/usage.qmd +++ b/docs/core/usage.qmd @@ -286,8 +286,8 @@ column | type | unit | restriction ------------- | ------- | ----- | ------------------------ subgrid_id | Int | - | sorted node_id | Int | - | constant per subgrid_id -basin_level | Float64 | $m$ | per subgrid_id: increasing -subgrid_level | Float64 | $m$ | per subgrid_id: increasing +basin_level | Float64 | $m$ | sorted per subgrid_id +subgrid_level | Float64 | $m$ | sorted per subgrid_id The table below shows example input for two subgrid elements: diff --git a/python/ribasim/ribasim/config.py b/python/ribasim/ribasim/config.py index 6e8ffbe0c..48d74e381 100644 --- a/python/ribasim/ribasim/config.py +++ b/python/ribasim/ribasim/config.py @@ -167,7 +167,7 @@ class Basin(NodeModel): _sort_keys: dict[str, list[str]] = { "profile": ["node_id", "level"], "time": ["time", "node_id"], - "subgrid_level": ["subgrid_id", "node_id", "basin_level"], + "subgrid": ["subgrid_id", "basin_level"], } From 79c3c4ec38d706df4e6040e6657ef89ed0af3f2e Mon Sep 17 00:00:00 2001 From: Martijn Visser Date: Fri, 24 Nov 2023 20:10:22 +0100 Subject: [PATCH 34/37] pixi run codegen --- docs/schema/Results.schema.json | 38 ++++--------------- .../{Config.schema.json => Toml.schema.json} | 26 ++++++------- 2 files changed, 18 insertions(+), 46 deletions(-) rename docs/schema/{Config.schema.json => Toml.schema.json} (89%) diff --git a/docs/schema/Results.schema.json b/docs/schema/Results.schema.json index 3c58d600b..eb23d21b6 100644 --- a/docs/schema/Results.schema.json +++ b/docs/schema/Results.schema.json @@ -5,31 +5,6 @@ "description": "A Results object based on Ribasim.config.Results", "type": "object", "properties": { - "basin": { - "format": "default", - "type": "string", - "default": "results/basin.arrow" - }, - "flow": { - "format": "default", - "type": "string", - "default": "results/flow.arrow" - }, - "control": { - "format": "default", - "type": "string", - "default": "results/control.arrow" - }, - "allocation": { - "format": "default", - "type": "string", - "default": "results/allocation.arrow" - }, - "subgrid_level": { - "format": "default", - "type": "string", - "default": "results/subgrid_level.arrow" - }, "outstate": { "format": "default", "anyOf": [ @@ -51,15 +26,16 @@ "format": "default", "type": "integer", "default": 6 + }, + "subgrid": { + "format": "default", + "type": "boolean", + "default": false } }, "required": [ - "basin", - "flow", - "control", - "allocation", - "subgrid_level", "compression", - "compression_level" + "compression_level", + "subgrid" ] } diff --git a/docs/schema/Config.schema.json b/docs/schema/Toml.schema.json similarity index 89% rename from docs/schema/Config.schema.json rename to docs/schema/Toml.schema.json index e7456f222..aeb9a0cdf 100644 --- a/docs/schema/Config.schema.json +++ b/docs/schema/Toml.schema.json @@ -1,8 +1,8 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://deltares.github.io/Ribasim/schema/Config.schema.json", - "title": "Config", - "description": "A Config object based on Ribasim.config.Config", + "$id": "https://deltares.github.io/Ribasim/schema/Toml.schema.json", + "title": "Toml", + "description": "A Toml object based on Ribasim.config.Toml", "type": "object", "properties": { "starttime": { @@ -15,17 +15,16 @@ }, "input_dir": { "format": "default", - "type": "string", - "default": "." + "type": "string" }, "results_dir": { "format": "default", - "type": "string", - "default": "results" + "type": "string" }, "database": { "format": "default", - "type": "string" + "type": "string", + "default": "database.gpkg" }, "allocation": { "$ref": "https://deltares.github.io/Ribasim/schema/Allocation.schema.json", @@ -39,7 +38,8 @@ "$ref": "https://deltares.github.io/Ribasim/schema/Solver.schema.json", "default": { "algorithm": "QNDF", - "saveat": [], + "saveat": [ + ], "adaptive": true, "dt": null, "dtmin": null, @@ -64,14 +64,10 @@ "results": { "$ref": "https://deltares.github.io/Ribasim/schema/Results.schema.json", "default": { - "basin": "results/basin.arrow", - "flow": "results/flow.arrow", - "control": "results/control.arrow", - "allocation": "results/allocation.arrow", - "subgrid_level": "results/subgrid_level.arrow", "outstate": null, "compression": "zstd", - "compression_level": 6 + "compression_level": 6, + "subgrid": false } }, "terminal": { From 38c3a5fe52444d5ed062c4d614771fd3e201a55e Mon Sep 17 00:00:00 2001 From: Martijn Visser Date: Fri, 24 Nov 2023 20:40:32 +0100 Subject: [PATCH 35/37] update last validation function to log errors directly --- core/src/solve.jl | 34 +++++++++++++++------------------- core/src/validation.jl | 23 ++++++++++++----------- core/test/validation_test.jl | 35 ++++++++++++++++++++++++----------- 3 files changed, 51 insertions(+), 41 deletions(-) diff --git a/core/src/solve.jl b/core/src/solve.jl index 909e0e700..06c05a957 100644 --- a/core/src/solve.jl +++ b/core/src/solve.jl @@ -141,25 +141,21 @@ struct Basin{T, C} <: AbstractParameterNode storage, time::StructVector{BasinTimeV1, C, Int}, ) where {T, C} - errors = valid_profiles(node_id, level, area) - if isempty(errors) - return new{T, C}( - node_id, - precipitation, - potential_evaporation, - drainage, - infiltration, - current_level, - current_area, - area, - level, - storage, - time, - ) - else - foreach(x -> @error(x), errors) - error("Errors occurred when parsing Basin data.") - end + is_valid = valid_profiles(node_id, level, area) + is_valid || error("Invalid Basin / profile table.") + return new{T, C}( + node_id, + precipitation, + potential_evaporation, + drainage, + infiltration, + current_level, + current_area, + area, + level, + storage, + time, + ) end end diff --git a/core/src/validation.jl b/core/src/validation.jl index 739a6f359..a6abe919e 100644 --- a/core/src/validation.jl +++ b/core/src/validation.jl @@ -460,29 +460,30 @@ function valid_profiles( node_id::Indices{NodeID}, level::Vector{Vector{Float64}}, area::Vector{Vector{Float64}}, -)::Vector{String} - errors = String[] +)::Bool + errors = false for (id, levels, areas) in zip(node_id, level, area) if !allunique(levels) - push!(errors, "Basin $id has repeated levels, this cannot be interpolated.") + errors = true + @error "Basin $id has repeated levels, this cannot be interpolated." end if areas[1] <= 0 - push!( - errors, - "Basin profiles cannot start with area <= 0 at the bottom for numerical reasons (got area $(areas[1]) for node $id).", + errors = true + @error( + "Basin profiles cannot start with area <= 0 at the bottom for numerical reasons.", + node_id = id, + area = areas[1], ) end if areas[end] < areas[end - 1] - push!( - errors, - "Basin profiles cannot have decreasing area at the top since extrapolating could lead to negative areas, found decreasing top areas for node $id.", - ) + errors = true + @error "Basin profiles cannot have decreasing area at the top since extrapolating could lead to negative areas, found decreasing top areas for node $id." end end - return errors + return !errors end """ diff --git a/core/test/validation_test.jl b/core/test/validation_test.jl index 76d652c9a..c2ae9e983 100644 --- a/core/test/validation_test.jl +++ b/core/test/validation_test.jl @@ -1,22 +1,35 @@ @testitem "Basin profile validation" begin using Dictionaries: Indices + using Ribasim: NodeID, valid_profiles, qh_interpolation using DataInterpolations: LinearInterpolation + using Logging - node_id = Indices([Ribasim.NodeID(1)]) + node_id = Indices([NodeID(1)]) level = [[0.0, 0.0, 1.0]] area = [[0.0, 100.0, 90]] - errors = Ribasim.valid_profiles(node_id, level, area) - @test "Basin #1 has repeated levels, this cannot be interpolated." in errors - @test "Basin profiles cannot start with area <= 0 at the bottom for numerical reasons (got area 0.0 for node #1)." in - errors - @test "Basin profiles cannot have decreasing area at the top since extrapolating could lead to negative areas, found decreasing top areas for node #1." in - errors - @test length(errors) == 3 - - itp, valid = Ribasim.qh_interpolation([0.0, 0.0], [1.0, 2.0]) + + logger = TestLogger() + with_logger(logger) do + @test !valid_profiles(node_id, level, area) + end + + @test length(logger.logs) == 3 + @test logger.logs[1].level == Error + @test logger.logs[1].message == + "Basin #1 has repeated levels, this cannot be interpolated." + @test logger.logs[2].level == Error + @test logger.logs[2].message == + "Basin profiles cannot start with area <= 0 at the bottom for numerical reasons." + @test logger.logs[2].kwargs[:node_id] == NodeID(1) + @test logger.logs[2].kwargs[:area] == 0 + @test logger.logs[3].level == Error + @test logger.logs[3].message == + "Basin profiles cannot have decreasing area at the top since extrapolating could lead to negative areas, found decreasing top areas for node #1." + + itp, valid = qh_interpolation([0.0, 0.0], [1.0, 2.0]) @test !valid @test itp isa LinearInterpolation - itp, valid = Ribasim.qh_interpolation([0.0, 0.1], [1.0, 2.0]) + itp, valid = qh_interpolation([0.0, 0.1], [1.0, 2.0]) @test valid @test itp isa LinearInterpolation end From 7cdcbb89d49b155c582a856bef8e6f6cc184841d Mon Sep 17 00:00:00 2001 From: Martijn Visser Date: Fri, 24 Nov 2023 20:55:28 +0100 Subject: [PATCH 36/37] fix runstats.jl --- utils/runstats.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utils/runstats.jl b/utils/runstats.jl index 9138b585b..ee14afcc8 100644 --- a/utils/runstats.jl +++ b/utils/runstats.jl @@ -18,7 +18,7 @@ using LibGit2 "Add key config settings like solver settings to a dictionary" function add_config!(dict, config::Ribasim.Config) - confdict = to_dict(config) + confdict = to_dict(getfield(config, :toml)) for (k, v) in confdict["solver"] if k == "saveat" # convert possible vector to scalar From 5ab0091bef4bc394f8b6e85901babdfda64e7729 Mon Sep 17 00:00:00 2001 From: Martijn Visser Date: Fri, 24 Nov 2023 21:16:46 +0100 Subject: [PATCH 37/37] Fix QGIS plugin in Python 3.9 --- ribasim_qgis/core/nodes.py | 4 +++- ribasim_qgis/widgets/dataset_widget.py | 2 ++ ribasim_qgis/widgets/ribasim_widget.py | 2 ++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/ribasim_qgis/core/nodes.py b/ribasim_qgis/core/nodes.py index 140f83677..403de37c7 100644 --- a/ribasim_qgis/core/nodes.py +++ b/ribasim_qgis/core/nodes.py @@ -20,6 +20,8 @@ """ +from __future__ import annotations + import abc from typing import Any, cast @@ -81,7 +83,7 @@ def create( path: str, crs: QgsCoordinateReferenceSystem, names: list[str], - ) -> "Input": + ) -> Input: if cls.input_type() in names: raise ValueError(f"Name already exists in geopackage: {cls.input_type()}") instance = cls(path) diff --git a/ribasim_qgis/widgets/dataset_widget.py b/ribasim_qgis/widgets/dataset_widget.py index 9bcd4f1c9..2c0a95ad0 100644 --- a/ribasim_qgis/widgets/dataset_widget.py +++ b/ribasim_qgis/widgets/dataset_widget.py @@ -4,6 +4,8 @@ This widget also allows enabling or disabling individual elements for a computation. """ +from __future__ import annotations + from collections.abc import Iterable from datetime import datetime from pathlib import Path diff --git a/ribasim_qgis/widgets/ribasim_widget.py b/ribasim_qgis/widgets/ribasim_widget.py index 5f2a5375f..bf61a27c2 100644 --- a/ribasim_qgis/widgets/ribasim_widget.py +++ b/ribasim_qgis/widgets/ribasim_widget.py @@ -5,6 +5,8 @@ connection to the QGIS Layers Panel, and ensures there is a group for the Ribasim layers there. """ +from __future__ import annotations + from pathlib import Path from typing import Any, cast