Routine#

Routine refers to scheduling-level model, and it includes two sectinos, namely, Data Section and Model Section.

Data Section#

A simplified code snippet for RTED is shown below as an example.

class RTED:

    def __init__(self):
        ... ...
        self.R10 = RParam(info='10-min ramp rate',
                          name='R10', tex_name=r'R_{10}',
                          model='StaticGen', src='R10',
                          unit='p.u./h',)
        self.gs = ZonalSum(u=self.zg, zone='Zone',
                           name='gs', tex_name=r'S_{g}',
                           info='Sum Gen vars vector in shape of zone',
                           no_parse=True, sparse=True)
        ... ...
        self.rbu = Constraint(name='rbu',
                              info='RegUp reserve balance',
                              e_str = 'gs @ cp.multiply(ug, pru) - dud == 0')
        ... ...

Routine Parameter#

As discussed in previous section, actual data parameters are stored in the device-level models. Thus, in routines, parameters are retrieved from target devices given the device name and the parameter name. In the example above, R10 is a 10-min ramp rate parameter for the static generator. The parameter is retrieved from the devices StaticGen with the parameter name R10.

Service#

Services are developed to assit the formulations. In the example above, ZonalSum is a service to sum the generator variables in a zone. Later, in the constraint, gs is multiplied to the reserve variable pru.

Model Section#

Descriptive Formulation#

Scheduling routine is the descriptive model of the optimization problem.

Further, to facilitate the routine definition, AMS developed a class ams.core.param.RParam to pass the model data to multiple routine modeling.

class ams.core.param.RParam(name: str | None = None, tex_name: str | None = None, info: str | None = None, src: str | None = None, unit: str | None = None, model: str | None = None, v: ndarray | None = None, indexer: str | None = None, imodel: str | None = None, horizon: RParam | None = None, hindexer: str | None = None, expand_dims: int | None = None, no_parse: bool | None = False, nonneg: bool | None = False, nonpos: bool | None = False, cplx: bool | None = False, imag: bool | None = False, symmetric: bool | None = False, diag: bool | None = False, hermitian: bool | None = False, boolean: bool | None = False, integer: bool | None = False, pos: bool | None = False, neg: bool | None = False, sparse: list | None = None)[source]

Class for parameters used in a routine. This class is developed to simplify the routine definition.

RParm is further used to define Parameter in the optimization model.

no_parse is used to skip parsing the RParam in optimization model. It means that the RParam will not be added to the optimization model. This is useful when the RParam contains non-numeric values, or it is not necessary to be added to the optimization model.

Parameters:
namestr, optional

Name of this parameter. If not provided, name will be set to the attribute name.

tex_namestr, optional

LaTeX-formatted parameter name. If not provided, tex_name will be assigned the same as name.

infostr, optional

A description of this parameter

srcstr, optional

Source name of the parameter.

unitstr, optional

Unit of the parameter.

modelstr, optional

Name of the owner model or group.

vnp.ndarray, optional

External value of the parameter.

indexerstr, optional

Primary-axis indexer of the parameter — name of an IdxParam on the row-owner model whose values get matched against imodel.get_all_idxes() to sort / position rows.

imodelstr, optional

Name of the owner model or group of the (primary) indexer.

horizonams.core.param.RParam, optional

Secondary-axis indexer. Mirrors the ams.opt.var.Var.horizon convention used for output Vars: when set together with indexer / imodel, the param's v returns a 2D matrix shaped (imodel.n, horizon.n) built by pivoting the long-format rows on hindexer (secondary) and indexer (primary). Cells with no matching row fall back to the source NumParam.default.

hindexerstr, optional

Name of the IdxParam on the row-owner model that carries the secondary key, matched against horizon.v. Required when horizon is set; ignored otherwise.

no_parse: bool, optional

True to skip parsing the parameter.

nonneg: bool, optional

True to set the parameter as non-negative.

nonpos: bool, optional

True to set the parameter as non-positive.

cplx: bool, optional

True to set the parameter as complex.

imag: bool, optional

True to set the parameter as imaginary.

symmetric: bool, optional

True to set the parameter as symmetric.

diag: bool, optional

True to set the parameter as diagonal.

hermitian: bool, optional

True to set the parameter as hermitian.

boolean: bool, optional

True to set the parameter as boolean.

integer: bool, optional

True to set the parameter as integer.

pos: bool, optional

True to set the parameter as positive.

neg: bool, optional

True to set the parameter as negative.

sparse: bool, optional

True to set the parameter as sparse.

Examples

Example 1: Define a routine parameter from a source model or group.

In this example, we define the parameter cru from the source model SFRCost with the parameter cru. Note since this parameter comes from model SFRCost, but it is used to multiply on generator output powers, we need to ensure the value is sorted in the same order as generators. gen is the indexer that comes from model SFR itself, and imodel is the indexer model, i.e., the model that has idx as its attribute. Then, we can ensure the value of cru is sorted in the same order as the indexer StaticGen.

>>> self.cru = RParam(info='RegUp reserve coefficient',
>>>                   tex_name=r'c_{r,u}',
>>>                   unit=r'$/(p.u.)',
>>>                   name='cru',
>>>                   src='cru',
>>>                   model='SFRCost',
>>>                   indexer='gen',
>>>                   imodel='StaticGen',
>>>                   )

Example 2: Define a routine parameter with a user-defined value.

In this example, we define the parameter with a user-defined value. TODO: Add example

Numerical Optimization#

Optimization model is the optimization problem. Var, Constraint, and Objective are the basic building blocks of the optimization model. OModel is the container of the optimization model. A summary table is shown below.

Var([name, tex_name, info, src, unit, ...])

Base class for variables used in a routine.

Constraint([name, e_str, e_fn, info])

Base class for constraints.

Objective([name, e_str, e_fn, info, unit, sense])

Base class for objective functions.

OModel(routine)

Base class for optimization models.

Expression Notation in e_str#

Routine constraints, objectives, and expressions are written as Python source strings in the e_str argument. e_str uses canonical CVXPY syntax: call cp.multiply(a, b), cp.sum(x), cp.power(x, n) etc. directly. Before evaluation, ams.core.symprocessor.SymProcessor only resolves bare symbol names (pg, Cft, rate_a, …) to the underlying CVXPY Variable / Parameter / sparse-matrix objects of the host routine. It does not rewrite function names: cp.* calls must be written explicitly.

Note

Prior to v1.2.3, AMS rewrote mul(...)cp.multiply(...), bare sum(...)cp.sum(...), and a dot ba * b automatically. That rewrite layer has been removed — see the v1.2.3 migration table in Release notes. User customizations (addConstrs(e_str=...) / obj.e_str += '...') that still use the old vocabulary will raise NameError at eval time.

Multiplication is the easiest place to introduce silent bugs because there are three semantically distinct operations that all read like "multiply":

Notation in e_str

Meaning

When to use

a @ b

Matrix / matrix-vector multiply

Topology matrix times a stacked vector, e.g. Cft @ pl, PTDF @ p.

cp.multiply(a, b)

Element-wise multiply

Per-device scaling, e.g. cp.multiply(ug, pg) to gate generator output by its commitment. Broadcasts a scalar / 1-D parameter against a vector variable.

2 * x, coeff * y

Scalar multiply

A literal number or a 0-D parameter scaling an expression. CVXPY accepts * here.

Bare ``a * b`` between two identifiers is not rewritten and will be passed to CVXPY as-is. CVXPY will then either raise a shape error or, worse, emit a deprecation warning and silently perform matrix multiplication. Always write @ or cp.multiply() explicitly.

Other canonical-CVXPY constructs in e_str:

  • Reductions and atoms — cp.sum(x), cp.norm(x), cp.pos(x), cp.square(x), cp.power(x, n), cp.vstack(...), cp.hstack(...), cp.maximum(...), cp.minimum(...), cp.quad_form(...), cp.sum_squares(...), cp.diag(...) — call directly.

  • Comparisons ... == 0 / ... <= 0 / ... >= 0 are embedded in the e_str itself; the LHS-zero authoring convention keeps Constraint.v reporting slack-from-zero uniformly. See Migration: canonical CVXPY in e_str (v1.2.3) for the v1.2.3 is_eq retirement.

  • Powers use ** (e.g. vmax**2).

Warning

Routine symbol names (Var, RParam, Service, Expression, Constraint) may not collide with CVXPY atom names (sum, multiply, vstack, hstack, power, norm, pos, neg, square, quad_form, sum_squares, diag, maximum, minimum, abs, exp, log, sqrt, inv_pos). The codegen raises an explanatory error on collision. Without this guard, a routine that declared a symbol named sum would have its eval-fallback path silently rewrite a user-appended sum(...) into self.om.sum(...).

If in doubt, check an existing routine such as ams.routines.dcopf or ams.routines.rted for canonical patterns.

How e_str becomes a CVXPY problem (codegen)#

The e_str rewrites above are applied once at prep time, not on every routine init. The first time a routine is instantiated, AMS:

  1. Walks the constructed routine's constrs / exprs / exprcs / obj registries.

  2. Resolves bare symbol names in each e_str (pgr.pg, …) via the same _build_symbol_regex / _collect_symbol_names pass used by the eval-fallback helper. Function names (cp.sum, cp.multiply, …) are passed through untouched.

  3. Emits a small Python module at ~/.ams/pycode/<routine>.py with one named callable per opt element — e.g. def _constr_pglb(r): return -r.pg + r.pmine — plus the pre-rendered LaTeX string for documentation.

  4. Wires those callables onto the routine's Constraint / Expression / Objective instances via their e_fn attribute.

Subsequent inits skip step 2-3 and just import. The cache is keyed by md5 of the routine source file; editing an e_str regenerates automatically. The whole cache can be refreshed or wiped via ams prep (see Release notes v1.2.2).

This is analogous to ANDES's andes prep / ~/.andes/pycode/ pipeline. Author-facing API is unchanged — you keep writing e_str. The codegen is what runs underneath.

Authors who prefer to skip the DSL and write a callable directly may pass e_fn=callable instead of e_str; the runtime accepts both, and the codegen leaves manually-set e_fn alone.

Two execution paths: codegen vs eval#

Internally, two execution paths from e_str to a CVXPY object live side by side:

  • Codegen path (fast, default for source-defined items). At init() time, ams.routines.routine.RoutineBase._link_pycode() loads the per-class pycode and wires each item's e_fn from a named callable in that module. parse() and evaluate() then just invoke the callable with a ams.core.routine_ns.RoutineNS proxy; no regex, no eval.

  • Eval-fallback path (used for items the codegen doesn't cover). At parse() time, ams.opt._runtime_eval.eval_e_str() resolves bare symbol names (pgr.pg, Cftr.Cft, …) via a single regex pass and evals the result against a RoutineNS proxy. Function names (cp.sum, cp.multiply, …) are not rewritten — author canonical CVXPY in e_str and the helper passes them through.

Both paths must produce the same CVXPY object given the same e_str — the codegen is the AOT-compiled version of the eval-fallback pipeline.

Which path runs is decided per-item by _link_pycode:

Item state

Path

Why

In source code, no runtime mutation

codegen

The pristine e_str matches what the cache was generated from; e_fn is wired from the cache.

Added at runtime via addConstrs / addExpressions / addExprcs

eval

The cache (always generated against a pristine instance — see The pristine-source invariant below) doesn't know about runtime additions.

e_str reassigned post-construction (e.g. obj.e_str += '+ ...')

eval

The descriptor mutex on e_str / e_fn marks the item _e_dirty; _link_pycode skips wiring so the user's new e_str flows through parse().

Authors and users can introspect which path is in effect:

The pristine-source invariant#

~/.ams/pycode/<routine>.py is always a faithful representation of the routine source code, never of any user customization. To guarantee this, _link_pycode runs codegen against a pristine routine instance fetched from a per-process singleton ams.System(no_input=True) (see ams.prep._get_pristine_system()), never against the user's self. A pristine = True marker in the generated module's header acts as a cache-validation tripwire — caches written by older AMS versions (which codegen'd against the live instance) lack the marker and are auto-invalidated on next read.

This means a user can do:

sp = ams.load(...)
sp.DCOPF.obj.e_str += '+ extra_term'
sp.DCOPF.run(...)                        # uses customization

sp0 = ams.load(...)                       # fresh instance
sp0.DCOPF.run(...)                        # uses original formulation

without sp's mutation leaking into sp0. See examples/ex8.ipynb for a working sp1 / sp2 / sp3 walkthrough.

Interoperation with ANDES#

The interoperation with dynamic simulator invovles both file conversion and data exchange. In AMS, the built-in interface with ANDES is implemented in ams.interface.

File Format Converter#

Power flow data is the bridge between scheduling study and dynamics study, where it defines grid topology and power flow. An AMS case can be converted to an ANDES case, with the option to supply additional dynamic data.

ams.interface.to_andes(system, addfile=None, setup=False, no_output=False, default_config=True, verify=False, tol=0.001, **kwargs)[source]

Convert the AMS system to an ANDES system.

A preferred dynamic system file to be added has following features: 1. The file contains both power flow and dynamic models. 2. The file can run in ANDES natively. 3. Power flow models are in the same shape as the AMS system. 4. Dynamic models, if any, are in the same shape as the AMS system.

This function is wrapped as the System class method to_andes(). Using the file conversion to_andes() will automatically link the AMS system instance to the converted ANDES system instance in the AMS system attribute dyn.

It should be noted that detailed dynamic simualtion requires extra dynamic models to be added to the ANDES system, which can be passed through the addfile argument.

Parameters:
systemSystem

The AMS system to be converted to ANDES format.

addfilestr, optional

The additional file to be converted to ANDES dynamic mdoels.

setupbool, optional

Whether to call setup() after the conversion. Default is True.

no_outputbool, optional

To ANDES system.

default_configbool, optional

To ANDES system.

verifybool

If True, the converted ANDES system will be verified with the source AMS system using AC power flow.

tolfloat

The tolerance of error.

Returns:
adsysandes.system.System

The converted ANDES system.

Notes

  • Power flow models in the addfile will be skipped and only dynamic models will be used.

  • The addfile format is guessed based on the file extension. Currently only xlsx is supported.

  • Index in the addfile is automatically adjusted when necessary.

Examples

>>> import ams
>>> import andes
>>> sp = ams.load(ams.get_case('ieee14/ieee14_uced.xlsx'), setup=True)
>>> sa = sp.to_andes(addfile=andes.get_case('ieee14/ieee14_full.xlsx'),
...                  setup=False, overwrite=True, no_output=True)

Data Exchange in Simulation#

To achieve scheduling-dynamics cosimulation, it requires bi-directional data exchange between scheduling and dynamics study. From the perspective of AMS, two functions, send and receive, are developed. The maping relationship for a specific routine is defined in the routine class as map1 and map2. Additionally, a link table for the ANDES case is used for the controller connections.

Module ams.interface.Dynamic, contains the necessary functions and classes for file conversion and data exchange.

class ams.interface.Dynamic(amsys=None, adsys=None)[source]

ANDES interface class.

Parameters:
amsysAMS.system.System

The AMS system.

adsysANDES.system.System

The ANDES system.

Attributes:
linkpandas.DataFrame

The ANDES system link table.

Notes

  • Using the file conversion to_andes() will automatically link the AMS system to the converted ANDES system in the attribute dyn.

Examples

>>> import ams
>>> import andes
>>> sp = ams.load(ams.get_case('ieee14/ieee14_rted.xlsx'), setup=True)
>>> sa = sp.to_andes(setup=True,
...                  addfile=andes.get_case('ieee14/ieee14_wt3.xlsx'),
...                  overwrite=True, keep=False, no_output=True)
>>> sp.RTED.run()
>>> sp.RTED.dc2ac()
>>> sp.dyn.send()  # send RTED results to ANDES system
>>> sa.PFlow.run()
>>> sp.TDS.run()
>>> sp.dyn.receive()  # receive TDS results from ANDES system
receive(adsys=None, routine=None, no_update=False)[source]

Receive ANDES system results to AMS devices.

Parameters:
adsysadsys.System.system, optional

The target ANDES dynamic system instance. If not provided, use the linked ANDES system isntance (sp.dyn.adsys).

routinestr, optional

The routine to be received from ANDES. If None, recent will be used.

no_updatebool, optional

True to skip update the AMS routine parameters after sync. Default is False.

send(adsys=None, routine=None)[source]

Send results of the recent sovled AMS routine (sp.recent) to the target ANDES system.

Note that converged AC conversion DOES NOT guarantee successful dynamic initialization TDS.init(). Failed initialization is usually caused by limiter violation.

Parameters:
adsysadsys.System.system, optional

The target ANDES dynamic system instance. If not provided, use the linked ANDES system isntance (sp.dyn.adsys).

routinestr, optional

The routine to be sent to ANDES. If None, recent will be used.

When you use this interface, it automatically picks either the dynamic or static model based on the TDS initialization status. If the TDS is running, it selects the dynamic model; otherwise, it goes for the static model. For more details, check out the full API reference or take a look at the source code.

Note

Check ANDES documentation StaticGen for more details about substituting static generators with dynamic generators.