Skip to content

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: int where a 0 or 1 is 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.