System Architecture#

The System class is the central orchestrator in ANDES, managing models, routines, and DAE data structures. The full API reference is found at andes.system.System.

System Overview#

import andes

ss = andes.load('case.xlsx')
# ss is a System instance containing:
# - All loaded models (ss.Bus, ss.PQ, ss.GENROU, etc.)
# - Analysis routines (ss.PFlow, ss.TDS, ss.EIG)
# - DAE storage (ss.dae)
# - Configuration (ss.config)

Dynamic Imports#

System dynamically imports groups, models, and routines at creation. To add new models, groups or routines, edit the corresponding registration file following existing examples.

System
├── Groups (StaticLoad, Generator, Exciter, ...)
├── Models (Bus, PQ, GENROU, ESST1A, ...)
└── Routines (PFlow, TDS, EIG)
andes.system.System.import_models(self)

Delegate to andes.system.registry.RegistryLoader.

andes.system.System.import_groups(self)

Delegate to andes.system.registry.RegistryLoader.

andes.system.System.import_routines(self)

Delegate to andes.system.registry.RegistryLoader.

Code Generation#

Under the hood, all models whose equations are provided in strings need to be processed to generate executable functions for simulations. We call this process "code generation". Code generation utilizes SymPy and can take up to one minute.

Code generation is automatically triggered upon the first ANDES run or whenever model changes are detected. The generated code is stored and reused for speed up.

The generated Python code is called pycode. It is a Python package (folder) with each module (a .py file) storing the executable Python code and metadata for numerical simulation. The default storage path is ~/.andes/.

Note

Code generation has been done if you have executed andes, andes selftest, or andes prepare.

Warning

For developers: when models are modified (such as adding new models or changing equation strings), code generation needs to be executed again for consistency. ANDES can automatically detect changes, and it can be manually triggered from command line using andes prepare -i.

andes.system.System.prepare(self, quick=False, incremental=False, models=None, nomp=False, ncpu=2)

Delegate to andes.system.codegen.CodegenManager.

andes.system.System.undill(self, autogen_stale=True)

Delegate to andes.system.codegen.CodegenManager.

DAE Storage#

The numerical DAE arrays are stored in System.dae:

ss.dae.x    # State variables (differential)
ss.dae.y    # Algebraic variables
ss.dae.f    # Differential equations (dx/dt)
ss.dae.g    # Algebraic equations (= 0)
ss.dae.t    # Current time

Time Series#

After TDS, use get_timeseries() to extract results as a pandas DataFrame:

omega_df = ss.TDS.get_timeseries(ss.GENROU.omega)  # State variable
voltage_df = ss.TDS.get_timeseries(ss.Bus.v)       # Algebraic variable

The underlying arrays are also available for direct access:

ss.dae.ts.t    # Time points
ss.dae.ts.x    # State history (n_steps x n_states)
ss.dae.ts.y    # Algebraic history (n_steps x n_algebs)
class andes.variables.dae.DAE(system)[source]

Class for storing numerical values of the DAE system, including variables, equations and first order derivatives (Jacobian matrices).

Variable values and equation values are stored as numpy.ndarray, while Jacobians are stored as kvxopt.spmatrix. The defined arrays and descriptions are as follows:

DAE Array

Description

x

Array for state variable values

y

Array for algebraic variable values

z

Array for 0/1 limiter states (if enabled)

f

Array for differential equation derivatives

Tf

Left-hand side time constant array for f

g

Array for algebraic equation mismatches

The defined scalar member attributes to store array sizes are

Scalar

Description

m

The number of algebraic variables/equations

n

The number of state variables/equations

o

The number of limiter state flags

The derivatives of f and g with respect to x and y are stored in four kvxopt.spmatrix sparse matrices: fx, fy, gx, and gy, where the first letter is the equation name, and the second letter is the variable name.

Notes

DAE in ANDES is defined in the form of

\[\begin{split}T \dot{x} = f(x, y) \\ 0 = g(x, y)\end{split}\]

DAE does not keep track of the association of variable and address. Only a variable instance keeps track of its addresses.

Device Connectivity#

Use find_connected() to find all devices referencing a given bus or device:

ss.find_connected('Bus', 1)
# OrderedDict([('Slack', [1]), ('Line', ['Line_1', 'Line_2']),
#              ('GENROU', ['GENROU_1']), ('BusFreq', ['BusFreq_2'])])

This works for any model or group, not just buses:

ss.find_connected('SynGen', 'GENROU_1')
# OrderedDict([('TGOV1', ['TGOV1_1']), ('ESST3A', ['ESST3A_2'])])
andes.system.System.find_connected(self, model_or_group, idx)

Find all devices connected to a given device.

Scans IdxParam references across all models to find devices that point to the specified target device.

Parameters:
model_or_groupstr

Model name (e.g., 'Bus') or group name (e.g., 'ACNode').

idxstr, int, float

Device idx to query.

Returns:
OrderedDict

{model_name: [idx, ...]} for each model with at least one device referencing the target. Empty models are omitted.

Examples

Find all devices connected to Bus 1:

ss.find_connected('Bus', 1)
# OrderedDict([('PQ', [1]), ('Line', [1, 4]), ('GENROU', [1])])

Decentralized Architecture#

ANDES uses a decentralized architecture between models and DAE value arrays. In this architecture, variables are initialized and equations are evaluated inside each model. Then, System provides methods for collecting initial values and equation values into DAE, as well as copying solved values back to each model.

  1. Models own their data: Each model has local copies of variable values

  2. System orchestrates: Collects/distributes values between models and DAE

  3. Parallel-friendly: Model equations can be evaluated independently

Data Flow#

┌─────────┐         ┌─────────┐         ┌─────────┐
│  Model  │ ──────► │   DAE   │ ◄────── │  Model  │
│  (PQ)   │         │ arrays  │         │ (Gen)   │
└─────────┘         └─────────┘         └─────────┘
      │                  ▲                   │
      │                  │                   │
      └──────────────────┼───────────────────┘
                         │
                    ┌────┴────┐
                    │  System │
                    │ methods │
                    └─────────┘

The collection of values from models needs to follow protocols to avoid conflicts. Details are given in the Variables section.

andes.system.System.vars_to_dae(self, model)

Copy variables values from models to System.dae.

This function clears DAE.x and DAE.y and collects values from models.

andes.system.System.vars_to_models(self)

Copy variable values from System.dae to models.

andes.system.System._e_to_dae(self, eq_name: str | Tuple = ('f', 'g'))

Helper function for collecting equation values into System.dae.f and System.dae.g.

Parameters:
eq_name'x' or 'y' or tuple

Equation type name

Jacobian Matrices#

System builds sparse Jacobian matrices incrementally:

ss.dae.fx    # df/dx (differential w.r.t. states)
ss.dae.fy    # df/dy (differential w.r.t. algebraic)
ss.dae.gx    # dg/dx (algebraic w.r.t. states)
ss.dae.gy    # dg/dy (algebraic w.r.t. algebraic)

Matrix Sparsity Patterns#

The largest overhead in building and solving nonlinear equations is the building of Jacobian matrices. This is especially relevant when using the implicit integration approach which algebraizes the differential equations. Given the unique data structure of power system models, the sparse matrices for Jacobians are built incrementally, model after model.

There are two common approaches to incrementally build a sparse matrix:

Approach 1: In-place addition

self.fx += spmatrix(v, i, j, (n, n), 'd')

Although simple, this involves creating and discarding temporary objects on the right hand side and, worse, changing the sparse pattern of self.fx.

Approach 2: Collect and construct Store the rows, columns and values in array-like objects and construct the Jacobians at the end. Very efficient but does not allow accessing the sparse matrix while building.

ANDES approach: Pre-allocation ANDES uses a pre-allocation approach to avoid changing sparse patterns by filling values into a known sparse matrix pattern. System collects the indices of rows and columns for each Jacobian matrix. Before in-place additions, ANDES builds a temporary zero-filled spmatrix, to which the actual Jacobian values are written later. Since these in-place add operations only modify existing values, it does not change the pattern and thus avoids memory copying.

andes.system.System.store_sparse_pattern(self, models: OrderedDict)

Collect and store the sparsity pattern of Jacobian matrices.

This is a runtime function specific to cases.

Notes

For gy matrix, always make sure the diagonal is reserved. It is a safeguard if the modeling user omitted the diagonal term in the equations.

Calling Model Methods#

System is an orchestrator for calling shared methods of models. These API methods are defined for initialization, equation update, Jacobian update, and discrete flags update.

The following methods take an argument models, which should be an OrderedDict of models with names as keys and instances as values.

Initialization#

andes.system.System.init(self, models: OrderedDict, routine: str)

Initialize the variables for each of the specified models.

For each model, the initialization procedure is:

  • Get values for all ExtService.

  • Call the model init() method, which initializes internal variables.

  • Copy variables to DAE and then back to the model.

Equation Updates#

andes.system.System.e_clear(self, models: OrderedDict)

Clear equation arrays in DAE and model variables.

This step must be called before calling f_update or g_update to flush existing values.

andes.system.System.l_update_var(self, models: OrderedDict, niter=0, err=None)

Update variable-based limiter discrete states by calling l_update_var of models.

This function is must be called before any equation evaluation.

andes.system.System.f_update(self, models: OrderedDict)

Call the differential equation update method for models in sequence.

Notes

Updated equation values remain in models and have not been collected into DAE at the end of this step.

andes.system.System.g_update(self, models: OrderedDict)

Call the algebraic equation update method for models in sequence.

Notes

Like f_update, updated values have not collected into DAE at the end of the step.

andes.system.System.l_update_eq(self, models: OrderedDict, init=False, niter=0)

Update equation-dependent limiter discrete components by calling l_check_eq of models. Force set equations after evaluating equations.

This function is must be called after differential equation updates.

andes.system.System.j_update(self, models: OrderedDict, info=None)

Call the Jacobian update method for models in sequence.

The procedure is - Restore the sparsity pattern with andes.variables.dae.DAE.restore_sparse() - For each sparse matrix in (fx, fy, gx, gy), evaluate the Jacobian function calls and add values.

Notes

Updated Jacobians are immediately reflected in the DAE sparse matrices (fx, fy, gx, gy).

Model Calling Protocol#

System calls model methods in a defined order during each iteration:

  1. model.init() - Initialize variables

  2. model.l_update_var() - Update discrete flags (pre-equation)

  3. model.f_update() - Evaluate differential equations

  4. model.g_update() - Evaluate algebraic equations

  5. model.j_update() - Update Jacobian contributions

  6. model.l_update_eq() - Update discrete flags (post-equation)

External Variable Protocol#

When models share variables (e.g., Bus voltage accessed by loads):

Flag

Purpose

v_setter=False

Values at same address are summed

v_setter=True

This variable sets the final value

e_setter=False

Equation values are summed

e_setter=True

This equation sets the final value

Example: PV generator sets bus voltage initial value:

# In PV model
self.v = ExtAlgeb(src='v', model='Bus',
                  indexer=self.bus,
                  v_str='v0',
                  v_setter=True)  # Overwrite bus voltage

Configuration#

System, models and routines have a member attribute config for specific configurations. System manages all configs, including saving to a config file and loading back.

ss.config              # System config
ss.PFlow.config        # Power flow config
ss.TDS.config          # TDS config
ss.GENROU.config       # Model-specific config
andes.system.System.save_config(self, file_path=None, overwrite=False)

Delegate to andes.system.config_runtime.SystemConfigRuntime.

Warning

Configs from files are passed to model constructors during instantiation. If you need to modify config for a run, it must be done before instantiating System, or before running andes from command line. Directly modifying Model.config may not take effect or have side effects in the current implementation.

Device Lifecycle#

Understanding how devices are created and managed helps when extending cases programmatically or developing new models.

Data Loading Pipeline#

When loading from files (xlsx, JSON, PSS/E), ANDES follows this sequence:

  1. File parsing: I/O readers (andes/io/xlsx.py, json.py, psse.py) parse the input format into dictionaries

  2. Device registration: Each row calls System.add(model_name, param_dict):

    • Validates the model exists

    • Gets a unique idx from the device's group

    • Calls Model.add() to store parameter values

    • Registers the device with its group

  3. System setup: System.setup() finalizes the structure (addresses, code generation)

# This is what happens internally during andes.load():
# (simplified from andes/io/xlsx.py)
for name, df in df_models.items():
    for row in df.to_dict(orient='records'):
        system.add(name, row)  # Called once per device row
andes.system.System.add(self, model_name, param_dict=None, **kwargs)

Add a device instance for an existing model.

This method calls the add method of the model and registers the device idx to its group.

Parameters can be passed as a dictionary, as keyword arguments, or both. When both are provided, keyword arguments are merged into the dictionary (kwargs take precedence on conflicts).

Parameters:
model_namestr

Name of the model (e.g., 'Fault', 'Toggle', 'PQ').

param_dictdict, optional

Dictionary of parameter names to values.

**kwargs

Parameter names and values as keyword arguments.

Returns:
idx

The assigned device index.

Examples

Keyword arguments are the preferred style:

ss.add('Fault', bus=5, tf=1.0, tc=1.1)

Models with a model parameter (e.g., Alter, Toggle) can now use keyword arguments directly:

ss.add('Alter', model='TGOV1', dev=1, src='paux0',
       t=1.0, method='=', amount=0.05)
ss.add('Toggle', model='Line', dev='Line_5', t=1.0)

Programmatic Device Addition#

Devices can be added programmatically before setup():

ss = andes.load('case.xlsx', setup=False)
ss.add('Fault', bus=3, tf=1.0, tc=1.1)
ss.setup()

Key constraints:

  • Devices cannot be added after setup() (raises NotImplementedError)

  • Referenced devices must exist (e.g., bus must reference a valid Bus.idx)

  • See Parameters for mandatory vs. optional parameters and per-unit conventions

Timed Event Mechanism#

Disturbance devices (Fault, Toggle, Alter) use TimerParam to schedule callbacks during time-domain simulation. These models belong to the TimedEvent group.

How It Works#

  1. TimerParam stores a time value and a callback function

  2. During TDS, the solver checks if dae.t matches any timer values

  3. When triggered, the callback executes (e.g., Fault.apply_fault())

  4. The callback modifies system state (shunt admittance, device u flag, parameter values)

# From andes/models/timer.py - Fault implementation
class Fault(ModelData, Model):
    def __init__(self, system, config):
        # ...
        self.tf = TimerParam(info='Bus fault start time',
                             callback=self.apply_fault)
        self.tc = TimerParam(info='Bus fault end time',
                             callback=self.clear_fault)

    def apply_fault(self, is_time: np.ndarray):
        """Apply fault when t = tf."""
        for i in range(self.n):
            if is_time[i] and self.u.v[i]:
                self.uf.v[i] = 1  # Enable fault equations
                # Store pre-fault algebraic variables for restoration
        return action

The fault equations inject a shunt admittance at the faulted bus:

self.a = ExtAlgeb(model='Bus', src='a', indexer=self.bus,
                  e_str='u * uf * (v ** 2 * gf)')  # Active power
self.v = ExtAlgeb(model='Bus', src='v', indexer=self.bus,
                  e_str='-u * uf * (v ** 2 * bf)')  # Reactive power

Available Timed Event Models#

Model

Purpose

Key Parameters

Fault

Three-phase fault at a bus

bus, tf, tc, xf, rf

Toggle

Switch device on/off

model, dev, t

Alter

Modify parameter value at runtime

model, dev, src, t, method, amount

  • Fault: Applies a shunt impedance (xf, rf) to ground at time tf, clears at tc

  • Toggle: Negates the u (connectivity) field of any device at time t

  • Alter: Applies arithmetic operations (+, -, *, /, =) to any parameter or service

For practical examples of adding disturbances, see Time-Domain Simulation.

See Also#