Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 18 additions & 6 deletions src/app/NewProject.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,19 +142,31 @@ export class NewProject extends React.Component<NewProjectProps, NewProjectState
return;
}
const file = event.target.files[0];
let contents = await readFile(file);
const contents = await readFile(file);
let logs: string | undefined;

try {
// convert vensim files to xmile
let engine2Project: Engine2Project;

if (file.name.endsWith('.mdl')) {
[contents, logs] = await convertMdlToXmile(contents, true);
if (contents.length === 0) {
throw new Error('Vensim converter: ' + (logs || 'unknown error'));
// For Vensim MDL files, try direct import first if available
const hasVensim = await Engine2Project.hasVensimSupport();
if (hasVensim) {
engine2Project = await Engine2Project.openVensim(contents);
} else {
// Fall back to xmutil conversion when direct Vensim support is not available
const [xmileContents, conversionLogs] = await convertMdlToXmile(contents, true);
logs = conversionLogs;
if (xmileContents.length === 0) {
throw new Error('Vensim converter: ' + (logs || 'unknown error'));
}
engine2Project = await Engine2Project.open(xmileContents);
}
} else {
// XMILE/STMX files open directly
engine2Project = await Engine2Project.open(contents);
}

const engine2Project = await Engine2Project.open(contents);
const projectPB = engine2Project.serializeProtobuf();
const json = JSON.parse(engine2Project.serializeJson()) as JsonProject;
const activeProject = ProjectDM.fromJson(json);
Expand Down
49 changes: 48 additions & 1 deletion src/engine2/src/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,12 @@ import {
simlin_project_get_errors,
simlin_project_apply_patch,
} from './internal/project';
import { simlin_project_open_xmile, simlin_project_serialize_xmile } from './internal/import-export';
import {
simlin_project_open_xmile,
simlin_project_open_vensim,
simlin_project_serialize_xmile,
hasVensimSupport,
} from './internal/import-export';
import { simlin_analyze_get_loops, readLoops, simlin_free_loops } from './internal/analysis';
import { SimlinProjectPtr, SimlinJsonFormat, ErrorDetail } from './internal/types';
import { readAllErrorDetails, simlin_error_free } from './internal/error';
Expand Down Expand Up @@ -95,6 +100,18 @@ export class Project {
return new Project(ptr);
}

/**
* Create a project from Vensim MDL data.
* Note: This requires libsimlin to be built with the 'vensim' feature.
* @param data MDL file data as Uint8Array
* @returns New Project instance
* @throws SimlinError if the MDL data is invalid or vensim support is not available
*/
private static fromVensim(data: Uint8Array): Project {
const ptr = simlin_project_open_vensim(data);
return new Project(ptr);
}

/**
* Create a project from XMILE data (string or bytes).
* Automatically initializes WASM if needed.
Expand Down Expand Up @@ -136,6 +153,36 @@ export class Project {
return Project.fromJson(data, format);
}

/**
* Create a project from Vensim MDL data (string or bytes).
* Automatically initializes WASM if needed.
*
* Note: This requires libsimlin to be built with the 'vensim' feature.
* Use Project.hasVensimSupport() to check availability before calling.
*
* @param data MDL file data as string or Uint8Array
* @param options Optional WASM configuration
* @returns Promise resolving to new Project instance
* @throws SimlinError if the MDL data is invalid or vensim support is not available
*/
static async openVensim(data: string | Uint8Array, options: ProjectOpenOptions = {}): Promise<Project> {
await ensureInitialized(options.wasm);
const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data;
return Project.fromVensim(bytes);
}

/**
* Check if the WASM module was built with Vensim MDL support.
* Automatically initializes WASM if needed.
*
* @param options Optional WASM configuration
* @returns Promise resolving to true if Project.openVensim() is available
*/
static async hasVensimSupport(options: ProjectOpenOptions = {}): Promise<boolean> {
await ensureInitialized(options.wasm);
return hasVensimSupport();
}

/**
* Get the internal WASM pointer. For internal use only.
*/
Expand Down
88 changes: 88 additions & 0 deletions src/engine2/tests/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,15 @@ function loadTestXmile(): Uint8Array {
return fs.readFileSync(xmilePath);
}

// Load the teacup test model in Vensim MDL format
function loadTestMdl(): Uint8Array {
const mdlPath = path.join(__dirname, '..', '..', '..', 'test', 'test-models', 'samples', 'teacup', 'teacup.mdl');
if (!fs.existsSync(mdlPath)) {
throw new Error('Required test MDL model not found: ' + mdlPath);
}
return fs.readFileSync(mdlPath);
}

async function openTestProject(): Promise<Project> {
return Project.open(loadTestXmile());
}
Expand Down Expand Up @@ -1067,4 +1076,83 @@ describe('High-Level API', () => {
project.dispose();
});
});

describe('Vensim MDL support', () => {
it('should check hasVensimSupport availability via Project static method', async () => {
// Project.hasVensimSupport() is an async method that ensures WASM is initialized
const supported = await Project.hasVensimSupport();
expect(typeof supported).toBe('boolean');
});

it('should handle openVensim when support is not available', async () => {
const supported = await Project.hasVensimSupport();

if (!supported) {
// When Vensim support is not available, openVensim should throw
const mdlData = loadTestMdl();
await expect(Project.openVensim(mdlData)).rejects.toThrow(/vensim/i);
}
});

it('should load MDL file when Vensim support is available', async () => {
const supported = await Project.hasVensimSupport();

if (supported) {
// When Vensim support is available, openVensim should work
const mdlData = loadTestMdl();
const project = await Project.openVensim(mdlData);

expect(project).toBeInstanceOf(Project);
expect(project.modelCount).toBeGreaterThan(0);

// The teacup model should have the expected variables
const model = project.mainModel;
const varNames = model.variables.map((v) => v.name.toLowerCase());
expect(varNames).toContain('teacup temperature');

project.dispose();
}
});

it('should accept MDL data as string when Vensim support is available', async () => {
const supported = await Project.hasVensimSupport();

if (supported) {
// openVensim should accept string data (like XMILE)
const mdlData = loadTestMdl();
const mdlString = new TextDecoder().decode(mdlData);
const project = await Project.openVensim(mdlString);

expect(project).toBeInstanceOf(Project);
project.dispose();
}
});

it('should simulate models loaded from MDL when Vensim support is available', async () => {
const supported = await Project.hasVensimSupport();

if (supported) {
const mdlData = loadTestMdl();
const project = await Project.openVensim(mdlData);
const model = project.mainModel;

// Run simulation
const run = model.run();
expect(run).toBeInstanceOf(Run);

// Get results
const results = run.results;
expect(results.size).toBeGreaterThan(0);

// Check that teacup temperature series exists and has expected behavior
// (temperature should decrease over time as teacup cools)
const tempSeries = results.get('teacup_temperature');
if (tempSeries && tempSeries.length > 1) {
expect(tempSeries[0]).toBeGreaterThan(tempSeries[tempSeries.length - 1]);
}

project.dispose();
}
});
});
});
Loading