Repository navigation
fmi-ls-dae: DAE FMUs, IDA for the system, and algebraic loops as rows (#1645) - #1646
Merged
Merged
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## master #1646 +/- ##
==========================================
+ Coverage 28.43% 30.22% +1.78%
==========================================
Files 72 74 +2
Lines 13981 14661 +680
Branches 9023 9432 +409
==========================================
+ Hits 3976 4431 +455
- Misses 8771 8892 +121
- Partials 1234 1338 +104
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
…delica#1645) The first half of DAE-FMU support: recognising one and being able to put it in DAE mode. The integration itself — an IDA path in SystemSC over the union of the components' states and algebraic variables, with the connection equations as residual rows — is the follow-up, and nothing here changes how a system is simulated yet. A Model Exchange FMU carrying extra/org.fmi-standard.fmi-ls-dae/fmi-ls-manifest.xml exposes the residuals of F(t, x, x', z) = 0 and lets the importer solve for the algebraic variables z beside the states, instead of iterating on them behind its own interface. It is an ordinary ODE FMU until the importer sets the structural parameter the manifest names. * LsDae: reads the manifest — the enable parameter, the algebraic variables, and the residuals with their dependencies (the sparsity a DAE master differences the Jacobian with). No manifest is not an error; it is the ordinary FMU. * ComponentFMU3ME: loads it when the FMU is unpacked, remembers the <ContinuousStateDerivative> value references (the model description's own order, which pairs them with the states), and gains enableDaeMode() — Configuration Mode, the switch, and the checks that the system is square and fully implicit — plus the residual, algebraic-variable and derivative accessors a master needs. * variable.py: FMI 3.0's structuralParameter causality. Without it Causality[causality] raises a KeyError and OMSimulator cannot even load an FMU that has one — which every fmi-ls-dae FMU does. Tested against a --daeMode Model Exchange FMU exported by OpenModelica (OpenModelica/OpenModelica#16622): OMSimulator reads the manifest and reports "3 residuals over 1 states and 2 algebraic variables", and the FMU's own refusal to run as an ODE FMU comes through as a diagnostic instead of a crash. Ordinary ODE FMUs are unaffected. Refs OpenModelica#1645, OpenModelica/OpenModelica#16622 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EfnWV25k2u9x4CHxcnQbF7
adrpo
force-pushed
the
fmi-ls-dae-support
branch
from
October 31, 2026 21:41
41915dd to
cb87beb
Compare
The other half: a strongly coupled system that holds a component in DAE mode is integrated as one implicit DAE instead of an explicit ODE. The unknowns are laid out per component, [x_i | z_i], and so are the rows: a component in DAE mode contributes the residuals it declares, an ODE component the explicit rows x'_i - f_i(x, u, t). Both blocks are square, so the system is, and the two mix — a DAE component next to an ordinary FMU needs nothing special. IDASetId says which unknowns are differential, and IDACalcIC solves for the algebraic ones and for the derivatives, at the start and again after every event: the components initialize consistently with themselves, not with the coupled system, and an event moves them the same way. The coupling stays where it was for CVODE: updateInputs() propagates connected outputs into inputs before the rows are evaluated, so the residuals see a consistent coupling instead of the value left over from the last accepted step, and a loop spanning components is still resolved by AlgLoop. Lifting the connection equations into residual rows of their own — which is what would let one global implicit system replace that inner solve — is the next step, not this one. * SystemSC: ida_res / ida_roots, the [x | z] layout, setDaePoint/getDaePoint, calcConsistentInitialConditions, doStepIDA, and the IDA setup and teardown beside the CVODE ones. Dense Jacobian for now; the manifest's residual dependencies are a sparsity pattern for KLU, which matters as soon as the system is more than small. * oms_solver_sc_ida: DAE mode is not really a choice — a component that declares a DAE formulation has no explicit ODE for CVODE to integrate — so the system switches to IDA on its own and says so. The solver still has a name, for the SSD and for --solver. * ComponentFMU3ME: DAE mode is switched on at instantiation, which is where Configuration Mode is. The checks that need the state count moved to validateDaeMode(), called from initialize(): an FMU only reports how many continuous states it has once it has left Initialization Mode. * instantiated_model.py: SolverType duplicates the C enum's *values*, so inserting a solver renumbered oms_ma/oms_mav/oms_mav2 and setSolver() started choosing the wrong one — 37 testsuite failures with no hint of why. Values fixed and the duplication called out in a comment; deriving them from the C API would be better. fmu.py has a second copy, _FMU_SOLVER, which still sent 6 for 'ma': that is oms_solver_wc_min now, SystemWC::setSolver refuses it, and the command line could no longer run any Co-Simulation FMU. Fixed, with 'ida' added there too, and covered by simulation/fmuSimulation3.py, which selects every solver by name on both kinds of an FMU. Requires OpenModelica/OMSimulator-3rdParty#111 (SUNDIALS_ENABLE_IDA was OFF). Tested with a --daeMode Model Exchange FMU exported by OpenModelica (OpenModelica/OpenModelica#16622): it simulates through IDA, and the trajectory agrees with the same model exported as an ordinary ODE FMU and run through CVODE by this same master to ~1e-8, state event included. ctest: 117/117, the same as master. Refs OpenModelica#1645 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EfnWV25k2u9x4CHxcnQbF7
FMI 3.0 changed this from FMI 2.0: the `dependencies` attribute of an <Output>, <InitialUnknown> and the rest holds **value references**, not one-based indices into the variable list. Both FMI 3.0 components read them as indices, the FMI 2.0 way, so the dependency graph was wired to whichever variables happened to sit at those positions — and an FMU whose dependency names value reference 0, a perfectly ordinary one, was rejected outright: warning: Output q has bad dependency on variable with index 0 which couldn't be resolved error: B: erroneous dependencies detected in modelDescription.xml which is how this was found: two connected FMI 3.0 FMUs, the driven one with its input at value reference 0. Resolve them as value references. ComponentFMUME keeps the index reading, which is right for FMI 2.0. Refs OpenModelica#1645 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EfnWV25k2u9x4CHxcnQbF7
…lica#1645) An algebraic loop over connections was solved by an inner KINSOL Newton, run to convergence inside every residual evaluation, on top of whatever solver each FMU hides behind its own interface. Where IDA drives the system it does not have to be: the loop's connections become unknowns and rows of the system itself, and the coupling constraint gets the integrator's own error control. The translation is direct, because it is the same system the inner solver had: each connection of the loop contributes one unknown — the value of the input it drives — and one row, output - input = 0. They are algebraic unknowns, they follow the components' in y, and updateInputs() leaves those loops alone since IDA has already set their inputs. A loop is only lifted if all of its connections carry reals; one with an integer or boolean connection keeps its own solver, and so does every loop when the system is not in DAE mode, where nothing here applies. Initialization and events leave every loop to IDACalcIC rather than to the inner solver. They have to: a component in DAE mode does not solve for its own algebraic outputs — the master does — so "set the input, read the output back" has nothing behind it, and KINSOL fails on a singular Jacobian. The consistent initial conditions are solved for the whole coupled system instead, which is where that belongs. Tested with a loop across two FMUs, one of them --daeMode, against the same two components written as a single Modelica model and simulated by OpenModelica: x(0.5) x(1.0) loop equation IDA, loop as rows 0.817368712680 0.701445480172 2.7e-14 CVODE + KINSOL 0.817433688498 0.701528323696 8.7e-08 monolithic omc 0.817368180018 0.701445143664 - The lifted rows hold the connection to machine precision instead of the inner solver's tolerance, and land two orders of magnitude closer to the monolithic answer. (The KINSOL run of the same loop needs --directionalDerivatives=false: it wants a Jacobian the FMU does not provide, where the lifted rows are part of the one IDA differences itself.) ctest: 117/117, the same as master. Refs OpenModelica#1645 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EfnWV25k2u9x4CHxcnQbF7
…lica#1645) IDA's own difference quotient costs one residual evaluation per unknown, and every one of those calls into every FMU of the system. The components' manifests say which unknowns each residual reaches, which is enough to colour the columns: two share a colour when no row reaches both, so one evaluation differences all of them at once. The whole Jacobian then costs as many evaluations as there are colours, which for a coupled system is a handful however many unknowns there are: 1 pair of components: 5x5, 16 nonzeros, 5 colours 4 pairs: 20x20, 64 nonzeros, 5 colours 8 pairs: 40x40, 128 nonzeros, 5 colours Where a manifest's <Residual> names dependencies they are used exactly; where it names none, or names a value reference that is not one of the unknowns, the row falls back to the whole component. An ODE component's rows and the rows of a lifted loop fall back the same way. Every case is a superset of the true structure, which is what a coloured difference quotient needs — omitting a nonzero silently attributes one column's perturbation to another. Two things the manifest does not say, and one it says in a way that had to be read correctly: * A residual's dependency on an *input* is not in it: the dependencies it declares are those of the FMU's own DAE Jacobian, whose columns are the states and the algebraic variables. An input only becomes an unknown here, where the master carries it for a lifted loop, so those columns are added to every row of the component regardless. Without them the Jacobian is wrong by exactly the -1 of the input's own row. * The `derivative` attribute of a Float64 is a *value reference* in FMI 3.0 where FMI 2.0 had an index — the same change as the ModelStructure dependencies, and fmi4c passes the raw attribute through under its FMI 2.0 name. Read as an index, `derivative="0"` (a perfectly ordinary value reference) meant "no state", and the structure was discarded. The linear solver is still dense: KLU wants SuiteSparse, which OMSimulator's 3rdParty does not carry. The structure is built and kept in a form a sparse matrix can take directly, so that is a small step once it does — and the evaluations, not the factorization, are what a coupled system pays for at this size. Checked against a column-at-a-time difference quotient of the same system: the coloured Jacobian agrees to the last bit, and the loop model's results and step counts are unchanged from the dense-differenced ones. ctest: 117/117. Refs OpenModelica#1645 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EfnWV25k2u9x4CHxcnQbF7
The structure the manifests give is what a sparse solver wants, so the Jacobian is held as a CSC matrix and factorized by KLU instead of being a dense one. The pattern never changes, so KLU reuses its symbolic factorization across the run, and the matrix carries its structure in the same arrays as its values — which is why SUNMatZero is not called on it: it would wipe the pattern, and every entry is written each time anyway, structure and value together. Without a usable structure nothing changes: the matrix is dense and IDA differences it itself. Needs OpenModelica/OMSimulator-3rdParty#111, which brings in SuiteSparse and turns SUNDIALS_ENABLE_KLU on. Same answers as the dense factorization, to the last digit or two of the trajectory, and the same step counts: x(0.5) x(1.0) loop equation dense 0.817368712680 0.701445480172 2.7e-14 KLU 0.817368712680 0.701445480171 2.6e-14 omc 0.817368180018 0.701445143664 - ctest: 117/117. Refs OpenModelica#1645 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EfnWV25k2u9x4CHxcnQbF7
adrpo
force-pushed
the
fmi-ls-dae-support
branch
from
November 1, 2026 10:16
cb87beb to
adff1b0
Compare
Nothing in the testsuite ran the DAE path, so codecov reported most of this PR as uncovered: the fmi-ls-dae manifest, the switch into DAE mode, IDA, the loop lifted into rows and the KLU factorization only ran in hand-made checks. * simulation/DaeMode1.py: DaeLoopA (--daeMode) and an ordinary LoopB in an algebraic loop, through an SSP. The system switches to IDA by itself, lifts the loop's two connections into rows and colours the 5x5 Jacobian. x(1) = 0.701445, the closed-loop solution 0.5 + 0.5*exp(-1/1.1). * simulation/DaeMode2.py: DaeLoopA alone through the FMU API, once with IDA selected by name and once with the default CVODE, which the system replaces with IDA. Both give x(1) = 0.683940 = 0.5 + 0.5*exp(-1). Both FMUs were exported by OpenModelica with --simCodeTarget=C+Rust (the ls-dae export of OpenModelica#17185), so they carry x86_64-linux binaries only and the tests are enabled for linux only. They need glibc 2.38 and run on the 24.04 and 26.04 images; ASan is off for them, not verified here. Refs OpenModelica#1645 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
adrpo
marked this pull request as ready for review
November 1, 2026 10:28
This was referenced Nov 1, 2026
Open
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Towards #1645. OpenModelica/OMSimulator-3rdParty#111 (SUNDIALS' IDA, and SuiteSparse for its KLU) is merged, and master's
3rdPartyalready contains it, so this PR no longer changes the submodule.Ready for review. The open points at the bottom are follow-ups, not blockers. The tests use FMUs from OpenModelica/OpenModelica#17185, which is now merged, so master can regenerate them.
A Model Exchange FMU carrying
extra/org.fmi-standard.fmi-ls-dae/fmi-ls-manifest.xmlexposes the residuals ofF(t, x, x', z) = 0and lets the importer solve for the algebraic variableszbeside the states, instead of iterating on them behind its own interface. OpenModelica exports such FMUs (OpenModelica/OpenModelica#16504 for wasm, OpenModelica/OpenModelica#16622 for the C target). This makes OMSimulator read one and integrate it.Reading the manifest (commit 1)
LsDaereads it: the enable parameter, the algebraic variables, and the residuals with their dependencies — the sparsity a DAE master differences the Jacobian with. A missing manifest is not an error; it is the ordinary FMU.ComponentFMU3MEloads it when the FMU is unpacked, remembers the<ContinuousStateDerivative>value references (the model description's own order, which pairs them with the states), switches the FMU into DAE mode at instantiation — that is where Configuration Mode is — and gains the residual, algebraic-variable and derivative accessors.fmi4calready bindsfmi3EnterConfigurationMode, so no 3rdParty change was needed for that.Integrating it (commit 2)
The unknowns are laid out per component,
[x_i | z_i], and so are the rows: a component in DAE mode contributes the residuals it declares, an ODE component the explicit rowsx'_i - f_i(x, u, t). Both blocks are square, so the system is, and the two mix — a DAE component next to an ordinary FMU needs nothing special.IDASetIdsays which unknowns are differential;IDACalcICsolves for the algebraic ones and for the derivatives at the start and again after every event, because the components initialize consistently with themselves rather than with the coupled system, and an event moves them the same way.Direct (non-loop) coupling stays where it was for CVODE:
updateInputs()propagates connected outputs into inputs before the rows are evaluated. Algebraic loops do not — see the next section.DAE mode is not really a solver choice: a component that declares a DAE formulation has no explicit ODE for CVODE to integrate, so the system switches to IDA on its own and says so in the log. It still has a name (
oms_solver_sc_ida,--solver ida) for the SSD and the CLI.Algebraic loops as rows, not an inner solve (commit 4)
An algebraic loop over connections was solved by an inner KINSOL Newton, run to convergence inside every residual evaluation, on top of whatever solver each FMU hides behind its own interface. Where IDA drives the system it does not have to be. The translation is direct, because it is the same system the inner solver had: each connection of the loop contributes one unknown — the value of the input it drives — and one row,
output - input = 0.A loop is lifted only if all of its connections carry reals; one with an integer or boolean connection keeps its own solver, and so does every loop when the system is not in DAE mode, where none of this applies.
Initialization and events leave every loop to
IDACalcICrather than to the inner solver. They have to: a component in DAE mode does not solve for its own algebraic outputs — the master does — so "set the input, read the output back" has nothing behind it and KINSOL fails on a singular Jacobian. The consistent initial conditions are solved for the whole coupled system instead, which is where that belongs.Tested with a loop across two FMUs, one of them
--daeMode, against the same two components written as a single Modelica model and simulated by OpenModelica:The lifted rows hold the connection equation to machine precision instead of the inner solver's tolerance, and land two orders of magnitude closer to the monolithic answer. The KINSOL run of that same loop also needs
--directionalDerivatives=false, because it wants a Jacobian the FMU does not provide — where the lifted rows are simply part of the one IDA differences itself.The Jacobian: coloured, and factorized by KLU (commits 5 and 6)
IDA's own difference quotient costs one residual evaluation per unknown, and every one of those calls into every FMU of the system. The manifests say which unknowns each residual reaches, which is enough to colour the columns — two share a colour when no row reaches both — so one evaluation differences all of them at once:
The cost of a Jacobian stays flat as the system grows. The same structure holds the matrix sparse (CSC) and lets KLU factorize it, reusing its symbolic factorization since the pattern never changes. Without a usable structure nothing changes: dense matrix, IDA differences it itself.
Two things about the structure are worth flagging:
-1of the input's own row, and a coloured difference quotient then attributes one column's perturbation to another. It may be worth raising with the layered standard: a residual has no way to declare that it depends on an input.derivativeattribute is a value reference in FMI 3.0, where FMI 2.0 had an index — the same change as the ModelStructure dependencies, and fmi4c hands the raw attribute through under its FMI 2.0 name. Read as an index,derivative="0"meant "no state" and the structure was discarded.Validated against a column-at-a-time difference quotient of the same system: the coloured Jacobian agrees to the last bit. KLU and the dense factorization give the same trajectory and the same step counts.
Three bugs found on the way
ModelStructuredependencies are value references, not indices. Both FMI 3.0 components read them the FMI 2.0 way, as one-based indices into the variable list, so the dependency graph was wired to whichever variables sat at those positions — and an FMU whose dependency names value reference 0 was rejected outright (bad dependency on variable with index 0). That is what two connected FMI 3.0 FMUs ran into here. Fixed in its own commit;ComponentFMUMEkeeps the index reading, which is correct for FMI 2.0.Causalityhad nostructuralParameter, soCausality[causality]raised aKeyErrorwhile parsingmodelDescription.xml: OMSimulator could not load any FMI 3.0 FMU with a structural parameter, nothing to do with fmi-ls-dae. Happy to split this out.SolverTypeininstantiated_model.pyduplicates the C enum's integer values. Insertingoms_solver_sc_idarenumberedoms_ma/oms_mav/oms_mav2,setSolver()quietly began selecting the wrong solver, and 37 tests failed withFailed to set solverand no hint of why. Fixed, with a comment saying the two have to be kept in step — deriving them from the C API would be better, and I did not want to widen this PR into that.fmu.pyhas a second copy,_FMU_SOLVER, which I missed at first (see the update at the top).Tested
Against a
--daeModeModel Exchange FMU exported by OpenModelica (OpenModelica/OpenModelica#16622), an index-1 DAE with an algebraic loop, a nonlinear algebraic variable and a state event:The same model was also exported as an ordinary ODE FMU and run through this same master with CVODE. The two agree to ~1e-8, state event included:
(Both differ from OpenModelica's own run of the model by ~1e-5, but identically so — that is OMSimulator's existing behaviour against omc, not something this PR introduces, which is why the comparison above is against the CVODE path rather than against omc.)
ctest: 117/117, the same as master.Open points
fmi-ls-daeis not a released layered standard yet (1.0.0-alpha.1, draft of 2026-09-02).Generated by Claude Code