Skip to content

Add subtimestepping for the internal tracer calculation#3014

Draft
evetion wants to merge 2 commits into
mainfrom
feat/tracer-subtimestepping
Draft

Add subtimestepping for the internal tracer calculation#3014
evetion wants to merge 2 commits into
mainfrom
feat/tracer-subtimestepping

Conversation

@evetion

@evetion evetion commented Apr 1, 2026

Copy link
Copy Markdown
Member

Fixes #2438

  • Adds subtimestepping to the tracer calculation
  • Adds concentration to config (moving a solver option)
  • Adds max_subtimesteps to solver stats output

Draft for now, will remove the debug statements and do some validation, ideally using the HWS model to see a different response on the Rhine. I'm also considering to rename concentration to tracer in at least the config.

The implementation follows Delwaq scheme 24 with subtimesteps (also used for the new prototype). Based on the mean age of the water from the previous timestep in a basin (called ResidenceTime currently, half correct), the dt and a safety factor, we determine a number of required substeps. That is, the mean age should be less than dt * substep_ratio (default of 0.2) , otherwise we create subtimesteps. The number of subtimesteps can vary per basin, and come in powers of two, up to 2^substep_depth (default of 10). So the number of subtimesteps can vary between 1 and 1024.

We take the maximum number of timesteps, and use that as a number of iterations, only triggering the basins that require that iteration (basin with subtimestep of 1 will trigger once at the end, subts of 2 halfway and at the end, and a subts of 1024 will trigger each iteration). By using powers of two all the basins synchronize nicely without odd fractions, bookkeeping, while also enabling a nice depth (range from a day to a minute or two).

@evetion evetion requested a review from visr April 1, 2026 15:58
@evetion

evetion commented Apr 1, 2026

Copy link
Copy Markdown
Member Author
wave_comparison_subts

The subtimestepping approach clearly works. An experiment based on backwater with different basins show there's no lag in terms of flow/level, but there is a significant one for tracer if you go to more basins. Subtimestepping nicely fixes this.

Comment thread core/src/callback.jl
cumulative_in .= vertical_flux.drainage * dt
cumulative_in .+= vertical_flux.surface_runoff * dt
# Determine number of substeps needed based on a fraction of the residence time
safe = x -> isinf(x) ? 0 : x

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like this type unstable anonymous function should be made stable, and defined outside the callback.

Comment thread core/src/callback.jl
cumulative_in .+= vertical_flux.surface_runoff * dt
# Determine number of substeps needed based on a fraction of the residence time
safe = x -> isinf(x) ? 0 : x
@. nsubsteps = 2^clamp(floor(Int, safe(log2(dt / (concentration_state[:, Substance.ResidenceTime] * substep_ratio)))), 0, substep_depth)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Line is a bit too long.

Comment thread core/src/callback.jl
# Debug: check continuity tracer after dilution step
c_cont = concentration_state[node_id.idx, Substance.Continuity]
if !iszero(storage_only_in) && abs(c_cont - 1.0) > 0.01
@warn "Continuity drift after dilution" node_id substep c_cont storage_only_in cumulative_in = cumulative_in[node_id.idx] s_start mass_cont = mass[node_id.idx][Substance.Continuity]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Line too long.

Comment thread core/src/callback.jl
Comment on lines +323 to +328
# Take care of infinitely small masses, possibly becoming negative due to truncation.
for I in eachindex(mass_node)
if (-eps(Float64)) < mass_node[I] < (eps(Float64))
mass_node[I] = 0.0
end
end

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was already there, but I wonder if this will bite us when we start simulating substances that only appear in trace amounts. eps(Float64) == eps(1.0) == 2.220446049250313e-16 is not that small for a Float64, and closer to 0 the eps is much smaller, in the limit eps(0.0) == 5.0e-324.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're talking about mass here, and waterquality tends to be about microgram, or even milligram / L. So the limit for a basin of 1 m3 will already be in the -19, and for 1000 m3 it becomes -22, close to the order of a single molecule. Anyway, these quantities are dwarfed by the uncertainty of the calculation.

@verheem

verheem commented Apr 8, 2026

Copy link
Copy Markdown
Contributor

Have you measured performance impact? A possibility for 1024 sub time steps sounds impactfull.

It may be worth while to try and use the analytical solution to:
V * dc/dt = Q (c_in - c)
assuming the volume of a basin is constant over a time step (i.e. Q_in * dt - Q_out * dt << V):
c(t) = c_in * (1 - exp(-t / tau))
which might be a good enough approximation

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Improve substance propagation using subtimesteps

3 participants