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()
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: ...
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
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
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"
}
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"
}
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 &
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
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:
- Create
dertwin/devices/<your_device>/simulator.pyimplementingupdate(dt)andget_telemetry() - Create a telemetry dataclass in
dertwin/telemetry/ - Add a register map YAML in
configs/register_maps/ - Wire it into
SiteController._create_device() - Add tests
Adding a new protocol:
- Implement
run_server()andshutdown()indertwin/protocol/ - Register the new
kindinSiteController - 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
Source: github.com/AlexSpivak/dertwin
Top comments (0)