diff --git a/.teamcity/Ribasim/buildTypes/Windows_1.kt b/.teamcity/Ribasim/buildTypes/Windows_1.kt index 442b41428..d97fc4254 100644 --- a/.teamcity/Ribasim/buildTypes/Windows_1.kt +++ b/.teamcity/Ribasim/buildTypes/Windows_1.kt @@ -7,10 +7,6 @@ object Windows_1 : Template({ name = "Ribasim_Windows" description = "Template for agent that uses Windows OS" - params { - param("env.JULIA_SSL_CA_ROOTS_PATH", "") - } - vcs { cleanCheckout = true } diff --git a/.teamcity/Ribasim_Windows/buildTypes/Windows_TestRibasimBinaries.kt b/.teamcity/Ribasim_Windows/buildTypes/Windows_TestRibasimBinaries.kt index 3b2ae0176..00c669f61 100644 --- a/.teamcity/Ribasim_Windows/buildTypes/Windows_TestRibasimBinaries.kt +++ b/.teamcity/Ribasim_Windows/buildTypes/Windows_TestRibasimBinaries.kt @@ -33,7 +33,7 @@ object Windows_TestRibasimBinaries : BuildType({ id = "RUNNER_1503" workingDir = "ribasim" scriptContent = """ - pixi run install + pixi run install-ci pixi run test-ribasim-api pixi run test-ribasim-cli """.trimIndent() diff --git a/.teamcity/patches/buildTypes/GenerateTestmodels.kts b/.teamcity/patches/buildTypes/GenerateTestmodels.kts deleted file mode 100644 index fee827bdf..000000000 --- a/.teamcity/patches/buildTypes/GenerateTestmodels.kts +++ /dev/null @@ -1,17 +0,0 @@ -package patches.buildTypes - -import jetbrains.buildServer.configs.kotlin.* -import jetbrains.buildServer.configs.kotlin.ui.* - -/* -This patch script was generated by TeamCity on settings change in UI. -To apply the patch, change the buildType with id = 'GenerateTestmodels' -accordingly, and delete the patch script. -*/ -changeBuildType(RelativeId("GenerateTestmodels")) { - params { - add { - param("env.PIXI_CACHE_DIR", "/u/svc-teamcity-ansible/.cache") - } - } -} diff --git a/.teamcity/patches/buildTypes/Ribasim_Windows_FixJuliaArtifactPermissions.kts b/.teamcity/patches/buildTypes/Ribasim_Windows_FixJuliaArtifactPermissions.kts deleted file mode 100644 index 025bea2cb..000000000 --- a/.teamcity/patches/buildTypes/Ribasim_Windows_FixJuliaArtifactPermissions.kts +++ /dev/null @@ -1,28 +0,0 @@ -package patches.buildTypes - -import jetbrains.buildServer.configs.kotlin.* -import jetbrains.buildServer.configs.kotlin.BuildType -import jetbrains.buildServer.configs.kotlin.buildSteps.powerShell -import jetbrains.buildServer.configs.kotlin.ui.* - -/* -This patch script was generated by TeamCity on settings change in UI. -To apply the patch, create a buildType with id = 'Ribasim_Windows_FixJuliaArtifactPermissions' -in the project with id = 'Ribasim_Windows', and delete the patch script. -*/ -create(RelativeId("Ribasim_Windows"), BuildType({ - id("Ribasim_Windows_FixJuliaArtifactPermissions") - name = "Fix Julia Artifact permissions" - description = "Temporary build to run on a failing agent" - - steps { - powerShell { - name = "Reset permissions" - id = "Reset_permissions" - scriptMode = script { - content = "icacls ~/.julia/artifacts /q /c /t /reset" - } - } - } -})) - diff --git a/core/src/allocation_optim.jl b/core/src/allocation_optim.jl index 9189e4e39..db650ed59 100644 --- a/core/src/allocation_optim.jl +++ b/core/src/allocation_optim.jl @@ -346,7 +346,11 @@ function get_basin_capacity( return 0.0 else level_max = level_demand.max_level[level_demand_idx](t) - storage_max = get_storage_from_level(p.basin, basin_idx, level_max) + if isinf(level_max) + storage_max = Inf + else + storage_max = get_storage_from_level(p.basin, basin_idx, level_max) + end return max(0.0, (storage_basin - storage_max) / Δt_allocation + influx) end end diff --git a/core/test/allocation_test.jl b/core/test/allocation_test.jl index d51173413..fd1333fcd 100644 --- a/core/test/allocation_test.jl +++ b/core/test/allocation_test.jl @@ -550,3 +550,23 @@ end @test all(isapprox.(fractions[1], fractions[3], atol = 1e-4)) @test all(isapprox.(fractions[1], fractions[4], atol = 1e-4)) end + +@testitem "level_demand_without_max_level" begin + using Ribasim: NodeID, get_basin_capacity, outflow_id + using JuMP + + toml_path = normpath(@__DIR__, "../../generated_testmodels/level_demand/ribasim.toml") + @test ispath(toml_path) + model = Ribasim.Model(toml_path) + (; p, u, t) = model.integrator + (; allocation_models) = p.allocation + (; basin, level_demand, graph) = p + + fill!(level_demand.max_level[1].u, Inf) + fill!(level_demand.max_level[2].u, Inf) + + # Given a max_level of Inf, the basin capacity is 0.0 because it is not possible for the basin level to be > Inf + @test Ribasim.get_basin_capacity(allocation_models[1], u, p, t, basin.node_id[1]) == 0.0 + @test Ribasim.get_basin_capacity(allocation_models[1], u, p, t, basin.node_id[2]) == 0.0 + @test Ribasim.get_basin_capacity(allocation_models[1], u, p, t, basin.node_id[3]) == 0.0 +end diff --git a/docs/install.qmd b/docs/install.qmd index f55dd1144..c87362908 100644 --- a/docs/install.qmd +++ b/docs/install.qmd @@ -65,14 +65,20 @@ pip install ribasim Ribasim is typically used as a command-line interface (CLI). It is distributed as a `.zip` archive, that must be downloaded and unpacked. It can be placed anywhere, however it is important that the contents of the zip file are kept together in a directory. The Ribasim -CLI executable is in the `bin` directory. +executable is in the main folder. + +To check whether the installation was performed successfully, open a terminal and go to the path where the executable is for example 'C:\Ribasim\ribasim_windows'. +If you are using cmd.exe type `ribasim`, or for PowerShell `./ribasim`. -To check whether the installation was performed successfully, run `ribasim` with no -arguments in the command line. This will give the following message: ``` -Usage: ribasim 'path/to/model/ribasim.toml' +error: the following required arguments were not provided: + + +Usage: ribasim + +For more information, try '--help'.' ``` # Ribasim Python diff --git a/pixi.toml b/pixi.toml index beb3b01a7..3883796d5 100644 --- a/pixi.toml +++ b/pixi.toml @@ -77,7 +77,7 @@ lint = { depends_on = [ build = { "cmd" = "julia --project build.jl", cwd = "build", depends_on = [ "generate-testmodels", "initialize-julia", -] } +], env = {JULIA_SSL_CA_ROOTS_PATH = ""} } remove-artifacts = "julia --eval 'rm(joinpath(Base.DEPOT_PATH[1], \"artifacts\"), force=true, recursive=true)'" # Tests test-ribasim-cli = "pytest --numprocesses=4 --basetemp=build/tests/temp --junitxml=report.xml build/tests" diff --git a/python/ribasim/ribasim/config.py b/python/ribasim/ribasim/config.py index e45970cbf..a66860b0a 100644 --- a/python/ribasim/ribasim/config.py +++ b/python/ribasim/ribasim/config.py @@ -52,6 +52,18 @@ class Allocation(ChildModel): + """ + Defines the allocation optimization algorithm options. + + Attributes + ---------- + timestep : float + The simulated time in seconds between successive allocation calls (Optional, defaults to 86400) + use_allocation : bool + Whether the allocation algorithm should be active. If not, `UserDemand` nodes attempt to + abstract their full demand (Optional, defaults to False) + """ + timestep: float = 86400.0 use_allocation: bool = False @@ -64,6 +76,38 @@ class Results(ChildModel): class Solver(ChildModel): + """ + Defines the numerical solver options. + For more details see . + + Attributes + ---------- + algorithm : str + The used numerical time integration algorithm (Optional, defaults to QNDF) + saveat : float + Time interval in seconds between saves of output data. + 0 saves every timestep, inf only saves at start- and endtime. (Optional, defaults to 86400) + dt : float + Timestep of the solver. (Optional, defaults to None which implies adaptive timestepping) + dtmin : float + The minimum allowed timestep of the solver (Optional, defaults to 0.0) + dtmax : float + The maximum allowed timestep size (Optional, defaults to 0.0 which implies the total length of the simulation) + force_dtmin : bool + If a smaller dt than dtmin is needed to meet the set error tolerances, the simulation stops, unless force_dtmin = true + (Optional, defaults to False) + abstol : float + The absolute tolerance for adaptive timestepping (Optional, defaults to 1e-6) + reltol : float + The relative tolerance for adaptive timestepping (Optional, defaults to 1e-5) + maxiters : int + The total number of linear iterations over the whole simulation. (Defaults to 1e9, only needs to be increased for extremely long simulations) + sparse : bool + Whether a sparse Jacobian matrix is used, which gives a significant speedup for models with >~10 basins. + autodiff : bool + Whether automatic differentiation instead of fine difference is used to compute the Jacobian. (Optional, defaults to true) + """ + algorithm: str = "QNDF" saveat: float = 86400.0 dt: float | None = None @@ -85,11 +129,37 @@ class Verbosity(str, Enum): class Logging(ChildModel): + """ + Defines the logging behavior of the core. + + Attributes + ---------- + verbosity : Verbosity + The verbosity of the logging: debug/info/warn/error (Optional, defaults to info) + timing : Bool + Enable timings (Optional, defaults to False) + """ + verbosity: Verbosity = Verbosity.info timing: bool = False class Node(pydantic.BaseModel): + """ + Defines a node for the model. + + Attributes + ---------- + node_id : NonNegativeInt + Integer ID of the node. Must be unique within the same node type. + geometry : shapely.geometry.Point + The coordinates of the node. + name : str + An optional name of the node. + subnetwork_id : int + Optionally adds this node to a subnetwork, which is input for the allocation algorithm. + """ + node_id: NonNegativeInt geometry: Point name: str = "" @@ -124,6 +194,18 @@ def filter(self) -> "MultiNodeModel": return self def add(self, node: Node, tables: Sequence[TableModel[Any]] | None = None) -> None: + """Add a node and the associated data to the model. + + Parameters + ---------- + node : Ribasim.Node + tables : Sequence[TableModel[Any]] | None + + Raises + ------ + ValueError + When the given node ID already exists for this node type + """ if tables is None: tables = [] diff --git a/python/ribasim/ribasim/geometry/edge.py b/python/ribasim/ribasim/geometry/edge.py index bba452853..98e57b4a4 100644 --- a/python/ribasim/ribasim/geometry/edge.py +++ b/python/ribasim/ribasim/geometry/edge.py @@ -53,6 +53,24 @@ def add( subnetwork_id: int | None = None, **kwargs, ): + """Add an edge between nodes. The type of the edge (flow or control) + is automatically inferred from the type of the `from_node`. + + Parameters + ---------- + from_node : NodeData + A node indexed by its node ID, e.g. `model.basin[1]` + to_node: NodeData + A node indexed by its node ID, e.g. `model.linear_resistance[1]` + geometry : LineString | MultiLineString | None + The geometry of a line. If not supplied, it creates a straight line between the nodes. + name : str + An optional name for the edge. + subnetwork_id : int | None + An optional subnetwork id for the edge. This edge indicates a source for + the allocation algorithm, and should thus not be set for every edge in a subnetwork. + **kwargs : Dict + """ geometry_to_append = ( [LineString([from_node.geometry, to_node.geometry])] if geometry is None diff --git a/python/ribasim/ribasim/model.py b/python/ribasim/ribasim/model.py index f951ef5f1..329ea0611 100644 --- a/python/ribasim/ribasim/model.py +++ b/python/ribasim/ribasim/model.py @@ -180,6 +180,13 @@ def _save(self, directory: DirectoryPath, input_dir: DirectoryPath): sub._save(directory, input_dir) def set_crs(self, crs: str) -> None: + """Set the coordinate reference system of the data in the model. + + Parameters + ---------- + crs : str + Coordinate reference system, like "EPSG:4326" for WGS84 latitude longitude. + """ self._apply_crs_function("set_crs", crs) def to_crs(self, crs: str) -> None: @@ -229,18 +236,24 @@ def _children(self): @classmethod def read(cls, filepath: str | PathLike[str]) -> "Model": - """Read model from TOML file.""" + """Read a model from a TOML file. + + Parameters + ---------- + filepath : str | PathLike[str] + The path to the TOML file. + """ return cls(filepath=filepath) # type: ignore def write(self, filepath: str | PathLike[str]) -> Path: - """ - Write the contents of the model to disk and save it as a TOML configuration file. + """Write the contents of the model to disk and save it as a TOML configuration file. If ``filepath.parent`` does not exist, it is created before writing. Parameters ---------- - filepath: str | PathLike[str] A file path with .toml extension + filepath : str | PathLike[str] + A file path with .toml extension. """ # TODO # self.validate_model() @@ -280,6 +293,8 @@ def reset_contextvar(self) -> "Model": return self def plot_control_listen(self, ax): + """Plot the implicit listen edges of the model.""" + df_listen_edge = pd.DataFrame( data={ "control_node_id": pd.Series([], dtype=np.int32), @@ -341,17 +356,19 @@ def plot_control_listen(self, ax): return def plot(self, ax=None, indicate_subnetworks: bool = True) -> Any: - """ - Plot the nodes, edges and allocation networks of the model. + """Plot the nodes, edges and allocation networks of the model. Parameters ---------- - ax : matplotlib.pyplot.Artist, optional + ax : matplotlib.pyplot.Artist Axes on which to draw the plot. + indicate_subnetworks : bool + Whether to indicate subnetworks with a convex hull backdrop. Returns ------- ax : matplotlib.pyplot.Artist + Axis on which the plot is drawn. """ if ax is None: _, ax = plt.subplots() @@ -377,13 +394,17 @@ def plot(self, ax=None, indicate_subnetworks: bool = True) -> Any: return ax def to_xugrid(self, add_flow: bool = False, add_allocation: bool = False): - """ - Convert the network to a `xugrid.UgridDataset`. - To add flow results, set `add_flow=True`. - To add allocation results, set `add_allocation=True`. - Both cannot be added to the same dataset. - This method will throw `ImportError`, - if the optional dependency `xugrid` isn't installed. + """Convert the network to a `xugrid.UgridDataset`. + + Either the flow or the allocation data can be added, but not both simultaneously. + This method will throw `ImportError` if the optional dependency `xugrid` isn't installed. + + Parameters + ---------- + add_flow : bool + add flow results (Optional, defaults to False) + add_allocation : bool + add allocation results (Optional, defaults to False) """ if add_flow and add_allocation: diff --git a/python/ribasim/tests/test_io.py b/python/ribasim/tests/test_io.py index 0bd8e13ed..c8fe47b84 100644 --- a/python/ribasim/tests/test_io.py +++ b/python/ribasim/tests/test_io.py @@ -1,4 +1,5 @@ from datetime import datetime +from pathlib import Path import numpy as np import pytest @@ -202,3 +203,10 @@ def test_datetime_timezone(): assert isinstance(model.endtime, datetime) assert model.starttime.tzinfo is None assert model.endtime.tzinfo is None + + +def test_minimal_toml(): + # Check if the TOML used in QGIS tests is still valid. + toml_path = Path(__file__).parents[3] / "ribasim_qgis/tests/data/simple_valid.toml" + model = ribasim.Model.read(toml_path) + assert model.crs == "EPSG:28992" diff --git a/ribasim_qgis/tests/data/simple_valid.toml b/ribasim_qgis/tests/data/simple_valid.toml index 4df80d496..ae18a6578 100644 --- a/ribasim_qgis/tests/data/simple_valid.toml +++ b/ribasim_qgis/tests/data/simple_valid.toml @@ -1,4 +1,6 @@ starttime = 2020-01-01 00:00:00 endtime = 2021-01-01 00:00:00 +crs = "EPSG:28992" input_dir = "." results_dir = "results" +ribasim_version = "2024.9.0" diff --git a/ribasim_qgis/widgets/dataset_widget.py b/ribasim_qgis/widgets/dataset_widget.py index 5c2be37f2..0b6258a5a 100644 --- a/ribasim_qgis/widgets/dataset_widget.py +++ b/ribasim_qgis/widgets/dataset_widget.py @@ -93,8 +93,8 @@ def remove_geopackage_layers(self) -> None: # Start deleting elements = {item.element for item in selection} # type: ignore[attr-defined] # TODO: dynamic item.element should be in some dict. - qgs_instance = QgsProject.instance() - assert qgs_instance is not None + project = QgsProject.instance() + assert project is not None for element in elements: layer = element.layer @@ -102,7 +102,7 @@ def remove_geopackage_layers(self) -> None: if layer is None: continue try: - qgs_instance.removeMapLayer(layer.id()) + project.removeMapLayer(layer.id()) except (RuntimeError, AttributeError) as e: if e.args[0] in ( "wrapped C/C++ object of type QgsVectorLayer has been deleted", @@ -268,8 +268,10 @@ def _write_new_model(self) -> None: [ f"starttime = {datetime(2020, 1, 1)}\n", f"endtime = {datetime(2021, 1, 1)}\n", + f'crs = "{self.ribasim_widget.crs.authid()}"\n', 'input_dir = "."\n', 'results_dir = "results"\n', + 'ribasim_version = "2024.9.0"\n', ] )