Repository: Deltares/Wflow.jl (language: python)

Issue:
OpenDA coupling
~~This will likely need BMI 2.0 support in OpenDA first.~~
We will probably do this using the black box model approach, using the `wflow_cli`.


Discussion / hints:
Started with OpenDA memory coupling approach with Thrift.jl (black box model approach is too slow). It seems the handling of user-defined exceptions is not working properly, with Thrift-0.8.4 an error is thrown immediately, and with Thrift-0.7.1 an error is thrown when the user-defined exception occurs, see also the following issue https://github.com/tanmaykm/Thrift.jl/issues/76.

Fix patch:
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 000000000..700707ced
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,7 @@
+# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
+version: 2
+updates:
+  - package-ecosystem: "github-actions"
+    directory: "/" # Location of package manifests
+    schedule:
+      interval: "weekly"
diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml
index 5a94f1f7a..ca916af89 100644
--- a/.github/workflows/CI.yml
+++ b/.github/workflows/CI.yml
@@ -44,6 +44,7 @@ jobs:
         env:
           JULIA_NUM_THREADS: ${{ matrix.threads }}
       - uses: julia-actions/julia-processcoverage@v1
-      - uses: codecov/codecov-action@v3
+      - uses: codecov/codecov-action@v4
         with:
-          file: lcov.info
+          files: lcov.info
+          token: ${{ secrets.CODECOV_TOKEN }}
diff --git a/.github/workflows/CIWflowServer.yml b/.github/workflows/CIWflowServer.yml
new file mode 100644
index 000000000..89e442d80
--- /dev/null
+++ b/.github/workflows/CIWflowServer.yml
@@ -0,0 +1,56 @@
+name: Wflow Server CI
+on:
+  pull_request:
+  push:
+    branches:
+      - master
+    tags: '*'
+jobs:
+  test:
+    name: WflowServer Julia ${{ matrix.version }} - ${{ matrix.os }} - ${{ matrix.arch }} - ${{ github.event_name }}
+    runs-on: ${{ matrix.os }}
+    strategy:
+      fail-fast: false
+      matrix:
+        version:
+          - '1.6'
+          - '1'
+        os:
+          - ubuntu-latest
+          - windows-latest
+        arch:
+          - x64
+    steps:
+      - uses: actions/checkout@v2
+      - uses: julia-actions/setup-julia@v1
+        with:
+          version: ${{ matrix.version }}
+          arch: ${{ matrix.arch }}
+      - uses: actions/cache@v1
+        env:
+          cache-name: cache-artifacts
+        with:
+          path: ~/.julia/artifacts
+          key: ${{ runner.os }}-test-${{ env.cache-name }}-${{ hashFiles('**/Project.toml') }}
+          restore-keys: |
+            ${{ runner.os }}-test-${{ env.cache-name }}-
+            ${{ runner.os }}-test-
+            ${{ runner.os }}-
+      - name: Install Julia dependencies
+        shell: julia --project=server {0}
+        run: |
+          using Pkg;
+          # dev install Wflow
+          pkg"dev ."
+      - name: Run the Wflow Server tests
+        continue-on-error: true
+        run: >
+          julia --color=yes --project=server -e 'using Pkg; Pkg.test("WflowServer", coverage=true)'
+          shell: bash
+      - uses: julia-actions/julia-processcoverage@v1
+        with:
+          directories: server/src
+      - uses: codecov/codecov-action@v4
+        with:
+          files: lcov.info
+          token: ${{ secrets.CODECOV_TOKEN }}
diff --git a/docs/src/user_guide/additional_options.md b/docs/src/user_guide/additional_options.md
index 7862a5f7c..1f2bdcf57 100644
--- a/docs/src/user_guide/additional_options.md
+++ b/docs/src/user_guide/additional_options.md
@@ -289,3 +289,9 @@ expectations, which then can get parsed with these Delft-FEWS log parsing settin
     <debugLinePattern >* [Debug] *</debugLinePattern >
 </logFile>
 ```
+
+## [Run Wflow as a ZMQ Server]
+It is possible to run Wflow as a ZMQ Server, for example for the coupling to the
+[OpenDA](https://openda.org/) software for data-assimilation. The code for the Wflow ZMQ
+Server is not part of the Wflow.jl package, and is located
+[here](https://github.com/Deltares/Wflow.jl/tree/zmq_server/server).
diff --git a/server/Project.toml b/server/Project.toml
new file mode 100644
index 000000000..38bcca654
--- /dev/null
+++ b/server/Project.toml
@@ -0,0 +1,25 @@
+name = "WflowServer"
+uuid = "6f3aa2dc-f527-41f4-85fc-b3cbabe74cf6"
+authors = ["Deltares and contributors"]
+version = "0.1.0"
+
+[deps]
+JSON3 = "0f8b85d8-7281-11e9-16c2-39a750bddbf1"
+Logging = "56ddb016-857b-54e1-b83d-db4d58db5568"
+StructTypes = "856f2bd8-1eba-4b0a-8007-ebc267875bd4"
+Wflow = "d48b7d99-76e7-47ae-b1d5-ff0c1cf9a818"
+ZMQ = "c2297ded-f4af-51ae-bb23-16f91089e4e1"
+
+[compat]
+JSON3 = "1.14"
+StructTypes = "1.10"
+ZMQ = "1.2"
+julia = "1.6"
+
+[extras]
+Downloads = "f43a241f-c20a-4ad4-852c-f6b1247861c6"
+Statistics = "10745b16-79ce-11e8-11f9-7d13ad32a3b2"
+Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
+
+[targets]
+test = ["Downloads", "Statistics", "Test"]
diff --git a/server/README.md b/server/README.md
new file mode 100644
index 000000000..8be13597a
--- /dev/null
+++ b/server/README.md
@@ -0,0 +1,51 @@
+# Wflow ZMQ Server
+Call [Wflow](https://github.com/Deltares/Wflow.jl) functions exposed through the Basic Model
+Interface (BMI) implementation of Wflow and three additonal Wflow functions related to model
+states and start time of the model in Unix time, using [ZeroMQ](https://zeromq.org/) with
+TCP data transport. The Wflow ZMQ Server allows users to interact with a Wflow model from
+many other programming languages with ZeroMQ bindings. An example is the use of
+[OpenDA](https://openda.org/) Java software for data-assimilation. The coupling of OpenDA
+and Wflow through a black box model approach is too slow because it requires a restart of
+Wflow (multiple times), while this is not required with a client-server approach.
+
+The Wflow ZMQ Server can be started through an interactive Julia version, using different
+functions. First, start the `WflowServer` environment on startup, in the current directory:
+```
+julia --project=.
+```
+then start the Wflow ZMQ Server, listening on port 5556 with the `start` function as
+follows:
+```julia-repl
+julia> using WflowServer
+julia> WflowServer.start(5556)
+```
+or start the Wflow ZMQ Server, listening on port 5556 with the `main` function:
+```julia-repl
+julia> using WflowServer
+julia> WflowServer.main(["port=5556"])
+```
+or start the Wflow ZMQ Server with the `main` function using the default port number 5555:
+```julia-repl
+julia> using WflowServer
+julia> WflowServer.main()
+```
+Finally, it is also possible to start the Wflow ZMQ server directly from the command line
+with the Julia script `run_server.jl` in the current directory, as follows:
+```
+julia --project=. run_server.jl --port 5556
+```
+
+## JSON
+JSON is used for data serialization. The Wflow ZMQ Server maps a JSON message to a Julia
+structure directly. Below examples of two messages that provide a function name `fn` and
+other arguments required by the exposed Wflow functions (mostly through BMI):
+
+```
+# initialize a Wflow model through the configuration file `sbm_config.toml`
+"""{"fn": "initialize", "config_file": "sbm_config.toml"}"""
+# update model until time 86400.0
+"""{"fn": "update_until", "time": 86400.0}"""
+```
+
+The [tests](/server/test/) provide further examples of the interface to the Wflow (BMI) and
+server functions.
diff --git a/server/run_server.jl b/server/run_server.jl
new file mode 100644
index 000000000..421c1f4c5
--- /dev/null
+++ b/server/run_server.jl
@@ -0,0 +1,3 @@
+using WflowServer
+
+WflowServer.main()
diff --git a/server/src/WflowServer.jl b/server/src/WflowServer.jl
new file mode 100644
index 000000000..c96c7731e
--- /dev/null
+++ b/server/src/WflowServer.jl
@@ -0,0 +1,9 @@
+module WflowServer
+import ZMQ
+import JSON3
+import StructTypes
+import Wflow
+
+include("bmi_service.jl")
+include("server.jl")
+end
diff --git a/server/src/bmi_service.jl b/server/src/bmi_service.jl
new file mode 100644
index 000000000..3a344179a
--- /dev/null
+++ b/server/src/bmi_service.jl
@@ -0,0 +1,357 @@
+# Structs that contain function name `fn` and possible other function arguments. These
+# structs are used in `wflow_bmi` functions (below these structs), that run Wflow.BMI (and
+# Wflow) functions.
+
+struct Initialize
+    config_file::String
+    fn::String
+end
+
+struct GetComponentName
+    fn::String
+end
+
+struct GetInputItemCount
+    fn::String
+end
+
+struct GetOutputItemCount
+    fn::String
+end
+
+struct GetStartTime
+    fn::String
+end
+
+struct GetStartUnixTime
+    fn::String
+end
+
+struct GetEndTime
+    fn::String
+end
+
+struct GetTimeStep
+    fn::String
+end
+
+struct GetTimeUnits
+    fn::String
+end
+
+struct GetCurrentTime
+    fn::String
+end
+
+struct Update
+    fn::String
+end
+
+struct UpdateUntil
+    fn::String
+    time::Float64
+end
+
+struct GetInputVarNames
+    fn::String
+end
+
+struct GetOutputVarNames
+    fn::String
+end
+
+struct GetVarItemSize
+    fn::String
+    name::String
+end
+
+struct GetVarType
+    fn::String
+    name::String
+end
+
+struct GetVarUnits
+    fn::String
+    name::String
+end
+
+struct GetVarNbytes
+    fn::String
+    name::String
+end
+
+struct GetVarLocation
+    fn::String
+    name::String
+end
+
+struct GetValue
+    fn::String
+    name::String
+    dest::Vector{Wflow.Float}
+end
+
+struct GetValuePtr
+    fn::String
+    name::String
+end
+
+struct GetValueAtIndices
+    fn::String
+    inds::Vector{Int}
+    dest::Vector{Wflow.Float}
+    name::String
+end
+
+struct SetValue
+    fn::String
+    name::String
+    src::Vector{Wflow.Float}
+end
+
+struct SetValueAtIndices
+    fn::String
+    inds::Vector{Int}
+    name::String
+    src::Vector{Wflow.Float}
+end
+
+struct GetGridType
+    fn::String
+    grid::Int
+end
+
+struct GetGridRank
+    fn::String
+    grid::Int
+end
+
+struct GetGridSize
+    fn::String
+    grid::Int
+end
+
+struct GetGridX
+    fn::String
+    grid::Int
+    x::Vector{Wflow.Float}
+end
+
+struct GetGridY
+    fn::String
+    grid::Int
+    y::Vector{Wflow.Float}
+end
+
+struct GetGridNodeCount
+    fn::String
+    grid::Int
+end
+
+struct GetGridEdgeCount
+    fn::String
+    grid::Int
+end
+
+struct GetGridEdgeNodes
+    fn::String
+    grid::Int
+    edge_nodes::Vector{Int}
+end
+
+struct GetVarGrid
+    fn::String
+    name::String
+end
+
+struct Finalize
+    fn::String
+end
+
+struct LoadState
+    fn::String
+end
+
+struct SaveState
+    fn::String
+end
+
+function wflow_bmi(m::Initialize, model::Union{Wflow.Model,Nothing})
+    model = getfield(Wflow.BMI, Symbol(m.fn))(Wflow.Model, m.config_file)
+    return model
+end
+
+function wflow_bmi(m::GetComponentName, model::Wflow.Model)
+    component_name = getfield(Wflow.BMI, Symbol(m.fn))(model)
+    return Dict("component_name" => component_name)
+end
+
+function wflow_bmi(m::GetInputItemCount, model::Wflow.Model)
+    input_item_count = getfield(Wflow.BMI, Symbol(m.fn))(model)
+    return Dict("input_item_count" => input_item_count)
+end
+
+function wflow_bmi(m::GetOutputItemCount, model::Wflow.Model)
+    output_item_count = getfield(Wflow.BMI, Symbol(m.fn))(model)
+    return Dict("output_item_count" => output_item_count)
+end
+
+function wflow_bmi(m::GetStartTime, model::Wflow.Model)
+    start_time = getfield(Wflow.BMI, Symbol(m.fn))(model)
+    return Dict("start_time" => start_time)
+end
+
+function wflow_bmi(m::GetStartUnixTime, model::Wflow.Model)
+    start_unix_time = getfield(Wflow, Symbol(m.fn))(model)
+    return Dict("start_unix_time" => start_unix_time)
+end
+
+function wflow_bmi(m::GetEndTime, model::Wflow.Model)
+    end_time = getfield(Wflow.BMI, Symbol(m.fn))(model)
+    return Dict("end_time" => end_time)
+end
+
+function wflow_bmi(m::GetTimeStep, model::Wflow.Model)
+    time_step = getfield(Wflow.BMI, Symbol(m.fn))(model)
+    return Dict("time_step" => time_step)
+end
+
+function wflow_bmi(m::GetTimeUnits, model::Wflow.Model)
+    time_units = getfield(Wflow.BMI, Symbol(m.fn))(model)
+    return Dict("time_units" => time_units)
+end
+
+function wflow_bmi(m::GetCurrentTime, model::Wflow.Model)
+    current_time = getfield(Wflow.BMI, Symbol(m.fn))(model)
+    return Dict("current_time" => current_time)
+end
+
+function wflow_bmi(m::UpdateUntil, model::Wflow.Model)
+    model = getfield(Wflow.BMI, Symbol(m.fn))(model, m.time)
+    return model
+end
+
+function wflow_bmi(m::Update, model::Wflow.Model)
+    model = getfield(Wflow.BMI, Symbol(m.fn))(model)
+    return model
+end
+
+function wflow_bmi(m::GetInputVarNames, model::Wflow.Model)
+    input_var_names = getfield(Wflow.BMI, Symbol(m.fn))(model)
+    return Dict("input_var_names" => input_var_names)
+end
+
+function wflow_bmi(m::GetOutputVarNames, model::Wflow.Model)
+    output_var_names = getfield(Wflow.BMI, Symbol(m.fn))(model)
+    return Dict("output_var_names" => output_var_names)
+end
+
+function wflow_bmi(m::GetVarItemSize, model::Wflow.Model)
+    var_itemsize = getfield(Wflow.BMI, Symbol(m.fn))(model, m.name)
+    return Dict("var_itemsize" => var_itemsize)
+end
+
+function wflow_bmi(m::GetVarType, model::Wflow.Model)
+    var_type = getfield(Wflow.BMI, Symbol(m.fn))(model, m.name)
+    return Dict("var_type" => var_type)
+end
+
+function wflow_bmi(m::GetVarUnits, model::Wflow.Model)
+    var_units = getfield(Wflow.BMI, Symbol(m.fn))(model, m.name)
+    return Dict("var_units" => var_units)
+end
+
+function wflow_bmi(m::GetVarNbytes, model::Wflow.Model)
+    var_nbytes = getfield(Wflow.BMI, Symbol(m.fn))(model, m.name)
+    return Dict("var_nbytes" => var_nbytes)
+end
+
+function wflow_bmi(m::GetVarLocation, model::Wflow.Model)
+    var_location = getfield(Wflow.BMI, Symbol(m.fn))(model, m.name)
+    return Dict("var_location" => var_location)
+end
+
+function wflow_bmi(m::GetValue, model::Wflow.Model)
+    getfield(Wflow.BMI, Symbol(m.fn))(model, m.name, m.dest)
+    return Dict("value" => m.dest)
+end
+
+function wflow_bmi(m::GetValuePtr, model::Wflow.Model)
+    value_ptr = getfield(Wflow.BMI, Symbol(m.fn))(model, m.name)
+    return Dict("value_ptr" => value_ptr)
+end
+
+function wflow_bmi(m::GetValueAtIndices, model::Wflow.Model)
+    getfield(Wflow.BMI, Symbol(m.fn))(model, m.name, m.dest, m.inds)
+    return Dict("value_at_indices" => m.dest)
+end
+
+function wflow_bmi(m::SetValue, model::Wflow.Model)
+    getfield(Wflow.BMI, Symbol(m.fn))(model, m.name, m.src)
+    return nothing
+end
+
+function wflow_bmi(m::SetValueAtIndices, model::Wflow.Model)
+    getfield(Wflow.BMI, Symbol(m.fn))(model, m.name, m.inds, m.src)
+    return nothing
+end
+
+function wflow_bmi(m::GetGridType, model::Wflow.Model)
+    grid_type = getfield(Wflow.BMI, Symbol(m.fn))(model, m.grid)
+    return Dict("grid_type" => grid_type)
+end
+
+function wflow_bmi(m::GetGridRank, model::Wflow.Model)
+    grid_rank = getfield(Wflow.BMI, Symbol(m.fn))(model, m.grid)
+    return Dict("grid_rank" => grid_rank)
+end
+
+function wflow_bmi(m::GetGridSize, model::Wflow.Model)
+    grid_size = getfield(Wflow.BMI, Symbol(m.fn))(model, m.grid)
+    return Dict("grid_size" => grid_size)
+end
+
+function wflow_bmi(m::GetGridX, model::Wflow.Model)
+    getfield(Wflow.BMI, Symbol(m.fn))(model, m.grid, m.x)
+    return Dict("grid_x" => m.x)
+end
+
+function wflow_bmi(m::GetGridY, model::Wflow.Model)
+    getfield(Wflow.BMI, Symbol(m.fn))(model, m.grid, m.y)
+    return Dict("grid_y" => m.y)
+end
+
+function wflow_bmi(m::GetGridNodeCount, model::Wflow.Model)
+    grid_node_count = getfield(Wflow.BMI, Symbol(m.fn))(model, m.grid)
+    return Dict("grid_node_count" => grid_node_count)
+end
+
+function wflow_bmi(m::GetGridEdgeCount, model::Wflow.Model)
+    grid_edge_count = getfield(Wflow.BMI, Symbol(m.fn))(model, m.grid)
+    return Dict("grid_edge_count" => grid_edge_count)
+end
+
+function wflow_bmi(m::GetGridEdgeNodes, model::Wflow.Model)
+    getfield(Wflow.BMI, Symbol(m.fn))(model, m.grid, m.edge_nodes)
+    return Dict("grid_edge_nodes" => m.edge_nodes)
+end
+
+function wflow_bmi(m::GetVarGrid, model::Wflow.Model)
+    var_grid = getfield(Wflow.BMI, Symbol(m.fn))(model, m.name)
+    return Dict("var_grid" => var_grid)
+end
+
+function wflow_bmi(m::Finalize, model::Wflow.Model)
+    getfield(Wflow.BMI, Symbol(m.fn))(model)
+    return Dict("status" => "OK")
+end
+
+function wflow_bmi(m::LoadState, model::Wflow.Model)
+    model = getfield(Wflow, Symbol(m.fn))(model)
+    return model
+end
+
+function wflow_bmi(m::SaveState, model::Wflow.Model)
+    getfield(Wflow, Symbol(m.fn))(model)
+    return Dict("status" => "OK")
+end
diff --git a/server/src/server.jl b/server/src/server.jl
new file mode 100644
index 000000000..4e183af90
--- /dev/null
+++ b/server/src/server.jl
@@ -0,0 +1,185 @@
+# map JSON function name to Struct (bmi_service.jl)
+const map_structs = Dict(
+    "initialize" => Initialize,
+    "get_component_name" => GetComponentName,
+    "get_input_item_count" => GetInputItemCount,
+    "get_output_item_count" => GetOutputItemCount,
+    "get_start_time" => GetStartTime,
+    "get_start_unix_time" => GetStartUnixTime,
+    "get_end_time" => GetEndTime,
+    "get_time_step" => GetTimeStep,
+    "get_time_units" => GetTimeUnits,
+    "get_current_time" => GetCurrentTime,
+    "update_until" => UpdateUntil,
+    "update" => Update,
+    "get_input_var_names" => GetInputVarNames,
+    "get_output_var_names" => GetOutputVarNames,
+    "get_var_itemsize" => GetVarItemSize,
+    "get_var_type" => GetVarType,
+    "get_var_units" => GetVarUnits,
+    "get_var_location" => GetVarLocation,
+    "get_var_nbytes" => GetVarNbytes,
+    "get_value" => GetValue,
+    "get_value_ptr" => GetValuePtr,
+    "get_value_at_indices" => GetValueAtIndices,
+    "set_value" => SetValue,
+    "set_value_at_indices" => SetValueAtIndices,
+    "get_grid_type" => GetGridType,
+    "get_var_grid" => GetVarGrid,
+    "get_grid_rank" => GetGridRank,
+    "get_grid_size" => GetGridSize,
+    "get_grid_x" => GetGridX,
+    "get_grid_y" => GetGridY,
+    "get_grid_node_count" => GetGridNodeCount,
+    "get_grid_edge_count" => GetGridEdgeCount,
+    "get_grid_edge_nodes" => GetGridEdgeNodes,
+    "finalize" => Finalize,
+    "load_state" => LoadState,
+    "save_state" => SaveState,
+)
+
+mutable struct ModelHandler
+    model::Union{Wflow.Model,Nothing}
+end
+
+"Shutdown ZMQ server"
+function shutdown(s::ZMQ.Socket, ctx::ZMQ.Context)
+    @info "Shutting down Wflow ZMQ server on request..."
+    ZMQ.close(s)
+    ZMQ.close(ctx)
+end
+
+"Error response ZMQ server"
+function response(err::AbstractString, s::ZMQ.Socket)
+    @info "Send error response"
+    resp = Dict{String,String}("status" => "ERROR", "error" => err)
+    ZMQ.send(s, JSON3.write(resp))
+end
+
+"Status response ZMQ server"
+function response(s::ZMQ.Socket)
+    @info "Send status response"
+    resp = Dict{String,String}("status" => "OK")
+    ZMQ.send(s, JSON3.write(resp))
+end
+
+"Validate JSON request against mapped Struct"
+function valid_request(json)
+    for f in fieldnames(map_structs[json.fn])
+        if f ∉ keys(json)
+            return f
+            break
+        end
+    end
+end
+
+"""
+    wflow_bmi(s::Socket, handler::ModelHandler, f)
+
+Run a Wflow function through `wflow.bmi(f, handler.model)` and update Wflow Model `handler`
+if required, depending on return type of `wflow.bmi(f, handler.model)`.
+"""
+function wflow_bmi(s::ZMQ.Socket, handler::ModelHandler, f)
+    try
+        ret = wflow_bmi(f, handler.model)
+        if typeof(ret) <: Wflow.Model # update of Wflow model
+            handler.model = ret
+            response(s)
+        elseif isnothing(ret) # for SetValue and SetValueAtIndices
+            response(s)
+        else
+            @info "Send response including output from Wflow function `$(f.fn)`"
+            ZMQ.send(s, JSON3.write(ret))
+        end
+    catch e
+        @error "Wflow function `$(f.fn)` failed" exception = (e, catch_backtrace())
+        err = string(
+            "Wflow function `$(f.fn)` failed\n exception =\n ",
+            sprint(showerror, e, catch_backtrace()),
+        )
+        response(err, s)
+    end
+end
+
+main() = main(ARGS)
+
+"""
+    main(ARGS::Vector{String})
+    main()
+
+This is the main entry point of the Wflow ZMQ Server. Performs argument parsing and starts
+the Wflow ZMQ Server, with `WflowServer.start(port::Int)`.
+"""
+function main(ARGS::Vector{String})
+    n = length(ARGS)
+    if n == 0
+        port = 5555
+    elseif startswith(ARGS[1], "--port")
+        if occursin("=", ARGS[1])
+            port = parse(Int, split(ARGS[1], "=")[2])
+        else
+            port = parse(Int, ARGS[2])
+        end
+    else
+        throw(
+            ArgumentError(
+                "One argument is allowed to specify the port number: `--port=<port>` or `--port <port>`, 
+                where `<port>` refers to the port number.",
+            ),
+        )
+    end
+    start(port)
+end
+
+"""
+    start(port::Int)
+
+Start the Wflow ZMQ Server using port number `port`.
+"""
+function start(port::Int)
+    @info "Start Wflow ZMQ Server..."
+
+    # initialize Wflow model handler
+    handler = ModelHandler(nothing)
+
+    # set up a ZMQ context, with optional port number (default = 5555) argument
+    context = ZMQ.Context()
+    socket = ZMQ.Socket(context, ZMQ.REP)
+    ZMQ.bind(socket, "tcp://*:$port")
+
+    try
+        while true
+            # Wait for next request from client
+            req = ZMQ.recv(socket)
+            json = JSON3.read(req)
+            @info "Received request to run function `$(json.fn)`..."
+
+            if haskey(map_structs, json.fn)
+                v = valid_request(json)
+                if isnothing(v)
+                    f = StructTypes.constructfrom(map_structs[json.fn], json)
+                    wflow_bmi(socket, handler, f)
+                else
+                    err = (
+                        """At least one required argument name (`$v`) not available for function: `$(json.fn)`"""
+                    )
+                    @error err
+                    response(err, socket)
+                end
+            elseif json.fn === "shutdown"
+                response(socket)
+                break
+            else
+                err = "Received invalid Wflow function: `$(json.fn)`"
+                @error err
+                response(err, socket)
+            end
+        end
+    catch e
+        err = "Wflow ZMQ Server: exception in process"
+        @error err
+        response(err, socket)
+    finally
+        shutdown(socket, context)
+    end
+end
diff --git a/src/flow.jl b/src/flow.jl
index c0c0a1515..c9c8d25e8 100644
--- a/src/flow.jl
+++ b/src/flow.jl
@@ -283,7 +283,6 @@ function update(sf::SurfaceFlowRiver, network, inflow_wb, doy)
                 for (n, v) in zip(indices_subdomain[m], topo_subdomain[m])
                     # sf.qin by outflow from upstream reservoir or lake location is added
                     sf.qin[v] += sum_at(sf.q, upstream_nodes[n])
-
                     # Inflow supply/abstraction is added to qlat (divide by flow length)
                     # If inflow < 0, abstraction is limited
                     if sf.inflow[v] < 0.0
@@ -349,7 +348,6 @@ function update(sf::SurfaceFlowRiver, network, inflow_wb, doy)
                     # update h
                     crossarea = sf.α[v] * pow(sf.q[v], sf.β)
                     sf.h[v] = crossarea / sf.width[v]
-
                     sf.q_av[v] += sf.q[v]
                     sf.h_av[v] += sf.h[v]
                 end
diff --git a/src/sediment_model.jl b/src/sediment_model.jl
index aea2ceb83..6e1f1397d 100644
--- a/src/sediment_model.jl
+++ b/src/sediment_model.jl
@@ -149,8 +149,8 @@ function initialize_sediment_model(config::Config)
     )
 
     model = set_states(model)
-
     @info "Initialized model"
+
     return model
 end
 


Test patch:
diff --git a/server/test/client.jl b/server/test/client.jl
new file mode 100644
index 000000000..41fc1182d
--- /dev/null
+++ b/server/test/client.jl
@@ -0,0 +1,170 @@
+# start Wflow ZMQ server (@async)
+@async begin
+    WflowServer.main()
+end
+
+# Connecting to the Wflow ZMQ Server
+context = ZMQ.Context()
+socket = ZMQ.Socket(context, ZMQ.REQ)
+ZMQ.connect(socket, "tcp://localhost:5555")
+
+function request(message)
+    ZMQ.send(socket, JSON3.write(message))
+    ret_value = JSON3.read(ZMQ.recv(socket), Dict)
+    return ret_value
+end
+
+@testset "initialization and time functions" begin
+    msg =
+        (fn = "initialize", config_file = joinpath(@__DIR__, "sbm_config.toml"))
+    @test request(msg) == Dict("status" => "OK")
+    @test request((fn = "get_end_time",)) == Dict("end_time" => 2678400)
+    @test request((fn = "get_start_time",)) == Dict("start_time" => 0)
+    @test request((fn = "get_start_unix_time",)) == Dict("start_unix_time" => 946684800)
+    @test request((fn = "get_time_step",)) == Dict("time_step" => 86400)
+    @test request((fn = "get_time_units",)) == Dict("time_units" => "s")
+end
+
+@testset "update functions" begin
+    @test request((fn = "update_until", time = 86400.0)) == Dict("status" => "OK")
+    @test request((fn = "get_current_time",)) == Dict("current_time" => 86400)
+    @test request((fn = "update",)) == Dict("status" => "OK")
+end
+
+@testset "model information functions" begin
+    @test request((fn = "get_component_name",)) == Dict("component_name" => "sbm")
+    @test request((fn = "get_input_item_count",)) == Dict("input_item_count" => 181)
+    @test request((fn = "get_output_item_count",)) == Dict("output_item_count" => 181)
+    @test request((fn = "get_input_var_names",))["input_var_names"][[1, 5, 151, 175]] == [
+        "vertical.nlayers",
+        "vertical.θᵣ",
+        "lateral.river.q",
+        "lateral.river.reservoir.outflow",
+    ]
+    @test request((fn = "get_output_var_names",))["output_var_names"][[1, 5, 151, 175]] == [
+        "vertical.nlayers",
+        "vertical.θᵣ",
+        "lateral.river.q",
+        "lateral.river.reservoir.outflow",
+    ]
+end
+
+zi_size = 0
+vwc_1_size = 0
+@testset "variable information and get and set functions" begin
+    @test request((fn = "get_var_itemsize", name = "lateral.subsurface.ssf")) ==
+          Dict("var_itemsize" => sizeof(Wflow.Float))
+    @test request((fn = "get_var_type", name = "vertical.n"))["status"] == "ERROR"
+    @test request((fn = "get_var_units", name = "vertical.θₛ")) == Dict("var_units" => "-")
+    @test request((fn = "get_var_location", name = "lateral.river.q")) ==
+          Dict("var_location" => "node")
+    zi_nbytes = request((fn = "get_var_nbytes", name = "vertical.zi"))["var_nbytes"]
+    @test zi_nbytes == 400504
+    zi_itemsize = request((fn = "get_var_itemsize", name = "vertical.zi"))["var_itemsize"]
+    zi_size = Int(zi_nbytes / zi_itemsize)
+    vwc_1_nbytes = request((fn = "get_var_nbytes", name = "vertical.vwc[1]"))["var_nbytes"]
+    @test vwc_1_nbytes == 400504
+    vwc_1_itemsize =
+        request((fn = "get_var_itemsize", name = "vertical.vwc[1]"))["var_itemsize"]
+    vwc_1_size = Int(vwc_1_nbytes / vwc_1_itemsize)
+    @test request((fn = "get_var_grid", name = "lateral.river.h")) == Dict("var_grid" => 3)
+    msg = (fn = "get_value", name = "vertical.zi", dest = fill(0.0, zi_size))
+    @test mean(request(msg)["value"]) ≈ 278.1510965581235
+    msg = (fn = "get_value_ptr", name = "vertical.θₛ")
+    @test mean(request(msg)["value_ptr"]) ≈ 0.4409211971535584
+    msg = (
+        fn = "get_value_at_indices",
+        name = "lateral.river.q",
+        dest = [0.0, 0.0, 0.0],
+        inds = [1, 5, 10],
+    )
+    @test request(msg)["value_at_indices"] ≈
+          [2.1901434445889123, 2.6778265820894545, 3.470059871798756]
+    msg = (fn = "set_value", name = "vertical.zi", src = fill(300.0, zi_size))
+    @test request(msg) == Dict("status" => "OK")
+    msg = (fn = "get_value", name = "vertical.zi", dest = fill(0.0, zi_size))
+    @test mean(request(msg)["value"]) == 300.0
+    msg = (
+        fn = "set_value_at_indices",
+        name = "vertical.zi",
+        src = [250.0, 350.0],
+        inds = [1, 2],
+    )
+    @test request(msg) == Dict("status" => "OK")
+    msg = (
+        fn = "get_value_at_indices",
+        name = "vertical.zi",
+        dest = [0.0, 0.0, 0.0],
+        inds = [1, 2, 3],
+    )
+    @test request(msg)["value_at_indices"] == [250.0, 350.0, 300.0]
+    msg = (fn = "get_value", name = "vertical.vwc[1]", dest = fill(0.0, vwc_1_size))
+    @test mean(request(msg)["value"]) ≈ 0.1845159140308566f0
+    msg = (
+        fn = "get_value_at_indices",
+        name = "vertical.vwc[1]",
+        dest = [0.0, 0.0, 0.0],
+        inds = [1, 2, 3],
+    )
+    @test request(msg)["value_at_indices"] ≈
+          [0.12089607119560242f0, 0.11967185322648062f0, 0.14503555864288548f0]
+    msg = (fn = "set_value", name = "vertical.vwc[1]", src = fill(0.3, vwc_1_size))
+    @test request(msg) == Dict("status" => "OK")
+    msg = (fn = "get_value", name = "vertical.vwc[1]", dest = fill(0.0, vwc_1_size))
+    @test mean(request(msg)["value"]) ≈ 0.3f0
+    msg = (
+        fn = "get_value_at_indices",
+        name = "vertical.vwc[1]",
+        dest = [0.0, 0.0, 0.0],
+        inds = [1, 2, 3],
+    )
+    @test request(msg)["value_at_indices"] == [0.3, 0.3, 0.3]
+    msg = (
+        fn = "set_value_at_indices",
+        name = "vertical.vwc[1]",
+        src = [0.1, 0.25],
+        inds = [1, 2],
+    )
+    @test request(msg) == Dict("status" => "OK")
+    msg = (
+        fn = "get_value_at_indices",
+        name = "vertical.vwc[1]",
+        dest = [0.0, 0.0],
+        inds = [1, 2],
+    )
+    @test request(msg)["value_at_indices"] == [0.1, 0.25]
+end
+
+@testset "model grid functions" begin
+    @test request((fn = "get_grid_type", grid = 0)) == Dict("grid_type" => "points")
+    @test request((fn = "get_grid_rank", grid = 0)) == Dict("grid_rank" => 2)
+    grid_size = request((fn = "get_grid_size", grid = 4))["grid_size"]
+    @test grid_size == 50063
+    msg = (fn = "get_grid_x", grid = 4, x = fill(0.0, grid_size))
+    @test request(msg)["grid_x"][1:3] ≈
+          [6.826666666666673, 6.810000000000006, 6.81833333333334]
+    msg = (fn = "get_grid_y", grid = 4, y = fill(0.0, grid_size))
+    @test request(msg)["grid_y"][1:3] ≈ [47.8175, 47.825833333333335, 47.825833333333335]
+    @test request((fn = "get_grid_node_count", grid = 0)) == Dict("grid_node_count" => 2)
+    @test request((fn = "get_grid_edge_count", grid = 3))["grid_edge_count"] == 5808
+    msg = (fn = "get_grid_edge_nodes", grid = 3, edge_nodes = fill(0, 2 * 5808))
+    @test request(msg)["grid_edge_nodes"][1:6] == [1, 5, 2, 1, 3, 2]
+end
+
+@testset "model states and finalize functions" begin
+    @test request((fn = "load_state",)) == Dict("status" => "OK")
+    @test request((fn = "save_state",)) == Dict("status" => "OK")
+    @test request((fn = "finalize",)) == Dict("status" => "OK")
+end
+
+@testset "Error handling and shutdown" begin
+    @test request((fn = "not_existing_function",)) == Dict(
+        "status" => "ERROR",
+        "error" => "Received invalid Wflow function: `not_existing_function`",
+    )
+    @test request((fn = "initialize",)) == Dict(
+        "status" => "ERROR",
+        "error" => "At least one required argument name (`config_file`) not available for function: `initialize`",
+    )
+    @test request((fn = "shutdown",)) == Dict("status" => "OK")
+end
diff --git a/server/test/runtests.jl b/server/test/runtests.jl
new file mode 100644
index 000000000..8d016a2b6
--- /dev/null
+++ b/server/test/runtests.jl
@@ -0,0 +1,35 @@
+import ZMQ
+import JSON3
+import StructTypes
+import Wflow
+import WflowServer
+import Statistics: mean
+import Logging: with_logger, NullLogger
+import Test: @testset, @test
+import Downloads
+
+# ensure test data is present
+testdir = @__DIR__
+datadir = joinpath(testdir, "data")
+inputdir = joinpath(datadir, "input")
+isdir(inputdir) || mkpath(inputdir)
+
+"Download a test data file if it does not already exist"
+function testdata(version, source_filename, target_filename)
+    target_path = joinpath(inputdir, target_filename)
+    base_url = "https://github.com/visr/wflow-artifacts/releases/download"
+    url = string(base_url, '/', string('v', version), '/', source_filename)
+    isfile(target_path) || Downloads.download(url, target_path)
+    return target_path
+end
+
+staticmaps_moselle_path =
+    testdata(v"0.2.9", "staticmaps-moselle.nc", "staticmaps-moselle.nc")
+forcing_moselle_path = testdata(v"0.2.6", "forcing-moselle.nc", "forcing-moselle.nc")
+instates_moselle_path = testdata(v"0.2.6", "instates-moselle.nc", "instates-moselle.nc")
+
+with_logger(NullLogger()) do
+    @testset "Test client server Wflow ZMQ Server" begin
+        include("client.jl")
+    end
+end
diff --git a/server/test/sbm_config.toml b/server/test/sbm_config.toml
new file mode 100644
index 000000000..3ab5c92d0
--- /dev/null
+++ b/server/test/sbm_config.toml
@@ -0,0 +1,232 @@
+# This is a TOML configuration file for Wflow.
+# Relative file paths are interpreted as being relative to this TOML file.
+# Wflow documentation https://deltares.github.io/Wflow.jl/dev/
+# TOML documentation: https://github.com/toml-lang/toml
+
+calendar = "proleptic_gregorian"
+endtime = 2000-02-01T00:00:00
+starttime = 2000-01-01T00:00:00
+time_units = "days since 1900-01-01 00:00:00"
+timestepsecs = 86400
+dir_input = "data/input"
+dir_output = "data/output"
+loglevel = "info"
+
+[state]
+path_input = "instates-moselle.nc"
+path_output = "outstates-moselle.nc"
+
+# if listed, the variable must be present in the NetCDF or error
+# if not listed, the variable can get a default value if it has one
+
+[state.vertical]
+canopystorage = "canopystorage"
+satwaterdepth = "satwaterdepth"
+snow = "snow"
+snowwater = "snowwater"
+tsoil = "tsoil"
+ustorelayerdepth = "ustorelayerdepth"
+
+[state.lateral.river]
+h = "h_river"
+h_av = "h_av_river"
+q = "q_river"
+
+[state.lateral.river.reservoir]
+volume = "volume_reservoir"
+
+[state.lateral.subsurface]
+ssf = "ssf"
+
+[state.lateral.land]
+h = "h_land"
+h_av = "h_av_land"
+q = "q_land"
+
+[input]
+path_forcing = "forcing-moselle.nc"
+path_static = "staticmaps-moselle.nc"
+
+# these are not directly part of the model
+gauges = "wflow_gauges_grdc"
+ldd = "wflow_ldd"
+river_location = "wflow_river"
+subcatchment = "wflow_subcatch"
+
+# specify the internal IDs of the parameters which vary over time
+# the external name mapping needs to be below together with the other mappings
+forcing = [
+  "vertical.precipitation",
+  "vertical.temperature",
+  "vertical.potential_evaporation",
+]
+
+cyclic = ["vertical.leaf_area_index"]
+
+[input.vertical]
+c = "c"
+cf_soil = "cf_soil"
+cfmax = "Cfmax"
+e_r = "EoverR"
+f = "f"
+infiltcappath = "InfiltCapPath"
+infiltcapsoil = "InfiltCapSoil"
+kext = "Kext"
+leaf_area_index = "LAI"
+maxleakage = "MaxLeakage"
+pathfrac = "PathFrac"
+potential_evaporation = "pet"
+precipitation = "precip"
+rootdistpar = "rootdistpar"
+rootingdepth = "RootingDepth"
+soilthickness = "SoilThickness"
+specific_leaf = "Sl"
+storage_wood = "Swood"
+temperature = "temp"
+tt = "TT"
+tti = "TTI"
+ttm = "TTM"
+water_holding_capacity = "WHC"
+waterfrac = "WaterFrac"
+theta_r = "thetaR"
+theta_s = "thetaS"
+
+[input.vertical.kv_0]
+netcdf.variable.name = "KsatVer"
+scale = 1.0
+offset = 0.0
+
+[input.lateral.river]
+length = "wflow_riverlength"
+n = "N_River"
+slope = "RiverSlope"
+width = "wflow_riverwidth"
+bankfull_elevation = "RiverZ"
+bankfull_depth = "RiverDepth"
+
+[input.lateral.river.reservoir]
+area = "ResSimpleArea"
+areas = "wflow_reservoirareas"
+demand = "ResDemand"
+locs = "wflow_reservoirlocs"
+maxrelease = "ResMaxRelease"
+maxvolume = "ResMaxVolume"
+targetfullfrac = "ResTargetFullFrac"
+targetminfrac = "ResTargetMinFrac"
+
+[input.lateral.subsurface]
+ksathorfrac = "KsatHorFrac"
+
+[input.lateral.land]
+n = "N"
+slope = "Slope"
+
+[model]
+kin_wave_iteration = true
+masswasting = true
+reinit = true
+reservoirs = true
+snow = true
+thicknesslayers = [100, 300, 800]
+type = "sbm"
+min_streamorder_river = 6
+min_streamorder_land = 5
+
+[output]
+path = "output_moselle.nc"
+
+[output.vertical]
+canopystorage = "canopystorage"
+satwaterdepth = "satwaterdepth"
+snow = "snow"
+snowwater = "snowwater"
+tsoil = "tsoil"
+ustorelayerdepth = "ustorelayerdepth"
+
+[output.lateral.river]
+h = "h_river"
+q = "q_river"
+
+[output.lateral.river.reservoir]
+volume = "volume_reservoir"
+
+[output.lateral.subsurface]
+ssf = "ssf"
+
+[output.lateral.land]
+h = "h_land"
+q = "q_land"
+
+[netcdf]
+path = "output_scalar_moselle.nc"
+
+[[netcdf.variable]]
+name = "Q"
+map = "gauges"
+parameter = "lateral.river.q"
+
+[[netcdf.variable]]
+coordinate.x = 6.255
+coordinate.y = 50.012
+name = "temp_coord"
+location = "temp_bycoord"
+parameter = "vertical.temperature"
+
+[[netcdf.variable]]
+location = "temp_byindex"
+name = "temp_index"
+index.x = 100
+index.y = 264
+parameter = "vertical.temperature"
+
+[csv]
+path = "output_moselle.csv"
+
+[[csv.column]]
+header = "Q"
+parameter = "lateral.river.q"
+reducer = "maximum"
+
+[[csv.column]]
+header = "volume"
+index = 1
+parameter = "lateral.river.reservoir.volume"
+
+[[csv.column]]
+coordinate.x = 6.255
+coordinate.y = 50.012
+header = "temp_bycoord"
+parameter = "vertical.temperature"
+
+[[csv.column]]
+coordinate.x = 6.255
+coordinate.y = 50.012
+header = "vwc_layer2_bycoord"
+parameter = "vertical.vwc"
+layer = 2
+
+[[csv.column]]
+header = "temp_byindex"
+index.x = 100
+index.y = 264
+parameter = "vertical.temperature"
+
+[[csv.column]]
+header = "Q"
+map = "gauges"
+parameter = "lateral.river.q"
+
+[[csv.column]]
+header = "recharge"
+map = "subcatchment"
+parameter = "vertical.recharge"
+reducer = "mean"
+
+[API]
+components = [
+  "vertical",
+  "lateral.subsurface",
+  "lateral.land",
+  "lateral.river",
+  "lateral.river.reservoir",
+]