├── docs ├── .nojekyll ├── _publish.yml ├── CNAME ├── assets │ ├── ribasim.ico │ ├── styles.css │ ├── c4_system.mmd │ └── c4_component_ribasim.mmd ├── contact.qmd ├── reference │ ├── index.qmd │ ├── node │ │ ├── terminal.qmd │ │ ├── linear-resistance.qmd │ │ ├── flow-demand.qmd │ │ ├── junction.qmd │ │ ├── level-boundary.qmd │ │ ├── level-demand.qmd │ │ ├── flow-boundary.qmd │ │ └── continuous-control.qmd │ └── test-models.qmd ├── .gitignore ├── _extensions │ └── quarto-ext │ │ └── include-code-files │ │ ├── _extension.yml │ │ └── include-code-files.lua ├── dev │ ├── copilot.qmd │ ├── python.qmd │ ├── benchmark.qmd │ └── qgis_test_plan.qmd ├── known_issues.qmd ├── run_ribasim.py ├── guide │ ├── coupling.qmd │ ├── debug.qmd │ └── updating-ribasim.qmd └── index.qmd ├── python ├── ribasim │ ├── ribasim │ │ ├── py.typed │ │ ├── delwaq │ │ │ ├── .gitignore │ │ │ ├── __init__.py │ │ │ ├── template │ │ │ │ ├── B8_initials.inc.j2 │ │ │ │ ├── delwaq.atr.j2 │ │ │ │ └── B5_bounddata.inc.j2 │ │ │ ├── reference │ │ │ │ └── dimr_config.xml │ │ │ ├── parse.py │ │ │ └── README.md │ │ ├── nodes │ │ │ ├── linear_resistance.py │ │ │ ├── manning_resistance.py │ │ │ ├── pump.py │ │ │ ├── outlet.py │ │ │ ├── pid_control.py │ │ │ ├── flow_demand.py │ │ │ ├── level_demand.py │ │ │ ├── tabulated_rating_curve.py │ │ │ ├── continuous_control.py │ │ │ ├── user_demand.py │ │ │ ├── level_boundary.py │ │ │ ├── discrete_control.py │ │ │ ├── flow_boundary.py │ │ │ ├── __init__.py │ │ │ └── basin.py │ │ ├── geometry │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ └── area.py │ │ ├── styles │ │ │ └── README.md │ │ └── __init__.py │ ├── tests │ │ ├── test_input_base.py │ │ ├── test_migrations.py │ │ ├── test_link.py │ │ ├── conftest.py │ │ └── test_schemas.py │ ├── README.md │ ├── LICENSE │ └── pyproject.toml ├── ribasim_api │ ├── ribasim_api │ │ ├── py.typed │ │ ├── __init__.py │ │ └── ribasim_api.py │ ├── README.md │ ├── pyproject.toml │ ├── LICENSE │ └── tests │ │ └── conftest.py └── ribasim_testmodels │ ├── ribasim_testmodels │ ├── py.typed │ ├── trivial.py │ ├── bucket.py │ ├── backwater.py │ └── two_basin.py │ ├── README.md │ ├── pyproject.toml │ └── LICENSE ├── ribasim_qgis ├── core │ ├── __init__.py │ ├── geopackage.py │ ├── arrow.py │ └── model.py ├── tests │ ├── ui │ │ ├── __init__.py │ │ └── test_load_plugin.py │ ├── core │ │ ├── __init__.py │ │ └── test_model.py │ ├── data │ │ └── simple_valid.toml │ └── __init__.py ├── icon.png ├── .coveragerc ├── resources.qrc ├── __init__.py ├── tomllib │ ├── _types.py │ └── __init__.py ├── scripts │ ├── run_qgis_ui_tests.py │ ├── enable_plugin.py │ ├── install_ribasim_qgis.py │ └── install_qgis_plugin.py ├── LICENSE └── metadata.txt ├── open-vscode.bat ├── .dvc ├── .gitignore └── config ├── data └── .gitignore ├── .env.default ├── yamlfmt.yaml ├── .typos.toml ├── core ├── test │ ├── aqua_test.jl │ ├── data │ │ ├── logging_test_no_loglevel.toml │ │ ├── logging_test_loglevel_debug.toml │ │ ├── config_test.toml │ │ └── allocation_problems │ │ │ ├── level_demand │ │ │ └── allocation_problem_3.lp │ │ │ ├── allocation_control │ │ │ └── allocation_problem_1.lp │ │ │ ├── drain_surplus │ │ │ └── allocation_problem_2.lp │ │ │ ├── linear_resistance_demand │ │ │ └── allocation_problem_2.lp │ │ │ ├── primary_and_secondary_subnetworks │ │ │ └── allocation_problem_5.lp │ │ │ ├── secondary_networks_with_sources │ │ │ └── allocation_problem_5.lp │ │ │ ├── small_primary_secondary_network │ │ │ └── allocation_problem_2.lp │ │ │ └── medium_primary_secondary_network │ │ │ └── allocation_problem_2.lp │ ├── runtests.jl │ ├── main_test.jl │ ├── libribasim_test.jl │ └── carrays_test.jl ├── LICENSE └── integration_test │ └── hws_integration_test.jl ├── .dvcignore ├── codecov.yml ├── models ├── integration.toml ├── hws.dvc ├── benchmark.dvc └── hws_migration_test.dvc ├── .teamcity ├── README ├── Testbench │ ├── Testbench.kt │ ├── IntegrationTestHWS.kt │ └── RegressionTestODESolve.kt ├── Templates │ ├── WindowsAgent.kt │ ├── LinuxAgent.kt │ ├── GithubIntegration.kt │ ├── GenerateCache.kt │ ├── Build.kt │ └── TestDelwaqCoupling.kt ├── Ribasim │ ├── vcsRoots │ │ └── Ribasim.kt │ └── buildTypes │ │ ├── Ribasim_MakeQgisPlugin.kt │ │ ├── GenerateCache.kt │ │ └── GenerateTestmodels.kt ├── settings.kts └── Ribasim_Linux │ └── RibasimLinuxProject.kt ├── .devcontainer ├── dockerfile └── devcontainer.json ├── .docker ├── compose.yml └── test.sh ├── utils ├── unset-ssl-cert.bat ├── s3_settings.py ├── utils.jl ├── migrate_model.py ├── generate-sbom.jl ├── templates │ ├── validation.py.jinja │ └── schemas.py.jinja ├── s3_upload.py ├── generate-testmodels.py ├── update-manifest.jl ├── github-release.py ├── plot.jl ├── s3_download.py ├── testmodelrun.jl └── write_allocation_problems.jl ├── ruff.toml ├── dvc.yaml ├── .github ├── workflows │ ├── pre-commit_check.yml │ ├── qgis.yml │ ├── python_codegen.yml │ ├── pre-commit_auto_update.yml │ ├── mypy.yml │ ├── julia_auto_update.yml │ ├── pixi_auto_update.yml │ ├── python_tests.yml │ ├── core_testmodels.yml │ ├── core_tests.yml │ └── docs.yml ├── dependabot.yml └── ISSUE_TEMPLATE │ └── feature_request.md ├── .gitattributes ├── .JuliaFormatter.toml ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── dvc.lock ├── mypy.ini ├── .sonarcloud.properties ├── .pre-commit-config.yaml ├── LICENSE └── README.md /docs/.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/_publish.yml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/CNAME: -------------------------------------------------------------------------------- 1 | ribasim.org 2 | -------------------------------------------------------------------------------- /python/ribasim/ribasim/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ribasim_qgis/core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ribasim_qgis/tests/ui/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ribasim_qgis/tests/core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /open-vscode.bat: -------------------------------------------------------------------------------- 1 | pixi run code . | exit 2 | -------------------------------------------------------------------------------- /python/ribasim_api/ribasim_api/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.dvc/.gitignore: -------------------------------------------------------------------------------- 1 | /config.local 2 | /tmp 3 | /cache 4 | -------------------------------------------------------------------------------- /python/ribasim_testmodels/ribasim_testmodels/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /data/.gitignore: -------------------------------------------------------------------------------- 1 | /predict.dat 2 | /integration.csv 3 | /integration.toml 4 | -------------------------------------------------------------------------------- /python/ribasim/ribasim/delwaq/.gitignore: -------------------------------------------------------------------------------- 1 | model 2 | *.map 3 | *.nc 4 | *.pdf 5 | *.out 6 | -------------------------------------------------------------------------------- /docs/assets/ribasim.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Deltares/Ribasim/main/docs/assets/ribasim.ico -------------------------------------------------------------------------------- /ribasim_qgis/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Deltares/Ribasim/main/ribasim_qgis/icon.png -------------------------------------------------------------------------------- /.env.default: -------------------------------------------------------------------------------- 1 | # Create a copy of this file as .env and fill in the values. 2 | MINIO_ACCESS_KEY= 3 | MINIO_SECRET_KEY= 4 | -------------------------------------------------------------------------------- /ribasim_qgis/.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = . 3 | omit = 4 | */resources.py 5 | */tests/* 6 | */tomllib/* 7 | -------------------------------------------------------------------------------- /docs/contact.qmd: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Contact us" 3 | --- 4 | 5 | You can contact us [via email](mailto:ribasim.info@deltares.nl). 6 | -------------------------------------------------------------------------------- /yamlfmt.yaml: -------------------------------------------------------------------------------- 1 | # https://github.com/google/yamlfmt/blob/main/docs/config-file.md 2 | gitignore_excludes: true 3 | line_ending: lf 4 | -------------------------------------------------------------------------------- /.typos.toml: -------------------------------------------------------------------------------- 1 | [default.extend-words] 2 | # Ignore false-positives 3 | Missings = "Missings" 4 | stichting = "stichting" 5 | typ = "typ" 6 | -------------------------------------------------------------------------------- /core/test/aqua_test.jl: -------------------------------------------------------------------------------- 1 | @testitem "Aqua" begin 2 | import Aqua 3 | Aqua.test_all(Ribasim; ambiguities = false, persistent_tasks = false) 4 | end 5 | -------------------------------------------------------------------------------- /ribasim_qgis/resources.qrc: -------------------------------------------------------------------------------- 1 | 2 | 3 | icon.png 4 | 5 | 6 | -------------------------------------------------------------------------------- /docs/reference/index.qmd: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Reference" 3 | --- 4 | 5 | These pages contain the test models, node descriptions and Ribasim Python API documentation. 6 | -------------------------------------------------------------------------------- /python/ribasim_api/ribasim_api/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "2026.1.0-rc1" 2 | 3 | from ribasim_api.ribasim_api import RibasimApi 4 | 5 | __all__ = ["RibasimApi"] 6 | -------------------------------------------------------------------------------- /.dvcignore: -------------------------------------------------------------------------------- 1 | # Add patterns of files dvc should ignore, which could improve 2 | # the performance. Learn more at 3 | # https://dvc.org/doc/user-guide/dvcignore 4 | docs 5 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | patch: 4 | default: 5 | informational: true 6 | project: 7 | default: 8 | informational: true 9 | -------------------------------------------------------------------------------- /models/integration.toml: -------------------------------------------------------------------------------- 1 | starttime = 2023-01-20 00:00:00 2 | endtime = 2023-07-01 00:00:00 3 | crs = "EPSG:28992" 4 | input_dir = "hws" 5 | results_dir = "hws/results" 6 | ribasim_version = "2025.3.0" 7 | -------------------------------------------------------------------------------- /core/test/data/logging_test_no_loglevel.toml: -------------------------------------------------------------------------------- 1 | starttime = 2019-01-01 2 | endtime = 2019-12-31 3 | crs = "EPSG:28992" 4 | input_dir = "input" 5 | results_dir = "results" 6 | ribasim_version = "2026.1.0-rc1" 7 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | /.quarto/ 2 | /_site/ 3 | /site_libs/ 4 | /reference/python/ 5 | /getting-started/tutorial/crystal-basin/ 6 | /getting-started/data/ 7 | *.html 8 | objects.json 9 | 10 | **/*.quarto_ipynb 11 | -------------------------------------------------------------------------------- /ribasim_qgis/tests/data/simple_valid.toml: -------------------------------------------------------------------------------- 1 | starttime = 2020-01-01 00:00:00 2 | endtime = 2021-01-01 00:00:00 3 | crs = "EPSG:28992" 4 | input_dir = "." 5 | results_dir = "results" 6 | ribasim_version = "2026.1.0-rc1" 7 | -------------------------------------------------------------------------------- /docs/_extensions/quarto-ext/include-code-files/_extension.yml: -------------------------------------------------------------------------------- 1 | title: Include Code Files 2 | author: Bruno Beaufils 3 | version: 1.0.0 4 | quarto-required: ">=1.2" 5 | contributes: 6 | filters: 7 | - include-code-files.lua 8 | -------------------------------------------------------------------------------- /docs/assets/styles.css: -------------------------------------------------------------------------------- 1 | /* css styles */ 2 | 3 | /* freeze first column of a table */ 4 | .freeze-left th:first-child, 5 | .freeze-left td:first-child { 6 | position: sticky; 7 | left: 0; 8 | background: white; 9 | z-index: 1; 10 | } 11 | -------------------------------------------------------------------------------- /core/test/data/logging_test_loglevel_debug.toml: -------------------------------------------------------------------------------- 1 | starttime = 2019-01-01 2 | endtime = 2019-12-31 3 | crs = "EPSG:28992" 4 | input_dir = "input" 5 | results_dir = "results" 6 | ribasim_version = "2026.1.0-rc1" 7 | 8 | [logging] 9 | verbosity = "debug" 10 | -------------------------------------------------------------------------------- /ribasim_qgis/__init__.py: -------------------------------------------------------------------------------- 1 | """A script which initializes the plugin, making it known to QGIS.""" 2 | 3 | 4 | def classFactory(iface): # pylint: disable=invalid-name 5 | from ribasim_qgis.ribasim_qgis import RibasimPlugin 6 | 7 | return RibasimPlugin(iface) 8 | -------------------------------------------------------------------------------- /.teamcity/README: -------------------------------------------------------------------------------- 1 | The archive contains settings for a TeamCity project. 2 | 3 | To edit the settings in IntelliJ Idea, open the pom.xml and 4 | select the 'Open as a project' option. 5 | 6 | If you want to move this dsl to version control, save it in the 7 | .teamcity directory. -------------------------------------------------------------------------------- /.devcontainer/dockerfile: -------------------------------------------------------------------------------- 1 | FROM julia:latest 2 | ARG PIXI_VERSION=v0.49.0 3 | 4 | RUN curl -fsSL https://pixi.sh/install.sh | bash -s -- ${PIXI_VERSION} 5 | ENV PATH="/root/.pixi/bin:${PATH}" 6 | RUN apt-get update && \ 7 | apt-get upgrade -y && \ 8 | apt-get install -y git 9 | -------------------------------------------------------------------------------- /core/test/data/config_test.toml: -------------------------------------------------------------------------------- 1 | starttime = 2019-01-01 2 | endtime = 2019-12-31 3 | crs = "EPSG:28992" 4 | input_dir = "input" 5 | results_dir = "results" 6 | ribasim_version = "2026.1.0-rc1" 7 | 8 | [basin] 9 | time = "basin/time.arrow" 10 | 11 | [solver] 12 | saveat = 3600 13 | -------------------------------------------------------------------------------- /python/ribasim/ribasim/nodes/linear_resistance.py: -------------------------------------------------------------------------------- 1 | from ribasim.input_base import TableModel 2 | from ribasim.schemas import ( 3 | LinearResistanceStaticSchema, 4 | ) 5 | 6 | __all__ = ["Static"] 7 | 8 | 9 | class Static(TableModel[LinearResistanceStaticSchema]): 10 | pass 11 | -------------------------------------------------------------------------------- /.docker/compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | qgis: 4 | image: qgis/qgis:release-3_34 5 | container_name: qgis 6 | volumes: 7 | - ../ribasim_qgis/:/tests_directory/ribasim_qgis 8 | environment: 9 | - CI=true 10 | - DISPLAY=:99 11 | tty: true 12 | -------------------------------------------------------------------------------- /python/ribasim/ribasim/nodes/manning_resistance.py: -------------------------------------------------------------------------------- 1 | from ribasim.input_base import TableModel 2 | from ribasim.schemas import ( 3 | ManningResistanceStaticSchema, 4 | ) 5 | 6 | __all__ = ["Static"] 7 | 8 | 9 | class Static(TableModel[ManningResistanceStaticSchema]): 10 | pass 11 | -------------------------------------------------------------------------------- /utils/unset-ssl-cert.bat: -------------------------------------------------------------------------------- 1 | rem Workaround a conflict between conda openssl activation and julia, 2 | rem until these two PRs are released: 3 | rem https://github.com/JuliaLang/NetworkOptions.jl/pull/37 4 | rem https://github.com/JuliaLang/julia/pull/56924 5 | set SSL_CERT_FILE= 6 | set SSL_CERT_DIR= 7 | -------------------------------------------------------------------------------- /python/ribasim/ribasim/geometry/__init__.py: -------------------------------------------------------------------------------- 1 | from ribasim.geometry.area import BasinAreaSchema, FlowBoundaryAreaSchema 2 | from ribasim.geometry.link import LinkTable 3 | from ribasim.geometry.node import NodeTable 4 | 5 | __all__ = ["BasinAreaSchema", "FlowBoundaryAreaSchema", "LinkTable", "NodeTable"] 6 | -------------------------------------------------------------------------------- /python/ribasim_testmodels/README.md: -------------------------------------------------------------------------------- 1 | # Ribasim Testmodels 2 | 3 | `ribasim_testmodels` can generate model files with data that can be run by Ribasim. 4 | The test models are used for internal testing of Ribasim. 5 | 6 | # Contributing 7 | 8 | For the developer docs please have a look at https://ribasim.org/dev/ 9 | -------------------------------------------------------------------------------- /utils/s3_settings.py: -------------------------------------------------------------------------------- 1 | from pydantic_settings import BaseSettings, SettingsConfigDict 2 | 3 | 4 | class Settings(BaseSettings): 5 | minio_access_key: str = "" 6 | minio_secret_key: str = "" 7 | 8 | model_config = SettingsConfigDict(env_file=(".env")) 9 | 10 | 11 | settings = Settings() 12 | -------------------------------------------------------------------------------- /python/ribasim/ribasim/nodes/pump.py: -------------------------------------------------------------------------------- 1 | from ribasim.input_base import TableModel 2 | from ribasim.schemas import PumpStaticSchema, PumpTimeSchema 3 | 4 | __all__ = ["Static", "Time"] 5 | 6 | 7 | class Static(TableModel[PumpStaticSchema]): 8 | pass 9 | 10 | 11 | class Time(TableModel[PumpTimeSchema]): 12 | pass 13 | -------------------------------------------------------------------------------- /.dvc/config: -------------------------------------------------------------------------------- 1 | [core] 2 | remote = minio 3 | ['remote "minio"'] # for tracking artifacts with unreadable names 4 | url = s3://ribasim/dvc 5 | endpointurl = https://s3.deltares.nl 6 | ['remote "minio_readonly"'] # for readable named models in the top-dir 7 | url = s3://ribasim 8 | endpointurl = https://s3.deltares.nl 9 | -------------------------------------------------------------------------------- /python/ribasim/ribasim/nodes/outlet.py: -------------------------------------------------------------------------------- 1 | from ribasim.input_base import TableModel 2 | from ribasim.schemas import OutletStaticSchema, OutletTimeSchema 3 | 4 | __all__ = ["Static", "Time"] 5 | 6 | 7 | class Static(TableModel[OutletStaticSchema]): 8 | pass 9 | 10 | 11 | class Time(TableModel[OutletTimeSchema]): 12 | pass 13 | -------------------------------------------------------------------------------- /utils/utils.jl: -------------------------------------------------------------------------------- 1 | # Shared utility functions that are not part of the Ribasim core 2 | 3 | using Glob: glob 4 | 5 | "Retrieve the names of all test models" 6 | function get_testmodels()::Vector{String} 7 | models_dir = normpath(@__DIR__, "..", "generated_testmodels") 8 | toml_paths = sort(glob("**/ribasim.toml", models_dir)) 9 | end 10 | -------------------------------------------------------------------------------- /ruff.toml: -------------------------------------------------------------------------------- 1 | exclude = ["ribasim_qgis/tomllib/*"] 2 | target-version = "py312" 3 | 4 | [lint] 5 | # See https://docs.astral.sh/ruff/rules/ 6 | select = ["C4", "D2", "D3", "D4", "E", "F", "I", "NPY", "PD", "UP", "RUF"] 7 | ignore = [ 8 | "E501", 9 | "PD002", 10 | ] 11 | fixable = ["ALL"] 12 | 13 | [lint.pydocstyle] 14 | convention = "numpy" 15 | -------------------------------------------------------------------------------- /python/ribasim/ribasim/nodes/pid_control.py: -------------------------------------------------------------------------------- 1 | from ribasim.input_base import TableModel 2 | from ribasim.schemas import PidControlStaticSchema, PidControlTimeSchema 3 | 4 | __all__ = ["Static", "Time"] 5 | 6 | 7 | class Static(TableModel[PidControlStaticSchema]): 8 | pass 9 | 10 | 11 | class Time(TableModel[PidControlTimeSchema]): 12 | pass 13 | -------------------------------------------------------------------------------- /python/ribasim/ribasim/delwaq/__init__.py: -------------------------------------------------------------------------------- 1 | from .generate import add_tracer, generate 2 | from .parse import parse 3 | from .plot import plot_fraction, plot_spatial 4 | from .util import run_delwaq 5 | 6 | __all__ = [ 7 | "add_tracer", 8 | "generate", 9 | "parse", 10 | "plot_fraction", 11 | "plot_spatial", 12 | "run_delwaq", 13 | ] 14 | -------------------------------------------------------------------------------- /models/hws.dvc: -------------------------------------------------------------------------------- 1 | md5: ac8f106c844227171975b9d0417f33a6 2 | frozen: true 3 | deps: 4 | - md5: a5de3ddf9da718b7d431dbce2b09deea.dir 5 | size: 42621406 6 | nfiles: 3 7 | hash: md5 8 | path: remote://minio_readonly/hws_2025_4_0 9 | outs: 10 | - md5: ba7c476e3d8b0759c73b838693b9e54e.dir 11 | size: 42621406 12 | nfiles: 3 13 | hash: md5 14 | path: hws 15 | -------------------------------------------------------------------------------- /models/benchmark.dvc: -------------------------------------------------------------------------------- 1 | md5: 2fac758278d29071bb3e4632d69cbb0c 2 | frozen: true 3 | deps: 4 | - md5: 8620613cb3fc380a20bcbbb2bd36b28c.dir 5 | size: 187932 6 | nfiles: 6 7 | hash: md5 8 | path: remote://minio_readonly/benchmark 9 | outs: 10 | - md5: ceb5e5e81c402510497da43d29dce099.dir 11 | size: 187932 12 | nfiles: 6 13 | hash: md5 14 | path: benchmark 15 | -------------------------------------------------------------------------------- /python/ribasim/ribasim/delwaq/template/B8_initials.inc.j2: -------------------------------------------------------------------------------- 1 | ; Generated by Ribasim 2 | ; Hard coded initials for mass balance check. 3 | MASS/M2 ; The bed substances are specified in mass/m2 4 | 1 ; Input in this file 5 | 1 ; Input without defaults 6 | {{ substances | length }}*1.0 ; Scale value 7 | 8 | ; "{{ substances | join('" "') }}" 9 | {{ initial_concentrations }} 10 | -------------------------------------------------------------------------------- /python/ribasim/ribasim/nodes/flow_demand.py: -------------------------------------------------------------------------------- 1 | from ribasim.input_base import TableModel 2 | from ribasim.schemas import ( 3 | FlowDemandStaticSchema, 4 | FlowDemandTimeSchema, 5 | ) 6 | 7 | __all__ = ["Static", "Time"] 8 | 9 | 10 | class Static(TableModel[FlowDemandStaticSchema]): 11 | pass 12 | 13 | 14 | class Time(TableModel[FlowDemandTimeSchema]): 15 | pass 16 | -------------------------------------------------------------------------------- /python/ribasim/ribasim/nodes/level_demand.py: -------------------------------------------------------------------------------- 1 | from ribasim.input_base import TableModel 2 | from ribasim.schemas import ( 3 | LevelDemandStaticSchema, 4 | LevelDemandTimeSchema, 5 | ) 6 | 7 | __all__ = ["Static", "Time"] 8 | 9 | 10 | class Static(TableModel[LevelDemandStaticSchema]): 11 | pass 12 | 13 | 14 | class Time(TableModel[LevelDemandTimeSchema]): 15 | pass 16 | -------------------------------------------------------------------------------- /models/hws_migration_test.dvc: -------------------------------------------------------------------------------- 1 | md5: bda2339cc165379b0f6da21aa67039d9 2 | frozen: true 3 | deps: 4 | - md5: ab64e9bee55f9a7524f36ca317ade8fb.dir 5 | size: 43307158 6 | nfiles: 2 7 | hash: md5 8 | path: remote://minio_readonly/hws_migration_test 9 | outs: 10 | - md5: 6ca6218661d0f021bc53bf468000a62b.dir 11 | size: 43307158 12 | nfiles: 2 13 | hash: md5 14 | path: hws_migration_test 15 | -------------------------------------------------------------------------------- /dvc.yaml: -------------------------------------------------------------------------------- 1 | stages: 2 | integration: 3 | cmd: pixi run model-integration-test 4 | deps: 5 | - core/ 6 | - models/hws 7 | params: 8 | - models/integration.toml: 9 | - solver.algorithm 10 | - solver.abstol 11 | - solver.reltol 12 | - solver.autodiff 13 | outs: 14 | - data/integration.toml 15 | metrics: 16 | - data/integration.toml 17 | -------------------------------------------------------------------------------- /.github/workflows/pre-commit_check.yml: -------------------------------------------------------------------------------- 1 | name: Pre-commit 2 | on: 3 | pull_request: 4 | merge_group: 5 | push: 6 | branches: [main] 7 | workflow_dispatch: 8 | jobs: 9 | check: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v6 13 | - uses: actions/setup-python@v6 14 | with: 15 | python-version: "3.12" 16 | - uses: pre-commit/action@v3.0.1 17 | -------------------------------------------------------------------------------- /.teamcity/Testbench/Testbench.kt: -------------------------------------------------------------------------------- 1 | package Testbench 2 | 3 | import Templates.* 4 | import Testbench.IntegrationTestHWS.IntegrationTestHWS 5 | import Testbench.RegressionTestODESolve.RegressionTestODESolve 6 | import jetbrains.buildServer.configs.kotlin.Project 7 | 8 | object Testbench : Project({ 9 | name = "Testbench" 10 | subProject(IntegrationTestHWS) 11 | subProject(RegressionTestODESolve) 12 | }) 13 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 2 | version: 2 3 | enable-beta-ecosystems: true 4 | updates: 5 | - package-ecosystem: "github-actions" 6 | directory: "/" 7 | schedule: 8 | interval: "weekly" 9 | - package-ecosystem: "julia" 10 | directories: 11 | - "/core" 12 | schedule: 13 | interval: "weekly" 14 | -------------------------------------------------------------------------------- /python/ribasim/ribasim/nodes/tabulated_rating_curve.py: -------------------------------------------------------------------------------- 1 | from ribasim.input_base import TableModel 2 | from ribasim.schemas import ( 3 | TabulatedRatingCurveStaticSchema, 4 | TabulatedRatingCurveTimeSchema, 5 | ) 6 | 7 | __all__ = ["Static", "Time"] 8 | 9 | 10 | class Static(TableModel[TabulatedRatingCurveStaticSchema]): 11 | pass 12 | 13 | 14 | class Time(TableModel[TabulatedRatingCurveTimeSchema]): 15 | pass 16 | -------------------------------------------------------------------------------- /python/ribasim/ribasim/nodes/continuous_control.py: -------------------------------------------------------------------------------- 1 | from ribasim.input_base import TableModel 2 | from ribasim.schemas import ( 3 | ContinuousControlFunctionSchema, 4 | ContinuousControlVariableSchema, 5 | ) 6 | 7 | __all__ = ["Function", "Variable"] 8 | 9 | 10 | class Variable(TableModel[ContinuousControlVariableSchema]): 11 | pass 12 | 13 | 14 | class Function(TableModel[ContinuousControlFunctionSchema]): 15 | pass 16 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | 3 | # SCM syntax highlighting & preventing 3-way merges 4 | pixi.lock merge=binary linguist-language=YAML linguist-generated=true 5 | 6 | *.lp linguist-generated=true 7 | Manifest.toml linguist-generated=true 8 | Ribasim.spdx.json merge=binary linguist-generated=true 9 | Ribasim-python.spdx.json merge=binary linguist-generated=true 10 | python/ribasim/ribasim/styles/*.qml merge=binary linguist-generated=true 11 | -------------------------------------------------------------------------------- /ribasim_qgis/tomllib/_types.py: -------------------------------------------------------------------------------- 1 | # Copied from https://github.com/python/cpython/blob/v3.12.0/Lib/tomllib/_types.py 2 | # QGIS does not guarantee a toml reader 3 | 4 | # SPDX-License-Identifier: MIT 5 | # SPDX-FileCopyrightText: 2021 Taneli Hukkinen 6 | # Licensed to PSF under a Contributor Agreement. 7 | 8 | from typing import Any, Callable, Tuple 9 | 10 | # Type annotations 11 | ParseFloat = Callable[[str], Any] 12 | Key = Tuple[str, ...] 13 | Pos = int 14 | -------------------------------------------------------------------------------- /.teamcity/Templates/WindowsAgent.kt: -------------------------------------------------------------------------------- 1 | package Templates 2 | 3 | import jetbrains.buildServer.configs.kotlin.* 4 | 5 | object WindowsAgent : Template({ 6 | id("Ribasim_Windows") 7 | name = "Ribasim_Windows" 8 | description = "Template for agent that uses Windows OS" 9 | 10 | params { 11 | param("env.JULIA_NUM_THREADS", "4") 12 | } 13 | 14 | requirements { 15 | contains("teamcity.agent.jvm.os.name", "Windows", "RQ_422") 16 | } 17 | }) 18 | -------------------------------------------------------------------------------- /docs/reference/node/terminal.qmd: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Terminal" 3 | --- 4 | 5 | A Terminal is a water sink without state or properties. 6 | Any water that flows into a Terminal node is removed from the model. 7 | No water can come into the model from a Terminal node. 8 | For example, Terminal nodes can be used as a downstream boundary. 9 | 10 | # Tables 11 | 12 | No tables are required for Terminal nodes. 13 | 14 | # Equations 15 | 16 | The incoming node determines the flow into the Terminal node. 17 | -------------------------------------------------------------------------------- /python/ribasim/ribasim/nodes/user_demand.py: -------------------------------------------------------------------------------- 1 | from ribasim.input_base import TableModel 2 | from ribasim.schemas import ( 3 | UserDemandConcentrationSchema, 4 | UserDemandStaticSchema, 5 | UserDemandTimeSchema, 6 | ) 7 | 8 | __all__ = ["Static", "Time"] 9 | 10 | 11 | class Static(TableModel[UserDemandStaticSchema]): 12 | pass 13 | 14 | 15 | class Time(TableModel[UserDemandTimeSchema]): 16 | pass 17 | 18 | 19 | class Concentration(TableModel[UserDemandConcentrationSchema]): 20 | pass 21 | -------------------------------------------------------------------------------- /ribasim_qgis/tomllib/__init__.py: -------------------------------------------------------------------------------- 1 | # Copied from https://github.com/python/cpython/blob/v3.12.0/Lib/tomllib/__init__.py 2 | # QGIS does not guarantee a toml reader 3 | 4 | # SPDX-License-Identifier: MIT 5 | # SPDX-FileCopyrightText: 2021 Taneli Hukkinen 6 | # Licensed to PSF under a Contributor Agreement. 7 | 8 | __all__ = ("loads", "load", "TOMLDecodeError") 9 | 10 | from ._parser import TOMLDecodeError, load, loads 11 | 12 | # Pretend this exception was created here. 13 | TOMLDecodeError.__module__ = __name__ 14 | -------------------------------------------------------------------------------- /.JuliaFormatter.toml: -------------------------------------------------------------------------------- 1 | # Options for the JuliaFormatter auto syntax formatting tool. 2 | # https://domluna.github.io/JuliaFormatter.jl/stable/ 3 | # https://docs.sciml.ai/SciMLStyle/stable/ 4 | 5 | # Based on the default style we do pick these non-default options from SciML style: 6 | whitespace_ops_in_indices = true 7 | remove_extra_newlines = true 8 | always_for_in = true 9 | whitespace_typedefs = true 10 | 11 | # And add other options we like: 12 | separate_kwargs_with_semicolon = true 13 | 14 | ignore = [".pixi"] 15 | -------------------------------------------------------------------------------- /.teamcity/Templates/LinuxAgent.kt: -------------------------------------------------------------------------------- 1 | package Templates 2 | 3 | import jetbrains.buildServer.configs.kotlin.* 4 | 5 | object LinuxAgent : Template({ 6 | id("Ribasim_Linux") 7 | name = "Ribasim_Linux" 8 | description = "Template for agent that uses Linux OS" 9 | 10 | publishArtifacts = PublishMode.SUCCESSFUL 11 | 12 | params { 13 | param("env.JULIA_NUM_THREADS", "8") 14 | } 15 | 16 | requirements { 17 | equals("teamcity.agent.jvm.os.name", "Linux", "RQ_418") 18 | } 19 | }) 20 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "charliermarsh.ruff", 4 | "julialang.language-julia", 5 | "ms-python.mypy-type-checker", 6 | "ms-python.python", 7 | "ms-toolsai.jupyter", 8 | "njpwerner.autodocstring", 9 | "quarto.quarto", 10 | "renan-r-santos.pixi-code", 11 | "samuelcolvin.jinjahtml", 12 | "streetsidesoftware.code-spell-checker", 13 | "tamasfe.even-better-toml", 14 | "yy0931.vscode-sqlite3-editor", 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /docs/dev/copilot.qmd: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Copilot instructions" 3 | --- 4 | 5 | The developers sometimes use GitHub Copilot to assist in their work. 6 | To make this more effective, we supply custom instructions for this repository, as documented [here](https://docs.github.com/en/copilot/how-tos/custom-instructions/adding-repository-custom-instructions-for-github-copilot). 7 | These instructions are automatically used by Copilot. 8 | 9 | # Current repository Copilot instructions 10 | 11 | {{< include ../../.github/copilot-instructions.md >}} 12 | -------------------------------------------------------------------------------- /python/ribasim/ribasim/nodes/level_boundary.py: -------------------------------------------------------------------------------- 1 | from ribasim.input_base import TableModel 2 | from ribasim.schemas import ( 3 | LevelBoundaryConcentrationSchema, 4 | LevelBoundaryStaticSchema, 5 | LevelBoundaryTimeSchema, 6 | ) 7 | 8 | __all__ = ["Concentration", "Static", "Time"] 9 | 10 | 11 | class Static(TableModel[LevelBoundaryStaticSchema]): 12 | pass 13 | 14 | 15 | class Time(TableModel[LevelBoundaryTimeSchema]): 16 | pass 17 | 18 | 19 | class Concentration(TableModel[LevelBoundaryConcentrationSchema]): 20 | pass 21 | -------------------------------------------------------------------------------- /python/ribasim/ribasim/nodes/discrete_control.py: -------------------------------------------------------------------------------- 1 | from ribasim.input_base import TableModel 2 | from ribasim.schemas import ( 3 | DiscreteControlConditionSchema, 4 | DiscreteControlLogicSchema, 5 | DiscreteControlVariableSchema, 6 | ) 7 | 8 | __all__ = ["Condition", "Logic", "Variable"] 9 | 10 | 11 | class Variable(TableModel[DiscreteControlVariableSchema]): 12 | pass 13 | 14 | 15 | class Condition(TableModel[DiscreteControlConditionSchema]): 16 | pass 17 | 18 | 19 | class Logic(TableModel[DiscreteControlLogicSchema]): 20 | pass 21 | -------------------------------------------------------------------------------- /core/test/runtests.jl: -------------------------------------------------------------------------------- 1 | using TestItemRunner 2 | 3 | include("utils.jl") 4 | 5 | function test_type(item)::Bool 6 | dir = basename(dirname(item.filename)) 7 | is_integration = dir == "integration_test" 8 | is_regression = dir == "regression_test" 9 | if in("integration", ARGS) 10 | is_integration 11 | elseif in("regression", ARGS) 12 | is_regression 13 | elseif in("skip", ARGS) 14 | false 15 | else 16 | !is_integration && !is_regression 17 | end 18 | end 19 | 20 | @run_package_tests filter = test_type 21 | -------------------------------------------------------------------------------- /python/ribasim/ribasim/styles/README.md: -------------------------------------------------------------------------------- 1 | # Ribasim QGIS Styles 2 | 3 | Ribasim Python always adds layer styles to the GeoPackage, such that the node types can be recognized also without the Ribasim QGIS plugin installed. 4 | 5 | One can update the Ribasim styles in QGIS itself, saving the style to the GeoPackage or disk, and retrieve it from there. 6 | See https://github.com/Deltares/Ribasim/issues/610#issuecomment-1729511312. 7 | 8 | Here we store the Node, Basin / area and Link styles, saved in the QML format, from the `styleQML` attribute in the `layer_styles` in the GeoPackage. 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: New issue template 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **What** 11 | A clear and concise description of what the problem is. Ex. A user wants to [...] 12 | 13 | **Why** 14 | A clear and concise description of why this feature is needed and its context. 15 | 16 | **How** 17 | A clear and concise description of how a technical implementation in code should look like. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.docker/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eux 3 | 4 | docker compose -f compose.yml up -d --force-recreate --remove-orphans 5 | echo "Installation of the plugin Ribasim" 6 | docker exec -t qgis sh -c "qgis_setup.sh ribasim_qgis" 7 | echo "Containers are running" 8 | 9 | docker exec -t qgis sh -c "python3 -m pip install pandas" 10 | docker exec -t qgis sh -c "cd /tests_directory && xvfb-run -a qgis_testrunner.sh ribasim_qgis.tests" 11 | exit_code=$? 12 | 13 | echo 'Stopping/killing containers' 14 | docker compose -f compose.yml kill 15 | docker compose -f compose.yml rm -f 16 | 17 | exit $exit_code 18 | -------------------------------------------------------------------------------- /docs/known_issues.qmd: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Known issues" 3 | --- 4 | 5 | Known issues can be found on the [GitHub issues page](https://github.com/Deltares/Ribasim/issues). 6 | Besides the issues that need to be fixed, there are also considerations that had to be made while developing the application. 7 | 8 | # QGIS plugin 9 | 10 | - Input tables that are in Arrow files are currently not automatically loaded in QGIS ([#318](https://github.com/Deltares/Ribasim/issues/318)). 11 | - Results in NetCDF format are currently not automatically loaded in QGIS ([#2519](https://github.com/Deltares/Ribasim/issues/2519)). 12 | -------------------------------------------------------------------------------- /ribasim_qgis/tests/__init__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from pathlib import Path 3 | 4 | import coverage 5 | from qgis.testing import unittest 6 | 7 | testfolder = Path(__file__).parent 8 | 9 | 10 | def run_all(): 11 | test_loader = unittest.defaultTestLoader 12 | test_suite = test_loader.discover(".", pattern="test_*.py") 13 | 14 | cov = coverage.Coverage(config_file=testfolder.parent / ".coveragerc") 15 | cov.start() 16 | unittest.TextTestRunner(verbosity=3, stream=sys.stdout).run(test_suite) 17 | 18 | cov.stop() 19 | cov.save() 20 | cov.xml_report(outfile=testfolder / "coverage.xml") 21 | -------------------------------------------------------------------------------- /.teamcity/Ribasim/vcsRoots/Ribasim.kt: -------------------------------------------------------------------------------- 1 | package Ribasim.vcsRoots 2 | 3 | import jetbrains.buildServer.configs.kotlin.vcs.GitVcsRoot 4 | 5 | object Ribasim : GitVcsRoot({ 6 | name = "Ribasim" 7 | url = "https://github.com/Deltares/Ribasim" 8 | branch = "main" 9 | branchSpec = """ 10 | +:refs/heads/main 11 | +:refs/tags/* 12 | +:refs/heads/gh-readonly-queue/* 13 | """.trimIndent() 14 | useTagsAsBranches = true 15 | authMethod = password { 16 | userName = "teamcity-deltares" 17 | password = "credentialsJSON:abf605ce-e382-4b10-b5de-8a7640dc58d9" 18 | } 19 | }) 20 | -------------------------------------------------------------------------------- /python/ribasim_api/README.md: -------------------------------------------------------------------------------- 1 | # Ribasim API 2 | 3 | `ribasim_api` is an extension to [xmipy](https://pypi.org/project/xmipy/) for the Ribasim API. 4 | 5 | The `ribasim_api` can be used to access functionality in the eXtended Model Interface (XMI) wrapper (XmiWrapper) 6 | and additional functionality specific to the Ribasim API. 7 | 8 | 9 | `ribasim_api` can be installed by running 10 | 11 | ``` 12 | pip install ribasim-api 13 | ``` 14 | 15 | or 16 | 17 | ``` 18 | conda install -c conda-forge ribasim-api 19 | ``` 20 | 21 | # Contributing 22 | 23 | For the developer docs please have a look at https://ribasim.org/dev/. 24 | -------------------------------------------------------------------------------- /python/ribasim/ribasim/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "2026.1.0-rc1" 2 | __schema_version__ = 10 3 | 4 | from pyogrio import set_gdal_config_options 5 | 6 | from ribasim.cli import run_ribasim 7 | from ribasim.config import Allocation, Logging, Node, Solver 8 | from ribasim.db_utils import fake_date 9 | from ribasim.geometry.link import LinkTable 10 | from ribasim.model import Model 11 | 12 | set_gdal_config_options( 13 | { 14 | "OGR_CURRENT_DATE": fake_date, # %Y-%m-%dT%H:%M:%fZ 15 | } 16 | ) 17 | 18 | __all__ = [ 19 | "Allocation", 20 | "LinkTable", 21 | "Logging", 22 | "Model", 23 | "Node", 24 | "Solver", 25 | "run_ribasim", 26 | ] 27 | -------------------------------------------------------------------------------- /python/ribasim/ribasim/nodes/flow_boundary.py: -------------------------------------------------------------------------------- 1 | from ribasim.geometry import FlowBoundaryAreaSchema 2 | from ribasim.input_base import SpatialTableModel, TableModel 3 | from ribasim.schemas import ( 4 | FlowBoundaryConcentrationSchema, 5 | FlowBoundaryStaticSchema, 6 | FlowBoundaryTimeSchema, 7 | ) 8 | 9 | __all__ = ["Concentration", "Static", "Time"] 10 | 11 | 12 | class Static(TableModel[FlowBoundaryStaticSchema]): 13 | pass 14 | 15 | 16 | class Time(TableModel[FlowBoundaryTimeSchema]): 17 | pass 18 | 19 | 20 | class Concentration(TableModel[FlowBoundaryConcentrationSchema]): 21 | pass 22 | 23 | 24 | class Area(SpatialTableModel[FlowBoundaryAreaSchema]): 25 | pass 26 | -------------------------------------------------------------------------------- /utils/migrate_model.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from pathlib import Path 3 | 4 | from ribasim import Model 5 | 6 | parser = argparse.ArgumentParser(description="Migrate Ribasim model.") 7 | parser.add_argument("input_toml", help="Path to input TOML file") 8 | parser.add_argument( 9 | "output_toml", 10 | nargs="?", 11 | help="Path to output TOML file (defaults to input_toml if not provided)", 12 | ) 13 | args = parser.parse_args() 14 | 15 | input_path = Path(args.input_toml) 16 | # If no output path is provided, use the input path (in-place modification) 17 | output_path = Path(args.output_toml) if args.output_toml else input_path 18 | 19 | model = Model.read(input_path) 20 | model.write(output_path) 21 | -------------------------------------------------------------------------------- /python/ribasim/ribasim/delwaq/template/delwaq.atr.j2: -------------------------------------------------------------------------------- 1 | ; Generated by Ribasim 2 | ; DELWAQ_COMPLETE_ATTRIBUTES 3 | 2 ; two blocks with input 4 | 1 ; number of attributes, they are : 5 | 1 ; '1' is active '0' is not 6 | 1 ; data follows in this file 7 | 1 ; all data is given without defaults 8 | ; layer: 1 9 | {{ nsegments }}*1 10 | 1 ; number of attributes, they are : 11 | 2 ; '1' has surface '3' has bottom 12 | ; '0' has both '2' has none 13 | 1 ; data follows in this file 14 | 1 ; all data is given without defaults 15 | ; layer: 1 16 | {{ nsegments }}*0 17 | 0 ; no time dependent attributes 18 | -------------------------------------------------------------------------------- /python/ribasim/ribasim/nodes/__init__.py: -------------------------------------------------------------------------------- 1 | from ribasim.nodes import ( 2 | basin, 3 | continuous_control, 4 | discrete_control, 5 | flow_boundary, 6 | level_boundary, 7 | level_demand, 8 | linear_resistance, 9 | manning_resistance, 10 | outlet, 11 | pid_control, 12 | pump, 13 | tabulated_rating_curve, 14 | user_demand, 15 | ) 16 | 17 | __all__ = [ 18 | "basin", 19 | "continuous_control", 20 | "discrete_control", 21 | "flow_boundary", 22 | "level_boundary", 23 | "level_demand", 24 | "linear_resistance", 25 | "manning_resistance", 26 | "outlet", 27 | "pid_control", 28 | "pump", 29 | "tabulated_rating_curve", 30 | "user_demand", 31 | ] 32 | -------------------------------------------------------------------------------- /dvc.lock: -------------------------------------------------------------------------------- 1 | schema: '2.0' 2 | stages: 3 | integration: 4 | cmd: pixi run model-integration-test 5 | deps: 6 | - path: core/ 7 | hash: md5 8 | md5: 6228f56acef895eeb5d4fdcd479cd1ee.dir 9 | size: 474129 10 | nfiles: 45 11 | - path: models/hws_2025_4_0 12 | hash: md5 13 | md5: e88123c69a0f7e3590b94c55dbe0062e.dir 14 | size: 45421022 15 | nfiles: 10 16 | params: 17 | models/integration.toml: 18 | solver.abstol: 1e-07 19 | solver.algorithm: QNDF 20 | solver.autodiff: true 21 | solver.reltol: 1e-07 22 | outs: 23 | - path: data/integration.toml 24 | hash: md5 25 | md5: aa6ff2820a6eda91df1073d3bc41755e 26 | size: 109 27 | -------------------------------------------------------------------------------- /ribasim_qgis/scripts/run_qgis_ui_tests.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | 3 | qgis_process = subprocess.run( 4 | [ 5 | "qgis", 6 | "--profiles-path", 7 | ".pixi/qgis_env", 8 | "--version-migration", 9 | "--nologo", 10 | "--code", 11 | "ribasim_qgis/scripts/qgis_testrunner.py", 12 | "ribasim_qgis.tests", 13 | ], 14 | stdout=subprocess.PIPE, 15 | stderr=subprocess.STDOUT, 16 | text=True, 17 | ) 18 | 19 | print(qgis_process.stdout) 20 | qgis_process.check_returncode() 21 | 22 | # QGIS always finishes with exit code 0, even when tests fail, so we have to check the output 23 | if any(s in qgis_process.stdout for s in ["QGIS died on signal", "FAILED"]): 24 | exit(1) 25 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | plugins = pydantic.mypy, numpy.typing.mypy_plugin, pandera.mypy 3 | ignore_missing_imports = True 4 | 5 | warn_unused_configs = True 6 | warn_redundant_casts = True 7 | warn_unused_ignores = True 8 | strict_equality = True 9 | extra_checks = True 10 | disallow_subclassing_any = True 11 | disallow_untyped_decorators = True 12 | disallow_any_generics = True 13 | mypy_path = .pixi/envs/$PIXI_ENVIRONMENT_NAME/Library/python,.pixi/envs/$PIXI_ENVIRONMENT_NAME/share/qgis/python 14 | 15 | # Ignore errors for imported packages. 16 | [mypy-console.*] 17 | ignore_errors = True 18 | 19 | [mypy-qgis.*] 20 | ignore_errors = True 21 | 22 | [mypy-plugins.*] 23 | ignore_errors = True 24 | 25 | [mypy-ribasim_qgis.tomllib.*] 26 | ignore_errors = True 27 | -------------------------------------------------------------------------------- /python/ribasim/tests/test_input_base.py: -------------------------------------------------------------------------------- 1 | from ribasim import geometry, nodes 2 | from ribasim.input_base import TableModel 3 | from ribasim.schemas import BasinSubgridSchema 4 | 5 | 6 | def test_tablemodel_schema(): 7 | schema = TableModel[BasinSubgridSchema].tableschema() 8 | assert schema == BasinSubgridSchema 9 | 10 | 11 | def test_tablename(): 12 | cls = nodes.tabulated_rating_curve.Static 13 | assert cls.tablename() == "TabulatedRatingCurve / static" 14 | 15 | cls = nodes.basin.ConcentrationExternal 16 | assert cls.tablename() == "Basin / concentration_external" 17 | 18 | cls = geometry.NodeTable 19 | assert cls.tablename() == "Node" 20 | 21 | cls = geometry.LinkTable 22 | assert cls.tablename() == "Link" 23 | -------------------------------------------------------------------------------- /.github/workflows/qgis.yml: -------------------------------------------------------------------------------- 1 | name: QGIS Tests 2 | on: 3 | push: 4 | branches: [main] 5 | paths: ["ribasim_qgis/**", "pixi.toml", "pixi.lock"] 6 | tags: ["*"] 7 | pull_request: 8 | paths: ["ribasim_qgis/**", "pixi.toml", "pixi.lock"] 9 | merge_group: 10 | workflow_dispatch: 11 | jobs: 12 | test-qgis: 13 | name: "Test" 14 | runs-on: ubuntu-latest 15 | defaults: 16 | run: 17 | working-directory: .docker 18 | steps: 19 | - uses: actions/checkout@v6 20 | - uses: prefix-dev/setup-pixi@v0.9.3 21 | with: 22 | pixi-version: "latest" 23 | - name: Run tests 24 | run: pixi run test-ribasim-qgis-docker 25 | - name: Upload coverage to Codecov 26 | uses: codecov/codecov-action@v5 27 | with: 28 | token: ${{ secrets.CODECOV_TOKEN }} 29 | -------------------------------------------------------------------------------- /python/ribasim/ribasim/delwaq/reference/dimr_config.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 1.1 5 | Deltares, Coupling team 6 | 2016-01-20T07:56:32+01:00 7 | 8 | 9 | 10 | 11 | 12 | 13 | delwaq 14 | . 15 | 16 | delwaq.inp 17 | 18 | 19 | -------------------------------------------------------------------------------- /docs/reference/test-models.qmd: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Test models" 3 | --- 4 | 5 | Ribasim developers use the following models in their testbench and in order to test new features. 6 | 7 | ```{python} 8 | # | output: asis 9 | # | code-fold: true 10 | import ribasim_testmodels 11 | import matplotlib.pyplot as plt 12 | from IPython.display import Markdown, display 13 | 14 | for model_name, model_constructor in ribasim_testmodels.constructors.items(): 15 | if model_name.startswith("invalid"): 16 | continue 17 | 18 | display(Markdown(f"\n# {model_name}\n")) 19 | if model_constructor.__doc__ is not None: 20 | display(Markdown(model_constructor.__doc__)) 21 | 22 | model = model_constructor() 23 | fig, ax = plt.subplots(figsize=(6, 4)) 24 | model.plot(ax) 25 | ax.axis("off") 26 | plt.show() 27 | plt.close(fig) 28 | ``` 29 | -------------------------------------------------------------------------------- /.sonarcloud.properties: -------------------------------------------------------------------------------- 1 | # Path to sources 2 | sonar.sources=python/ribasim/ribasim,python/ribasim_api/ribasim_api,python/ribasim_testmodels/ribasim_testmodels,ribasim_qgis/core,ribasim_qgis/scripts,ribasim_qgis/ribasim_qgis.py,docs 3 | # sonar.exclusions= 4 | # sonar.inclusions= 5 | 6 | # Path to tests 7 | sonar.tests=python/ribasim/tests,python/ribasim_api/tests,ribasim_qgis/tests 8 | # sonar.test.exclusions= 9 | # sonar.test.inclusions= 10 | 11 | # Source encoding 12 | # sonar.sourceEncoding= 13 | 14 | # Exclusions for copy-paste detection 15 | # sonar.cpd.exclusions= 16 | 17 | # Python version (for python projects only) 18 | sonar.python.version=3.12,3.13 19 | 20 | # C++ standard version (for C++ projects only) 21 | # If not specified, it defaults to the latest supported standard 22 | # sonar.cfamily.reportingCppStandardOverride=c++98|c++11|c++14|c++17|c++20 23 | -------------------------------------------------------------------------------- /ribasim_qgis/scripts/enable_plugin.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import sys 3 | from pathlib import Path 4 | 5 | 6 | def enable_plugin(plugin_name: str, profile_path: str) -> None: 7 | config_file = Path(profile_path) / "QGIS/QGIS3.ini" 8 | config_file.parent.mkdir(parents=True, exist_ok=True) 9 | config_file.touch() 10 | 11 | config = configparser.ConfigParser() 12 | config.read(config_file) 13 | 14 | plugins_section = "PythonPlugins" 15 | if not config.has_section(plugins_section): 16 | config.add_section(plugins_section) 17 | 18 | config[plugins_section][plugin_name] = "true" 19 | 20 | with config_file.open("w") as f: 21 | config.write(f) 22 | 23 | 24 | if __name__ == "__main__": 25 | print(f"Enabling QGIS plugin {sys.argv[1]} in profile {sys.argv[2]}") 26 | enable_plugin(sys.argv[1], sys.argv[2]) 27 | -------------------------------------------------------------------------------- /python/ribasim_api/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "ribasim_api" 7 | description = "Python bindings for libribasim" 8 | readme = "README.md" 9 | authors = [{ name = "Deltares and contributors", email = "ribasim.info@deltares.nl" }] 10 | license = { text = "MIT" } 11 | classifiers = [ 12 | "Intended Audience :: Science/Research", 13 | "Topic :: Scientific/Engineering :: Hydrology", 14 | ] 15 | requires-python = ">=3.12" 16 | dependencies = [ 17 | "xmipy >=1.3" 18 | ] 19 | dynamic = ["version"] 20 | 21 | [project.optional-dependencies] 22 | tests = ["pytest", "ribasim", "ribasim_testmodels"] 23 | 24 | [project.urls] 25 | Documentation = "https://ribasim.org/" 26 | Source = "https://github.com/Deltares/Ribasim" 27 | 28 | [tool.hatch.version] 29 | path = "ribasim_api/__init__.py" 30 | -------------------------------------------------------------------------------- /python/ribasim/README.md: -------------------------------------------------------------------------------- 1 | # Ribasim Python 2 | 3 | The Ribasim Python package (named `ribasim`) aims to make it easy to build, update and analyze Ribasim models 4 | programmatically. 5 | 6 | The Ribasim QGIS plugin allows users to construct a model from scratch without programming. 7 | For specific tasks, like adding observed rainfall timeseries, it can be faster to use 8 | Python instead. 9 | 10 | One can also use Ribasim Python to build entire models from base data, such that your model 11 | setup is fully reproducible. 12 | 13 | The package is [registered in PyPI](https://pypi.org/project/ribasim/) and can therefore 14 | be installed with `pip install ribasim`. 15 | 16 | For documentation please see the https://ribasim.org/ and [API reference](https://ribasim.org/reference/python/) 17 | 18 | # Contributing 19 | 20 | For the developer docs please have a look at https://ribasim.org/dev/. 21 | -------------------------------------------------------------------------------- /python/ribasim_testmodels/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "ribasim_testmodels" 7 | description = "Ribasim Testmodels" 8 | readme = "README.md" 9 | authors = [ 10 | { name = "Deltares and contributors", email = "ribasim.info@deltares.nl" }, 11 | ] 12 | license = { text = "MIT" } 13 | classifiers = [ 14 | "Intended Audience :: Science/Research", 15 | "Topic :: Scientific/Engineering :: Hydrology", 16 | ] 17 | requires-python = ">=3.12" 18 | dependencies = ["geopandas >=1.0", "numpy >=2.0", "pandas >=2.2", "ribasim"] 19 | dynamic = ["version"] 20 | 21 | [project.optional-dependencies] 22 | tests = ["pytest"] 23 | 24 | [project.urls] 25 | Documentation = "https://ribasim.org/" 26 | Source = "https://github.com/Deltares/Ribasim" 27 | 28 | [tool.hatch.version] 29 | path = "ribasim_testmodels/__init__.py" 30 | -------------------------------------------------------------------------------- /python/ribasim/ribasim/geometry/base.py: -------------------------------------------------------------------------------- 1 | from typing import get_type_hints 2 | 3 | import pandera as pa 4 | from geopandas import GeoSeries as _GeoSeries 5 | from pandera.typing import Series 6 | from pandera.typing.geopandas import GeoSeries 7 | 8 | from ribasim.schemas import _BaseSchema 9 | 10 | 11 | class _GeoBaseSchema(_BaseSchema): 12 | @pa.check("geometry") 13 | def is_correct_geometry_type(cls, geoseries: GeoSeries[object]) -> Series[bool]: 14 | T = get_type_hints(cls)["geometry"].__args__[0] 15 | return geoseries.map(lambda geom: isinstance(geom, T)) 16 | 17 | @pa.parser("geometry") 18 | def force_2d(cls, geometry: GeoSeries[object]) -> GeoSeries[object]: 19 | if isinstance(geometry, _GeoSeries): 20 | # Pandera requires GeoSeries to have a name 21 | return GeoSeries(geometry.force_2d(), name=geometry.name) 22 | else: 23 | return geometry 24 | -------------------------------------------------------------------------------- /.github/workflows/python_codegen.yml: -------------------------------------------------------------------------------- 1 | name: Python Codegen 2 | on: 3 | push: 4 | branches: [main] 5 | paths: ["core/**", "python/**", "pixi.toml", "pixi.lock"] 6 | tags: ["*"] 7 | pull_request: 8 | paths: ["core/**", "python/**", "pixi.toml", "pixi.lock"] 9 | merge_group: 10 | workflow_dispatch: 11 | concurrency: 12 | group: ${{ github.workflow }}-${{ github.ref }} 13 | cancel-in-progress: true 14 | jobs: 15 | codegen: 16 | name: Codegen 17 | runs-on: ubuntu-latest 18 | continue-on-error: true 19 | steps: 20 | - uses: actions/checkout@v6 21 | - uses: prefix-dev/setup-pixi@v0.9.3 22 | with: 23 | pixi-version: "latest" 24 | - name: Prepare pixi 25 | run: pixi run install-ci 26 | - name: Test if codegen runs without errors 27 | run: pixi run codegen 28 | - name: Ensure that no code has been generated 29 | run: git diff --exit-code 30 | -------------------------------------------------------------------------------- /.github/workflows/pre-commit_auto_update.yml: -------------------------------------------------------------------------------- 1 | name: Pre-commit auto update 2 | on: 3 | schedule: 4 | # At 03:00 on day 3 of the month 5 | - cron: "0 3 3 * *" 6 | # on demand 7 | workflow_dispatch: 8 | jobs: 9 | auto-update: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v6 13 | - uses: prefix-dev/setup-pixi@v0.9.3 14 | with: 15 | pixi-version: "latest" 16 | - name: Update pre-commit hooks 17 | run: | 18 | pixi run pre-commit-autoupdate 19 | - name: Run pre-commit on all files 20 | run: | 21 | pixi run pre-commit 22 | continue-on-error: true 23 | - uses: peter-evans/create-pull-request@v8 24 | with: 25 | token: ${{ secrets.CI_PR_PAT }} 26 | branch: update/pre-commit 27 | title: Update pre-commit hooks 28 | commit-message: "Update pre-commit hooks" 29 | author: "GitHub " 30 | -------------------------------------------------------------------------------- /ribasim_qgis/scripts/install_ribasim_qgis.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from pathlib import Path 3 | 4 | from enable_plugin import enable_plugin 5 | 6 | if __name__ == "__main__": 7 | target_path = Path("ribasim_qgis").absolute() 8 | plugins_path = Path(sys.argv[1]) / "python/plugins" 9 | source_path = plugins_path / "ribasim_qgis" 10 | 11 | styles_source_path = target_path / "core" / "styles" 12 | styles_target_path = Path("python/ribasim/ribasim/styles").absolute() 13 | 14 | # Symlink ribasim_qgis styles to ribasim styles 15 | styles_source_path.unlink(missing_ok=True) 16 | styles_source_path.symlink_to(styles_target_path, target_is_directory=True) 17 | 18 | # Symlink qgis_env to ribasim_qgis, and hence qgis_env styles to ribasim styles 19 | plugins_path.mkdir(parents=True, exist_ok=True) 20 | source_path.unlink(missing_ok=True) 21 | source_path.symlink_to(target_path, target_is_directory=True) 22 | enable_plugin("ribasim_qgis", sys.argv[1]) 23 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "julia", 6 | "request": "launch", 7 | "name": "debug Ribasim", 8 | "program": "debug/debug.jl", 9 | "stopOnEntry": false, 10 | "cwd": "${workspaceFolder}", 11 | "juliaEnv": "${command:activeJuliaEnvironment}" 12 | }, 13 | { 14 | "type": "julia", 15 | "request": "launch", 16 | "name": "Julia: current file", 17 | "program": "${file}", 18 | "stopOnEntry": false, 19 | "cwd": "${workspaceFolder}", 20 | "juliaEnv": "${command:activeJuliaEnvironment}" 21 | }, 22 | { 23 | "name": "Ribasim QGIS: Attach to QGIS", 24 | "type": "python", 25 | "request": "attach", 26 | "host": "localhost", 27 | "port": 5678, 28 | "justMyCode": false 29 | } 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /docs/assets/c4_system.mmd: -------------------------------------------------------------------------------- 1 | flowchart TB 2 | modeler([Modeler]):::user 3 | 4 | api["Ribasim Python\n[python]"]:::system 5 | modeler-->|prepare model|api 6 | 7 | ribasim["Ribasim\n[julia]"]:::system 8 | modeler-->|start|ribasim 9 | 10 | subgraph qgisBoundary[QGIS] 11 | QGIS[QGIS Application]:::system_ext 12 | qgisPlugin["Ribasim QGIS plugin\n[python]"]:::system 13 | QGIS-->qgisPlugin 14 | end 15 | modeler-->|prepare model|qgisBoundary 16 | 17 | model[("input model data\n[toml + geopackage + arrow]")]:::system 18 | qgisPlugin-->|read/write|model 19 | api-->|read/write|model 20 | ribasim-->|simulate|model 21 | 22 | output[("simulation results\n[arrow]")]:::system 23 | ribasim-->|write|output 24 | 25 | class qgisBoundary boundary 26 | 27 | %% class definitions for C4 model 28 | classDef default stroke-width:1px,stroke:white,color:white 29 | classDef system fill:#1168bd 30 | classDef user fill:#08427b 31 | classDef system_ext fill:#999999 32 | classDef boundary fill:transparent,stroke-dasharray:5 5,stroke:black,color:black 33 | -------------------------------------------------------------------------------- /ribasim_qgis/tests/ui/test_load_plugin.py: -------------------------------------------------------------------------------- 1 | from qgis.testing import unittest 2 | from qgis.utils import iface, plugins 3 | 4 | 5 | class TestPlugin(unittest.TestCase): 6 | def test_plugin_is_loaded(self): 7 | """Test plugin is properly loaded and appears in QGIS plugins.""" 8 | plugin = plugins.get("ribasim_qgis") 9 | self.assertTrue(plugin, "Ribasim plugin not loaded") 10 | 11 | def test_plugin(self): 12 | """Triggers Ribasim button and checks that Dock is added.""" 13 | # This checks the *actual* QGIS interface, not just a stub 14 | self.assertTrue(iface is not None, "QGIS interface not available") 15 | 16 | toolbars = [ 17 | c for c in iface.mainWindow().children() if c.objectName() == "Ribasim" 18 | ] 19 | self.assertTrue(len(toolbars) == 2, "No Ribasim toolbar and menu") 20 | actions = toolbars[0].actions() 21 | self.assertTrue( 22 | len(actions) == 1, "No (single) Ribasim action button in toolbar" 23 | ) 24 | -------------------------------------------------------------------------------- /utils/generate-sbom.jl: -------------------------------------------------------------------------------- 1 | using PkgToSoftwareBOM 2 | using UUIDs 3 | using Pkg 4 | 5 | Pkg.activate("core") 6 | 7 | ribasimLicense = SpdxLicenseExpressionV2("MIT") 8 | organization = SpdxCreatorV2("Organization", "Deltares", "software@deltares.nl") 9 | packageInstructions = spdxPackageInstructions(; 10 | spdxfile_toexclude = ["Ribasim.spdx.json"], 11 | originator = organization, 12 | declaredLicense = ribasimLicense, 13 | copyright = "Copyright (c) 2025 Deltares ", 14 | name = "Ribasim", 15 | ) 16 | 17 | dependencies = Pkg.project().dependencies; 18 | spdxData = spdxCreationData(; 19 | Name = "Ribasim.jl", 20 | Creators = [organization], 21 | NamespaceURL = "https://github.com/Deltares/Ribasim/Ribasim.spdx.json", 22 | rootpackages = dependencies, 23 | find_artifactsource = true, 24 | packageInstructions = Dict{UUID, spdxPackageInstructions}( 25 | Pkg.project().uuid => packageInstructions, 26 | ), 27 | ) 28 | 29 | sbom = generateSPDX(spdxData) 30 | writespdx(sbom, "Ribasim.spdx.json") 31 | -------------------------------------------------------------------------------- /docs/run_ribasim.py: -------------------------------------------------------------------------------- 1 | """ 2 | Julia interface setup for Ribasim simulations. 3 | 4 | In QMD files using the jupyter engine, this script can be included using 5 | `%run path/to/run_ribasim.py`. This will start a Julia session, 6 | import Ribasim, and define the `run_ribasim` function. 7 | 8 | In a file where multiple simulations are run this has the benefit 9 | of only needing to start Julia once, compared to `subprocess.run`. 10 | 11 | Since Ribasim Python also has `run_ribasim`, we can include 12 | this script in a hidden evaluated cell, and put `from ribasim import run_ribasim` 13 | in a visible unevaluated cell. That way we can run this version on CI but 14 | it looks like we run the Ribasim Python version. 15 | """ 16 | 17 | from pathlib import Path 18 | 19 | from juliacall import Main as jl 20 | 21 | jl.seval("import Ribasim") 22 | 23 | 24 | def run_ribasim(toml_path: str | Path) -> None: 25 | """Run a Ribasim simulation via juliacall.""" 26 | retcode = jl.Ribasim.main(str(toml_path)) 27 | assert retcode == 0, f"Simulation failed: {toml_path}" 28 | -------------------------------------------------------------------------------- /ribasim_qgis/core/geopackage.py: -------------------------------------------------------------------------------- 1 | """ 2 | Geopackage management utilities. 3 | 4 | This module lightly wraps a QGIS built in functions to: 5 | 6 | * List the layers of a geopackage 7 | 8 | """ 9 | 10 | import sqlite3 11 | from contextlib import contextmanager 12 | from pathlib import Path 13 | 14 | 15 | @contextmanager 16 | def sqlite3_cursor(path: Path): 17 | connection = sqlite3.connect(path) 18 | cursor = connection.cursor() 19 | try: 20 | yield cursor 21 | finally: 22 | cursor.close() 23 | connection.commit() 24 | connection.close() 25 | 26 | 27 | def layers(path: Path) -> list[str]: 28 | """ 29 | Return all layers that are present in the geopackage. 30 | 31 | Parameters 32 | ---------- 33 | path: Path 34 | Path to the geopackage 35 | 36 | Returns 37 | ------- 38 | layernames: list[str] 39 | """ 40 | with sqlite3_cursor(path) as cursor: 41 | cursor.execute("Select table_name from gpkg_contents") 42 | layers = [item[0] for item in cursor.fetchall()] 43 | return layers 44 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | exclude: 'core/test/data/allocation_problems' 2 | repos: 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v6.0.0 5 | hooks: 6 | - id: check-added-large-files 7 | - id: check-case-conflict 8 | - id: check-yaml 9 | - id: check-toml 10 | - id: check-merge-conflict 11 | - id: check-vcs-permalinks 12 | - id: end-of-file-fixer 13 | exclude: '.teamcity' 14 | - id: trailing-whitespace 15 | - repo: https://github.com/astral-sh/ruff-pre-commit 16 | rev: v0.14.7 17 | hooks: 18 | - id: ruff-check 19 | types_or: [python, pyi, jupyter] 20 | args: [--fix, --exit-non-zero-on-fix] 21 | - id: ruff-format 22 | types_or: [python, pyi, jupyter] 23 | - repo: https://github.com/google/yamlfmt 24 | rev: v0.20.0 25 | hooks: 26 | - id: yamlfmt 27 | - repo: https://github.com/kynan/nbstripout 28 | rev: 0.8.2 29 | hooks: 30 | - id: nbstripout 31 | - repo: https://github.com/adhtruong/mirrors-typos 32 | rev: v1.40.0 33 | hooks: 34 | - id: typos 35 | -------------------------------------------------------------------------------- /.github/workflows/mypy.yml: -------------------------------------------------------------------------------- 1 | name: Mypy Type Check 2 | on: 3 | push: 4 | branches: [main] 5 | paths: ["ribasim_qgis/**", "python/**", "pixi.toml", "pixi.lock"] 6 | tags: ["*"] 7 | pull_request: 8 | paths: ["ribasim_qgis/**", "python/**", "pixi.toml", "pixi.lock"] 9 | merge_group: 10 | workflow_dispatch: 11 | concurrency: 12 | group: ${{ github.workflow }}-${{ github.ref }} 13 | cancel-in-progress: true 14 | jobs: 15 | mypy: 16 | name: Mypy 17 | runs-on: ubuntu-latest 18 | continue-on-error: true 19 | steps: 20 | - uses: actions/checkout@v6 21 | - uses: prefix-dev/setup-pixi@v0.9.3 22 | with: 23 | pixi-version: "latest" 24 | - name: Run mypy on python/ribasim 25 | run: | 26 | pixi run mypy-ribasim-python 27 | - name: Run mypy on python/ribasim_testmodels 28 | run: | 29 | pixi run mypy-ribasim-testmodels 30 | - name: Run mypy on python/ribasim_api 31 | run: | 32 | pixi run mypy-ribasim-api 33 | - name: Run mypy on ribasim_qgis 34 | run: | 35 | pixi run mypy-ribasim-qgis 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Ribasim developers 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/julia_auto_update.yml: -------------------------------------------------------------------------------- 1 | name: Julia auto update 2 | on: 3 | schedule: 4 | # At 03:00 on day 2 of the month 5 | - cron: "0 3 2 * *" 6 | # on demand 7 | workflow_dispatch: 8 | jobs: 9 | auto-update: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v6 13 | with: 14 | ssh-key: ${{ secrets.SSH_PRIVATE_KEY }} 15 | - uses: prefix-dev/setup-pixi@v0.9.3 16 | with: 17 | pixi-version: "latest" 18 | - name: Update Julia manifest file 19 | run: | 20 | pixi run install-julia 21 | pixi run update-manifest-julia 22 | pixi update pyjuliacall 23 | pixi run generate-sbom-ribasim-core 24 | env: 25 | JULIA_PKG_PRECOMPILE_AUTO: "0" 26 | - uses: peter-evans/create-pull-request@v8 27 | with: 28 | token: ${{ secrets.CI_PR_PAT }} 29 | branch: update/julia-manifest 30 | title: Update Julia manifest 31 | commit-message: "Update Julia manifest" 32 | body-path: .pixi/update-manifest-julia.md 33 | author: "GitHub " 34 | -------------------------------------------------------------------------------- /core/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Ribasim developers 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /python/ribasim/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Ribasim developers 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /ribasim_qgis/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Ribasim developers 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /python/ribasim_api/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Ribasim developers 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /python/ribasim_testmodels/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Ribasim developers 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/pixi_auto_update.yml: -------------------------------------------------------------------------------- 1 | name: Update Pixi lockfile 2 | permissions: 3 | contents: write 4 | pull-requests: write 5 | on: 6 | schedule: 7 | # At 03:00 on day 3 of the month 8 | - cron: "0 3 3 * *" 9 | # on demand 10 | workflow_dispatch: 11 | jobs: 12 | pixi-update: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v6 16 | - name: Set up pixi 17 | uses: prefix-dev/setup-pixi@v0.9.3 18 | with: 19 | pixi-version: "latest" 20 | run-install: false 21 | - name: Update lockfiles 22 | run: | 23 | set -o pipefail 24 | pixi update --json | pixi exec pixi-diff-to-markdown >> diff.md 25 | pixi run generate-sbom-ribasim-python 26 | - name: Create pull request 27 | uses: peter-evans/create-pull-request@v8 28 | with: 29 | token: ${{ secrets.CI_PR_PAT }} 30 | commit-message: Update pixi lockfile 31 | title: Update pixi lockfile 32 | body-path: diff.md 33 | branch: update/pixi-lock 34 | base: main 35 | delete-branch: true 36 | add-paths: pixi.lock, Ribasim-python.spdx.json 37 | -------------------------------------------------------------------------------- /python/ribasim/ribasim/delwaq/template/B5_bounddata.inc.j2: -------------------------------------------------------------------------------- 1 | ; Generated by Ribasim 2 | ITEM 'LevelBoundary' 3 | CONCENTRATIONS 'Continuity' 'LevelBoundary' 4 | DATA 1 1 5 | 6 | ITEM 'FlowBoundary' 7 | CONCENTRATIONS 'Continuity' 'FlowBoundary' 8 | DATA 1 1 9 | 10 | ITEM 'UserDemand' 11 | CONCENTRATIONS 'Continuity' 'UserDemand' 12 | DATA 1 1 13 | 14 | ITEM 'Drainage' 15 | CONCENTRATIONS 'Continuity' 'Drainage' 16 | DATA 1 1 17 | 18 | ITEM 'Precipitation' 19 | CONCENTRATIONS 'Continuity' 'Precipitation' 20 | DATA 1 1 21 | 22 | ITEM 'SurfaceRunoff' 23 | CONCENTRATIONS 'Continuity' 'SurfaceRunoff' 24 | DATA 1 1 25 | 26 | ITEM 'Infiltration' 27 | CONCENTRATIONS 'Continuity' 28 | DATA 1 29 | 30 | ITEM 'Terminal' 31 | CONCENTRATIONS 'Continuity' 32 | DATA 1 33 | 34 | 35 | {% for boundary in boundaries -%} 36 | ITEM '{{ boundary.name }}' 37 | CONCENTRATIONS {{ boundary.substances | join(" ") | safe }} 38 | ABSOLUTE TIME 39 | LINEAR DATA {{ boundary.substances | join(" ") | safe }} 40 | {{ boundary.df | safe }} 41 | 42 | {% endfor -%} 43 | {% for state in states -%} 44 | ITEM '{{ state.name }}' 45 | CONCENTRATIONS {{ state.substances | join(" ")}} 46 | DATA {{ state.concentrations | join(" ")}} 47 | 48 | {% endfor -%} 49 | -------------------------------------------------------------------------------- /.github/workflows/python_tests.yml: -------------------------------------------------------------------------------- 1 | name: Ribasim Python Tests 2 | on: 3 | push: 4 | branches: [main] 5 | paths: ["python/**", "pixi.toml", "pixi.lock"] 6 | tags: ["*"] 7 | pull_request: 8 | paths: ["python/**", "pixi.toml", "pixi.lock"] 9 | merge_group: 10 | workflow_dispatch: 11 | concurrency: 12 | group: ${{ github.workflow }}-${{ github.ref }} 13 | cancel-in-progress: true 14 | jobs: 15 | test: 16 | name: ${{ matrix.pixi-environment }} - ${{ matrix.os }} 17 | runs-on: ${{ matrix.os }} 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | os: 22 | - ubuntu-latest 23 | - macOS-latest 24 | - windows-latest 25 | pixi-environment: 26 | - default 27 | - py312 28 | steps: 29 | - uses: actions/checkout@v6 30 | - uses: prefix-dev/setup-pixi@v0.9.3 31 | with: 32 | pixi-version: "latest" 33 | - name: Test Ribasim Python 34 | run: pixi run --environment ${{ matrix.pixi-environment }} test-ribasim-python-cov 35 | - name: Upload coverage to Codecov 36 | uses: codecov/codecov-action@v5 37 | with: 38 | token: ${{ secrets.CODECOV_TOKEN }} 39 | -------------------------------------------------------------------------------- /docs/guide/coupling.qmd: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Coupling" 3 | --- 4 | 5 | # iMOD 6 | 7 | Ribasim can also be (online) coupled to other kernels with the help of iMOD Coupler. The corresponding documentation can be found within the [iMOD Suite Documentation](https://deltares.github.io/iMOD-Documentation/coupler.html). 8 | 9 | # Water quality {#sec-waterquality} 10 | 11 | Ribasim can be offline coupled to Delwaq, the Deltares Water Quality model. 12 | 13 | ```{mermaid} 14 | flowchart LR 15 | Ribasim --> Delwaq 16 | ``` 17 | 18 | ## Setup 19 | 20 | Delwaq can calculate the concentration of substances in [Basin](/reference/node/basin.qmd) nodes over time, based on initial concentrations, and of [FlowBoundary](/reference/node/flow-boundary.qmd) nodes. Ribasim exposes the `Basin / concentration`, `Basin / concentration_state`, `FlowBoundary / concentration`, and `LevelBoundary / concentration` tables to setup these substances and concentrations. 21 | 22 | When a Ribasim model ran with the above tables, one can use the utilities in the `delwaq` namespace of the Ribasim Python API to generate the input required for Delwaq to run, as well as to parse the output from Delwaq into a Ribasim compatible format. For more information see the [guide](/guide/delwaq.qmd). 23 | -------------------------------------------------------------------------------- /python/ribasim_api/ribasim_api/ribasim_api.py: -------------------------------------------------------------------------------- 1 | # %% 2 | from ctypes import byref, c_int, create_string_buffer 3 | 4 | from xmipy import XmiWrapper 5 | 6 | 7 | class RibasimApi(XmiWrapper): 8 | def get_constant_int(self, name: str) -> int: 9 | match name: 10 | case "BMI_LENVARTYPE": 11 | return 51 12 | case "BMI_LENGRIDTYPE": 13 | return 17 14 | case "BMI_LENVARADDRESS": 15 | return 68 16 | case "BMI_LENCOMPONENTNAME": 17 | return 256 18 | case "BMI_LENVERSION": 19 | return 256 20 | case "BMI_LENERRMESSAGE": 21 | return 1025 22 | raise ValueError(f"{name} does not map to an integer exposed by Ribasim") 23 | 24 | def init_julia(self) -> None: 25 | argument = create_string_buffer(0) 26 | self.lib.init_julia(c_int(0), byref(argument)) 27 | 28 | def shutdown_julia(self) -> None: 29 | self.lib.shutdown_julia(c_int(0)) 30 | 31 | def update_subgrid_level(self) -> None: 32 | self.lib.update_subgrid_level() 33 | 34 | def execute(self, config_file: str) -> None: 35 | self._execute_function(self.lib.execute, config_file.encode()) 36 | -------------------------------------------------------------------------------- /python/ribasim/ribasim/geometry/area.py: -------------------------------------------------------------------------------- 1 | import pandera as pa 2 | from pandera.dtypes import Int32 3 | from pandera.typing import Index, Series 4 | from pandera.typing.geopandas import GeoSeries 5 | from shapely.geometry import MultiPolygon, Polygon 6 | 7 | from .base import _GeoBaseSchema 8 | 9 | 10 | class BasinAreaSchema(_GeoBaseSchema): 11 | fid: Index[Int32] = pa.Field(default=0, check_name=True) 12 | node_id: Series[Int32] = pa.Field(nullable=False, default=0) 13 | geometry: GeoSeries[MultiPolygon] = pa.Field(default=None, nullable=True) 14 | 15 | @pa.parser("geometry") 16 | def convert_to_multi(cls, series): 17 | return series.apply( 18 | lambda geom: MultiPolygon([geom]) if isinstance(geom, Polygon) else geom 19 | ) 20 | 21 | 22 | class FlowBoundaryAreaSchema(_GeoBaseSchema): 23 | fid: Index[Int32] = pa.Field(default=0, check_name=True) 24 | node_id: Series[Int32] = pa.Field(nullable=False, default=0) 25 | geometry: GeoSeries[MultiPolygon] = pa.Field(default=None, nullable=True) 26 | 27 | @pa.parser("geometry") 28 | def convert_to_multi(cls, series): 29 | return series.apply( 30 | lambda geom: MultiPolygon([geom]) if isinstance(geom, Polygon) else geom 31 | ) 32 | -------------------------------------------------------------------------------- /docs/index.qmd: -------------------------------------------------------------------------------- 1 | --- 2 | title: Ribasim 3 | --- 4 | 5 | Ribasim is a water resources model to simulate the physical behavior of a managed open water system based on a set of control rules and a prioritized water allocation strategy. 6 | 7 | Ribasim is written in the [Julia programming language](https://julialang.org/) and is built 8 | on top of the [SciML: Open Source Software for Scientific Machine Learning](https://sciml.ai/) 9 | libraries. 10 | 11 | The initial version of Ribasim is developed by [Deltares](https://www.deltares.nl) as part of a consortium for the Dutch watersystem. 12 | This activity is co-funded by [TKI Deltatechnology](https://tkideltatechnologie.nl/), a Dutch public–private partnership innovation program from the Ministry of Economic Affairs. 13 | Ribasim will be used as the surface water module of the [Netherlands Hydrologic Instrument (NHI)](https://nhi.nu/). 14 | 15 | ![](https://publicwiki.deltares.nl/download/attachments/232326570/image-2024-9-23_16-2-1-1.png?version=1&modificationDate=1727100121340&api=v2){fig-alt="TKI partner logos"} 16 | 17 | Ribasim model of the main water distribution network in the Netherlands. 18 | 19 | HWS on map 20 | -------------------------------------------------------------------------------- /utils/templates/validation.py.jinja: -------------------------------------------------------------------------------- 1 | # Automatically generated file. Do not modify. 2 | 3 | # Table for connectivity 4 | # "Basin": ["LinearResistance"] means that the downstream of basin can be LinearResistance only 5 | node_type_connectivity: dict[str, list[str]] = { 6 | {% for n in nodes %} 7 | '{{n[:name]}}': [{% for value in n[:connectivity] %} 8 | '{{ value }}', 9 | {% end %}], 10 | {% end %} 11 | } 12 | 13 | # Function to validate connection 14 | def can_connect(node_type_up: str, node_type_down: str) -> bool: 15 | if node_type_up in node_type_connectivity: 16 | return node_type_down in node_type_connectivity[node_type_up] 17 | return False 18 | 19 | flow_link_neighbor_amount: dict[str, list[int]] = { 20 | {% for n in nodes %} 21 | '{{n[:name]}}': 22 | [{{ n[:flow_neighbor_bound].in_min }}, {{ n[:flow_neighbor_bound].in_max }}, {{ n[:flow_neighbor_bound].out_min }}, {{ n[:flow_neighbor_bound].out_max }}], 23 | {% end %} 24 | } 25 | 26 | control_link_neighbor_amount: dict[str, list[int]] = { 27 | {% for n in nodes %} 28 | '{{n[:name]}}': 29 | [{{ n[:control_neighbor_bound].in_min }}, {{ n[:control_neighbor_bound].in_max }}, {{ n[:control_neighbor_bound].out_min }}, {{ n[:control_neighbor_bound].out_max }}], 30 | {% end %} 31 | } 32 | -------------------------------------------------------------------------------- /python/ribasim/tests/test_migrations.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | from ribasim import Model 5 | from ribasim.db_utils import _get_db_schema_version 6 | 7 | root_folder = Path(__file__).parent.parent.parent.parent 8 | print(root_folder) 9 | 10 | 11 | @pytest.mark.regression 12 | def test_hws_migration(tmp_path): 13 | toml_path = root_folder / "models/hws_migration_test/hws.toml" 14 | db_path = root_folder / "models/hws_migration_test/database.gpkg" 15 | 16 | assert toml_path.exists(), ( 17 | "Can't find the model, did you retrieve it with s3_download.py?" 18 | ) 19 | 20 | assert _get_db_schema_version(db_path) == 0 21 | model = Model.read(toml_path) 22 | 23 | assert model.link.df.index.name == "link_id" 24 | assert len(model.link.df) == 454 25 | model.write(tmp_path / "hws_migrated.toml") 26 | 27 | 28 | def test_active_migration(): 29 | from pandas import DataFrame 30 | from ribasim.migrations import check_inactive 31 | 32 | df = DataFrame({"node_id": [1, 2, 3], "active": [True, False, None]}) 33 | 34 | with pytest.raises( 35 | ValueError, 36 | match=r"Inactive node\(s\) with node_id \[2\] in test_nodes cannot be migrated automatically", 37 | ): 38 | check_inactive(df, "test_nodes") 39 | -------------------------------------------------------------------------------- /docs/assets/c4_component_ribasim.mmd: -------------------------------------------------------------------------------- 1 | flowchart TB 2 | modeler([Modeler]):::user 3 | 4 | api["Ribasim Python\n[python]"]:::system 5 | modeler-->|prepare model|api 6 | 7 | subgraph ribasimBoundary[Ribasim] 8 | ribasim["Ribasim.jl\n[julia]"]:::system 9 | libribasim["libribasim\n[julia + python + BMI]"]:::system 10 | cli["Ribasim CLI\n[julia]"]:::system 11 | cli-->ribasim 12 | libribasim-->ribasim 13 | end 14 | modeler-->|start|cli 15 | modeler-->|coupled simulation|libribasim 16 | 17 | subgraph qgisBoundary[QGIS] 18 | QGIS[QGIS Application]:::system_ext 19 | qgisPlugin["Ribasim QGIS plugin\n[python]"]:::system 20 | QGIS-->qgisPlugin 21 | end 22 | modeler-->|prepare model|qgisBoundary 23 | 24 | model[("input model data\n[toml + geopackage + arrow]")]:::system 25 | qgisPlugin-->|read/write|model 26 | api-->|read/write|model 27 | ribasim-->|simulate|model 28 | 29 | output[("simulation output\n[arrow]")]:::system 30 | ribasim-->|write|output 31 | 32 | class qgisBoundary,ribasimBoundary boundary 33 | 34 | %% class definitions for C4 model 35 | classDef default stroke-width:1px,stroke:white,color:white 36 | classDef system fill:#1168bd 37 | classDef user fill:#08427b 38 | classDef system_ext fill:#999999 39 | classDef boundary fill:transparent,stroke-dasharray:5 5,stroke:black,color:black 40 | -------------------------------------------------------------------------------- /.teamcity/Ribasim/buildTypes/Ribasim_MakeQgisPlugin.kt: -------------------------------------------------------------------------------- 1 | package Ribasim.buildTypes 2 | 3 | import Templates.LinuxAgent 4 | import jetbrains.buildServer.configs.kotlin.* 5 | import jetbrains.buildServer.configs.kotlin.buildSteps.script 6 | 7 | object Ribasim_MakeQgisPlugin : BuildType({ 8 | templates(LinuxAgent) 9 | name = "Make QGIS plugin" 10 | 11 | artifactRules = "ribasim_qgis.zip" 12 | 13 | vcs { 14 | root(Ribasim.vcsRoots.Ribasim) 15 | cleanCheckout = true 16 | } 17 | 18 | steps { 19 | script { 20 | id = "RUNNER_2193" 21 | scriptContent = """ 22 | #!/bin/bash 23 | source /usr/share/Modules/init/bash 24 | 25 | module load pixi 26 | pixi run install-ribasim-qgis 27 | 28 | rm --recursive ribasim_qgis/scripts 29 | rm --recursive ribasim_qgis/tests 30 | rm ribasim_qgis/.coveragerc 31 | rsync --verbose --recursive --delete ribasim_qgis/ ribasim_qgis 32 | rm --force ribasim_qgis.zip 33 | zip -r ribasim_qgis.zip ribasim_qgis 34 | """.trimIndent() 35 | } 36 | } 37 | 38 | requirements { 39 | doesNotEqual("env.OS", "Windows_NT", "RQ_338") 40 | } 41 | 42 | disableSettings("RQ_338") 43 | }) 44 | -------------------------------------------------------------------------------- /.teamcity/settings.kts: -------------------------------------------------------------------------------- 1 | import jetbrains.buildServer.configs.kotlin.* 2 | 3 | /* 4 | The settings script is an entry point for defining a single 5 | TeamCity project. TeamCity looks for the 'settings.kts' file in a 6 | project directory and runs it if it's found, so the script name 7 | shouldn't be changed and its package should be the same as the 8 | project's id. 9 | 10 | The script should contain a single call to the project() function 11 | with a Project instance or an init function as an argument. 12 | 13 | VcsRoots, BuildTypes, and Templates of this project must be 14 | registered inside project using the vcsRoot(), buildType(), and 15 | template() methods respectively. 16 | 17 | Subprojects can be defined either in their own settings.kts or by 18 | calling the subProjects() method in this project. 19 | 20 | To debug settings scripts in command-line, run the 21 | 22 | mvnDebug org.jetbrains.teamcity:teamcity-configs-maven-plugin:generate 23 | 24 | command and attach your debugger to the port 8000. 25 | 26 | To debug in IntelliJ Idea, open the 'Maven Projects' tool window (View -> 27 | Tool Windows -> Maven Projects), find the generate task 28 | node (Plugins -> teamcity-configs -> teamcity-configs:generate), 29 | the 'Debug' option is available in the context menu for the task. 30 | */ 31 | 32 | version = "2025.07" 33 | project(Ribasim.Project) 34 | -------------------------------------------------------------------------------- /utils/s3_upload.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from pathlib import Path 3 | 4 | from minio import Minio 5 | from minio.error import S3Error 6 | from s3_settings import settings 7 | 8 | minioServer = "s3.deltares.nl" 9 | bucketName = "ribasim" 10 | 11 | parser = argparse.ArgumentParser(description="Upload a file to the MinIO server") 12 | parser.add_argument("source", type=Path, help="The source file to upload") 13 | parser.add_argument("destination", help="The destination file in the MinIO server") 14 | parser.add_argument( 15 | "--accesskey", 16 | help="The access key to access the MinIO server", 17 | default=settings.minio_access_key, 18 | ) 19 | parser.add_argument( 20 | "--secretkey", 21 | help="The secret key to access the MinIO server", 22 | default=settings.minio_secret_key, 23 | ) 24 | args = parser.parse_args() 25 | 26 | if not args.source.is_file(): 27 | raise ValueError("The source file does not exist") 28 | 29 | if (not args.accesskey) or (not args.secretkey): 30 | raise ValueError("No MinIO access key or secret key provided") 31 | 32 | # Minio client connection 33 | client = Minio(minioServer, access_key=args.accesskey, secret_key=args.secretkey) 34 | 35 | try: 36 | client.fput_object( 37 | bucketName, 38 | args.destination, 39 | args.source, 40 | ) 41 | except S3Error as e: 42 | print(f"Error occurred: {e}") 43 | -------------------------------------------------------------------------------- /.teamcity/Templates/GithubIntegration.kt: -------------------------------------------------------------------------------- 1 | package Templates 2 | 3 | import Ribasim.vcsRoots.Ribasim 4 | import jetbrains.buildServer.configs.kotlin.Template 5 | import jetbrains.buildServer.configs.kotlin.buildFeatures.PullRequests 6 | import jetbrains.buildServer.configs.kotlin.buildFeatures.commitStatusPublisher 7 | import jetbrains.buildServer.configs.kotlin.buildFeatures.pullRequests 8 | 9 | object GithubCommitStatusIntegration : Template({ 10 | name = "GithubCommitStatusIntegrationTemplate" 11 | 12 | features { 13 | commitStatusPublisher { 14 | vcsRootExtId = "${Ribasim.id}" 15 | publisher = github { 16 | githubUrl = "https://api.github.com" 17 | authType = personalToken { 18 | token = "credentialsJSON:6b37af71-1f2f-4611-8856-db07965445c0" 19 | } 20 | } 21 | } 22 | } 23 | }) 24 | 25 | object GithubPullRequestsIntegration : Template({ 26 | name = "GithubPullRequestsIntegrationTemplate" 27 | 28 | features { 29 | pullRequests { 30 | vcsRootExtId = "${Ribasim.id}" 31 | provider = github { 32 | authType = token { 33 | token = "credentialsJSON:6b37af71-1f2f-4611-8856-db07965445c0" 34 | } 35 | filterAuthorRole = PullRequests.GitHubRoleFilter.MEMBER 36 | } 37 | } 38 | } 39 | }) -------------------------------------------------------------------------------- /docs/reference/node/linear-resistance.qmd: -------------------------------------------------------------------------------- 1 | --- 2 | title: "LinearResistance" 3 | --- 4 | 5 | Bidirectional flow proportional to the level difference between the connected basins. 6 | 7 | # Tables 8 | 9 | ## Static 10 | 11 | column | type | unit | restriction 12 | ------------- | ------- | --------------------- | ----------- 13 | node_id | Int32 | - | 14 | resistance | Float64 | $\text{s}/\text{m}^2$ | - 15 | max_flow_rate | Float64 | $\text{m}^3/s$ | non-negative 16 | control_state | String | - | (optional) 17 | 18 | # Equations 19 | 20 | A LinearResistance connects two Basins together. 21 | The flow between the two Basins is determined by a linear relationship, up to an optional maximum flow rate: 22 | 23 | $$ 24 | Q_\text{linear\_resistance} = \phi\mathrm{clamp}\left(\frac{h_a - h_b}{R}, -Q_{\max}, Q_{\max}\right) 25 | $$ 26 | 27 | Here $h_a$ is the water level in the incoming Basin and $h_b$ is the water level in the outgoing Basin. 28 | $R$ is the resistance of the link, and $Q_{\max}$ is the maximum flow rate. 29 | Water flows from high to low; either direction is possible. 30 | $\phi$ is the reduction factor which makes the flow go smoothly to $0$ as the upstream storage (as determined by the flow direction) becomes smaller than the equivalent of a water depth of $10 \;\text{cm}$ by default (change with solver setting `depth_threshold`). 31 | -------------------------------------------------------------------------------- /ribasim_qgis/scripts/install_qgis_plugin.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import subprocess 4 | import sys 5 | from pathlib import Path 6 | 7 | 8 | def install_qgis_plugin(plugin_name: str, profile_path: str) -> None: 9 | plugin_path = Path(profile_path) / "python/plugins" 10 | plugin_path.mkdir(parents=True, exist_ok=True) 11 | 12 | try: 13 | env = os.environ.copy() 14 | env["QGIS_PLUGIN_MANAGER_QGIS_VERSION"] = "3.40" 15 | 16 | subprocess.check_call(["qgis-plugin-manager", "init"], cwd=plugin_path, env=env) 17 | subprocess.check_call( 18 | ["qgis-plugin-manager", "update"], cwd=plugin_path, env=env 19 | ) 20 | subprocess.check_call( 21 | [ 22 | "qgis-plugin-manager", 23 | "install", 24 | plugin_name, 25 | "--deprecated", 26 | "--force", 27 | "--upgrade", 28 | ], 29 | cwd=plugin_path, 30 | env=env, 31 | ) 32 | finally: 33 | # remove the qgis-manager-plugin cache, because QGIS tries to load it as a plugin 34 | os.remove(plugin_path / "sources.list") 35 | shutil.rmtree(plugin_path / ".cache_qgis_plugin_manager") 36 | 37 | 38 | if __name__ == "__main__": 39 | print(f"Installing QGIS plugin {sys.argv[1]} to profile {sys.argv[2]}") 40 | install_qgis_plugin(sys.argv[1], sys.argv[2]) 41 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/docker-existing-dockerfile 3 | { 4 | "name": "ci env ubuntu", 5 | "build": { 6 | "dockerfile": "dockerfile", 7 | "context": ".." 8 | }, 9 | "features": { 10 | "ghcr.io/julialang/devcontainer-features/julia:1": { 11 | "channel": "release" 12 | } 13 | }, 14 | "customizations": { 15 | "vscode": { 16 | "extensions": [ 17 | "GitHub.copilot", 18 | "julialang.language-julia", 19 | "ms-python.python", 20 | "ms-python.mypy-type-checker", 21 | "charliermarsh.ruff", 22 | "njpwerner.autodocstring", 23 | "quarto.quarto", 24 | "tamasfe.even-better-toml", 25 | "samuelcolvin.jinjahtml", 26 | "yy0931.vscode-sqlite3-editor", 27 | "streetsidesoftware.code-spell-checker", 28 | "ms-toolsai.jupyter" 29 | ] 30 | } 31 | }, 32 | "postCreateCommand": "pixi run install && pixi run generate-testmodels", 33 | "postStartCommand": "pixi run initialize-julia" 34 | } 35 | -------------------------------------------------------------------------------- /ribasim_qgis/metadata.txt: -------------------------------------------------------------------------------- 1 | 2 | # This file contains metadata for your plugin. 3 | 4 | # This file should be included when you package your plugin.# Mandatory items: 5 | 6 | [general] 7 | name=Ribasim 8 | qgisMinimumVersion=3.34 9 | description=QGIS plugin to inspect Ribasim models 10 | version=2026.1.0-rc1 11 | author=Deltares 12 | email=ribasim.info@deltares.nl 13 | 14 | about=QGIS plugin to inspect Ribasim models. Requires `pandas` to be installed in the QGIS Python environment. 15 | 16 | tracker=https://github.com/Deltares/Ribasim/issues 17 | repository=https://github.com/Deltares/Ribasim 18 | # End of mandatory metadata 19 | 20 | # Recommended items: 21 | 22 | hasProcessingProvider=no 23 | changelog=https://ribasim.org/changelog 24 | 25 | # Tags are comma separated with spaces allowed 26 | tags=hydrology, water, water resources, model, julia 27 | 28 | homepage=https://ribasim.org/guide/qgis 29 | category=Plugins 30 | icon=icon.png 31 | # experimental flag 32 | experimental=False 33 | 34 | # deprecated flag (applies to the whole plugin, not just a single version) 35 | deprecated=False 36 | 37 | # Since QGIS 3.8, a comma separated list of plugins to be installed 38 | # (or upgraded) can be specified. 39 | # Check the documentation for more information. 40 | plugin_dependencies=iMOD==0.5.3 41 | 42 | Category of the plugin: Raster, Vector, Database or Web 43 | # category= 44 | 45 | # If the plugin can run on QGIS Server. 46 | server=False 47 | -------------------------------------------------------------------------------- /utils/templates/schemas.py.jinja: -------------------------------------------------------------------------------- 1 | # Automatically generated file. Do not modify. 2 | 3 | from collections.abc import Callable 4 | from typing import Any 5 | 6 | import numpy as np 7 | import pandas as pd 8 | import pandera.pandas as pa 9 | from pandera.dtypes import Int32 10 | from pandera.typing import Index 11 | 12 | from ribasim import migrations 13 | 14 | class _BaseSchema(pa.DataFrameModel): 15 | class Config: 16 | add_missing_columns = True 17 | coerce = True 18 | 19 | @classmethod 20 | def _index_name(cls) -> str: 21 | return "fid" 22 | 23 | @pa.dataframe_parser 24 | @classmethod 25 | def _name_index(cls, df): 26 | df.index.name = cls._index_name() 27 | return df 28 | 29 | @classmethod 30 | def migrate(cls, df: Any, schema_version: int) -> Any: 31 | f: Callable[[Any, Any], Any] = getattr( 32 | migrations, str(cls.__name__).lower() + "_migration", lambda x, _: x 33 | ) 34 | return f(df, schema_version) 35 | 36 | {% for m in models %} 37 | class {{m[:name]}}Schema(_BaseSchema): 38 | fid: Index[Int32] = pa.Field(default=1, check_name=True, coerce=True) 39 | {% for f in m[:fields] %} 40 | {% if (f[1] == :node_id) %} 41 | {{ f[1] }}: {{ f[2] }} = pa.Field(nullable={{ f[3] }}, default=0) 42 | {% else %} 43 | {{ f[1] }}: {{ f[2] }} = pa.Field(nullable={{ f[3] }}) 44 | {% end %} 45 | {% end %} 46 | {% end %} 47 | -------------------------------------------------------------------------------- /python/ribasim/ribasim/nodes/basin.py: -------------------------------------------------------------------------------- 1 | from ribasim.geometry import BasinAreaSchema 2 | from ribasim.input_base import SpatialTableModel, TableModel 3 | from ribasim.schemas import ( 4 | BasinConcentrationExternalSchema, 5 | BasinConcentrationSchema, 6 | BasinConcentrationStateSchema, 7 | BasinProfileSchema, 8 | BasinStateSchema, 9 | BasinStaticSchema, 10 | BasinSubgridSchema, 11 | BasinSubgridTimeSchema, 12 | BasinTimeSchema, 13 | ) 14 | 15 | __all__ = [ 16 | "Area", 17 | "Concentration", 18 | "Profile", 19 | "State", 20 | "Static", 21 | "Subgrid", 22 | "SubgridTime", 23 | "Time", 24 | ] 25 | 26 | 27 | class Static(TableModel[BasinStaticSchema]): 28 | pass 29 | 30 | 31 | class Time(TableModel[BasinTimeSchema]): 32 | pass 33 | 34 | 35 | class State(TableModel[BasinStateSchema]): 36 | pass 37 | 38 | 39 | class Profile(TableModel[BasinProfileSchema]): 40 | pass 41 | 42 | 43 | class Subgrid(TableModel[BasinSubgridSchema]): 44 | pass 45 | 46 | 47 | class SubgridTime(TableModel[BasinSubgridTimeSchema]): 48 | pass 49 | 50 | 51 | class Area(SpatialTableModel[BasinAreaSchema]): 52 | pass 53 | 54 | 55 | class Concentration(TableModel[BasinConcentrationSchema]): 56 | pass 57 | 58 | 59 | class ConcentrationExternal(TableModel[BasinConcentrationExternalSchema]): 60 | pass 61 | 62 | 63 | class ConcentrationState(TableModel[BasinConcentrationStateSchema]): 64 | pass 65 | -------------------------------------------------------------------------------- /utils/generate-testmodels.py: -------------------------------------------------------------------------------- 1 | import multiprocessing 2 | import shutil 3 | import sys 4 | from functools import partial 5 | from pathlib import Path 6 | 7 | import ribasim_testmodels 8 | 9 | selection = ( 10 | sys.argv[1:] if len(sys.argv) > 1 else ribasim_testmodels.constructors.keys() 11 | ) 12 | 13 | 14 | def generate_model(args, datadir): 15 | model_name, model_constructor = args 16 | model = model_constructor() 17 | model.write(datadir / model_name / "ribasim.toml") 18 | return model_name 19 | 20 | 21 | if __name__ == "__main__": 22 | datadir = Path("generated_testmodels") 23 | # Don't remove all models if we only (re)generate a subset 24 | if datadir.is_dir() and len(sys.argv) == 0: 25 | shutil.rmtree(datadir, ignore_errors=True) 26 | 27 | datadir.mkdir(exist_ok=True) 28 | readme = datadir / "README.md" 29 | readme.write_text( 30 | """\ 31 | # Ribasim testmodels 32 | 33 | The content of this directory are generated testmodels for Ribasim 34 | Don't put important stuff in here, it will be emptied for every run.""" 35 | ) 36 | 37 | generate_model_partial = partial(generate_model, datadir=datadir) 38 | 39 | models = [ 40 | (k, v) for k, v in ribasim_testmodels.constructors.items() if k in selection 41 | ] 42 | 43 | with multiprocessing.Pool(processes=4) as p: 44 | for model_name in p.imap_unordered(generate_model_partial, models): 45 | print(f"Generated {model_name}") 46 | -------------------------------------------------------------------------------- /.github/workflows/core_testmodels.yml: -------------------------------------------------------------------------------- 1 | name: Julia Run Testmodels 2 | on: 3 | push: 4 | branches: [main] 5 | paths: ["core/**", "python/**", "pixi.toml", "pixi.lock", "Project.toml", "Manifest.toml"] 6 | tags: ["*"] 7 | pull_request: 8 | paths: ["core/**", "python/**", "pixi.toml", "pixi.lock", "Project.toml", "Manifest.toml"] 9 | merge_group: 10 | workflow_dispatch: 11 | concurrency: 12 | group: ${{ github.workflow }}-${{ github.ref }} 13 | cancel-in-progress: true 14 | # needed to allow julia-actions/cache to delete old caches that it has created 15 | permissions: 16 | actions: write 17 | contents: read 18 | jobs: 19 | test: 20 | name: Julia ${{ matrix.os }} - ${{ matrix.arch }} 21 | runs-on: ${{ matrix.os }} 22 | timeout-minutes: 60 23 | strategy: 24 | fail-fast: false 25 | matrix: 26 | os: 27 | - ubuntu-latest 28 | # https://github.com/Deltares/Ribasim/issues/825 29 | # - macOS-latest 30 | - windows-latest 31 | arch: 32 | - x64 33 | steps: 34 | - uses: actions/checkout@v6 35 | - uses: julia-actions/cache@v2 36 | - uses: prefix-dev/setup-pixi@v0.9.3 37 | with: 38 | pixi-version: "latest" 39 | - name: Prepare pixi 40 | run: pixi run install-ci 41 | - name: Run testmodels with Ribasim Core 42 | run: | 43 | pixi run ribasim-core-testmodels 44 | env: 45 | JULIA_NUM_THREADS: 4 46 | -------------------------------------------------------------------------------- /core/test/data/allocation_problems/level_demand/allocation_problem_3.lp: -------------------------------------------------------------------------------- 1 | minimize 2 | obj: 3 | subject to 4 | storage_constraint_lower_Basin_#7,1_: 1 basin_storage_change_Basin_#7_ + 1 level_demand_error_Basin_#7,1,lower,first_ >= -250 5 | storage_constraint_upper_Basin_#7,1_: -1 basin_storage_change_Basin_#7_ + 1 level_demand_error_Basin_#7,1,upper,first_ >= -750 6 | level_demand_fairness_error_constraint_Basin_#7,1,lower_: -0.001 level_demand_error_Basin_#7,1,lower,first_ + 1 level_demand_error_Basin_#7,1,lower,second_ + 1 average_storage_unit_error_1,lower_ >= 0 7 | level_demand_fairness_error_constraint_Basin_#7,1,upper_: -0.001 level_demand_error_Basin_#7,1,upper,first_ + 1 level_demand_error_Basin_#7,1,upper,second_ + 1 average_storage_unit_error_1,upper_ >= 0 8 | volume_conservation_Basin_#7_: 1 basin_storage_change_Basin_#7_ + 1 low_storage_factor_Basin_#7_ = 1 9 | average_storage_unit_error_constraint_1,upper_: -1 level_demand_error_Basin_#7,1,upper,first_ + 1000 average_storage_unit_error_1,upper_ = 0 10 | average_storage_unit_error_constraint_1,lower_: -1 level_demand_error_Basin_#7,1,lower,first_ + 1000 average_storage_unit_error_1,lower_ = 0 11 | Bounds 12 | -0.2 <= basin_storage_change_Basin_#7_ <= 0.8 13 | 0 <= low_storage_factor_Basin_#7_ <= 1 14 | level_demand_error_Basin_#7,1,lower,first_ >= 0 15 | level_demand_error_Basin_#7,1,lower,second_ >= 0 16 | level_demand_error_Basin_#7,1,upper,first_ >= 0 17 | level_demand_error_Basin_#7,1,upper,second_ >= 0 18 | average_storage_unit_error_1,lower_ >= 0 19 | average_storage_unit_error_1,upper_ >= 0 20 | End 21 | -------------------------------------------------------------------------------- /ribasim_qgis/core/arrow.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | 3 | 4 | def postprocess_concentration_arrow(df: pd.DataFrame) -> pd.DataFrame: 5 | """Postprocess the concentration arrow data to a wide format.""" 6 | ndf = pd.pivot_table(df, columns="substance", index=["time", "node_id"]) 7 | ndf.columns = ndf.columns.droplevel(0) 8 | ndf.reset_index("node_id", inplace=True) 9 | return ndf 10 | 11 | 12 | def postprocess_allocation_arrow(df: pd.DataFrame) -> pd.DataFrame: 13 | """Postprocess the allocation arrow data to a wide format by summing over priorities.""" 14 | ndf = df.groupby(["time", "node_id"]).aggregate( 15 | {"demand": "sum", "allocated": "sum", "realized": "sum"} 16 | ) 17 | ndf.reset_index("node_id", inplace=True) 18 | return ndf 19 | 20 | 21 | def postprocess_allocation_flow_arrow(df: pd.DataFrame) -> pd.DataFrame: 22 | """Postprocess the allocation flow arrow data to a wide format by summing over priorities.""" 23 | ndf = df.groupby(["time", "link_id"]).aggregate({"flow_rate": "sum"}) 24 | # Drop Basin to Basin flows, as we can't join/visualize them 25 | ndf.drop(ndf[ndf.index.get_level_values("link_id") == 0].index, inplace=True) 26 | ndf.reset_index("link_id", inplace=True) 27 | return ndf 28 | 29 | 30 | def postprocess_flow_arrow(df: pd.DataFrame) -> pd.DataFrame: 31 | """Postprocess the allocation flow arrow data to a wide format by summing over priorities.""" 32 | ndf = df.set_index(pd.DatetimeIndex(df["time"])) 33 | ndf.drop(columns=["time", "from_node_id", "to_node_id"], inplace=True) 34 | return ndf 35 | -------------------------------------------------------------------------------- /utils/update-manifest.jl: -------------------------------------------------------------------------------- 1 | import Pkg 2 | 3 | const IS_INSTALLED = r"\s*Installed (.+)" 4 | const DETAILS_BEGIN = """ 5 |
6 | 7 | All package versions 8 | 9 | 10 | ``` 11 | """ 12 | 13 | """ 14 | Update the Julia Manifest.toml and show the changes as well as outdated packages. 15 | The output is written to a file that can be used as the body of a pull request. 16 | """ 17 | function (@main)(_) 18 | path = normpath(@__DIR__, "../.pixi/update-manifest-julia.md") 19 | redirect_stdio(; stdout = path, stderr = path) do 20 | println("Update the Julia Manifest.toml to get the latest dependencies.\n") 21 | println("__Changed packages__\n```") 22 | Pkg.update() 23 | println("```\n\n__Packages still outdated after update__\n```") 24 | Pkg.status(; outdated = true) 25 | println("```") 26 | end 27 | 28 | # The Pkg.update output first prints all installed package versions. 29 | # This is a lot, strip it out, sort it, and put it in a details tag at the end. 30 | installed_lines = String[] 31 | lines = readlines(path) 32 | open(path, "w") do io 33 | for line in lines 34 | m = match(IS_INSTALLED, line) 35 | if m === nothing 36 | println(io, line) 37 | else 38 | push!(installed_lines, only(m.captures)) 39 | end 40 | end 41 | 42 | println(io, DETAILS_BEGIN) 43 | sort!(installed_lines) 44 | foreach(line -> println(io, line), installed_lines) 45 | println("```\n\n
") 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /.teamcity/Testbench/IntegrationTestHWS.kt: -------------------------------------------------------------------------------- 1 | package Testbench.IntegrationTestHWS 2 | 3 | import Ribasim_Linux.Linux_BuildRibasim 4 | import Ribasim_Windows.Windows_BuildRibasim 5 | import Templates.* 6 | import jetbrains.buildServer.configs.kotlin.BuildType 7 | import jetbrains.buildServer.configs.kotlin.Project 8 | import jetbrains.buildServer.configs.kotlin.triggers.schedule 9 | 10 | object IntegrationTestHWS : Project ({ 11 | id("IntegrationTestHWS") 12 | name = "IntegrationTestHWS" 13 | 14 | buildType(IntegrationTest_Windows) 15 | buildType(IntegrationTest_Linux) 16 | 17 | template(IntegrationTestWindows) 18 | template(IntegrationTestLinux) 19 | }) 20 | 21 | object IntegrationTest_Windows : BuildType({ 22 | name = "IntegrationTestWindows" 23 | templates(WindowsAgent, GithubCommitStatusIntegration, IntegrationTestWindows) 24 | 25 | triggers{ 26 | schedule { 27 | id = "" 28 | schedulingPolicy = daily { 29 | hour = 0 30 | } 31 | 32 | branchFilter = "+:" 33 | triggerBuild = always() 34 | withPendingChangesOnly = true 35 | } 36 | } 37 | }) 38 | 39 | object IntegrationTest_Linux : BuildType({ 40 | name = "IntegrationTestLinux" 41 | templates(LinuxAgent, GithubCommitStatusIntegration, IntegrationTestLinux) 42 | 43 | triggers{ 44 | schedule { 45 | id = "" 46 | schedulingPolicy = daily { 47 | hour = 0 48 | } 49 | 50 | branchFilter = "+:" 51 | triggerBuild = always() 52 | withPendingChangesOnly = true 53 | } 54 | } 55 | }) -------------------------------------------------------------------------------- /core/test/data/allocation_problems/allocation_control/allocation_problem_1.lp: -------------------------------------------------------------------------------- 1 | minimize 2 | obj: 3 | subject to 4 | user_demand_relative_error_constraint_UserDemand_#4,1_: 1 user_demand_allocated_UserDemand_#4,1_ + 2 user_demand_error_UserDemand_#4,1,first_ >= 2 5 | user_demand_fairness_error_constraint_UserDemand_#4,1_: -1 user_demand_error_UserDemand_#4,1,first_ + 1 user_demand_error_UserDemand_#4,1,second_ + 1 average_flow_unit_error_1_ >= 0 6 | flow_conservation_outlet_Outlet_#2_: 1 flow_(LevelBoundary_#1,_Outlet_#2)_ - 1 flow_(Outlet_#2,_Basin_#3)_ = 0 7 | volume_conservation_Basin_#3_: 1 basin_storage_change_Basin_#3_ + 1 low_storage_factor_Basin_#3_ - 1 flow_(Outlet_#2,_Basin_#3)_ + 1 flow_(Basin_#3,_UserDemand_#4)_ - 1 flow_(UserDemand_#4,_Basin_#3)_ = 1 8 | user_demand_allocated_sum_constraint_UserDemand_#4_: 1 flow_(Basin_#3,_UserDemand_#4)_ - 1 user_demand_allocated_UserDemand_#4,1_ = 0 9 | user_demand_return_flow_UserDemand_#4_: -0.5 flow_(Basin_#3,_UserDemand_#4)_ + 1 flow_(UserDemand_#4,_Basin_#3)_ = 0 10 | average_flow_unit_error_constraint_1_: -1 user_demand_error_UserDemand_#4,1,first_ + 1 average_flow_unit_error_1_ = 0 11 | Bounds 12 | -0.2 <= basin_storage_change_Basin_#3_ <= 0.8 13 | 0 <= low_storage_factor_Basin_#3_ <= 1 14 | 0 <= flow_(LevelBoundary_#1,_Outlet_#2)_ <= 155.52 15 | 0 <= flow_(Outlet_#2,_Basin_#3)_ <= 155.52 16 | 0 <= flow_(Basin_#3,_UserDemand_#4)_ <= 8640000 17 | 0 <= flow_(UserDemand_#4,_Basin_#3)_ <= 8640000 18 | boundary_level_LevelBoundary_#1_ = 0 19 | 0 <= user_demand_allocated_UserDemand_#4,1_ <= 2 20 | 0 <= user_demand_error_UserDemand_#4,1,first_ <= 1 21 | 0 <= user_demand_error_UserDemand_#4,1,second_ <= 1 22 | 0 <= average_flow_unit_error_1_ <= 1 23 | End 24 | -------------------------------------------------------------------------------- /.teamcity/Ribasim/buildTypes/GenerateCache.kt: -------------------------------------------------------------------------------- 1 | package Ribasim.buildTypes 2 | 3 | import Templates.GithubCommitStatusIntegration 4 | import Templates.WindowsAgent 5 | import Templates.LinuxAgent 6 | import Templates.GenerateCacheWindows 7 | import Templates.GenerateCacheLinux 8 | import jetbrains.buildServer.configs.kotlin.* 9 | import jetbrains.buildServer.configs.kotlin.buildSteps.script 10 | import jetbrains.buildServer.configs.kotlin.triggers.vcs 11 | 12 | object Windows_GenerateCache : BuildType({ 13 | templates(WindowsAgent, GithubCommitStatusIntegration, GenerateCacheWindows) 14 | name = "Generate Windows TC cache" 15 | 16 | triggers { 17 | vcs { 18 | id = "TRIGGER_RIBA_W1" 19 | triggerRules = """ 20 | +:root=Ribasim_Ribasim:/Manifest.toml 21 | +:root=Ribasim_Ribasim:/Project.toml 22 | +:root=Ribasim_Ribasim:/pixi.lock 23 | +:root=Ribasim_Ribasim:/pixi.toml 24 | """.trimIndent() 25 | branchFilter = "+:" 26 | } 27 | } 28 | }) 29 | 30 | object Linux_GenerateCache : BuildType({ 31 | templates(LinuxAgent, GithubCommitStatusIntegration, GenerateCacheLinux) 32 | name = "Generate Linux TC cache" 33 | 34 | triggers { 35 | vcs { 36 | id = "TRIGGER_RIBA_L1" 37 | triggerRules = """ 38 | +:root=Ribasim_Ribasim:/Manifest.toml 39 | +:root=Ribasim_Ribasim:/Project.toml 40 | +:root=Ribasim_Ribasim:/pixi.lock 41 | +:root=Ribasim_Ribasim:/pixi.toml 42 | """.trimIndent() 43 | branchFilter = "+:" 44 | } 45 | } 46 | }) 47 | -------------------------------------------------------------------------------- /docs/reference/node/flow-demand.qmd: -------------------------------------------------------------------------------- 1 | --- 2 | title: "FlowDemand" 3 | --- 4 | 5 | A `FlowDemand` node associates a non-consuming flow demand to a connector node (e.g. `Pump`, `TabulatedRatingCurve`) for one or more demand priorities. 6 | FlowDemand nodes can set a flow demand only for a single connector node. 7 | FlowDemand nodes do nothing when allocation is not activated, except when they are connected to a Pump or an Outlet. In that case the flow demand flow rate is taken as the minimum flow through that Pump or Outlet. 8 | 9 | # Tables 10 | 11 | ## Static 12 | 13 | column | type | unit | restriction 14 | --------------- | -------- | --------------------- | ----------- 15 | node_id | Int32 | - | 16 | demand_priority | Int32 | - | positive 17 | demand | Float64 | $\text{m}^3/\text{s}$ | non-negative 18 | 19 | ## Time 20 | 21 | This table is the transient form of the `FlowDemand` table, in which a time-dependent demand can be supplied. 22 | With this the demand can be updated over time. In between the given times the 23 | demand is block interpolated (forward fill), and outside the demand is constant given by the 24 | nearest time value. The allocation algorithm evaluates the interpolation at the start of the allocation time step. 25 | 26 | column | type | unit | restriction 27 | --------------- | -------- | --------------------- | ----------- 28 | node_id | Int32 | - | 29 | time | DateTime | - | 30 | demand_priority | Int32 | - | positive 31 | demand | Float64 | $\text{m}^3/\text{s}$ | non-negative 32 | -------------------------------------------------------------------------------- /.teamcity/Templates/GenerateCache.kt: -------------------------------------------------------------------------------- 1 | package Templates 2 | 3 | import Ribasim.vcsRoots.Ribasim 4 | import jetbrains.buildServer.configs.kotlin.Template 5 | import jetbrains.buildServer.configs.kotlin.buildSteps.script 6 | import jetbrains.buildServer.configs.kotlin.* 7 | 8 | fun generateJuliaDepotPath(platformOs: String): String { 9 | if (platformOs == "Linux") { 10 | return "%teamcity.build.checkoutDir%/.julia:" 11 | } else { 12 | return "%teamcity.build.checkoutDir%/.julia;" 13 | } 14 | } 15 | 16 | open class GenerateCache(platformOs: String) : Template() { 17 | init { 18 | name = "GenerateCache${platformOs}_Template" 19 | 20 | artifactRules = """%teamcity.build.checkoutDir%/.julia => cache.zip""" 21 | publishArtifacts = PublishMode.SUCCESSFUL 22 | 23 | vcs { 24 | root(Ribasim, ". => ribasim") 25 | cleanCheckout = true 26 | } 27 | 28 | val depot_path = generateJuliaDepotPath(platformOs) 29 | params { 30 | param("env.JULIA_DEPOT_PATH", depot_path) 31 | } 32 | 33 | val header = generateTestBinariesHeader(platformOs) 34 | steps { 35 | script { 36 | name = "Set up pixi" 37 | id = "Set_up_pixi" 38 | workingDir = "ribasim" 39 | scriptContent = header + 40 | """ 41 | pixi --version 42 | pixi run install-ci 43 | pixi run initialize-julia-test 44 | """.trimIndent() 45 | } 46 | } 47 | } 48 | } 49 | 50 | object GenerateCacheWindows : GenerateCache("Windows") 51 | object GenerateCacheLinux : GenerateCache("Linux") 52 | -------------------------------------------------------------------------------- /core/test/main_test.jl: -------------------------------------------------------------------------------- 1 | @testitem "main output" begin 2 | using IOCapture: capture 3 | import TOML 4 | using Ribasim: Config, results_path 5 | 6 | model_path = normpath(@__DIR__, "../../generated_testmodels/basic/") 7 | toml_path = normpath(model_path, "ribasim.toml") 8 | 9 | # change the ribasim_version in the toml file to check warning 10 | toml_dict = TOML.parsefile(toml_path) 11 | toml_dict["ribasim_version"] = "a_different_version" 12 | open(toml_path, "w") do io 13 | TOML.print(io, toml_dict) 14 | end 15 | 16 | @test ispath(toml_path) 17 | (; value, output, error, backtrace) = capture() do 18 | Ribasim.main(toml_path) 19 | end 20 | @test value == 0 21 | if value != 0 22 | @show output 23 | @show error 24 | @show backtrace 25 | end 26 | @test occursin("version in the TOML config file does not match", output) 27 | end 28 | 29 | @testitem "main error logging" begin 30 | using IOCapture: capture 31 | import TOML 32 | using Ribasim: Config, results_path 33 | 34 | model_path = normpath(@__DIR__, "../../generated_testmodels/invalid_link_types/") 35 | toml_path = normpath(model_path, "ribasim.toml") 36 | 37 | @test ispath(toml_path) 38 | (; value, output) = capture() do 39 | Ribasim.main(toml_path) 40 | end 41 | @test value == 1 42 | 43 | # Stacktraces should be written to both the terminal and log file. 44 | @test occursin("\nStacktrace:\n", output) 45 | config = Config(toml_path) 46 | log_path = results_path(config, "ribasim.log") 47 | @test ispath(log_path) 48 | log_str = read(log_path, String) 49 | @test occursin("\nStacktrace:\n", log_str) 50 | end 51 | -------------------------------------------------------------------------------- /python/ribasim/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "ribasim" 7 | description = "Pre- and post-process Ribasim" 8 | readme = "README.md" 9 | authors = [ 10 | { name = "Deltares and contributors", email = "ribasim.info@deltares.nl" }, 11 | ] 12 | license = { text = "MIT" } 13 | classifiers = [ 14 | "Intended Audience :: Science/Research", 15 | "Topic :: Scientific/Engineering :: Hydrology", 16 | ] 17 | requires-python = ">=3.12" 18 | dependencies = [ 19 | "datacompy >=0.16", 20 | "geopandas >=1.0", 21 | "matplotlib >=3.9", 22 | "netCDF4 >=1.7.1", 23 | "numpy >=2.0", 24 | "packaging >=23.0", 25 | "pandas >=2.2", 26 | "pandera >=0.25", 27 | "pyarrow >=17.0", 28 | "pydantic >=2.0", 29 | "pyogrio >=0.8", 30 | "shapely >=2.0", 31 | "tomli >=2.0", 32 | "tomli-w >=1.0", 33 | "xarray >=2023.11", 34 | ] 35 | dynamic = ["version"] 36 | 37 | [project.optional-dependencies] 38 | tests = [ 39 | "pytest", 40 | "pytest-xdist", 41 | "pytest-cov", 42 | "ribasim_testmodels", 43 | "teamcity-messages", 44 | ] 45 | netcdf = ["xugrid"] 46 | delwaq = ["jinja2", "networkx", "ribasim[netcdf]"] 47 | all = ["ribasim[tests]", "ribasim[netcdf]", "ribasim[delwaq]"] 48 | 49 | [project.urls] 50 | Documentation = "https://ribasim.org/" 51 | Source = "https://github.com/Deltares/Ribasim" 52 | 53 | [tool.hatch.version] 54 | path = "ribasim/__init__.py" 55 | 56 | [tool.hatch.build.targets.sdist] 57 | artifacts = ["delwaq/reference/*", "delwaq/template/*"] 58 | 59 | [tool.pytest.ini_options] 60 | markers = [ 61 | "regression: Older models that are not on the current database schema.", 62 | ] 63 | -------------------------------------------------------------------------------- /python/ribasim/tests/test_link.py: -------------------------------------------------------------------------------- 1 | import geopandas as gpd 2 | import pytest 3 | import shapely.geometry as sg 4 | from pydantic import ValidationError 5 | from ribasim.geometry.link import LinkTable, NodeData 6 | 7 | 8 | @pytest.fixture(scope="session") 9 | def link() -> LinkTable: 10 | a = (0.0, 0.0) 11 | b = (0.0, 1.0) 12 | c = (0.2, 0.5) 13 | d = (1.0, 1.0) 14 | geometry = [sg.LineString([a, b, c]), sg.LineString([a, d])] 15 | df = gpd.GeoDataFrame( 16 | data={"link_id": [0, 1], "from_node_id": [1, 1], "to_node_id": [2, 3]}, 17 | geometry=geometry, 18 | ) 19 | df.set_index("link_id", inplace=True) 20 | link = LinkTable(df=df) 21 | return link 22 | 23 | 24 | def test_validation(link): 25 | assert isinstance(link, LinkTable) 26 | 27 | with pytest.raises(ValidationError): 28 | df = gpd.GeoDataFrame( 29 | data={ 30 | "link_id": [0, 1], 31 | "from_node_id": [1, 1], 32 | "to_node_id": ["foo", 3], 33 | }, # None is coerced to 0 without errors 34 | geometry=[None, None], 35 | ) 36 | df.set_index("link_id", inplace=True) 37 | LinkTable(df=df) 38 | 39 | 40 | def test_link_plot(link): 41 | link.plot() 42 | 43 | 44 | def test_link_indexing(link): 45 | with pytest.raises(NotImplementedError): 46 | link[1] 47 | 48 | 49 | def test_invalid_retour_link(basic): 50 | with pytest.raises(ValueError, match="opposite link already exists"): 51 | basic.link.add(basic.manning_resistance[2], basic.basin[1]) 52 | 53 | 54 | def test_node_data(): 55 | node = NodeData(node_id=5, node_type="Pump", geometry=sg.Point(0, 0)) 56 | assert repr(node) == "Pump #5" 57 | -------------------------------------------------------------------------------- /docs/reference/node/junction.qmd: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Junction" 3 | --- 4 | 5 | A Junction node allows explicitly representing confluences and bifurcations in the network. It doesn't introduce new behavior but makes it easier to make the network layout recognizable. 6 | 7 | Junctions can connect to other Junctions, but are not allowed to form cycles. Note that a confluence Junction followed by bifurcation Junction is generally invalid, as it will connect a connector node with multiple basins. 8 | 9 | 10 | # Tables 11 | 12 | No tables are required for Junction nodes. 13 | 14 | # Equations 15 | 16 | Junctions connect all upstream nodes with all downstream nodes, 17 | and are not used in the equations themselves. 18 | 19 | # Examples 20 | 21 | This testmodel (juction_combined) with Junctions 22 | ```{mermaid} 23 | flowchart LR 24 | C{Boundary} --> E[/Junction\] 25 | E --> F((Basin)) 26 | E --> G((Basin)) 27 | F --> H{Connector} 28 | G --> I{Connector} 29 | H --> J[/Junction\] 30 | I --> J 31 | J --> K((Basin)) 32 | ``` 33 | 34 | translates to the following model: 35 | 36 | ```{mermaid} 37 | flowchart LR 38 | C{Boundary} --> F((Basin)) 39 | C --> G((Basin)) 40 | F --> H{Connector} 41 | G --> I{Connector} 42 | H --> K((Basin)) 43 | I --> K 44 | ``` 45 | 46 | And this testmodel (junction_chained) with Junctions 47 | 48 | ```{mermaid} 49 | flowchart LR 50 | A{Connector} --> D[/Junction\] 51 | B{Connector} --> D[/Junction\] 52 | C{Connector} --> E[/Junction\] 53 | D --> E 54 | E --> F((Basin)) 55 | ``` 56 | 57 | translates to the following model: 58 | 59 | ```{mermaid} 60 | flowchart LR 61 | A{Connector} --> F((Basin)) 62 | B{Connector} --> F 63 | C{Connector} --> F 64 | ``` 65 | -------------------------------------------------------------------------------- /core/test/libribasim_test.jl: -------------------------------------------------------------------------------- 1 | @testitem "libribasim" begin 2 | toml_path = normpath(@__DIR__, "../../generated_testmodels/basic/ribasim.toml") 3 | 4 | # data from which we create pointers for libribasim 5 | time = [-1.0] 6 | var_name = "basin.storage" 7 | type = ones(UInt8, 8) 8 | 9 | GC.@preserve time var_name type toml_path begin 10 | var_name_ptr = Base.unsafe_convert(Cstring, var_name) 11 | time_ptr = pointer(time) 12 | type_ptr = Cstring(pointer(type)) 13 | toml_path_ptr = Base.unsafe_convert(Cstring, toml_path) 14 | 15 | # safe to finalize uninitialized model 16 | @test libribasim.model === nothing 17 | @test libribasim.finalize() == 0 18 | @test libribasim.model === nothing 19 | 20 | # cannot get time of uninitialized model 21 | @test libribasim.last_error_message == "" 22 | retcode = libribasim.get_current_time(time_ptr) 23 | @test retcode == 1 24 | @test time[1] == -1 25 | @test libribasim.last_error_message == "Model not initialized" 26 | 27 | @test libribasim.initialize(toml_path_ptr) == 0 28 | @test libribasim.model isa Ribasim.Model 29 | @test libribasim.model.integrator.t == 0.0 30 | @test libribasim.update_retcode(libribasim.model) == 1 31 | 32 | @test libribasim.get_current_time(time_ptr) == 0 33 | @test time[1] == 0.0 34 | 35 | @test libribasim.get_var_type(var_name_ptr, type_ptr) == 0 36 | @test unsafe_string(type_ptr) == "double" 37 | 38 | @test libribasim.update() == 0 39 | @test libribasim.update_retcode(libribasim.model) == 0 40 | 41 | @test libribasim.finalize() == 0 42 | @test libribasim.model === nothing 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /.teamcity/Testbench/RegressionTestODESolve.kt: -------------------------------------------------------------------------------- 1 | package Testbench.RegressionTestODESolve 2 | 3 | import Ribasim_Windows.Windows_BuildRibasim 4 | import Ribasim_Linux.Linux_BuildRibasim 5 | import Templates.* 6 | import jetbrains.buildServer.configs.kotlin.BuildType 7 | import jetbrains.buildServer.configs.kotlin.Project 8 | import jetbrains.buildServer.configs.kotlin.matrix 9 | import jetbrains.buildServer.configs.kotlin.triggers.schedule 10 | import jetbrains.buildServer.configs.kotlin.* 11 | 12 | object RegressionTestODESolve : Project({ 13 | id("RegressionTestODE") 14 | name = "RegressionTestODE" 15 | 16 | buildType(RegressionTest_Windows) 17 | buildType(RegressionTest_Linux) 18 | 19 | template(RegressionTestWindows) 20 | template(RegressionTestLinux) 21 | }) 22 | 23 | object RegressionTest_Windows : BuildType({ 24 | name = "RegressionTestWindows" 25 | templates(WindowsAgent, GithubCommitStatusIntegration, RegressionTestWindows) 26 | 27 | triggers{ 28 | schedule { 29 | id = "" 30 | schedulingPolicy = daily { 31 | hour = 0 32 | } 33 | 34 | branchFilter = "+:" 35 | triggerBuild = always() 36 | withPendingChangesOnly = true 37 | } 38 | } 39 | }) 40 | 41 | object RegressionTest_Linux : BuildType({ 42 | name = "RegressionTestLinux" 43 | templates(LinuxAgent, GithubCommitStatusIntegration, RegressionTestLinux) 44 | 45 | triggers{ 46 | schedule { 47 | id = "" 48 | schedulingPolicy = daily { 49 | hour = 0 50 | } 51 | 52 | branchFilter = "+:" 53 | triggerBuild = always() 54 | withPendingChangesOnly = true 55 | } 56 | } 57 | }) -------------------------------------------------------------------------------- /utils/github-release.py: -------------------------------------------------------------------------------- 1 | import re 2 | import subprocess 3 | 4 | 5 | def git_describe() -> subprocess.CompletedProcess[str]: 6 | return subprocess.run( 7 | ["git", "describe", "--tags", "--exact-match"], 8 | capture_output=True, 9 | text=True, 10 | ) 11 | 12 | 13 | def main(proc: subprocess.CompletedProcess[str]): 14 | # Get the name of the currently checked out tag 15 | tag_name = proc.stdout.strip() 16 | 17 | print(f"Currently checked out tag: {tag_name}") 18 | 19 | # Define regex patterns for valid tag names 20 | normal_pattern = r"^v20\d{2}\.[1-9]\.\d{1,2}$" 21 | prerelease_pattern = r"^v20\d{2}\.[1-9]\.\d{1,2}-rc[1-9]\d?$" 22 | 23 | is_normal = re.match(normal_pattern, tag_name) 24 | is_prerelease = re.match(prerelease_pattern, tag_name) 25 | 26 | if not (is_normal or is_prerelease): 27 | raise ValueError(f"Tag name '{tag_name}' does not match expected pattern.") 28 | 29 | # Build the command 30 | cmd = [ 31 | "gh", 32 | "release", 33 | "create", 34 | tag_name, 35 | "--generate-notes", 36 | ] 37 | 38 | if is_prerelease: 39 | cmd.append("--prerelease") 40 | 41 | cmd.extend( 42 | [ 43 | "ribasim_linux.zip", 44 | "ribasim_windows.zip", 45 | "ribasim_qgis.zip", 46 | "generated_testmodels.zip", 47 | ] 48 | ) 49 | 50 | # Create a release using gh 51 | subprocess.check_call(cmd) 52 | 53 | 54 | if __name__ == "__main__": 55 | proc = git_describe() 56 | if proc.returncode == 0 and proc.stdout.startswith("v20"): 57 | main(proc) 58 | else: 59 | print("Current checkout is not a tag starting with 'v20', no release made.") 60 | print(proc.stderr) 61 | -------------------------------------------------------------------------------- /utils/plot.jl: -------------------------------------------------------------------------------- 1 | # Utility functions to plot Ribasim results. 2 | 3 | using DataFrames: DataFrame 4 | using Makie: Figure, Axis, scatterlines!, axislegend 5 | using Ribasim: Ribasim, Model 6 | 7 | function plot_basin_data!(model::Model, ax::Axis, column::Symbol) 8 | basin_data = DataFrame(Ribasim.basin_data(model)) 9 | for node_id in unique(basin_data.node_id) 10 | group = filter(:node_id => ==(node_id), basin_data) 11 | scatterlines!(ax, group.time, getproperty(group, column); label = "Basin #$node_id") 12 | end 13 | 14 | axislegend(ax) 15 | return nothing 16 | end 17 | 18 | function plot_basin_data(model::Model) 19 | f = Figure() 20 | ax1 = Axis(f[1, 1]; ylabel = "level [m]") 21 | ax2 = Axis(f[2, 1]; xlabel = "time", ylabel = "storage [m³]") 22 | plot_basin_data!(model, ax1, :level) 23 | plot_basin_data!(model, ax2, :storage) 24 | f 25 | end 26 | 27 | function plot_flow!(model::Model, ax::Axis, link_metadata::Ribasim.LinkMetadata) 28 | flow_table = DataFrame(Ribasim.flow_data(model)) 29 | flow_table = filter(:link_id => ==(link_metadata.id), flow_table) 30 | label = "$(link_metadata.link[1]) → $(link_metadata.link[2])" 31 | scatterlines!(ax, flow_table.time, flow_table.flow_rate; label) 32 | return nothing 33 | end 34 | 35 | function plot_flow(model::Model; skip_conservative_out = true) 36 | f = Figure() 37 | ax = Axis(f[1, 1]; xlabel = "time", ylabel = "flow rate [m³s⁻¹]") 38 | for link_metadata in values(model.integrator.p.p_independent.graph.edge_data) 39 | if skip_conservative_out && 40 | link_metadata.link[1].type in Ribasim.conservative_nodetypes 41 | continue 42 | end 43 | plot_flow!(model, ax, link_metadata) 44 | end 45 | axislegend(ax) 46 | f 47 | end 48 | -------------------------------------------------------------------------------- /utils/s3_download.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from os import makedirs 3 | 4 | from minio import Minio 5 | from minio.error import S3Error 6 | from s3_settings import settings 7 | 8 | minioServer = "s3.deltares.nl" 9 | bucketName = "ribasim" 10 | 11 | parser = argparse.ArgumentParser( 12 | description="Download a folder (recursively) from the MinIO server" 13 | ) 14 | parser.add_argument("remote", help="The path to download in the MinIO server") 15 | parser.add_argument("local", help="The path to the local file system") 16 | parser.add_argument( 17 | "--accesskey", 18 | help="The access key to access the MinIO server", 19 | default=settings.minio_access_key, 20 | ) 21 | parser.add_argument( 22 | "--secretkey", 23 | help="The secret key to access the MinIO server", 24 | default=settings.minio_secret_key, 25 | ) 26 | args = parser.parse_args() 27 | if (not args.accesskey) or (not args.secretkey): 28 | raise ValueError("No MinIO access key or secret key provided") 29 | 30 | client = Minio(minioServer, access_key=args.accesskey, secret_key=args.secretkey) 31 | objects = list(client.list_objects(bucketName, prefix=args.remote, recursive=True)) 32 | 33 | if not objects: 34 | raise ValueError(f"Remote path '{args.remote}' does not exist or is empty.") 35 | 36 | for obj in objects: 37 | try: 38 | if obj.is_dir: 39 | local_dir = f"models/{args.local}" + obj.object_name.removeprefix( 40 | args.remote 41 | ) 42 | makedirs(local_dir, exist_ok=True) 43 | else: 44 | client.fget_object( 45 | bucketName, 46 | obj.object_name, 47 | f"models/{args.local}" + obj.object_name.removeprefix(args.remote), 48 | ) 49 | except S3Error as e: 50 | print(f"Error occurred: {e}") 51 | -------------------------------------------------------------------------------- /core/test/data/allocation_problems/drain_surplus/allocation_problem_2.lp: -------------------------------------------------------------------------------- 1 | minimize 2 | obj: 3 | subject to 4 | storage_constraint_lower_Basin_#1,1_: 1 basin_storage_change_Basin_#1_ + 1 level_demand_error_Basin_#1,1,lower,first_ >= -250 5 | storage_constraint_upper_Basin_#1,1_: -1 basin_storage_change_Basin_#1_ + 1 level_demand_error_Basin_#1,1,upper,first_ >= -750 6 | level_demand_fairness_error_constraint_Basin_#1,1,lower_: -0.001 level_demand_error_Basin_#1,1,lower,first_ + 1 level_demand_error_Basin_#1,1,lower,second_ + 1 average_storage_unit_error_1,lower_ >= 0 7 | level_demand_fairness_error_constraint_Basin_#1,1,upper_: -0.001 level_demand_error_Basin_#1,1,upper,first_ + 1 level_demand_error_Basin_#1,1,upper,second_ + 1 average_storage_unit_error_1,upper_ >= 0 8 | flow_conservation_outlet_Outlet_#2_: 1 flow_(Basin_#1,_Outlet_#2)_ - 1 flow_(Outlet_#2,_Terminal_#3)_ = 0 9 | volume_conservation_Basin_#1_: 1 basin_storage_change_Basin_#1_ + 1 low_storage_factor_Basin_#1_ + 1 flow_(Basin_#1,_Outlet_#2)_ = 1 10 | average_storage_unit_error_constraint_1,upper_: -1 level_demand_error_Basin_#1,1,upper,first_ + 1000 average_storage_unit_error_1,upper_ = 0 11 | average_storage_unit_error_constraint_1,lower_: -1 level_demand_error_Basin_#1,1,lower,first_ + 1000 average_storage_unit_error_1,lower_ = 0 12 | Bounds 13 | -1.6666666666666667 <= basin_storage_change_Basin_#1_ <= 6.666666666666667 14 | 0 <= low_storage_factor_Basin_#1_ <= 1 15 | 0 <= flow_(Basin_#1,_Outlet_#2)_ <= 0.14400000000000002 16 | 0 <= flow_(Outlet_#2,_Terminal_#3)_ <= 0.14400000000000002 17 | level_demand_error_Basin_#1,1,lower,first_ >= 0 18 | level_demand_error_Basin_#1,1,lower,second_ >= 0 19 | level_demand_error_Basin_#1,1,upper,first_ >= 0 20 | level_demand_error_Basin_#1,1,upper,second_ >= 0 21 | average_storage_unit_error_1,lower_ >= 0 22 | average_storage_unit_error_1,upper_ >= 0 23 | End 24 | -------------------------------------------------------------------------------- /python/ribasim/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import ribasim 3 | import ribasim_testmodels 4 | 5 | 6 | # we can't call fixtures directly, so we keep separate versions 7 | @pytest.fixture() 8 | def basic() -> ribasim.Model: 9 | return ribasim_testmodels.basic_model() 10 | 11 | 12 | @pytest.fixture() 13 | def basic_arrow() -> ribasim.Model: 14 | return ribasim_testmodels.basic_arrow_model() 15 | 16 | 17 | @pytest.fixture() 18 | def basic_transient() -> ribasim.Model: 19 | return ribasim_testmodels.basic_transient_model() 20 | 21 | 22 | @pytest.fixture() 23 | def bucket() -> ribasim.Model: 24 | return ribasim_testmodels.bucket_model() 25 | 26 | 27 | @pytest.fixture() 28 | def pid_control_equation() -> ribasim.Model: 29 | return ribasim_testmodels.pid_control_equation_model() 30 | 31 | 32 | @pytest.fixture() 33 | def tabulated_rating_curve() -> ribasim.Model: 34 | return ribasim_testmodels.tabulated_rating_curve_model() 35 | 36 | 37 | @pytest.fixture() 38 | def outlet() -> ribasim.Model: 39 | return ribasim_testmodels.outlet_model() 40 | 41 | 42 | @pytest.fixture() 43 | def backwater() -> ribasim.Model: 44 | return ribasim_testmodels.backwater_model() 45 | 46 | 47 | @pytest.fixture() 48 | def discrete_control_of_pid_control() -> ribasim.Model: 49 | return ribasim_testmodels.discrete_control_of_pid_control_model() 50 | 51 | 52 | @pytest.fixture() 53 | def level_range() -> ribasim.Model: 54 | return ribasim_testmodels.level_range_model() 55 | 56 | 57 | @pytest.fixture() 58 | def trivial() -> ribasim.Model: 59 | return ribasim_testmodels.trivial_model() 60 | 61 | 62 | @pytest.fixture() 63 | def tabulated_rating_curve_control() -> ribasim.Model: 64 | return ribasim_testmodels.tabulated_rating_curve_control_model() 65 | 66 | 67 | @pytest.fixture() 68 | def drought() -> ribasim.Model: 69 | return ribasim_testmodels.drought_model() 70 | -------------------------------------------------------------------------------- /utils/testmodelrun.jl: -------------------------------------------------------------------------------- 1 | import Ribasim 2 | 3 | include("utils.jl") 4 | 5 | """ 6 | Run all testmodels in parallel and check if they pass. 7 | 8 | A selection can be made by passing the name(s) of the individual testmodel(s) as (an) argument(s). 9 | """ 10 | function main(ARGS) 11 | toml_paths = get_testmodels() 12 | if length(ARGS) > 0 13 | toml_paths = filter(x -> basename(dirname(x)) in ARGS, toml_paths) 14 | end 15 | n_model = length(toml_paths) 16 | n_pass = 0 17 | n_fail = 0 18 | lk = ReentrantLock() 19 | failed = fill("", length(toml_paths)) 20 | skipped_allocation = fill("", length(toml_paths)) 21 | 22 | Threads.@threads for i in eachindex(toml_paths) 23 | toml_path = toml_paths[i] 24 | modelname = basename(dirname(toml_path)) 25 | 26 | if Ribasim.Config(toml_path).experimental.allocation 27 | skipped_allocation[i] = modelname 28 | continue 29 | end 30 | 31 | ret_code = Ribasim.main(toml_path) 32 | 33 | # Treat models starting with "invalid_" as expected to fail (non-zero ret_code means pass) :) 34 | if startswith(modelname, "invalid_") 35 | ret_code = ret_code == 0 ? 1 : 0 36 | end 37 | 38 | lock(lk) do 39 | if ret_code != 0 40 | failed[i] = modelname 41 | n_fail += 1 42 | else 43 | n_pass += 1 44 | end 45 | end 46 | end 47 | 48 | println("Ran $n_model models, $n_pass passed, $n_fail failed.\n") 49 | if length(skipped_allocation) > 0 50 | println("Skipped the following models that use allocation:") 51 | foreach(println, skipped_allocation) 52 | end 53 | if n_fail > 0 54 | println("Failed models:") 55 | foreach(println, failed) 56 | error("Model run failed") 57 | end 58 | end 59 | 60 | main(ARGS) 61 | -------------------------------------------------------------------------------- /docs/dev/python.qmd: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Python tooling development" 3 | --- 4 | 5 | # Set up the developer environment 6 | 7 | ## Running the tests {#sec-test} 8 | 9 | In order to run tests on Ribasim Python execute 10 | 11 | ```sh 12 | pixi run test-ribasim-python 13 | ``` 14 | 15 | ## Updating example notebooks 16 | 17 | Make sure to run `Clear All Outputs` on the notebook before committing. 18 | 19 | ## Prepare model input 20 | 21 | Before running the Julia tests or building binaries, example model input needs to created. 22 | This is done by running the following: 23 | 24 | ```sh 25 | pixi run generate-testmodels 26 | ``` 27 | 28 | This places example model input files under `./generated_testmodels/`. 29 | If the example models change, re-run this script. 30 | 31 | ## Setup Visual Studio Code (optional) {#sec-vscode} 32 | 33 | Install the [Python](https://marketplace.visualstudio.com/items?itemName=ms-python.python), [ruff](https://marketplace.visualstudio.com/items?itemName=charliermarsh.ruff) and [autoDocstring](https://marketplace.visualstudio.com/items?itemName=njpwerner.autodocstring) extensions. 34 | 35 | ## Linting 36 | 37 | To run our linting suite locally, execute: 38 | 39 | ```sh 40 | pixi run lint 41 | ``` 42 | 43 | # Code maintenance {#sec-codecov} 44 | 45 | For new features new tests have to be added. To monitor how much of the code is covered by the tests we use [Codecov](https://about.codecov.io/). 46 | For a simple overview of the local code coverage run 47 | ```sh 48 | pixi shell 49 | pytest --cov=ribasim tests/ 50 | ``` 51 | from `python/ribasim`. For an extensive overview in `html` format use 52 | ```sh 53 | pixi shell 54 | pytest --cov=ribasim --cov-report=html tests/ 55 | ``` 56 | which creates a folder `htmlcov` in the working directory. To see te contents open `htmlcov/index.html` in a browser. 57 | 58 | The code coverage of pushed branches can be seen [here](https://app.codecov.io/gh/Deltares/Ribasim). 59 | -------------------------------------------------------------------------------- /docs/guide/debug.qmd: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Debugging models" 3 | --- 4 | 5 | ## Slow models 6 | 7 | When your model is slow, it's often only a handful of nodes that are hard to solve. If the model finishes or is interrupted, convergence bottlenecks are shown like so: 8 | 9 | ```julia 10 | ┌ Info: Convergence bottlenecks in descending order of severity: 11 | │ ManningResistance #251242 = 0.09023997405863035 12 | │ ManningResistance #70523 = 0.006218636603583534 13 | │ ManningResistance #251181 = 0.004716432403226626 14 | │ ManningResistance #251182 = 0.0035319514660666165 15 | └ ManningResistance #591558 = 0.003284110004804508 16 | ``` 17 | 18 | It's best to inspect these nodes, and try to adjust the parametrization, or merge smaller nodes. You can find the convergence measure per node over time in the `flow.arrow` and `basin.arrow` [output files](/reference/usage.qmd#sec-results). 19 | 20 | To gain further insight into model performance, one can inspect the `solver_stats.arrow` output file, which gives the number of computations, number of rejected and accepted solutions, and the size of each calculation timestep. 21 | 22 | ## Unstable models 23 | 24 | When your model exits with a message like so: 25 | ```julia 26 | ┌ Error: The model exited at model time 2024-01-27T14:46:17.791 with return code Unstable. See https://docs.sciml.ai/DiffEqDocs/stable/basics/solution/#retcodes 27 | ``` 28 | 29 | it's best to rerun the model with `saveat = 0` in the [solver](/reference/usage.qmd#sec-solver-settings) settings. The model might then instead exit with 30 | 31 | ```julia 32 | ┌ Error: Too large water balance error 33 | │ id = Basin #2 34 | │ balance_error = 0.0017985748886167501 35 | │ relative_error = 1.3503344464431657 36 | ``` 37 | 38 | which helps you pin down the problematic node(s). 39 | The normal output for every calculation timestep is written until the moment of error, so one can use this information to understand the problem. 40 | -------------------------------------------------------------------------------- /core/test/data/allocation_problems/linear_resistance_demand/allocation_problem_2.lp: -------------------------------------------------------------------------------- 1 | minimize 2 | obj: 3 | subject to 4 | flow_demand_relative_error_constraint_LinearResistance_#2,1_: 1 flow_demand_allocated_LinearResistance_#2,1_ + 2 flow_demand_error_LinearResistance_#2,1,first_ >= 2 5 | flow_demand_fairness_error_constraint_LinearResistance_#2,1_: -1 flow_demand_error_LinearResistance_#2,1,first_ + 1 flow_demand_error_LinearResistance_#2,1,second_ + 1 average_flow_unit_error_1_ >= 0 6 | flow_conservation_linear_resistance_LinearResistance_#2_: 1 flow_(Basin_#1,_LinearResistance_#2)_ - 1 flow_(LinearResistance_#2,_Basin_#3)_ = 0 7 | volume_conservation_Basin_#1_: 1 basin_storage_change_Basin_#1_ + 1 low_storage_factor_Basin_#1_ + 1 flow_(Basin_#1,_LinearResistance_#2)_ = 1 8 | volume_conservation_Basin_#3_: 1 basin_storage_change_Basin_#3_ + 1 low_storage_factor_Basin_#3_ - 1 flow_(LinearResistance_#2,_Basin_#3)_ = 1 9 | linear_resistance_constraint_LinearResistance_#2_: -0.01 basin_storage_change_Basin_#1_ + 0.01 basin_storage_change_Basin_#3_ + 1 flow_(Basin_#1,_LinearResistance_#2)_ = 1 10 | flow_demand_allocated_sum_constraint_LinearResistance_#2_: 1 flow_(Basin_#1,_LinearResistance_#2)_ - 1 flow_demand_allocated_LinearResistance_#2,1_ - 1 flow_demand_extra_LinearResistance_#2_ = 0 11 | average_flow_unit_error_constraint_1_: -1 flow_demand_error_LinearResistance_#2,1,first_ + 1 average_flow_unit_error_1_ = 0 12 | Bounds 13 | -2 <= basin_storage_change_Basin_#1_ <= 8 14 | -2 <= basin_storage_change_Basin_#3_ <= 8 15 | 0 <= low_storage_factor_Basin_#1_ <= 1 16 | 0 <= low_storage_factor_Basin_#3_ <= 1 17 | -345.6 <= flow_(Basin_#1,_LinearResistance_#2)_ <= 345.6 18 | -345.6 <= flow_(LinearResistance_#2,_Basin_#3)_ <= 345.6 19 | -86400000 <= flow_demand_allocated_LinearResistance_#2,1_ <= 2 20 | 0 <= flow_demand_extra_LinearResistance_#2_ <= 86400000 21 | 0 <= flow_demand_error_LinearResistance_#2,1,first_ <= 1 22 | 0 <= flow_demand_error_LinearResistance_#2,1,second_ <= 1 23 | 0 <= average_flow_unit_error_1_ <= 1 24 | End 25 | -------------------------------------------------------------------------------- /ribasim_qgis/tests/core/test_model.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from qgis.testing import unittest 4 | 5 | from ribasim_qgis.core.model import ( 6 | get_database_path_from_model_file, 7 | get_directory_path_from_model_file, 8 | ) 9 | 10 | 11 | class TestModel(unittest.TestCase): 12 | tests_folder_path = Path(__file__).parent.parent.resolve() 13 | data_folder_path = tests_folder_path / "data" 14 | 15 | def test_get_directory_path_from_model_file(self): 16 | """Tests that get_directory_path_from_model_file() can resolve paths from a toml file.""" 17 | for test_case in [("input_dir", "."), ("results_dir", "results")]: 18 | with self.subTest(property=test_case[0], value=test_case[1]): 19 | path = get_directory_path_from_model_file( 20 | self.data_folder_path / "simple_valid.toml", 21 | property=test_case[0], 22 | ) 23 | self.assertTrue(path.is_absolute(), msg=f"{path} is not absolute") 24 | self.assertTrue( 25 | path.is_relative_to(self.tests_folder_path), 26 | msg=f"Path '{path}' is not relative to {self.tests_folder_path}", 27 | ) 28 | self.assertEqual(path, self.data_folder_path / test_case[1]) 29 | 30 | def test_get_database_path_from_model_file(self): 31 | """Tests that get_database_path_from_model_file() can find the input directory and appends the database.gpkg to it.""" 32 | path = get_database_path_from_model_file( 33 | self.data_folder_path / "simple_valid.toml" 34 | ) 35 | self.assertTrue(path.is_absolute(), msg=f"{path} is not absolute") 36 | self.assertTrue( 37 | path.is_relative_to(self.tests_folder_path), 38 | msg=f"Path '{path}' is not relative to {self.tests_folder_path}", 39 | ) 40 | self.assertEqual(path, self.data_folder_path / "database.gpkg") 41 | 42 | 43 | if __name__ == "__main__": 44 | unittest.main() 45 | -------------------------------------------------------------------------------- /python/ribasim_api/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import platform 2 | from pathlib import Path 3 | 4 | import pytest 5 | import ribasim 6 | from ribasim_api import RibasimApi 7 | from ribasim_testmodels import ( 8 | basic_model, 9 | basic_transient_model, 10 | leaky_bucket_model, 11 | two_basin_model, 12 | user_demand_model, 13 | ) 14 | 15 | 16 | @pytest.fixture(scope="session") 17 | def libribasim_paths() -> tuple[Path, Path]: 18 | repo_root = Path(__file__).parents[3].resolve() 19 | lib_or_bin = "bin" if platform.system() == "Windows" else "lib" 20 | extension = ".dll" if platform.system() == "Windows" else ".so" 21 | lib_folder = repo_root / "build" / "ribasim" / lib_or_bin 22 | lib_path = lib_folder / f"libribasim{extension}" 23 | return lib_path, lib_folder 24 | 25 | 26 | @pytest.fixture(scope="session", autouse=True) 27 | def load_julia(libribasim_paths) -> None: 28 | lib_path, lib_folder = libribasim_paths 29 | libribasim = RibasimApi(lib_path, lib_folder) 30 | libribasim.init_julia() 31 | 32 | 33 | @pytest.fixture(scope="function") 34 | def libribasim(libribasim_paths, request) -> RibasimApi: 35 | lib_path, lib_folder = libribasim_paths 36 | libribasim = RibasimApi(lib_path, lib_folder) 37 | 38 | # If initialized, call finalize() at end of use 39 | request.addfinalizer(libribasim.__del__) 40 | return libribasim 41 | 42 | 43 | # we can't call fixtures directly, so we keep separate versions 44 | @pytest.fixture(scope="session") 45 | def basic() -> ribasim.Model: 46 | return basic_model() 47 | 48 | 49 | @pytest.fixture(scope="session") 50 | def basic_transient(basic) -> ribasim.Model: 51 | return basic_transient_model(basic) 52 | 53 | 54 | @pytest.fixture(scope="session") 55 | def leaky_bucket() -> ribasim.Model: 56 | return leaky_bucket_model() 57 | 58 | 59 | @pytest.fixture(scope="session") 60 | def user_demand() -> ribasim.Model: 61 | return user_demand_model() 62 | 63 | 64 | @pytest.fixture(scope="session") 65 | def two_basin() -> ribasim.Model: 66 | return two_basin_model() 67 | -------------------------------------------------------------------------------- /docs/_extensions/quarto-ext/include-code-files/include-code-files.lua: -------------------------------------------------------------------------------- 1 | --- include-code-files.lua – filter to include code from source files 2 | --- 3 | --- Copyright: © 2020 Bruno BEAUFILS 4 | --- License: MIT – see LICENSE file for details 5 | 6 | --- Dedent a line 7 | local function dedent (line, n) 8 | return line:sub(1,n):gsub(" ","") .. line:sub(n+1) 9 | end 10 | 11 | --- Filter function for code blocks 12 | local function transclude (cb) 13 | if cb.attributes.include then 14 | local content = "" 15 | local fh = io.open(cb.attributes.include) 16 | if not fh then 17 | io.stderr:write("Cannot open file " .. cb.attributes.include .. " | Skipping includes\n") 18 | else 19 | local number = 1 20 | local start = 1 21 | 22 | -- change hyphenated attributes to PascalCase 23 | for i,pascal in pairs({"startLine", "endLine"}) 24 | do 25 | local hyphen = pascal:gsub("%u", "-%0"):lower() 26 | if cb.attributes[hyphen] then 27 | cb.attributes[pascal] = cb.attributes[hyphen] 28 | cb.attributes[hyphen] = nil 29 | end 30 | end 31 | 32 | if cb.attributes.startLine then 33 | cb.attributes.startFrom = cb.attributes.startLine 34 | start = tonumber(cb.attributes.startLine) 35 | end 36 | for line in fh:lines ("L") 37 | do 38 | if cb.attributes.dedent then 39 | line = dedent(line, cb.attributes.dedent) 40 | end 41 | if number >= start then 42 | if not cb.attributes.endLine or number <= tonumber(cb.attributes.endLine) then 43 | content = content .. line 44 | end 45 | end 46 | number = number + 1 47 | end 48 | fh:close() 49 | end 50 | -- remove key-value pair for used keys 51 | cb.attributes.include = nil 52 | cb.attributes.startLine = nil 53 | cb.attributes.endLine = nil 54 | cb.attributes.dedent = nil 55 | -- return final code block 56 | return pandoc.CodeBlock(content, cb.attr) 57 | end 58 | end 59 | 60 | return { 61 | { CodeBlock = transclude } 62 | } 63 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ribasim 2 | 3 | [![Julia Tests](https://github.com/Deltares/Ribasim/actions/workflows/core_tests.yml/badge.svg)](https://github.com/Deltares/Ribasim/actions/workflows/core_tests.yml) 4 | [![Ribasim Python Tests](https://github.com/Deltares/Ribasim/actions/workflows/python_tests.yml/badge.svg)](https://github.com/Deltares/Ribasim/actions/workflows/python_tests.yml) 5 | [![QGIS Tests](https://github.com/Deltares/Ribasim/actions/workflows/qgis.yml/badge.svg)](https://github.com/Deltares/Ribasim/actions/workflows/qgis.yml) 6 | [![codecov](https://codecov.io/gh/Deltares/Ribasim/branch/main/graph/badge.svg)](https://codecov.io/gh/Deltares/Ribasim) 7 | 8 | **Documentation: https://ribasim.org/** 9 | 10 | Ribasim is a water resources model, designed to be the replacement of the regional surface 11 | water modules Mozart and SIMRES in the Netherlands Hydrological Instrument (NHI). Ribasim is 12 | a work in progress, it is a prototype that demonstrates all essential functionalities. 13 | 14 | Ribasim is written in the [Julia programming language](https://julialang.org/) and is built 15 | on top of the [SciML: Open Source Software for Scientific Machine Learning](https://sciml.ai/) 16 | libraries. 17 | 18 | # Download 19 | 20 | For most users the [latest release](https://github.com/Deltares/Ribasim/releases/latest) is recommended, it can be downloaded here: 21 | 22 | - Ribasim executable - Linux: [ribasim_linux.zip](https://github.com/Deltares/Ribasim/releases/latest/download/ribasim_linux.zip) 23 | - Ribasim executable - Windows: [ribasim_windows.zip](https://github.com/Deltares/Ribasim/releases/latest/download/ribasim_windows.zip) 24 | - QGIS plugin: [ribasim_qgis.zip](https://github.com/Deltares/Ribasim/releases/latest/download/ribasim_qgis.zip). 25 | - Generated testmodels: [generated_testmodels.zip](https://github.com/Deltares/Ribasim/releases/latest/download/generated_testmodels.zip) 26 | 27 | # Example model 28 | 29 | Ribasim model of the main water distribution network in the Netherlands. 30 | 31 | ![dutch_model_on_map](https://github.com/user-attachments/assets/5095a4fe-c336-4380-aa0c-851c851d3895) 32 | -------------------------------------------------------------------------------- /docs/reference/node/level-boundary.qmd: -------------------------------------------------------------------------------- 1 | --- 2 | title: "LevelBoundary" 3 | --- 4 | 5 | LevelBoundary is a node whose water level is determined by the input. 6 | It can be used as a boundary condition like the level of the sea or a lake. 7 | Since the water level is unaffected by flow, it acts like an infinitely large Basin. 8 | Connect the LevelBoundary to a node that will look at the level to calculate the flow, like a LinearResistance. 9 | 10 | # Tables 11 | 12 | ## Static 13 | 14 | column | type | unit | restriction 15 | ------------- | ------- | ------------ | ----------- 16 | node_id | Int32 | - | 17 | level | Float64 | $\text{m}$ | - 18 | 19 | ## Time 20 | 21 | This table is the transient form of the `LevelBoundary` table. 22 | The only difference is that a time column is added. 23 | With this the levels can be updated over time. In between the given times the 24 | level is interpolated linearly, and outside the flow rate is constant given by the 25 | nearest time value. 26 | Note that a `node_id` can be either in this table or in the static one, but not both. 27 | 28 | column | type | unit | restriction 29 | --------- | ------- | ------------ | ----------- 30 | node_id | Int32 | - | 31 | time | DateTime | - | 32 | level | Float64 | $\text{m}$ | - 33 | 34 | ## Concentration {#sec-level-boundary-conc} 35 | This table defines the concentration of substances for the flow from the LevelBoundary. 36 | 37 | column | type | unit | restriction 38 | -------------- | -------- | --------------------- | ----------- 39 | node_id | Int32 | - | 40 | time | DateTime | - | 41 | substance | String | - | can correspond to known Delwaq substances 42 | concentration | Float64 | $\text{g}/\text{m}^3$ | 43 | 44 | # Equations 45 | 46 | A LevelBoundary can be connected to a Basin via a LinearResistance. 47 | This boundary node will then exchange water with the Basin based on the difference in water level between the two. 48 | -------------------------------------------------------------------------------- /python/ribasim_testmodels/ribasim_testmodels/trivial.py: -------------------------------------------------------------------------------- 1 | from ribasim.config import Experimental, Node, Results 2 | from ribasim.model import Model 3 | from ribasim.nodes import basin, tabulated_rating_curve 4 | from shapely.geometry import Point 5 | 6 | 7 | def trivial_model() -> Model: 8 | """Trivial model with just a basin, tabulated rating curve and terminal node.""" 9 | model = Model( 10 | starttime="2020-01-01", 11 | endtime="2021-01-01", 12 | crs="EPSG:28992", 13 | results=Results(subgrid=True, compression=False), 14 | use_validation=True, 15 | experimental=Experimental(concentration=True), 16 | ) 17 | 18 | # Convert steady forcing to m/s 19 | # 2 mm/d precipitation, 1 mm/d evaporation 20 | precipitation = 0.002 / 86400 21 | potential_evaporation = 0.001 / 86400 22 | 23 | # Create a subgrid level interpolation from one basin to three elements. Scale one to one, but: 24 | # 22. start at -1.0 25 | # 11. start at 0.0 26 | # 33. start at 1.0 27 | basin6 = model.basin.add( 28 | Node(6, Point(400, 200)), 29 | [ 30 | basin.Static( 31 | precipitation=[precipitation], 32 | potential_evaporation=[potential_evaporation], 33 | ), 34 | basin.Profile(area=[0.01, 1000.0], level=[0.0, 1.0]), 35 | basin.State(level=[0.04471158417652035]), 36 | basin.Subgrid( 37 | subgrid_id=[22, 22, 11, 11, 33, 33], 38 | basin_level=[0.0, 1.0, 0.0, 1.0, 0.0, 1.0], 39 | subgrid_level=[-1.0, 0.0, 0.0, 1.0, 1.0, 2.0], 40 | ), 41 | ], 42 | ) 43 | 44 | # TODO largest signed 32 bit integer, to check encoding 45 | terminal_id = 2147483647 46 | term = model.terminal.add(Node(terminal_id, Point(500, 200))) 47 | trc0 = model.tabulated_rating_curve.add( 48 | Node(0, Point(450, 200)), 49 | [tabulated_rating_curve.Static(level=[0.0, 1.0], flow_rate=[0.0, 10 / 86400])], 50 | ) 51 | 52 | model.link.add(basin6, trc0, link_id=100) 53 | model.link.add(trc0, term) 54 | 55 | return model 56 | -------------------------------------------------------------------------------- /python/ribasim_testmodels/ribasim_testmodels/bucket.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pandas as pd 3 | import ribasim 4 | from ribasim.config import Experimental, Node 5 | from ribasim.nodes import ( 6 | basin, 7 | ) 8 | from shapely.geometry import Point 9 | 10 | 11 | def bucket_model() -> ribasim.Model: 12 | """Bucket model with just a single basin at Deltares' headquarter.""" 13 | model = ribasim.Model( 14 | starttime="2020-01-01", 15 | endtime="2021-01-01", 16 | crs="EPSG:28992", 17 | experimental=Experimental(concentration=True), 18 | ) 19 | 20 | model.basin.add( 21 | Node(1, Point(85825.6, 444613.9)), 22 | [ 23 | basin.Profile( 24 | area=[1000.0, 1000.0], 25 | level=[0.0, 1.0], 26 | ), 27 | basin.State(level=[1.0]), 28 | basin.Static( 29 | drainage=[np.nan], 30 | potential_evaporation=[np.nan], 31 | infiltration=[np.nan], 32 | precipitation=[np.nan], 33 | ), 34 | ], 35 | ) 36 | return model 37 | 38 | 39 | def leaky_bucket_model() -> ribasim.Model: 40 | """Bucket model with dynamic forcing with missings at Deltares' headquarter.""" 41 | model = ribasim.Model( 42 | starttime="2020-01-01", 43 | endtime="2020-01-05", 44 | crs="EPSG:28992", 45 | experimental=Experimental(concentration=True), 46 | ) 47 | 48 | model.basin.add( 49 | Node(1, Point(85825.6, 444613.9)), 50 | [ 51 | basin.Profile( 52 | area=[1000.0, 1000.0], 53 | level=[0.0, 1.0], 54 | ), 55 | basin.State(level=[1.0]), 56 | basin.Time( 57 | time=pd.date_range("2020-01-01", "2020-01-05"), 58 | node_id=1, 59 | drainage=[0.003, np.nan, 0.001, 0.002, 0.0], 60 | potential_evaporation=np.nan, 61 | infiltration=[np.nan, 0.001, 0.002, 0.0, 0.0], 62 | precipitation=np.nan, 63 | ), 64 | ], 65 | ) 66 | 67 | return model 68 | -------------------------------------------------------------------------------- /.github/workflows/core_tests.yml: -------------------------------------------------------------------------------- 1 | name: Julia Tests 2 | on: 3 | push: 4 | branches: [main] 5 | paths: ["core/**", "python/**", "pixi.toml", "pixi.lock", "Project.toml", "Manifest.toml"] 6 | tags: ["*"] 7 | pull_request: 8 | paths: ["core/**", "python/**", "pixi.toml", "pixi.lock", "Project.toml", "Manifest.toml"] 9 | merge_group: 10 | workflow_dispatch: 11 | concurrency: 12 | group: ${{ github.workflow }}-${{ github.ref }} 13 | cancel-in-progress: true 14 | # needed to allow julia-actions/cache to delete old caches that it has created 15 | permissions: 16 | actions: write 17 | contents: read 18 | jobs: 19 | test: 20 | name: Julia ${{ matrix.os }} - ${{ matrix.arch }} 21 | runs-on: ${{ matrix.os }} 22 | strategy: 23 | fail-fast: false 24 | matrix: 25 | os: 26 | - ubuntu-latest 27 | # https://github.com/Deltares/Ribasim/issues/825 28 | # - macOS-latest 29 | - windows-latest 30 | arch: 31 | - x64 32 | steps: 33 | - uses: actions/checkout@v6 34 | - uses: julia-actions/cache@v2 35 | - uses: prefix-dev/setup-pixi@v0.9.3 36 | with: 37 | pixi-version: "latest" 38 | - name: Prepare pixi 39 | run: pixi run install-ci 40 | - name: Test Ribasim Core 41 | run: | 42 | pixi run test-ribasim-core-cov 43 | id: julia-tests 44 | env: 45 | JULIA_NUM_THREADS: 2 46 | - name: Upload allocation debug files 47 | if: always() 48 | uses: actions/upload-artifact@v6 49 | with: 50 | name: allocation-debug-${{ matrix.os }}-${{ matrix.arch }}-${{ github.run_id }} 51 | path: | 52 | **/allocation_infeasible_problem.lp 53 | **/allocation_problem_from_tests_*.lp 54 | **/allocation_analysis_*.log 55 | retention-days: 7 56 | if-no-files-found: ignore 57 | - uses: julia-actions/julia-processcoverage@v1 58 | with: 59 | directories: core/src 60 | - uses: codecov/codecov-action@v5 61 | with: 62 | files: lcov.info 63 | token: ${{ secrets.CODECOV_TOKEN }} 64 | -------------------------------------------------------------------------------- /docs/reference/node/level-demand.qmd: -------------------------------------------------------------------------------- 1 | --- 2 | title: "LevelDemand" 3 | --- 4 | 5 | A `LevelDemand` node associates a minimum and a maximum level (possibly multiple over different priorities) with connected Basins to be used by the allocation algorithm. 6 | 7 | Since this connection conveys information rather than flow, an outgoing control link must be used. 8 | Below the minimum level the Basin has a demand, above the maximum level the Basin has a surplus and acts as a source. 9 | The source can be used by all nodes with demands in order of demand priority. 10 | 11 | The same `LevelDemand` node can be used for Basins in different subnetworks. 12 | 13 | Both `min_level` and `max_level` are optional, to be able to handle only the demand or surplus side. 14 | If both are missing, `LevelDemand` won't have any effects on allocation. 15 | 16 | # Tables 17 | 18 | ## Static 19 | 20 | column | type | unit | restriction 21 | --------------- | ------- | ------------ | ----------- 22 | node_id | Int32 | - | 23 | min_level | Float64 | $\text{m}$ | (optional, default -Inf) 24 | max_level | Float64 | $\text{m}$ | (optional, default Inf) 25 | demand_priority | Int32 | - | positive 26 | 27 | ## Time 28 | 29 | This table is the transient form of the `LevelDemand` table, in which time-dependent minimum and maximum levels can be supplied. 30 | With this the levels can be updated over time. In between the given times the 31 | levels are interpolated with forward fill (block interpolation), and outside the demand is constant given by the 32 | nearest time value. The allocation algorithm evaluates the interpolation at the end of the allocation time step. 33 | 34 | This is in contrast with UserDemand and FlowDemand, as allocation will aim to reach the desired level at the end of the allocation time step. 35 | 36 | column | type | unit | restriction 37 | --------------- | ------- | ------------ | ----------- 38 | node_id | Int32 | - | 39 | time | DateTime | - | 40 | min_level | Float64 | $\text{m}$ | - 41 | max_level | Float64 | $\text{m}$ | - 42 | demand_priority | Int32 | - | positive 43 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Docs 2 | on: 3 | push: 4 | branches: [main] 5 | paths: ["core/**", "docs/**", "python/**", "pixi.toml", "pixi.lock"] 6 | pull_request: 7 | paths: ["core/**", "docs/**", "python/**", "pixi.toml", "pixi.lock"] 8 | merge_group: 9 | workflow_dispatch: 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.ref }} 12 | cancel-in-progress: true 13 | # needed to allow julia-actions/cache to delete old caches that it has created 14 | permissions: 15 | actions: write 16 | contents: write 17 | pages: write 18 | jobs: 19 | publish: 20 | name: Docs Julia 21 | runs-on: ubuntu-latest 22 | timeout-minutes: 120 23 | permissions: 24 | contents: write 25 | steps: 26 | - name: Configure Git for quarto-publish 27 | run: | 28 | git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" 29 | git config --global user.name "github-actions[bot]" 30 | - uses: actions/checkout@v6 31 | - name: Propagate git credentials for v6 32 | run: | 33 | # Propagate git credentials to worktrees (required for actions/checkout@v6) 34 | # See: https://github.com/quarto-dev/quarto-actions/issues/133 35 | if git config get includeIf.gitdir:"$(git rev-parse --path-format=absolute --git-dir)".path &>/dev/null; then 36 | GIT_DIR=$(git rev-parse --path-format=absolute --git-dir) 37 | CRED_PATH=$(git config get --path includeIf.gitdir:"${GIT_DIR}".path) 38 | git config set --path includeIf.gitdir:"${GIT_DIR}/worktrees/*".path "${CRED_PATH}" 39 | fi 40 | - uses: julia-actions/cache@v2 41 | - uses: prefix-dev/setup-pixi@v0.9.3 42 | with: 43 | pixi-version: "latest" 44 | - name: Prepare pixi 45 | run: pixi run install-ci 46 | - name: Check Quarto installation and all engines 47 | run: pixi run quarto-check 48 | - name: Render Quarto Project 49 | run: pixi run quarto-render 50 | - name: Publish Quarto Project 51 | if: github.ref == 'refs/heads/main' 52 | run: pixi run quarto-publish 53 | env: 54 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 55 | -------------------------------------------------------------------------------- /utils/write_allocation_problems.jl: -------------------------------------------------------------------------------- 1 | import Ribasim 2 | 3 | include("utils.jl") 4 | 5 | """ 6 | For all test models that use allocation, write for each subnetwork the problem to file. 7 | 8 | A selection can be made by passing the name(s) of the individual testmodel(s) as (an) argument(s). 9 | """ 10 | function main(ARGS) 11 | toml_paths = get_testmodels() 12 | if length(ARGS) > 0 13 | toml_paths = filter(x -> basename(dirname(x)) in ARGS, toml_paths) 14 | end 15 | 16 | results_path = normpath(@__DIR__, "../core/test/data/allocation_problems") 17 | if ispath(results_path) 18 | rm(results_path; recursive = true) 19 | end 20 | 21 | mkdir(results_path) 22 | 23 | Threads.@threads for toml_path in toml_paths 24 | model_name = basename(dirname(toml_path)) 25 | 26 | if !startswith(model_name, "invalid_") 27 | config = Ribasim.Config(toml_path) 28 | 29 | if config.experimental.allocation 30 | try 31 | model = Ribasim.Model(config) 32 | (; allocation_models) = model.integrator.p.p_independent.allocation 33 | 34 | if !isempty(allocation_models) 35 | model_dir = normpath(results_path, model_name) 36 | mkdir(model_dir) 37 | end 38 | 39 | for allocation_model in allocation_models 40 | (; problem, subnetwork_id) = allocation_model 41 | 42 | Ribasim.write_problem_to_file( 43 | problem, 44 | config; 45 | info = false, 46 | path = normpath( 47 | model_dir, 48 | "allocation_problem_$subnetwork_id.lp", 49 | ), 50 | ) 51 | end 52 | println("Wrote allocation problem(s) for $model_name") 53 | catch e 54 | @error "Failed to process model $model_name" exception = e 55 | end 56 | end 57 | end 58 | end 59 | end 60 | 61 | main(ARGS) 62 | -------------------------------------------------------------------------------- /.teamcity/Ribasim/buildTypes/GenerateTestmodels.kt: -------------------------------------------------------------------------------- 1 | package Ribasim.buildTypes 2 | 3 | import Templates.* 4 | import Templates.GithubCommitStatusIntegration 5 | import jetbrains.buildServer.configs.kotlin.* 6 | import jetbrains.buildServer.configs.kotlin.buildSteps.script 7 | import jetbrains.buildServer.configs.kotlin.triggers.vcs 8 | 9 | object GenerateTestmodels : BuildType({ 10 | templates(GithubCommitStatusIntegration) 11 | name = "Generate Testmodels" 12 | 13 | templates(GithubPullRequestsIntegration) 14 | 15 | artifactRules = """ribasim\generated_testmodels => generated_testmodels.zip""" 16 | publishArtifacts = PublishMode.SUCCESSFUL 17 | 18 | vcs { 19 | cleanCheckout = true 20 | root(Ribasim.vcsRoots.Ribasim, ". => ribasim") 21 | } 22 | 23 | steps { 24 | script { 25 | name = "Set up pixi" 26 | id = "RUNNER_2415" 27 | workingDir = "ribasim" 28 | scriptContent = """ 29 | #!/bin/bash 30 | # black magic 31 | source /usr/share/Modules/init/bash 32 | 33 | module load pixi 34 | pixi --version 35 | """.trimIndent() 36 | } 37 | script { 38 | name = "Generate testmodels" 39 | id = "RUNNER_2416" 40 | workingDir = "ribasim" 41 | scriptContent = """ 42 | #!/bin/bash 43 | # black magic 44 | source /usr/share/Modules/init/bash 45 | 46 | module load pixi 47 | pixi run generate-testmodels 48 | """.trimIndent() 49 | } 50 | } 51 | 52 | triggers { 53 | vcs { 54 | id = "TRIGGER_646" 55 | branchFilter = """ 56 | +: 57 | +:refs/pull/* 58 | +:pull/* 59 | """.trimIndent() 60 | triggerRules = "-:comment=skip ci:**" 61 | } 62 | } 63 | 64 | failureConditions { 65 | executionTimeoutMin = 120 66 | } 67 | 68 | requirements { 69 | doesNotEqual("env.OS", "Windows_NT", "RQ_275") 70 | doesNotEqual("teamcity.agent.name", "Default Agent", "RQ_339") 71 | } 72 | }) 73 | -------------------------------------------------------------------------------- /python/ribasim/ribasim/delwaq/parse.py: -------------------------------------------------------------------------------- 1 | """Read a Delwaq model generated from a Ribasim model and inject the results back to Ribasim.""" 2 | 3 | from pathlib import Path 4 | 5 | import ribasim 6 | from ribasim.utils import MissingOptionalModule, _concat 7 | 8 | try: 9 | import xugrid as xu 10 | except ImportError: 11 | xu = MissingOptionalModule("xugrid", "delwaq") 12 | 13 | 14 | def parse( 15 | model: Path | ribasim.Model, graph, substances, output_folder=None 16 | ) -> ribasim.Model: 17 | if not isinstance(model, ribasim.Model): 18 | model = ribasim.Model.read(model) 19 | else: 20 | model = model.copy(deep=True) 21 | 22 | # Output of Delwaq 23 | if output_folder is None: 24 | assert model.filepath is not None 25 | output_folder = model.filepath.parent / "delwaq" 26 | with xu.open_dataset(output_folder / "delwaq_map.nc") as ug: 27 | mapping = dict(graph.nodes(data="id")) 28 | # Continuity is a (default) tracer representing the mass balance 29 | substances.add("Continuity") 30 | 31 | dfs = [] 32 | for substance in substances: 33 | df = ( 34 | ug[f"ribasim_{substance.replace(' ', '_')}"] 35 | .to_dataframe() 36 | .reset_index() 37 | ) 38 | df.rename( 39 | columns={ 40 | "ribasim_nNodes": "node_id", 41 | "nTimesDlwq": "time", 42 | f"ribasim_{substance.replace(' ', '_')}": "concentration", 43 | }, 44 | inplace=True, 45 | ) 46 | df["substance"] = substance 47 | df.drop(columns=["ribasim_node_x", "ribasim_node_y"], inplace=True) 48 | # Map the node_id (logical index) to the original node_id 49 | # TODO Check if this is correct 50 | df.node_id += 1 51 | df.node_id = df.node_id.map(mapping) 52 | 53 | dfs.append(df) 54 | 55 | df = _concat(dfs).reset_index(drop=True) 56 | df.sort_values(["time", "node_id"], inplace=True) 57 | 58 | model.basin.concentration_external = df 59 | df.to_feather(model.results_path / "basin_concentration_external.arrow") 60 | 61 | return model 62 | -------------------------------------------------------------------------------- /core/test/data/allocation_problems/primary_and_secondary_subnetworks/allocation_problem_5.lp: -------------------------------------------------------------------------------- 1 | minimize 2 | obj: 3 | subject to 4 | user_demand_relative_error_constraint_UserDemand_#32,1_: 1 user_demand_allocated_UserDemand_#32,1_ + 2 user_demand_error_UserDemand_#32,1,first_ >= 2 5 | user_demand_fairness_error_constraint_UserDemand_#32,1_: -1 user_demand_error_UserDemand_#32,1,first_ + 1 user_demand_error_UserDemand_#32,1,second_ + 1 average_flow_unit_error_1_ >= 0 6 | flow_conservation_tabulated_rating_curve_TabulatedRatingCurve_#26_: 1 flow_(Basin_#25,_TabulatedRatingCurve_#26)_ - 1 flow_(TabulatedRatingCurve_#26,_Basin_#31)_ = 0 7 | volume_conservation_Basin_#25_: 1 basin_storage_change_Basin_#25_ + 1 low_storage_factor_Basin_#25_ - 1 flow_(Pump_#24,_Basin_#25)_ + 1 flow_(Basin_#25,_TabulatedRatingCurve_#26)_ = 1 8 | volume_conservation_Basin_#31_: 1 basin_storage_change_Basin_#31_ + 1 low_storage_factor_Basin_#31_ - 1 flow_(TabulatedRatingCurve_#26,_Basin_#31)_ + 1 flow_(Basin_#31,_UserDemand_#32)_ - 1 flow_(UserDemand_#32,_Basin_#31)_ = 1 9 | tabulated_rating_curve_constraint_TabulatedRatingCurve_#26_: -0.01 basin_storage_change_Basin_#25_ + 0.01 basin_storage_change_Basin_#31_ + 1 flow_(Basin_#25,_TabulatedRatingCurve_#26)_ = 1 10 | user_demand_allocated_sum_constraint_UserDemand_#32_: 1 flow_(Basin_#31,_UserDemand_#32)_ - 1 user_demand_allocated_UserDemand_#32,1_ = 0 11 | user_demand_return_flow_UserDemand_#32_: -0.5 flow_(Basin_#31,_UserDemand_#32)_ + 1 flow_(UserDemand_#32,_Basin_#31)_ = 0 12 | average_flow_unit_error_constraint_1_: -1 user_demand_error_UserDemand_#32,1,first_ + 1 average_flow_unit_error_1_ = 0 13 | Bounds 14 | -2 <= basin_storage_change_Basin_#25_ <= 8 15 | -2 <= basin_storage_change_Basin_#31_ <= 8 16 | 0 <= low_storage_factor_Basin_#25_ <= 1 17 | 0 <= low_storage_factor_Basin_#31_ <= 1 18 | 0 <= flow_(Pump_#24,_Basin_#25)_ <= 172.8 19 | 0 <= flow_(Basin_#25,_TabulatedRatingCurve_#26)_ <= 86400000 20 | 0 <= flow_(TabulatedRatingCurve_#26,_Basin_#31)_ <= 86400000 21 | 0 <= flow_(Basin_#31,_UserDemand_#32)_ <= 86400000 22 | 0 <= flow_(UserDemand_#32,_Basin_#31)_ <= 86400000 23 | 0 <= user_demand_allocated_UserDemand_#32,1_ <= 2 24 | 0 <= user_demand_error_UserDemand_#32,1,first_ <= 1 25 | 0 <= user_demand_error_UserDemand_#32,1,second_ <= 1 26 | 0 <= average_flow_unit_error_1_ <= 1 27 | End 28 | -------------------------------------------------------------------------------- /core/test/data/allocation_problems/secondary_networks_with_sources/allocation_problem_5.lp: -------------------------------------------------------------------------------- 1 | minimize 2 | obj: 3 | subject to 4 | user_demand_relative_error_constraint_UserDemand_#32,1_: 1 user_demand_allocated_UserDemand_#32,1_ + 2 user_demand_error_UserDemand_#32,1,first_ >= 2 5 | user_demand_fairness_error_constraint_UserDemand_#32,1_: -1 user_demand_error_UserDemand_#32,1,first_ + 1 user_demand_error_UserDemand_#32,1,second_ + 1 average_flow_unit_error_1_ >= 0 6 | flow_conservation_tabulated_rating_curve_TabulatedRatingCurve_#26_: 1 flow_(Basin_#25,_TabulatedRatingCurve_#26)_ - 1 flow_(TabulatedRatingCurve_#26,_Basin_#31)_ = 0 7 | volume_conservation_Basin_#25_: 1 basin_storage_change_Basin_#25_ + 1 low_storage_factor_Basin_#25_ - 1 flow_(Pump_#24,_Basin_#25)_ + 1 flow_(Basin_#25,_TabulatedRatingCurve_#26)_ = 1 8 | volume_conservation_Basin_#31_: 1 basin_storage_change_Basin_#31_ + 1 low_storage_factor_Basin_#31_ - 1 flow_(TabulatedRatingCurve_#26,_Basin_#31)_ + 1 flow_(Basin_#31,_UserDemand_#32)_ - 1 flow_(UserDemand_#32,_Basin_#31)_ = 1 9 | tabulated_rating_curve_constraint_TabulatedRatingCurve_#26_: -0.01 basin_storage_change_Basin_#25_ + 0.01 basin_storage_change_Basin_#31_ + 1 flow_(Basin_#25,_TabulatedRatingCurve_#26)_ = 1 10 | user_demand_allocated_sum_constraint_UserDemand_#32_: 1 flow_(Basin_#31,_UserDemand_#32)_ - 1 user_demand_allocated_UserDemand_#32,1_ = 0 11 | user_demand_return_flow_UserDemand_#32_: -0.5 flow_(Basin_#31,_UserDemand_#32)_ + 1 flow_(UserDemand_#32,_Basin_#31)_ = 0 12 | average_flow_unit_error_constraint_1_: -1 user_demand_error_UserDemand_#32,1,first_ + 1 average_flow_unit_error_1_ = 0 13 | Bounds 14 | -2 <= basin_storage_change_Basin_#25_ <= 8 15 | -2 <= basin_storage_change_Basin_#31_ <= 8 16 | 0 <= low_storage_factor_Basin_#25_ <= 1 17 | 0 <= low_storage_factor_Basin_#31_ <= 1 18 | 0 <= flow_(Pump_#24,_Basin_#25)_ <= 172.8 19 | 0 <= flow_(Basin_#25,_TabulatedRatingCurve_#26)_ <= 86400000 20 | 0 <= flow_(TabulatedRatingCurve_#26,_Basin_#31)_ <= 86400000 21 | 0 <= flow_(Basin_#31,_UserDemand_#32)_ <= 86400000 22 | 0 <= flow_(UserDemand_#32,_Basin_#31)_ <= 86400000 23 | 0 <= user_demand_allocated_UserDemand_#32,1_ <= 2 24 | 0 <= user_demand_error_UserDemand_#32,1,first_ <= 1 25 | 0 <= user_demand_error_UserDemand_#32,1,second_ <= 1 26 | 0 <= average_flow_unit_error_1_ <= 1 27 | End 28 | -------------------------------------------------------------------------------- /.teamcity/Templates/Build.kt: -------------------------------------------------------------------------------- 1 | package Templates 2 | 3 | 4 | import jetbrains.buildServer.configs.kotlin.Template 5 | import jetbrains.buildServer.configs.kotlin.* 6 | import jetbrains.buildServer.configs.kotlin.buildSteps.script 7 | 8 | fun generateBuildHeader(platformOs: String): String { 9 | if (platformOs == "Linux") { 10 | return """ 11 | #!/bin/bash 12 | # black magic 13 | source /usr/share/Modules/init/bash 14 | 15 | module load pixi 16 | module load gcc/12.2.0_gcc12.2.0 17 | """.trimIndent() + System.lineSeparator() 18 | } 19 | 20 | return "" 21 | } 22 | 23 | open class Build(platformOs: String) : Template() { 24 | init { 25 | name = "Build${platformOs}_Template" 26 | 27 | vcs { 28 | root(Ribasim.vcsRoots.Ribasim, ". => ribasim") 29 | cleanCheckout = true 30 | } 31 | 32 | val depot_path = generateJuliaDepotPath(platformOs) 33 | params { 34 | param("env.JULIA_DEPOT_PATH", depot_path) 35 | } 36 | 37 | dependencies { 38 | artifacts(AbsoluteId("Ribasim_${platformOs}_GenerateCache")) { 39 | buildRule = lastSuccessful() 40 | artifactRules = "cache.zip!** => %teamcity.build.checkoutDir%/.julia" 41 | } 42 | } 43 | 44 | val header = generateBuildHeader(platformOs) 45 | steps { 46 | script { 47 | name = "Set up pixi" 48 | id = "RUNNER_2415" 49 | workingDir = "ribasim" 50 | scriptContent = header + 51 | """ 52 | pixi --version 53 | pixi run install-ci 54 | """.trimIndent() 55 | } 56 | script { 57 | name = "Build binary" 58 | id = "RUNNER_2416" 59 | workingDir = "ribasim" 60 | scriptContent = header + 61 | """ 62 | pixi run build 63 | """.trimIndent() 64 | } 65 | } 66 | 67 | failureConditions { 68 | executionTimeoutMin = 120 69 | } 70 | } 71 | } 72 | 73 | object BuildWindows : Build("Windows") 74 | object BuildLinux : Build("Linux") 75 | -------------------------------------------------------------------------------- /core/integration_test/hws_integration_test.jl: -------------------------------------------------------------------------------- 1 | @testitem "HWS model integration test" begin 2 | using Dates 3 | using Statistics 4 | using Arrow 5 | using TOML 6 | include(joinpath(@__DIR__, "../test/utils.jl")) 7 | 8 | toml_path = normpath(@__DIR__, "../../models/integration.toml") 9 | @test ispath(toml_path) 10 | model = Ribasim.run(toml_path) 11 | @test model isa Ribasim.Model 12 | @test success(model) 13 | 14 | basin_bytes_bench = 15 | read(normpath(@__DIR__, "../../models/hws/benchmark/basin_state.arrow")) 16 | basin_bench = Arrow.Table(basin_bytes_bench) 17 | 18 | basin_bytes = 19 | read(normpath(dirname(toml_path), model.config.results_dir, "basin_state.arrow")) 20 | basin = Arrow.Table(basin_bytes) 21 | 22 | @testset "Results values" begin 23 | @test basin.node_id == basin_bench.node_id 24 | @test all(q -> abs(q) < 1.0, basin.level - basin_bench.level) 25 | end 26 | 27 | diff = basin.level - basin_bench.level 28 | 29 | timed = @timed Ribasim.run(toml_path) 30 | dt = Millisecond(round(Int, timed.time * 1000)) + Time(0) 31 | 32 | @tcstatistic "time" timed.time 33 | @tcstatistic "min_diff" minimum(diff) 34 | @tcstatistic "max_diff" maximum(diff) 35 | @tcstatistic "med_diff" median(diff) 36 | 37 | data = Dict( 38 | "time" => timed.time, 39 | "min_diff" => minimum(diff), 40 | "max_diff" => maximum(diff), 41 | "med_diff" => median(diff), 42 | ) 43 | open(joinpath(@__DIR__, "../../data/integration.toml"), "w") do io 44 | TOML.print(io, data) 45 | end 46 | 47 | # current benchmark in seconds, TeamCity is up to 4x slower than local 48 | benchmark_runtime = 60 49 | performance_diff = 50 | round((timed.time - benchmark_runtime) / benchmark_runtime * 100; digits = 2) 51 | if performance_diff < 0.0 52 | performance_diff = abs(performance_diff) 53 | @tcstatus "Runtime is $(dt) and it is $performance_diff % faster than benchmark" 54 | elseif performance_diff > 0.0 && performance_diff < 0.2 55 | @tcstatus "Runtime is $(dt) and it is $performance_diff % slower than benchmark" 56 | else 57 | @tcstatus "Runtime is $(dt) and it is $performance_diff % slower than benchmark, close to fail the benchmark" 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /python/ribasim/tests/test_schemas.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | import pandas as pd 4 | import pytest 5 | import ribasim 6 | from pandas.testing import assert_frame_equal 7 | from pydantic import ValidationError 8 | from ribasim import Model 9 | from ribasim.db_utils import _get_db_schema_version, _set_db_schema_version 10 | from ribasim.migrations import _rename_column 11 | from ribasim.nodes import basin 12 | from ribasim.schemas import BasinProfileSchema 13 | from shapely.geometry import Point 14 | 15 | 16 | def test_config_inheritance(): 17 | assert BasinProfileSchema.__config__.add_missing_columns 18 | assert BasinProfileSchema.__config__.coerce 19 | 20 | 21 | @patch("ribasim.schemas.migrations.nodeschema_migration") 22 | def test_migration(migration, basic, tmp_path): 23 | toml_path = tmp_path / "basic.toml" 24 | db_path = tmp_path / "input/database.gpkg" 25 | basic.write(toml_path) 26 | 27 | # Migration is not needed on default model 28 | Model.read(toml_path) 29 | assert not migration.called 30 | 31 | # Fake old schema that needs migration 32 | _set_db_schema_version(db_path, 0) 33 | Model.read(toml_path) 34 | assert migration.called 35 | 36 | 37 | def test_model_schema(basic, tmp_path): 38 | toml_path = tmp_path / "basic.toml" 39 | db_path = tmp_path / "input/database.gpkg" 40 | basic.write(toml_path) 41 | 42 | assert _get_db_schema_version(db_path) == ribasim.__schema_version__ 43 | _set_db_schema_version(db_path, 0) 44 | assert _get_db_schema_version(db_path) == 0 45 | 46 | 47 | def test_geometry_validation(): 48 | with pytest.raises( 49 | ValidationError, 50 | match="Column 'geometry' failed element-wise validator number 0: failure cases", 51 | ): 52 | basin.Area(geometry=[Point([1.0, 2.0])]) 53 | 54 | 55 | def test_column_rename(): 56 | df = pd.DataFrame({"edge_type": [1], "link_type": [2]}) 57 | _rename_column(df, "edge_type", "link_type") 58 | assert_frame_equal(df, pd.DataFrame({"link_type": [1]})) 59 | df = pd.DataFrame({"edge_type": [1]}) 60 | _rename_column(df, "edge_type", "link_type") 61 | assert_frame_equal(df, pd.DataFrame({"link_type": [1]})) 62 | df = pd.DataFrame({"link_type": [2]}) 63 | assert_frame_equal(df, _rename_column(df, "edge_type", "link_type")) 64 | -------------------------------------------------------------------------------- /.teamcity/Ribasim_Linux/RibasimLinuxProject.kt: -------------------------------------------------------------------------------- 1 | package Ribasim_Linux 2 | 3 | import Ribasim.vcsRoots.Ribasim 4 | import Templates.* 5 | import jetbrains.buildServer.configs.kotlin.BuildType 6 | import jetbrains.buildServer.configs.kotlin.FailureAction 7 | import jetbrains.buildServer.configs.kotlin.Project 8 | import jetbrains.buildServer.configs.kotlin.triggers.vcs 9 | 10 | object RibasimLinuxProject : Project({ 11 | id("Ribasim_Linux") 12 | name = "Ribasim_Linux" 13 | 14 | buildType(Linux_Main) 15 | buildType(Linux_BuildRibasim) 16 | buildType(Linux_TestRibasimBinaries) 17 | 18 | template(TestBinariesLinux) 19 | template(GenerateCacheLinux) 20 | }) 21 | 22 | object Linux_Main : BuildType({ 23 | name = "RibasimMain" 24 | 25 | templates(GithubPullRequestsIntegration) 26 | 27 | allowExternalStatus = true 28 | type = Type.COMPOSITE 29 | 30 | vcs { 31 | root(Ribasim, ". => ribasim") 32 | cleanCheckout = true 33 | } 34 | 35 | triggers { 36 | vcs { 37 | id = "TRIGGER_RIBA_SKIPL1" 38 | branchFilter = """ 39 | +: 40 | +:refs/pull/* 41 | +:pull/* 42 | """.trimIndent() 43 | triggerRules = "-:comment=skip ci:**" 44 | } 45 | } 46 | 47 | dependencies { 48 | snapshot(Linux_TestRibasimBinaries) { 49 | onDependencyFailure = FailureAction.FAIL_TO_START 50 | } 51 | } 52 | }) 53 | 54 | object Linux_BuildRibasim : BuildType({ 55 | templates( 56 | LinuxAgent, 57 | GithubCommitStatusIntegration, 58 | BuildLinux 59 | ) 60 | 61 | name = "Build Ribasim" 62 | 63 | artifactRules = """ribasim\build\ribasim => ribasim_linux.zip!/ribasim""" 64 | }) 65 | 66 | object Linux_TestRibasimBinaries : BuildType({ 67 | templates(LinuxAgent, GithubCommitStatusIntegration, TestBinariesLinux) 68 | name = "Test Ribasim Binaries" 69 | 70 | dependencies { 71 | dependency(Linux_BuildRibasim) { 72 | snapshot { 73 | } 74 | 75 | artifacts { 76 | id = "ARTIFACT_DEPENDENCY_570" 77 | cleanDestination = true 78 | artifactRules = """ 79 | ribasim_linux.zip!/ribasim/** => ribasim/build/ribasim 80 | """.trimIndent() 81 | } 82 | } 83 | } 84 | }) 85 | -------------------------------------------------------------------------------- /.teamcity/Templates/TestDelwaqCoupling.kt: -------------------------------------------------------------------------------- 1 | package Templates 2 | 3 | import Ribasim.vcsRoots.Ribasim 4 | import jetbrains.buildServer.configs.kotlin.* 5 | import jetbrains.buildServer.configs.kotlin.Template 6 | import jetbrains.buildServer.configs.kotlin.buildSteps.script 7 | 8 | open class TestDelwaqCoupling(platformOs: String) : Template() { 9 | init { 10 | name = "TestDelwaqCoupling${platformOs}_Template" 11 | 12 | vcs { 13 | root(Ribasim, ". => ribasim") 14 | cleanCheckout = true 15 | } 16 | 17 | val depot_path = generateJuliaDepotPath(platformOs) 18 | params { 19 | param("env.MINIO_ACCESS_KEY", "KwKRzscudy3GvRB8BN1Z") 20 | password("env.MINIO_SECRET_KEY", "credentialsJSON:86cbf3e5-724c-437d-9962-7a3f429b0aa2") 21 | param("env.JULIA_DEPOT_PATH", depot_path) 22 | } 23 | 24 | dependencies { 25 | artifacts(AbsoluteId("Ribasim_${platformOs}_GenerateCache")) { 26 | buildRule = lastSuccessful() 27 | artifactRules = "cache.zip!** => %teamcity.build.checkoutDir%/.julia" 28 | } 29 | } 30 | 31 | steps { 32 | script { 33 | name = "Set up pixi" 34 | id = "Set_up_pixi" 35 | workingDir = "ribasim" 36 | scriptContent = """ 37 | pixi --version 38 | pixi run install-ci 39 | """.trimIndent() 40 | } 41 | script { 42 | name = "Run Delwaq" 43 | id = "Run_Delwaq" 44 | workingDir = "ribasim" 45 | scriptContent = """ 46 | pixi run ribasim-core-testmodels basic 47 | set D3D_HOME=%teamcity.build.checkoutDir%/dimr 48 | pixi run delwaq 49 | """.trimIndent() 50 | } 51 | script { 52 | name = "Upload delwaq model" 53 | id = "Delwaq_upload" 54 | workingDir = "ribasim" 55 | scriptContent = """ 56 | pixi run s3-upload "python/ribasim/ribasim/delwaq/model/test_offline_delwaq_coupling_ecurrent/delwaq/delwaq_map.nc" "doc-image/delwaq/delwaq_map.nc" 57 | """.trimIndent() 58 | } 59 | } 60 | } 61 | } 62 | 63 | object TestDelwaqCouplingWindows : TestDelwaqCoupling("Windows") 64 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[julia]": { 3 | "editor.formatOnSave": true 4 | }, 5 | "[python]": { 6 | "editor.codeActionsOnSave": { 7 | "source.fixAll": "explicit", 8 | "source.organizeImports": "explicit" 9 | }, 10 | "editor.defaultFormatter": "charliermarsh.ruff", 11 | "editor.formatOnSave": true 12 | }, 13 | "cSpell.enabledLanguageIds": [ 14 | "asciidoc", 15 | "c", 16 | "cpp", 17 | "csharp", 18 | "css", 19 | "elixir", 20 | "erlang", 21 | "git-commit", 22 | "go", 23 | "graphql", 24 | "handlebars", 25 | "haskell", 26 | "html", 27 | "jade", 28 | "java", 29 | "javascript", 30 | "javascriptreact", 31 | "json", 32 | "jsonc", 33 | "jupyter", 34 | "latex", 35 | "less", 36 | "markdown", 37 | "php", 38 | "plaintext", 39 | "python", 40 | "pug", 41 | "restructuredtext", 42 | "rust", 43 | "scala", 44 | "scss", 45 | "scminput", 46 | "swift", 47 | "text", 48 | "typescript", 49 | "typescriptreact", 50 | "vue", 51 | "yaml", 52 | "yml", 53 | "quarto", 54 | "julia" 55 | ], 56 | "cSpell.words": [ 57 | "gpkg", 58 | "ipynb", 59 | "pixi", 60 | "pkgdown", 61 | "qgis", 62 | "quartodoc", 63 | "Ribasim" 64 | ], 65 | "files.insertFinalNewline": true, 66 | "julia.executablePath": "julia +1.11.7", 67 | "julia.additionalArgs": [ 68 | "--check-bounds=yes" 69 | ], 70 | "julia.lint.disabledDirs": [ 71 | ".pixi" 72 | ], 73 | "julia.lint.run": true, 74 | "julia.numTestProcesses": 6, 75 | "mypy-type-checker.importStrategy": "fromEnvironment", 76 | "notebook.codeActionsOnSave": { 77 | "notebook.source.fixAll": "explicit", 78 | "notebook.source.organizeImports": "explicit" 79 | }, 80 | "notebook.formatOnSave.enabled": true, 81 | "python.testing.pytestArgs": [ 82 | "." 83 | ], 84 | "python.testing.pytestEnabled": true, 85 | "python.testing.unittestEnabled": false, 86 | "python-envs.defaultEnvManager": "renan-r-santos.pixi-code:pixi", 87 | "python-envs.defaultPackageManager": "renan-r-santos.pixi-code:pixi", 88 | "python-envs.pythonProjects": [] 89 | } 90 | -------------------------------------------------------------------------------- /ribasim_qgis/core/model.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Any 3 | 4 | from qgis.core import qgsfunction 5 | 6 | import ribasim_qgis.tomllib as tomllib 7 | 8 | 9 | def get_directory_path_from_model_file(model_path: Path, *, property: str) -> Path: 10 | """Generate database absolute full path from model .toml file. 11 | 12 | Parameters 13 | ---------- 14 | path : Path 15 | Path to model .toml file. 16 | property : str 17 | The property to retrieve from the model file and append to the path. 18 | 19 | Returns 20 | ------- 21 | Path 22 | Full path to database Geopackage. 23 | """ 24 | with open(model_path, "rb") as f: 25 | found_property = Path(tomllib.load(f)[property]) 26 | # The .joinpath method (/) of pathlib.Path will take care of an absolute input_dir. 27 | # No need to check it ourselves! 28 | return (Path(model_path).parent / found_property).resolve() 29 | 30 | 31 | def get_toml_dict(model_path: Path) -> dict[str, Any]: 32 | with open(model_path, "rb") as f: 33 | return tomllib.load(f) 34 | 35 | 36 | def get_database_path_from_model_file(model_path: Path) -> Path: 37 | """Get the database path database.gpkg based on the model file's input_dir. 38 | 39 | Parameters 40 | ---------- 41 | model_path : Path 42 | Path to the model (.toml) file. 43 | 44 | Returns 45 | ------- 46 | Path 47 | The full path to database.gpkg 48 | """ 49 | return ( 50 | get_directory_path_from_model_file(model_path, property="input_dir") 51 | / "database.gpkg" 52 | ) 53 | 54 | 55 | @qgsfunction(args="auto", group="Custom", referenced_columns=[]) # type: ignore 56 | def label_flow_rate(value: float) -> str: 57 | """ 58 | Format the label for `flow_rate`. 59 | 60 | Above 1, show 2 decimals. 61 | Show 0 as 0. 62 | Between 0 and 1, and below 1, show scientific notation and 2 decimals. 63 | Example outputs: 0, 1.23e-06, 12345.68 64 | """ 65 | if abs(value) >= 1: 66 | return f"{value:.2f}" 67 | if abs(value) == 0.0: 68 | return "0" 69 | else: 70 | return f"{value:.2e}" 71 | 72 | 73 | @qgsfunction(args="auto", group="Custom", referenced_columns=[]) # type: ignore 74 | def label_scientific(value: float) -> str: 75 | """ 76 | Format the label for `concentration`. 77 | 78 | Uses scientific notation with 3 decimals. 79 | """ 80 | return f"{value:.3e}" 81 | -------------------------------------------------------------------------------- /docs/dev/benchmark.qmd: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Benchmark" 3 | --- 4 | 5 | This document describes how the benchmarking and performance testing of Ribasim is handled. 6 | In Ribasim, the benchmarking includes and regression tests on the test models and regressive performance tests on the production models. 7 | 8 | The idea of regression tests on the test models is to run models with various solvers, run models with a sparse Jacobian and a dense one and compare the outputs. 9 | It will possibly involve production models in the future. 10 | And runtime performance test is lined up for the next step (in [issue #1698](https://github.com/Deltares/Ribasim/issues/1698)). 11 | 12 | The idea of regressive performance tests on the production models is to test the performance of running the production models. 13 | It will report if the new changes in the code decrease the model's performance or result in failed runs. 14 | 15 | # Benchmarking of the test models 16 | ## Benchmark the ODE solvers 17 | The benchmarking of the ODE solvers is done by running the test models with different ODE solvers and solver settings and comparing the output with the benchmark. 18 | 19 | The settings include toggling the `sparse` and `autodiff` solver settings. 20 | Currently, 4 models are chosen to undergo the regression tests. 21 | They are `trivial`, `basic`, `pid_control` and `subnetwork_with_sources`. 22 | 23 | The benchmark reference are the output files of a run of the test models with default solver settings. 24 | The output files `basin.arrow` and `flow.arrow` are used for comparison. 25 | Different margins are set for the comparison of the outputs, and the benchmark is considered passed if the output is within the margin. 26 | Since we are still in the process of evaluating the performance of different solvers, the margin is subject to change. 27 | 28 | The regression tests are run on a weekly basis. 29 | 30 | # Benchmarking of the production model 31 | Regressive performance tests on the production models are done by running the production models with the new changes of the code and comparing the runtime performance with the reference run. 32 | The references are the output files of a run of the production models with the default solver settings. 33 | The output file `basin_state.arrow` which records the end states of the basin is used for comparison. 34 | Since the development of the model is still ongoing, the benchmark is subject to change. 35 | 36 | The regressive performance tests are currently run on a weekly basis. 37 | -------------------------------------------------------------------------------- /python/ribasim/ribasim/delwaq/README.md: -------------------------------------------------------------------------------- 1 | # Ribasim Delwaq coupling 2 | This folder contains scripts to setup a Delwaq model from a Ribasim model, and to update the Ribasim model with the Delwaq output. 3 | 4 | ## Steps 5 | Setup a Ribasim model with substances and concentrations and run it. For example, we can run the basic testmodel with some default concentrations using the Ribasim CLI: 6 | 7 | ```bash 8 | ribasim generated_testmodels/basic/ribasim.toml 9 | ``` 10 | 11 | Afterwards we can use the Ribasim model and the generated output to setup a Delwaq model using Python from this folder. 12 | 13 | ```python 14 | from pathlib import Path 15 | 16 | from generate import generate 17 | from parse import parse 18 | from util import run_delwaq 19 | 20 | toml_path = Path("generated_testmodels/basic/ribasim.toml") 21 | 22 | graph, substances = generate(toml_path) 23 | run_delwaq() 24 | model = parse(toml_path, graph, substances) 25 | ``` 26 | 27 | The resulting Ribasim model will have an updated `model.basin.concentration_external` table with the Delwaq output. 28 | We also store the same table in the `basin_concentration_external.arrow` file in the results folder, which can be 29 | referred to using the Ribasim config file. 30 | 31 | ## Running Delwaq 32 | If you have access to a DIMR release, you can find the Delwaq executables in the `bin` folder. You can run a model directly with the `run_dimr.bat` script, and providing the path to the generated `.inp` file to it. In `util.py` we provide a `run_delwaq` (as used above) that does this for you, if you set the `D3D_HOME` environment variable to the path of the unzipped DIMR release, using the generated `model/ribasim.inp` configuration file. 33 | 34 | ### Running Delwaq with Docker 35 | Alternative to running Delwaq with a DIMR release, you can also run the Delwaq model in a Docker container if you are a Deltares employee. 36 | First install WSL and install docker in WSL, then create a CLI secret and log into the Deltares containers. To install docker in WSL and create a CLI secret for the following steps, follow this guide https://publicwiki.deltares.nl/display/Delft3DContainers/. 37 | 38 | Log into Deltares containers in docker: 39 | ```bash 40 | docker login containers.deltares.nl # use your deltares email + token 41 | ``` 42 | 43 | You can now run the Delwaq model from this directory. 44 | ```bash 45 | docker run --mount type=bind,source="$(pwd)/model",target=/mnt/myModel \ 46 | --workdir /mnt/myModel containers.deltares.nl/delft3d/delft3dfm run_dimr.sh 47 | ``` 48 | -------------------------------------------------------------------------------- /docs/dev/qgis_test_plan.qmd: -------------------------------------------------------------------------------- 1 | --- 2 | title: "QGIS plugin manual test plan" 3 | --- 4 | 5 | This document describes how to perform a full manual test on the Ribasim QGIS plugin. 6 | Known shortcomings and issues can be documented [here](/known_issues.qmd). 7 | Bugs can be reported on [GitHub](https://github.com/Deltares/Ribasim/issues). 8 | 9 | # Clean slate tests 10 | Before starting with data, perform the following tests to see if the plugin doesn't result in any errors. 11 | 12 | ## Enable and disable 13 | 14 | - Open QGIS and navigate to "Plugins > Manage and Install Plugins...": _The plugin management window opens_. 15 | - Navigate to "Installed": _Ribasim plugin is in the list (enabled)_. 16 | - Disable the Ribasim plugin: _Ribasim plugin panel hides if it was open, Ribasim button hides from navigation toolbar_. 17 | - Enable the Ribasim plugin: _Ribasim button shows on the navigation toolbar_. 18 | 19 | # Model tab button interaction tests 20 | 21 | ## Open model twice 22 | 23 | - Open QGIS and ensure that the Ribasim plugin is installed and enabled. 24 | - Click the Ribasim button on the QGIS toolbar: _file navigation window pops up_. 25 | - Choose an existing model from the `generated_testmodels` folder. 26 | - Press OK: _The model layers appear in the layer panel and on the map_. 27 | - Click the Ribasim button on the QGIS toolbar: _file navigation window pops up_. 28 | - Open the same model again: _A new layer group is added to the layers panel_. 29 | 30 | Intended behavior: The same model is loaded twice, but there is only a connection on the last loaded model when interacting with the plugin. 31 | 32 | # Map interaction tests 33 | 34 | ## Node selection on map triggers table selection 35 | 36 | - Open QGIS and ensure that the Ribasim plugin is installed and enabled. 37 | - Choose an existing model from the `generated_testmodels` folder in the file explorer. 38 | - Drag the TOML file onto QGIS: _The model layers appear in the layer panel and on the map_. 39 | - Select the node layer, and make a subselection of nodes on the map: _Nodes are highlighted in yellow, including their links_. 40 | - Open the Link attribute table: _The highlighted rows are those with a from/to node\_id that was selected_. 41 | - Open any non-spatial attribute table: _The highlighted rows are those with a node\_id that was selected_. 42 | 43 | # Result inspection tests 44 | 45 | ## Run a model and check the time series 46 | TODO 47 | 48 | # Tutorial tests 49 | 50 | ## Perform tutorial in documentation 51 | 52 | Go through the tutorial as described in the [How-to guide](/guide/qgis.qmd). 53 | -------------------------------------------------------------------------------- /docs/guide/updating-ribasim.qmd: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Updating Ribasim" 3 | --- 4 | 5 | This guide explains how to update Ribasim models from older versions to newer versions. 6 | To update the Ribasim installation itself, follow the [installation steps](/getting-started/install.qmd), ensuring that all installed components are the same. 7 | To see the most important changes in Ribasim, consult the [changelog](/changelog.qmd). 8 | 9 | # Version numbers and breaking changes 10 | 11 | Ribasim uses version numbers like `2023.1.0`, following `YYYY.MINOR.MICRO` from [calver](https://calver.org/). 12 | It starts with the year the release was made, followed by the minor version number for normal releases, and a micro number for non-breaking, hotfix releases. 13 | This means that whenever the year or minor version changes, there is a possibility that the user has to make changes to the model for it to keep working. 14 | If this is the case, it will be highlighted in the [changelog](/changelog.qmd). 15 | When possible, we automate this process using model migration in Ribasim Python, see the section below. 16 | 17 | # Automatic model migration 18 | 19 | Models are automatically migrated when read from file using Ribasim Python, and this is the recommended way to update your models. 20 | 21 | The Ribasim Python package contains a set of migration functions that are applied automatically when you read a model file from an older version. 22 | When Ribasim developers make changes to the model structure (for example, adding new required columns to tables), these changes would normally break compatibility with existing models. 23 | To prevent this, migration functions automatically update your model data to match the current version's requirements. 24 | 25 | The core always expects models to be written by the same Ribasim Python version as itself, which is why it gives a warning whenever the `ribasim_version` in the TOML does not match `ribasim --version`. 26 | 27 | When you read an older model, Ribasim Python will automatically apply all necessary migrations and update the model version accordingly. 28 | To migrate an existing model to the latest version, simply use the following script: 29 | 30 | ```python 31 | import ribasim 32 | 33 | # Read the old model (migration happens automatically) 34 | model = ribasim.Model.read("path/to/your/old_model.toml") 35 | 36 | # Write the migrated model 37 | model.write("path/to/your/migrated_model.toml") 38 | ``` 39 | 40 | If you have a script that builds your model from scratch, simply re-running that script with the new Ribasim Python version will produce a model with the updated version, making the above migration step unnecessary. 41 | -------------------------------------------------------------------------------- /python/ribasim_testmodels/ribasim_testmodels/backwater.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import ribasim 3 | from ribasim.config import Experimental, Node 4 | from ribasim.model import Model 5 | from ribasim.nodes import ( 6 | basin, 7 | flow_boundary, 8 | manning_resistance, 9 | ) 10 | from shapely.geometry import Point 11 | 12 | 13 | def backwater_model() -> Model: 14 | """Backwater curve as an integration test for ManningResistance.""" 15 | node_type = np.full(102, "ManningResistance") 16 | node_type[1::2] = "Basin" 17 | node_type[0] = "FlowBoundary" 18 | node_type[-1] = "LevelBoundary" 19 | 20 | ids = np.arange(1, node_type.size + 1, dtype=np.int32) 21 | 22 | model = ribasim.Model( 23 | starttime="2020-01-01", 24 | endtime="2021-01-01", 25 | crs="EPSG:28992", 26 | solver=ribasim.Solver(autodiff=True, specialize=True), 27 | experimental=Experimental(concentration=True), 28 | ) 29 | 30 | model.flow_boundary.add( 31 | Node(1, Point(0.0, 0.0)), [flow_boundary.Static(flow_rate=[5.0])] 32 | ) 33 | 34 | # Rectangular profile, width of 1.0 m. 35 | basin_ids = ids[node_type == "Basin"] 36 | basin_x = np.arange(10.0, 1000.0, 20.0) 37 | for id, x in zip(basin_ids, basin_x): 38 | model.basin.add( 39 | Node(id, Point(x, 0.0)), 40 | [ 41 | basin.Profile(area=[20.0, 20.0], level=[0.0, 1.0]), 42 | basin.State(level=[0.05]), 43 | ], 44 | ) 45 | model.manning_resistance.add( 46 | Node(id + 1, Point(x + 10.0, 0.0)), 47 | [ 48 | manning_resistance.Static( 49 | length=[20.0], 50 | manning_n=[0.04], 51 | profile_width=[1.0], 52 | profile_slope=[0.0], 53 | ) 54 | ], 55 | ) 56 | if id == 2: 57 | model.link.add( 58 | model.flow_boundary[1], 59 | model.basin[2], 60 | ) 61 | else: 62 | model.link.add( 63 | model.manning_resistance[id - 1], 64 | model.basin[id], 65 | ) 66 | 67 | model.link.add( 68 | model.basin[id], 69 | model.manning_resistance[id + 1], 70 | ) 71 | 72 | model.basin.add( 73 | Node(102, Point(1010.0, 0.0)), 74 | [basin.State(level=[2.0]), basin.Profile(level=[0.0, 1.0], area=1e10)], 75 | ) 76 | model.link.add( 77 | model.manning_resistance[101], 78 | model.basin[102], 79 | ) 80 | 81 | return model 82 | -------------------------------------------------------------------------------- /core/test/carrays_test.jl: -------------------------------------------------------------------------------- 1 | @testitem "UnitRange" begin 2 | using Ribasim.CArrays: CArray, CVector, getdata, getaxes 3 | data = [1.0, 2.0, 3.0] 4 | axes = (a = 1:1, b = 2:3) 5 | x = CArray(data, axes) 6 | @test x isa CVector{Float64} 7 | @test x isa DenseVector{Float64} 8 | @test length(x) == 3 9 | @test size(x) == (3,) 10 | @test x[1] == 1.0 11 | @test x[2] == 2.0 12 | @test x[3] == 3.0 13 | @test x[1:2] == [1.0, 2.0] 14 | @test x.a isa SubArray 15 | @test x.b isa SubArray 16 | @test getdata(x) === data 17 | @test getaxes(x) === axes 18 | @test keys(x) === (:a, :b) 19 | @inferred getproperty(x, :a) 20 | @test similar(x) isa CVector{Float64} 21 | @test similar(x, Int) isa CVector{Int} 22 | @test similar(x, 2, 3) isa Matrix{Float64} 23 | @test similar(x, Int, 2, 3) isa Matrix{Int} 24 | @test iterate(x) === (1.0, 2) 25 | 26 | @test map(identity, x) isa CVector{Float64} 27 | @test map!(identity, similar(data), x) isa Vector{Float64} 28 | @test map!(identity, similar(x), x) isa CVector{Float64} 29 | end 30 | 31 | @testitem "Int" begin 32 | using Ribasim.CArrays: CArray, CVector 33 | data = [1.0, 2.0, 3.0] 34 | axes = (a = 1, b = 2:3) 35 | x = CArray(data, axes) 36 | @test x.a === 1.0 37 | @test x.b isa SubArray 38 | @test x.b == [2.0, 3.0] 39 | FloatView = SubArray{Float64, 1, Vector{Float64}, Tuple{UnitRange{Int64}}, true} 40 | @inferred Union{Float64, FloatView} getproperty(x, :a) 41 | @inferred Union{Float64, FloatView} getproperty(x, :b) 42 | end 43 | 44 | @testitem "Nested" begin 45 | using Ribasim.CArrays: CArray, CVector, getdata, getaxes 46 | data = [1.0, 2.0, 3.0] 47 | axes = (; a = (; b = 1, c = 2:3)) 48 | x = CArray(data, axes) 49 | xa = x.a 50 | @test xa isa CVector 51 | @test getdata(xa) === data 52 | @test getaxes(xa) === axes.a 53 | @test_throws ErrorException x.b 54 | @test x.a.b === 1.0 55 | @test x.a.c isa SubArray 56 | @test x.a.c == [2.0, 3.0] 57 | @inferred getproperty(x, :a) 58 | FloatView = SubArray{Float64, 1, Vector{Float64}, Tuple{UnitRange{Int64}}, true} 59 | @inferred Union{Float64, FloatView} getproperty(xa, :b) 60 | @inferred Union{Float64, FloatView} getproperty(xa, :c) 61 | end 62 | 63 | @testitem "CMatrix" begin 64 | using Ribasim.CArrays: CArray, CMatrix 65 | data = [1.0; 2; 3;; 4; 5; 6] 66 | axes = (a = 1, b = 2, c = CartesianIndex(3, 2)) 67 | x = CArray(data, axes) 68 | x isa CMatrix 69 | x.a === 1.0 70 | x.b === 2.0 71 | x.c === 6.0 72 | @inferred getproperty(x, :a) 73 | @inferred getproperty(x, :b) 74 | @inferred getproperty(x, :c) 75 | end 76 | -------------------------------------------------------------------------------- /docs/reference/node/flow-boundary.qmd: -------------------------------------------------------------------------------- 1 | --- 2 | title: "FlowBoundary" 3 | --- 4 | 5 | A FlowBoundary adds water to the model at a specified flow rate. 6 | It can be used as a boundary condition like a measured upstream flow rate, or lateral inflow. 7 | We require that an link connected to a FlowBoundary is always outgoing, and points towards a Basin. 8 | 9 | # Tables 10 | 11 | ## Static 12 | 13 | column | type | unit | restriction 14 | ------------- | ------- | --------------------- | ----------- 15 | node_id | Int32 | - | 16 | flow_rate | Float64 | $\text{m}^3/\text{s}$ | non-negative 17 | 18 | ## Time 19 | 20 | This table is the transient form of the `FlowBoundary` table. 21 | The only difference is that a time column is added. 22 | With this the flow rates can be updated over time. In between the given times the 23 | flow rate is interpolated in a way specified in the [interpolation settings](/reference/usage.qmd#interpolation-settings) (block interpolation by default), and outside the flow rate is constant given by the 24 | nearest time value unless the node is cyclic in time. 25 | Note that a `node_id` can be either in this table or in the static one, but not both. 26 | 27 | column | type | unit | restriction 28 | --------- | ------- | --------------------- | ----------- 29 | node_id | Int32 | - | 30 | time | DateTime | - | 31 | flow_rate | Float64 | $\text{m}^3/\text{s}$ | non-negative 32 | 33 | ## Concentration {#sec-flow-boundary-conc} 34 | This table defines the concentration of substances for the flow from the FlowBoundary. 35 | 36 | column | type | unit | restriction 37 | -------------- | -------- | --------------------- | ----------- 38 | node_id | Int32 | - | 39 | time | DateTime | - | 40 | substance | String | - | can correspond to known Delwaq substances 41 | concentration | Float64 | $\text{g}/\text{m}^3$ | 42 | 43 | ## Area 44 | 45 | The optional area table is not used during computation, but provides a place to associate areas in the form of polygons to a FlowBoundary. 46 | Using this makes it easier to recognize which water or land surfaces are represented by a FlowBoundary. 47 | 48 | column | type | restriction 49 | --------- | ----------------------- | ----------- 50 | node_id | Int32 | 51 | geom | Polygon or MultiPolygon | (optional) 52 | 53 | # Equations 54 | 55 | A FlowBoundary can be connected directly to a Basin and prescribes the flow to that Basin. 56 | Since the `flow_rate` cannot be negative a FlowBoundary can only add water to the model. 57 | -------------------------------------------------------------------------------- /core/test/data/allocation_problems/small_primary_secondary_network/allocation_problem_2.lp: -------------------------------------------------------------------------------- 1 | minimize 2 | obj: 3 | subject to 4 | user_demand_relative_error_constraint_UserDemand_#6,2_: 1 user_demand_allocated_UserDemand_#6,2_ + 2 user_demand_error_UserDemand_#6,2,first_ >= 2 5 | storage_constraint_lower_Basin_#5,1_: 1 basin_storage_change_Basin_#5_ + 1 level_demand_error_Basin_#5,1,lower,first_ >= -250 6 | storage_constraint_upper_Basin_#5,1_: -1 basin_storage_change_Basin_#5_ + 1 level_demand_error_Basin_#5,1,upper,first_ >= -750 7 | user_demand_fairness_error_constraint_UserDemand_#6,2_: -1 user_demand_error_UserDemand_#6,2,first_ + 1 user_demand_error_UserDemand_#6,2,second_ + 1 average_flow_unit_error_2_ >= 0 8 | level_demand_fairness_error_constraint_Basin_#5,1,lower_: -0.001 level_demand_error_Basin_#5,1,lower,first_ + 1 level_demand_error_Basin_#5,1,lower,second_ + 1 average_storage_unit_error_1,lower_ >= 0 9 | level_demand_fairness_error_constraint_Basin_#5,1,upper_: -0.001 level_demand_error_Basin_#5,1,upper,first_ + 1 level_demand_error_Basin_#5,1,upper,second_ + 1 average_storage_unit_error_1,upper_ >= 0 10 | volume_conservation_Basin_#5_: 1 basin_storage_change_Basin_#5_ + 1 low_storage_factor_Basin_#5_ - 1 flow_(Outlet_#4,_Basin_#5)_ + 1 flow_(Basin_#5,_UserDemand_#6)_ - 1 flow_(UserDemand_#6,_Basin_#5)_ = 1 11 | user_demand_allocated_sum_constraint_UserDemand_#6_: 1 flow_(Basin_#5,_UserDemand_#6)_ - 1 user_demand_allocated_UserDemand_#6,2_ = 0 12 | user_demand_return_flow_UserDemand_#6_: -0.5 flow_(Basin_#5,_UserDemand_#6)_ + 1 flow_(UserDemand_#6,_Basin_#5)_ = 0 13 | average_flow_unit_error_constraint_2_: -1 user_demand_error_UserDemand_#6,2,first_ + 1 average_flow_unit_error_2_ = 0 14 | average_storage_unit_error_constraint_1,upper_: -1 level_demand_error_Basin_#5,1,upper,first_ + 1000 average_storage_unit_error_1,upper_ = 0 15 | average_storage_unit_error_constraint_1,lower_: -1 level_demand_error_Basin_#5,1,lower,first_ + 1000 average_storage_unit_error_1,lower_ = 0 16 | Bounds 17 | -0.006666666666666667 <= basin_storage_change_Basin_#5_ <= 0.02666666666666667 18 | 0 <= low_storage_factor_Basin_#5_ <= 1 19 | 0 <= flow_(Outlet_#4,_Basin_#5)_ <= 1.728 20 | 0 <= flow_(Basin_#5,_UserDemand_#6)_ <= 288000 21 | 0 <= flow_(UserDemand_#6,_Basin_#5)_ <= 288000 22 | 0 <= user_demand_allocated_UserDemand_#6,2_ <= 2 23 | 0 <= user_demand_error_UserDemand_#6,2,first_ <= 1 24 | 0 <= user_demand_error_UserDemand_#6,2,second_ <= 1 25 | level_demand_error_Basin_#5,1,lower,first_ >= 0 26 | level_demand_error_Basin_#5,1,lower,second_ >= 0 27 | level_demand_error_Basin_#5,1,upper,first_ >= 0 28 | level_demand_error_Basin_#5,1,upper,second_ >= 0 29 | 0 <= average_flow_unit_error_2_ <= 1 30 | average_storage_unit_error_1,lower_ >= 0 31 | average_storage_unit_error_1,upper_ >= 0 32 | End 33 | -------------------------------------------------------------------------------- /python/ribasim_testmodels/ribasim_testmodels/two_basin.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from ribasim.config import Experimental, Node, Results 4 | from ribasim.input_base import TableModel 5 | from ribasim.model import Model 6 | from ribasim.nodes import basin, flow_boundary, tabulated_rating_curve 7 | from shapely.geometry import Point 8 | 9 | 10 | def two_basin_model() -> Model: 11 | """ 12 | Create a model of two basins. 13 | 14 | The basins are not connected; the model is mostly designed to test in 15 | combination with a groundwater model. 16 | 17 | The left basin receives water. In case of a coupled run, the water 18 | infiltrates in the left basin, and exfiltrates in the right basin. 19 | The right basin fills up and discharges over the rating curve. 20 | """ 21 | model = Model( 22 | starttime="2020-01-01", 23 | endtime="2021-01-01", 24 | crs="EPSG:28992", 25 | experimental=Experimental(concentration=True), 26 | results=Results(subgrid=True), 27 | ) 28 | 29 | model.flow_boundary.add( 30 | Node(1, Point(0, 0)), [flow_boundary.Static(flow_rate=[1e-2])] 31 | ) 32 | basin_shared: list[TableModel[Any]] = [ 33 | basin.Profile(area=400.0, level=[0.0, 1.0]), 34 | basin.State(level=[0.01]), 35 | ] 36 | model.basin.add( 37 | Node(2, Point(250, 0)), 38 | [ 39 | *basin_shared, 40 | basin.Subgrid( 41 | subgrid_id=1, 42 | basin_level=[0.0, 1.0], 43 | subgrid_level=[0.0, 1.0], 44 | meta_x=250.0, 45 | meta_y=0.0, 46 | ), 47 | ], 48 | ) 49 | model.basin.add( 50 | Node(3, Point(750, 0)), 51 | [ 52 | *basin_shared, 53 | # Raise the subgrid levels by a meter after a month 54 | basin.SubgridTime( 55 | subgrid_id=2, 56 | time=["2020-01-01", "2020-01-01", "2020-02-01", "2020-02-01"], 57 | basin_level=[0.0, 1.0, 0.0, 1.0], 58 | subgrid_level=[0.0, 1.0, 1.0, 2.0], 59 | meta_x=750.0, 60 | meta_y=0.0, 61 | ), 62 | ], 63 | ) 64 | model.tabulated_rating_curve.add( 65 | Node(4, Point(1000, 0)), 66 | [tabulated_rating_curve.Static(level=[0.0, 1.0], flow_rate=[0.0, 0.01])], 67 | ) 68 | model.terminal.add(Node(5, Point(1100, 0))) 69 | 70 | model.link.add(model.flow_boundary[1], model.basin[2]) 71 | model.link.add( 72 | model.basin[3], 73 | model.tabulated_rating_curve[4], 74 | ) 75 | model.link.add( 76 | model.tabulated_rating_curve[4], 77 | model.terminal[5], 78 | ) 79 | return model 80 | -------------------------------------------------------------------------------- /docs/reference/node/continuous-control.qmd: -------------------------------------------------------------------------------- 1 | --- 2 | title: "ContinuousControl" 3 | --- 4 | 5 | The `ContinuousControl` node allows for fine control of a controllable property of a connector node, which is updated at each time step. This control can be set up as follows: 6 | 7 | 1. Define a compound variable. This is a linear combination of variables in the model the `ContinuousControl` node can listen to, i.e. flows and levels.; 8 | 2. Define a piecewise linear function by providing datapoints which get interpolated. The controlled parameter is then set to the outcome of this function given the value of the compound variable. 9 | 10 | :::{.callout-note} 11 | Having `ContinuousControl` nodes depend on eachother or `PidControl` nodes does *not* work. 12 | For instance, if one `ContinuousControl` node sets the flow rate of a pump, this flow rate cannot be used as the input of another `ContinuousControl` node. This will not throw an error but will produce incorrect results. 13 | ::: 14 | 15 | # Tables 16 | 17 | ## Variable 18 | 19 | The compound variable schema defines linear combinations of variables which can be used as an input for continuous functions described below. This means that 20 | this schema defines new variables that look like 21 | $$ 22 | \text{weight}_1 * \text{variable}_1 + \text{weight}_2 * \text{variable}_2 + \ldots, 23 | $$ 24 | 25 | which can be for instance an average or a difference of variables. If a variable comes from a timeseries, a look ahead $\Delta t$ can be supplied. There is only one compound variable per `ContinuousControl` node. 26 | 27 | column | type | unit | restriction 28 | -------------------- | -------- | ---------- | ----------- 29 | node_id | Int32 | - | 30 | listen_node_id | Int32 | - | cannot be a Junction 31 | variable | String | - | must be "level" or "flow_rate" 32 | weight | Float64 | - | (optional, default 1.0) 33 | look_ahead | Float64 | $\text{s}$ | Only on transient boundary conditions, non-negative (optional, default 0.0). 34 | 35 | ## Function 36 | 37 | The function table defines a smooth function $f$ interpolating between `(input, output)` datapoints for each `ContinuousControl`. The interpolation type is PCHIP, for more information see [here](https://www.mathworks.com/help/matlab/ref/pchip.html). The total computation thus looks like 38 | 39 | $$ 40 | f(\text{weight}_1 * \text{variable}_1 + \text{weight}_2 * \text{variable}_2 + \ldots). 41 | $$ 42 | 43 | column | type | unit | restriction 44 | -------------------- | ------- | ------- | ----------- 45 | node_id | Int32 | - | 46 | input | Float64 | - | 47 | output | Float64 | - | - 48 | controlled_variable | String | - | must be "level" or "flow_rate" 49 | -------------------------------------------------------------------------------- /core/test/data/allocation_problems/medium_primary_secondary_network/allocation_problem_2.lp: -------------------------------------------------------------------------------- 1 | minimize 2 | obj: 3 | subject to 4 | user_demand_relative_error_constraint_UserDemand_#14,3_: 1 user_demand_allocated_UserDemand_#14,3_ + 2 user_demand_error_UserDemand_#14,3,first_ >= 2 5 | storage_constraint_lower_Basin_#13,2_: 1 basin_storage_change_Basin_#13_ + 1 level_demand_error_Basin_#13,2,lower,first_ >= -250 6 | storage_constraint_upper_Basin_#13,2_: -1 basin_storage_change_Basin_#13_ + 1 level_demand_error_Basin_#13,2,upper,first_ >= -750 7 | user_demand_fairness_error_constraint_UserDemand_#14,3_: -1 user_demand_error_UserDemand_#14,3,first_ + 1 user_demand_error_UserDemand_#14,3,second_ + 1 average_flow_unit_error_3_ >= 0 8 | level_demand_fairness_error_constraint_Basin_#13,2,lower_: -0.001 level_demand_error_Basin_#13,2,lower,first_ + 1 level_demand_error_Basin_#13,2,lower,second_ + 1 average_storage_unit_error_2,lower_ >= 0 9 | level_demand_fairness_error_constraint_Basin_#13,2,upper_: -0.001 level_demand_error_Basin_#13,2,upper,first_ + 1 level_demand_error_Basin_#13,2,upper,second_ + 1 average_storage_unit_error_2,upper_ >= 0 10 | volume_conservation_Basin_#13_: 1 basin_storage_change_Basin_#13_ + 1 low_storage_factor_Basin_#13_ - 1 flow_(Outlet_#3,_Basin_#13)_ - 1 flow_(Pump_#6,_Basin_#13)_ + 1 flow_(Basin_#13,_UserDemand_#14)_ - 1 flow_(UserDemand_#14,_Basin_#13)_ = 1 11 | user_demand_allocated_sum_constraint_UserDemand_#14_: 1 flow_(Basin_#13,_UserDemand_#14)_ - 1 user_demand_allocated_UserDemand_#14,3_ = 0 12 | user_demand_return_flow_UserDemand_#14_: -0.5 flow_(Basin_#13,_UserDemand_#14)_ + 1 flow_(UserDemand_#14,_Basin_#13)_ = 0 13 | average_flow_unit_error_constraint_3_: -1 user_demand_error_UserDemand_#14,3,first_ + 1 average_flow_unit_error_3_ = 0 14 | average_storage_unit_error_constraint_2,upper_: -1 level_demand_error_Basin_#13,2,upper,first_ + 1000 average_storage_unit_error_2,upper_ = 0 15 | average_storage_unit_error_constraint_2,lower_: -1 level_demand_error_Basin_#13,2,lower,first_ + 1000 average_storage_unit_error_2,lower_ = 0 16 | Bounds 17 | -0.02 <= basin_storage_change_Basin_#13_ <= 0.08 18 | 0 <= low_storage_factor_Basin_#13_ <= 1 19 | 0 <= flow_(Outlet_#3,_Basin_#13)_ <= 0.001728 20 | 0 <= flow_(Pump_#6,_Basin_#13)_ <= 1.728 21 | 0 <= flow_(Basin_#13,_UserDemand_#14)_ <= 864000 22 | 0 <= flow_(UserDemand_#14,_Basin_#13)_ <= 864000 23 | 0 <= user_demand_allocated_UserDemand_#14,3_ <= 2 24 | 0 <= user_demand_error_UserDemand_#14,3,first_ <= 1 25 | 0 <= user_demand_error_UserDemand_#14,3,second_ <= 1 26 | level_demand_error_Basin_#13,2,lower,first_ >= 0 27 | level_demand_error_Basin_#13,2,lower,second_ >= 0 28 | level_demand_error_Basin_#13,2,upper,first_ >= 0 29 | level_demand_error_Basin_#13,2,upper,second_ >= 0 30 | 0 <= average_flow_unit_error_3_ <= 1 31 | average_storage_unit_error_2,lower_ >= 0 32 | average_storage_unit_error_2,upper_ >= 0 33 | End 34 | --------------------------------------------------------------------------------