@idfkit/engine
v0.4.0
Published
Run building energy simulations in the browser via WebAssembly. Framework-agnostic loader, worker and output parsers.
Downloads
674
Maintainers
Readme
@idfkit/engine
Run building energy simulations in the browser, via WebAssembly. No server, no install, no upload — the model never leaves the page.
This package is the loader, worker and output parsers. It carries no engine
binary of its own; pair it with @idfkit/engine-assets,
whose package version is the EnergyPlus version it bundles.
npm install @idfkit/engine @idfkit/[email protected]Quickstart
Assets have to be reachable over HTTP at runtime. The reliable way is to copy them somewhere your app serves, and point the loader at that path:
// package.json
{
"scripts": {
"prebuild": "idfkit-engine-assets public/energyplus",
"predev": "idfkit-engine-assets public/energyplus"
}
}import { createEnergyPlus } from '@idfkit/engine';
const ep = await createEnergyPlus({
assetBaseUrl: '/energyplus',
onConsole: (line) => console.log(line),
onProgress: ({ phase, progress }) => console.log(phase, progress),
});
const result = await ep.run({ idf, epw });
if (result.success) {
// `variables` is a Map keyed by the output file's variable id.
console.log(`${result.eso?.variables.size ?? 0} output variables`);
} else {
console.error(result.fatalError);
for (const entry of result.err?.entries ?? []) console.error(entry.message);
}
ep.dispose();Add public/energyplus/ to .gitignore — it is generated, and it is ~50 MB.
Choosing an EnergyPlus version
The asset package's version is the EnergyPlus version:
npm install @idfkit/[email protected] # EnergyPlus version 26.1.0 software
npm install @idfkit/[email protected] # EnergyPlus version 25.2.0 softwareRebuilds of the same upstream release use the PATCH slot: 26.1.1, 26.1.2. A
prerelease suffix like 26.1.0-build.2 does not work here -- npm sorts a
prerelease BELOW its release and excludes it from ordinary ranges, so
@idfkit/[email protected] and ^26.1.0 would both skip it and users would
never receive the rebuild. Reserve prereleases for builds nobody should adopt
automatically.
To offer several versions at runtime, skip the copy step and point
assetBaseUrl at a CDN per version:
const ep = await createEnergyPlus({
assetBaseUrl: `https://cdn.jsdelivr.net/npm/@idfkit/engine-assets@${version}/assets`,
// The binary is named after its version, so this must move with it. Each
// asset package exports the right value as `wasmFile`.
wasmFile: `energyplus.js-${version}.wasm`,
// The worker ships in @idfkit/engine, a different package, so it cannot be
// derived from assetBaseUrl here.
workerUrl: 'https://cdn.jsdelivr.net/npm/@idfkit/engine/dist/engine.worker.js',
});That path is cross-origin, which brings the caveats in the next section.
Serving requirements
| Requirement | Why |
| --- | --- |
| Content-Type: application/wasm on .wasm | Streaming compilation rejects other types |
| Long-lived immutable caching | The binary is ~28 MB and content-addressed by version |
| worker-src blob: in your CSP | Only if loading the worker cross-origin — see below |
| script-src 'unsafe-eval' in your CSP | The Emscripten glue is fetched as text and run via new Function. 'wasm-unsafe-eval' alone is not sufficient. |
Browser floor. Engine binaries built with WebAssembly exception handling — every asset package published after idfkit-engine#11 — need Chrome/Edge 95, Firefox 100, or Safari and iOS Safari 15.2. The engine uses exceptions to report its own fatal errors, so this is not optional on those builds. The loader itself has no such requirement, and an older asset package does not either.
Cross-origin loading. A worker script cannot itself be cross-origin, so when
workerUrl is on another origin the loader wraps it in a same-origin Blob that
importScripts() the real one. That needs worker-src blob: in your CSP, and
the remote origin must send permissive CORS headers. Serving from your own
origin avoids all of this, which is why the copy step is the documented default.
Threads. The bundled build is single-threaded, so it does not require
cross-origin isolation (COOP/COEP). If you swap in a threaded build, you
will need both headers and SharedArrayBuffer.
API
createEnergyPlus(options): Promise<EnergyPlusEngine>
Loads the runtime. Downloads and compiles ~28 MB, so call it once and reuse the result across runs.
| Option | Default | Notes |
| --- | --- | --- |
| assetBaseUrl | required | Where the WASM/IDD/datasets live. No default exists — this package ships no binary. |
| workerUrl | ${assetBaseUrl}/engine.worker.js | The copy step puts it there. Override only if the worker is served elsewhere. |
| wasmFile | energyplus.js-26.1.wasm | Must match your asset package version. |
| loaderFile | energyplus.js | Emscripten glue filename. |
| loadTimeoutMs | 30000 | Raise on slow connections. |
| onConsole | — | Every stdout/stderr line. |
| onProgress | — | Coarse phase + percentage. |
engine.run({ idf, epw?, files?, outputs? }): Promise<EngineRunResult>
Runs one simulation. HVACTemplate:* objects are expanded automatically first.
Resolves even when the engine fails. A model that does not converge is data,
not an exception — check result.success and read result.err. A failed
HVACTemplate expansion and a cancelled run also resolve. It rejects only when
the run could not be attempted at all (worker died, runtime never loaded,
engine disposed).
Returns exitCode, success, cancelled, err, eso, mtr, rdd, mdd,
sql, html, csv, expand, omittedOutputs, consoleOutput, fatalError
and crash.
crash is set only when a C++ exception unwound out of the engine instead of
being handled inside it — { type, message }, e.g.
EnergyPlus::FatalError. That is a defect in the engine binary, not in the
model: report it with the asset package version rather than asking the user to
fix their input.
Runs are serialised; a second run() while one is in flight is rejected.
Reading back less: outputs
Every output the model wrote is read back and, for eso and mtr, parsed. For
an annual timestep model that is tens of megabytes copied out of the WASM heap,
decoded, turned into Maps and structured-cloned — wasted on an app that reads
its results from the SQLite output.
const result = await ep.run({ idf, epw, outputs: { eso: false, mtr: false } });
const db = await parseSQL(result.sql!);Keys are eso, mtr, rdd, mdd, csv, html and sql; each defaults to
true, and naming one does not switch off the others. Prefer this to making the
model suppress the files with OutputControl:Files — that edits the user's
document to save work in the parser.
eplusout.err is always read: success and fatalError come from it. Whatever
you deselect is absent from the result and named in result.omittedOutputs, so
"not read" stays distinguishable from "the model never wrote one".
Models that read a file: files
Schedule:File, Table:Lookup, Chiller:Electric:ASHRAE205 and friends name a
file the engine has to be able to open. Pass its contents in files, keyed by
exactly the path the model names:
// Schedule:File,
// Occupancy, Fraction,
// occupancy.csv, !- File Name
// 1, 1, 8760, Comma, No, 60;
await ep.run({ idf, files: { 'occupancy.csv': csvText } });Values may be a string or a Uint8Array — ASHRAE 205 representations are
commonly CBOR and WINDOW data files are binary.
The rules, all of them:
- Keys are relative to the model, and subdirectories work: a model naming
data/profile.csvneeds the keydata/profile.csv. - Keys are case-sensitive, because the simulation filesystem is.
Occupancy.CSVwill not openoccupancy.csv. - A leading
/, a.., a backslash, or a name the engine owns (input.idf,weather.epw,Energy+.idd, anything underoutput/ordatasets/) is rejected —run()rejects, since that is a mistake in your code. - A model naming a file that is not in
filesfails before the engine starts, resolving withsuccess: falseand afatalErrornaming the object, the field and the path. That is a modelling problem, so it resolves like any other failed run. - Staged files last exactly one run. The next
run()on the same engine starts from a clean filesystem.
To find out what a model needs before running it — to prompt for an upload, say
— use detectExternalFileReferences(idf), exported from the package root.
ExternalInterface:* FMU objects are not covered: they need a co-simulation
partner process, not a file.
engine.cancel() / engine.dispose()
cancel() is best-effort and usually will not stop a run in progress.
callMain is synchronous inside the worker and blocks its event loop, so a
cancel message posted after the engine starts is not even dispatched until the
run has finished. In practice it only lands in the window before the engine
starts — after HVACTemplate expansion, for a model that has templates.
To stop a running simulation, dispose() and create a new engine. That
costs a full ~28 MB reload, so reserve it for a user who explicitly wants out.
When cancellation does land, run() resolves with cancelled: true rather than
rejecting.
Reading eplusout.sql
The SQLite output carries the tabular summary reports that have no ESO/MTR
equivalent. It needs sql.js, an optional peer dependency:
npm install sql.jsimport { parseSQL, configureSqlJs } from '@idfkit/engine/sql';
// Default fetches the sql.js runtime from sql.js.org. Override it if your CSP
// blocks that origin, or if you need to work offline.
configureSqlJs({ locateFile: (f) => `/sql-js/${f}` });
const db = await parseSQL(result.sql!);EnergyPlus writes ReportData — the table holding every reported value — with
no index on the variable it belongs to, so an unindexed getTimeSeries() scans
the whole run. parseSQL therefore builds that index while loading. On a 40 MB
annual file (1.8 M rows) that costs ~0.9 s once and takes five series from
~490 ms to ~130 ms; a database opened only for the tabular reports can skip it:
const db = await parseSQL(result.sql!, { indexTimeSeries: false });
renderSummary(db.getTabularData('AnnualBuildingUtilityPerformanceSummary'));
queueMicrotask(() => db.ensureTimeSeriesIndex()); // later, off the critical pathMeters and variables share one dictionary here: getVariables() returns both,
isMeter tells them apart, and getTimeSeries(id) reads either.
getEnvironments() gives each period's index (what a data point's
timestamp.environmentIndex refers to) and environmentType; its
latitude/longitude are 0, because the SQLite output stores no site
location — read those from result.eso?.environments.
A query naming a column the database does not have throws rather than answering empty. That can only be a defect in this package, and a silent empty result is indistinguishable from a model that reported nothing — which is how #14 survived a release. A table that is simply absent is still tolerated.
What is and is not supported
The browser build is not the full desktop engine. Verified against the shipped binary:
Not available
| Feature | Why |
| --- | --- |
| PythonPlugin:* / Python EMS API | Compiled with LINK_WITH_PYTHON=OFF. Use EMS/Erl instead — it is fully supported. |
| ShadowCalculation → PixelCounting | The one GPU path in EnergyPlus (OpenGL via Penumbra). Compiled with OPENGL_REQUIRED=OFF. Degrades gracefully: warns and reverts to PolygonClipping. |
| GHX borehole field design | Needs PYTHON_CLI. Modelling a pre-sized GHX is fine. |
| Basement, Slab, EPMacro, IDFVersionUpdater | Separate Fortran binaries; BUILD_FORTRAN=OFF. Pre-run natively. |
| ExternalInterface:* (FMU / BCVTB) | Needs sockets and a co-simulation partner process. |
| Multithreading | Single-threaded by design — which is why no COOP/COEP headers are needed. |
Available: HVACTemplate:* (via the bundled ExpandObjects), EMS/Erl, SQLite
output, Foundation:Kiva, AirflowNetwork, conduction finite difference and PCM.
Memory ceiling: 1 GB (starts at 256 MB and grows). Large models — many zones, sub-hourly output, long run periods — can exhaust it. Request fewer output variables or shorten the run period before suspecting the model.
Full detail, including agent-facing guidance, is in
.agents/skills/using-idfkit-engine/references/capability-support.md.
Validation
Every release is verified to reproduce the native EnergyPlus 26.1.0 build across annual simulations, on a corpus chosen to stress distinct physics:
| Case | Stresses | Data points |
| --- | --- | --- |
| envelope-freefloat | Conduction and infiltration, no HVAC to damp errors | 1,242,704 |
| solar-fenestration | Solar position, transmission, angular dependence | 1,523,712 |
| thermal-mass-pcm | Phase-change material via conduction finite difference | 387,552 |
| hvac-airloop-plant | Air loop + plant, component sizing, controller iteration | 639,480 |
Each reported variable is reduced to a fingerprint — count, sum, min, max and an
order-sensitive checksum — and compared against the native result. Aggregates
(sum, checksum) must agree to 1e-9 relative; order statistics
(min, max) to 1e-6, because a single controller iterating once more at one
timestep relocates an extremum. Variable and data-point counts must match
exactly.
The worst divergence measured across the corpus is 3.5e-9, on the maximum of one VAV outlet humidity ratio — whose own sum and checksum agree to better than 1e-9. Everything else is tighter.
Reproduce it yourself:
npm run differential:reference # needs a native EnergyPlus install
npm run test:differential # needs only a browserOn ASHRAE Standard 140 / BESTEST. This suite is not Standard 140. Those case files are copyrighted by ASHRAE and are redistributed by nobody — they are absent from both the EnergyPlus source tree and its installer — so this corpus uses EnergyPlus's own BSD-licensed test files instead.
The claim above is deliberately narrow and entirely about this package: the WebAssembly build is numerically indistinguishable from the native build. The Standard 140 validation of the simulation engine itself is NREL's, performed and published by them for EnergyPlus. What this test establishes is that compiling to WebAssembly does not disturb it.
Attribution
This package runs EnergyPlus version 26.1.0 software, compiled to
WebAssembly. EnergyPlus is developed by NREL and the U.S. Department of Energy
and is distributed under its own licence — see LICENSE-EnergyPlus.txt and
NOTICE in @idfkit/engine-assets.
This project is not affiliated with, endorsed by, or sponsored by NREL, the U.S. Department of Energy, or any EnergyPlus copyright holder.
The loader, worker and parsers in this package are MIT licensed.
