<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Oleksandr</title>
    <description>The latest articles on DEV Community by Oleksandr (@oleksandrs).</description>
    <link>https://dev.to/oleksandrs</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3815678%2F1a8b761d-c7a1-451b-b01d-2c36fb5515ef.jpeg</url>
      <title>DEV Community: Oleksandr</title>
      <link>https://dev.to/oleksandrs</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/oleksandrs"/>
    <language>en</language>
    <item>
      <title>How DER Twin Works — Architecture of an Open Source Energy Device Simulator</title>
      <dc:creator>Oleksandr</dc:creator>
      <pubDate>Tue, 07 Apr 2026 12:34:37 +0000</pubDate>
      <link>https://dev.to/oleksandrs/how-der-twin-works-architecture-of-an-open-source-energy-device-simulator-fd0</link>
      <guid>https://dev.to/oleksandrs/how-der-twin-works-architecture-of-an-open-source-energy-device-simulator-fd0</guid>
      <description>&lt;p&gt;This is Part 3 of my series on DER Twin. &lt;a href="https://dev.to/oleksandrs/why-i-built-a-modbus-simulator-after-a-year-of-testing-on-production-47o"&gt;Part 1&lt;/a&gt; covered why I built it. &lt;a href="https://dev.to/oleksandrs/20-minutes-of-battery-operation-in-030-seconds-27d5"&gt;Part 2&lt;/a&gt; showed how to use it for EMS testing. This one is about how it works inside.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Four Layers
&lt;/h2&gt;

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




&lt;h2&gt;
  
  
  &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffhlzubrc87s8b42mx795.png" alt=" " width="800" height="779"&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Protocol layer&lt;/strong&gt; — accepts Modbus connections and exposes register addresses. Never touches device state directly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Controller layer&lt;/strong&gt; — maps register reads and writes to device telemetry and commands. Owns the encoding/decoding logic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Device layer&lt;/strong&gt; — owns physics. Knows nothing about protocols. A BESS device doesn't know if it's being read via TCP or RTU.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Simulation engine&lt;/strong&gt; — owns time. Calls &lt;code&gt;step(dt)&lt;/code&gt; on every device on every tick.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;External models&lt;/strong&gt; — the environment. Irradiance, ambient temperature, grid frequency, grid voltage. Devices read from them, never write to them.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Simulation Engine
&lt;/h2&gt;

&lt;p&gt;The engine is the heartbeat of the simulator. On each tick it advances every device by one time step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;step_once&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;dt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;clock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;step&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;external_models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sim_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;controller&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;devices&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;clock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;advance&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The clock has two modes:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;real_time: true&lt;/code&gt;&lt;/strong&gt; — the engine sleeps between ticks to match wall clock time. Use this for live operation where your EMS polls at a realistic rate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;real_time: false&lt;/code&gt;&lt;/strong&gt; — no sleeping. The engine runs as fast as the CPU allows. This is what makes 20 minutes of simulation complete in 0.30 seconds.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxhg3w5efx6wsfvuop7qg.png" alt=" " width="800" height="816"&gt;
&lt;/h2&gt;

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




&lt;h2&gt;
  
  
  Device Models
&lt;/h2&gt;

&lt;p&gt;Each device type follows the same interface:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SimulatedDevice&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_telemetry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;...:&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;apply_commands&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;commands&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;h3&gt;
  
  
  BESS
&lt;/h3&gt;

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




&lt;h2&gt;
  
  
  &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4fe3k8m7u60bkamwspk5.png" alt=" " width="800" height="611"&gt;
&lt;/h2&gt;

&lt;p&gt;Power is clamped to &lt;code&gt;max_charge_kw&lt;/code&gt; and &lt;code&gt;max_discharge_kw&lt;/code&gt;. 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;SOC&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;power_kw&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;capacity_kwh&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dt&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Temperature from the ambient model affects available power. Grid voltage and frequency are reflected in telemetry and can trigger protection responses.&lt;/p&gt;

&lt;h3&gt;
  
  
  PV Inverter — DC/AC Separation
&lt;/h3&gt;

&lt;p&gt;The PV model separates DC generation from AC conversion, mirroring the physical reality of a solar installation:&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6f5nheocd5t789lr6xci.png" alt=" " width="800" height="589"&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;DC side&lt;/strong&gt; (&lt;code&gt;PVArrayModel&lt;/code&gt;) 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AC side&lt;/strong&gt; (&lt;code&gt;PVInverterModel&lt;/code&gt;) 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.&lt;/p&gt;

&lt;p&gt;Both &lt;code&gt;total_input_power&lt;/code&gt; (DC) and &lt;code&gt;total_active_power&lt;/code&gt; (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.&lt;/p&gt;




&lt;h2&gt;
  
  
  External Models
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fogx7mqr689p2d21mwtjk.png" alt=" " width="800" height="415"&gt;
&lt;/h2&gt;

&lt;p&gt;The irradiance model produces a sine curve between sunrise and sunset:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;angle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;π&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time_h&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;sunrise&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sunset&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;sunrise&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;irradiance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;peak&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;angle&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# zero outside daylight hours
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;code&gt;seed&lt;/code&gt; parameter for full determinism.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Protocol Layer
&lt;/h2&gt;

&lt;p&gt;This is where DER Twin becomes genuinely useful for integration testing — it speaks the same protocol your real devices use.&lt;/p&gt;

&lt;h3&gt;
  
  
  Modbus TCP
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"kind"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"modbus_tcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ip"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0.0.0.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"port"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;55001&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"unit_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"register_map"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bess_modbus.yaml"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Modbus RTU
&lt;/h3&gt;

&lt;p&gt;As of the latest release, DER Twin also supports Modbus RTU over serial:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"kind"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"modbus_rtu"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"port"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/tmp/dertwin_device"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"baudrate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;9600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"parity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"N"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"stopbits"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"unit_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"register_map"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bess_modbus.yaml"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For development and CI without real serial hardware, use &lt;code&gt;socat&lt;/code&gt; to create a virtual serial port pair:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;socat &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; pty,raw,echo&lt;span class="o"&gt;=&lt;/span&gt;0,link&lt;span class="o"&gt;=&lt;/span&gt;/tmp/sim_port pty,raw,echo&lt;span class="o"&gt;=&lt;/span&gt;0,link&lt;span class="o"&gt;=&lt;/span&gt;/tmp/client_port &amp;amp;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Point the simulator at &lt;code&gt;/tmp/sim_port&lt;/code&gt; and your EMS client at &lt;code&gt;/tmp/client_port&lt;/code&gt;. The rest works identically to TCP.&lt;/p&gt;

&lt;p&gt;A single device can expose both protocols at the same time — just list both in its &lt;code&gt;protocols&lt;/code&gt; array. A site can freely mix TCP and RTU devices.&lt;/p&gt;

&lt;h3&gt;
  
  
  Protocol Abstraction
&lt;/h3&gt;

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

&lt;p&gt;IEC 61850, DNP3, and MQTT are all on the roadmap. The architecture is ready for them.&lt;/p&gt;




&lt;h2&gt;
  
  
  Register Maps
&lt;/h2&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;active_power&lt;/span&gt;
  &lt;span class="na"&gt;internal_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;active_power&lt;/span&gt;
  &lt;span class="na"&gt;address&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;32064&lt;/span&gt;
  &lt;span class="na"&gt;func&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;0x04&lt;/span&gt;
  &lt;span class="na"&gt;direction&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;int32&lt;/span&gt;
  &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
  &lt;span class="na"&gt;scale&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.1&lt;/span&gt;
  &lt;span class="na"&gt;unit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kW&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;on_grid_power_setpoint&lt;/span&gt;
  &lt;span class="na"&gt;internal_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;active_power_setpoint&lt;/span&gt;
  &lt;span class="na"&gt;address&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10126&lt;/span&gt;
  &lt;span class="na"&gt;func&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;0x10&lt;/span&gt;
  &lt;span class="na"&gt;direction&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;int32&lt;/span&gt;
  &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
  &lt;span class="na"&gt;scale&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.1&lt;/span&gt;
  &lt;span class="na"&gt;unit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kW&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  Contributing
&lt;/h2&gt;

&lt;p&gt;The architecture is designed to make extensions straightforward. Full guidelines are in &lt;a href="https://github.com/AlexSpivak/dertwin/blob/main/CONTRIBUTING.md" rel="noopener noreferrer"&gt;CONTRIBUTING.md&lt;/a&gt;, but the short version:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Adding a new device type:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create &lt;code&gt;dertwin/devices/&amp;lt;your_device&amp;gt;/simulator.py&lt;/code&gt; implementing &lt;code&gt;update(dt)&lt;/code&gt; and &lt;code&gt;get_telemetry()&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Create a telemetry dataclass in &lt;code&gt;dertwin/telemetry/&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Add a register map YAML in &lt;code&gt;configs/register_maps/&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Wire it into &lt;code&gt;SiteController._create_device()&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Add tests&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Adding a new protocol:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Implement &lt;code&gt;run_server()&lt;/code&gt; and &lt;code&gt;shutdown()&lt;/code&gt; in &lt;code&gt;dertwin/protocol/&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Register the new &lt;code&gt;kind&lt;/code&gt; in &lt;code&gt;SiteController&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;The device layer needs no changes&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The separation between layers means you rarely need to touch more than one part of the codebase for any given change.&lt;/p&gt;




&lt;h2&gt;
  
  
  Documentation and Installation
&lt;/h2&gt;

&lt;p&gt;Full documentation, setup guides, and API reference are at &lt;strong&gt;&lt;a href="https://dertwin.com" rel="noopener noreferrer"&gt;dertwin.com&lt;/a&gt;&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;dertwin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source: &lt;strong&gt;&lt;a href="https://github.com/AlexSpivak/dertwin" rel="noopener noreferrer"&gt;github.com/AlexSpivak/dertwin&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>iot</category>
      <category>opensource</category>
      <category>systemdesign</category>
    </item>
    <item>
      <title>20 Minutes of Battery Operation in 0.30 Seconds</title>
      <dc:creator>Oleksandr</dc:creator>
      <pubDate>Sat, 21 Mar 2026 02:38:38 +0000</pubDate>
      <link>https://dev.to/oleksandrs/20-minutes-of-battery-operation-in-030-seconds-27d5</link>
      <guid>https://dev.to/oleksandrs/20-minutes-of-battery-operation-in-030-seconds-27d5</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;In &lt;a href="https://dev.to/oleksandrs/why-i-built-a-modbus-simulator-after-a-year-of-testing-on-production-47o"&gt;Part 1&lt;/a&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;p&gt;To make this concrete I put together a small demo project alongside the simulator. It has a simple EMS — &lt;code&gt;SimpleEMS&lt;/code&gt; — 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 &lt;code&gt;main.py&lt;/code&gt; to run it against a live simulator, and the integration tests we'll walk through below.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SimpleEMS&lt;/code&gt; 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.&lt;/p&gt;




&lt;h2&gt;
  
  
  Running the Example
&lt;/h2&gt;

&lt;p&gt;DER Twin is available on PyPI so there's no need to clone the simulator itself — just install it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/AlexSpivak/ems-demo.git
&lt;span class="nb"&gt;cd &lt;/span&gt;ems-demo
python &lt;span class="nt"&gt;-m&lt;/span&gt; venv .venv &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;source&lt;/span&gt; .venv/bin/activate
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;".[dev]"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;pip install dertwin&lt;/code&gt; pulls in the simulator as a dependency. No hardware, no Docker, no infrastructure.&lt;/p&gt;

&lt;p&gt;To run the EMS against a live simulator, start the simulator in one terminal and the EMS in another:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Terminal 1&lt;/span&gt;
dertwin &lt;span class="nt"&gt;-c&lt;/span&gt; site_config.json

&lt;span class="c"&gt;# Terminal 2&lt;/span&gt;
python main.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Key Concept: Headless Mode
&lt;/h2&gt;

&lt;p&gt;DER Twin has two modes:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;real_time: true&lt;/code&gt;&lt;/strong&gt; — 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;real_time: false&lt;/code&gt;&lt;/strong&gt; — the engine has no clock. You drive it step by step from your test code by calling &lt;code&gt;step_once()&lt;/code&gt;. This is the mode that makes everything below possible.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run_steps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;site&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;site&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;step_once&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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




&lt;h2&gt;
  
  
  Building a Site in Code
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;SiteController&lt;/code&gt; 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dertwin.controllers.site_controller&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;SiteController&lt;/span&gt;

&lt;span class="n"&gt;site&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SiteController&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;site_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;test-site&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;step&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;real_time&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;register_map_root&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;register_maps&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;assets&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bess&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;capacity_kwh&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;10.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;initial_soc&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;40.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;max_charge_kw&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;20.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;max_discharge_kw&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;20.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;protocols&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kind&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;modbus_tcp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ip&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;port&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;55501&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unit_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;register_map&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bess_modbus.yaml&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;}],&lt;/span&gt;
    &lt;span class="p"&gt;}],&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="n"&gt;site&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  Example 1 — Fast Forward
&lt;/h2&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@pytest.mark.asyncio&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_fast_forward&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;site&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;make_site&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nf"&gt;bess_asset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;55501&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;initial_soc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;40.0&lt;/span&gt;&lt;span class="p"&gt;)])&lt;/span&gt;
    &lt;span class="n"&gt;site&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;task&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;site&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;wait_ready&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;55501&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;bess&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;site&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;controllers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;device&lt;/span&gt;
    &lt;span class="n"&gt;bess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;apply_commands&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;start_stop_standby&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;active_power_setpoint&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;

    &lt;span class="c1"&gt;# 20 minutes = 1200 seconds = 12000 steps at 0.1s
&lt;/span&gt;    &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perf_counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;run_steps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;site&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;12000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perf_counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;

    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Simulated 20 minutes in &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;elapsed&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;bess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;soc&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;89.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Simulated 20 minutes in 0.30s
PASSED
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;20 minutes of battery operation in 0.30 seconds.&lt;/strong&gt; 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.&lt;/p&gt;




&lt;h2&gt;
  
  
  Example 2 — Deterministic Replay
&lt;/h2&gt;

&lt;p&gt;Every simulation run with the same config produces identical results. You can reproduce any scenario exactly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@pytest.mark.asyncio&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_deterministic_replay&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;

    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run_scenario&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="n"&gt;site&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;make_site&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nf"&gt;bess_asset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;initial_soc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;50.0&lt;/span&gt;&lt;span class="p"&gt;)])&lt;/span&gt;
        &lt;span class="n"&gt;site&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;task&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;site&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="n"&gt;samples&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;wait_ready&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;bess&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;site&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;controllers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;device&lt;/span&gt;
        &lt;span class="n"&gt;bess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;apply_commands&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;start_stop_standby&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;active_power_setpoint&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;run_steps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;site&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;samples&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;soc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;site&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;samples&lt;/span&gt;

    &lt;span class="n"&gt;run1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;run_scenario&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;55601&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;run2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;run_scenario&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;55602&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;run1&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;run2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  Example 3 — Assert on EMS Control Logic
&lt;/h2&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@pytest.mark.asyncio&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_ems_soc_bounds&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;site&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;make_site&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nf"&gt;bess_asset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;55701&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;initial_soc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;50.0&lt;/span&gt;&lt;span class="p"&gt;)])&lt;/span&gt;
    &lt;span class="n"&gt;site&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;task&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;site&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;wait_ready&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;55701&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;bess&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;site&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;controllers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;device&lt;/span&gt;
    &lt;span class="n"&gt;bess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;apply_commands&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;start_stop_standby&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;run_steps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;site&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;soc_samples&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="n"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;charge&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8000&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;run_steps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;site&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;soc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;bess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;soc&lt;/span&gt;
        &lt;span class="n"&gt;soc_samples&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;soc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;charge&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;bess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;apply_commands&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;active_power_setpoint&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;soc&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;60.0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;discharge&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;discharge&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;bess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;apply_commands&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;active_power_setpoint&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;soc&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mf"&gt;40.0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;charge&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SOC range: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;soc_samples&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;% – &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;soc_samples&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;%&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;soc_samples&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;38.0&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;soc_samples&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mf"&gt;62.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SOC range over 8000 steps: 40.0% – 60.0%
PASSED
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;8000 steps is 800 simulated seconds — roughly 13 minutes of BESS operation, enough for several full charge/discharge cycles. The control logic from &lt;code&gt;SimpleEMS&lt;/code&gt; 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.&lt;/p&gt;




&lt;h2&gt;
  
  
  All Three Tests Together
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;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
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three tests covering fast-forward, determinism, and control logic correctness. &lt;strong&gt;1.71 seconds total.&lt;/strong&gt; The equivalent real-hardware test time would be measured in hours.&lt;/p&gt;




&lt;h2&gt;
  
  
  What This Enables
&lt;/h2&gt;

&lt;p&gt;Once you have this pattern in place you can write tests for things that were previously impossible to automate:&lt;/p&gt;

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

&lt;p&gt;All of these become standard pytest tests that run in CI on every push. No hardware. No lab. No waiting.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Code
&lt;/h2&gt;

&lt;p&gt;Demo project with all examples:&lt;br&gt;
&lt;strong&gt;&lt;a href="https://github.com/AlexSpivak/ems-demo" rel="noopener noreferrer"&gt;github.com/AlexSpivak/ems-demo&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Simulator library:&lt;br&gt;
&lt;strong&gt;&lt;a href="https://github.com/AlexSpivak/dertwin" rel="noopener noreferrer"&gt;github.com/AlexSpivak/dertwin&lt;/a&gt;&lt;/strong&gt; — &lt;code&gt;pip install dertwin&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>python</category>
      <category>ems</category>
      <category>modbus</category>
    </item>
    <item>
      <title>Why I Built a Modbus Simulator After a Year of Testing on Production</title>
      <dc:creator>Oleksandr</dc:creator>
      <pubDate>Tue, 10 Mar 2026 18:23:04 +0000</pubDate>
      <link>https://dev.to/oleksandrs/why-i-built-a-modbus-simulator-after-a-year-of-testing-on-production-47o</link>
      <guid>https://dev.to/oleksandrs/why-i-built-a-modbus-simulator-after-a-year-of-testing-on-production-47o</guid>
      <description>&lt;p&gt;While leading engineering at an energy management system startup, I ran into a problem that kept getting worse the longer I ignored it.&lt;/p&gt;

&lt;p&gt;Whenever my engineer and I needed to test something — even something as basic as reading telemetry from a battery — someone had to physically connect a device, pull a Docker image to the field hardware, run the code against it, and check whether the data made sense. Either by inspecting logs locally or watching values flow through the queue.&lt;/p&gt;

&lt;p&gt;This is done even for the simplest operation like collecting telemetry. A read operation. Of course with write operation it was getting even more complicated.&lt;/p&gt;

&lt;p&gt;We practically were testing on production. The assets weren't prequalified yet, every test cycle took longer than it should have, and I kept thinking about what happens when these batteries are actually running 24/7. You can't afford to pull a Docker image to a production BESS at 2am to check if your register parser is correct.&lt;/p&gt;

&lt;p&gt;We were a small team moving fast and building a proper test environment kept getting pushed. So we kept going the way we had been — carefully, slowly, and with more anxiety than necessary.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;The testing loop without a simulator looked pretty much like this:&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwfdyunyploxwhth3hoxy.png" alt="No simulator testing loop" width="800" height="400"&gt;
&lt;/h2&gt;

&lt;h2&gt;
  
  
  What We Were Building
&lt;/h2&gt;

&lt;p&gt;For context: we were building an Energy Management System from scratch. The stack was real — GKE, MQTT, TimescaleDB, Pub/Sub, BigQuery, Dagster, Grafana, Raspberry Pis in the field talking Modbus to actual batteries. Up to five engineers at peak. Real assets, real protocols, real production pressure.&lt;/p&gt;

&lt;p&gt;The core job of an EMS is to talk to energy devices — batteries, inverters, meters — read their state, and send commands. In our case we were preparing to participate in the Swedish frequency regulation market — FCR-D up, FCR-D down, FCR-N. These are ancillary services where your battery responds to disturbances in grid frequency, charging or discharging within seconds to help stabilise the grid. The protocol between EMS and device is usually Modbus TCP: a simple binary protocol over TCP where you read and write registers at specific addresses.&lt;/p&gt;

&lt;p&gt;The prequalification process for these markets is a formal test — roughly an hour where the TSO verifies your asset actually responds correctly to frequency disturbances. You can unit test the algebra behind your control logic all day. But at some point you have to run your code against a real device and watch every register react correctly to a simulated disturbance event. One hour, one shot, no room for a parsing bug you didn't catch.&lt;/p&gt;

&lt;p&gt;The problem with testing this kind of system is that the other side of the connection is a physical device. You can't mock a Modbus server in a unit test and call it done — register maps differ between manufacturers, scale factors are easy to get wrong, and the only way to know your telemetry parsing is correct is to see real values come back from something that behaves like the actual hardware.&lt;/p&gt;

&lt;p&gt;Without a simulator, every test required the physical device. And physical devices are expensive, shared, and inconveniently located.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Idea
&lt;/h2&gt;

&lt;p&gt;So over the last few weeks I built what I wished we'd had: a simulator for distributed energy resources that speaks real Modbus TCP, runs on your laptop, and behaves like actual hardware.&lt;/p&gt;

&lt;p&gt;Instead of connecting your EMS to a real battery, you connect it to a simulator that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Accepts Modbus TCP connections on configurable ports&lt;/li&gt;
&lt;li&gt;Exposes the same register map as the real device&lt;/li&gt;
&lt;li&gt;Actually models the physics — SOC changes when you charge, power ramps up at the configured rate, PV output follows an irradiance model&lt;/li&gt;
&lt;li&gt;Lets you inject grid events — voltage sags, frequency deviations — to test fault handling&lt;/li&gt;
&lt;li&gt;Runs deterministically so you can reproduce the same scenario twice&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You define your site in a JSON config file — which devices, which ports, what parameters — and the simulator builds the whole thing. Want a dual-BESS site with a PV inverter, energy meter, and a grid frequency model starting at solar noon? That's a config file.&lt;/p&gt;

&lt;p&gt;The project is called DER Twin — &lt;strong&gt;&lt;a href="https://github.com/AlexSpivak/dertwin" rel="noopener noreferrer"&gt;github.com/AlexSpivak/dertwin&lt;/a&gt;&lt;/strong&gt; — and the rest of this article walks through what it looks like in practice.&lt;/p&gt;




&lt;h2&gt;
  
  
  What It Looks Like
&lt;/h2&gt;

&lt;p&gt;Start the simulator:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python &lt;span class="nt"&gt;-m&lt;/span&gt; dertwin.main &lt;span class="nt"&gt;-c&lt;/span&gt; configs/simple_config.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;INFO | Building site: local-dev-site
INFO | Starting Modbus server | 0.0.0.0:55001 | unit=1
INFO | Simulation engine started | step=0.100s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Connect your EMS — or the included example — from another terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python examples/main_simple.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The EMS is talking Modbus TCP to a simulated BESS. SOC is changing. Power is ramping. It works exactly like the real thing — because from the EMS's perspective, it is the real thing.&lt;/p&gt;

&lt;p&gt;For a full site with PV producing at noon, dual BESS cycling independently, and an energy meter tracking net grid power:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[SITE] Grid= -34.63 kW | PV= 24.60 kW (producing) | Freq=50.000 Hz
  [BESS-1] RUN  | SOC= 59.8% | P= +50.00 kW | MODE=discharge
  [BESS-2] RUN  | SOC= 40.2% | P= -25.00 kW | MODE=charge
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Grid exporting 34.6 kW because PV is generating 24.6 kW and BESS-1 is discharging at 50 kW while BESS-2 charges at 25 kW. The math is right. The physics is right.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why This Changes the Development Loop
&lt;/h2&gt;

&lt;p&gt;With a simulator, the workflow becomes straightforward:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Write your Modbus client code&lt;/li&gt;
&lt;li&gt;Start the simulator&lt;/li&gt;
&lt;li&gt;Run your code against it&lt;/li&gt;
&lt;li&gt;If something is wrong, fix it immediately — no hardware, no waiting, no pulling Docker images at 2am&lt;/li&gt;
&lt;li&gt;Write integration tests that run the simulator headlessly and assert on telemetry values&lt;/li&gt;
&lt;li&gt;Merge with confidence&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That last point is the one I care about most. The simulator supports a headless mode where there is no real-time clock — you drive it step by step from your test code, the same way &lt;code&gt;test_site_controller.py&lt;/code&gt; does in the project. That means you can write integration tests that spin up a full simulated site, run thousands of simulation steps in milliseconds, and assert that your EMS made the right decisions at every point. No hardware, no timing dependencies, no flaky tests.&lt;/p&gt;

&lt;p&gt;The fragile, anxiety-inducing "test on production" loop is gone. You know your code works before it touches a real device.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Now the testing loop with a simulator looks like this:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fay49njds96werbgygslw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fay49njds96werbgygslw.png" alt="Testing loop with simulator" width="800" height="751"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Project
&lt;/h2&gt;

&lt;p&gt;DER Twin is open source and MIT licensed.&lt;/p&gt;

&lt;p&gt;In the next article I'll walk through the architecture — how the engine, device models, and protocol layer are separated, and why that separation matters if you want to add new device types or protocols.&lt;/p&gt;

&lt;p&gt;If you're building EMS software or working with Modbus — let me know if this is useful.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>python</category>
      <category>ems</category>
      <category>modbus</category>
    </item>
  </channel>
</rss>
