Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add optional 'Basin / area' spatial table #1200

Merged
merged 5 commits into from
Mar 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/core/usage.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,16 @@ 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 / area

The optional area table is not used during computation, but provides a place to associate areas in the form of polygons to Basins.
Using this makes it easier to recognize which water or land surfaces are represented by Basins.
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
Using this makes it easier to recognize which water or land surfaces are represented by Basins.
Using this makes it easier to recognize which water or land surfaces are represented by basins.

Was this intentionally capitalized?

Copy link
Member Author

@visr visr Mar 1, 2024

Choose a reason for hiding this comment

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

Yeah I prefer to use capitals when referring to particular node types, to distinguish from a general drainage basin.


column | type | restriction
--------- | ----------------------- | -----------
node_id | Int | sorted
geom | Polygon or MultiPolygon | (optional)

## Basin / subgrid

The subgrid_level table defines a piecewise linear interpolation from a basin water level to a subgrid element water level.
Expand Down
7 changes: 6 additions & 1 deletion python/ribasim/ribasim/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

from pydantic import Field

from ribasim.input_base import ChildModel, NodeModel, TableModel
from ribasim.geometry import BasinAreaSchema
from ribasim.input_base import ChildModel, NodeModel, SpatialTableModel, TableModel

# These schemas are autogenerated
from ribasim.schemas import (
Expand Down Expand Up @@ -174,6 +175,10 @@ class Basin(NodeModel):
default_factory=TableModel[BasinSubgridSchema],
json_schema_extra={"sort_keys": ["subgrid_id", "basin_level"]},
)
area: SpatialTableModel[BasinAreaSchema] = Field(
default_factory=SpatialTableModel[BasinAreaSchema],
json_schema_extra={"sort_keys": ["node_id"]},
)


class ManningResistance(NodeModel):
Expand Down
3 changes: 2 additions & 1 deletion python/ribasim/ribasim/geometry/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from ribasim.geometry.area import BasinAreaSchema
from ribasim.geometry.edge import Edge
from ribasim.geometry.node import Node

__all__ = ["Edge", "Node"]
__all__ = ["BasinAreaSchema", "Edge", "Node"]
12 changes: 12 additions & 0 deletions python/ribasim/ribasim/geometry/area.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from typing import Any

import pandera as pa
from pandera.typing import Series
from pandera.typing.geopandas import GeoSeries

from ribasim.schemas import _BaseSchema


class BasinAreaSchema(_BaseSchema):
node_id: Series[int]
geometry: GeoSeries[Any] = pa.Field(default=None, nullable=True)
3 changes: 3 additions & 0 deletions python/ribasim/tests/test_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,9 @@ def test_node_ids_unsequential(basic):

def test_tabulated_rating_curve_model(tabulated_rating_curve, tmp_path):
model_orig = tabulated_rating_curve
basin_area = tabulated_rating_curve.basin.area.df
assert basin_area is not None
assert basin_area.geometry.geom_type[0] == "Polygon"
model_orig.write(tmp_path / "tabulated_rating_curve/ribasim.toml")
model_new = Model.read(tmp_path / "tabulated_rating_curve/ribasim.toml")
pd.testing.assert_series_equal(
Expand Down
77 changes: 47 additions & 30 deletions python/ribasim_testmodels/ribasim_testmodels/basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -287,34 +287,9 @@ def tabulated_rating_curve_model() -> ribasim.Model:
Only the upstream Basin receives a (constant) precipitation.
"""

# Setup the basins:
profile = pd.DataFrame(
data={
"node_id": [1, 1, 4, 4],
"area": [0.01, 1000.0] * 2,
"level": [0.0, 1.0] * 2,
}
)

# Convert steady forcing to m/s
# 2 mm/d precipitation
seconds_in_day = 24 * 3600
precipitation = 0.002 / seconds_in_day
# only the upstream basin gets precipitation
static = pd.DataFrame(
data={
"node_id": [1, 4],
"precipitation": [precipitation, 0.0],
}
)
state = pd.DataFrame(
data={"node_id": static["node_id"], "level": 0.04471158417652035}
)

basin = ribasim.Basin(profile=profile, static=static, state=state)

# Set up a rating curve node:
# Discharge: lose 1% of storage volume per day at storage = 1000.0.
seconds_in_day = 24 * 3600
q1000 = 1000.0 * 0.01 / seconds_in_day

rating_curve = ribasim.TabulatedRatingCurve(
Expand Down Expand Up @@ -354,18 +329,60 @@ def tabulated_rating_curve_model() -> ribasim.Model:
)
node_xy = gpd.points_from_xy(x=xy[:, 0], y=xy[:, 1])

node_id, node_type = ribasim.Node.node_ids_and_types(basin, rating_curve)

# Make sure the feature id starts at 1: explicitly give an index.
node = ribasim.Node(
df=gpd.GeoDataFrame(
data={"node_type": node_type},
index=pd.Index(node_id, name="fid"),
data={
"node_type": [
"Basin",
"TabulatedRatingCurve",
"TabulatedRatingCurve",
"Basin",
]
},
index=pd.Index([1, 2, 3, 4], name="fid"),
geometry=node_xy,
crs="EPSG:28992",
)
)

# Setup the basins:
profile = pd.DataFrame(
data={
"node_id": [1, 1, 4, 4],
"area": [0.01, 1000.0] * 2,
"level": [0.0, 1.0] * 2,
}
)

# Convert steady forcing to m/s
# 2 mm/d precipitation
precipitation = 0.002 / seconds_in_day
# only the upstream basin gets precipitation
static = pd.DataFrame(
data={
"node_id": [1, 4],
"precipitation": [precipitation, 0.0],
}
)
state = pd.DataFrame(
data={"node_id": static["node_id"], "level": 0.04471158417652035}
)
# Turn the basin node point geometries into polygons describing the area
assert node.df is not None
basin_area = (
node.df[node.df.node_type == "Basin"]
.geometry.buffer(1.0)
.reset_index(drop=True)
)
area = gpd.GeoDataFrame(
data={"node_id": static.node_id},
geometry=basin_area,
crs="EPSG:28992",
)

basin = ribasim.Basin(profile=profile, static=static, state=state, area=area)

# Setup the edges:
from_id = np.array([1, 1, 2, 3], dtype=np.int64)
to_id = np.array([2, 3, 4, 4], dtype=np.int64)
Expand Down
30 changes: 24 additions & 6 deletions ribasim_qgis/core/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
The classes specify:

* The (unabbreviated) name
* The type of geometry (No geometry, point, linestring, polygon)
* The type of geometry ("No Geometry", "Point", "LineString", "Polygon")
* The required attributes of the attribute table

Each node layer is (optionally) represented in multiple places:
Expand Down Expand Up @@ -263,7 +263,7 @@

@classmethod
def geometry_type(cls) -> str:
return "Linestring"
return "LineString"

Check warning on line 266 in ribasim_qgis/core/nodes.py

View check run for this annotation

Codecov / codecov/patch

ribasim_qgis/core/nodes.py#L266

Added line #L266 was not covered by tests

@classmethod
def input_type(cls) -> str:
Expand Down Expand Up @@ -366,7 +366,7 @@

@classmethod
def geometry_type(cls) -> str:
return "No geometry"
return "No Geometry"

Check warning on line 369 in ribasim_qgis/core/nodes.py

View check run for this annotation

Codecov / codecov/patch

ribasim_qgis/core/nodes.py#L369

Added line #L369 was not covered by tests

@classmethod
def attributes(cls) -> list[QgsField]:
Expand Down Expand Up @@ -421,6 +421,24 @@
]


class BasinArea(Input):
@classmethod
def input_type(cls) -> str:
return "Basin / area"

Check warning on line 427 in ribasim_qgis/core/nodes.py

View check run for this annotation

Codecov / codecov/patch

ribasim_qgis/core/nodes.py#L424-L427

Added lines #L424 - L427 were not covered by tests

@classmethod
def is_spatial(cls):
return True

Check warning on line 431 in ribasim_qgis/core/nodes.py

View check run for this annotation

Codecov / codecov/patch

ribasim_qgis/core/nodes.py#L429-L431

Added lines #L429 - L431 were not covered by tests

@classmethod
def geometry_type(cls) -> str:
return "Polygon"

Check warning on line 435 in ribasim_qgis/core/nodes.py

View check run for this annotation

Codecov / codecov/patch

ribasim_qgis/core/nodes.py#L433-L435

Added lines #L433 - L435 were not covered by tests

@classmethod
def attributes(cls) -> list[QgsField]:
return [QgsField("node_id", QVariant.Int)]

Check warning on line 439 in ribasim_qgis/core/nodes.py

View check run for this annotation

Codecov / codecov/patch

ribasim_qgis/core/nodes.py#L437-L439

Added lines #L437 - L439 were not covered by tests


class BasinState(Input):
@classmethod
def input_type(cls) -> str:
Expand Down Expand Up @@ -692,7 +710,7 @@

@classmethod
def geometry_type(cls) -> str:
return "LineString"
return "No Geometry"

Check warning on line 713 in ribasim_qgis/core/nodes.py

View check run for this annotation

Codecov / codecov/patch

ribasim_qgis/core/nodes.py#L713

Added line #L713 was not covered by tests

@classmethod
def attributes(cls) -> list[QgsField]:
Expand All @@ -710,7 +728,7 @@

@classmethod
def geometry_type(cls) -> str:
return "LineString"
return "No Geometry"

Check warning on line 731 in ribasim_qgis/core/nodes.py

View check run for this annotation

Codecov / codecov/patch

ribasim_qgis/core/nodes.py#L731

Added line #L731 was not covered by tests

@classmethod
def attributes(cls) -> list[QgsField]:
Expand All @@ -732,7 +750,7 @@

@classmethod
def geometry_type(cls) -> str:
return "LineString"
return "No Geometry"

Check warning on line 753 in ribasim_qgis/core/nodes.py

View check run for this annotation

Codecov / codecov/patch

ribasim_qgis/core/nodes.py#L753

Added line #L753 was not covered by tests

@classmethod
def attributes(cls) -> list[QgsField]:
Expand Down
33 changes: 25 additions & 8 deletions ribasim_qgis/widgets/dataset_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,16 +62,17 @@ def items(self) -> list[QTreeWidgetItem]:
root = self.invisibleRootItem()
return [root.child(i) for i in range(root.childCount())]

def add_item(self, name: str):
def add_item(self, name: str) -> QTreeWidgetItem:
item = QTreeWidgetItem()
self.addTopLevelItem(item)
item.setText(0, name)
return item

def add_node_layer(self, element: Input) -> None:
def add_node_layer(self, element: Input) -> QTreeWidgetItem:
# These are mandatory elements, cannot be unticked
item = self.add_item(name=element.input_type())
item.element = element
item.element = element # type: ignore[attr-defined]
return item

def remove_geopackage_layers(self) -> None:
"""
Expand Down Expand Up @@ -260,16 +261,32 @@ def load_geopackage(self) -> None:
self.dataset_tree.clear()
geo_path = get_database_path_from_model_file(self.path)
nodes = load_nodes_from_geopackage(geo_path)
for node_layer in nodes.values():
self.dataset_tree.add_node_layer(node_layer)

name = self.path.stem
self.ribasim_widget.create_groups(name)
for item in self.dataset_tree.items():

# Make sure "Node", "Edge", "Basin / area" are the top three layers
node = nodes.pop("Node")
item = self.dataset_tree.add_node_layer(node)
self.add_item_to_qgis(item)

edge = nodes.pop("Edge")
item = self.dataset_tree.add_node_layer(edge)
self.add_item_to_qgis(item)

basin_area_layer = nodes.pop("Basin / area", None)
if basin_area_layer is not None:
item = self.dataset_tree.add_node_layer(basin_area_layer)
self.add_item_to_qgis(item)

# Add the remaining layers
for node_layer in nodes.values():
item = self.dataset_tree.add_node_layer(node_layer)
self.add_item_to_qgis(item)

# Connect node and edge layer to derive connectivities.
self.node_layer = nodes["Node"].layer
self.edge_layer = nodes["Edge"].layer
self.node_layer = node.layer
self.edge_layer = edge.layer
self.edge_layer.editingStopped.connect(self.explode_and_connect)
return

Expand Down
2 changes: 2 additions & 0 deletions utils/templates/model.py.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ import pandera as pa
from pandera.dtypes import Timestamp
from pandera.typing import Series


class _BaseSchema(pa.DataFrameModel):
class Config:
add_missing_columns = True
coerce = True


{% for m in models %}
class {{m[:name]}}Schema(_BaseSchema):
{% for f in m[:fields] %}
Expand Down
Loading