Back to list
dev_to 2026年3月21日

20 分間のバッテリー動作を 0.30 セカンズドで

20 Minutes of Battery Operation in 0.30 Seconds

Translated: 2026/3/21 3:06:58
battery-management-systemenergy-simulatortesting-frameworkasync-pythoniot-protocol

Japanese Translation

これは、10 kWh のバッテリーをシミュレートした pytest テストによる出力です。20 分の物理シミュレーションを、0.30 セカンズドという実際の壁掛け時計時間で実行しました。第 1 部では、DER Twin(エネルギー機器向けの Modbus シミュレーター)を作成した理由について説明しました。簡潔に言えば、適切ないくつかのテスト環境がなかった状態で 1 年間 EMS ソフトウェアを作成し、それが苦痛であったことです。テストには物理的なハードウェアが必要であり、各サイクルが非常に時間がかかり、実質的には生産環境でテストを行わなければなりませんでした。この記事は、シミュレーターを所有することにより可能になることについてです。具体的には、数秒以内に実行し、任意のシナリオを決定論的に再現し、ハードウェアが一切不要な EMS 制御ロジックの自動化されたテスト手法についてです。具体的に理解を深めるために、上記のシミュレーターに沿った小型のデモプロジェクトを作成しました。これには SimpleEMS というシンプルな EMS が含まれており、Modbus TCP を介して BESS と接続し、SOC(荷電状態)を 40% から 60% の間で循環させる機能を備えています。レジスター名を読み書きする薄い Modbus クライアント、ライブシミュレーターに対して実行する main.py、そして以下で解説する統合テストが含まれています。SimpleEMS は意図的にミニマルで、約 60 リンクで構成されています。各Pollで SOC と工作状态を読み取り、装置がアイドルの場合は有効化し、SOC が 40–60% のウィンドウのどの側にあるかに応じて充電または放電します。すぐに理解可能であり、テストパターンを実証するのに十分現実的です。DER Twin は PyPI 利用可能なので、シミュレーター自体をクローンする必要はありません。ただインストールすればよいです: `git clone https://github.com/AlexSpivak/ems-demo.git` `cd ems-demo` `python -m venv .venv && source .venv/bin/activate` `pip install -e ".[dev]"` `pip install dertwin` これにより、シミュレーターが依存関係として引込まれます。ハードウェアも Docker もインフラも必要ありません。 EMS ライブシミュレーターに対して実行するには、1 つのターミナルでシミュレーターを開始し、もう 1 つのターミナルで EMS を実行します: # Terminal 1 `dertwin -c site_config.json` # Terminal 2 `python main.py` [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 これは手動のワークフローであり、開発には役立ちます。しかし、この記事の目的は、シミュレーターを動かす必要もない自動化されたテストワークフローです。DER Twin は 2 つのモードを持っています: `real_time: true` — エンジン自身のアシンクロンループを実行します。これは、あなたの EMS が Modbus TCP を介して接続するスタンドアロンプロセスとしてシミュレーターを実行する場合に使います。 `real_time: false` — エンジンには時計がありません。あなたのテストコードから `step_once()` を呼び出すことで、一歩ずつ操作します。これは、以下のすべてを可能にするモードです。 ```python async def run_steps(site, n: int): for _ in range(n): await site.engine.step_once() ``` 各呼び出しは、シミュレーションを進める 1 ステップ(デフォルトでは 0.1s)を前進させます。あなたは完全に時間を制御できます。ミリ秒で時間を数時間シミュレーションできます。SiteController は単なる Python クラスです。設定ファイルなしで直接インスタンス化できます。JSON やファイルシステムなしです。これがテストに正確に必要とされることです: ```python 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() ``` デュアル-BESS サイトを求めますか?リストに追加の資産を追加します。PV やメーターを求めますか?それらも追加してください。 完全なサイトトポロジは Python dict です。あなたのテストのニーズに合わせてどのように構成するかです。実世界では、10 kWh のバッテリーを 20 kW で充電すると、40% から約 90% の SOC まで 20 分かかります。ヘッドレスモードでは、同じシナリオは数分の一秒で実行されます: ```python @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, "activ...`

Original Content

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. 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. 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]" 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 [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 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. 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() Each call advances the simulation by one step (0.1s by default). You control time completely — you can simulate hours in milliseconds. 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() 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. 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 Output: Simulated 20 minutes in 0.30s PASSED 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. 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 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. 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 Output: SOC range over 8000 steps: 40.0% – 60.0% PASSED 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. 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 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. 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. Demo project with all examples: github.com/AlexSpivak/ems-demo Simulator library: github.com/AlexSpivak/dertwin — pip 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.