ContinuousCallback for early termination of ODE fails to trigger

Hi all,

I’m new to Julia, and I’m trying to simulate a simple ODE problem with some callbacks. My model consists of one single equation with two terms, one positive for growth, and one negative for decrease. However, the term for decrease depends on an external parameter I, which acts as a cyclic ON – OFF switch, the duration of an on-off cycle is defined by τ, and the fraction of the cycle where I is on is determined by duty. To represent the switch between on and off I’ve calculated all the timepoints where there would be a change in I, store those in 3 vectors (discontinuities, ON and OFF) and used the vectors to set up two discrete callbacks, where I save the states right before and after the callbacks.

My issue arises with the last callback I want to implement. As I follows a cyclic ON – OFF pattern, my state u ends up having a sawtooth pattern, with multiple local maxima and minima, but in the long term, its overall behavior is a decrease from its initial value of 1.0 to a certain stability point. I want to set up a continuous callback that stops the integration when the solution is close enough to that stable point, and for that, I set up a continuous callback that saves the local maxima and then compares the values of the last two saved maxima, and if they are within a certain tolerance, stops the integration.

I’ve run the script in the debugger from VSCode and verified that the continuous callback is indeed properly saving the values of the local maxima, and it does compare the value of the last maximum with the previous one, but for some reason it fails to trigger the callback. I leave you here a minimal working example of my code:

using DifferentialEquations, Plots, Sundials

I = 200.0
I_max = 200.0   
I_min = 0.0     
tolerance = 0.00001
discontinuities::Vector{Float64} = []
ON::Vector{Float64} = []
OFF::Vector{Float64} = []
saved_maxima::Vector{Tuple{Float64, Float64}} = []
timestep = 0.0
t_end = 45000
τ = 1.5*3       
duty = 2/3

while timestep < t_end
    push!(discontinuities, timestep + duty*τ, timestep + τ)
    push!(OFF, timestep + duty*τ)
    push!(ON, timestep + τ)
    global timestep += τ
end

p = (; I, I_max, I_min, tolerance, discontinuities, ON, OFF, saved_maxima)

function ODE!(du, u, p, t)
    growth = 10.0^(-4) * (1 - u[1])
    decrease = 15.0^(-7) * p.I * u[1]
    du[1] = growth - decrease
    nothing
end

condition_turn_ON(u, t, integrator) = t in integrator.p.ON
function turn_ON!(integrator)
    integrator.p = (; integrator.p..., I = integrator.p.I_max)
end

condition_turn_OFF(u, t, integrator) = t in integrator.p.OFF
function turn_OFF!(integrator)
    integrator.p = (; integrator.p..., I = integrator.p.I_min)
end

function condition_stop(u, t, integrator)
# Check when a certain stability ϵ ( |u_i - u_i-1| < ϵ ) is reached
    if length(integrator.sol.u) > 3
    # Avoid out-of-bounds errors
        if (integrator.sol.u[end - 1][1] > integrator.sol.u[end][1]) & (integrator.sol.u[end - 1][1] > integrator.sol.u[end - 3][1])
        # Check whether a value is larger than the previous / next points. Maxima will be located at discontinuties, where data is saved twice, so [end - 2] = [end - 1]
            if length(integrator.p.saved_maxima) == 0
            # If it is the first maxima found, save it directly
                push!(integrator.p.saved_maxima, (integrator.sol.u[end - 1][1], length(integrator.sol.u)))
            elseif length(integrator.sol.u) != integrator.p.saved_maxima[end][2]
            # If it is not the first maxima, check the length of integrator.sol to verify that it has been updated (it does not update every step like integrator.t)
                push!(integrator.p.saved_maxima, (integrator.sol.u[end - 1][1], length(integrator.sol.u)))
            end
        end
    end

    if length(integrator.p.saved_maxima) >= 2
    # Avoid out-of-bounds errors
        if abs(integrator.p.saved_maxima[end][1] - integrator.p.saved_maxima[end - 1][1]) < integrator.p.tolerance
        # Check if the absolute difference between two consecutive maxima is below the desired tolerance
            return true
        end
    end

    return false
end
function end_computation!(integrator)
    println("Stability target (ϵ ≤ $(integrator.p.tolerance)) has been reached at time $(integrator.t) s, timestep #$(length(integrator.sol.t))")
    terminate!(integrator)
end

cb_ON = DiscreteCallback(condition_turn_ON, turn_ON!, save_positions = (true, true))
cb_OFF = DiscreteCallback(condition_turn_OFF, turn_OFF!, save_positions = (true, true))
cb_stop = ContinuousCallback(condition_stop, end_computation!)
cbs_ODE = CallbackSet(cb_ON, cb_OFF, cb_stop)

ODE_model = ODEProblem(ODE!, [1.0], (0.0, t_end), p)
sol = solve(ODE_model, CVODE_BDF(), callback = cbs_ODE, tstops = discontinuities, progress = true)

NOTE: While writing this post I thought of a different way to define the continuous callback (I leave it below), this implementation saves the same maxima values and uses the same code to check if two maxima are close enough, but this one does trigger the callback successfully. Although it works, I`d still like to understand why is the first one failing.

saved_maxima::Vector{Float64} = []

function condition_stop(u, t, integrator)
    if (t in integrator.p.ON) & (integrator.p.I == integrator.p.I_max)  # maxima occur in the transtion from ON to OFF
        push!(integrator.p.saved_maxima, integrator.sol.u[end - 1][1])
        if length(integrator.p.saved_maxima) >= 2
            if abs(integrator.p.saved_maxima[end] - integrator.p.saved_maxima[end - 1]) < integrator.p.tolerance
                return true
            end
        end
    end
    return false
end

Thanks for your help, and best regards.

TL;DR: Use DiscreteCallback instead of ContinuousCallback.


Full answer:

Hi apazo,

Welcome to the Julia community!

It seems to me that the problem is that you’re using a ContinuousCallback in the first place. Reading through the documentation, a ContinuousCallback is triggered when a continuous function has a zero-crossing. As we are always working with discrete timesteps, this requires some interpolation. The benefit is that you can then work back to find the ‘precise’ time of the zero-crossing.
If you want to use ContinuousCallback, your condition_stop should therefore return a float, not a boolean (though Bools seem to get converted to Float64 (0. or 1.), I guess). Potentially you might be able to rewrite condition_stop to use something like

if ...
    return abs(integrator.p.saved_maxima[end][1] - integrator.p.saved_maxima[end - 1][1]) - integrator.p.tolerance  # - instead of <
end

which changes from positive to negative when we should stop. But I couldn’t get this to work. In any case, your stopping criterion seems fundamentally discrete to me, as you’re explicitly relying on a discrete sampling saved_maxima. (As a continuous alternative, maybe you can check that the derivative is sufficiently close to 0 instead?)



If I simply keep your code for condition_stop, but change

cb_stop = ContinuousCallback(condition_stop, end_computation!)

to

cb_stop = DiscreteCallback(condition_stop, end_computation!)

I get as output

Stability target (ϵ ≤ 1.0e-5) has been reached at time 10.456329814160023 s, timestep #19

i.e. the stopping criterion does now trigger.

For reference, with your second (implementation of the) stopping criterion as a DiscreteCallback, I get

Stability target (ϵ ≤ 1.0e-5) has been reached at time 9.0 s, timestep #18

I imagine this occurs one timestep earlier, as in the iteration where you get a maximum,

(t in integrator.p.ON) & (integrator.p.I == integrator.p.I_max)

is already true, whereas in the

(integrator.sol.u[end - 1][1] > integrator.sol.u[end][1]) & (integrator.sol.u[end - 1][1] > integrator.sol.u[end - 3][1])

approach, this will only become true in the next iteration, when the maximum is found in integrator.sol.u[end - 1][1].

(By the way, if I understand correctly, t in integrator.p.ON and integrator.p.I == integrator.p.I_max are equivalent, so you only need one of them? Also, you’d want to use && (logical and) instead of & (bitwise and).)



What is unclear to me, though, is why ContinuousCallBack still works for the second implementation, but not for the first. If you look at the returned signals, in the first implementation you get false, false, ..., false, true, true, ..., true, whereas in the second you get false, false, ..., false, true, false, false. So I guess the double ‘zero-crossing’ in the second case causes the callback to actually trigger. You can sort of simulate this in the first implementation by using

if abs(integrator.p.saved_maxima[end][1] - integrator.p.saved_maxima[end - 1][1]) < integrator.p.tolerance
    # Check if the absolute difference between two consecutive maxima is below the desired tolerance
    return rand() < 0.5  # or rand() - 0.5
end 

(though you really shouldn’t). Perhaps someone who knows how ContinuousCallBack works under the hood can explain why false, true (0., 1.) does not count as a ‘zero-crossing’ (nor true, false, or -0.5, 0.5 for that matter) while false, true, false does.

In any case, if you just use DiscreteCallBack you don’t need to worry about any of this :slight_smile:.

1 Like