From 0b558cc703a50168298b7fc91bd012928bd0dad2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Sj=C3=B6lund?= Date: Fri, 30 Oct 2026 15:14:23 +0100 Subject: [PATCH] Run me_cs FMUs with OMSimulator CS CVODE and Euler too The FMI job builds every FMU once as me_cs and simulates it four ways: OMSimulator and FMPy through Model Exchange as before, and OMSimulator through Co-Simulation with CVODE and with Euler, filling -fmi-cs-cvode and -fmi-cs-euler. The C runtime reads its Co-Simulation solver from resources/_flags.json when the FMU is instantiated, so switching solver needs no new build: an entry of fmi-simulators.json with fmiFlags runs a copy of the FMU whose flags file holds those flags. Asking for s:cvode in any entry builds the FMU with --fmiFlags=s:cvode, which is what links CVODE into it. Neither OMSimulator nor FMPy can be given an extracted FMU directory instead, OMSimulator always unzips it itself. An entry's interface is passed as OMSimulator --mode only for an me_cs FMU: OMSimulator defaults to Co-Simulation for those, and refuses --mode for an FMU exporting one interface, which the cs-fmu-cvode jobs build. Assisted-by: Claude Opus 5.5 --- .CI/Jenkinsfile | 27 ++++++++++++---------- README.md | 42 ++++++++++++++++++++++++++++------ configs/fmi-simulators.json | 42 +++++++++++++++++++++++++++++++--- test.py | 2 +- testmodel.py | 45 +++++++++++++++++++++++++++++-------- 5 files changed, 126 insertions(+), 32 deletions(-) diff --git a/.CI/Jenkinsfile b/.CI/Jenkinsfile index 8c07913..099aa1e 100644 --- a/.CI/Jenkinsfile +++ b/.CI/Jenkinsfile @@ -7,17 +7,17 @@ pipeline { booleanParam(name: 'v1_27', defaultValue: false, description: 'maintenance/v1.27 branch (ryzen-5950x-1)') booleanParam(name: 'master', defaultValue: false, description: 'master branch, simulated with the solver each model asks for, filling master (ryzen-9950x).') - booleanParam(name: 'fmi_v1_26', defaultValue: false, description: 'maintenance/v1.26 branch with FMI, simulated by OMSimulator, filling v1.26-fmi (ryzen-5950x-2). Ticking it together with fmpy_fmi_v1_26 runs one job that builds every FMU once and simulates it with both.') - booleanParam(name: 'fmi_v1_27', defaultValue: false, description: 'maintenance/v1.27 branch with FMI, simulated by OMSimulator, filling v1.27-fmi (ryzen-5950x-2). Ticking it together with fmpy_fmi_v1_27 runs one job that builds every FMU once and simulates it with both.') - booleanParam(name: 'fmi_master', defaultValue: false, description: 'master branch with FMI, simulated by OMSimulator, filling master-fmi (ryzen-5950x-2). Ticking it together with fmpy_fmi_master runs one job that builds every FMU once and simulates it with both.') + booleanParam(name: 'fmi_v1_26', defaultValue: false, description: 'maintenance/v1.26 branch with FMI, simulated by OMSimulator: Model Exchange filling v1.26-fmi, Co-Simulation with CVODE filling v1.26-fmi-cs-cvode and with Euler filling v1.26-fmi-cs-euler (ryzen-5950x-2). Ticking it together with fmpy_fmi_v1_26 runs one job that builds every FMU once and simulates it with all of them.') + booleanParam(name: 'fmi_v1_27', defaultValue: false, description: 'maintenance/v1.27 branch with FMI, simulated by OMSimulator: Model Exchange filling v1.27-fmi, Co-Simulation with CVODE filling v1.27-fmi-cs-cvode and with Euler filling v1.27-fmi-cs-euler (ryzen-5950x-2). Ticking it together with fmpy_fmi_v1_27 runs one job that builds every FMU once and simulates it with all of them.') + booleanParam(name: 'fmi_master', defaultValue: false, description: 'master branch with FMI, simulated by OMSimulator: Model Exchange filling master-fmi, Co-Simulation with CVODE filling master-fmi-cs-cvode and with Euler filling master-fmi-cs-euler (ryzen-5950x-2). Ticking it together with fmpy_fmi_master runs one job that builds every FMU once and simulates it with all of them.') booleanParam(name: 'cs_fmu_cvode_v1_26', defaultValue: false, description: 'maintenance/v1.26 branch with CVODE CS FMUs running OMSimulator (ryzen-5950x-2)') booleanParam(name: 'cs_fmu_cvode_v1_27', defaultValue: false, description: 'maintenance/v1.27 branch with CVODE CS FMUs running OMSimulator (ryzen-5950x-2)') booleanParam(name: 'cs_fmu_cvode_master', defaultValue: false, description: 'master branch with CVODE CS FMUs running OMSimulator (ryzen-5950x-2)') - booleanParam(name: 'fmpy_fmi_v1_26', defaultValue: false, description: 'maintenance/v1.26 branch with FMI, simulated by FMPy, filling v1.26-fmi-fmpy (ryzen-5950x-2). Ticking it together with fmi_v1_26 runs one job that builds every FMU once and simulates it with both.') - booleanParam(name: 'fmpy_fmi_v1_27', defaultValue: false, description: 'maintenance/v1.27 branch with FMI, simulated by FMPy, filling v1.27-fmi-fmpy (ryzen-5950x-2). Ticking it together with fmi_v1_27 runs one job that builds every FMU once and simulates it with both.') - booleanParam(name: 'fmpy_fmi_master', defaultValue: false, description: 'master branch with FMI, simulated by FMPy, filling master-fmi-fmpy (ryzen-5950x-2). Ticking it together with fmi_master runs one job that builds every FMU once and simulates it with both.') + booleanParam(name: 'fmpy_fmi_v1_26', defaultValue: false, description: 'maintenance/v1.26 branch with FMI, simulated by FMPy, filling v1.26-fmi-fmpy (ryzen-5950x-2). Ticking it together with fmi_v1_26 runs one job that builds every FMU once and simulates it with all of them.') + booleanParam(name: 'fmpy_fmi_v1_27', defaultValue: false, description: 'maintenance/v1.27 branch with FMI, simulated by FMPy, filling v1.27-fmi-fmpy (ryzen-5950x-2). Ticking it together with fmi_v1_27 runs one job that builds every FMU once and simulates it with all of them.') + booleanParam(name: 'fmpy_fmi_master', defaultValue: false, description: 'master branch with FMI, simulated by FMPy, filling master-fmi-fmpy (ryzen-5950x-2). Ticking it together with fmi_master runs one job that builds every FMU once and simulates it with all of them.') booleanParam(name: 'newInst_daeMode', defaultValue: false, description: 'master branch, --daeMode with -d=newInst (ryzen-5950x-2)') booleanParam(name: 'newInst_newBackend', defaultValue: false, description: 'master branch, -d=newInst --newBackend, (ryzen-5950x-1)') @@ -549,7 +549,7 @@ pipeline { } environment { GITBRANCHES = 'maintenance/v1.20 maintenance/v1.21 maintenance/v1.22 maintenance/v1.23 maintenance/v1.24 maintenance/v1.25 maintenance/v1.26 maintenance/v1.27 master newInst-newBackend' - GITBRANCHES_FMI = 'maintenance/v1.20-fmi maintenance/v1.21-fmi maintenance/v1.22-fmi maintenance/v1.23-fmi maintenance/v1.24-fmi maintenance/v1.25-fmi maintenance/v1.26-fmi maintenance/v1.27-fmi maintenance/v1.22-fmi-fmpy maintenance/v1.23-fmi-fmpy maintenance/v1.24-fmi-fmpy maintenance/v1.25-fmi-fmpy maintenance/v1.26-fmi-fmpy maintenance/v1.27-fmi-fmpy master-fmi master-fmi-fmpy' + GITBRANCHES_FMI = 'maintenance/v1.20-fmi maintenance/v1.21-fmi maintenance/v1.22-fmi maintenance/v1.23-fmi maintenance/v1.24-fmi maintenance/v1.25-fmi maintenance/v1.26-fmi maintenance/v1.27-fmi maintenance/v1.22-fmi-fmpy maintenance/v1.23-fmi-fmpy maintenance/v1.24-fmi-fmpy maintenance/v1.25-fmi-fmpy maintenance/v1.26-fmi-fmpy maintenance/v1.27-fmi-fmpy master-fmi master-fmi-fmpy maintenance/v1.26-fmi-cs-cvode maintenance/v1.27-fmi-cs-cvode master-fmi-cs-cvode maintenance/v1.26-fmi-cs-euler maintenance/v1.27-fmi-cs-euler master-fmi-cs-euler' GITBRANCHES_NEWINST = 'oldInst' GITBRANCHES_DAE = 'newInst-daeMode' GITBRANCHES_NEWBACKEND_DAE = 'newBackend-daeMode' @@ -917,11 +917,11 @@ def buildsWithCMake(branch) { * omc stay outside. Passing `''` runs the job on the node itself. */ /* The FMI simulators a job runs, from the parameters that used to start one job each. - * Both ticked is one job that builds every FMU once and simulates it with both, - * filling -fmi and -fmi-fmpy exactly as the two jobs did. */ + * Both ticked is one job that builds every FMU once and simulates it with all of them, + * filling -fmi, -fmi-cs-cvode, -fmi-cs-euler and -fmi-fmpy. */ def fmiSimulators(boolean omsimulator, boolean fmpy) { def simulators = [] - if (omsimulator) simulators << 'OMSimulator' + if (omsimulator) simulators += ['OMSimulator', 'OMSimulator-cs-cvode', 'OMSimulator-cs-euler'] if (fmpy) simulators << 'fmpy' return simulators } @@ -1010,7 +1010,8 @@ def runRegressiontest(branch, name, extraFlags, omsHash, omcompiler, extrasimfla } FMI_TESTING_FLAG = "" SOLVER_FLAG = (solvers ?: []).collect { " --solver=${it}" }.join('') - if (simulators.contains('OMSimulator') && omsHash) { + def omsSimulators = simulators.findAll { it.startsWith('OMSimulator') } + if (omsSimulators && omsHash) { // In the image rather than on the node: test.py runs this binary from inside it, and one built // against the node's libraries need not load there. runSh(""" @@ -1048,7 +1049,7 @@ def runRegressiontest(branch, name, extraFlags, omsHash, omcompiler, extrasimfla echo OMSimulator version: ${env.HOME}/saved_omc/OMSimulator/install/bin/OMSimulator --version """) - FMI_TESTING_FLAG = " --fmisimulator=${env.HOME}/saved_omc/OMSimulator/install/bin/OMSimulator" + FMI_TESTING_FLAG = omsSimulators.collect { " --fmisimulator=${it}=${env.HOME}/saved_omc/OMSimulator/install/bin/OMSimulator" }.join('') } if (simulators.contains('fmpy')) { @@ -1062,6 +1063,8 @@ def runRegressiontest(branch, name, extraFlags, omsHash, omcompiler, extrasimfla FMI_TESTING_FLAG = "--fmi=true${FMI_TESTING_FLAG} --default=ulimitExe=50" if (name.contains('cvode')) { FMI_TESTING_FLAG += " --fmuType=cs" + } else if (fmiSimulators != null) { + FMI_TESTING_FLAG += " --fmuType=me_cs" } } diff --git a/README.md b/README.md index 6686277..8c320d9 100644 --- a/README.md +++ b/README.md @@ -269,12 +269,33 @@ are the ones that simulator produced. Only the simulator may differ between results that share an FMU. Anything that changes the FMU itself - a different compiler, a different library, a different -`--fmuType` or `--fmiFlags` - is a different job, which is why the Co-Simulation -jobs with CVODE are not merged with the Model Exchange ones. +`--fmuType` or `--fmiFlags` - is a different job. -In Jenkins the parameters keep their meaning: `fmi_v1_27` asks for OMSimulator -and `fmpy_fmi_v1_27` for FMPy. Ticking both runs one job that builds every FMU -once and simulates it with both; ticking one runs that tool alone. +The one exception is the Co-Simulation solver of the C runtime. An FMU reads it +from `resources/_flags.json` when it is instantiated, so an entry with +`fmiFlags` runs a copy of the FMU with that file replaced. Build the FMU with +`--fmuType=me_cs` and one build covers both interfaces and both solvers: + +```bash +./test.py --branch=v1.27-fmi --fmi=true --fmuType=me_cs \ + --fmisimulator=OMSimulator=/path/to/OMSimulator \ + --fmisimulator=OMSimulator-cs-cvode=/path/to/OMSimulator \ + --fmisimulator=OMSimulator-cs-euler=/path/to/OMSimulator \ + --fmisimulator='python3 -m fmpy' \ + configs/myConf.json +``` + +| simulator | interface | branch | +| --- | --- | --- | +| `OMSimulator` | Model Exchange | `v1.27-fmi` | +| `OMSimulator-cs-cvode` | Co-Simulation, CVODE | `v1.27-fmi-cs-cvode` | +| `OMSimulator-cs-euler` | Co-Simulation, Euler | `v1.27-fmi-cs-euler` | +| `fmpy` | Model Exchange | `v1.27-fmi-fmpy` | + +In Jenkins the parameters keep their meaning: `fmi_v1_27` asks for the three +OMSimulator runs and `fmpy_fmi_v1_27` for FMPy, and either builds `me_cs` FMUs. +Ticking both runs one job that builds every FMU once and simulates it with all +four; ticking one runs those tools alone. ### Adding an FMI simulator @@ -302,8 +323,15 @@ Adding one is an entry there and no change to any script: - `resultExtension` is what the tool writes, so that the comparison against the reference file knows what to read. - `versionArgument` prints the version, which is recorded with the results. -- `branchSuffix` overrides the `-` a tool adds to the branch. Only - OMSimulator needs it, with `""`. +- `branchSuffix` overrides the `-` a tool adds to the branch. OMSimulator + has `""`. +- `interface`, `me` or `cs`, is the interface the tool runs of an `me_cs` FMU, + available to the templates as `{interface}`. It is empty for an FMU with one + interface, which the tool then runs. +- `fmiFlags` makes the tool run a copy of the FMU whose + `resources/_flags.json` holds these flags. `"s": "cvode"` in any entry + of the job builds the FMU with `--fmiFlags=s:cvode`, which links CVODE into it; + the Euler solver needs nothing linked. - `untested` marks an entry nobody has run yet; the run then says so instead of failing every model with a puzzling error. Remove it once it works. diff --git a/configs/fmi-simulators.json b/configs/fmi-simulators.json index 56cb39e..4ab7e86 100644 --- a/configs/fmi-simulators.json +++ b/configs/fmi-simulators.json @@ -15,20 +15,56 @@ "resultExtension what the tool writes, so the verification knows what to compare.", "versionArgument the flag that prints the version, recorded with the results.", "branchSuffix appended to --branch to get the table of this simulator; the default is", - " -, so OMSimulator is the only one that needs to say anything here.", + " -.", + "interface me or cs: which interface the tool runs of an FMU built with --fmuType=me_cs,", + " available to the templates as {interface}. It is empty for an FMU exporting", + " one interface, which the tool then runs; OMSimulator refuses --mode for it.", + "fmiFlags what the tool runs is a copy of the FMU whose resources/_flags.json", + " holds these flags instead, which is how the Co-Simulation solver of the C", + " runtime is picked without building the FMU again. s:cvode in any of them", + " builds the FMU with --fmiFlags=s:cvode, which links CVODE into it.", "untested set it while nobody has run the entry yet; the run then says so." ], "OMSimulator": { "branchSuffix": "", + "interface": "me", "resultExtension": "mat", "versionArgument": "--version", - "arguments": "{resultArgument} --stripRoot --tempDir={tempDir} --startTime={startTime:g} --stopTime={stopTime:g}{stepSizeArgument} --timeout={timeout:.0f} --tolerance={tolerance:g} {fmu}", + "arguments": "{resultArgument} --stripRoot --tempDir={tempDir} --startTime={startTime:g} --stopTime={stopTime:g}{stepSizeArgument}{modeArgument} --timeout={timeout:.0f} --tolerance={tolerance:g} {fmu}", "optionalArguments": { "resultArgument": "--resultFile={requestedResult}", - "stepSizeArgument": " --stepSize={stepSize:g}" + "stepSizeArgument": " --stepSize={stepSize:g}", + "modeArgument": " --mode={interface}" + } + }, + "OMSimulator-cs-cvode": { + "branchSuffix": "-cs-cvode", + "interface": "cs", + "fmiFlags": {"s": "cvode"}, + "resultExtension": "mat", + "versionArgument": "--version", + "arguments": "{resultArgument} --stripRoot --tempDir={tempDir} --startTime={startTime:g} --stopTime={stopTime:g}{stepSizeArgument}{modeArgument} --timeout={timeout:.0f} --tolerance={tolerance:g} {fmu}", + "optionalArguments": { + "resultArgument": "--resultFile={requestedResult}", + "stepSizeArgument": " --stepSize={stepSize:g}", + "modeArgument": " --mode={interface}" + } + }, + "OMSimulator-cs-euler": { + "branchSuffix": "-cs-euler", + "interface": "cs", + "fmiFlags": {"s": "euler"}, + "resultExtension": "mat", + "versionArgument": "--version", + "arguments": "{resultArgument} --stripRoot --tempDir={tempDir} --startTime={startTime:g} --stopTime={stopTime:g}{stepSizeArgument}{modeArgument} --timeout={timeout:.0f} --tolerance={tolerance:g} {fmu}", + "optionalArguments": { + "resultArgument": "--resultFile={requestedResult}", + "stepSizeArgument": " --stepSize={stepSize:g}", + "modeArgument": " --mode={interface}" } }, "fmpy": { + "interface": "me", "resultExtension": "csv", "versionArgument": "--version", "command": "{simulator} simulate", diff --git a/test.py b/test.py index 2e4efab..e52d72e 100755 --- a/test.py +++ b/test.py @@ -26,7 +26,7 @@ parser.add_argument('configs', nargs='*') parser.add_argument('--branch', default='master') parser.add_argument('--fmi', default=False) -parser.add_argument('--fmuType', default='me', help="FMU type: 'me' for Model Exchange, 'cs' for Co-Simulation.") +parser.add_argument('--fmuType', default='me', help="FMU type: 'me' for Model Exchange, 'cs' for Co-Simulation, 'me_cs' for both; the interface of configs/fmi-simulators.json then says which one a simulator runs.") parser.add_argument('--output', default='') parser.add_argument('--docker', default='') parser.add_argument('--libraries', help="Directory omc will search in to load system libraries/libraries to test.", default='') diff --git a/testmodel.py b/testmodel.py index 9ab087a..6f7843b 100755 --- a/testmodel.py +++ b/testmodel.py @@ -12,7 +12,7 @@ from monotonic import monotonic import OMPython from OMPython import FindBestOMCSession, OMCSession, OMCSessionZMQ -import shared, glob +import shared, glob, zipfile parser = argparse.ArgumentParser(description='OpenModelica library testing tool helper (single model)') parser.add_argument('config') @@ -713,6 +713,20 @@ def simulateCmd(resimulate): # TODO: Detect and handle the case where RT_CLOCK is not available in OMC total_before = omc.sendExpression("OpenModelica.Scripting.Internal.Time.timerTock(OpenModelica.Scripting.Internal.Time.RT_CLOCK_SIMULATE_TOTAL)") + +# The FMU is built once and simulated with every tool the job asked for, so +# that testing FMPy no longer means building the same FMU a second time. The +# tools are described in configs/fmi-simulators.json. +fmisimulator = conf.get("fmisimulator") +fmisimulators = shared.parseFmiSimulators(conf.get("fmisimulators")) if conf.get("fmi") else [] +if conf.get("fmi") and not fmisimulators and fmisimulator: + fmisimulators = shared.parseFmiSimulators([fmisimulator]) + +if conf.get("fmi") and any(shared.fmiSimulator(name).get("fmiFlags", {}).get("s") == "cvode" for (name, _) in fmisimulators): + with open(errFile, 'a+') as fp: + fp.write("Running command: setCommandLineOptions(\"--fmiFlags=s:cvode\")\n") + sendExpressionOldOrNew('setCommandLineOptions("--fmiFlags=s:cvode")') + start=monotonic() timeout = conf["ulimitOmc"] # If the command has to be killed there is no way to tell which phase it was in, @@ -847,13 +861,6 @@ def simulateCmd(resimulate): writeResult() # Do the simulation -# The FMU is built once and simulated with every tool the job asked for, so -# that testing FMPy no longer means building the same FMU a second time. The -# tools are described in configs/fmi-simulators.json. -fmisimulator = conf.get("fmisimulator") -fmisimulators = shared.parseFmiSimulators(conf.get("fmisimulators")) if conf.get("fmi") else [] -if conf.get("fmi") and not fmisimulators and fmisimulator: - fmisimulators = shared.parseFmiSimulators([fmisimulator]) wasmfmurunners = shared.parseWasmFmuRunners(conf.get("wasmfmurunners")) if useWasmFmu else [] # The model is built once and its executable run once per solver; the solvers are # described in configs/solvers.json. @@ -864,6 +871,25 @@ def simulateCmd(resimulate): # several ways and a model run by several solvers fan out the same way. runners = fmisimulators or wasmfmurunners or solverRunners +def fmuWithFlags(name): + """The FMU a simulator runs: the one built, or a copy whose flags file holds + the fmiFlags of its entry, from which the C runtime picks its CS solver.""" + prefix = conf["fileName"].replace(".","_") + fmu = "%s.fmu" % prefix + flags = shared.fmiSimulator(name).get("fmiFlags") + if not flags: + return fmu + copy = "%s_%s.fmu" % (prefix, name) + flagsFile = "resources/%s_flags.json" % prefix + with zipfile.ZipFile(fmu) as src, zipfile.ZipFile(copy, "w") as dst: + for item in src.infolist(): + if item.filename != flagsFile: + dst.writestr(item, src.read(item)) + dst.writestr(flagsFile, "{\n%s\n}\n" % ",\n".join(' "%s" : "%s"' % kv for kv in flags.items()), zipfile.ZIP_DEFLATED) + return copy + +fmus = dict((name, fmuWithFlags(name)) for (name, _) in fmisimulators) + def runnerSuffix(name): """What tells the files of one runner from those of another. The first one publishes the workspace itself, so its files keep the plain names.""" @@ -915,7 +941,8 @@ def simulateFmu(name, command, resFile, simFile): with open("%s.tmpfiles" % conf["fileName"], "a+") as fp: fp.write("%s\n" % (os.path.join(runDir, fmitmpdir) if runDir else fmitmpdir)) cmd = shared.fmiSimulatorCommand(name, command, - fmu=fromRunnerDir(runDir, "%s.fmu" % conf["fileName"].replace(".","_")), + fmu=fromRunnerDir(runDir, fmus[name]), + interface=shared.fmiSimulator(name).get("interface", "") if conf["fmuType"] == "me_cs" else "", result=fromRunnerDir(runDir, resFile), requestedResult=fromRunnerDir(runDir, resFile) if outputFormat != "empty" else "", tempDir=fmitmpdir, startTime=startTime, stopTime=stopTime,