From c91ed7ba2c99fede5fb6d202b11a5350332c880a Mon Sep 17 00:00:00 2001 From: Martijn Visser Date: Mon, 30 Jan 2023 11:20:21 +0100 Subject: [PATCH 01/33] Initial commit --- .gitignore | 129 +++++++++++++++++++++++++++++++++++++++++++++++++++++ LICENSE | 21 +++++++++ README.md | 2 + 3 files changed, 152 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..b6e47617d --- /dev/null +++ b/.gitignore @@ -0,0 +1,129 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..b11ba51f1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Deltares + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 000000000..4eea6925b --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# ribasim-python +Python package for working with Ribasim.jl From 2a449ed13817997ade1a90dfc1fd0c0da4c7389e Mon Sep 17 00:00:00 2001 From: Martijn Visser Date: Mon, 30 Jan 2023 11:21:51 +0100 Subject: [PATCH 02/33] Update README.md --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4eea6925b..046832e65 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,3 @@ # ribasim-python -Python package for working with Ribasim.jl + +(This will be a) Python package for working with [Ribasim.jl](https://github.com/Deltares/Ribasim.jl) From 0dcdfbc739162f0fbea12e0e017e6ddd5d8880c1 Mon Sep 17 00:00:00 2001 From: Huite Bootsma Date: Thu, 16 Feb 2023 17:41:34 +0100 Subject: [PATCH 03/33] Start with plugin --- README.md => README.rst | 0 plugin/ribasim_qgis/__init__.py | 9 + plugin/ribasim_qgis/core/__init__.py | 0 plugin/ribasim_qgis/core/geopackage.py | 94 +++ plugin/ribasim_qgis/core/nodes.py | 280 +++++++++ plugin/ribasim_qgis/core/topology.py | 41 ++ plugin/ribasim_qgis/icon.png | Bin 0 -> 8096 bytes plugin/ribasim_qgis/metadata.txt | 47 ++ plugin/ribasim_qgis/resources.py | 569 ++++++++++++++++++ plugin/ribasim_qgis/resources.qrc | 5 + plugin/ribasim_qgis/ribasim_qgis.py | 56 ++ plugin/ribasim_qgis/widgets/dataset_widget.py | 290 +++++++++ plugin/ribasim_qgis/widgets/nodes_widget.py | 66 ++ plugin/ribasim_qgis/widgets/ribasim_widget.py | 141 +++++ ribasim/__init__.py | 0 tox.ini | 39 ++ 16 files changed, 1637 insertions(+) rename README.md => README.rst (100%) create mode 100644 plugin/ribasim_qgis/__init__.py create mode 100644 plugin/ribasim_qgis/core/__init__.py create mode 100644 plugin/ribasim_qgis/core/geopackage.py create mode 100644 plugin/ribasim_qgis/core/nodes.py create mode 100644 plugin/ribasim_qgis/core/topology.py create mode 100644 plugin/ribasim_qgis/icon.png create mode 100644 plugin/ribasim_qgis/metadata.txt create mode 100644 plugin/ribasim_qgis/resources.py create mode 100644 plugin/ribasim_qgis/resources.qrc create mode 100644 plugin/ribasim_qgis/ribasim_qgis.py create mode 100644 plugin/ribasim_qgis/widgets/dataset_widget.py create mode 100644 plugin/ribasim_qgis/widgets/nodes_widget.py create mode 100644 plugin/ribasim_qgis/widgets/ribasim_widget.py create mode 100644 ribasim/__init__.py create mode 100644 tox.ini diff --git a/README.md b/README.rst similarity index 100% rename from README.md rename to README.rst diff --git a/plugin/ribasim_qgis/__init__.py b/plugin/ribasim_qgis/__init__.py new file mode 100644 index 000000000..be1a5afca --- /dev/null +++ b/plugin/ribasim_qgis/__init__.py @@ -0,0 +1,9 @@ +""" +This script initializes the plugin, making it known to QGIS. +""" + + +def classFactory(iface): # pylint: disable=invalid-name + from ribasim_qgis.ribasim_qgis import RibasimPlugin + + return RibasimPlugin(iface) diff --git a/plugin/ribasim_qgis/core/__init__.py b/plugin/ribasim_qgis/core/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/plugin/ribasim_qgis/core/geopackage.py b/plugin/ribasim_qgis/core/geopackage.py new file mode 100644 index 000000000..c863be204 --- /dev/null +++ b/plugin/ribasim_qgis/core/geopackage.py @@ -0,0 +1,94 @@ +""" +Geopackage management utilities. + +This module lightly wraps a few QGIS built in functions to: + + * List the layers of a geopackage + * Write a layer to a geopackage + * Remove a layer from a geopackage + +""" +import sqlite3 +from contextlib import contextmanager +from typing import List + +from qgis import processing +from qgis.core import QgsVectorFileWriter, QgsVectorLayer + + +@contextmanager +def sqlite3_cursor(path): + connection = sqlite3.connect(path) + cursor = connection.cursor() + try: + yield cursor + finally: + cursor.close() + connection.close() + + +def layers(path: str) -> List[str]: + """ + Return all layers that are present in the geopackage. + + Parameters + ---------- + path: str + Path to the geopackage + + Returns + ------- + layernames: List[str] + """ + with sqlite3_cursor(path) as cursor: + cursor.execute("Select table_name from gpkg_contents") + layers = [item[0] for item in cursor.fetchall()] + return layers + + +def write_layer( + path: str, layer: QgsVectorLayer, layername: str, newfile: bool = False +) -> QgsVectorLayer: + """ + Writes a QgsVectorLayer to a GeoPackage file. + + Parameters + ---------- + path: str + Path to the GeoPackage file + layer: QgsVectorLayer + QGIS map layer (in-memory) + layername: str + Layer name to write in the GeoPackage + newfile: bool, optional + Whether to write a new GPGK file. Defaults to false. + + Returns + ------- + layer: QgsVectorLayer + The layer, now associated with the both GeoPackage and its QGIS + representation. + """ + options = QgsVectorFileWriter.SaveVectorOptions() + options.driverName = "gpkg" + options.layerName = layername + if not newfile: + options.actionOnExistingFile = QgsVectorFileWriter.CreateOrOverwriteLayer + write_result, error_message = QgsVectorFileWriter.writeAsVectorFormat( + layer, path, options + ) + if write_result != QgsVectorFileWriter.NoError: + raise RuntimeError( + f"Layer {layername} could not be written to geopackage: {path}" + f" with error: {error_message}" + ) + layer = QgsVectorLayer(f"{path}|layername={layername}", layername, "ogr") + return layer + + +def remove_layer(path: str, layer: str) -> None: + query = {"DATABASE": f"{path}|layername={layer}", "SQL": f"drop table {layer}"} + try: + processing.run("native:spatialiteexecutesql", query) + except Exception: + raise RuntimeError(f"Failed to remove layer with {query}") diff --git a/plugin/ribasim_qgis/core/nodes.py b/plugin/ribasim_qgis/core/nodes.py new file mode 100644 index 000000000..64d9f59ac --- /dev/null +++ b/plugin/ribasim_qgis/core/nodes.py @@ -0,0 +1,280 @@ +""" +This module contains the classes to represent the Ribasim ndoe layers. + +The classes specify: + +* The (unabbreviated) name +* 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: + +* It always lives in a GeoPackage. +* While a geopackage is active within plugin, it is always represented in a + Dataset Tree: the Dataset Tree provides a direct look at the state of the + GeoPackage. In this tree, steady and transient input are on the same row. + Associated input is, to potentially enable transient associated data later + on (like a building pit with changing head top boundary). +* It can be added to the Layers Panel in QGIS. This enables a user to visualize + and edit its data. + +""" + +import abc +from typing import Any, List, Tuple + +from PyQt5.QtCore import QVariant +from PyQt5.QtWidgets import ( + QDialog, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QVBoxLayout, +) +from qgis.core import ( + QgsDefaultValue, + QgsFeature, + QgsField, + QgsFillSymbol, + QgsGeometry, + QgsLineSymbol, + QgsPointXY, + QgsSingleSymbolRenderer, + QgsVectorLayer, +) +from ribasim_qgis.core import geopackage + + +class NameDialog(QDialog): + def __init__(self, parent=None): + super(NameDialog, self).__init__(parent) + self.name_line_edit = QLineEdit() + self.ok_button = QPushButton("OK") + self.cancel_button = QPushButton("Cancel") + self.ok_button.clicked.connect(self.accept) + self.cancel_button.clicked.connect(self.reject) + first_row = QHBoxLayout() + first_row.addWidget(QLabel("Layer name")) + first_row.addWidget(self.name_line_edit) + second_row = QHBoxLayout() + second_row.addStretch() + second_row.addWidget(self.ok_button) + second_row.addWidget(self.cancel_button) + layout = QVBoxLayout() + layout.addLayout(first_row) + layout.addLayout(second_row) + self.setLayout(layout) + + +class RibasimInput(abc.ABC): + """ + Abstract base class for Ribasim input layers. + """ + + element_type = None + geometry_type = None + attributes = [] + + def _initialize_default(self, path, name): + """Things to always initialize for every input layer.""" + self.name = name + self.path = path + self.ribasim_name = f"Ribasim {self.element_type}:{name}" + self.layer = None + + @abc.abstractmethod + def _initialize(self, path, name): + pass + + def __init__(self, path: str, name: str): + self._initialize_default(path, name) + self._initialize() + + @staticmethod + def dialog( + path: str, crs: Any, iface: Any, klass: type, names: List[str] + ) -> Tuple[Any]: + dialog = NameDialog() + dialog.show() + ok = dialog.exec_() + if not ok: + return + + name = dialog.name_line_edit.text() + if name in names: + raise ValueError(f"Name already exists in geopackage: {name}") + + instance = klass(path, name) + instance.create_layers(crs) + return instance + + def new_layer(self, crs: Any, geometry_type: str, name: str, attributes: List): + layer = QgsVectorLayer(geometry_type, name, "memory") + provider = layer.dataProvider() + provider.addAttributes(attributes) + layer.updateFields() + layer.setCrs(crs) + self.layer = layer + return + + def renderer(self): + return + + def layer_from_geopackage(self) -> QgsVectorLayer: + self.timml_layer = QgsVectorLayer( + f"{self.path}|layername={self.ribasim_name}", self.ribasim_name + ) + return + + def from_geopackage(self): + self.layer_from_geopackage() + return (self.layer, self.renderer()) + + def write(self): + self.layer = geopackage.write_layer(self.path, self.layer, self.ribasim_name) + return + + def remove_from_geopackage(self): + geopackage.remove_layer(self.path, self.ribasim_name) + + +class Edges(RibasimInput): + def _initialize(self): + self.element_type = "edge" + self.geometry_type = "Linestring" + self.attributes = [ + QgsField("from_id", QVariant.Int), + QgsField("from_node", QVariant.String), + QgsField("to_id", QVariant.Int), + QgsField("to_node", QVariant.String), + ] + + +class Lsw(RibasimInput): + def _initialize(self): + self.element_type = "node" + self.geometry_type = "Point" + self.attributes = [ + QgsField("id", QVariant.Int), + ] + + +class LswLookup(RibasimInput): + def _initialize(self): + self.element_type = "lookup_LSW" + self.geometry_type = "No Geometry" + self.attributes = [ + QgsField("id", QVariant.Int), + QgsField("volume", QVariant.Double), + QgsField("area", QVariant.Double), + QgsField("level", QVariant.Double), + ] + + +class OutflowTableLookup(RibasimInput): + def _initialize(self): + self.element_type = "lookup_OutflowTable" + self.geometry_type = "No Geometry" + self.attributes = [ + QgsField("id", QVariant.Int), + QgsField("level", QVariant.Double), + QgsField("discharge", QVariant.Double), + ] + + +class Bifurcation(RibasimInput): + def _initialize(self): + self.element_type = "static_Bifurcation" + self.geometry_type = "No Geometry" + self.attributes = [ + QgsField("id", QVariant.Int), + QgsField("fraction_1", QVariant.Double), + QgsField("fraction_2", QVariant.Double), + ] + + +class LevelControl(RibasimInput): + def _initialize(self): + self.element_type = "static_LevelControl" + self.geometry_type = "No Geometry" + self.attributes = [ + QgsField("id", QVariant.Int), + QgsField("target_volume", QVariant.Double), + ] + + +class LswState(RibasimInput): + def _initialize(self): + self.element_type = "state_LSW" + self.geometry_type = "No Geometry" + self.attributes = [ + QgsField("id", QVariant.Int), + QgsField("S", QVariant.Double), + QgsField("C", QVariant.Double), + ] + + +class LswForcing(RibasimInput): + def _initialize(self): + self.element_type = "forcing_LSW" + self.geometry_type = "No Geometry" + self.attributes = [ + QgsField("id", QVariant.Int), + QgsField("time", QVariant.QDateTime), + QgsField("P", QVariant.Double), + QgsField("ET", QVariant.Double), + ] + + +NODES = { + "Edges": Edges, + "LSW": Lsw, + "lookup LSW": LswLookup, + "lookup OutflowTable": OutflowTableLookup, + "static Bifurcation": Bifurcation, + "static LevelControl": LevelControl, +} + + +def parse_name(layername: str) -> Tuple[str, str]: + """ + Based on the layer name find out: + + * whether it's a Ribasim input layer; + * which element type it is; + * what the user provided name is. + + For example: + parse_name("Ribasim Edges: network") -> ("Edges", "network") + """ + values = layername.split("_") + if len(values) == 2: + _, kind = values + nodetype = None + elif len(values) == 3: + _, kind, nodetype = values + else: + raise ValueError( + 'Expected layer name of "ribasim_{kind}_{nodetype}", ' + f'"ribasim_node", "ribasim_edge". Received {layername}' + ) + return kind, nodetype + + +def load_nodes_from_geopackage(path: str) -> List[RibasimInput]: + # List the names in the geopackage + gpkg_names = geopackage.layers(path) + + # Group them on the basis of name + nodes = [] + for layername in gpkg_names: + if layername.startswith("ribasim_"): + kind, nodetype = parse_name(layername) + if kind in ("node", "edge"): + key = kind + else: + key = f"{kind} {nodetype}" + nodes.append(NODES[key](path)) + + return nodes diff --git a/plugin/ribasim_qgis/core/topology.py b/plugin/ribasim_qgis/core/topology.py new file mode 100644 index 000000000..aec048725 --- /dev/null +++ b/plugin/ribasim_qgis/core/topology.py @@ -0,0 +1,41 @@ +import numpy as np + + +def derive_connectivity(node, edge): + """ + Derive connectivity on the basis of xy locations. + + If the edges have been setup neatly through snapping in QGIS, the points + should be the same. + """ + # collect xy + # stack all into a single array + n_node = node.featureCount() + node_xy = np.empty((n_node, 2), dtype=float) + node_index = np.empty(n_node, dtype=int) + for i, feature in node.getFeatures(): + point = feature.geometry() + node_xy[i, 0] = point.x() + node_xy[i, 1] = point.y() + node_index[i] = feature.attribute(0) + + edge_xy = np.empty((edge.featureCount(), 2, 2), dtype=float) + for i, feature in edge.getFeatures(): + geometry = feature.geometry().asPolyLine() + for j, point in enumerate(geometry): + edge_xy[i, j, 0] = point.x() + edge_xy[i, j, 1] = point.y() + edge_xy = edge_xy.reshape((-1, 2)) + + xy = np.vstack([node_xy, edge_xy]) + _, inverse = np.unique(xy, return_inverse=True, axis=0) + edge_node_id = inverse[node_xy.size :].reshape((-1, 2)) + try: + from_id = node_index[edge_node_id[:, 0]] + to_id = node_index[edge_node_id[:, 1]] + except IndexError: + raise ValueError( + "Edge layer contains vertices that are not present in node layer. " + "Please ensure all edges are snapped to nodes exactly." + ) + return from_id, to_id diff --git a/plugin/ribasim_qgis/icon.png b/plugin/ribasim_qgis/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..69dc0d9696f805748b47703425e3abeeb02c2b8a GIT binary patch literal 8096 zcmV;RA79{!P)Dg|00001b5ch_0Itp) z=>Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!~g&e!~vBn4jTXfA2mrtK~#8N?VSf$ zR7cmxkAQ#(f`T9_cB5FL24mN#*rQ_aH5xURL|7E zMg;*u1O!A7kZ=BH@5-`lxp&#c{fIxGeeljM%kG?+GpEhmd>I1LK~ho@B_}6ST3Q;t zeECvpeM4?xV`D>hc6Q|G=txdZPEu=DvI#FCA%WuJ;`o>I<;y3vzTtHN@!;X=>Pjvy zE>f$CEW(S7jHJ}m)Hi~&G9V&oKYM$7^7QnSS~Y|R!9hhraB3hwCr3MSu(u^UTL|-w zKywR-B{l6OB_*fPi&WLJ1pz{pdU<*AH4863KA!NfwYB9&V4vTH9EH&4241C|heGH< zM1u6p8!Qc`Qvoq)-l^h2!@B!Y6F+Zy9GlF~rKhLAQlTuo(9lp8oLW_4rbRwUq7EZ> z3u$NY8>wdA8*&Q>5Hm04X$o~}UWOJ-ZcI@z&v`~dfIdDxlrJ$cF+**j02`ZptQ`Hi zZzNY|dofKj)#w{u4>3=ZsavaZG<{rM7ADlEo0}Wiz#Y&uP6`#wPm^YyrUDLPwpo$j zFyfvI{r@x}g<|8A`Db!U8n@?l3Kw#q)!S~<&HHhz0UB2*Sd`GSot{v&%>Sgd|Y6aeK*Kbci+ZG$EO4`WB7g&adstV3)tiNyyMKUdA@sA@36hPhpMbiz8$t7yx{YE4PZy3dE# zm#*Y7Zg3s`y?+ZD0biMd?Z&czw!?H0dhuW3|Fd6Kd1bU3WBHA7x9r3U!C8fSv&_QZiIvX+Rc{HVEwZhA9XG7=zek5JV=+QAr zH6;B^uvT^6eoPf#H1H_CMsjK!gF-6r7in!(XIP2Qj1|QOAnm9PmjfngJ0OuKdZ6R zt9=y;`D-+_Z(4!EA`{pZu@so$ShFoX)2&u`a6*fDIFo<>-|5lgM5$Fb_a4Sk`(f*d zL|^?p$)6he`AE-XXVw=j>2P3wN)bLGW}&&jY)%qxO5~Gg{Jt&)?b&UKjDE&HEiv1z zPI#CRP;W7D&$$x{I@#0P9hTB}i;lAaqRI1=H2P}J5vtpD1!qJ022`a%UEY?S)lZcQ z9&~H(2=cFAh9ZQDeQBU$(dn2)2ms)xF7W$M)mUGz>=;V_92iL_)(xb)!5;~gJ&by^ zt<2AxdnV=!4-bF!jxZ8#-ixE&6N0ITYwpJhk-tKaeCDJUY;0V*8AWS%UZc~O!#TqN z!=t#TiO)m47Uecw+zpAP#=TdQM`7{1VxTDneN)n_ zAZ?r9hMvSFs{+&3B0P*r%s8w|+B_38aADASTD|2euUF_~J6n~~NEGhDq~Y~x!iV+v z+7!?+2yTZ*C6KM?6r`I9I_0N{!|Kz(&ef$?skklH;N}AYsj#afhpV6_H_hux4c;lK zdVv8h-H4=CgZ`jmMGNw>paiQHUP59DeI`0!QTVJyYZse5RGBe9wYnuQIeUmA66kes7zNE2lnl9o3=y#pe1WB()XV>qa90nNUeq`Ce~xvI51*r zSNE1$l5W7O0X4cwYl5|>zH}syA%>uV{S42`(UcmWjV!*b@uO_AIdT|w5e3qoo62mC?z0> z00EiuxN^|g9txc#MWCGx9R~MbJZ+UO&8N)mXdrH^uMpJa`bI$uAo{7RKXEA%#Y7Eg1 z_=11meoVn99#H6`c%I!@SK$(z+AxU9mnzJ!HHEWRBB;ag_3TpI+5eH$Y8>A-i`mUl zI{U1~RX~TsYo69l&x((?kIaM*H^7IVUdZnVQy!$SgK{4E#S}4pjHW5{gd%>jbUH>Ne50oAZI7# zOT=q+70_`g;R_dQMEO!~v}Iv8R$D#W`Et|*;V?t2*?N`6OxsItg&fJtqaZuUuuxGk zhM#ChTM@Sc{J{V5oRgT5YKIoR97`sAS{0;xiTCk!}hYIIs}+ z8c@wYEjvXEem}=57o7q%yKYWrs#T?!>IFLJ@yTX7DlAs0&%|e`)VqBZYF4i_2M|zO zdgycrYYb=cd`?`UTvwZL0bpI#dAm3*{-!O*EA=t z(!B=V*fX3iZ2f>{f6k(ePc z7PKiOI?qtfO%sIL;M1MiG+1gO`sKeZsB+mNY>l=U7|3mvS}&$YkDqa@)ELO>A3hU8 zSB2Z5ly>h`pes8+MAXC5+d%35Wj;( zfBmlOQbQ)Cgn=XHi{tabhl3@n9kt)LvJd-K$jA9Kna64v{-GSOULg-R?%VxRi*ovO ztjb1{Ig#DN=okpCUDNV(@4!epzvTnkG`}lbZ}89NeA$wt>@X70F&C~yO1~+Or-><4 zP*`ItHeHcgGV%6w=5<4RhHHZXGDLTwr`$xf#U|Q*aZS zMXvUci>z( zT{p6s*Y#?ZV2e>3Xa!W4t()ChYBA25p96RuGsS@L5Pk8%q#e?gd{A1U$DlPCqryXR z&(Ll)q$_#!?^KkGvuOzRh+90rvta?N7 zyb`dd4{NxwbyOP;D2rbiFk!pYA`h&!rbdVKVN`ffRW+-q&iznAQFkZyZ_ddmS)Z}= z6jy<%!PzgXwp`(YBsFlB@#QraeasxJCG)>(Ew#v}Lg~UZrhjeTBCHfdVNl>ZeerRr z#W;wS=DP4O%An4aQo!Dht= z@wF*T8Pk{t4c2F+z;&3j@+{ZB$RRv3kt@^8iP3k1nz;AysdOcuFyU_@{s#4F2*ec8 z5!$go7zcMA#M0bVXQUR@x~Dc;pk_PxS2P$jmcz#RUATNtpZY{Qz5`}C9{9idwa9L) z*mPM{t!;#mNAYB1=Cf>!3J=kR4SQ}#SMorj5<*ACbIXQhrPl21ICz^b3%^NUW*KRn zsq>C;$_2bz&6`7C=)eT=-pjswpWAanMd4m_Z(Et;uxb!FLcTFd2o)|8)>?cYJP)0LZ#$+O-pTDIYm^bXlUU|e0OYc7mt6)?*1Vb53^rLv*50Dm6|)WDe?sa2zLRJ`FFo|Q=ADh2>wYX7TW2muaO&skt;g)@d^xHSeLSGH^h)BWPQC6c`Tb4B zBhhG~A5G4-?awEP7 zqXl~n%*ou%*j}D zqbM1R1sj_?s+9NOaF~y`3+K&FT?`i^?j{Qv2~9-wp$_%YVo)GkUr>Ka;I-~q3f((6 zN@_7LGk-fp^M60j9SzNa5(j)g>?Z2@zAtrdSxI`OGIk!iL*u6hb7HuVvwb$>Ob%wt zq{C>y2SFAA9|R)~i#&GI95521LY-r+fMuMVRr)1h01fF@gV#Gt0QKxue|DD7hBkaR zSho-)b|WGA+M|BOhb>a3oZX2piv=I)AxqM}Sp}V8H~>K+_KGC|gLa-U za$x=H%0cP_1`*O}a6>Swb3%@V_e`t{6BV3Pgw&2H5sfrq$r&mFG8Z*`y;wyV18NTf zM1TP$i7?jWf&uu=xc4{{k`r<)JRCNM?7CdWe^8fV6L8?CvQdYZy9#w{X%H?`G9w^F z>>5H4eo>J8(|NeVnB?(n2*?HNd%UnTEls=_ zSf;GUn1MemIU!xiL$0f&j~ojR=KjrlF|=&`MXAL&wX1p4-QbbDb|9_@AsQQi2u&fP zVQH{;Txp2}z~EA%1`&>IN)!f@_5I=J5q~f*e=#CQ;s}FOtfq3%F>m7E?$qM^34~L zQvo|l7vB%-9;{oXHUKb-@Blg&<>)6bg>k@n$y#McPhlSdPIS1meJGt1rZfV(sJOz$ z6l{V-+z@fl*(;U#R!Rsyai0f_QU@AF2QuXohZ#PRV)n{2)V%-iyza-vr%;uO9@MH) zS*j^4TvX5DkOU=%+*xF=0!9&b!1sI68y4>8_GMzKTK#&{dF9kfQs>vU+p1B zG4r+K!e)!G7z4t4t85X9KR=#*p=$5{O4Y>!k$X8?r!>Kqs9`;8(Xq7yXxb-DgzB`V zzT>w^&zcYQXG3TVSqp^!+nD1^YF+R+Pt6}pJY_5?kwJ}HDuCG;TN5!W9J@nxJVzUW5Fp!A5Gp( zQ|27u!GNev?{QnCSL7TN(oUUwl-Ig`0oCa0&JWq&lT+c0K>fn0ZKSL096b4eBQOxg z!PR}TRdVa%?3Te4BW9N|AJ8Id6C<%-r|L9ibR(%H6MTswlm6rr{c!w0!d{5Dpr96I z0uT;V3px(gC$bb61W+Z&>Z?s_)RH1gzx3O7d~GU7aW(7rJC`)zFu7)ROVOffs##R6 z-LI4({y~lE0-w>hygLs}efV|yjn^~Oye|K~z0_))?-m}T-!@);wHY(l)FuQ{%3F8V z9UZZ1Mh7~yrZ2y)U;%qJbPzH^)*8i5@U!rtxlKHWDr`8knm!TQf{)*SwQi`xhi1_vJ_*K;4Qrd~l``*pCkObx`g~qB7Bg6I zGM^j}VMm>&l2Dl@Sf?;M=L+E=7dQCx9rVY}Yn*k*%z(9u>^Rn*Z{{Cm!zp0I23|{1 z39dOn@PEwwiyE6`kh}A6bIKA-SNUWaG^(%RqpL~?&7t;HO^Mo|?9vdH5uQ*fN{VeR zY#qYBoT^ilgHCr5Yb6HEi3?#g>8C@~a!?ReZnK0cwpc`UyRD!>6L-+c&8i(p_-y*t zvHbaMvhA^$Gr47!*#~z5XNbuG8U|YT*8L|^i#)h8%}DCZVlxj;w-N$p25^omCqRg> z=AcfoX2QwG@rWLURi{Jnesd!;0I}MW!YbATQ6TI?#O#A05}&`2ofPiYMiq5g_ouS* z$qjpM8a*1zBErLBuJn|jChwNw{Rnc*b7Npr2Tp##K5-mPCD&&ISffr~$|^iCX+(Vv z*}cqo$pcfaX6Y|n4+wpgnDzRSyt?>)@d+MmhOmtAq@PMzHoZNU_!;wW41sOYs54QQ z@1466!S04!V7%Z64sB3;gwPcZ2*7qTxj+-t@bzSE(4faEsnsxThpgoYi`tOVhed>E z_)qEwU$9%7N+!)d2prYTNOJxv`F^nSP^u3rQZ7)vSod)3l{VOoggTnCrIejlhO?*P z79osQrR@^EyKC|J>-ShmcOJwTRhhsd!o!F$RsH?^xjzSamCQc878d1q*ua}Lw|HRtGC;gG{$ zt=VxI8*awbr5cL}4~Mwv+TAJ#eDHo9tH~~{z=($6f_@1QW_?YmRmIHTPV;9`3OX1i z{@T1--J8oXl&E2V*Q5;PhDR0Mey? zjDdR};NWj#t^*7anA9gX4CH+bngofCLD8%+>rxk(srs_w&FhusjENFZ$g6#H6$FZb zg+KuM3n4(ISVVXLB@SnVu>ci;GcIv@`u}DgqD{gC))lePX2lQf_3m6n8{Pw%>(C=( zc&Cpx6Ms4&)=<;ZP_Vlm+XjC%M@7-hI!%&+S% z(Wt3=s8Y*CRHy3-KIL2=ts9r4(f#~bov6b^u=Wu2hMFAFyB43BsmmC_(TIq=7_-*& zfxoBk06VxdcVyJkpgJ1czOLVS!iJb7u!!(5a$&N=NygC{TNZSsy(@atlCRr{kz1P0 zQ7p`@2M5uny*H)b$_|W=`CqjWZ>TyT^~H;q)KxgWVMoW%q~W?t-Vp}dy8kwZ+)M$V z9qTOyk1h}~!QI020ii+DEM0$zbAy(cacaz-62h0qu~0DAwQt6x2I>%d`0!dJK*NuI zOpZA$g9ET|%QTx$o<&qy0wtgmP*Z}>HvQA4oB-B`YptK@>4ovqmCD4&{&bY`yE;3N zldy2jy^+%5DIo|0M+4(L6n%mN7?Ia@4P%1@p3mmRA1Q-k{(Bssu3mFw0vB?NOuSQa&^YMOfIw zVQ#_c!>lt8J`ipwG1*9}M9r03QPgr^kmxi!dT=Ob+qV1%{La1^vJ>uwov6ET_7Rix zE${IvUk4DPLh%o>3Tk%*hK;^8P8a; zd5Tf2I+qegh^b&h|A~v?T!n?O81AGsd30m84BCK5M~n| zq7qp1l!9^ssGb{l)GWt&DCM^B^Nb}*a|5#pPwj{+<-~=D9L+$b zwH#21jf!lPPxNT#E4AhxaNuFFni>EwCuS2K)Ce?*(sMRZjEW*8E0PD5(g zYZZO=<3ZXyzboH5G^Zf$ielSo^Nw;2DGHN}v2@)fK5#&3ros}K)m6Yk4oCU6%-gf{ z8oOB-Dp725V?^h-wV|C$d+>&PHT#dSDbz9ybq780+GvUVr z9Gk_-5SWpu=0;JIlB~Qr$u;2t2msb69B_3MB1X(Ar1ud*!rw9n$TVP9;Cz-JK5H(u z>ZaG&Ep$w%N`&W5Z#1kKFfe@EF5!HexsO^j1&c;lw85HynT5^v5Eg{zT~u`gCvyo7 zpkq;%w|@5xzP;JS8@kW;{N$UxEI6oKV}b)v*TF0M2+LA;xw|!xD?P#3k~nf z8&t$v)M`)=^%}Q@9>-+XfcE@kGi}*-n+MRRL+VPc#=-sd+BPZ2%YZfV`t^Ysfg#~t z)P>8{Yjjvf62D!3C~&hn^Wfx(UiZ9q* z#*GXCcF$san5A4CrD)5>MsqRd&GX_5TI&LB4iN1*FI)*6W`nI@y1=g5s_lN|s%<6Z z=avLAQOPFrI$uOYL!>+lH; znw#kJA{ej;nIT(%J?*^V!^#8)V&hY&f5(bcsf-(;&ip4zhfEwh} z5Rh#@YB!Hal#W|N&Bnh8%qPsSn5QY!NvO*OUpA(w7}dVT^z?M{@$pd!FFrn=@PGi- z;LJ$~abDn6+Hoj^LL$_4m2ZS-X&@k+vGz`tqBN|BFE#P=rpK|WomCK=tE(%yxVWf< zhsMarNTDWi1Hw$Tfr*G&2O+9XWPc;L+yY|3%)2~SN@QW7O6 uC$lPr)ZdW1VDdmk!m4y~(skww(f + + icon.png + + diff --git a/plugin/ribasim_qgis/ribasim_qgis.py b/plugin/ribasim_qgis/ribasim_qgis.py new file mode 100644 index 000000000..2c2395c9f --- /dev/null +++ b/plugin/ribasim_qgis/ribasim_qgis.py @@ -0,0 +1,56 @@ +""" +Setup a dockwidget to hold the ribasim plugin widgets. +""" +from pathlib import Path + +from qgis.gui import QgsDockWidget +from qgis.PyQt.QtCore import Qt +from qgis.PyQt.QtGui import QIcon +from qgis.PyQt.QtWidgets import QAction + + +class RibasimDockWidget(QgsDockWidget): + def closeEvent(self, event) -> None: + # TODO: if we implement talking to a Julia server, shut it down here. + event.accept() + + +class RibasimPlugin: + def __init__(self, iface): + # Save reference to the QGIS interface + self.iface = iface + self.ribasim_widget = None + self.plugin_dir = Path(__file__).parent + self.pluginIsActive = False + self.toolbar = iface.addToolBar("Ribasim") + self.toolbar.setObjectName("Ribasim") + return + + def add_action(self, icon_name, text="", callback=None, add_to_menu=False): + icon = QIcon(str(self.plugin_dir / icon_name)) + action = QAction(icon, text, self.iface.mainWindow()) + action.triggered.connect(callback) + if add_to_menu: + self.toolbar.addAction(action) + return action + + def initGui(self): + icon_name = "icon.png" + self.action_timml = self.add_action( + icon_name, "Ribasim", self.toggle_ribasim, True + ) + + def toggle_ribasim(self): + if self.ribasim_widget is None: + from ribasim_qgis.widgets.ribasim_widget import RibasimWidget + + self.ribasim_widget = RibasimDockWidget("Ribasim") + self.ribasim_widget.setObjectName("RibasimDock") + self.iface.addDockWidget(Qt.RightDockWidgetArea, self.ribasim_widget) + widget = RibasimWidget(self.ribasim_widget, self.iface) + self.ribasim_widget.setWidget(widget) + self.ribasim_widget.hide() + self.ribasim_widget.setVisible(not self.ribasim_widget.isVisible()) + + def unload(self): + self.toolbar.deleteLater() diff --git a/plugin/ribasim_qgis/widgets/dataset_widget.py b/plugin/ribasim_qgis/widgets/dataset_widget.py new file mode 100644 index 000000000..17ff5a094 --- /dev/null +++ b/plugin/ribasim_qgis/widgets/dataset_widget.py @@ -0,0 +1,290 @@ +""" +This widgets displays the available elements in the GeoPackage. + +This widget also allows enabling or disabling individual elements for a +computation. It also forms the link between the geometry layers and the +associated layers for homogeneities, or for timeseries layers for ttim +elements. + +Not every TimML element has a TTim equivalent (yet). This means that when a +user chooses the transient simulation mode, a number of elements must be +disabled (such as inhomogeneities). +""" +from pathlib import Path +from typing import Any, List, Set + +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import ( + QAbstractItemView, + QCheckBox, + QFileDialog, + QHBoxLayout, + QHeaderView, + QLineEdit, + QMessageBox, + QPushButton, + QSizePolicy, + QTreeWidget, + QTreeWidgetItem, + QVBoxLayout, + QWidget, +) +from qgis.core import QgsMapLayer, QgsProject +from ribasim_qgis.core.nodes import load_nodes_from_geopackage + + +class DatasetTreeWidget(QTreeWidget): + def __init__(self, parent=None): + super().__init__(parent) + self.setSelectionMode(QAbstractItemView.ExtendedSelection) + self.setHeaderHidden(True) + self.setSortingEnabled(True) + self.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Preferred) + self.setHeaderLabels(["", "steady", "", "transient"]) + self.setHeaderHidden(False) + header = self.header() + header.setSectionResizeMode(1, QHeaderView.Stretch) + header.setSectionResizeMode(3, QHeaderView.Stretch) + header.setSectionsMovable(False) + self.setColumnCount(4) + self.setColumnWidth(0, 1) + self.setColumnWidth(2, 1) + self.domain = None + + def items(self) -> List[QTreeWidgetItem]: + root = self.invisibleRootItem() + return [root.child(i) for i in range(root.childCount())] + + def add_item(self, timml_name: str, ttim_name: str = None, enabled: bool = True): + item = QTreeWidgetItem() + self.addTopLevelItem(item) + item.timml_checkbox = QCheckBox() + item.timml_checkbox.setChecked(True) + item.timml_checkbox.setEnabled(enabled) + self.setItemWidget(item, 0, item.timml_checkbox) + item.setText(1, timml_name) + item.ttim_checkbox = QCheckBox() + item.ttim_checkbox.setChecked(True) + item.ttim_checkbox.setEnabled(enabled) + if ttim_name is None: + item.ttim_checkbox.setChecked(False) + item.ttim_checkbox.setEnabled(False) + self.setItemWidget(item, 2, item.ttim_checkbox) + item.setText(3, ttim_name) + # Disable ttim layer when timml layer is unticked + # as timml layer is always required for ttim layer + item.timml_checkbox.toggled.connect( + lambda checked: not checked and item.ttim_checkbox.setChecked(False) + ) + item.assoc_item = None + return item + + def add_node_layer(self, element) -> None: + # These are mandatory elements, cannot be unticked + item = self.add_item( + timml_name=element.timml_name, ttim_name=element.ttim_name, enabled=True + ) + item.element = element + + def remove_geopackage_layers(self) -> None: + """ + Remove layers from: + + * The dataset tree widget + * The QGIS layer panel + * The geopackage + """ + + # Collect the selected items + selection = self.selectedItems() + # Append associated items + for item in selection: + if item.assoc_item is not None and item.assoc_item not in selection: + selection.append(item.assoc_item) + + # Warn before deletion + message = "\n".join([f"- {item.text(1)}" for item in selection]) + reply = QMessageBox.question( + self, + "Deleting from Geopackage", + f"Deleting:\n{message}", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if reply == QMessageBox.No: + return + + # Start deleting + elements = set([item.element for item in selection]) + qgs_instance = QgsProject.instance() + + for element in elements: + for layer in [ + element.timml_layer, + element.ttim_layer, + element.assoc_layer, + ]: + # QGIS layers + if layer is None: + continue + try: + qgs_instance.removeMapLayer(layer.id()) + except (RuntimeError, AttributeError) as e: + if e.args[0] in ( + "wrapped C/C++ object of type QgsVectorLayer has been deleted", + "'NoneType' object has no attribute 'id'", + ): + pass + else: + raise + + # Geopackage + element.remove_from_geopackage() + + for item in selection: + # Dataset tree + index = self.indexOfTopLevelItem(item) + self.takeTopLevelItem(index) + + return + + +class DatasetWidget(QWidget): + def __init__(self, parent): + super().__init__(parent) + self.parent = parent + self.dataset_tree = DatasetTreeWidget() + self.dataset_tree.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding) + self.dataset_line_edit = QLineEdit() + self.dataset_line_edit.setEnabled(False) # Just used as a viewing port + self.new_geopackage_button = QPushButton("New") + self.open_geopackage_button = QPushButton("Open") + self.remove_button = QPushButton("Remove from Dataset") + self.add_button = QPushButton("Add to QGIS") + self.new_geopackage_button.clicked.connect(self.new_geopackage) + self.open_geopackage_button.clicked.connect(self.open_geopackage) + self.suppress_popup_checkbox = QCheckBox("Suppress attribute form pop-up") + self.suppress_popup_checkbox.stateChanged.connect(self.suppress_popup_changed) + self.remove_button.clicked.connect(self.remove_geopackage_layer) + self.add_button.clicked.connect(self.add_selection_to_qgis) + # Layout + dataset_layout = QVBoxLayout() + dataset_row = QHBoxLayout() + layer_row = QHBoxLayout() + dataset_row.addWidget(self.dataset_line_edit) + dataset_row.addWidget(self.open_geopackage_button) + dataset_row.addWidget(self.new_geopackage_button) + dataset_layout.addLayout(dataset_row) + dataset_layout.addWidget(self.dataset_tree) + dataset_layout.addWidget(self.suppress_popup_checkbox) + layer_row.addWidget(self.add_button) + layer_row.addWidget(self.remove_button) + dataset_layout.addLayout(layer_row) + self.setLayout(dataset_layout) + + @property + def path(self) -> str: + """Returns currently active path to GeoPackage""" + return self.dataset_line_edit.text() + + def add_layer( + self, + layer: Any, + destination: Any, + renderer: Any = None, + suppress: bool = None, + on_top: bool = False, + ) -> QgsMapLayer: + return self.parent.add_layer( + layer, + destination, + renderer, + suppress, + on_top, + ) + + def add_item_to_qgis(self, item) -> None: + layers = item.element.from_geopackage() + suppress = self.suppress_popup_checkbox.isChecked() + timml_layer, renderer = layers[0] + maplayer = self.add_layer(timml_layer, "timml", renderer, suppress) + self.add_layer(layers[1][0], "ttim") + self.add_layer(layers[2][0], "timml") + + def add_selection_to_qgis(self) -> None: + selection = self.dataset_tree.selectedItems() + for item in selection: + self.add_item_to_qgis(item) + + def load_geopackage(self) -> None: + """ + Load the layers of a GeoPackage into the Layers Panel + """ + self.dataset_tree.clear() + nodes = load_nodes_from_geopackage(self.path) + for node_layer in nodes: + self.dataset_tree.add_node_layer(node_layer) + name = str(Path(self.path).stem) + self.parent.create_groups(name) + for item in self.dataset_tree.items(): + self.add_item_to_qgis(item) + + def new_geopackage(self) -> None: + """ + Create a new GeoPackage file, and set it as the active dataset. + """ + path, _ = QFileDialog.getSaveFileName(self, "Select file", "", "*.gpkg") + if path != "": # Empty string in case of cancel button press + self.dataset_line_edit.setText(path) + self.load_geopackage() + self.parent.toggle_element_buttons(True) + self.parent.on_transient_changed() + + def open_geopackage(self) -> None: + """ + Open a GeoPackage file, containing qgis-tim + """ + self.dataset_tree.clear() + path, _ = QFileDialog.getOpenFileName(self, "Select file", "", "*.gpkg") + if path != "": # Empty string in case of cancel button press + self.dataset_line_edit.setText(path) + self.load_geopackage() + self.parent.toggle_element_buttons(True) + self.dataset_tree.sortByColumn(0, Qt.SortOrder.AscendingOrder) + self.parent.on_transient_changed() + + def remove_geopackage_layer(self) -> None: + """ + Remove layers from: + * The dataset tree widget + * The QGIS layer panel + * The geopackage + """ + self.dataset_tree.remove_geopackage_layers() + + def suppress_popup_changed(self): + suppress = self.suppress_popup_checkbox.isChecked() + for item in self.dataset_tree.items(): + layer = item.element.timml_layer + if layer is not None: + config = layer.editFormConfig() + config.setSuppress(suppress) + layer.setEditFormConfig(config) + + def active_nodes(self): + active_nodes = {} + for item in self.dataset_tree.items(): + active_nodes[item.text(1)] = not (item.timml_checkbox.isChecked() == 0) + active_nodes[item.text(3)] = not (item.ttim_checkbox.isChecked() == 0) + return active_nodes + + def selection_names(self) -> Set[str]: + selection = self.dataset_tree.items() + # Append associated items + for item in selection: + if item.assoc_item is not None and item.assoc_item not in selection: + selection.append(item.assoc_item) + return set([item.element.name for item in selection]) + + def add_node_layer(self, element) -> None: + self.dataset_tree.add_node_layer(element) diff --git a/plugin/ribasim_qgis/widgets/nodes_widget.py b/plugin/ribasim_qgis/widgets/nodes_widget.py new file mode 100644 index 000000000..e6f5b698d --- /dev/null +++ b/plugin/ribasim_qgis/widgets/nodes_widget.py @@ -0,0 +1,66 @@ +from functools import partial + +from PyQt5.QtWidgets import QGridLayout, QPushButton, QVBoxLayout, QWidget +from ribasim_qgis.core.nodes import NODES + + +class NodesWidget(QWidget): + def __init__(self, parent): + super().__init__(parent) + self.parent = parent + + self.node_buttons = {} + for node in NODES: + if node in ("Edges", "Basins"): + continue + button = QPushButton(node) + button.clicked.connect(partial(self.new_node_layer, node_type=node)) + self.node_buttons[node] = button + self.toggle_node_buttons(False) # no dataset loaded yet + + node_layout = QVBoxLayout() + node_grid = QGridLayout() + n_row = -(len(self.node_buttons) // -2) # Ceiling division + for i, button in enumerate(self.node_buttons.values()): + if i < n_row: + node_grid.addWidget(button, i, 0) + else: + node_grid.addWidget(button, i % n_row, 1) + node_layout.addLayout(node_grid) + node_layout.addStretch() + self.setLayout(node_layout) + + def toggle_node_buttons(self, state: bool) -> None: + """ + Enables or disables the node buttons. + + Parameters + ---------- + state: bool + True to enable, False to disable + """ + for button in self.node_buttons.values(): + button.setEnabled(state) + + def new_node_layer(self, node_type: str) -> None: + """ + Create a new Ribasim node input layer. + + Parameters + ---------- + node_type: str + Name of the element type. + """ + klass = NODES[node_type] + names = self.parent.selection_names() + node = klass.dialog( + self.parent.path, self.parent.crs, self.parent.iface, klass, names + ) + if node is None: # dialog cancelled + return + # Write to geopackage + node.write() + # Add to QGIS + self.parent.add_layer(node.timml_layer, "timml", node.renderer()) + # Add to dataset tree + self.parent.add_node(node) diff --git a/plugin/ribasim_qgis/widgets/ribasim_widget.py b/plugin/ribasim_qgis/widgets/ribasim_widget.py new file mode 100644 index 000000000..cd592cd0e --- /dev/null +++ b/plugin/ribasim_qgis/widgets/ribasim_widget.py @@ -0,0 +1,141 @@ +""" +This module forms the high level DockWidget. + +It ensures the underlying widgets can talk to each other. It also manages the +connection to the QGIS Layers Panel, and ensures there is a group for the +Ribasim layers there. +""" +from pathlib import Path +from typing import Any + +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import QTabWidget, QVBoxLayout, QWidget +from qgis.core import QgsMapLayer, QgsProject +from ribasim_qgis.widgets.dataset_widget import DatasetWidget +from ribasim_qgis.widgets.nodes_widget import NodesWidget + +PYQT_DELETED_ERROR = "wrapped C/C++ object of type QgsLayerTreeGroup has been deleted" + + +class RibasimWidget(QWidget): + def __init__(self, parent, iface): + super().__init__(parent) + + self.iface = iface + self.message_bar = self.iface.messageBar() + + self.dataset_widget = DatasetWidget(self) + self.nodes_widget = NodesWidget(self) + + # Layout + self.layout = QVBoxLayout() + self.tabwidget = QTabWidget() + self.layout.addWidget(self.tabwidget) + self.tabwidget.addTab(self.dataset_widget, "GeoPackage") + self.tabwidget.addTab(self.nodes_widget, "Nodes") + self.setLayout(self.layout) + + # QGIS Layers Panel groups + self.group = None + self.groups = {} + + return + + # Inter-widget communication + # -------------------------- + @property + def path(self) -> str: + return self.dataset_widget.path + + @property + def crs(self) -> Any: + """Returns coordinate reference system of current mapview""" + return self.iface.mapCanvas().mapSettings().destinationCrs() + + def add_node_layer(self, element: Any): + self.dataset_widget.add_node_layer(element) + + # QGIS layers + # ----------- + def create_subgroup(self, name: str, part: str) -> None: + try: + value = self.group.addGroup(f"{name}-{part}") + self.groups[part] = value + except RuntimeError as e: + if e.args[0] == PYQT_DELETED_ERROR: + # This means the main group has been deleted: recreate + # everything. + self.create_groups(name) + + def create_groups(self, name: str) -> None: + """ + Create an empty legend group in the QGIS Layers Panel. + """ + root = QgsProject.instance().layerTreeRoot() + self.group = root.addGroup(name) + self.create_subgroup(name, "Ribasim") + + def add_to_group(self, maplayer: Any, destination: str, on_top: bool): + """ + Try to add to a group; it might have been deleted. In that case, we add + as many groups as required. + """ + group = self.groups[destination] + try: + if on_top: + group.insertLayer(0, maplayer) + else: + group.addLayer(maplayer) + except RuntimeError as e: + if e.args[0] == PYQT_DELETED_ERROR: + # Then re-create groups and try again + name = str(Path(self.path).stem) + self.create_subgroup(name, destination) + self.add_to_group(maplayer, destination, on_top) + else: + raise e + + def add_layer( + self, + layer: Any, + destination: Any, + renderer: Any = None, + suppress: bool = None, + on_top: bool = False, + ) -> QgsMapLayer: + """ + Add a layer to the Layers Panel + + Parameters + ---------- + layer: + QGIS map layer, raster or vector layer + destination: + Legend group + renderer: + QGIS layer renderer, optional + suppress: + optional, bool. Default value is None. + This controls whether attribute form popup is suppressed or not. + Only relevant for vector (input) layers. + on_top: optional, bool. Default value is False. + Whether to place the layer on top in the destination legend group. + Handy for transparent layers such as contours. + + Returns + ------- + maplayer: QgsMapLayer or None + """ + if layer is None: + return + add_to_legend = self.group is None + maplayer = QgsProject.instance().addMapLayer(layer, add_to_legend) + if suppress is not None: + config = maplayer.editFormConfig() + config.setSuppress(1) + maplayer.setEditFormConfig(config) + if renderer is not None: + maplayer.setRenderer(renderer) + if destination is not None: + self.add_to_group(maplayer, destination, on_top) + return maplayer diff --git a/ribasim/__init__.py b/ribasim/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tox.ini b/tox.ini new file mode 100644 index 000000000..a516d7be6 --- /dev/null +++ b/tox.ini @@ -0,0 +1,39 @@ +# usage: +# tox --> default, runs pytest +# tox -e fmt --> format the code and README +# tox -e lint --> check code formating and lint the code + +[tox] +envlist = py3 +isolated_build = True + +[testenv] +deps = + pytest + pytest-cov + pytest-codeblocks +extras = all +commands = + pytest {posargs} --codeblocks + +[testenv:format] +skip_install = True +commands = + isort . + black . + blacken-docs README.rst +deps = + black + isort + blacken-docs + +[testenv:lint] +skip_install = True +commands = + isort --check . + black --check . + flake8 . +deps = + black + flake8 + isort From 9b816e89305aaedf370e6e7bb39eedd2906887a1 Mon Sep 17 00:00:00 2001 From: Huite Bootsma Date: Mon, 20 Feb 2023 21:14:34 +0100 Subject: [PATCH 04/33] Get it working --- plugin/ribasim_qgis/core/nodes.py | 368 +++++++++++------- plugin/ribasim_qgis/core/topology.py | 65 ++-- plugin/ribasim_qgis/resources.py | 13 +- plugin/ribasim_qgis/ribasim_qgis.py | 2 +- plugin/ribasim_qgis/widgets/dataset_widget.py | 177 +++++---- plugin/ribasim_qgis/widgets/nodes_widget.py | 14 +- plugin/ribasim_qgis/widgets/ribasim_widget.py | 17 +- 7 files changed, 395 insertions(+), 261 deletions(-) diff --git a/plugin/ribasim_qgis/core/nodes.py b/plugin/ribasim_qgis/core/nodes.py index 64d9f59ac..e393f6e3e 100644 --- a/plugin/ribasim_qgis/core/nodes.py +++ b/plugin/ribasim_qgis/core/nodes.py @@ -21,9 +21,10 @@ """ import abc -from typing import Any, List, Tuple +from typing import Any, Dict, List, Tuple from PyQt5.QtCore import QVariant +from PyQt5.QtGui import QColor from PyQt5.QtWidgets import ( QDialog, QHBoxLayout, @@ -33,217 +34,284 @@ QVBoxLayout, ) from qgis.core import ( + QgsCategorizedSymbolRenderer, QgsDefaultValue, + QgsEditorWidgetSetup, QgsFeature, QgsField, QgsFillSymbol, QgsGeometry, QgsLineSymbol, + QgsMarkerSymbol, + QgsPalLayerSettings, QgsPointXY, + QgsRendererCategory, + QgsSimpleMarkerSymbolLayerBase, QgsSingleSymbolRenderer, QgsVectorLayer, + QgsVectorLayerSimpleLabeling, ) from ribasim_qgis.core import geopackage -class NameDialog(QDialog): - def __init__(self, parent=None): - super(NameDialog, self).__init__(parent) - self.name_line_edit = QLineEdit() - self.ok_button = QPushButton("OK") - self.cancel_button = QPushButton("Cancel") - self.ok_button.clicked.connect(self.accept) - self.cancel_button.clicked.connect(self.reject) - first_row = QHBoxLayout() - first_row.addWidget(QLabel("Layer name")) - first_row.addWidget(self.name_line_edit) - second_row = QHBoxLayout() - second_row.addStretch() - second_row.addWidget(self.ok_button) - second_row.addWidget(self.cancel_button) - layout = QVBoxLayout() - layout.addLayout(first_row) - layout.addLayout(second_row) - self.setLayout(layout) - - class RibasimInput(abc.ABC): """ Abstract base class for Ribasim input layers. """ - element_type = None - geometry_type = None - attributes = [] - - def _initialize_default(self, path, name): - """Things to always initialize for every input layer.""" - self.name = name + def __init__(self, path: str): + self.name = f"ribasim_{self.input_type}" self.path = path - self.ribasim_name = f"Ribasim {self.element_type}:{name}" self.layer = None - @abc.abstractmethod - def _initialize(self, path, name): - pass - - def __init__(self, path: str, name: str): - self._initialize_default(path, name) - self._initialize() - - @staticmethod - def dialog( - path: str, crs: Any, iface: Any, klass: type, names: List[str] - ) -> Tuple[Any]: - dialog = NameDialog() - dialog.show() - ok = dialog.exec_() - if not ok: - return - - name = dialog.name_line_edit.text() - if name in names: - raise ValueError(f"Name already exists in geopackage: {name}") - - instance = klass(path, name) - instance.create_layers(crs) + @classmethod + def create(cls, path: str, crs: Any, names: List[str]) -> "RibasimInput": + instance = cls(path) + if instance.name in names: + raise ValueError(f"Name already exists in geopackage: {instance.name}") + instance.layer = instance.new_layer(crs) return instance - def new_layer(self, crs: Any, geometry_type: str, name: str, attributes: List): - layer = QgsVectorLayer(geometry_type, name, "memory") + def new_layer(self, crs: Any) -> Any: + """ + Separate creation of the instance with creating the layer, since the + layer might also come from an existing geopackage. + """ + layer = QgsVectorLayer(self.geometry_type, self.name, "memory") provider = layer.dataProvider() - provider.addAttributes(attributes) + provider.addAttributes(self.attributes) layer.updateFields() layer.setCrs(crs) - self.layer = layer + return layer + + def set_defaults(self): + layer = self.layer + defaults = getattr(self, "defaults", None) + if layer is None or defaults is None: + return + fields = layer.fields() + for name, definition in defaults.items(): + index = fields.indexFromName(name) + layer.setDefaultValueDefinition(index, definition) return - def renderer(self): + def set_read_only(self) -> None: + return + + @property + def renderer(self) -> None: + return + + @property + def labels(self) -> None: return def layer_from_geopackage(self) -> QgsVectorLayer: - self.timml_layer = QgsVectorLayer( - f"{self.path}|layername={self.ribasim_name}", self.ribasim_name - ) + self.layer = QgsVectorLayer(f"{self.path}|layername={self.name}", self.name) return - def from_geopackage(self): + def from_geopackage(self) -> Tuple[Any, Any]: self.layer_from_geopackage() - return (self.layer, self.renderer()) + return (self.layer, self.renderer, self.labels) - def write(self): - self.layer = geopackage.write_layer(self.path, self.layer, self.ribasim_name) + def write(self) -> None: + self.layer = geopackage.write_layer(self.path, self.layer, self.name) + self.set_defaults() return - def remove_from_geopackage(self): - geopackage.remove_layer(self.path, self.ribasim_name) - + def remove_from_geopackage(self) -> None: + geopackage.remove_layer(self.path, self.name) + return -class Edges(RibasimInput): - def _initialize(self): - self.element_type = "edge" - self.geometry_type = "Linestring" - self.attributes = [ - QgsField("from_id", QVariant.Int), - QgsField("from_node", QVariant.String), - QgsField("to_id", QVariant.Int), - QgsField("to_node", QVariant.String), - ] + def set_editor_widget(self) -> None: + """Calling during new_layer doesn't have any effect...""" + return class Lsw(RibasimInput): - def _initialize(self): - self.element_type = "node" - self.geometry_type = "Point" - self.attributes = [ - QgsField("id", QVariant.Int), - ] + input_type = "node" + geometry_type = "Point" + attributes = [ + # TODO: node should be a ComboBox? + QgsField("node", QVariant.String), # TODO discuss + ] + + def write(self) -> None: + """ + Special the LSW layer write because it needs to generate a new file. + """ + self.layer = geopackage.write_layer( + self.path, self.layer, self.name, newfile=True + ) + self.set_defaults() + return + + def set_editor_widget(self) -> None: + layer = self.layer + index = layer.fields().indexFromName("node") + setup = QgsEditorWidgetSetup( + "ValueMap", + { + "map": { + "LSW": "LSW", + "Bifurcation": "Bifurcation", + "OutflowTable": "OutflowTable", + "LevelControl": "LevelControl", + }, + }, + ) + layer.setEditorWidgetSetup(index, setup) + + layer_form_config = layer.editFormConfig() + layer_form_config.setReuseLastValue(1, True) + layer.setEditFormConfig(layer_form_config) + + return + + @property + def renderer(self) -> QgsCategorizedSymbolRenderer: + shape = QgsSimpleMarkerSymbolLayerBase + markers = { + "LSW": (QColor("blue"), "LSW", shape.Circle), + "Bifurcation": (QColor("red"), "Bifurcation", shape.Triangle), + "OutflowTable": (QColor("green"), "OutflowTable", shape.Diamond), + "LevelControl": (QColor("blue"), "LevelControl", shape.Star), + "": ( + QColor("white"), + "", + shape.Circle, + ), # All other nodes, or incomplete input + } + + categories = [] + for value, (colour, label, shape) in markers.items(): + symbol = QgsMarkerSymbol() + symbol.symbolLayer(0).setShape(shape) + symbol.setColor(QColor(colour)) + symbol.setSize(4) + category = QgsRendererCategory(value, symbol, label, shape) + categories.append(category) + + renderer = QgsCategorizedSymbolRenderer(attrName="node", categories=categories) + return renderer + + @property + def labels(self) -> Any: + pal_layer = QgsPalLayerSettings() + pal_layer.fieldName = "fid" + pal_layer.enabled = True + pal_layer.dist = 2.0 + labels = QgsVectorLayerSimpleLabeling(pal_layer) + return labels + + +class Edges(RibasimInput): + input_type = "edge" + geometry_type = "Linestring" + attributes = [ + QgsField("from_node_fid", QVariant.Int), + QgsField("to_node_fid", QVariant.Int), + ] + + @property + def renderer(self) -> QgsSingleSymbolRenderer: + symbol = QgsLineSymbol.createSimple( + { + "color": "#3690c0", # lighter blue + "width": "0.5", + } + ) + return QgsSingleSymbolRenderer(symbol) + + def set_read_only(self) -> None: + layer = self.layer + config = layer.editFormConfig() + for index in range(len(layer.fields())): + config.setReadOnly(index, True) + layer.setEditFormConfig(config) + return class LswLookup(RibasimInput): - def _initialize(self): - self.element_type = "lookup_LSW" - self.geometry_type = "No Geometry" - self.attributes = [ - QgsField("id", QVariant.Int), - QgsField("volume", QVariant.Double), - QgsField("area", QVariant.Double), - QgsField("level", QVariant.Double), - ] + input_type = "lookup_LSW" + geometry_type = "No Geometry" + attributes = [ + QgsField("node_fid", QVariant.Int), + QgsField("volume", QVariant.Double), + QgsField("area", QVariant.Double), + QgsField("level", QVariant.Double), + ] class OutflowTableLookup(RibasimInput): - def _initialize(self): - self.element_type = "lookup_OutflowTable" - self.geometry_type = "No Geometry" - self.attributes = [ - QgsField("id", QVariant.Int), - QgsField("level", QVariant.Double), - QgsField("discharge", QVariant.Double), - ] + input_type = "lookup_OutflowTable" + geometry_type = "No Geometry" + attributes = [ + QgsField("node_fid", QVariant.Int), + QgsField("level", QVariant.Double), + QgsField("discharge", QVariant.Double), + ] class Bifurcation(RibasimInput): - def _initialize(self): - self.element_type = "static_Bifurcation" - self.geometry_type = "No Geometry" - self.attributes = [ - QgsField("id", QVariant.Int), - QgsField("fraction_1", QVariant.Double), - QgsField("fraction_2", QVariant.Double), - ] + input_type = "static_Bifurcation" + geometry_type = "No Geometry" + attributes = [ + QgsField("node_fid", QVariant.Int), + QgsField("fraction_1", QVariant.Double), + QgsField("fraction_2", QVariant.Double), + ] class LevelControl(RibasimInput): - def _initialize(self): - self.element_type = "static_LevelControl" - self.geometry_type = "No Geometry" - self.attributes = [ - QgsField("id", QVariant.Int), - QgsField("target_volume", QVariant.Double), - ] + input_type = "static_LevelControl" + geometry_type = "No Geometry" + attributes = [ + QgsField("node_fid", QVariant.Int), + QgsField("target_volume", QVariant.Double), + ] class LswState(RibasimInput): - def _initialize(self): - self.element_type = "state_LSW" - self.geometry_type = "No Geometry" - self.attributes = [ - QgsField("id", QVariant.Int), - QgsField("S", QVariant.Double), - QgsField("C", QVariant.Double), - ] + input_type = "state_LSW" + geometry_type = "No Geometry" + attributes = [ + QgsField("node_fid", QVariant.Int), + QgsField("S", QVariant.Double), + QgsField("C", QVariant.Double), + ] class LswForcing(RibasimInput): - def _initialize(self): - self.element_type = "forcing_LSW" - self.geometry_type = "No Geometry" - self.attributes = [ - QgsField("id", QVariant.Int), - QgsField("time", QVariant.QDateTime), - QgsField("P", QVariant.Double), - QgsField("ET", QVariant.Double), - ] + input_type = "forcing_LSW" + geometry_type = "No Geometry" + attributes = [ + QgsField("time", QVariant.DateTime), + QgsField("node_fid", QVariant.Int), + QgsField("demand", QVariant.Double), + QgsField("drainage", QVariant.Double), + QgsField("E_pot", QVariant.Double), + QgsField("infiltration", QVariant.Double), + QgsField("P", QVariant.Double), + QgsField("priority", QVariant.Double), + QgsField("urban_runoff", QVariant.Double), + ] NODES = { - "Edges": Edges, - "LSW": Lsw, - "lookup LSW": LswLookup, - "lookup OutflowTable": OutflowTableLookup, - "static Bifurcation": Bifurcation, - "static LevelControl": LevelControl, + "node": Lsw, + "edge": Edges, + "lookup_LSW": LswLookup, + "lookup_OutflowTable": OutflowTableLookup, + "static_Bifurcation": Bifurcation, + "static_LevelControl": LevelControl, + "forcing_LSW": LswForcing, } def parse_name(layername: str) -> Tuple[str, str]: """ - Based on the layer name find out: - - * whether it's a Ribasim input layer; - * which element type it is; - * what the user provided name is. + Based on the layer name find out which type it is. For example: parse_name("Ribasim Edges: network") -> ("Edges", "network") @@ -262,19 +330,17 @@ def parse_name(layername: str) -> Tuple[str, str]: return kind, nodetype -def load_nodes_from_geopackage(path: str) -> List[RibasimInput]: +def load_nodes_from_geopackage(path: str) -> Dict[str, RibasimInput]: # List the names in the geopackage gpkg_names = geopackage.layers(path) - - # Group them on the basis of name - nodes = [] + nodes = {} for layername in gpkg_names: if layername.startswith("ribasim_"): kind, nodetype = parse_name(layername) if kind in ("node", "edge"): key = kind else: - key = f"{kind} {nodetype}" - nodes.append(NODES[key](path)) + key = f"{kind}_{nodetype}" + nodes[key] = NODES[key](path) return nodes diff --git a/plugin/ribasim_qgis/core/topology.py b/plugin/ribasim_qgis/core/topology.py index aec048725..017658af1 100644 --- a/plugin/ribasim_qgis/core/topology.py +++ b/plugin/ribasim_qgis/core/topology.py @@ -1,7 +1,37 @@ +from typing import Tuple + import numpy as np +from qgis import processing +from qgis.core import QgsVectorLayer +from qgis.core.additions.edit import edit + + +def explode_lines(edge: QgsVectorLayer) -> None: + args = { + "INPUT": edge, + "OUTPUT": "memory:", + } + memory_layer = processing.run("native:explodelines", args)["OUTPUT"] + + # Now overwrite the contents of the original layer. + try: + # Avoid infinite recursion and stackoverflow + edge.blockSignals(True) + provider = edge.dataProvider() + + with edit(edge): + provider.deleteFeatures([f.id() for f in edge.getFeatures()]) + new_features = list(memory_layer.getFeatures()) + for i, feature in enumerate(new_features): + feature["fid"] = i + 1 + provider.addFeatures(new_features) + finally: + edge.blockSignals(False) + return -def derive_connectivity(node, edge): + +def derive_connectivity(node_index, node_xy, edge_xy) -> Tuple[np.ndarray, np.ndarray]: """ Derive connectivity on the basis of xy locations. @@ -10,32 +40,19 @@ def derive_connectivity(node, edge): """ # collect xy # stack all into a single array - n_node = node.featureCount() - node_xy = np.empty((n_node, 2), dtype=float) - node_index = np.empty(n_node, dtype=int) - for i, feature in node.getFeatures(): - point = feature.geometry() - node_xy[i, 0] = point.x() - node_xy[i, 1] = point.y() - node_index[i] = feature.attribute(0) - - edge_xy = np.empty((edge.featureCount(), 2, 2), dtype=float) - for i, feature in edge.getFeatures(): - geometry = feature.geometry().asPolyLine() - for j, point in enumerate(geometry): - edge_xy[i, j, 0] = point.x() - edge_xy[i, j, 1] = point.y() - edge_xy = edge_xy.reshape((-1, 2)) - xy = np.vstack([node_xy, edge_xy]) _, inverse = np.unique(xy, return_inverse=True, axis=0) - edge_node_id = inverse[node_xy.size :].reshape((-1, 2)) - try: - from_id = node_index[edge_node_id[:, 0]] - to_id = node_index[edge_node_id[:, 1]] - except IndexError: + _, index, inverse = np.unique(xy, return_index=True, return_inverse=True, axis=0) + uniques_index = index[inverse] + + node_node_id, edge_node_id = np.split(uniques_index, [len(node_xy)]) + if not np.isin(edge_node_id, node_node_id).all(): raise ValueError( - "Edge layer contains vertices that are not present in node layer. " + "Edge layer contains coordinates that are not in the node layer. " "Please ensure all edges are snapped to nodes exactly." ) + + edge_node_id = edge_node_id.reshape((-1, 2)) + from_id = node_index[edge_node_id[:, 0]] + to_id = node_index[edge_node_id[:, 1]] return from_id, to_id diff --git a/plugin/ribasim_qgis/resources.py b/plugin/ribasim_qgis/resources.py index 410940222..a87374c6b 100644 --- a/plugin/ribasim_qgis/resources.py +++ b/plugin/ribasim_qgis/resources.py @@ -552,7 +552,7 @@ \x00\x00\x01\x86\x5b\x19\xf3\x90\ " -qt_version = [int(v) for v in QtCore.qVersion().split('.')] +qt_version = [int(v) for v in QtCore.qVersion().split(".")] if qt_version < [5, 8, 0]: rcc_version = 1 qt_resource_struct = qt_resource_struct_v1 @@ -560,10 +560,17 @@ rcc_version = 2 qt_resource_struct = qt_resource_struct_v2 + def qInitResources(): - QtCore.qRegisterResourceData(rcc_version, qt_resource_struct, qt_resource_name, qt_resource_data) + QtCore.qRegisterResourceData( + rcc_version, qt_resource_struct, qt_resource_name, qt_resource_data + ) + def qCleanupResources(): - QtCore.qUnregisterResourceData(rcc_version, qt_resource_struct, qt_resource_name, qt_resource_data) + QtCore.qUnregisterResourceData( + rcc_version, qt_resource_struct, qt_resource_name, qt_resource_data + ) + qInitResources() diff --git a/plugin/ribasim_qgis/ribasim_qgis.py b/plugin/ribasim_qgis/ribasim_qgis.py index 2c2395c9f..cc05bda2b 100644 --- a/plugin/ribasim_qgis/ribasim_qgis.py +++ b/plugin/ribasim_qgis/ribasim_qgis.py @@ -36,7 +36,7 @@ def add_action(self, icon_name, text="", callback=None, add_to_menu=False): def initGui(self): icon_name = "icon.png" - self.action_timml = self.add_action( + self.action_ribasim = self.add_action( icon_name, "Ribasim", self.toggle_ribasim, True ) diff --git a/plugin/ribasim_qgis/widgets/dataset_widget.py b/plugin/ribasim_qgis/widgets/dataset_widget.py index 17ff5a094..63a59d33d 100644 --- a/plugin/ribasim_qgis/widgets/dataset_widget.py +++ b/plugin/ribasim_qgis/widgets/dataset_widget.py @@ -1,18 +1,13 @@ """ -This widgets displays the available elements in the GeoPackage. +This widgets displays the available input layers in the GeoPackage. This widget also allows enabling or disabling individual elements for a -computation. It also forms the link between the geometry layers and the -associated layers for homogeneities, or for timeseries layers for ttim -elements. - -Not every TimML element has a TTim equivalent (yet). This means that when a -user chooses the transient simulation mode, a number of elements must be -disabled (such as inhomogeneities). +computation. """ from pathlib import Path from typing import Any, List, Set +import numpy as np from PyQt5.QtCore import Qt from PyQt5.QtWidgets import ( QAbstractItemView, @@ -30,7 +25,9 @@ QWidget, ) from qgis.core import QgsMapLayer, QgsProject -from ribasim_qgis.core.nodes import load_nodes_from_geopackage +from qgis.core.additions.edit import edit +from ribasim_qgis.core.nodes import Edges, Lsw, load_nodes_from_geopackage +from ribasim_qgis.core.topology import derive_connectivity, explode_lines class DatasetTreeWidget(QTreeWidget): @@ -40,50 +37,32 @@ def __init__(self, parent=None): self.setHeaderHidden(True) self.setSortingEnabled(True) self.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Preferred) - self.setHeaderLabels(["", "steady", "", "transient"]) + self.setHeaderLabels(["", ""]) self.setHeaderHidden(False) header = self.header() header.setSectionResizeMode(1, QHeaderView.Stretch) - header.setSectionResizeMode(3, QHeaderView.Stretch) header.setSectionsMovable(False) - self.setColumnCount(4) + self.setColumnCount(2) self.setColumnWidth(0, 1) self.setColumnWidth(2, 1) - self.domain = None def items(self) -> List[QTreeWidgetItem]: root = self.invisibleRootItem() return [root.child(i) for i in range(root.childCount())] - def add_item(self, timml_name: str, ttim_name: str = None, enabled: bool = True): + def add_item(self, name: str, enabled: bool = True): item = QTreeWidgetItem() self.addTopLevelItem(item) - item.timml_checkbox = QCheckBox() - item.timml_checkbox.setChecked(True) - item.timml_checkbox.setEnabled(enabled) - self.setItemWidget(item, 0, item.timml_checkbox) - item.setText(1, timml_name) - item.ttim_checkbox = QCheckBox() - item.ttim_checkbox.setChecked(True) - item.ttim_checkbox.setEnabled(enabled) - if ttim_name is None: - item.ttim_checkbox.setChecked(False) - item.ttim_checkbox.setEnabled(False) - self.setItemWidget(item, 2, item.ttim_checkbox) - item.setText(3, ttim_name) - # Disable ttim layer when timml layer is unticked - # as timml layer is always required for ttim layer - item.timml_checkbox.toggled.connect( - lambda checked: not checked and item.ttim_checkbox.setChecked(False) - ) - item.assoc_item = None + item.checkbox = QCheckBox() + item.checkbox.setChecked(True) + item.checkbox.setEnabled(enabled) + self.setItemWidget(item, 0, item.checkbox) + item.setText(1, name) return item def add_node_layer(self, element) -> None: # These are mandatory elements, cannot be unticked - item = self.add_item( - timml_name=element.timml_name, ttim_name=element.ttim_name, enabled=True - ) + item = self.add_item(name=element.name, enabled=True) item.element = element def remove_geopackage_layers(self) -> None: @@ -119,24 +98,20 @@ def remove_geopackage_layers(self) -> None: qgs_instance = QgsProject.instance() for element in elements: - for layer in [ - element.timml_layer, - element.ttim_layer, - element.assoc_layer, - ]: - # QGIS layers - if layer is None: - continue - try: - qgs_instance.removeMapLayer(layer.id()) - except (RuntimeError, AttributeError) as e: - if e.args[0] in ( - "wrapped C/C++ object of type QgsVectorLayer has been deleted", - "'NoneType' object has no attribute 'id'", - ): - pass - else: - raise + layer = element.layer + # QGIS layers + if layer is None: + continue + try: + qgs_instance.removeMapLayer(layer.id()) + except (RuntimeError, AttributeError) as e: + if e.args[0] in ( + "wrapped C/C++ object of type QgsVectorLayer has been deleted", + "'NoneType' object has no attribute 'id'", + ): + pass + else: + raise # Geopackage element.remove_from_geopackage() @@ -167,6 +142,8 @@ def __init__(self, parent): self.suppress_popup_checkbox.stateChanged.connect(self.suppress_popup_changed) self.remove_button.clicked.connect(self.remove_geopackage_layer) self.add_button.clicked.connect(self.add_selection_to_qgis) + self.edge_layer = None + self.node_layer = None # Layout dataset_layout = QVBoxLayout() dataset_row = QHBoxLayout() @@ -187,6 +164,50 @@ def path(self) -> str: """Returns currently active path to GeoPackage""" return self.dataset_line_edit.text() + def explode_and_connect(self) -> None: + node = self.node_layer + edge = self.edge_layer + explode_lines(edge) + + n_node = node.featureCount() + n_edge = edge.featureCount() + if n_node == 0 or n_edge == 0: + return + + node_xy = np.empty((n_node, 2), dtype=float) + node_index = np.empty(n_node, dtype=int) + for i, feature in enumerate(node.getFeatures()): + point = feature.geometry().asPoint() + node_xy[i, 0] = point.x() + node_xy[i, 1] = point.y() + node_index[i] = feature.attribute(0) # Store the feature id + + edge_xy = np.empty((n_edge, 2, 2), dtype=float) + for i, feature in enumerate(edge.getFeatures()): + geometry = feature.geometry().asPolyline() + for j, point in enumerate(geometry): + edge_xy[i, j, 0] = point.x() + edge_xy[i, j, 1] = point.y() + edge_xy = edge_xy.reshape((-1, 2)) + from_id, to_id = derive_connectivity(node_index, node_xy, edge_xy) + + fields = edge.fields() + field1 = fields.indexFromName("from_node_fid") + field2 = fields.indexFromName("to_node_fid") + try: + # Avoid infinite recursion + edge.blockSignals(True) + with edit(edge): + for feature, id1, id2 in zip(edge.getFeatures(), from_id, to_id): + fid = feature.id() + # Nota bene: will fail with numpy integers, has to be Python type! + edge.changeAttributeValue(fid, field1, int(id1)) + edge.changeAttributeValue(fid, field2, int(id2)) + finally: + edge.blockSignals(False) + + return + def add_layer( self, layer: Any, @@ -194,6 +215,7 @@ def add_layer( renderer: Any = None, suppress: bool = None, on_top: bool = False, + labels: Any = None, ) -> QgsMapLayer: return self.parent.add_layer( layer, @@ -201,15 +223,19 @@ def add_layer( renderer, suppress, on_top, + labels, ) def add_item_to_qgis(self, item) -> None: - layers = item.element.from_geopackage() + element = item.element + layer, renderer, labels = element.from_geopackage() suppress = self.suppress_popup_checkbox.isChecked() - timml_layer, renderer = layers[0] - maplayer = self.add_layer(timml_layer, "timml", renderer, suppress) - self.add_layer(layers[1][0], "ttim") - self.add_layer(layers[2][0], "timml") + if element.input_type == "edge": + suppress = True + self.add_layer(layer, "Ribasim Input", renderer, suppress, labels=labels) + element.set_editor_widget() + element.set_read_only() + return def add_selection_to_qgis(self) -> None: selection = self.dataset_tree.selectedItems() @@ -222,13 +248,19 @@ def load_geopackage(self) -> None: """ self.dataset_tree.clear() nodes = load_nodes_from_geopackage(self.path) - for node_layer in nodes: + for node_layer in nodes.values(): self.dataset_tree.add_node_layer(node_layer) name = str(Path(self.path).stem) self.parent.create_groups(name) for item in self.dataset_tree.items(): 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.edge_layer.afterCommitChanges.connect(self.explode_and_connect) + return + def new_geopackage(self) -> None: """ Create a new GeoPackage file, and set it as the active dataset. @@ -236,22 +268,23 @@ def new_geopackage(self) -> None: path, _ = QFileDialog.getSaveFileName(self, "Select file", "", "*.gpkg") if path != "": # Empty string in case of cancel button press self.dataset_line_edit.setText(path) + for input_type in (Lsw, Edges): + instance = input_type.create(path, self.parent.crs, names=[]) + instance.write() self.load_geopackage() - self.parent.toggle_element_buttons(True) - self.parent.on_transient_changed() + self.parent.toggle_node_buttons(True) def open_geopackage(self) -> None: """ - Open a GeoPackage file, containing qgis-tim + Open a GeoPackage file, containing Ribasim input. """ self.dataset_tree.clear() path, _ = QFileDialog.getOpenFileName(self, "Select file", "", "*.gpkg") if path != "": # Empty string in case of cancel button press self.dataset_line_edit.setText(path) self.load_geopackage() - self.parent.toggle_element_buttons(True) + self.parent.toggle_node_buttons(True) self.dataset_tree.sortByColumn(0, Qt.SortOrder.AscendingOrder) - self.parent.on_transient_changed() def remove_geopackage_layer(self) -> None: """ @@ -265,25 +298,25 @@ def remove_geopackage_layer(self) -> None: def suppress_popup_changed(self): suppress = self.suppress_popup_checkbox.isChecked() for item in self.dataset_tree.items(): - layer = item.element.timml_layer + layer = item.element.layer if layer is not None: config = layer.editFormConfig() - config.setSuppress(suppress) + # Always suppress the attribute form pop-up for edges. + if item.element.input_type == "edge": + config.setSuppress(True) + else: + config.setSuppress(suppress) layer.setEditFormConfig(config) def active_nodes(self): active_nodes = {} for item in self.dataset_tree.items(): - active_nodes[item.text(1)] = not (item.timml_checkbox.isChecked() == 0) - active_nodes[item.text(3)] = not (item.ttim_checkbox.isChecked() == 0) + active_nodes[item.text(1)] = not (item.checkbox.isChecked() == 0) return active_nodes def selection_names(self) -> Set[str]: selection = self.dataset_tree.items() # Append associated items - for item in selection: - if item.assoc_item is not None and item.assoc_item not in selection: - selection.append(item.assoc_item) return set([item.element.name for item in selection]) def add_node_layer(self, element) -> None: diff --git a/plugin/ribasim_qgis/widgets/nodes_widget.py b/plugin/ribasim_qgis/widgets/nodes_widget.py index e6f5b698d..166e1ddce 100644 --- a/plugin/ribasim_qgis/widgets/nodes_widget.py +++ b/plugin/ribasim_qgis/widgets/nodes_widget.py @@ -11,7 +11,7 @@ def __init__(self, parent): self.node_buttons = {} for node in NODES: - if node in ("Edges", "Basins"): + if node in ("node", "edge"): continue button = QPushButton(node) button.clicked.connect(partial(self.new_node_layer, node_type=node)) @@ -53,14 +53,12 @@ def new_node_layer(self, node_type: str) -> None: """ klass = NODES[node_type] names = self.parent.selection_names() - node = klass.dialog( - self.parent.path, self.parent.crs, self.parent.iface, klass, names - ) - if node is None: # dialog cancelled - return + node = klass.create(self.parent.path, self.parent.crs, names) # Write to geopackage node.write() # Add to QGIS - self.parent.add_layer(node.timml_layer, "timml", node.renderer()) + self.parent.add_layer( + node.layer, "Ribasim Input", node.renderer, labels=node.labels + ) # Add to dataset tree - self.parent.add_node(node) + self.parent.add_node_layer(node) diff --git a/plugin/ribasim_qgis/widgets/ribasim_widget.py b/plugin/ribasim_qgis/widgets/ribasim_widget.py index cd592cd0e..2844c8464 100644 --- a/plugin/ribasim_qgis/widgets/ribasim_widget.py +++ b/plugin/ribasim_qgis/widgets/ribasim_widget.py @@ -55,6 +55,12 @@ def crs(self) -> Any: def add_node_layer(self, element: Any): self.dataset_widget.add_node_layer(element) + def toggle_node_buttons(self, state: bool) -> None: + self.nodes_widget.toggle_node_buttons(state) + + def selection_names(self): + return self.dataset_widget.selection_names() + # QGIS layers # ----------- def create_subgroup(self, name: str, part: str) -> None: @@ -73,7 +79,7 @@ def create_groups(self, name: str) -> None: """ root = QgsProject.instance().layerTreeRoot() self.group = root.addGroup(name) - self.create_subgroup(name, "Ribasim") + self.create_subgroup(name, "Ribasim Input") def add_to_group(self, maplayer: Any, destination: str, on_top: bool): """ @@ -102,6 +108,7 @@ def add_layer( renderer: Any = None, suppress: bool = None, on_top: bool = False, + labels: Any = None, ) -> QgsMapLayer: """ Add a layer to the Layers Panel @@ -121,6 +128,8 @@ def add_layer( on_top: optional, bool. Default value is False. Whether to place the layer on top in the destination legend group. Handy for transparent layers such as contours. + labels: optional + Whether to place labels, based on which column, styling, etc. Returns ------- @@ -132,10 +141,14 @@ def add_layer( maplayer = QgsProject.instance().addMapLayer(layer, add_to_legend) if suppress is not None: config = maplayer.editFormConfig() - config.setSuppress(1) + config.setSuppress(1 if suppress else 0) maplayer.setEditFormConfig(config) if renderer is not None: maplayer.setRenderer(renderer) + if labels is not None: + layer.setLabeling(labels) + layer.setLabelsEnabled(True) if destination is not None: self.add_to_group(maplayer, destination, on_top) + return maplayer From b5a4f9993204175abf89ddacd7fabb63950fdd5f Mon Sep 17 00:00:00 2001 From: Huite Bootsma Date: Thu, 23 Feb 2023 17:35:26 +0100 Subject: [PATCH 05/33] Make a start with classes --- .gitignore | 6 ++++ docs/.gitkeep | 0 pyproject.toml | 59 ++++++++++++++++++++++++++++++++++++++++ ribasim/__init__.py | 9 ++++++ ribasim/basin.py | 14 ++++++++++ ribasim/bifurcation.py | 9 ++++++ ribasim/edge.py | 10 +++++++ ribasim/forcing.py | 10 +++++++ ribasim/input_base.py | 24 ++++++++++++++++ ribasim/level_control.py | 9 ++++++ ribasim/model.py | 54 ++++++++++++++++++++++++++++++++++++ ribasim/node.py | 10 +++++++ ribasim/outflow_table.py | 9 ++++++ ribasim/types.py | 5 ++++ tests/test_edge.py | 17 ++++++++++++ 15 files changed, 245 insertions(+) create mode 100644 docs/.gitkeep create mode 100644 pyproject.toml create mode 100644 ribasim/basin.py create mode 100644 ribasim/bifurcation.py create mode 100644 ribasim/edge.py create mode 100644 ribasim/forcing.py create mode 100644 ribasim/input_base.py create mode 100644 ribasim/level_control.py create mode 100644 ribasim/model.py create mode 100644 ribasim/node.py create mode 100644 ribasim/outflow_table.py create mode 100644 ribasim/types.py create mode 100644 tests/test_edge.py diff --git a/.gitignore b/.gitignore index b6e47617d..cf3f9fe5b 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,9 @@ dmypy.json # Pyre type checker .pyre/ + +# Generated docs +/docs + +# VS Code settings +*.vscode \ No newline at end of file diff --git a/docs/.gitkeep b/docs/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..27cabbb20 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,59 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[project] +name = "ribasim" +description = "Pre- and post-processs Ribasim.jl" +readme = "README.md" +authors = [ + {name = "Huite Bootsma", email = "Huite.Bootsma@deltares.nl"}, + {name = "Julian Hofer", email = "Julian.Hofer@deltares.nl"}, +] +license = {text = "MIT"} +classifiers = [ + "Intended Audience :: Science/Research", + "Topic :: Scientific/Engineering :: Hydrology", +] +requires-python = ">=3.9" +dependencies = [ + "geopandas", + "pandas", + "pyarrow", + "pydantic", + "tomli-w", +] +dynamic = ["version"] + +[project.optional-dependencies] +tests = [ + "pytest", + "pytest-cov", +] +lint = [ + "black", + "ruff", + "mypy", +] +docs = [ + "pdoc", +] + +[tool.setuptools] +zip-safe = true + +[tool.setuptools.dynamic] +version = {attr = "ribasim.__version__"} +[tool.setuptools.packages.find] +include = ["ribasim"] + +[tool.setuptools.package-data] +"ribasim" = ["py.typed"] + +[project.urls] +Documentation = "https://deltares.github.io/ribasim-python/ribasim.html" +Source = "https://github.com/Deltares/ribasim-python" + +[tool.isort] +profile = "black" +multi_line_output = 3 \ No newline at end of file diff --git a/ribasim/__init__.py b/ribasim/__init__.py index e69de29bb..c9f14467a 100644 --- a/ribasim/__init__.py +++ b/ribasim/__init__.py @@ -0,0 +1,9 @@ +__version__ = "0.1.0" + + +from ribasim.node import Node +from ribasim.edge import Edge +from ribasim.bifurcation import Bifurcation +from ribasim.basin import BasinLookup, BasinState +from ribasim.outflow_table import OutflowTable +from ribasim.model import RibasimModel diff --git a/ribasim/basin.py b/ribasim/basin.py new file mode 100644 index 000000000..6910e3310 --- /dev/null +++ b/ribasim/basin.py @@ -0,0 +1,14 @@ +from pydantic import BaseModel + +from ribasim.input_base import ArrowInputMixin +from ribasim.types import DataFrame + + +class BasinState(BaseModel, ArrowInputMixin): + _input_type = "state_Basin" + dataframe: DataFrame + + +class BasinLookup(BaseModel, ArrowInputMixin): + _input_type = "lookup_Basin" + dataframe: DataFrame diff --git a/ribasim/bifurcation.py b/ribasim/bifurcation.py new file mode 100644 index 000000000..9856d202f --- /dev/null +++ b/ribasim/bifurcation.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel + +from ribasim.input_base import ArrowInputMixin +from ribasim.types import DataFrame + + +class Bifurcation(BaseModel, ArrowInputMixin): + _input_type = "static_Bifurcation" + dataframe: DataFrame diff --git a/ribasim/edge.py b/ribasim/edge.py new file mode 100644 index 000000000..9dac25946 --- /dev/null +++ b/ribasim/edge.py @@ -0,0 +1,10 @@ +from pydantic import BaseModel, PrivateAttr +import pandas as pd + +from ribasim.input_base import InputMixin +from ribasim.types import DataFrame + + +class Edge(BaseModel, InputMixin): + _input_type = "edge" + dataframe: DataFrame diff --git a/ribasim/forcing.py b/ribasim/forcing.py new file mode 100644 index 000000000..b8098aafa --- /dev/null +++ b/ribasim/forcing.py @@ -0,0 +1,10 @@ +from pydantic import BaseModel +import pandas as pd + +from ribasim.input_base import ArrowInputMixin +from ribasim.types import DataFrame + + +class BasinLsw(BaseModel, ArrowInputMixin): + _input_type = "forcing_LSW" + dataframe: DataFrame diff --git a/ribasim/input_base.py b/ribasim/input_base.py new file mode 100644 index 000000000..1bf336eed --- /dev/null +++ b/ribasim/input_base.py @@ -0,0 +1,24 @@ +from pathlib import Path +import abc + + +class InputMixin(abc.ABC): + def _write_geopackage(self, directory: Path, modelname: str) -> None: + self.dataframe.to_file( + directory / f"{modelname}.gpkg", layer=f"ribasim_{self.input_type}" + ) + return + + def write(self, directory, modelname, to_arrow: bool = False) -> None: + if to_arrow: + self._write_arrow(directory) + else: + self._write_geopackage(directory, modelname) + return + + +class ArrowInputMixin(InputMixin, abc.ABC): + def _write_arrow(self, directory: Path) -> None: + path = directory / f"{self.input_type}.arrow" + self.dataframe.write_feather(path) + return diff --git a/ribasim/level_control.py b/ribasim/level_control.py new file mode 100644 index 000000000..340f222e7 --- /dev/null +++ b/ribasim/level_control.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel + +from ribasim.input_base import ArrowInputMixin +from ribasim.types import DataFrame + + +class LevelControl(BaseModel, ArrowInputMixin): + _input_type = "static_LevelControl" + dataframe: DataFrame diff --git a/ribasim/model.py b/ribasim/model.py new file mode 100644 index 000000000..fdeb86c13 --- /dev/null +++ b/ribasim/model.py @@ -0,0 +1,54 @@ +from typing import Optional +from pathlib import Path + +from pydantic import BaseModel +import tomli_w + +from ribasim import ( + Node, + Edge, + Bifurcation, + BasinLookup, + BasinState, + OutflowTable, +) + + +class RibasimModel(BaseModel): + node: Node + edge: Edge + basin_state: Optional[BasinState] = None + basin_lookup: Optional[BasinLookup] = None + bifurcation: Optional[Bifurcation] = None + outflow_table: Optional[OutflowTable] = None + + def __iter__(self): + return iter(self.__root__) + + def items(self): + return self.__root__.items() + + def values(self): + return self.__root__.values() + + def _write_toml(self, directory: Path, modelname: str): + content = {} + with open(directory / f"{modelname}.toml", "w") as f: + tomli_w.dump(content, f) + return + + def _write_tables(self, directory: Path, modelname: str) -> None: + """ + Write the input to GeoPackage and Arrow tables. + """ + for input_table in self.values(): + input_table.write(directory, modelname) + return + + def write(self, directory, modelname: str) -> None: + directory = Path(directory) + directory.mkdir(parents=True, exist_ok=True) + + self._write_toml(directory, modelname) + self._write_tables(directory, modelname) + return diff --git a/ribasim/node.py b/ribasim/node.py new file mode 100644 index 000000000..b1a04ab88 --- /dev/null +++ b/ribasim/node.py @@ -0,0 +1,10 @@ +from pydantic import BaseModel +import pandas as pd + +from ribasim.input_base import InputMixin +from ribasim.types import DataFrame + + +class Node(BaseModel, InputMixin): + _input_type = "node" + dataframe: DataFrame diff --git a/ribasim/outflow_table.py b/ribasim/outflow_table.py new file mode 100644 index 000000000..f77193161 --- /dev/null +++ b/ribasim/outflow_table.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel + +from ribasim.input_base import ArrowInputMixin +from ribasim.types import DataFrame + + +class OutflowTable(BaseModel, ArrowInputMixin): + _input_type = "lookup_OutflowTable" + dataframe: DataFrame diff --git a/ribasim/types.py b/ribasim/types.py new file mode 100644 index 000000000..3cc9f33ac --- /dev/null +++ b/ribasim/types.py @@ -0,0 +1,5 @@ +from typing import TypeVar +from pandas import DataFrame + + +DataFrame = TypeVar("DataFrame") diff --git a/tests/test_edge.py b/tests/test_edge.py new file mode 100644 index 000000000..174cb8d95 --- /dev/null +++ b/tests/test_edge.py @@ -0,0 +1,17 @@ +import geopandas as gpd +import shapely.geometry as sg +import pytest + +from ribasim.edge import Edge + + +def test(): + a = (0.0, 0.0) + b = (0.0, 1.0) + c = (1.0, 1.0) + geometry = [sg.LineString([a, b]), sg.LineString([a, c])] + df = gpd.GeoDataFrame( + data={"from_node_id": [1, 1], "to_node_id": [2, 3]}, geometry=geometry + ) + edge = Edge(dataframe=df) + assert isinstance(edge, Edge) From e4cbbd8311f6a8ee67f3755b9e07a39033e67691 Mon Sep 17 00:00:00 2001 From: Hofer-Julian Date: Fri, 24 Feb 2023 10:54:12 +0100 Subject: [PATCH 06/33] Add API docs with pydoc --- .github/workflows/docs.yml | 42 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .github/workflows/docs.yml diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 000000000..ce1a512a5 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,42 @@ +name: website + +# build the documentation whenever there are new commits on main +on: + push: + branches: + - main + +# security: restrict permissions for CI jobs. +permissions: + contents: read + +jobs: + # Build the documentation and upload the static HTML files as an artifact. + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + + - run: pip install -e ".[docs]" + # We use a custom build script for pdoc itself, ideally you just run `pdoc -o docs/ ...` here. + - run: pdoc -o docs/ ribasim + + - uses: actions/upload-pages-artifact@v1 + with: + path: docs/ + + # Deploy the artifact to GitHub pages. + # This is a separate job so that only actions/deploy-pages has the necessary permissions. + deploy: + needs: build + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v1 From e66889aaf2784505076fd91ce8a45c468a8d996d Mon Sep 17 00:00:00 2001 From: Hofer-Julian Date: Fri, 24 Feb 2023 10:55:08 +0100 Subject: [PATCH 07/33] Specify Python version --- .github/workflows/docs.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index ce1a512a5..c4f63c5d1 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -17,6 +17,8 @@ jobs: steps: - uses: actions/checkout@v3 - uses: actions/setup-python@v4 + with: + python-version: '3.11' - run: pip install -e ".[docs]" # We use a custom build script for pdoc itself, ideally you just run `pdoc -o docs/ ...` here. From 8d8df1e086359e1c3f952d6a175223147b8bde55 Mon Sep 17 00:00:00 2001 From: Huite Bootsma Date: Fri, 24 Feb 2023 11:13:06 +0100 Subject: [PATCH 08/33] new names for input types --- plugin/ribasim_qgis/core/nodes.py | 14 +++++++------- plugin/ribasim_qgis/widgets/dataset_widget.py | 4 ++-- ribasim/basin.py | 16 +++++++++++++--- ribasim/bifurcation.py | 7 ++++++- ribasim/edge.py | 2 +- ribasim/forcing.py | 10 ---------- ribasim/input_base.py | 2 +- ribasim/level_control.py | 7 ++++++- ribasim/node.py | 2 +- ribasim/outflow_table.py | 4 ++-- 10 files changed, 39 insertions(+), 29 deletions(-) delete mode 100644 ribasim/forcing.py diff --git a/plugin/ribasim_qgis/core/nodes.py b/plugin/ribasim_qgis/core/nodes.py index e393f6e3e..39bee8bd0 100644 --- a/plugin/ribasim_qgis/core/nodes.py +++ b/plugin/ribasim_qgis/core/nodes.py @@ -128,7 +128,7 @@ def set_editor_widget(self) -> None: return -class Lsw(RibasimInput): +class Basin(RibasimInput): input_type = "node" geometry_type = "Point" attributes = [ @@ -232,7 +232,7 @@ def set_read_only(self) -> None: return -class LswLookup(RibasimInput): +class BasinLookup(RibasimInput): input_type = "lookup_LSW" geometry_type = "No Geometry" attributes = [ @@ -272,7 +272,7 @@ class LevelControl(RibasimInput): ] -class LswState(RibasimInput): +class BasinState(RibasimInput): input_type = "state_LSW" geometry_type = "No Geometry" attributes = [ @@ -282,7 +282,7 @@ class LswState(RibasimInput): ] -class LswForcing(RibasimInput): +class BasinForcing(RibasimInput): input_type = "forcing_LSW" geometry_type = "No Geometry" attributes = [ @@ -299,13 +299,13 @@ class LswForcing(RibasimInput): NODES = { - "node": Lsw, + "node": Basin, "edge": Edges, - "lookup_LSW": LswLookup, + "lookup_LSW": BasinLookup, "lookup_OutflowTable": OutflowTableLookup, "static_Bifurcation": Bifurcation, "static_LevelControl": LevelControl, - "forcing_LSW": LswForcing, + "forcing_LSW": BasinForcing, } diff --git a/plugin/ribasim_qgis/widgets/dataset_widget.py b/plugin/ribasim_qgis/widgets/dataset_widget.py index 63a59d33d..5215a3868 100644 --- a/plugin/ribasim_qgis/widgets/dataset_widget.py +++ b/plugin/ribasim_qgis/widgets/dataset_widget.py @@ -26,7 +26,7 @@ ) from qgis.core import QgsMapLayer, QgsProject from qgis.core.additions.edit import edit -from ribasim_qgis.core.nodes import Edges, Lsw, load_nodes_from_geopackage +from ribasim_qgis.core.nodes import Edges, Basin, load_nodes_from_geopackage from ribasim_qgis.core.topology import derive_connectivity, explode_lines @@ -268,7 +268,7 @@ def new_geopackage(self) -> None: path, _ = QFileDialog.getSaveFileName(self, "Select file", "", "*.gpkg") if path != "": # Empty string in case of cancel button press self.dataset_line_edit.setText(path) - for input_type in (Lsw, Edges): + for input_type in (Basin, Edges): instance = input_type.create(path, self.parent.crs, names=[]) instance.write() self.load_geopackage() diff --git a/ribasim/basin.py b/ribasim/basin.py index 6910e3310..f6ff73e6f 100644 --- a/ribasim/basin.py +++ b/ribasim/basin.py @@ -4,11 +4,21 @@ from ribasim.types import DataFrame +class Basin(BaseModel, ArrowInputMixin): + _input_type = "Basin" + dataframe: DataFrame + + class BasinState(BaseModel, ArrowInputMixin): - _input_type = "state_Basin" + _input_type = "Basin / state" dataframe: DataFrame -class BasinLookup(BaseModel, ArrowInputMixin): - _input_type = "lookup_Basin" +class BasinProfile(BaseModel, ArrowInputMixin): + _input_type = "Basin / profile" dataframe: DataFrame + + +class BasinForcing(BaseModel, ArrowInputMixin): + _input_type = "Basin / forcing" + dataframe: DataFrame \ No newline at end of file diff --git a/ribasim/bifurcation.py b/ribasim/bifurcation.py index 9856d202f..2f3608221 100644 --- a/ribasim/bifurcation.py +++ b/ribasim/bifurcation.py @@ -5,5 +5,10 @@ class Bifurcation(BaseModel, ArrowInputMixin): - _input_type = "static_Bifurcation" + _input_type = "Bifurcation" + dataframe: DataFrame + + +class BifurcationForcing(BaseModel, ArrowInputMixin): + _input_type = "Bifurcation / forcing" dataframe: DataFrame diff --git a/ribasim/edge.py b/ribasim/edge.py index 9dac25946..6534a074e 100644 --- a/ribasim/edge.py +++ b/ribasim/edge.py @@ -6,5 +6,5 @@ class Edge(BaseModel, InputMixin): - _input_type = "edge" + _input_type = "Edge" dataframe: DataFrame diff --git a/ribasim/forcing.py b/ribasim/forcing.py deleted file mode 100644 index b8098aafa..000000000 --- a/ribasim/forcing.py +++ /dev/null @@ -1,10 +0,0 @@ -from pydantic import BaseModel -import pandas as pd - -from ribasim.input_base import ArrowInputMixin -from ribasim.types import DataFrame - - -class BasinLsw(BaseModel, ArrowInputMixin): - _input_type = "forcing_LSW" - dataframe: DataFrame diff --git a/ribasim/input_base.py b/ribasim/input_base.py index 1bf336eed..dfa20efac 100644 --- a/ribasim/input_base.py +++ b/ribasim/input_base.py @@ -5,7 +5,7 @@ class InputMixin(abc.ABC): def _write_geopackage(self, directory: Path, modelname: str) -> None: self.dataframe.to_file( - directory / f"{modelname}.gpkg", layer=f"ribasim_{self.input_type}" + directory / f"{modelname}.gpkg", layer=f"{self.input_type}" ) return diff --git a/ribasim/level_control.py b/ribasim/level_control.py index 340f222e7..729da2de8 100644 --- a/ribasim/level_control.py +++ b/ribasim/level_control.py @@ -5,5 +5,10 @@ class LevelControl(BaseModel, ArrowInputMixin): - _input_type = "static_LevelControl" + _input_type = "LevelControl" + dataframe: DataFrame + + +class LevelControlForcing(BaseModel, ArrowInputMixin): + _input_type = "LevelControl / forcing" dataframe: DataFrame diff --git a/ribasim/node.py b/ribasim/node.py index b1a04ab88..1ac1157b3 100644 --- a/ribasim/node.py +++ b/ribasim/node.py @@ -6,5 +6,5 @@ class Node(BaseModel, InputMixin): - _input_type = "node" + _input_type = "Node" dataframe: DataFrame diff --git a/ribasim/outflow_table.py b/ribasim/outflow_table.py index f77193161..d6764dc0f 100644 --- a/ribasim/outflow_table.py +++ b/ribasim/outflow_table.py @@ -4,6 +4,6 @@ from ribasim.types import DataFrame -class OutflowTable(BaseModel, ArrowInputMixin): - _input_type = "lookup_OutflowTable" +class TabulatedRatingCurve(BaseModel, ArrowInputMixin): + _input_type = "TabulatedRatingCurve" dataframe: DataFrame From 6e0187636626a1420691b59267e17a9a3b3ddbe6 Mon Sep 17 00:00:00 2001 From: Huite Bootsma Date: Fri, 24 Feb 2023 11:14:24 +0100 Subject: [PATCH 09/33] More renaming --- ribasim/__init__.py | 4 ++-- ribasim/{outflow_table.py => tabulated_rating_curve.py} | 0 2 files changed, 2 insertions(+), 2 deletions(-) rename ribasim/{outflow_table.py => tabulated_rating_curve.py} (100%) diff --git a/ribasim/__init__.py b/ribasim/__init__.py index c9f14467a..587aa77bc 100644 --- a/ribasim/__init__.py +++ b/ribasim/__init__.py @@ -4,6 +4,6 @@ from ribasim.node import Node from ribasim.edge import Edge from ribasim.bifurcation import Bifurcation -from ribasim.basin import BasinLookup, BasinState -from ribasim.outflow_table import OutflowTable +from ribasim.basin import BasinProfile, BasinState +from ribasim.tabulated_rating_curve import TabulatedRatingCurve from ribasim.model import RibasimModel diff --git a/ribasim/outflow_table.py b/ribasim/tabulated_rating_curve.py similarity index 100% rename from ribasim/outflow_table.py rename to ribasim/tabulated_rating_curve.py From 25396931da2c90337b79f83769889cb4461cb546 Mon Sep 17 00:00:00 2001 From: Huite Bootsma Date: Fri, 24 Feb 2023 13:08:20 +0100 Subject: [PATCH 10/33] Renaming in the plugin --- plugin/ribasim_qgis/core/nodes.py | 166 ++++++++---------- plugin/ribasim_qgis/widgets/dataset_widget.py | 20 +-- plugin/ribasim_qgis/widgets/nodes_widget.py | 2 +- ribasim/__init__.py | 2 +- ribasim/basin.py | 2 +- ribasim/model.py | 2 +- 6 files changed, 81 insertions(+), 113 deletions(-) diff --git a/plugin/ribasim_qgis/core/nodes.py b/plugin/ribasim_qgis/core/nodes.py index 39bee8bd0..f5a3049f2 100644 --- a/plugin/ribasim_qgis/core/nodes.py +++ b/plugin/ribasim_qgis/core/nodes.py @@ -25,26 +25,13 @@ from PyQt5.QtCore import QVariant from PyQt5.QtGui import QColor -from PyQt5.QtWidgets import ( - QDialog, - QHBoxLayout, - QLabel, - QLineEdit, - QPushButton, - QVBoxLayout, -) from qgis.core import ( QgsCategorizedSymbolRenderer, - QgsDefaultValue, QgsEditorWidgetSetup, - QgsFeature, QgsField, - QgsFillSymbol, - QgsGeometry, QgsLineSymbol, QgsMarkerSymbol, QgsPalLayerSettings, - QgsPointXY, QgsRendererCategory, QgsSimpleMarkerSymbolLayerBase, QgsSingleSymbolRenderer, @@ -54,18 +41,18 @@ from ribasim_qgis.core import geopackage -class RibasimInput(abc.ABC): +class Input(abc.ABC): """ Abstract base class for Ribasim input layers. """ def __init__(self, path: str): - self.name = f"ribasim_{self.input_type}" + self.name = self.input_type self.path = path self.layer = None @classmethod - def create(cls, path: str, crs: Any, names: List[str]) -> "RibasimInput": + def create(cls, path: str, crs: Any, names: List[str]) -> "Input": instance = cls(path) if instance.name in names: raise ValueError(f"Name already exists in geopackage: {instance.name}") @@ -128,17 +115,14 @@ def set_editor_widget(self) -> None: return -class Basin(RibasimInput): - input_type = "node" +class Node(Input): + input_type = "Node" geometry_type = "Point" - attributes = [ - # TODO: node should be a ComboBox? - QgsField("node", QVariant.String), # TODO discuss - ] + attributes = [] def write(self) -> None: """ - Special the LSW layer write because it needs to generate a new file. + Special the Basin layer write because it needs to generate a new file. """ self.layer = geopackage.write_layer( self.path, self.layer, self.name, newfile=True @@ -148,14 +132,14 @@ def write(self) -> None: def set_editor_widget(self) -> None: layer = self.layer - index = layer.fields().indexFromName("node") + index = layer.fields().indexFromName("Node") setup = QgsEditorWidgetSetup( "ValueMap", { "map": { - "LSW": "LSW", + "Basin": "Basin", "Bifurcation": "Bifurcation", - "OutflowTable": "OutflowTable", + "TabulatedRatingCurve": "TabulatedRatingCurve", "LevelControl": "LevelControl", }, }, @@ -172,9 +156,9 @@ def set_editor_widget(self) -> None: def renderer(self) -> QgsCategorizedSymbolRenderer: shape = QgsSimpleMarkerSymbolLayerBase markers = { - "LSW": (QColor("blue"), "LSW", shape.Circle), + "Basin": (QColor("blue"), "Basin", shape.Circle), "Bifurcation": (QColor("red"), "Bifurcation", shape.Triangle), - "OutflowTable": (QColor("green"), "OutflowTable", shape.Diamond), + "TabulatedRatingCurve": (QColor("green"), "TabulatedRatingCurve", shape.Diamond), "LevelControl": (QColor("blue"), "LevelControl", shape.Star), "": ( QColor("white"), @@ -192,7 +176,7 @@ def renderer(self) -> QgsCategorizedSymbolRenderer: category = QgsRendererCategory(value, symbol, label, shape) categories.append(category) - renderer = QgsCategorizedSymbolRenderer(attrName="node", categories=categories) + renderer = QgsCategorizedSymbolRenderer(attrName="Node", categories=categories) return renderer @property @@ -205,12 +189,28 @@ def labels(self) -> Any: return labels -class Edges(RibasimInput): - input_type = "edge" + +class Basin(Input): + input_type = "Basin" + geometry_type = "No geometry" + attributes = [ + # TODO: node should be a ComboBox? + QgsField("time", QVariant.DateTime), + QgsField("node_id", QVariant.Int), + QgsField("drainage", QVariant.Double), + QgsField("potential_evaporation", QVariant.Double), + QgsField("infiltration", QVariant.Double), + QgsField("precipitation", QVariant.Double), + QgsField("urban_runoff", QVariant.Double), + ] + + +class Edge(Input): + input_type = "Edge" geometry_type = "Linestring" attributes = [ - QgsField("from_node_fid", QVariant.Int), - QgsField("to_node_fid", QVariant.Int), + QgsField("from_node_id", QVariant.Int), + QgsField("to_node_id", QVariant.Int), ] @property @@ -232,115 +232,87 @@ def set_read_only(self) -> None: return -class BasinLookup(RibasimInput): - input_type = "lookup_LSW" +class BasinProfile(Input): + input_type = "Basin / profile" geometry_type = "No Geometry" attributes = [ - QgsField("node_fid", QVariant.Int), - QgsField("volume", QVariant.Double), + QgsField("node_id", QVariant.Int), + QgsField("storage", QVariant.Double), QgsField("area", QVariant.Double), QgsField("level", QVariant.Double), ] -class OutflowTableLookup(RibasimInput): - input_type = "lookup_OutflowTable" +class TabulatedRatingCurve(Input): + input_type = "TabulatedRatingCurve" geometry_type = "No Geometry" attributes = [ - QgsField("node_fid", QVariant.Int), + QgsField("node_id", QVariant.Int), QgsField("level", QVariant.Double), QgsField("discharge", QVariant.Double), ] -class Bifurcation(RibasimInput): - input_type = "static_Bifurcation" +class FractionalFlow(Input): + input_type = "FractionalFlow" geometry_type = "No Geometry" attributes = [ - QgsField("node_fid", QVariant.Int), - QgsField("fraction_1", QVariant.Double), - QgsField("fraction_2", QVariant.Double), + QgsField("node_id", QVariant.Int), + QgsField("fraction", QVariant.Double), ] -class LevelControl(RibasimInput): - input_type = "static_LevelControl" +class LevelControl(Input): + input_type = "LevelControl" geometry_type = "No Geometry" attributes = [ - QgsField("node_fid", QVariant.Int), - QgsField("target_volume", QVariant.Double), + QgsField("node_id", QVariant.Int), + QgsField("target_level", QVariant.Double), ] -class BasinState(RibasimInput): - input_type = "state_LSW" +class BasinState(Input): + input_type = "LSW / state" geometry_type = "No Geometry" attributes = [ - QgsField("node_fid", QVariant.Int), - QgsField("S", QVariant.Double), - QgsField("C", QVariant.Double), + QgsField("node_id", QVariant.Int), + QgsField("storage", QVariant.Double), + QgsField("concentration", QVariant.Double), ] -class BasinForcing(RibasimInput): - input_type = "forcing_LSW" +class BasinForcing(Input): + input_type = "Basin / forcing" geometry_type = "No Geometry" attributes = [ QgsField("time", QVariant.DateTime), - QgsField("node_fid", QVariant.Int), - QgsField("demand", QVariant.Double), + QgsField("node_id", QVariant.Int), QgsField("drainage", QVariant.Double), - QgsField("E_pot", QVariant.Double), + QgsField("potential_evaporation", QVariant.Double), QgsField("infiltration", QVariant.Double), - QgsField("P", QVariant.Double), - QgsField("priority", QVariant.Double), + QgsField("precipitation", QVariant.Double), QgsField("urban_runoff", QVariant.Double), ] NODES = { - "node": Basin, - "edge": Edges, - "lookup_LSW": BasinLookup, - "lookup_OutflowTable": OutflowTableLookup, - "static_Bifurcation": Bifurcation, - "static_LevelControl": LevelControl, - "forcing_LSW": BasinForcing, + "Node": Node, + "Edge": Edge, + "Basin": Basin, + "Basin / state": BasinState, + "Basin / profile": BasinProfile, + "Basin / forcing": BasinForcing, + "TabulatedRatingCurve": TabulatedRatingCurve, + "FractionalFlow": FractionalFlow, + "LevelControl": LevelControl, } -def parse_name(layername: str) -> Tuple[str, str]: - """ - Based on the layer name find out which type it is. - - For example: - parse_name("Ribasim Edges: network") -> ("Edges", "network") - """ - values = layername.split("_") - if len(values) == 2: - _, kind = values - nodetype = None - elif len(values) == 3: - _, kind, nodetype = values - else: - raise ValueError( - 'Expected layer name of "ribasim_{kind}_{nodetype}", ' - f'"ribasim_node", "ribasim_edge". Received {layername}' - ) - return kind, nodetype - - -def load_nodes_from_geopackage(path: str) -> Dict[str, RibasimInput]: +def load_nodes_from_geopackage(path: str) -> Dict[str, Input]: # List the names in the geopackage gpkg_names = geopackage.layers(path) nodes = {} for layername in gpkg_names: - if layername.startswith("ribasim_"): - kind, nodetype = parse_name(layername) - if kind in ("node", "edge"): - key = kind - else: - key = f"{kind}_{nodetype}" - nodes[key] = NODES[key](path) + nodes[layername] = NODES[layername](path) return nodes diff --git a/plugin/ribasim_qgis/widgets/dataset_widget.py b/plugin/ribasim_qgis/widgets/dataset_widget.py index 5215a3868..d7263af4d 100644 --- a/plugin/ribasim_qgis/widgets/dataset_widget.py +++ b/plugin/ribasim_qgis/widgets/dataset_widget.py @@ -26,7 +26,7 @@ ) from qgis.core import QgsMapLayer, QgsProject from qgis.core.additions.edit import edit -from ribasim_qgis.core.nodes import Edges, Basin, load_nodes_from_geopackage +from ribasim_qgis.core.nodes import Edge, Node, load_nodes_from_geopackage from ribasim_qgis.core.topology import derive_connectivity, explode_lines @@ -76,10 +76,6 @@ def remove_geopackage_layers(self) -> None: # Collect the selected items selection = self.selectedItems() - # Append associated items - for item in selection: - if item.assoc_item is not None and item.assoc_item not in selection: - selection.append(item.assoc_item) # Warn before deletion message = "\n".join([f"- {item.text(1)}" for item in selection]) @@ -192,8 +188,8 @@ def explode_and_connect(self) -> None: from_id, to_id = derive_connectivity(node_index, node_xy, edge_xy) fields = edge.fields() - field1 = fields.indexFromName("from_node_fid") - field2 = fields.indexFromName("to_node_fid") + field1 = fields.indexFromName("from_node_id") + field2 = fields.indexFromName("to_node_id") try: # Avoid infinite recursion edge.blockSignals(True) @@ -230,7 +226,7 @@ def add_item_to_qgis(self, item) -> None: element = item.element layer, renderer, labels = element.from_geopackage() suppress = self.suppress_popup_checkbox.isChecked() - if element.input_type == "edge": + if element.input_type == "Edge": suppress = True self.add_layer(layer, "Ribasim Input", renderer, suppress, labels=labels) element.set_editor_widget() @@ -256,8 +252,8 @@ def load_geopackage(self) -> None: 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 = nodes["Node"].layer + self.edge_layer = nodes["Edge"].layer self.edge_layer.afterCommitChanges.connect(self.explode_and_connect) return @@ -268,7 +264,7 @@ def new_geopackage(self) -> None: path, _ = QFileDialog.getSaveFileName(self, "Select file", "", "*.gpkg") if path != "": # Empty string in case of cancel button press self.dataset_line_edit.setText(path) - for input_type in (Basin, Edges): + for input_type in (Node, Edge): instance = input_type.create(path, self.parent.crs, names=[]) instance.write() self.load_geopackage() @@ -302,7 +298,7 @@ def suppress_popup_changed(self): if layer is not None: config = layer.editFormConfig() # Always suppress the attribute form pop-up for edges. - if item.element.input_type == "edge": + if item.element.input_type == "Edge": config.setSuppress(True) else: config.setSuppress(suppress) diff --git a/plugin/ribasim_qgis/widgets/nodes_widget.py b/plugin/ribasim_qgis/widgets/nodes_widget.py index 166e1ddce..a44ebb12f 100644 --- a/plugin/ribasim_qgis/widgets/nodes_widget.py +++ b/plugin/ribasim_qgis/widgets/nodes_widget.py @@ -11,7 +11,7 @@ def __init__(self, parent): self.node_buttons = {} for node in NODES: - if node in ("node", "edge"): + if node in ("Node", "Edge"): continue button = QPushButton(node) button.clicked.connect(partial(self.new_node_layer, node_type=node)) diff --git a/ribasim/__init__.py b/ribasim/__init__.py index 587aa77bc..b2f3caf17 100644 --- a/ribasim/__init__.py +++ b/ribasim/__init__.py @@ -6,4 +6,4 @@ from ribasim.bifurcation import Bifurcation from ribasim.basin import BasinProfile, BasinState from ribasim.tabulated_rating_curve import TabulatedRatingCurve -from ribasim.model import RibasimModel +from ribasim.model import Model diff --git a/ribasim/basin.py b/ribasim/basin.py index f6ff73e6f..f6f70ca39 100644 --- a/ribasim/basin.py +++ b/ribasim/basin.py @@ -21,4 +21,4 @@ class BasinProfile(BaseModel, ArrowInputMixin): class BasinForcing(BaseModel, ArrowInputMixin): _input_type = "Basin / forcing" - dataframe: DataFrame \ No newline at end of file + dataframe: DataFrame diff --git a/ribasim/model.py b/ribasim/model.py index fdeb86c13..26a2c96b8 100644 --- a/ribasim/model.py +++ b/ribasim/model.py @@ -14,7 +14,7 @@ ) -class RibasimModel(BaseModel): +class Model(BaseModel): node: Node edge: Edge basin_state: Optional[BasinState] = None From 219ca2ba43fe61747f993f060b2c5314ec3a7290 Mon Sep 17 00:00:00 2001 From: Huite Bootsma Date: Fri, 24 Feb 2023 13:26:47 +0100 Subject: [PATCH 11/33] format --- plugin/ribasim_qgis/core/nodes.py | 85 ++++++++++++++++--------------- ribasim/__init__.py | 8 +-- ribasim/edge.py | 2 +- ribasim/input_base.py | 2 +- ribasim/model.py | 13 ++--- ribasim/node.py | 2 +- ribasim/types.py | 2 +- tests/test_edge.py | 2 +- 8 files changed, 55 insertions(+), 61 deletions(-) diff --git a/plugin/ribasim_qgis/core/nodes.py b/plugin/ribasim_qgis/core/nodes.py index f5a3049f2..ecdd5e5b6 100644 --- a/plugin/ribasim_qgis/core/nodes.py +++ b/plugin/ribasim_qgis/core/nodes.py @@ -118,7 +118,7 @@ def set_editor_widget(self) -> None: class Node(Input): input_type = "Node" geometry_type = "Point" - attributes = [] + attributes = (QgsField("Node", QVariant.String),) def write(self) -> None: """ @@ -138,7 +138,7 @@ def set_editor_widget(self) -> None: { "map": { "Basin": "Basin", - "Bifurcation": "Bifurcation", + "FractionalFlow": "FractionalFlow", "TabulatedRatingCurve": "TabulatedRatingCurve", "LevelControl": "LevelControl", }, @@ -157,8 +157,12 @@ def renderer(self) -> QgsCategorizedSymbolRenderer: shape = QgsSimpleMarkerSymbolLayerBase markers = { "Basin": (QColor("blue"), "Basin", shape.Circle), - "Bifurcation": (QColor("red"), "Bifurcation", shape.Triangle), - "TabulatedRatingCurve": (QColor("green"), "TabulatedRatingCurve", shape.Diamond), + "FractionalFlow": (QColor("red"), "FractionalFlow", shape.Triangle), + "TabulatedRatingCurve": ( + QColor("green"), + "TabulatedRatingCurve", + shape.Diamond, + ), "LevelControl": (QColor("blue"), "LevelControl", shape.Star), "": ( QColor("white"), @@ -189,22 +193,6 @@ def labels(self) -> Any: return labels - -class Basin(Input): - input_type = "Basin" - geometry_type = "No geometry" - attributes = [ - # TODO: node should be a ComboBox? - QgsField("time", QVariant.DateTime), - QgsField("node_id", QVariant.Int), - QgsField("drainage", QVariant.Double), - QgsField("potential_evaporation", QVariant.Double), - QgsField("infiltration", QVariant.Double), - QgsField("precipitation", QVariant.Double), - QgsField("urban_runoff", QVariant.Double), - ] - - class Edge(Input): input_type = "Edge" geometry_type = "Linestring" @@ -243,55 +231,68 @@ class BasinProfile(Input): ] -class TabulatedRatingCurve(Input): - input_type = "TabulatedRatingCurve" +class Basin(Input): + input_type = "Basin" + geometry_type = "No geometry" + attributes = [ + QgsField("node_id", QVariant.Int), + QgsField("drainage", QVariant.Double), + QgsField("potential_evaporation", QVariant.Double), + QgsField("infiltration", QVariant.Double), + QgsField("precipitation", QVariant.Double), + QgsField("urban_runoff", QVariant.Double), + ] + + +class BasinForcing(Input): + input_type = "Basin / forcing" geometry_type = "No Geometry" attributes = [ + QgsField("time", QVariant.DateTime), QgsField("node_id", QVariant.Int), - QgsField("level", QVariant.Double), - QgsField("discharge", QVariant.Double), + QgsField("drainage", QVariant.Double), + QgsField("potential_evaporation", QVariant.Double), + QgsField("infiltration", QVariant.Double), + QgsField("precipitation", QVariant.Double), + QgsField("urban_runoff", QVariant.Double), ] -class FractionalFlow(Input): - input_type = "FractionalFlow" +class BasinState(Input): + input_type = "LSW / state" geometry_type = "No Geometry" attributes = [ QgsField("node_id", QVariant.Int), - QgsField("fraction", QVariant.Double), + QgsField("storage", QVariant.Double), + QgsField("concentration", QVariant.Double), ] -class LevelControl(Input): - input_type = "LevelControl" +class TabulatedRatingCurve(Input): + input_type = "TabulatedRatingCurve" geometry_type = "No Geometry" attributes = [ QgsField("node_id", QVariant.Int), - QgsField("target_level", QVariant.Double), + QgsField("level", QVariant.Double), + QgsField("discharge", QVariant.Double), ] -class BasinState(Input): - input_type = "LSW / state" +class FractionalFlow(Input): + input_type = "FractionalFlow" geometry_type = "No Geometry" attributes = [ QgsField("node_id", QVariant.Int), - QgsField("storage", QVariant.Double), - QgsField("concentration", QVariant.Double), + QgsField("fraction", QVariant.Double), ] -class BasinForcing(Input): - input_type = "Basin / forcing" +class LevelControl(Input): + input_type = "LevelControl" geometry_type = "No Geometry" attributes = [ - QgsField("time", QVariant.DateTime), QgsField("node_id", QVariant.Int), - QgsField("drainage", QVariant.Double), - QgsField("potential_evaporation", QVariant.Double), - QgsField("infiltration", QVariant.Double), - QgsField("precipitation", QVariant.Double), - QgsField("urban_runoff", QVariant.Double), + QgsField("target_level", QVariant.Double), ] diff --git a/ribasim/__init__.py b/ribasim/__init__.py index b2f3caf17..e9f2d0dcb 100644 --- a/ribasim/__init__.py +++ b/ribasim/__init__.py @@ -1,9 +1,9 @@ __version__ = "0.1.0" -from ribasim.node import Node -from ribasim.edge import Edge -from ribasim.bifurcation import Bifurcation from ribasim.basin import BasinProfile, BasinState -from ribasim.tabulated_rating_curve import TabulatedRatingCurve +from ribasim.bifurcation import Bifurcation +from ribasim.edge import Edge from ribasim.model import Model +from ribasim.node import Node +from ribasim.tabulated_rating_curve import TabulatedRatingCurve diff --git a/ribasim/edge.py b/ribasim/edge.py index 6534a074e..a6ef0eb9e 100644 --- a/ribasim/edge.py +++ b/ribasim/edge.py @@ -1,5 +1,5 @@ -from pydantic import BaseModel, PrivateAttr import pandas as pd +from pydantic import BaseModel, PrivateAttr from ribasim.input_base import InputMixin from ribasim.types import DataFrame diff --git a/ribasim/input_base.py b/ribasim/input_base.py index dfa20efac..1571df9ee 100644 --- a/ribasim/input_base.py +++ b/ribasim/input_base.py @@ -1,5 +1,5 @@ -from pathlib import Path import abc +from pathlib import Path class InputMixin(abc.ABC): diff --git a/ribasim/model.py b/ribasim/model.py index 26a2c96b8..91253062d 100644 --- a/ribasim/model.py +++ b/ribasim/model.py @@ -1,17 +1,10 @@ -from typing import Optional from pathlib import Path +from typing import Optional -from pydantic import BaseModel import tomli_w +from pydantic import BaseModel -from ribasim import ( - Node, - Edge, - Bifurcation, - BasinLookup, - BasinState, - OutflowTable, -) +from ribasim import BasinLookup, BasinState, Bifurcation, Edge, Node, OutflowTable class Model(BaseModel): diff --git a/ribasim/node.py b/ribasim/node.py index 1ac1157b3..0c1989ec5 100644 --- a/ribasim/node.py +++ b/ribasim/node.py @@ -1,5 +1,5 @@ -from pydantic import BaseModel import pandas as pd +from pydantic import BaseModel from ribasim.input_base import InputMixin from ribasim.types import DataFrame diff --git a/ribasim/types.py b/ribasim/types.py index 3cc9f33ac..03f829600 100644 --- a/ribasim/types.py +++ b/ribasim/types.py @@ -1,5 +1,5 @@ from typing import TypeVar -from pandas import DataFrame +from pandas import DataFrame DataFrame = TypeVar("DataFrame") diff --git a/tests/test_edge.py b/tests/test_edge.py index 174cb8d95..27da1e932 100644 --- a/tests/test_edge.py +++ b/tests/test_edge.py @@ -1,6 +1,6 @@ import geopandas as gpd -import shapely.geometry as sg import pytest +import shapely.geometry as sg from ribasim.edge import Edge From a92aaa1d6f7cbbcaf5502cc9edae94e697bd8896 Mon Sep 17 00:00:00 2001 From: Huite Bootsma Date: Fri, 24 Feb 2023 14:34:20 +0100 Subject: [PATCH 12/33] Typo fix --- plugin/ribasim_qgis/core/nodes.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/plugin/ribasim_qgis/core/nodes.py b/plugin/ribasim_qgis/core/nodes.py index ecdd5e5b6..d6ace3cb0 100644 --- a/plugin/ribasim_qgis/core/nodes.py +++ b/plugin/ribasim_qgis/core/nodes.py @@ -141,6 +141,7 @@ def set_editor_widget(self) -> None: "FractionalFlow": "FractionalFlow", "TabulatedRatingCurve": "TabulatedRatingCurve", "LevelControl": "LevelControl", + "LinearLevelConnection": "LinearLevelConnection", }, }, ) @@ -158,6 +159,11 @@ def renderer(self) -> QgsCategorizedSymbolRenderer: markers = { "Basin": (QColor("blue"), "Basin", shape.Circle), "FractionalFlow": (QColor("red"), "FractionalFlow", shape.Triangle), + "LinearLevelConnection": ( + QColor("green"), + "LinearLevelConnection", + shape.Triangle, + ), "TabulatedRatingCurve": ( QColor("green"), "TabulatedRatingCurve", @@ -259,7 +265,7 @@ class BasinForcing(Input): class BasinState(Input): - input_type = "LSW / state" + input_type = "Basin / state" geometry_type = "No Geometry" attributes = [ QgsField("node_id", QVariant.Int), @@ -273,7 +279,7 @@ class TabulatedRatingCurve(Input): geometry_type = "No Geometry" attributes = [ QgsField("node_id", QVariant.Int), - QgsField("level", QVariant.Double), + QgsField("storage", QVariant.Double), QgsField("discharge", QVariant.Double), ] @@ -287,6 +293,15 @@ class FractionalFlow(Input): ] +class LinearLevelConnection(Input): + input_type = "LinearLevelConnection" + geometry_type = "No Geometry" + attributes = [ + QgsField("node_id", QVariant.Int), + QgsField("conductance", QVariant.Double), + ] + + class LevelControl(Input): input_type = "LevelControl" geometry_type = "No Geometry" @@ -305,6 +320,7 @@ class LevelControl(Input): "Basin / forcing": BasinForcing, "TabulatedRatingCurve": TabulatedRatingCurve, "FractionalFlow": FractionalFlow, + "LinearLevelConnection": LinearLevelConnection, "LevelControl": LevelControl, } @@ -315,5 +331,4 @@ def load_nodes_from_geopackage(path: str) -> Dict[str, Input]: nodes = {} for layername in gpkg_names: nodes[layername] = NODES[layername](path) - return nodes From e52aa2fc5d9d88b5865d165e35f5d4314332cc6a Mon Sep 17 00:00:00 2001 From: Huite Bootsma Date: Fri, 24 Feb 2023 17:54:28 +0100 Subject: [PATCH 13/33] "Node" column in Node layer should be "type" --- plugin/ribasim_qgis/core/nodes.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugin/ribasim_qgis/core/nodes.py b/plugin/ribasim_qgis/core/nodes.py index d6ace3cb0..244f9b5d3 100644 --- a/plugin/ribasim_qgis/core/nodes.py +++ b/plugin/ribasim_qgis/core/nodes.py @@ -118,7 +118,7 @@ def set_editor_widget(self) -> None: class Node(Input): input_type = "Node" geometry_type = "Point" - attributes = (QgsField("Node", QVariant.String),) + attributes = (QgsField("type", QVariant.String),) def write(self) -> None: """ @@ -132,7 +132,7 @@ def write(self) -> None: def set_editor_widget(self) -> None: layer = self.layer - index = layer.fields().indexFromName("Node") + index = layer.fields().indexFromName("node") setup = QgsEditorWidgetSetup( "ValueMap", { From 6bfbccc56ca9147ed417b32c6499f7a0506a5c27 Mon Sep 17 00:00:00 2001 From: Huite Bootsma Date: Fri, 24 Feb 2023 17:55:09 +0100 Subject: [PATCH 14/33] Some reading, writing, dataframe validation with pandera --- pyproject.toml | 1 + ribasim/__init__.py | 6 ++- ribasim/basin.py | 51 ++++++++++++++----- ribasim/bifurcation.py | 14 ----- ribasim/edge.py | 13 +++-- ribasim/fractional_flow.py | 24 +++++++++ ribasim/input_base.py | 60 ++++++++++++++++++---- ribasim/level_control.py | 17 ++++--- ribasim/linear_level_connection.py | 15 ++++++ ribasim/model.py | 82 +++++++++++++++++++++--------- ribasim/node.py | 9 +++- ribasim/tabulated_rating_curve.py | 15 ++++-- ribasim/types.py | 5 -- 13 files changed, 226 insertions(+), 86 deletions(-) delete mode 100644 ribasim/bifurcation.py create mode 100644 ribasim/fractional_flow.py create mode 100644 ribasim/linear_level_connection.py delete mode 100644 ribasim/types.py diff --git a/pyproject.toml b/pyproject.toml index 27cabbb20..391846215 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ requires-python = ">=3.9" dependencies = [ "geopandas", "pandas", + "pandera", "pyarrow", "pydantic", "tomli-w", diff --git a/ribasim/__init__.py b/ribasim/__init__.py index e9f2d0dcb..ba7db5689 100644 --- a/ribasim/__init__.py +++ b/ribasim/__init__.py @@ -1,9 +1,11 @@ __version__ = "0.1.0" -from ribasim.basin import BasinProfile, BasinState -from ribasim.bifurcation import Bifurcation +from ribasim.basin import Basin from ribasim.edge import Edge +from ribasim.fractional_flow import FractionalFlow +from ribasim.level_control import LevelControl +from ribasim.linear_level_connection import LinearLevelConnection from ribasim.model import Model from ribasim.node import Node from ribasim.tabulated_rating_curve import TabulatedRatingCurve diff --git a/ribasim/basin.py b/ribasim/basin.py index f6f70ca39..98bb886fb 100644 --- a/ribasim/basin.py +++ b/ribasim/basin.py @@ -1,24 +1,47 @@ +from typing import Optional + +import pandera as pa +from pandera.typing import DataFrame, Series from pydantic import BaseModel -from ribasim.input_base import ArrowInputMixin -from ribasim.types import DataFrame +from ribasim.input_base import InputMixin -class Basin(BaseModel, ArrowInputMixin): - _input_type = "Basin" - dataframe: DataFrame +class StaticSchema(pa.SchemaModel): + node_id: Series[int] = pa.Field(unique=True) + drainage: Series[float] = pa.Field() + potential_evaporation: Series[float] = pa.Field() + infiltration: Series[float] = pa.Field() + precipitation: Series[float] = pa.Field() + urban_runoff: Series[float] = pa.Field() + +class ForcingSchema(pa.SchemaModel): + node_id: Series[int] = pa.Field() + time: Series[pa.dtypes.DateTime] = pa.Field() + drainage: Series[float] = pa.Field() + potential_evaporation: Series[float] = pa.Field() + infiltration: Series[float] = pa.Field() + precipitation: Series[float] = pa.Field() + urban_runoff: Series[float] = pa.Field() -class BasinState(BaseModel, ArrowInputMixin): - _input_type = "Basin / state" - dataframe: DataFrame +class ProfileSchema(pa.SchemaModel): + node_id: Series[int] = pa.Field() + storage: Series[float] = pa.Field() + area: Series[float] = pa.Field() + level: Series[float] = pa.Field() -class BasinProfile(BaseModel, ArrowInputMixin): - _input_type = "Basin / profile" - dataframe: DataFrame +class StateSchema(pa.SchemaModel): + node_id: Series[int] = pa.Field(unique=True) + storage: Series[float] = pa.Field() + concentration: Series[float] = pa.Field() -class BasinForcing(BaseModel, ArrowInputMixin): - _input_type = "Basin / forcing" - dataframe: DataFrame + +class Basin(BaseModel, InputMixin): + _input_type = "Basin" + profile: DataFrame[ProfileSchema] + static: Optional[DataFrame[StaticSchema]] = None + forcing: Optional[DataFrame[ForcingSchema]] = None + state: Optional[DataFrame[StateSchema]] = None diff --git a/ribasim/bifurcation.py b/ribasim/bifurcation.py deleted file mode 100644 index 2f3608221..000000000 --- a/ribasim/bifurcation.py +++ /dev/null @@ -1,14 +0,0 @@ -from pydantic import BaseModel - -from ribasim.input_base import ArrowInputMixin -from ribasim.types import DataFrame - - -class Bifurcation(BaseModel, ArrowInputMixin): - _input_type = "Bifurcation" - dataframe: DataFrame - - -class BifurcationForcing(BaseModel, ArrowInputMixin): - _input_type = "Bifurcation / forcing" - dataframe: DataFrame diff --git a/ribasim/edge.py b/ribasim/edge.py index a6ef0eb9e..42e3a8fdc 100644 --- a/ribasim/edge.py +++ b/ribasim/edge.py @@ -1,10 +1,15 @@ -import pandas as pd -from pydantic import BaseModel, PrivateAttr +import pandera as pa +from pandera.typing import DataFrame, Series +from pydantic import BaseModel from ribasim.input_base import InputMixin -from ribasim.types import DataFrame + + +class StaticSchema(pa.SchemaModel): + from_node_id: Series[int] = pa.Field() + to_node_id: Series[int] = pa.Field() class Edge(BaseModel, InputMixin): _input_type = "Edge" - dataframe: DataFrame + static: DataFrame[StaticSchema] diff --git a/ribasim/fractional_flow.py b/ribasim/fractional_flow.py new file mode 100644 index 000000000..3a0821b3e --- /dev/null +++ b/ribasim/fractional_flow.py @@ -0,0 +1,24 @@ +from typing import Optional + +import pandera as pa +from pandera.typing import DataFrame, Series +from pydantic import BaseModel + +from ribasim.input_base import InputMixin + + +class StaticSchema(pa.SchemaModel): + node_id: Series[int] = pa.Field() + fraction: Series[float] = pa.Field() + + +class ForcingSchema(pa.SchemaModel): + node_id: Series[int] = pa.Field() + time: Series[pa.dtypes.DateTime] = pa.Field() + fraction: Series[float] = pa.Field() + + +class FractionalFlow(BaseModel, InputMixin): + _input_type = "FractionalFlow" + static: DataFrame[StaticSchema] + forcing: Optional[DataFrame[ForcingSchema]] = None diff --git a/ribasim/input_base.py b/ribasim/input_base.py index 1571df9ee..244f9c91e 100644 --- a/ribasim/input_base.py +++ b/ribasim/input_base.py @@ -1,24 +1,64 @@ import abc from pathlib import Path +from typing import Dict, Tuple + +import geopandas as gpd +import pandas as pd class InputMixin(abc.ABC): + @classmethod + def fields(cls): + return cls.__fields__.keys() + + def values(self): + return self._dict().values() + def _write_geopackage(self, directory: Path, modelname: str) -> None: self.dataframe.to_file( directory / f"{modelname}.gpkg", layer=f"{self.input_type}" ) return - def write(self, directory, modelname, to_arrow: bool = False) -> None: - if to_arrow: - self._write_arrow(directory) - else: - self._write_geopackage(directory, modelname) - return - - -class ArrowInputMixin(InputMixin, abc.ABC): def _write_arrow(self, directory: Path) -> None: - path = directory / f"{self.input_type}.arrow" + path = directory / f"{self._input_type}.arrow" self.dataframe.write_feather(path) return + + def write(self, directory, modelname): + directory = Path(directory) + for key, dataframe in self.dict().values(): + name = self._input_type + if key != "static": + name = f"{name} / {key}" + dataframe.to_file(directory / f"{modelname}.gpkg", layer=name) + return + + @classmethod + def _kwargs_from_geopackage(cls, path): + kwargs = {} + for key in cls.keys(): + if key == "static": + df = gpd.read_file(path, layer=cls._input_type) + else: + df = gpd.read_file(path, layer=f"{cls._input_type} / {key}") + kwargs[key] = df + return kwargs + + @classmethod + def _kwargs_from_toml(cls, config): + return {key: pd.read_feather(path) for key, path in config.items()} + + @classmethod + def from_geopackage(cls, path): + kwargs = cls._kwargs_from_geopackage(path) + return cls(**kwargs) + + @classmethod + def from_config(cls, config): + geopackage = config["geopackage"] + kwargs = cls._kwargs_from_geopackage(geopackage) + input_content = config.get(cls._input_type, None) + if input_content: + kwargs.update(**cls._kwargs_from_toml(config)) + return cls(**kwargs) diff --git a/ribasim/level_control.py b/ribasim/level_control.py index 729da2de8..ea594ac4e 100644 --- a/ribasim/level_control.py +++ b/ribasim/level_control.py @@ -1,14 +1,15 @@ +import pandera as pa +from pandera.typing import DataFrame, Series from pydantic import BaseModel -from ribasim.input_base import ArrowInputMixin -from ribasim.types import DataFrame +from ribasim.input_base import InputMixin -class LevelControl(BaseModel, ArrowInputMixin): - _input_type = "LevelControl" - dataframe: DataFrame +class StaticSchema(pa.SchemaModel): + node_id: Series[int] = pa.Field(unique=True) + target_level: Series[float] = pa.Field() -class LevelControlForcing(BaseModel, ArrowInputMixin): - _input_type = "LevelControl / forcing" - dataframe: DataFrame +class LevelControl(BaseModel, InputMixin): + _input_type = "LevelControl" + static: DataFrame[StaticSchema] diff --git a/ribasim/linear_level_connection.py b/ribasim/linear_level_connection.py new file mode 100644 index 000000000..937dd1685 --- /dev/null +++ b/ribasim/linear_level_connection.py @@ -0,0 +1,15 @@ +import pandera as pa +from pandera.typing import DataFrame, Series +from pydantic import BaseModel + +from ribasim.input_base import InputMixin + + +class StaticSchema(pa.SchemaModel): + node_id: Series[int] = pa.Field(unique=True) + conductance: Series[float] = pa.Field() + + +class LinearLevelConnection(BaseModel, InputMixin): + _input_type = "LinearLevelConnection" + static: DataFrame[StaticSchema] diff --git a/ribasim/model.py b/ribasim/model.py index 91253062d..590028517 100644 --- a/ribasim/model.py +++ b/ribasim/model.py @@ -1,47 +1,83 @@ +import datetime from pathlib import Path from typing import Optional +import tomli import tomli_w from pydantic import BaseModel -from ribasim import BasinLookup, BasinState, Bifurcation, Edge, Node, OutflowTable +from ribasim import ( + Basin, + Edge, + FractionalFlow, + LevelControl, + LinearLevelConnection, + Node, + TabulatedRatingCurve, +) + +_NODES = ( + (Node, "node"), + (Edge, "edge"), + (Basin, "basin"), + (FractionalFlow, "fractional_flow"), + (LevelControl, "level_control"), + (LinearLevelConnection, "linear_level_connection"), + (TabulatedRatingCurve, "tabulated_rating_curve"), +) class Model(BaseModel): + modelname: str node: Node edge: Edge - basin_state: Optional[BasinState] = None - basin_lookup: Optional[BasinLookup] = None - bifurcation: Optional[Bifurcation] = None - outflow_table: Optional[OutflowTable] = None - - def __iter__(self): - return iter(self.__root__) + basin: Basin + fractional_flow: Optional[FractionalFlow] + level_control: Optional[LevelControl] + linear_level_connection: Optional[LinearLevelConnection] + tabulated_rating_curve: Optional[TabulatedRatingCurve] + starttime: datetime.datetime + endtime: datetime.datetime - def items(self): - return self.__root__.items() + @classmethod + def fields(cls): + return cls.__fields__.keys() - def values(self): - return self.__root__.values() - - def _write_toml(self, directory: Path, modelname: str): - content = {} - with open(directory / f"{modelname}.toml", "w") as f: + def _write_toml(self, directory: Path): + content = { + "starttime": self.starttime, + "endtime": self.endtime, + "geopackage": f"{self.modelname}.gpkg", + } + with open(directory / f"{self.modelname}.toml", "w") as f: tomli_w.dump(content, f) return - def _write_tables(self, directory: Path, modelname: str) -> None: + def _write_tables(self, directory: Path) -> None: """ Write the input to GeoPackage and Arrow tables. """ - for input_table in self.values(): - input_table.write(directory, modelname) + for input_table in self.dict().values(): + input_table.write(directory, self.modelname) return - def write(self, directory, modelname: str) -> None: + def write(self, directory) -> None: directory = Path(directory) directory.mkdir(parents=True, exist_ok=True) - - self._write_toml(directory, modelname) - self._write_tables(directory, modelname) + self._write_toml(directory) + self._write_tables(directory) return + + @staticmethod + def from_toml(path): + with open(path, "rb") as f: + config = tomli.load(f) + + kwargs = {} + for cls, kwarg_name in _NODES.items(): + kwargs[kwarg_name] = cls.from_config(config) + + kwargs["start_time"] = config["start_time"] + kwargs["end_time"] = config["end_time"] + + return Model(**kwargs) diff --git a/ribasim/node.py b/ribasim/node.py index 0c1989ec5..f4d89ff8b 100644 --- a/ribasim/node.py +++ b/ribasim/node.py @@ -1,10 +1,15 @@ import pandas as pd +import pandera as pa +from pandera.typing import DataFrame, Series from pydantic import BaseModel from ribasim.input_base import InputMixin -from ribasim.types import DataFrame + + +class StaticSchema(pd.SchemaModel): + type: Series[str] = pa.Field() class Node(BaseModel, InputMixin): _input_type = "Node" - dataframe: DataFrame + static: DataFrame[StaticSchema] diff --git a/ribasim/tabulated_rating_curve.py b/ribasim/tabulated_rating_curve.py index d6764dc0f..ee1243b31 100644 --- a/ribasim/tabulated_rating_curve.py +++ b/ribasim/tabulated_rating_curve.py @@ -1,9 +1,16 @@ +import pandera as pa +from pandera.typing import DataFrame, Series from pydantic import BaseModel -from ribasim.input_base import ArrowInputMixin -from ribasim.types import DataFrame +from ribasim.input_base import InputMixin -class TabulatedRatingCurve(BaseModel, ArrowInputMixin): +class StaticSchema(pa.SchemaModel): + node_id: Series[int] = pa.Field() + storage: Series[float] = pa.Field() + discharge: Series[float] = pa.Field() + + +class TabulatedRatingCurve(BaseModel, InputMixin): _input_type = "TabulatedRatingCurve" - dataframe: DataFrame + static: DataFrame[StaticSchema] diff --git a/ribasim/types.py b/ribasim/types.py deleted file mode 100644 index 03f829600..000000000 --- a/ribasim/types.py +++ /dev/null @@ -1,5 +0,0 @@ -from typing import TypeVar - -from pandas import DataFrame - -DataFrame = TypeVar("DataFrame") From cedf88359a3940018c09be8756979eabc517e8f2 Mon Sep 17 00:00:00 2001 From: Huite Bootsma Date: Fri, 24 Feb 2023 18:03:58 +0100 Subject: [PATCH 15/33] Add GeoSeries validation, remove pa.Field() declarations --- ribasim/basin.py | 36 +++++++++++++++--------------- ribasim/edge.py | 7 +++--- ribasim/fractional_flow.py | 10 ++++----- ribasim/level_control.py | 2 +- ribasim/linear_level_connection.py | 2 +- ribasim/node.py | 5 +++-- ribasim/tabulated_rating_curve.py | 6 ++--- 7 files changed, 35 insertions(+), 33 deletions(-) diff --git a/ribasim/basin.py b/ribasim/basin.py index 98bb886fb..063df7bf5 100644 --- a/ribasim/basin.py +++ b/ribasim/basin.py @@ -9,34 +9,34 @@ class StaticSchema(pa.SchemaModel): node_id: Series[int] = pa.Field(unique=True) - drainage: Series[float] = pa.Field() - potential_evaporation: Series[float] = pa.Field() - infiltration: Series[float] = pa.Field() - precipitation: Series[float] = pa.Field() - urban_runoff: Series[float] = pa.Field() + drainage: Series[float] + potential_evaporation: Series[float] + infiltration: Series[float] + precipitation: Series[float] + urban_runoff: Series[float] class ForcingSchema(pa.SchemaModel): - node_id: Series[int] = pa.Field() - time: Series[pa.dtypes.DateTime] = pa.Field() - drainage: Series[float] = pa.Field() - potential_evaporation: Series[float] = pa.Field() - infiltration: Series[float] = pa.Field() - precipitation: Series[float] = pa.Field() - urban_runoff: Series[float] = pa.Field() + node_id: Series[int] + time: Series[pa.dtypes.DateTime] + drainage: Series[float] + potential_evaporation: Series[float] + infiltration: Series[float] + precipitation: Series[float] + urban_runoff: Series[float] class ProfileSchema(pa.SchemaModel): - node_id: Series[int] = pa.Field() - storage: Series[float] = pa.Field() - area: Series[float] = pa.Field() - level: Series[float] = pa.Field() + node_id: Series[int] + storage: Series[float] + area: Series[float] + level: Series[float] class StateSchema(pa.SchemaModel): node_id: Series[int] = pa.Field(unique=True) - storage: Series[float] = pa.Field() - concentration: Series[float] = pa.Field() + storage: Series[float] + concentration: Series[float] class Basin(BaseModel, InputMixin): diff --git a/ribasim/edge.py b/ribasim/edge.py index 42e3a8fdc..d7ce3a6fa 100644 --- a/ribasim/edge.py +++ b/ribasim/edge.py @@ -1,13 +1,14 @@ import pandera as pa -from pandera.typing import DataFrame, Series +from pandera.typing import DataFrame, GeoSeries, Series from pydantic import BaseModel from ribasim.input_base import InputMixin class StaticSchema(pa.SchemaModel): - from_node_id: Series[int] = pa.Field() - to_node_id: Series[int] = pa.Field() + from_node_id: Series[int] + to_node_id: Series[int] + geometry: GeoSeries class Edge(BaseModel, InputMixin): diff --git a/ribasim/fractional_flow.py b/ribasim/fractional_flow.py index 3a0821b3e..9196157e9 100644 --- a/ribasim/fractional_flow.py +++ b/ribasim/fractional_flow.py @@ -8,14 +8,14 @@ class StaticSchema(pa.SchemaModel): - node_id: Series[int] = pa.Field() - fraction: Series[float] = pa.Field() + node_id: Series[int] + fraction: Series[float] class ForcingSchema(pa.SchemaModel): - node_id: Series[int] = pa.Field() - time: Series[pa.dtypes.DateTime] = pa.Field() - fraction: Series[float] = pa.Field() + node_id: Series[int] + time: Series[pa.dtypes.DateTime] + fraction: Series[float] class FractionalFlow(BaseModel, InputMixin): diff --git a/ribasim/level_control.py b/ribasim/level_control.py index ea594ac4e..8bd2f39e5 100644 --- a/ribasim/level_control.py +++ b/ribasim/level_control.py @@ -7,7 +7,7 @@ class StaticSchema(pa.SchemaModel): node_id: Series[int] = pa.Field(unique=True) - target_level: Series[float] = pa.Field() + target_level: Series[float] class LevelControl(BaseModel, InputMixin): diff --git a/ribasim/linear_level_connection.py b/ribasim/linear_level_connection.py index 937dd1685..4a8ca52c3 100644 --- a/ribasim/linear_level_connection.py +++ b/ribasim/linear_level_connection.py @@ -7,7 +7,7 @@ class StaticSchema(pa.SchemaModel): node_id: Series[int] = pa.Field(unique=True) - conductance: Series[float] = pa.Field() + conductance: Series[float] class LinearLevelConnection(BaseModel, InputMixin): diff --git a/ribasim/node.py b/ribasim/node.py index f4d89ff8b..31dcb5046 100644 --- a/ribasim/node.py +++ b/ribasim/node.py @@ -1,13 +1,14 @@ import pandas as pd import pandera as pa -from pandera.typing import DataFrame, Series +from pandera.typing import DataFrame, GeoSeries, Series from pydantic import BaseModel from ribasim.input_base import InputMixin class StaticSchema(pd.SchemaModel): - type: Series[str] = pa.Field() + type: Series[str] + geometry: GeoSeries class Node(BaseModel, InputMixin): diff --git a/ribasim/tabulated_rating_curve.py b/ribasim/tabulated_rating_curve.py index ee1243b31..b33fe4140 100644 --- a/ribasim/tabulated_rating_curve.py +++ b/ribasim/tabulated_rating_curve.py @@ -6,9 +6,9 @@ class StaticSchema(pa.SchemaModel): - node_id: Series[int] = pa.Field() - storage: Series[float] = pa.Field() - discharge: Series[float] = pa.Field() + node_id: Series[int] + storage: Series[float] + discharge: Series[float] class TabulatedRatingCurve(BaseModel, InputMixin): From f9c4ba0d64d2310f760801617ccd32d416a784e6 Mon Sep 17 00:00:00 2001 From: Huite Bootsma Date: Fri, 24 Feb 2023 18:22:30 +0100 Subject: [PATCH 16/33] Remove values on InputMixin --- ribasim/input_base.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/ribasim/input_base.py b/ribasim/input_base.py index 244f9c91e..72eeeb493 100644 --- a/ribasim/input_base.py +++ b/ribasim/input_base.py @@ -11,9 +11,6 @@ class InputMixin(abc.ABC): def fields(cls): return cls.__fields__.keys() - def values(self): - return self._dict().values() - def _write_geopackage(self, directory: Path, modelname: str) -> None: self.dataframe.to_file( directory / f"{modelname}.gpkg", layer=f"{self.input_type}" From 9c1c4c6e2f8a7bdf309dbcf93c7ada18a491da5f Mon Sep 17 00:00:00 2001 From: Huite Bootsma Date: Sun, 26 Feb 2023 15:53:00 +0100 Subject: [PATCH 17/33] Read & Write, __repr__ --- plugin/ribasim_qgis/core/nodes.py | 7 +- ribasim/basin.py | 31 ++++++- ribasim/edge.py | 7 +- ribasim/fractional_flow.py | 4 +- ribasim/input_base.py | 125 +++++++++++++++++++++++++---- ribasim/level_control.py | 4 +- ribasim/linear_level_connection.py | 4 +- ribasim/model.py | 87 +++++++++++++++----- ribasim/node.py | 10 ++- ribasim/tabulated_rating_curve.py | 4 +- ribasim/types.py | 6 ++ 11 files changed, 238 insertions(+), 51 deletions(-) create mode 100644 ribasim/types.py diff --git a/plugin/ribasim_qgis/core/nodes.py b/plugin/ribasim_qgis/core/nodes.py index 244f9b5d3..4497539c8 100644 --- a/plugin/ribasim_qgis/core/nodes.py +++ b/plugin/ribasim_qgis/core/nodes.py @@ -122,7 +122,8 @@ class Node(Input): def write(self) -> None: """ - Special the Basin layer write because it needs to generate a new file. + Special case the Node layer write because it needs to generate a new + file. """ self.layer = geopackage.write_layer( self.path, self.layer, self.name, newfile=True @@ -132,7 +133,7 @@ def write(self) -> None: def set_editor_widget(self) -> None: layer = self.layer - index = layer.fields().indexFromName("node") + index = layer.fields().indexFromName("type") setup = QgsEditorWidgetSetup( "ValueMap", { @@ -186,7 +187,7 @@ def renderer(self) -> QgsCategorizedSymbolRenderer: category = QgsRendererCategory(value, symbol, label, shape) categories.append(category) - renderer = QgsCategorizedSymbolRenderer(attrName="Node", categories=categories) + renderer = QgsCategorizedSymbolRenderer(attrName="type", categories=categories) return renderer @property diff --git a/ribasim/basin.py b/ribasim/basin.py index 063df7bf5..bab2f1532 100644 --- a/ribasim/basin.py +++ b/ribasim/basin.py @@ -6,6 +6,8 @@ from ribasim.input_base import InputMixin +__all__ = ("Basin",) + class StaticSchema(pa.SchemaModel): node_id: Series[int] = pa.Field(unique=True) @@ -39,7 +41,34 @@ class StateSchema(pa.SchemaModel): concentration: Series[float] -class Basin(BaseModel, InputMixin): +class Basin(InputMixin, BaseModel): + """ + Input for a (sub-)basin: an area of land where all flowing surface water + converges to a single point. + + A basin is defined by a tabulation of: + + * storage + * area + * water level + + This data is provided by the ``profile`` DataFrame. + + In Ribasim, the basin receives water balance terms such as: + + * potential evaporation + * precipitation + * groundwater drainage + * groundwater infiltration + * urban runoff + + This may be set in the ``static`` dataframe for constant data, or ``forcing`` + for time varying data. + + A basin may be initialized with an initial state for storage or + concentration. This is set in the ``state`` dataframe. + """ + _input_type = "Basin" profile: DataFrame[ProfileSchema] static: Optional[DataFrame[StaticSchema]] = None diff --git a/ribasim/edge.py b/ribasim/edge.py index d7ce3a6fa..1b7429c6b 100644 --- a/ribasim/edge.py +++ b/ribasim/edge.py @@ -1,9 +1,12 @@ import pandera as pa -from pandera.typing import DataFrame, GeoSeries, Series +from pandera.typing import DataFrame, Series +from pandera.typing.geopandas import GeoSeries from pydantic import BaseModel from ribasim.input_base import InputMixin +__all__ = ("Edge",) + class StaticSchema(pa.SchemaModel): from_node_id: Series[int] @@ -11,6 +14,6 @@ class StaticSchema(pa.SchemaModel): geometry: GeoSeries -class Edge(BaseModel, InputMixin): +class Edge(InputMixin, BaseModel): _input_type = "Edge" static: DataFrame[StaticSchema] diff --git a/ribasim/fractional_flow.py b/ribasim/fractional_flow.py index 9196157e9..073a37455 100644 --- a/ribasim/fractional_flow.py +++ b/ribasim/fractional_flow.py @@ -6,6 +6,8 @@ from ribasim.input_base import InputMixin +__all__ = ("FractionalFlow",) + class StaticSchema(pa.SchemaModel): node_id: Series[int] @@ -18,7 +20,7 @@ class ForcingSchema(pa.SchemaModel): fraction: Series[float] -class FractionalFlow(BaseModel, InputMixin): +class FractionalFlow(InputMixin, BaseModel): _input_type = "FractionalFlow" static: DataFrame[StaticSchema] forcing: Optional[DataFrame[ForcingSchema]] = None diff --git a/ribasim/input_base.py b/ribasim/input_base.py index 72eeeb493..2951e655c 100644 --- a/ribasim/input_base.py +++ b/ribasim/input_base.py @@ -1,61 +1,152 @@ import abc +import textwrap from pathlib import Path -from typing import Dict, Tuple +from typing import Any, Dict, Type, TypeVar +import fiona import geopandas as gpd import pandas as pd +from ribasim.types import FilePath + +T = TypeVar("T") + +__all__ = () + class InputMixin(abc.ABC): @classmethod def fields(cls): + """Return the input fields.""" return cls.__fields__.keys() - def _write_geopackage(self, directory: Path, modelname: str) -> None: + def __repr__(self) -> str: + content = [f""] + for field in self.fields(): + attr = getattr(self, field) + if isinstance(attr, pd.DataFrame): + colnames = "(" + ", ".join(attr.columns) + ")" + if len(colnames) > 50: + colnames = textwrap.indent( + textwrap.fill(colnames, width=50), prefix=" " + ) + entry = f"{field}: DataFrame(rows={len(attr)})\n{colnames}" + else: + entry = f"{field}: DataFrame(rows={len(attr)}) {colnames}" + else: + entry = f"{field}: {attr}" + content.append(textwrap.indent(entry, prefix=" ")) + return "\n".join(content) + + def _write_geopackage(self, directory: FilePath, modelname: str) -> None: self.dataframe.to_file( directory / f"{modelname}.gpkg", layer=f"{self.input_type}" ) return - def _write_arrow(self, directory: Path) -> None: + def _write_arrow(self, directory: FilePath) -> None: path = directory / f"{self._input_type}.arrow" self.dataframe.write_feather(path) return - def write(self, directory, modelname): + def write(self, directory: FilePath, modelname: str) -> None: + """ + Write the contents of the input to a GeoPackage. + + The Geopackage will be written in ``directory`` and will be be named + ``{modelname}.gpkg``. + + Parameters + ---------- + directory: FilePath + modelname: str + """ directory = Path(directory) - for key, dataframe in self.dict().values(): + for field in self.fields(): + dataframe = getattr(self, field) + if dataframe is None: + continue name = self._input_type - if key != "static": - name = f"{name} / {key}" - dataframe.to_file(directory / f"{modelname}.gpkg", layer=name) + if field != "static": + name = f"{name} / {field}" + + gdf = gpd.GeoDataFrame(data=dataframe) + if "geometry" in gdf.columns: + gdf.set_geometry("geometry", inplace=True) + else: + gdf["geometry"] = None + gdf.to_file(directory / f"{modelname}.gpkg", layer=name) + return @classmethod - def _kwargs_from_geopackage(cls, path): + def _kwargs_from_geopackage(cls: Type[T], path: FilePath) -> T: kwargs = {} - for key in cls.keys(): - if key == "static": + layers = fiona.listlayers(path) + for key in cls.fields(): + df = None + layername = f"{cls._input_type} / {key}" + if key == "static" and cls._input_type in layers: df = gpd.read_file(path, layer=cls._input_type) - else: - df = gpd.read_file(path, layer=f"{cls._input_type} / {key}") + elif layername in layers: + df = gpd.read_file(path, layer=layername) + + if df is not None and df["geometry"].isnull().all(): + df.pop("geometry") + kwargs[key] = df return kwargs @classmethod - def _kwargs_from_toml(cls, config): + def _kwargs_from_toml( + cls: Type[T], config: Dict[str, Any] + ) -> Dict[str, pd.DataFrame]: return {key: pd.read_feather(path) for key, path in config.items()} @classmethod - def from_geopackage(cls, path): + def from_geopackage(cls: Type[T], path: FilePath) -> T: + """ + Initialize input from a GeoPackage. + + The GeoPackage tables are searched for the relevant table names. + + Parameters + ---------- + path: Path + Path to the GeoPackage + + Returns + ------- + ribasim_input + """ kwargs = cls._kwargs_from_geopackage(path) return cls(**kwargs) @classmethod - def from_config(cls, config): + def from_config(cls: Type[T], config: Dict[str, Any]) -> T: + """ + Initialize input from a TOML configuration file. + + The GeoPackage tables are searched for the relevant table names. Arrow + tables will also be read if specified. If a table is present in both + the GeoPackage and as an Arrow table, the data of the Arrow table is + used. + + Parameters + ---------- + config: Dict[str, Any] + + Returns + ------- + ribasim_input + """ geopackage = config["geopackage"] kwargs = cls._kwargs_from_geopackage(geopackage) input_content = config.get(cls._input_type, None) if input_content: kwargs.update(**cls._kwargs_from_toml(config)) - return cls(**kwargs) + + if all(v is None for v in kwargs.values()): + return None + else: + return cls(**kwargs) diff --git a/ribasim/level_control.py b/ribasim/level_control.py index 8bd2f39e5..260dc9ac2 100644 --- a/ribasim/level_control.py +++ b/ribasim/level_control.py @@ -4,12 +4,14 @@ from ribasim.input_base import InputMixin +__all__ = ("LevelControl",) + class StaticSchema(pa.SchemaModel): node_id: Series[int] = pa.Field(unique=True) target_level: Series[float] -class LevelControl(BaseModel, InputMixin): +class LevelControl(InputMixin, BaseModel): _input_type = "LevelControl" static: DataFrame[StaticSchema] diff --git a/ribasim/linear_level_connection.py b/ribasim/linear_level_connection.py index 4a8ca52c3..af681630f 100644 --- a/ribasim/linear_level_connection.py +++ b/ribasim/linear_level_connection.py @@ -4,12 +4,14 @@ from ribasim.input_base import InputMixin +__all__ = ("LinearLevelConnection",) + class StaticSchema(pa.SchemaModel): node_id: Series[int] = pa.Field(unique=True) conductance: Series[float] -class LinearLevelConnection(BaseModel, InputMixin): +class LinearLevelConnection(InputMixin, BaseModel): _input_type = "LinearLevelConnection" static: DataFrame[StaticSchema] diff --git a/ribasim/model.py b/ribasim/model.py index 590028517..2a9c6d935 100644 --- a/ribasim/model.py +++ b/ribasim/model.py @@ -6,15 +6,18 @@ import tomli_w from pydantic import BaseModel -from ribasim import ( - Basin, - Edge, - FractionalFlow, - LevelControl, - LinearLevelConnection, - Node, - TabulatedRatingCurve, -) +from ribasim.basin import Basin +from ribasim.edge import Edge +from ribasim.fractional_flow import FractionalFlow + +# Do not import from ribasim namespace: will create import errors. +# E.g. not: from ribasim import Basin +from ribasim.input_base import InputMixin +from ribasim.level_control import LevelControl +from ribasim.linear_level_connection import LinearLevelConnection +from ribasim.node import Node +from ribasim.tabulated_rating_curve import TabulatedRatingCurve +from ribasim.types import FilePath _NODES = ( (Node, "node"), @@ -41,27 +44,58 @@ class Model(BaseModel): @classmethod def fields(cls): + """Returns the names of the fields contained in the Model.""" return cls.__fields__.keys() - def _write_toml(self, directory: Path): + def __repr__(self) -> str: + first = [] + second = [] + for field in self.fields(): + attr = getattr(self, field) + if isinstance(attr, InputMixin): + second.append(f"{field}: {repr(attr)}") + else: + first.append(f"{field}={repr(attr)}") + content = [""] + first + second + return "\n".join(content) + + def _repr_html(self): + # Default to standard repr for now + return self.__repr__() + + def _write_toml(self, directory: FilePath): content = { "starttime": self.starttime, "endtime": self.endtime, "geopackage": f"{self.modelname}.gpkg", } - with open(directory / f"{self.modelname}.toml", "w") as f: + with open(directory / f"{self.modelname}.toml", "wb") as f: tomli_w.dump(content, f) return - def _write_tables(self, directory: Path) -> None: + def _write_tables(self, directory: FilePath) -> None: """ Write the input to GeoPackage and Arrow tables. """ - for input_table in self.dict().values(): - input_table.write(directory, self.modelname) + for name in self.fields(): + input_entry = getattr(self, name) + if isinstance(input_entry, InputMixin): + input_entry.write(directory, self.modelname) return - def write(self, directory) -> None: + def write(self, directory: FilePath) -> None: + """ + Write the contents of the model to a GeoPackage and a TOML + configuration file. + + If ``directory`` does not exist, it is created before writing. + The GeoPackage and TOML file will be called ``{modelname}.gpkg`` and + ``{modelname}.toml`` respectively. + + Parameters + ---------- + directory: FilePath + """ directory = Path(directory) directory.mkdir(parents=True, exist_ok=True) self._write_toml(directory) @@ -69,15 +103,28 @@ def write(self, directory) -> None: return @staticmethod - def from_toml(path): + def from_toml(path: FilePath) -> "Model": + """ + Initialize a model from the TOML configuration file. + + Parameters + ---------- + path: FilePath + Path to the configuration TOML file. + + Returns + ------- + model: Model + """ + path = Path(path) with open(path, "rb") as f: config = tomli.load(f) - kwargs = {} - for cls, kwarg_name in _NODES.items(): + kwargs = {"modelname": path.stem} + for cls, kwarg_name in _NODES: kwargs[kwarg_name] = cls.from_config(config) - kwargs["start_time"] = config["start_time"] - kwargs["end_time"] = config["end_time"] + kwargs["starttime"] = config["starttime"] + kwargs["endtime"] = config["endtime"] return Model(**kwargs) diff --git a/ribasim/node.py b/ribasim/node.py index 31dcb5046..c21e2b8f8 100644 --- a/ribasim/node.py +++ b/ribasim/node.py @@ -1,16 +1,18 @@ -import pandas as pd import pandera as pa -from pandera.typing import DataFrame, GeoSeries, Series +from pandera.typing import DataFrame, Series +from pandera.typing.geopandas import GeoSeries from pydantic import BaseModel from ribasim.input_base import InputMixin +__all__ = ("Node",) -class StaticSchema(pd.SchemaModel): + +class StaticSchema(pa.SchemaModel): type: Series[str] geometry: GeoSeries -class Node(BaseModel, InputMixin): +class Node(InputMixin, BaseModel): _input_type = "Node" static: DataFrame[StaticSchema] diff --git a/ribasim/tabulated_rating_curve.py b/ribasim/tabulated_rating_curve.py index b33fe4140..e02e7df18 100644 --- a/ribasim/tabulated_rating_curve.py +++ b/ribasim/tabulated_rating_curve.py @@ -4,6 +4,8 @@ from ribasim.input_base import InputMixin +__all__ = ("TabulatedRatingCurve",) + class StaticSchema(pa.SchemaModel): node_id: Series[int] @@ -11,6 +13,6 @@ class StaticSchema(pa.SchemaModel): discharge: Series[float] -class TabulatedRatingCurve(BaseModel, InputMixin): +class TabulatedRatingCurve(InputMixin, BaseModel): _input_type = "TabulatedRatingCurve" static: DataFrame[StaticSchema] diff --git a/ribasim/types.py b/ribasim/types.py new file mode 100644 index 000000000..5c7433399 --- /dev/null +++ b/ribasim/types.py @@ -0,0 +1,6 @@ +from os import PathLike +from typing import Union + +FilePath = Union[str, PathLike[str]] + +__all__ = () From e834701ee5338bdaa8f34fcb5bce7db57792ebe8 Mon Sep 17 00:00:00 2001 From: Huite Bootsma Date: Sun, 26 Feb 2023 17:15:34 +0100 Subject: [PATCH 18/33] Add basic example --- examples/basic.py | 149 +++++++++++++++++++++++++++++ ribasim/__init__.py | 1 + ribasim/basin.py | 9 +- ribasim/edge.py | 7 +- ribasim/fractional_flow.py | 5 +- ribasim/level_control.py | 3 +- ribasim/linear_level_connection.py | 3 +- ribasim/tabulated_rating_curve.py | 3 +- ribasim/utils.py | 36 +++++++ 9 files changed, 205 insertions(+), 11 deletions(-) create mode 100644 examples/basic.py create mode 100644 ribasim/utils.py diff --git a/examples/basic.py b/examples/basic.py new file mode 100644 index 000000000..6b71dc089 --- /dev/null +++ b/examples/basic.py @@ -0,0 +1,149 @@ +# %% +import os + +os.environ["USE_PYGEOS"] = "0" + +import geopandas as gpd +import numpy as np +import pandas as pd + +import ribasim + +# %% +# Set up the nodes: + +xy = np.array( + [ + (0.0, 0.0), # Basin, + (1.0, 0.0), # LinearLevelConnection + (2.0, 0.0), # Basin + (3.0, 0.0), # TabulatedRatingCurve + (3.0, 1.0), # FractionalFlow + (3.0, 2.0), # Basin + (4.0, 0.0), # FractionalFlow + (5.0, 0.0), # Basin + (6.0, 0.0), # LevelControl + ] +) +node_xy = gpd.points_from_xy(x=xy[:, 0], y=xy[:, 1]) + +node_type = [ + "Basin", + "LinearLevelConnection", + "Basin", + "TabulatedRatingCurve", + "FractionalFlow", + "Basin", + "FractionalFlow", + "Basin", + "LevelControl", +] +node = ribasim.Node(static=gpd.GeoDataFrame(data={"type": node_type}, geometry=node_xy)) + +# %% +# Setup the edges: + +from_id = np.array([0, 1, 2, 3, 3, 4, 6, 7], dtype=np.int64) +to_id = np.array([1, 2, 3, 4, 6, 5, 7, 8], dtype=np.int64) +lines = ribasim.utils.geometry_from_connectivity(node, from_id, to_id) +edge = ribasim.Edge( + static=gpd.GeoDataFrame( + data={"from_node_id": from_id, "to_node_id": to_id, "geometry": lines} + ) +) + +# %% +# Setup the basins: + +profile = pd.DataFrame( + data={ + "node_id": [0, 0], + "storage": [0.0, 1000.0], + "area": [0.0, 1000.0], + "level": [0.0, 1.0], + } +) +repeat = np.tile([0, 1], 4) +profile = profile.iloc[repeat] +profile["node_id"] = [0, 0, 2, 2, 5, 5, 7, 7] + +static = pd.DataFrame( + data={ + "node_id": [0], + "drainage": [0.006], + "potential_evaporation": [0.0115], + "infiltration": [0.0], + "precipitation": [0.0], + "urban_runoff": [0.0], + } +) +static = static.iloc[[0, 0, 0, 0]] +static["node_id"] = [0, 2, 5, 7] + +basin = ribasim.Basin(profile=profile, static=static) + +# %% +# Setup linear level connection: + +linear_connection = ribasim.LinearLevelConnection( + static=pd.DataFrame(data={"node_id": [1], "conductance": [1.5e-4]}) +) + + +# %% +# Set up a rating curve node: + +rating_curve = ribasim.TabulatedRatingCurve( + static=pd.DataFrame( + data={ + "node_id": [3, 3], + "storage": [0.0, 1000.0], + "discharge": [0.0, 1.5e-4], + } + ) +) + +# %% +# Setup fractional flows: + +fractional_flow = ribasim.FractionalFlow( + static=pd.DataFrame( + data={ + "node_id": [4, 6], + "fraction": [0.3, 0.7], + } + ) +) + +# %% +# Setup level control: + +level_control = ribasim.LevelControl( + static=pd.DataFrame( + data={ + "node_id": [8], + "target_level": [1.5], + } + ) +) + +# %% +# Setup a model: + +model = ribasim.Model( + modelname="basic", + node=node, + edge=edge, + basin=basin, + level_control=level_control, + linear_level_connection=linear_connection, + tabulated_rating_curve=rating_curve, + starttime="2020-01-01 00:00:00", + endtime="2021-01-01 00:00:00", +) + +# %% +# Write the model to a TOML and GeoPackage: + +model.write("basic") +# %% diff --git a/ribasim/__init__.py b/ribasim/__init__.py index ba7db5689..2ddfaa743 100644 --- a/ribasim/__init__.py +++ b/ribasim/__init__.py @@ -1,6 +1,7 @@ __version__ = "0.1.0" +from ribasim import utils from ribasim.basin import Basin from ribasim.edge import Edge from ribasim.fractional_flow import FractionalFlow diff --git a/ribasim/basin.py b/ribasim/basin.py index bab2f1532..61494ebf6 100644 --- a/ribasim/basin.py +++ b/ribasim/basin.py @@ -1,6 +1,7 @@ from typing import Optional import pandera as pa +from pandera.dtypes import Int from pandera.typing import DataFrame, Series from pydantic import BaseModel @@ -10,7 +11,7 @@ class StaticSchema(pa.SchemaModel): - node_id: Series[int] = pa.Field(unique=True) + node_id: Series[Int] = pa.Field(unique=True) drainage: Series[float] potential_evaporation: Series[float] infiltration: Series[float] @@ -19,7 +20,7 @@ class StaticSchema(pa.SchemaModel): class ForcingSchema(pa.SchemaModel): - node_id: Series[int] + node_id: Series[Int] time: Series[pa.dtypes.DateTime] drainage: Series[float] potential_evaporation: Series[float] @@ -29,14 +30,14 @@ class ForcingSchema(pa.SchemaModel): class ProfileSchema(pa.SchemaModel): - node_id: Series[int] + node_id: Series[Int] storage: Series[float] area: Series[float] level: Series[float] class StateSchema(pa.SchemaModel): - node_id: Series[int] = pa.Field(unique=True) + node_id: Series[Int] = pa.Field(unique=True) storage: Series[float] concentration: Series[float] diff --git a/ribasim/edge.py b/ribasim/edge.py index 1b7429c6b..028df482f 100644 --- a/ribasim/edge.py +++ b/ribasim/edge.py @@ -1,4 +1,7 @@ +from typing import Optional + import pandera as pa +from pandera.dtypes import Int from pandera.typing import DataFrame, Series from pandera.typing.geopandas import GeoSeries from pydantic import BaseModel @@ -9,8 +12,8 @@ class StaticSchema(pa.SchemaModel): - from_node_id: Series[int] - to_node_id: Series[int] + from_node_id: Series[Int] + to_node_id: Series[Int] geometry: GeoSeries diff --git a/ribasim/fractional_flow.py b/ribasim/fractional_flow.py index 073a37455..fb8b500a9 100644 --- a/ribasim/fractional_flow.py +++ b/ribasim/fractional_flow.py @@ -1,6 +1,7 @@ from typing import Optional import pandera as pa +from pandera.dtypes import Int from pandera.typing import DataFrame, Series from pydantic import BaseModel @@ -10,12 +11,12 @@ class StaticSchema(pa.SchemaModel): - node_id: Series[int] + node_id: Series[Int] fraction: Series[float] class ForcingSchema(pa.SchemaModel): - node_id: Series[int] + node_id: Series[Int] time: Series[pa.dtypes.DateTime] fraction: Series[float] diff --git a/ribasim/level_control.py b/ribasim/level_control.py index 260dc9ac2..5a8c3e10f 100644 --- a/ribasim/level_control.py +++ b/ribasim/level_control.py @@ -1,4 +1,5 @@ import pandera as pa +from pandera.dtypes import Int from pandera.typing import DataFrame, Series from pydantic import BaseModel @@ -8,7 +9,7 @@ class StaticSchema(pa.SchemaModel): - node_id: Series[int] = pa.Field(unique=True) + node_id: Series[Int] = pa.Field(unique=True) target_level: Series[float] diff --git a/ribasim/linear_level_connection.py b/ribasim/linear_level_connection.py index af681630f..eb9c8b722 100644 --- a/ribasim/linear_level_connection.py +++ b/ribasim/linear_level_connection.py @@ -1,4 +1,5 @@ import pandera as pa +from pandera.dtypes import Int from pandera.typing import DataFrame, Series from pydantic import BaseModel @@ -8,7 +9,7 @@ class StaticSchema(pa.SchemaModel): - node_id: Series[int] = pa.Field(unique=True) + node_id: Series[Int] = pa.Field(unique=True) conductance: Series[float] diff --git a/ribasim/tabulated_rating_curve.py b/ribasim/tabulated_rating_curve.py index e02e7df18..621ae8252 100644 --- a/ribasim/tabulated_rating_curve.py +++ b/ribasim/tabulated_rating_curve.py @@ -1,4 +1,5 @@ import pandera as pa +from pandera.dtypes import Int from pandera.typing import DataFrame, Series from pydantic import BaseModel @@ -8,7 +9,7 @@ class StaticSchema(pa.SchemaModel): - node_id: Series[int] + node_id: Series[Int] storage: Series[float] discharge: Series[float] diff --git a/ribasim/utils.py b/ribasim/utils.py new file mode 100644 index 000000000..7fe84f8ae --- /dev/null +++ b/ribasim/utils.py @@ -0,0 +1,36 @@ +from typing import Sequence + +import numpy as np +import shapely + +from ribasim import Node + + +def geometry_from_connectivity( + node: Node, from_id: Sequence[int], to_id: Sequence[int] +) -> np.ndarray: + """ + Create edge shapely geometries from connectivities. + + Parameters + ---------- + node: Ribasim.Node + from_id: Sequence[int] + First node of every edge. + to_id: Sequence[int] + Second node of every edge. + + Returns + ------- + edge_geometry: np.ndarray + Array of shapely LineStrings. + """ + geometry = node.static["geometry"] + from_points = shapely.get_coordinates(geometry.loc[from_id]) + to_points = shapely.get_coordinates(geometry.loc[to_id]) + n = len(from_points) + vertices = np.empty((n * 2, 2), dtype=from_points.dtype) + vertices[0::2, :] = from_points + vertices[1::2, :] = to_points + indices = np.repeat(np.arange(n), 2) + return shapely.linestrings(coords=vertices, indices=indices) From 135d980d78ffd54be539a619060e9f8b8dfada29 Mon Sep 17 00:00:00 2001 From: Huite Bootsma Date: Sun, 26 Feb 2023 17:21:30 +0100 Subject: [PATCH 19/33] Shapely to >= 2.0 --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 391846215..2916899c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ dependencies = [ "pandera", "pyarrow", "pydantic", + "shapely >= 2.0", "tomli-w", ] dynamic = ["version"] From 15e9dfcc632a720355983b100b2053204ef347da Mon Sep 17 00:00:00 2001 From: Huite Bootsma Date: Sun, 26 Feb 2023 20:35:17 +0100 Subject: [PATCH 20/33] No validation error on int32 vs int64. Use pyogrio to keep fid from geopackage. --- pyproject.toml | 1 + ribasim/basin.py | 9 ++++----- ribasim/edge.py | 7 ++----- ribasim/fractional_flow.py | 5 ++--- ribasim/input_base.py | 11 ++++++----- ribasim/level_control.py | 3 +-- ribasim/linear_level_connection.py | 3 +-- ribasim/tabulated_rating_curve.py | 3 +-- setup.cfg | 12 ++++++++++++ 9 files changed, 30 insertions(+), 24 deletions(-) create mode 100644 setup.cfg diff --git a/pyproject.toml b/pyproject.toml index 2916899c4..882b7cdf3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ "geopandas", "pandas", "pandera", + "pyogrio", "pyarrow", "pydantic", "shapely >= 2.0", diff --git a/ribasim/basin.py b/ribasim/basin.py index 61494ebf6..5dfd59bac 100644 --- a/ribasim/basin.py +++ b/ribasim/basin.py @@ -1,7 +1,6 @@ from typing import Optional import pandera as pa -from pandera.dtypes import Int from pandera.typing import DataFrame, Series from pydantic import BaseModel @@ -11,7 +10,7 @@ class StaticSchema(pa.SchemaModel): - node_id: Series[Int] = pa.Field(unique=True) + node_id: Series[int] = pa.Field(coerce=True) drainage: Series[float] potential_evaporation: Series[float] infiltration: Series[float] @@ -20,7 +19,7 @@ class StaticSchema(pa.SchemaModel): class ForcingSchema(pa.SchemaModel): - node_id: Series[Int] + node_id: Series[int] = pa.Field(coerce=True) time: Series[pa.dtypes.DateTime] drainage: Series[float] potential_evaporation: Series[float] @@ -30,14 +29,14 @@ class ForcingSchema(pa.SchemaModel): class ProfileSchema(pa.SchemaModel): - node_id: Series[Int] + node_id: Series[int] = pa.Field(coerce=True) storage: Series[float] area: Series[float] level: Series[float] class StateSchema(pa.SchemaModel): - node_id: Series[Int] = pa.Field(unique=True) + node_id: Series[int] = pa.Field(coerce=True) storage: Series[float] concentration: Series[float] diff --git a/ribasim/edge.py b/ribasim/edge.py index 028df482f..6101fbaa2 100644 --- a/ribasim/edge.py +++ b/ribasim/edge.py @@ -1,7 +1,4 @@ -from typing import Optional - import pandera as pa -from pandera.dtypes import Int from pandera.typing import DataFrame, Series from pandera.typing.geopandas import GeoSeries from pydantic import BaseModel @@ -12,8 +9,8 @@ class StaticSchema(pa.SchemaModel): - from_node_id: Series[Int] - to_node_id: Series[Int] + from_node_id: Series[int] = pa.Field(coerce=True) + to_node_id: Series[int] = pa.Field(coerce=True) geometry: GeoSeries diff --git a/ribasim/fractional_flow.py b/ribasim/fractional_flow.py index fb8b500a9..c4c4c32e0 100644 --- a/ribasim/fractional_flow.py +++ b/ribasim/fractional_flow.py @@ -1,7 +1,6 @@ from typing import Optional import pandera as pa -from pandera.dtypes import Int from pandera.typing import DataFrame, Series from pydantic import BaseModel @@ -11,12 +10,12 @@ class StaticSchema(pa.SchemaModel): - node_id: Series[Int] + node_id: Series[int] = pa.Field(coerce=True) fraction: Series[float] class ForcingSchema(pa.SchemaModel): - node_id: Series[Int] + node_id: Series[int] = pa.Field(coerce=True) time: Series[pa.dtypes.DateTime] fraction: Series[float] diff --git a/ribasim/input_base.py b/ribasim/input_base.py index 2951e655c..93b3c9ddc 100644 --- a/ribasim/input_base.py +++ b/ribasim/input_base.py @@ -87,12 +87,13 @@ def _kwargs_from_geopackage(cls: Type[T], path: FilePath) -> T: df = None layername = f"{cls._input_type} / {key}" if key == "static" and cls._input_type in layers: - df = gpd.read_file(path, layer=cls._input_type) + df = gpd.read_file( + path, layer=cls._input_type, engine="pyogrio", fid_as_index=True + ) elif layername in layers: - df = gpd.read_file(path, layer=layername) - - if df is not None and df["geometry"].isnull().all(): - df.pop("geometry") + df = gpd.read_file( + path, layer=layername, engine="pyogrio", fid_as_index=True + ) kwargs[key] = df return kwargs diff --git a/ribasim/level_control.py b/ribasim/level_control.py index 5a8c3e10f..a565e184e 100644 --- a/ribasim/level_control.py +++ b/ribasim/level_control.py @@ -1,5 +1,4 @@ import pandera as pa -from pandera.dtypes import Int from pandera.typing import DataFrame, Series from pydantic import BaseModel @@ -9,7 +8,7 @@ class StaticSchema(pa.SchemaModel): - node_id: Series[Int] = pa.Field(unique=True) + node_id: Series[int] = pa.Field(coerce=True) target_level: Series[float] diff --git a/ribasim/linear_level_connection.py b/ribasim/linear_level_connection.py index eb9c8b722..8fd073ff7 100644 --- a/ribasim/linear_level_connection.py +++ b/ribasim/linear_level_connection.py @@ -1,5 +1,4 @@ import pandera as pa -from pandera.dtypes import Int from pandera.typing import DataFrame, Series from pydantic import BaseModel @@ -9,7 +8,7 @@ class StaticSchema(pa.SchemaModel): - node_id: Series[Int] = pa.Field(unique=True) + node_id: Series[int] = pa.Field(coerce=True) conductance: Series[float] diff --git a/ribasim/tabulated_rating_curve.py b/ribasim/tabulated_rating_curve.py index 621ae8252..3bd9dd22e 100644 --- a/ribasim/tabulated_rating_curve.py +++ b/ribasim/tabulated_rating_curve.py @@ -1,5 +1,4 @@ import pandera as pa -from pandera.dtypes import Int from pandera.typing import DataFrame, Series from pydantic import BaseModel @@ -9,7 +8,7 @@ class StaticSchema(pa.SchemaModel): - node_id: Series[Int] + node_id: Series[int] = pa.Field(coerce=True) storage: Series[float] discharge: Series[float] diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 000000000..43104e227 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,12 @@ +[flake8] +ignore = + # whitespace before ':' - doesn't work well with black + E203, + # module level import not at top of file + E402, + # line too long - let black worry about that + E501, + # line break before binary operator + W503, +per-file-ignores = + __init__.py:F401 From 44e80d6b2ba86e54e86b4bbfa8ea4352379e8826 Mon Sep 17 00:00:00 2001 From: Huite Bootsma Date: Sun, 26 Feb 2023 20:35:55 +0100 Subject: [PATCH 21/33] Add outupt widget --- README.rst | 11 +++++-- plugin/ribasim_qgis/widgets/output_widget.py | 30 +++++++++++++++++++ plugin/ribasim_qgis/widgets/ribasim_widget.py | 4 ++- 3 files changed, 42 insertions(+), 3 deletions(-) create mode 100644 plugin/ribasim_qgis/widgets/output_widget.py diff --git a/README.rst b/README.rst index 046832e65..d0a3bd0ac 100644 --- a/README.rst +++ b/README.rst @@ -1,3 +1,10 @@ -# ribasim-python +ribasim-python +============== -(This will be a) Python package for working with [Ribasim.jl](https://github.com/Deltares/Ribasim.jl) +A Python package for working with `Ribasim.jl `_. + + +Documentation +------------- + +`Here `_ \ No newline at end of file diff --git a/plugin/ribasim_qgis/widgets/output_widget.py b/plugin/ribasim_qgis/widgets/output_widget.py new file mode 100644 index 000000000..b5faad312 --- /dev/null +++ b/plugin/ribasim_qgis/widgets/output_widget.py @@ -0,0 +1,30 @@ +from PyQt5.QtWidgets import ( + QFileDialog, + QPushButton, + QVBoxLayout, + QWidget, +) + + +class OutputWidget(QWidget): + def __init__(self, parent): + super().__init__(parent) + self.parent = parent + self.output_button = QPushButton("Associate Output") + + self.output_button.clicked.connect(self.set_output) + + layout = QVBoxLayout() + layout.addWidget(self.output_button) + layout.addStretch() + self.setLayout(layout) + + def set_output(self): + path, _ = QFileDialog.getOpenFileName(self, "Select file", "", "*.arrow") + if path == "": + return + node_layer = self.parent.dataset_widget.node_layer + if node_layer is not None: + node_layer.setCustomProperty("arrow_type", "timeseries") + node_layer.setCustomProperty("arrow_path", path) + return diff --git a/plugin/ribasim_qgis/widgets/ribasim_widget.py b/plugin/ribasim_qgis/widgets/ribasim_widget.py index 2844c8464..65ce84fa1 100644 --- a/plugin/ribasim_qgis/widgets/ribasim_widget.py +++ b/plugin/ribasim_qgis/widgets/ribasim_widget.py @@ -8,11 +8,11 @@ from pathlib import Path from typing import Any -from PyQt5.QtCore import Qt from PyQt5.QtWidgets import QTabWidget, QVBoxLayout, QWidget from qgis.core import QgsMapLayer, QgsProject from ribasim_qgis.widgets.dataset_widget import DatasetWidget from ribasim_qgis.widgets.nodes_widget import NodesWidget +from ribasim_qgis.widgets.output_widget import OutputWidget PYQT_DELETED_ERROR = "wrapped C/C++ object of type QgsLayerTreeGroup has been deleted" @@ -26,6 +26,7 @@ def __init__(self, parent, iface): self.dataset_widget = DatasetWidget(self) self.nodes_widget = NodesWidget(self) + self.output_widget = OutputWidget(self) # Layout self.layout = QVBoxLayout() @@ -33,6 +34,7 @@ def __init__(self, parent, iface): self.layout.addWidget(self.tabwidget) self.tabwidget.addTab(self.dataset_widget, "GeoPackage") self.tabwidget.addTab(self.nodes_widget, "Nodes") + self.tabwidget.addTab(self.output_widget, "Output") self.setLayout(self.layout) # QGIS Layers Panel groups From a35a1daee1035098a37a13ec9a179e7728e9d12c Mon Sep 17 00:00:00 2001 From: Martijn Visser Date: Sun, 26 Feb 2023 18:34:18 +0100 Subject: [PATCH 22/33] basic example: fid starts at 1 --- examples/basic.py | 41 +++++++++++++++++++++++------------------ 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/examples/basic.py b/examples/basic.py index 6b71dc089..e2cc5a112 100644 --- a/examples/basic.py +++ b/examples/basic.py @@ -14,15 +14,15 @@ xy = np.array( [ - (0.0, 0.0), # Basin, - (1.0, 0.0), # LinearLevelConnection - (2.0, 0.0), # Basin - (3.0, 0.0), # TabulatedRatingCurve - (3.0, 1.0), # FractionalFlow - (3.0, 2.0), # Basin - (4.0, 0.0), # FractionalFlow - (5.0, 0.0), # Basin - (6.0, 0.0), # LevelControl + (0.0, 0.0), # 1: Basin, + (1.0, 0.0), # 2: LinearLevelConnection + (2.0, 0.0), # 3: Basin + (3.0, 0.0), # 4: TabulatedRatingCurve + (3.0, 1.0), # 5: FractionalFlow + (3.0, 2.0), # 6: Basin + (4.0, 0.0), # 7: FractionalFlow + (5.0, 0.0), # 8: Basin + (6.0, 0.0), # 9: LevelControl ] ) node_xy = gpd.points_from_xy(x=xy[:, 0], y=xy[:, 1]) @@ -38,13 +38,17 @@ "Basin", "LevelControl", ] -node = ribasim.Node(static=gpd.GeoDataFrame(data={"type": node_type}, geometry=node_xy)) +node = ribasim.Node( + static=gpd.GeoDataFrame( + data={"type": node_type}, index=np.arange(len(xy)) + 1, geometry=node_xy + ) +) # %% # Setup the edges: -from_id = np.array([0, 1, 2, 3, 3, 4, 6, 7], dtype=np.int64) -to_id = np.array([1, 2, 3, 4, 6, 5, 7, 8], dtype=np.int64) +from_id = np.array([1, 2, 3, 4, 4, 5, 7, 8], dtype=np.int64) +to_id = np.array([2, 3, 4, 5, 7, 6, 8, 9], dtype=np.int64) lines = ribasim.utils.geometry_from_connectivity(node, from_id, to_id) edge = ribasim.Edge( static=gpd.GeoDataFrame( @@ -65,7 +69,7 @@ ) repeat = np.tile([0, 1], 4) profile = profile.iloc[repeat] -profile["node_id"] = [0, 0, 2, 2, 5, 5, 7, 7] +profile["node_id"] = [1, 1, 3, 3, 6, 6, 8, 8] static = pd.DataFrame( data={ @@ -78,7 +82,7 @@ } ) static = static.iloc[[0, 0, 0, 0]] -static["node_id"] = [0, 2, 5, 7] +static["node_id"] = [1, 3, 6, 8] basin = ribasim.Basin(profile=profile, static=static) @@ -86,7 +90,7 @@ # Setup linear level connection: linear_connection = ribasim.LinearLevelConnection( - static=pd.DataFrame(data={"node_id": [1], "conductance": [1.5e-4]}) + static=pd.DataFrame(data={"node_id": [2], "conductance": [1.5e-4]}) ) @@ -96,7 +100,7 @@ rating_curve = ribasim.TabulatedRatingCurve( static=pd.DataFrame( data={ - "node_id": [3, 3], + "node_id": [4, 4], "storage": [0.0, 1000.0], "discharge": [0.0, 1.5e-4], } @@ -109,7 +113,7 @@ fractional_flow = ribasim.FractionalFlow( static=pd.DataFrame( data={ - "node_id": [4, 6], + "node_id": [5, 7], "fraction": [0.3, 0.7], } ) @@ -121,7 +125,7 @@ level_control = ribasim.LevelControl( static=pd.DataFrame( data={ - "node_id": [8], + "node_id": [9], "target_level": [1.5], } ) @@ -138,6 +142,7 @@ level_control=level_control, linear_level_connection=linear_connection, tabulated_rating_curve=rating_curve, + fractional_flow=fractional_flow, starttime="2020-01-01 00:00:00", endtime="2021-01-01 00:00:00", ) From fa9efa2deb7d9dd24284ecb0801f16f2e4a7fc7d Mon Sep 17 00:00:00 2001 From: Huite Bootsma Date: Sun, 26 Feb 2023 20:48:30 +0100 Subject: [PATCH 23/33] Add environment.yml --- environment.yml | 16 ++++++++++++++++ pyproject.toml | 3 ++- 2 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 environment.yml diff --git a/environment.yml b/environment.yml new file mode 100644 index 000000000..5585d1c75 --- /dev/null +++ b/environment.yml @@ -0,0 +1,16 @@ +name: ribasim + +channels: + - conda-forge + +dependencies: + - python >= 3.9 + - pandas + - geopandas + - pandera + - pyarrow + - pydantic + - pyogrio + - shapely >=2.0 + - tomli + - tomli-w diff --git a/pyproject.toml b/pyproject.toml index 882b7cdf3..95d230d46 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,10 +20,11 @@ dependencies = [ "geopandas", "pandas", "pandera", - "pyogrio", "pyarrow", "pydantic", + "pyogrio", "shapely >= 2.0", + "tomli", "tomli-w", ] dynamic = ["version"] From b7b73918f2fecabd68dc519060712dee2f4c62bf Mon Sep 17 00:00:00 2001 From: Huite Bootsma Date: Mon, 27 Feb 2023 10:02:49 +0100 Subject: [PATCH 24/33] More reasonable values, CRS --- examples/basic.py | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/examples/basic.py b/examples/basic.py index e2cc5a112..09c90b275 100644 --- a/examples/basic.py +++ b/examples/basic.py @@ -38,9 +38,14 @@ "Basin", "LevelControl", ] + +# Make sure the feature id starts at 1: explicitly give an index. node = ribasim.Node( static=gpd.GeoDataFrame( - data={"type": node_type}, index=np.arange(len(xy)) + 1, geometry=node_xy + data={"type": node_type}, + index=np.arange(len(xy)) + 1, + geometry=node_xy, + crs="EPSG:28992", ) ) @@ -52,7 +57,9 @@ lines = ribasim.utils.geometry_from_connectivity(node, from_id, to_id) edge = ribasim.Edge( static=gpd.GeoDataFrame( - data={"from_node_id": from_id, "to_node_id": to_id, "geometry": lines} + data={"from_node_id": from_id, "to_node_id": to_id}, + geometry=lines, + crs="EPSG:28992", ) ) @@ -71,13 +78,21 @@ profile = profile.iloc[repeat] profile["node_id"] = [1, 1, 3, 3, 6, 6, 8, 8] + +# Convert steady forcing to m/s +# 2 mm/d precipitation, 1 mm/d evaporation +seconds_in_day = 24 * 3600 +precipitation = 0.002 / seconds_in_day +evaporation = 0.001 / seconds_in_day + + static = pd.DataFrame( data={ "node_id": [0], - "drainage": [0.006], - "potential_evaporation": [0.0115], + "drainage": [0.0], + "potential_evaporation": [evaporation], "infiltration": [0.0], - "precipitation": [0.0], + "precipitation": [precipitation], "urban_runoff": [0.0], } ) From 1784416f30ba7e55931776cf837ebcb496f41a96 Mon Sep 17 00:00:00 2001 From: Huite Bootsma Date: Mon, 27 Feb 2023 10:18:40 +0100 Subject: [PATCH 25/33] Add another rating curve so water can leave node 6 --- examples/basic.py | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/examples/basic.py b/examples/basic.py index 09c90b275..4db68251c 100644 --- a/examples/basic.py +++ b/examples/basic.py @@ -20,9 +20,10 @@ (3.0, 0.0), # 4: TabulatedRatingCurve (3.0, 1.0), # 5: FractionalFlow (3.0, 2.0), # 6: Basin - (4.0, 0.0), # 7: FractionalFlow - (5.0, 0.0), # 8: Basin - (6.0, 0.0), # 9: LevelControl + (3.0, 3.0), # 7: TabulatedRatingCurve + (4.0, 0.0), # 8: FractionalFlow + (5.0, 0.0), # 9: Basin + (6.0, 0.0), # 10: LevelControl ] ) node_xy = gpd.points_from_xy(x=xy[:, 0], y=xy[:, 1]) @@ -34,6 +35,7 @@ "TabulatedRatingCurve", "FractionalFlow", "Basin", + "TabulatedRatingCurve", "FractionalFlow", "Basin", "LevelControl", @@ -52,8 +54,8 @@ # %% # Setup the edges: -from_id = np.array([1, 2, 3, 4, 4, 5, 7, 8], dtype=np.int64) -to_id = np.array([2, 3, 4, 5, 7, 6, 8, 9], dtype=np.int64) +from_id = np.array([1, 2, 3, 4, 4, 5, 6, 8, 9], dtype=np.int64) +to_id = np.array([2, 3, 4, 5, 8, 6, 7, 9, 10], dtype=np.int64) lines = ribasim.utils.geometry_from_connectivity(node, from_id, to_id) edge = ribasim.Edge( static=gpd.GeoDataFrame( @@ -76,7 +78,7 @@ ) repeat = np.tile([0, 1], 4) profile = profile.iloc[repeat] -profile["node_id"] = [1, 1, 3, 3, 6, 6, 8, 8] +profile["node_id"] = [1, 1, 3, 3, 6, 6, 9, 9] # Convert steady forcing to m/s @@ -97,7 +99,7 @@ } ) static = static.iloc[[0, 0, 0, 0]] -static["node_id"] = [1, 3, 6, 8] +static["node_id"] = [1, 3, 6, 9] basin = ribasim.Basin(profile=profile, static=static) @@ -110,14 +112,16 @@ # %% -# Set up a rating curve node: +# Set up a rating curve node. +# Discharge: lose 1% of storage volume per day at storage = 1000.0. +q1000 = 1000.0 * 0.01 / seconds_in_day rating_curve = ribasim.TabulatedRatingCurve( static=pd.DataFrame( data={ - "node_id": [4, 4], - "storage": [0.0, 1000.0], - "discharge": [0.0, 1.5e-4], + "node_id": [4, 4, 7, 7], + "storage": [0.0, 1000.0, 0.0, 1000.0], + "discharge": [0.0, q1000, 0.0, q1000], } ) ) @@ -128,7 +132,7 @@ fractional_flow = ribasim.FractionalFlow( static=pd.DataFrame( data={ - "node_id": [5, 7], + "node_id": [5, 8], "fraction": [0.3, 0.7], } ) @@ -140,7 +144,7 @@ level_control = ribasim.LevelControl( static=pd.DataFrame( data={ - "node_id": [9], + "node_id": [10], "target_level": [1.5], } ) From 454542a557eea687902fa2671f7724a30d5252ea Mon Sep 17 00:00:00 2001 From: Huite Bootsma Date: Mon, 27 Feb 2023 10:56:32 +0100 Subject: [PATCH 26/33] Multiple: * Resolve import error in utils for CI * Please linter --- plugin/ribasim_qgis/widgets/output_widget.py | 7 +-- ribasim/utils.py | 45 +++++++++++++++++++- tests/test_edge.py | 9 +++- 3 files changed, 52 insertions(+), 9 deletions(-) diff --git a/plugin/ribasim_qgis/widgets/output_widget.py b/plugin/ribasim_qgis/widgets/output_widget.py index b5faad312..c26ad6c56 100644 --- a/plugin/ribasim_qgis/widgets/output_widget.py +++ b/plugin/ribasim_qgis/widgets/output_widget.py @@ -1,9 +1,4 @@ -from PyQt5.QtWidgets import ( - QFileDialog, - QPushButton, - QVBoxLayout, - QWidget, -) +from PyQt5.QtWidgets import QFileDialog, QPushButton, QVBoxLayout, QWidget class OutputWidget(QWidget): diff --git a/ribasim/utils.py b/ribasim/utils.py index 7fe84f8ae..22a5b28f1 100644 --- a/ribasim/utils.py +++ b/ribasim/utils.py @@ -1,9 +1,9 @@ -from typing import Sequence +from typing import Sequence, Tuple import numpy as np import shapely -from ribasim import Node +from ribasim.node import Node def geometry_from_connectivity( @@ -34,3 +34,44 @@ def geometry_from_connectivity( vertices[1::2, :] = to_points indices = np.repeat(np.arange(n), 2) return shapely.linestrings(coords=vertices, indices=indices) + + +def connectivity_from_geometry( + node: Node, lines: np.ndarray +) -> Tuple[np.ndarray, np.ndarray]: + """ + Derive from_node_id and to_node_id for every edge in lines. LineStrings + may be used to connect multiple nodes in a sequence, but every linestring + vertex must also a node. + + Parameters + ---------- + node: Node + lines: np.ndarray + Array of shapely linestrings. + + Returns + ------- + from_node_id: np.ndarray of int + to_node_id: np.ndarray of int + """ + node_index = node.static.index + node_xy = shapely.get_coordinates(node.static.geometry.values) + edge_xy = shapely.get_coordinates(lines) + + xy = np.vstack([node_xy, edge_xy]) + _, inverse = np.unique(xy, return_inverse=True, axis=0) + _, index, inverse = np.unique(xy, return_index=True, return_inverse=True, axis=0) + uniques_index = index[inverse] + + node_node_id, edge_node_id = np.split(uniques_index, [len(node_xy)]) + if not np.isin(edge_node_id, node_node_id).all(): + raise ValueError( + "Edge lines contain coordinates that are not in the node layer. " + "Please ensure all edges are snapped to nodes exactly." + ) + + edge_node_id = edge_node_id.reshape((-1, 2)) + from_id = node_index[edge_node_id[:, 0]] + to_id = node_index[edge_node_id[:, 1]] + return from_id, to_id diff --git a/tests/test_edge.py b/tests/test_edge.py index 27da1e932..c28e10c49 100644 --- a/tests/test_edge.py +++ b/tests/test_edge.py @@ -1,6 +1,7 @@ import geopandas as gpd import pytest import shapely.geometry as sg +from pydantic import ValidationError from ribasim.edge import Edge @@ -13,5 +14,11 @@ def test(): df = gpd.GeoDataFrame( data={"from_node_id": [1, 1], "to_node_id": [2, 3]}, geometry=geometry ) - edge = Edge(dataframe=df) + edge = Edge(static=df) assert isinstance(edge, Edge) + + with pytest.raises(ValidationError): + df = gpd.GeoDataFrame( + data={"from_node_id": [1, 1], "to_node_id": [2, 3]}, geometry=[None, None] + ) + Edge(static=df) From d02006f5c8701289a18ab23c665750b393b749e0 Mon Sep 17 00:00:00 2001 From: Huite Bootsma Date: Mon, 27 Feb 2023 11:58:39 +0100 Subject: [PATCH 27/33] Tweak docs, define __init__ for all classes --- .github/workflows/docs.yml | 2 +- ribasim/basin.py | 57 ++++++++++++++++++++++-------- ribasim/edge.py | 15 ++++++++ ribasim/fractional_flow.py | 27 ++++++++++++++ ribasim/input_base.py | 2 +- ribasim/level_control.py | 18 ++++++++++ ribasim/linear_level_connection.py | 19 ++++++++++ ribasim/model.py | 35 +++++++++++++++++- ribasim/node.py | 18 ++++++++++ ribasim/tabulated_rating_curve.py | 20 +++++++++++ 10 files changed, 195 insertions(+), 18 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index c4f63c5d1..b107e128e 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -22,7 +22,7 @@ jobs: - run: pip install -e ".[docs]" # We use a custom build script for pdoc itself, ideally you just run `pdoc -o docs/ ...` here. - - run: pdoc -o docs/ ribasim + - run: pdoc -o docs/ ribasim --docformat numpy - uses: actions/upload-pages-artifact@v1 with: diff --git a/ribasim/basin.py b/ribasim/basin.py index 5dfd59bac..71df4fa68 100644 --- a/ribasim/basin.py +++ b/ribasim/basin.py @@ -1,5 +1,6 @@ from typing import Optional +import pandas as pd import pandera as pa from pandera.typing import DataFrame, Series from pydantic import BaseModel @@ -46,27 +47,44 @@ class Basin(InputMixin, BaseModel): Input for a (sub-)basin: an area of land where all flowing surface water converges to a single point. - A basin is defined by a tabulation of: + Parameters + ---------- + profile: pandas.DataFrame - * storage - * area - * water level + A tabulation with the columns: - This data is provided by the ``profile`` DataFrame. + * storage + * area + * water level - In Ribasim, the basin receives water balance terms such as: + static: pandas.DataFrame, optional - * potential evaporation - * precipitation - * groundwater drainage - * groundwater infiltration - * urban runoff + Static forcing with columns: - This may be set in the ``static`` dataframe for constant data, or ``forcing`` - for time varying data. + * potential evaporation + * precipitation + * groundwater drainage + * groundwater infiltration + * urban runoff + + forcing: pandas.DataFrame, optional + + Time varying forcing with columns: + + * time + * potential evaporation + * precipitation + * groundwater drainage + * groundwater infiltration + * urban runoff + + state: pandas.DataFrame, optional + + Initial state with columns: + + * storage + * concentration - A basin may be initialized with an initial state for storage or - concentration. This is set in the ``state`` dataframe. """ _input_type = "Basin" @@ -74,3 +92,12 @@ class Basin(InputMixin, BaseModel): static: Optional[DataFrame[StaticSchema]] = None forcing: Optional[DataFrame[ForcingSchema]] = None state: Optional[DataFrame[StateSchema]] = None + + def __init__( + self, + profile: pd.DataFrame, + static: Optional[pd.DataFrame] = None, + forcing: Optional[pd.DataFrame] = None, + state: Optional[pd.DataFrame] = None, + ): + super().__init__(**locals()) diff --git a/ribasim/edge.py b/ribasim/edge.py index 6101fbaa2..cf4f7aa33 100644 --- a/ribasim/edge.py +++ b/ribasim/edge.py @@ -15,5 +15,20 @@ class StaticSchema(pa.SchemaModel): class Edge(InputMixin, BaseModel): + """ + Defines the connections between nodes. + + Parameters + ---------- + static: pandas.DataFrame + + With columns: + + * from_node_id + * to_node_id + * geometry + + """ + _input_type = "Edge" static: DataFrame[StaticSchema] diff --git a/ribasim/fractional_flow.py b/ribasim/fractional_flow.py index c4c4c32e0..6189dac63 100644 --- a/ribasim/fractional_flow.py +++ b/ribasim/fractional_flow.py @@ -1,5 +1,6 @@ from typing import Optional +import pandas as pd import pandera as pa from pandera.typing import DataFrame, Series from pydantic import BaseModel @@ -21,6 +22,32 @@ class ForcingSchema(pa.SchemaModel): class FractionalFlow(InputMixin, BaseModel): + """ + Receives a fraction of the flow. The fractions must sum to 1.0 for a + furcation. + + Parameters + ---------- + static: pandas.DataFrame + + With columns: + + * node_id + * fraction + + forcing: pandas.DataFrame, optional + + With columns: + + * node_id + * time + * fraction + + """ + _input_type = "FractionalFlow" static: DataFrame[StaticSchema] forcing: Optional[DataFrame[ForcingSchema]] = None + + def __init__(self, static: pd.DataFrame, forcing: Optional[pd.DataFrame]): + super().__init__(**locals()) diff --git a/ribasim/input_base.py b/ribasim/input_base.py index 93b3c9ddc..21ce24e86 100644 --- a/ribasim/input_base.py +++ b/ribasim/input_base.py @@ -11,7 +11,7 @@ T = TypeVar("T") -__all__ = () +__all__ = ("InputMixin",) class InputMixin(abc.ABC): diff --git a/ribasim/level_control.py b/ribasim/level_control.py index a565e184e..062835479 100644 --- a/ribasim/level_control.py +++ b/ribasim/level_control.py @@ -1,3 +1,4 @@ +import pandas as pd import pandera as pa from pandera.typing import DataFrame, Series from pydantic import BaseModel @@ -13,5 +14,22 @@ class StaticSchema(pa.SchemaModel): class LevelControl(InputMixin, BaseModel): + """ + Controls the level in a basin. + + Parameters + ---------- + static: pandas.DataFrame + + With columns: + + * node_id + * target_level + + """ + _input_type = "LevelControl" static: DataFrame[StaticSchema] + + def __init__(self, static: pd.DataFrame): + super().__init__(**locals()) diff --git a/ribasim/linear_level_connection.py b/ribasim/linear_level_connection.py index 8fd073ff7..1169ff400 100644 --- a/ribasim/linear_level_connection.py +++ b/ribasim/linear_level_connection.py @@ -1,3 +1,4 @@ +import pandas as pd import pandera as pa from pandera.typing import DataFrame, Series from pydantic import BaseModel @@ -13,5 +14,23 @@ class StaticSchema(pa.SchemaModel): class LinearLevelConnection(InputMixin, BaseModel): + """ + Flow through this connection linearly depends on the level difference + between the two connected basins. + + Parameters + ---------- + static: pd.DataFrame + + With columns: + + * node_id + * conductance + + """ + _input_type = "LinearLevelConnection" static: DataFrame[StaticSchema] + + def __init__(self, static: pd.DataFrame): + super().__init__(**locals()) diff --git a/ribasim/model.py b/ribasim/model.py index 2a9c6d935..bb1caa5f6 100644 --- a/ribasim/model.py +++ b/ribasim/model.py @@ -1,6 +1,6 @@ import datetime from pathlib import Path -from typing import Optional +from typing import Optional, Union import tomli import tomli_w @@ -31,6 +31,24 @@ class Model(BaseModel): + """ + Ribasim model containing the location of the nodes, the edges between the + nodes, and the node parametrization. + + Parameters + ---------- + modelname: str + node: Node + edge: Edge + basin: Basin + fractional_flow: Optional[FractionalFlow] + level_control: Optional[LevelControl] + linear_level_connection: Optional[LinearLevelConnection] + tabulated_rating_curve: Optional[TabulatedRatingCurve] + starttime: Union[str, datetime.datetime] + endtime: Union[str, datetime.datetime] + """ + modelname: str node: Node edge: Edge @@ -42,6 +60,21 @@ class Model(BaseModel): starttime: datetime.datetime endtime: datetime.datetime + def __init__( + self, + modelname: str, + starttime: Union[str, datetime.datetime], + endtime: Union[str, datetime.datetime], + node: Node, + edge: Edge, + basin: Basin, + fractional_flow: Optional[FractionalFlow] = None, + level_control: Optional[LevelControl] = None, + linear_level_connection: Optional[LinearLevelConnection] = None, + tabulated_rating_curve: Optional[TabulatedRatingCurve] = None, + ): + super().__init__(**locals()) + @classmethod def fields(cls): """Returns the names of the fields contained in the Model.""" diff --git a/ribasim/node.py b/ribasim/node.py index c21e2b8f8..66b3471e8 100644 --- a/ribasim/node.py +++ b/ribasim/node.py @@ -1,3 +1,4 @@ +import pandas as pd import pandera as pa from pandera.typing import DataFrame, Series from pandera.typing.geopandas import GeoSeries @@ -14,5 +15,22 @@ class StaticSchema(pa.SchemaModel): class Node(InputMixin, BaseModel): + """ + The Ribasim nodes as Point geometries. + + Parameters + ---------- + static: geopandas.GeoDataFrame + + With columns: + + * type + * geometry + + """ + _input_type = "Node" static: DataFrame[StaticSchema] + + def __init__(self, static: pd.DataFrame): + super().__init__(**locals()) diff --git a/ribasim/tabulated_rating_curve.py b/ribasim/tabulated_rating_curve.py index 3bd9dd22e..a53a027ae 100644 --- a/ribasim/tabulated_rating_curve.py +++ b/ribasim/tabulated_rating_curve.py @@ -1,3 +1,4 @@ +import pandas as pd import pandera as pa from pandera.typing import DataFrame, Series from pydantic import BaseModel @@ -14,5 +15,24 @@ class StaticSchema(pa.SchemaModel): class TabulatedRatingCurve(InputMixin, BaseModel): + """ + Linearly interpolates discharge between a tabulation of storage and + discharge. + + Parameters + ---------- + static: pd.DataFrame + + Tabulation with columns: + + * node_id + * storage + * discharge + + """ + _input_type = "TabulatedRatingCurve" static: DataFrame[StaticSchema] + + def __init__(self, static: pd.DataFrame): + super().__init__(**locals()) From a7fd6fcb76b895aad43b7ff99756130b32a275a1 Mon Sep 17 00:00:00 2001 From: Huite Bootsma Date: Mon, 27 Feb 2023 17:14:22 +0100 Subject: [PATCH 28/33] Take into account that geopackage path is relative to toml --- ribasim/model.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ribasim/model.py b/ribasim/model.py index bb1caa5f6..8b0bbf88b 100644 --- a/ribasim/model.py +++ b/ribasim/model.py @@ -154,6 +154,7 @@ def from_toml(path: FilePath) -> "Model": config = tomli.load(f) kwargs = {"modelname": path.stem} + config["geopackage"] = path.parent / config["geopackage"] for cls, kwarg_name in _NODES: kwargs[kwarg_name] = cls.from_config(config) From a2b5978b24785e6ede300cf824fb6e7b475cffc2 Mon Sep 17 00:00:00 2001 From: Huite Bootsma Date: Mon, 27 Feb 2023 18:18:27 +0100 Subject: [PATCH 29/33] validate on assignment, add transient example, some plotting --- environment.yml | 2 + examples/basic-transient.py | 78 ++++++++++++++++++++++++++++++ ribasim/basin.py | 3 ++ ribasim/edge.py | 18 +++++++ ribasim/fractional_flow.py | 5 +- ribasim/level_control.py | 3 ++ ribasim/linear_level_connection.py | 3 ++ ribasim/model.py | 25 +++++++++- ribasim/node.py | 42 ++++++++++++++++ ribasim/tabulated_rating_curve.py | 3 ++ 10 files changed, 180 insertions(+), 2 deletions(-) create mode 100644 examples/basic-transient.py diff --git a/environment.yml b/environment.yml index 5585d1c75..9b530057e 100644 --- a/environment.yml +++ b/environment.yml @@ -5,6 +5,7 @@ channels: dependencies: - python >= 3.9 + - matplotlib - pandas - geopandas - pandera @@ -14,3 +15,4 @@ dependencies: - shapely >=2.0 - tomli - tomli-w + - xarray diff --git a/examples/basic-transient.py b/examples/basic-transient.py new file mode 100644 index 000000000..72de03738 --- /dev/null +++ b/examples/basic-transient.py @@ -0,0 +1,78 @@ +# %% +import os + +os.environ["USE_PYGEOS"] = "0" + +import numpy as np +import pandas as pd +import xarray as xr + +import ribasim + +# %% + +model = ribasim.Model.from_toml("basic/basic.toml") + +# %% + +time = pd.date_range(model.starttime, model.endtime) +day_of_year = time.day_of_year.values +seconds_per_day = 24 * 60 * 60 +evaporation = ( + (-1.0 * np.cos(day_of_year / 365.0 * 2 * np.pi) + 1.0) * 0.0025 / seconds_per_day +) +rng = np.random.default_rng() +precipitation = ( + rng.lognormal(mean=-1.0, sigma=1.7, size=time.size) * 0.001 / seconds_per_day +) + +# %% +# We'll use xarray to easily broadcast the values. + +timeseries = ( + pd.DataFrame( + data={ + "node_id": 1, + "time": time, + "drainage": 0.0, + "potential_evaporation": evaporation, + "infiltration": 0.0, + "precipitation": precipitation, + "urban_runoff": 0.0, + } + ) + .set_index("time") + .to_xarray() +) + +basin_ids = model.basin.static["node_id"].unique() +basin_nodes = xr.DataArray( + np.ones(len(basin_ids)), coords={"node_id": basin_ids}, dims=["node_id"] +) +forcing = (timeseries * basin_nodes).to_dataframe().reset_index() + +# %% + +state = pd.DataFrame( + data={ + "node_id": basin_ids, + "storage": 1000.0, + "concentration": 0.0, + } +) + +# %% + +model.basin.forcing = forcing +model.basin.state = state + +# %% + +model.write("basic-transient") +# %% +# After running the model, read back the input: + +df = pd.read_feather(r"c:\src\ribasim.jl\examples\basic-transient\basin.arrow") +output = df.set_index(["time", "node_id"]).to_xarray() +output["level"].plot(hue="node_id") +# %% diff --git a/ribasim/basin.py b/ribasim/basin.py index 71df4fa68..26918e5c0 100644 --- a/ribasim/basin.py +++ b/ribasim/basin.py @@ -93,6 +93,9 @@ class Basin(InputMixin, BaseModel): forcing: Optional[DataFrame[ForcingSchema]] = None state: Optional[DataFrame[StateSchema]] = None + class Config: + validate_assignment = True + def __init__( self, profile: pd.DataFrame, diff --git a/ribasim/edge.py b/ribasim/edge.py index cf4f7aa33..51d383d77 100644 --- a/ribasim/edge.py +++ b/ribasim/edge.py @@ -1,3 +1,7 @@ +from typing import Any + +import matplotlib.pyplot as plt +import pandas as pd import pandera as pa from pandera.typing import DataFrame, Series from pandera.typing.geopandas import GeoSeries @@ -32,3 +36,17 @@ class Edge(InputMixin, BaseModel): _input_type = "Edge" static: DataFrame[StaticSchema] + + class Config: + validate_assignment = True + + def __init__(self, static: pd.DataFrame): + super().__init__(**locals()) + + def plot(self, **kwargs) -> Any: + ax = kwargs.get("ax", None) + if ax is None: + _, ax = plt.subplots() + kwargs["ax"] = ax + self.static.plot(**kwargs) + return ax diff --git a/ribasim/fractional_flow.py b/ribasim/fractional_flow.py index 6189dac63..affdd9f82 100644 --- a/ribasim/fractional_flow.py +++ b/ribasim/fractional_flow.py @@ -49,5 +49,8 @@ class FractionalFlow(InputMixin, BaseModel): static: DataFrame[StaticSchema] forcing: Optional[DataFrame[ForcingSchema]] = None - def __init__(self, static: pd.DataFrame, forcing: Optional[pd.DataFrame]): + class Config: + validate_assignment = True + + def __init__(self, static: pd.DataFrame, forcing: Optional[pd.DataFrame] = None): super().__init__(**locals()) diff --git a/ribasim/level_control.py b/ribasim/level_control.py index 062835479..55294ca8a 100644 --- a/ribasim/level_control.py +++ b/ribasim/level_control.py @@ -31,5 +31,8 @@ class LevelControl(InputMixin, BaseModel): _input_type = "LevelControl" static: DataFrame[StaticSchema] + class Config: + validate_assignment = True + def __init__(self, static: pd.DataFrame): super().__init__(**locals()) diff --git a/ribasim/linear_level_connection.py b/ribasim/linear_level_connection.py index 1169ff400..b034098fc 100644 --- a/ribasim/linear_level_connection.py +++ b/ribasim/linear_level_connection.py @@ -32,5 +32,8 @@ class LinearLevelConnection(InputMixin, BaseModel): _input_type = "LinearLevelConnection" static: DataFrame[StaticSchema] + class Config: + validate_assignment = True + def __init__(self, static: pd.DataFrame): super().__init__(**locals()) diff --git a/ribasim/model.py b/ribasim/model.py index 8b0bbf88b..3249d45d4 100644 --- a/ribasim/model.py +++ b/ribasim/model.py @@ -1,7 +1,8 @@ import datetime from pathlib import Path -from typing import Optional, Union +from typing import Any, Optional, Union +import matplotlib.pyplot as plt import tomli import tomli_w from pydantic import BaseModel @@ -60,6 +61,9 @@ class Model(BaseModel): starttime: datetime.datetime endtime: datetime.datetime + class Config: + validate_assignment = True + def __init__( self, modelname: str, @@ -162,3 +166,22 @@ def from_toml(path: FilePath) -> "Model": kwargs["endtime"] = config["endtime"] return Model(**kwargs) + + def plot(self, ax=None) -> Any: + """ + Plot the nodes and edges of the model. + + Parameters + ---------- + ax: matplotlib.pyplot.Artist, optional + axes on which to draw the plot + + Returns + ------- + ax: matplotlib.pyplot.Artist + """ + if ax is None: + _, ax = plt.subplots() + self.edge.plot(ax=ax, zorder=2) + self.node.plot(ax=ax, zorder=3) + return ax diff --git a/ribasim/node.py b/ribasim/node.py index 66b3471e8..3d96389de 100644 --- a/ribasim/node.py +++ b/ribasim/node.py @@ -1,3 +1,6 @@ +from typing import Any + +import matplotlib.pyplot as plt import pandas as pd import pandera as pa from pandera.typing import DataFrame, Series @@ -9,6 +12,16 @@ __all__ = ("Node",) +_MARKERS = { + "Basin": "o", + "FractionalFlow": "^", + "LevelControl": "*", + "LinearLevelConnection": "^", + "TabulatedRatingCurve": "D", + "": "o", +} + + class StaticSchema(pa.SchemaModel): type: Series[str] geometry: GeoSeries @@ -32,5 +45,34 @@ class Node(InputMixin, BaseModel): _input_type = "Node" static: DataFrame[StaticSchema] + class Config: + validate_assignment = True + def __init__(self, static: pd.DataFrame): super().__init__(**locals()) + + def plot(self, **kwargs) -> Any: + """ + Plot the nodes. Each node type is given a separate marker. + + Parameters + ---------- + **kwargs: optional + Keyword arguments forwarded to GeoDataFrame.plot. + + Returns + ------- + None + """ + kwargs = kwargs.copy() + ax = kwargs.get("ax", None) + if ax is None: + _, ax = plt.subplots() + kwargs["ax"] = ax + + for nodetype, df in self.static.groupby("type"): + marker = _MARKERS[nodetype] + kwargs["marker"] = marker + df.plot(**kwargs) + + return ax diff --git a/ribasim/tabulated_rating_curve.py b/ribasim/tabulated_rating_curve.py index a53a027ae..6eb569cc1 100644 --- a/ribasim/tabulated_rating_curve.py +++ b/ribasim/tabulated_rating_curve.py @@ -34,5 +34,8 @@ class TabulatedRatingCurve(InputMixin, BaseModel): _input_type = "TabulatedRatingCurve" static: DataFrame[StaticSchema] + class Config: + validate_assignment = True + def __init__(self, static: pd.DataFrame): super().__init__(**locals()) From 7ca76ae5a7c14d172cc3e039554ee5477d27256d Mon Sep 17 00:00:00 2001 From: Huite Bootsma Date: Mon, 27 Feb 2023 18:21:31 +0100 Subject: [PATCH 30/33] matplotlib to dependencies --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 95d230d46..abe047afd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ classifiers = [ requires-python = ">=3.9" dependencies = [ "geopandas", + "matplotlib", "pandas", "pandera", "pyarrow", From 5a76f42ecff9dd8989a06abf5cccb13d51b3dcdb Mon Sep 17 00:00:00 2001 From: Huite Bootsma Date: Mon, 27 Feb 2023 18:25:12 +0100 Subject: [PATCH 31/33] version 0.1.1 --- ribasim/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ribasim/__init__.py b/ribasim/__init__.py index 2ddfaa743..0147d2800 100644 --- a/ribasim/__init__.py +++ b/ribasim/__init__.py @@ -1,4 +1,4 @@ -__version__ = "0.1.0" +__version__ = "0.1.1" from ribasim import utils From b0fd3ec1ecf6ec5407c195b14e55248ce8d2f7c9 Mon Sep 17 00:00:00 2001 From: Martijn Visser Date: Tue, 28 Feb 2023 12:21:36 +0100 Subject: [PATCH 32/33] ignore example models --- .gitignore | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index cf3f9fe5b..961b922b5 100644 --- a/.gitignore +++ b/.gitignore @@ -132,4 +132,8 @@ dmypy.json /docs # VS Code settings -*.vscode \ No newline at end of file +*.vscode + +# Example models +/examples/basic +/examples/basic-transient From 3462f9bfedeb34a198aae5f8c571c1be99517142 Mon Sep 17 00:00:00 2001 From: Martijn Visser Date: Mon, 6 Mar 2023 15:40:46 +0100 Subject: [PATCH 33/33] mention examples --- README.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index d0a3bd0ac..d788917d2 100644 --- a/README.rst +++ b/README.rst @@ -7,4 +7,5 @@ A Python package for working with `Ribasim.jl `_ \ No newline at end of file +API documentation can be found `here `_. +See also the examples directory.