Attributes and Scheduling
The attribute system, and how it decides the order everything runs in.
Attributes are the unit of state in DPsim, and they are not only a way to expose a value: the
scheduler builds the execution order from the dependencies that components declare over them. The
two subjects are one subject, which is why they sit together.
This is also what logging and the co-simulation
interfaces operate on, so a quantity that is not an attribute cannot be recorded or exchanged.
1 - Attributes
The attribute system that carries component state and drives task scheduling.
In DPsim, an attribute is a special kind of variable which usually stores a scalar or matrix value used in the simulation.
Examples for attributes are the voltage of a node, the reference current of a current source, or the left and right vectors of the MNA matrix system.
In general, attributes are instances of the Attribute<T> class, but they are usually stored and accessed through a custom smart pointer of type
const AttributeBase::Ptr (which expands to const AttributePointer<AttributeBase>).
Through the template parameter T of the Attribute<T> class, attributes can have different value types, most commonly Real, Complex, Matrix, or MatrixComp. Additionally, attributes can fall into one of two categories:
Static attributes have a fixed value which can only be changed explicitly through the attribute’s set-method or through a mutable reference obtained through get.
Dynamic attributes on the other hand can dynamically re-compute their value from other attributes every time they are read. This can for example be used to create a scalar attribute of type Real whose value always contains the magnitude of another, different attribute of type Complex.
Any simulation component or class which inherits from IdentifiedObject contains an instance of an AttributeList.
This list can be used to store all the attributes present in this component and later access them via a String instead of having to use the member variable directly.
For reasons of code clarity and runtime safety, the member variables should still be used whenever possible.
Creating and Storing Attributes
Normally, a new attribute is created by using the create or createDynamic method of an AttributeList object.
These two methods will create a new attribute of the given type and insert it into the AttributeList under the given name. After the name, create can take an additional parameter of type T which will be used as the initial value for this attribute.
Afterwards, a pointer to the attribute is returned which can then be stored in a component’s member variable. Usually this is done in the
component’s constructor in an initialization list:
/// Component class Base::Ph1::PiLine
public:
// Definition of attributes
const Attribute<Real>::Ptr mSeriesRes;
const Attribute<Real>::Ptr mSeriesInd;
const Attribute<Real>::Ptr mParallelCap;
const Attribute<Real>::Ptr mParallelCond;
// Component constructor: Initializes the attributes in the initialization list
Base::Ph1::PiLine(CPS::AttributeList::Ptr attributeList) :
mSeriesRes(attributeList->create<Real>("R_series")),
mSeriesInd(attributeList->create<Real>("L_series")),
mParallelCap(attributeList->create<Real>("C_parallel")),
mParallelCond(attributeList->create<Real>("G_parallel")) { };
When a class has no access to an AttributeList object (for example the Simulation class), attributes can instead be created through the
make methods on AttributeStatic<T> and AttributeDynamic<T>:
// Simulation class
Simulation::Simulation(String name, Logger::Level logLevel) :
mName(AttributeStatic<String>::make(name)),
mFinalTime(AttributeStatic<Real>::make(0.001)),
mTimeStep(AttributeStatic<Real>::make(0.001)),
mSplitSubnets(AttributeStatic<Bool>::make(true)),
mSteadyStateInit(AttributeStatic<Bool>::make(false)),
//...
{
// ...
}
Working with Static Attributes
As stated above, the value of a static attribute can only be changed through the attribute’s set-method or by writing its value through a mutable reference obtained by calling get. This means that the value will not change between consecutive reads. Because of the performance benefits static
attributes provide over dynamic attributes, attributes should be static whenever possible.
The value of a static attribute can be read by using the attribute’s get-function (i.e. attr->get) or by applying the * operator on the already dereferenced pointer (i.e. **attr), which is overloaded to also call the get function. Both methods return a mutable reference to the attribute’s value of type T&:
AttributeBase::Ptr attr = AttributeStatic<Real>::make(0.001);
Real read1 = attr->get(); //read1 = 0.001
Real read2 = **attr; //read2 = 0.001
Real& read3 = **attr; //read3 = 0.001
The value of an attribute can be changed by either writing to the mutable reference obtained from get, or by calling the set-method:
AttributeBase::Ptr attr = AttributeStatic<Real>::make(0.001);
Real read1 = **attr; //read1 = 0.001
**attr = 0.002;
Real read2 = **attr; //read2 = 0.002
attr->set(0.003);
Real read3 = **attr; //read3 = 0.003
Working with Dynamic Attributes
In general, dynamic attributes can be accessed via the same get and set-methods described above for static attributes. However,
dynamic attributes can additionally have dependencies on other attributes which affect the behavior of these methods.
Usually, this is used to dynamically compute the attribute’s value from the value of another attribute. In the simplest case, a dynamic
attribute can be set to reference another (static or dynamic) attribute using the setReference-method. After this method has been called,
the dynamic attribute’s value will always reflect the value of the attribute it references:
AttributeBase::Ptr attr1 = AttributeStatic<Real>::make(0.001);
AttributeBase::Ptr attr2 = AttributeDynamic<Real>::make();
attr2->setReference(attr1);
Real read1 = **attr2; //read1 = 0.001
**attr1 = 0.002;
Real read2 = **attr2; //read2 = 0.002
When working with references between multiple dynamic attributes, the direction in which the references are defined can be important:
References should always be set in such a way that the reference relationships form a one-way chain. Only the last attribute in such a reference chain (which itself does not reference anything) should be modified by external code (i.e. through mutable references or the set-method). This ensures that changes are always reflected in all attributes in the chain. For example, the following setup might lead to errors because it overwrites an existing reference:
// Overwriting an existing reference relationship
AttributeBase::Ptr A = AttributeDynamic<Real>::make();
AttributeBase::Ptr B = AttributeDynamic<Real>::make();
AttributeBase::Ptr C = AttributeDynamic<Real>::make();
B->setReference(A); // Current chain: B -> A
B->setReference(C); // Current chain: B -> C, reference on A is overwritten
**C = 0.1; // Change will not be reflected in A
Correct implementation:
AttributeBase::Ptr A = AttributeDynamic<Real>::make();
AttributeBase::Ptr B = AttributeDynamic<Real>::make();
AttributeBase::Ptr C = AttributeDynamic<Real>::make();
B->setReference(A); // Current chain: B -> A
C->setReference(B); // Current chain: C -> B -> A
**A = 0.1; // Updating the last attribute in the chain will update A, B, and C
Aside from setting references, it is also possible to completely recompute a dynamic attribute’s value every time it is read. This can for example be used to create attributes which reference a single matrix coefficient of another attribute, or which represent the magnitude or phase of a complex attribute.
Dynamic attributes which depend on one other attribute in this way are also called derived attributes, and they can be created by calling one
of the various derive... methods on the original attribute:
AttributeBase::Ptr attr1 = AttributeStatic<Complex>::make(Complex(3, 4));
AttributeBase::Ptr attr2 = attr1->deriveMag();
Real read1 = **attr2; // read1 = 5
**attr1 = Complex(1, 0);
Real read2 = **attr2; // read2 = 1
There is also a general derive-method which can take a custom getter and setter lambda function for computing the derived attribute from its dependency.
For more complex cases involving dependencies on multiple attributes, the AttributeDynamic class has a method called addTask which can be used to add arbitrary computation tasks which are executed when the attribute is read or written to. For more information, check the method comments in Attribute.h.
Using Attributes for Logging and Interfacing
When setting up a simulation, there are some methods which require an instance of AttributeBase::Ptr as a parameter. Examples for this
are the logger methods (e.g. DataLogger::logAttribute) and interface methods (e.g. InterfaceVillas::exportAttribute). To obtain the
required attribute pointer, one can either directly access the public member variables of the component the attribute belongs to, or use the component’s attribute(String name) method which will look up the attribute in the component’s AttributeList:
auto r1 = DP::Ph1::Resistor::make("r_1");
r1->setParameters(5);
auto logger = DataLogger::make("simName");
// Access the attribute through the member variable
logger->logAttribute("i12", r1->mIntfCurrent);
auto intf = std::make_shared<InterfaceVillas>(config);
// Access the attribute through the AttributeList
intf->exportAttribute(r1->attribute('i_intf'), 0, true, true);
// Access the attribute through the member variable and use deriveCoeff to convert it to a scalar value
intf->exportAttribute(r1->mIntfVoltage->deriveCoeff<Complex>(0, 0), 0, true);
When creating a simulation in Python, the component’s member variables are usually not accessible, so the attr-method has to be used for all accesses:
# dpsim-mqtt.py
intf = dpsimpyvillas.InterfaceVillas(name='dpsim-mqtt', config=mqtt_config)
intf.import_attribute(evs.attr('V_ref'), 0, True)
intf.export_attribute(r12.attr('i_intf').derive_coeff(0, 0), 0)
Using Attributes to Schedule Tasks
Attributes are also used to determine dependencies of tasks on data, which is information required by the scheduler.
For the usual MNAPreStep and MNAPostStep tasks, these dependencies are configured in the mnaAddPreStepDependencies and mnaAddPostStepDependencies methods:
void DP::Ph1::Inductor::mnaAddPostStepDependencies(
AttributeBase::List &prevStepDependencies, AttributeBase::List &attributeDependencies,
AttributeBase::List &modifiedAttributes, Attribute<Matrix>::Ptr &leftVector
) {
attributeDependencies.push_back(leftVector);
modifiedAttributes.push_back(mIntfVoltage);
modifiedAttributes.push_back(mIntfCurrent);
}
Here, the MNA post step depends on the solution vector of the system, leftVector, and modifies mIntfVoltage and mIntfCurrent.
Therefore, this task needs to be scheduled after the system solution that computes leftVector and before tasks that require the voltage and current interface vectors of the inductance, e.g. the task logging these values.
2 - Attribute Usage Guidelines
When a model variable should be an attribute and when a plain member is enough.
This page gives practical rules for deciding when a model variable should be a DPsim attribute. For details on the attribute mechanism itself, see attributes.
Rule of Thumb
Use an attribute if the value must be visible to DPsim infrastructure, for example logging, interfaces, Python access, string-based lookup, or scheduling.
Otherwise, prefer a normal C++ member variable or a local variable.
Quick Decision Checklist
Before adding a new attribute, ask:
- Does it need to be logged?
- Does it need to be imported or exported?
- Does it need to be accessed from Python or by name?
- Is it used as a scheduler dependency?
- Is it an externally relevant model input, output, state, or setpoint?
- Is a normal C++ variable insufficient?
If the answer to all questions is no, do not make it an attribute.
Use an Attribute For
Use an attribute if the value:
- should be logged
- should be imported or exported through an interface
- should be accessed from Python or generic code by name
- is read or modified by scheduled tasks
- is an externally relevant model input, output, state, or setpoint
- is a derived view of another attribute, for example one matrix coefficient
Typical examples are interface voltages and currents, source references, controller setpoints, and values exchanged through VILLASnode.
Do Not Use an Attribute For
Prefer a normal C++ variable if the value:
- is only used inside one method
- is a temporary intermediate result
- is a cached coefficient or solver helper
- is a fixed implementation detail
- duplicates another existing attribute
- never needs logging, interface access, Python access, or scheduling
Do not create attributes for every variable in the model equations.
Choose the Simplest Attribute Type
If decided that a value should be an attribute, choose the simplest suitable attribute type.
Prefer a static attribute when the value is stored directly by the component:
const Attribute<Real>::Ptr mPower;
MyComponent::MyComponent(const String& name)
: IdentifiedObject(name),
mPower(mAttributes->create<Real>("P", 0.0)) {}
Use dynamic, referenced, or derived attributes only when the value must depend on another attribute.
For example, use a derived attribute when exporting one coefficient of a matrix or vector attribute:
intf->exportAttribute(component->mIntfCurrent->deriveCoeff<Complex>(0, 0), 0, true);
Avoid long chains of dynamic, referenced, or derived attributes unless they are really needed.
Access Attributes in C++
In model code, prefer typed attribute members:
mPower->set(power);
const Real power = **mPower;
Use string-based lookup mainly in generic code, logging, interfaces, tests, or Python-style access:
logger->logAttribute("P", component->mPower);
intf->exportAttribute(component->attribute("P"), 0, true);
When assigning a new value, prefer set() if update tasks should be triggered or if the assignment should be explicit. Direct mutable access through **attribute can be used for simple static attributes, but it does not express this intent as clearly.
Examples
Private member variable: no need to use an attribute
This value is stored as a member because it is used by several functions of the class. It is still internal to the implementation: it does not need to be logged, imported or exported, used by the scheduler, or accessed by name.
class MyComponent : public IdentifiedObject {
private:
Real mConductance = 0.0; // used internally by several methods
};
Externally visible output: use an attribute
This value is a model output. It may be useful for logging, plotting, interfaces, tests, or Python access, so it should be registered as an attribute.
class MyComponent : public IdentifiedObject {
public:
const Attribute<Real>::Ptr mPower;
explicit MyComponent(const String& name)
: IdentifiedObject(name),
mPower(mAttributes->create<Real>("P", 0.0)) {}
void updatePower(Real power) {
mPower->set(power);
}
};
Runtime setpoint: use an attribute
This value is a model input or setpoint. It may be changed from outside the component, for example through Python, an interface, or a test setup.
class MySource : public IdentifiedObject {
public:
const Attribute<Real>::Ptr mVoltageRef;
explicit MySource(const String& name)
: IdentifiedObject(name),
mVoltageRef(mAttributes->create<Real>("V_ref", 0.0)) {}
void setParameters(Real voltageRef) {
mVoltageRef->set(voltageRef);
}
};
Derived scalar value: use a derived attribute
This value is not stored separately. It is a scalar view of an existing vector or matrix attribute, which avoids duplicating data manually.
intf->exportAttribute(component->mIntfCurrent->deriveCoeff<Complex>(0, 0), 0, true);
3 - Interfaces
Configuring a VILLASnode interface, and the tasks and threads behind it.
Why you would exchange data at all, and what it costs, is under
co-simulation. This page is how it is configured
and what it does to the task graph.
Choosing and configuring an interface
Requires a build with VILLASnode
This feature requires DPsim compiled with the WITH_VILLAS flag. Using the interface from Python
additionally needs the dpsimpyvillas target built alongside the normal dpsimpy package.The VILLASnode interface is designed to make use of the various node types and protocols supported by the VILLASframework.
By utilizing the nodes provided by VILLASnode, it can be configured to import and export attributes using a wide range of protocols.
There are two interface implementations for VILLASnode: InterfaceVillas, which is queued, and
InterfaceVillasQueueless.
Watch out: only the queued interface is available from Python
dpsimpyvillas exposes InterfaceVillas and nothing else, so InterfaceVillasQueueless can only be
used from C++. A Python script needing the unbuffered path has no way to reach it today.InterfaceVillas uses a ring buffer to store signal data between DPsim and VILLASnode to allow the protocol used in VILLASnode to operate at a different rate and non-synchronized to the DPsim time step.
InterfaceVillasQueueless uses direct communication with a VILLASnode node type implementing a specific protocol without using a buffer, thus enabling significantly lower latency communication.
With
InterfaceVillasQueueless, the protocol operates at the time step of DPsim, i.e., an attribute update directly triggers a
write() call to the connected VILLASnode node type.
InterfaceVillas should be used when using non- or soft real-time protocols or communication mediums, such as MQTT or connections via the internet.
InterfaceVillasQueueless should be used when communicating using reliable, low latency, real-time protocols, e.g., with FPGAs, via dedicated fibre networks, or with local real-time applications.
To create and configure one of the VILLASnode interface instance, create a new shared pointer of type InterfaceVillas or InterfaceVillasQueueless and supply it with a configuration string in the first constructor argument.
This configuration must be a valid JSON object containing the settings for the VILLASnode node type that should be used for data import and export.
This means that the JSON contains a type key describing what node type to use, as well as any additional configuration options required for this node type.
The valid configuration keys can be found in the VILLASnode documentation.
Watch out: the queueless interface reserves the first signal
The queueless interface expects the first input signal in the VILLASnode configuration to be a
sequence number incremented every time step. If it does not increase by one between consecutive
steps, an overrun is detected. Because logging can cause large delays and overruns should not be
reported spuriously, the interface only warns once a large number of them occur.After the object is created, the exportAttribute and importAttribute methods can be used to set up the data exchange between the DPsim simulation and the configured node.
The attributes given as the first parameter to these methods are attributes belonging to components in the simulation which should be read or updated by the interface.
As an example, for exporting and importing attributes via the MQTT protocol, the VILLASnode interfaces can be configured as follows:
Using C++:
// JSON configuration adhering to the VILLASnode documentation
std::string mqttConfig = R"STRING({
"type": "mqtt",
"format": "json",
"host": "mqtt",
"in": {
"subscribe": "/mqtt-dpsim"
},
"out": {
"publish": "/dpsim-mqtt"
}
})STRING";
// Creating a new InterfaceVillas object
std::shared_ptr<InterfaceVillas> intf = std::make_shared<InterfaceVillas>(mqttConfig);
// Configuring the InterfaceVillas to import and export attributes
intf->importAttribute(evs->mVoltageRef, 0, true, true);
intf->exportAttribute(r12->mIntfCurrent->deriveCoeff<Complex>(0, 0), 1, true, "v_load");
Using Python:
# JSON configuration adhering to the VILLASnode documentation
mqtt_config = '''{
"type": "mqtt",
"format": "json",
"host": "mqtt",
"in": {
"subscribe": "/mqtt-dpsim"
},
"out": {
"publish": "/dpsim-mqtt"
}
}'''
# Creating a new InterfaceVillas object
intf = dpsimpyvillas.InterfaceVillas(name='dpsim-mqtt', config=mqtt_config)
# Configuring the InterfaceVillas to import and export attributes
intf.import_attribute(evs.attr('V_ref'), 0, True)
intf.export_attribute(r12.attr('i_intf').derive_coeff(0, 0), 0)
Adding an Interface to the Simulation
After a new interface has been created and configured, it can be added to a simulation using the Simulation::addInterface method:
// Create and configure simulation
RealTimeSimulation sim(simName);
sim.setSystem(sys);
sim.setTimeStep(timeStep);
sim.setFinalTime(10.0);
// Create and configure interface
auto intf = //...
// Add interface to simulation
sim.addInterface(intf);
Adding an interface also adds two tasks to the simulation, one before the step and one after, so an
imported value is in place before anything reads it and an exported one is sent after everything
that could change it. The transfer itself happens on separate threads, so a slow far side does not
hold up the solver. How that is arranged, and why it matters, is under
how an interface is scheduled.
Synchronizing the Simulation with the Environment
To allow for synchronizing the DPsim simulation with external services, the Interface class provides some additional configuration options in the importAttribute and exportAttribute methods. For imports, setting the blockOnRead parameter will completely halt the simulation at the start of
every time step until a new value for this attribute was read from the environment. Additionally, the syncOnSimulationStart parameter can be set for every
import to indicate that this attribute is used to synchronize the start of the simulation. When a simulation contains any interfaces importing attributes
which have syncOnSimulationStart set, the Simulation::sync will be called before the first time step. This method will:
- write out all attributes configured for export to the environment
- block until all attributes with
syncOnSimulationStart set have been read from the environment at least once - write out all exported attributes again
Note that this setting operates independently of the blockOnRead flag. This means that with both flags set, the simulation will block again after the synchronization at the start of the first time step until another value is received for the attribute in question.
The two tasks
Adding an interface adds a PreStep and a PostStep task.
PreStep is declared to modify every attribute imported from the environment, so the scheduler
places it before any task that depends on those attributes. An imported value is therefore in place
before anything reads it.
PostStep is declared to depend on every attribute exported to the environment, so it runs after
anything that might modify them.
Why PostStep declares a modified attribute
PostStep modifies nothing in the simulation: it only sends values outward. The scheduler prunes
tasks whose outputs nothing needs, so a task that modifies nothing is dropped, and the export would
silently never happen.
To prevent that, PostStep is declared to modify Scheduler::external. That attribute exists to
make a task reachable when its real effect is outside the simulation.
Watch out: a task that modifies nothing is pruned
This is the general rule, not a quirk of interfaces. The scheduler keeps a task only if something
needs what it produces, so any task whose effect leaves the simulation must declare a modified
attribute or it will be dropped without warning. The same mechanism explains why logging an
attribute can change which tasks run; see
adding tasks to a component.
Task execution is not the moment of transfer
When these tasks execute is not when the data actually crosses the boundary. The interface spawns a
reader thread and a writer thread and communicates with them over a lock-free queue.
The consequence is the useful part: a slow import or export does not block the solver. The simulation
hands a value to the queue and continues. That is what makes an interface to a slow or unreliable
far side usable at all, and it is also why a value read this step may have been produced some time
ago.
Blocking is opt-in through blockOnRead and syncOnSimulationStart on the import, described on the
co-simulation page. Those are the only ways the exchange paces the simulation.
4 - Task Scheduling
How DPsim builds, orders, and executes the task graph each timestep.
Within each simulation timestep, DPsim executes a set of tasks: discrete units of computation contributed by components, the solver, interfaces, and loggers.
Before the first timestep the scheduler collects all tasks, resolves their data dependencies into a directed acyclic graph, and produces an ordered schedule.
That schedule is then replayed on every timestep with no further graph analysis.
Tasks
The Task base class
Every task is an instance of a class that inherits from CPS::Task
(dpsim-models/include/dpsim-models/Task.h).
Each subclass implements one member function:
virtual void execute(Real time, Int timeStepCount) = 0;
To participate in scheduling, a task declares its data dependencies through three attribute lists that are populated in the task’s constructor:
| List | Meaning |
|---|
mAttributeDependencies | Attributes this task reads in execute() |
mModifiedAttributes | Attributes this task writes in execute() |
mPrevStepDependencies | Attributes whose value from the previous timestep this task needs |
All three lists hold AttributeBase::Ptr objects, the same pointers used throughout the component model.
See Attributes for details on the attribute system.
Watch out: only attributes can leave a component
Only attributes can participate in scheduling. Plain C++ member variables, a Real, a Matrix or
an internal state struct, are invisible to the scheduler, so no dependency edge can be formed around
them.
The same constraint governs recording and exchange: DataLogger and RealTimeDataLogger both
implement DataLoggerInterface, whose logAttribute() accepts only an AttributeBase::Ptr, and the
VILLASnode interface works the same way.
So any value that must cross a task boundary, be written to a result file, or be exchanged with
another tool has to be stored in an Attribute<T>. Deciding that late means changing the component
rather than the call site.
The component text logger (CPS::Logger, backed by spdlog) is a separate mechanism used for human-readable debug and diagnostic output.
It is not part of the scheduling system and can print any value regardless of whether it is an attribute.
For practical rules on when a variable should be an attribute versus a plain member variable, see Attribute Usage Guidelines.
Common component task conventions
The names below are component and solver conventions, not scheduler-level concepts.
The scheduler only sees the attribute dependencies a task declares; it has no notion of a “PreStep” or “PostStep” and never orders tasks by these names.
MNA components typically define two task classes per component:
| Task | Typical responsibility |
|---|
MnaPreStep | Component-specific preparation before the matrix solve, often updating internal state and stamping the right-hand-side contribution |
MnaPostStep | Component-specific update after the matrix solve, often reading the solution vector to update interface voltages and currents |
This is a common pattern rather than a fixed rule; the exact work each task does is component-specific.
Signal-domain components (regulators, governors, control blocks) define their own task list via getTasks(); many separate previous-step state handling from output updates, for example a PreStep that copies state from the previous step and a Step that updates the block outputs.
The solver itself contributes a task that solves the MNA system; individual components do not depend on it by name, they depend on leftVector instead (see below).
Building the schedule
Task collection
Simulation::prepSchedule() collects all tasks before the first timestep from three top-level sources:
- Solvers: each solver contributes its task list via
Solver::getTasks(). For MNA solvers this list bundles:- the matrix-solve task,
- MNA component pre-/post-step tasks from
MNASimPowerComp::mnaTasks() (built during solver initialization via mnaAddPreStepDependencies() / mnaAddPostStepDependencies()), - signal-domain component tasks returned by
SimSignalComp::getTasks(), - optional solver-side tasks, such as state-space extraction, when enabled.
- Interfaces: each interface contributes its own tasks via
Interface::getTasks(). These typically depend on the attributes exchanged with external systems. - Loggers: each logger contributes a logging task via
Logger::getTask(), depending on the logged attributes so values are written after the producing tasks have run.
All tasks are placed in a flat Task::List and handed to the scheduler.
Dependency resolution
Scheduler::resolveDeps() (dpsim/src/Scheduler.cpp) translates the attribute-level declarations into directed edges between tasks.
For every attribute in mModifiedAttributes, it finds all tasks that list that attribute in their mAttributeDependencies and adds an edge:
graph LR
A["Task A
modifies attr_X"] -->|attr_X| B["Task B
depends on attr_X"]
Task A modifies attr_X and task B depends on it, so the edge runs A to B and the scheduler must
place A first.
A special Root sentinel task is inserted as a sink for all mPrevStepDependencies entries.
Its role is explained in the pruning step below.
Topological sort and pruning
Scheduler::topologicalSort() first runs a backward breadth-first search (BFS) from Root, marking every task that transitively contributes to a simulation output.
Tasks not reachable in this pass are dropped from the schedule because they produce data no downstream consumer reads in the current timestep.
Kahn’s algorithm then processes the remaining tasks in dependency order and appends them to the schedule.
The result is a flat, ordered list in which every task appears after all of its current-step predecessors.
The Root sentinel matters here: it holds a reference to an external attribute updated by an interface or by the solver, so the backward BFS reaches it and keeps every task that writes previous-timestep state, even when that output is only consumed in the next timestep.
Level scheduling
For parallel execution the ordered list is converted into levels by Scheduler::levelSchedule().
Each task is assigned to the level one greater than the highest-level task it depends on:
graph TD
subgraph L0["level 0: no dependencies, all start at once"]
T1; T2; T3
end
subgraph L1["level 1: depend only on level 0"]
T4; T5
end
subgraph L2["level 2"]
T6
end
T1 --> T4
T2 --> T4
T3 --> T5
T4 --> T6
T5 --> T6
Tasks within the same level have no data dependencies between them and can execute in parallel.
The scheduler guarantees that all tasks in level k finish before any task in level k+1 starts.

Scheduler variants
| Class | Parallelism strategy |
|---|
SequentialScheduler | Single-threaded; follows topological order |
ThreadLevelScheduler | Distributes each level across N worker threads |
ThreadListScheduler | Distributes tasks greedily across N threads |
OpenMPLevelScheduler | Uses #pragma omp parallel for per level |
The scheduler is chosen at Simulation construction time; SequentialScheduler is the default.
Per-timestep execution
Scheduler::step(time, timeStepCount) is called once per timestep.
For the sequential scheduler:
for (auto& task : mSchedule)
task->execute(time, timeStepCount);
Parallel schedulers distribute tasks across threads within each level and synchronize with a barrier before advancing to the next level.
This page describes how the scheduler works. For how to give a component tasks of its own, see
adding tasks to a component.
5 - Adding Tasks to a Component
Giving a component pre-step and post-step tasks and declaring their dependencies.
How to attach tasks to a component. For how the scheduler consumes them, see
scheduling.
Signal components
Signal components inherit from SimSignalComp and return their tasks from getTasks().
The usual pattern is to define inner Task classes whose constructors populate the dependency lists, then instantiate them in getTasks():
class MyComponent : public SimSignalComp {
public:
const Attribute<Real>::Ptr mInput; // written by upstream component
const Attribute<Real>::Ptr mOutput; // read by downstream component
const Attribute<Real>::Ptr mOutputPrev; // state carried across timesteps
class PreStep : public Task {
public:
explicit PreStep(MyComponent& comp)
: Task(**comp.mName + ".PreStep"), mComp(comp) {
mPrevStepDependencies.push_back(mComp.mOutput);
mModifiedAttributes.push_back(mComp.mOutputPrev);
}
void execute(Real time, Int timeStepCount) override {
**mComp.mOutputPrev = **mComp.mOutput;
}
private:
MyComponent& mComp;
};
class Step : public Task {
public:
explicit Step(MyComponent& comp)
: Task(**comp.mName + ".Step"), mComp(comp) {
mAttributeDependencies.push_back(mComp.mInput);
mModifiedAttributes.push_back(mComp.mOutput);
}
void execute(Real time, Int timeStepCount) override {
mComp.signalStep(time, timeStepCount);
}
private:
MyComponent& mComp;
};
Task::List getTasks() override {
return { std::make_shared<PreStep>(*this),
std::make_shared<Step>(*this) };
}
};
PreStep uses mPrevStepDependencies for mOutput because it reads the value produced last timestep, not the value that Step will produce this timestep.
Using mAttributeDependencies here would create a same-step dependency on Step and force PreStep after Step, which is backwards.
MNA power components
MNA components inherit from MNASimPowerComp<VarType>.
Instead of getTasks(), they implement two hook functions that MNASimPowerComp calls when it builds the MnaPreStep and MnaPostStep tasks during solver initialization.
void DP::Ph1::MyComponent::mnaAddPreStepDependencies(
AttributeBase::List& prevStepDependencies,
AttributeBase::List& attributeDependencies,
AttributeBase::List& modifiedAttributes) {
prevStepDependencies.push_back(mIntfCurrent); // read from previous step
modifiedAttributes.push_back(mRightVector); // stamp right-hand side
}
void DP::Ph1::MyComponent::mnaAddPostStepDependencies(
AttributeBase::List& prevStepDependencies,
AttributeBase::List& attributeDependencies,
AttributeBase::List& modifiedAttributes,
Attribute<Matrix>::Ptr& leftVector) {
attributeDependencies.push_back(leftVector); // wait for matrix solve
modifiedAttributes.push_back(mIntfVoltage);
modifiedAttributes.push_back(mIntfCurrent);
}
PostStep must always list leftVector in attributeDependencies.
This creates the edge from the solver’s matrix-solve task to every component’s PostStep, ensuring the solution vector is available before voltages and currents are extracted.
Dependency declaration checklist
- Every attribute read inside
execute() must appear in mAttributeDependencies or mPrevStepDependencies. - Every attribute written inside
execute() must appear in mModifiedAttributes. - State carried from the previous timestep goes in
mPrevStepDependencies, not mAttributeDependencies. MnaPostStep must list leftVector in attributeDependencies.- No attribute should appear in both
mAttributeDependencies and mPrevStepDependencies for the same task.
Watch out: a missing declaration produces wrong results, not a crash
Missing a declaration does not always cause a crash; it silently produces incorrect results or a wrong execution order, which is harder to debug.
Two common failure modes follow from the pruning step:
- A
PreStep or PostStep task is dropped entirely because none of its declared modified attributes is needed by another task, a logger, an interface, or a previous-step dependency. The simulation then runs but its results are always wrong. - The same task appears to work only when a particular variable is logged or exchanged by an interface, because that logger or interface adds a dependency on the attribute and keeps the producing task reachable. The results then depend on logger or interface configuration even though the physical model did not change.
Declare dependencies conservatively.