feat: add FMI validation framework with reference model testing (#62) · modelscript/modelscript@15b5ef6 · GitHub
Skip to content

Commit 15b5ef6

Browse files
authored
feat: add FMI validation framework with reference model testing (#62)
1 parent 69c139f commit 15b5ef6

41 files changed

Lines changed: 3730 additions & 702 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/ci.yml‎

Lines changed: 6 additions & 0 deletions

‎apps/cli/src/commands/fmu.ts‎

Lines changed: 51 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// SPDX-License-Identifier: AGPL-3.0-or-later
22

3+
import { initBltWasm } from "@modelscript/compiler";
34
import { ArenaSimulator } from "@modelscript/compiler/simulator";
45
import { Context } from "@modelscript/core";
56
import {
@@ -9,11 +10,9 @@ import {
910
FMI2_TYPES_PLATFORM_H,
1011
buildFmuArchive,
1112
compileToWasm,
12-
createZip,
1313
generateFmu,
1414
generateFmuCSources,
1515
generateFmuWasmSource,
16-
serializeArenaToJson,
1716
} from "@modelscript/fmi";
1817
import Modelica from "@modelscript/modelica/parser";
1918
import { execSync } from "node:child_process";
@@ -146,6 +145,8 @@ export const Fmu: CommandModule<{}, FmuArgs> = {
146145
return;
147146
}
148147

148+
await initBltWasm();
149+
149150
// Prepare simulator to get state variable info
150151
const simulator = new ArenaSimulator(arena);
151152
simulator.prepare();
@@ -200,6 +201,8 @@ export const Fmu: CommandModule<{}, FmuArgs> = {
200201
}
201202

202203
// ── Full FMU archive mode ──
204+
const fmiVersionStr =
205+
String(args["fmi-version"]) === "3" ? "3" : String(args["fmi-version"]) === "2" ? "2" : "both";
203206
const archiveOptions: FmuArchiveOptions = {
204207
modelIdentifier,
205208
description: args.description,
@@ -210,7 +213,7 @@ export const Fmu: CommandModule<{}, FmuArgs> = {
210213
fmuType,
211214
includeSources: args.source !== false,
212215
includeModelJson: true,
213-
fmiVersion: args["fmi-version"] as "2" | "3" | "both",
216+
fmiVersion: fmiVersionStr,
214217
};
215218

216219
profiler.start("codegen");
@@ -251,29 +254,41 @@ export const Fmu: CommandModule<{}, FmuArgs> = {
251254
fs.mkdirSync(srcDir, { recursive: true });
252255
fs.writeFileSync(path.join(srcDir, `${modelIdentifier}_model.h`), sources.modelH);
253256
fs.writeFileSync(path.join(srcDir, `${modelIdentifier}_model.c`), sources.modelC);
254-
fs.writeFileSync(path.join(srcDir, "fmi2Functions.c"), sources.fmi2FunctionsC);
257+
const fmi2c = path.join(srcDir, "fmi2Functions.c");
258+
const fmi3c = path.join(srcDir, "fmi3Functions.c");
259+
if (archiveOptions.fmiVersion === "2" || archiveOptions.fmiVersion === "both") {
260+
fs.writeFileSync(fmi2c, sources.fmi2FunctionsC);
261+
}
262+
if (archiveOptions.fmiVersion === "3" || archiveOptions.fmiVersion === "both") {
263+
fs.writeFileSync(fmi3c, sources.fmi3FunctionsC);
264+
}
255265

256266
// Write FMI headers from archive
257267
const encoder = new TextEncoder();
258268
for (const [name, content] of Object.entries(FMI_HEADERS)) {
259269
fs.writeFileSync(path.join(srcDir, name), encoder.encode(content));
260270
}
261271

262-
// Detect platform
263-
const arch = os.arch() === "x64" ? "64" : "32";
264-
const plat =
272+
const arch2 = os.arch() === "x64" ? "64" : "32";
273+
const plat2 =
265274
process.platform === "win32"
266-
? `win${arch}`
275+
? `win${arch2}`
267276
: process.platform === "darwin"
268-
? `darwin${arch}`
269-
: `linux${arch}`;
277+
? `darwin${arch2}`
278+
: `linux${arch2}`;
279+
const arch3 = os.arch() === "x64" ? "x86_64" : os.arch() === "arm64" ? "aarch64" : "x86";
280+
const plat3 =
281+
process.platform === "win32"
282+
? `${arch3}-windows`
283+
: process.platform === "darwin"
284+
? `${arch3}-darwin`
285+
: `${arch3}-linux`;
270286
const ext = process.platform === "win32" ? ".dll" : process.platform === "darwin" ? ".dylib" : ".so";
271287

272288
// Compile
273289
const cc = process.env.CC ?? "gcc";
274-
const binDir = path.join(tmpDir, "binaries", plat);
275-
fs.mkdirSync(binDir, { recursive: true });
276-
const sharedLib = path.join(binDir, `${modelIdentifier}${ext}`);
290+
// Just build to temp dir first
291+
const sharedLib = path.join(tmpDir, `${modelIdentifier}${ext}`);
277292

278293
const ccCmd = [
279294
cc,
@@ -284,16 +299,20 @@ export const Fmu: CommandModule<{}, FmuArgs> = {
284299
"-Wextra",
285300
`-I${srcDir}`,
286301
path.join(srcDir, `${modelIdentifier}_model.c`),
287-
path.join(srcDir, "fmi2Functions.c"),
302+
archiveOptions.fmiVersion === "2" || archiveOptions.fmiVersion === "both" ? fmi2c : "",
303+
archiveOptions.fmiVersion === "3" || archiveOptions.fmiVersion === "both" ? fmi3c : "",
288304
"-o",
289305
sharedLib,
290306
"-lm",
291-
].join(" ");
307+
"-pthread",
308+
]
309+
.filter(Boolean)
310+
.join(" ");
292311

293312
console.log(`Compiling with: ${ccCmd}`);
294313
try {
295314
execSync(ccCmd, { stdio: "pipe", timeout: 60000 });
296-
console.log(` Compiled: binaries/${plat}/${modelIdentifier}${ext}`);
315+
console.log(` Compiled native binary`);
297316
} catch (compileErr) {
298317
const msg =
299318
compileErr instanceof Error && "stderr" in compileErr
@@ -306,50 +325,27 @@ export const Fmu: CommandModule<{}, FmuArgs> = {
306325
return;
307326
}
308327

309-
// Rebuild archive with the compiled binary
310-
const finalEntries = new Map<string, Uint8Array>();
311-
312-
// Actually, it's easier to use the resourceFiles map for the native binary
313-
if (!archiveOptions.resourceFiles) archiveOptions.resourceFiles = new Map();
314-
// FMI standard expects native binaries in binaries/PLATFORM/
315-
// So we hack it in via resourceFiles by using a relative path that breaks out of resources/
316-
// Wait, fmu-archive adds "resources/" prefix. So we can't use resourceFiles.
317-
318-
// Let's just re-implement the entry copying like in export-fmu.ts
319-
finalEntries.set("modelDescription.xml", encoder.encode(result.fmuResult.modelDescriptionXml));
320-
321-
if (archiveOptions.fmiVersion === "3" || archiveOptions.fmiVersion === "both") {
322-
// Add terminalsAndIcons if present
323-
// We can't easily get fmi3Result without calling generateFmi3.
324-
// A cleaner way is to just call buildFmuArchive again but modify it to accept native binaries.
325-
}
326-
327-
// Since we're replacing export-fmu, let's copy its logic directly.
328-
finalEntries.set("modelDescription.xml", encoder.encode(result.fmuResult.modelDescriptionXml));
329-
if (archiveOptions.includeSources !== false) {
330-
finalEntries.set(`sources/${modelIdentifier}_model.h`, encoder.encode(sources.modelH));
331-
finalEntries.set(`sources/${modelIdentifier}_model.c`, encoder.encode(sources.modelC));
332-
finalEntries.set("sources/fmi2Functions.c", encoder.encode(sources.fmi2FunctionsC));
333-
finalEntries.set("sources/CMakeLists.txt", encoder.encode(sources.cmakeLists));
334-
for (const [name, content] of Object.entries(FMI_HEADERS)) {
335-
finalEntries.set(`sources/${name}`, encoder.encode(content));
336-
}
328+
const binData = fs.readFileSync(sharedLib);
329+
archiveOptions.nativeBinaries = [];
330+
if (archiveOptions.fmiVersion === "2" || archiveOptions.fmiVersion === "both") {
331+
archiveOptions.nativeBinaries.push({ platform: plat2, ext, binary: binData });
337332
}
338-
if (archiveOptions.includeModelJson !== false) {
339-
finalEntries.set(
340-
"resources/model.json",
341-
encoder.encode(JSON.stringify(serializeArenaToJson(arena), null, 2)),
342-
);
333+
if (archiveOptions.fmiVersion === "3" || archiveOptions.fmiVersion === "both") {
334+
archiveOptions.nativeBinaries.push({ platform: plat3, ext, binary: binData });
343335
}
344336

345-
if (archiveOptions.wasmBinary && archiveOptions.wasmJsGlue) {
346-
finalEntries.set(`binaries/wasm32/${modelIdentifier}.wasm`, archiveOptions.wasmBinary);
347-
finalEntries.set(`binaries/wasm32/${modelIdentifier}.js`, encoder.encode(archiveOptions.wasmJsGlue));
348-
}
337+
console.log("FMI VERSION: ", archiveOptions.fmiVersion);
338+
console.log(
339+
"NATIVE BINARIES: ",
340+
archiveOptions.nativeBinaries.map((b) => b.platform),
341+
);
349342

350-
finalEntries.set(`binaries/${plat}/${modelIdentifier}${ext}`, fs.readFileSync(sharedLib));
343+
// Re-build archive to include native binaries properly
344+
profiler.start("codegen");
345+
result = buildFmuArchive(arena, archiveOptions, stateVars);
346+
profiler.end("codegen");
351347

352-
fs.writeFileSync(outputPath, createZip(finalEntries));
348+
fs.writeFileSync(outputPath, result.archive);
353349
} finally {
354350
fs.rmSync(tmpDir, { recursive: true, force: true });
355351
profiler.end("compilation_c");

‎apps/cli/src/commands/simulate.ts‎

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
// SPDX-License-Identifier: AGPL-3.0-or-later
22

3-
import { type ArenaDAEBuilder } from "@modelscript/compiler";
3+
import { initBltWasm, type ArenaDAEBuilder } from "@modelscript/compiler";
44
import {
55
ArenaSimulator,
66
runWasmSimulation,
77
simulateArena,
8+
simulateArenaAsync,
89
snapshotMemory,
910
type MemorySnapshot,
1011
} from "@modelscript/compiler/simulator";
@@ -82,7 +83,7 @@ export const Simulate: CommandModule<{}, SimulateArgs> = {
8283
.option("solver", {
8384
description: "ODE solver to use",
8485
type: "string",
85-
choices: ["rk4", "dopri5", "bdf", "auto"],
86+
choices: ["euler", "rk4", "dopri5", "bdf", "auto", "cvode"],
8687
default: "dopri5",
8788
})
8889
.option("realtime", {
@@ -150,6 +151,8 @@ export const Simulate: CommandModule<{}, SimulateArgs> = {
150151
lastSnap = snap;
151152
}
152153

154+
await initBltWasm();
155+
153156
if (!arena) {
154157
console.error(`'${args.name}' not found or had flattening errors.`);
155158
return;
@@ -168,7 +171,7 @@ export const Simulate: CommandModule<{}, SimulateArgs> = {
168171
await simulateC(arena, args, profiler, startTime, stopTime, step, memProfiles, lastSnap);
169172
break;
170173
case "arena":
171-
simulateArenaEngine(arena, args, profiler, startTime, stopTime, step, memProfiles, lastSnap);
174+
await simulateArenaEngine(arena, args, profiler, startTime, stopTime, step, memProfiles, lastSnap);
172175
break;
173176
case "js":
174177
default:
@@ -198,13 +201,22 @@ function simulateJs(
198201
): void {
199202
profiler.start("simulation");
200203

204+
const outputStringIds: number[] = [];
205+
for (let i = 0; i < arena.varCount; i++) {
206+
const name = arena.getVarName(i);
207+
if (!name.startsWith("$") && !name.startsWith("der(")) {
208+
outputStringIds.push(arena.getVarNameId(i));
209+
}
210+
}
211+
201212
// Map CLI solver to simulateArena solver
202-
const solver = args.solver as "euler" | "rk4" | "dopri5" | "bdf" | "auto";
213+
const solver = args.solver as "euler" | "rk4" | "dopri5" | "bdf" | "auto" | "cvode";
203214
const result = simulateArena(arena, {
204215
startTime,
205216
stopTime,
206217
step,
207218
solver,
219+
outputStringIds,
208220
});
209221

210222
profiler.end("simulation");
@@ -219,7 +231,7 @@ function simulateJs(
219231

220232
// ── Arena Engine ──
221233

222-
function simulateArenaEngine(
234+
async function simulateArenaEngine(
223235
arena: ArenaDAEBuilder,
224236
args: SimulateArgs,
225237
profiler: Profiler,
@@ -228,16 +240,30 @@ function simulateArenaEngine(
228240
step: number,
229241
memProfiles: Record<string, unknown>,
230242
lastSnap: MemorySnapshot | null,
231-
): void {
243+
): Promise<void> {
232244
profiler.start("simulation");
233245

234-
const solver = args.solver as "euler" | "rk4" | "dopri5" | "bdf" | "auto";
235-
const result = simulateArena(arena, {
246+
const outputStringIds: number[] = [];
247+
for (let i = 0; i < arena.varCount; i++) {
248+
const name = arena.getVarName(i);
249+
if (!name.startsWith("$") && !name.startsWith("der(")) {
250+
outputStringIds.push(arena.getVarNameId(i));
251+
}
252+
}
253+
254+
const solver = args.solver as "euler" | "rk4" | "dopri5" | "bdf" | "auto" | "cvode";
255+
const opts: any = {
236256
startTime,
237257
stopTime,
238258
step,
239259
solver,
240-
});
260+
outputStringIds,
261+
};
262+
if (args.tolerance) {
263+
opts.atol = Number(args.tolerance);
264+
opts.rtol = Number(args.tolerance);
265+
}
266+
const result = await simulateArenaAsync(arena, opts);
241267

242268
profiler.end("simulation");
243269

‎apps/cli/src/main.ts‎

Lines changed: 4 additions & 4 deletions

0 commit comments

Comments
 (0)