Skip to content

Authoring and parametrizing models

Every built-in physical model is a ModelComponent (energysim.models.common), a SimulatorAdapter with an ergonomic contract. You can write your own in a few lines, reuse the lookup helpers (Curve1D, Surface2D) and the OperatingStateMachine, or just re-parametrize an existing model.

What ModelComponent gives you

You write The framework provides
register_variables(inputs=[...], outputs=[...], defaults={...}) in __init__ strict variable access: set_value accepts only declared inputs and registered parameters, get_value only declared outputs / inputs / parameters; anything else raises SimulatorVariableError listing the valid names
register_parameters('a', 'b') numeric attributes that set_value, options({'init': ...}), ensembles and sweeps may overwrite
step_model(dt, time) step / step_partial / advance (a shorter final step gets the true dt)
a class attribute units = {'var': 'unit'} unit-aware connections and Results.units, see Units
nothing outputs that are valid right after init() (evaluated with step_model(0.0, 0.0) and the state restored), re-runnable init() / reset() (the first init() snapshots the instance, later ones restore it), describe() / describe_class()
optionally time_to_event() event hints for variable macro steps

Rules of thumb: create all state in __init__ (it is snapshotted by the first init()), make step_model(0.0, t) change no state (quiescent at dt = 0), write every declared output into self._outputs_val, read inputs from self._inputs_val, and give every input and output a unit.

A complete example: a supercapacitor

import math

from energysim import world
from energysim.base import SimulatorVariableError
from energysim.models.common import ModelComponent


class Supercap(ModelComponent):
    """Supercapacitor module: dV/dt = (I - I_leak) / C and P = V * I."""

    units = {"P_cmd": "kW", "P_actual": "kW", "V": "V", "SoC": "1"}

    def __init__(self, name="supercap", capacitance_f=500.0, v_rated=48.0, i_leak_a=0.002, v_init=24.0,
                 step_size=1.0):
        super().__init__(name=name, step_size=step_size)
        self.capacitance_f = float(capacitance_f)
        self.v_rated = float(v_rated)
        self.i_leak_a = float(i_leak_a)
        self.v = float(v_init)
        self.register_variables(inputs=["P_cmd"], outputs=["P_actual", "V", "SoC"], defaults={"P_cmd": 0.0})
        self.register_parameters("capacitance_f", "i_leak_a")

    def step_model(self, dt, time):
        p_w = 1000.0 * self._inputs_val["P_cmd"]
        i = p_w / max(self.v, 1.0)
        if (i > 0 and self.v >= self.v_rated - 0.01) or (i < 0 and self.v <= 1.0):
            i = 0.0                                             # full / empty
        self.v = min(self.v_rated, max(0.0, self.v + (i - self.i_leak_a) / self.capacitance_f * dt))
        self._outputs_val["P_actual"] = self.v * i / 1000.0
        self._outputs_val["V"] = self.v
        self._outputs_val["SoC"] = (self.v / self.v_rated) ** 2

    def time_to_event(self):                                    # seconds until the module is full
        i = 1000.0 * self._inputs_val["P_cmd"] / max(self.v, 1.0)
        if i <= 0 or self.v >= self.v_rated:
            return math.inf
        return (self.v_rated - self.v) * self.capacitance_f / i


cap = Supercap()
cap.init()
print(cap.get_value(["V", "SoC"], 0.0))                       # valid before the first step: [24.0, 0.25]
print(cap.describe()["inputs"], cap.describe()["units"]["V"])  # ['P_cmd'] V
try:
    cap.set_value(["nonsense"], [1.0])
except SimulatorVariableError as exc:
    print(exc)                      # supercap: 'nonsense' is not a declared input or parameter. ...

w = world(start_time=0, stop_time=600, t_macro=60, t_macro_min=1)
w.add_signal("cmd", {"type": "step", "time": 120, "before": 0.0, "after": 5.0}, step_size=60, units={"y": "kW"})
w.add_model("cap", cap, step_size=1)
w.add_connections({"cmd.y": "cap.P_cmd"})

res = w.simulate(pbar=False, record_all=False)
print(res["cap.V"].iloc[-1])                                  # full at 48 V
print([round(t, 1) for t in res["cap"].index[:7]])            # steps shrink towards the full point
print(res.units["cap.V"], w.simulate(pbar=False, record_all=False)["cap.V"].iloc[-1])   # re-runnable

w2 = world(start_time=0, stop_time=60, t_macro=60)
w2.add_model("cap", Supercap, v_init=12.0)                    # a class: constructed with the extra keywords
print(w2.get_adapter("cap").v)                                # 12.0

add_model(name, model, ...) also accepts a class or factory (w.add_model("cap", Supercap, v_init=12.0); extra keyword arguments are passed to it). Inputs and outputs default to the registered ones; pass outputs=[...] to record fewer.

Lookup curves and surfaces

Curve1D(x, y, clamp=True, min_val=None, max_val=None, name='') interpolates piecewise linearly and, by default, clamps queries to the data range (no wild extrapolation) and to optional output bounds. Surface2D(x, y, z) is the bilinear counterpart with z.shape == (len(x), len(y)). Both are immutable by convention, are shared (not copied) by the init() snapshot, and Curve1D serialises with to_dict() / from_dict() (Surface2D does not). Like Curve1D, Surface2D accepts NumPy arrays of query points, so one call evaluates a whole vectorized fleet.

from energysim.models.common import Curve1D, Surface2D

load = [0.2, 0.4, 0.6, 0.8, 1.0]
eta = [0.28, 0.36, 0.42, 0.44, 0.435]            # efficiency from a test bench (your data)
eff = Curve1D(load, eta, min_val=0.1, max_val=0.5, name="efficiency")

print(eff(0.72))                # 0.432 (interpolated)
print(eff(2.0))                 # 0.435 (clamped to the last point)
print(eff([0.3, 0.5]))          # array([0.32, 0.39])
print(Curve1D.from_dict(eff.to_dict())(0.72))

derate = Surface2D(x=[0, 10, 20], y=[0, 1], z=[[1, 2], [3, 4], [5, 6]])
print(derate(5, 0.5))           # 2.5

Several built-in models take curves as constructor arguments, for example BatteryBESS(ocv_curve=..., rin_curve=..., inverter_curve=...), GasTurbine_OCGT(ambient_derate_curve=..., heat_rate_curve=...) and CombinedCycle_CCGT(ambient_derate_curve=...).

Start-up timers and ramps: OperatingStateMachine

Thermal plants cannot jump to full load. OperatingStateMachine tracks the state (OFF, STANDBY, PURGE, STARTING, RUNNING, COOLDOWN, FAULT), how long the unit was offline (which picks a hot / warm / cold start duration), minimum up and down times, and ramp limits. You drive it from step_model:

from energysim import world
from energysim.models.common import ModelComponent, OperatingState, OperatingStateMachine


class Peaker(ModelComponent):
    """Gas peaker with a 5 minute start-up and ramp limits (MW)."""

    units = {"P_cmd": "MW", "P_actual": "MW", "state": "-"}

    def __init__(self, p_rated_mw=25.0, step_size=10.0):
        super().__init__(name="peaker", step_size=step_size)
        self.p_rated_mw = p_rated_mw
        self.sm = OperatingStateMachine(ramp_up_rate_min=5.0, ramp_down_rate_min=10.0,
                                        hot_start_duration_sec=300.0, warm_start_duration_sec=300.0,
                                        cold_start_duration_sec=300.0)
        self.p = 0.0
        self.register_variables(inputs=["P_cmd"], outputs=["P_actual", "state"], defaults={"P_cmd": 0.0})

    def step_model(self, dt, time):
        sm = self.sm
        cmd = min(self._inputs_val["P_cmd"], self.p_rated_mw)
        sm.update_time(dt)
        if cmd > 0 and sm.state in (OperatingState.OFF, OperatingState.STANDBY):
            sm.current_startup_category = sm.determine_startup_category()
            sm.transition(OperatingState.STARTING)
        if sm.state == OperatingState.STARTING and sm.time_in_state_sec >= sm.get_startup_duration():
            sm.transition(OperatingState.RUNNING)
        if sm.state == OperatingState.RUNNING:
            self.p = sm.apply_ramp(self.p, cmd, dt)
            if cmd <= 0 and self.p <= 0:
                sm.transition(OperatingState.OFF)
        self._outputs_val["P_actual"] = self.p
        self._outputs_val["state"] = sm.state.value


w = world(start_time=0, stop_time=1200, t_macro=60)
w.add_signal("cmd", 20.0, step_size=60, units={"y": "MW"})
w.add_model("gt", Peaker(), step_size=10)
w.add_connections({"cmd.y": "gt.P_cmd"})
res = w.simulate(pbar=False, record_all=False)
print(res["gt"].loc[[240.0, 360.0, 540.0], ["P_actual", "state"]])      # STARTING -> RUNNING -> ramped to 20 MW

The built-in GasTurbine_OCGT, CombinedCycle_CCGT, ReciprocatingGenset and CHP_SteamTurbine use the same machine; their time_to_event() reports when a start-up or minimum up/down timer ends.

Re-parametrizing a built-in model

Constructor arguments are the parameters. describe() lists them with their defaults, inputs, outputs and units; the generated component pages document each model's variables and validity range.

from energysim.models import CombinedCycle_CCGT, PEMElectrolyzer
from energysim.models.common import Curve1D

# Site-specific ambient derating for a combined cycle (multiplier on rated power vs ambient degC)
derate = Curve1D([-10, 0, 15, 30, 45], [1.10, 1.05, 1.00, 0.90, 0.78], name="site derate")
ccgt = CombinedCycle_CCGT(name="site_ccgt", p_rated_mw=250.0, min_stable_gen_pct=30.0,
                          ramp_rate_mw_min=20.0, ambient_derate_curve=derate)

pem = PEMElectrolyzer(name="pem", p_nominal_mw=5.0, min_turndown=0.05, t_stack_nom_c=65.0)

print(sorted(ccgt.describe()["parameters"])[:4])
print(pem.describe()["outputs"][:3])

A model whose parameters are inconsistent refuses to construct: for example a PEMElectrolyzer whose p_nominal_mw exceeds what its cell count and area can absorb raises a ValueError that names the fix.

Your own archetype catalog

The built-in energysim.models.catalog holds generic archetypes (BESS_2h_2MW, BESS_4h_2MW, Alkaline_2MW, PEM_5MW, SOEC_1MW, CCGT_570MW, Gas_Turbine_60MW, Gas_Engine_10MW, Solar_Farm, Wind_Turbine_4MW), each an illustrative archetype with a provenance note (catalog.PROVENANCE), not a manufacturer datasheet. The manufacturer-branded names of 2.x (Tesla_Megapack_2XL, Nel_A1000, GE_7HA_02, ...) are deprecated aliases that emit a DeprecationWarning. Put your own verified equipment into a module of factories in the same style:

from energysim.models import BatteryBESS, catalog


def substation_battery(name="booster", **kwargs):
    """2 MW / 2 MWh battery for our substations (parameters from our own acceptance tests)."""
    params = dict(capacity_mwh=2.0, p_max_mw=2.0, soc_init=0.8)
    params.update(kwargs)
    return BatteryBESS(name=name, **params)


bess = substation_battery(soc_init=0.6)
generic = catalog.BESS_2h_2MW(name="reference")
print(bess.capacity_mwh, generic.capacity_mwh, catalog.PROVENANCE["BESS_2h_2MW"])