Parameters, variables, constraints and the objective#
These four blocks carry the math. Each takes an optional description:.
A description is free text with no length limit. The parser throws a # comment
away, but keeps a description, so a renderer or a checker can print it. The
typeset legend prints the description of every dimension,
parameter and variable.
A description is plain prose, with one piece of notation. A name in
backticks, such as `capital_cost`, sets in monospace in every output
format. Everything else is text, and each format escapes whatever its own
syntax would read as markup: an underscore stays an underscore, and $\ell$
prints as those five characters. Write the thing rather than its symbol: "flow
on a line", not "flow on line \(\ell\)".
parameters#
A parameter declares a shape and nothing more. The engine that builds the model supplies the numbers, by name, from its own tables. How the engine reads those tables is fixed by three rules that every engine follows.
dimensions:
snapshot: { dtype: int }
parameters:
load:
dims: [snapshot]
discount_rate:
dims: [] # a scalar
| Field | ||
|---|---|---|
dims |
required. The dimensions it is indexed by. [] means a scalar |
|
dtype |
float, int, bool, str |
default float |
coverage |
total, masked |
default total |
description |
free text | default null |
coverage says whether a missing row was meant. A table short of a
coordinate and a table that never had one look identical in the data, and they
mean opposite things: total is the claim that every coordinate the dims
reach has a value, so a row that went missing in preparation is an error rather
than a mask; masked is the file saying the gap is the point — the parameter
is a mask, and a coordinate it leaves out reads as the value that contributes
nothing, 0 as a coefficient and false in a where
(absence says where no such value exists).
parameters:
cost: { dims: [generator] } # total: every generator has one
ramp_limit: { dims: [generator], coverage: masked } # no row means no limit
Without it the reading is a consumer's to pick, and two consumers picking
differently would build different models from one file and one table — so the
declaration says it and no consumer guesses. The default is total because
a model that never considered the question wants the strict reading: a row lost
in preparation should be an error, and a mask is a thing you write down.
Where a masked parameter may stand is a question about rows rather than about
the file. A bound and a divisor refuse absence outright, so one
reaching either has to carry a row wherever the declaration naming it exists —
which that declaration's own where: may already guarantee, as it does where a
variable is masked on the parameter that also bounds it. The file does not
settle that, so whatever binds the table refuses it row by row, naming the
coordinate.
A parameter a piecewise: block consumes does not declare coverage:. The
block already owns the shape of its curve: points: says how
far a curve runs where they are not all the same length, and a breakpoint left
out declares no weight and is not asked for. A values parameter is therefore
total over the points its block admits — which is not a claim about every
coordinate its dims reach, and not a mask either. Writing coverage: on one
is a load error naming points:.
The dtype is a claim about the values, and the column has to match it:
| declared | the column | |
|---|---|---|
float |
a float column, or an integer one | whole numbers are numbers, and this is the one widening allowed |
int |
an integer column | so a fractional position or offset cannot arrive |
bool |
a boolean column | 1 and 0 are not booleans. Cast the column, or declare int |
str |
a string column |
The dtype decides four things: whether the name is a value in an
expression; what a where comparison is checked against; what
a bare name in a where means; and whether the
name may stand where an operator reads a
position.
Only float and int are values. A str parameter is a label and a bool
parameter is a mask: each names rows rather than scaling them. Writing either
one as a coefficient, a term or a divisor is a load error, and nothing casts it
on the way past.
- Select with a label:
where: "fuel == 'gas'". Carry the numbers that the label picks out in a parameter of their own. - Mask with a flag:
where: "committable". - Declare
dtype: intwhere a0or1is meant to arrive as data and be multiplied by.
variables#
A variable is what the solver decides. There is one column per coordinate of
dims.
dimensions:
snapshot: { dtype: int }
generator: { dtype: str }
parameters:
capacity: { dims: [generator] }
variables:
dispatch:
dims: [snapshot, generator]
where: "capacity > 0"
bounds:
lower: 0
upper: capacity
| Field | ||
|---|---|---|
dims |
required. The dimensions it is indexed by | |
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. binary carries fixed 0/1 bounds |
default continuous |
absence |
undefined or zero: what a masked-out coordinate means (absence) |
default undefined |
description |
free text | default null |
A bound you omit leaves the variable unbounded on that side
You write non-negativity. The language does not assume it.
A bound is a name or a number, never arithmetic. upper: capacity is accepted, and
upper: -rating is refused with a message that says so. Ship the negated column
as data. Arithmetic in a bound is
#31. The dimensions of a bound
parameter must not exceed its dims.
Equal bounds pin a variable. That 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 the quantity is fixed, and rate - relmax * size <= 0 is one
equation whether size is chosen or given. A pinned variable is still a
variable, so size * on is variable * variable, and a pinned variable cannot
stand in another variable's bounds.
constraints#
One block is one rule. The name of the block is the name of the constraint, and that name is how a row is read back after a solve.
dimensions:
snapshot: { dtype: int }
generator: { dtype: str }
parameters:
load: { dims: [snapshot] }
variables:
dispatch: { dims: [snapshot, generator] }
constraints:
power_balance:
dims: [snapshot]
expression: sum(dispatch, over=generator) == load
| Field | ||
|---|---|---|
dims |
required. The rows this rule builds | |
expression |
required. It uses exactly one of <=, >= or == |
|
where |
which rows are built (absence) | default null |
description |
free text | default null |
The dimensions of the expression must equal its dims. See
how dimensions combine.
Either side of the comparator may carry variables, and one side must. A comparison between numbers and parameters alone is refused at load, because it is settled before the solve. A single row can still end up with no variable terms, because the data left its terms nowhere to sit. Such a row is not built. See absence.
dims: [] gives one scalar row, for a rule such as a system-wide budget. An
empty dimension list means one value for a parameter, one column for a variable
and one row for a constraint, so a scalar is never written as a dummy dimension
of size 1. 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, each with a name a reader chose:
storage_balance:
dims: [snapshot, storage]
expression: soc == shift(soc, along=snapshot, offset=1) * (1 - loss) + charge - discharge
storage_balance_initial:
dims: [snapshot, storage]
where: "position(snapshot) == 0"
expression: soc == soc_initial
shift vacates the first snapshot, and a vacated position is
absent, so the first row of storage_balance drops without a
where saying so. Writing edge='wrap' and gating on where: "snapshot > 0"
builds the same rows here, but a different model on a horizon that does not start
at 0, because the gate hardcodes the origin.
objective#
The objective is a single block with no name. Its value is a scalar, so there is nothing for a name to read back.
dimensions:
generator: { dtype: str }
parameters:
cost: { dims: [generator] }
variables:
dispatch: { dims: [generator] }
objective:
sense: minimize
expression: sum(dispatch * cost)
| Field | ||
|---|---|---|
expression |
required. Arithmetic, with no comparator | |
sense |
minimize or maximize |
default minimize |
description |
free text | default null |
The expression must be scalar. Anything else is a load error that names the
sum it wants.
Nothing is summed for you, so the file says where each sum closes. With x and
a on i, and y and b on j, sum(x * a) + sum(y * b) has |i| + |j|
terms and sum(x * a + y * b) has |i| · |j|. Both are allowed, and they are
different models.
A second objective cannot be written, because the schema holds one block. To pursue several goals, weight them into one expression.