Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
".": "0.0.0-alpha.116"
".": "0.0.0-alpha.117"
}
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ contained a literal `## [X.Y.Z]` heading, release-please inserts above the first
`##` it finds, and so the entire release landed inside the comment and rendered
nowhere.

## [0.0.0-alpha.117](https://github.com/energy-models/math-spec/compare/v0.0.0-alpha.116...v0.0.0-alpha.117) (2026-09-22)


### Documentation

* **limits:** a where cannot test a relation against its target dimension, and composition is a limit on the file ([#624](https://github.com/energy-models/math-spec/issues/624)) ([216208b](https://github.com/energy-models/math-spec/commit/216208b7dd548c99309ae409e34d264eb1b70421))

## [0.0.0-alpha.116](https://github.com/energy-models/math-spec/compare/v0.0.0-alpha.115...v0.0.0-alpha.116) (2026-09-22)


Expand Down
5 changes: 3 additions & 2 deletions docs/about/limits.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ That another tool has a feature is not by itself a reason to add it.
| Normalisation, `x / sum(x)` | dividing by a variable is not a polynomial, and no solver takes it | write the ratio as a constraint, or fix the denominator |
| An `if`, a loop, or declarations that depend on the data | `to_spec` could no longer read the file without the data | `where:` masks and `dims:` dimensions. A tool may loop over models |
| A Python API for building models | the model is the file you review and diff | YAML, or a `dict` with the same keys ([below](#composition-component-libraries)) |
| A `where` comparing a relation column against the dimension it maps into | the relation already pairs the two, and a mask over the pair is the same fact in a bigger shape | place the quantity with `sum(by=)`, or read it with `at(by=)` ([operators](../reference/language/operators.md#sum)) |

## Composition (component libraries)

Expand All @@ -154,5 +155,5 @@ grows with the number of component _types_.
Merging happens before `to_spec`. Every function here takes a `dict` as well as
a path, so a model assembled in Python is checked exactly as a file is, and
`Spec.to_yaml()` writes the file a reviewer reads. A `dict` may hold only what a
file may hold. A built-in merge, and namespaces so that two templates can each
declare a `p`, are both things a library does before it hands over a `dict`.
file may hold, so the file itself states no composition. A template names no
sibling, and no key says which fragment wins where two declare a `p`.
78 changes: 60 additions & 18 deletions docs/reference/language/expressions.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,34 +116,36 @@ where_expr ::= atom | "NOT" where_expr | where_expr ("AND"|"OR") where_expr
| "(" where_expr ")"
atom ::= NAME | NAME COMPARATOR value | expression COMPARATOR expression
| POSITION COMPARATOR INTEGER | COUNT COMPARATOR INTEGER | TRANSLATED
| "True" | "False"
| READ | "True" | "False"
COMPARATOR ::= "<=" | ">=" | "==" | "!=" | "<" | ">"
value ::= NUMBER | QUOTED | NAME_OR_STRING
expression ::= the arithmetic grammar above, with no variable and no dual in it
POSITION ::= "position" "(" NAME [ "," "by" "=" NAME "," "within" "=" COLUMNS ] ")"
COUNT ::= "count" "(" where_expr "," "over" "=" NAME ")"
TRANSLATED ::= "shift" "(" where_expr "," "along" "=" NAME "," "offset" "=" INTEGER ")"
READ ::= "at" "(" where_expr "," "by" "=" NAME "," "over" "=" COLUMNS "," "into" "=" COLUMNS ")"
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 |
| `name` (bare) | variable | The variable exists at this coordinate |
| `name` (bare) | relation | A row exists, read at the relation's key. A relation may be [partial](relations.md#the-data-contract), and this selects the labels that do map |
| `name` (bare) | dimension | A load error. It would be true everywhere |
| `name OP value` | parameter | Element-wise, and a null 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 |
| `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](#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, by=relation, within=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](#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 |
| `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 a declaration with no rows. A [case `when:`](named.md#the-rules-that-keep-the-cases-apart) may not fold to either |
| 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 |
| `name` (bare) | variable | The variable exists at this coordinate |
| `name` (bare) | relation | A row exists, read at the relation's key. A relation may be [partial](relations.md#the-data-contract), and this selects the labels that do map |
| `name` (bare) | dimension | A load error. It would be true everywhere |
| `name OP value` | parameter | Element-wise, and a null 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 |
| `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](#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, by=relation, within=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](#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, over=a, into=b)` | a predicate | The predicate read through the relation ([reading a predicate through a 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 a declaration with no rows. A [case `when:`](named.md#the-rules-that-keep-the-cases-apart) may not fold to either |

The dimensions of the mask must not exceed the frame it sits in. A bare name
that is not declared is a load error.
Expand Down Expand Up @@ -212,6 +214,46 @@ A negative `offset` reads forwards. `by=`, `within=` and `edge='wrap'` are not
in this form; where you need a grouped or cyclic translation, compare the
arithmetic one instead.

### Reading a predicate through a relation

`at(<where_expr>, by=<relation>, over=<a>, into=<b>)` reads a predicate over
coarse coordinates at fine ones, as [`at`](operators.md#at) reads an array. It
is true at a coordinate where the relation has a row and the predicate holds at
the coordinate that row maps to. It is **false** where the relation has no row,
which is what a missing row already means in a mask.

```yaml
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, over=converter, into=flow)"
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 consumed dimension goes and the produced one arrives, so the mask above is
over `flow` alone. The rules are those of `at` in an expression: `by=`,
`over=` and `into=` are all written, the read lands on the relation's key, and
the predicate carries every dimension the read consumes. The read maps one
dimension onto another and adds none, so a mask still may not widen its frame.

A parameter compared as arithmetic reads through a relation too:
`at(cap, by=bus_of, over=bus, into=generator) > 0`. The predicate form reads
what arithmetic cannot: whether a row is defined, a `bool`, a variable's
existence, and any connective over them.

### The right-hand side of a comparison

A bare name on the right is read as a string label when the model does not
Expand Down
34 changes: 30 additions & 4 deletions docs/reference/language/piecewise.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,10 @@ binds the numbers runs them. Each one is asked only where the block's
!!! warning "A values parameter short of a row does not build a shorter curve"

The missing row reads as a breakpoint at the origin. Every block states
`<block>_complete` for this, whatever its `method:`, so the table is
refused when the data binds and the refusal names `where:` as the way to
say how far a curve runs.
`<block>_complete` for this, whatever its `method:`, and a link that walks
a relation states `<block>_<link>_complete` for its own values. So the
table is refused when the data binds, and the refusal names `where:` as the
way to say how far a curve runs.

### `dims`

Expand Down Expand Up @@ -229,12 +230,37 @@ A block whose only link walks a relation is a curve. Two links is what a curve
needs when a link is one row; a walked link is one row per fine coordinate, so
the relation supplies the second.

**A walked row reads the block's `where:` through its relation.** The mask is
over `dims:` and the row is over the dimensions the walk produces, so the row
takes `at(<where>, by=…, over=…, into=…)`, a
[predicate read through a relation](expressions.md#reading-a-predicate-through-a-relation).
Only some generators have a curve:

```yaml
piecewise:
coupling:
along: bp
dims: [generator, snapshot]
where: has_curve # over generator: a generator with no curve has no weights and no rows
links:
power: { expression: power, values: bp_power, by: generator_of, over: generator, into: flow }
```

The `power` row is built where
`at(has_curve, by=generator_of, over=generator, into=flow)` holds, which is at
every flow of a generator with a curve. The values of a walked link are asked
for at the same rows, so a flow of a generator with no curve needs no row in
`bp_power`. A mask over dimensions the walk keeps, such as `snapshot` alone,
reaches the row as written. A mask that carries some of the dimensions the walk
reads through and not the others is refused, and the message names the ones
missing.

| A walked link | |
| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| _over_ | names a column over a dimension of `dims:` |
| _into_ | names a column over a dimension that `dims:` does not carry, and that is not `along` |
| _values_ | follows the **link's** row: `bp_power` is per flow, not per generator |
| `where:` | is refused on the block, because the walk replaces the dimension of `dims:` that the mask tests. Mask the link's own variable instead |
| `where:` | on the block reaches the link's row read through the relation, or as written where the mask carries none of the dimensions the walk reads through |
| `method:` | `adjacency` or `sos2`. `lp` loses the abscissa its segment line is written against, and `convex` loses the pair of values parameters it reads a shape from |

### Signs
Expand Down
15 changes: 15 additions & 0 deletions docs/reference/notation.md
Original file line number Diff line number Diff line change
Expand Up @@ -806,6 +806,21 @@ run_start:
\mathit{slack}_{t} \le \mathrm{load}_{t,b} \qquad \forall\, t \in \mathcal{T},\ b \in \mathcal{B} \,:\, \mathrm{load}_{t,b} \text{ is defined} \wedge \neg \left( \mathrm{load}_{t - 1,b} \text{ is defined} \right)
```

#### `zoned`

a predicate read through a relation: a bus is held only where its zone has a cap at all

```yaml
zoned:
dims: [bus]
where: "at(zone_cap, by=zone_of, over=zone, into=bus)"
expression: theta <= budget
```

```math
\theta_{b} \le \mathrm{budget} \qquad \forall\, b \in \mathcal{B} \,:\, \mathrm{zone\_cap}_{\mathrm{zone\_of}(b)} \text{ is defined}
```

#### `capped`

an expressions: entry on a side, read by the name the file gave it
Expand Down
Loading
Loading