Skip to content

Expressions#

You write every expression: in one arithmetic grammar: in a constraint, the objective, a named expression and a macro template.

expression  ::= arithmetic | arithmetic COMPARATOR arithmetic
arithmetic  ::= atom | unary_op arithmetic | arithmetic binary_op arithmetic
             |  function_call | "(" arithmetic ")"
atom        ::= NUMBER | NAME
unary_op    ::= "+" | "-"       binary_op ::= "+" | "-" | "*" | "/" | "**"
COMPARATOR  ::= "<=" | ">=" | "=="
function_call ::= NAME "(" [pos_arg ("," pos_arg)*] ["," kwarg ("," kwarg)*] ")"
kwarg       ::= NAME "=" (arithmetic | QUOTED | "[" NAME ("," NAME)* "]" | COLUMNS)
COLUMNS     ::= NAME "[" NAME ("," NAME)* "]"
NAME        ::= [a-zA-Z_][a-zA-Z0-9_]*
NUMBER      ::= integer | float | "inf" | ".inf"
  • Operators bind in this order, highest first: **, then unary + and -, then * and /, then binary + and -. So -x ** 2 is -(x ** 2), as in Python.
  • A float may carry an exponent, as in 1e5 or 2.5e-3.
  • The same keyword twice in one call is an error.
  • An expression and a where: string nest at most 100 levels deep, and at most 300 with every named expression they read written in.

Where a product of two variables is allowed#

The objective and the constraints take variable * variable. A quadratic cost is sum(p * p * wear, over=g), and a quadratic row is p * q >= floor. Three rules limit it:

  • At most one factor may be a sum of terms, so sum(p, over=g) * sum(q, over=g) is refused. Multiply before you reduce, or constrain a variable to equal the reduction. Factors on different dimensions are allowed: x * y * link broadcasts, and the table link says which pairs exist.
  • The degree stops at 2, so p * p * p is refused.
  • A bound and a piecewise: link stay affine. A bound is one number per column.

A reported expression is not held to these.

/ needs a divisor that carries no variable, and ** needs a base and an exponent that carry no variable. Those places accept any arithmetic over numbers and parameters, so you write a discount factor where you use it:

dimensions:
  period: { dtype: int }
parameters:
  rate: { dims: [] }
  years: { dims: [period] }
  price: { dims: [period] }
variables:
  build: { dims: [period], bounds: { lower: 0 } }
constraints:
  enough: { dims: [], expression: "sum(build, over=period) >= 1" }
objective:
  sense: minimize
  expression: sum(build * price / (1 + rate) ** years, over=period)

years is a parameter over period. The exponent cannot be period itself, because an expression reads no dimension. Write x * x for a square.

Name resolution#

One flat namespace covers dimensions, relations, parameters, variables, named expressions, masks, macros and the built-in operators. Two entries with one name are a load error, and the message names both, so no name hides another.

The place where a name stands decides which kinds of name are legal:

Position Legal kinds
expression (p * cost) a variable, or a parameter whose values are numbers (dtype)
dimension argument (over=, along=) a dimension, or a list of them for over=. With by=, a name in over= is a column of the relation, if it has one
column argument (by=, within=) columns of one relation, written relation[column] or relation[column, …]
where string a parameter, variable, dimension, relation or mask (where strings)
bounds.lower / bounds.upper a parameter name, or a number
the edge key of shift 'wrap' in quotes, or a bare number
dual argument (dual(c)) a constraint. It resolves against the constraints alone (named expressions)

A bare word in the value of a keyword argument is a name to resolve, but a keyword's key is never a name.

Four sections sit outside the flat namespace: constraints, assumptions, sos and piecewise. No expression reads them by a bare name, so a name in one of them may be the name of an entry in the flat namespace, or of an entry in one of the other three. dual() is the one reader of a constraint, by its name. A term adds_to a named expression, and never to a constraint of the same name.

How dimensions combine#

to_spec knows the dimension set of every expression before any data is attached:

Node Dim set Error
number {}
parameter / variable its dims
-x, +x dims(x)
a + b, a * b, a / b dims(a) ∪ dims(b)
sum(x) {} error if dims(x) is already empty
sum(x, over=d) dims(x) − {d} error if d ∉ dims(x)
sum(x, over=d, by=l[c]) (dims(x) − joined) ∪ grouped the refusals under how a relation is used
at(x, by=l[c]) (dims(x) − joined) ∪ grouped the same
shift(x, along=d, offset=n) dims(x) error if d ∉ dims(x)
sum_back(x, along=d, window=n) dims(x) error if d ∉ dims(x)

An outer product is allowed. The dimensions an entry ranges over are its frame, and an entry may not disagree with its expression:

  • A constraint requires dims(lhs) ∪ dims(rhs) to equal its dims.
  • An objective must carry no dimensions, so write the sums that reduce it.
  • A where predicate and a bound parameter must not exceed the frame they sit in.

Each of these is a load error.

where strings#

A where: is a boolean mask, and true means "this coordinate exists".

where_expr ::= atom | "NOT" where_expr | where_expr ("AND"|"OR") where_expr
            |  "(" where_expr ")"
atom       ::= NAME | (NAME | COLUMN) COMPARATOR value | expression COMPARATOR expression
            |  POSITION COMPARATOR INTEGER | COUNT COMPARATOR INTEGER | TRANSLATED
            |  READ | "True" | "False"
COMPARATOR ::= "<=" | ">=" | "==" | "!=" | "<" | ">"
value      ::= NUMBER | QUOTED | NAME_OR_STRING | COLUMN
expression ::= the arithmetic grammar above, with no variable and no dual in it
POSITION   ::= "position" "(" NAME [ "," "within" "=" COLUMNS ] ")"
COUNT      ::= "count" "(" where_expr "," "over" "=" NAME ")"
TRANSLATED ::= "shift" "(" where_expr "," "along" "=" NAME "," "offset" "=" INTEGER ")"
READ       ::= "at" "(" where_expr "," "by" "=" COLUMNS ")"
COLUMN     ::= NAME "[" NAME "]"
COLUMNS    ::= NAME "[" NAME { "," NAME } "]"
QUOTED     ::= "'" chars "'" | '"' chars '"'
Written as Names a… Meaning
name (bare) parameter The value is defined here. A bool is its own answer. A str is defined wherever the table has a row. A number has to have a row and be finite, and 0.0 is a row: write inflow != 0 for non-zero. An int or str under missing: refused is a load error: it has a row at every coordinate
name (bare) variable The variable exists at this coordinate
name (bare) relation A row exists, read at the relation's key. A missing: absent relation is partial, and this selects the labels that do map
name (bare) dimension A load error. It would be true everywhere
name (bare) mask The mask's predicate (masks). A given mask is true or false at each coordinate, as a bool parameter is
name OP value parameter Element-wise. A missing row with no missing: value compares false
name OP value dimension A filter on the frame's own coordinate column
name OP value, name[col] OP value relation A filter on a value column, read at the relation's key. Name the column where the key determines several. A comparison reads one column
name OP name, name[a] OP name[b] two relation columns Legal where both relations are keyed over the same dimensions and both columns are over one dimension. ends[bus0] != ends[bus1] excludes a self-loop
expression OP expression arithmetic over parameters Coordinate by coordinate, over every dimension either side carries (arithmetic in a comparison). A side with no value at a coordinate compares false
position(name) OP i dimension Where the row sits along the dimension's own order. 0 is first, and a negative number counts from the end
position(name, within=relation[c]) dimension The same, counted within each group the relation makes
count(where_expr, over=name) OP i a predicate How many coordinates along the dimension the predicate admits (counting what a predicate admits)
shift(where_expr, along=name, offset=i) a predicate The predicate read i coordinates back, and false where that vacates
at(where_expr, by=relation[c]) a predicate The predicate read through the relation (reading a predicate through a relation), and false where the relation has no row
AND OR NOT — Case-insensitive. NOT binds tighter than AND, and AND tighter than OR
True / False — True is the same as no where; False gives an entry with no rows

A bare name that is not declared is a load error.

At a missing row, a comparison and a bare bool name read the value that the missing: of the parameter names. A bare numeric name asks whether the data has a row, whatever the missing:.

Counting what a predicate admits#

count(<where_expr>, over=<dimension>) is how many coordinates along that dimension the predicate is true at:

dimensions:
  bp: { dtype: int }
  generator: { dtype: str }
parameters:
  bp_x: { dims: [generator, bp] }
  points: { dims: [generator, bp], dtype: bool }
variables:
  p:
    dims: [generator]
    where: "count(points, over=bp) >= 2"
    bounds: { lower: 0 }
constraints:
  cap:
    dims: [generator]
    expression: p <= 1
objective:
  sense: minimize
  expression: sum(p, over=generator)
\[\lvert \{ b \in \mathcal{B} \thinspace : \thinspace \mathrm{points}_{g,b} \} \rvert \ge 2 \qquad \forall\thinspace g \in \mathcal{G}\]

The dimension counted over is removed, as a sum(over=) removes it: one count per generator above. Counting along a dimension the predicate does not read is a load error.

The right-hand side is a whole number. A fraction, a parameter, and a comparison a count can never fail or never meet (>= 0, < 0, or any negative number) are load errors.

Reading a predicate at the previous coordinate#

shift(<where_expr>, along=<dimension>, offset=<integer>) reads the predicate offset coordinates back. It is false where the translation vacates, and it takes no edge=. With count, it names the start of a run:

where: "count(points AND NOT shift(points, along=bp, offset=1), over=bp) == 1"

That where is true when the marked breakpoints are one consecutive run.

A negative offset reads forwards. This form accepts neither within= nor edge='wrap'. For a grouped or cyclic translation, compare the arithmetic shift.

Reading a predicate through a relation#

at(<where_expr>, by=<relation>[<column>]) reads a predicate over coarse coordinates at fine ones, as at reads an array. It is true where the relation has a row and the predicate holds at the coordinate that row maps to, and false where the relation has no row.

dimensions:
  converter: { dtype: str }
  flow: { dtype: str }
relations:
  converter_of: { key: flow, values: converter }
parameters:
  has_curve: { dims: [converter], dtype: bool }
  cap: { dims: [flow] }
variables:
  rate:
    dims: [flow]
    where: "at(has_curve, by=converter_of[converter])"
    bounds: { lower: 0, upper: cap }
objective:
  sense: minimize
  expression: sum(rate, over=flow)
\[0 \le \mathit{rate}_{f} \le \mathrm{cap}_{f} \qquad \forall\thinspace f \in \mathcal{F} \thinspace : \thinspace \mathrm{has\_curve}_{\mathrm{converter\_of}(f)}\]

The read removes the column it reads, converter, and adds the key, flow, so the mask above is over flow alone. The rules are those of at in an expression. by= names a value column, or several as relation[a, …]. The read groups by the key of the relation, and the predicate carries every dimension that the read joins on.

The right-hand side of a comparison#

A bare name on the right is read as a string label when the spec does not declare it, and a declared name there is a load error.

Quote a label that is not an identifier, such as 'combined-cycle'. A quoted word is never read as an entry.

A comparison is checked against the declared dtype. A datetime dimension is compared against a quoted ISO date such as '2030-01-01' or '2030-01-01T06:00', and a number against it is a load error.

String labels compare bytewise, whatever the dimension's order, and a label the dimension does not carry compares equal to nothing.

The language cannot compare two dimensions, so precompute a boolean parameter instead.

Arithmetic in a comparison#

Either side of a comparison may be an expression over parameters: p_min <= 0.5 * p_max, or p_max <= at(bus_cap, by=bus_of[bus]). The side is read as an expression is, macros and named expressions included. A variable and a dual() are refused. A relation column and a quoted label are compared on their own, and are not read in arithmetic.

A side absent at a coordinate compares false there, but under a summing operator the absent term is one summand fewer. To compare against the previous row, give the shift an edge=, and add a position() term to keep the first row out:

dimensions:
  snapshot: { dtype: int, ordered: true }
parameters:
  load: { dims: [snapshot] }
  ramp: { dims: [] }
variables:
  shed: { dims: [snapshot], bounds: { lower: 0 } }
constraints:
  shed_when_load_jumps:
    dims: [snapshot]
    where: "load - shift(load, along=snapshot, offset=1, edge=0) > ramp AND position(snapshot) > 0"
    expression: shed >= load - ramp

A case when: may compare expressions only where cases: holds one case. A comparison with a number on both sides, such as 2 < 1, is refused everywhere.

position()#

position(dim) counts along the order shift steps along, not the label:

dimensions:
  snapshot: { dtype: int, ordered: true }
parameters:
  soc_initial: { dims: [] }
variables:
  soc: { dims: [snapshot], bounds: { lower: 0 } }
constraints:
  soc_start:
    dims: [snapshot]
    where: "position(snapshot) == 0" # not: snapshot == 0
    expression: soc == soc_initial

A position that no coordinate occupies is an error when the data is attached.

within= counts inside each group a partition makes, so each period gets one seeded row:

dimensions:
  snapshot: { dtype: int, ordered: true }
  period: { dtype: int }
relations:
  period_of: { key: snapshot, values: period }
parameters:
  soc_initial: { dims: [period] }
variables:
  soc: { dims: [snapshot], bounds: { lower: 0 } }
constraints:
  soc_start:
    dims: [snapshot]
    where: "position(snapshot, within=period_of[period]) == 0"
    expression: soc == at(soc_initial, by=period_of[period])