Simulation Architecture¶
This page explains the design decisions behind idfkit's simulation module and why certain approaches were chosen.
Python only, permanently
Running a locally installed EnergyPlus and reading its results belongs to Python alone. JavaScript is not waiting on a port and will not gain a counterpart: this is a permanent boundary, so no issue tracks it, and moving the capability out of that state takes a constitutional amendment rather than a ledger edit.
Requires an EnergyPlus installation on the machine and a subprocess to drive it, then
reads the eplusout files that run leaves on disk. Neither the installation nor the
subprocess is available in a browser, which is the runtime the JavaScript library
targets. JavaScript reaches EnergyPlus by the other mechanism instead: see
browser-simulation, which is not a
workaround for this entry but a different capability.
The full entry, including the vocabulary this capability owns, is on the capability parity page.
Subprocess Execution¶
idfkit runs EnergyPlus as a subprocess rather than linking to its libraries directly. This approach has several benefits:
- Isolation — Each simulation runs in its own directory with clean state
- Compatibility — Works with any EnergyPlus version (8.9+)
- Robustness — Crashes in EnergyPlus don't crash your Python process
- Simplicity — No C++ bindings or version-specific compilation needed
The simulate() function:
- Creates an isolated temporary directory
- Copies the model (as IDF) and weather file
- Injects
Output:SQLiteif not present - Invokes the EnergyPlus executable
- Returns a
SimulationResultwith access to all outputs
from idfkit.simulation import simulate
result = simulate(model, "weather.epw", design_day=True)
print(f"Outputs in: {result.run_dir}")
SQLite Over ESO¶
idfkit's result parsers focus on the SQLite output database rather than the traditional ESO/MTR text files. The SQLite format:
- Contains all simulation data in one queryable file
- Provides structured access to time-series and tabular data
- Is faster to parse than text formats
- Includes metadata (environments, variables, units)
The module automatically ensures Output:SQLite is present in your model,
so you don't need to add it manually.
# All time-series data is accessible via SQL queries
ts = result.sql.get_timeseries(
variable_name="Zone Mean Air Temperature",
key_value="ZONE 1",
)
# Tabular reports (normally in HTML) are also in SQLite
tables = result.sql.get_tabular_data("AnnualBuildingUtilityPerformanceSummary")
Why SQLite first?¶
idfkit reads results from the SQLite output by default because it exposes the same data more reliably and uniformly than the text formats. The other output formats are still parsed on demand when you need them:
| Format | Accessor |
|---|---|
| SQLite (time-series + tabular) | result.sql |
| ESO / MTR (time-series) | result.eso / result.mtr |
| HTML (tabular reports) | result.html |
| CSV (ReadVarsESO output) | result.csv |
EIO metadata is available through the SQLite metadata tables.
Lazy Loading¶
SimulationResult uses lazy loading — output files are only parsed when
you access them:
result = simulate(model, weather) # Fast: just runs EnergyPlus
# These are lazy — parsed on first access:
result.errors # Parses ERR file
result.sql # Opens SQLite database
result.variables # Parses RDD file
This keeps memory usage low and startup fast, especially for batch simulations where you might only need specific outputs.
Model Immutability¶
The simulate() function copies your model before simulation:
result = simulate(model, weather)
# model is unchanged — Output:SQLite was added to a copy
assert "Output:SQLite" not in model
This ensures:
- Your original model isn't mutated
- Multiple simulations can run concurrently with the same base model
- No unexpected side effects
EnergyPlus Discovery¶
idfkit auto-discovers EnergyPlus installations using a priority chain:
- Explicit config — Pass an
EnergyPlusConfigasenergyplus=tosimulate(), or apath=tofind_energyplus() - Environment variable — Set
ENERGYPLUS_DIR - System PATH — Looks for
energyplusexecutable /opt/eplus— Standard install location in Claude Code web sessions- Platform defaults:
- macOS:
/Applications/EnergyPlus-*/ - Linux:
/usr/local/EnergyPlus-*/ - Windows:
C:\EnergyPlusV*/
- macOS:
When multiple versions are found in the default directories, the most recent version is selected.
from idfkit.simulation import find_energyplus
config = find_energyplus()
print(f"Version: {config.version}")
print(f"Path: {config.executable}")
Concurrent Execution¶
For parametric studies, simulate_batch() runs simulations in parallel
using a thread pool:
from idfkit.simulation import simulate_batch, SimulationJob
jobs = [
SimulationJob(model=variant1, weather="weather.epw", label="case-1"),
SimulationJob(model=variant2, weather="weather.epw", label="case-2"),
]
batch = simulate_batch(jobs, max_workers=4)
Each simulation runs in its own subprocess and directory, so there are no conflicts between concurrent runs.
Async Execution¶
The async simulation API (async_simulate, async_simulate_batch,
async_simulate_batch_stream) provides non-blocking counterparts to the
sync API using Python's asyncio module.
Why Async?¶
The sync API blocks the calling thread during subprocess.run(). This is
fine for scripts but problematic when:
- Running inside an async web server (FastAPI, aiohttp)
- Mixing simulations with other async I/O (network, database)
- Wanting streaming progress without callbacks
How It Works¶
The async runner replaces subprocess.run() with
asyncio.create_subprocess_exec(). All preparation steps (model copy,
directory setup, cache lookup) are synchronous and fast — only the
EnergyPlus subprocess execution is truly async.
Preprocessing (ExpandObjects, Slab, Basement) uses subprocess.run()
internally. Rather than rewriting the entire preprocessor stack, these
are delegated to a thread via asyncio.to_thread() so they don't block
the event loop.
Concurrency Model¶
| API | Concurrency mechanism |
|---|---|
simulate_batch() |
ThreadPoolExecutor with max_workers |
async_simulate_batch() |
asyncio.Semaphore with max_concurrent |
Both achieve the same effect: limiting the number of concurrent EnergyPlus subprocesses to avoid overwhelming the system.
Streaming¶
async_simulate_batch_stream() uses an asyncio.Queue to decouple
producer tasks from the consumer's async for loop. Events arrive in
completion order. Breaking out of the loop cancels remaining tasks.
from idfkit.simulation import async_simulate_batch_stream
async def report_progress() -> None:
async for event in async_simulate_batch_stream(jobs, max_concurrent=4):
print(f"[{event.completed}/{event.total}] {event.label}")
See Also¶
- Caching Strategy — Content-addressed result caching
- How to use cloud and remote storage — S3 and custom backends
- How to run a simulation — Practical guide