DEV Community

Oleksandr
Oleksandr

Posted on

20 Minutes of Battery Operation in 0.30 Seconds

That's the output of a pytest test running against a simulated 10 kWh battery — 20 minutes of physics, 0.30 seconds of wall clock time.

In Part 1 I explained why I built DER Twin — a Modbus simulator for energy devices. The short version: I spent a year building EMS software without a proper test environment and it was painful. Testing required physical hardware, every cycle took too long, and we were essentially testing on production.

This article is about what becomes possible once you have a simulator. Specifically: how to write automated tests for EMS control logic that run in seconds, reproduce any scenario deterministically, and require no hardware at all.


The Setup

To make this concrete I put together a small demo project alongside the simulator. It has a simple EMS — SimpleEMS — that connects to a BESS over Modbus TCP and cycles it between 40% and 60% SOC. There's a thin Modbus client that reads and writes registers by name, a main.py to run it against a live simulator, and the integration tests we'll walk through below.

SimpleEMS is deliberately minimal — about 60 lines. It reads SOC and working status on each poll, enables the device if it's idle, and charges or discharges depending on which side of the 40–60% window the SOC is on. Simple enough to understand immediately, realistic enough to demonstrate the testing patterns that matter.


Running the Example

DER Twin is available on PyPI so there's no need to clone the simulator itself — just install it:

git clone https://github.com/AlexSpivak/ems-demo.git
cd ems-demo
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
Enter fullscreen mode Exit fullscreen mode

pip install dertwin pulls in the simulator as a dependency. No hardware, no Docker, no infrastructure.

To run the EMS against a live simulator, start the simulator in one terminal and the EMS in another:

# Terminal 1
dertwin -c site_config.json

# Terminal 2
python main.py
Enter fullscreen mode Exit fullscreen mode
[EMS] Connected to BESS
[EMS] Starting in CHARGE mode
[EMS] STATUS=1.0 | SOC= 50.10% | P= -20.00 kW | MODE=charge
[EMS] STATUS=1.0 | SOC= 50.30% | P= -20.00 kW | MODE=charge
Enter fullscreen mode Exit fullscreen mode

This is the manual workflow — useful for development. But the point of this article is the automated test workflow where you don't need a running simulator at all.


The Key Concept: Headless Mode

DER Twin has two modes:

real_time: true — the engine runs its own async loop. Use this when running the simulator as a standalone process that your EMS connects to over Modbus TCP.

real_time: false — the engine has no clock. You drive it step by step from your test code by calling step_once(). This is the mode that makes everything below possible.

async def run_steps(site, n: int):
    for _ in range(n):
        await site.engine.step_once()
Enter fullscreen mode Exit fullscreen mode

Each call advances the simulation by one step (0.1s by default). You control time completely — you can simulate hours in milliseconds.


Building a Site in Code

SiteController is just a Python class. You can instantiate it directly without a config file — no JSON, no filesystem — which is exactly what you want in tests:

from dertwin.controllers.site_controller import SiteController

site = SiteController({
    "site_name": "test-site",
    "step": 0.1,
    "real_time": False,
    "register_map_root": "register_maps",
    "assets": [{
        "type": "bess",
        "capacity_kwh": 10.0,
        "initial_soc": 40.0,
        "max_charge_kw": 20.0,
        "max_discharge_kw": 20.0,
        "protocols": [{
            "kind": "modbus_tcp",
            "ip": "127.0.0.1",
            "port": 55501,
            "unit_id": 1,
            "register_map": "bess_modbus.yaml",
        }],
    }],
})

site.build()
Enter fullscreen mode Exit fullscreen mode

Want a dual-BESS site? Add another asset to the list. Want PV and a meter? Add those too. The full site topology is a Python dict — you compose it however your test needs it.


Example 1 — Fast Forward

A 10 kWh battery charging at 20 kW takes 20 minutes to go from 40% to ~90% SOC in real life. In headless mode the same scenario runs in a fraction of a second:

@pytest.mark.asyncio
async def test_fast_forward():
    site = make_site([bess_asset(port=55501, initial_soc=40.0)])
    site.build()
    task = asyncio.create_task(site.start())

    await wait_ready(55501)

    bess = site.controllers[0].device
    bess.apply_commands({"start_stop_standby": 1, "active_power_setpoint": -20})

    # 20 minutes = 1200 seconds = 12000 steps at 0.1s
    start = time.perf_counter()
    await run_steps(site, 12000)
    elapsed = time.perf_counter() - start

    print(f"Simulated 20 minutes in {elapsed:.2f}s")
    assert bess.soc >= 89.0
Enter fullscreen mode Exit fullscreen mode

Output:

Simulated 20 minutes in 0.30s
PASSED
Enter fullscreen mode Exit fullscreen mode

20 minutes of battery operation in 0.30 seconds. You can simulate an entire day of operation — charge cycles, peak shaving, frequency response events — in a few seconds as part of a normal CI run.


Example 2 — Deterministic Replay

Every simulation run with the same config produces identical results. You can reproduce any scenario exactly:

@pytest.mark.asyncio
async def test_deterministic_replay():

    async def run_scenario(port: int) -> list[float]:
        site = make_site([bess_asset(port=port, initial_soc=50.0)])
        site.build()
        task = asyncio.create_task(site.start())
        samples = []
        await wait_ready(port)
        bess = site.controllers[0].device
        bess.apply_commands({"start_stop_standby": 1, "active_power_setpoint": -20})
        for _ in range(200):
            await run_steps(site, 5)
            samples.append(round(bess.soc, 4))
        await site.stop()
        task.cancel()
        return samples

    run1 = await run_scenario(port=55601)
    run2 = await run_scenario(port=55602)

    assert run1 == run2
Enter fullscreen mode Exit fullscreen mode

This matters more than it might seem. If your asset is being certified for grid frequency response — FCR-D on the Nordic market, for example — you need to rehearse the exact disturbance scenario repeatedly until you're confident every register responds correctly. With a deterministic simulator you can run the same hour-long prequalification test in seconds, as many times as you need, before touching real hardware. If something fails, you can reproduce it exactly.


Example 3 — Assert on EMS Control Logic

This is the most useful pattern. Instead of running the full EMS loop as an async task — which introduces timing dependencies — we simulate the control logic directly against the device and assert it makes the right decisions at every step:

@pytest.mark.asyncio
async def test_ems_soc_bounds():
    site = make_site([bess_asset(port=55701, initial_soc=50.0)])
    site.build()
    task = asyncio.create_task(site.start())

    await wait_ready(55701)
    bess = site.controllers[0].device
    bess.apply_commands({"start_stop_standby": 1})
    await run_steps(site, 10)

    soc_samples = []
    mode = "charge"

    for _ in range(8000):
        await run_steps(site, 1)
        soc = bess.soc
        soc_samples.append(soc)

        if mode == "charge":
            bess.apply_commands({"active_power_setpoint": -20})
            if soc >= 60.0:
                mode = "discharge"
        elif mode == "discharge":
            bess.apply_commands({"active_power_setpoint": 20})
            if soc <= 40.0:
                mode = "charge"

    print(f"SOC range: {min(soc_samples):.1f}% – {max(soc_samples):.1f}%")

    assert min(soc_samples) >= 38.0
    assert max(soc_samples) <= 62.0
Enter fullscreen mode Exit fullscreen mode

Output:

SOC range over 8000 steps: 40.0% – 60.0%
PASSED
Enter fullscreen mode Exit fullscreen mode

8000 steps is 800 simulated seconds — roughly 13 minutes of BESS operation, enough for several full charge/discharge cycles. The control logic from SimpleEMS is mirrored directly in the test loop, which means we're testing the exact same decision logic the EMS runs in production. On real hardware this would take actual minutes per cycle with no way to assert on intermediate states.


All Three Tests Together

pytest test_examples.py -v

test_examples.py::test_fast_forward PASSED
test_examples.py::test_deterministic_replay PASSED
test_examples.py::test_ems_soc_bounds PASSED

3 passed in 1.71s
Enter fullscreen mode Exit fullscreen mode

Three tests covering fast-forward, determinism, and control logic correctness. 1.71 seconds total. The equivalent real-hardware test time would be measured in hours.


What This Enables

Once you have this pattern in place you can write tests for things that were previously impossible to automate:

  • SOC boundary violations — does your EMS ever accidentally overcharge or overdischarge?
  • Ramp rate behavior — does power ramp correctly when you change setpoints?
  • Mode transition logic — does the EMS switch modes at exactly the right thresholds?
  • Recovery after fault — what happens when the BESS trips and comes back online?
  • Multi-asset coordination — if you have two BESS units, do they stay independent?

All of these become standard pytest tests that run in CI on every push. No hardware. No lab. No waiting.


The Code

Demo project with all examples:
github.com/AlexSpivak/ems-demo

Simulator library:
github.com/AlexSpivak/dertwinpip install dertwin

In Part 3 I'll walk through the simulator architecture — how the engine, device models, and protocol layer are structured, and how to add support for new device types.

Top comments (0)