Skip to content

Commit 8542f0a

Browse files
committed
feat(core,lsp,vscode): multi-fmu wrapper model generation, createCosimWrapper lsp request, cosim panel wrapper button
1 parent 7799a3a commit 8542f0a

6 files changed

Lines changed: 243 additions & 0 deletions

File tree

packages/core/src/compiler/modelica/test-synth.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,3 +110,48 @@ for (const el of elements) {
110110
}
111111

112112
console.log("\n✅ All FMU diagram synthesis tests passed.");
113+
114+
// ── Test 3: Wrapper template generation ──
115+
116+
import { generateMultiModelWrapper } from "./wrapper-template.js";
117+
118+
console.log("\n=== Test 3: Wrapper template ===");
119+
120+
const wrapperSource = generateMultiModelWrapper(
121+
"CosimWrapper",
122+
[
123+
{ className: "SineWave", instanceName: "sineWave", fileName: "SineWave.fmu" },
124+
{ className: "Controller", instanceName: "controller", fileName: "Controller.fmu" },
125+
],
126+
[{ source: "sineWave.y", target: "controller.u" }],
127+
);
128+
129+
console.log(wrapperSource);
130+
131+
// Verify wrapper contains expected elements
132+
const checks = [
133+
["model CosimWrapper", "model declaration"],
134+
['SineWave sineWave(fileName="SineWave.fmu")', "sineWave component"],
135+
['Controller controller(fileName="Controller.fmu")', "controller component"],
136+
["connect(sineWave.y, controller.u)", "connect equation"],
137+
["end CosimWrapper;", "end statement"],
138+
["annotation(Placement", "placement annotation"],
139+
["Diagram(", "diagram annotation"],
140+
];
141+
142+
let allPassed = true;
143+
for (const [pattern, label] of checks) {
144+
if (!pattern || !wrapperSource.includes(pattern)) {
145+
console.error(` ✗ Missing: ${label} ("${pattern}")`);
146+
allPassed = false;
147+
} else {
148+
console.log(` ✓ ${label}`);
149+
}
150+
}
151+
152+
if (allPassed) {
153+
console.log("\n✅ All wrapper template tests passed.");
154+
} else {
155+
console.error("\n✗ Some wrapper template tests failed.");
156+
process.exit(1);
157+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+
/**
4+
* Generate a Modelica wrapper model for multi-FMU co-simulation.
5+
*
6+
* Produces a valid `.mo` file containing component declarations for each
7+
* FMU participant (with `fileName` parameters and `Placement` annotations)
8+
* and optional `connect()` equations for wiring outputs to inputs.
9+
*/
10+
11+
/** Descriptor for an FMU participant in the wrapper model. */
12+
export interface WrapperFmuDescriptor {
13+
/** Modelica class name (e.g. "SineWave"). */
14+
className: string;
15+
/** Instance name in the wrapper model (e.g. "sineWave"). */
16+
instanceName: string;
17+
/** FMU file name (e.g. "SineWave.fmu" or "SineWave.xml"). */
18+
fileName: string;
19+
}
20+
21+
/** A connection between two FMU ports. */
22+
export interface WrapperConnection {
23+
/** Qualified source (e.g. "sineWave.y"). */
24+
source: string;
25+
/** Qualified target (e.g. "controller.u"). */
26+
target: string;
27+
}
28+
29+
/**
30+
* Generate a Modelica wrapper model source string from FMU descriptors.
31+
*
32+
* The generated model:
33+
* - Declares each FMU as a component with a `fileName` parameter
34+
* - Adds `Placement` annotations to lay out blocks in a horizontal row
35+
* - Includes `connect()` equations for any specified connections
36+
*
37+
* @param modelName Name of the wrapper model (e.g. "CosimWrapper")
38+
* @param fmus List of FMU participants
39+
* @param connections Optional connections between FMU ports
40+
* @returns Valid Modelica source text
41+
*/
42+
export function generateMultiModelWrapper(
43+
modelName: string,
44+
fmus: WrapperFmuDescriptor[],
45+
connections: WrapperConnection[] = [],
46+
): string {
47+
const lines: string[] = [];
48+
49+
lines.push(`model ${modelName}`);
50+
lines.push(` "Multi-FMU co-simulation wrapper model"`);
51+
52+
// Layout: distribute FMU blocks horizontally with spacing
53+
const spacing = 120; // Modelica units between block centers
54+
const blockSize = 40; // half-extent of each block
55+
const startX = -Math.floor(((fmus.length - 1) * spacing) / 2);
56+
57+
for (const [i, fmu] of fmus.entries()) {
58+
const cx = startX + i * spacing;
59+
const cy = 0;
60+
const ext1x = cx - blockSize;
61+
const ext1y = cy - blockSize;
62+
const ext2x = cx + blockSize;
63+
const ext2y = cy + blockSize;
64+
65+
lines.push(` ${fmu.className} ${fmu.instanceName}(fileName="${fmu.fileName}")`);
66+
lines.push(
67+
` annotation(Placement(transformation(origin={${cx},${cy}}, extent={{${ext1x - cx},${ext1y - cy}},{${ext2x - cx},${ext2y - cy}}})));`,
68+
);
69+
}
70+
71+
if (connections.length > 0) {
72+
lines.push(`equation`);
73+
for (const conn of connections) {
74+
lines.push(` connect(${conn.source}, ${conn.target});`);
75+
}
76+
}
77+
78+
lines.push(` annotation(`);
79+
lines.push(` Diagram(`);
80+
lines.push(` coordinateSystem(`);
81+
lines.push(` extent={{-200,-200},{200,200}},`);
82+
lines.push(` preserveAspectRatio=true`);
83+
lines.push(` )`);
84+
lines.push(` )`);
85+
lines.push(` );`);
86+
lines.push(`end ${modelName};`);
87+
lines.push(``);
88+
89+
return lines.join("\n");
90+
}

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export * from "./compiler/modelica/syntax.js";
2727
export * from "./compiler/modelica/tape.js";
2828
export * from "./compiler/modelica/types.js";
2929
export * from "./compiler/modelica/units.js";
30+
export * from "./compiler/modelica/wrapper-template.js";
3031
export * from "./compiler/scope.js";
3132
export * from "./util/color-inversion.js";
3233
export * from "./util/enum.js";

packages/lsp/src/browserServerMain.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ import {
6868
ModelicaStoredDefinitionSyntaxNode,
6969
ModelicaSyntaxNode,
7070
Scope,
71+
generateMultiModelWrapper,
7172
registerOptimizeDeps,
7273
registerSimulateDeps,
7374
type Dirent,
@@ -3169,6 +3170,29 @@ connection.onRequest("modelscript/extractCosimGraph", (params: { uri: string; te
31693170
}
31703171
});
31713172

3173+
/**
3174+
* Custom request: create a Modelica wrapper model for multi-FMU co-simulation.
3175+
*
3176+
* Takes a model name and list of FMU descriptors, returns the generated
3177+
* Modelica source text that can be written to a .mo file.
3178+
*/
3179+
connection.onRequest(
3180+
"modelscript/createCosimWrapper",
3181+
(params: {
3182+
modelName: string;
3183+
fmus: { className: string; instanceName: string; fileName: string }[];
3184+
connections?: { source: string; target: string }[];
3185+
}): { ok: boolean; source?: string; error?: string } => {
3186+
try {
3187+
const source = generateMultiModelWrapper(params.modelName, params.fmus, params.connections ?? []);
3188+
return { ok: true, source };
3189+
} catch (e) {
3190+
console.error("[createCosimWrapper] Error:", e);
3191+
return { ok: false, error: e instanceof Error ? e.message : String(e) };
3192+
}
3193+
},
3194+
);
3195+
31723196
// Custom request: get library tree children (lazy loading)
31733197
interface TreeNodeInfo {
31743198
id: string;

packages/vscode/src/cosimPanel.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,13 @@ export class CosimViewProvider implements vscode.WebviewViewProvider {
400400
break;
401401
}
402402

403+
case "createCosimWrapper": {
404+
if (this.localMode) {
405+
await this.localCreateCosimWrapper(msg.sessionId as string);
406+
}
407+
break;
408+
}
409+
403410
case "fetchFmus": {
404411
try {
405412
const resp = await fetch(`${apiUrl}/api/v1/fmus`);
@@ -696,6 +703,78 @@ export class CosimViewProvider implements vscode.WebviewViewProvider {
696703
}
697704
}
698705

706+
/** Create a Modelica wrapper model from the session's FMU participants and open it in the editor. */
707+
private async localCreateCosimWrapper(sessionId: string): Promise<void> {
708+
const session = this.localSessions.get(sessionId);
709+
if (!session) {
710+
this.postMessage({ type: "error", message: "Session not found." });
711+
return;
712+
}
713+
714+
if (session.participants.length === 0) {
715+
vscode.window.showWarningMessage("No participants in session. Add FMUs first.");
716+
return;
717+
}
718+
719+
try {
720+
// Build FMU descriptors from session participants
721+
const fmus = session.participants.map((p) => ({
722+
className: p.modelName,
723+
instanceName: p.modelName.charAt(0).toLowerCase() + p.modelName.slice(1),
724+
fileName: p.type === "fmu" ? (p.uri.split("/").pop() ?? `${p.modelName}.fmu`) : `${p.modelName}.mo`,
725+
}));
726+
727+
// Build connections from session couplings
728+
const connections = session.couplings.map((c) => {
729+
const fromP = session.participants.find((p) => p.id === c.from.participantId);
730+
const toP = session.participants.find((p) => p.id === c.to.participantId);
731+
const fromName = fromP
732+
? fromP.modelName.charAt(0).toLowerCase() + fromP.modelName.slice(1)
733+
: c.from.participantId;
734+
const toName = toP ? toP.modelName.charAt(0).toLowerCase() + toP.modelName.slice(1) : c.to.participantId;
735+
return {
736+
source: `${fromName}.${c.from.variableName}`,
737+
target: `${toName}.${c.to.variableName}`,
738+
};
739+
});
740+
741+
// Call LSP to generate the wrapper model source
742+
const result = (await this.client.sendRequest("modelscript/createCosimWrapper", {
743+
modelName: "CosimWrapper",
744+
fmus,
745+
connections,
746+
})) as { ok: boolean; source?: string; error?: string };
747+
748+
if (!result.ok || !result.source) {
749+
this.postMessage({
750+
type: "error",
751+
message: `Failed to generate wrapper: ${result.error ?? "unknown error"}`,
752+
});
753+
return;
754+
}
755+
756+
// Write the wrapper file to the workspace
757+
const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri;
758+
if (!workspaceFolder) {
759+
vscode.window.showWarningMessage("No workspace folder open.");
760+
return;
761+
}
762+
763+
const wrapperUri = vscode.Uri.joinPath(workspaceFolder, "CosimWrapper.mo");
764+
await vscode.workspace.fs.writeFile(wrapperUri, new TextEncoder().encode(result.source));
765+
766+
// Open the file in the editor
767+
const doc = await vscode.workspace.openTextDocument(wrapperUri);
768+
await vscode.window.showTextDocument(doc);
769+
770+
vscode.window.showInformationMessage(
771+
`Created wrapper model "CosimWrapper.mo" with ${fmus.length} FMU(s). Open the Diagram view to wire ports.`,
772+
);
773+
} catch (e) {
774+
this.postMessage({ type: "error", message: `Failed to create wrapper: ${e}` });
775+
}
776+
}
777+
699778
private async publishCurrentModel(sessionId: string): Promise<void> {
700779
const editor = vscode.window.activeTextEditor;
701780
if (!editor || editor.document.languageId !== "modelica") {

packages/vscode/src/webview/cosimWebview.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,7 @@ function renderSessions(sessions: SessionInfo[]): void {
232232
${s.state === "created" ? `<button data-action="publish" data-id="${id}" class="secondary">📡 Publish Model</button>` : ""}
233233
${s.state === "created" ? `<button data-action="publishFmu" data-id="${id}" class="secondary">📦 Publish FMU</button>` : ""}
234234
${s.state === "created" ? `<button data-action="publishCosim" data-id="${id}" class="secondary">🔗 Publish Co-Sim</button>` : ""}
235+
${s.state === "created" ? `<button data-action="createWrapper" data-id="${id}" class="secondary">🔧 Create Wrapper</button>` : ""}
235236
${s.state === "running" ? `<button data-action="livePlot" data-id="${id}" class="secondary">📈 Live Plot</button>` : ""}
236237
<button data-action="delete" data-id="${id}" class="secondary">✕</button>
237238
</div>
@@ -266,6 +267,9 @@ function renderSessions(sessions: SessionInfo[]): void {
266267
case "publishCosim":
267268
vscode.postMessage({ type: "publishCosimModel", sessionId: id });
268269
break;
270+
case "createWrapper":
271+
vscode.postMessage({ type: "createCosimWrapper", sessionId: id });
272+
break;
269273
case "livePlot":
270274
vscode.postMessage({ type: "openLivePlot", sessionId: id });
271275
break;

0 commit comments

Comments
 (0)