Two Inverter Static Droop Control
This example simulates a native two-inverter network inspired by the OpenModelica two inverter static droop control example. The original OpenModelica network is useful as the physical schematic: two three-phase inverters feed a shared AC network and load through output filters.

The upper branch is the master inverter. It is voltage-forming: it tries to
shape the AC bus voltage and frequency. The lower branch is the slave inverter.
It synchronizes to the local voltage and injects controlled \(i_{abc}\)
according to the inverse droop law. In the original network image, the upper
branch uses an LC filter lc1; the lower branch uses an LCL filter lcl1; the
right side is the shared load network.
The runnable Regelum example lives in
examples/two_inverter_static_droop/standalone.py. It is a complete standalone
script with the controller, branch dynamics, PRS construction, and plotting in
one file, so it can be simulated without an OpenModelica/FMU runtime.
Control Idea
The two-inverter network has to keep the AC bus in a useful operating region while both inverters share the load.
The master inverter uses direct droop control. It measures instantaneous active and reactive power at the bus:
Then it shifts its frequency and voltage setpoint through filtered droop laws:
Those setpoints go through \(v\)- and \(i\)-PI loops, and the result is a three-phase modulation signal for the master inverter.
The slave inverter does the opposite direction. It runs a PLL on its local capacitor voltage, estimates the local frequency, and uses inverse droop:
So the master forms the AC bus and the slave reacts to the AC bus. That is the important control split: one inverter creates the voltage reference; the other injects \(i_{abc}\) to help supply the load.
ODE Regelum Model
The example keeps the same conceptual network but writes the electrical plant as
rg.ODENode equations wrapped into one rg.ODESystem. The controller is also
split into regular rg.Node blocks: droop, PI loops, PLL, inverse droop, and
\(i\)-control all keep their memory in NodeState.
First, the standalone script imports NumPy for the three-phase vectors and the
framework as rg:
The master starts with a droop node. It reads the lc1.i_abc and lc1.v_abc
signals from the ODE plant and publishes frequency, voltage setpoint, and AC
phase:
class MasterDroop(rg.Node):
class Inputs(rg.NodeInputs):
lc1_i_abc: np.ndarray = rg.src(lambda: Lc1.State.i_abc)
lc1_v_abc: np.ndarray = rg.src(lambda: Lc1.State.v_abc)
class State(rg.NodeState):
frequency_hz: float = rg.var(init=50.0)
voltage_setpoint_v: float = rg.var(init=230.0 * math.sqrt(2.0))
ac_phase_rad: float = rg.var(init=0.0)
ac_phase_turns: float = rg.var(init=0.0)
p_filter: float = rg.var(init=0.0)
q_filter: float = rg.var(init=0.0)
The \(v\)- and \(i\)-PI loops are separate nodes. Their integrators and anti-windup terms are explicit state variables:
class MasterLc1VPI(rg.Node):
class Inputs(rg.NodeInputs):
lc1_v_abc: np.ndarray = rg.src(lambda: Lc1.State.v_abc)
ac_phase_rad: float = rg.src(MasterDroop.State.ac_phase_rad)
voltage_setpoint_v: float = rg.src(MasterDroop.State.voltage_setpoint_v)
class State(rg.NodeState):
i_ref_dq0: np.ndarray = rg.var(init=zero_abc)
integral: np.ndarray = rg.var(init=zero_abc)
windup: np.ndarray = rg.var(init=zero_abc)
The slave side is also explicit. The PLL estimates phase/frequency, inverse
droop computes the \(dq\) \(i\)-reference, and SlaveLcl1IPI produces slave
modulation:
class SlavePLL(rg.Node):
class State(rg.NodeState):
cos_ac_phase: float = rg.var(init=1.0)
sin_ac_phase: float = rg.var(init=0.0)
frequency_hz: float = rg.var(init=50.0)
ac_phase_rad: float = rg.var(init=0.0)
ac_phase_turns: float = rg.var(init=0.0)
integral: float = rg.var(init=0.0)
Two small inverter nodes convert controller modulation into phase voltages:
class Inverter1(rg.Node):
class Inputs(rg.NodeInputs):
modulation: np.ndarray = rg.src(MasterLc1IPI.State.modulation)
class State(rg.NodeState):
v_abc: np.ndarray = rg.var(init=zero_abc)
The physical network is the continuous part. Lc1, Lcl1, Lc2, and Rl1
are rg.ODENode classes. Their states are normalized: each node stores only
its own dynamic variables. Neighboring branches are read through rg.src
declarations in dstate(...).
Thus Lc1.State and Lc2.State contain v_abc and i_abc, Lcl1.State
contains v_abc, inv_i_abc, and bus_i_abc, and Rl1.State contains
i_abc. Regelum traces these arrays as CasADi MX vectors inside
dstate(...). For example, the master LC filter is ordinary vector algebra:
class Lc1(rg.ODENode):
class State(rg.NodeState):
v_abc: np.ndarray = rg.var(init=zero_abc)
i_abc: np.ndarray = rg.var(init=zero_abc)
def dstate(
self,
state: State,
inverter_v_abc: np.ndarray = rg.src(Inverter1.State.v_abc),
lcl1_bus_i_abc: np.ndarray = rg.src(lambda: Lcl1.State.bus_i_abc),
lc2_i_abc: np.ndarray = rg.src(lambda: Lc2.State.i_abc),
) -> State:
return self.State(
v_abc=(state.i_abc + lcl1_bus_i_abc - lc2_i_abc)
/ self.capacitance,
i_abc=(inverter_v_abc - state.v_abc) / self.inductance,
)
The LCL slave branch is also an ODE node. Its v_abc signal is what the plot
records:
class Lcl1(rg.ODENode):
class State(rg.NodeState):
v_abc: np.ndarray = rg.var(init=zero_abc)
inv_i_abc: np.ndarray = rg.var(init=zero_abc)
bus_i_abc: np.ndarray = rg.var(init=zero_abc)
Lc2 and Rl1 complete the load-side network. The load resistance is
not embedded as symbolic branching inside the ODE; it is a normal discrete
scenario node. For a 2000-step run, the first third uses R, the second third
uses 2R, and the final third returns to R:
class ResistanceScenario(rg.Node):
class Inputs(rg.NodeInputs):
tick: int = rg.src(rg.Clock.tick)
class State(rg.NodeState):
resistance: float = rg.var(init=20.0)
def update(self, inputs: Inputs) -> State:
if inputs.tick < self.first_switch_tick:
resistance = self.base_resistance
elif inputs.tick < self.second_switch_tick:
resistance = 2.0 * self.base_resistance
else:
resistance = self.base_resistance
return self.State(resistance=resistance)
class Rl1(rg.ODENode):
class Inputs(rg.NodeInputs):
lc2_v_abc: np.ndarray = rg.src(Lc2.State.v_abc)
resistance: float = rg.src(ResistanceScenario.State.resistance)
class State(rg.NodeState):
i_abc: np.ndarray = rg.var(init=zero_abc)
def dstate(self, inputs: Inputs, state: State) -> State:
return self.State(
i_abc=(inputs.lc2_v_abc - inputs.resistance * state.i_abc) / self.inductance,
)
Build The System
The PRS splits one simulation tick into four phases. The continuous electrical
network is one rg.ODESystem, so Regelum integrates the coupled ODE nodes
together on the base electrical step.
def build_system(*, steps: int = 2000) -> rg.PhasedReactiveSystem:
master_droop = MasterDroop()
master_lc1_v_pi = MasterLc1VPI()
master_lc1_i_pi = MasterLc1IPI()
slave_pll = SlavePLL()
slave_inverse_droop = SlaveInverseDroop()
slave_lcl1_i_pi = SlaveLcl1IPI()
inverter1 = Inverter1()
inverter2 = Inverter2()
resistance = ResistanceScenario(
first_switch_tick=steps // 3,
second_switch_tick=2 * steps // 3,
)
lc1 = Lc1()
lcl1 = Lcl1()
lc2 = Lc2()
rl1 = Rl1()
electrical = rg.ODESystem(
nodes=(lc1, lcl1, lc2, rl1),
dt="0.00005",
backend="casadi",
method="cvodes",
options={"abstol": 1e-9, "reltol": 1e-8},
)
logger = Logger()
return rg.PhasedReactiveSystem(
phases=[
rg.Phase(
"control",
nodes=(
master_droop,
master_lc1_v_pi,
master_lc1_i_pi,
slave_pll,
slave_inverse_droop,
slave_lcl1_i_pi,
),
transitions=(rg.Goto("inverters"),),
is_initial=True,
),
rg.Phase("inverters", nodes=(inverter1, inverter2), transitions=(rg.Goto("scenario"),)),
rg.Phase("scenario", nodes=(resistance,), transitions=(rg.Goto("electrical"),)),
rg.Phase("electrical", nodes=(electrical,), transitions=(rg.Goto("log"),)),
rg.Phase("log", nodes=(logger,), transitions=(rg.Goto(rg.terminate),)),
],
)
This order is deliberate. The control phase resolves the controller DAG from
measurements to modulation, the inverter nodes compute phase voltages, the
scenario node publishes the sampled load resistance, electrical integrates the
continuous LC/LCL/load ODEs, and finally the logger records the LCL capacitor
voltage plus the active resistance.
Phase Graph
flowchart LR
init([init]) --> control["control<br/>droop + PI + PLL nodes"]
control --> inverters["inverters<br/>Inverter1 + Inverter2"]
inverters --> scenario["scenario<br/>ResistanceScenario"]
scenario --> electrical["electrical<br/>ODESystem(dt = 0.00005, backend = casadi)"]
electrical --> log["log<br/>Logger"]
log --> done([⊥])
classDef control fill:#d9770622,stroke:#d97706,color:#111318
classDef inverters fill:#2f6fed22,stroke:#2f6fed,color:#111318
classDef scenario fill:#7c3aed22,stroke:#7c3aed,color:#111318
classDef electrical fill:#15803d22,stroke:#15803d,color:#111318
classDef log fill:#64748b22,stroke:#64748b,color:#111318
class control control
class inverters inverters
class scenario scenario
class electrical electrical
class log log
Node Graph
Node colors follow phase colors. Dashed self-state arrows show state carried from the previous tick.
flowchart LR
masterDroop["MasterDroop"] --> masterLc1VPI["MasterLc1VPI"]
masterLc1VPI --> masterLc1IPI["MasterLc1IPI"]
masterLc1IPI --> inv1["Inverter1"]
slavePLL["SlavePLL"] --> slaveInverseDroop["SlaveInverseDroop"]
slaveInverseDroop --> slaveLcl1IPI["SlaveLcl1IPI"]
slavePLL --> slaveLcl1IPI
slaveLcl1IPI --> inv2["Inverter2"]
scenario["ResistanceScenario"] --> rl1["Rl1<br/>ODENode"]
inv1 --> lc1["Lc1<br/>ODENode"]
inv2 --> lcl1["Lcl1<br/>ODENode"]
lcl1 --> lc1
lc1 --> lcl1
lc1 --> lc2["Lc2<br/>ODENode"]
lc2 --> rl1
rl1 --> lc2
lc1 --> masterDroop
lc1 --> masterLc1VPI
lc1 --> masterLc1IPI
lcl1 --> slavePLL
lcl1 --> slaveInverseDroop
lcl1 --> slaveLcl1IPI
lcl1 --> logger["Logger"]
master_droop_state(("state")) -.-> masterDroop
master_lc1_v_pi_state(("state")) -.-> masterLc1VPI
master_lc1_i_pi_state(("state")) -.-> masterLc1IPI
slave_pll_state(("state")) -.-> slavePLL
slave_inverse_state(("state")) -.-> slaveInverseDroop
slave_lcl1_i_pi_state(("state")) -.-> slaveLcl1IPI
lc1_state(("state")) -.-> lc1
lcl1_state(("state")) -.-> lcl1
lc2_state(("state")) -.-> lc2
rl1_state(("state")) -.-> rl1
logger_state(("state")) -.-> logger
scenario_state(("state")) -.-> scenario
classDef control fill:#d9770622,stroke:#d97706,color:#111318
classDef inverters fill:#2f6fed22,stroke:#2f6fed,color:#111318
classDef scenarioPhase fill:#7c3aed22,stroke:#7c3aed,color:#111318
classDef electrical fill:#15803d22,stroke:#15803d,color:#111318
classDef log fill:#64748b22,stroke:#64748b,color:#111318
classDef state fill:#94a3b822,stroke:#94a3b8,stroke-dasharray:3 3,color:#111318
class masterDroop,masterLc1VPI,masterLc1IPI,slavePLL,slaveInverseDroop,slaveLcl1IPI control
class inv1,inv2 inverters
class scenario scenarioPhase
class lc1,lcl1,lc2,rl1 electrical
class logger log
class master_droop_state,master_lc1_v_pi_state,master_lc1_i_pi_state,slave_pll_state,slave_inverse_state,slave_lcl1_i_pi_state,scenario_state,lc1_state,lcl1_state,lc2_state,rl1_state,logger_state state
Phase Table
| Phase | Nodes | Role |
|---|---|---|
| control | MasterDroop, MasterLc1VPI, MasterLc1IPI, SlavePLL, SlaveInverseDroop, SlaveLcl1IPI |
Computes master voltage-forming and slave modulation as a DAG of stateful nodes. |
| inverters | Inverter1, Inverter2 |
Converts modulation into three-phase inverter voltages. |
| scenario | ResistanceScenario |
Publishes the sampled load resistance: R, then 2R, then R. |
| electrical | ODESystem(Lc1, Lcl1, Lc2, Rl1) |
Integrates the coupled electrical differential equations. |
| log | Logger |
Stores the LCL capacitor voltages and resistance for plotting. |
Node Table
| Node | State | Reads |
|---|---|---|
| MasterDroop | frequency, voltage setpoint, AC phase, droop filter states | lc1.v_abc, lc1.i_abc |
| MasterLc1VPI | i_ref_dq0, PI integral, windup |
lc1.v_abc and MasterDroop AC phase/setpoint |
| MasterLc1IPI | master modulation, PI integral, windup | lc1.i_abc and MasterLc1VPI.i_ref_dq0 |
| SlavePLL | AC phase estimate, frequency estimate, PLL integral | lcl1.v_abc |
| SlaveInverseDroop | i_ref_dq0 and inverse-droop filter states |
SlavePLL frequency and lcl1.v_abc |
| SlaveLcl1IPI | slave modulation, PI integral, windup | lcl1.inv_i_abc, SlavePLL AC phase, SlaveInverseDroop.i_ref_dq0 |
| Inverter1 | v_abc |
Master modulation |
| Inverter2 | v_abc |
Slave modulation |
ResistanceScenario |
resistance |
rg.Clock.tick |
| Lc1 | v_abc, i_abc |
Inverter1.v_abc, Lcl1.bus_i_abc, Lc2.i_abc |
| Lcl1 | v_abc, inv_i_abc, bus_i_abc |
Inverter2.v_abc and bus_v_abc |
| Lc2 | v_abc, i_abc |
bus_v_abc, Rl1.i_abc |
| Rl1 | i_abc |
Lc2.v_abc and ResistanceScenario.resistance |
| Logger | samples |
Built-in rg.Clock.time, Lcl1.v_abc, and resistance |
Simulation Result
The documentation plot is generated by:
The plotted signal combines the three-phase capacitor voltage of the slave LCL filter and the sampled load resistance. The resistance scenario occupies equal thirds of the run: nominal resistance, doubled resistance, and nominal resistance again.
The full standalone listing below is the concrete executable example: helper math, controllers, Regelum node definitions, PRS construction, simulation, and plotting in one file.
Standalone Python listing
from __future__ import annotations
import argparse
import math
import shutil
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
import regelum as rg
VoltageResistanceSample = tuple[float, float, float, float, float]
def repo_root() -> Path:
for parent in Path(__file__).resolve().parents:
if (parent / "pyproject.toml").exists() and (parent / "src" / "regelum").exists():
return parent
raise RuntimeError("Could not locate the regelum repository root.")
def zero_abc() -> np.ndarray:
return np.zeros(3, dtype=float)
def clip(value: float, lower: float, upper: float) -> float:
return max(lower, min(upper, value))
def inst_rms(value: np.ndarray) -> float:
return float(np.linalg.norm(value) / math.sqrt(3.0))
def normalise_abc(value: np.ndarray) -> np.ndarray:
magnitude = inst_rms(value)
if magnitude == 0:
return value
return value / magnitude
def abc_to_alpha_beta(abc: np.ndarray) -> tuple[float, float]:
alpha = (2.0 / 3.0) * (abc[0] - 0.5 * abc[1] - 0.5 * abc[2])
beta = (2.0 / 3.0) * (0.866 * abc[1] - 0.866 * abc[2])
return float(alpha), float(beta)
def cos_sin(theta: float) -> tuple[float, float]:
return math.cos(theta), math.sin(theta)
def dq0_to_abc_cos_sin(dq0: np.ndarray, cos_value: float, sin_value: float) -> np.ndarray:
transform = np.array(
[
[cos_value, -sin_value, 1.0],
[
cos_value * (-0.5) - sin_value * (-0.866),
-(sin_value * (-0.5) + cos_value * (-0.866)),
1.0,
],
[
cos_value * (-0.5) - sin_value * 0.866,
-(sin_value * (-0.5) + cos_value * 0.866),
1.0,
],
],
dtype=float,
)
return transform @ dq0
def dq0_to_abc(dq0: np.ndarray, theta: float) -> np.ndarray:
return dq0_to_abc_cos_sin(dq0, *cos_sin(theta))
def abc_to_dq0_cos_sin(abc: np.ndarray, cos_value: float, sin_value: float) -> np.ndarray:
cos_shift_neg = cos_value * (-0.5) - sin_value * (-0.866)
sin_shift_neg = sin_value * (-0.5) + cos_value * (-0.866)
cos_shift_pos = cos_value * (-0.5) - sin_value * 0.866
sin_shift_pos = sin_value * (-0.5) + cos_value * 0.866
return np.array(
[
(2.0 / 3.0) * (cos_value * abc[0] + cos_shift_neg * abc[1] + cos_shift_pos * abc[2]),
(2.0 / 3.0) * (-sin_value * abc[0] - sin_shift_neg * abc[1] - sin_shift_pos * abc[2]),
float(np.sum(abc) / 3.0),
],
dtype=float,
)
def abc_to_dq0(abc: np.ndarray, theta: float) -> np.ndarray:
return abc_to_dq0_cos_sin(abc, *cos_sin(theta))
def inst_power(v_abc: np.ndarray, i_abc: np.ndarray) -> float:
return float(np.dot(v_abc, i_abc))
def inst_reactive(v_abc: np.ndarray, i_abc: np.ndarray) -> float:
quadrature_v = np.roll(v_abc, -1) - np.roll(v_abc, -2)
return float(-0.5773502691896258 * np.dot(quadrature_v, i_abc))
def pi_update(
*,
setpoint: np.ndarray,
measured: np.ndarray,
integral: np.ndarray,
windup: np.ndarray,
kp: float,
ki: float,
limits: tuple[float, float],
dt: float,
feedforward: np.ndarray | None = None,
kb: float = 1.0,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
if feedforward is None:
feedforward = zero_abc()
error = setpoint - measured
next_integral = integral + (ki * error + windup) * dt
raw = kp * error + next_integral + feedforward
output = np.clip(raw, limits[0], limits[1])
next_windup = (raw - output) * kb
return output, next_integral, next_windup
def pt1_update(*, value: float, integral: float, gain: float, tau: float, dt: float) -> float:
output = value * gain - integral
if tau != 0:
return integral + output / tau * dt
if gain != 0:
return 0.0
return 0.0
def advance_phase_turns(phase_turns: float, frequency_hz: float, dt: float) -> float:
next_phase = phase_turns + dt * frequency_hz
if next_phase > 1.0:
next_phase -= 1.0
return next_phase
def save_lcl1_plot(samples: list[VoltageResistanceSample], output: Path) -> None:
output.parent.mkdir(parents=True, exist_ok=True)
time = [row[0] for row in samples]
v1 = [row[1] for row in samples]
v2 = [row[2] for row in samples]
v3 = [row[3] for row in samples]
resistance = [row[4] for row in samples]
fig, voltage_ax = plt.subplots(figsize=(8.0, 4.8), dpi=100)
voltage_ax.plot(time, v1, label="AC phase A voltage", linewidth=1.2)
voltage_ax.plot(time, v2, label="AC phase B voltage", linewidth=1.2)
voltage_ax.plot(time, v3, label="AC phase C voltage", linewidth=1.2)
voltage_ax.set_xlabel("time, s")
voltage_ax.set_ylabel("AC phase voltage, V")
voltage_ax.set_xlim(min(time), max(time))
voltage_ax.grid(True, alpha=0.25)
resistance_ax = voltage_ax.twinx()
resistance_ax.step(
time,
resistance,
where="post",
color="black",
linestyle="--",
linewidth=1.2,
label="load resistance",
)
resistance_ax.set_ylabel("load resistance, Ohm")
voltage_lines, voltage_labels = voltage_ax.get_legend_handles_labels()
resistance_lines, resistance_labels = resistance_ax.get_legend_handles_labels()
fig.legend(
voltage_lines + resistance_lines,
voltage_labels + resistance_labels,
loc="upper center",
bbox_to_anchor=(0.5, 0.99),
ncol=4,
frameon=False,
)
fig.tight_layout(rect=(0.0, 0.0, 1.0, 0.92))
fig.savefig(output)
plt.close(fig)
class MasterDroop(rg.Node):
def __init__(
self,
*,
dt: float = 0.5e-4,
v_nom: float = 230.0 * math.sqrt(2.0),
freq_nom: float = 50.0,
) -> None:
self.dt = dt
self.v_nom = v_nom
self.freq_nom = freq_nom
class Inputs(rg.NodeInputs):
lc1_i_abc: np.ndarray = rg.src(lambda: Lc1.State.i_abc)
lc1_v_abc: np.ndarray = rg.src(lambda: Lc1.State.v_abc)
class State(rg.NodeState):
frequency_hz: float = rg.var(init=50.0)
voltage_setpoint_v: float = rg.var(init=230.0 * math.sqrt(2.0))
ac_phase_rad: float = rg.var(init=0.0)
ac_phase_turns: float = rg.var(init=0.0)
p_filter: float = rg.var(init=0.0)
q_filter: float = rg.var(init=0.0)
def update(self, inputs: Inputs, prev_state: State) -> State:
instant_power = -inst_power(inputs.lc1_v_abc, inputs.lc1_i_abc)
p_filter = pt1_update(
value=instant_power,
integral=prev_state.p_filter,
gain=1.0 / 40000.0,
tau=0.005,
dt=self.dt,
)
frequency_hz = p_filter + self.freq_nom
instant_q = -inst_reactive(inputs.lc1_v_abc, inputs.lc1_i_abc)
q_filter = pt1_update(
value=instant_q,
integral=prev_state.q_filter,
gain=1.0 / 1000.0,
tau=0.002,
dt=self.dt,
)
voltage_setpoint_v = q_filter + self.v_nom
ac_phase_turns = advance_phase_turns(prev_state.ac_phase_turns, frequency_hz, self.dt)
return self.State(
frequency_hz=frequency_hz,
voltage_setpoint_v=voltage_setpoint_v,
ac_phase_rad=ac_phase_turns * 2.0 * math.pi,
ac_phase_turns=ac_phase_turns,
p_filter=p_filter,
q_filter=q_filter,
)
class MasterLc1VPI(rg.Node):
def __init__(self, *, dt: float = 0.5e-4, i_lim: float = 30.0) -> None:
self.dt = dt
self.i_lim = i_lim
class Inputs(rg.NodeInputs):
lc1_v_abc: np.ndarray = rg.src(lambda: Lc1.State.v_abc)
ac_phase_rad: float = rg.src(MasterDroop.State.ac_phase_rad)
voltage_setpoint_v: float = rg.src(MasterDroop.State.voltage_setpoint_v)
class State(rg.NodeState):
i_ref_dq0: np.ndarray = rg.var(init=zero_abc)
integral: np.ndarray = rg.var(init=zero_abc)
windup: np.ndarray = rg.var(init=zero_abc)
def update(self, inputs: Inputs, prev_state: State) -> State:
voltage_dq0 = abc_to_dq0(inputs.lc1_v_abc, inputs.ac_phase_rad)
voltage_setpoint_dq0 = np.array([inputs.voltage_setpoint_v, 0.0, 0.0], dtype=float)
i_ref_dq0, integral, windup = pi_update(
setpoint=voltage_setpoint_dq0,
measured=voltage_dq0,
integral=prev_state.integral,
windup=prev_state.windup,
kp=0.025,
ki=60.0,
limits=(-self.i_lim, self.i_lim),
dt=self.dt,
)
return self.State(
i_ref_dq0=i_ref_dq0,
integral=integral,
windup=windup,
)
class MasterLc1IPI(rg.Node):
def __init__(self, *, dt: float = 0.5e-4) -> None:
self.dt = dt
class Inputs(rg.NodeInputs):
lc1_i_abc: np.ndarray = rg.src(lambda: Lc1.State.i_abc)
ac_phase_rad: float = rg.src(MasterDroop.State.ac_phase_rad)
i_ref_dq0: np.ndarray = rg.src(MasterLc1VPI.State.i_ref_dq0)
class State(rg.NodeState):
modulation: np.ndarray = rg.var(init=zero_abc)
integral: np.ndarray = rg.var(init=zero_abc)
windup: np.ndarray = rg.var(init=zero_abc)
def update(self, inputs: Inputs, prev_state: State) -> State:
i_dq0 = abc_to_dq0(inputs.lc1_i_abc, inputs.ac_phase_rad)
modulation_dq0, integral, windup = pi_update(
setpoint=inputs.i_ref_dq0,
measured=i_dq0,
integral=prev_state.integral,
windup=prev_state.windup,
kp=0.012,
ki=90.0,
limits=(-1.0, 1.0),
dt=self.dt,
)
modulation = np.clip(dq0_to_abc(modulation_dq0, inputs.ac_phase_rad), -1.0, 1.0)
return self.State(modulation=modulation, integral=integral, windup=windup)
class SlavePLL(rg.Node):
def __init__(self, *, dt: float = 0.5e-4, f_nom: float = 50.0) -> None:
self.dt = dt
self.f_nom = f_nom
class Inputs(rg.NodeInputs):
lcl1_v_abc: np.ndarray = rg.src(lambda: Lcl1.State.v_abc)
class State(rg.NodeState):
cos_ac_phase: float = rg.var(init=1.0)
sin_ac_phase: float = rg.var(init=0.0)
frequency_hz: float = rg.var(init=50.0)
ac_phase_rad: float = rg.var(init=0.0)
ac_phase_turns: float = rg.var(init=0.0)
integral: float = rg.var(init=0.0)
def update(self, inputs: Inputs, prev_state: State) -> State:
normalised = normalise_abc(inputs.lcl1_v_abc)
alpha_beta = abc_to_alpha_beta(normalised)
dphi = alpha_beta[1] * prev_state.cos_ac_phase - alpha_beta[0] * prev_state.sin_ac_phase
integral = prev_state.integral + 200.0 * dphi * self.dt
frequency_hz = 10.0 * dphi + integral + self.f_nom
ac_phase_turns = advance_phase_turns(prev_state.ac_phase_turns, frequency_hz, self.dt)
ac_phase_rad = ac_phase_turns * 2.0 * math.pi
cos_ac_phase, sin_ac_phase = cos_sin(ac_phase_rad)
return self.State(
cos_ac_phase=cos_ac_phase,
sin_ac_phase=sin_ac_phase,
frequency_hz=frequency_hz,
ac_phase_rad=ac_phase_rad,
ac_phase_turns=ac_phase_turns,
integral=integral,
)
class SlaveInverseDroop(rg.Node):
def __init__(
self,
*,
dt: float = 0.5e-4,
v_nom: float = 230.0 * math.sqrt(2.0),
i_lim: float = 30.0,
) -> None:
self.dt = dt
self.v_nom = v_nom
self.i_lim = i_lim
class Inputs(rg.NodeInputs):
lcl1_v_abc: np.ndarray = rg.src(lambda: Lcl1.State.v_abc)
frequency_hz: float = rg.src(SlavePLL.State.frequency_hz)
class State(rg.NodeState):
i_ref_dq0: np.ndarray = rg.var(init=zero_abc)
p_filter: float = rg.var(init=0.0)
p_previous: float = rg.var(init=0.0)
q_filter: float = rg.var(init=0.0)
q_previous: float = rg.var(init=0.0)
def update(self, inputs: Inputs, prev_state: State) -> State:
v_inst = inst_rms(inputs.lcl1_v_abc)
if v_inst <= 150.0:
return self.State(
i_ref_dq0=zero_abc(),
p_filter=prev_state.p_filter,
p_previous=prev_state.p_previous,
q_filter=prev_state.q_filter,
q_previous=prev_state.q_previous,
)
p_filter = pt1_update(
value=inputs.frequency_hz - 50.0,
integral=prev_state.p_filter,
gain=1.0,
tau=0.04,
dt=self.dt,
)
p_output = p_filter * 40000.0 + (p_filter - prev_state.p_previous)
q_filter = pt1_update(
value=v_inst - self.v_nom,
integral=prev_state.q_filter,
gain=1.0,
tau=0.01,
dt=self.dt,
)
q_output = q_filter * 50.0 + (q_filter - prev_state.q_previous)
active_i = p_output / v_inst
reactive_i = q_output / v_inst
droop = np.array(
[
clip(active_i / 3.0 * math.sqrt(2.0), -self.i_lim, self.i_lim),
clip(reactive_i / 3.0 * math.sqrt(2.0), -self.i_lim, self.i_lim),
0.0,
],
dtype=float,
)
return self.State(
i_ref_dq0=np.array([-droop[0], droop[1], droop[2]], dtype=float),
p_filter=p_filter,
p_previous=p_filter,
q_filter=q_filter,
q_previous=q_filter,
)
class SlaveLcl1IPI(rg.Node):
def __init__(self, *, dt: float = 0.5e-4) -> None:
self.dt = dt
class Inputs(rg.NodeInputs):
lcl1_inv_i_abc: np.ndarray = rg.src(lambda: Lcl1.State.inv_i_abc)
cos_ac_phase: float = rg.src(SlavePLL.State.cos_ac_phase)
sin_ac_phase: float = rg.src(SlavePLL.State.sin_ac_phase)
i_ref_dq0: np.ndarray = rg.src(SlaveInverseDroop.State.i_ref_dq0)
class State(rg.NodeState):
modulation: np.ndarray = rg.var(init=zero_abc)
integral: np.ndarray = rg.var(init=zero_abc)
windup: np.ndarray = rg.var(init=zero_abc)
def update(self, inputs: Inputs, prev_state: State) -> State:
i_dq0 = abc_to_dq0_cos_sin(
inputs.lcl1_inv_i_abc,
inputs.cos_ac_phase,
inputs.sin_ac_phase,
)
modulation_dq0, integral, windup = pi_update(
setpoint=inputs.i_ref_dq0,
measured=i_dq0,
integral=prev_state.integral,
windup=prev_state.windup,
kp=0.005,
ki=200.0,
limits=(-1.0, 1.0),
dt=self.dt,
)
modulation = np.clip(
dq0_to_abc_cos_sin(modulation_dq0, inputs.cos_ac_phase, inputs.sin_ac_phase),
-1.0,
1.0,
)
return self.State(modulation=modulation, integral=integral, windup=windup)
class Inverter1(rg.Node):
def __init__(self, *, v_dc: float = 1000.0) -> None:
self.gain = 0.5 * v_dc
class Inputs(rg.NodeInputs):
modulation: np.ndarray = rg.src(MasterLc1IPI.State.modulation)
class State(rg.NodeState):
v_abc: np.ndarray = rg.var(init=zero_abc)
def update(self, inputs: Inputs) -> State:
return self.State(v_abc=inputs.modulation * self.gain)
class Inverter2(rg.Node):
def __init__(self, *, v_dc: float = 1000.0) -> None:
self.gain = 0.5 * v_dc
class Inputs(rg.NodeInputs):
modulation: np.ndarray = rg.src(SlaveLcl1IPI.State.modulation)
class State(rg.NodeState):
v_abc: np.ndarray = rg.var(init=zero_abc)
def update(self, inputs: Inputs) -> State:
return self.State(v_abc=inputs.modulation * self.gain)
class ResistanceScenario(rg.Node):
def __init__(
self,
*,
base_resistance: float = 20.0,
first_switch_tick: int,
second_switch_tick: int,
) -> None:
self.base_resistance = base_resistance
self.first_switch_tick = first_switch_tick
self.second_switch_tick = second_switch_tick
class Inputs(rg.NodeInputs):
tick: int = rg.src(rg.Clock.tick)
class State(rg.NodeState):
resistance: float = rg.var(init=20.0)
def update(self, inputs: Inputs) -> State:
if inputs.tick < self.first_switch_tick:
resistance = self.base_resistance
elif inputs.tick < self.second_switch_tick:
resistance = 2.0 * self.base_resistance
else:
resistance = self.base_resistance
return self.State(resistance=resistance)
class Lc1(rg.ODENode):
def __init__(self, *, inductance: float = 0.001, capacitance: float = 1.0e-5) -> None:
self.inductance = inductance
self.capacitance = capacitance
class State(rg.NodeState):
v_abc: np.ndarray = rg.var(init=zero_abc)
i_abc: np.ndarray = rg.var(init=zero_abc)
def dstate(
self,
state: State,
inverter_v_abc: np.ndarray = rg.src(Inverter1.State.v_abc),
lcl1_bus_i_abc: np.ndarray = rg.src(lambda: Lcl1.State.bus_i_abc),
lc2_i_abc: np.ndarray = rg.src(lambda: Lc2.State.i_abc),
) -> State: # ty: ignore[invalid-method-override]
return self.State(
v_abc=(state.i_abc + lcl1_bus_i_abc - lc2_i_abc) / self.capacitance,
i_abc=(inverter_v_abc - state.v_abc) / self.inductance,
)
class Lcl1(rg.ODENode):
def __init__(self, *, inductance: float = 0.001, capacitance: float = 1.0e-5) -> None:
self.inductance = inductance
self.capacitance = capacitance
class State(rg.NodeState):
v_abc: np.ndarray = rg.var(init=zero_abc)
inv_i_abc: np.ndarray = rg.var(init=zero_abc)
bus_i_abc: np.ndarray = rg.var(init=zero_abc)
def dstate(
self,
state: State,
inverter_v_abc: np.ndarray = rg.src(Inverter2.State.v_abc),
bus_v_abc: np.ndarray = rg.src(Lc1.State.v_abc),
) -> State: # ty: ignore[invalid-method-override]
return self.State(
v_abc=(state.inv_i_abc - state.bus_i_abc) / self.capacitance,
inv_i_abc=(inverter_v_abc - state.v_abc) / self.inductance,
bus_i_abc=(state.v_abc - bus_v_abc) / self.inductance,
)
class Lc2(rg.ODENode):
def __init__(self, *, inductance: float = 0.001, capacitance: float = 1.0e-5) -> None:
self.inductance = inductance
self.capacitance = capacitance
class State(rg.NodeState):
v_abc: np.ndarray = rg.var(init=zero_abc)
i_abc: np.ndarray = rg.var(init=zero_abc)
def dstate(
self,
state: State,
bus_v_abc: np.ndarray = rg.src(Lc1.State.v_abc),
rl1_i_abc: np.ndarray = rg.src(lambda: Rl1.State.i_abc),
) -> State: # ty: ignore[invalid-method-override]
return self.State(
v_abc=(state.i_abc - rl1_i_abc) / self.capacitance,
i_abc=(bus_v_abc - state.v_abc) / self.inductance,
)
class Rl1(rg.ODENode):
def __init__(self, *, inductance: float = 0.001) -> None:
self.inductance = inductance
class Inputs(rg.NodeInputs):
lc2_v_abc: np.ndarray = rg.src(Lc2.State.v_abc)
resistance: float = rg.src(ResistanceScenario.State.resistance)
class State(rg.NodeState):
i_abc: np.ndarray = rg.var(init=zero_abc)
def dstate(self, inputs: Inputs, state: State) -> State: # ty: ignore[invalid-method-override]
return self.State(
i_abc=(inputs.lc2_v_abc - inputs.resistance * state.i_abc) / self.inductance,
)
class Logger(rg.Node):
class Inputs(rg.NodeInputs):
time_s: float = rg.src(rg.Clock.time)
lcl1_v_abc: np.ndarray = rg.src(Lcl1.State.v_abc)
resistance: float = rg.src(ResistanceScenario.State.resistance)
class State(rg.NodeState):
samples: list[VoltageResistanceSample] = rg.var(init=list)
def update(self, inputs: Inputs, prev_state: State) -> State:
sample = (
inputs.time_s,
float(inputs.lcl1_v_abc[0]),
float(inputs.lcl1_v_abc[1]),
float(inputs.lcl1_v_abc[2]),
inputs.resistance,
)
prev_state.samples.append(sample)
return self.State(samples=prev_state.samples)
def build_system(*, steps: int = 2000) -> rg.PhasedReactiveSystem:
master_droop = MasterDroop()
master_lc1_v_pi = MasterLc1VPI()
master_lc1_i_pi = MasterLc1IPI()
slave_pll = SlavePLL()
slave_inverse_droop = SlaveInverseDroop()
slave_lcl1_i_pi = SlaveLcl1IPI()
inverter1 = Inverter1()
inverter2 = Inverter2()
resistance = ResistanceScenario(
first_switch_tick=steps // 3,
second_switch_tick=2 * steps // 3,
)
lc1 = Lc1()
lcl1 = Lcl1()
lc2 = Lc2()
rl1 = Rl1()
electrical = rg.ODESystem(
nodes=(lc1, lcl1, lc2, rl1),
dt="0.00005",
backend="casadi",
method="cvodes",
options={"abstol": 1e-9, "reltol": 1e-8},
)
logger = Logger()
return rg.PhasedReactiveSystem(
phases=[
rg.Phase(
"control",
nodes=(
master_droop,
master_lc1_v_pi,
master_lc1_i_pi,
slave_pll,
slave_inverse_droop,
slave_lcl1_i_pi,
),
transitions=(rg.Goto("inverters"),),
is_initial=True,
),
rg.Phase("inverters", nodes=(inverter1, inverter2), transitions=(rg.Goto("scenario"),)),
rg.Phase("scenario", nodes=(resistance,), transitions=(rg.Goto("electrical"),)),
rg.Phase("electrical", nodes=(electrical,), transitions=(rg.Goto("log"),)),
rg.Phase("log", nodes=(logger,), transitions=(rg.Goto(rg.terminate),)),
],
)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--steps", type=int, default=2000)
parser.add_argument(
"--output",
type=Path,
default=Path(__file__).resolve().parent / "lcl1_voltage_and_resistance.svg",
)
parser.add_argument(
"--docs-output",
type=Path,
default=repo_root()
/ "docs"
/ "assets"
/ "examples"
/ "two_inverter_static_droop"
/ "lcl1_voltage_and_resistance.svg",
)
args = parser.parse_args()
system = build_system(steps=args.steps)
system.run(args.steps)
snapshot = system.snapshot()
samples = snapshot["Logger.samples"]
save_lcl1_plot(samples, args.output)
args.docs_output.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(args.output, args.docs_output)
print(f"steps={args.steps}")
print(f"samples={len(samples)}")
print(args.output.resolve())
print(args.docs_output.resolve())
if __name__ == "__main__":
main()