diff --git a/.prettierignore b/.prettierignore index 9585e8fe..cb04aad8 100644 --- a/.prettierignore +++ b/.prettierignore @@ -18,6 +18,10 @@ CHANGELOG.md docs/examples/dispatch.md docs/examples/commitment.md docs/examples/operators.md +docs/examples/piecewise.md +docs/examples/piecewise_adjacency.md +docs/examples/sos.md +docs/examples/piecewise_lp.md docs/examples/pypsa.md docs/examples/pypsa_linearized_uc.md docs/examples/library/surface.md diff --git a/CHANGELOG.md b/CHANGELOG.md index b0f02c18..287c0119 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ it releases that version ([RELEASING.md](https://github.com/energy-models/mathsp ## Upcoming version +- feat(language): a piecewise block states its dims and names its links, and its where reaches links that walk a relation ([#630](https://github.com/energy-models/mathspec/pull/630)) - feat(language): a parameter value that is null or NaN is refused when the data is attached ([#788](https://github.com/energy-models/mathspec/pull/788)) - fix(language): a where string names several columns in at's over= and into=, as an expression does ([#782](https://github.com/energy-models/mathspec/pull/782)) - fix(language): a file whose terms read each other's sums is refused at load ([#780](https://github.com/energy-models/mathspec/pull/780)) diff --git a/docs/examples/dispatch.md b/docs/examples/dispatch.md index 853ba21f..772ea1a6 100644 --- a/docs/examples/dispatch.md +++ b/docs/examples/dispatch.md @@ -49,29 +49,27 @@ Least-cost dispatch of a generator fleet against an hourly load. | Symbol | Meaning | |---|---| -| $`\mathcal{T}`$ | index $`t`$ — `snapshot` — dispatch periods | +| $`\mathcal{S}`$ | index $`s`$ — `snapshot` — dispatch periods | | $`\mathcal{G}`$ | index $`g`$ — `generator` — generating units | #### Parameters | Symbol | Meaning | |---|---| -| $`\mathrm{capacity}`$ | `capacity` over $`\mathcal{G}`$ — installed capacity | -| $`\mathrm{load}`$ | `load` over $`\mathcal{T}`$ — demand to be met | -| $`\mathrm{cost}`$ | `cost` over $`\mathcal{G}`$ — marginal cost | +| $`\bar p`$ | `capacity` over $`\mathcal{G}`$ — installed capacity | +| $`\ell`$ | `load` over $`\mathcal{S}`$ — demand to be met | +| $`c`$ | `cost` over $`\mathcal{G}`$ — marginal cost | #### Variables | Symbol | Meaning | |---|---| -| $`\mathit{dispatch}`$ | `dispatch` over $`\mathcal{T} \times \mathcal{G}`$ — output of a generator in a snapshot | - -Upright is what the data supplies — a parameter such as $`\mathrm{capacity}`$, a coordinate map, a label — and italic is what the solver chooses, such as $`\mathit{dispatch}`$. An index is italic too, being what a quantifier chooses, and a set is script. +| $`\mathit{dispatch}`$ | `dispatch` over $`\mathcal{S} \times \mathcal{G}`$ — output of a generator in a snapshot | #### Objective ```math -\min \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} \mathit{dispatch}_{t,g} \cdot \mathrm{cost}_{g} +\min \sum_{s \in \mathcal{S},\ g \in \mathcal{G}} \mathit{dispatch}_{s,g} \cdot c_{g} ``` #### Subject to @@ -79,7 +77,7 @@ Upright is what the data supplies — a parameter such as $`\mathrm{capacity}`$, **`power_balance`** ```math -\sum_{g \in \mathcal{G}} \mathit{dispatch}_{t,g} = \mathrm{load}_{t} \qquad \forall\, t \in \mathcal{T} +\sum_{g \in \mathcal{G}} \mathit{dispatch}_{s,g} = \ell_{s} \qquad \forall\, s \in \mathcal{S} ``` #### Variable domains @@ -87,7 +85,7 @@ Upright is what the data supplies — a parameter such as $`\mathrm{capacity}`$, **`dispatch`** ```math -0 \le \mathit{dispatch}_{t,g} \le \mathrm{capacity}_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{capacity}_{g} > 0 +0 \le \mathit{dispatch}_{s,g} \le \bar p_{g} \qquad \forall\, s \in \mathcal{S},\ g \in \mathcal{G} \,:\, \bar p_{g} > 0 ``` diff --git a/docs/examples/index.md b/docs/examples/index.md index 7a53f66d..8a1413db 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -14,6 +14,17 @@ Every spec is a file under `examples/` in the repository. by region, so a single inequality covers both regimes. - [One construct per spec](operators.md) declares each operator in the smallest file that can, and prints the equation beside it. +- [A curve by convex combination](piecewise.md) is the floor of the `piecewise` + family: one weight per breakpoint, one row summing them to 1, and one row per + link. The three pages after it are the same spec, restricted another way. +- [A curve that is not convex](piecewise_adjacency.md) adds a binary per segment + and the two rows that hold the weights on it. This is what the default method + builds. +- [A curve as a special-ordered set](sos.md) hands that same restriction to the + solver. The binaries and their rows are gone, and a declaration stands where + they were. +- [A curve as segment lines](piecewise_lp.md) states the curve as inequalities + instead of breakpoints, and declares no auxiliary variable at all. - [A component library](library/index.md) is several files that compose into one spec. Each file reads the coupling surface and prints on its own, and the composed page shows what `merge` returns. diff --git a/docs/examples/piecewise.md b/docs/examples/piecewise.md new file mode 100644 index 00000000..773d1edc --- /dev/null +++ b/docs/examples/piecewise.md @@ -0,0 +1,173 @@ + + +# A curve by convex combination + +A generator's cost curve, tied to its dispatch through one weight per +breakpoint. This is the floor of the [`piecewise`](../reference/language/piecewise.md) +family: the three pages after it are this same model with the weights +restricted a different way. + +Read the math for what the block expands to. The file declares no weight, and +`cost_curve_lam` appears below because the block emits it. One row makes the +weights sum to 1, and one row per link ties that link's expression to the +weighted breakpoints. `method: convex` adds nothing further. A convex curve +under a minimised cost settles on one segment without being held there. + + +```yaml +description: >- + Least-cost dispatch where each generator's cost curve is piecewise-linear in + its output, expanded into a lambda formulation. + +dimensions: + snapshot: + description: dispatch periods + dtype: int + generator: + description: dispatchable units + dtype: str + bp: + description: breakpoints of the cost curve + dtype: int + +parameters: + capacity: + description: maximum dispatch + dims: [generator] + load: + description: demand to be met + dims: [snapshot] + bp_x: + description: breakpoint dispatch levels, one curve per generator + dims: [generator, bp] + bp_y: + description: cost at each breakpoint, one curve per generator + dims: [generator, bp] + +variables: + dispatch: + description: dispatched power + dims: [snapshot, generator] + bounds: + lower: 0 + upper: capacity + op_cost: + description: operating cost, piecewise-linear in dispatch + dims: [snapshot, generator] + bounds: + lower: 0 + +piecewise: + cost_curve: + description: >- + cost read off the generator's curve — convex, so the weights need no + binaries to keep them on one segment + along: bp + dims: [snapshot, generator] + links: + dispatch: [dispatch, bp_x] + op_cost: [op_cost, bp_y] + method: convex + +constraints: + balance: + dims: [snapshot] + expression: sum(dispatch, over=generator) == load + +objective: + sense: minimize + description: total operating cost, taken off the curves rather than from a marginal rate + expression: sum(op_cost) +``` + +Least-cost dispatch where each generator's cost curve is piecewise-linear in its output, expanded into a lambda formulation. + +#### Sets + +| Symbol | Meaning | +|---|---| +| $`\mathcal{T}`$ | index $`t`$ — `snapshot` — dispatch periods | +| $`\mathcal{G}`$ | index $`g`$ — `generator` — dispatchable units | +| $`\mathcal{B}`$ | index $`b`$ — `bp` — breakpoints of the cost curve | + +#### Parameters + +| Symbol | Meaning | +|---|---| +| $`\mathrm{capacity}`$ | `capacity` over $`\mathcal{G}`$ — maximum dispatch | +| $`\mathrm{load}`$ | `load` over $`\mathcal{T}`$ — demand to be met | +| $`\mathrm{x}`$ | `bp_x` over $`\mathcal{G} \times \mathcal{B}`$ — breakpoint dispatch levels, one curve per generator | +| $`\mathrm{y}`$ | `bp_y` over $`\mathcal{G} \times \mathcal{B}`$ — cost at each breakpoint, one curve per generator | + +#### Variables + +| Symbol | Meaning | +|---|---| +| $`\mathit{dispatch}`$ | `dispatch` over $`\mathcal{T} \times \mathcal{G}`$ — dispatched power | +| $`\mathit{op\_cost}`$ | `op_cost` over $`\mathcal{T} \times \mathcal{G}`$ — operating cost, piecewise-linear in dispatch | + +Upright is what the data supplies — a parameter such as $`\mathrm{capacity}`$, a coordinate map, a label — and italic is what the solver chooses, such as $`\mathit{dispatch}`$. An index is italic too, being what a quantifier chooses, and a set is script. + +$`t \boxminus_{v} k`$ denotes translation with $`v`$ standing where index $`t-k`$ leaves the dimension (`shift(edge=v)`), so the row at that boundary is built and carries $`v`$ rather than being dropped. + +$`\mathrm{pos}(t)`$ denotes where index $`t`$ sits along its dimension's own order — the order `shift` steps along, not the order labels sort in — counted from $`0`$. The index itself stays the coordinate, so $`t`$ compares against labels and $`\mathrm{pos}(t)`$ against positions. + +$`\lvert \mathcal{T} \rvert`$ denotes the size of the set being counted along, and a position counted from the end prints against it — $`\lvert \mathcal{T} \rvert - 1`$ is the last position, one less than the size because the first is $`0`$. + +#### Objective + +```math +\min \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} \mathit{op\_cost}_{t,g} +``` + +#### Subject to + +**`balance`** + +```math +\sum_{g \in \mathcal{G}} \mathit{dispatch}_{t,g} = \mathrm{load}_{t} \qquad \forall\, t \in \mathcal{T} +``` + +**`cost_curve`** + +```math +\left( \mathit{dispatch}_{t,g},\ \mathit{op\_cost}_{t,g} \right) \in \mathrm{conv}_{b \in \mathcal{B}}(\mathrm{x}_{g,b},\ \mathrm{y}_{g,b}) \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +``` + +#### Variable domains + +**`dispatch`** + +```math +0 \le \mathit{dispatch}_{t,g} \le \mathrm{capacity}_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +``` + +**`op_cost`** + +```math +\mathit{op\_cost}_{t,g} \ge 0 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +``` + +#### Assumptions + +**`cost_curve_complete`** + +```math +\mathrm{x}_{g,b} \text{ is defined} \wedge \mathrm{y}_{g,b} \text{ is defined} \qquad \forall\, g \in \mathcal{G},\ b \in \mathcal{B} +``` + +**`cost_curve_increasing`** + +```math +\mathrm{x}_{g,b \boxminus_{0} 1} < \mathrm{x}_{g,b} \qquad \forall\, g \in \mathcal{G},\ b \in \mathcal{B} \,:\, \mathrm{pos}(b) > 0 +``` + +**`cost_curve_curvature`** + +```math +\lvert \{ b \in \mathcal{B} \,:\, \left( \mathrm{y}_{g,b} - \mathrm{y}_{g,b \boxminus_{0} 1} \right) \cdot \left( \mathrm{x}_{g,b \boxplus_{0} 1} - \mathrm{x}_{g,b} \right) > \left( \mathrm{y}_{g,b \boxplus_{0} 1} - \mathrm{y}_{g,b} \right) \cdot \left( \mathrm{x}_{g,b} - \mathrm{x}_{g,b \boxminus_{0} 1} \right) \wedge \mathrm{pos}(b) > 0 \wedge \mathrm{pos}(b) \neq \lvert \mathcal{B} \rvert - 1 \} \rvert = 0 \vee \lvert \{ b \in \mathcal{B} \,:\, \left( \mathrm{y}_{g,b} - \mathrm{y}_{g,b \boxminus_{0} 1} \right) \cdot \left( \mathrm{x}_{g,b \boxplus_{0} 1} - \mathrm{x}_{g,b} \right) < \left( \mathrm{y}_{g,b \boxplus_{0} 1} - \mathrm{y}_{g,b} \right) \cdot \left( \mathrm{x}_{g,b} - \mathrm{x}_{g,b \boxminus_{0} 1} \right) \wedge \mathrm{pos}(b) > 0 \wedge \mathrm{pos}(b) \neq \lvert \mathcal{B} \rvert - 1 \} \rvert = 0 \qquad \forall\, g \in \mathcal{G} +``` + diff --git a/docs/examples/piecewise_adjacency.md b/docs/examples/piecewise_adjacency.md new file mode 100644 index 00000000..fc8f4f55 --- /dev/null +++ b/docs/examples/piecewise_adjacency.md @@ -0,0 +1,157 @@ + + +# A curve that is not convex + +The same dispatch model, with a curve that bends both ways. Nothing about the +objective now keeps the weights on one segment, so the method builds the +restriction out of binaries. `adjacency` is the default, and this is what it +costs. + +Compare the math with [the convex page](piecewise.md). A second variable +appears, `cost_curve_seg`, one binary per segment. Two rows come with it: +`cost_curve_pick` picks exactly one segment, and `cost_curve_adjacency` holds +each weight under the segments it borders. The link rows and the convexity row +are unchanged. + + +```yaml +description: >- + The same least-cost dispatch as `piecewise.yaml`, with a cost curve that is + not convex. The weights need binaries to hold them on one segment, which is + what the default method builds. + +dimensions: + snapshot: + description: dispatch periods + dtype: int + generator: + description: dispatchable units + dtype: str + bp: + description: breakpoints of the cost curve + dtype: int + +parameters: + capacity: + description: maximum dispatch + dims: [generator] + load: + description: demand to be met + dims: [snapshot] + bp_x: + description: breakpoint dispatch levels, one curve per generator + dims: [generator, bp] + bp_y: + description: cost at each breakpoint, one curve per generator + dims: [generator, bp] + +variables: + dispatch: + description: dispatched power + dims: [snapshot, generator] + bounds: + lower: 0 + upper: capacity + op_cost: + description: operating cost, piecewise-linear in dispatch + dims: [snapshot, generator] + bounds: + lower: 0 + +piecewise: + cost_curve: + description: >- + cost read off the generator's curve. The curve bends both ways, so + nothing but the restriction keeps the weights on one segment: a binary + per segment picks the one they may sit on + along: bp + dims: [snapshot, generator] + links: + dispatch: [dispatch, bp_x] + op_cost: [op_cost, bp_y] + method: adjacency + +constraints: + balance: + dims: [snapshot] + expression: sum(dispatch, over=generator) == load + +objective: + sense: minimize + description: total operating cost, taken off the curves rather than from a marginal rate + expression: sum(op_cost) +``` + +The same least-cost dispatch as `piecewise.yaml`, with a cost curve that is not convex. The weights need binaries to hold them on one segment, which is what the default method builds. + +#### Sets + +| Symbol | Meaning | +|---|---| +| $`\mathcal{T}`$ | index $`t`$ — `snapshot` — dispatch periods | +| $`\mathcal{G}`$ | index $`g`$ — `generator` — dispatchable units | +| $`\mathcal{B}`$ | index $`b`$ — `bp` — breakpoints of the cost curve | + +#### Parameters + +| Symbol | Meaning | +|---|---| +| $`\mathrm{capacity}`$ | `capacity` over $`\mathcal{G}`$ — maximum dispatch | +| $`\mathrm{load}`$ | `load` over $`\mathcal{T}`$ — demand to be met | +| $`\mathrm{x}`$ | `bp_x` over $`\mathcal{G} \times \mathcal{B}`$ — breakpoint dispatch levels, one curve per generator | +| $`\mathrm{y}`$ | `bp_y` over $`\mathcal{G} \times \mathcal{B}`$ — cost at each breakpoint, one curve per generator | + +#### Variables + +| Symbol | Meaning | +|---|---| +| $`\mathit{dispatch}`$ | `dispatch` over $`\mathcal{T} \times \mathcal{G}`$ — dispatched power | +| $`\mathit{op\_cost}`$ | `op_cost` over $`\mathcal{T} \times \mathcal{G}`$ — operating cost, piecewise-linear in dispatch | + +Upright is what the data supplies — a parameter such as $`\mathrm{capacity}`$, a coordinate map, a label — and italic is what the solver chooses, such as $`\mathit{dispatch}`$. An index is italic too, being what a quantifier chooses, and a set is script. + +#### Objective + +```math +\min \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} \mathit{op\_cost}_{t,g} +``` + +#### Subject to + +**`balance`** + +```math +\sum_{g \in \mathcal{G}} \mathit{dispatch}_{t,g} = \mathrm{load}_{t} \qquad \forall\, t \in \mathcal{T} +``` + +**`cost_curve`** + +```math +\left( \mathit{dispatch}_{t,g},\ \mathit{op\_cost}_{t,g} \right) \in \mathrm{pwl}_{b \in \mathcal{B}}(\mathrm{x}_{g,b},\ \mathrm{y}_{g,b}) \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +``` + +#### Variable domains + +**`dispatch`** + +```math +0 \le \mathit{dispatch}_{t,g} \le \mathrm{capacity}_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +``` + +**`op_cost`** + +```math +\mathit{op\_cost}_{t,g} \ge 0 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +``` + +#### Assumptions + +**`cost_curve_complete`** + +```math +\mathrm{x}_{g,b} \text{ is defined} \wedge \mathrm{y}_{g,b} \text{ is defined} \qquad \forall\, g \in \mathcal{G},\ b \in \mathcal{B} +``` + diff --git a/docs/examples/piecewise_lp.md b/docs/examples/piecewise_lp.md new file mode 100644 index 00000000..6139c1ed --- /dev/null +++ b/docs/examples/piecewise_lp.md @@ -0,0 +1,184 @@ + + +# A curve as segment lines + +The same curve again, stated as the lines its segments lie on rather than as +breakpoints to interpolate between. The `>=` on the second link says which side +of the lines the cost sits on. + +This is the one method that declares no auxiliary variable. No weights appear in +the math below, so nothing has to be restricted and the model stays a linear +program. `cost_curve_chord` is one inequality per segment, and the two domain +rows hold dispatch between the curve's ends. The form reads correctly only where +the curvature matches the sign. Lines that envelope a convex curve would cut a +concave one, and the solve comes back optimal either way. + + +```yaml +description: >- + The same least-cost dispatch as `piecewise.yaml`, with each generator's cost + curve stated as the lines its segments lie on rather than interpolated + between its breakpoints. The curve is convex and the objective pushes the + cost down, so a cost above every segment line settles on the curve — which + needs no interpolation weights, and so declares no auxiliary variable at all. + +dimensions: + snapshot: + description: dispatch periods + dtype: int + generator: + description: dispatchable units + dtype: str + bp: + description: breakpoints of the cost curve + dtype: int + +parameters: + capacity: + description: maximum dispatch + dims: [generator] + load: + description: demand to be met + dims: [snapshot] + bp_x: + description: breakpoint dispatch levels, one curve per generator + dims: [generator, bp] + bp_y: + description: cost at each breakpoint, one curve per generator + dims: [generator, bp] + +variables: + dispatch: + description: dispatched power + dims: [snapshot, generator] + bounds: + lower: 0 + upper: capacity + op_cost: + description: operating cost, held above every segment of the generator's curve + dims: [snapshot, generator] + bounds: + lower: 0 + +piecewise: + cost_curve: + description: >- + cost bounded below by the curve — the `>=` is what says which side of the + lines the cost sits on, and the curvature has to match it: lines that + envelope a convex curve would cut a concave one, and the solve comes back + optimal either way + along: bp + dims: [snapshot, generator] + links: + dispatch: [dispatch, bp_x] + op_cost: [op_cost, bp_y, ">="] + method: lp + +constraints: + balance: + dims: [snapshot] + expression: sum(dispatch, over=generator) == load + +objective: + sense: minimize + description: total operating cost, taken off the curves rather than from a marginal rate + expression: sum(op_cost) +``` + +The same least-cost dispatch as `piecewise.yaml`, with each generator's cost curve stated as the lines its segments lie on rather than interpolated between its breakpoints. The curve is convex and the objective pushes the cost down, so a cost above every segment line settles on the curve — which needs no interpolation weights, and so declares no auxiliary variable at all. + +#### Sets + +| Symbol | Meaning | +|---|---| +| $`\mathcal{T}`$ | index $`t`$ — `snapshot` — dispatch periods | +| $`\mathcal{G}`$ | index $`g`$ — `generator` — dispatchable units | +| $`\mathcal{B}`$ | index $`b`$ — `bp` — breakpoints of the cost curve | + +#### Parameters + +| Symbol | Meaning | +|---|---| +| $`\mathrm{capacity}`$ | `capacity` over $`\mathcal{G}`$ — maximum dispatch | +| $`\mathrm{load}`$ | `load` over $`\mathcal{T}`$ — demand to be met | +| $`\mathrm{x}`$ | `bp_x` over $`\mathcal{G} \times \mathcal{B}`$ — breakpoint dispatch levels, one curve per generator | +| $`\mathrm{y}`$ | `bp_y` over $`\mathcal{G} \times \mathcal{B}`$ — cost at each breakpoint, one curve per generator | + +#### Variables + +| Symbol | Meaning | +|---|---| +| $`\mathit{dispatch}`$ | `dispatch` over $`\mathcal{T} \times \mathcal{G}`$ — dispatched power | +| $`\mathit{op\_cost}`$ | `op_cost` over $`\mathcal{T} \times \mathcal{G}`$ — operating cost, held above every segment of the generator's curve | + +Upright is what the data supplies — a parameter such as $`\mathrm{capacity}`$, a coordinate map, a label — and italic is what the solver chooses, such as $`\mathit{dispatch}`$. An index is italic too, being what a quantifier chooses, and a set is script. + +$`t \boxminus_{v} k`$ denotes translation with $`v`$ standing where index $`t-k`$ leaves the dimension (`shift(edge=v)`), so the row at that boundary is built and carries $`v`$ rather than being dropped. + +$`\mathrm{pos}(t)`$ denotes where index $`t`$ sits along its dimension's own order — the order `shift` steps along, not the order labels sort in — counted from $`0`$. The index itself stays the coordinate, so $`t`$ compares against labels and $`\mathrm{pos}(t)`$ against positions. + +$`\lvert \mathcal{T} \rvert`$ denotes the size of the set being counted along, and a position counted from the end prints against it — $`\lvert \mathcal{T} \rvert - 1`$ is the last position, one less than the size because the first is $`0`$. + +#### Objective + +```math +\min \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} \mathit{op\_cost}_{t,g} +``` + +#### Subject to + +**`balance`** + +```math +\sum_{g \in \mathcal{G}} \mathit{dispatch}_{t,g} = \mathrm{load}_{t} \qquad \forall\, t \in \mathcal{T} +``` + +**`cost_curve`** + +```math +\mathit{op\_cost}_{t,g} \ge \mathrm{pwl}_{b \in \mathcal{B}}(\mathrm{x}_{g,b},\ \mathrm{y}_{g,b})(\mathit{dispatch}_{t,g}) \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +``` + +#### Variable domains + +**`dispatch`** + +```math +0 \le \mathit{dispatch}_{t,g} \le \mathrm{capacity}_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +``` + +**`op_cost`** + +```math +\mathit{op\_cost}_{t,g} \ge 0 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +``` + +#### Assumptions + +**`cost_curve_complete`** + +```math +\mathrm{x}_{g,b} \text{ is defined} \wedge \mathrm{y}_{g,b} \text{ is defined} \qquad \forall\, g \in \mathcal{G},\ b \in \mathcal{B} +``` + +**`cost_curve_increasing`** + +```math +\mathrm{x}_{g,b \boxminus_{0} 1} < \mathrm{x}_{g,b} \qquad \forall\, g \in \mathcal{G},\ b \in \mathcal{B} \,:\, \mathrm{pos}(b) > 0 +``` + +**`cost_curve_curvature`** + +```math +\left( \mathrm{y}_{g,b} - \mathrm{y}_{g,b \boxminus_{0} 1} \right) \cdot \left( \mathrm{x}_{g,b \boxplus_{0} 1} - \mathrm{x}_{g,b} \right) \le \left( \mathrm{y}_{g,b \boxplus_{0} 1} - \mathrm{y}_{g,b} \right) \cdot \left( \mathrm{x}_{g,b} - \mathrm{x}_{g,b \boxminus_{0} 1} \right) \qquad \forall\, g \in \mathcal{G},\ b \in \mathcal{B} \,:\, \mathrm{pos}(b) > 0 \wedge \mathrm{pos}(b) \neq \lvert \mathcal{B} \rvert - 1 +``` + +**`cost_curve_breakpoints`** + +```math +\lvert \{ b \in \mathcal{B} \,:\, \mathrm{x}_{g,b} \text{ is defined} \} \rvert \ge 2 \qquad \forall\, g \in \mathcal{G} +``` + diff --git a/docs/examples/sos.md b/docs/examples/sos.md new file mode 100644 index 00000000..e3ae06eb --- /dev/null +++ b/docs/examples/sos.md @@ -0,0 +1,155 @@ + + +# A curve as a special-ordered set + +The same restriction as [the adjacency page](piecewise_adjacency.md), handed to +the solver instead of built. `method: sos2` says that at most two weights may +be non-zero and they must be neighbours, which is the definition of a type-2 +set. + +Read the two pages together. The binaries are gone here, and so are the two +rows that constrained them. What replaces them is a declaration rather than a +row, because a special-ordered set is something a solver enforces directly. +Whether a given solver does is that solver's business, not the file's. + + +```yaml +description: >- + A piecewise-linear cost curve stated as a special-ordered set, so the solver + is handed the adjacency restriction rather than binaries that encode it. + +dimensions: + snapshot: + description: dispatch periods + dtype: int + generator: + description: dispatchable units + dtype: str + bp: + description: breakpoints of the cost curve + dtype: int + +parameters: + capacity: + description: maximum dispatch + dims: [generator] + load: + description: demand to be met + dims: [snapshot] + bp_x: + description: breakpoint dispatch levels, one curve per generator + dims: [generator, bp] + bp_y: + description: cost at each breakpoint, one curve per generator + dims: [generator, bp] + +variables: + dispatch: + description: dispatched power + dims: [snapshot, generator] + bounds: + lower: 0 + upper: capacity + op_cost: + description: operating cost, piecewise-linear in dispatch + dims: [snapshot, generator] + bounds: + lower: 0 + +piecewise: + cost_curve: + description: >- + cost read off the generator's curve, with at most two adjacent weights + non-zero — the restriction the default method builds out of binaries, + declared as a set instead + along: bp + dims: [snapshot, generator] + links: + dispatch: [dispatch, bp_x] + op_cost: [op_cost, bp_y] + method: sos2 + +constraints: + balance: + dims: [snapshot] + expression: sum(dispatch, over=generator) == load + +objective: + sense: minimize + description: total operating cost, taken off the curves rather than from a marginal rate + expression: sum(op_cost) +``` + +A piecewise-linear cost curve stated as a special-ordered set, so the solver is handed the adjacency restriction rather than binaries that encode it. + +#### Sets + +| Symbol | Meaning | +|---|---| +| $`\mathcal{T}`$ | index $`t`$ — `snapshot` — dispatch periods | +| $`\mathcal{G}`$ | index $`g`$ — `generator` — dispatchable units | +| $`\mathcal{B}`$ | index $`b`$ — `bp` — breakpoints of the cost curve | + +#### Parameters + +| Symbol | Meaning | +|---|---| +| $`\mathrm{capacity}`$ | `capacity` over $`\mathcal{G}`$ — maximum dispatch | +| $`\mathrm{load}`$ | `load` over $`\mathcal{T}`$ — demand to be met | +| $`\mathrm{x}`$ | `bp_x` over $`\mathcal{G} \times \mathcal{B}`$ — breakpoint dispatch levels, one curve per generator | +| $`\mathrm{y}`$ | `bp_y` over $`\mathcal{G} \times \mathcal{B}`$ — cost at each breakpoint, one curve per generator | + +#### Variables + +| Symbol | Meaning | +|---|---| +| $`\mathit{dispatch}`$ | `dispatch` over $`\mathcal{T} \times \mathcal{G}`$ — dispatched power | +| $`\mathit{op\_cost}`$ | `op_cost` over $`\mathcal{T} \times \mathcal{G}`$ — operating cost, piecewise-linear in dispatch | + +Upright is what the data supplies — a parameter such as $`\mathrm{capacity}`$, a coordinate map, a label — and italic is what the solver chooses, such as $`\mathit{dispatch}`$. An index is italic too, being what a quantifier chooses, and a set is script. + +#### Objective + +```math +\min \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} \mathit{op\_cost}_{t,g} +``` + +#### Subject to + +**`balance`** + +```math +\sum_{g \in \mathcal{G}} \mathit{dispatch}_{t,g} = \mathrm{load}_{t} \qquad \forall\, t \in \mathcal{T} +``` + +**`cost_curve`** + +```math +\left( \mathit{dispatch}_{t,g},\ \mathit{op\_cost}_{t,g} \right) \in \mathrm{pwl}_{b \in \mathcal{B}}(\mathrm{x}_{g,b},\ \mathrm{y}_{g,b}) \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +``` + +#### Variable domains + +**`dispatch`** + +```math +0 \le \mathit{dispatch}_{t,g} \le \mathrm{capacity}_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +``` + +**`op_cost`** + +```math +\mathit{op\_cost}_{t,g} \ge 0 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +``` + +#### Assumptions + +**`cost_curve_complete`** + +```math +\mathrm{x}_{g,b} \text{ is defined} \wedge \mathrm{y}_{g,b} \text{ is defined} \qquad \forall\, g \in \mathcal{G},\ b \in \mathcal{B} +``` + diff --git a/docs/howto/curve-by-hand.md b/docs/howto/curve-by-hand.md deleted file mode 100644 index d55da39c..00000000 --- a/docs/howto/curve-by-hand.md +++ /dev/null @@ -1,45 +0,0 @@ - - -# Write a piecewise curve out by hand - -Tie a number of flows to one curve where that number is data: a boiler ties two -flows and a CHP unit ties three, in one spec. A -[`piecewise:`](../reference/language/piecewise.md) block lists its links in the -file, so it cannot say this. The formulation written out can. - -1. **Declare the weights as a variable over the breakpoint dimension**, masked - to how far each curve runs: - - ```yaml - variables: - weight: # the convex combination, one per converter and period - dims: [converter, time, bp] - where: bp_present # how far each curve runs - bounds: { lower: 0, upper: 1 } - ``` - -2. **Restrict the weights with an `sos:` block.** `type: 2` states the - restriction that `method: sos2` emits: - - ```yaml - sos: - on_one_segment: { variable: weight, along: bp, type: 2 } - ``` - -3. **Write the convexity row, and one row per flow.** The row per flow is where - the count goes, and a relation carries it: - - ```yaml - constraints: - one_operating_point: - dims: [converter, time] - expression: sum(weight, over=bp) == 1 - on_the_curve: # one row per flow - dims: [flow, time] - expression: rate == sum(at(weight, by=converter_of, over=converter, into=flow) * bp_rate, over=bp) - ``` - -A converter with a fourth flow is then a row in a table. diff --git a/docs/howto/see-an-expansion.md b/docs/howto/see-an-expansion.md index e92988ef..c40833bf 100644 --- a/docs/howto/see-an-expansion.md +++ b/docs/howto/see-an-expansion.md @@ -164,11 +164,12 @@ the set out too. piecewise: curve: - over: bp + along: bp + dims: [] method: sos2 links: - - [x, x_bp] - - [y, y_bp] + x: [x, x_bp] + y: [y, y_bp] ``` === "Math" @@ -227,10 +228,10 @@ the set out too. curve_convexity: dims: [] expression: sum(curve_lam, over=bp) == 1 - curve_link0: + curve_x: dims: [] expression: (x) == sum(curve_lam * x_bp, over=bp) - curve_link1: + curve_y: dims: [] expression: (y) == sum(curve_lam * y_bp, over=bp) @@ -247,7 +248,7 @@ the set out too. piecewise 'curve': every breakpoint the curve runs through needs a row in 'x_bp', 'y_bp' — a missing row is read as a zero rather than as a shorter curve, so it sits the curve on the origin. Attach the rows, or declare - points: to say how far the curve runs. + where: to say how far the curve runs. ``` === "Math" @@ -260,13 +261,13 @@ the set out too. \sum_{b \in \mathcal{B}} \mathit{curve\_lam}_{b} = 1 ``` - **`curve_link0`** + **`curve_x`** ```math x = \sum_{b \in \mathcal{B}} \mathit{curve\_lam}_{b} \cdot \mathrm{x}^{\mathrm{bp}}_{b} ``` - **`curve_link1`** + **`curve_y`** ```math y = \sum_{b \in \mathcal{B}} \mathit{curve\_lam}_{b} \cdot \mathrm{y}^{\mathrm{bp}}_{b} @@ -334,10 +335,10 @@ the set out too. curve_convexity: dims: [] expression: sum(curve_lam, over=bp) == 1 - curve_link0: + curve_x: dims: [] expression: (x) == sum(curve_lam * x_bp, over=bp) - curve_link1: + curve_y: dims: [] expression: (y) == sum(curve_lam * y_bp, over=bp) curve_pick: @@ -354,7 +355,7 @@ the set out too. piecewise 'curve': every breakpoint the curve runs through needs a row in 'x_bp', 'y_bp' — a missing row is read as a zero rather than as a shorter curve, so it sits the curve on the origin. Attach the rows, or declare - points: to say how far the curve runs. + where: to say how far the curve runs. ``` === "Math" @@ -367,13 +368,13 @@ the set out too. \sum_{b \in \mathcal{B}} \mathit{curve\_lam}_{b} = 1 ``` - **`curve_link0`** + **`curve_x`** ```math x = \sum_{b \in \mathcal{B}} \mathit{curve\_lam}_{b} \cdot \mathrm{x}^{\mathrm{bp}}_{b} ``` - **`curve_link1`** + **`curve_y`** ```math y = \sum_{b \in \mathcal{B}} \mathit{curve\_lam}_{b} \cdot \mathrm{y}^{\mathrm{bp}}_{b} diff --git a/docs/reference/language/assumptions.md b/docs/reference/language/assumptions.md index d8d1edb9..2c132a83 100644 --- a/docs/reference/language/assumptions.md +++ b/docs/reference/language/assumptions.md @@ -110,16 +110,18 @@ this section. ## What a curve assumes A [`piecewise:`](piecewise.md) block `curve` adds its own assumptions, derived -from its `method:`, its `points:` and the sign on its links. They print under -the same _Assumptions_ heading as the written ones. - -| Entry | Added for | Holds | -| ------------------- | ----------------- | ----------------------------------------------------------------------------------------------------- | -| `curve_complete` | every block | every values parameter has a row at every breakpoint the curve runs through | -| `curve_increasing` | `convex`, `lp` | the pinned link's breakpoints (the first link's, when both are pinned) strictly increase along `over` | -| `curve_curvature` | `convex`, `lp` | with a `>=` link the curve is convex, with `<=` concave; with both links pinned it bends one way only | -| `curve_breakpoints` | `lp` | each curve has at least two breakpoints | -| `curve_contiguous` | a block `points:` | the marked breakpoints are one consecutive run of at least one | +from its `method:`, its `where:` and the sign on its links. Each is asked only +where the block's `where:` says a curve runs. They print under the same +_Assumptions_ heading as the written ones. + +| Entry | Added for | Holds | +| ----------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------ | +| `curve_complete` | every block | every values parameter has a row at every breakpoint the curve runs through | +| `curve__complete` | a link that walks a relation | the link's values parameter has a row at every breakpoint, at every row the link reads the curve at | +| `curve_increasing` | `convex`, `lp` | the pinned link's breakpoints (the first link's, when both are pinned) strictly increase along `along` | +| `curve_curvature` | `convex`, `lp` | with a `>=` link the curve is convex, with `<=` concave; with both links pinned it bends one way only | +| `curve_breakpoints` | `lp` | each curve has at least two breakpoints | +| `curve_contiguous` | a `where:` that reads `along` | the marked breakpoints are one consecutive run of at least one | [Reading a spec and its program](../reading.md#what-the-data-has-to-satisfy) says how a consumer runs them. diff --git a/docs/reference/language/piecewise.md b/docs/reference/language/piecewise.md index b48f1771..bf7f6313 100644 --- a/docs/reference/language/piecewise.md +++ b/docs/reference/language/piecewise.md @@ -12,48 +12,143 @@ variables of which only one, or only two neighbours, may be non-zero. Both are ## `piecewise` -A `piecewise` block ties two or more expressions to one piecewise-linear curve. -The curve is given as breakpoints: the corner values each expression takes -together. +A `piecewise` block ties expressions to one piecewise-linear curve for every +coordinate of its `dims:`. The curve is given as breakpoints: the corner values +each expression takes together. ```yaml piecewise: chp: - over: bp # breakpoint dimension - links: - - [power, power_bp] # [expression, values-parameter] - - [fuel, fuel_bp] - - [heat, heat_bp] + along: bp # the dimension each curve runs along + dims: [generator, snapshot] # one curve per coordinate of these + links: # each link by the name of the row it writes, chp_ + power: [power, power_bp] # [expression, values-parameter] + fuel: [fuel, fuel_bp] + heat: [heat, heat_bp] method: adjacency # how the weights are restricted — below activity: null # optional: a binary variable that the weights sum to - # a two-link block may bound one side instead of pinning it + # a link may be bounded by the curve instead of pinned to it fuel_cap: - over: bp + along: bp + dims: [generator, snapshot] links: - - [power, power_bp] - - [fuel, fuel_bp, "<="] + power: [power, power_bp] + fuel: [fuel, fuel_bp, "<="] ``` -| Part of a link | | -| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| _expression_ | Any affine expression. The simplest is a bare variable name | -| _values_ | A parameter that carries the `over` dimension, plus any dimensions the link expressions carry. A dimension the links do not carry is refused | -| _sign_ | `<=` or `>=`. At most one per block, and only in a block with exactly two links. It bounds the link instead of pinning it | +| Part of a link | | +| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| _name_ | The key. The link's row in the expansion is `_` | +| _expression_ | Any affine expression over the link's row. The simplest is a bare variable name | +| _values_ | A parameter that carries the `along` dimension. Every other dimension it carries is one the link's row carries | +| _sign_ | `<=` or `>=`. It bounds the link by the curve instead of pinning it to it. Any number of links may carry one, as long as at least one link does not ([below](#signs)) | +| _by_, _over_, _into_ | A relation walk from the curve's `dims:` to the link's row ([below](#a-link-that-walks-a-relation)) | | Key | | | | ---------- | ---------------------------------------------------------------------------------------- | ------------------- | -| `over` | required. The breakpoint dimension | | -| `links` | required. Two or more links | | +| `along` | required. The dimension each curve runs along | | +| `dims` | required. The dimensions the block builds one curve per coordinate of ([below](#dims)) | | +| `links` | required. Two or more links, or one that walks a relation | | +| `where` | which coordinates have a curve, and how far each runs ([below](#where)) | default `null` | | `method` | `adjacency`, `sos2`, `convex` or `lp`: how the weights are restricted ([below](#method)) | default `adjacency` | | `activity` | a binary variable that gates the curve ([below](#activity)) | default `null` | -| `points` | how far each curve runs, where the curves are not all the same length ([below](#points)) | default `null` | A block states one weight per breakpoint in `[0, 1]`, a row making the weights sum to 1, and a row per link tying its expression to the weighted breakpoints. -The breakpoint order is the declared order of `over`. What a block assumes of +The breakpoint order is the declared order of `along`. What a block assumes of its numbers is on [what a curve assumes](assumptions.md#what-a-curve-assumes). +A link names the row it writes, so a link may not take a name the block +already writes for itself, such as `convexity` or `lam`. No two blocks may write +the same name: in a file with blocks `a` and `a_b`, a link `b_x` of `a` is +refused, because its row `a_b_x` is also the row of the link `x` of `a_b`. + +### `dims` + +A block builds one curve for every coordinate of `dims:`. Each curve is one set +of weights. `dims:` may not carry the breakpoint dimension, because every curve +runs along it. + +**A link expression carries exactly the dimensions of its row.** The row of a +link is `dims:`, or the dimensions a [walk](#a-link-that-walks-a-relation) +reaches. A dimension the expression carries and the row does not multiplies the +rows the link builds. A dimension the row carries and the expression does not +repeats one row across it, which pins the expression to a single operating point +along a dimension the curve varies over. Both are refused, and the message +names which one it is. + +A quantity that varies along a dimension the curve does not, such as a rate per +period read off a curve that has none, is said by adding that dimension to +`dims:`. The curve then varies along it too. Whether the breakpoint values also +vary along it is the data's business: values that do not carry it give one curve +shape and a per-period operating point. + +An [`activity:`](#activity) gate carries no dimension that `dims:` does not. A +gate over fewer dimensions switches every curve it covers: a gate per generator +switches that generator's curve in every snapshot. + +### `where` + +`where:` says which coordinates of `dims:` have a curve: + +```yaml +piecewise: + cost_curve: + along: bp + dims: [generator] + where: has_curve # only some generators run on a cost curve + links: + dispatch: [dispatch, bp_x] + op_cost: [op_cost, bp_y] +``` + +Off the mask the block builds nothing. There are no weights, no convexity row +and no link row, so the linked expressions are left free. The breakpoint values +are not read there either: a generator with no curve needs no row in `bp_x` or +`bp_y`. + +`where:` is not [`activity:`](#activity). A coordinate outside the mask has no +curve. A gated coordinate has a curve that the solver may switch off, and its +rows are built either way. + +A mask carrying a dimension that `dims:` does not carry is refused, because a +mask cannot add coordinates. The breakpoint dimension is the one exception, and +reading it is how a block says how far each curve runs. + +#### Curves of unequal length + +A values parameter short of a row does not build a shorter curve: the missing +row reads as a breakpoint at the origin. A curve with fewer breakpoints than the +dimension holds says so with a `where:` that reads the breakpoint dimension. Name one of the block's own values +parameters, and the curve is as long as that parameter has rows: + +```yaml +piecewise: + cost_curve: + along: bp + dims: [generator] + where: bp_x # this curve runs as far as its own breakpoints do + links: + p: [p, bp_x] + op_cost: [op_cost, bp_y] +``` + +The other links are still read against the parameter you named, so a row missing +from `bp_y` is refused. Where the length is its own data, name a boolean +parameter over `dims:` and the breakpoint dimension instead. Either composes +with a mask over `dims:`: `has_curve AND bp_x` says which generators have a +curve and how far each one runs. + +The marked breakpoints must be consecutive. They need not start at the head of +the axis. A gap is refused when the data is attached, and a coordinate the mask +leaves with no breakpoint has no curve. + +The rows a block writes over `dims:` alone, such as the one making the weights +sum to 1, cannot read the breakpoint dimension. There the mask reads as +`count(where, over=bp) > 0`: a curve exists where it admits at least one +breakpoint. + ### `activity` `activity:` names a binary variable, and the weights then sum to that variable @@ -69,29 +164,123 @@ variables: where: committable # only some units have a commitment decision ``` -Where the gate does not exist, the curve is ungated. To have no curve there -instead, put `absence: zero` on the gate. +Where the gate does not exist, the curve is ungated. To pin the curve off +there instead, put `absence: zero` on the gate. To build no curve there at all, +use [`where:`](#where). -### `points` +### A link that walks a relation -A values parameter short of a row does not build a shorter curve: the missing -row reads as a breakpoint at the origin. A curve with fewer breakpoints than the -dimension holds says so with `points:`. Name one of the block's own values -parameters, and the curve is as long as that parameter has rows: +A link that names `by:`, `over:` and `into:` reads the curve's weights through a +[relation](relations.md#how-a-relation-is-used), as [`at`](operators.md#at) +does. It builds one row per coordinate that the walk reaches, and every row +reads the curve of the coordinate it maps back to. So the number of **rows** a +link builds is data. A converter with two flows and a converter with five share +one block: ```yaml +relations: + generator_of: { key: flow, values: generator } + piecewise: - cost_curve: - over: bp - points: bp_x # this curve runs as far as its own breakpoints do + coupling: + along: bp + dims: [generator, snapshot] # one curve per generator + links: + power: { expression: power, values: bp_power, by: generator_of, over: generator, into: flow } + fuel: [fuel, bp_fuel] +``` + +`power` is per flow and the curve is per generator, so the `power` link builds +one row for each flow of a generator. A sixth flow is a row in `generator_of`, +not an edit to the spec. + +The row of a walked link is `dims:` with the dimension that `over:` consumes +replaced by the one that `into:` produces: `[flow, snapshot]` above. The block +writes `at(coupling_lam, by=generator_of, over=generator, into=flow)` into that +row, so the weights stay on `dims:` and the spec never names them. + +`by:`, `over:` and `into:` are written together. A walk states the relation, the +columns it consumes and the columns it produces, and none is defaulted. Each of +`over:` and `into:` names at least one column. A link whose row is finer than +`dims:` is always a walk: a link that names only `into:` is refused. + +A walk is held to every rule of `at`, as the spec loads, and a refusal names +the link. `into:` names key columns of the relation, and the read has one value +at each coordinate it lands on. A key column that the walk does not name is +joined on, so its dimension is one of `dims:`. + +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(, 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 that carries no dimension the walk consumes, such as +`snapshot` alone, reaches the row as written. This is also true when the +relation is keyed on `snapshot` too, because the row keeps every dimension the +walk joins on. A mask that carries a dimension the walk consumes and not every +dimension the walk joins on is refused, and the message names the ones missing. +A mask over a dimension a walk produces, such as `flow`, is refused: the mask +says which curves exist, and there is one curve per coordinate of `dims:`. + +| A walked link | | +| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| _over_ | names a column over a dimension of `dims:` | +| _into_ | names key columns over dimensions that `dims:` does not carry, and that are not `along` | +| _by_ | a relation whose other key columns are over dimensions of `dims:` | +| _values_ | follows the **link's** row: `bp_power` is per flow, not per generator | +| `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 consumes | +| `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 + +A link with no sign is **pinned** to the curve: its expression equals the +weighted breakpoints. A link carrying `<=` or `>=` is **bounded** by the curve +instead, and each link carries its own. + +**At least one link is pinned.** A pinned link fixes the operating point every +other link is read at. With every link bounded the weights are free, and the +block no longer says that its quantities sit together on a curve. It says only +that some point on the curve satisfies the bounds. That is a different model, +so it is refused. + +```yaml +piecewise: + chp: + along: bp + dims: [generator, snapshot] links: - - [p, bp_x] - - [op_cost, bp_y] + power: [power, power_bp] # pinned: it fixes the operating point + fuel: [fuel, fuel_bp, ">="] # bounded below by the curve + heat: [heat, heat_bp, "<="] # bounded above, at that same point ``` -A row missing from `bp_y` is still refused. Where the length is its own data, -name a boolean parameter instead. The marked breakpoints are one consecutive -run, anywhere on the axis. +The typeset line prints this block as the point `(power, fuel, heat)` on the +curve plus `{0} × ℝ≥0 × ℝ≤0`: the pinned coordinate moves by nothing, and each +bounded one by the half-line its sign allows. A block with exactly two links +prints its bounded link as a function of the pinned one instead. + +`convex` and `lp` take exactly two links, so there a sign is one link's at +most. Under `adjacency` and `sos2` each link is its own row against the shared +weights, so the count is whatever the spec needs. ### `method` @@ -107,21 +296,21 @@ run, anywhere on the axis. `convex` takes exactly two links and no `activity:`. The shape it needs is an [assumption](assumptions.md#what-a-curve-assumes). -`lp` states the curve as its segment lines. It needs **exactly two links**, one -of them bounded with `<=` or `>=`, and no `activity:`: +`lp` states the curve as its segment lines. It takes **exactly two links**, +because a line is one quantity against another: one link names the abscissa and +one is bounded by the lines. It takes no `activity:`: ```yaml piecewise: cost_curve: - over: bp + along: bp + dims: [generator] method: lp links: - - [p, bp_x] - - [op_cost, bp_y, ">="] # cost bounded below by the curve + p: [p, bp_x] + op_cost: [op_cost, bp_y, ">="] # cost bounded below by the curve ``` -Where the number of links is data, write the formulation out ([a curve by hand](../../howto/curve-by-hand.md)). - ## `sos` An `sos` block declares a **special-ordered set**: one dimension of one diff --git a/docs/reference/notation.md b/docs/reference/notation.md index 231f3664..fec6347a 100644 --- a/docs/reference/notation.md +++ b/docs/reference/notation.md @@ -1002,10 +1002,11 @@ names: ```yaml piecewise: economies_of_scale: - over: bp + along: bp + dims: [plant, market] links: - - [shipment, bp_x] - - [scaled, bp_y] + shipment: [shipment, bp_x] + scaled: [scaled, bp_y] ``` ```math @@ -1064,10 +1065,11 @@ names: ```yaml piecewise: cost_curve: - over: bp + along: bp + dims: [snapshot, generator] links: - - [dispatch, bp_x] - - [op_cost, bp_y] + dispatch: [dispatch, bp_x] + op_cost: [op_cost, bp_y] method: sos2 ``` @@ -1119,10 +1121,11 @@ names: ```yaml piecewise: cost_curve: - over: bp + along: bp + dims: [snapshot, generator] links: - - [dispatch, bp_x] - - [op_cost, bp_y] + dispatch: [dispatch, bp_x] + op_cost: [op_cost, bp_y] method: convex ``` @@ -1177,10 +1180,11 @@ names: ```yaml piecewise: cost_curve: - over: bp + along: bp + dims: [snapshot, generator] links: - - [dispatch, bp_x] - - [op_cost, bp_y, ">="] + dispatch: [dispatch, bp_x] + op_cost: [op_cost, bp_y, ">="] method: lp ``` diff --git a/docs/reference/reading.md b/docs/reference/reading.md index c1043645..04105b9f 100644 --- a/docs/reference/reading.md +++ b/docs/reference/reading.md @@ -50,10 +50,11 @@ variables: bounds: { lower: 0 } piecewise: curve: - over: bp + along: bp + dims: [generator] links: - - [p, bp_x] - - [cost, bp_y, ">="] + p: [p, bp_x] + cost: [cost, bp_y, ">="] method: convex assumptions: cost_is_never_negative: @@ -77,7 +78,7 @@ sorted(program.constraints) # ['target'] sorted(program.piecewise) # ['curve'] rows = spec.expand('piecewise').program -sorted(rows.constraints) # ['curve_convexity', 'curve_link0', 'curve_link1', 'target'] +sorted(rows.constraints) # ['curve_convexity', 'curve_cost', 'curve_p', 'target'] sorted(rows.variables) # ['cost', 'curve_lam', 'p'] ``` diff --git a/examples/piecewise.yaml b/examples/piecewise.yaml index c50ccd34..00a4f7f3 100644 --- a/examples/piecewise.yaml +++ b/examples/piecewise.yaml @@ -49,10 +49,11 @@ piecewise: description: >- cost read off the generator's curve — convex, so the weights need no binaries to keep them on one segment - over: bp + along: bp + dims: [snapshot, generator] links: - - [dispatch, bp_x] - - [op_cost, bp_y] + dispatch: [dispatch, bp_x] + op_cost: [op_cost, bp_y] method: convex constraints: diff --git a/examples/piecewise_adjacency.yaml b/examples/piecewise_adjacency.yaml new file mode 100644 index 00000000..9675e38d --- /dev/null +++ b/examples/piecewise_adjacency.yaml @@ -0,0 +1,69 @@ +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +description: >- + The same least-cost dispatch as `piecewise.yaml`, with a cost curve that is + not convex. The weights need binaries to hold them on one segment, which is + what the default method builds. + +dimensions: + snapshot: + description: dispatch periods + dtype: int + generator: + description: dispatchable units + dtype: str + bp: + description: breakpoints of the cost curve + dtype: int + +parameters: + capacity: + description: maximum dispatch + dims: [generator] + load: + description: demand to be met + dims: [snapshot] + bp_x: + description: breakpoint dispatch levels, one curve per generator + dims: [generator, bp] + bp_y: + description: cost at each breakpoint, one curve per generator + dims: [generator, bp] + +variables: + dispatch: + description: dispatched power + dims: [snapshot, generator] + bounds: + lower: 0 + upper: capacity + op_cost: + description: operating cost, piecewise-linear in dispatch + dims: [snapshot, generator] + bounds: + lower: 0 + +piecewise: + cost_curve: + description: >- + cost read off the generator's curve. The curve bends both ways, so + nothing but the restriction keeps the weights on one segment: a binary + per segment picks the one they may sit on + along: bp + dims: [snapshot, generator] + links: + dispatch: [dispatch, bp_x] + op_cost: [op_cost, bp_y] + method: adjacency + +constraints: + balance: + dims: [snapshot] + expression: sum(dispatch, over=generator) == load + +objective: + sense: minimize + description: total operating cost, taken off the curves rather than from a marginal rate + expression: sum(op_cost) diff --git a/examples/piecewise_coupling.yaml b/examples/piecewise_coupling.yaml new file mode 100644 index 00000000..d0cfd05b --- /dev/null +++ b/examples/piecewise_coupling.yaml @@ -0,0 +1,109 @@ +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +description: >- + A heat and power system whose converters mix. A CHP unit runs on one + piecewise curve that ties all of its flows, and a boiler turns gas into heat + at a fixed ratio. The curve reads `has_curve` through `converter_of`, so a + boiler flow has no row on it, and the fixed-ratio row reads the same mask + negated. How many flows a converter ties is a row in `converter_of`, not a + line here. + +dimensions: + snapshot: + description: dispatch periods + dtype: int + converter: + description: units that turn one carrier into others + dtype: str + flow: + description: one carrier entering or leaving one converter + dtype: str + carrier: + description: what a flow carries — gas, heat, electricity + dtype: str + bp: + description: breakpoints of the operating curve + dtype: int + +relations: + converter_of: + description: which converter each flow belongs to + key: flow + values: converter + carrier_of: + description: what each flow carries + key: flow + values: carrier + +parameters: + demand: + description: what each carrier has to deliver in each period + dims: [carrier, snapshot] + price: + description: what a unit bought on the market costs + dims: [carrier] + has_curve: + description: whether a converter runs on an operating curve rather than at a fixed ratio + dims: [converter] + dtype: bool + bp_rate: + description: >- + the rate of each flow at each corner of its converter's curve — the + table that says a CHP's fuel, heat and power move together + dims: [flow, bp] + ratio: + description: the rate of each flow of a fixed-ratio converter per unit of the level it runs at + dims: [flow] + +variables: + rate: + description: >- + how much of each flow runs in each period, positive where the flow leaves + its converter and negative where it enters + dims: [flow, snapshot] + level: + description: how hard a converter with no curve runs + dims: [converter, snapshot] + where: NOT has_curve + bounds: + lower: 0 + bought: + description: what the market supplies where the converters do not + dims: [carrier, snapshot] + bounds: + lower: 0 + +piecewise: + operating_point: + description: >- + every flow of a converter with a curve is read off that curve at one + operating point, so the converter's flows move together + along: bp + dims: [converter, snapshot] + where: has_curve + links: + rate: + expression: rate + values: bp_rate + by: converter_of + over: converter + into: flow + method: sos2 + +constraints: + fixed_ratio: + description: every flow of a converter with no curve runs in proportion to that converter's level + dims: [flow, snapshot] + where: NOT at(has_curve, by=converter_of, over=converter, into=flow) + expression: rate == ratio * at(level, by=converter_of, over=converter, into=flow) + balance: + description: every carrier is delivered by the converters, or bought + dims: [carrier, snapshot] + expression: sum(rate, by=carrier_of, over=flow, into=carrier) + bought == demand + +objective: + sense: minimize + description: what the market supplies, priced by carrier + expression: sum(bought * price) diff --git a/examples/piecewise_lp.yaml b/examples/piecewise_lp.yaml index 817c9765..b85dc286 100644 --- a/examples/piecewise_lp.yaml +++ b/examples/piecewise_lp.yaml @@ -54,10 +54,11 @@ piecewise: lines the cost sits on, and the curvature has to match it: lines that envelope a convex curve would cut a concave one, and the solve comes back optimal either way - over: bp + along: bp + dims: [snapshot, generator] links: - - [dispatch, bp_x] - - [op_cost, bp_y, ">="] + dispatch: [dispatch, bp_x] + op_cost: [op_cost, bp_y, ">="] method: lp constraints: diff --git a/examples/piecewise_ragged.yaml b/examples/piecewise_ragged.yaml index 582a28c2..1bec931a 100644 --- a/examples/piecewise_ragged.yaml +++ b/examples/piecewise_ragged.yaml @@ -5,10 +5,10 @@ description: >- The same least-cost dispatch as `piecewise_lp.yaml`, with curves of different lengths. A large unit is metered at every breakpoint and a small one at the - first few, so the breakpoint table is ragged. `points:` is what says how far - each curve runs: without it every curve claims the whole axis, and a - breakpoint with no row is read as a zero rather than as a shorter curve — - which sits the curve on the origin instead of ending it. + first few, so the breakpoint table is ragged. A `where:` reading `bp` is what + says how far each curve runs: without it every curve claims the whole axis, + and a breakpoint with no row is read as a zero rather than as a shorter curve + — which sits the curve on the origin instead of ending it. dimensions: snapshot: @@ -61,11 +61,12 @@ piecewise: mask narrows every condition the method states: the breakpoints are counted over it, and the increasing and curvature tests are checked between admitted neighbours rather than along the whole axis. - over: bp - points: metered + along: bp + dims: [snapshot, generator] + where: metered links: - - [dispatch, bp_x] - - [op_cost, bp_y, ">="] + dispatch: [dispatch, bp_x] + op_cost: [op_cost, bp_y, ">="] method: lp constraints: diff --git a/examples/ports/transport_pwl.yaml b/examples/ports/transport_pwl.yaml index 678aa6fc..9b6c20f7 100644 --- a/examples/ports/transport_pwl.yaml +++ b/examples/ports/transport_pwl.yaml @@ -63,10 +63,11 @@ piecewise: chord underneath the true curve and buy transport cheaper than the model allows. The binaries are what make the answer right — and what make this port a MILP. - over: bp + along: bp + dims: [plant, market] links: - - [shipment, bp_x] - - [scaled, bp_y] + shipment: [shipment, bp_x] + scaled: [scaled, bp_y] constraints: within_capacity: diff --git a/examples/sos.yaml b/examples/sos.yaml index 527b2299..0f349ca2 100644 --- a/examples/sos.yaml +++ b/examples/sos.yaml @@ -50,10 +50,11 @@ piecewise: cost read off the generator's curve, with at most two adjacent weights non-zero — the restriction the default method builds out of binaries, declared as a set instead - over: bp + along: bp + dims: [snapshot, generator] links: - - [dispatch, bp_x] - - [op_cost, bp_y] + dispatch: [dispatch, bp_x] + op_cost: [op_cost, bp_y] method: sos2 constraints: diff --git a/examples/symbols/piecewise_adjacency.yaml b/examples/symbols/piecewise_adjacency.yaml new file mode 100644 index 00000000..1aaecf4e --- /dev/null +++ b/examples/symbols/piecewise_adjacency.yaml @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +# The sidecar symbol table for `examples/piecewise_adjacency.yaml`, the same +# one `piecewise.yaml` carries plus the segment binary this method adds. Papers +# write the convex-combination weight as lambda and the binary that admits a +# segment as z, and the adjacency row names both in one line. +notation: latex + +names: + cost_curve_lam: "\\lambda" + cost_curve_seg: "z" + bp_x: "\\mathrm{x}" + bp_y: "\\mathrm{y}" diff --git a/examples/symbols/piecewise_coupling.yaml b/examples/symbols/piecewise_coupling.yaml new file mode 100644 index 00000000..3102b170 --- /dev/null +++ b/examples/symbols/piecewise_coupling.yaml @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +# The sidecar symbol table for `examples/piecewise_coupling.yaml`. The weights +# are named after the block that declared them, which reads well in the file +# and badly in an equation that carries the name twice with a relation applied +# to its first index. Papers write the convex-combination weight as lambda. + +notation: latex + +names: + operating_point_lam: "\\lambda" + bp_rate: "\\mathrm{r}" diff --git a/mkdocs.yml b/mkdocs.yml index 253a94a4..8228474a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -41,7 +41,6 @@ nav: - State a rule that differs by regime: howto/regimes.md - Declare a column of data: howto/declare-a-column.md - Fix a quantity that is data in one model and a decision in another: howto/pin-a-variable.md - - Write a piecewise curve out by hand: howto/curve-by-hand.md - See what a curve or a set expands to: howto/see-an-expansion.md - Compare two specs: howto/compare.md - Compose a spec from several files: howto/compose.md @@ -67,6 +66,10 @@ nav: - Least-cost dispatch: examples/dispatch.md - Unit commitment: examples/commitment.md - One construct per spec: examples/operators.md + - A curve by convex combination: examples/piecewise.md + - A curve that is not convex: examples/piecewise_adjacency.md + - A curve as a special-ordered set: examples/sos.md + - A curve as segment lines: examples/piecewise_lp.md - A component library: - examples/library/index.md - The coupling surface: examples/library/surface.md diff --git a/schema/mathspec.schema.json b/schema/mathspec.schema.json index 720bbbad..4c2857d6 100644 --- a/schema/mathspec.schema.json +++ b/schema/mathspec.schema.json @@ -590,7 +590,7 @@ }, "PiecewiseBlock": { "additionalProperties": false, - "description": "N expressions jointly pinned to a breakpoint-indexed piecewise curve.\n\nMirrors ``linopy.Spec.add_piecewise_formulation``. Each link is\n``[expression, values_parameter]`` or ``[expression, values_parameter,\nsign]``: *expression* is any affine expression string, *values_parameter*\nnames a parameter carrying the ``over`` dim, and *sign* bounds the link by\nthe curve instead of pinning it (at most one non-``\"==\"``, and only with\nexactly two links).", + "description": "Expressions tied to one breakpoint-indexed piecewise curve per coordinate of ``dims:``.\n\nMirrors ``linopy.Spec.add_piecewise_formulation``. ``links:`` maps a name\nto ``[expression, values_parameter]`` or ``[expression, values_parameter,\nsign]``: *expression* is any affine expression string over ``dims:``,\n*values_parameter* names a parameter carrying ``along`` and no dim the\nlink's row lacks, and *sign* bounds the link by the curve instead of\npinning it. The name is the\nlink's row in the expansion, ``_``.\n\n``dims:`` alone decides how many curves the block builds: one set of\nweights per coordinate of it. ``where:`` says which of those coordinates\nhave a curve, and how far each runs along ``along`` where it reads that\ndim too; ``activity:`` whether a curve that exists is switched on. A link\nthat walks a relation builds a row per fine coordinate, each reading the\none curve its coarse coordinate has, so how many expressions a curve ties\nis data.", "properties": { "activity": { "anyOf": [ @@ -604,6 +604,10 @@ "default": null, "title": "Activity" }, + "along": { + "title": "Along", + "type": "string" + }, "description": { "anyOf": [ { @@ -616,12 +620,19 @@ "default": null, "title": "Description" }, - "links": { + "dims": { "items": { + "type": "string" + }, + "title": "Dims", + "type": "array" + }, + "links": { + "additionalProperties": { "$ref": "#/$defs/PiecewiseLink" }, "title": "Links", - "type": "array" + "type": "object" }, "method": { "default": "adjacency", @@ -634,11 +645,7 @@ "title": "Method", "type": "string" }, - "over": { - "title": "Over", - "type": "string" - }, - "points": { + "where": { "anyOf": [ { "type": "string" @@ -648,11 +655,12 @@ } ], "default": null, - "title": "Points" + "title": "Where" } }, "required": [ - "over", + "along", + "dims", "links" ], "title": "PiecewiseBlock", @@ -662,12 +670,60 @@ "anyOf": [ { "additionalProperties": false, - "description": "One link of a piecewise block: an expression pinned to a values curve.\n\nWritten in YAML as ``[expression, values]`` or ``[expression, values,\nsign]`` and serialised back to exactly that form, so a round trip through\n[`Spec.to_yaml`][] reproduces the file.", + "description": "One link of a piecewise block: an expression tied to the curve through a values parameter.\n\nWritten in YAML as ``[expression, values]`` or ``[expression, values,\nsign]``, and serialised back to exactly that form, so a round trip through\n[`Spec.to_yaml`][] reproduces the file.\n\nA link that names ``by:``, ``over:`` and ``into:`` reads the curve's\nweights through a relation, as ``at`` reads an array, and is written as\na mapping. Its row is the block's ``dims:`` with the consumed columns'\ndims replaced by the produced ones, so one link emits a row per fine\ncoordinate and every one of them reads the curve of the coarse coordinate\nit maps to. That is what lets one curve tie as many expressions as the\ndata says.", "properties": { + "by": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "By" + }, "expression": { "title": "Expression", "type": "string" }, + "into": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Into" + }, + "over": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Over" + }, "sign": { "default": "==", "enum": [ diff --git a/src/mathspec/_expression_resolver.py b/src/mathspec/_expression_resolver.py index ae0630d0..00986adf 100644 --- a/src/mathspec/_expression_resolver.py +++ b/src/mathspec/_expression_resolver.py @@ -518,7 +518,7 @@ def relation_ref( return self.partition(name, operator, along, named['within']) if not ({'over', 'into'} <= set(named)): return None # refused already, by the call shape or by the role that named no column - return self._direction(name, operator, named['over'], named['into']) + return self.direction(name, operator, named['over'], named['into']) def _role_name(self, value: ArithmeticNode, operator: str, key: str) -> tuple[str, ...] | None: """``over=`` or ``into=`` as the column names it must be — one bare name, or a bracketed list of them.""" @@ -529,7 +529,7 @@ def _role_name(self, value: ArithmeticNode, operator: str, key: str) -> tuple[st ) return None - def _direction( + def direction( self, name: str, operator: str, diff --git a/src/mathspec/canonical.py b/src/mathspec/canonical.py index 8198d3a8..5b8a8500 100644 --- a/src/mathspec/canonical.py +++ b/src/mathspec/canonical.py @@ -202,9 +202,17 @@ def _canonical_block(block: object) -> object: return block -def _canonical_links(links: list[list[object]]) -> list[list[object]]: - """A piecewise block's links, whose expression is the first position of the list the file wrote.""" - return [[canonical_text(cast('str', link[0])), *link[1:]] for link in links] +def _canonical_links(links: dict[str, object]) -> dict[str, object]: + """A piecewise block's links by name, in the order the file wrote them. + + A link in the list form carries its expression in the first position. A + walk is written as a mapping, whose ``expression`` is already in the normal + form. + """ + return { + name: [canonical_text(cast('str', link[0])), *link[1:]] if isinstance(link, list) else link + for name, link in links.items() + } def _sorted_blocks(section: dict[str, object], *, bare_is_expression: bool = False) -> dict[str, object]: diff --git a/src/mathspec/lowering.py b/src/mathspec/lowering.py index a826a297..60dbe7c9 100644 --- a/src/mathspec/lowering.py +++ b/src/mathspec/lowering.py @@ -20,7 +20,7 @@ from mathspec.dimensions import check_schema, dims_of from mathspec.errors import SchemaError, did_you_mean, prefixed from mathspec.expansion import expand, parse_template -from mathspec.piecewise import assumptions_of, curve_frame, lp_domain_refusal, resolve_links +from mathspec.piecewise import assumptions_of, declaration_of, lp_domain_refusal, resolve_links, resolve_walks from mathspec.program import ( Assumption, BooleanLiteral, @@ -30,13 +30,11 @@ ExpressionDeclaration, GivenDeclaration, GivenTargets, - Link, Mask, Named, ObjectiveDeclaration, Parameter, ParameterDeclaration, - PiecewiseDeclaration, Program, SosDeclaration, VariableDeclaration, @@ -58,7 +56,7 @@ if TYPE_CHECKING: from collections.abc import Mapping - from mathspec.program import Expression + from mathspec.program import Direction, Expression from mathspec.spec import AssumptionBlock, Spec @@ -83,11 +81,12 @@ def lower(schema: Spec) -> Program: curve as lowered; - every dim rule (``dimensions.check_schema``), once names resolve. - A ``piecewise:`` block's links are resolved and its frame checked here, on - the link the file wrote, so the expansion writes rows the language has - already held to every rule; what its method assumes of the breakpoints - stands under the program's assumptions with the file's own, so a spec - states what it assumes whether or not its curves are written out. + A ``piecewise:`` block's links and ``where:`` are resolved here, and each + link's row and its fit decided, on the link the file wrote, so the + expansion writes rows the language has already held to every rule; what + its method assumes of the breakpoints stands under the program's + assumptions with the file's own, so a spec states what it assumes whether + or not its curves are written out. Returns: The program of what *schema* declares, section for section. @@ -196,14 +195,16 @@ def lower(schema: Spec) -> Program: if (assumption := _assumption(aname, adef, ns, errors)) is not None: assumptions[aname] = assumption - curves: dict[str, tuple[Expression, ...]] = {} + curves: dict[str, tuple[tuple[Expression, ...], dict[str, Direction], Mask | None]] = {} for pname, pdef in schema.piecewise.items(): links = resolve_links(pname, pdef, ns, errors) - if links is None: + walks = resolve_walks(pname, pdef, ns, errors) + where = mask_of(resolve_where_text(pdef.where, ns, f"piecewise '{pname}' where", errors)) + if links is None or walks is None: continue if pdef.method == 'lp' and (refusal := lp_domain_refusal(pname, pdef, links)) is not None: errors.append(refusal) - curves[pname] = links + curves[pname] = (links, walks, where) if errors: raise SchemaError('\n'.join(errors)) @@ -211,23 +212,15 @@ def lower(schema: Spec) -> Program: roots = [side for c in constraints.values() for side in (c.lhs, c.rhs)] if objective is not None: roots.append(objective.expression) - roots.extend(link for links in curves.values() for link in links) + roots.extend(link for links, _, _ in curves.values() for link in links) roots.extend(terms.values()) in_math = frozenset(node.name for node in walk(*roots) if isinstance(node, Named)) piecewise = {} - for pname, links in curves.items(): + for pname, (links, walks, where) in curves.items(): pdef = schema.piecewise[pname] - piecewise[pname] = PiecewiseDeclaration( - over=pdef.over, - links=tuple(Link(node, link.values, link.sign) for node, link in zip(links, pdef.links, strict=True)), - method=pdef.method, - frame=curve_frame(schema, pname, pdef, links), - activity=pdef.activity, - points=pdef.points, - description=pdef.description, - ) - for aname, assumed in assumptions_of(pname, piecewise[pname]).items(): + piecewise[pname] = declaration_of(schema, pname, pdef, links, walks, where) + for aname, assumed in assumptions_of(pname, piecewise[pname], pdef.where).items(): assumption = _assumption(aname, assumed, ns, errors) assert assumption is not None and not errors, 'what a method assumes is stated in the language' assumptions[aname] = assumption diff --git a/src/mathspec/piecewise.py b/src/mathspec/piecewise.py index e4dafb83..fe4464c2 100644 --- a/src/mathspec/piecewise.py +++ b/src/mathspec/piecewise.py @@ -8,36 +8,111 @@ [`expand`][mathspec.spec.Spec.expand] for them, under names prefixed with the block's own; what each method emits is tabled in ``docs/reference/language/piecewise.md``. Every rule a block is held to is -decided at load, before this runs: the names it references in -[`Spec`][], its links where every expression is typed, and -its frame in [`curve_frame`][]. +decided as the spec loads, before its rows are written: the names it +references in [`reference_errors`][mathspec.validation.reference_errors], its +links and its ``where:`` as lowering types them, and the fit of each link's row +to its expression, its values and the mask in [`declaration_of`][]. A refusal +names the link or key the file wrote rather than an emitted declaration. """ from __future__ import annotations +import re from dataclasses import dataclass from typing import TYPE_CHECKING, Literal import mathspec.sos as sos -from mathspec.dimensions import dims_of +from mathspec._expression_parser import NAME +from mathspec._expression_resolver import ExpressionResolver +from mathspec.dimensions import dims_of, pulled_back_dims from mathspec.errors import DimensionError -from mathspec.program import PiecewiseDeclaration, PiecewiseMethod, VariableDeclaration, carries_variable +from mathspec.program import Link, PiecewiseDeclaration, PiecewiseMethod, VariableDeclaration, carries_variable from mathspec.resolution import resolve_expression_text -from mathspec.spec import AssumptionBlock, Curvature, PiecewiseBlock, Spec, VariableBlock +from mathspec.spec import AssumptionBlock, Curvature, PiecewiseBlock, PiecewiseLink, Spec, VariableBlock if TYPE_CHECKING: from collections.abc import Iterable - from mathspec.program import Expression + from mathspec.program import Direction, Expression, Mask from mathspec.resolution import Namespace -#: The suffix on the second gate row, where the gate variable does not exist. -_UNGATED = '_ungated' +# --------------------------------------------------------------------------- +# the mask, as each shape of row reads it +# --------------------------------------------------------------------------- + + +def _masks(where: str | None, along: str, *, ragged: bool) -> tuple[str | None, str | None, str | None]: + """The block's ``where:`` as three shapes of row read it: ``(mask, frame, exists)``. + + A **ragged** where reads the breakpoint dim, so it says how far each curve + runs: a row over the frame and that dim takes it as written (*mask*), and + a row over the frame alone, which cannot read that dim, takes the count of + breakpoints it admits (*exists*). A where over the frame alone says which + curves exist: every row conjoins it as written (*frame*, and *exists*), + and no row is ragged (*mask* is ``None``). + """ + if ragged: + return where, None, f'count({where}, over={along}) > 0' + return None, where, where + + +def _all_of(*clauses: str | None) -> str | None: + """The where admitting a row only where every clause given does, or ``None`` where none of them speaks. + + A lone clause passes through as it was written, so a block with no + ``where:`` emits exactly the string it always did. Joined clauses are + parenthesised, because a disjunction inside one of them would otherwise + bind only its last operand to the ``AND``. + """ + kept = [clause for clause in clauses if clause] + if len(kept) <= 1: + return kept[0] if kept else None + return ' AND '.join(f'({clause})' for clause in kept) + + +def _operand(mask: str) -> str: + """The mask as one operand of a connective: a bare name as it is, anything else parenthesised.""" + return mask if re.fullmatch(NAME, mask) else f'({mask})' + +def _shifted(over: str, mask: str, offset: int) -> str: + """*mask* read *offset* breakpoints back, false where that vacates.""" + return f'shift({mask}, along={over}, offset={offset})' -def _curvature_required(pw: PiecewiseDeclaration) -> Curvature | None: - """The curvature *pw*'s method is only exact for, or ``None`` if any shape works. + +def _neighbours(over: str, mask: str | None) -> str: + """Where a breakpoint and the one before it are both there: the rows a claim about a segment is true of.""" + if mask is None: + return f'position({over}) > 0' + return f'{_operand(mask)} AND {_shifted(over, mask, 1)}' + + +def _edge(over: str, mask: str | None, end: Literal['first', 'last']) -> str: + """The first or last breakpoint of each curve: where the mask holds and does not one step outward. + + The vacated edge of a ``shift`` in a ``where`` is false, which is what + makes the head and the tail of the axis their own edge. + """ + if mask is None: + return f'position({over}) == {0 if end == "first" else -1}' + return f'{_operand(mask)} AND NOT {_shifted(over, mask, 1 if end == "first" else -1)}' + + +def _interior(over: str, mask: str | None) -> str: + """Where a breakpoint has one on either side: the rows a claim about a bend is true of.""" + if mask is None: + return f'position({over}) > 0 AND position({over}) != -1' + return f'{_operand(mask)} AND {_shifted(over, mask, 1)} AND {_shifted(over, mask, -1)}' + + +# --------------------------------------------------------------------------- +# what a block assumes of its numbers +# --------------------------------------------------------------------------- + + +def _curvature_required(curve: PiecewiseDeclaration) -> Curvature | None: + """The curvature *curve*'s method is only exact for, or ``None`` if any shape works. A bounded link binds from one side, and that side is the hull boundary the weights are driven onto: ``>=`` reaches the lower one, which is the curve @@ -51,104 +126,88 @@ def _curvature_required(pw: PiecewiseDeclaration) -> Curvature | None: most a method states is ``'either'``: a mixed curve is wrong whichever way the pressure runs, and a single bend is exact one of the two ways. """ - if pw.method not in ('convex', 'lp'): + if curve.method not in ('convex', 'lp'): return None - if (sign := pw.curve[1].sign) == '==': + if (sign := curve.curve[1].sign) == '==': return 'either' return 'convex' if sign == '>=' else 'concave' -def resolve_links(name: str, pw: PiecewiseBlock, ns: Namespace, errors: list[str]) -> tuple[Expression, ...] | None: - """Block *name*'s link expressions typed, in link order, or ``None`` once one failed, its refusal appended. - - A link is read affinely, so it is held to degree 1 where it is read. - """ - links = [ - resolve_expression_text(link.expression, ns, f"piecewise '{name}' link {i}", errors, ceiling=1) - for i, link in enumerate(pw.links) - ] - if any(link is None for link in links): - return None - return tuple(link for link in links if link is not None) - - -def lp_domain_refusal(name: str, pw: PiecewiseBlock, links: tuple[Expression, ...]) -> str | None: - """The refusal for a ``method: lp`` curve whose x-link carries no variable, or ``None``. - - The method bounds the curve's domain with two rows comparing the x-link - against the first and the last breakpoint, and a row with no variable - decides nothing. Decided on the link the file wrote, rather than on the - row the expansion would write under a name the file never declared. - """ - x = pw.curve[0] - i = next(i for i, link in enumerate(pw.links) if link is x) - if carries_variable(links[i]): - return None - return ( - f"piecewise '{name}' link {i}: method: lp bounds the curve's domain by rows comparing this link's expression " - f'against its first and last breakpoint, and {x.expression!r} carries no variable, so those rows decide ' - f'nothing. Name a variable in the link, or use method: convex, sos2 or adjacency, whose weights pin the ' - f'domain themselves.' - ) - - -def assumptions_of(block: str, pw: PiecewiseDeclaration) -> dict[str, AssumptionBlock]: - """What *block* assumes of its numbers, by the name the document prints and a refusal quotes. +def assumptions_of(name: str, curve: PiecewiseDeclaration, where: str | None) -> dict[str, AssumptionBlock]: + """What block *name* assumes of its numbers, by the name the document prints and a refusal quotes. Every curve assumes its breakpoints are there: a missing parameter row is not absence, it is a zero, so an undeclared breakpoint sits the curve on - the origin rather than shortening it. A curve has an x-axis only where two - links tie it, so the increasing condition — and the shape it is checked - with — exist only there; ``lp`` alone needs a segment to state a line for; - a mask must be one run. - - Read off the block rather than off an expansion, so a spec states what it - assumes whether or not its curves have been written out. Each condition is - an ``assumptions:`` entry over the parameters the file declared, its - ``description`` naming the method and the rewrite that takes a curve of any - shape: the expansion writes them into the spec, and a spec that still - declares the block resolves the same entries at load. + the origin rather than shortening it. A walked link's breakpoints are over + its own rows, so each is asked of the rows that link reads the curve at, + under a name of its own. A curve has an x-axis only where two links tie + it, so the increasing condition — and the shape it is checked with — + exist only there; ``lp`` alone needs a segment to state a line for; a + ragged ``where:`` must mark one run. + + Each condition is a where string over the parameters the file declared, + so *where* is the block's ``where:`` as the file wrote it — a typed mask + has no text — and *curve* says how each row reads it. The expansion + writes the conditions into ``assumptions:``, and a spec that still + declares the block derives the same text at load. Each is asked only + where a curve runs, so the ``where:`` goes into every one of them: a + spec written out and read back holds the data to what the block did. """ - d, mask = pw.over, pw.points + d = curve.along + mask, frame, exists = _masks(where, d, ragged=curve.ragged) + if mask is not None: + rewrite = f'Attach the rows, or narrow where: {mask!r} to where the curve runs.' + elif where is not None: + rewrite = f"Attach the rows, or let where: {where!r} test '{d}' too, to say how far each curve runs." + else: + rewrite = 'Attach the rows, or declare where: to say how far the curve runs.' assumed: dict[str, AssumptionBlock] = {} - assumed[f'{block}_complete'] = AssumptionBlock( - holds=' AND '.join(dict.fromkeys(link.values for link in pw.links)), - where=mask, - description=f"piecewise '{block}': every breakpoint the curve runs through needs a row in " - f'{_quoted(link.values for link in pw.links)} — a missing row is read as a zero rather than as a ' - f'shorter curve, so it sits the curve on the origin. ' - + ( - f"Attach the rows, or narrow points: '{mask}' to where the curve runs." - if mask is not None - else 'Attach the rows, or declare points: to say how far the curve runs.' - ), - ) - curvature = _curvature_required(pw) + if values := [link.values for link in curve.links if not link.walks]: + assumed[f'{name}_complete'] = AssumptionBlock( + holds=' AND '.join(dict.fromkeys(values)), + where=where, + description=f"piecewise '{name}': every breakpoint the curve runs through needs a row in " + f'{_quoted(values)} — a missing row is read as a zero rather than as a shorter curve, so it sits ' + f'the curve on the origin. {rewrite}', + ) + for link in curve.links: + if link.walks: + assumed[f'{name}_{link.name}_complete'] = AssumptionBlock( + holds=link.values, + where=through(where, link) if link.reads else _all_of(link.by, where), + description=f"piecewise '{name}' link '{link.name}': every breakpoint the curve runs through needs a " + f"row in '{link.values}' at every row the link reads the curve at — a missing row is read as a zero " + f'rather than as a shorter curve, so it sits that row on the origin. {rewrite}', + ) + curvature = _curvature_required(curve) if curvature is not None: - x, y = (link.values for link in pw.curve) - assumed[f'{block}_increasing'] = AssumptionBlock( + x, y = (link.values for link in curve.curve) + assumed[f'{name}_increasing'] = AssumptionBlock( holds=f'{_back(x, d, 1)} < {x}', - where=_neighbours(d, mask), - description=f"piecewise '{block}': method: {pw.method} requires strictly increasing breakpoints in '{x}' along '{d}'", + where=_all_of(frame, _neighbours(d, mask)), + description=f"piecewise '{name}': method: {curve.method} requires strictly increasing breakpoints " + f"in '{x}' along '{d}'", ) - assumed[f'{block}_curvature'] = _bends(block, pw, x, y, curvature) - if pw.method == 'lp': - assumed[f'{block}_breakpoints'] = AssumptionBlock( - holds=f'count({mask or pw.curve[0].values}, over={d}) >= 2', - description=f"piecewise '{block}': method: lp needs at least two breakpoints per curve — the method *is* its " - f'segment lines, so a curve with no segment states nothing and leaves the bounded link on its own ' - f'bound. Use method: adjacency, sos2 or convex, which pin it to the points it does have.', + assumed[f'{name}_curvature'] = _bends(name, curve, x, y, curvature, mask=mask, frame=frame, exists=exists) + if curve.method == 'lp': + assumed[f'{name}_breakpoints'] = AssumptionBlock( + holds=f'count({mask or curve.curve[0].values}, over={d}) >= 2', + where=exists, + description=f"piecewise '{name}': method: lp needs at least two breakpoints per curve — the method " + f'*is* its segment lines, so a curve with no segment states nothing and leaves the bounded link on ' + f'its own bound. Use method: adjacency, sos2 or convex, which pin it to the points it does have.', ) if mask is not None: - assumed[f'{block}_contiguous'] = AssumptionBlock( + assumed[f'{name}_contiguous'] = AssumptionBlock( holds=f'count({_edge(d, mask, "first")}, over={d}) == 1', - description=f"piecewise '{block}': points: '{mask}' must mark a consecutive run of at least one breakpoint per " - f'curve — {_GAP[pw.method]}.', + where=exists, + description=f"piecewise '{name}': where: {mask!r} must mark a consecutive run of at least one " + f'breakpoint per curve — {_GAP[curve.method]}.', ) return assumed -#: Why a gap in ``points:`` breaks each method, in the rows that method writes. +#: Why a gap in a ragged ``where:`` breaks each method, in the rows that method writes. _GAP: dict[PiecewiseMethod, str] = { 'adjacency': 'the weights are nonzero only on two neighbouring breakpoints, and a gap leaves no neighbour across it', 'sos2': 'the weights are nonzero only on two neighbouring breakpoints, and a gap leaves no neighbour across it', @@ -158,13 +217,25 @@ def assumptions_of(block: str, pw: PiecewiseDeclaration) -> dict[str, Assumption } +#: What a block may assume of its numbers, by suffix — the names +#: [`assumptions_of`][] writes, reserved whether or not the method states each. +_ASSUMED = ('complete', 'increasing', 'curvature', 'breakpoints', 'contiguous') + + +def through(text: str | None, link: Link) -> str | None: + """*text* as *link*'s row reads it: through the link's relation where the row reads the where so, else as written.""" + if text is None or not link.reads: + return text + return f'at({text}, by={link.by}, over={_columns(link.over)}, into={_columns(link.into)})' + + def _quoted(names: Iterable[str]) -> str: """Parameter names as a refusal lists them, in link order and without repeats.""" return ', '.join(f"'{name}'" for name in dict.fromkeys(names)) def _back(parameter: str, over: str, offset: int) -> str: - """One breakpoint along *over* from here, the vacated row filled with zero and excluded by the ``where``. + """*parameter* read *offset* breakpoints back, the vacated row filled with zero and excluded by the ``where``. ``edge=0`` is what the language admits over data, and the mask beside it is what keeps the invented zero from ever being read. @@ -172,32 +243,17 @@ def _back(parameter: str, over: str, offset: int) -> str: return f'shift({parameter}, along={over}, offset={offset}, edge=0)' -def _neighbours(over: str, mask: str | None) -> str: - """Where a breakpoint and the one before it are both there: the rows a claim about a segment is true of.""" - if mask is None: - return f'position({over}) > 0' - return f'{mask} AND shift({mask}, along={over}, offset=1)' - - -def _edge(over: str, mask: str | None, end: Literal['first', 'last']) -> str: - """The first or last breakpoint of each curve: where the mask holds and does not one step outward. - - The vacated edge of a ``shift`` in a ``where`` is false, which is what - makes the head and the tail of the axis their own edge. - """ - if mask is None: - return f'position({over}) == {0 if end == "first" else -1}' - return f'{mask} AND NOT shift({mask}, along={over}, offset={1 if end == "first" else -1})' - - -def _interior(over: str, mask: str | None) -> str: - """Where a breakpoint has one on either side: the rows a claim about a bend is true of.""" - if mask is None: - return f'position({over}) > 0 AND position({over}) != -1' - return f'{mask} AND shift({mask}, along={over}, offset=1) AND shift({mask}, along={over}, offset=-1)' - - -def _bends(block: str, pw: PiecewiseDeclaration, x: str, y: str, curvature: Curvature) -> AssumptionBlock: +def _bends( + name: str, + curve: PiecewiseDeclaration, + x: str, + y: str, + curvature: Curvature, + *, + mask: str | None, + frame: str | None, + exists: str | None, +) -> AssumptionBlock: """The curve bends the way *curvature* says, as a comparison of the two slopes at each breakpoint. The slopes are compared as a cross-product rather than as two quotients, @@ -206,14 +262,14 @@ def _bends(block: str, pw: PiecewiseDeclaration, x: str, y: str, curvature: Curv whole axis rather than about a breakpoint: it counts the bends that go the wrong way and asks that one of the two directions has none. """ - d, mask = pw.over, pw.points + d = curve.along rise, run = f'({y} - {_back(y, d, 1)})', f'({x} - {_back(x, d, 1)})' next_rise, next_run = f'({_back(y, d, -1)} - {y})', f'({_back(x, d, -1)} - {x})' bend = f'{rise} * {next_run} {{}} {next_rise} * {run}' interior = _interior(d, mask) shape = 'a single bend' if curvature == 'either' else f'a {curvature} curve' description = ( - f"piecewise '{block}': method: {pw.method} is exact only for {shape}, and '{y}' over '{x}' along " + f"piecewise '{name}': method: {curve.method} is exact only for {shape}, and '{y}' over '{x}' along " f"'{d}' is not one, so the answer is wrong rather than loose. Use method: adjacency " f'or sos2, which take a curve of any shape.' ) @@ -221,20 +277,32 @@ def _bends(block: str, pw: PiecewiseDeclaration, x: str, y: str, curvature: Curv up, down = bend.format('>'), bend.format('<') return AssumptionBlock( holds=f'count({up} AND {interior}, over={d}) == 0 OR count({down} AND {interior}, over={d}) == 0', + where=exists, description=description, ) return AssumptionBlock( - holds=bend.format('<=' if curvature == 'convex' else '>='), where=interior, description=description + holds=bend.format('<=' if curvature == 'convex' else '>='), + where=_all_of(frame, interior), + description=description, ) +# --------------------------------------------------------------------------- +# the names a block writes +# --------------------------------------------------------------------------- + +#: The suffix on the second gate row, where the gate variable does not exist. +_UNGATED = '_ungated' + + @dataclass(frozen=True) class Emitted: """Every name one block's expansion may write, spelled once for the emitter and the collision check. - The set a block states writes names of its own, and they are reserved - whichever method the block declares: which of the two write them is the - method's business, and a collision is the file's either way. + Every name is reserved whichever method the block declares: which method + writes which is the method's business, and a collision is the file's + either way. ``set`` holds the names a method that states a set writes + through [`mathspec.sos.emit`][]. """ name: str @@ -248,8 +316,8 @@ class Emitted: assumptions: tuple[str, ...] @classmethod - def of(cls, name: str, pw: PiecewiseDeclaration) -> Emitted: - """The names block *name* writes.""" + def of(cls, name: str, curve: PiecewiseDeclaration) -> Emitted: + """The names block *name* writes, a link's row named after the link.""" return cls( name, f'{name}_lam', @@ -258,10 +326,18 @@ def of(cls, name: str, pw: PiecewiseDeclaration) -> Emitted: f'{name}_chord', f'{name}_domain_lo', f'{name}_domain_hi', - tuple(f'{name}_link{i}' for i in range(len(pw.links))), - tuple(assumptions_of(name, pw)), + tuple(f'{name}_{link.name}' for link in curve.links), + ( + *(f'{name}_{what}' for what in _ASSUMED), + *(f'{name}_{link.name}_complete' for link in curve.links if link.walks), + ), ) + @property + def ungated(self) -> str: + """The second gate row, where the gate variable does not exist.""" + return self.convexity + _UNGATED + def written(self, method: PiecewiseMethod, *, ungated: bool) -> tuple[str, ...]: """The variables and constraints [`expand`][mathspec.spec.Spec.expand] declares for a block of *method*. @@ -271,28 +347,36 @@ def written(self, method: PiecewiseMethod, *, ungated: bool) -> tuple[str, ...]: """ if method == 'lp': return (self.chord, self.domain_lo, self.domain_hi) - convexity = (self.convexity, self.convexity + _UNGATED) if ungated else (self.convexity,) + convexity = (self.convexity, self.ungated) if ungated else (self.convexity,) restriction = (self.set.seg, self.set.pick, self.set.link) if method in ('sos2', 'adjacency') else () return (self.lam, *convexity, *self.links, *restriction) + @property + def rows(self) -> tuple[str, ...]: + """Every constraint the block writes for itself, its link rows aside.""" + return ( + self.convexity, + self.ungated, + self.set.pick, + self.set.link, + self.set.below, + self.chord, + self.domain_lo, + self.domain_hi, + ) + + @property + def reused(self) -> tuple[str, ...]: + """Each link row whose name the block's own rows or variables already take.""" + own = {self.lam, self.set.seg, *self.rows} + return tuple(row for row in self.links if row in own) + @property def by_kind(self) -> tuple[tuple[str, tuple[str, ...]], ...]: """Each name by the kind of declaration it would collide with.""" return ( ('variable', (self.lam, self.set.seg)), - ( - 'constraint', - ( - self.convexity, - self.convexity + _UNGATED, - self.set.pick, - self.set.link, - self.chord, - self.domain_lo, - self.domain_hi, - *self.links, - ), - ), + ('constraint', (*self.rows, *self.links)), ('sos', (self.name,)), ('assumption', self.assumptions), ) @@ -307,55 +391,300 @@ def leaves_ungated(gate: VariableBlock | VariableDeclaration | None) -> bool: return gate is not None and gate.where is not None and gate.absence != 'zero' -def curve_frame(schema: Spec, name: str, pw: PiecewiseBlock, links: Iterable[Expression]) -> tuple[str, ...]: - """The dimensions block *name* builds one curve per coordinate of: every one its links and its gate carry. +# --------------------------------------------------------------------------- +# the block as lowering types it +# --------------------------------------------------------------------------- + + +def resolve_links(name: str, pw: PiecewiseBlock, ns: Namespace, errors: list[str]) -> tuple[Expression, ...] | None: + """Block *name*'s link expressions typed, in link order, or ``None`` once one failed, its refusal appended. - In declaration order, because iterating a set would vary the emitted - ``dims`` — and every column index behind it — per process. *links* are the - block's link expressions typed, as [`resolve_links`][] answers. + A link is read affinely, so it is held to degree 1 where it is read. + """ + links = [ + resolve_expression_text(link.expression, ns, f"piecewise '{name}' link '{key}'", errors, ceiling=1) + for key, link in pw.links.items() + ] + if any(link is None for link in links): + return None + return tuple(link for link in links if link is not None) + + +def resolve_walks(name: str, pw: PiecewiseBlock, ns: Namespace, errors: list[str]) -> dict[str, Direction] | None: + """Block *name*'s walks by link key, each read as ``at`` reads its relation, or ``None`` once one failed. + + The expansion writes a walked row as ``at(_lam, by=, over=, + into=)``, so a walk is held to every rule that call is held to, and + refused here on the link the file wrote. Each refusal is appended to + *errors*. + """ + walks: dict[str, Direction] = {} + failed = False + for key, link in pw.links.items(): + if not link.walks: + continue + assert link.by is not None + resolver = ExpressionResolver(ns, f"piecewise '{name}' link '{key}'", errors) + if (problem := resolver.not_a_relation(link.by, 'at', 'by')) is not None: + errors.append(problem) + failed = True + elif (direction := resolver.direction(link.by, 'at', _named(link.over), _named(link.into))) is None: + failed = True + else: + walks[key] = direction + return None if failed else walks + + +def lp_domain_refusal(name: str, pw: PiecewiseBlock, links: tuple[Expression, ...]) -> str | None: + """The refusal for a ``method: lp`` curve whose x-link carries no variable, or ``None``. + + The method bounds the curve's domain with two rows comparing the x-link + against the first and the last breakpoint, and a row with no variable + decides nothing. Decided on the link the file wrote, rather than on the + row the expansion would write under a name the file never declared. + """ + x = pw.curve[0] + i, key = next((i, key) for i, (key, link) in enumerate(pw.links.items()) if link is x) + if carries_variable(links[i]): + return None + return ( + f"piecewise '{name}' link '{key}': method: lp bounds the curve's domain by rows comparing this link's " + f'expression against its first and last breakpoint, and {x.expression!r} carries no variable, so those rows ' + f'decide nothing. Name a variable in the link, or use method: convex, sos2 or adjacency, whose weights pin ' + f'the domain themselves.' + ) + + +def declaration_of( + schema: Spec, + name: str, + pw: PiecewiseBlock, + links: tuple[Expression, ...], + walks: dict[str, Direction], + where: Mask | None, +) -> PiecewiseDeclaration: + """Block *name* as the program carries it, with *links*, *walks* and *where* typed, every fit rule decided. + + A walk reads the curve's weights at the block's own dims, so it consumes + dims of ``dims:``, joins on dims of ``dims:``, and produces dims of its + own. Each link's row is ``dims:``, or its refinement through the link's + walk; its expression carries exactly that row, its values parameter + varies along it and the breakpoint dim and nothing else, and the + ``where:`` tests ``dims:`` and the breakpoint dim alone. A walked row + reads the where through its relation when the mask carries a dim the + walk consumes. Decided here, on the link the file wrote, rather than on + the emitted declarations, whose refusal would name ``_lam`` — a + variable the author never wrote. Raises: - DimensionError: A link or the gate carries the breakpoint dimension, or - a values or ``points:`` parameter varies along a dimension no link - expression carries. + DimensionError: A walk that does not fit ``dims:``, a link that does + not fit its row, a where outside ``dims:``, or a mask carrying + part of what a walk reads through. """ - context = f"piecewise '{name}'" - carried = [(f'link {i} expression', dims_of(node, schema, f'{context} link {i}')) for i, node in enumerate(links)] - if pw.activity is not None: - carried.append(('activity', frozenset(schema.variables[pw.activity].dims))) - frame: list[str] = [] - for what, found in carried: - for d in (d for d in schema.dimensions if d in found): - if d == pw.over: - raise DimensionError(f"{context}: {what} already carries the breakpoint dim '{pw.over}'") - if d not in frame: - frame.append(d) - for i, link in enumerate(pw.links): - if stray := [d for d in schema.parameters[link.values].dims if d != pw.over and d not in frame]: - raise DimensionError( - f"{context}: link {i} values parameter '{link.values}' carries {stray}, which no link " - f'expression does — the block builds one curve per coordinate of {frame}, so a curve ' - f'varying along {stray} has nothing to vary against. Declare a link expression over ' - f"it, or drop it from '{link.values}'." - ) - if pw.points is not None and pw.nominated is None: - mask = schema.parameters[pw.points].dims - if stray := [d for d in mask if d != pw.over and d not in frame]: + ctx = f"piecewise '{name}'" + for key, walk in walks.items(): + _walk_fits(f"{ctx} link '{key}'", pw, walk) + rows = {key: _row(schema, pw, walks.get(key)) for key in pw.links} + for node, (key, row) in zip(links, rows.items(), strict=True): + _link_fits(ctx, key, pw, dims_of(node, schema, f"{ctx} link '{key}'"), row) + for (key, link), row in zip(pw.links.items(), rows.values(), strict=True): + _values_fit(schema, ctx, key, pw, link, row) + _where_fits(ctx, pw, where, walks) + carried = (where.dims if where is not None else frozenset()) - {pw.along} + typed = tuple( + Link( + key, + node, + link.values, + rows[key], + link.sign, + link.by, + _named(link.over), + _named(link.into), + _reads(ctx, key, pw, walks.get(key), carried), + ) + for node, (key, link) in zip(links, pw.links.items(), strict=True) + ) + return PiecewiseDeclaration( + pw.along, typed, pw.method, tuple(pw.dims), where, activity=pw.activity, description=pw.description + ) + + +def _walk_fits(ctx: str, block: PiecewiseBlock, walk: Direction) -> None: + """A walk reads the curve's weights, which are over ``dims:`` and the breakpoint dim, as ``at`` would. + + The rules ``at`` holds its operand to are [`pulled_back_dims`][]'s. + The ones checked first are the same rules, refused in terms of the + block, since there the rewrite is an edit to ``dims:``. + """ + consumed, produced = set(walk.consumed_dims), set(walk.produced_dims) + if missing := sorted(consumed - set(block.dims)): + raise DimensionError( + f"{ctx}: over reaches {missing}, which the block's dims {block.dims} do not carry. A walk " + f"consumes one of the curve's own dimensions — name a column over one of {block.dims}, or declare " + f'it in dims:.' + ) + if framed := sorted(produced & set(block.dims)): + raise DimensionError( + f"{ctx}: into reaches {framed}, which the block's dims {block.dims} already carry. The block " + f"builds one curve per coordinate of dims:, so {framed} cannot also index this link's rows — drop " + f'it from dims:, or walk into a dimension of its own.' + ) + if block.along in produced: + raise DimensionError( + f"{ctx}: into reaches '{block.along}', the breakpoint dim. A walk indexes the link's rows, " + f'and every row runs along the breakpoints.' + ) + if joined := sorted(set(walk.joined_dims) - set(block.dims)): + raise DimensionError( + f"{ctx}: '{walk.name}' is keyed on {joined} too, which the block's dims {block.dims} do not carry. " + f'A walk reads the curve at every key column it does not name, so the curve varies along them — add ' + f'{joined} to dims:, or walk through a relation keyed by the columns into names.' + ) + pulled_back_dims(walk, frozenset((*block.dims, block.along)), ctx, "the curve's weights") + + +def _row(schema: Spec, block: PiecewiseBlock, walk: Direction | None) -> tuple[str, ...]: + """The dims one link's row is built over: ``dims:``, or its refinement through the link's walk. + + The produced dims stand where the consumed ones did, so a walked row + reads in the shape of the curve it ties rather than in relation order. + """ + if walk is None: + return tuple(block.dims) + consumed, produced = set(walk.consumed_dims), set(walk.produced_dims) + refined: list[str] = [] + for d in block.dims: + if d in consumed: + refined.extend(p for p in schema.dimensions if p in produced and p not in refined) + elif d not in refined: + refined.append(d) + return tuple(refined) + + +def _reads(ctx: str, key: str, block: PiecewiseBlock, walk: Direction | None, carried: frozenset[str]) -> bool: + """Whether a walked link's row reads the block's ``where:`` through its relation; ``False`` for one that does not walk. + + A walked row is over the dims the walk produces, where a mask over a + dim it consumes cannot be read as written. Read through the relation it + can, as ``at`` reads it, when the mask carries every dim the walk + consumes or joins on (*carried* is what the mask carries, the breakpoint + dim aside). A mask carrying none the walk consumes is over dims the row + keeps, the joined ones among them, and reads as written. + + Raises: + DimensionError: The mask carries a dim the walk consumes, and not + every dim the walk reads through. + """ + if walk is None: + return False + consumed = frozenset(walk.consumed_dims) + if not consumed & carried: + return False + needed = consumed | frozenset(walk.joined_dims) + if partial := sorted(needed - carried): + raise DimensionError( + f"{ctx} link '{key}': where {block.where!r} carries {sorted(needed & carried)} and not {partial}, and " + f"the link reads the curve through '{walk.name}' at all of {sorted(needed)}. Carry all of them in the " + f'where, so the row reads it through the relation, or none of {sorted(consumed)}, so the row reads it ' + f'as written.' + ) + return True + + +def _named(written: str | list[str] | None) -> tuple[str, ...]: + """The relation columns a walk names on one side, as written: none, one bare, or a list.""" + if written is None: + return () + return (written,) if isinstance(written, str) else tuple(written) + + +def _columns(columns: tuple[str, ...]) -> str: + """One relation column as its bare name, several as the bracketed list the operators take.""" + return columns[0] if len(columns) == 1 else f'[{", ".join(columns)}]' + + +def _link_fits(ctx: str, key: str, block: PiecewiseBlock, found: frozenset[str], own: tuple[str, ...]) -> None: + """A link expression carries exactly its row's frame — the rule a constraint's own ``dims:`` holds to. + + Both directions are refused because both broadcast one side of the row. + A stray dim multiplies the rows the link builds; a missing one repeats + the same row across it, which pins the expression to one operating point + along a dimension the curve varies over. Neither is sayable another way, + so neither is guessed. + """ + if block.along in found: + raise DimensionError(f"{ctx}: link '{key}' expression already carries the breakpoint dim '{block.along}'") + if stray := sorted(found - set(own)): + raise DimensionError( + f"{ctx}: link '{key}' expression carries {stray}, which its row {list(own)} does not — " + f'every stray dim multiplies the rows the link builds. Add it to dims:, sum it out, or read ' + f'it through a relation with by, over and into.' + ) + if missing := sorted(set(own) - found): + raise DimensionError( + f"{ctx}: link '{key}' expression does not carry {missing}, which its row {list(own)} does — " + f'the same row would repeat across {missing}, pinning the expression to one operating point ' + f'along {"it" if len(missing) == 1 else "them"}. Drop {missing} from dims:, or vary the ' + f'expression along {missing}.' + ) + + +def _values_fit( + schema: Spec, ctx: str, key: str, block: PiecewiseBlock, link: PiecewiseLink, own: tuple[str, ...] +) -> None: + """A values parameter varies along its own link's row and the breakpoint dim, and nothing else. + + Its own link's, because a walked link's curve is read per fine + coordinate: ``bp_power`` is per flow where the block's frame is per + converter, and comparing it against the frame would refuse it. + """ + if stray := [d for d in schema.parameters[link.values].dims if d != block.along and d not in own]: + raise DimensionError( + f"{ctx}: link '{key}' values parameter '{link.values}' carries {stray}, which its row " + f'{list(own)} does not — the link reads one curve per coordinate of {list(own)}, so a curve ' + f"varying along {stray} has nothing to vary against. Drop it from '{link.values}', or add it to " + f'dims:.' + ) + + +def _where_fits(ctx: str, block: PiecewiseBlock, where: Mask | None, walks: dict[str, Direction]) -> None: + """A block's ``where:`` tests ``dims:`` and the breakpoint dim, and nothing else. + + A walked link's values parameter carries the link's own row, so a where + naming it is refused here too: raggedness is the curve's. A dim a walk + produces is refused without the advice to add it to ``dims:``, which the + walk would then refuse. + """ + dims = where.dims if where is not None else frozenset() + stray = sorted(dims - set(block.dims) - {block.along}) + for key, walk in walks.items(): + if into := [d for d in stray if d in walk.produced_dims]: raise DimensionError( - f"{context}: points parameter '{pw.points}' carries {stray}, which the links do not — " - f"a mask says which of the block's own coordinates exist, and cannot add coordinates" + f"{ctx}: where {block.where!r} tests {into}, and {into} is what link '{key}' walks into — the " + f'where says which curves exist, one per coordinate of dims {block.dims}, and {into} indexes only ' + f"that link's rows. Test {block.dims} in the where, or mask the link's own variable over {into} " + f'to leave its rows unbuilt.' ) - return tuple(frame) + if stray: + raise DimensionError( + f'{ctx}: where {block.where!r} tests {stray}, which dims {block.dims} does not carry — a mask says ' + f'which of the curves the block builds exist, and cannot add coordinates. Add {stray} to dims:, ' + f'or drop it from the where.' + ) + + +# --------------------------------------------------------------------------- +# the rows a block writes +# --------------------------------------------------------------------------- class _Block: - """One ``piecewise:`` block being expanded into the raw spec it writes. + """One ``piecewise:`` block, written out into the raw spec. - ``mask`` is the parameter masking the weights, or ``None`` for a whole - curve: the ``bool`` the file named, or one of the block's own values - parameters, which as a bare name in a ``where`` is true wherever it has a - row. Nothing here can fail: every rule a block is held to was decided when - *schema* loaded. + Every rule the block is held to was decided as the spec loaded, so what + is left is writing: the link text the file wrote, on the row the program + says each link builds, under the where each row reads. """ def __init__( @@ -364,13 +693,14 @@ def __init__( self.schema = schema self.raw = raw self.name = name - #: The block as the file wrote it, for the link text the rows repeat. + #: The block as the file wrote it, for the link text the rows repeat and the where they carry. self.pw = pw - #: The block as the program carries it, for its frame and the names it writes. + #: The block as the program carries it, for each link's row, how it reads the where, and the names it writes. self.curve = curve self.emitted = Emitted.of(name, curve) - self.mask = pw.points - self.frame = curve.frame + self.frame = list(curve.frame) + #: The where as a ragged row, a frame row, and a row over the frame alone read it. + self.mask, self.frame_mask, self.exists = _masks(pw.where, pw.along, ragged=curve.ragged) def expand(self) -> None: """Write the block's declarations into the raw spec.""" @@ -388,47 +718,54 @@ def _assumptions(self) -> None: as something a consumer has to know to ask for. """ assumptions = sos.section(self.raw, 'assumptions') - for name, assumed in assumptions_of(self.name, self.curve).items(): + for name, assumed in assumptions_of(self.name, self.curve, self.pw.where).items(): assumptions[name] = assumed.model_dump() # -- emitters ---------------------------------------------------------- - def _weight(self, name: str, **fields: object) -> None: - """A variable over the frame and the breakpoint dim, masked as the block is.""" - sos.section(self.raw, 'variables')[name] = { - 'dims': [*self.frame, self.pw.over], - **({'where': self.mask} if self.mask else {}), - **fields, - } - - def _constraint(self, name: str, dims: list[str], expression: str, where: str | None = None) -> None: + def _constraint(self, name: str, dims: Iterable[str], expression: str, where: str | None = None) -> None: sos.section(self.raw, 'constraints')[name] = { - 'dims': dims, + 'dims': list(dims), **({'where': where} if where else {}), 'expression': expression, } def _weights(self) -> None: """The convex-combination form: weights, their convexity, a row per link, and the method's restriction.""" - d = self.pw.over - self._weight( - self.emitted.lam, - bounds={'lower': 0.0, 'upper': 1.0}, - description='convex-combination weight on a breakpoint', - ) - gated = self._gate_rows() - for suffix, where, rhs in gated: + pw, emitted, d = self.pw, self.emitted, self.pw.along + sos.section(self.raw, 'variables')[emitted.lam] = { + 'dims': [*self.frame, d], + **({'where': pw.where} if pw.where else {}), + 'bounds': {'lower': 0.0, 'upper': 1.0}, + 'description': 'convex-combination weight on a breakpoint', + } + for suffix, gate, rhs in self._gate_rows(): self._constraint( - self.emitted.convexity + suffix, list(self.frame), f'sum({self.emitted.lam}, over={d}) == {rhs}', where + emitted.convexity + suffix, + self.frame, + f'sum({emitted.lam}, over={d}) == {rhs}', + _all_of(self.exists, gate), ) - for cname, link in zip(self.emitted.links, self.pw.links, strict=True): + for cname, written, link in zip(emitted.links, pw.links.values(), self.curve.links, strict=True): self._constraint( cname, - list(self.frame), - f'({link.expression}) {link.sign} sum({self.emitted.lam} * {link.values}, over={d})', + link.dims, + f'({written.expression}) {link.sign} sum({self._weights_read(link)} * {link.values}, over={d})', + through(self.exists, link), ) - if self.pw.method in ('sos2', 'adjacency'): - sos.section(self.raw, 'sos')[self.name] = {'variable': self.emitted.lam, 'along': d, 'type': 2} + if pw.method in ('sos2', 'adjacency'): + sos.section(self.raw, 'sos')[self.name] = {'variable': emitted.lam, 'along': d, 'type': 2} + + def _weights_read(self, link: Link) -> str: + """How one link reads the curve's weights: by name, or through the relation that refines its frame. + + The walk is an ``at``, so the weights stay on the curve's own frame and + the spec never names them — which is the whole reason the block emits + the row rather than the file writing it. + """ + if not link.walks: + return self.emitted.lam + return f'at({self.emitted.lam}, by={link.by}, over={_columns(link.over)}, into={_columns(link.into)})' def _gate_rows(self) -> tuple[tuple[str, str | None, str], ...]: """What the weights sum to, as ``(name suffix, where, right-hand side)``. @@ -461,23 +798,27 @@ def _segment_lines(self) -> None: run rather than dividing, which keeps its sense only because the breakpoints are strictly monotone. The domain rows are ``linopy``'s ``_add_lp`` rows under its names, each sitting on the edge of the curve - the mask marks, which is why the mask has to be one run. + the mask marks, which is why the mask has to be one run. Every row here + is written from the link expressions and the breakpoint values, none of + which the block masks, so a ``where`` over the frame alone is conjoined + onto each rather than inherited as the weight rows inherit it. """ - x_link, y_link = self.pw.curve - d = self.pw.over - run = f'({x_link.values} - shift({x_link.values}, along={d}, offset=1, edge=0))' - rise = f'({y_link.values} - shift({y_link.values}, along={d}, offset=1, edge=0))' + pw, emitted, d = self.pw, self.emitted, self.pw.along + x_link, y_link = pw.curve + mask, frame = self.mask, self.frame_mask + dims = (*self.frame, d) + run = f'({x_link.values} - {_back(x_link.values, d, 1)})' + rise = f'({y_link.values} - {_back(y_link.values, d, 1)})' self._constraint( - self.emitted.chord, - [*self.frame, d], + emitted.chord, + dims, f'({y_link.expression}) * {run} {y_link.sign} ' f'{rise} * (({x_link.expression}) - {x_link.values}) + {y_link.values} * {run}', - _neighbours(d, self.mask), + _all_of(frame, _neighbours(d, mask)), ) - edges = ((self.emitted.domain_lo, '>=', 'first'), (self.emitted.domain_hi, '<=', 'last')) - for cname, sense, end in edges: + for cname, sense, end in ((emitted.domain_lo, '>=', 'first'), (emitted.domain_hi, '<=', 'last')): self._constraint( - cname, [*self.frame, d], f'({x_link.expression}) {sense} {x_link.values}', _edge(d, self.mask, end) + cname, dims, f'({x_link.expression}) {sense} {x_link.values}', _all_of(frame, _edge(d, mask, end)) ) @@ -488,7 +829,7 @@ def expand_piecewise(schema: Spec) -> Spec: ``method: sos2`` states, and then that set is written out here too: the binaries are what the method *is*, so the spec that comes back carries no set of its own ([`mathspec.sos.emit`][] is where they are spelled). - Each block's frame and names are read off the program *schema* lowered to. + Each block's rows and names are read off the program *schema* lowered to. """ if not schema.piecewise: return schema diff --git a/src/mathspec/program.py b/src/mathspec/program.py index f32a9955..0014d42f 100644 --- a/src/mathspec/program.py +++ b/src/mathspec/program.py @@ -728,13 +728,32 @@ class ExpressionDeclaration: class Link: """One link of a ``piecewise:`` block: an expression tied to the breakpoints a values parameter holds. - ``sign`` is ``'=='`` where the link is pinned to the curve, and one side - of it where the link is bounded by the curve instead. + ``name`` is the link's key in the block, and ``_`` is the row + the expansion writes for it. ``sign`` is ``'=='`` where the link is pinned + to the curve, and one side of it where the link is bounded by the curve + instead. A link that walks a relation reads the curve's weights through + it, as ``at`` reads an array: ``by`` names the relation, ``over`` the + columns it consumes and ``into`` the ones it produces. ``dims`` is the row + the link builds — the block's frame, or, for a walked link, that frame + with the consumed dims replaced by the produced ones — and ``reads`` says + whether that row reads the block's ``where`` through the relation, which + it does when the mask carries every dim the walk consumes or joins on. """ + name: str expression: Expression values: str + dims: tuple[str, ...] sign: ConstraintSense = '==' + by: str | None = None + over: tuple[str, ...] = () + into: tuple[str, ...] = () + reads: bool = False + + @property + def walks(self) -> bool: + """Whether the link reads the curve's weights through a relation, rather than on the block's own frame.""" + return self.by is not None @dataclass(frozen=True) @@ -748,29 +767,32 @@ class PiecewiseDeclaration: consumer building rows takes the expanded spec. Attributes: - over: The breakpoint dimension. - links: The links, in the order the file wrote them. + along: The dimension each curve runs along. + links: The links, in the order the file wrote them, each with the row + it builds. method: How the weights are restricted. + frame: The ``dims:`` the block builds one curve per coordinate of, in + the order the file wrote them. + where: Which coordinates of ``frame`` have a curve — and, where it + reads ``along`` too, how far each runs — or ``None`` where every + coordinate has a whole curve. activity: The binary the weights sum to, or ``None`` where they sum to 1. - points: The parameter saying how far each curve runs, or ``None``. - frame: The dimensions the block builds one curve per coordinate of, - in declaration order. description: What the file wrote under ``description:``, or ``None``. """ - over: str + along: str links: tuple[Link, ...] method: PiecewiseMethod frame: tuple[str, ...] + where: Mask | None = None activity: str | None = None - points: str | None = None description: str | None = None @property - def nominated(self) -> str | None: - """The block's own values parameter ``points:`` names, so the mask is derived from it — or ``None``.""" - return self.points if self.points in {link.values for link in self.links} else None + def ragged(self) -> bool: + """Whether ``where`` reads ``along``, and so says how far each curve runs rather than only which exist.""" + return self.where is not None and self.along in self.where.dims @property def curve(self) -> tuple[Link, Link]: diff --git a/src/mathspec/spec.py b/src/mathspec/spec.py index ae1767f2..0cb7f8d3 100644 --- a/src/mathspec/spec.py +++ b/src/mathspec/spec.py @@ -626,11 +626,19 @@ def _as_written(self) -> str | dict[str, object]: class PiecewiseLink(_StrictBlock): - """One link of a piecewise block: an expression pinned to a values curve. + """One link of a piecewise block: an expression tied to the curve through a values parameter. Written in YAML as ``[expression, values]`` or ``[expression, values, - sign]`` and serialised back to exactly that form, so a round trip through + sign]``, and serialised back to exactly that form, so a round trip through [`Spec.to_yaml`][] reproduces the file. + + A link that names ``by:``, ``over:`` and ``into:`` reads the curve's + weights through a relation, as ``at`` reads an array, and is written as + a mapping. Its row is the block's ``dims:`` with the consumed columns' + dims replaced by the produced ones, so one link emits a row per fine + coordinate and every one of them reads the curve of the coarse coordinate + it maps to. That is what lets one curve tie as many expressions as the + data says. """ _label: ClassVar[str] = 'a piecewise link' @@ -638,6 +646,35 @@ class PiecewiseLink(_StrictBlock): expression: str values: str sign: ComparisonOperator = '==' + #: The relation the link reads the curve's weights through, where it walks one. + by: str | None = None + #: The relation columns the walk consumes, over the block's own dims. + over: str | list[str] | None = None + #: The relation columns the walk produces, which index the link's rows. + into: str | list[str] | None = None + + @property + def walks(self) -> bool: + """Whether the link reads the curve's weights through a relation, rather than on the block's own dims.""" + return self.by is not None + + @model_validator(mode='after') + def _check_walk(self) -> PiecewiseLink: + written = {'by': self.by, 'over': self.over, 'into': self.into} + if (missing := [k for k, v in written.items() if v is None]) and len(missing) < len(written): + msg = ( + f'a link reads the curve through a relation with by, over and into together — {missing} ' + f'{"is" if len(missing) == 1 else "are"} missing. A walk states the relation, the columns it ' + f'consumes and the columns it produces, as at() does; none is defaulted.' + ) + raise ValueError(msg) + if empty := [k for k in ('over', 'into') if written[k] == []]: + msg = ( + f'{empty[0]}: [] names no column — a walk consumes at least one column of the relation and produces ' + f'at least one. Name a column, or a list of them.' + ) + raise ValueError(msg) + return self @model_validator(mode='before') @classmethod @@ -657,8 +694,19 @@ def __get_pydantic_json_schema__(cls, core_schema: CoreSchema, handler: GetJsonS return _also_written_as(core_schema, handler, list_form) @model_serializer - def _as_list(self) -> list[str]: - return [self.expression, self.values] if self.sign == '==' else [self.expression, self.values, self.sign] + def _as_written(self) -> list[str] | dict[str, str | list[str]]: + """The list form, or the mapping form a walk cannot be written in a list.""" + if not self.walks: + return [self.expression, self.values] if self.sign == '==' else [self.expression, self.values, self.sign] + assert self.by is not None and self.over is not None and self.into is not None + written: dict[str, str | list[str]] = { + 'expression': self.expression, + 'values': self.values, + 'by': self.by, + 'over': self.over, + 'into': self.into, + } + return written if self.sign == '==' else {**written, 'sign': self.sign} #: How a ``piecewise:`` block restricts its interpolation weights, and what @@ -675,44 +723,97 @@ def _as_list(self) -> list[str]: } +#: Why ``convex`` and ``lp`` take exactly two links. The two reasons are not +#: one: ``lp`` needs an abscissa to write a line *against*, and ``convex`` +#: needs one to certify its relaxation against. ``convex``'s own formulation +#: is n-ary — weights on the simplex and a row per link — and only its +#: exactness argument is not. +_TWO_LINKS = { + 'lp': ( + 'It states the curve as its segment lines, and a line is one quantity against another: without ' + 'a link for the abscissa there is no line to write.' + ), + 'convex': ( + 'It relaxes the weights onto the hull, which is exact only where the optimisation pressure meets ' + 'the curve, and the sign on the bounded link is what names that direction. Past two links there ' + 'is no single direction to check against, so the relaxation would ship uncertified.' + ), +} + +#: Why neither takes a link that walks a relation. Again two reasons: ``lp`` +#: cannot tell which of the walked rows is its abscissa, and ``convex`` has no +#: pair of values parameters on one frame to read a shape from. +_NO_WALK = { + 'lp': ( + 'Its segment line is written against an abscissa, and a walked link is one quantity at many fine ' + 'coordinates, so which row plays it is data rather than declaration.' + ), + 'convex': ( + 'It reads one values parameter against another to certify its relaxation, and a walk puts them ' + 'on different frames, so there is no shape left to check.' + ), +} + + class PiecewiseBlock(_StrictBlock): - """N expressions jointly pinned to a breakpoint-indexed piecewise curve. - - Mirrors ``linopy.Spec.add_piecewise_formulation``. Each link is - ``[expression, values_parameter]`` or ``[expression, values_parameter, - sign]``: *expression* is any affine expression string, *values_parameter* - names a parameter carrying the ``over`` dim, and *sign* bounds the link by - the curve instead of pinning it (at most one non-``"=="``, and only with - exactly two links). + """Expressions tied to one breakpoint-indexed piecewise curve per coordinate of ``dims:``. + + Mirrors ``linopy.Spec.add_piecewise_formulation``. ``links:`` maps a name + to ``[expression, values_parameter]`` or ``[expression, values_parameter, + sign]``: *expression* is any affine expression string over ``dims:``, + *values_parameter* names a parameter carrying ``along`` and no dim the + link's row lacks, and *sign* bounds the link by the curve instead of + pinning it. The name is the + link's row in the expansion, ``_``. + + ``dims:`` alone decides how many curves the block builds: one set of + weights per coordinate of it. ``where:`` says which of those coordinates + have a curve, and how far each runs along ``along`` where it reads that + dim too; ``activity:`` whether a curve that exists is switched on. A link + that walks a relation builds a row per fine coordinate, each reading the + one curve its coarse coordinate has, so how many expressions a curve ties + is data. """ _label: ClassVar[str] = 'a piecewise declaration' - #: The breakpoint dimension. - over: str - links: list[PiecewiseLink] + #: The dimension each curve runs along — its breakpoints, in that dimension's declared order. + along: str + #: The curve's frame — one curve per coordinate of it. + dims: list[str] + #: Each link by the name its row takes, ``_``. + links: dict[str, PiecewiseLink] + #: Which coordinates have a curve — none builds one everywhere. Over the + #: frame it says which curves exist; reading ``along`` too, it says which + #: breakpoints each runs through, for curves of unequal length. + where: str | None = None #: Which of [`PIECEWISE_METHODS`][] restricts the weights. method: PiecewiseMethod = 'adjacency' #: What the weights sum to — 1 where absent, or a binary that pins the formulation to 0 when it is 0. activity: str | None = None - #: A boolean parameter saying how far each curve runs, for curves of unequal length. - points: str | None = None description: str | None = None - @property - def nominated(self) -> str | None: - """The block's own values parameter ``points:`` names, so the mask is derived from it — or ``None``.""" - return self.points if self.points in {link.values for link in self.links} else None - @property def curve(self) -> tuple[PiecewiseLink, PiecewiseLink]: """The two links as ``(x, y)``, the bounded one last. Two-link blocks only. """ - x, y = self.links + x, y = self.links.values() return (y, x) if x.sign != '==' else (x, y) + @model_validator(mode='before') + @classmethod + def _check_dims(cls, data: object) -> object: + if isinstance(data, dict) and data.get('dims') is None: + msg = ( + 'a piecewise block states dims:, the dimensions it builds one curve per coordinate of — as a ' + 'variable or a constraint states the coordinates it has. Write the dims its links are over, ' + 'less any a link walks into: dims: [generator, snapshot] for a link on p over [generator, snapshot].' + ) + raise ValueError(msg) + return data + @field_validator('method', mode='wrap') @classmethod def _check_method(cls, v: object, handler: ValidatorFunctionWrapHandler) -> PiecewiseMethod: @@ -725,13 +826,22 @@ def _check_method(cls, v: object, handler: ValidatorFunctionWrapHandler) -> Piec @model_validator(mode='after') def _check_method_shape(self) -> PiecewiseBlock: - if self.method == 'convex' and len(self.links) != 2: + walked = [key for key, link in self.links.items() if link.walks] + if walked and self.method in ('convex', 'lp'): msg = ( - 'method: convex requires exactly two links (the hull relaxation ' - 'is only well-defined for a single y=f(x) curve).' + f"method: {self.method} does not take a link that walks a relation (link '{walked[0]}'). " + f'{_NO_WALK[self.method]} Use method: adjacency or sos2, which state the curve through its ' + f'weights instead.' ) raise ValueError(msg) - if self.method == 'lp' and sum(link.sign != '==' for link in self.links) != 1: + if self.method in ('convex', 'lp') and len(self.links) != 2: + msg = ( + f'method: {self.method} requires exactly two links. {_TWO_LINKS[self.method]} Use method: ' + f'adjacency or sos2, which state the curve through its weights and tie as many links as ' + f'the data says.' + ) + raise ValueError(msg) + if self.method == 'lp' and sum(link.sign != '==' for link in self.links.values()) != 1: msg = ( "method: lp needs exactly one link bounded by the curve — a '<=' or '>=' third " 'element on it. With every link pinned the segment lines have nothing to bound.' @@ -744,16 +854,27 @@ def _check_method_shape(self) -> PiecewiseBlock: @field_validator('links') @classmethod - def _check_links(cls, v: list[PiecewiseLink]) -> list[PiecewiseLink]: - if len(v) < 2: - msg = 'piecewise needs at least two links ([expression, values, sign?]).' + def _check_links(cls, v: dict[str, PiecewiseLink]) -> dict[str, PiecewiseLink]: + if unnamed := [key for key in v if not re.fullmatch(NAME, key)]: + msg = ( + f'links: {unnamed} {"is" if len(unnamed) == 1 else "are"} not a name. A link names the row it ' + f'emits, _, so it is named the way a declaration is — a letter or an underscore, ' + f'then letters, digits or underscores.' + ) raise ValueError(msg) - non_eq = [link.sign for link in v if link.sign != '=='] - if len(non_eq) > 1: - msg = "at most one link may carry a non-'==' sign." + if len(v) < 2 and not any(link.walks for link in v.values()): + msg = ( + 'piecewise needs at least two links ([expression, values, sign?]). One quantity on a curve is ' + 'a bound rather than a curve — a curve ties two or more through shared weights. A single link ' + 'that walks a relation is enough, because how many rows it builds is data.' + ) raise ValueError(msg) - if non_eq and len(v) != 2: - msg = "a non-'==' sign is only supported with exactly two links." + if all(link.sign != '==' for link in v.values()): + msg = ( + 'every link is bounded by the curve, so nothing pins the operating point they are read ' + 'at. The weights are then free, and the block states only that some point on the curve ' + "satisfies the bounds. Pin at least one link with '=='." + ) raise ValueError(msg) return v @@ -780,6 +901,7 @@ class SosBlock(_StrictBlock): _label: ClassVar[str] = 'a sos declaration' variable: str + #: The dimension the set runs along — one set per coordinate of the rest. along: str type: SosType description: str | None = None diff --git a/src/mathspec/typesetting/format.py b/src/mathspec/typesetting/format.py index ee7913b0..862510a1 100644 --- a/src/mathspec/typesetting/format.py +++ b/src/mathspec/typesetting/format.py @@ -58,6 +58,9 @@ 'sos_set', 'curve', 'hull', + 'origin', + 'nonnegative', + 'nonpositive', 'position', 'dual', 'minimize', @@ -69,7 +72,8 @@ #: so no format can be missing one. ``such_that`` is the colon in #: "∀ t ∈ T : condition", ``times`` sits between sets in the legend, #: ``maps_to`` is the → in a coordinate map, ``curve`` and ``hull`` are the two -#: sets a ``piecewise:`` block states its links lie on, and the three +#: sets a ``piecewise:`` block states its links lie on, ``origin``, +#: ``nonnegative`` and ``nonpositive`` the cone its signs add to them, and the three #: translations are three conventions: plain leaves the vacated position absent, #: ``cyclic_*`` wraps, ``edge_*`` fills it with the value it carries as a #: subscript. @@ -104,6 +108,9 @@ 'sos_set': (r'\mathrm{SOS}', 'upright("SOS")'), 'curve': (r'\mathrm{pwl}', 'upright("pwl")'), 'hull': (r'\mathrm{conv}', 'upright("conv")'), + 'origin': (r'\{0\}', '{0}'), + 'nonnegative': (r'\mathbb{R}_{\ge 0}', 'RR_(>= 0)'), + 'nonpositive': (r'\mathbb{R}_{\le 0}', 'RR_(<= 0)'), 'position': (r'\mathrm{pos}', 'upright("pos")'), 'dual': (r'\lambda', 'lambda'), 'minimize': (r'\min', 'min'), diff --git a/src/mathspec/typesetting/walk.py b/src/mathspec/typesetting/walk.py index a34bc360..77f321be 100644 --- a/src/mathspec/typesetting/walk.py +++ b/src/mathspec/typesetting/walk.py @@ -63,8 +63,8 @@ import datetime from collections.abc import Iterable, Mapping - from mathspec._expression_parser import BinaryOperator - from mathspec.program import PiecewiseDeclaration, Program, SosDeclaration + from mathspec._expression_parser import BinaryOperator, ComparisonOperator + from mathspec.program import Link, PiecewiseDeclaration, Program, SosDeclaration from mathspec.typesetting.format import Format from mathspec.typesetting.symbols import Symbols @@ -99,6 +99,10 @@ '>': 'gt', } +#: What a link's sign leaves between its expression and the curve's coordinate: +#: nothing where it is pinned, a half-line where the curve bounds it. +_HALF_LINES: dict[ComparisonOperator, OperatorName] = {'==': 'origin', '>=': 'nonnegative', '<=': 'nonpositive'} + #: Edge policy -> the operator pair that renders it, backward then forward — #: the vacated row dropped, wrapped, or filled. @@ -884,31 +888,76 @@ def _assumption(self, name: str) -> Line: return Line(label=name, left=left, right=right, condition=self._quantifier(frame, self._condition(ctx, where))) def _piecewise(self, name: str) -> Line: - """One ``piecewise:`` block as the curve it states, over the frame it states one per coordinate of. + """One ``piecewise:`` block as the curve it states, over the ``dims:`` it states one per coordinate of. The links' expressions are a point, and the block says that point lies on the piecewise-linear locus through the breakpoints. A bounded link - states one side of the locus instead, so there the locus prints as the - function of the pinned link that it is and the link's own sign says - which side. + states one side of the locus instead. Beside one pinned link the locus + prints as the function of it that it is, and the bounded link's own + sign says which side; beside more, the point lies on the locus plus + the cone the signs span, ``{0}`` for a pinned coordinate and a + half-line for a bounded one. """ block = self.program.piecewise[name] - links = [link.expression for link in block.links] frame = list(block.frame) - ctx = self._context([*frame, block.over]) - locus = self._locus(block, ctx) - bounded = next((i for i, link in enumerate(block.links) if link.sign != '=='), None) - if bounded is None: - left = self._tuple([self._expression(node, ctx) for node in links]) - right = f'{self._op("in")} {locus}' + ctx = self._context([*frame, block.along]) + points, values = zip(*(self._link(link, ctx) for link in block.links), strict=True) + locus = self._locus(block, list(values), block.where if block.ragged else None, ctx) + bounded = [i for i, link in enumerate(block.links) if link.sign != '=='] + if not bounded: + left, right = self._tuple(list(points)), f'{self._op("in")} {locus}' + elif len(block.links) == 2: + (i,) = bounded + left = points[i] + right = f'{self._op(_PREDICATES[block.links[i].sign])} {self.format.apply(locus, points[1 - i])}' else: - pinned = links[1 - bounded] - left = self._expression(links[bounded], ctx) - sign = self._op(_PREDICATES[block.links[bounded].sign]) - right = f'{sign} {self.format.apply(locus, self._expression(pinned, ctx))}' - return Line(label=name, left=left, right=right, condition=self._quantifier(frame, '')) + cone = self.format.joined([self._op(_HALF_LINES[link.sign]) for link in block.links], self._op('times')) + left, right = self._tuple(list(points)), f'{self._op("in")} {locus} {self._op("plus")} {cone}' + condition = '' if block.ragged else self._condition(ctx, block.where) + return Line(label=name, left=left, right=right, condition=self._quantifier(frame, condition)) + + def _link(self, link: Link, ctx: _Context) -> tuple[str, str]: + """One link's point coordinate and its breakpoints, each a family where the link walks a relation. + + A walked link is one coordinate per fine index that maps to the curve's + own, so it prints as the family over those, which is what ties them to + the one curve rather than to a curve each. + """ + dims = list(self.program.parameters[link.values].dims) + if not link.walks: + return self._expression(link.expression, ctx), ctx.indexed(self.symbols.name[link.values], dims) + domain, inner = self._walked(link, ctx) + return ( + self.format.subscript(self.format.parenthesise(self._expression(link.expression, inner)), [domain]), + self.format.subscript( + self.format.parenthesise(inner.indexed(self.symbols.name[link.values], dims)), [domain] + ), + ) + + def _walked(self, link: Link, ctx: _Context) -> tuple[str, _Context]: + """The fine indices a walked link's family runs over, and the context its members read under. - def _locus(self, block: PiecewiseDeclaration, ctx: _Context) -> str: + The members are every produced index whose row in the relation reads + the curve's own coordinate at the consumed columns. + """ + assert link.by is not None + relation = self.program.relations[link.by] + roles = dict(relation.columns) + dummies: dict[str, str] = {} + inner = ctx + for column in link.into: + dummies[column], inner = inner.reducing(roles[column]) + at = {c: dummies.get(c) or ctx.subscript(roles[c]) for c in roles} + fixed = [c for c in relation.values if c in at] + conditions = ( + [f'{self._relation_read(link.by, at, c)} {self._op("equal")} {at[c]}' for c in fixed] + if fixed + else [self._relation_row(link.by, at)] + ) + members = self.format.joined([self._membership(roles[c], dummies[c]) for c in link.into], '') + return f'{members} {self._op("such_that")} {self.format.joined(conditions, self._op("and"))}', inner + + def _locus(self, block: PiecewiseDeclaration, values: list[str], admitted: Mask | None, ctx: _Context) -> str: """The set the links lie on: the curve through the breakpoints, or the hull ``convex`` relaxes it onto. A gate multiplies it, which is what gating a curve does — the weights @@ -916,30 +965,21 @@ def _locus(self, block: PiecewiseDeclaration, ctx: _Context) -> str: curve where it is 1. """ operator = self._op('hull' if block.method == 'convex' else 'curve') - through = self.format.subscript(operator, [self._breakpoints(block, ctx)]) - values = self.format.joined( - [ - ctx.indexed(self.symbols.name[link.values], list(self.program.parameters[link.values].dims)) - for link in block.links - ], - '', - ) - locus = self.format.apply(through, values) + through = self.format.subscript(operator, [self._breakpoints(block, admitted, ctx)]) + locus = self.format.apply(through, self.format.joined(values, '')) gate = self._gate(block, ctx) return f'{gate} {self._op("cdot")} {locus}' if gate else locus - def _breakpoints(self, block: PiecewiseDeclaration, ctx: _Context) -> str: - """Which breakpoints the curve runs through: every one of the dimension, or the ones ``points:`` admits. + def _breakpoints(self, block: PiecewiseDeclaration, admitted: Mask | None, ctx: _Context) -> str: + """Which breakpoints the curve runs through: every one of the dimension, or the ones a ragged ``where:`` admits. - A ``points:`` naming a boolean parameter reads as the flag it is, and - one naming a values parameter as the rows that parameter has, which is - the same reading a ``where`` gives either of them. + A ``where:`` over the frame alone prints on the quantifier instead, + because it says which curves exist rather than how far each runs. """ - over = self._membership(block.over) - if block.points is None: + over = self._membership(block.along) + if admitted is None: return over - admitted = ParameterDefined(block.points, tuple(self.program.parameters[block.points].dims)) - return f'{over} {self._op("such_that")} {self._predicate(admitted, ctx)}' + return f'{over} {self._op("such_that")} {self._predicate(admitted.root, ctx)}' def _gate(self, block: PiecewiseDeclaration, ctx: _Context) -> str: """The factor an ``activity:`` puts on the locus, or ``''`` where the block has none. diff --git a/src/mathspec/validation.py b/src/mathspec/validation.py index 3b7424ae..052020b8 100644 --- a/src/mathspec/validation.py +++ b/src/mathspec/validation.py @@ -35,6 +35,7 @@ from pathlib import Path from mathspec.program import Program + from mathspec.spec import PiecewiseBlock, PiecewiseLink def to_spec(spec: str | Path | Mapping[str, object] | Spec) -> Spec: @@ -66,16 +67,38 @@ def to_spec(spec: str | Path | Mapping[str, object] | Spec) -> Spec: def emitted_name_errors(schema: Spec, program: Program) -> list[str]: - """Every name a set or curve of *program* would write out that *schema* already declares. + """Every name a set or curve of *program* would write out that *schema* declares, or another one writes too. Read off the program rather than the file, since what a curve writes is decided by the curve as lowered — its links, its method, its mask. """ - by_block = [ + emitters = [ *((f"Sos '{name}'", EmittedSet.of(name, block.sos_type).by_kind) for name, block in program.sos.items()), *((f"piecewise '{name}'", EmittedCurve.of(name, curve).by_kind) for name, curve in program.piecewise.items()), ] - return [error for context, by_kind in by_block for error in _collisions(schema, context, by_kind)] + errors = [ + f"piecewise '{name}': link '{row.removeprefix(f'{name}_')}' names its row '{row}', which the block " + f'already writes for itself. Rename the link.' + for name, curve in program.piecewise.items() + for row in EmittedCurve.of(name, curve).reused + ] + for context, by_kind in emitters: + errors.extend(_collisions(schema, context, by_kind)) + errors.extend(_shared(emitters)) + return errors + + +def _shared(emitters: Iterable[tuple[str, Iterable[tuple[str, Iterable[str]]]]]) -> Iterator[str]: + """The refusal for each name two expansions both write, since the second would overwrite the first.""" + first: dict[tuple[str, str], str] = {} + for context, by_kind in emitters: + for kind, names in by_kind: + for one in names: + if (owner := first.setdefault((kind, one), context)) != context: + yield ( + f"{context}: its expansion writes {kind} '{one}', which {owner} also writes. Rename one of " + f'the blocks, or the link whose row it is.' + ) def reference_errors(schema: Spec) -> list[str]: @@ -296,49 +319,67 @@ def _sos_bounds(schema: Spec) -> Iterator[str]: def _piecewise_references(schema: Spec) -> Iterator[str]: - """A curve runs along a declared dimension through numeric values parameters carrying it, gated by a binary, masked by a bool.""" - for name, pw in schema.piecewise.items(): + """Every declaration a block names by key exists and has the shape the block needs. + + The breakpoint dim, the frame ``dims:`` states, each link's values + parameter, and the gate. What a link's expression, its walk and the + where carry is resolution's to say, and whether the pieces fit together + is decided as the block is lowered + ([`declaration_of`][mathspec.piecewise.declaration_of]). + """ + for name, block in schema.piecewise.items(): context = f"piecewise '{name}'" - if pw.over not in schema.dimensions: - yield undeclared_dimension('piecewise', name, pw.over) + if block.along not in schema.dimensions: + yield undeclared_dimension('piecewise', name, block.along) continue - for i, link in enumerate(pw.links): - if link.values not in schema.parameters: - yield f"{context}: link {i} values references undeclared parameter '{link.values}'" - elif (dtype := schema.parameters[link.values].dtype) not in NUMERIC_DTYPES: - yield ( - f"{context}: link {i} values parameter '{link.values}' is declared dtype: {dtype}, and a " - f'breakpoint is a number. Declare it dtype: float or int.' - ) - elif pw.over not in schema.parameters[link.values].dims: - yield ( - f"{context}: link {i} values parameter '{link.values}' must carry dim " - f"'{pw.over}' (has {schema.parameters[link.values].dims})" - ) - if (activity := pw.activity) is not None: - if activity not in schema.variables: + for d in block.dims: + if d not in schema.dimensions: + yield undeclared_dimension('piecewise', name, d) + elif d == block.along: yield ( - f"{context}: activity '{activity}' is not a declared variable. A gate is a binary variable; " - f'declare it, or drop activity: for weights that sum to 1.' + f"{context}: dims carries '{block.along}', the breakpoint dim. The frame is what the block " + f'builds one curve per, and every curve runs along the breakpoints — drop it from dims:.' ) - elif schema.variables[activity].domain != 'binary': - yield f"{context}: activity variable '{activity}' must be binary" - if (points := pw.points) is None or pw.nominated is not None: + if len(set(block.dims)) != len(block.dims): + yield f'{context}: dims repeats a dimension: {block.dims}' + for key, link in block.links.items(): + yield from _piecewise_link_shape(schema, name, block, key, link) + if (activity := block.activity) is None: continue - if points not in schema.parameters: - yield f"{context}: points references undeclared parameter '{points}'" - elif (dtype := schema.parameters[points].dtype) != 'bool': + if activity not in schema.variables: yield ( - f"{context}: points parameter '{points}' is {dtype}, and a mask is a bool parameter — one " - f'saying, per breakpoint, whether the curve reaches it. Declare it dtype: bool.' + f"{context}: activity '{activity}' is not a declared variable. A gate is a binary variable; " + f'declare it, or drop activity: for weights that sum to 1.' ) - elif pw.over not in schema.parameters[points].dims: + elif schema.variables[activity].domain != 'binary': + yield f"{context}: activity variable '{activity}' must be binary" + elif stray := [d for d in schema.variables[activity].dims if d not in block.dims]: yield ( - f"{context}: points parameter '{points}' must carry dim '{pw.over}' — " - f'it says how far each curve runs along it (has {schema.parameters[points].dims})' + f"{context}: activity '{activity}' carries {stray}, which dims {block.dims} does not. The gate " + f'switches the curve of one coordinate of dims:, and a gate varying along {stray} would need a ' + f'curve per coordinate of it — add {stray} to dims:, or gate with a variable over dims:.' ) +def _piecewise_link_shape( + schema: Spec, name: str, block: PiecewiseBlock, key: str, link: PiecewiseLink +) -> Iterator[str]: + """One link's values parameter exists as the link needs it.""" + context = f"piecewise '{name}' link '{key}'" + if link.values not in schema.parameters: + yield f"{context}: values references undeclared parameter '{link.values}'" + elif (dtype := schema.parameters[link.values].dtype) not in NUMERIC_DTYPES: + yield ( + f"{context}: values parameter '{link.values}' is declared dtype: {dtype}, and a breakpoint is a " + f'number. Declare it dtype: float or int.' + ) + elif block.along not in schema.parameters[link.values].dims: + yield ( + f"{context}: values parameter '{link.values}' must carry dim " + f"'{block.along}' (has {schema.parameters[link.values].dims})" + ) + + def _collisions(schema: Spec, context: str, by_kind: Iterable[tuple[str, Iterable[str]]]) -> Iterator[str]: """The refusal for each name *context*'s expansion writes that the file already declares, by kind. diff --git a/tests/expand/curve-activity/after.yaml b/tests/expand/curve-activity/after.yaml index 4ec42085..44de3ec2 100644 --- a/tests/expand/curve-activity/after.yaml +++ b/tests/expand/curve-activity/after.yaml @@ -22,10 +22,10 @@ constraints: curve_convexity: dims: [] expression: sum(curve_lam, over=bp) == (running) - curve_link0: + curve_x: dims: [] expression: (x) == sum(curve_lam * x_bp, over=bp) - curve_link1: + curve_y: dims: [] expression: (y) == sum(curve_lam * y_bp, over=bp) curve_pick: @@ -42,4 +42,4 @@ assumptions: piecewise 'curve': every breakpoint the curve runs through needs a row in 'x_bp', 'y_bp' — a missing row is read as a zero rather than as a shorter curve, so it sits the curve on the origin. Attach the rows, or declare - points: to say how far the curve runs. + where: to say how far the curve runs. diff --git a/tests/expand/curve-activity/before.yaml b/tests/expand/curve-activity/before.yaml index 41948c3d..88865dff 100644 --- a/tests/expand/curve-activity/before.yaml +++ b/tests/expand/curve-activity/before.yaml @@ -12,8 +12,9 @@ variables: piecewise: curve: - over: bp + along: bp + dims: [] activity: running links: - - [x, x_bp] - - [y, y_bp] + x: [x, x_bp] + y: [y, y_bp] diff --git a/tests/expand/curve-adjacency-points/after.yaml b/tests/expand/curve-adjacency-points/after.yaml index 0dd6a7d6..1f2e13f7 100644 --- a/tests/expand/curve-adjacency-points/after.yaml +++ b/tests/expand/curve-adjacency-points/after.yaml @@ -22,12 +22,15 @@ variables: constraints: curve_convexity: dims: [] + where: count(x_bp, over=bp) > 0 expression: sum(curve_lam, over=bp) == 1 - curve_link0: + curve_x: dims: [] + where: count(x_bp, over=bp) > 0 expression: (x) == sum(curve_lam * x_bp, over=bp) - curve_link1: + curve_y: dims: [] + where: count(x_bp, over=bp) > 0 expression: (y) == sum(curve_lam * y_bp, over=bp) curve_pick: dims: [] @@ -44,10 +47,11 @@ assumptions: piecewise 'curve': every breakpoint the curve runs through needs a row in 'x_bp', 'y_bp' — a missing row is read as a zero rather than as a shorter curve, so it sits the curve on the origin. Attach the rows, or narrow - points: 'x_bp' to where the curve runs. + where: 'x_bp' to where the curve runs. curve_contiguous: holds: count(x_bp AND NOT shift(x_bp, along=bp, offset=1), over=bp) == 1 + where: count(x_bp, over=bp) > 0 description: >- - piecewise 'curve': points: 'x_bp' must mark a consecutive run of at least + piecewise 'curve': where: 'x_bp' must mark a consecutive run of at least one breakpoint per curve — the weights are nonzero only on two neighbouring breakpoints, and a gap leaves no neighbour across it. diff --git a/tests/expand/curve-adjacency-points/before.yaml b/tests/expand/curve-adjacency-points/before.yaml index 45b82dfe..af3612ac 100644 --- a/tests/expand/curve-adjacency-points/before.yaml +++ b/tests/expand/curve-adjacency-points/before.yaml @@ -11,8 +11,9 @@ variables: piecewise: curve: - over: bp - points: x_bp + along: bp + dims: [] + where: x_bp links: - - [x, x_bp] - - [y, y_bp] + x: [x, x_bp] + y: [y, y_bp] diff --git a/tests/expand/curve-adjacency/after.yaml b/tests/expand/curve-adjacency/after.yaml index 317493b6..c4ff7044 100644 --- a/tests/expand/curve-adjacency/after.yaml +++ b/tests/expand/curve-adjacency/after.yaml @@ -21,10 +21,10 @@ constraints: curve_convexity: dims: [] expression: sum(curve_lam, over=bp) == 1 - curve_link0: + curve_x: dims: [] expression: (x) == sum(curve_lam * x_bp, over=bp) - curve_link1: + curve_y: dims: [] expression: (y) == sum(curve_lam * y_bp, over=bp) curve_pick: @@ -41,4 +41,4 @@ assumptions: piecewise 'curve': every breakpoint the curve runs through needs a row in 'x_bp', 'y_bp' — a missing row is read as a zero rather than as a shorter curve, so it sits the curve on the origin. Attach the rows, or declare - points: to say how far the curve runs. + where: to say how far the curve runs. diff --git a/tests/expand/curve-adjacency/before.yaml b/tests/expand/curve-adjacency/before.yaml index 133038e2..1782e097 100644 --- a/tests/expand/curve-adjacency/before.yaml +++ b/tests/expand/curve-adjacency/before.yaml @@ -11,7 +11,8 @@ variables: piecewise: curve: - over: bp + along: bp + dims: [] links: - - [x, x_bp] - - [y, y_bp] + x: [x, x_bp] + y: [y, y_bp] diff --git a/tests/expand/curve-convex/after.yaml b/tests/expand/curve-convex/after.yaml index 4d69388a..1a99c00c 100644 --- a/tests/expand/curve-convex/after.yaml +++ b/tests/expand/curve-convex/after.yaml @@ -17,10 +17,10 @@ constraints: curve_convexity: dims: [] expression: sum(curve_lam, over=bp) == 1 - curve_link0: + curve_x: dims: [] expression: (x) == sum(curve_lam * x_bp, over=bp) - curve_link1: + curve_y: dims: [] expression: (y) >= sum(curve_lam * y_bp, over=bp) @@ -31,7 +31,7 @@ assumptions: piecewise 'curve': every breakpoint the curve runs through needs a row in 'x_bp', 'y_bp' — a missing row is read as a zero rather than as a shorter curve, so it sits the curve on the origin. Attach the rows, or declare - points: to say how far the curve runs. + where: to say how far the curve runs. curve_increasing: where: position(bp) > 0 holds: shift(x_bp, along=bp, offset=1, edge=0) < x_bp diff --git a/tests/expand/curve-convex/before.yaml b/tests/expand/curve-convex/before.yaml index 269b4761..3fa71750 100644 --- a/tests/expand/curve-convex/before.yaml +++ b/tests/expand/curve-convex/before.yaml @@ -11,8 +11,9 @@ variables: piecewise: curve: - over: bp + along: bp + dims: [] method: convex links: - - [x, x_bp] - - [y, y_bp, ">="] + x: [x, x_bp] + y: [y, y_bp, ">="] diff --git a/tests/expand/curve-lp-points/after.yaml b/tests/expand/curve-lp-points/after.yaml index 9a24e7b0..19a70cea 100644 --- a/tests/expand/curve-lp-points/after.yaml +++ b/tests/expand/curve-lp-points/after.yaml @@ -34,7 +34,7 @@ assumptions: piecewise 'curve': every breakpoint the curve runs through needs a row in 'x_bp', 'y_bp' — a missing row is read as a zero rather than as a shorter curve, so it sits the curve on the origin. Attach the rows, or narrow - points: 'x_bp' to where the curve runs. + where: 'x_bp' to where the curve runs. curve_increasing: where: x_bp AND shift(x_bp, along=bp, offset=1) holds: shift(x_bp, along=bp, offset=1, edge=0) < x_bp @@ -55,6 +55,7 @@ assumptions: shape. curve_breakpoints: holds: count(x_bp, over=bp) >= 2 + where: count(x_bp, over=bp) > 0 description: >- piecewise 'curve': method: lp needs at least two breakpoints per curve — the method *is* its segment lines, so a curve with no segment states @@ -62,7 +63,8 @@ assumptions: adjacency, sos2 or convex, which pin it to the points it does have. curve_contiguous: holds: count(x_bp AND NOT shift(x_bp, along=bp, offset=1), over=bp) == 1 + where: count(x_bp, over=bp) > 0 description: >- - piecewise 'curve': points: 'x_bp' must mark a consecutive run of at least + piecewise 'curve': where: 'x_bp' must mark a consecutive run of at least one breakpoint per curve — the chord row joins a breakpoint to the one before it, and the domain rows sit on the curve's own first and last. diff --git a/tests/expand/curve-lp-points/before.yaml b/tests/expand/curve-lp-points/before.yaml index 17748873..9e006175 100644 --- a/tests/expand/curve-lp-points/before.yaml +++ b/tests/expand/curve-lp-points/before.yaml @@ -11,9 +11,10 @@ variables: piecewise: curve: - over: bp + along: bp + dims: [] method: lp - points: x_bp + where: x_bp links: - - [x, x_bp] - - [y, y_bp, ">="] + x: [x, x_bp] + y: [y, y_bp, ">="] diff --git a/tests/expand/curve-lp/after.yaml b/tests/expand/curve-lp/after.yaml index 0c7f627b..b1d5af78 100644 --- a/tests/expand/curve-lp/after.yaml +++ b/tests/expand/curve-lp/after.yaml @@ -33,7 +33,7 @@ assumptions: piecewise 'curve': every breakpoint the curve runs through needs a row in 'x_bp', 'y_bp' — a missing row is read as a zero rather than as a shorter curve, so it sits the curve on the origin. Attach the rows, or declare - points: to say how far the curve runs. + where: to say how far the curve runs. curve_increasing: where: position(bp) > 0 holds: shift(x_bp, along=bp, offset=1, edge=0) < x_bp diff --git a/tests/expand/curve-lp/before.yaml b/tests/expand/curve-lp/before.yaml index 7f281533..abc2f843 100644 --- a/tests/expand/curve-lp/before.yaml +++ b/tests/expand/curve-lp/before.yaml @@ -11,8 +11,9 @@ variables: piecewise: curve: - over: bp + along: bp + dims: [] method: lp links: - - [x, x_bp] - - [y, y_bp, ">="] + x: [x, x_bp] + y: [y, y_bp, ">="] diff --git a/tests/expand/curve-sos2-piecewise/after.yaml b/tests/expand/curve-sos2-piecewise/after.yaml index 2efdb329..17891cf3 100644 --- a/tests/expand/curve-sos2-piecewise/after.yaml +++ b/tests/expand/curve-sos2-piecewise/after.yaml @@ -17,10 +17,10 @@ constraints: curve_convexity: dims: [] expression: sum(curve_lam, over=bp) == 1 - curve_link0: + curve_x: dims: [] expression: (x) == sum(curve_lam * x_bp, over=bp) - curve_link1: + curve_y: dims: [] expression: (y) == sum(curve_lam * y_bp, over=bp) @@ -37,4 +37,4 @@ assumptions: piecewise 'curve': every breakpoint the curve runs through needs a row in 'x_bp', 'y_bp' — a missing row is read as a zero rather than as a shorter curve, so it sits the curve on the origin. Attach the rows, or declare - points: to say how far the curve runs. + where: to say how far the curve runs. diff --git a/tests/expand/curve-sos2-piecewise/before.yaml b/tests/expand/curve-sos2-piecewise/before.yaml index c3c82ec7..1709fe97 100644 --- a/tests/expand/curve-sos2-piecewise/before.yaml +++ b/tests/expand/curve-sos2-piecewise/before.yaml @@ -11,8 +11,9 @@ variables: piecewise: curve: - over: bp + along: bp + dims: [] method: sos2 links: - - [x, x_bp] - - [y, y_bp] + x: [x, x_bp] + y: [y, y_bp] diff --git a/tests/expand/curve-sos2/after.yaml b/tests/expand/curve-sos2/after.yaml index 317493b6..c4ff7044 100644 --- a/tests/expand/curve-sos2/after.yaml +++ b/tests/expand/curve-sos2/after.yaml @@ -21,10 +21,10 @@ constraints: curve_convexity: dims: [] expression: sum(curve_lam, over=bp) == 1 - curve_link0: + curve_x: dims: [] expression: (x) == sum(curve_lam * x_bp, over=bp) - curve_link1: + curve_y: dims: [] expression: (y) == sum(curve_lam * y_bp, over=bp) curve_pick: @@ -41,4 +41,4 @@ assumptions: piecewise 'curve': every breakpoint the curve runs through needs a row in 'x_bp', 'y_bp' — a missing row is read as a zero rather than as a shorter curve, so it sits the curve on the origin. Attach the rows, or declare - points: to say how far the curve runs. + where: to say how far the curve runs. diff --git a/tests/expand/curve-sos2/before.yaml b/tests/expand/curve-sos2/before.yaml index c3c82ec7..1709fe97 100644 --- a/tests/expand/curve-sos2/before.yaml +++ b/tests/expand/curve-sos2/before.yaml @@ -11,8 +11,9 @@ variables: piecewise: curve: - over: bp + along: bp + dims: [] method: sos2 links: - - [x, x_bp] - - [y, y_bp] + x: [x, x_bp] + y: [y, y_bp] diff --git a/tests/test_advice.py b/tests/test_advice.py index 679d491f..48ac650e 100644 --- a/tests/test_advice.py +++ b/tests/test_advice.py @@ -41,7 +41,7 @@ dimensions={'g': {'dtype': 'str'}, 'h': {'dtype': 'str'}, 'bp': {'dtype': 'int'}}, parameters={'c': {'dims': ['g']}, 'bp_x': {'dims': ['bp']}, 'bp_y': {'dims': ['bp']}}, variables={'p': {'dims': ['g']}, 'cost': {'dims': ['g']}}, - piecewise={'curve': {'over': 'bp', 'links': [['p', 'bp_x'], ['cost', 'bp_y']]}}, + piecewise={'curve': {'along': 'bp', 'dims': ['g'], 'links': {'p': ['p', 'bp_x'], 'cost': ['cost', 'bp_y']}}}, ) diff --git a/tests/test_boundedness.py b/tests/test_boundedness.py index 1d456cc9..eba5baf9 100644 --- a/tests/test_boundedness.py +++ b/tests/test_boundedness.py @@ -106,7 +106,9 @@ def test_a_named_constant_coefficient_carries_its_sign(objective, side): 'dimensions.bp': {'dtype': 'int'}, 'parameters.bp_x': {'dims': ['bp']}, 'parameters.bp_y': {'dims': ['bp']}, - 'piecewise': {'curve': {'over': 'bp', 'links': [['v', 'bp_x'], ['w', 'bp_y']]}}, + 'piecewise': { + 'curve': {'along': 'bp', 'dims': ['g'], 'links': {'v': ['v', 'bp_x'], 'w': ['w', 'bp_y']}} + }, }, id='carried-by-a-curve', ), diff --git a/tests/test_canonical.py b/tests/test_canonical.py index 2b645f47..6943a8f2 100644 --- a/tests/test_canonical.py +++ b/tests/test_canonical.py @@ -202,7 +202,8 @@ def _commitment_with_its_cases_reversed() -> dict[str, object]: def _piecewise_with_its_links_reversed() -> dict[str, object]: raw = raw_of(EXAMPLES / 'piecewise.yaml') - return varied(raw, **{'piecewise.cost_curve.links': raw['piecewise']['cost_curve']['links'][::-1]}) + links = raw['piecewise']['cost_curve']['links'] + return varied(raw, **{'piecewise.cost_curve.links': dict(reversed(links.items()))}) @pytest.mark.parametrize( diff --git a/tests/test_expand.py b/tests/test_expand.py index 49b1a90e..0fa413d2 100644 --- a/tests/test_expand.py +++ b/tests/test_expand.py @@ -31,8 +31,8 @@ CURVE, **{ 'piecewise.cost_curve.method': 'lp', - 'piecewise.cost_curve.points': 'bp_x', - 'piecewise.cost_curve.links': [['p', 'bp_x'], ['op_cost', 'bp_y', '>=']], + 'piecewise.cost_curve.where': 'bp_x', + 'piecewise.cost_curve.links': {'p': ['p', 'bp_x'], 'op_cost': ['op_cost', 'bp_y', '>=']}, }, ) diff --git a/tests/test_piecewise.py b/tests/test_piecewise.py index eed8ecde..c557424c 100644 --- a/tests/test_piecewise.py +++ b/tests/test_piecewise.py @@ -16,9 +16,10 @@ import pytest from mathspec.errors import LanguageError, SchemaError -from mathspec.piecewise import expand_piecewise +from mathspec.piecewise import Emitted, assumptions_of, expand_piecewise from mathspec.program import Assumption, Variable, assumption_message from mathspec.spec import Curvature +from mathspec.validation import to_spec from tests.fixtures import DISPATCH_MODEL, expanded, raw_of, schema_of, varied #: Larger than a minimal probe on purpose: a curve that exercises adjacency @@ -43,10 +44,11 @@ piecewise: cost_curve: - over: bp + along: bp + dims: [snapshot] links: - - [p, bp_x] - - [op_cost, bp_y] + p: [p, bp_x] + op_cost: [op_cost, bp_y] constraints: balance: @@ -66,17 +68,18 @@ raw_of(NONCONVEX_YAML), **{ 'piecewise.cost_curve.method': 'lp', - 'piecewise.cost_curve.links': [['p', 'bp_x'], ['op_cost', 'bp_y', '>=']], + 'piecewise.cost_curve.links': {'p': ['p', 'bp_x'], 'op_cost': ['op_cost', 'bp_y', '>=']}, 'variables.running': {'dims': ['snapshot'], 'domain': 'binary'}, }, ) #: The ``lp`` curve masked by one of its own values-parameters, so every check a block can carry is on it. -LP_MASKED = varied(LP, **{'piecewise.cost_curve.points': 'bp_x'}) +LP_MASKED = varied(LP, **{'piecewise.cost_curve.where': 'bp_x'}) #: Two dims in the frame, so the emitted ``dims`` has an order to get wrong. TWO_DIM = varied( raw_of(NONCONVEX_YAML), **{ 'dimensions.generator': {'dtype': 'str'}, + 'piecewise.cost_curve.dims': ['snapshot', 'generator'], 'parameters.bp_x.dims': ['generator', 'bp'], 'parameters.bp_y.dims': ['generator', 'bp'], 'variables.p.dims': ['snapshot', 'generator'], @@ -187,18 +190,16 @@ def test_expansion_is_idempotent(): @pytest.mark.parametrize( - 'order', + 'dims', [ - pytest.param(['snapshot', 'generator', 'bp'], id='snapshot-first'), - pytest.param(['generator', 'snapshot', 'bp'], id='generator-first'), + pytest.param(['snapshot', 'generator'], id='snapshot-first'), + pytest.param(['generator', 'snapshot'], id='generator-first'), ], ) -def test_the_emitted_foreach_follows_declaration_order(order): - """The frame is a set until something orders it, and a set iterates the - same way for the same names within one process — so a run that reads the - set rather than the declaration fails one of the two orderings.""" - schema = schema_of(TWO_DIM, dimensions={d: TWO_DIM['dimensions'][d] for d in order}) - assert expand_piecewise(schema).variables['cost_curve_lam'].dims == order +def test_the_emitted_foreach_follows_the_dims_the_block_writes(dims): + """The weights are over ``dims:`` as written, then the breakpoint dim, whatever order the dimensions are declared in.""" + schema = schema_of(TWO_DIM, **{'piecewise.cost_curve.dims': dims}) + assert expand_piecewise(schema).variables['cost_curve_lam'].dims == [*dims, 'bp'] @pytest.mark.parametrize( @@ -216,9 +217,9 @@ def test_any_affine_expression_is_a_legal_link(link): NONCONVEX_YAML, macros={'twice': {'args': ['x'], 'template': 'x * 2'}}, expressions={'doubled': 'p * 2'}, - **{'piecewise.cost_curve.links': [[link, 'bp_x'], ['op_cost', 'bp_y']]}, + **{'piecewise.cost_curve.links': {'x': [link, 'bp_x'], 'op_cost': ['op_cost', 'bp_y']}}, ) - assert expand_piecewise(schema).constraints['cost_curve_link0'].expression.startswith(f'({link}) ==') + assert expand_piecewise(schema).constraints['cost_curve_x'].expression.startswith(f'({link}) ==') @pytest.mark.parametrize( @@ -226,25 +227,19 @@ def test_any_affine_expression_is_a_legal_link(link): [ pytest.param( NONCONVEX_YAML, - {'piecewise.cost_curve.links': [['p', 'bp_x', '<='], ['op_cost', 'bp_y', '>=']]}, - 'at most one link', - id='at-most-one-link', + {'piecewise.cost_curve.links': {'p': ['p', 'bp_x', '<='], 'op_cost': ['op_cost', 'bp_y', '>=']}}, + 'nothing pins the operating point', + id='every-link-bounded', ), pytest.param( NONCONVEX_YAML, - {'piecewise.cost_curve.links': [['p', 'bp_x']]}, + {'piecewise.cost_curve.links': {'p': ['p', 'bp_x']}}, 'at least two links', id='a-single-link', ), pytest.param( NONCONVEX_YAML, - {'piecewise.cost_curve.links': [['p', 'bp_x'], ['op_cost', 'bp_y', '>='], ['p', 'bp_x']]}, - "a non-'==' sign is only supported with exactly two links", - id='a-bound-link-among-three', - ), - pytest.param( - NONCONVEX_YAML, - {'piecewise.cost_curve.links': [['p', 'bp_x'], ['op_cost', 'bp_y', '>=', 'extra']]}, + {'piecewise.cost_curve.links': {'p': ['p', 'bp_x'], 'op_cost': ['op_cost', 'bp_y', '>=', 'extra']}}, r'each link must be \[expression, values\] or \[expression, values, sign\]', id='a-link-of-four-elements', ), @@ -252,7 +247,7 @@ def test_any_affine_expression_is_a_legal_link(link): NONCONVEX_YAML, { 'piecewise.cost_curve.method': 'convex', - 'piecewise.cost_curve.links': [['p', 'bp_x'], ['op_cost', 'bp_y'], ['p', 'bp_x']], + 'piecewise.cost_curve.links': {'p': ['p', 'bp_x'], 'op_cost': ['op_cost', 'bp_y'], 'p2': ['p', 'bp_x']}, }, 'exactly two links', id='convex-needs-exactly-two-links', @@ -271,27 +266,21 @@ def test_any_affine_expression_is_a_legal_link(link): ), pytest.param( NONCONVEX_YAML, - {'piecewise.cost_curve.links': [['p', 'bp_x'], ['op_cost', 'nope']]}, + {'piecewise.cost_curve.links': {'p': ['p', 'bp_x'], 'op_cost': ['op_cost', 'nope']}}, "undeclared parameter 'nope'", id='undeclared-parameter', ), - pytest.param( - NONCONVEX_YAML, - {'parameters.reach': {'dims': ['bp']}, 'piecewise.cost_curve.points': 'reach'}, - "points parameter 'reach' is float, and a mask is a bool parameter", - id='points-that-are-not-a-mask', - ), pytest.param( LP, - {'piecewise.cost_curve.links': [['p', 'bp_x'], ['op_cost', 'bp_y']]}, + {'piecewise.cost_curve.links': {'p': ['p', 'bp_x'], 'op_cost': ['op_cost', 'bp_y']}}, 'needs exactly one link bounded by the curve', id='lp-with-both-links-pinned', ), pytest.param( LP, - {'piecewise.cost_curve.links': [['p', 'bp_x'], ['op_cost', 'bp_y'], ['p', 'bp_x']]}, - 'needs exactly one link bounded by the curve', - id='lp-with-three-links-none-bounded', + {'piecewise.cost_curve.links': {'p': ['p', 'bp_x'], 'op_cost': ['op_cost', 'bp_y'], 'p2': ['p', 'bp_x']}}, + 'requires exactly two links', + id='lp-with-three-links', ), pytest.param( LP, @@ -302,49 +291,43 @@ def test_any_affine_expression_is_a_legal_link(link): pytest.param( NONCONVEX_YAML, {'parameters.bp_x.dtype': 'bool'}, - "link 0 values parameter 'bp_x' is declared dtype: bool, and a breakpoint is a number", + "link 'p': values parameter 'bp_x' is declared dtype: bool, and a breakpoint is a number", id='values-that-are-not-numbers', ), pytest.param( LP, - {'piecewise.cost_curve.links': [['load', 'bp_x'], ['op_cost', 'bp_y', '>=']]}, - "link 0: method: lp bounds the curve's domain by rows comparing this link's expression", + {'piecewise.cost_curve.links': {'x': ['load', 'bp_x'], 'op_cost': ['op_cost', 'bp_y', '>=']}}, + "link 'x': method: lp bounds the curve's domain by rows comparing this link's expression", id='lp-with-an-x-link-carrying-no-variable', ), pytest.param( NONCONVEX_YAML, {'parameters.bp_x.dims': []}, - "link 0 values parameter 'bp_x' must carry dim 'bp'", + "link 'p': values parameter 'bp_x' must carry dim 'bp'", id='a-breakpoint-parameter-without-the-breakpoint-dim', ), pytest.param( NONCONVEX_YAML, - {'piecewise.cost_curve.points': 'nope'}, - "points references undeclared parameter 'nope'", - id='undeclared-points', - ), - pytest.param( - NONCONVEX_YAML, - {'parameters.reach': {'dims': [], 'dtype': 'bool'}, 'piecewise.cost_curve.points': 'reach'}, - "points parameter 'reach' must carry dim 'bp'", - id='points-without-the-breakpoint-dim', + {'piecewise.cost_curve.where': 'nope'}, + "piecewise 'cost_curve' where: 'nope' not found", + id='an-undeclared-name-in-the-where', ), pytest.param( NONCONVEX_YAML, - {'piecewise.cost_curve.links': [['p + bp_x', 'bp_x'], ['op_cost', 'bp_y']]}, - "link 0 expression already carries the breakpoint dim 'bp'", + {'piecewise.cost_curve.links': {'p': ['p + bp_x', 'bp_x'], 'op_cost': ['op_cost', 'bp_y']}}, + "link 'p' expression already carries the breakpoint dim 'bp'", id='a-link-carrying-the-breakpoint-dim', ), pytest.param( NONCONVEX_YAML, {'variables.u': {'dims': ['snapshot', 'bp'], 'domain': 'binary'}, 'piecewise.cost_curve.activity': 'u'}, - "activity already carries the breakpoint dim 'bp'", - id='a-gate-carrying-the-breakpoint-dim', + r"activity 'u' carries \['bp'\], which dims \['snapshot'\] does not", + id='a-gate-carrying-a-dim-the-block-does-not', ), pytest.param( NONCONVEX_YAML, {'dimensions.generator': {'dtype': 'str'}, 'parameters.bp_x.dims': ['generator', 'bp']}, - r"values parameter 'bp_x' carries \['generator'\], which no link expression does", + r"link 'p' values parameter 'bp_x' carries \['generator'\], which its row", id='a-breakpoint-varying-along-a-dim-no-link-carries', ), pytest.param( @@ -352,10 +335,10 @@ def test_any_affine_expression_is_a_legal_link(link): { 'dimensions.generator': {'dtype': 'str'}, 'parameters.reach': {'dims': ['generator', 'bp'], 'dtype': 'bool'}, - 'piecewise.cost_curve.points': 'reach', + 'piecewise.cost_curve.where': 'reach', }, - r"points parameter 'reach' carries \['generator'\], which the links do not", - id='a-mask-adding-a-coordinate-the-curve-does-not-have', + r"where 'reach' tests \['generator'\], which dims \['snapshot'\] does not carry", + id='a-where-adding-a-coordinate-the-curve-does-not-have', ), ], ) @@ -376,10 +359,13 @@ def test_a_malformed_block_is_refused(model, patch, match): ], ) def test_a_link_outside_the_language_is_named_where_the_user_wrote_it(link_expression, message): - """Lowering would catch these too, but naming ``cost_curve_link0`` — a declaration the user never wrote.""" + """Lowering would catch these too, but naming ``cost_curve_x`` — a declaration the user never wrote.""" with pytest.raises(SchemaError, match=message) as exc: - schema_of(NONCONVEX_YAML, **{'piecewise.cost_curve.links': [[link_expression, 'bp_x'], ['op_cost', 'bp_y']]}) - assert "piecewise 'cost_curve' link 0" in str(exc.value) + schema_of( + NONCONVEX_YAML, + **{'piecewise.cost_curve.links': {'x': [link_expression, 'bp_x'], 'op_cost': ['op_cost', 'bp_y']}}, + ) + assert "piecewise 'cost_curve' link 'x'" in str(exc.value) @pytest.mark.parametrize( @@ -388,7 +374,7 @@ def test_a_link_outside_the_language_is_named_where_the_user_wrote_it(link_expre pytest.param(NONCONVEX_YAML, {'parameters.bp_x.dtype': 'str'}, id='a-label-as-a-breakpoint'), pytest.param( LP, - {'piecewise.cost_curve.links': [['load', 'bp_x'], ['op_cost', 'bp_y', '>=']]}, + {'piecewise.cost_curve.links': {'x': ['load', 'bp_x'], 'op_cost': ['op_cost', 'bp_y', '>=']}}, id='a-variable-free-x-link', ), ], @@ -397,14 +383,14 @@ def test_a_block_is_refused_on_the_link_the_file_wrote_and_not_on_a_row_it_would """Both were refused only once written out, under `cost_curve_increasing` or `cost_curve_domain_lo` — rows the file never declared.""" with pytest.raises(SchemaError) as exc: schema_of(model, **patch) - assert "piecewise 'cost_curve'" in str(exc.value) and 'link 0' in str(exc.value) + assert "piecewise 'cost_curve' link '" in str(exc.value) assert 'cost_curve_' not in str(exc.value), 'the refusal names the block, not a declaration the expansion writes' def test_an_undeclared_breakpoint_dimension_is_refused_once(): - """`over: nope` also said, per link, that the values parameter must carry `nope` — lines that follow from the first.""" + """`along: nope` also said, per link, that the values parameter must carry `nope` — lines that follow from the first.""" with pytest.raises(SchemaError) as exc: - schema_of(NONCONVEX_YAML, **{'piecewise.cost_curve.over': 'nope'}) + schema_of(NONCONVEX_YAML, **{'piecewise.cost_curve.along': 'nope'}) assert str(exc.value).splitlines() == [ "piecewise 'cost_curve' references undeclared dimension 'nope'. Declare it under 'dimensions:'." ] @@ -415,12 +401,16 @@ def test_a_link_reading_a_refused_entry_names_it_and_its_refusal_is_listed(): with pytest.raises(SchemaError) as exc: schema_of( NONCONVEX_YAML, - **{'expressions': {'bad': 'nope'}, 'piecewise.cost_curve.links': [['bad', 'bp_x'], ['op_cost', 'bp_y']]}, + **{ + 'expressions': {'bad': 'nope'}, + 'piecewise.cost_curve.links': {'p': ['bad', 'bp_x'], 'op_cost': ['op_cost', 'bp_y']}, + }, ) message = str(exc.value) assert "Named expression 'bad': 'nope' not found" in message assert ( - "piecewise 'cost_curve' link 0: named expression 'bad' does not load. Its refusal is listed with it." in message + "piecewise 'cost_curve' link 'p': named expression 'bad' does not load. Its refusal is listed with it." + in message ) @@ -438,10 +428,10 @@ def test_a_link_reading_a_nonlinear_entry_is_refused(): NONCONVEX_YAML, **{ 'expressions': {'ratio': 'op_cost / sum(p, over=snapshot)'}, - 'piecewise.cost_curve.links': [['ratio', 'bp_x'], ['op_cost', 'bp_y']], + 'piecewise.cost_curve.links': {'ratio': ['ratio', 'bp_x'], 'op_cost': ['op_cost', 'bp_y']}, }, ) - assert "piecewise 'cost_curve' link 0" in str(exc.value) + assert "piecewise 'cost_curve' link 'ratio'" in str(exc.value) def test_a_link_reading_a_degree_two_product_entry_is_refused(): @@ -457,17 +447,20 @@ def test_a_link_reading_a_degree_two_product_entry_is_refused(): NONCONVEX_YAML, **{ 'expressions': {'sq': 'p * op_cost'}, - 'piecewise.cost_curve.links': [['sq', 'bp_x'], ['op_cost', 'bp_y']], + 'piecewise.cost_curve.links': {'sq': ['sq', 'bp_x'], 'op_cost': ['op_cost', 'bp_y']}, }, ) - assert "piecewise 'cost_curve' link 0" in str(exc.value) + assert "piecewise 'cost_curve' link 'sq'" in str(exc.value) def test_an_entry_a_link_reads_is_in_the_math(): """A link's expression stands inside the constraints its expansion emits, so an entry it names is one the math reads.""" schema = schema_of( NONCONVEX_YAML, - **{'expressions': {'twice': 'p * 2'}, 'piecewise.cost_curve.links': [['twice', 'bp_x'], ['op_cost', 'bp_y']]}, + **{ + 'expressions': {'twice': 'p * 2'}, + 'piecewise.cost_curve.links': {'twice': ['twice', 'bp_x'], 'op_cost': ['op_cost', 'bp_y']}, + }, ) assert schema.expand('piecewise').program.expressions['twice'].in_math is True @@ -484,7 +477,7 @@ def test_a_link_reading_a_dual_entry_is_refused(): NONCONVEX_YAML, **{ 'expressions': {'price': 'dual(balance)'}, - 'piecewise.cost_curve.links': [['price', 'bp_x'], ['op_cost', 'bp_y']], + 'piecewise.cost_curve.links': {'price': ['price', 'bp_x'], 'op_cost': ['op_cost', 'bp_y']}, }, ) @@ -508,15 +501,19 @@ def test_a_gate_that_is_not_a_variable_is_refused(activity, match): raw_of(NONCONVEX_YAML), **{ 'piecewise.cost_curve.method': 'lp', - 'piecewise.cost_curve.links': [['p', 'bp_x'], ['op_cost', 'bp_y', '<=']], + 'piecewise.cost_curve.links': {'p': ['p', 'bp_x'], 'op_cost': ['op_cost', 'bp_y', '<=']}, }, ) #: Both links pinned, so nothing says which way the weights are pushed. CONVEX = varied(raw_of(NONCONVEX_YAML), **{'piecewise.cost_curve.method': 'convex'}) #: The hull bounded below, which is the same relaxation ``lp`` states as its segment lines. -CONVEX_BOUNDED = varied(CONVEX, **{'piecewise.cost_curve.links': [['p', 'bp_x'], ['op_cost', 'bp_y', '>=']]}) +CONVEX_BOUNDED = varied( + CONVEX, **{'piecewise.cost_curve.links': {'p': ['p', 'bp_x'], 'op_cost': ['op_cost', 'bp_y', '>=']}} +) #: The hull bounded above, so the binding side is the upper one. -CONVEX_BOUNDED_BELOW = varied(CONVEX, **{'piecewise.cost_curve.links': [['p', 'bp_x'], ['op_cost', 'bp_y', '<=']]}) +CONVEX_BOUNDED_BELOW = varied( + CONVEX, **{'piecewise.cost_curve.links': {'p': ['p', 'bp_x'], 'op_cost': ['op_cost', 'bp_y', '<=']}} +) #: Named so the completeness check below can read the answers back off them. @@ -573,9 +570,9 @@ def test_a_masked_lp_curve_sits_its_rows_on_predicates_rather_than_on_parameters def test_a_file_supplied_mask_is_what_the_contiguity_condition_reads(): - """A ``points:`` naming a parameter the file declared is bound like any other, and the mask check names it.""" + """A ``where:`` naming a parameter the file declared is bound like any other, and the mask check names it.""" program = expanded( - varied(LP, **{'parameters.reach': {'dims': ['bp'], 'dtype': 'bool'}, 'piecewise.cost_curve.points': 'reach'}), + varied(LP, **{'parameters.reach': {'dims': ['bp'], 'dtype': 'bool'}, 'piecewise.cost_curve.where': 'reach'}), 'piecewise', ).program @@ -597,15 +594,15 @@ def test_a_file_supplied_mask_is_what_the_contiguity_condition_reads(): def test_a_gap_is_explained_by_the_rows_the_method_writes(method, reason): """Every method gave the ``lp`` reason, naming a chord row and domain rows that only ``lp`` writes.""" links = ( - [['p', 'bp_x'], ['op_cost', 'bp_y', '>=']] + {'p': ['p', 'bp_x'], 'op_cost': ['op_cost', 'bp_y', '>=']} if method in {'convex', 'lp'} - else [['p', 'bp_x'], ['op_cost', 'bp_y']] + else {'p': ['p', 'bp_x'], 'op_cost': ['op_cost', 'bp_y']} ) spec = schema_of( NONCONVEX_YAML, **{ 'piecewise.cost_curve.method': method, - 'piecewise.cost_curve.points': 'bp_x', + 'piecewise.cost_curve.where': 'bp_x', 'piecewise.cost_curve.links': links, }, ) @@ -646,6 +643,28 @@ def test_a_curves_conditions_cannot_collide_with_a_written_assumption(): expanded(varied(LP, assumptions={'cost_curve_increasing': 'bp_x > 0'}), 'piecewise') +@pytest.mark.parametrize( + ('where', 'advice'), + [ + pytest.param(None, 'declare where: to say how far the curve runs', id='no-where'), + pytest.param('curved', "let where: 'curved' test 'bp' too", id='a-where-over-dims'), + pytest.param('curved AND bp_power_on', "narrow where: 'curved AND bp_power_on'", id='a-ragged-where'), + ], +) +def test_a_missing_breakpoint_names_a_rewrite_the_block_can_take(where, advice): + """A block with a `where:` over `dims:` was told to declare `where:`, which it already had.""" + model = varied( + WALKED, + **{ + 'parameters.curved': {'dims': ['generator'], 'dtype': 'bool'}, + 'parameters.bp_power_on': {'dims': ['generator', 'bp'], 'dtype': 'bool'}, + }, + ) + assumptions = expand_piecewise(schema_of(model, **{'piecewise.coupling.where': where})).assumptions + for name in ('coupling_complete', 'coupling_power_complete'): + assert advice in assumptions[name].description, f'{name} names the rewrite for its own where' + + @pytest.mark.parametrize('suffix', ['increasing', 'curvature', 'breakpoints', 'contiguous']) def test_every_check_has_a_sentence(suffix): assumptions = expanded(LP_MASKED, 'piecewise').program.assumptions @@ -656,3 +675,834 @@ def test_every_check_has_a_sentence(suffix): 'the refusal names the columns a consumer has to look at before it says why' ) assert "— piecewise 'cost_curve':" in message, 'and trails the sentence the method implies' + + +#: A curve only some members have: the frame is two dims, and the mask names one of them. +MASKED = varied( + TWO_DIM, + **{ + 'parameters.has_curve': {'dims': ['generator'], 'dtype': 'bool'}, + 'piecewise.cost_curve.where': 'has_curve', + }, +) +#: The same mask on the block that states its curve as segment lines, which emits no weights to inherit one. +LP_WHERE = varied( + varied(LP, **{'parameters.has_curve': {'dims': ['bp'], 'dtype': 'bool'}}), + **{'parameters.has_curve.dims': ['snapshot'], 'piecewise.cost_curve.where': 'has_curve'}, +) + + +@pytest.mark.parametrize( + 'emitted', + [ + pytest.param('cost_curve_p', id='link-p'), + pytest.param('cost_curve_op_cost', id='link-op_cost'), + pytest.param('cost_curve_convexity', id='convexity'), + ], +) +def test_a_where_reaches_every_row_the_block_emits(emitted): + """A link row left unmasked is the bug: the weighted sum is empty off the mask, so the row pins `p == 0`. + + The convexity row is a reduction too, and absence does not spread out of + one — unmasked it would read `0 == 1` at a member with no curve. + """ + expanded = expand_piecewise(schema_of(MASKED)) + assert expanded.constraints[emitted].where == 'has_curve' + + +def test_the_row_the_set_states_needs_no_mask_of_its_own(): + """`adjacency` states its restriction as a set, and the set's row is an inequality. + + Unmasked it reads `0 <= 1` at a member with no curve, which every row is + free to say. The rows that would read `0 == 1` there are the block's own, + and those carry the mask. + """ + expanded = expand_piecewise(schema_of(MASKED)) + assert expanded.constraints['cost_curve_pick'].expression == 'sum(cost_curve_seg, over=bp) <= 1' + assert expanded.constraints['cost_curve_pick'].where is None, 'the inequality holds off the mask on its own' + + +@pytest.mark.parametrize( + 'emitted', [pytest.param('cost_curve_lam', id='lam'), pytest.param('cost_curve_seg', id='seg')] +) +def test_a_where_reaches_the_weights(emitted): + assert expand_piecewise(schema_of(MASKED)).variables[emitted].where == 'has_curve' + + +def test_the_adjacency_row_inherits_the_mask_rather_than_restating_it(): + """Its every term is a weight, and absence spreads through arithmetic — which is how a ragged `where:` reaches it too.""" + expanded = expand_piecewise(schema_of(MASKED)) + assert expanded.constraints['cost_curve_adjacency'].where is None + + +def test_a_ragged_where_reaches_the_weights_as_written_and_the_frame_rows_as_a_count(): + """One mask says which coordinates have a curve and how far each runs; a row over the frame alone cannot read it.""" + expanded = expand_piecewise(schema_of(MASKED, **{'piecewise.cost_curve.where': 'has_curve AND bp_x'})) + + assert expanded.variables['cost_curve_lam'].where == 'has_curve AND bp_x' + assert expanded.constraints['cost_curve_convexity'].where == 'count(has_curve AND bp_x, over=bp) > 0' + assert expanded.constraints['cost_curve_p'].where == 'count(has_curve AND bp_x, over=bp) > 0' + + +def test_a_where_joins_both_gate_rows(): + """`activity:` splits the convexity row across the gate's own mask, and the block's where holds over both halves.""" + schema = schema_of( + MASKED, + **{ + 'variables.u': {'dims': ['snapshot', 'generator'], 'domain': 'binary', 'where': 'committable'}, + 'parameters.committable': {'dims': ['generator'], 'dtype': 'bool'}, + 'piecewise.cost_curve.activity': 'u', + }, + ) + expanded = expand_piecewise(schema) + assert expanded.constraints['cost_curve_convexity'].where == '(has_curve) AND (u)' + assert expanded.constraints['cost_curve_convexity_ungated'].where == '(has_curve) AND (NOT u)' + + +def test_a_ragged_where_is_grouped_where_an_edge_row_shifts_it(): + """Unparenthesised, `a OR b AND shift(…)` binds the AND to `b` alone and the edge is read off half the mask.""" + schema = schema_of( + MASKED, + **{ + 'parameters.also_curved': {'dims': ['generator'], 'dtype': 'bool'}, + 'piecewise.cost_curve.where': 'has_curve OR also_curved AND bp_x', + 'piecewise.cost_curve.method': 'lp', + 'piecewise.cost_curve.links': {'p': ['p', 'bp_x'], 'op_cost': ['op_cost', 'bp_y', '>=']}, + }, + ) + expanded = expand_piecewise(schema) + + assert expanded.constraints['cost_curve_domain_lo'].where == ( + '(has_curve OR also_curved AND bp_x) AND NOT shift(has_curve OR also_curved AND bp_x, along=bp, offset=1)' + ) + + +@pytest.mark.parametrize( + ('patch', 'match'), + [ + pytest.param( + { + 'dimensions.region': {'dtype': 'str'}, + 'parameters.onshore': {'dims': ['region'], 'dtype': 'bool'}, + 'piecewise.cost_curve.where': 'onshore', + }, + 'cannot add coordinates', + id='outside-the-frame', + ), + pytest.param({'piecewise.cost_curve.where': 'nowhere'}, 'nowhere', id='naming-nothing'), + ], +) +def test_a_where_the_block_cannot_read_is_refused(patch, match): + with pytest.raises(LanguageError, match=match): + schema_of(MASKED, **patch) + + +def test_a_where_over_a_dim_a_link_walks_into_is_not_sent_to_dims(): + """The refusal said to add `flow` to `dims:`, and the walk into `flow` was then refused for that very edit.""" + with pytest.raises(LanguageError, match=r"\['flow'\] is what link 'power' walks into") as refused: + schema_of( + WALKED, + **{'parameters.on_flow': {'dims': ['flow'], 'dtype': 'bool'}, 'piecewise.coupling.where': 'on_flow'}, + ) + assert 'to dims:' not in str(refused.value), 'no advice the walk refuses' + + +def test_segment_lines_carry_the_mask_that_no_weight_can_hand_them(): + """`method: lp` emits no weights, so its three rows take the block's where themselves or stand everywhere.""" + expanded = expand_piecewise(schema_of(LP_WHERE)) + assert expanded.constraints['cost_curve_chord'].where == '(has_curve) AND (position(bp) > 0)' + assert expanded.constraints['cost_curve_domain_lo'].where == '(has_curve) AND (position(bp) == 0)' + assert expanded.constraints['cost_curve_domain_hi'].where == '(has_curve) AND (position(bp) == -1)' + + +@pytest.mark.parametrize( + ('where', 'method'), + [ + pytest.param('has_curve', 'lp', id='a-mask-over-the-frame'), + pytest.param('has_curve AND bp_x', 'lp', id='a-ragged-mask'), + pytest.param('has_curve', 'convex', id='a-single-bend-over-the-frame'), + ], +) +def test_a_model_written_out_and_read_back_asks_its_conditions_only_where_a_curve_runs(where, method): + """The mask lived only on the program's declaration, so the file `to_yaml()` wrote asked every generator. + + Read back, that file held a generator with no curve to breakpoints it has + no rows for. A ragged mask had the same gap on the conditions over the + frame alone: a generator the mask admits no breakpoint of failed + `count(...) == 1`, though it has no curve. + """ + links = {'p': ['p', 'bp_x'], 'op_cost': ['op_cost', 'bp_y', '>=' if method == 'lp' else '==']} + model = varied(MASKED, **{'piecewise.cost_curve.where': where, 'piecewise.cost_curve.method': method}) + written = schema_of(model, **{'piecewise.cost_curve.links': links}).expand('piecewise') + read_back = to_spec(raw_of(written.to_yaml())).program + + unmasked = { + name + for name, assumption in read_back.assumptions.items() + if assumption.where is None or 'has_curve' not in assumption.where.names_read + } + assert not unmasked, f'{sorted(unmasked)} would be asked at a generator with no curve' + + +#: fluxopt's converter: one curve per generator, tying however many flows the +#: relation gives it. The link that carries `flow` walks the relation from the +#: curve's `generator` to its own `flow`. +WALKED = { + 'dimensions': { + 'snapshot': {'dtype': 'int'}, + 'generator': {'dtype': 'str'}, + 'flow': {'dtype': 'str'}, + 'bp': {'dtype': 'int'}, + }, + 'relations': {'generator_of': {'key': 'flow', 'values': 'generator'}}, + 'parameters': { + 'load': {'dims': ['snapshot']}, + 'bp_power': {'dims': ['flow', 'bp']}, + 'bp_fuel': {'dims': ['generator', 'bp']}, + }, + 'variables': { + 'power': {'dims': ['flow', 'snapshot'], 'bounds': {'lower': 0}}, + 'fuel': {'dims': ['generator', 'snapshot'], 'bounds': {'lower': 0}}, + }, + 'piecewise': { + 'coupling': { + 'along': 'bp', + 'dims': ['generator', 'snapshot'], + 'links': { + 'power': { + 'expression': 'power', + 'values': 'bp_power', + 'by': 'generator_of', + 'over': 'generator', + 'into': 'flow', + }, + 'fuel': ['fuel', 'bp_fuel'], + }, + } + }, + 'constraints': {'balance': {'dims': ['snapshot'], 'expression': 'sum(power, over=flow) == load'}}, + 'objective': {'sense': 'minimize', 'expression': 'sum(fuel)'}, +} +#: The walked link alone, which the relation gives its arity. +POWER_ONLY = { + 'piecewise.coupling.links': { + 'power': { + 'expression': 'power', + 'values': 'bp_power', + 'by': 'generator_of', + 'over': 'generator', + 'into': 'flow', + } + }, + 'objective.expression': 'sum(power)', +} + + +def test_a_block_builds_one_curve_per_coordinate_of_its_dims(): + """The curve is per generator, though one of its links is per flow.""" + expanded = expand_piecewise(schema_of(WALKED)) + assert expanded.variables['coupling_lam'].dims == ['generator', 'snapshot', 'bp'] + assert expanded.constraints['coupling_convexity'].dims == ['generator', 'snapshot'] + + +def test_a_walked_link_emits_one_row_per_fine_coordinate(): + """The link reads the curve's weights through the relation, so a generator's flows share one curve.""" + link = expand_piecewise(schema_of(WALKED)).constraints['coupling_power'] + assert link.dims == ['flow', 'snapshot'], 'dims: with the consumed dim replaced by the produced one' + assert link.expression == ( + '(power) == sum(at(coupling_lam, by=generator_of, over=generator, into=flow) * bp_power, over=bp)' + ) + + +def test_a_link_that_walks_nothing_stays_on_the_blocks_dims(): + link = expand_piecewise(schema_of(WALKED)).constraints['coupling_fuel'] + assert link.dims == ['generator', 'snapshot'] + assert link.expression == '(fuel) == sum(coupling_lam * bp_fuel, over=bp)' + + +def test_a_link_row_is_named_after_the_link_and_not_after_its_place(): + """Rows named by position renamed every constraint after the one a reordering moved.""" + swapped = varied( + WALKED, **{'piecewise.coupling.links': dict(reversed(WALKED['piecewise']['coupling']['links'].items()))} + ) + for model in (WALKED, swapped): + rows = expand_piecewise(schema_of(model)).constraints + assert rows['coupling_fuel'].expression == '(fuel) == sum(coupling_lam * bp_fuel, over=bp)' + + +def test_the_checks_still_name_the_values_parameters_a_walked_block_ties(): + curve = schema_of(WALKED).program.piecewise['coupling'] + assert [link.values for link in curve.links] == ['bp_power', 'bp_fuel'], 'the values parameters, in link order' + + +def test_a_walked_block_round_trips_through_yaml(): + """A link the file wrote as a mapping cannot serialise back as a two-item list.""" + schema = schema_of(WALKED) + assert to_spec(raw_of(schema.to_yaml())).piecewise['coupling'] == schema.piecewise['coupling'] + + +def _walk(**written: object) -> dict[str, object]: + """The `power` link with *written* in place of its walk keys, and `None` dropping one.""" + link = {'expression': 'power', 'values': 'bp_power', 'by': 'generator_of', 'over': 'generator', 'into': 'flow'} + link |= written + return {'piecewise.coupling.links.power': {k: v for k, v in link.items() if v is not None}} + + +@pytest.mark.parametrize( + ('patch', 'match'), + [ + pytest.param({'piecewise.coupling.dims': None}, 'one curve per coordinate of', id='a-block-without-dims'), + pytest.param(_walk(over=None, into=None), r"\['over', 'into'\] are missing", id='a-walk-naming-no-columns'), + pytest.param(_walk(by=None, over=None), r"\['by', 'over'\] are missing", id='into-without-a-relation'), + pytest.param(_walk(by=None, into=None), r"\['by', 'into'\] are missing", id='over-without-a-relation'), + pytest.param( + {'piecewise.coupling.dims': ['generator', 'snapshot', 'bp']}, + 'breakpoint dim', + id='dims-carrying-the-breakpoint-dim', + ), + pytest.param( + {'piecewise.coupling.dims': ['generator']}, + r"link 'power' expression carries \['snapshot'\], which its row \['flow'\] does not", + id='dims-a-link-expression-leaves', + ), + pytest.param(_walk(by='nowhere_of'), 'nowhere_of', id='a-walk-through-an-undeclared-relation'), + pytest.param( + {'piecewise.coupling.dims': ['snapshot']}, + r"link 'power': over reaches \['generator'\], which the block's dims \['snapshot'\] do not carry", + id='a-walk-consuming-a-dim-the-block-lacks', + ), + pytest.param( + {'piecewise.coupling.dims': ['generator', 'flow', 'snapshot']}, + r"link 'power': into reaches \['flow'\], which the block's dims .* already carry", + id='a-walk-into-a-dim-the-block-has', + ), + pytest.param(_walk(into=['flow', 'flow']), r"link 'power': .*names a column twice", id='a-repeated-column'), + pytest.param( + {'relations.slot_of': {'key': 'bp', 'values': 'generator'}} + | _walk(by='slot_of', over='generator', into='bp'), + r"link 'power': into reaches 'bp', the breakpoint dim", + id='a-walk-into-the-breakpoint-dim', + ), + pytest.param( + {'piecewise.coupling.links.fuel': ['fuel', 'bp_fuel', '>=']} | _walk(sign='<='), + 'nothing pins the operating point', + id='every-row-bounded', + ), + pytest.param( + _walk(over=[]) + | { + 'variables.power.dims': ['generator', 'snapshot'], + 'parameters.bp_power.dims': ['generator', 'bp'], + 'constraints.balance.expression': 'sum(power, over=generator) == load', + }, + r'links.power: over: \[\] names no column', + id='an-empty-over', + ), + pytest.param( + _walk(into=[]) + | { + 'variables.power.dims': ['snapshot'], + 'parameters.bp_power.dims': ['bp'], + 'constraints.balance.expression': 'power == load', + }, + r'links.power: into: \[\] names no column', + id='an-empty-into', + ), + pytest.param( + { + 'piecewise.coupling.dims': ['flow', 'snapshot'], + 'piecewise.coupling.links': { + 'power': ['power', 'bp_power'], + 'fuel': { + 'expression': 'fuel', + 'values': 'bp_fuel', + 'by': 'generator_of', + 'over': 'flow', + 'into': 'generator', + }, + }, + }, + r"link 'fuel': at\(by=generator_of\): into=\['generator'\] names \['generator'\], which the key", + id='a-walk-landing-off-the-key', + ), + pytest.param( + { + 'dimensions.period': {'dtype': 'int'}, + 'relations.generator_of': {'key': ['flow', 'period'], 'values': 'generator'}, + }, + r"link 'power': 'generator_of' is keyed on \['period'\] too, which the block's dims", + id='a-walk-joining-on-a-dim-the-block-lacks', + ), + pytest.param( + {'relations.generator_of': {'key': {'flow': 'flow', 'site': 'generator'}, 'values': 'generator'}}, + r"link 'power': at\(by=generator_of\) joins 'generator_of' on \['generator'\] through more than one", + id='a-walk-joining-on-the-dim-it-consumes', + ), + ], +) +def test_a_walked_block_the_language_cannot_read_is_refused(patch, match): + """Each refusal names the link the file wrote, not a constraint its expansion would write.""" + with pytest.raises(LanguageError, match=match): + schema_of(WALKED, **patch) + + +def test_a_link_that_only_gains_a_dimension_is_refused_and_names_the_walk(): + """A row finer than the curve is reached through a relation, which says which curve each fine row reads.""" + model = varied( + WALKED, + **{ + 'dimensions.carrier': {'dtype': 'str'}, + 'parameters.bp_rate': {'dims': ['generator', 'carrier', 'bp']}, + 'variables.rate': {'dims': ['generator', 'carrier', 'snapshot']}, + 'piecewise.coupling.links.rate': {'expression': 'rate', 'values': 'bp_rate', 'into': 'carrier'}, + }, + ) + with pytest.raises(LanguageError, match=r"\['by', 'over'\] are missing"): + schema_of(model) + + +@pytest.mark.parametrize( + ('link', 'match'), + [ + pytest.param('convexity', "link 'convexity' names its row 'coupling_convexity'", id='the-convexity-row'), + pytest.param('lam', "link 'lam' names its row 'coupling_lam'", id='the-weights'), + pytest.param('adjacency_below', "link 'adjacency_below'", id='a-row-the-set-writes'), + ], +) +def test_a_link_named_after_a_row_the_block_writes_is_refused(link, match): + with pytest.raises(LanguageError, match=match): + schema_of(WALKED, **{f'piecewise.coupling.links.{link}': ['fuel', 'bp_fuel']}) + + +#: A second curve whose name extends the first's, so a link of the first can spell one of its rows. +BESIDE = varied( + WALKED, + **{ + 'piecewise.coupling_b': { + 'along': 'bp', + 'dims': ['generator', 'snapshot'], + 'links': {'fuel': ['fuel', 'bp_fuel'], 'power': WALKED['piecewise']['coupling']['links']['power']}, + } + }, +) + + +@pytest.mark.parametrize( + ('key', 'link', 'match'), + [ + pytest.param( + 'b_fuel', + ['fuel', 'bp_fuel'], + "writes constraint 'coupling_b_fuel', which piecewise 'coupling' also writes", + id='a-link-row', + ), + pytest.param( + 'b_convexity', + ['fuel', 'bp_fuel'], + "writes constraint 'coupling_b_convexity', which piecewise 'coupling' also writes", + id='a-row-the-other-block-writes-for-itself', + ), + pytest.param( + 'b', + WALKED['piecewise']['coupling']['links']['power'], + "writes assumption 'coupling_b_complete', which piecewise 'coupling' also writes", + id='a-walked-links-own-condition', + ), + ], +) +def test_a_name_two_blocks_would_both_write_is_refused(key, link, match): + """Links take any name, so `coupling`'s link `b_fuel` spelled `coupling_b`'s row `coupling_b_fuel`. + + Both blocks loaded, and the expansion wrote one row over the other, so + one block's link was never stated. + """ + with pytest.raises(LanguageError, match=match): + schema_of(BESIDE, **{f'piecewise.coupling.links.{key}': link}) + + +def test_a_link_name_no_row_could_take_is_refused(): + with pytest.raises(LanguageError, match=r"links: \['2nd'\] is not a name"): + schema_of(WALKED, **{'piecewise.coupling.links.2nd': ['fuel', 'bp_fuel']}) + + +def test_a_gate_over_more_than_the_blocks_dims_is_refused(): + """The gate widened a declared `dims:` silently, and built the weights over a dimension the file never gave the curve.""" + with pytest.raises( + LanguageError, match=r"activity 'u' carries \['generator'\], which dims \['snapshot'\] does not" + ): + schema_of( + TWO_DIM, + **{ + 'piecewise.cost_curve.dims': ['snapshot'], + 'piecewise.cost_curve.links': {'p': ['load', 'bp_z'], 'op_cost': ['load * 2', 'bp_z']}, + 'parameters.bp_z': {'dims': ['bp']}, + 'variables.u': {'dims': ['snapshot', 'generator'], 'domain': 'binary'}, + 'piecewise.cost_curve.activity': 'u', + }, + ) + + +def test_a_gate_over_fewer_dims_than_the_block_switches_each_curve_it_covers(): + """A unit commitment per generator gates that generator's curve in every snapshot.""" + expanded = expand_piecewise( + schema_of( + TWO_DIM, + **{'variables.u': {'dims': ['generator'], 'domain': 'binary'}, 'piecewise.cost_curve.activity': 'u'}, + ) + ) + assert expanded.constraints['cost_curve_convexity'].expression == 'sum(cost_curve_lam, over=bp) == (u)' + assert expanded.variables['cost_curve_lam'].dims == ['snapshot', 'generator', 'bp'] + + +#: fluxopt's system: only some generators run on a curve, and the rest have none at all. +CURVED = varied( + WALKED, + **{ + 'parameters.curved': {'dims': ['generator'], 'dtype': 'bool'}, + 'piecewise.coupling.where': 'curved', + }, +) + + +def test_a_block_mask_reaches_a_walked_link_through_its_relation(): + """The row is over flows and the mask over generators, so the row reads the mask at each flow's generator. + + The block refused a mask beside a walk, so a model with one curved + generator built a curve, a convexity row and its binaries for every + generator it declared. + """ + expanded = expand_piecewise(schema_of(CURVED)) + assert expanded.variables['coupling_lam'].where == 'curved', 'no weights where a generator has no curve' + assert expanded.constraints['coupling_convexity'].where == 'curved' + assert expanded.constraints['coupling_fuel'].where == 'curved', 'a link on dims: reads the mask as written' + assert expanded.constraints['coupling_power'].where == ('at(curved, by=generator_of, over=generator, into=flow)'), ( + 'a walked link reads it at the generator each flow maps to' + ) + + +#: fluxopt's investment cost curve (fluxopt/fluxopt#26): each effect of a sized flow is read off its curve. +INVESTED = { + 'dimensions': {name: {'dtype': 'str'} for name in ('cost_curve', 'effect', 'flow')} | {'bp': {'dtype': 'int'}}, + 'relations': {'cost_of': {'key': ['flow', 'effect'], 'values': 'cost_curve'}}, + 'parameters': { + 'has_cost_curve': {'dims': ['cost_curve'], 'dtype': 'bool'}, + 'size_bp': {'dims': ['cost_curve', 'bp']}, + 'cost_bp': {'dims': ['flow', 'effect', 'bp']}, + }, + 'variables': {'size': {'dims': ['cost_curve']}, 'invest': {'dims': ['flow', 'effect']}}, + 'piecewise': { + 'invest_curve': { + 'along': 'bp', + 'dims': ['cost_curve'], + 'where': 'has_cost_curve', + 'links': { + 'size': ['size', 'size_bp'], + 'cost': { + 'expression': 'invest', + 'values': 'cost_bp', + 'by': 'cost_of', + 'over': 'cost_curve', + 'into': ['flow', 'effect'], + }, + }, + } + }, + 'objective': {'sense': 'minimize', 'expression': 'sum(invest)'}, +} + + +def test_a_block_mask_reaches_a_walk_into_several_columns(): + """The mask is read through the walk as `at(…, into=[flow, effect])`, which a where string could not parse (#781). + + The block loaded without its `where:`, and with it the load failed on + the assertion that what a method assumes is stated in the language. + """ + expanded = schema_of(INVESTED).expand() + read = 'at(has_cost_curve, by=cost_of, over=cost_curve, into=[flow, effect])' + assert expanded.constraints['invest_curve_cost'].where == read, ( + 'the cost row reads the mask at each flow and effect' + ) + assert expanded.assumptions['invest_curve_cost_complete'].where == read, 'and so does its breakpoint check' + assert to_spec(expanded.to_yaml()).program == expanded.program, 'the rows it writes load again' + + +def test_a_ragged_mask_reaches_a_walked_link_as_the_count_of_its_curves_breakpoints(): + """A walked row is over the curve's dims less the walk, so it takes what a row over dims: alone takes, read through.""" + expanded = expand_piecewise( + schema_of( + WALKED, + **{ + 'parameters.reach': {'dims': ['generator', 'bp'], 'dtype': 'bool'}, + 'piecewise.coupling.where': 'reach', + }, + ) + ) + assert expanded.variables['coupling_lam'].where == 'reach' + assert expanded.constraints['coupling_power'].where == ( + 'at(count(reach, over=bp) > 0, by=generator_of, over=generator, into=flow)' + ) + + +def test_a_mask_over_dims_the_walk_keeps_reaches_the_walked_row_as_written(): + """A mask over `snapshot` alone says nothing about generators, and the walked row keeps `snapshot`.""" + expanded = expand_piecewise( + schema_of( + WALKED, + **{ + 'parameters.season': {'dims': ['snapshot'], 'dtype': 'bool'}, + 'piecewise.coupling.where': 'season', + }, + ) + ) + assert expanded.constraints['coupling_power'].where == 'season' + + +def test_a_mask_over_a_dim_the_walk_joins_on_reaches_the_walked_row_as_written(): + """The relation is keyed by flow and snapshot, and `season` tests only `snapshot`, which the walked row keeps. + + The join column was counted with the ones the walk consumes, so this mask + was refused as carrying part of what the walk reads through, and no + rewrite kept it. + """ + expanded = expand_piecewise( + schema_of( + WALKED, + **{ + 'relations.generator_of': {'key': ['flow', 'snapshot'], 'values': 'generator'}, + 'parameters.season': {'dims': ['snapshot'], 'dtype': 'bool'}, + 'piecewise.coupling.where': 'season', + }, + ) + ) + assert expanded.constraints['coupling_power'].where == 'season' + + +def test_a_mask_carrying_part_of_what_a_walk_reads_through_is_refused(): + """The relation is keyed by flow and snapshot, so the read joins on snapshot and needs the mask to carry it too.""" + model = varied( + CURVED, + **{ + 'relations.generator_of': {'key': ['flow', 'snapshot'], 'values': 'generator'}, + }, + ) + with pytest.raises(LanguageError, match=r"where 'curved' carries \['generator'\] and not \['snapshot'\]"): + schema_of(model) + + +@pytest.mark.parametrize( + ('model', 'read'), + [ + pytest.param(WALKED, {'generator_of'}, id='no-mask-asks-only-where-the-relation-reaches'), + pytest.param(CURVED, {'curved', 'generator_of'}, id='a-mask-read-through'), + ], +) +def test_a_walked_links_breakpoints_are_asked_only_at_the_rows_it_reads_the_curve_at(model, read): + """Asked with the other links, `bp_power` was demanded at every flow, including those of a generator with no curve.""" + assumptions = schema_of(model).expand('piecewise').program.assumptions + walked = assumptions['coupling_power_complete'] + assert walked.predicate.names_read == frozenset({'bp_power'}), 'the walked link asks for its own values alone' + assert walked.where is not None and walked.where.dims == frozenset({'flow'}), 'asked per flow the walk reaches' + assert walked.where.names_read == frozenset(read), 'the where reads the mask, if any, and the relation' + assert assumptions['coupling_complete'].predicate.names_read == frozenset({'bp_fuel'}), ( + 'the link on dims: keeps the block condition to itself' + ) + + +def test_a_walked_links_own_condition_is_a_name_the_block_reserves(): + with pytest.raises( + LanguageError, match="writes assumption 'coupling_power_complete', which this file already declares" + ): + schema_of(WALKED, assumptions={'coupling_power_complete': 'bp_power >= 0'}) + + +def test_a_mask_on_the_links_own_variable_leaves_the_walked_row_unbuilt(): + """Absence spreads through arithmetic, so a flow with no variable has no row, with or without a block mask.""" + expanded = expand_piecewise( + schema_of( + WALKED, + **{ + 'parameters.on_a_curve': {'dims': ['flow'], 'dtype': 'bool'}, + 'variables.power.where': 'on_a_curve', + }, + ) + ) + assert expanded.variables['power'].where == 'on_a_curve' + + +def test_one_walked_link_is_a_curve_because_the_relation_gives_it_its_arity(): + """A converter whose coupled quantities are all flows of one variable is one link, and it ties them all. + + 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 arity that the + second link otherwise would. + """ + expanded = expand_piecewise(schema_of(WALKED, **POWER_ONLY)) + assert expanded.constraints['coupling_convexity'].dims == ['generator', 'snapshot'], 'one curve per generator' + assert expanded.constraints['coupling_power'].dims == ['flow', 'snapshot'], 'one row per flow, sharing it' + assert 'coupling_fuel' not in expanded.constraints, 'no row for a link the block does not declare' + + +def test_one_link_that_walks_nothing_is_still_a_bound_rather_than_a_curve(): + with pytest.raises(LanguageError, match='a bound rather than a curve'): + schema_of(NONCONVEX_YAML, **{'piecewise.cost_curve.links': {'p': ['p', 'bp_x']}}) + + +@pytest.mark.parametrize( + ('method', 'match'), + [ + pytest.param('convex', 'no shape left to check', id='convex'), + pytest.param('lp', 'which row plays it is data', id='lp'), + ], +) +def test_the_two_restricted_methods_refuse_a_walked_link_for_their_own_reasons(method, match): + """`lp` loses the abscissa its line is written against; `convex` loses the pair it reads a shape from. + + The two reasons are not one, so neither message may stand in for the other. + """ + with pytest.raises(LanguageError, match=match): + schema_of(WALKED, **{'piecewise.coupling.method': method}) + + +def test_links_that_disagree_on_their_dims_are_refused_rather_than_read_as_one_curve_each(): + """A link finer than `dims:` would multiply the rows it builds, each pinning the same `fuel` to a curve of its own.""" + with pytest.raises(LanguageError, match=r"link 'power' expression carries \['flow'\]"): + schema_of(WALKED, **{'piecewise.coupling.links': {'power': ['power', 'bp_power'], 'fuel': ['fuel', 'bp_fuel']}}) + + +@pytest.mark.parametrize( + ('patch', 'match'), + [ + pytest.param( + {'variables.power.dims': ['flow', 'snapshot', 'period']}, + r'carries \[.period.\]', + id='finer-than-its-row', + ), + pytest.param( + { + 'piecewise.coupling.dims': ['generator', 'snapshot', 'period'], + 'variables.fuel.dims': ['generator', 'snapshot', 'period'], + }, + r'does not carry \[.period.\]', + id='coarser-than-its-row', + ), + ], +) +def test_a_link_spanning_a_dimension_its_row_does_not_is_refused_both_ways(patch, match): + """A curve and the quantity on it vary together or the file says which — neither direction is guessed.""" + model = varied( + WALKED, + **{ + 'dimensions.period': {'dtype': 'int'}, + 'parameters.load': {'dims': ['snapshot', 'period']}, + 'constraints.balance': {'dims': ['snapshot', 'period'], 'expression': 'sum(power, over=flow) == load'}, + }, + ) + with pytest.raises(LanguageError, match=match): + schema_of(model, **patch) + + +def test_a_period_the_curve_and_its_links_both_carry_loads(): + """The rewrite both refusals name: put the dimension in dims:, and the curve varies along it.""" + expanded = expand_piecewise( + schema_of( + WALKED, + **{ + 'dimensions.period': {'dtype': 'int'}, + 'parameters.load': {'dims': ['snapshot', 'period']}, + 'constraints.balance': { + 'dims': ['snapshot', 'period'], + 'expression': 'sum(power, over=flow) == load', + }, + 'variables.power.dims': ['flow', 'snapshot', 'period'], + 'variables.fuel.dims': ['generator', 'snapshot', 'period'], + 'piecewise.coupling.dims': ['generator', 'snapshot', 'period'], + }, + ) + ) + assert expanded.variables['coupling_lam'].dims == ['generator', 'snapshot', 'period', 'bp'] + assert expanded.constraints['coupling_power'].dims == ['flow', 'snapshot', 'period'] + + +#: Three quantities on one curve, two of them bounded rather than pinned. +THREE_WAY = varied( + raw_of(NONCONVEX_YAML), + **{ + 'parameters.bp_z': {'dims': ['bp']}, + 'variables.heat': {'dims': ['snapshot'], 'bounds': {'lower': 0}}, + }, +) + + +@pytest.mark.parametrize( + 'links', + [ + pytest.param( + {'p': ['p', 'bp_x'], 'op_cost': ['op_cost', 'bp_y', '>='], 'heat': ['heat', 'bp_z']}, + id='three-links-one-bounded', + ), + pytest.param( + {'p': ['p', 'bp_x'], 'op_cost': ['op_cost', 'bp_y', '>='], 'heat': ['heat', 'bp_z', '<=']}, + id='two-bounded-signs-at-once', + ), + pytest.param( + {'p': ['p', 'bp_x'], 'op_cost': ['op_cost', 'bp_y', '>=']}, id='the-two-link-case-that-always-worked' + ), + ], +) +def test_a_curve_bounds_as_many_links_as_it_likes_while_one_pins_it(links): + """Under adjacency each link is its own row against the shared weights, so a sign is per link. + + The old rule capped a block at one non-`==` sign and only with exactly two + links. Nothing in the emission needed that: `_weights` writes + `(expr) sign sum(lam * values, along=bp)` per link and reaches for no other. + """ + expanded = expand_piecewise(schema_of(THREE_WAY, **{'piecewise.cost_curve.links': links})) + for key, link in links.items(): + sign = link[2] if len(link) == 3 else '==' + expression = expanded.constraints[f'cost_curve_{key}'].expression + assert f') {sign} sum(' in expression, f'link on {key} carries its own {sign}' + + +@pytest.mark.parametrize( + ('method', 'match'), + [ + pytest.param('convex', 'would ship uncertified', id='convex'), + pytest.param('lp', 'no line to write', id='lp'), + ], +) +def test_the_two_restricted_methods_take_exactly_two_links_for_their_own_reasons(method, match): + """`lp` had no such rule and leaned on the sign cap for it, so three links raised `ValueError`. + + `convex` builds the same rows for any number of links; what it cannot do + past two is certify that relaxing onto the hull is exact, because the sign + on the bounded link is what names the direction to check. + """ + with pytest.raises(LanguageError, match=match): + schema_of( + THREE_WAY, + **{ + 'piecewise.cost_curve.method': method, + 'piecewise.cost_curve.links': { + 'p': ['p', 'bp_x'], + 'op_cost': ['op_cost', 'bp_y', '>='], + 'heat': ['heat', 'bp_z'], + }, + }, + ) + + +@pytest.mark.parametrize('method', ['adjacency', 'sos2', 'convex', 'lp']) +def test_every_assumption_a_block_may_derive_is_a_name_it_reserves(method): + """The collision check reserves the assumption names at load, before the mask that decides which are written is typed.""" + links = ( + {'p': ['p', 'bp_x'], 'op_cost': ['op_cost', 'bp_y', '>=']} + if method in {'convex', 'lp'} + else {'p': ['p', 'bp_x'], 'op_cost': ['op_cost', 'bp_y']} + ) + spec = schema_of(NONCONVEX_YAML, **{'piecewise.cost_curve.method': method, 'piecewise.cost_curve.links': links}) + curve = spec.program.piecewise['cost_curve'] + + derived = set(assumptions_of('cost_curve', curve, spec.piecewise['cost_curve'].where)) + assert derived <= set(Emitted.of('cost_curve', curve).assumptions), ( + 'a condition the block derives under no reserved name' + ) diff --git a/tests/test_sos.py b/tests/test_sos.py index 9da45d48..a379a675 100644 --- a/tests/test_sos.py +++ b/tests/test_sos.py @@ -35,7 +35,14 @@ 'p': {'dims': ['snapshot'], 'bounds': {'lower': 0, 'upper': 100}}, 'op_cost': {'dims': ['snapshot'], 'bounds': {'lower': 0}}, }, - 'piecewise': {'cost_curve': {'over': 'bp', 'method': 'sos2', 'links': [['p', 'bp_x'], ['op_cost', 'bp_y']]}}, + 'piecewise': { + 'cost_curve': { + 'along': 'bp', + 'dims': ['snapshot'], + 'method': 'sos2', + 'links': {'p': ['p', 'bp_x'], 'op_cost': ['op_cost', 'bp_y']}, + } + }, 'constraints': {'balance': {'dims': ['snapshot'], 'expression': 'p == load'}}, 'objective': {'sense': 'minimize', 'expression': 'sum(op_cost, over=snapshot)'}, } diff --git a/tests/test_validation.py b/tests/test_validation.py index 099f9e52..2b18a339 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -2085,7 +2085,7 @@ def test_a_name_no_expression_could_write_is_refused(self, section: str, name: s 'expressions': {'expression': 'c'}, 'macros': {'args': ['x'], 'template': 'x * 2'}, 'constraints': {'dims': ['g'], 'expression': 'p <= c'}, - 'piecewise': {'over': 'g', 'links': [['p', 'c'], ['q', 'c']], 'method': 'convex'}, + 'piecewise': {'along': 'g', 'dims': [], 'links': {'p': ['p', 'c'], 'q': ['q', 'c']}, 'method': 'convex'}, 'sos': {'variable': 'p', 'along': 'g', 'type': 1}, } model = copy.deepcopy(SMALL_MODEL) @@ -2208,7 +2208,11 @@ def record(*args, **kwargs): 'parameters.bp_x': {'dims': ['generator', 'bp']}, 'parameters.bp_y': {'dims': ['generator', 'bp']}, 'variables.op_cost': {'dims': ['snapshot', 'generator'], 'bounds': {'lower': 0}}, - 'piecewise.curve': {'over': 'bp', 'links': [['p', 'bp_x'], ['op_cost', 'bp_y']]}, + 'piecewise.curve': { + 'along': 'bp', + 'dims': ['snapshot', 'generator'], + 'links': {'p': ['p', 'bp_x'], 'op_cost': ['op_cost', 'bp_y']}, + }, }, ) ) @@ -2221,8 +2225,8 @@ def record(*args, **kwargs): ('resolve_expression', "Named expression 'headroom', case 'opening'"), ('resolve_expression', "Named expression 'headroom', otherwise"), ('resolve_expression', 'The objective'), - ('resolve_expression', "piecewise 'curve' link 0"), - ('resolve_expression', "piecewise 'curve' link 1"), + ('resolve_expression', "piecewise 'curve' link 'op_cost'"), + ('resolve_expression', "piecewise 'curve' link 'p'"), ('resolve_where_text', "Assumption 'curve_complete'"), ('resolve_where_text', "Assumption 'curve_complete', where"), ('resolve_where_text', "Constraint 'balance'"), @@ -2230,6 +2234,7 @@ def record(*args, **kwargs): ('resolve_where_text', "Named expression 'headroom', case 'opening'"), ('resolve_where_text', "Variable 'op_cost'"), ('resolve_where_text', "Variable 'p'"), + ('resolve_where_text', "piecewise 'curve' where"), ], 'every expression and where position once, under the context validation reads it in, and nothing after' diff --git a/tests/typesetting/golden/latex.out b/tests/typesetting/golden/latex.out index b6b5e665..87f498b1 100644 --- a/tests/typesetting/golden/latex.out +++ b/tests/typesetting/golden/latex.out @@ -140,7 +140,9 @@ \text{cost\_curve} && \mathit{op\_cost}_{t,g} & \ge \begin{cases} \mathit{warm}_{t,g} & \text{if } \mathrm{is\_flexible}_{g} \\ 1 & \text{otherwise} \end{cases} \cdot \mathrm{pwl}_{a \in \mathcal{A} \,:\, \mathrm{bp\_run}_{g,a}}(\mathrm{bp\_x}_{g,a},\ \mathrm{bp\_y}_{g,a})(p_{t,g}) && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ \text{hull\_curve} && \left( p_{t,g},\ \mathit{fuel}_{t,g} \right) & \in \mathrm{conv}_{a \in \mathcal{A}}(\mathrm{bp\_x}_{g,a},\ \mathrm{bp\_heat}_{g,a}) && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ \text{lp\_curve} && \mathit{fuel}_{t,g} & \ge \mathrm{pwl}_{a \in \mathcal{A} \,:\, \mathrm{bp\_x}_{g,a} \text{ is defined}}(\mathrm{bp\_x}_{g,a},\ \mathrm{bp\_y}_{g,a})(p_{t,g}) && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ -\text{ramp\_curve} && \mathit{heat}_{t,g} & \le \mathrm{pwl}_{a \in \mathcal{A}}(\mathrm{bp\_x}_{g,a},\ \mathrm{bp\_heat}_{g,a})(p_{t,g}) && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +\text{ramp\_curve} && \mathit{heat}_{t,g} & \le \mathrm{pwl}_{a \in \mathcal{A}}(\mathrm{bp\_x}_{g,a},\ \mathrm{bp\_heat}_{g,a})(p_{t,g}) && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ +\text{chp\_curve} && \left( p_{t,g},\ \mathit{fuel}_{t,g},\ \mathit{heat}_{t,g} \right) & \in \mathrm{pwl}_{a \in \mathcal{A}}(\mathrm{bp\_x}_{g,a},\ \mathrm{bp\_y}_{g,a},\ \mathrm{bp\_heat}_{g,a}) + \{0\} \times \mathbb{R}_{\ge 0} \times \mathbb{R}_{\le 0} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ +\text{bus\_curve} && \left( p_{t,g} \right)_{g \in \mathcal{G} \,:\, \mathrm{gen\_bus}(g) = b} & \in \mathrm{pwl}_{a \in \mathcal{A}}(\left( \mathrm{bp\_x}_{g,a} \right)_{g \in \mathcal{G} \,:\, \mathrm{gen\_bus}(g) = b}) && \forall\, t \in \mathcal{T},\ b \in \mathcal{B} \,:\, \mathrm{load}_{t,b} \text{ is defined} \end{align} \paragraph{Definitions} @@ -185,19 +187,21 @@ \text{northern\_demand\_is\_real} && \mathrm{load}_{t,b} & \ge 0 && \forall\, t \in \mathcal{T},\ b \in \mathcal{B} \,:\, \mathrm{zone\_of}(b) = \text{'}\mathrm{north}\text{'} \\ \text{fuel\_curve\_complete} && \mathrm{bp\_x}_{g,a} \text{ is defined} \wedge \mathrm{bp\_y}_{g,a} \text{ is defined} \wedge \mathrm{bp\_heat}_{g,a} \text{ is defined} & && \forall\, g \in \mathcal{G},\ a \in \mathcal{A} \\ \text{cost\_curve\_complete} && \mathrm{bp\_x}_{g,a} \text{ is defined} \wedge \mathrm{bp\_y}_{g,a} \text{ is defined} & && \forall\, g \in \mathcal{G},\ a \in \mathcal{A} \,:\, \mathrm{bp\_run}_{g,a} \\ -\text{cost\_curve\_contiguous} && \lvert \{ a \in \mathcal{A} \,:\, \mathrm{bp\_run}_{g,a} \wedge \neg \mathrm{bp\_run}_{g,a - 1} \} \rvert & = 1 && \forall\, g \in \mathcal{G} \\ +\text{cost\_curve\_contiguous} && \lvert \{ a \in \mathcal{A} \,:\, \mathrm{bp\_run}_{g,a} \wedge \neg \mathrm{bp\_run}_{g,a - 1} \} \rvert & = 1 && \forall\, g \in \mathcal{G} \,:\, \lvert \{ a \in \mathcal{A} \,:\, \mathrm{bp\_run}_{g,a} \} \rvert > 0 \\ \text{hull\_curve\_complete} && \mathrm{bp\_x}_{g,a} \text{ is defined} \wedge \mathrm{bp\_heat}_{g,a} \text{ is defined} & && \forall\, g \in \mathcal{G},\ a \in \mathcal{A} \\ \text{hull\_curve\_increasing} && \mathrm{bp\_x}_{g,a \boxminus_{0} 1} & < \mathrm{bp\_x}_{g,a} && \forall\, g \in \mathcal{G},\ a \in \mathcal{A} \,:\, \mathrm{pos}(a) > 0 \\ \text{hull\_curve\_curvature} && \lvert \{ a \in \mathcal{A} \,:\, \left( \mathrm{bp\_heat}_{g,a} - \mathrm{bp\_heat}_{g,a \boxminus_{0} 1} \right) \cdot \left( \mathrm{bp\_x}_{g,a \boxplus_{0} 1} - \mathrm{bp\_x}_{g,a} \right) > \left( \mathrm{bp\_heat}_{g,a \boxplus_{0} 1} - \mathrm{bp\_heat}_{g,a} \right) \cdot \left( \mathrm{bp\_x}_{g,a} - \mathrm{bp\_x}_{g,a \boxminus_{0} 1} \right) \wedge \mathrm{pos}(a) > 0 \wedge \mathrm{pos}(a) \neq \lvert \mathcal{A} \rvert - 1 \} \rvert = 0 \vee \lvert \{ a \in \mathcal{A} \,:\, \left( \mathrm{bp\_heat}_{g,a} - \mathrm{bp\_heat}_{g,a \boxminus_{0} 1} \right) \cdot \left( \mathrm{bp\_x}_{g,a \boxplus_{0} 1} - \mathrm{bp\_x}_{g,a} \right) < \left( \mathrm{bp\_heat}_{g,a \boxplus_{0} 1} - \mathrm{bp\_heat}_{g,a} \right) \cdot \left( \mathrm{bp\_x}_{g,a} - \mathrm{bp\_x}_{g,a \boxminus_{0} 1} \right) \wedge \mathrm{pos}(a) > 0 \wedge \mathrm{pos}(a) \neq \lvert \mathcal{A} \rvert - 1 \} \rvert = 0 & && \forall\, g \in \mathcal{G} \\ \text{lp\_curve\_complete} && \mathrm{bp\_x}_{g,a} \text{ is defined} \wedge \mathrm{bp\_y}_{g,a} \text{ is defined} & && \forall\, g \in \mathcal{G},\ a \in \mathcal{A} \,:\, \mathrm{bp\_x}_{g,a} \text{ is defined} \\ \text{lp\_curve\_increasing} && \mathrm{bp\_x}_{g,a \boxminus_{0} 1} & < \mathrm{bp\_x}_{g,a} && \forall\, g \in \mathcal{G},\ a \in \mathcal{A} \,:\, \mathrm{bp\_x}_{g,a} \text{ is defined} \wedge \mathrm{bp\_x}_{g,a - 1} \text{ is defined} \\ \text{lp\_curve\_curvature} && \left( \mathrm{bp\_y}_{g,a} - \mathrm{bp\_y}_{g,a \boxminus_{0} 1} \right) \cdot \left( \mathrm{bp\_x}_{g,a \boxplus_{0} 1} - \mathrm{bp\_x}_{g,a} \right) & \le \left( \mathrm{bp\_y}_{g,a \boxplus_{0} 1} - \mathrm{bp\_y}_{g,a} \right) \cdot \left( \mathrm{bp\_x}_{g,a} - \mathrm{bp\_x}_{g,a \boxminus_{0} 1} \right) && \forall\, g \in \mathcal{G},\ a \in \mathcal{A} \,:\, \mathrm{bp\_x}_{g,a} \text{ is defined} \wedge \mathrm{bp\_x}_{g,a - 1} \text{ is defined} \wedge \mathrm{bp\_x}_{g,a + 1} \text{ is defined} \\ -\text{lp\_curve\_breakpoints} && \lvert \{ a \in \mathcal{A} \,:\, \mathrm{bp\_x}_{g,a} \text{ is defined} \} \rvert & \ge 2 && \forall\, g \in \mathcal{G} \\ -\text{lp\_curve\_contiguous} && \lvert \{ a \in \mathcal{A} \,:\, \mathrm{bp\_x}_{g,a} \text{ is defined} \wedge \neg \left( \mathrm{bp\_x}_{g,a - 1} \text{ is defined} \right) \} \rvert & = 1 && \forall\, g \in \mathcal{G} \\ +\text{lp\_curve\_breakpoints} && \lvert \{ a \in \mathcal{A} \,:\, \mathrm{bp\_x}_{g,a} \text{ is defined} \} \rvert & \ge 2 && \forall\, g \in \mathcal{G} \,:\, \lvert \{ a \in \mathcal{A} \,:\, \mathrm{bp\_x}_{g,a} \text{ is defined} \} \rvert > 0 \\ +\text{lp\_curve\_contiguous} && \lvert \{ a \in \mathcal{A} \,:\, \mathrm{bp\_x}_{g,a} \text{ is defined} \wedge \neg \left( \mathrm{bp\_x}_{g,a - 1} \text{ is defined} \right) \} \rvert & = 1 && \forall\, g \in \mathcal{G} \,:\, \lvert \{ a \in \mathcal{A} \,:\, \mathrm{bp\_x}_{g,a} \text{ is defined} \} \rvert > 0 \\ \text{ramp\_curve\_complete} && \mathrm{bp\_x}_{g,a} \text{ is defined} \wedge \mathrm{bp\_heat}_{g,a} \text{ is defined} & && \forall\, g \in \mathcal{G},\ a \in \mathcal{A} \\ \text{ramp\_curve\_increasing} && \mathrm{bp\_x}_{g,a \boxminus_{0} 1} & < \mathrm{bp\_x}_{g,a} && \forall\, g \in \mathcal{G},\ a \in \mathcal{A} \,:\, \mathrm{pos}(a) > 0 \\ \text{ramp\_curve\_curvature} && \left( \mathrm{bp\_heat}_{g,a} - \mathrm{bp\_heat}_{g,a \boxminus_{0} 1} \right) \cdot \left( \mathrm{bp\_x}_{g,a \boxplus_{0} 1} - \mathrm{bp\_x}_{g,a} \right) & \ge \left( \mathrm{bp\_heat}_{g,a \boxplus_{0} 1} - \mathrm{bp\_heat}_{g,a} \right) \cdot \left( \mathrm{bp\_x}_{g,a} - \mathrm{bp\_x}_{g,a \boxminus_{0} 1} \right) && \forall\, g \in \mathcal{G},\ a \in \mathcal{A} \,:\, \mathrm{pos}(a) > 0 \wedge \mathrm{pos}(a) \neq \lvert \mathcal{A} \rvert - 1 \\ -\text{ramp\_curve\_breakpoints} && \lvert \{ a \in \mathcal{A} \,:\, \mathrm{bp\_x}_{g,a} \text{ is defined} \} \rvert & \ge 2 && \forall\, g \in \mathcal{G} +\text{ramp\_curve\_breakpoints} && \lvert \{ a \in \mathcal{A} \,:\, \mathrm{bp\_x}_{g,a} \text{ is defined} \} \rvert & \ge 2 && \forall\, g \in \mathcal{G} \\ +\text{chp\_curve\_complete} && \mathrm{bp\_x}_{g,a} \text{ is defined} \wedge \mathrm{bp\_y}_{g,a} \text{ is defined} \wedge \mathrm{bp\_heat}_{g,a} \text{ is defined} & && \forall\, g \in \mathcal{G},\ a \in \mathcal{A} \\ +\text{bus\_curve\_p\_complete} && \mathrm{bp\_x}_{g,a} \text{ is defined} & && \forall\, t \in \mathcal{T},\ g \in \mathcal{G},\ a \in \mathcal{A} \,:\, \mathrm{load}_{t,\mathrm{gen\_bus}(g)} \text{ is defined} \end{align} \end{document} diff --git a/tests/typesetting/golden/markdown.out b/tests/typesetting/golden/markdown.out index 09d67bd6..4836ebe0 100644 --- a/tests/typesetting/golden/markdown.out +++ b/tests/typesetting/golden/markdown.out @@ -406,6 +406,18 @@ p_{t,g} \le \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\, t \in \mathcal{T},\ g \mathit{heat}_{t,g} \le \mathrm{pwl}_{a \in \mathcal{A}}(\mathrm{bp\_x}_{g,a},\ \mathrm{bp\_heat}_{g,a})(p_{t,g}) \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} ``` +**`chp_curve`** + +```math +\left( p_{t,g},\ \mathit{fuel}_{t,g},\ \mathit{heat}_{t,g} \right) \in \mathrm{pwl}_{a \in \mathcal{A}}(\mathrm{bp\_x}_{g,a},\ \mathrm{bp\_y}_{g,a},\ \mathrm{bp\_heat}_{g,a}) + \{0\} \times \mathbb{R}_{\ge 0} \times \mathbb{R}_{\le 0} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +``` + +**`bus_curve`** + +```math +\left( p_{t,g} \right)_{g \in \mathcal{G} \,:\, \mathrm{gen\_bus}(g) = b} \in \mathrm{pwl}_{a \in \mathcal{A}}(\left( \mathrm{bp\_x}_{g,a} \right)_{g \in \mathcal{G} \,:\, \mathrm{gen\_bus}(g) = b}) \qquad \forall\, t \in \mathcal{T},\ b \in \mathcal{B} \,:\, \mathrm{load}_{t,b} \text{ is defined} +``` + #### Definitions **`spend_cap`** @@ -607,7 +619,7 @@ p_{t,g} \le \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\, t \in \mathcal{T},\ g **`cost_curve_contiguous`** ```math -\lvert \{ a \in \mathcal{A} \,:\, \mathrm{bp\_run}_{g,a} \wedge \neg \mathrm{bp\_run}_{g,a - 1} \} \rvert = 1 \qquad \forall\, g \in \mathcal{G} +\lvert \{ a \in \mathcal{A} \,:\, \mathrm{bp\_run}_{g,a} \wedge \neg \mathrm{bp\_run}_{g,a - 1} \} \rvert = 1 \qquad \forall\, g \in \mathcal{G} \,:\, \lvert \{ a \in \mathcal{A} \,:\, \mathrm{bp\_run}_{g,a} \} \rvert > 0 ``` **`hull_curve_complete`** @@ -649,13 +661,13 @@ p_{t,g} \le \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\, t \in \mathcal{T},\ g **`lp_curve_breakpoints`** ```math -\lvert \{ a \in \mathcal{A} \,:\, \mathrm{bp\_x}_{g,a} \text{ is defined} \} \rvert \ge 2 \qquad \forall\, g \in \mathcal{G} +\lvert \{ a \in \mathcal{A} \,:\, \mathrm{bp\_x}_{g,a} \text{ is defined} \} \rvert \ge 2 \qquad \forall\, g \in \mathcal{G} \,:\, \lvert \{ a \in \mathcal{A} \,:\, \mathrm{bp\_x}_{g,a} \text{ is defined} \} \rvert > 0 ``` **`lp_curve_contiguous`** ```math -\lvert \{ a \in \mathcal{A} \,:\, \mathrm{bp\_x}_{g,a} \text{ is defined} \wedge \neg \left( \mathrm{bp\_x}_{g,a - 1} \text{ is defined} \right) \} \rvert = 1 \qquad \forall\, g \in \mathcal{G} +\lvert \{ a \in \mathcal{A} \,:\, \mathrm{bp\_x}_{g,a} \text{ is defined} \wedge \neg \left( \mathrm{bp\_x}_{g,a - 1} \text{ is defined} \right) \} \rvert = 1 \qquad \forall\, g \in \mathcal{G} \,:\, \lvert \{ a \in \mathcal{A} \,:\, \mathrm{bp\_x}_{g,a} \text{ is defined} \} \rvert > 0 ``` **`ramp_curve_complete`** @@ -681,3 +693,15 @@ p_{t,g} \le \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\, t \in \mathcal{T},\ g ```math \lvert \{ a \in \mathcal{A} \,:\, \mathrm{bp\_x}_{g,a} \text{ is defined} \} \rvert \ge 2 \qquad \forall\, g \in \mathcal{G} ``` + +**`chp_curve_complete`** + +```math +\mathrm{bp\_x}_{g,a} \text{ is defined} \wedge \mathrm{bp\_y}_{g,a} \text{ is defined} \wedge \mathrm{bp\_heat}_{g,a} \text{ is defined} \qquad \forall\, g \in \mathcal{G},\ a \in \mathcal{A} +``` + +**`bus_curve_p_complete`** + +```math +\mathrm{bp\_x}_{g,a} \text{ is defined} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G},\ a \in \mathcal{A} \,:\, \mathrm{load}_{t,\mathrm{gen\_bus}(g)} \text{ is defined} +``` diff --git a/tests/typesetting/golden/model.yaml b/tests/typesetting/golden/model.yaml index fcdce15e..8d1b118b 100644 --- a/tests/typesetting/golden/model.yaml +++ b/tests/typesetting/golden/model.yaml @@ -104,39 +104,57 @@ sos: piecewise: fuel_curve: # three expressions on one curve, gated by a binary every unit has - over: bp + along: bp + dims: [snapshot, generator] links: - - [p, bp_x] - - [fuel, bp_y] - - [heat, bp_heat] + p: [p, bp_x] + fuel: [fuel, bp_y] + heat: [heat, bp_heat] activity: on cost_curve: # a bounded link, which prints as the side of the curve it is on, and a gate only some units have - over: bp + along: bp + dims: [snapshot, generator] method: sos2 - points: bp_run + where: bp_run activity: warm links: - - [p, bp_x] - - [op_cost, bp_y, ">="] + p: [p, bp_x] + op_cost: [op_cost, bp_y, ">="] hull_curve: # the convex method, whose weights range over the hull the breakpoints span rather than the curve - over: bp + along: bp + dims: [snapshot, generator] method: convex links: - - [p, bp_x] - - [fuel, bp_heat] + p: [p, bp_x] + fuel: [fuel, bp_heat] lp_curve: # the lp method masked by one of its own breakpoints: every condition that method puts on data - over: bp + along: bp + dims: [snapshot, generator] method: lp - points: bp_x + where: bp_x links: - - [p, bp_x] - - [fuel, bp_y, ">="] + p: [p, bp_x] + fuel: [fuel, bp_y, ">="] ramp_curve: # the same method bounded the other way over a whole axis, so the shape is concave and nothing masks it - over: bp + along: bp + dims: [snapshot, generator] method: lp links: - - [p, bp_x] - - [heat, bp_heat, "<="] + p: [p, bp_x] + heat: [heat, bp_heat, "<="] + chp_curve: # bounds on two sides of one point, which print as the cone their signs add to the curve + along: bp + dims: [snapshot, generator] + links: + p: [p, bp_x] + fuel: [fuel, bp_y, ">="] + heat: [heat, bp_heat, "<="] + bus_curve: # one curve per bus, read by each generator on it: a link that walks a relation prints as the family it ties, and the mask is read through the relation too + along: bp + dims: [snapshot, bus] + where: load + links: + p: { expression: p, values: bp_x, by: gen_bus, over: bus, into: generator } expressions: spend_cap: cost * 2 # a data-only entry, so a where may compare it diff --git a/tests/typesetting/golden/typst.out b/tests/typesetting/golden/typst.out index 8ffd07dd..7feefc3e 100644 --- a/tests/typesetting/golden/typst.out +++ b/tests/typesetting/golden/typst.out @@ -127,7 +127,9 @@ $ upright("budgeted") & italic("spend")_(t) & <= upright("budget") & forall t in upright("cost_curve") & italic("op_cost")_(t,g) & >= cases(italic("warm")_(t,g) & upright("if ") upright("is_flexible")_(g), 1 & upright("otherwise")) dot upright("pwl")_(a in cal(A) colon upright("bp_run")_(g,a))(upright("bp_x")_(g,a), upright("bp_y")_(g,a))(p_(t,g)) & forall t in cal(T), g in cal(G) \ upright("hull_curve") & (p_(t,g), italic("fuel")_(t,g)) & in upright("conv")_(a in cal(A))(upright("bp_x")_(g,a), upright("bp_heat")_(g,a)) & forall t in cal(T), g in cal(G) \ upright("lp_curve") & italic("fuel")_(t,g) & >= upright("pwl")_(a in cal(A) colon upright("bp_x")_(g,a) upright(" is defined"))(upright("bp_x")_(g,a), upright("bp_y")_(g,a))(p_(t,g)) & forall t in cal(T), g in cal(G) \ - upright("ramp_curve") & italic("heat")_(t,g) & <= upright("pwl")_(a in cal(A))(upright("bp_x")_(g,a), upright("bp_heat")_(g,a))(p_(t,g)) & forall t in cal(T), g in cal(G) $ + upright("ramp_curve") & italic("heat")_(t,g) & <= upright("pwl")_(a in cal(A))(upright("bp_x")_(g,a), upright("bp_heat")_(g,a))(p_(t,g)) & forall t in cal(T), g in cal(G) \ + upright("chp_curve") & (p_(t,g), italic("fuel")_(t,g), italic("heat")_(t,g)) & in upright("pwl")_(a in cal(A))(upright("bp_x")_(g,a), upright("bp_y")_(g,a), upright("bp_heat")_(g,a)) + {0} times RR_(>= 0) times RR_(<= 0) & forall t in cal(T), g in cal(G) \ + upright("bus_curve") & (p_(t,g))_(g in cal(G) colon upright("gen_bus")(g) = b) & in upright("pwl")_(a in cal(A))((upright("bp_x")_(g,a))_(g in cal(G) colon upright("gen_bus")(g) = b)) & forall t in cal(T), b in cal(B) colon upright("load")_(t,b) upright(" is defined") $ == Definitions #set math.equation(numbering: "(1)") @@ -169,16 +171,18 @@ $ upright("bounds_do_not_cross") & upright("p")^(upright("min"))_(g) & <= uprigh upright("northern_demand_is_real") & upright("load")_(t,b) & >= 0 & forall t in cal(T), b in cal(B) colon upright("zone_of")(b) = upright("'north'") \ upright("fuel_curve_complete") & upright("bp_x")_(g,a) upright(" is defined") and upright("bp_y")_(g,a) upright(" is defined") and upright("bp_heat")_(g,a) upright(" is defined") & & forall g in cal(G), a in cal(A) \ upright("cost_curve_complete") & upright("bp_x")_(g,a) upright(" is defined") and upright("bp_y")_(g,a) upright(" is defined") & & forall g in cal(G), a in cal(A) colon upright("bp_run")_(g,a) \ - upright("cost_curve_contiguous") & abs({a in cal(A) colon upright("bp_run")_(g,a) and not upright("bp_run")_(g,a - 1)}) & = 1 & forall g in cal(G) \ + upright("cost_curve_contiguous") & abs({a in cal(A) colon upright("bp_run")_(g,a) and not upright("bp_run")_(g,a - 1)}) & = 1 & forall g in cal(G) colon abs({a in cal(A) colon upright("bp_run")_(g,a)}) > 0 \ upright("hull_curve_complete") & upright("bp_x")_(g,a) upright(" is defined") and upright("bp_heat")_(g,a) upright(" is defined") & & forall g in cal(G), a in cal(A) \ upright("hull_curve_increasing") & upright("bp_x")_(g,a minus.square_(0) 1) & < upright("bp_x")_(g,a) & forall g in cal(G), a in cal(A) colon upright("pos")(a) > 0 \ upright("hull_curve_curvature") & abs({a in cal(A) colon (upright("bp_heat")_(g,a) - upright("bp_heat")_(g,a minus.square_(0) 1)) dot (upright("bp_x")_(g,a plus.square_(0) 1) - upright("bp_x")_(g,a)) > (upright("bp_heat")_(g,a plus.square_(0) 1) - upright("bp_heat")_(g,a)) dot (upright("bp_x")_(g,a) - upright("bp_x")_(g,a minus.square_(0) 1)) and upright("pos")(a) > 0 and upright("pos")(a) != abs(cal(A)) - 1}) = 0 or abs({a in cal(A) colon (upright("bp_heat")_(g,a) - upright("bp_heat")_(g,a minus.square_(0) 1)) dot (upright("bp_x")_(g,a plus.square_(0) 1) - upright("bp_x")_(g,a)) < (upright("bp_heat")_(g,a plus.square_(0) 1) - upright("bp_heat")_(g,a)) dot (upright("bp_x")_(g,a) - upright("bp_x")_(g,a minus.square_(0) 1)) and upright("pos")(a) > 0 and upright("pos")(a) != abs(cal(A)) - 1}) = 0 & & forall g in cal(G) \ upright("lp_curve_complete") & upright("bp_x")_(g,a) upright(" is defined") and upright("bp_y")_(g,a) upright(" is defined") & & forall g in cal(G), a in cal(A) colon upright("bp_x")_(g,a) upright(" is defined") \ upright("lp_curve_increasing") & upright("bp_x")_(g,a minus.square_(0) 1) & < upright("bp_x")_(g,a) & forall g in cal(G), a in cal(A) colon upright("bp_x")_(g,a) upright(" is defined") and upright("bp_x")_(g,a - 1) upright(" is defined") \ upright("lp_curve_curvature") & (upright("bp_y")_(g,a) - upright("bp_y")_(g,a minus.square_(0) 1)) dot (upright("bp_x")_(g,a plus.square_(0) 1) - upright("bp_x")_(g,a)) & <= (upright("bp_y")_(g,a plus.square_(0) 1) - upright("bp_y")_(g,a)) dot (upright("bp_x")_(g,a) - upright("bp_x")_(g,a minus.square_(0) 1)) & forall g in cal(G), a in cal(A) colon upright("bp_x")_(g,a) upright(" is defined") and upright("bp_x")_(g,a - 1) upright(" is defined") and upright("bp_x")_(g,a + 1) upright(" is defined") \ - upright("lp_curve_breakpoints") & abs({a in cal(A) colon upright("bp_x")_(g,a) upright(" is defined")}) & >= 2 & forall g in cal(G) \ - upright("lp_curve_contiguous") & abs({a in cal(A) colon upright("bp_x")_(g,a) upright(" is defined") and not (upright("bp_x")_(g,a - 1) upright(" is defined"))}) & = 1 & forall g in cal(G) \ + upright("lp_curve_breakpoints") & abs({a in cal(A) colon upright("bp_x")_(g,a) upright(" is defined")}) & >= 2 & forall g in cal(G) colon abs({a in cal(A) colon upright("bp_x")_(g,a) upright(" is defined")}) > 0 \ + upright("lp_curve_contiguous") & abs({a in cal(A) colon upright("bp_x")_(g,a) upright(" is defined") and not (upright("bp_x")_(g,a - 1) upright(" is defined"))}) & = 1 & forall g in cal(G) colon abs({a in cal(A) colon upright("bp_x")_(g,a) upright(" is defined")}) > 0 \ upright("ramp_curve_complete") & upright("bp_x")_(g,a) upright(" is defined") and upright("bp_heat")_(g,a) upright(" is defined") & & forall g in cal(G), a in cal(A) \ upright("ramp_curve_increasing") & upright("bp_x")_(g,a minus.square_(0) 1) & < upright("bp_x")_(g,a) & forall g in cal(G), a in cal(A) colon upright("pos")(a) > 0 \ upright("ramp_curve_curvature") & (upright("bp_heat")_(g,a) - upright("bp_heat")_(g,a minus.square_(0) 1)) dot (upright("bp_x")_(g,a plus.square_(0) 1) - upright("bp_x")_(g,a)) & >= (upright("bp_heat")_(g,a plus.square_(0) 1) - upright("bp_heat")_(g,a)) dot (upright("bp_x")_(g,a) - upright("bp_x")_(g,a minus.square_(0) 1)) & forall g in cal(G), a in cal(A) colon upright("pos")(a) > 0 and upright("pos")(a) != abs(cal(A)) - 1 \ - upright("ramp_curve_breakpoints") & abs({a in cal(A) colon upright("bp_x")_(g,a) upright(" is defined")}) & >= 2 & forall g in cal(G) $ + upright("ramp_curve_breakpoints") & abs({a in cal(A) colon upright("bp_x")_(g,a) upright(" is defined")}) & >= 2 & forall g in cal(G) \ + upright("chp_curve_complete") & upright("bp_x")_(g,a) upright(" is defined") and upright("bp_y")_(g,a) upright(" is defined") and upright("bp_heat")_(g,a) upright(" is defined") & & forall g in cal(G), a in cal(A) \ + upright("bus_curve_p_complete") & upright("bp_x")_(g,a) upright(" is defined") & & forall t in cal(T), g in cal(G), a in cal(A) colon upright("load")_(t,upright("gen_bus")(g)) upright(" is defined") $ diff --git a/tests/typesetting/test_golden.py b/tests/typesetting/test_golden.py index 9fd74179..fab0b710 100644 --- a/tests/typesetting/test_golden.py +++ b/tests/typesetting/test_golden.py @@ -146,6 +146,8 @@ def _rendered_trees() -> Iterator[object]: yield program.expressions[name].expression for curve in program.piecewise.values(): yield from (link.expression for link in curve.links) + if curve.where is not None: + yield curve.where.root #: A dataclass the walk steps *through* rather than renders: a region has no diff --git a/tests/typesetting/test_symbols.py b/tests/typesetting/test_symbols.py index 2caa11fa..6f7467f5 100644 --- a/tests/typesetting/test_symbols.py +++ b/tests/typesetting/test_symbols.py @@ -104,7 +104,11 @@ def test_a_named_expression_has_a_legend_row_exactly_while_its_symbol_prints(nam 'parameters.bp_x': {'dims': ['generator', 'bp']}, 'parameters.bp_y': {'dims': ['generator', 'bp']}, 'variables.op_cost': {'dims': ['snapshot', 'generator'], 'bounds': {'lower': 0}}, - 'piecewise.curve': {'over': 'bp', 'links': [['p', 'bp_x'], ['op_cost', 'bp_y']]}, + 'piecewise.curve': { + 'along': 'bp', + 'dims': ['snapshot', 'generator'], + 'links': {'p': ['p', 'bp_x'], 'op_cost': ['op_cost', 'bp_y']}, + }, }, ) @@ -135,7 +139,10 @@ def _names(spec: Spec) -> set[str]: pytest.param({'piecewise.curve.method': 'sos2'}, id='sos2'), pytest.param({'piecewise.curve.method': 'convex'}, id='convex'), pytest.param( - {'piecewise.curve.method': 'lp', 'piecewise.curve.links': [['p', 'bp_x'], ['op_cost', 'bp_y', '>=']]}, + { + 'piecewise.curve.method': 'lp', + 'piecewise.curve.links': {'p': ['p', 'bp_x'], 'op_cost': ['op_cost', 'bp_y', '>=']}, + }, id='lp', ), pytest.param( @@ -156,7 +163,7 @@ def test_a_table_spells_the_names_the_expansion_declares_and_no_other(patch): reserved = { 'curve', *(f'curve_{s}' for s in ('lam', 'convexity', 'convexity_ungated', 'chord', 'domain_lo', 'domain_hi')), - *(f'curve_{s}' for s in ('seg', 'pick', 'adjacency', 'adjacency_below', 'link0', 'link1')), + *(f'curve_{s}' for s in ('seg', 'pick', 'adjacency', 'adjacency_below', 'p', 'op_cost')), *(f'curve_{s}' for s in ('complete', 'increasing')), } assert written <= reserved, 'the reserved names cover every name the expansion writes' diff --git a/tests/typesetting/test_walk.py b/tests/typesetting/test_walk.py index 090d334b..05ad0ff1 100644 --- a/tests/typesetting/test_walk.py +++ b/tests/typesetting/test_walk.py @@ -990,7 +990,9 @@ def test_a_condition_a_method_states_is_a_line_that_may_be_asked_for_before_it_i 'on': {'dims': ['snapshot'], 'domain': 'binary'}, 'warm': {'dims': ['snapshot'], 'domain': 'binary', 'where': 'committable'}, }, - 'piecewise': {'curve': {'over': 'bp', 'links': [['p', 'bp_x'], ['op_cost', 'bp_y']]}}, + 'piecewise': { + 'curve': {'along': 'bp', 'dims': ['snapshot'], 'links': {'p': ['p', 'bp_x'], 'op_cost': ['op_cost', 'bp_y']}} + }, 'objective': {'sense': 'minimize', 'expression': 'sum(op_cost, over=snapshot)'}, } @@ -1010,15 +1012,31 @@ def test_a_condition_a_method_states_is_a_line_that_may_be_asked_for_before_it_i id='the-convex-method-relaxes-it-onto-the-hull', ), pytest.param( - {'piecewise.curve.links': [['p', 'bp_x'], ['op_cost', 'bp_y', '>=']], 'piecewise.curve.method': 'lp'}, + { + 'piecewise.curve.links': {'p': ['p', 'bp_x'], 'op_cost': ['op_cost', 'bp_y', '>=']}, + 'piecewise.curve.method': 'lp', + }, r'\mathit{op\_cost}_{t} \ge \mathrm{pwl}_{b \in \mathcal{B}}' r'(\mathrm{bp\_x}_{b},\ \mathrm{bp\_y}_{b})(p_{t})', id='a-bounded-link-states-one-side-of-the-curve', ), pytest.param( - {'piecewise.curve.points': 'reaches'}, + { + 'piecewise.curve.links': { + 'p': ['p', 'bp_x'], + 'op_cost': ['op_cost', 'bp_y', '>='], + 'twice': ['p * 2', 'bp_x', '<='], + }, + }, + r'\left( p_{t},\ \mathit{op\_cost}_{t},\ p_{t} \cdot 2 \right) \in \mathrm{pwl}_{b \in \mathcal{B}}' + r'(\mathrm{bp\_x}_{b},\ \mathrm{bp\_y}_{b},\ \mathrm{bp\_x}_{b}) + \{0\} \times \mathbb{R}_{\ge 0} ' + r'\times \mathbb{R}_{\le 0}', + id='bounded-links-beside-more-than-one-other-add-the-cone-their-signs-span', + ), + pytest.param( + {'piecewise.curve.where': 'reaches'}, r'\mathrm{pwl}_{b \in \mathcal{B} \,:\, \mathrm{reaches}_{b}}', - id='points-narrows-the-breakpoints-to-the-ones-it-admits', + id='a-ragged-where-narrows-the-breakpoints-to-the-ones-it-admits', ), pytest.param( {'piecewise.curve.activity': 'on'}, @@ -1047,11 +1065,12 @@ def test_a_gate_that_does_not_exist_everywhere_prints_the_two_arms_the_expansion def test_a_curve_prints_over_the_frame_its_expansion_builds_one_per_coordinate_of(): - """Two homes for one union, so the line's quantifier is held to the rows the expansion emits.""" + """The quantifier is the block's `dims:`, as the rows the expansion emits are.""" model = varied( _CURVE, **{ 'dimensions.generator': {'dtype': 'str'}, + 'piecewise.curve.dims': ['snapshot', 'generator'], 'parameters.bp_x.dims': ['generator', 'bp'], 'parameters.bp_y.dims': ['generator', 'bp'], 'variables.p.dims': ['snapshot', 'generator'], @@ -1060,13 +1079,25 @@ def test_a_curve_prints_over_the_frame_its_expansion_builds_one_per_coordinate_o }, ) spec = to_spec(model) - emitted = spec.expand('piecewise').constraints['curve_link0'].dims + emitted = spec.expand('piecewise').constraints['curve_p'].dims printed = typeset_declaration(spec, 'curve', 'latex') assert printed.endswith(r'\forall\, t \in \mathcal{T},\ g \in \mathcal{G}') assert emitted == ['snapshot', 'generator'], 'the quantifier above is that frame, in that order' +def test_a_walked_link_prints_as_the_family_of_rows_that_read_one_curve(): + """Printed off the union of the links' dims, every flow sat on every converter's curve and no relation showed.""" + printed = typeset_declaration(to_spec(EXAMPLES / 'piecewise_coupling.yaml'), 'operating_point', 'latex') + + family = r'_{f \in \mathcal{F} \,:\, \mathrm{converter\_of}(f) = c}' + assert printed.startswith(rf'\left( \mathit{{rate}}_{{f,t}} \right){family} \in'), 'a converter ties its own flows' + assert rf'(\left( \mathrm{{bp\_rate}}_{{f,b}} \right){family})' in printed, 'each flow reads its own breakpoints' + assert printed.endswith(r'\forall\, c \in \mathcal{C},\ t \in \mathcal{T} \,:\, \mathrm{has\_curve}_{c}'), ( + 'one curve per converter that has one, not per flow' + ) + + def test_the_expansion_prints_the_rows_the_block_states(): """Which is the whole reason the block prints as one line: the two readings are one call apart.""" spec = to_spec(_CURVE) diff --git a/tools/gallery.py b/tools/gallery.py index 67d2df05..a7708081 100644 --- a/tools/gallery.py +++ b/tools/gallery.py @@ -50,6 +50,10 @@ MODELS = { 'dispatch.md': ROOT / 'examples' / 'dispatch.yaml', 'commitment.md': ROOT / 'examples' / 'commitment.yaml', + 'piecewise.md': ROOT / 'examples' / 'piecewise.yaml', + 'piecewise_adjacency.md': ROOT / 'examples' / 'piecewise_adjacency.yaml', + 'sos.md': ROOT / 'examples' / 'sos.yaml', + 'piecewise_lp.md': ROOT / 'examples' / 'piecewise_lp.yaml', 'library/surface.md': LIBRARY / 'surface.yaml', 'library/generator.md': LIBRARY / 'generator.yaml', 'library/load.md': LIBRARY / 'load.yaml', @@ -86,8 +90,15 @@ def model_block(path: Path) -> str: - """One spec, then the whole document the typesetter prints from it.""" - return f'```yaml\n{without_header(path)}\n```\n\n{to_markdown(path, numbered=False).strip()}' + """One spec, then the whole document the typesetter prints from it. + + Under the spec's own symbol table where it has one, as + [`declared_block`][] is: a weight named after the block that declared it + is right in the file and unreadable in the equation that names it six + times. + """ + page = to_markdown(path, symbols=sidecar_for(path), numbered=False) + return f'```yaml\n{without_header(path)}\n```\n\n{page.strip()}' def symbols_for(model: Spec, table_path: Path = LIBRARY_SYMBOLS) -> dict[str, Any]: