Skip to content

FMIODE2.jac: an FMU failure while forming the Jacobian is recoverable - #426

Draft
hubertus65 wants to merge 4 commits into
modelon-community:masterfrom
hubertus65:pr-jac-recoverable
Draft

hubertus65 wants to merge 4 commits into
modelon-community:masterfrom
hubertus65:pr-jac-recoverable

Conversation

@hubertus65

Copy link
Copy Markdown

Summary

One commit. Two related robustness fixes on the Jacobian path for ME FMUs, both found by running
a single-step stiff solver over a model whose property functions refuse to evaluate in part of
the state space:

  1. FMIODE2.jac: an FMU failure while forming the Jacobian is recoverable. It raised a raw
    FMUException out of the Assimulo callback, which ends the simulation. An rhs failure at a
    trial point is already treated as recoverable — a solver responds by shrinking the step — and
    a Jacobian failure at a trial point is the same kind of event. It now signals a recoverable
    failure, so the solver can retry with a smaller step instead of the run dying at t = 3110 s.
  2. The finite-difference kink guard's probe steps aside when the model refuses it. The guard
    re-differences a flagged colour group at h/10; if the model cannot be evaluated at that
    probe point, the probe is abandoned and the ordinary difference is kept, rather than the
    probe's failure propagating.

Review notes

  • ~25 lines across src/pyfmi/fmi2.pyx and src/pyfmi/simulation/assimulo_interface_fmi2.pyx.
  • Depends on the jacobian_mode / kink-guard PR (the second fix is inside the guard).
  • No behaviour change on a model whose callbacks never fail: the new paths are only reached
    after an FMU has already returned an error status.
  • The matching solver-side change (a stepper that responds to the recoverable failure instead of
    giving up) is in Assimulo Feed data into the model #138 for Radau5 and in the TRBDF2 branch, Added possibility to read very large result files #140.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PyrxGeWuwCmAH9LishMW6p

hubertus65 and others added 4 commits September 22, 2026 19:38
…Jacobian

When 'with_jacobian' is in effect and the FMU provides directional derivatives,
PyFMI evaluates the Jacobian with one fmi2GetDirectionalDerivative call per
colour group of the sparsity pattern. On FMUs where one such call costs more
than an rhs evaluation (measured 1.7x on OCT exports of 140-320 state models),
coloured forward differences give the same Jacobian for a third of the cost,
and because the colouring is bounded by the densest Jacobian row, Jacobian
evaluation was 55-85 % of the solver time on those models. Measured with
CVode: absorptionplant (226 states) 164 -> 89 s; Radau5ODE 529 -> 206 s; same
step counts, events and accuracy (deviation <= 4e-4 at rtol 1e-6).

'jacobian_mode' = "auto" (default) times one rhs and one directional-derivative
call at initialization and picks "fd" when the derivative is the more expensive
one, except for RodasODE (a Rosenbrock method needs the exact Jacobian), the
sparse linear solver, and rtol < 1e-7 (forward-difference truncation error
~sqrt(eps) then stalls Newton, measured on a 318-state plant at rtol 1e-8).
"dd" and "fd" force either. Without directional derivatives the mode is
always "fd". The setting is scoped to the simulation: the model's
force_finite_differences attribute is restored afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A forward difference taken across a kink of the model -- a limiter, a region
boundary of a property function -- returns jump/h (1e8 and more) where the
derivative on this side is small. Newton-based integrators converge on the
corrupted iteration matrix and reuse it for many steps. Measured on a 318-
state combined-cycle plant at rtol 1e-6: two such entries in 200 Jacobian
evaluations (d der(pump.s)/d s = -3.2e8 vs 0 at the end of the steam-table
failure zone; d der(drum.p)/d hl = -1.3e8 vs -0.18 at t = 0) moved 91 of 308
plant states more than 1e-3 off the rtol 1e-8 reference.

The guard (FMUModelME2.fd_kink_guard, default True) works per colour group of
_estimate_directional_derivative:
- flag: an entry that grew more than 1e3x relative to the previous Jacobian
  and is not negligible relative to it marks the group as suspect; the first
  evaluation checks every group. Kinks are transient, so this is nearly free.
- confirm: a suspect group is re-differenced with a 10x smaller step; entries
  whose quotient changes by more than 10 % are kinks (a jump scales with 1/h,
  a smooth function does not).
- fix: kink entries take the FMU's directional derivative for the group when
  it has one, else the smaller in magnitude of the forward and the backward
  difference (the side without the jump).
Model variables are restored after each check; statistics in _fd_guard_stats.

With the guard, CVode with the FD Jacobian on the plant matches the DD run's
accuracy (plant median 5.0e-6 vs 9.8e-6, 4 vs 1 states beyond 1e-3, 23
events) at 5458 instead of 14353 steps and 320 instead of 511 Jacobians, each
half the cost: 229 s instead of 340 s. On a 226-state plant and a 141-state
vehicle model the guard changes nothing (accuracy and time within noise).
FMI3's separate finite-difference routine is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PyrxGeWuwCmAH9LishMW6p
…tion's help

The default stays "auto" (as introduced); this records what the kink guard
protects against and asserts the default in the option test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PyrxGeWuwCmAH9LishMW6p
…; the FD kink guard's probe steps aside when the model refuses it

rhs() already turns an FMU failure at a trial point into AssimuloRecoverableError; jac()
let the raw FMUException through, which ended the run under any solver that asks for the
Jacobian (CVode with_jacobian, TRBDF2, Radau5). Now the same mapping: CVode answers with
CVDLS_JACFUNC_RECVR, TRBDF2 falls back to its own differences (Assimulo trbdf2 branch).

The one path that actually raised: _fd_kink_guard_group()'s probe at a smaller step
called get_real() unguarded, so a nonlinear block that does not converge at the probe
point ('Failed to get the Real values.') killed a Jacobian whose columns were already
computed. A refused probe is not a kink: the guard returns None and the column stands;
counted in _fd_guard_stats['probe_failures'].

Measured 2026-09-21: DataCenter.Examples.Systems.StandardChillerSystem and
ChillerWithEconomizerSystem (30 days, no directional derivatives) now complete under
TRBDF2 with CVode's event counts (133 / 132 state events); each had exactly one refused
probe, at t = 0 for the second.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PyrxGeWuwCmAH9LishMW6p
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.

1 participant