Skip to content

Ensembles, Monte-Carlo studies and sweeps

energysim.Ensemble runs many variants of one co-simulation (a parameter grid, random samples, noisy inputs, or just different seeds) and collects the outcomes. energysim.Sweep is the one-at-a-time / full-factorial special case built on top of it.

Key properties:

  • Every run gets a fresh world from your factory, so there is no state leakage between runs and the order of parameters never changes a result.
  • Seeds are derived with numpy.random.SeedSequence(master).spawn(n): the same master seed gives the same run seeds, independent of serial or process execution.
  • A failing run is recorded (status, traceback) and does not abort the ensemble unless you ask for on_error='raise'.

A first ensemble

The factory has the signature factory(params: dict, seed: int) -> world and returns a world that is ready to simulate. params holds the sampled values, seed the run seed (use it for stochastic inputs).

from energysim import Ensemble, noisy_signal, world
from energysim.ensemble import Uniform
from energysim.models import BatteryBESS


def build(params, seed):
    w = world(start_time=0, stop_time=6 * 3600, t_macro=900, seed=seed)
    # a set-point of 0.4 MW with seeded, autocorrelated additive noise (sigma 0.5 MW)
    cmd = noisy_signal(lambda t: 0.4, sigma=0.5, seed=seed, rel=False, ar1=0.9, dt=900.0)
    w.add_signal("cmd", cmd, step_size=900, units={"y": "MW"})
    w.add_model("bess", BatteryBESS(capacity_mwh=params.get("capacity_mwh", 4.0), p_max_mw=1.0, soc_init=0.5),
                step_size=60)
    w.add_connections({"cmd.y": "bess.P_cmd"})
    return w


ens = Ensemble(
    build,
    sampler=Uniform({"capacity_mwh": (2.0, 8.0)}, n=8, seed=1),     # 8 random capacities
    seeds=42,                                                       # master seed for the run seeds
    kpis={"soc_end": lambda r: float(r["bess.SoC"].iloc[-1])},      # your own scalar per run
    variables=["bess.SoC"], kpi_names=["min", "max"],               # plus registered KPIs per variable
)
res = ens.run()                                       # an EnsembleResults

print(res)                                            # EnsembleResults(runs=8, completed=8, failed=0, keep='all')
print(res.params.head(3))                             # one row per run: sampled parameters and the 'seed' column
print(res.status["status"].value_counts().to_dict())  # status, error, duration_s per run
print(res.kpis().round(3).head(3))                    # one row per run
print(res.summary().round(3))                         # count, mean, std, min, quantiles, max per KPI

Looking at the runs

fan = res.fan("bess.SoC", quantiles=(0.1, 0.5, 0.9))   # quantile bands over time (needs keep='all')
wide = res.to_frame("bess.SoC")                        # one column per run
one = res.run(0)                                       # the Results of run 0
every = res.ensemble                                   # all runs in one Results (is_ensemble == True)
again = ens.rerun(3)                                   # re-execute run 3 (same params and seed) in this process
print(fan.iloc[[0, -1]].round(3))
print(wide.shape, one["bess.SoC"].iloc[-1], every.is_ensemble, again["bess.SoC"].iloc[-1])

Run keys are the integers 0..n-1. With keep='kpis' only the KPI table is retained (use it for thousands of runs); runs, run(), fan() and to_frame() then raise EnsembleError.

Samplers

Sampler Use
Grid({'a': [1, 2], 'b': [10, 20, 30]}) full factorial
Scenarios([{...}, {...}]) explicit list (Ensemble(..., scenarios=[...]) is a shortcut)
Uniform({'x': (lo, hi)}, n=, seed=) independent uniform draws
Normal({'x': (mean, std)}, n=, seed=) independent normal draws
LatinHypercube({'x': (lo, hi)}, n=, seed=, method=) stratified samples (scipy's QMC, or a NumPy fallback)
Sobol({'x': (lo, hi)}, n=, seed=) scrambled Sobol' points (needs scipy; prefer n a power of two)

Random samplers are deterministic given seed. With no sampler, Ensemble(build, n_runs=100) gives 100 parameter-free runs that differ only by their seed, which is what a pure noise study needs. The name seed is reserved for the run seed and cannot be used as a parameter.

Applying parameters

apply_params(world, {'sim.var': value}) merges values into world.options({'init': ...}); they are applied at the start of every run. A factory can use it, or you can let Ensemble.from_world do it for a parameter-free world factory:

from energysim import Ensemble, world
from energysim.ensemble import Grid
from energysim.models import BatteryBESS


def plain_world():
    w = world(start_time=0, stop_time=3600, t_macro=300)
    w.add_signal("cmd", 0.5, step_size=300, units={"y": "MW"})
    w.add_model("bess", BatteryBESS(capacity_mwh=4.0, p_max_mw=1.0, soc_init=0.5), step_size=60)
    w.add_connections({"cmd.y": "bess.P_cmd"})
    return w


ens = Ensemble.from_world(
    plain_world,
    parameter_map={"capacity": "bess.capacity_mwh"},         # optional: sampled name -> 'sim.var'
    sampler=Grid({"capacity": [2.0, 4.0, 8.0]}),
    kpis={"soc_end": lambda r: float(r["bess.SoC"].iloc[-1])},
)
print(ens.run().kpis()["soc_end"].round(3).tolist())            # a smaller pack charges further

Only registered parameters and declared inputs of a ModelComponent can be set; anything else raises SimulatorVariableError at the start of the run, which the ensemble records as a failed run.

Uncertain inputs: NoisyInput

noisy_signal(base, sigma, seed, rel=True, ar1=0.9, dt=1.0) (class NoisyInput) wraps a callable f(t), a number, or a pandas.Series indexed by time. The perturbation is piecewise constant on a grid of dt seconds, optionally AR(1) correlated (ar1), multiplicative (rel=True, base * (1 + n)) or additive. It is a pure function of t for a given seed, so it does not depend on how often or in which order it is evaluated, and the result is identical across serial and process runs. Use the run seed of the factory to give every run its own realisation.

Parallel runs

Ensemble(..., parallel='process', max_workers=4) uses a spawn process pool. The factory (and every KPI callable) must then be importable from a module: lambdas and closures (<lambda> / <locals>) and unpicklable objects are rejected with a hint, and functions defined in __main__ of an interactive session will fail in the workers. Worker results are bit-identical to the serial ones.

```python notest

runs in a script, not in the docs test (needs an importable module and a main guard)

from energysim import Ensemble from my_study import build, final_soc # module-level functions

if name == "main": res = Ensemble(build, n_runs=200, seeds=7, parallel="process", max_workers=4, keep="kpis", kpis={"soc_end": final_soc}).run()

## Persisting

`Ensemble(..., out_dir="study")` (or `res.save("study")`) writes the parameter, status and KPI tables and, with
`keep='all'`, every run; `EnsembleResults.load("study")` restores them.

## Sweeps

`Sweep(world_or_factory, sensitivity_info, kind='single' | 'parametric')` evaluates one output (a metric of one
`'sim.var'`) over a grid of parameters.

* `kind='single'` varies each parameter on its own around the baseline (the others untouched).
* `kind='parametric'` runs the full factorial grid (at least two parameters).
* `bounds` entries are `[lo, hi]` (10 points) or `[lo, hi, n]`; `metric` is one of `last`, `mean`, `max`, `min`,
  `sum`, `integral` (zero-order-hold integral over seconds).
* A world instance is deep-copied for every run and never modified; pass a factory `f(params, seed)` for worlds that
  cannot be copied.

```python
from energysim import Sweep, world
from energysim.models import BatteryBESS

w = world(start_time=0, stop_time=3600, t_macro=300)
w.add_signal("cmd", 0.5, step_size=300, units={"y": "MW"})
w.add_model("bess", BatteryBESS(capacity_mwh=4.0, p_max_mw=1.0, soc_init=0.5), step_size=60)
w.add_connections({"cmd.y": "bess.P_cmd"})

info = {
    "parameters": ["bess.capacity_mwh", "bess.soc_max"],
    "bounds": [[2.0, 8.0, 4], [0.7, 0.95, 3]],
    "output_parameter": "bess.SoC",
    "metric": "last",
}
single = Sweep(w, info, kind="single").sweep()            # SweepResult
print(single.table.round(3))                              # columns: parameter, value, result
print(single.baseline)                                    # dict {'values': {...}, 'result': float}; None if a factory was passed

grid = Sweep(w, info, kind="parametric").sweep()
print(grid.table.shape)                                   # (12, 3): both parameters plus 'result'
grid.export_to_csv("sweep.csv")
fig = grid.plot()                                         # heatmap for two parameters (needs matplotlib)

SweepResult.ensemble is the underlying EnsembleResults; Sweep(..., keep='all', parallel='process') forwards extra keywords to Ensemble. SweepResult.plot(kind='curves') plots the output time series of single sweeps and needs keep='all'.