Tutorials
A ladder of worked simulations, each adding one idea to the one before.
Worked simulations in order of difficulty, starting from something trivial. Each one adds exactly
one new idea to the one before it, and each is a complete script you can run rather than a fragment
to assemble.
Every script on these pages was run before it was written up, and the numbers quoted are the numbers
it produced. Where a result is surprising, the page says so rather than leaving you to wonder
whether you typed something wrong.
Two tracks
Python is the track to start with, and the difference is not a matter of
taste. With the Python package installed you edit a script and run it; there is no build step
between a change and a result, and the loop is a few seconds long. That is the right way to learn
what the simulator does.
A C++ track covers the same ground for anyone embedding the solver in an application or writing a
new model, where the Python API is not the interface being used. It costs a compile and link on
every change, so it is the wrong place to learn the concepts and the right place to work once you
know them. It also requires a working build of DPsim itself rather than only the installed package;
see build. The
C++ examples are the
material it is being built from.
The two tracks describe the same simulator and share the concept pages behind them. Only the calling
code differs, so nothing learned in one track has to be relearned in the other.
If a tutorial is not what you want
The User Guide covers installation and individual features,
Concepts has the mathematics behind the models, the
Developer Guide covers changing DPsim rather than using it,
and Reference has the generated API and the model availability
tables.
1 - Python Tutorials
The ladder, worked in Python.
Each tutorial starts from the one before and adds exactly one new thing. Work through them in order;
each is a complete runnable script rather than a fragment.
- Your first simulation. A source and a resistor. The shape of
a script, and how to read a result back.
- Adding dynamics. An inductor, the transient it produces, and
how to choose a time step.
- A network, and where it starts from. A line between two
buses, and initializing the dynamic run from a powerflow.
- Applying a fault. Switching during a run, and why clearing
a fault needs more care than applying one.
- The same circuit in two domains. Waveforms against envelopes,
and what the envelope buys.
- Adding a machine. A synchronous generator, initializing it correctly,
and what the model order changes.
- Exchanging data with another tool. Handing a value out of a
running simulation, and where the boundary lies.
A rung on converters and their control belongs between the last two and is not written yet.
What you need
DPsim importable from Python. If it is not, see
install or
build.
Reading results back and plotting them uses the data processing package the example notebooks also
use. It is separate from DPsim and is imported as villas.dataprocessing.
1.1 - Your First Simulation
Build a network in Python, run it, and read the results back.
This page goes from nothing to a plotted result. It assumes DPsim is installed and importable; if
it is not, start with install or build.
The circuit is deliberately trivial, a voltage source feeding a resistor, so that nothing in it
distracts from the shape of the script. Every real simulation has the same five parts in the same
order.

The whole script
import dpsimpy
import villas.dataprocessing.readtools as rt
from villas.dataprocessing.timeseries import TimeSeries as ts
name = "first_simulation"
# 1. Nodes
gnd = dpsimpy.dp.SimNode.gnd
n1 = dpsimpy.dp.SimNode("n1")
# 2. Components
src = dpsimpy.dp.ph1.VoltageSource("src")
src.V_ref = complex(100, 0)
load = dpsimpy.dp.ph1.Resistor("load")
load.R = 10.0
# 3. Connections
src.connect([gnd, n1])
load.connect([n1, gnd])
# 4. Topology
system = dpsimpy.SystemTopology(50, [gnd, n1], [src, load])
# 5. Logging, then run
logger = dpsimpy.Logger(name)
logger.log_attribute("n1.v", "v", n1)
logger.log_attribute("load.i_intf", "i_intf", load)
sim = dpsimpy.Simulation(name)
sim.set_domain(dpsimpy.Domain.DP)
sim.set_system(system)
sim.set_time_step(1e-3)
sim.set_final_time(0.1)
sim.add_logger(logger)
sim.run()
Running it prints solver progress and writes logs/first_simulation.csv.
What each part is doing
Nodes come first because components connect to them, not to each other. SimNode.gnd is the
reference node and is shared; every network needs it. Nodes are chosen from a domain namespace,
dpsimpy.dp here, and a node from one domain cannot be connected to a component from another.
Components are created, then configured through their attributes. src.V_ref = complex(100, 0)
sets the source reference as a complex phasor, because this is the dynamic phasor domain and a
voltage is an envelope rather than an instantaneous value. In EMT the same field would carry a
different meaning; see dynamic phasors.
Watch out: connection order sets the sign
Connection order defines polarity. src.connect([gnd, n1]) means terminal 0 at ground and
terminal 1 at n1, so a positive current flows from terminal 0 to terminal 1 inside the component.
Reversing the list reverses the sign of everything that component reports. Nothing checks this for
you, and a sign error here produces a simulation that runs and is wrong.Watch out: a component left out of the topology is ignored
The topology takes the system frequency first, then the nodes, then the components. Anything not
in those two lists is not simulated, even if it was created and connected. This is the most common
reason a component appears to have no effect.Logging is opt-in, and takes three steps in order. Nothing is recorded unless a logger asks for
it.
logger = dpsimpy.Logger(name) # 1. create it; the name becomes the file name
logger.log_attribute("n1.v", "v", n1) # 2. register each attribute you want
logger.log_attribute("load.i_intf", "i_intf", load)
sim.add_logger(logger) # 3. attach it to the simulation, before run()
log_attribute takes the column name you want, the name of the attribute on the object, and the
object itself. "v" on a node is its voltage; "i_intf" on a component is the current through it.
The first argument is yours to choose and is the key you will use when reading the file back; the
second must be an attribute the object actually publishes, and print_attribute_list() on the
object shows what that is.
Watch out: only attributes can be logged
A value a component computes internally but does not publish as an attribute cannot be recorded by
any logger option. print_attribute_list() on an object shows what it publishes.The order is what makes it work. A logger registers attributes before it is attached, and it must be
attached before run(), because the column header is written from whatever is registered when the
first row is written. A logger created but never passed to add_logger produces no file at all,
which is the usual reason for a run that appears to have logged nothing.
Reading the results
results = rt.read_timeseries_dpsim("logs/" + name + ".csv")
print(sorted(results.keys()))
# ['load.i_intf', 'n1.v']
v = results["n1.v"]
print(v.time[1], abs(v.values[1]))
# 0.001 100.0
The keys are the column names given to log_attribute. Each value is a time series with time and
values arrays; in a dynamic phasor simulation the values are complex, and abs() gives the
envelope magnitude.
Note that the first sample is zero. The log is written before the first solve, so row zero is the
state the simulation started from rather than a result. From t = 0.001 onwards this circuit sits
at exactly 100 V and 10 A, which is what a 100 V source across 10 Ω should give.
Plotting
import villas.dataprocessing.plottools as pt
pt.plot_timeseries(1, results["n1.v"].abs())
pt.plot_timeseries(2, results["load.i_intf"].abs())
The first argument is a figure number, so repeated calls with the same number overlay curves on one
axis. .abs() is needed for complex results; plotting a complex series directly is not meaningful.
A dynamic phasor result is an envelope, not a waveform. To compare it against an instantaneous
result, shift it back onto the carrier:
emt = ts.frequency_shift_list(results, 50)
print(sorted(emt.keys()))
# ['load.i_intf_shift', 'n1.v_shift']
Watch out: every key gains a _shift suffix
Every key gains a _shift suffix, which is easy to miss and produces a KeyError that reads as
though the quantity were never logged. The result is a real waveform at the given carrier frequency,
and can be plotted or compared against an EMT run directly.The script
The complete script for this page is 01_first_simulation.py under examples/Python/Tutorials. The numbers quoted above are the numbers it prints, so if the two ever disagree the page is the one that is wrong.
Next
This circuit has no dynamics at all: it is a source and a resistor, so it reaches its final value in
one step and stays there. The next step is to add an element that stores energy, which is where the
choice of time step starts to matter and where a result becomes worth plotting.
The examples work through larger networks, and the models used here are
derived under sources and
RLC elements.
1.2 - Adding Dynamics
An element that stores energy, the transient it produces, and how to choose a time step.
The circuit in your first simulation reaches its final value in
a single step. A resistor is a purely algebraic element: it stores no energy, so the circuit has no
state variable and its response to a change is instantaneous.
An inductor stores energy in its magnetic field, and its current cannot change instantaneously. That
current becomes a state variable, the circuit becomes first order, and it acquires a transient worth
looking at and a reason to care about the time step.

The circuit
A source, a resistor and an inductor in series. Only the inductor is new; everything else is the
same shape as before.
import dpsimpy
import villas.dataprocessing.readtools as rt
name = "rl_circuit"
gnd = dpsimpy.dp.SimNode.gnd
n1 = dpsimpy.dp.SimNode("n1")
n2 = dpsimpy.dp.SimNode("n2")
src = dpsimpy.dp.ph1.VoltageSource("src")
src.V_ref = complex(100, 0)
r = dpsimpy.dp.ph1.Resistor("r")
r.R = 10.0
l = dpsimpy.dp.ph1.Inductor("l")
l.L = 0.05
src.connect([gnd, n1])
r.connect([n1, n2])
l.connect([n2, gnd])
system = dpsimpy.SystemTopology(50, [gnd, n1, n2], [src, r, l])
logger = dpsimpy.Logger(name)
logger.log_attribute("i_l", "i_intf", l)
sim = dpsimpy.Simulation(name)
sim.set_domain(dpsimpy.Domain.DP)
sim.set_system(system)
sim.set_time_step(1e-4)
sim.set_final_time(0.05)
sim.add_logger(logger)
sim.run()
current = rt.read_timeseries_dpsim("logs/" + name + ".csv")["i_l"]
A second node appears because the resistor and the inductor meet somewhere, and that junction is a
node like any other. Components connect to nodes, never directly to each other, so a series chain of
two elements always needs the node between them.
What to expect before running it
Two numbers are worth working out first, because they are what the result should be checked against.
The steady-state current follows from the impedance at the system frequency,
$$|I| = \frac{|V|}{\sqrt{R^2 + (\omega L)^2}}
= \frac{100}{\sqrt{10^2 + (2\pi \cdot 50 \cdot 0.05)^2}}
= \frac{100}{18.62} = 5.37 \ \mathrm{A},$$
and the transient decays with the time constant $\tau = L/R = 5$ ms, so the circuit settles after
roughly five of those, about 25 ms. The simulation runs for 50 ms, comfortably past that.
Getting 5.37 A at the end is the check that the circuit was built as intended. A wrong connection
order or a missing component usually shows up here rather than as an error.
Why the time step matters now
Run the same circuit twice, once at 0.1 ms and once at 5 ms, and compare the inductor current:
| Time | 0.1 ms step | 5 ms step |
|---|
| 5 ms | 5.699 A | 2.953 A |
| 10 ms | 6.105 A | 6.256 A |
| 20 ms | 5.271 A | 5.276 A |
| 50 ms | 5.371 A | 5.366 A |
At 5 ms the two disagree by nearly a factor of two. By 20 ms they agree to better than a tenth of a
percent, and both end at the right steady-state value.
Watch out: a bad step size hides in the final value
That pattern is the whole point. The coarse run is not uniformly wrong; it is wrong during the
transient and right afterwards. A step size equal to the time constant cannot resolve a change
that happens over one time constant, but it has no trouble with a value that is no longer changing.
Checking a simulation only at its final value will therefore not detect a step size that is far too
large.The rule that follows: choose the step against the fastest thing you need to see, not against the
duration of the run or the value you expect at the end. Here the fastest thing is $\tau = 5$ ms, and
0.1 ms resolves it with room to spare.
What the values mean in this domain
The current is complex, and abs() gives the magnitude of the envelope rather than an instantaneous
current. In this domain the 50 Hz oscillation is not in the numbers at all: it has been moved into
the carrier and handled analytically, which is why a 0.1 ms step is generous here and would be
merely adequate for the same circuit solved as a waveform.
That difference is the subject of a later step. For now it is enough to know that a flat line in a
dynamic phasor result means a steady sinusoid, not a constant.
The script
The complete script for this page is 02_adding_dynamics.py under examples/Python/Tutorials. The numbers quoted above are the numbers it prints, so if the two ever disagree the page is the one that is wrong.
Next
The circuit still has one source and one branch. Next is a network with a line between two buses,
where the state the simulation starts from stops being obvious.
The elements used here are derived under
RLC elements, and the trapezoidal companion
models behind them under nodal analysis.
1.3 - A Network, and Where It Starts From
A line between two buses, and initializing the dynamic run from a powerflow.
The circuits so far started from nothing and settled. That is fine for a resistor and an inductor,
and useless for a network: a real system is already running when you start looking at it, and the
transient you care about is the one caused by an event, not by switching the whole grid on.
This tutorial builds a two-bus network, solves its steady state with a powerflow, and starts the
dynamic simulation from that solution.

Part one: the powerflow
A powerflow is a different kind of simulation. It has no time step in any meaningful sense; it
solves the algebraic steady state, iterating until the bus voltages are consistent with the
specified powers.
import dpsimpy
import villas.dataprocessing.readtools as rt
Vnom = 20e3
n1pf = dpsimpy.sp.SimNode("n1", dpsimpy.PhaseType.Single)
n2pf = dpsimpy.sp.SimNode("n2", dpsimpy.PhaseType.Single)
slack = dpsimpy.sp.ph1.NetworkInjection("slack")
slack.set_parameters(voltage_set_point=Vnom)
slack.set_base_voltage(Vnom)
slack.modify_power_flow_bus_type(dpsimpy.PowerflowBusType.VD)
line = dpsimpy.sp.ph1.PiLine("line")
line.set_parameters(R=0.5, L=0.5 / 314, C=50e-6)
line.set_base_voltage(Vnom)
load = dpsimpy.sp.ph1.Load("load")
load.set_parameters(active_power=100e3, reactive_power=50e3, nominal_voltage=Vnom)
load.modify_power_flow_bus_type(dpsimpy.PowerflowBusType.PQ)
slack.connect([n1pf])
line.connect([n1pf, n2pf])
load.connect([n2pf])
system_pf = dpsimpy.SystemTopology(50, [n1pf, n2pf], [slack, line, load])
logger_pf = dpsimpy.Logger("pf")
logger_pf.log_attribute("v1", "v", n1pf)
logger_pf.log_attribute("v2", "v", n2pf)
sim_pf = dpsimpy.Simulation("pf")
sim_pf.set_system(system_pf)
sim_pf.set_domain(dpsimpy.Domain.SP)
sim_pf.set_solver(dpsimpy.Solver.NRP)
sim_pf.set_solver_component_behaviour(dpsimpy.SolverBehaviour.Initialization)
sim_pf.do_init_from_nodes_and_terminals(False)
sim_pf.set_time_step(0.1)
sim_pf.set_final_time(0.1)
sim_pf.add_logger(logger_pf)
sim_pf.run()
Four things here are new and none of them are optional.
The powerflow is built in the static phasor domain, dpsimpy.sp, whatever domain the dynamic
run will use. The solver is set to Solver.NRP, the Newton-Raphson powerflow solver, rather than
the default nodal solver.
modify_power_flow_bus_type is what makes the problem solvable. Every bus must declare which two of
its four quantities are known: VD fixes voltage magnitude and angle, and there must be exactly one
such bus, the slack, which absorbs whatever mismatch remains. PQ fixes active and reactive power,
which is what a load specifies. Without these the powerflow has no boundary conditions.
set_base_voltage is required on components because the solver works in per unit, and
do_init_from_nodes_and_terminals(False) tells the components not to try to initialize themselves
from node voltages that do not exist yet, since establishing those voltages is the job this
simulation is doing.
Running it gives 20 000 V at the slack and 20 075 V at the load bus. The load bus sitting above
nominal is not an error: the line’s shunt capacitance supplies more reactive power at this load than
the series impedance drops.
Part two: the dynamic run
The dynamic network is built separately, in the domain the simulation will actually use, and then
takes its initial state from the powerflow solution.
n1 = dpsimpy.dp.SimNode("n1", dpsimpy.PhaseType.Single)
n2 = dpsimpy.dp.SimNode("n2", dpsimpy.PhaseType.Single)
slack_d = dpsimpy.dp.ph1.NetworkInjection("slack")
slack_d.set_parameters(V_ref=complex(Vnom, 0))
line_d = dpsimpy.dp.ph1.PiLine("line")
line_d.set_parameters(
series_resistance=0.5,
series_inductance=0.5 / 314,
parallel_capacitance=50e-6,
)
load_d = dpsimpy.dp.ph1.RXLoad("load")
load_d.set_parameters(active_power=100e3, reactive_power=50e3, volt=Vnom)
slack_d.connect([n1])
line_d.connect([n1, n2])
load_d.connect([n2])
system_dp = dpsimpy.SystemTopology(50, [n1, n2], [slack_d, line_d, load_d])
system_dp.init_with_powerflow(systemPF=system_pf, domain=dpsimpy.Domain.DP)
logger = dpsimpy.Logger("dyn")
logger.log_attribute("v2", "v", n2)
sim = dpsimpy.Simulation("dyn")
sim.set_system(system_dp)
sim.set_domain(dpsimpy.Domain.DP)
sim.set_time_step(1e-3)
sim.set_final_time(0.05)
sim.add_logger(logger)
sim.run()
init_with_powerflow matches the two networks by node name and copies the solved voltages across,
which is why the node names must agree between the two topologies. It is the only line connecting
the two halves.
The result is the point of the whole exercise. The dynamic run starts at 20 074.8 V and ends at
20 074.9 V: it begins in steady state rather than settling into one. Without the powerflow it
would start from zero and spend the first several cycles charging the line, and any event applied
during that period would be mixed in with a startup transient that has nothing to do with the
system.
Parameter names differ between domains
The same component takes different keyword names in different domains. The static phasor line takes
R, L and C; the dynamic phasor line takes series_resistance, series_inductance and
parallel_capacitance. The load is Load with nominal_voltage in the powerflow and RXLoad with
volt in the dynamic run.
Watch out: parameter names differ between domains
This catches people, and the failure is loud rather than silent: passing the wrong keyword raises a
TypeError that lists the accepted signature. Read that list rather than guessing, and check the
generated reference when adding a component you have not used
before.
The script
The complete script for this page is 03_two_bus_network.py under examples/Python/Tutorials. The numbers quoted above are the numbers it prints, so if the two ever disagree the page is the one that is wrong.
Next
The network now starts where it should, so an event applied to it produces a clean response. Next is
applying one: a fault, using a switch.
The powerflow method is described under powerflow, and
the loads and lines used here under loads and
branches.
1.4 - Applying a Fault
Switching during a run, and why clearing a fault needs more care than applying one.
The network from the previous tutorial starts in steady state,
so anything that happens to it now is a response to the event rather than to startup. This tutorial
applies a fault at the load bus, clears it, and looks at what the clearing does.

Scheduling an event
A switch is an ordinary component. What makes it a fault is that its state is changed partway
through the run by an event.
fault = dpsimpy.dp.ph1.Switch("fault")
fault.set_parameters(open_resistance=1e9, closed_resistance=10.0)
fault.open()
fault.connect([gnd, n2])
# ... build the topology including `fault` ...
sim.add_event(dpsimpy.event.SwitchEvent(0.1, fault, True)) # apply
sim.add_event(dpsimpy.event.SwitchEvent(0.2, fault, False)) # clear
The switch is created open and connected between the load bus and ground, so closing it puts a
10 Ω path to ground at that bus. SwitchEvent takes the time, the switch, and the state to move to:
True closes, False opens.
The switch must be in the topology’s component list like anything else. A switch that is created,
connected and given events but left out of the list produces a run with no fault and no error.
Set a time step small enough to resolve the event. At 0.1 ms the fault instant is captured within
one step; a millisecond step would smear it.
What happens
The load bus sits at 20 080 V, drops to 19 090 V while the fault is on, and recovers afterwards.
The drop is modest because a 10 Ω fault on a 20 kV bus is not a solid short and the source is stiff.
The interesting part is the instant of clearing:
| Time | Bus voltage |
|---|
| 0.1999 s | 19 090 V |
| 0.2000 s | 22 684 V |
| 0.2001 s | 29 036 V |
| 0.2002 s | 33 103 V |
| 0.2003 s | 34 011 V |
| 0.2005 s | 26 726 V |
| 0.2007 s | 14 642 V |
| 0.2009 s | 9 081 V |
The bus voltage rings between 34 kV and 9 kV within a millisecond, a 70% overshoot on a network that
was in steady state a moment earlier.
Why, and what to do about it
This is not the physical response of the circuit. Opening the switch asks the simulation to
interrupt the current flowing through the line inductance within one time step, and an inductor
current cannot change instantaneously. With the trapezoidal companion model the result is a
numerical oscillation that decays slowly, as explained under
switches.
A real breaker does not do this, because an arc forms across the opening contacts and dissipates the
stored energy over a short but finite interval. The variable-resistance switch reproduces that: it
raises its resistance over several steps rather than in one.
Two changes are needed, and the second is easy to forget:
fault = dpsimpy.dp.ph1.varResSwitch("fault")
fault.set_parameters(open_resistance=1e9, closed_resistance=10.0)
fault.open()
fault.set_init_parameters(1e-4) # must match the simulation time step
Watch out: set_init_parameters must match your time step
set_init_parameters takes the time step and derives the rate at which the resistance is raised
from it. Without the call the component keeps a default rate that is correct only for a 1 ms step,
so a simulation at any other step size gets a transition of the wrong duration. Nothing warns you.With the same fault at the same instant:
| Time | Plain switch | Variable-resistance switch |
|---|
| 0.1999 s | 19 090 V | 19 090 V |
| 0.2001 s | 29 036 V | 19 595 V |
| 0.2003 s | 34 011 V | 20 432 V |
| 0.2006 s | 20 511 V | 20 943 V |
| 0.2009 s | 9 081 V | 20 580 V |
The oscillation is gone. The voltage rises smoothly to a 20 943 V peak, a 4% overshoot rather than
70%, and settles back to its pre-fault value.
Use the plain switch for switching that does not interrupt inductive current, and the
variable-resistance switch for faults, particularly at a machine terminal or a transformer winding.
The cost is that the system matrix changes on every step of the transition rather than once, so each
of those steps needs a refactorisation.
The script
The complete script for this page is 04_applying_a_fault.py under examples/Python/Tutorials. The numbers quoted above are the numbers it prints, so if the two ever disagree the page is the one that is wrong.
Next
The results so far have been envelopes. Next is running the same circuit as instantaneous waveforms
and comparing the two, which is where the domains stop being an abstraction.
1.5 - The Same Circuit in Two Domains
Running one circuit as waveforms and as envelopes, and comparing the two.
Every result so far has been an envelope, and the pages have said that an envelope is not a
waveform without showing what the difference costs. This tutorial runs the same circuit both ways
and puts the two on one axis.
The circuit is the RL branch from adding dynamics, unchanged.
Building the same circuit twice
Only the namespaces differ. dpsimpy.emt.ph1 instead of dpsimpy.dp.ph1, and Domain.EMT instead
of Domain.DP:
def build(domain, ns, ph1, name, dt):
gnd = ns.SimNode.gnd
n1 = ns.SimNode("n1")
n2 = ns.SimNode("n2")
src = ph1.VoltageSource("src")
src.set_parameters(
V_ref=complex(100, 0),
f_src=(50.0 if domain == dpsimpy.Domain.EMT else 0.0),
)
r = ph1.Resistor("r"); r.set_parameters(R=10.0)
l = ph1.Inductor("l"); l.set_parameters(L=0.05)
src.connect([gnd, n1]); r.connect([n1, n2]); l.connect([n2, gnd])
system = dpsimpy.SystemTopology(50, [gnd, n1, n2], [src, r, l])
logger = dpsimpy.Logger(name)
logger.log_attribute("i_l", "i_intf", l)
sim = dpsimpy.Simulation(name)
sim.set_domain(domain); sim.set_system(system)
sim.set_time_step(dt); sim.set_final_time(0.06)
sim.add_logger(logger)
sim.run()
return rt.read_timeseries_dpsim("logs/%s.csv" % name)
emt = build(dpsimpy.Domain.EMT, dpsimpy.emt, dpsimpy.emt.ph1, "cmp_emt", 5e-5)["i_l"]
dp = build(dpsimpy.Domain.DP, dpsimpy.dp, dpsimpy.dp.ph1, "cmp_dp", 1e-3)
dp_shift = ts.frequency_shift_list(dp, 50)["i_l_shift"]
The one parameter that means different things
f_src is not the same quantity in the two domains, and this is the single easiest way to get a
wrong answer here.
Watch out: f_src means different things per domain
In EMT it is the absolute frequency of the source, so 50 Hz means 50 Hz. In DP and SP it is an
offset from the carrier, so passing 50 there gives a source at 100 Hz. Leave it at zero, or
omit it, when you want a source at the system frequency in an envelope domain.
Getting this wrong is not obvious from the output: the simulation runs, and the current is simply
smaller than it should be because the inductive reactance has doubled. In this circuit the wrong
setting gives 3.02 A instead of 5.37 A, which looks like a plausible number rather than an error.
The comparison

The dashed line is the dynamic phasor result shifted back onto the 50 Hz carrier. It lies on the EMT
waveform. The third curve is the envelope magnitude itself, which is what the DP simulation actually
computed: the smooth rise to 5.37 A that the oscillation is riding on.
| Quantity | EMT | DP shifted back |
|---|
| Time step | 50 µs | 1 ms |
| Samples over 60 ms | 1201 | 61 |
| Peak current, steady state | 5.3703 A | 5.3603 A |
| Current at t = 60 ms | −2.8839 A | −2.8840 A |
Twenty times fewer steps, and the same answer to four significant figures.
Why this works, and when it does not
The saving is not because the envelope model is coarser. For a single carrier the transform is
exact. The 50 Hz oscillation has been moved out of the integrated quantity and into a coefficient
handled analytically, so the step size is set by how fast the envelope changes rather than by the
carrier. Here the envelope settles with a 5 ms time constant, and 1 ms resolves it comfortably.
What an envelope domain cannot represent is content outside the band it retains around the carrier.
A harmonic, a fast switching transient, or a wideband disturbance is simply absent. That is the
trade, and it is the reason both domains exist rather than one being better.
The transform itself is derived under
dynamic phasors.
Reading it back
frequency_shift_list appends _shift to every key, so the shifted series is i_l_shift and not
i_l. Asking for the original name after shifting raises a KeyError that reads like a missing
signal.
The script
The complete script for this page is 05_comparing_domains.py under examples/Python/Tutorials. The numbers quoted above are the numbers it prints, so if the two ever disagree the page is the one that is wrong.
Next
The circuits so far have been passive. Next is a synchronous machine, where the model order becomes
a choice and initialization from a powerflow stops being optional.
1.6 - Adding a Machine
A synchronous generator, initializing it correctly, and what the model order changes.
Everything so far has been passive. A synchronous machine brings two things that no previous
tutorial needed: it has mechanical state, so it can swing, and it has to be told the operating point
it starts from rather than deducing it.
The network is a machine feeding a strong grid through a line, with a fault applied at the machine
terminal for 100 ms.
Machine parameters
A machine is specified by operational parameters rather than winding data:
gen = dpsimpy.dp.ph1.SynchronGenerator4OrderVBR("gen")
gen.set_operational_parameters_per_unit(
nom_power=555e6, nom_voltage=24e3, nom_frequency=60.0, H=3.7,
Ld=1.81, Lq=1.76, L0=0.15,
Ld_t=0.3, Lq_t=0.65, Td0_t=8.0, Tq0_t=1.0,
)
The inductances are in per unit on the machine’s own base and the time constants in seconds. H is
the inertia constant, and it sets how fast the machine can accelerate: a low H swings further for
the same disturbance.
Each model order takes a different parameter set, and the difference is exactly the states it keeps.
The third order model omits Lq_t and Tq0_t because it has no q-axis rotor state at all. The
sixth order model adds the subtransient set, Ld_s, Lq_s, Td0_s, Tq0_s and Taa. Passing the
wrong set raises a TypeError listing the accepted signature. The equations are derived under
reduced order machine models.
The machine base and the network around it
The machine parameters are per unit on the machine’s own base, while the line is given in ohms, so
the two only make sense together. The base impedance follows from the machine rating,
$$Z_{base} = \frac{V_{nom}^2}{S_{nom}} = \frac{(24\,\mathrm{kV})^2}{555\,\mathrm{MVA}} = 1.04 \ \Omega .$$
A line reactance of a few tenths of an ohm is therefore a few tenths per unit, which is an ordinary
transmission connection. The same line specified as 20 mH would be 7.5 Ω, above 7 per unit, and no
machine delivers rated power through that.
Watch out: an impossible operating point looks like instability
The consequence is worth knowing because it is not reported as an error. A machine asked to deliver
more power than the network can carry simply accelerates: the rotor speed climbs monotonically and
never returns, which looks like an unstable model rather than an impossible operating point.Initializing the machine
The network is initialized from a powerflow exactly as in
the two-bus tutorial. The machine additionally needs its own
operating point:
system_dp.init_with_powerflow(systemPF=system_pf, domain=dpsimpy.Domain.DP)
vterm = n1.initial_single_voltage() # from the powerflow, magnitude and angle
gen.set_initial_values(
init_complex_electrical_power=complex(300e6, 0),
init_mechanical_power=300e6,
init_complex_terminal_voltage=vterm,
)
Watch out: take the terminal voltage from the powerflow
Take the terminal voltage from the node rather than writing it out. It is tempting to pass
complex(24e3, 0) since that is the scheduled magnitude, but the generator bus is a PV bus and its
voltage leads the slack: here by 0.158 rad, about 9°. Supplying angle zero gives the machine a
rotor position inconsistent with the network it is connected to, and it starts by swinging into
agreement.The difference is measurable. With the angle assumed zero, the rotor speed oscillates by ±0.33% for
the first half second, and a fault applied at 0.5 s lands on top of a transient that has nothing to
do with it. Taking the voltage from the node, the pre-fault speed is flat to 5 × 10⁻⁶ pu and the
only thing in the result is the fault.
That the mechanical power equals the scheduled electrical power is the other half of the same
condition: if they disagree, the machine accelerates or decelerates from the first step.
What the model order changes
The same fault, applied to the same machine at the same instant, with three model orders:

| Model order | Peak speed | States |
|---|
| 3rd | 1.00362 pu | field winding only |
| 4th | 1.00308 pu | field and one q-axis damper |
| 6a | 1.00228 pu | transient and subtransient, both axes |
All three peak at the clearing instant and all three recover, but the third order model swings
noticeably further. That is not numerical: omitting the q-axis rotor removes damping that is
physically present, so the third order machine is optimistic about how far it swings and
pessimistic about how well it settles.
The practical reading is that model order is a statement about which phenomena you intend to
capture. For a first-swing stability question the fourth order model is the usual choice. The
subtransient orders matter when the first cycles after the fault are the subject rather than the
envelope of the swing.
The script
The complete script for this page is 06_a_machine.py under examples/Python/Tutorials. The numbers quoted above are the numbers it prints, so if the two ever disagree the page is the one that is wrong.
Next
The machine is a source of energy with its own dynamics. Next is a converter, where the dynamics
are in the control rather than in a rotor.
1.7 - Exchanging Data With Another Tool
Handing a value out of a running simulation, and where the boundary lies.
Everything so far has ended with a CSV read after the run. This tutorial hands a value out while
the simulation runs, which is what co-simulation, hardware in the loop and any live coupling are
built on.
The far side here is a file, so nothing external has to be running. A file is a poor co-simulation
partner and an excellent first one: the mechanism is identical to an MQTT broker or an FPGA, and
only the configuration changes.
What is different about this run
sim = dpsimpy.RealTimeSimulation(name)
...
sim.run(1)
It is a RealTimeSimulation, not a Simulation, and run takes a start delay in seconds. An
exchange is paced by the wall clock rather than by how fast the solver can go, because the other
side is a real thing running in real time. A one-second simulation takes one second.
That also means the results carry wall-clock timestamps rather than simulation time, which is
visible in the output below.
Configuring the far side
interface_config = {
"type": "file",
"format": "csv",
"uri": "logs/exchanged.csv",
"out": {"flush": True},
}
interface = dpsimpyvillas.InterfaceVillas(
name="dpsim-file", config=json.dumps(interface_config)
)
This dictionary is not DPsim configuration. It is a VILLASnode node description, passed through
as JSON, and its keys are documented by VILLASnode rather than here. Changing type from file to
mqtt and giving a broker address is the entire difference between writing to disk and publishing
to a broker; nothing in the simulation changes.
Requires a build with VILLASnode
This tutorial needs dpsimpyvillas, a separate extension module from dpsimpy, which only exists
in a build configured with VILLASnode available. The rest of the ladder needs only the installed
Python package.Choosing what crosses the boundary
interface.export_attribute(boundary.attr("i_intf").derive_coeff(0, 0), 0)
sim.add_interface(interface)
Two things are worth reading slowly.
The exported quantity is an attribute, the same unit the logger works in. attr("i_intf") takes
the whole interface current, which is a matrix, and derive_coeff(0, 0) selects one element of it.
Without that you would be handing across a matrix where the far side expects a number.
Watch out: the mapping is positional, not by name
The second argument is a position, not a name. It is the index in the signal list on the
VILLASnode side, and the mapping between the two is entirely positional. Exporting two attributes in
one order and describing them in another produces a run that exchanges the wrong quantities without
any error at all, which is the failure to watch for.What comes out
The file is written in VILLASnode’s sample format, not as a DPsim result CSV:
# secs,nsecs,offset,sequence,signal0
1785540392,258011384,nan,0,5.00000000000000000+0.00000000000000000i
A wall-clock timestamp in seconds and nanoseconds, an offset, a sequence number, then one column per
exported signal. Over one second at a 10 ms step this run wrote 101 rows.
The offset column is nan here, and that is correct rather than a misconfiguration. It reports the
delay between when a sample was created and when it was received, so it only has a value on an
incoming path, where those two instants genuinely differ and the difference is the transport latency.
These samples originate in the simulation and go straight out, so there is no receive event to
measure against and the column is empty by construction.
The sequence number is what the far side uses to detect a missed sample. The queueless interface
relies on it directly, which is why it reserves the first imported signal for a sequence counter.
Reading in the other direction
import_attribute is the mirror of export_attribute and makes an incoming value drive something
in the simulation, typically the reference of a controlled source. Two options change the timing:
blockOnRead halts the simulation at the start of every step until a new value arrives, and
syncOnSimulationStart holds the whole run until the far side has produced its first value. Both
are described under co-simulation.
Without either, the simulation reads whatever arrived most recently and carries on, which is the
right behaviour when the far side is slower and the wrong one when the exchange must be lock-step.
The script
The complete script for this page is 08_exchanging_data.py under examples/Python/Tutorials. It needs a build with VILLASnode available.
Where to read further
DPsim documents its own side of the boundary: which attributes cross, and when they are read and
written relative to the step. Everything on the other side of that JSON belongs to VILLASnode and is
documented there. The co-simulation page collects
the links.
The theory of what a delay across a coupling costs is under
the ideal transformer model, which is
the same argument whether the two sides are two solvers or two machines.