DEV Community

Oleksandr
Oleksandr

Posted on

How DER Twin Works — Architecture of an Open Source Energy Device Simulator

This is Part 3 of my series on DER Twin. Part 1 covered why I built it. Part 2 showed how to use it for EMS testing. This one is about how it works inside.

If you want to contribute — add a new device type, extend the physics, or add a new protocol — this is the article to read first.


The Four Layers

DER Twin is built around a strict separation of concerns. Every component lives in exactly one layer and communicates only through defined interfaces.


Protocol layer — accepts Modbus connections and exposes register addresses. Never touches device state directly.

Controller layer — maps register reads and writes to device telemetry and commands. Owns the encoding/decoding logic.

Device layer — owns physics. Knows nothing about protocols. A BESS device doesn't know if it's being read via TCP or RTU.

Simulation engine — owns time. Calls step(dt) on every device on every tick.

External models — the environment. Irradiance, ambient temperature, grid frequency, grid voltage. Devices read from them, never write to them.

This separation is the most important architectural decision in the project. It's what makes the simulator extensible — you can add a new protocol without touching device code, and add a new device without touching protocol code.


The Simulation Engine

The engine is the heartbeat of the simulator. On each tick it advances every device by one time step:

async def step_once(self):
    dt = self.clock.step
    self.external_models.update(self.sim_time, dt)
    for controller in self.devices:
        controller.step(dt)
    self.clock.advance()
Enter fullscreen mode Exit fullscreen mode

The clock has two modes:

real_time: true — the engine sleeps between ticks to match wall clock time. Use this for live operation where your EMS polls at a realistic rate.

real_time: false — no sleeping. The engine runs as fast as the CPU allows. This is what makes 20 minutes of simulation complete in 0.30 seconds.


The clock also has a start_time_h parameter — set it to 12.0 and the simulation starts at solar noon. All external models see the correct time from the first tick.


Device Models

Each device type follows the same interface:

class SimulatedDevice:
    def update(self, dt: float) -> None: ...
    def get_telemetry(self) -> ...: ...
    def apply_commands(self, commands: dict) -> dict: ...
Enter fullscreen mode Exit fullscreen mode

update() advances the physics by dt seconds. get_telemetry() returns the current state snapshot. apply_commands() accepts a dict of register name → value and applies whatever the device understands.

BESS

The BESS model is built around energy integration. On each step it tracks SOC, enforces power limits, and applies ramp rate constraints:


Power is clamped to max_charge_kw and max_discharge_kw. A ramp rate limits how fast power changes when a new setpoint arrives — the BESS doesn't jump instantly to full power, as real hardware wouldn't. SOC is updated each step:

SOC += (power_kw / capacity_kwh) * (dt / 3600) * 100
Enter fullscreen mode Exit fullscreen mode

Temperature from the ambient model affects available power. Grid voltage and frequency are reflected in telemetry and can trigger protection responses.

PV Inverter — DC/AC Separation

The PV model separates DC generation from AC conversion, mirroring the physical reality of a solar installation:


DC side (PVArrayModel) converts irradiance into available DC power using a NOCT-based thermal model. Cell temperature rises with irradiance — above 25°C the module loses roughly 0.4% per degree, which meaningfully affects output on hot days.

AC side (PVInverterModel) takes DC input and applies inverter efficiency, AC rating clamp, and curtailment. It also runs grid protection — if voltage or frequency moves outside safe limits the inverter trips and sets a fault code. The thermal model tracks inverter temperature as a function of conversion losses.

Both total_input_power (DC) and total_active_power (AC) are exposed in telemetry so your EMS can read both sides of the conversion. The panel doesn't know about the grid and the inverter doesn't know about irradiance — extending one doesn't require touching the other.


External Models

External models represent the environment that devices operate in. They're updated once per tick before devices are stepped, so every device sees a consistent environment snapshot within each step.


The irradiance model produces a sine curve between sunrise and sunset:

angle = π * (time_h - sunrise) / (sunset - sunrise)
irradiance = peak * sin(angle)  # zero outside daylight hours
Enter fullscreen mode Exit fullscreen mode

Grid frequency and voltage models add Gaussian noise and slow drift on top of nominal values, with event injection for voltage sags and frequency deviations. All models accept a seed parameter for full determinism.


The Protocol Layer

This is where DER Twin becomes genuinely useful for integration testing — it speaks the same protocol your real devices use.

Modbus TCP

{
  "kind": "modbus_tcp",
  "ip": "0.0.0.0",
  "port": 55001,
  "unit_id": 1,
  "register_map": "bess_modbus.yaml"
}
Enter fullscreen mode Exit fullscreen mode

Each device gets its own async TCP server. Multiple devices run on separate ports simultaneously. Your EMS connects exactly as it would to real hardware.

Modbus RTU

As of the latest release, DER Twin also supports Modbus RTU over serial:

{
  "kind": "modbus_rtu",
  "port": "/tmp/dertwin_device",
  "baudrate": 9600,
  "parity": "N",
  "stopbits": 1,
  "unit_id": 1,
  "register_map": "bess_modbus.yaml"
}
Enter fullscreen mode Exit fullscreen mode

For development and CI without real serial hardware, use socat to create a virtual serial port pair:

socat -d -d pty,raw,echo=0,link=/tmp/sim_port pty,raw,echo=0,link=/tmp/client_port &
Enter fullscreen mode Exit fullscreen mode

Point the simulator at /tmp/sim_port and your EMS client at /tmp/client_port. The rest works identically to TCP.

A single device can expose both protocols at the same time — just list both in its protocols array. A site can freely mix TCP and RTU devices.

Protocol Abstraction

The controller layer is transport-agnostic. DeviceController accesses .context and .unit_id on any protocol object regardless of whether it's TCP or RTU. Adding a new protocol means implementing two methods — run_server() and shutdown() — and registering the new kind in SiteController._build_protocol(). The device layer doesn't change at all.

IEC 61850, DNP3, and MQTT are all on the roadmap. The architecture is ready for them.


Register Maps

Register maps are YAML files that define the bridge between the protocol layer and the device layer. Each entry maps a Modbus address to a device telemetry or command field:

- name: active_power
  internal_name: active_power
  address: 32064
  func: 0x04
  direction: read
  type: int32
  count: 2
  scale: 0.1
  unit: kW

- name: on_grid_power_setpoint
  internal_name: active_power_setpoint
  address: 10126
  func: 0x10
  direction: write
  type: int32
  count: 2
  scale: 0.1
  unit: kW
Enter fullscreen mode Exit fullscreen mode

name is what your EMS sees on the wire. internal_name maps to the device's internal attribute. They can differ — on_grid_power_setpoint on the wire becomes active_power_setpoint inside the BESS. This lets the simulator match real device register naming while keeping internal code clean.

Register maps are user-owned files. If you're integrating a specific manufacturer's device, you write a register map that matches their documentation and point the simulator at it. No code changes needed.


Contributing

The architecture is designed to make extensions straightforward. Full guidelines are in CONTRIBUTING.md, but the short version:

Adding a new device type:

  1. Create dertwin/devices/<your_device>/simulator.py implementing update(dt) and get_telemetry()
  2. Create a telemetry dataclass in dertwin/telemetry/
  3. Add a register map YAML in configs/register_maps/
  4. Wire it into SiteController._create_device()
  5. Add tests

Adding a new protocol:

  1. Implement run_server() and shutdown() in dertwin/protocol/
  2. Register the new kind in SiteController
  3. The device layer needs no changes

The separation between layers means you rarely need to touch more than one part of the codebase for any given change.


Documentation and Installation

Full documentation, setup guides, and API reference are at dertwin.com.

pip install dertwin
Enter fullscreen mode Exit fullscreen mode

Source: github.com/AlexSpivak/dertwin

Top comments (0)