Skip to content

Commit 770c9fb

Browse files
committed
feat(core): add ModelicaFmuEntity for first-class FMU support, auto-resolve FMU XML as Modelica blocks
1 parent 13f1d07 commit 770c9fb

5 files changed

Lines changed: 305 additions & 29 deletions

File tree

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+
/**
4+
* FMU (Functional Mock-up Interface) entity support.
5+
*
6+
* When an FMI 2.0 `modelDescription.xml` file is found in the workspace,
7+
* it is represented as a `ModelicaFmuEntity` — a specialization of
8+
* `ModelicaClassInstance` that exposes FMU scalar variables as synthetic
9+
* Modelica component instances. This lets other Modelica models reference
10+
* FMU participants seamlessly via normal name resolution and `connect`
11+
* equations.
12+
*/
13+
14+
import type { Scope } from "../scope.js";
15+
import type { IModelicaModelVisitor } from "./model.js";
16+
import {
17+
ModelicaClassInstance,
18+
ModelicaComponentInstance,
19+
type ModelicaElement,
20+
type ModelicaNamedElement,
21+
} from "./model.js";
22+
import { ModelicaCausality, ModelicaClassKind, type ModelicaIdentifierSyntaxNode } from "./syntax.js";
23+
24+
// ── FMU model description types ──
25+
26+
interface FmuScalarVariable {
27+
name: string;
28+
causality: "input" | "output" | "local" | "parameter" | "calculatedParameter" | "independent";
29+
variability: "continuous" | "discrete" | "fixed" | "tunable" | "constant";
30+
description: string;
31+
start?: number;
32+
}
33+
34+
// ── XML parsing ──
35+
36+
/**
37+
* Parse scalar variables from an FMI 2.0 `modelDescription.xml` string.
38+
* Uses lightweight regex matching — no DOM parser required.
39+
*/
40+
function parseFmuModelDescription(xml: string): {
41+
modelName: string;
42+
description: string;
43+
variables: FmuScalarVariable[];
44+
} {
45+
// Extract modelName
46+
const nameMatch = xml.match(/modelName\s*=\s*"([^"]*)"/);
47+
const modelName = nameMatch?.[1] ?? "FMU";
48+
49+
// Extract top-level description
50+
const descMatch = xml.match(/<fmiModelDescription[^>]*\bdescription\s*=\s*"([^"]*)"/);
51+
const description = descMatch?.[1] ?? "";
52+
53+
// Extract scalar variables
54+
const variables: FmuScalarVariable[] = [];
55+
const scalarRegex =
56+
/<ScalarVariable\b([^>]*)\/?>[\s\S]*?(?:<\/ScalarVariable>|(?=<ScalarVariable|<\/ModelVariables))/g;
57+
let match: RegExpExecArray | null;
58+
59+
while ((match = scalarRegex.exec(xml)) !== null) {
60+
const attrs = match[0] ?? "";
61+
const headerAttrs = match[1] ?? "";
62+
63+
const varName = headerAttrs.match(/\bname\s*=\s*"([^"]*)"/)?.[1];
64+
if (!varName) continue;
65+
66+
const causality = (headerAttrs.match(/\bcausality\s*=\s*"([^"]*)"/)?.[1] ??
67+
"local") as FmuScalarVariable["causality"];
68+
const variability = (headerAttrs.match(/\bvariability\s*=\s*"([^"]*)"/)?.[1] ??
69+
"continuous") as FmuScalarVariable["variability"];
70+
const varDesc = headerAttrs.match(/\bdescription\s*=\s*"([^"]*)"/)?.[1] ?? "";
71+
72+
// Extract start value from nested <Real>, <Integer>, etc.
73+
const startMatch = attrs.match(/\bstart\s*=\s*"([^"]*)"/);
74+
const start = startMatch ? parseFloat(startMatch[1] ?? "") : undefined;
75+
76+
variables.push({
77+
name: varName,
78+
causality,
79+
variability,
80+
description: varDesc,
81+
...(Number.isFinite(start) ? { start: start as number } : {}),
82+
});
83+
}
84+
85+
return { modelName, description, variables };
86+
}
87+
88+
// ── ModelicaFmuEntity ──
89+
90+
/**
91+
* A Modelica class instance backed by an FMI 2.0 model description XML file.
92+
*
93+
* Acts as a `block` with input/output connectors derived from the FMU's
94+
* scalar variables. The instantiation algorithm creates synthetic
95+
* `ModelicaComponentInstance` objects for each variable, so that other
96+
* Modelica models can reference them via normal name resolution.
97+
*/
98+
export class ModelicaFmuEntity extends ModelicaClassInstance {
99+
/** Absolute path to the model description XML file. */
100+
path: string;
101+
/** Parsed FMU scalar variables. */
102+
fmuVariables: FmuScalarVariable[] = [];
103+
/** Synthetic component instances created during instantiation. */
104+
#syntheticComponents: ModelicaComponentInstance[] = [];
105+
#loaded = false;
106+
/** Raw XML content (for browser/memfs environments where path may not be readable). */
107+
#xmlContent: string | null = null;
108+
109+
constructor(parent: Scope, path: string, xmlContent?: string) {
110+
super(parent);
111+
this.path = path;
112+
this.classKind = ModelicaClassKind.BLOCK;
113+
this.#xmlContent = xmlContent ?? null;
114+
}
115+
116+
/** Load from pre-supplied XML content (for browser memfs environments). */
117+
static fromXml(parent: Scope, name: string, xmlContent: string): ModelicaFmuEntity {
118+
const entity = new ModelicaFmuEntity(parent, `__fmu__:${name}`, xmlContent);
119+
entity.name = name;
120+
return entity;
121+
}
122+
123+
override accept<R, A>(visitor: IModelicaModelVisitor<R, A>, argument?: A): R {
124+
return visitor.visitClassInstance(this, argument);
125+
}
126+
127+
override clone(modification?: import("./model.js").ModelicaModification | null): ModelicaClassInstance {
128+
if (!this.#loaded) this.load();
129+
const cloned = new ModelicaFmuEntity(this.parent ?? this, this.path, this.#xmlContent ?? undefined);
130+
cloned.name = this.name;
131+
cloned.fmuVariables = this.fmuVariables;
132+
cloned.#loaded = true;
133+
if (modification) {
134+
// FMU entities don't use Modelica modifications, but pass through for compatibility
135+
}
136+
cloned.instantiate();
137+
return cloned;
138+
}
139+
140+
override get elements(): IterableIterator<ModelicaElement> {
141+
if (!this.instantiated && !this.instantiating) this.instantiate();
142+
const components = this.#syntheticComponents;
143+
return (function* () {
144+
yield* components;
145+
})();
146+
}
147+
148+
override resolveSimpleName(
149+
identifier: ModelicaIdentifierSyntaxNode | string | null | undefined,
150+
global = false,
151+
encapsulated = false,
152+
): ModelicaNamedElement | null {
153+
const simpleName = typeof identifier === "string" ? identifier : identifier?.text;
154+
if (!simpleName) return null;
155+
if (!this.instantiated && !this.instantiating) this.instantiate();
156+
157+
// Check synthetic components
158+
for (const comp of this.#syntheticComponents) {
159+
if (comp.name === simpleName) return comp;
160+
}
161+
162+
return super.resolveSimpleName(identifier, global, encapsulated);
163+
}
164+
165+
override instantiate(): void {
166+
if (this.instantiated) return;
167+
if (this.instantiating) return;
168+
this.instantiating = true;
169+
try {
170+
if (!this.#loaded) this.load();
171+
this.declaredElements = [];
172+
this.#syntheticComponents = [];
173+
174+
// Resolve the predefined Real type for component class instances
175+
const realType = this.root?.resolveSimpleName("Real") as ModelicaClassInstance | null;
176+
177+
for (const v of this.fmuVariables) {
178+
// Create a synthetic component instance with null AST node
179+
const comp = new ModelicaComponentInstance(this, null);
180+
comp.name = v.name;
181+
comp.description = v.description || null;
182+
183+
// Set causality from FMU variable
184+
if (v.causality === "input") {
185+
comp.causality = ModelicaCausality.INPUT;
186+
} else if (v.causality === "output") {
187+
comp.causality = ModelicaCausality.OUTPUT;
188+
}
189+
190+
// Set the class instance to Real (all FMU 2.0 continuous variables are Real)
191+
if (realType) {
192+
comp.classInstance = realType.clone();
193+
}
194+
195+
comp.instantiated = true;
196+
this.#syntheticComponents.push(comp);
197+
this.declaredElements.push(comp);
198+
}
199+
200+
this.instantiated = true;
201+
} finally {
202+
this.instantiating = false;
203+
}
204+
}
205+
206+
/** Read and parse the model description XML. */
207+
load(): void {
208+
if (this.#loaded) return;
209+
this.#loaded = true;
210+
211+
let xmlContent = this.#xmlContent;
212+
213+
// Try reading from filesystem if no pre-supplied content
214+
if (!xmlContent) {
215+
const context = this.context;
216+
if (context) {
217+
try {
218+
xmlContent = context.fs.read(this.path);
219+
this.#xmlContent = xmlContent;
220+
} catch {
221+
console.warn(`[ModelicaFmuEntity] Failed to read FMU XML: ${this.path}`);
222+
return;
223+
}
224+
}
225+
}
226+
227+
if (!xmlContent) return;
228+
229+
const parsed = parseFmuModelDescription(xmlContent);
230+
if (!this.name) {
231+
this.name = parsed.modelName;
232+
}
233+
this.description = parsed.description || this.description;
234+
this.fmuVariables = parsed.variables;
235+
}
236+
}

packages/core/src/compiler/modelica/model.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1763,7 +1763,27 @@ export class ModelicaEntity extends ModelicaClassInstance {
17631763
const pkgPath = context.fs.join(this.path, dirent.name, "package.mo");
17641764
if (!context.fs.stat(pkgPath)?.isFile()) continue;
17651765
} else if (dirent.isFile()) {
1766-
if (dirent.name === "package.mo" || context.fs.extname(dirent.name) !== ".mo") continue;
1766+
const ext = context.fs.extname(dirent.name);
1767+
if (dirent.name === "package.mo") continue;
1768+
// Check for FMI model description XML files
1769+
if (ext === ".xml") {
1770+
const xmlPath = context.fs.join(this.path, dirent.name);
1771+
try {
1772+
const xmlContent = context.fs.read(xmlPath);
1773+
if (xmlContent.includes("fmiModelDescription")) {
1774+
// Lazy import to avoid circular dependency (fmu.ts imports from model.ts)
1775+
// eslint-disable-next-line @typescript-eslint/no-require-imports
1776+
const { ModelicaFmuEntity } = require("./fmu.js") as typeof import("./fmu.js");
1777+
const fmuEntity = new ModelicaFmuEntity(this, xmlPath);
1778+
fmuEntity.name = dirent.name.replace(/\.xml$/, "");
1779+
this.subEntities.push(fmuEntity as unknown as ModelicaEntity);
1780+
}
1781+
} catch {
1782+
// Skip unreadable XML files
1783+
}
1784+
continue;
1785+
}
1786+
if (ext !== ".mo") continue;
17671787
}
17681788
const subEntity = new ModelicaEntity(this, context.fs.join(this.path, dirent.name));
17691789
// Set name from filesystem path without parsing — enables lazy loading

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export * from "./compiler/modelica/flattener.js";
1313
export * from "./compiler/modelica/fmi.js";
1414
export * from "./compiler/modelica/fmu-archive.js";
1515
export * from "./compiler/modelica/fmu-codegen.js";
16+
export * from "./compiler/modelica/fmu.js";
1617
export * from "./compiler/modelica/i18n.js";
1718
export * from "./compiler/modelica/interpreter.js";
1819
export * from "./compiler/modelica/linter.js";

packages/vscode/src/browserClientMain.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -620,15 +620,15 @@ async function initWorkspaceAndTree(
620620
"",
621621
].join("\n");
622622

623-
// Write all three files
623+
// Write all files
624624
const controllerUri = Uri.joinPath(workspaceUri, "Controller.mo");
625625
const plantUri = Uri.joinPath(workspaceUri, "Plant.xml");
626626
const readmeUri = Uri.joinPath(workspaceUri, "README.md");
627627

628628
const cosimSetupMo = [
629629
'model CosimSetup "Co-simulation wiring diagram"',
630630
" Controller controller;",
631-
' FMUBlock plant(fileName = "Plant.xml");',
631+
" Plant plant;",
632632
"equation",
633633
" connect(controller.y, plant.u);",
634634
" connect(plant.y, controller.u);",

packages/vscode/src/cosimPanel.ts

Lines changed: 45 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -581,42 +581,36 @@ export class CosimViewProvider implements vscode.WebviewViewProvider {
581581
for (const p of result.participants) {
582582
const participantId = `cosim-${p.id}-${Date.now().toString(36)}`;
583583

584-
if (p.type === "modelica") {
585-
// Find the document URI for this Modelica class
586-
// Convention: class name matches filename (e.g., Controller → Controller.mo)
587-
const moFileName = `${p.className}.mo`;
588-
let moUri = uri; // Default to the wrapper model's URI
589-
590-
if (workspaceFolder) {
591-
const candidateUri = vscode.Uri.joinPath(workspaceFolder, moFileName);
592-
try {
593-
await vscode.workspace.fs.stat(candidateUri);
594-
moUri = candidateUri.toString();
595-
} catch {
596-
// File not found — fall back to wrapper URI
584+
// Auto-detect participant type: check if there's a matching .xml file in the workspace
585+
let isFmu = p.type === "fmu";
586+
let fmuFileName = p.fileName;
587+
588+
if (!isFmu && workspaceFolder) {
589+
// Check if {className}.xml exists and is an FMI model description
590+
const xmlCandidateUri = vscode.Uri.joinPath(workspaceFolder, `${p.className}.xml`);
591+
try {
592+
const data = await vscode.workspace.fs.readFile(xmlCandidateUri);
593+
const xmlContent = new TextDecoder().decode(data);
594+
if (xmlContent.includes("fmiModelDescription")) {
595+
isFmu = true;
596+
fmuFileName = `${p.className}.xml`;
597597
}
598+
} catch {
599+
// No matching XML file — treat as Modelica
598600
}
601+
}
599602

600-
const lspParticipant = new LspSimulatorParticipant(this.client, participantId, p.className, moUri);
601-
session.participants.push({
602-
id: participantId,
603-
modelName: p.className,
604-
uri: moUri,
605-
type: "modelica",
606-
variables: 0,
607-
participant: lspParticipant,
608-
});
609-
} else if (p.type === "fmu" && p.fileName) {
603+
if (isFmu && fmuFileName) {
610604
// Resolve the XML file relative to the workspace
611605
let xmlContent: string | undefined;
612606

613607
if (workspaceFolder) {
614-
const xmlUri = vscode.Uri.joinPath(workspaceFolder, p.fileName);
608+
const xmlUri = vscode.Uri.joinPath(workspaceFolder, fmuFileName);
615609
try {
616610
const data = await vscode.workspace.fs.readFile(xmlUri);
617611
xmlContent = new TextDecoder().decode(data);
618612
} catch {
619-
this.postMessage({ type: "error", message: `FMU file not found: ${p.fileName}` });
613+
this.postMessage({ type: "error", message: `FMU file not found: ${fmuFileName}` });
620614
continue;
621615
}
622616
}
@@ -627,11 +621,36 @@ export class CosimViewProvider implements vscode.WebviewViewProvider {
627621
session.participants.push({
628622
id: participantId,
629623
modelName: fmuParticipant.modelName || p.className,
630-
uri: p.fileName,
624+
uri: fmuFileName,
631625
type: "fmu",
632626
variables: 0,
633627
participant: fmuParticipant,
634628
});
629+
} else {
630+
// Find the document URI for this Modelica class
631+
// Convention: class name matches filename (e.g., Controller → Controller.mo)
632+
const moFileName = `${p.className}.mo`;
633+
let moUri = uri; // Default to the wrapper model's URI
634+
635+
if (workspaceFolder) {
636+
const candidateUri = vscode.Uri.joinPath(workspaceFolder, moFileName);
637+
try {
638+
await vscode.workspace.fs.stat(candidateUri);
639+
moUri = candidateUri.toString();
640+
} catch {
641+
// File not found — fall back to wrapper URI
642+
}
643+
}
644+
645+
const lspParticipant = new LspSimulatorParticipant(this.client, participantId, p.className, moUri);
646+
session.participants.push({
647+
id: participantId,
648+
modelName: p.className,
649+
uri: moUri,
650+
type: "modelica",
651+
variables: 0,
652+
participant: lspParticipant,
653+
});
635654
}
636655
}
637656

0 commit comments

Comments
 (0)