This is the multi-page printable view of this section. Click here to print.

Return to the regular view of this page.

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.

  1. Your first simulation. A source and a resistor. The shape of a script, and how to read a result back.
  2. Adding dynamics. An inductor, the transient it produces, and how to choose a time step.
  3. A network, and where it starts from. A line between two buses, and initializing the dynamic run from a powerflow.
  4. Applying a fault. Switching during a run, and why clearing a fault needs more care than applying one.
  5. The same circuit in two domains. Waveforms against envelopes, and what the envelope buys.
  6. Adding a machine. A synchronous generator, initializing it correctly, and what the model order changes.
  7. 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 - 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.

A voltage source feeding a resistor.

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.

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.

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.

Recovering the waveform from a dynamic phasor result

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']

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.

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.

A source, a resistor and an inductor in series.

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:

Time0.1 ms step5 ms step
5 ms5.699 A2.953 A
10 ms6.105 A6.256 A
20 ms5.271 A5.276 A
50 ms5.371 A5.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.

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.

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.

A slack bus, a line, and a load at the far bus.

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.

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.

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.

The same network with a switched fault branch at the load bus.

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:

TimeBus voltage
0.1999 s19 090 V
0.2000 s22 684 V
0.2001 s29 036 V
0.2002 s33 103 V
0.2003 s34 011 V
0.2005 s26 726 V
0.2007 s14 642 V
0.2009 s9 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

With the same fault at the same instant:

TimePlain switchVariable-resistance switch
0.1999 s19 090 V19 090 V
0.2001 s29 036 V19 595 V
0.2003 s34 011 V20 432 V
0.2006 s20 511 V20 943 V
0.2009 s9 081 V20 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.

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.

The comparison

EMT waveform and DP envelope for the same RL circuit

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.

QuantityEMTDP shifted back
Time step50 µs1 ms
Samples over 60 ms120161
Peak current, steady state5.3703 A5.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.

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.

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,
)

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:

Rotor speed through a fault for third, fourth and sixth order machine models

Model orderPeak speedStates
3rd1.00362 pufield winding only
4th1.00308 pufield and one q-axis damper
6a1.00228 putransient 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.

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.

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.

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.