Skip to content

FMI build once simulate many - #297

Merged
adrpo merged 6 commits into
OpenModelica:masterfrom
adrpo:fmi-build-once-simulate-many
Aug 11, 2026
Merged

FMI build once simulate many#297
adrpo merged 6 commits into
OpenModelica:masterfrom
adrpo:fmi-build-once-simulate-many

Conversation

@adrpo

@adrpo adrpo commented Aug 11, 2026

Copy link
Copy Markdown
Member

Fixes #78, the first task of #295: each FMU is built once and simulated with every tool the job asks for, instead of once per tool.

The duplication

The FMI jobs come in pairs. Both jobs of a pair build the same Model Exchange FMUs — about 19500 of them — and differ only in what simulates them:

OMSimulator job FMPy job compiler
v1.26-fmi v1.26-fmi-fmpy maintenance/v1.26
v1.27-fmi v1.27-fmi-fmpy maintenance/v1.27
master-fmi master-fmi-fmpy master

Building is what costs. Measured here on the twelve models of ExternData, nine of which build an FMU:

separate   164s building + 0.60s simulating   OMSimulator
           167s building + 3.09s simulating   FMPy       <- the same FMUs, rebuilt
merged     178s building + 0.74s + 2.58s      both       <- one build

48% of the run, three times over.

The three *-cs-fmu-cvode jobs are not part of this: they build Co-Simulation FMUs with --fmiFlags=s:cvode --fmuRuntimeDepends=modelica, which are different artifacts. Only the simulator may differ between results that share an FMU; anything that changes the FMU is a different job.

Using it

--fmisimulator can be given once per tool:

./test.py --branch=v1.27-fmi --fmi=true \
          --fmisimulator=/path/to/OMSimulator \
          --fmisimulator='python3 -m fmpy' \
          configs/conf.json

--branch names the job, and every simulator derives its own branch from it:

simulator branch published to
OMSimulator v1.27-fmi branches/v1.27-fmi
FMPy v1.27-fmi-fmpy branches/v1.27-fmi-fmpy

which is where those results have always been, so no report and no history has to move. The branch follows the tool, not the order, so a job given FMPy alone still fills v1.27-fmi-fmpy and leaves v1.27-fmi untouched — including where it publishes.

A branch directory looks exactly as it did before, file names included. A simulator writes <model>_<simulator>.sim in the workspace so that the tools of one job do not overwrite each other, but each branch is published from a directory where those appear under the plain <model>.sim. The .err is written by the build, so every simulator of a model publishes the same one; a copy rather than a link into another branch, since a job may fill only one of them, and 66 MB of 3.4 kB files against the 58 GB such a directory already holds is not worth the coupling.

Adding a simulator

The simulators are described in configs/fmi-simulators.json. Adding one is an entry there and no change to any script:

"fmusim": {
  "resultExtension": "csv",
  "versionArgument": "--version",
  "optionalArguments": { "stepSizeArgument": " --output-interval {stepSize:g}" },
  "arguments": "--interface-type ModelExchange --output-file {result} --start-time {startTime:g} --stop-time {stopTime:g}{stepSizeArgument} {fmu}"
}

arguments is a template over the model and the experiment; optionalArguments are the flags that have to disappear when there is nothing to put in them. That last one is not hypothetical: OMSimulator hangs on --stepSize=0 rather than ignoring it (OMSimulator#1622, found while doing this), while FMPy wants --output-interval 0 all the same. The entry for fmusim is included because it is the next tool we expect to want; it is marked untested, and asking for a simulator nobody has run yet says so rather than failing every model with a puzzling error. A Python package without a command line needs a small driver script that the entry points command at.

The README has both: how to run several simulators, and how to add one.

Jenkins

Six stages become three. The parameters keep their meaning:

  • fmi_v1_27 asks for OMSimulator, fmpy_fmi_v1_27 for FMPy
  • both ticked runs one job that builds every FMU once and simulates it with both
  • either alone runs that tool alone and fills only its table

Which simulators a job runs is now something it is told rather than something guessed from its name, but a job that says nothing still gets the tool its name implies, so the cs-fmu-cvode jobs and every job that does not test FMI are untouched. OMSimulator is only cloned and built when a job actually asks for it.

How it was checked

Everything below was run here against real FMUs, with omc 1.28.0~dev-284, OMSimulator 3.0.0~154 and FMPy 0.3.30.

  • The results are the same either way. Every model's phase, verification time, number of variables compared and number that differ is identical between a merged run and the two separate runs, for both tables.
  • All three ways of asking: OMSimulator alone fills v1.27-fmi, FMPy alone fills v1.27-fmi-fmpy, both fill the two of them from a single build — results and published directories alike.
  • The command lines are unchanged. The templates reproduce, character for character, what testmodel.py passed the two tools before, with a step size and without one.
  • The published pages resolve: 21 links, 0 missing, in both branches' pages, each holding its own simulator's .sim.

That testing earned its keep: it caught FMPy's --output-file being blanked by OMSimulator's omit-the-flag rule, models failing before the FMU exists being reported as phase 0 instead of the phase the build stopped at, omc_new captured as a local when the verification became a function, and the first simulator's branch being the name of the job rather than of the tool. Each of those would have looked like a mass regression in one of the tables.

Before it runs for real


Generated by Claude Code.

adrpo added 6 commits August 11, 2026 14:55
First step of OpenModelica#78: the FMI jobs come in pairs - v1.26-fmi and v1.26-fmi-fmpy,
v1.27-fmi and v1.27-fmi-fmpy, master-fmi and master-fmi-fmpy - and both jobs of
a pair build the same Model Exchange FMUs, some 19500 of them, only to simulate
them with a different tool. Measured on ExternData here: 164 seconds building
the FMUs and 0.6 simulating them with OMSimulator, then another 167 seconds
building the very same FMUs to simulate them for 3 seconds with FMPy.

--fmisimulator can now be repeated, as "name=command" or as the bare command it
has always been, so that one job can simulate every FMU with several tools
after building it once.

The FMU can only be shared when everything that goes into it is the same - the
same compiler, the same library and the same build flags - so what a merged job
varies is the simulator and nothing else. That is why the three cs-fmu-cvode
jobs stay as they are: their FMUs are Co-Simulation ones built with
--fmiFlags=s:cvode, not the same artifact at all.

--branch now names the job rather than the table, and every simulator derives
its own from it: OMSimulator keeps the plain v1.27-fmi it has always had, FMPy
fills v1.27-fmi-fmpy, PyFMI would fill v1.27-fmi-pyfmi. The mapping is keyed by
the simulator and not by the order it was given in, so that a job asked for
FMPy alone still writes to v1.27-fmi-fmpy instead of taking over v1.27-fmi.
No report and no history has to move.

A job that passes a single simulator behaves as it always did, and the version
of every tool is now read rather than only the first one's.

This only adds the option and the naming rules; testmodel.py still simulates
with one tool, which the next commit changes.

---
Generated by Claude Code.

Signed-off-by: Adrian Pop <adrian.pop@liu.se>
Adding a simulator to the testing was a case in testmodel.py, one in the
version check and one in the choice of result file. It is now an entry in
configs/fmi-simulators.json and nothing else:

  "fmusim": {
    "untested": true,
    "resultExtension": "csv",
    "versionArgument": "--version",
    "stepSizeArgument": " --output-interval {stepSize:g}",
    "arguments": "--interface-type ModelExchange --output-file {result} ..."
  }

An entry says how the tool is invoked, what it writes, how it prints its
version, how it spells a step size and which table it fills. The arguments are
a template formatted with the model and the experiment, so a tool with
different flags needs no code, and a Python package without a command line
needs a driver script that the entry then points "command" at.

stepSizeArgument is a key of its own because the tools disagree about an
experiment that has no step size: OMSimulator leaves the flag out, while FMPy
passes --output-interval 0 all the same. A tool of the second kind writes
{stepSize:g} into its arguments and needs no stepSizeArgument at all.

The entry for fmusim is there because it is the next one we expect to want. It
is marked untested, and asking for a simulator nobody has run yet says so
rather than failing every model with a puzzling error.

Checked against the format strings testmodel.py uses today: both tools are
given exactly the command line they were given before, with a step size and
without one.

---
Generated by Claude Code.

Signed-off-by: Adrian Pop <adrian.pop@liu.se>
The heart of OpenModelica#78. testmodel.py builds the FMU as before and then runs every
simulator the job was given on it, and test.py writes one row per simulator
into the branch of that simulator. A job given OMSimulator and FMPy on
--branch=v1.27-fmi therefore fills v1.27-fmi and v1.27-fmi-fmpy from a single
build, which is what the two jobs of a pair used to do twice.

Measured on ExternData, 12 models of which 9 build an FMU:

  separate   164s building + 0.60s simulating   OMSimulator
             167s building + 3.09s simulating   FMPy
  merged     178s building + 0.74s + 2.58s      both

so the FMUs are built once instead of twice and the run costs about half.

The results are the same either way: every model's phase, verification time,
number of variables compared and number that differ is identical to what the
two separate runs produced, for both tables.

The verification of a result against its reference file became a function that
returns rather than one that ends the run, so that the next simulator of the
same FMU can be verified too, and omc is now quit once all of them are done
rather than after the first. A simulator that fails or times out records its
own failure and leaves the others alone; the first one keeps ending the model
as it always has, so put the tool you trust most first.

A model that fails before its FMU exists reports the phase the build stopped at
for every simulator, rather than pretending each of them failed.

---
Generated by Claude Code.

Signed-off-by: Adrian Pop <adrian.pop@liu.se>
The .err and .sim files of a run are collected per library and rsynced to a
directory named after the branch under libraries.openmodelica.org/branches. A
job that runs several simulators has one set of results per simulator, so it
now does that once per branch: v1.27-fmi and v1.27-fmi-fmpy each get their own
directory, their own library page and their own files, as they did when two
jobs produced them.

A branch directory looks exactly as it did before, down to the file names. A
simulator writes <model>_<simulator>.sim in the workspace so that the tools of
one job do not overwrite each other, but each branch is published from a
directory of its own where those appear under the plain <model>.sim that has
always been there. The links are hard, not symbolic, so rsync sees ordinary
files and nothing is copied twice on disk; where that is not possible, across
file systems, the file is copied.

The .err is written by the build and is therefore the same for every simulator
of a model, and each branch gets a copy rather than a link into another branch:
a job asked for FMPy alone publishes no v1.27-fmi to link into, and a branch
that is re-run must not break the pages of another. It costs about 66 MB of
3.4 kB files against the 58 GB such a directory already holds.

The report of a library is generated from the results of the simulator whose
branch it belongs to, so its phases, times and links describe that simulator
and not the first one.

The two ways of publishing disagreed about --output and still do: the rsync
path is given the directory of the branch, the --noSync path the directory the
branches live in. Both now derive the directory of each simulator the way they
already derived the one of the job.

---
Generated by Claude Code.

Signed-off-by: Adrian Pop <adrian.pop@liu.se>
The last part of OpenModelica#78. The three pairs of FMI jobs - v1.26-fmi and
v1.26-fmi-fmpy, v1.27-fmi and v1.27-fmi-fmpy, master-fmi and master-fmi-fmpy -
are one job each now. Six stages become three, and the FMUs of a release are
built once instead of twice.

The parameters keep their meaning: fmi_v1_27 asks for OMSimulator, fmpy_fmi_v1_27
for FMPy, and ticking both runs one job that builds every FMU once and
simulates it with both, filling v1.27-fmi and v1.27-fmi-fmpy as the two jobs
did. Ticking one runs that tool alone and fills only its table, and its results
and files still go where they always went: a job given FMPy alone stores them
in v1.27-fmi-fmpy and publishes them to branches/v1.27-fmi-fmpy, not to the
branch it was started with.

Which simulators a job runs is now something it is told rather than something
guessed from its name, but a job that says nothing still gets the tool its name
implies, so the cs-fmu-cvode jobs and every job that does not test FMI are
untouched. OMSimulator is only cloned and built when a job actually asks for
it.

Checked here on ExternData, for all three ways of asking: OMSimulator alone
fills v1.27-fmi, FMPy alone fills v1.27-fmi-fmpy, both fill the two of them
from a single build, and the results of a merged run are the same as those of
the two separate runs.

---
Generated by Claude Code.

Signed-off-by: Adrian Pop <adrian.pop@liu.se>
Two sections in the README: how to run a job with more than one FMI simulator,
what each of them fills and where it is published, and what may differ between
results that share an FMU; and how to add a simulator, which is an entry in
configs/fmi-simulators.json and no change to any script.

The description of every key of an entry is there, including why a step size
flag may have to disappear rather than be passed empty, and what a Python
package without a command line needs.

---
Generated by Claude Code.

Signed-off-by: Adrian Pop <adrian.pop@liu.se>
@adrpo
adrpo merged commit cbd3912 into OpenModelica:master Aug 11, 2026
7 checks passed
adrpo added a commit that referenced this pull request Aug 12, 2026
A library whose reference files are already prepared by another library took a
`continue` that skipped the rest of the configuration loop - and the only thing
after it is the line that turns FMI on:

    if allTestsFmi:
      c["fmi"] = "2.0"

So four libraries of configs/conf.json have been running as ordinary native
models inside the FMI jobs: PowerGrids_symb_jac, PowerGrids_dev, ClaRa_dev and
ScalableTestSuite_noopt, each of them the second library to want a reference
directory that PowerGrids, ClaRa and ScalableTestSuite had already cloned. They
verify identically in master and in master-fmi - 66, 19, 72 and 244 models -
while every library that prepares its own directory drops as an FMI job should
(242 to 176 for ScalableTestSuite, 65 to 31 for PowerGrids).

The reuse also looked the directory up under a key it had not computed yet, so
it took whatever `destination` the previous library left behind. Both are fixed
by computing the normalised destination first and putting the preparation in an
else branch, which leaves nothing after the loop's body unreachable.

Since #297 this crashed the run rather than quietly mistesting it. Such a model
has no per-simulator results, so the second simulator's branch reported the
phase the *first* simulator reached - 7, verified - with no comparison to go
with it, and building the report died on it after seven hours:

    ("%s (%d verified)" % (timeSeconds(diff.get("time")), ...)) if s[3]["phase"]>=7
    AttributeError: 'NoneType' object has no attribute 'get'

A simulator that never ran a model now reports the phase the shared build
reached and never a phase another simulator went on to reach with the same FMU,
in the report and in the database row alike; and a model with nothing to compare
renders as an empty cell whatever phase it claims, so a mismatch can never again
throw away a finished run.

Fixes the master-fmi failure of build 11369.
adrpo added a commit that referenced this pull request Aug 12, 2026
An FMI job that runs several simulators over one FMU (#297, #309) gives the
first of them a veto over the rest. When it fails, the TimeoutError handler
calls writeResultAndExit, the process is gone, and the loop that runs
fmisimulators[1:] never starts -- but a row is still written for every
simulator, recording the phase the first one stopped at. The other tools are
marked as having failed at a model they were never given.

checkOutputTimeout raises TimeoutError for a command that exits non-zero as
well as for one that runs out of time, so this covers an ordinary failure and
not just a slow one.

In the master-fmi run of 2026-08-12 this cost 433 models. They had never
simulated under OMSimulator -- phase 5 in every master-fmi run back to
2026-08-08 -- but FMPy ran them, and the previous master-fmi-fmpy run has them
at phase 6 or 7. In the first combined run they are phase 5 in both tables,
with FMPy's simulation time recorded as exactly 0: OMSimulator failed in under
a second for 344 of them, and FMPy never started. They show up as 433 of the
653 regressions on the comparison page, next to the 213 that PR #309 really did
find. It is also why that page reports OMSimulator's version, and its JSON
parse warning, in place of "FMPy version 0.3.29": FMPy never ran to say what it
was.

The first simulator now records its failure and falls through to the others,
which is what every simulator after it already did. Nothing changes when only
one simulator is configured: that case still ends the model where it always
has, so the non-FMI paths and the single-simulator FMI jobs are untouched.

Verified on MyLibrary.Blocks.Examples.PID_Controller with the first simulator
replaced by /bin/false:

  before   phase 5, "simulators": {}
  after    phase 5, "simulators": {"fmpy": {"sim": 0.64, "phase": 7}}

and, unchanged in both:

  one failing simulator     phase 5, os._exit as before
  both simulators working   OMSimulator phase 7, fmpy phase 7


Claude-Session: https://claude.ai/code/session_01MYvMaAotMy425H7nvKzWX7

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

Optimize FMI testing

1 participant