Parameters, variables, constraints#
The four blocks that carry the math. Each block takes an optional
description: — free text, never parsed, no length limit. Unlike a # comment
it is part of the loaded model, so it reaches everything downstream: the
typeset legend prints the one on a dimension, parameter or
variable.
A description is plain prose, in no notation. Every output format sets the
same words as text, escaping whatever its own syntax would otherwise read as
markup — an underscore stays an underscore, and a $\ell$ prints as those five
characters rather than as a symbol. Write the thing rather than its symbol —
"flow on a line", not "flow on line \(\ell\)".
parameters#
Declared shape only; the numbers bind by name at run time, in whatever consumes the AST. What that binding may not decide for itself — where a dimension's members come from, the order they stand in, and that a table carries each coordinate at most once — is in dimensions.
dimensions:
snapshot: { dtype: int }
parameters:
load:
dims: [snapshot]
discount_rate:
dims: [] # a scalar
| Field | ||
|---|---|---|
dims |
required — the dimensions it is indexed by; [] is a scalar |
|
dtype |
float, int, bool, str |
default float |
description |
free text | default null |
dtype is a claim about the values, and the column has to be it. It
decides four things — whether the name is a value in an
expression at all, what a where comparison is checked
against, what a bare where on the name means
(where strings), and whether the name may stand
where an operator reads a
position — so a column that
disagrees describes a model the data does not build, and does not bind.
| declared | the column | |
|---|---|---|
float |
a float column — or an integer one | whole numbers are numbers, the one widening |
int |
an integer column | which is why a fractional position cannot arrive |
bool |
a boolean column | 1/0 is not one; cast it, or declare int |
str |
a string column |
Arithmetic is over numbers, so only float and int are values. A str
parameter is a label and a bool one is a mask — each of them names rows
rather than scaling them — so writing either as a coefficient, a term or a
divisor is a load error, not a cast the engine performs on the way past.
Select with the label (where: "fuel == 'gas'") and carry the numbers it picks
out in a parameter of its own; mask with the flag (where: "committable"), or
declare it dtype: int where the 0/1 is meant to arrive as data and be
multiplied by.
variables#
What the solver decides — one column per coordinate of foreach.
dimensions:
snapshot: { dtype: int }
generator: { dtype: str }
parameters:
p_max: { dims: [generator] }
variables:
p:
foreach: [snapshot, generator]
where: "p_max > 0"
bounds:
lower: 0
upper: p_max
| Field | ||
|---|---|---|
foreach |
required — the dim signature | |
where |
which coordinates exist (absence) | default null |
bounds.lower / bounds.upper |
a number, or the name of a float or int parameter; two numbers that cross are refused at load, a named bound is checked against its data |
default -inf / inf |
domain |
continuous, integer or binary — which carries fixed 0/1 bounds |
default continuous |
absence |
undefined or zero — what the masked-out coordinates mean (absence) |
default undefined |
description |
free text | default null |
Omitting a bound means unbounded on that side — non-negativity is written, not assumed.
Bounds take a name or a number, never arithmetic. upper: p_max is fine;
upper: -rating is not, and the error says so rather than reporting a parse
failure. Ship the negated column as data. (Expressions there are
#31.) A bound parameter's dims
must not exceed foreach.
Equal bounds pin a variable, which is how one declaration covers a quantity
that is a decision in one model and data in another: bind lower and upper
to the same value where it is fixed, and rate - relmax * size <= 0 is one
equation whether size is chosen or given. Presolve substitutes the pinned
column, so the solver receives the LP the pre-multiplied form would have
produced. Two limits: a pinned variable is still a variable, so size * on is
refused as variable × variable (expressions), and it cannot
appear in another variable's bounds.
constraints#
One rule per block. The block's name is the constraint's name, which is what a row is read back by after a solve.
dimensions:
snapshot: { dtype: int }
generator: { dtype: str }
parameters:
load: { dims: [snapshot] }
variables:
p: { foreach: [snapshot, generator] }
constraints:
power_balance:
foreach: [snapshot]
expression: sum(p, over=generator) == load
| Field | ||
|---|---|---|
foreach |
required — the rows this rule builds | |
expression |
required — exactly one of <=, >=, == |
|
where |
which rows are built (absence) | default null |
description |
free text | default null |
The expression's dims must equal foreach
(dim algebra). Either side may carry the
variables, and one of them must: a comparison of numbers and parameters is
settled before the solve, so it is refused when the file is read. A row that
ends up with none, because the data left its terms nowhere to sit, is not a
constraint and is not built
(absence).
foreach: [] is one scalar row — a single system-wide budget, where the
expression reduces every dim away. Nothing special: sum(x, over=f) <= 120 has
no free dims, so [] is the signature that matches it. An empty dim list is
the empty coordinate everywhere it appears — one value for a parameter's
dims: [], one column for a variable's foreach: [], one row for a
constraint's — so a dummy dimension of size 1 is never how a scalar is written.
One gap: a scalar variable may not carry a where
(#340); put the condition on
the constraints that use it.
Two regimes of one rule are two blocks, and each gets a name a reader chose rather than a position in a list:
storage_balance:
foreach: [snapshot, storage]
expression: soc == shift(soc, over=snapshot, offset=1) * (1 - loss) + charge - discharge
storage_balance_initial:
foreach: [snapshot, storage]
where: "position(snapshot) == 0"
expression: soc == soc_initial
shift vacates the first snapshot and a vacated position is
absent, so that row drops without a where saying so. Spelling
it edge='wrap' gated on where: "snapshot > 0" builds the same rows here and
a different model on a horizon that does not start at 0 — the gate hardcodes
the origin, the operator does not.
objective#
A single block, not a mapping, and it carries no name — there is nothing a name would read back, the value being scalar.
dimensions:
generator: { dtype: str }
parameters:
cost: { dims: [generator] }
variables:
p: { foreach: [generator] }
objective:
sense: minimize
expression: sum(p * cost)
| Field | ||
|---|---|---|
expression |
required — arithmetic, no comparator | |
sense |
minimize or maximize |
default minimize |
description |
free text | default null |
There is no foreach, and the expression must be scalar: a load error
otherwise, naming the wrapper it wants. Nothing is summed for you, so where the
sum closes is a thing the file says rather than a rule to remember —
sum(x * a) + sum(y * b) with x, a on i and y, b on j is |i| + |j|
summands, and sum(x * a + y * b) is |i| · |j|. Both are sayable, they are
different models, and the bracket is the difference.
A second objective is unsayable rather than checked — the schema holds one block. Weight several goals into one expression.