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

Return to the regular view of this page.

Writing a Model

Adding a component, interfacing it with the solver, and finding out why it is wrong.

The path from an empty file to a working component: what to declare, which hooks the solver calls and in what order, how a component built from other components is assembled, and how to debug one that runs but produces the wrong answer.

For the equations a model should implement, see Concepts. For worked examples of finished models, see model implementations.

1 - Component and Solver Initialization

How DPsim initializes components and solvers before the first simulation timestep.

Initialization is the phase between constructing the system topology and running the first timestep. Its job is to size the system matrices, derive initial state from power-flow results, register MNA tasks, and stamp static conductances. Two constraints drive its structure:

  • The system matrix size depends on the total number of simulation nodes, including virtual nodes declared by composite components and their sub-components. All virtual nodes must therefore be known before the matrices are allocated.
  • Component parameter values (impedances, initial phasors) depend on terminal voltages and powers, which are only available after a power-flow solve.

These two constraints impose an ordering that is captured in the solver’s initialization sequence.


MNA Solver Initialization Sequence

MnaSolver::initialize() executes the following steps in order.

flowchart TD start([Simulation::run]) --> init[MnaSolver::initialize] init --> s1["S1: identifyTopologyObjects()\nSort into mMNAComponents,\nmSimSignalComps, ..."] s1 --> s2["S2: createSubComponents() pre-pass\nRecursively instantiate sub-components\nso all virtual nodes exist"] s2 --> s3["S3: collectVirtualNodes()\nassignMatrixNodeIndices()\nMatrix size is now fixed"] s3 --> s4["S4: createEmptyVectors()\ncreateEmptySystemMatrix()"] s4 --> s5a["S5a: initializeFromNodesAndTerminals(freq)\nfor each SimPowerComp"] s5a --> s5b["S5b: initialize(omega, dt)\nfor each SimSignalComp"] s5b --> s5c["S5c: mnaInitialize(omega, dt, v)\nfor each MNAInterface component"] s5c --> cond{mSteadyStateInit?} cond -- yes --> s6["S6: steadyStateInitialization()\nIterate MNA until phasors converge"] s6 --> s7 cond -- no --> s7["S7: setBehaviour(MNASimulation)\non all components"] s7 --> s8["S8: initializeSystem()\nStamp static elements,\ncompute LU factorizations"] s8 --> done([Ready for timesteps])

Step 1 — Identify topology objects

identifyTopologyObjects() iterates over SystemTopology::mComponents and sorts each component into one of four lists:

ListContents
mMNAComponentsStatic MNA power components
mMNAIntfVariableCompsVariable-stamp MNA components (e.g. under MNAVariableCompInterface)
mMNAIntfSwitchesComponents with a switch interface
mSimSignalCompsSignal components (SimSignalComp)

Ground nodes are excluded here.

Step 2 — Create sub-components (pre-pass)

Before the matrix can be sized, every composite component’s sub-component tree must be fully instantiated so that all virtual nodes are visible. The solver calls createSubComponents() recursively on every MNA component:

  • Only sub-components newly registered by this call are recursed into, because eagerly-constructed sub-components (created in the constructor before connect() has run) are not yet safe to recurse into.
  • This step is a pre-pass only — it must not set parameter values derived from terminal data or frequency.

For details on the three-stage composite lifecycle (createSubComponents, initializeParentFromNodesAndTerminals, mnaCompInitialize), see Subcomponent Handling.

Step 3 — Collect virtual nodes and assign indices

collectVirtualNodes() visits every component and calls virtualNodes() to collect all virtual SimNode objects, then appends them to the solver’s node list. assignMatrixNodeIndices() then assigns a contiguous integer index to every simulation node (real and virtual), which determines the row/column layout of the system matrices.

After this step the matrix size is fixed.

Step 4 — Allocate empty matrices

createEmptyVectors() and createEmptySystemMatrix() allocate the left-side vector, right-side vector, system matrix (dense or sparse depending on the solver variant), and switch-variant copies. For sparse solvers, mBaseSystemMatrix and mLuFactorizations are also allocated here, with one variant per switch combination.

Step 5 — Initialize components (initializeComponents)

This step has three sub-passes over the component lists.

5a — Power components: initializeFromNodesAndTerminals

For every SimPowerComp<VarType> in mMNAComponents and mMNAIntfVariableComps:

  1. checkForUnconnectedTerminals() validates connectivity.
  2. If mInitFromNodesAndTerminals is set (the default), initializeFromNodesAndTerminals(mSystem.mSystemFrequency) is called.

This is where components read their terminal voltages and powers and derive physical parameters (impedances, initial phasor values, per-unit quantities). For composite components initializeFromNodesAndTerminals() is final in CompositePowerComp and sequences the three lifecycle stages automatically; non-composite power components override it directly.

5b — Signal components: initialize(omega, timeStep)

Each SimSignalComp in mSimSignalComps receives initialize(mSystem.mSystemOmega, mTimeStep). This is the hook for signal-domain components (regulators, governors, PSS blocks) to allocate their state buffers, set initial values, and wire up attribute connections.

5c — MNA components: mnaInitialize

Each MNA component (including switches) receives mnaInitialize(omega, timeStep, leftVector). In MNASimPowerComp this method:

  1. Clears and re-registers MNAPreStep / MNAPostStep tasks according to the hasPreStep / hasPostStep flags.
  2. Initializes mRightVector to zero with the correct size.
  3. Calls mnaCompInitialize(omega, timeStep, leftVector) on the component.

In mnaCompInitialize, component classes call updateMatrixNodeIndices() and perform any one-time MNA setup that requires the final node layout (e.g. allocating per-component history vectors sized to the system).

Nodes are initialized last via SimNode::initialize(), which zeros the node voltage.

Step 6 — Optional steady-state initialization

If mSteadyStateInit is set, steadyStateInitialization() iterates the MNA solve until the phasor solution converges. The flag mIsInInitialization is set to true for this sub-phase so that components can distinguish initialization solves from simulation solves via mBehaviour (see below).

Step 7 — Set simulation behaviour

After initialization solves are complete, the solver calls setBehaviour(TopologicalPowerComp::Behaviour::MNASimulation) on every TopologicalPowerComp and setBehaviour(SimSignalComp::Behaviour::Simulation) on every SimSignalComp.

The Behaviour enum (defined in TopologicalPowerComp) has three values:

ValueWhen activeTypical use
Behaviour::InitializationDuring PF steady-state init passComponents may disable transient update equations
Behaviour::PFSimulationDuring PFSolver runActivates power-flow-specific stamping
Behaviour::MNASimulationAfter initialize() completesNormal simulation; components should be in their run-time mode

Components that need different behaviour between initialization and simulation check mBehaviour in their pre/post-step methods or in mnaCompPreStep.

Step 8 — Initialize system matrices (initializeSystem)

initializeSystem() selects one of three paths:

  • Parallel frequencies (initializeSystemWithParallelFrequencies): stamps each frequency into a separate thread.
  • Variable matrix (initializeSystemWithVariableMatrix): used by MnaSolverSysRecomp; saves static switch matrices as base matrices and adds variable elements on top.
  • Precomputed matrices (initializeSystemWithPrecomputedMatrices): the common path. Calls switchedMatrixStamp() for each switch combination, which iterates over all static MNA components and calls mnaApplySystemMatrixStamp() and mnaApplyRightSideVectorStamp(). LU factorizations are computed for each variant.

After this step the solver is ready to execute timesteps.


Component Class Hierarchy and Init Hooks

The following diagram shows which initialization methods live in which class, and the override points for component authors.

classDiagram class TopologicalPowerComp { +Behaviour mBehaviour +setBehaviour(b) } class SimPowerComp~T~ { +initialize(Matrix frequencies) +initializeFromNodesAndTerminals(Real freq) +virtualNodes() } class MNASimPowerComp~T~ { +mnaInitialize(omega, dt, v) final +mnaCompInitialize(omega, dt, v)* +mnaCompApplySystemMatrixStamp()* +mnaCompPreStep()* +mnaCompPostStep()* } class CompositePowerComp~T~ { +createSubComponents()* +initializeFromNodesAndTerminals(freq) final +initializeParentFromNodesAndTerminals(freq)* +mnaParentInitialize(omega, dt, v)* +mnaParentPreStep()* +mnaParentPostStep()* } class SimSignalComp { +initialize(Real omega, Real dt)* } TopologicalPowerComp <|-- SimPowerComp SimPowerComp <|-- MNASimPowerComp MNASimPowerComp <|-- CompositePowerComp

Methods marked * are the virtual override points for component authors. Methods marked final must not be overridden; the base class sequences them correctly.


Component Method Contracts

The table below summarizes which initialization method has which responsibilities. A tick means the operation belongs in that method; a cross means it must not appear there.

ResponsibilityConstructor / setParameterscreateSubComponentsinitializeFromNodesAndTerminalsmnaCompInitialize
Declare virtual node count
Allocate sub-component objects
connect() sub-components to virtual nodes
addMNASubComponent() registration
Read terminal voltage / power
Read system frequency✓ (via argument)✓ (via omega)
Compute impedance / admittance
Call setParameters() on sub-components
Call updateMatrixNodeIndices()
Allocate per-step vectors (history, right vector)
Register MNA tasks (handled by base class)✓ (via mnaCompInitialize)

Common pitfalls

  • Accessing terminals in the constructor or createSubComponents: terminal data (initial voltage, connected power) is not yet populated. The topology is set up but power-flow has not run.
  • Accessing mFrequencies(0,0) in createSubComponents: the system frequency matrix is set on SimPowerComp via initialize(Matrix) which only runs later. Use the frequency argument passed to initializeParentFromNodesAndTerminals or the omega argument in mnaCompInitialize.
  • Zero-valued shunt branches: a capacitor or reactor with zero admittance injects a zero row/column into the system matrix, which makes the LU factorization singular. Guard with a strict > 0 check and omit the branch rather than inserting a zero stamp.

Composite Component Initialization Sequence

The following diagram shows how the solver and a composite component interact during initialization. For further details see Subcomponent Handling.

sequenceDiagram participant MNA as MnaSolver participant CC as CompositePowerComp participant SC as SubComponent Note over MNA,SC: Step 2 - pre-pass (topology only) MNA->>CC: createSubComponents() CC->>SC: make_shared + connect() + addMNASubComponent() Note over MNA,SC: Step 3 - matrix sizing MNA->>CC: collectVirtualNodes() MNA->>MNA: assignMatrixNodeIndices() Note over MNA,SC: Step 5a - parameterization MNA->>CC: initializeFromNodesAndTerminals(freq) CC->>CC: createSubComponents() idempotent guard CC->>CC: initializeParentFromNodesAndTerminals(freq) CC->>SC: initialize(frequencies) CC->>SC: initializeFromNodesAndTerminals(freq) Note over MNA,SC: Step 5c - MNA setup MNA->>CC: mnaInitialize(omega, dt, v) CC->>SC: mnaInitialize(omega, dt, v) CC->>CC: mnaParentInitialize(omega, dt, v)

PFSolver Initialization

PFSolver::initialize() follows a simpler sequence because it operates only on single-phase SP components with no sub-component tree and does not need a createSubComponents pre-pass.

flowchart TD pf[PFSolver::initialize] --> p1[Classify components\ninto generator/load/line/... lists] p1 --> p2[setBaseApparentPower\nCompute per-unit base] p2 --> p3[assignMatrixNodeIndices] p3 --> p4[initializeComponents\ninitializeFromNodesAndTerminals\ncalculatePerUnitParameters] p4 --> p5[determinePFBusType\nPQ / PV / VD] p5 --> p6[determineNodeBaseVoltages] p6 --> p7[composeAdmittanceMatrix\nBuild Y-bus] p7 --> done([Ready to solve power flow])

PFSolver::setSolverAndComponentBehaviour() is the equivalent of Step 7 for the MNA solver: it calls setBehaviour(Behaviour::PFSimulation) or setBehaviour(Behaviour::Initialization) on all components to allow them to switch stamping modes.


Known Design Issues (issue #59)

The following areas were identified in GitHub issue #59 as needing improvement.

SimPowerComp::initialize(Matrix frequencies) naming clash

SimPowerComp<T>::initialize(Matrix frequencies) is called by the solver to propagate frequency information down the component tree.

The recommended path is:

  • For power components, use initializeFromNodesAndTerminals() or initializeParentFromNodesAndTerminals().
  • For signal components, use the initialize(Real omega, Real timeStep) hook provided by SimSignalComp.
  • For anything else (e.g. setting up state-space matrices), add a named helper called from one of the above.

The base implementation of SimPowerComp::initialize(Matrix) should be renamed to something that cannot be accidentally overridden (e.g. propagateFrequencies()), and an override guard should be added to catch accidental overrides.

Sub-component construction in constructors

Some components create and register sub-components eagerly in their constructor before connect() has been called on those sub-components. This works today because the solver’s createSubComponents pre-pass skips already-registered sub-components, but it couples topology creation to object construction and makes components harder to reason about. The long-term goal is to migrate all sub-component construction to createSubComponents(), giving a clear rule: the constructor only allocates and the topology stage wires.

Signal component initialize not sequenced with power flow

Signal components receive initialize(omega, timeStep) after initializeFromNodesAndTerminals on power components but before the MNA tasks are registered. If a signal component’s initial state depends on the power-flow solution (e.g. an exciter initializing to match the generator terminal voltage), it must read the relevant attribute values directly — there is no formal mechanism today to express this dependency in the initialization sequence. A future improvement would be to give signal components access to the settled power-flow solution before their initialize is called.

2 - Real-Time Execution

Tuning the host and writing a model that can hold a deadline.

Why you would run in real time, and how to start such a run, is under real-time simulation. This page is what has to be true of the host and of the models for a deadline to be met.

DPsim runs in real time on any system, but without tuning the smallest reliable step is nowhere near microseconds, because operating system noise and other processes interfere. With the tuning below, steps as low as 5 us synchronised to an FPGA through VILLASnode have been achieved.

Operating System and Kernel

A kernel built with PREEMPT_RT improves latency when issuing system calls and enables the FIFO scheduler that avoids preemption during the run.

This used to mean tracking down an out-of-tree patch set. It no longer does: PREEMPT_RT was merged into the mainline Linux kernel in 6.12, so a recent kernel can be built with it directly and a growing number of distributions ship or package one. Check what you already have before installing anything:

uname -v | grep -q PREEMPT_RT && echo "already real-time" || echo "not a PREEMPT_RT kernel"

If you need one, most distributions still offer a binary package. On Rocky Linux:

sudo dnf --enablerepo=rt install kernel-rt kernel-rt-devel

More aggressive tuning can involve isolating a set of cores for exclusive use by the real-time simulation. This way, the kernel will not schedule any processes on these cores. Add the kernel parameters isolcpus and nohz_full using, for example, grubby:

sudo grubby --update-kernel=ALL --args="isolcpus=9,11,13,15 nohz_full=9,11,13,15"

Something similar, but less invasive and non-permanent can be achieved using tuna:

sudo tuna isolate -c 9,11,13,15

To avoid real-time throttling to cause overruns disable this feature:

sudo bash -c "echo -1 > /proc/sys/kernel/sched_rt_runtime_us"

Note that this is not persistent when rebooting.

Simulation Model Tuning

Real time capable models cannot issue any system calls during simulation as the context switch to the kernel introduces unacceptable latencies. This means models cannot allocate memory, use mutexes or other interrupt-driven synchronization primitives, read or write data from files. You should turn off logging, when time steps in the low milliseconds are desired. There is a RealTimeDataLogger that can be used to output simulation results in these cases. Note however, that this logger pre-allocated the memory required for all of the logging required during simulations. Your machine may run out of memory, when the simulation is long or you log too many signals.

You can increase the performance of your simulation by adding the -flto and -march=native compiler flags:

diff --git a/CMakeLists.txt b/CMakeLists.txt
index 8801cbe8d..4a2843269 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -79,7 +79,7 @@ include(CheckSymbolExists)
 check_symbol_exists(timerfd_create sys/timerfd.h HAVE_TIMERFD)
 check_symbol_exists(getopt_long getopt.h HAVE_GETOPT)
 if(CMAKE_BUILD_TYPE STREQUAL "Release" OR CMAKE_BUILD_TYPE STREQUAL "RelWithDebInfo")
-       add_compile_options(-Ofast)
+       add_compile_options(-Ofast -flto -march=native)
 endif()

 # Get version info and buildid from Git

Where the time step comes from

By default the simulation paces itself against the host clock, which is enough for almost everything. Synchronising the step to an external source instead is only necessary when the accuracy of the step itself matters at the nanosecond level, which in practice means hardware in the loop against equipment with its own clock.

That distinction is worth making before reaching for it: locking to an external source constrains the whole run and is not a general improvement, only the answer to a specific requirement.

Writing a model that can hold a deadline

A real-time capable model must issue no system calls during simulation: the context switch into the kernel costs more than the deadline allows.

Turn logging off when the step is in the low milliseconds. RealTimeDataLogger exists for the cases that still need results: it buffers in memory and writes at the end rather than touching the disk inside the step.

3 - Interfacing with the MNA Solver

The hooks a component implements to take part in the nodal solve.

The various solver classes based on MNASolver are used to perform Nodal Analysis during a DPsim simulation. For components to be able to influence the input variables of the MNA, they have to implement certain methods defined in the MNAInterface interface class. While it is possible to individually implement MNAInterface for every component, the behavior of many components can be unified in a common base class. This base class is called MNASimPowerComp<T>. Currently, it is the only class which directly implements MNAInterface and in turn all MNA components inherit from this class. Much like the CompositePowerComp class for Composite Components, the MNASimPowerComp class provides some common behavior for all MNA components, e.g. the creation and registration of the MNAPreStep and MNAPostStep tasks. Additionally, MNASimPowerComp provides a set of virtual methods prefixed mnaComp... which can be implemented by the child component classes to provide their own MNA behavior. These methods are:

virtual void mnaCompInitialize(Real omega, Real timeStep, Attribute<Matrix>::Ptr leftVector);
virtual void mnaCompApplySystemMatrixStamp(SparseMatrixRow& systemMatrix);
virtual void mnaCompApplyRightSideVectorStamp(Matrix& rightVector);
virtual void mnaCompUpdateVoltage(const Matrix& leftVector);
virtual void mnaCompUpdateCurrent(const Matrix& leftVector);
virtual void mnaCompPreStep(Real time, Int timeStepCount);
virtual void mnaCompPostStep(Real time, Int timeStepCount, Attribute<Matrix>::Ptr &leftVector);
virtual void mnaCompAddPreStepDependencies(AttributeBase::List &prevStepDependencies, AttributeBase::List &attributeDependencies, AttributeBase::List &modifiedAttributes);
virtual void mnaCompAddPostStepDependencies(AttributeBase::List &prevStepDependencies, AttributeBase::List &attributeDependencies, AttributeBase::List &modifiedAttributes, Attribute<Matrix>::Ptr &leftVector);
virtual void mnaCompInitializeHarm(Real omega, Real timeStep, std::vector<Attribute<Matrix>::Ptr> leftVector);
virtual void mnaCompApplySystemMatrixStampHarm(SparseMatrixRow& systemMatrix, Int freqIdx);
virtual void mnaCompApplyRightSideVectorStampHarm(Matrix& sourceVector);
virtual void mnaCompApplyRightSideVectorStampHarm(Matrix& sourceVector, Int freqIdx);

MNASimPowerComp provides empty default implementations for all of these methods, so component classes are not forced to implement any of them.

Controlling Common Base Class Behavior

Child component classes can control the behavior of the base class through the constructor arguments of MNASimPowerComp. The two boolean variables hasPreStep and hasPostStep can be used to control whether the MNAPreStep and MNAPostStep tasks will be created and registered. If these tasks are created, the mnaCompPreStep / mnaCompPostStep and mnaCompAddPreStepDependencies / mnaCompAddPostStepDependencies methods will be called during the component’s lifecycle. If the tasks are not created, these methods are superfluous and should not be implemented in the child class.

Currently, the MNASimPowerComp base class only exhibits additional behavior over the mnaComp... methods in the mnaInitialize method. In this method, the list of MNA tasks is cleared, and the new tasks are added according to the hasPreStep and hasPostStep parameters. Additionally, the right vector attribute mRightVector required by MNAInterface is set to a zero-vector with its length equal to that of the system leftVector. If this behavior is not desired, e.g. for resistors which have no influence on the system right vector, the right vector can be re-set to have zero size in the mnaCompInitialize method:

void DP::Ph1::Resistor::mnaCompInitialize(Real omega, Real timeStep, Attribute<Matrix>::Ptr leftVector) {
  updateMatrixNodeIndices();

  **mRightVector = Matrix::Zero(0, 0);
  //...
}

For all other MNA methods, the MNASimPowerComp base class will just call the associated mnaComp... method. For more details, take a look at the implementations in MNASimPowerComp.cpp.

4 - Subcomponent Handling

Building a component out of other components with CompositePowerComp.

In DPsim, there are many components which can be broken down into individual subcomponents. Examples are the PiLine, consisting of an inductor, three resistors, and two capacitors, or the NetworkInjection which contains a voltage source. On the C++ class level, these subcomponents are represented by member variables within the larger component class. In this guide, all components which have subcomponents are called composite components.

Creating Composite Components

While normal components are usually subclasses of SimPowerComp<T> or MNASimPowerComp<T>, there exists a special base class for composite components called CompositePowerComp<T>. This class provides multiple methods and parameters for configuring how the subcomponents should be handled with respect to the MNAPreStep and MNAPostStep tasks. The main idea here is that the subcomponents do not register their own MNA tasks, but instead their MNA methods like mnaPreStep and mnaPostStep are called explicitly in the tasks of the composite component. In the constructor of CompositePowerComp<T>, the parameters hasPreStep and hasPostStep can be set to automatically create and register a MNAPreStep or MNAPostStep task that will call the mnaCompPreStep or mnaCompPostStep method on execution. Additionally, all subcomponents should be registered as soon as they are created using the addMNASubComponent-method. This method takes multiple parameters defining how and in what order the subcomponent’s pre- and post- steps should be called, as well as if the subcomponent should be stamped into the system rightVector.

Initialization lifecycle

Composite components are initialized in three stages, each with a defined role. (These are distinct from the electrical phases A/B/C of a three-phase component.)

  1. Topology stage (createSubComponents()). Decides which sub-components exist and how they are wired: make_shared, connect() to network/virtual nodes, and addMNASubComponent(). This runs in a pre-pass before the MNA system matrix is sized, so any virtual nodes owned by sub-components are visible to collectVirtualNodes(). Because it runs before power-flow results or the simulation frequency are guaranteed to be available, createSubComponents() must not read terminal data (initialSingleVoltage(), singleActivePower(), …), system frequency (mFrequencies(0,0)), or compute any power-/impedance-derived value. It must be idempotent — guard the body with mSubCompCreated (a protected field inherited from CompositePowerComp).
  2. Parameterization stage (initializeParentFromNodesAndTerminals(Real frequency)). Sets the values the sub-components created in stage 1 will use. This is where terminal reads, frequency-dependent impedance/admittance calculations, and setParameters() calls on sub-components belong. The simulation frequency is passed in as a direct argument, so there is no need to access mFrequencies(0,0). This is the hook concrete composites must implement — do not override initializeFromNodesAndTerminals() directly; the base class owns that method and calls this hook at the right time.
  3. MNA-init stage (mnaCompInitialize()). Unchanged; already recurses into sub-components.

CompositePowerComp<VarType>::initializeFromNodesAndTerminals() is final and sequences these stages:

void initializeFromNodesAndTerminals(Real frequency) final {
  createSubComponents();                            // idempotent safety net for paths
                                                     //   that reach this composite without
                                                     //   the solver's pre-pass having run
  initializeParentFromNodesAndTerminals(frequency); // parent derives values,
                                                     //   setParameters() on subs
  for (auto subComp : mSubComponents) {
    subComp->initialize(mFrequencies);              // propagate frequencies down
    subComp->initializeFromNodesAndTerminals(frequency);
  }
}

The loop re-enters this same final wrapper for any sub-component that is itself a composite, so the whole tree initializes correctly without each level manually calling initialize()/initializeFromNodesAndTerminals() on its children.

A sub-component whose very existence (not just its value) depends on a parameterization-stage value — e.g. picking an inductor vs. a capacitor based on the sign of computed reactive power — cannot be registered in createSubComponents(). Create and register it directly inside initializeParentFromNodesAndTerminals() instead. This is safe because the MNA-registered sub-component list is not consumed until MnaSolver::initialize() finishes the parameterization stage for all components. The one constraint: the late-registered sub-component must not introduce new virtual nodes — those must be declared in the constructor or setParameters(), before collectVirtualNodes() runs.

// DP_Ph1_PiLine.cpp
DP::Ph1::PiLine::PiLine(String uid, String name, Logger::Level logLevel)
  : Base::Ph1::PiLine(mAttributes),
  // Call the constructor of CompositePowerComp and enable automatic pre- and post-step creation
  CompositePowerComp<Complex>(uid, name, true, true, logLevel)
{
  //...
}

void DP::Ph1::PiLine::createSubComponents() {
  if (mSubCompCreated)
    return;
  mSubCompCreated = true;

  // Create series sub components
  mSubSeriesResistor = std::make_shared<DP::Ph1::Resistor>(**mName + "_res", mLogLevel);

  // Setup mSubSeriesResistor... (only from values already known from this
  // component's own setParameters()/constructor/Attributes - no terminal or
  // frequency reads here)

  // Register the resistor as a subcomponent. The resistor's pre- and post-step will be called before the pre- and post-step of the parent,
  // and the resistor does not contribute to the `rightVector`.
  addMNASubComponent(mSubSeriesResistor, MNA_SUBCOMP_TASK_ORDER::TASK_BEFORE_PARENT, MNA_SUBCOMP_TASK_ORDER::TASK_BEFORE_PARENT, false);

  mSubSeriesInductor = std::make_shared<DP::Ph1::Inductor>(**mName + "_ind", mLogLevel);

  // Setup mSubSeriesInductor...

  // Register the inductor as a subcomponent. The inductor's pre- and post-step will be called before the pre- and post-step of the parent,
  // and the inductor does contribute to the `rightVector`.
  addMNASubComponent(mSubSeriesInductor, MNA_SUBCOMP_TASK_ORDER::TASK_BEFORE_PARENT, MNA_SUBCOMP_TASK_ORDER::TASK_BEFORE_PARENT, true);
  //...
}

void DP::Ph1::PiLine::initializeParentFromNodesAndTerminals(Real frequency) {
  //...
  // Frequency-dependent values go here, not in createSubComponents().
  Real omega = 2. * PI * frequency;
  Complex impedance = {**mSeriesRes, omega * **mSeriesInd};
  //...
}

Orchestrating MNA Method Calls

By choosing which methods to override in the composite component class, subcomponent handling can either be offloaded to the CompositePowerComp base class or manually implemented in the new component class. By default, CompositePowerComp provides all methods demanded by MNAInterface in such a way that the subcomponents’ MNA-methods are properly called. To also allow for the composite component class to perform further actions in these MNA-methods, there exist multiple methods prefixed with mnaParent, e.g. mnaParentPreStep or mnaParentAddPostStepDependencies. These parent methods will usually be called after the respective method has been called on the subcomponents. For the mnaPreStep and mnaPostStep methods, this behavior can be set explicitly in the addMNASubComponent method.

If a composite component requires a completely custom implementation of some MNA-method, e.g. for skipping certain subcomponents or for calling the subcomponent’s methods in a different order, the composite component class can still override the original MNA-method with the mnaComp prefix instead of the mnaParent prefix. This will prevent the CompositePowerComp base class from doing any subcomponent handling in this specific MNA-method, so the subcomponent method calls have to be performed explicitly if desired. Given this, the following two implementations of the mnaAddPreStepDependencies method are equivalent:

void DP::Ph1::PiLine::mnaParentAddPreStepDependencies(AttributeBase::List &prevStepDependencies, AttributeBase::List &attributeDependencies, AttributeBase::List &modifiedAttributes) {
  // Only add the dependencies of the composite component, the subcomponent's dependencies are handled by the base class
  prevStepDependencies.push_back(mIntfCurrent);
  prevStepDependencies.push_back(mIntfVoltage);
  modifiedAttributes.push_back(mRightVector);
}
void DP::Ph1::PiLine::mnaCompAddPreStepDependencies(AttributeBase::List &prevStepDependencies, AttributeBase::List &attributeDependencies, AttributeBase::List &modifiedAttributes) {
  // Manually add pre-step dependencies of subcomponents
  for (auto subComp : mSubcomponentsMNA) {
    subComp->mnaAddPreStepDependencies(prevStepDependencies, attributeDependencies, modifiedAttributes);
  }

  // Add pre-step dependencies of component itself
  prevStepDependencies.push_back(mIntfCurrent);
  prevStepDependencies.push_back(mIntfVoltage);
  modifiedAttributes.push_back(mRightVector);
}

5 - Add New Model

Extending the simulator with new component or control models.

This page walks through adding a component model, using a three phase dynamic phasor inductor as the example.

Where the code lives

Component models live in the dpsim-models subproject, which builds the CPS library. Headers and sources are separate trees, both organised by domain:

dpsim-models
 |- include
 |   \ dpsim-models
 |       |- Base            shared base classes, one per component family
 |       |- DP              dynamic phasor
 |       |- EMT             electromagnetic transient
 |       |- SP              static phasor
 |       \ Signal           domain independent control and signal models
 \- src
     |- Base
     |- DP
     |- EMT
     |- SP
     \ Signal

Namespaces follow the same shape, with the phase count nested inside the domain:

CPS::{DP,EMT,SP}::{Ph1,Ph3}::{Name}
CPS::Signal::{Name}

File names encode the same information, so the example model needs two files:

  • dpsim-models/include/dpsim-models/DP/DP_Ph3_Inductor.h
  • dpsim-models/src/DP/DP_Ph3_Inductor.cpp

declaring the class CPS::DP::Ph3::Inductor.

Choosing a base class

DPsim supports several solvers, and each requires certain member functions on the component. Which ones you implement is determined by the interfaces you inherit rather than by the solver itself.

For an MNA component, derive from MNASimPowerComp<VarType>, with Complex as the variable type in the DP and SP domains and Real in EMT. The MNA hooks are declared on MNAInterface, which MNASimPowerComp implements, so that is where to look for the full set.

If the model is naturally expressed as several existing components wired together rather than as a single stamp, derive from CompositePowerComp and add subcomponents instead. The pi-line is a worked example. See subcomponents.

If the component is better described by its own state-space model coupled to the network, see state-space nodal for that alternative.

Attributes

Every component exposes its parameters and state through attributes, declared in the class and registered in the constructor. Attributes are what make a value visible to the logger, to the Python bindings and to the task scheduler.

How to declare, read and derive attributes is described under attributes and attribute usage.

Tasks and step functions

Pre-step and post-step functions are registered as tasks, and the scheduler derives the order in which they may run from the attributes each task reads and writes. Declaring those dependencies correctly matters: a task that modifies an attribute without declaring it can be scheduled in the wrong order, or in parallel with a reader.

How tasks are built and how the dependency graph is derived is described under scheduling.

Registering the new component

A new component is not picked up automatically. Three places have to be updated:

  • dpsim-models/src/CMakeLists.txt, adding the new source file to the list, for example DP/DP_Ph3_Inductor.cpp
  • dpsim-models/include/dpsim-models/Components.h, adding the header so that including that one file gives access to every component
  • dpsim/src/pybind/DPComponents.cpp, or the matching EMTComponents.cpp, SPComponents.cpp or SignalComponents.cpp, to expose the class to Python

The Python binding follows the existing pattern in those files:

py::class_<CPS::DP::Ph1::Resistor, std::shared_ptr<CPS::DP::Ph1::Resistor>,
           CPS::SimPowerComp<CPS::Complex>>(mDPPh1, "Resistor", py::multiple_inheritance())
    .def("set_parameters", &CPS::DP::Ph1::Resistor::setParameters, "R"_a);

Name the arguments using the "R"_a form shown above. Without it the Python signature and the generated reference fall back to positional placeholders such as arg0, and callers cannot use keyword arguments.

Initialization

Components are initialized either from power flow results or from explicitly set initial values, and the solver calls into the component in a defined order. Do not add an initialize(Real) overload of your own for user-facing initialization; use the documented hooks instead.

See initialization for the full sequence, and note the scaling conventions in guidelines, since initialization quantities are RMS3PH while EMT simulation quantities are PEAK1PH.

6 - Create New Simulation

Using DPsim for a new simulation scenario.

Here, we will show the implementation of a new simulation scenario defined in C++, which is using DPsim as a library.

Directory Structure

In the end, your directory structure should look like as follows:

my-project
  |- CMakeLists.txt
  |- source
      |- my-scenario.cpp
  |- dpsim (as submodule)

CMake File

Your CMakeLists.txt could look like this:

cmake_minimum_required(VERSION 3.14)
project(my-project CXX)

add_subdirectory(dpsim)

add_executable(my-scenario source/my-scenario.cpp)
target_link_libraries(my-scenario dpsim)

Build the Project

The build process is similar to the one of DPsim:

cd my-project
mkdir build && cd build
cmake ..
make my-scenario

7 - Debugging

Finding the cause when a simulation runs but produces the wrong answer.

Mixed Python C++ Debugging

Prerequisites

Your vscode launch.json should have two configurations, one to launch the python process and one to attach gdb:

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Python: Current File",
            "type": "python",
            "request": "launch",
            "program": "${file}",
            "console": "integratedTerminal",
            "stopOnEntry": true,
            "env": {"PYTHONPATH": "${workspaceFolder}/build${pathSeparator}${env:PYTHONPATH}"}
        },
        {
            "name": "(gdb) Attach",
            "type": "cppdbg",
            "request": "attach",
            "program": "/usr/bin/python",
            "processId": "${command:pickProcess}",
            "MIMode": "gdb",
            "setupCommands": [
                {
                    "description": "Enable pretty-printing for gdb",
                    "text": "-enable-pretty-printing",
                    "ignoreFailures": true
                }
            ]
        }
    ]
}

The python debugger will stop on entry (“stopOnEntry”: true). Make sure to adapt your PYTHONPATH variable if necessary.

The C++ code has to be build in debug mode

cmake .. -DCMAKE_BUILD_TYPE=Debug

Attaching C++ Debugger

  • open the python example to be debugged
  • go to the debug menu and select / run the “Python: Current File” configuration
  • the python debugger should stop at entry
  • set C++ breakpoints
  • go to the debug menu and run the “(gdb) Attach” configuration
  • select a process… choose the python process with the “—adapter-access-token” part
  • you can view the whole description when you hover over the process with the mouse
  • press play to continue Python debugging… the c++ debugger will stop at the next breakpoint

You can automate this by using the vscode extension “Python C++ Debugger” and by adding this configuration to the launch.json above:

{
    "name": "Python C++ Debugger",
    "type": "pythoncpp",
    "request": "launch",
    "pythonConfig": "custom",
    "pythonLaunchName": "Python: Current File",
    "cppConfig": "default (gdb) Attach"
}

This will automatically run both debuggers and select the current process.

It can take a while before the debugger hits the C++ breakpoints.

C++ Debugging

Use the following launch.json for vscode and set the program path:

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "(gdb) Launch",
            "type": "cppdbg",
            "request": "launch",
            "program": "${workspaceFolder}/dpsim/build/Examples/Cxx/example",
            "args": [],
            "stopAtEntry": true,
            "cwd": "${workspaceFolder}",
            "environment": [],
            "externalConsole": false,
            "MIMode": "gdb",
            "setupCommands": [
                {
                    "description": "Enable pretty-printing for gdb",
                    "text": "-enable-pretty-printing",
                    "ignoreFailures": true
                }
            ]
        }
    ]
}

8 - Logger Implementation

The classes called a logger, and the seam for adding another.

Using the loggers is covered under logging results. This page covers the classes.

Distinct things share the name

ClassPurpose
DPsim::DataLoggerNumerical results to CSV, one row per step
DPsim::RealTimeDataLoggerThe same results, buffered in memory for real-time runs
DPsim::DataLoggerInterfaceThe seam both implement, and the one to implement for a new sink
CPS::LoggerThe diagnostic text log, controlled by LogLevel

Only the data loggers have anything to do with results. CPS::Logger is a different subsystem that happens to share the word, and conflating the two is the most common confusion here. A component constructed with Logger::Level::debug writes prose about its own initialization and contributes nothing to any CSV.

DataLogger

Holds a map from column name to attribute and appends a row per step. log(Real time, Int timeStepCount) returns early when the logger is disabled or when timeStepCount % mDownsampling != 0, so down-sampling is a modulo on the step counter rather than a time comparison, and it is exact regardless of step size.

The header is written lazily on the first row, by testing mLogFile.tellp() == 0. That means the column set is fixed by whatever was registered before the first log call; registering an attribute afterwards would produce rows that no longer match the header.

Values are written with std::scientific in fixed-width columns, which is what makes the output readable as a table and also what makes it larger than a minimal CSV would be.

The constructor takes (name, enabled, downsampling). The Python binding exposes only the name, so enabled and downsampling are unreachable from Python. A binding that took all three would make down-sampling available to notebook users, who currently have only the time step.

RealTimeDataLogger

Exists because writing to disk inside a real-time step is not acceptable: the file system offers no bound on how long a write takes, and one slow write overruns the step. It preallocates mAttributeData from either the final time and step size or an explicit row count, fills it during the run, and writes at the end.

The preallocation is the point, and it is also the constraint: the row count must be known before the run, so a real-time simulation of indefinite length needs a different arrangement.

DataLoggerInterface

The abstract seam. Implement it to send results somewhere other than a file, which is what the co-simulation interfaces do rather than logging and re-reading. Simulation::addLogger accepts anything implementing it.

Scheduling

A logger contributes a task like any other component, so the scheduler places it by its declared attribute dependencies. A logged attribute therefore keeps alive the task that produces it, which has a consequence worth knowing: logging an attribute can change which tasks the scheduler considers reachable. A model whose results change when a logger is added is exhibiting a missing dependency declaration elsewhere, not a logging bug. See adding tasks to a component.

Source

dpsim/src/DataLogger.cpp, dpsim/src/RealTimeDataLogger.cpp, dpsim/include/dpsim/DataLoggerInterface.h, and dpsim-models/include/dpsim-models/Logger.h for the unrelated diagnostic logger.