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 |
description |
free text | default null |
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.
given_variables#
A given variable is a column this file reads and another file introduces. It is what lets a template stand on its own: the file loads, and it prints as math, without the file that owns the column.
dimensions:
snapshot: { dtype: int }
port: { dtype: str }
generator: { dtype: str }
relations:
gen_port: { key: generator, value: port }
given_variables:
flow:
dims: [snapshot, port]
description: what a port puts into its bus
variables:
gen_p: { dims: [snapshot, generator], bounds: { lower: 0 } }
constraints:
gen_injects:
dims: [snapshot, generator]
expression: at(flow, by=gen_port) == gen_p
| Field | ||
|---|---|---|
dims |
required. The dimensions the column is indexed by | |
domain |
continuous, integer or binary |
default continuous |
description |
free text | default null |
There is no bounds and no where. The file that introduces the column owns
both, and a second spelling here would be a second home for one fact.
An expression reads a given variable as it reads any other, so
at(flow, by=gen_port) lands on the generator frame and the dim algebra
checks it at load.
merge folds each given declaration into the
declaration that introduces it, so a composed library carries none of them. The
folded declaration is the introducer's, and what the reader stated has to agree
with it.
Where nothing in this language introduces the column โ a layer over a model built in Python โ the declaration stays, and the program carries it for a consumer to bind. See what a program does not build.
given_constraints#
A given constraint is a row family this file reads the dual of and another model builds. It is what lets a layer price something the base model settles.
given_constraints:
balance:
dims: [snapshot, bus]
description: the host model clears each bus
expressions:
price:
expression: dual(balance)
| Field | ||
|---|---|---|
dims |
required. The dimensions the row family runs over | |
description |
free text | default null |
There is no expression, because nothing here builds the row, and no sense.
The dual comes back from whoever solved the model, under that model's own
convention, and a sense written here would be a claim no file could check.
dual(name) is the only place a given row family may be named, and the frame
is what gives the reported expression its dimensions.
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.