Digital Twin infrastructure for modern energy systems.
DER Twin is a lightweight simulator for Distributed Energy Resources (DER) β BESS, PV inverters, CHP units, energy meters, and grid models β exposed via Modbus TCP and Modbus RTU. Use it for EMS development, protocol testing, integration validation, and control algorithm sandboxing without touching real hardware.
pip install dertwinBring your own site config and register maps:
dertwin -c path/to/your/config.jsonYou should see:
INFO | Building site: my-site
INFO | Starting Modbus TCP server | 0.0.0.0:55001 | unit=1
INFO | Simulation engine started | step=0.100s
The simulator is now accepting Modbus TCP connections on the ports defined in your config.
git clone https://github.com/AlexSpivak/dertwin.git
cd dertwin
python -m venv .venv && source .venv/bin/activate
pip install -e .
python -m dertwin.main -c configs/simple_config.jsongit clone https://github.com/AlexSpivak/dertwin.git
cd dertwin
python generate_compose.py configs/simple_config.json
docker compose up --buildgenerate_compose.py reads the config and generates a docker-compose.yml with the correct ports automatically. No manual port configuration needed. For mixed-protocol configs with RTU, see Docker with RTU below.
With the simulator running, start the example EMS from a second terminal:
cd examples
python main_simple.pyYou'll see the EMS connecting over Modbus and cycling the BESS between 40β60% SOC:
[EMS] Connected to BESS
[EMS] Starting in CHARGE mode
[EMS] STATUS=1 | SOC= 42.30% | P= -20.00 kW | MODE=charge
[EMS] STATUS=1 | SOC= 44.10% | P= -20.00 kW | MODE=charge
...
[EMS] Reached 60% β switching to DISCHARGE
For a full multi-device site (dual BESS + PV + CHP + energy meter + external models):
python -m dertwin.main -c configs/full_site_config.json
# in another terminal:
python examples/main_full.pyYou'll see the CHP go through its realistic startup sequence (~2 minutes) before reaching RUNNING and dispatching power, while the BESS units cycle and the energy meter aggregates the full site balance.
For a mixed-protocol site (BESS on TCP + PV and meter on RTU), see Mixed Protocol Example below.
In addition to config-driven site definitions, DERTwin's SiteController supports adding and removing assets at runtime. Use this for scenario scripts ("at t=300s, a new PV inverter comes online"), live demos, and EMS-driven sites where the asset roster is decided by an external controller.
from dertwin.controllers.site_controller import SiteController
import asyncio
# Build an empty site
site = SiteController({
"site_name": "demo",
"step": 0.1,
"real_time": True,
"register_map_root": "configs/register_maps",
"assets": [],
})
site.build()
asyncio.create_task(site.start())
# Add an asset live β protocol server starts immediately, site power model picks it up next tick
await site.add_asset({
"asset_id": "bess-01",
"type": "bess",
"ip": "127.0.0.1",
"port": 55001,
"unit_id": 1,
"capacity_kwh": 200.0,
"initial_soc": 50.0,
})
# ...later, remove it
await site.remove_asset("bess-01")Key properties:
- The site power model captures device lists by reference, so dynamically added generators show up in the energy meter's grid balance on the next tick β no rebuild required.
add_assetis idempotent: re-registering an existingasset_idis a no-op.remove_assetcancels the protocol task and shuts down the server cleanly; the engine keeps running for the remaining assets.- See
dertwin/controllers/README.mdfor the full API and the two supported configuration shapes (legacyprotocols: [...]for multi-protocol use, flat spec for single-TCP-endpoint runtime use).
- Async Modbus TCP and RTU servers built on
pymodbus - Mixed-protocol support β TCP and RTU devices on the same site
- Full 16-bit Modbus address space on all four datastores (discrete inputs, coils, input registers, holding registers)
- Per-register function code routing β FC02 discrete inputs for binary flags, FC03/04 for analog telemetry
- Per-register endianness β big-endian default, little-endian for Sungrow/Carlo Gavazzi-style devices
- Realistic CHP simulation β MWM TEM Evolution-compatible state machine, configurable startup timings, thermal physics, heat output
- Config-driven site topology β add devices by editing JSON
- Runtime asset management β
add_asset/remove_assetfor live scenario scripting and EMS-driven rosters - Irradiance, ambient temperature, grid frequency, and grid voltage models
- Multi-device support across independent ports
- External model events (voltage sags, frequency deviations)
- Simulation start time control (
start_time_h) β start at noon, peak load, etc. - Docker support with auto-generated Compose files and RTU-over-TCP bridging
- Deterministic simulation with seeded random models
- Fully tested with
pytest
dertwin/
βββ configs/
β βββ register_maps/ # Modbus register definitions (YAML)
β βββ simple_config.json # Single BESS β good starting point
β βββ demo_config.json # BESS + PV + meter
β βββ full_site_config.json # BESS + BESS + PV + CHP + meter + external models
β βββ mixed_protocol_config.json # BESS (TCP) + PV (RTU) + meter (RTU)
βββ dertwin/
β βββ core/ # Clock, engine, register map loader
β βββ controllers/ # Site and device orchestration (incl. dynamic add/remove)
β βββ devices/ # BESS, PV, CHP, energy meter, external models
β βββ protocol/ # Modbus TCP + RTU servers
β βββ telemetry/ # Telemetry dataclasses
β βββ main.py
βββ examples/
β βββ simple/ # Single BESS EMS example
β βββ full/ # Multi-device EMS example with CHP (TCP)
β βββ mixed/ # Mixed-protocol EMS example (TCP + RTU)
β βββ protocol/ # Shared Modbus TCP and RTU clients
βββ tests/ # Full test suite
βββ generate_compose.py # Docker Compose generator
βββ docker-entrypoint.sh # Container entrypoint (socat + RTU bridge)
βββ Dockerfile
Sites are defined in JSON. Each asset declares its type, parameters, and protocol bindings:
{
"site_name": "my-site",
"step": 0.1,
"real_time": true,
"start_time_h": 12.0,
"register_map_root": "register_maps",
"external_models": {
"irradiance": { "peak": 1000.0, "sunrise": 6.0, "sunset": 18.0 },
"grid_frequency": { "nominal_hz": 50.0, "noise_std": 0.002, "seed": 42 }
},
"assets": [
{
"type": "bess",
"capacity_kwh": 100.0,
"initial_soc": 60.0,
"protocols": [{ "kind": "modbus_tcp", "ip": "0.0.0.0", "port": 55001, "unit_id": 1, "register_map": "bess_modbus.yaml" }]
},
{
"type": "chp",
"rated_kw": 4000.0,
"heat_to_power_ratio": 1.0,
"min_load_percent": 30.0,
"max_load_percent": 110.0,
"protocols": [{ "kind": "modbus_tcp", "ip": "0.0.0.0", "port": 55002, "unit_id": 1, "register_map": "chp_modbus.yaml" }]
}
]
}real_time: true β engine runs its own loop, use for dertwin CLI and EMS examples
real_time: false β caller drives the clock via step_once(), use for tests
start_time_h β sets simulation clock on startup (e.g. 12.0 for noon). All external models start from this time.
register_map_root β path to register map directory, resolved relative to the working directory where you run dertwin
ip: "0.0.0.0" β required when running inside Docker so port mapping works. Use 127.0.0.1 for local-only.
type |
Class | Notable parameters |
|---|---|---|
bess |
BESSSimulator |
capacity_kwh, initial_soc, max_charge_kw, max_discharge_kw, ramp_rate_kw_per_s |
inverter |
PVSimulator |
rated_kw, module_efficiency, area_m2 |
chp |
CHPSimulator |
rated_kw, heat_to_power_ratio, min_load_percent, max_load_percent |
energy_meter |
EnergyMeterSimulator |
(no parameters β observes the site power model) |
Each asset's protocols array supports both Modbus TCP and Modbus RTU. A single device can expose multiple protocols simultaneously.
Modbus TCP:
{ "kind": "modbus_tcp", "ip": "0.0.0.0", "port": 55001, "unit_id": 1, "register_map": "bess_modbus.yaml" }Modbus RTU:
{ "kind": "modbus_rtu", "port": "/tmp/dertwin_device", "baudrate": 9600, "parity": "N", "stopbits": 1, "unit_id": 1, "register_map": "bess_modbus.yaml" }RTU parameters baudrate, parity, stopbits, bytesize, and timeout all have sensible defaults (9600/N/1/8/1.0) and can be omitted.
Register map fields:
| Field | Required | Description |
|---|---|---|
name |
yes | Human-readable label, used in logs and the EMS client |
internal_name |
yes | Maps to the device's internal telemetry or command field β must match the attribute name in the corresponding telemetry class (see dertwin/telemetry/README.md) |
address |
yes | Modbus register address (0β65535) |
type |
yes | uint16, int16, uint32, int32 |
scale |
yes | Multiplier applied on read, divisor applied on write |
count |
yes | Number of registers (1 for 16-bit, 2 for 32-bit) |
func |
yes | Function code: 0x02 discrete input read, 0x03 holding read, 0x04 input read, 0x06 single write, 0x10 multi-register write |
direction |
yes | read or write |
endian |
no | big (default) or little β for devices like Sungrow BESS and Carlo Gavazzi meters that use little-endian 32-bit register layout |
unit |
no | Physical unit label (V, kW, Hz, etc.) |
description |
no | Free-text note |
options |
no | Enum mapping for status/mode registers |
name and internal_name can differ β name is what the EMS client sees, internal_name is what the device simulator uses internally. For example, on_grid_power_setpoint (name) maps to active_power_setpoint (internal_name) on the BESS device.
For detailed architecture and per-package docs, see dertwin/README.md.
This example runs a site with BESS on Modbus TCP and PV + energy meter on Modbus RTU. The EMS controls the BESS over TCP and monitors the RTU devices for observability.
Install socat to create virtual serial port pairs:
# macOS
brew install socat
# Ubuntu / Debian
sudo apt install socatTerminal 1 β create virtual serial pairs and start the simulator:
# Create virtual serial port pairs (simulator <-> EMS client)
socat -d -d pty,raw,echo=0,link=/tmp/dertwin_pv pty,raw,echo=0,link=/tmp/dertwin_pv_client &
socat -d -d pty,raw,echo=0,link=/tmp/dertwin_meter pty,raw,echo=0,link=/tmp/dertwin_meter_client &
# Start the simulator (from repo root)
dertwin -c configs/mixed_protocol_config.jsonYou should see:
INFO | Building site: mixed-protocol-site
INFO | Starting Modbus TCP server | 0.0.0.0:55001 | unit=1
INFO | Starting Modbus RTU server | port=/tmp/dertwin_pv | baudrate=9600 | unit=1
INFO | Starting Modbus RTU server | port=/tmp/dertwin_meter | baudrate=9600 | unit=1
INFO | Simulation engine started | step=0.100s
Terminal 2 β run the mixed-protocol EMS:
cd examples
python main_mixed.pyExpected output:
[BESS-1] TCP connected
[PV] RTU connected
[METER] RTU connected
[BESS-1] Starting in CHARGE mode
[EMS] Mixed-protocol EMS running
[BESS-1] RUN | SOC= 50.0% | P= -30.00 kW | MODE=charge
[PV] P= 16.40 kW (producing)
[METER] Grid= +23.57 kW (importing) | Freq=50.000 Hz | Import=0.1 kWh | Export=0.0 kWh
The key point: socat creates a pair of linked pseudo-terminals for each connection. The simulator opens one end (/tmp/dertwin_pv) and the EMS client opens the other (/tmp/dertwin_pv_client). Both sides must use different ends of the pair.
If RTU serial ports are unavailable, the EMS will still run with BESS-only control β PV and meter telemetry will show as unavailable.
For configs that only use Modbus TCP, Docker setup is straightforward:
python generate_compose.py configs/full_site_config.json
docker compose up --buildTCP ports are mapped automatically from the config. Connect your EMS to localhost:<port>.
RTU serial devices use pseudo-terminals (/dev/pts/) which exist only inside the container's kernel namespace and can't be accessed from the host via volume mounts. The entrypoint solves this by bridging each RTU serial port to a TCP port inside the container using a Python asyncio relay. From the host, socat converts the TCP connection back into a local PTY that the EMS opens as a normal serial port.
Step 1 β generate the compose file and start the container:
python generate_compose.py configs/mixed_protocol_config.json
docker compose up --buildYou should see:
[entrypoint] Creating serial pair: /tmp/dertwin_pv <-> /tmp/dertwin_pv_bridge
[entrypoint] Bridging /tmp/dertwin_pv_bridge -> TCP port 56001
[bridge] /tmp/dertwin_pv_bridge <-> TCP :56001
[entrypoint] Creating serial pair: /tmp/dertwin_meter <-> /tmp/dertwin_meter_bridge
[entrypoint] Bridging /tmp/dertwin_meter_bridge -> TCP port 56002
[bridge] /tmp/dertwin_meter_bridge <-> TCP :56002
[entrypoint] RTU bridges ready
[entrypoint] Starting DERTwin simulator
INFO | Starting Modbus TCP server | 0.0.0.0:55001 | unit=1
INFO | Starting Modbus RTU server | port=/tmp/dertwin_pv | baudrate=9600 | unit=1
INFO | Starting Modbus RTU server | port=/tmp/dertwin_meter | baudrate=9600 | unit=1
generate_compose.py detects RTU ports in the config and automatically exposes the bridge TCP ports (56001, 56002, ...) alongside the regular Modbus TCP ports in the generated docker-compose.yml.
Step 2 β in a separate terminal on the host, create local PTY endpoints (requires socat):
socat pty,raw,echo=0,link=/tmp/dertwin_pv_client tcp:localhost:56001 &
socat pty,raw,echo=0,link=/tmp/dertwin_meter_client tcp:localhost:56002 &This creates /tmp/dertwin_pv_client and /tmp/dertwin_meter_client on the host β the same paths the EMS expects.
Step 3 β run the EMS (same command as the local setup):
cd examples
python main_mixed.pyThe EMS uses the exact same config for both local and Docker setups β the serial paths (/tmp/dertwin_pv_client, /tmp/dertwin_meter_client) are identical. The only difference is how those paths are created: locally via direct socat pairs, or via Docker with TCP bridging.
docker run \
-v /path/to/my/configs:/app/configs:ro \
-e CONFIG_PATH=/app/configs/my_site.json \
-p 55001:55001 \
dertwin-simulatorpytestThe test suite covers device physics (BESS, PV, CHP, energy meter), register encoding with per-register endianness, FC02 discrete input routing, external models, protocol parity (TCP and RTU), mixed-protocol engine integration, runtime asset add/remove, and full end-to-end site integration via Modbus. See tests/ for structure.
- Scenario engine β scripted event sequences
- Dynamic asset add/remove at runtime
- CHP support with realistic state machine
- Modbus FC02 discrete input support
- Per-register endianness (big/little)
- Modbus RTU support
- Mixed-protocol sites (TCP + RTU)
- Docker RTU-over-TCP bridging
- Published PyPI package
- EMS algorithm development and validation
- SCADA/HMI integration testing
- Protocol compliance testing (TCP and RTU)
- DER fleet orchestration prototyping
- Frequency and voltage response simulation
- Mixed-protocol site simulation
- CHP dispatch and startup-sequence testing against EMS
- Live scenario scripting β bring assets online and offline during a simulation
- EMS-in-the-loop tests where the asset roster is decided by the controller under test
Contributions are welcome. Before diving in, read dertwin/README.md β it covers the simulator architecture, how devices are modeled, the engine and clock design, and how to add new device types or protocols.
See CONTRIBUTING.md for full guidelines, including how to add new protocols and test RTU without hardware.
MIT License
Oleksandr Spivak