Skip to content

Fix Heaviside variables of roots crossed back by event assignments - #3439

Draft
dweindl wants to merge 1 commit into
AMICI-dev:mainfrom
dweindl:fix_heaviside_flip_back
Draft

dweindl wants to merge 1 commit into
AMICI-dev:mainfrom
dweindl:fix_heaviside_flip_back

Conversation

@dweindl

@dweindl dweindl commented Oct 22, 2026 •

Copy link
Copy Markdown
Member

Fix the Heaviside variables of roots that an event assignment crosses back at the same time. Fixes #3425.

detect_secondary_events skipped all roots that had been crossed in the current discontinuity, since their values are within roundoff of zero and the sign isn't reliable. If an event assignment moved such a root back across zero, e.g., E1: at x < thr: x = 1.5 * x, the Heaviside variable kept the post-crossing value, and the next crossing incremented it again (h = 2, 3, …), so piecewise expressions took the wrong branch. In the issue model, the simulation eventually failed with AMICI_TOO_MUCH_WORK. Similarly, an event moving its own explicit trigger time, E: at time >= s: s = s + 1, fired only once. This is a regression since #3407, which takes the crossing direction of explicit roots from the Heaviside variables. It affects, e.g., periodic dosing schedules written as one event.

Now, root functions that the preceding event assignment didn't change are skipped, and the others are compared to the current Heaviside variables instead of to the root values at the trigger. A root crossed back gets roots_found = -1 (or +1 for its negated counterpart, which is queued as an event without assignments, as for other secondary roots). Roots that were crossed at the current time are only considered crossed back if they are clearly on the other side: their value has to exceed the change of the root function for a state perturbation by the integration tolerances. This guards against spurious flips due to roundoff or the re-computation of algebraic states after an event (DAEs). For these roots, exact zeros and NaNs are never considered crossings.

Tests:

  • test_event_crosses_own_trigger_back: the issue model against the analytical solution, the right-hand side at the final time point, the forward sensitivities against the analytical ones (including dx/dthr = 0, which requires the event-time terms to cancel), and the forward and adjoint gradients.
  • test_event_moves_own_trigger_time: the explicit-trigger-time model.
  • test_event_assignment_onto_threshold: an assignment that sets a just-crossed root to zero, exactly (at x > xmax: x = xmax) or one rounding error below the threshold (xmax := 0.1 + 0.2 computed at runtime, x = 0.3). The latter reverts the switch without the threshold.

The SECONDARY_EVENT warning is now also logged for events that cross their own trigger back, e.g., at each execution of E: at time >= s: s = s + T, x = x + D with forward or adjoint sensitivities. There, it is a false positive: the sensitivities are correct.

🤖 Generated with Claude Code

@codecov

codecov Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 80.12%. Comparing base (8f3e43c) to head (750547a).

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #3439      +/-   ##
==========================================
+ Coverage   80.11%   80.12%   +0.01%     
==========================================
  Files         331      331              
  Lines       23325    23346      +21     
  Branches     1619     1624       +5     
==========================================
+ Hits        18686    18707      +21     
  Misses       4633     4633              
  Partials        6        6              
Flag Coverage Δ
cpp 74.55% <100.00%> (+0.02%) ⬆️
cpp_python 34.64% <21.87%> (-0.03%) ⬇️
petab 46.57% <78.12%> (+0.04%) ⬆️
petab_sciml 16.67% <0.00%> (-0.02%) ⬇️
petab_sciml_benchmarks 15.26% <0.00%> (-0.02%) ⬇️
python 72.42% <84.37%> (-0.01%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
include/amici/forwardproblem.h 100.00% <ø> (ø)
include/amici/misc.h 89.47% <ø> (-0.53%) ⬇️
src/forwardproblem.cpp 93.02% <100.00%> (+0.22%) ⬆️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@dweindl
dweindl force-pushed the fix_heaviside_flip_back branch 5 times, most recently from ee59db3 to 6f0b8b4 Compare October 22, 2026 20:33
@dweindl dweindl self-assigned this Oct 23, 2026
@dweindl
dweindl force-pushed the fix_heaviside_flip_back branch from 288dd7a to 4db3e38 Compare October 23, 2026 19:24
@dweindl dweindl added events related to handing discrete events piecewise Related to piecewise functions / Heaviside switches labels Oct 24, 2026
@dweindl
dweindl force-pushed the fix_heaviside_flip_back branch 2 times, most recently from 74e54ed to 2277834 Compare October 24, 2026 16:56
@dweindl
dweindl marked this pull request as ready for review October 24, 2026 18:05
@dweindl
dweindl requested a review from a team as a code owner October 24, 2026 18:05

@FFroehlich FFroehlich left a comment

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.

I wonder whether we are taking the right approach here as I have the impression that we are just piling more and more complexity on this problem and it just keeps on failing in novel way.

The overall issue might be better solved by porting the root masking from the jax backend.

@dweindl

dweindl commented Oct 24, 2026

Copy link
Copy Markdown
Member Author

I wonder whether we are taking the right approach here as I have the impression that we are just piling more and more complexity on this problem and it just keeps on failing in novel way.

The overall issue might be better solved by porting the root masking from the jax backend.

Piling up complexity - yes. And we won't be able to handle every adversarial case either way. However, I am not convinced that the JAX backend is currently on par in terms of handling simultaneous events and that it would be that much simpler there to implement. We could give the root masking a try, but I think most the recently introduced functionality/complexity would be required either way. I would prefer fixing all those other event-related bugs first, though.

@FFroehlich

Copy link
Copy Markdown
Member

I wonder whether we are taking the right approach here as I have the impression that we are just piling more and more complexity on this problem and it just keeps on failing in novel way.
The overall issue might be better solved by porting the root masking from the jax backend.

Piling up complexity - yes. And we won't be able to handle every adversarial case either way. However, I am not convinced that the JAX backend is currently on par in terms of handling simultaneous events and that it would be that much simpler there to implement. We could give the root masking a try, but I think most the recently introduced functionality/complexity would be required either way. I would prefer fixing all those other event-related bugs first, though.

I would expect that the JAX hopefully isn't subject to as many issues due to it's restricted feature set, even though I wouldn't be suprised if there are many undiscovered bugs remaining.

Specifically for this PR, root masking + tracking root derivative and event-induced root sign changes should be a substantially more robust solution.

@dweindl

dweindl commented Oct 25, 2026

Copy link
Copy Markdown
Member Author

I would expect that the JAX hopefully isn't subject to as many issues due to it's restricted feature set, even though I wouldn't be suprised if there are many undiscovered bugs remaining.

Yes, that was my point. Once the same feature set is supported there, I am not sure this will be substantially less complex.

Specifically for this PR, root masking + tracking root derivative and event-induced root sign changes should be a substantially more robust solution.

I will push the alternative implementation shortly, then we can compare and decide.

@dweindl

dweindl commented Oct 26, 2026 •

Copy link
Copy Markdown
Member Author

I opened two PRs with two alternative implementations:

Both PRs go beyond main and the state of this PR. They have additional fixes, and they pass the same set of tests, so they are more comparable.

Diff: https://github.com/dweindl/AMICI/compare/root_filtering..root_masking

@dweindl

dweindl commented Oct 26, 2026

Copy link
Copy Markdown
Member Author

The diff is tiny, but okay, let's go for the masking, which avoids additional messing with solver internals.

@FFroehlich

Copy link
Copy Markdown
Member

The diff is tiny, but okay, let's go for the masking, which avoids additional messing with solver internals.

Maybe I am not as up to date with the code base as I would need to be, but I think this would only be the first part of the refactoring I had in mind. The JAX backend works with pairs of trigger functions and turns off the rootfinding for the root that triggered until the other one triggers (gated by direction). This means that unless the root function is rapidly oscillation (I don't think we can address that case), any spurious re-triggering should be avoided.

@dweindl

dweindl commented Oct 26, 2026

Copy link
Copy Markdown
Member Author

Maybe I am not as up to date with the code base as I would need to be, but I think this would only be the first part of the refactoring I had in mind. The JAX backend works with pairs of trigger functions and turns off the rootfinding for the root that triggered until the other one triggers (gated by direction).

I might be missing the point. Where would it make a difference whether we add a positive and a negative root function (as we currently do for Heavisides), or track only one direction of a single one? Which part of the logic do you think would become obsolete?

This means that unless the root function is rapidly oscillation (I don't think we can address that case), any spurious re-triggering should be avoided.

Do you have an example in mind that would break #3490 / #3491 but work with the paired trigger functions?

@FFroehlich

Copy link
Copy Markdown
Member

Maybe I am not as up to date with the code base as I would need to be, but I think this would only be the first part of the refactoring I had in mind. The JAX backend works with pairs of trigger functions and turns off the rootfinding for the root that triggered until the other one triggers (gated by direction).

I might be missing the point. Where would it make a difference whether we add a positive and a negative root function (as we currently do for Heavisides), or track only one direction of a single one? Which part of the logic do you think would become obsolete?

This means that unless the root function is rapidly oscillation (I don't think we can address that case), any spurious re-triggering should be avoided.

Do you have an example in mind that would break #3490 / #3491 but work with the paired trigger functions?

You can turn off event detection/root finding after the first triggering and only reactivate once the other direction triggers. I would hope that would render most of the re-triggering checks we have right now obsolete. The expected advantage would be that we don’t have to define any noise thresholds (either root function noise or trigger point closeness). Happy to give it a stab but I am already running pretty low on Claude tokens for the rest of the week.

@FFroehlich

Copy link
Copy Markdown
Member

Maybe I am not as up to date with the code base as I would need to be, but I think this would only be the first part of the refactoring I had in mind. The JAX backend works with pairs of trigger functions and turns off the rootfinding for the root that triggered until the other one triggers (gated by direction).

I might be missing the point. Where would it make a difference whether we add a positive and a negative root function (as we currently do for Heavisides), or track only one direction of a single one? Which part of the logic do you think would become obsolete?

This means that unless the root function is rapidly oscillation (I don't think we can address that case), any spurious re-triggering should be avoided.

Do you have an example in mind that would break #3490 / #3491 but work with the paired trigger functions?

You can turn off event detection/root finding after the first triggering and only reactivate once the other direction triggers. I would hope that would render most of the re-triggering checks we have right now obsolete. The expected advantage would be that we don’t have to define any noise thresholds (either root function noise or trigger point closeness). Happy to give it a stab but I am already running pretty low on Claude tokens for the rest of the week.

After a quick conversation with ChatGPT I concede my point. I think with #3490 in place there wouldn’t be much scope for improvement.

`detect_secondary_events` skipped all roots that were crossed in the
current discontinuity, as their values are within roundoff of zero.
If an event assignment moved such a root back across zero, e.g., for
`at x < thr: x = 1.5 * x`, the Heaviside variable was not updated. It
was then incremented again at each subsequent crossing, giving wrong
`piecewise` branches. For explicit trigger times, e.g.,
`at time >= s: s = s + 1`, the event did not fire again (since AMICI-dev#3407,
which takes the crossing direction of explicit roots from the Heaviside
variables).

Instead of comparing the new root values to those at the trigger, only
skip root functions that the preceding assignment did not change, and
compare the others to the current Heaviside variables. Roots crossed at
the current time are only considered crossed back if their value
exceeds the change of the root function for a state perturbation by the
integration tolerances, so that roundoff or the re-computation of
algebraic states doesn't flip them. Exact zeros and NaNs don't count as
crossings.

Besides an assignment exactly onto a threshold, test one that lands one
rounding error below a threshold computed at runtime (`0.1 + 0.2`),
which reverted the switch without the noise threshold.

Remove the unused `heaviside_differs`.

Fixes AMICI-dev#3425.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dweindl
dweindl force-pushed the fix_heaviside_flip_back branch from 2277834 to 750547a Compare October 27, 2026 07:53
dweindl added a commit to dweindl/AMICI that referenced this pull request Oct 28, 2026
…v#3480)

If an event assignment crossed a `piecewise` condition in the negative
direction without triggering another event, the jump in the right-hand
side was missing from the forward and adjoint sensitivities. A typical
case is a flag that is switched off by an event, e.g., `s = 1`, `z' =
piecewise(c, s >= 1, 0)`, `E: at x >= 1: s = 0`.

For each Heaviside function, there are root functions `r` and `-r`.
Usually, the negative crossing of `r` is accompanied by a positive
crossing of `-r`, which queues an (assignment-free) event whose
sensitivity update includes the right-hand side jump. If the assigned
variable was exactly at the threshold, `-r` goes from 0 to a positive
value, which is no change of its Heaviside value, so no event is queued,
and `update_heaviside` flips `h` after the last sensitivity update.

Now, if no event is pending after the Heaviside update of a secondary
discontinuity, the jump is applied separately: for FSA, `sx +=
(xdot_post - xdot_pre) * stau`; for ASA, the jump is stored in the
`Discontinuity`, and `xB^T (xdot_post - xdot_pre)` is added to `stauB`,
which is passed on to the triggering discontinuity via
`stauB_secondary_`. For DAEs, the algebraic states are made consistent
with the switched equations first (as after event applications);
otherwise, a switch in an algebraic rule was still missing. The
`SECONDARY_EVENT` warning no longer mentions this case.

The new test checks FSA and ASA against the analytical gradient for the
example above, the equivalent `piecewise(0, s < 1, c)`, the assignment
in a secondary event, and the switch in an algebraic rule of a DAE.

Not changed: for DAEs, if another event is pending after the Heaviside
update, the algebraic states are only made consistent after that event's
application, as before, so its assignments see algebraic states that are
consistent with the old Heaviside values.

Fixes AMICI-dev#3436. Possible textual conflicts with AMICI-dev#3439
(`detect_secondary_events`).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@dweindl
dweindl marked this pull request as draft October 29, 2026 13:07

This branch has not been deployed

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

Labels

events related to handing discrete events piecewise Related to piecewise functions / Heaviside switches

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Wrong piecewise branch after an event moves the state back across a switch that crossed at the same time

2 participants