diff --git a/core/src/bmi.jl b/core/src/bmi.jl index 628cea538..f895c33b2 100644 --- a/core/src/bmi.jl +++ b/core/src/bmi.jl @@ -70,6 +70,7 @@ function BMI.initialize(T::Type{Model}, config::Config)::Model # use state state = load_structvector(db, config, BasinStateV1) n = length(get_ids(db, "Basin")) + finally # always close the database, also in case of an error close(db) @@ -104,7 +105,7 @@ function BMI.initialize(T::Type{Model}, config::Config)::Model end @debug "Setup ODEProblem." - callback, saved_flow = create_callbacks(parameters, config; config.solver.saveat) + callback, saved = create_callbacks(parameters, config; config.solver.saveat) @debug "Created callbacks." # Initialize the integrator, providing all solver options as described in @@ -138,7 +139,7 @@ function BMI.initialize(T::Type{Model}, config::Config)::Model set_initial_discrete_controlled_parameters!(integrator, storage) - return Model(integrator, config, saved_flow) + return Model(integrator, config, saved) end """ @@ -171,6 +172,11 @@ function BMI.finalize(model::Model)::Model path = results_path(config, RESULTS_FILENAME.allocation) write_arrow(path, table, compress) + # exported levels + table = subgrid_level_table(model) + path = results_path(config, RESULTS_FILENAME.subgrid_levels) + write_arrow(path, table, compress) + @debug "Wrote results." return model end @@ -209,7 +215,7 @@ function create_callbacks( parameters::Parameters, config::Config; saveat, -)::Tuple{CallbackSet, SavedValues{Float64, Vector{Float64}}} +)::Tuple{CallbackSet, SavedResults} (; starttime, basin, tabulated_rating_curve, discrete_control) = parameters callbacks = SciMLBase.DECallback[] @@ -235,6 +241,20 @@ function create_callbacks( save_flow_cb = SavingCallback(save_flow, saved_flow; saveat, save_start = false) push!(callbacks, save_flow_cb) + # interpolate the levels + saved_subgrid_level = SavedValues(Float64, Vector{Float64}) + if config.results.subgrid + export_cb = SavingCallback( + save_subgrid_level, + saved_subgrid_level; + saveat, + save_start = false, + ) + push!(callbacks, export_cb) + end + + saved = SavedResults(saved_flow, saved_subgrid_level) + n_conditions = length(discrete_control.node_id) if n_conditions > 0 discrete_control_cb = VectorContinuousCallback( @@ -247,7 +267,7 @@ function create_callbacks( end callback = CallbackSet(callbacks...) - return callback, saved_flow + return callback, saved end """ @@ -486,6 +506,20 @@ function save_flow(u, t, integrator) copy(nonzeros(get_tmp(integrator.p.connectivity.flow, u))) end +function update_subgrid_level!(integrator)::Nothing + basin_level = get_tmp(integrator.p.basin.current_level, 0) + subgrid = integrator.p.subgrid + for (i, (index, interp)) in enumerate(zip(subgrid.basin_index, subgrid.interpolations)) + subgrid.level[i] = interp(basin_level[index]) + end +end + +"Interpolate the levels and save them to SavedValues" +function save_subgrid_level(u, t, integrator) + update_subgrid_level!(integrator) + return integrator.p.subgrid.level +end + "Load updates from 'Basin / time' into the parameters" function update_basin(integrator)::Nothing (; basin) = integrator.p @@ -567,6 +601,8 @@ function BMI.get_value_ptr(model::Model, name::AbstractString) model.integrator.p.basin.infiltration elseif name == "drainage" model.integrator.p.basin.drainage + elseif name == "subgrid_level" + model.integrator.p.subgrid.level else error("Unknown variable $name") end diff --git a/core/src/config.jl b/core/src/config.jl index 41e4b7d0d..3b2483861 100644 --- a/core/src/config.jl +++ b/core/src/config.jl @@ -112,6 +112,7 @@ end outstate::Union{String, Nothing} = nothing compression::Compression = "zstd" compression_level::Int = 6 + subgrid::Bool = false end @option struct Logging <: TableOption diff --git a/core/src/consts.jl b/core/src/consts.jl index e138e85fc..50126b04f 100644 --- a/core/src/consts.jl +++ b/core/src/consts.jl @@ -3,4 +3,5 @@ const RESULTS_FILENAME = ( flow = "flow.arrow", control = "control.arrow", allocation = "allocation.arrow", + subgrid_levels = "subgrid_levels.arrow", ) diff --git a/core/src/create.jl b/core/src/create.jl index 54b989628..81ec95fe7 100644 --- a/core/src/create.jl +++ b/core/src/create.jl @@ -785,6 +785,38 @@ function User(db::DB, config::Config)::User ) end +function Subgrid(db::DB, config::Config, basin::Basin)::Subgrid + node_to_basin = Dict(node_id => index for (index, node_id) in enumerate(basin.node_id)) + tables = load_structvector(db, config, BasinSubgridV1) + + basin_ids = Int[] + interpolations = ScalarInterpolation[] + has_error = false + for group in IterTools.groupby(row -> row.subgrid_id, tables) + subgrid_id = first(getproperty.(group, :subgrid_id)) + node_id = NodeID(first(getproperty.(group, :node_id))) + basin_level = getproperty.(group, :basin_level) + subgrid_level = getproperty.(group, :subgrid_level) + + is_valid = + valid_subgrid(subgrid_id, node_id, node_to_basin, basin_level, subgrid_level) + + if !is_valid + has_error = true + # Ensure it doesn't extrapolate before the first value. + pushfirst!(subgrid_level, first(subgrid_level)) + pushfirst!(basin_level, nextfloat(-Inf)) + new_interp = LinearInterpolation(subgrid_level, basin_level; extrapolate = true) + push!(basin_ids, node_to_basin[node_id]) + push!(interpolations, new_interp) + end + end + + has_error && error("Invalid Basin / subgrid table.") + + return Subgrid(basin_ids, interpolations, fill(NaN, length(basin_ids))) +end + function Parameters(db::DB, config::Config)::Parameters n_states = length(get_ids(db, "Basin")) + length(get_ids(db, "PidControl")) chunk_size = pickchunksize(n_states) @@ -805,6 +837,7 @@ function Parameters(db::DB, config::Config)::Parameters user = User(db, config) basin = Basin(db, config, chunk_size) + subgrid_level = Subgrid(db, config, basin) # Set is_pid_controlled to true for those pumps and outlets that are PID controlled for id in pid_control.node_id @@ -835,6 +868,8 @@ function Parameters(db::DB, config::Config)::Parameters discrete_control, pid_control, user, + Dict{Int, Symbol}(), + subgrid_level, ) # Allocation data structures diff --git a/core/src/io.jl b/core/src/io.jl index 601334740..657eaae71 100644 --- a/core/src/io.jl +++ b/core/src/io.jl @@ -179,8 +179,8 @@ function flow_table( to_node_id::Vector{Int}, flow::FlatVector{Float64}, } - (; config, saved_flow, integrator) = model - (; t, saveval) = saved_flow + (; config, saved, integrator) = model + (; t, saveval) = saved.flow (; connectivity) = integrator.p (; graph) = connectivity @@ -252,6 +252,27 @@ function allocation_table( ) end +function subgrid_level_table( + model::Model, +)::@NamedTuple{ + time::Vector{DateTime}, + subgrid_id::Vector{Int}, + subgrid_level::Vector{Float64}, +} + (; config, saved, integrator) = model + (; t, saveval) = saved.subgrid_level + subgrid = integrator.p.subgrid + + nelem = length(subgrid.basin_index) + ntsteps = length(t) + unique_elem_id = collect(1:nelem) + + time = repeat(datetime_since.(t, config.starttime); inner = nelem) + subgrid_id = repeat(unique_elem_id; outer = ntsteps) + subgrid_level = FlatVector(saveval) + return (; time, subgrid_id, subgrid_level) +end + "Write a result table to disk as an Arrow file" function write_arrow( path::AbstractString, diff --git a/core/src/lib.jl b/core/src/lib.jl index f07979eb1..6d29a5276 100644 --- a/core/src/lib.jl +++ b/core/src/lib.jl @@ -1,3 +1,8 @@ +struct SavedResults + flow::SavedValues{Float64, Vector{Float64}} + subgrid_level::SavedValues{Float64, Vector{Float64}} +end + """ Model(config_path::AbstractString) Model(config::Config) @@ -11,13 +16,13 @@ A Model can be created from the path to a TOML configuration file, or a Config o struct Model{T} integrator::T config::Config - saved_flow::SavedValues{Float64, Vector{Float64}} + saved::SavedResults function Model( integrator::T, config, - saved_flow, + saved, ) where {T <: SciMLBase.AbstractODEIntegrator} - new{T}(integrator, config, saved_flow) + new{T}(integrator, config, saved) end end diff --git a/core/src/solve.jl b/core/src/solve.jl index 9067a9b6b..06c05a957 100644 --- a/core/src/solve.jl +++ b/core/src/solve.jl @@ -141,25 +141,21 @@ struct Basin{T, C} <: AbstractParameterNode storage, time::StructVector{BasinTimeV1, C, Int}, ) where {T, C} - errors = valid_profiles(node_id, level, area) - if isempty(errors) - return new{T, C}( - node_id, - precipitation, - potential_evaporation, - drainage, - infiltration, - current_level, - current_area, - area, - level, - storage, - time, - ) - else - foreach(x -> @error(x), errors) - error("Errors occurred when parsing Basin data.") - end + is_valid = valid_profiles(node_id, level, area) + is_valid || error("Invalid Basin / profile table.") + return new{T, C}( + node_id, + precipitation, + potential_evaporation, + drainage, + infiltration, + current_level, + current_area, + area, + level, + storage, + time, + ) end end @@ -462,6 +458,13 @@ struct User <: AbstractParameterNode } end +"Subgrid linearly interpolates basin levels." +struct Subgrid + basin_index::Vector{Int} + interpolations::Vector{ScalarInterpolation} + level::Vector{Float64} +end + # TODO Automatically add all nodetypes here struct Parameters{T, TSparse, C1, C2} starttime::DateTime @@ -479,6 +482,8 @@ struct Parameters{T, TSparse, C1, C2} discrete_control::DiscreteControl pid_control::PidControl{T} user::User + lookup::Dict{Int, Symbol} + subgrid::Subgrid end """ diff --git a/core/src/validation.jl b/core/src/validation.jl index 41171c156..a6abe919e 100644 --- a/core/src/validation.jl +++ b/core/src/validation.jl @@ -8,6 +8,7 @@ @schema "ribasim.basin.time" BasinTime @schema "ribasim.basin.profile" BasinProfile @schema "ribasim.basin.state" BasinState +@schema "ribasim.basin.subgrid" BasinSubgrid @schema "ribasim.terminal.static" TerminalStatic @schema "ribasim.fractionalflow.static" FractionalFlowStatic @schema "ribasim.flowboundary.static" FlowBoundaryStatic @@ -30,7 +31,7 @@ tablename(sv::Type{SchemaVersion{T, N}}) where {T, N} = tablename(sv()) tablename(sv::SchemaVersion{T, N}) where {T, N} = join(filter(!isnothing, nodetype(sv)), delimiter) isnode(sv::Type{SchemaVersion{T, N}}) where {T, N} = isnode(sv()) -isnode(::SchemaVersion{T, N}) where {T, N} = length(split(string(T), ".")) == 3 +isnode(::SchemaVersion{T, N}) where {T, N} = length(split(string(T), '.'; limit = 3)) == 3 nodetype(sv::Type{SchemaVersion{T, N}}) where {T, N} = nodetype(sv()) """ @@ -43,9 +44,9 @@ function nodetype( # so we parse the related record Ribasim.BasinTimeV1 # to derive BasinTime from it. record = Legolas.record_type(sv) - node = last(split(string(Symbol(record)), ".")) + node = last(split(string(Symbol(record)), '.'; limit = 3)) - elements = split(string(T), ".") + elements = split(string(T), '.'; limit = 3) if isnode(sv) n = elements[2] k = Symbol(elements[3]) @@ -202,6 +203,13 @@ end level::Float64 end +@version BasinSubgridV1 begin + subgrid_id::Int + node_id::Int + basin_level::Float64 + subgrid_level::Float64 +end + @version FractionalFlowStaticV1 begin node_id::Int fraction::Float64 @@ -362,6 +370,7 @@ sort_by_id_level(row) = (row.node_id, row.level) sort_by_id_state_level(row) = (row.node_id, row.control_state, row.level) sort_by_priority(row) = (row.node_id, row.priority) sort_by_priority_time(row) = (row.node_id, row.priority, row.time) +sort_by_subgrid_level(row) = (row.subgrid_id, row.basin_level) # get the right sort by function given the Schema, with sort_by_id as the default sort_by_function(table::StructVector{<:Legolas.AbstractRecord}) = sort_by_id @@ -371,6 +380,7 @@ sort_by_function(table::StructVector{TabulatedRatingCurveStaticV1}) = sort_by_id sort_by_function(table::StructVector{BasinProfileV1}) = sort_by_id_level sort_by_function(table::StructVector{UserStaticV1}) = sort_by_priority sort_by_function(table::StructVector{UserTimeV1}) = sort_by_priority_time +sort_by_function(table::StructVector{BasinSubgridV1}) = sort_by_subgrid_level const TimeSchemas = Union{ BasinTimeV1, @@ -450,29 +460,30 @@ function valid_profiles( node_id::Indices{NodeID}, level::Vector{Vector{Float64}}, area::Vector{Vector{Float64}}, -)::Vector{String} - errors = String[] +)::Bool + errors = false for (id, levels, areas) in zip(node_id, level, area) if !allunique(levels) - push!(errors, "Basin $id has repeated levels, this cannot be interpolated.") + errors = true + @error "Basin $id has repeated levels, this cannot be interpolated." end if areas[1] <= 0 - push!( - errors, - "Basin profiles cannot start with area <= 0 at the bottom for numerical reasons (got area $(areas[1]) for node $id).", + errors = true + @error( + "Basin profiles cannot start with area <= 0 at the bottom for numerical reasons.", + node_id = id, + area = areas[1], ) end if areas[end] < areas[end - 1] - push!( - errors, - "Basin profiles cannot have decreasing area at the top since extrapolating could lead to negative areas, found decreasing top areas for node $id.", - ) + errors = true + @error "Basin profiles cannot have decreasing area at the top since extrapolating could lead to negative areas, found decreasing top areas for node $id." end end - return errors + return !errors end """ @@ -619,3 +630,33 @@ function valid_fractional_flow( end return !errors end + +""" +Validate the entries for a single subgrid element. +""" +function valid_subgrid( + subgrid_id::Int, + node_id::NodeID, + node_to_basin::Dict{NodeID, Int}, + basin_level::Vector{Float64}, + subgrid_level::Vector{Float64}, +)::Bool + errors = false + + if !(node_id in keys(node_to_basin)) + errors = true + @error "The node_id of the Basin / subgrid_level does not refer to a basin." node_id subgrid_id + end + + if !allunique(basin_level) + errors = true + @error "Basin / subgrid_level subgrid_id $(subgrid_id) has repeated basin levels, this cannot be interpolated." + end + + if !allunique(subgrid_level) + errors = true + @error "Basin / subgrid_level subgrid_id $(subgrid_id) has repeated element levels, this cannot be interpolated." + end + + return !errors +end diff --git a/core/test/run_models_test.jl b/core/test/run_models_test.jl index ea4a5153d..b4b1ce77f 100644 --- a/core/test/run_models_test.jl +++ b/core/test/run_models_test.jl @@ -6,6 +6,10 @@ model = Ribasim.run(toml_path) @test model isa Ribasim.Model @test successful_retcode(model) + + # The exporter interpolates 1:1 for three subgrid elements, but shifted by 1.0 meter. + subgrid = model.integrator.p.subgrid + @test all(diff(subgrid.level) .≈ 1.0) end @testitem "bucket model" begin @@ -191,7 +195,7 @@ end (; level) = level_boundary level = level[1] - timesteps = model.saved_flow.t + timesteps = model.saved.flow.t flow = DataFrame(Ribasim.flow_table(model)) outlet_flow = filter([:from_node_id, :to_node_id] => (from, to) -> from === 2 && to === 3, flow) @@ -301,6 +305,6 @@ end # https://www.hec.usace.army.mil/confluence/rasdocs/ras1dtechref/latest/theoretical-basis-for-one-dimensional-and-two-dimensional-hydrodynamic-calculations/1d-steady-flow-water-surface-profiles/friction-loss-evaluation @test all(isapprox.(h_expected, h_actual; atol = 0.02)) # Test for conservation of mass, flow at the beginning == flow at the end - @test model.saved_flow.saveval[end][2] ≈ 5.0 atol = 0.001 skip = Sys.isapple() - @test model.saved_flow.saveval[end][end - 1] ≈ 5.0 atol = 0.001 skip = Sys.isapple() + @test model.saved.flow.saveval[end][2] ≈ 5.0 atol = 0.001 skip = Sys.isapple() + @test model.saved.flow.saveval[end][end - 1] ≈ 5.0 atol = 0.001 skip = Sys.isapple() end diff --git a/core/test/validation_test.jl b/core/test/validation_test.jl index 3b16a3580..c2ae9e983 100644 --- a/core/test/validation_test.jl +++ b/core/test/validation_test.jl @@ -1,22 +1,35 @@ @testitem "Basin profile validation" begin using Dictionaries: Indices + using Ribasim: NodeID, valid_profiles, qh_interpolation using DataInterpolations: LinearInterpolation + using Logging - node_id = Indices([Ribasim.NodeID(1)]) + node_id = Indices([NodeID(1)]) level = [[0.0, 0.0, 1.0]] area = [[0.0, 100.0, 90]] - errors = Ribasim.valid_profiles(node_id, level, area) - @test "Basin #1 has repeated levels, this cannot be interpolated." in errors - @test "Basin profiles cannot start with area <= 0 at the bottom for numerical reasons (got area 0.0 for node #1)." in - errors - @test "Basin profiles cannot have decreasing area at the top since extrapolating could lead to negative areas, found decreasing top areas for node #1." in - errors - @test length(errors) == 3 - - itp, valid = Ribasim.qh_interpolation([0.0, 0.0], [1.0, 2.0]) + + logger = TestLogger() + with_logger(logger) do + @test !valid_profiles(node_id, level, area) + end + + @test length(logger.logs) == 3 + @test logger.logs[1].level == Error + @test logger.logs[1].message == + "Basin #1 has repeated levels, this cannot be interpolated." + @test logger.logs[2].level == Error + @test logger.logs[2].message == + "Basin profiles cannot start with area <= 0 at the bottom for numerical reasons." + @test logger.logs[2].kwargs[:node_id] == NodeID(1) + @test logger.logs[2].kwargs[:area] == 0 + @test logger.logs[3].level == Error + @test logger.logs[3].message == + "Basin profiles cannot have decreasing area at the top since extrapolating could lead to negative areas, found decreasing top areas for node #1." + + itp, valid = qh_interpolation([0.0, 0.0], [1.0, 2.0]) @test !valid @test itp isa LinearInterpolation - itp, valid = Ribasim.qh_interpolation([0.0, 0.1], [1.0, 2.0]) + itp, valid = qh_interpolation([0.0, 0.1], [1.0, 2.0]) @test valid @test itp isa LinearInterpolation end @@ -344,3 +357,41 @@ end @test logger.logs[2].message == "Invalid edge type 'bar' for edge #2 from node #2 to node #3." end + +@testitem "Subgrid validation" begin + using Ribasim: valid_subgrid, NodeID + using Logging + + node_to_basin = Dict(NodeID(9) => 1) + + logger = TestLogger() + with_logger(logger) do + @test !valid_subgrid(1, NodeID(10), node_to_basin, [-1.0, 0.0], [-1.0, 0.0]) + end + + @test length(logger.logs) == 1 + @test logger.logs[1].level == Error + @test logger.logs[1].message == + "The node_id of the Basin / subgrid_level does not refer to a basin." + @test logger.logs[1].kwargs[:node_id] == NodeID(10) + @test logger.logs[1].kwargs[:subgrid_id] == 1 + + logger = TestLogger() + with_logger(logger) do + @test !valid_subgrid( + 1, + NodeID(9), + node_to_basin, + [-1.0, 0.0, 0.0], + [-1.0, 0.0, 0.0], + ) + end + + @test length(logger.logs) == 2 + @test logger.logs[1].level == Error + @test logger.logs[1].message == + "Basin / subgrid_level subgrid_id 1 has repeated basin levels, this cannot be interpolated." + @test logger.logs[2].level == Error + @test logger.logs[2].message == + "Basin / subgrid_level subgrid_id 1 has repeated element levels, this cannot be interpolated." +end diff --git a/docs/core/usage.qmd b/docs/core/usage.qmd index 5e688fc30..d87b28db5 100644 --- a/docs/core/usage.qmd +++ b/docs/core/usage.qmd @@ -141,6 +141,7 @@ name it must have in the database if it is stored there. - `Basin / profile`: geometries of the basins - `Basin / time`: time series of the forcing values - `Basin / state`: used as initial condition of the basins + - `Basin / subgrid`: used to interpolate basin levels to a more detailed spatial representation - FractionalFlow: connect two of these from a Basin to get a fixed ratio bifurcation - `FractionalFlow / static`: fractions - LevelBoundary: stores water at a given level unaffected by flow, like an infinitely large basin @@ -275,6 +276,38 @@ Internally this get converted to two functions, $A(S)$ and $h(S)$, by integratin The minimum area cannot be zero to avoid numerical issues. The maximum area is used to convert the precipitation flux into an inflow. +## Basin / subgrid + +The subgrid_level table defines a piecewise linear interpolation from a basin water level to a subgrid element water level. +Many subgrid elements may be associated with a single basin, each with distinct interpolation functions. +This functionality can be used to translate a single lumped basin level to a more spatially detailed representation (e.g comparable to the output of a hydrodynamic simulation). + +column | type | unit | restriction +------------- | ------- | ----- | ------------------------ +subgrid_id | Int | - | sorted +node_id | Int | - | constant per subgrid_id +basin_level | Float64 | $m$ | sorted per subgrid_id +subgrid_level | Float64 | $m$ | sorted per subgrid_id + +The table below shows example input for two subgrid elements: + +subgrid_id | node_id | basin_level | subgrid_level +---------- | ------- | ----------- | ------------- + 1 | 9 | 0.0 | 0.0 + 1 | 9 | 1.0 | 1.0 + 1 | 9 | 2.0 | 2.0 + 2 | 9 | 0.0 | 0.5 + 2 | 9 | 1.0 | 1.5 + 2 | 9 | 2.0 | 2.5 + +Both subgrid elements use the water level of the basin with `node_id` 9 to interpolate to their respective water levels. +The first element has a one to one connection with the water level; the second also has a one to one connection, but is offset by half a meter. +A basin water level of 0.3 would be translated to a water level of 0.3 for the first subgrid element, and 0.8 for the second. +Water levels beyond the last `basin_level` are linearly extrapolated. + +Note that the interpolation to subgrid water level is not constrained by any water balance within Ribasim. +Generally, to create physically meaningful subgrid water levels, the subgrid table must be parametrized properly such that the spatially integrated water volume of the subgrid elements agrees with the total storage volume of the basin. + ## Basin results The basin table contains results of the storage and level of each basin at every solver diff --git a/docs/schema/BasinSubgrid.schema.json b/docs/schema/BasinSubgrid.schema.json new file mode 100644 index 000000000..244ab8189 --- /dev/null +++ b/docs/schema/BasinSubgrid.schema.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://deltares.github.io/Ribasim/schema/BasinSubgrid.schema.json", + "title": "BasinSubgrid", + "description": "A BasinSubgrid object based on Ribasim.BasinSubgridV1", + "type": "object", + "properties": { + "subgrid_id": { + "format": "default", + "type": "integer" + }, + "node_id": { + "format": "default", + "type": "integer" + }, + "basin_level": { + "format": "double", + "type": "number" + }, + "subgrid_level": { + "format": "double", + "type": "number" + }, + "remarks": { + "description": "a hack for pandera", + "type": "string", + "format": "default", + "default": "" + } + }, + "required": [ + "subgrid_id", + "node_id", + "basin_level", + "subgrid_level" + ] +} diff --git a/docs/schema/Results.schema.json b/docs/schema/Results.schema.json index 99f4ea9f9..eb23d21b6 100644 --- a/docs/schema/Results.schema.json +++ b/docs/schema/Results.schema.json @@ -5,26 +5,6 @@ "description": "A Results object based on Ribasim.config.Results", "type": "object", "properties": { - "basin": { - "format": "default", - "type": "string", - "default": "results/basin.arrow" - }, - "flow": { - "format": "default", - "type": "string", - "default": "results/flow.arrow" - }, - "control": { - "format": "default", - "type": "string", - "default": "results/control.arrow" - }, - "allocation": { - "format": "default", - "type": "string", - "default": "results/allocation.arrow" - }, "outstate": { "format": "default", "anyOf": [ @@ -46,14 +26,16 @@ "format": "default", "type": "integer", "default": 6 + }, + "subgrid": { + "format": "default", + "type": "boolean", + "default": false } }, "required": [ - "basin", - "flow", - "control", - "allocation", "compression", - "compression_level" + "compression_level", + "subgrid" ] } diff --git a/docs/schema/Config.schema.json b/docs/schema/Toml.schema.json similarity index 90% rename from docs/schema/Config.schema.json rename to docs/schema/Toml.schema.json index 09475a562..aeb9a0cdf 100644 --- a/docs/schema/Config.schema.json +++ b/docs/schema/Toml.schema.json @@ -1,8 +1,8 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://deltares.github.io/Ribasim/schema/Config.schema.json", - "title": "Config", - "description": "A Config object based on Ribasim.config.Config", + "$id": "https://deltares.github.io/Ribasim/schema/Toml.schema.json", + "title": "Toml", + "description": "A Toml object based on Ribasim.config.Toml", "type": "object", "properties": { "starttime": { @@ -15,17 +15,16 @@ }, "input_dir": { "format": "default", - "type": "string", - "default": "." + "type": "string" }, "results_dir": { "format": "default", - "type": "string", - "default": "results" + "type": "string" }, "database": { "format": "default", - "type": "string" + "type": "string", + "default": "database.gpkg" }, "allocation": { "$ref": "https://deltares.github.io/Ribasim/schema/Allocation.schema.json", @@ -39,7 +38,8 @@ "$ref": "https://deltares.github.io/Ribasim/schema/Solver.schema.json", "default": { "algorithm": "QNDF", - "saveat": [], + "saveat": [ + ], "adaptive": true, "dt": null, "dtmin": null, @@ -64,13 +64,10 @@ "results": { "$ref": "https://deltares.github.io/Ribasim/schema/Results.schema.json", "default": { - "basin": "results/basin.arrow", - "flow": "results/flow.arrow", - "control": "results/control.arrow", - "allocation": "results/allocation.arrow", "outstate": null, "compression": "zstd", - "compression_level": 6 + "compression_level": 6, + "subgrid": false } }, "terminal": { @@ -126,6 +123,7 @@ "profile": null, "state": null, "static": null, + "subgrid": null, "time": null } }, diff --git a/docs/schema/basin.schema.json b/docs/schema/basin.schema.json index 743b5ca8b..ce36b64ec 100644 --- a/docs/schema/basin.schema.json +++ b/docs/schema/basin.schema.json @@ -41,6 +41,18 @@ ], "default": null }, + "subgrid": { + "format": "default", + "anyOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ], + "default": null + }, "time": { "format": "default", "anyOf": [ diff --git a/docs/schema/root.schema.json b/docs/schema/root.schema.json index 751f152f0..e9089cc68 100644 --- a/docs/schema/root.schema.json +++ b/docs/schema/root.schema.json @@ -10,6 +10,9 @@ "basinstatic": { "$ref": "basinstatic.schema.json" }, + "basinsubgrid": { + "$ref": "basinsubgrid.schema.json" + }, "basintime": { "$ref": "basintime.schema.json" }, diff --git a/python/ribasim/ribasim/config.py b/python/ribasim/ribasim/config.py index 5f45daa99..48d74e381 100644 --- a/python/ribasim/ribasim/config.py +++ b/python/ribasim/ribasim/config.py @@ -9,6 +9,7 @@ BasinProfileSchema, BasinStateSchema, BasinStaticSchema, + BasinSubgridSchema, BasinTimeSchema, DiscreteControlConditionSchema, DiscreteControlLogicSchema, @@ -46,6 +47,7 @@ class Results(BaseModel): outstate: str | None = None compression: Compression = Compression.zstd compression_level: int = 6 + subgrid: bool = False class Solver(BaseModel): @@ -158,10 +160,14 @@ class Basin(NodeModel): time: TableModel[BasinTimeSchema] = Field( default_factory=TableModel[BasinTimeSchema] ) + subgrid: TableModel[BasinSubgridSchema] = Field( + default_factory=TableModel[BasinSubgridSchema] + ) _sort_keys: dict[str, list[str]] = { "profile": ["node_id", "level"], "time": ["time", "node_id"], + "subgrid": ["subgrid_id", "basin_level"], } diff --git a/python/ribasim/ribasim/models.py b/python/ribasim/ribasim/models.py index 1d82a6fa8..38de5e958 100644 --- a/python/ribasim/ribasim/models.py +++ b/python/ribasim/ribasim/models.py @@ -33,6 +33,14 @@ class BasinStatic(BaseModel): remarks: str = Field("", description="a hack for pandera") +class BasinSubgrid(BaseModel): + subgrid_id: int + node_id: int + basin_level: float + subgrid_level: float + remarks: str = Field("", description="a hack for pandera") + + class BasinTime(BaseModel): node_id: int time: datetime @@ -223,6 +231,7 @@ class Root(BaseModel): basinprofile: BasinProfile | None = None basinstate: BasinState | None = None basinstatic: BasinStatic | None = None + basinsubgrid: BasinSubgrid | None = None basintime: BasinTime | None = None discretecontrolcondition: DiscreteControlCondition | None = None discretecontrollogic: DiscreteControlLogic | None = None diff --git a/python/ribasim_testmodels/ribasim_testmodels/trivial.py b/python/ribasim_testmodels/ribasim_testmodels/trivial.py index 9524aa219..22131df7f 100644 --- a/python/ribasim_testmodels/ribasim_testmodels/trivial.py +++ b/python/ribasim_testmodels/ribasim_testmodels/trivial.py @@ -72,7 +72,22 @@ def trivial_model() -> ribasim.Model: "urban_runoff": [0.0], } ) - basin = ribasim.Basin(profile=profile, static=static) + + # Create a subgrid level interpolation from one basin to three elements. Scale one to one, but: + # + # 1. start at -1.0 + # 2. start at 0.0 + # 3. start at 1.0 + # + subgrid = pd.DataFrame( + data={ + "subgrid_id": [1, 1, 2, 2, 3, 3], + "node_id": [1, 1, 1, 1, 1, 1], + "basin_level": [0.0, 1.0, 0.0, 1.0, 0.0, 1.0], + "subgrid_level": [-1.0, 0.0, 0.0, 1.0, 1.0, 2.0], + } + ) + basin = ribasim.Basin(profile=profile, static=static, subgrid=subgrid) # Set up a rating curve node: # Discharge: lose 1% of storage volume per day at storage = 1000.0. @@ -106,5 +121,6 @@ def trivial_model() -> ribasim.Model: tabulated_rating_curve=rating_curve, starttime="2020-01-01 00:00:00", endtime="2021-01-01 00:00:00", + results=ribasim.Results(subgrid=True), ) return model diff --git a/ribasim_qgis/core/nodes.py b/ribasim_qgis/core/nodes.py index 69a04ebaf..403de37c7 100644 --- a/ribasim_qgis/core/nodes.py +++ b/ribasim_qgis/core/nodes.py @@ -20,6 +20,8 @@ """ +from __future__ import annotations + import abc from typing import Any, cast @@ -81,7 +83,7 @@ def create( path: str, crs: QgsCoordinateReferenceSystem, names: list[str], - ) -> "Input": + ) -> Input: if cls.input_type() in names: raise ValueError(f"Name already exists in geopackage: {cls.input_type()}") instance = cls(path) @@ -390,6 +392,25 @@ def attributes(cls) -> list[QgsField]: ] +class BasinSubgridLevel(Input): + @classmethod + def input_type(cls) -> str: + return "Basin / subgrid" + + @classmethod + def geometry_type(cls) -> str: + return "No Geometry" + + @classmethod + def attributes(cls) -> list[QgsField]: + return [ + QgsField("subgrid_id", QVariant.Int), + QgsField("node_id", QVariant.Int), + QgsField("basin_level", QVariant.Double), + QgsField("subgrid_level", QVariant.Double), + ] + + class BasinState(Input): @classmethod def input_type(cls) -> str: diff --git a/ribasim_qgis/widgets/dataset_widget.py b/ribasim_qgis/widgets/dataset_widget.py index 9bcd4f1c9..2c0a95ad0 100644 --- a/ribasim_qgis/widgets/dataset_widget.py +++ b/ribasim_qgis/widgets/dataset_widget.py @@ -4,6 +4,8 @@ This widget also allows enabling or disabling individual elements for a computation. """ +from __future__ import annotations + from collections.abc import Iterable from datetime import datetime from pathlib import Path diff --git a/ribasim_qgis/widgets/ribasim_widget.py b/ribasim_qgis/widgets/ribasim_widget.py index 5f2a5375f..bf61a27c2 100644 --- a/ribasim_qgis/widgets/ribasim_widget.py +++ b/ribasim_qgis/widgets/ribasim_widget.py @@ -5,6 +5,8 @@ connection to the QGIS Layers Panel, and ensures there is a group for the Ribasim layers there. """ +from __future__ import annotations + from pathlib import Path from typing import Any, cast diff --git a/utils/runstats.jl b/utils/runstats.jl index 9138b585b..ee14afcc8 100644 --- a/utils/runstats.jl +++ b/utils/runstats.jl @@ -18,7 +18,7 @@ using LibGit2 "Add key config settings like solver settings to a dictionary" function add_config!(dict, config::Ribasim.Config) - confdict = to_dict(config) + confdict = to_dict(getfield(config, :toml)) for (k, v) in confdict["solver"] if k == "saveat" # convert possible vector to scalar