The limits of the language#
A spec can only say what the language has words for. This page says which words can be added, and which cannot. Read it before you ask for a new operator, section or keyword. For the rules a spec itself has to obey, read the eleven rules.
Adding a new construct#
New constructs can be added, although the overhead to do so varies by kind.
- A macro is a template with arguments, written in the file under
macros:. Adding one costs nothing: it uses only operators that exist, so no engine has to change (macros). - A primitive is an operator built into the language:
sum,sum_back,at,shift, and thewherecomparisons. Adding one is the most work: every engine that builds models has to implement it, and the typesetter has to print it in LaTeX, Typst and Markdown. - A formulation is an entry that expands into ordinary variables and
constraints before the model is built.
piecewise:andsos:are the two formulations. A formulation is as much work to build as a primitive, but a file combines it with other constructs as freely as a macro. It emits variables, constraints and assumptions, and no parameter, sospec.expand()writes it out with the data the spec already expects. - A section is a top-level key that holds entries of one kind, such as
variables:orgiven:. A new section enters when all three of these are true: it states something no other section states, a file decides it without data, and the typesetter prints it.given:met all three. It is the only section that says a parameter, a column, a named expression or a row family belongs to another file, which lets a component file load and print on its own.
No other constructs are possible. See our table of refusals for our justification for this decision.
What a new primitive has to satisfy#
A macro must be able to call a new primitive, so everything a modeller can pass
in goes in the value of a keyword argument, such as over=snapshot.
An operator must say how many rows it reads for each output row.
sum(p, over=g) reads one row per generator, and shift(p, along=t, offset=1)
reads the row before. Each reads a bounded number of rows per output row, so an
engine builds the model one chunk of rows at a time. An operator that calls itself is refused, because
nothing bounds how far it expands.
| The operator | Allowed? |
|---|---|
| filters rows on a column they already carry | yes |
| joins each row against a parameter or a relation | yes |
| reads a fixed number of neighbouring rows | yes |
| reads only the coordinate labels | yes |
| reads every row | yes, at one full pass before any chunk builds |
| calls itself | no, and the message names what to write instead |
The degree of the result does not decide whether a new primitive is allowed, because the product rule holds for every operator.
A new primitive is finished when the loader lowers it into the program, the typesetter prints it in all three formats, and an engine's build of a spec that uses it matches its build of the same spec written out by hand.
Three kinds of refusal#
| The language refuses it because… | Examples | Can it change? |
|---|---|---|
| one solver cannot take it | indicator constraints; a quadratic constraint (solver capability) | yes, solver by solver |
| the file would stop being the artifact | arbitrary Python, whose content no loader can check and no typesetter can print | no |
| this project puts the work elsewhere | data preparation such as resampling; helpers for one domain; Python that decides which entries exist | it could; this project does not want it to |
What counts as data preparation#
Once attached, a column computed in pandas looks the same as a column the language could derive. One rule tells them apart:
Data preparation computes what the spec cannot state. The language derives what it can from the data the spec already expects.
A cycle basis is the first kind, because it needs the network's topology and
only the data has that. So cycle_incidence arrives as a parameter. A minimum
up time is the second kind, because min_up_time is a column the spec already
expects. So sum_back(window=min_up_time) reads the width from the column, and
you supply no window mask.
Checking a column is neither kind. Two tools that read the spec must not
answer p_min <= p_max differently, so the file states the check as
an assumption, and the tool that
attaches the numbers runs it.
Requests the language refuses#
Each row is a request the language refuses, with the reason and what to write instead.
| Request | Why refused | Instead |
|---|---|---|
| Resampling, clustering, file IO, unit conversion | not math | do it in data preparation, and pass a parameter |
| Unit checking at load | a unit: MW on a parameter is a claim that nothing checks against the column, and it needs a grammar of units the language then maintains |
convert to one unit system in data preparation, and name it in the description:. A range the data has to meet is an assumptions: entry |
Array operations such as merge and reindex |
there is no end to them | data preparation |
Helpers for one domain, such as reduce_carrier_dim |
writes one field's vocabulary into the language | a component library of macros over the operators that exist |
A vocabulary for tracked metrics: impacts:, effects:, a costs axis |
a named expression already does this | an impact dimension and one named expression. Cap it with a constraint, weight it in the objective, read it back after the solve |
** with a variable in the base or the exponent |
the exponent would decide the degree, and to_spec reads no data |
x * x for a square. ** over parameters and numbers is allowed |
Normalisation, x / sum(x) |
dividing by a variable is not a polynomial, and no solver takes it | write the ratio as a constraint, or fix the denominator |
An if, a loop, or entries that depend on the data |
to_spec could no longer read the file without the data |
where: masks and dims: dimensions. A tool may loop over specs |
| A Python API for building specs | the spec is the file you review and diff | YAML, or a dict with the same keys, merged before to_spec |
A where comparing a relation column against the dimension it maps into |
the relation already pairs the two, and a mask over the pair is the same fact in a bigger shape | place the quantity with sum(by=), or read it with at(by=) (operators) |
A join operator apart from sum and at, such as sum(join(p, gen_bus), over=generator) |
a join adds an axis for each column that the sum then removes. That axis is not a dimension, so no entry can carry it. One call adds the axis and removes it again, so no file can leave an axis in place | sum(p, over=generator, by=gen_bus[bus]) adds up each group, and at(x, by=gen_bus[bus]) reads one row (relations) |
A count(...) compared against a parameter or an expression |
a count is a whole number of coordinates, and the loader decides it without data. A bound read from a column can only be decided when the data is attached | data preparation supplies the number, and the spec trusts it. The PyPSA example does this for StorageUnit_inactive_snapshots and Store_inactive_snapshots: no assumption checks them against count(NOT {c}_active, over=snapshot). Compare against a literal (counting) |