From 216208b7dd548c99309ae409e34d264eb1b70421 Mon Sep 17 00:00:00 2001 From: Felix <117816358+FBumann@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:19:43 +0200 Subject: [PATCH 1/4] docs(limits): a where cannot test a relation against its target dimension, and composition is a limit on the file (#624) --- docs/about/limits.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/about/limits.md b/docs/about/limits.md index 106b75ba..06055806 100644 --- a/docs/about/limits.md +++ b/docs/about/limits.md @@ -140,6 +140,7 @@ That another tool has a feature is not by itself a reason to add it. | Normalisation, `x / sum(x)` | dividing by a variable is not a polynomial, and no solver takes it | write the ratio as a constraint, or fix the denominator | | An `if`, a loop, or declarations that depend on the data | `to_spec` could no longer read the file without the data | `where:` masks and `dims:` dimensions. A tool may loop over models | | A Python API for building models | the model is the file you review and diff | YAML, or a `dict` with the same keys ([below](#composition-component-libraries)) | +| A `where` comparing a relation column against the dimension it maps into | the relation already pairs the two, and a mask over the pair is the same fact in a bigger shape | place the quantity with `sum(by=)`, or read it with `at(by=)` ([operators](../reference/language/operators.md#sum)) | ## Composition (component libraries) @@ -154,5 +155,5 @@ grows with the number of component _types_. Merging happens before `to_spec`. Every function here takes a `dict` as well as a path, so a model assembled in Python is checked exactly as a file is, and `Spec.to_yaml()` writes the file a reviewer reads. A `dict` may hold only what a -file may hold. A built-in merge, and namespaces so that two templates can each -declare a `p`, are both things a library does before it hands over a `dict`. +file may hold, so the file itself states no composition. A template names no +sibling, and no key says which fragment wins where two declare a `p`. From f9cb30588da770248ab4df916347b5e2239bd5e7 Mon Sep 17 00:00:00 2001 From: "energy-models-release-please[bot]" <319464627+energy-models-release-please[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 18:22:34 +0000 Subject: [PATCH 2/4] chore(main): release 0.0.0-alpha.117 (#625) Co-authored-by: energy-models-release-please[bot] <319464627+energy-models-release-please[bot]@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 1693a25f..673dae75 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.0.0-alpha.116" + ".": "0.0.0-alpha.117" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 08b007f3..1db4bea3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,13 @@ contained a literal `## [X.Y.Z]` heading, release-please inserts above the first `##` it finds, and so the entire release landed inside the comment and rendered nowhere. +## [0.0.0-alpha.117](https://github.com/energy-models/math-spec/compare/v0.0.0-alpha.116...v0.0.0-alpha.117) (2026-09-22) + + +### Documentation + +* **limits:** a where cannot test a relation against its target dimension, and composition is a limit on the file ([#624](https://github.com/energy-models/math-spec/issues/624)) ([216208b](https://github.com/energy-models/math-spec/commit/216208b7dd548c99309ae409e34d264eb1b70421)) + ## [0.0.0-alpha.116](https://github.com/energy-models/math-spec/compare/v0.0.0-alpha.115...v0.0.0-alpha.116) (2026-09-22) From af62b2810f6e9a06eb0877739ba37ecaa3ae1984 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 20:27:34 +0000 Subject: [PATCH 3/4] feat(language): a where may read a predicate through a relation with at() `at(, by=, over=, into=)` reads a mask over coarse coordinates at fine ones, as `at` reads an array: true where the relation has a row and the predicate holds at the coordinate it maps to, false where the relation has no row. It is the third operator that reads a predicate, beside `shift` and `count`, and is held to the rules of the expression `at`, so a bad read is refused in the same words in both. The program carries it as `PulledBackPredicate`: the operand mask, the `Direction`, and the dims after the read. `names_read` includes the relation. Exclusivity refuses it in a case `when:` as undecidable, as it refuses `shift` and `count`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HxoXSQCyrcDs6vuRk3x6tz --- docs/reference/language/expressions.md | 78 ++++++++++++++++++++------ docs/reference/notation.md | 15 +++++ docs/reference/reading.md | 16 +++--- src/math_spec/dimensions.py | 16 +++++- src/math_spec/exclusivity.py | 16 +++++- src/math_spec/lowering.py | 2 +- src/math_spec/program.py | 21 +++++++ src/math_spec/resolution.py | 40 ++++++++++--- src/math_spec/typesetting/walk.py | 18 ++++-- tests/test_lowering.py | 25 +++++++++ tests/test_validation.py | 73 +++++++++++++++++++++++- tests/typesetting/golden/latex.out | 1 + tests/typesetting/golden/markdown.out | 6 ++ tests/typesetting/golden/model.yaml | 4 ++ tests/typesetting/golden/typst.out | 1 + 15 files changed, 288 insertions(+), 44 deletions(-) diff --git a/docs/reference/language/expressions.md b/docs/reference/language/expressions.md index 207a608c..6ea56fba 100644 --- a/docs/reference/language/expressions.md +++ b/docs/reference/language/expressions.md @@ -116,34 +116,36 @@ where_expr ::= atom | "NOT" where_expr | where_expr ("AND"|"OR") where_expr | "(" where_expr ")" atom ::= NAME | NAME COMPARATOR value | expression COMPARATOR expression | POSITION COMPARATOR INTEGER | COUNT COMPARATOR INTEGER | TRANSLATED - | "True" | "False" + | READ | "True" | "False" COMPARATOR ::= "<=" | ">=" | "==" | "!=" | "<" | ">" value ::= NUMBER | QUOTED | NAME_OR_STRING expression ::= the arithmetic grammar above, with no variable and no dual in it POSITION ::= "position" "(" NAME [ "," "by" "=" NAME "," "within" "=" COLUMNS ] ")" COUNT ::= "count" "(" where_expr "," "over" "=" NAME ")" TRANSLATED ::= "shift" "(" where_expr "," "along" "=" NAME "," "offset" "=" INTEGER ")" +READ ::= "at" "(" where_expr "," "by" "=" NAME "," "over" "=" COLUMNS "," "into" "=" COLUMNS ")" COLUMNS ::= NAME | "[" NAME { "," NAME } "]" QUOTED ::= "'" chars "'" | '"' chars '"' ``` -| Written as | Names a… | Meaning | -| ----------------------------------------- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `name` (bare) | parameter | The value is defined here. A `bool` is its own answer. A `str` is defined wherever the table has a row. A number has to have a row and be finite | -| `name` (bare) | variable | The variable exists at this coordinate | -| `name` (bare) | relation | A row exists, read at the relation's key. A relation may be [partial](relations.md#the-data-contract), and this selects the labels that do map | -| `name` (bare) | dimension | A load error. It would be true everywhere | -| `name OP value` | parameter | Element-wise, and a null compares false | -| `name OP value` | dimension | A filter on the frame's own coordinate column | -| `name OP value`, `name.col OP value` | relation | A filter on a value column, read at the relation's key. Name the column where the key determines several | -| `name OP name`, `name.a OP name.b` | two relation columns | Legal where both relations are keyed over the same dimensions and both columns are over one dimension. `ends.bus0 != ends.bus1` excludes a self-loop | -| `expression OP expression` | arithmetic over parameters | Coordinate by coordinate, over every dimension either side carries ([arithmetic in a comparison](#arithmetic-in-a-comparison)). A side with no value at a coordinate compares false | -| `position(name) OP i` | dimension | Where the row sits along the dimension's own order. `0` is first, and a negative number counts from the end | -| `position(name, by=relation, within=c)` | dimension | The same, counted within each group the relation makes | -| `count(where_expr, over=name) OP i` | a predicate | How many coordinates along the dimension the predicate admits ([counting what a predicate admits](#counting-what-a-predicate-admits)) | -| `shift(where_expr, along=name, offset=i)` | a predicate | The predicate read `i` coordinates back, and false where that vacates | -| `AND` `OR` `NOT` | — | Case-insensitive. `NOT` binds tighter than `AND`, and `AND` tighter than `OR` | -| `True` / `False` | — | `True` is the same as no `where`; `False` gives a declaration with no rows. A [case `when:`](named.md#the-rules-that-keep-the-cases-apart) may not fold to either | +| Written as | Names a… | Meaning | +| --------------------------------------------- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `name` (bare) | parameter | The value is defined here. A `bool` is its own answer. A `str` is defined wherever the table has a row. A number has to have a row and be finite | +| `name` (bare) | variable | The variable exists at this coordinate | +| `name` (bare) | relation | A row exists, read at the relation's key. A relation may be [partial](relations.md#the-data-contract), and this selects the labels that do map | +| `name` (bare) | dimension | A load error. It would be true everywhere | +| `name OP value` | parameter | Element-wise, and a null compares false | +| `name OP value` | dimension | A filter on the frame's own coordinate column | +| `name OP value`, `name.col OP value` | relation | A filter on a value column, read at the relation's key. Name the column where the key determines several | +| `name OP name`, `name.a OP name.b` | two relation columns | Legal where both relations are keyed over the same dimensions and both columns are over one dimension. `ends.bus0 != ends.bus1` excludes a self-loop | +| `expression OP expression` | arithmetic over parameters | Coordinate by coordinate, over every dimension either side carries ([arithmetic in a comparison](#arithmetic-in-a-comparison)). A side with no value at a coordinate compares false | +| `position(name) OP i` | dimension | Where the row sits along the dimension's own order. `0` is first, and a negative number counts from the end | +| `position(name, by=relation, within=c)` | dimension | The same, counted within each group the relation makes | +| `count(where_expr, over=name) OP i` | a predicate | How many coordinates along the dimension the predicate admits ([counting what a predicate admits](#counting-what-a-predicate-admits)) | +| `shift(where_expr, along=name, offset=i)` | a predicate | The predicate read `i` coordinates back, and false where that vacates | +| `at(where_expr, by=relation, over=a, into=b)` | a predicate | The predicate read through the relation ([reading a predicate through a relation](#reading-a-predicate-through-a-relation)), and false where the relation has no row | +| `AND` `OR` `NOT` | — | Case-insensitive. `NOT` binds tighter than `AND`, and `AND` tighter than `OR` | +| `True` / `False` | — | `True` is the same as no `where`; `False` gives a declaration with no rows. A [case `when:`](named.md#the-rules-that-keep-the-cases-apart) may not fold to either | The dimensions of the mask must not exceed the frame it sits in. A bare name that is not declared is a load error. @@ -212,6 +214,46 @@ A negative `offset` reads forwards. `by=`, `within=` and `edge='wrap'` are not in this form; where you need a grouped or cyclic translation, compare the arithmetic one instead. +### Reading a predicate through a relation + +`at(, by=, over=, into=)` reads a predicate over +coarse coordinates at fine ones, as [`at`](operators.md#at) reads an array. It +is true at a coordinate where the relation has a row and the predicate holds at +the coordinate that row maps to. It is **false** where the relation has no row, +which is what a missing row already means in a mask. + +```yaml +dimensions: + converter: { dtype: str } + flow: { dtype: str } +relations: + converter_of: { key: flow, values: converter } +parameters: + has_curve: { dims: [converter], dtype: bool } + cap: { dims: [flow] } +variables: + rate: + dims: [flow] + where: "at(has_curve, by=converter_of, over=converter, into=flow)" + bounds: { lower: 0, upper: cap } +objective: + sense: minimize + expression: sum(rate, over=flow) +``` + +$$0 \le \mathit{rate}_{f} \le \mathrm{cap}_{f} \qquad \forall\thinspace f \in \mathcal{F} \thinspace : \thinspace \mathrm{has\_curve}_{\mathrm{converter\_of}(f)}$$ + +The consumed dimension goes and the produced one arrives, so the mask above is +over `flow` alone. The rules are those of `at` in an expression: `by=`, +`over=` and `into=` are all written, the read lands on the relation's key, and +the predicate carries every dimension the read consumes. The read maps one +dimension onto another and adds none, so a mask still may not widen its frame. + +A parameter compared as arithmetic reads through a relation too: +`at(cap, by=bus_of, over=bus, into=generator) > 0`. The predicate form reads +what arithmetic cannot: whether a row is defined, a `bool`, a variable's +existence, and any connective over them. + ### The right-hand side of a comparison A bare name on the right is read as a string label when the model does not diff --git a/docs/reference/notation.md b/docs/reference/notation.md index b723023f..3452684a 100644 --- a/docs/reference/notation.md +++ b/docs/reference/notation.md @@ -806,6 +806,21 @@ run_start: \mathit{slack}_{t} \le \mathrm{load}_{t,b} \qquad \forall\, t \in \mathcal{T},\ b \in \mathcal{B} \,:\, \mathrm{load}_{t,b} \text{ is defined} \wedge \neg \left( \mathrm{load}_{t - 1,b} \text{ is defined} \right) ``` +#### `zoned` + +a predicate read through a relation: a bus is held only where its zone has a cap at all + +```yaml +zoned: + dims: [bus] + where: "at(zone_cap, by=zone_of, over=zone, into=bus)" + expression: theta <= budget +``` + +```math +\theta_{b} \le \mathrm{budget} \qquad \forall\, b \in \mathcal{B} \,:\, \mathrm{zone\_cap}_{\mathrm{zone\_of}(b)} \text{ is defined} +``` + #### `capped` an expressions: entry on a side, read by the name the file gave it diff --git a/docs/reference/reading.md b/docs/reference/reading.md index d296c024..9ea57a2a 100644 --- a/docs/reference/reading.md +++ b/docs/reference/reading.md @@ -158,13 +158,15 @@ A name compared against a literal does not arrive this way. `p_max > 5` is a both mask the same coordinates. Match both where you read a comparison over parameters. -Two predicates read another predicate rather than a declaration. A -`CountComparison` carries the mask it counts and the dimension it counts away; -a `TranslatedPredicate` carries the mask it reads at a neighbouring -coordinate. Each holds that mask as a `Mask`, where a connective holds a bare -predicate: the walk recurses through a connective and stops at these, so read -the field where you need what is inside. `.names_read` and `.dims` already see -through both. +Three predicates read another predicate rather than a declaration. A +`CountComparison` carries the mask it counts and the dimension it counts away. +A `TranslatedPredicate` carries the mask it reads at a neighbouring +coordinate. A `PulledBackPredicate` carries the mask it reads through a +relation, and the `Direction` it reads in. Each holds that mask as a `Mask`, +where a connective holds a bare predicate: the walk recurses through a +connective and stops at these, so read the field where you need what is +inside. `.names_read` and `.dims` already see through all three, and the +relation a `PulledBackPredicate` reads is in its `.names_read`. A predicate you build yourself answers the same four questions: wrap it in `Mask`, or build it there with `~`, `&` and `|`. A mask folds as it is built, diff --git a/src/math_spec/dimensions.py b/src/math_spec/dimensions.py index 5a5f5cbd..6ae0b740 100644 --- a/src/math_spec/dimensions.py +++ b/src/math_spec/dimensions.py @@ -51,6 +51,7 @@ ParameterComparison, ParameterDefined, Partition, + PulledBackPredicate, RelationComparison, RelationDefined, RelationPairComparison, @@ -178,11 +179,20 @@ def _at_dims(node: FunctionCallNode, inner: frozenset[str], schema: Spec, contex """``at`` is the adjoint of ``sum(by=)``: it consumes the dims a sum produces and produces the ones it consumes.""" by = node.kwargs['by'] assert isinstance(by, DirectionNode), 'resolution reads at(by=) in a direction' - direction = by.direction + return pulled_back_dims(by.direction, inner, context, 'the expression') + + +def pulled_back_dims(direction: Direction, inner: frozenset[str], context: str, operand: str) -> frozenset[str]: + """The dims *inner* has once ``at`` reads it through *direction*, an expression's or a predicate's alike. + + Raises: + DimensionError: *operand* does not carry a dim the read consumes or + joins on, or already carries one it lands on. + """ if absent := sorted(set(direction.consumed_dims) - inner): raise DimensionError( f'{context}: at(by={direction.name}) reads through ' - f'{absent}, which the expression does not carry (dims ' + f'{absent}, which {operand} does not carry (dims ' f'{sorted(inner)}). A pullback needs the coarse dims to read *from* — ' f'sum is the direction that produces them.' ) @@ -557,6 +567,8 @@ def _check_where_dims( leaf = f"a where-count over '{atom.over}'" case TranslatedPredicate(): leaf = f"a where-predicate translated along '{atom.along}'" + case PulledBackPredicate(): + leaf = f"a where-predicate read through '{atom.direction.name}'" case _: assert_never(atom) raise DimensionError( diff --git a/src/math_spec/exclusivity.py b/src/math_spec/exclusivity.py index b4f48d4f..4c316867 100644 --- a/src/math_spec/exclusivity.py +++ b/src/math_spec/exclusivity.py @@ -35,6 +35,7 @@ Or, ParameterComparison, ParameterDefined, + PulledBackPredicate, RelationComparison, RelationDefined, RelationPairComparison, @@ -274,6 +275,12 @@ def _observe( 'decides — test this row, or precompute the neighbour as a boolean parameter and test that' ) raise Undecidable(msg) + if isinstance(node, PulledBackPredicate): + msg = ( + f"it reads a predicate through '{node.direction.name}', and which rows that admits only the data " + 'decides — test this row, or precompute the read as a boolean parameter and test that' + ) + raise Undecidable(msg) if isinstance(node, DimensionPosition): values.add(node.position) elif isinstance(node, RelationPairComparison): @@ -317,6 +324,8 @@ def _subject_of(node: TypedPredicate) -> Subject: return Subject('expression', 'a count of the coordinates a predicate admits') case TranslatedPredicate(): return Subject('expression', 'a predicate read at a neighbouring coordinate') + case PulledBackPredicate(): + return Subject('expression', 'a predicate read through a relation') case _: assert_never(node) @@ -511,8 +520,11 @@ def _atom(node: TypedPredicate, cell: dict[Subject, Cell], grid: _Grid) -> bool: case ArithmeticComparison() | ExpressionComparison(): msg = 'a comparison of expressions is refused as undecidable before any cell is read' raise AssertionError(msg) - case CountComparison() | TranslatedPredicate(): - msg = 'a predicate read as a count or at a neighbour is refused as undecidable before any cell is read' + case CountComparison() | TranslatedPredicate() | PulledBackPredicate(): + msg = ( + 'a predicate read as a count, at a neighbour or through a relation is refused as undecidable ' + 'before any cell is read' + ) raise AssertionError(msg) case DimensionPosition(op=op, position=position): return _compare(value, op, position) diff --git a/src/math_spec/lowering.py b/src/math_spec/lowering.py index b7b64594..b82f2b04 100644 --- a/src/math_spec/lowering.py +++ b/src/math_spec/lowering.py @@ -300,7 +300,7 @@ def _predicate(self, node: program.Predicate) -> program.Predicate: return program.ExpressionComparison(self.expr(node.left), node.op, self.expr(node.right), node.dims) if isinstance(node, program.CountComparison): return replace(node, predicate=self._mask(node.predicate)) - if isinstance(node, program.TranslatedPredicate): + if isinstance(node, program.TranslatedPredicate | program.PulledBackPredicate): return replace(node, operand=self._mask(node.operand)) if isinstance(node, program.Not): return program.Not(self._predicate(node.operand)) diff --git a/src/math_spec/program.py b/src/math_spec/program.py index f33a74d1..21cb1220 100644 --- a/src/math_spec/program.py +++ b/src/math_spec/program.py @@ -84,6 +84,7 @@ 'PredicateOperator', 'Program', 'Pullback', + 'PulledBackPredicate', 'QuadraticPosition', 'Reach', 'Region', @@ -1189,6 +1190,21 @@ class TranslatedPredicate: dims: tuple[str, ...] +@dataclass(frozen=True) +class PulledBackPredicate: + """*operand* read through a relation — ``at(has_curve, by=converter_of, over=converter, into=flow)``. + + True at a coordinate where the relation has a row and *operand* holds at + the coordinate that row reads. False where the relation has no row, which + is what a missing row already means in a mask. The dims ``direction`` + consumes go and the dims it produces arrive, as :class:`Pullback`'s do. + """ + + operand: Mask + direction: Direction + dims: tuple[str, ...] + + @dataclass(frozen=True) class Not: operand: Predicate @@ -1226,6 +1242,7 @@ class Or: | RelationDefined | CountComparison | TranslatedPredicate + | PulledBackPredicate | Not | And | Or @@ -1247,6 +1264,7 @@ class Or: | RelationDefined | CountComparison | TranslatedPredicate + | PulledBackPredicate ) #: The boolean connectives — the only where nodes carrying other where nodes, @@ -1309,6 +1327,7 @@ def _atom_dims(atom: TypedPredicate) -> frozenset[str]: | VariableDefined() | CountComparison() | TranslatedPredicate() + | PulledBackPredicate() ): return frozenset(atom.dims) case DimensionComparison(): @@ -1346,6 +1365,8 @@ def _atom_names(atom: TypedPredicate) -> frozenset[str]: return atom.predicate.names_read case TranslatedPredicate(): return atom.operand.names_read + case PulledBackPredicate(): + return atom.operand.names_read | {atom.direction.name} case DimensionComparison() | DimensionPosition(): return frozenset() case _: diff --git a/src/math_spec/resolution.py b/src/math_spec/resolution.py index c9e883c3..02d5326b 100644 --- a/src/math_spec/resolution.py +++ b/src/math_spec/resolution.py @@ -55,8 +55,8 @@ UnresolvedWhereNode, parse_where, ) -from math_spec.dimensions import dims_of -from math_spec.errors import LanguageError, did_you_mean, prefixed +from math_spec.dimensions import dims_of, pulled_back_dims +from math_spec.errors import DimensionError, LanguageError, did_you_mean, prefixed from math_spec.expansion import expand from math_spec.model import NUMERIC_DTYPES from math_spec.operators import ( @@ -83,6 +83,7 @@ Partition, Predicate, PredicateOperator, + PulledBackPredicate, RelationComparison, RelationDeclaration, RelationDefined, @@ -818,7 +819,7 @@ def _where_name(self, node: UnresolvedNameNode) -> Predicate | UnresolvedWhereNo return node def _predicate_call(self, node: UnresolvedPredicateCallNode) -> Predicate | UnresolvedWhereNode: - """``shift(, along=, offset=)`` — the one operator that reads a predicate and answers one. + """``shift(, along=, offset=)`` or ``at(, by=, over=, into=)`` — the two operators that read a predicate and answer one. ``count`` answers a number, so it stands on a comparison's side and :meth:`_count` reads it there. Anything else naming a predicate is @@ -835,16 +836,18 @@ def _predicate_call(self, node: UnresolvedPredicateCallNode) -> Predicate | Unre f'count(, over=) .' ) return node - if node.name != 'shift': + if node.name not in ('shift', 'at'): self.errors.append( - f"{context}: '{node.name}()' does not read a predicate. `shift` reads one and answers one, " - f'`count` reads one and answers a number, and every other operator reads arithmetic. ' + f"{context}: '{node.name}()' does not read a predicate. `shift` and `at` read one and answer " + f'one, `count` reads one and answers a number, and every other operator reads arithmetic. ' f'Compare the predicate, or name a parameter carrying it.' ) return node operand = self._child(node.operand) if len(self.errors) > found: return node + if node.name == 'at': + return self._pulled_back(node, Mask(operand)) if (refusal := _kwargs_error(context, 'shift', node.kwargs, required=('along', 'offset'))) is not None: self.errors.append(refusal) return node @@ -871,6 +874,29 @@ def _predicate_call(self, node: UnresolvedPredicateCallNode) -> Predicate | Unre return node return TranslatedPredicate(mask, along.name, int(offset.value), tuple(sorted(mask.dims))) + def _pulled_back(self, node: UnresolvedPredicateCallNode, mask: Mask) -> Predicate | UnresolvedWhereNode: + """``at(, by=, over=, into=)`` — the predicate read through a relation, as ``at`` reads an array. + + The relation and its two ends are read by the rules an expression's + ``at`` is, so the one refusal a file meets for a bad read is the same + in a ``where:`` and in an expression. + """ + context = self.context + if (refusal := _kwargs_error(context, 'at', node.kwargs, required=('by', 'over', 'into'))) is not None: + self.errors.append(refusal) + return node + found = len(self.errors) + roles = {key: node.kwargs[key] for key in ('over', 'into')} + by = self._relation_ref(node.kwargs['by'], 'at', 'by', roles, None) + if len(self.errors) > found or not isinstance(by, DirectionNode): + return node + try: + dims = pulled_back_dims(by.direction, mask.dims, context, 'the predicate') + except DimensionError as refusal: + self.errors.append(str(refusal)) + return node + return PulledBackPredicate(mask, by.direction, tuple(sorted(dims))) + def _count(self, node: UnresolvedCountNode) -> Predicate | UnresolvedWhereNode: """``count(, over=) `` — how many coordinates the predicate admits. @@ -1307,7 +1333,7 @@ def _kwargs_error( return ( f'{context}: {name}() does not take {_listed([f"{key}=" for key in extra])}. ' f'It takes {_listed([f"{key}=" for key in required])}, and nothing else.' - f'{edge if "edge" in extra else ""}' + f'{edge if "edge" in extra and name == "shift" else ""}' ) return None diff --git a/src/math_spec/typesetting/walk.py b/src/math_spec/typesetting/walk.py index 9932ac1b..a65c4846 100644 --- a/src/math_spec/typesetting/walk.py +++ b/src/math_spec/typesetting/walk.py @@ -51,6 +51,7 @@ ParameterDefined, Predicate, PredicateOperator, + PulledBackPredicate, RelationComparison, RelationDefined, RelationPairComparison, @@ -464,12 +465,7 @@ def _call(self, node: FunctionCallNode, ctx: _Context) -> tuple[str, int]: if node.name == 'at': by = node.kwargs['by'] assert isinstance(by, DirectionNode) - outer = ctx - direction = by.direction - at = {r: outer.subscript(direction.dim(r)) for r in (*direction.produced, *direction.joined)} - for read in direction.consumed: - ctx = ctx.pulled_back(direction.dim(read), self._relation_read(direction.name, at, read)) - return self._arithmetic(node.args[0], ctx) + return self._arithmetic(node.args[0], self._pulled_back(by.direction, ctx)) if (by := node.kwargs.get('by')) is not None: assert isinstance(by, DirectionNode) @@ -496,6 +492,13 @@ def _call(self, node: FunctionCallNode, ctx: _Context) -> tuple[str, int]: domain = self.format.joined(memberships, '') return self.format.summation(domain, self._reduction_body(node.args[0], inner)), _PRECEDENCE['+'] + def _pulled_back(self, direction: Direction, ctx: _Context) -> _Context: + """*ctx* with each dimension *direction* consumes read at the relation, as ``at`` re-indexes a leaf.""" + at = {r: ctx.subscript(direction.dim(r)) for r in (*direction.produced, *direction.joined)} + for read in direction.consumed: + ctx = ctx.pulled_back(direction.dim(read), self._relation_read(direction.name, at, read)) + return ctx + def _grouping(self, direction: Direction, dummies: Mapping[str, str], ctx: _Context) -> list[str]: """The conditions a grouped sum's domain carries for one direction: what it fixes of the row it joins on. @@ -607,6 +610,9 @@ def _where(self, node: Predicate, ctx: _Context) -> tuple[str, int]: moved = ctx.translated(node.along, _Step(node.offset, 'plain')) return self._where(node.operand.root, moved) + if isinstance(node, PulledBackPredicate): + return self._where(node.operand.root, self._pulled_back(node.direction, ctx)) + if isinstance(node, RelationDefined): return self._relation_row(node.name, self._frame_key(node.name, ctx)), comparison diff --git a/tests/test_lowering.py b/tests/test_lowering.py index 921519e0..e0f2875f 100644 --- a/tests/test_lowering.py +++ b/tests/test_lowering.py @@ -51,6 +51,7 @@ Power, Program, Pullback, + PulledBackPredicate, Region, RelationDeclaration, Sum, @@ -469,6 +470,30 @@ def test_a_translated_predicate_keeps_what_it_reads_in_reach(): assert sorted(mask.dims) == ['g'] +def test_a_predicate_read_through_a_relation_is_lowered_and_keeps_the_relation_in_reach(): + """The comparison under the read is rebuilt, and the relation is data the consumer binds as well as the operand.""" + program = to_program( + override( + SHAPES_MODEL, + **{ + 'parameters.zcap': {'dims': ['z']}, + 'constraints.w': { + 'dims': ['g'], + 'where': 'at(zcap <= 0.5 * k, by=lk2, over=z, into=g)', + 'expression': 'p <= c', + }, + }, + ) + ) + mask = program.constraints['w'].where + assert mask is not None and isinstance(mask.root, PulledBackPredicate) + assert mask.root.operand.root == ExpressionComparison( + Parameter('zcap'), '<=', Multiply(Constant(0.5), Parameter('k')), ('z',) + ), 'the read predicate is rebuilt, not handed through with the resolved comparison still in it' + assert mask.names_read == frozenset({'zcap', 'k', 'lk2'}) + assert sorted(mask.dims) == ['g'], 'z is read at lk2(g), so the mask is over g alone' + + def test_assumptions_carry_the_file_s_entries_and_the_curves_behind_them(): """One mapping holds every fact about the data, so a consumer binding it has one loop and one refusal. diff --git a/tests/test_validation.py b/tests/test_validation.py index ac78541a..c1a76d71 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -730,7 +730,7 @@ def test_a_lone_case_comparing_expressions_is_refused_too(self): class TestAPredicateIsAnOperand: - """``count`` and ``shift`` over a predicate — the two calls that read one rather than arithmetic. + """``count``, ``shift`` and ``at`` over a predicate — the three calls that read one rather than arithmetic. Everything else in the language takes arithmetic, so the grammar reads these shapes itself and resolution decides what each name is. @@ -746,6 +746,9 @@ class TestAPredicateIsAnOperand: pytest.param('shift(flag, along=g, offset=1)', id='a-translated-mask'), pytest.param('shift(flag, along=g, offset=-1)', id='a-translation-forwards'), pytest.param('count(shift(flag, along=g, offset=1), over=g) >= 1', id='a-translation-under-a-count'), + pytest.param('at(r, by=lk, over=h, into=g)', id='a-mask-read-through-a-relation'), + pytest.param("flag AND NOT at(h == 'north', by=lk, over=h, into=g)", id='a-read-under-connectives'), + pytest.param('count(at(r, by=lk, over=h, into=g), over=g) >= 1', id='a-read-under-a-count'), ], ) def test_a_shape_the_language_admits(self, where): @@ -840,6 +843,36 @@ def test_a_shape_the_language_admits(self, where): ('counts whole coordinates back',), id='a-translation-by-a-fraction', ), + pytest.param( + 'at(r, by=lk)', + ("at() needs 'over=' and 'into='",), + id='a-read-naming-no-columns', + ), + pytest.param( + 'at(r, by=lk, over=h, into=g, edge=0)', + ("does not take 'edge='", "It takes 'by=', 'over=' and 'into=', and nothing else."), + id='a-read-with-a-keyword-it-lacks', + ), + pytest.param( + 'at(flag, by=lk, over=h, into=g)', + ("at(by=lk) reads through ['h'], which the predicate does not carry",), + id='a-read-through-a-dim-the-predicate-lacks', + ), + pytest.param( + 'at(q, by=lk, over=h, into=g)', + ("at(by=lk) lands on ['g'], which the expression already carries",), + id='a-read-onto-a-dim-the-predicate-carries', + ), + pytest.param( + 'at(flag, by=lk, over=g, into=h)', + ("into=['h'] names ['h'], which the key of 'lk' does not hold",), + id='a-read-landing-off-the-key', + ), + pytest.param( + 'at(r, by=nope, over=h, into=g)', + ('at(by=nope) does not name a relation',), + id='a-read-through-no-relation', + ), pytest.param( 'sum_back(flag, along=g, window=2)', ("'sum_back()' does not read a predicate", '`count` reads one and answers a number'), @@ -858,6 +891,7 @@ def test_a_shape_the_language_refuses(self, where, fragments): [ pytest.param('count(nope, over=g) >= 2', id='under-a-count'), pytest.param('shift(nope, along=g, offset=1)', id='under-a-translation'), + pytest.param('at(nope, by=lk, over=h, into=g)', id='under-a-read'), ], ) def test_a_name_the_operand_does_not_declare_is_reported_rather_than_walked(self, where): @@ -887,6 +921,43 @@ def test_a_count_is_undecidable_in_a_case_when(self): ) assert 'it counts the coordinates a predicate admits, which only the data decides' in message + def test_a_read_through_a_relation_is_undecidable_in_a_case_when(self): + """Which rows a relation maps onto a coordinate is the data's to say, so two cases split by one are not proved apart.""" + message = _refusal( + expressions={ + 'pick': { + 'dims': ['g'], + 'cases': { + 'mapped': {'when': 'at(r, by=lk, over=h, into=g)', 'expression': '1'}, + 'some': {'when': 'c > 0', 'expression': '2'}, + }, + 'otherwise': '0', + } + }, + constraints={'cap': {'dims': ['g'], 'expression': 'p <= pick'}}, + ) + assert "it reads a predicate through 'lk', and which rows that admits only the data decides" in message + + def test_a_read_landing_outside_the_frame_names_the_relation(self): + """The read adds the dims it lands on, so a mask over the coarse side cannot carry the fine one.""" + message = _refusal( + constraints={'cap': {'dims': ['h'], 'where': 'at(r, by=lk, over=h, into=g)', 'expression': 'r <= 1'}} + ) + assert "a where-predicate read through 'lk' reads dims ['g'] outside the frame ['h']" in message + + def test_a_read_given_an_edge_is_not_told_about_translations(self): + """The edge sentence explains a shift; under a read it would explain an operator the file did not write.""" + with pytest.raises(LanguageError) as caught: + where_of('at(r, by=lk, over=h, into=g, edge=0)', Namespace(_schema()), 'probe') + assert 'translation' not in str(caught.value) + + def test_a_read_lands_on_the_dims_it_produces_and_reads_the_relation(self): + """The mask is over what the relation maps onto, and a consumer binds the relation as well as the operand.""" + mask = where_of("at(h == 'north', by=lk, over=h, into=g)", Namespace(_schema()), 'probe') + assert mask is not None + assert sorted(mask.dims) == ['g'], "'h' is read at lk(g), so g is all the mask is over" + assert mask.names_read == frozenset({'lk'}), 'the relation is data a consumer binds, the label is not' + def test_a_count_reduces_the_dim_it_counts_along_away(self): """The count is one number per remaining coordinate, so a claim about each group needs no word for the group.""" mask = where_of('count(q, over=h) >= 2', Namespace(_schema()), 'probe') diff --git a/tests/typesetting/golden/latex.out b/tests/typesetting/golden/latex.out index c983c4f6..acebb2d1 100644 --- a/tests/typesetting/golden/latex.out +++ b/tests/typesetting/golden/latex.out @@ -130,6 +130,7 @@ \text{counted} && \theta_{b} & \le \mathrm{budget} && \forall\, b \in \mathcal{B} \,:\, \lvert \{ e \in \mathcal{E} \,:\, \mathrm{tech\_cap}_{b,e} > 0 \} \rvert \ge 2 \\ \text{counted\_here} && \theta_{b} & \le \mathrm{tech\_cap}_{b,e} && \forall\, b \in \mathcal{B},\ e \in \mathcal{E} \,:\, \lvert \{ e' \in \mathcal{E} \,:\, \mathrm{tech\_cap}_{b,e'} > 0 \} \rvert \ge 2 \\ \text{run\_start} && \mathit{slack}_{t} & \le \mathrm{load}_{t,b} && \forall\, t \in \mathcal{T},\ b \in \mathcal{B} \,:\, \mathrm{load}_{t,b} \text{ is defined} \wedge \neg \left( \mathrm{load}_{t - 1,b} \text{ is defined} \right) \\ +\text{zoned} && \theta_{b} & \le \mathrm{budget} && \forall\, b \in \mathcal{B} \,:\, \mathrm{zone\_cap}_{\mathrm{zone\_of}(b)} \text{ is defined} \\ \text{capped} && p_{t,g} & \le \mathrm{p}^{\mathrm{max}}_{g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{spend}^{\mathrm{cap}}_{g} > 0 \vee \neg \mathrm{is\_flexible}_{g} \\ \text{fuel\_curve} && \left( p_{t,g},\ \mathit{fuel}_{t,g},\ \mathit{heat}_{t,g} \right) & \in \mathit{on}_{t,g} \cdot \mathrm{pwl}_{a \in \mathcal{A}}(\mathrm{bp\_x}_{g,a},\ \mathrm{bp\_y}_{g,a},\ \mathrm{bp\_heat}_{g,a}) && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ \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} \\ diff --git a/tests/typesetting/golden/markdown.out b/tests/typesetting/golden/markdown.out index 8acbeee0..672578c3 100644 --- a/tests/typesetting/golden/markdown.out +++ b/tests/typesetting/golden/markdown.out @@ -350,6 +350,12 @@ p_{t,g} \le \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\, t \in \mathcal{T},\ g \mathit{slack}_{t} \le \mathrm{load}_{t,b} \qquad \forall\, t \in \mathcal{T},\ b \in \mathcal{B} \,:\, \mathrm{load}_{t,b} \text{ is defined} \wedge \neg \left( \mathrm{load}_{t - 1,b} \text{ is defined} \right) ``` +**`zoned`** + +```math +\theta_{b} \le \mathrm{budget} \qquad \forall\, b \in \mathcal{B} \,:\, \mathrm{zone\_cap}_{\mathrm{zone\_of}(b)} \text{ is defined} +``` + **`capped`** ```math diff --git a/tests/typesetting/golden/model.yaml b/tests/typesetting/golden/model.yaml index 75030a6c..9f406920 100644 --- a/tests/typesetting/golden/model.yaml +++ b/tests/typesetting/golden/model.yaml @@ -307,6 +307,10 @@ constraints: dims: [snapshot, bus] where: "load AND NOT shift(load, along=snapshot, offset=1)" expression: slack <= load + zoned: # a predicate read through a relation: a bus is held only where its zone has a cap at all + dims: [bus] + where: "at(zone_cap, by=zone_of, over=zone, into=bus)" + expression: theta <= budget capped: # an expressions: entry on a side, read by the name the file gave it dims: [snapshot, generator] where: "spend_cap > 0 OR NOT is_flexible" diff --git a/tests/typesetting/golden/typst.out b/tests/typesetting/golden/typst.out index 201b9b58..68bda848 100644 --- a/tests/typesetting/golden/typst.out +++ b/tests/typesetting/golden/typst.out @@ -117,6 +117,7 @@ $ upright("budgeted") & italic("spend")_(t) & <= upright("budget") & forall t in upright("counted") & theta_(b) & <= upright("budget") & forall b in cal(B) colon abs({e in cal(E) colon upright("tech_cap")_(b,e) > 0}) >= 2 \ upright("counted_here") & theta_(b) & <= upright("tech_cap")_(b,e) & forall b in cal(B), e in cal(E) colon abs({e' in cal(E) colon upright("tech_cap")_(b,e') > 0}) >= 2 \ upright("run_start") & italic("slack")_(t) & <= upright("load")_(t,b) & forall t in cal(T), b in cal(B) colon upright("load")_(t,b) upright(" is defined") and not (upright("load")_(t - 1,b) upright(" is defined")) \ + upright("zoned") & theta_(b) & <= upright("budget") & forall b in cal(B) colon upright("zone_cap")_(upright("zone_of")(b)) upright(" is defined") \ upright("capped") & p_(t,g) & <= upright("p")^(upright("max"))_(g) & forall t in cal(T), g in cal(G) colon upright("spend")^(upright("cap"))_(g) > 0 or not upright("is_flexible")_(g) \ upright("fuel_curve") & (p_(t,g), italic("fuel")_(t,g), italic("heat")_(t,g)) & in italic("on")_(t,g) dot upright("pwl")_(a in cal(A))(upright("bp_x")_(g,a), upright("bp_y")_(g,a), upright("bp_heat")_(g,a)) & forall t in cal(T), g in cal(G) \ 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) \ From abd68210baf244c841702c0dc0e0224fdde1c4d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 20:33:37 +0000 Subject: [PATCH 4/4] feat(language): a piecewise where reaches a link that walks a relation, so only some converters need a curve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A walked link's row is over the dims the walk produces, and the block's `where:` is over `dims:`. The row now reads the mask through the link's relation, `at(, by=…, over=…, into=…)`, where the mask carries every dim the walk consumes or joins on, and as written where it carries none of them. A mask carrying some and not the rest is refused, naming the missing dims. The refusal of a block `where:` beside a walk is gone. A walked link's values are asked for at the rows it reads the curve at, under `__complete`, so a flow of a converter with no curve needs no breakpoint row. `_complete` keeps the links on `dims:`. `examples/piecewise_coupling.yaml` is now fluxopt's system: a CHP on a curve beside a fixed-ratio boiler, one `has_curve` read both ways. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HxoXSQCyrcDs6vuRk3x6tz --- docs/reference/language/piecewise.md | 34 +++++++-- examples/piecewise_coupling.yaml | 33 +++++++-- src/math_spec/model.py | 8 --- src/math_spec/piecewise.py | 100 +++++++++++++++++++++----- src/math_spec/validation.py | 12 +++- tests/test_piecewise.py | 98 ++++++++++++++++++++++--- tests/typesetting/golden/latex.out | 4 +- tests/typesetting/golden/markdown.out | 6 +- tests/typesetting/golden/model.yaml | 3 +- tests/typesetting/golden/typst.out | 4 +- tests/typesetting/test_walk.py | 4 +- 11 files changed, 247 insertions(+), 59 deletions(-) diff --git a/docs/reference/language/piecewise.md b/docs/reference/language/piecewise.md index 849016a6..d3df698e 100644 --- a/docs/reference/language/piecewise.md +++ b/docs/reference/language/piecewise.md @@ -82,9 +82,10 @@ binds the numbers runs them. Each one is asked only where the block's !!! warning "A values parameter short of a row does not build a shorter curve" The missing row reads as a breakpoint at the origin. Every block states - `_complete` for this, whatever its `method:`, so the table is - refused when the data binds and the refusal names `where:` as the way to - say how far a curve runs. + `_complete` for this, whatever its `method:`, and a link that walks + a relation states `__complete` for its own values. So the + table is refused when the data binds, and the refusal names `where:` as the + way to say how far a curve runs. ### `dims` @@ -229,12 +230,37 @@ A block whose only link walks a relation is a curve. Two links is what a curve needs when a link is one row; a walked link is one row per fine coordinate, so the relation supplies the second. +**A walked row reads the block's `where:` through its relation.** The mask is +over `dims:` and the row is over the dimensions the walk produces, so the row +takes `at(, by=…, over=…, into=…)`, a +[predicate read through a relation](expressions.md#reading-a-predicate-through-a-relation). +Only some generators have a curve: + +```yaml +piecewise: + coupling: + along: bp + dims: [generator, snapshot] + where: has_curve # over generator: a generator with no curve has no weights and no rows + links: + power: { expression: power, values: bp_power, by: generator_of, over: generator, into: flow } +``` + +The `power` row is built where +`at(has_curve, by=generator_of, over=generator, into=flow)` holds, which is at +every flow of a generator with a curve. The values of a walked link are asked +for at the same rows, so a flow of a generator with no curve needs no row in +`bp_power`. A mask over dimensions the walk keeps, such as `snapshot` alone, +reaches the row as written. A mask that carries some of the dimensions the walk +reads through and not the others is refused, and the message names the ones +missing. + | A walked link | | | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | _over_ | names a column over a dimension of `dims:` | | _into_ | names a column over a dimension that `dims:` does not carry, and that is not `along` | | _values_ | follows the **link's** row: `bp_power` is per flow, not per generator | -| `where:` | is refused on the block, because the walk replaces the dimension of `dims:` that the mask tests. Mask the link's own variable instead | +| `where:` | on the block reaches the link's row read through the relation, or as written where the mask carries none of the dimensions the walk reads through | | `method:` | `adjacency` or `sos2`. `lp` loses the abscissa its segment line is written against, and `convex` loses the pair of values parameters it reads a shape from | ### Signs diff --git a/examples/piecewise_coupling.yaml b/examples/piecewise_coupling.yaml index 1088c325..70538754 100644 --- a/examples/piecewise_coupling.yaml +++ b/examples/piecewise_coupling.yaml @@ -3,10 +3,12 @@ # SPDX-License-Identifier: MIT description: >- - A heat and power system whose converters each run on one piecewise curve - tying all of their flows. A boiler ties two flows and a CHP unit three, in - one model: the curve reads its weights through `converter_of`, so how many - flows a converter ties is a row in that relation rather than a line here. + 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: @@ -42,11 +44,18 @@ parameters: 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: @@ -54,6 +63,12 @@ variables: 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] @@ -63,10 +78,11 @@ variables: piecewise: operating_point: description: >- - every flow of one converter is read off one curve at one operating point, - so the converter's flows move together + 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 @@ -77,6 +93,11 @@ piecewise: 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] diff --git a/src/math_spec/model.py b/src/math_spec/model.py index a7658ca5..652b08ee 100644 --- a/src/math_spec/model.py +++ b/src/math_spec/model.py @@ -757,14 +757,6 @@ def _check_method_shape(self) -> PiecewiseBlock: if self.activity is not None and self.method in ('convex', 'lp'): msg = f'activity is not supported with method: {self.method}.' raise ValueError(msg) - if self.where is not None and walked: - msg = ( - f"where: does not reach link '{walked[0]}', which reads through a relation. The mask tests the " - f"curve's frame and that link's row is built over a refinement of it, so the row would read " - f"its weights as absent and pin the expression to zero. Mask the link's own variable " - f'instead — a where: on it over the fine dimension leaves the row unbuilt.' - ) - raise ValueError(msg) return self @field_validator('links') diff --git a/src/math_spec/piecewise.py b/src/math_spec/piecewise.py index a3f12db6..fd429e7f 100644 --- a/src/math_spec/piecewise.py +++ b/src/math_spec/piecewise.py @@ -26,7 +26,7 @@ from math_spec.sos import Emitted, emit if TYPE_CHECKING: - from collections.abc import Iterable + from collections.abc import Iterable, Mapping # --------------------------------------------------------------------------- # the mask @@ -157,12 +157,17 @@ def declaration_of(block: PiecewiseBlock, where: Mask | None = None) -> Piecewis ) -def assumptions_of(name: str, block: PiecewiseBlock, where: CurveMask) -> dict[str, AssumptionBlock]: +def assumptions_of( + name: str, block: PiecewiseBlock, where: CurveMask, reads: Mapping[str, bool] +) -> dict[str, AssumptionBlock]: """What *block* 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 + 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; *reads* says, by link, whether that link reads the + ``where:`` through its relation (:func:`walk_reads`). 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. @@ -177,21 +182,29 @@ def assumptions_of(name: str, block: PiecewiseBlock, where: CurveMask) -> dict[s """ d = block.along mask = where.text if where.ragged else None - values = [link.values for link in block.links.values()] - assumed = { - f'{name}_complete': AssumptionBlock( + rewrite = ( + f'Bind the rows, or narrow where: {mask!r} to where the curve runs.' + if mask is not None + else 'Bind the rows, or declare where: to say how far the curve runs.' + ) + assumed: dict[str, AssumptionBlock] = {} + if values := [link.values for link in block.links.values() if not link.walks]: + assumed[f'{name}_complete'] = AssumptionBlock( holds=' AND '.join(dict.fromkeys(values)), where=where.text, 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. ' - + ( - f'Bind the rows, or narrow where: {mask!r} to where the curve runs.' - if mask is not None - else 'Bind the rows, or declare where: to say how far the curve runs.' - ), + f'the curve on the origin. {rewrite}', ) - } + for key, link in block.links.items(): + if link.walks: + assumed[f'{name}_{key}_complete'] = AssumptionBlock( + holds=link.values, + where=through(where.text, link, reads=True) if reads[key] else _all_of(link.by, where.text), + description=f"piecewise '{name}' link '{key}': every breakpoint the curve runs through needs a row " + f"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(block) if curvature is not None: x, y = (link.values for link in block.curve) @@ -230,6 +243,43 @@ def assumptions_of(name: str, block: PiecewiseBlock, where: CurveMask) -> dict[s } +def walk_reads(schema: Spec, name: str, block: PiecewiseBlock, where: CurveMask) -> dict[str, bool]: + """Whether each walked link reads the block's ``where:`` through its relation, by link. + + A walked row is over the dims the walk produces, where a mask over the + ones 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. A mask carrying none of them is over dims the row keeps, and + reads as written. + + Raises: + PiecewiseExpansionError: A mask carrying some of the dims a walk + reads through and not the rest. + """ + carried = where.dims - {block.along} + reads: dict[str, bool] = {} + for key, link in block.links.items(): + if not link.walks: + continue + needed = _walk_reads(schema, link) + if (partial := sorted(needed - carried)) and needed & carried: + raise PiecewiseExpansionError( + f"piecewise '{name}' link '{key}': where {block.where!r} carries " + f"{sorted(needed & carried)} and not {partial}, and the link reads the curve through '{link.by}' " + f'at all of {sorted(needed)}. Carry all of them in the where, so the row reads it through the ' + f'relation, or none, so the row reads it as written.' + ) + reads[key] = bool(needed & carried) + return reads + + +def through(text: str | None, link: PiecewiseLink, *, reads: bool) -> str | None: + """*text* as a link's row reads it: through the link's relation where it *reads* so, else as written.""" + if text is None or not link.walks or not 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)) @@ -344,7 +394,7 @@ def by_kind(self) -> tuple[tuple[str, tuple[str, ...]], ...]: ('variable', (self.weights, self.sos.seg)), ('constraint', (*rows, *self.links)), ('sos', (self.name,)), - ('assumption', tuple(f'{self.name}_{what}' for what in ASSUMED)), + ('assumption', (*(f'{self.name}_{what}' for what in ASSUMED), *(f'{row}_complete' for row in self.links))), ) @@ -366,6 +416,8 @@ class Curve: rows: Each link's row frame, in link order — ``dims:``, or its refinement through the link's relation. mask: The block's ``where:``, as each shape of row reads it. + reads: Whether each walked link reads the ``where:`` through its + relation, by link (:func:`walk_reads`). gates: The rows the weights sum under, one or two. names: Every name the expansion writes. """ @@ -374,6 +426,7 @@ class Curve: block: PiecewiseBlock rows: tuple[tuple[str, ...], ...] mask: CurveMask + reads: dict[str, bool] gates: tuple[GateRow, ...] names: Names @@ -410,7 +463,8 @@ def check(schema: Spec, name: str, block: PiecewiseBlock) -> Curve: _values_fit(schema, block, rows, ctx) mask = CurveMask(block, where) _where_fits(block, mask, ctx) - return Curve(name, block, rows, mask, _gate_rows(schema, block), Names.of(name, block.links)) + reads = walk_reads(schema, name, block, mask) + return Curve(name, block, rows, mask, reads, _gate_rows(schema, block), Names.of(name, block.links)) def _row(schema: Spec, block: PiecewiseBlock, link: PiecewiseLink) -> tuple[str, ...]: @@ -431,6 +485,16 @@ def _row(schema: Spec, block: PiecewiseBlock, link: PiecewiseLink) -> tuple[str, return tuple(refined) +def _walk_reads(schema: Spec, link: PiecewiseLink) -> frozenset[str]: + """The dims a walked link reads the curve at: the ones it consumes, and the ones its relation joins on.""" + assert link.by is not None and link.over is not None and link.into is not None + consumed, _ = _walk(schema, link) + relation = schema.relations[link.by] + roles = dict(relation.pairs) + written = {c for side in (link.over, link.into) for c in ([side] if isinstance(side, str) else side)} + return consumed | {roles[c] for c in relation.key_roles if c not in written} + + def _walk(schema: Spec, link: PiecewiseLink) -> tuple[frozenset[str], frozenset[str]]: """The dims one walked link consumes and produces, read off the relation it names.""" assert link.by is not None and link.over is not None and link.into is not None @@ -561,7 +625,7 @@ def _write(raw: dict[str, object], curve: Curve) -> None: else: _weights(raw, curve) section = _section(raw, 'assumptions') - for name, assumed in assumptions_of(curve.name, curve.block, curve.mask).items(): + for name, assumed in assumptions_of(curve.name, curve.block, curve.mask, curve.reads).items(): section[name] = assumed.model_dump() @@ -583,13 +647,13 @@ def _weights(raw: dict[str, object], curve: Curve) -> None: f'sum({names.weights}, over={d}) == {rhs}', _all_of(where.exists, gate), ) - for cname, link, row in zip(names.links, block.links.values(), curve.rows, strict=True): + for cname, (key, link), row in zip(names.links, block.links.items(), curve.rows, strict=True): _constraint( raw, cname, row, f'({link.expression}) {link.sign} sum({_weights_read(names, link)} * {link.values}, over={d})', - where.exists, + through(where.exists, link, reads=curve.reads.get(key, False)), ) if block.method in ('sos2', 'adjacency'): _section(raw, 'sos')[curve.name] = {'variable': names.weights, 'along': d, 'type': 2} diff --git a/src/math_spec/validation.py b/src/math_spec/validation.py index 10419e5f..bf0ae03c 100644 --- a/src/math_spec/validation.py +++ b/src/math_spec/validation.py @@ -33,12 +33,12 @@ ) from math_spec._yaml import read_model from math_spec.dimensions import check_schema -from math_spec.errors import LanguageError, SchemaError, prefixed +from math_spec.errors import LanguageError, PiecewiseExpansionError, SchemaError, prefixed from math_spec.exclusivity import overlapping from math_spec.expansion import expand, parse_and_expand, parse_template from math_spec.model import AssumptionBlock, Spec from math_spec.operators import BUILTINS, call_shape_error, unknown_operator_message -from math_spec.piecewise import CurveMask, assumptions_of +from math_spec.piecewise import CurveMask, assumptions_of, walk_reads from math_spec.program import BooleanLiteral, Mask, VariableDefined from math_spec.resolution import ( Namespace, @@ -185,7 +185,13 @@ def validate_expressions(schema: Spec) -> Resolved: where = mask_of(resolve_where_text(pdef.where, ns, f'{context} where', errors)) if all(link is not None for link in links): piecewise[pname] = ResolvedPiecewise(tuple(link for link in links if link is not None), where) - for aname, assumed in assumptions_of(pname, pdef, CurveMask(pdef, where)).items(): + mask = CurveMask(pdef, where) + try: + reads = walk_reads(schema, pname, pdef, mask) + except PiecewiseExpansionError as refusal: + errors.append(str(refusal)) + continue + for aname, assumed in assumptions_of(pname, pdef, mask, reads).items(): if (assumption := _assumption(aname, assumed, ns, errors)) is not None: assumptions[aname] = assumption diff --git a/tests/test_piecewise.py b/tests/test_piecewise.py index 81c87e1f..c5ef34cb 100644 --- a/tests/test_piecewise.py +++ b/tests/test_piecewise.py @@ -872,25 +872,101 @@ def test_a_gate_over_fewer_dims_than_the_block_switches_each_curve_it_covers(): assert expanded.variables['cost_curve_lam'].dims == ['snapshot', 'generator', 'bp'] -def test_a_block_mask_that_cannot_reach_a_walked_link_is_refused(): - """The mask is on the curve's dims and the row is on a refinement, so the row would pin its expression to zero. +#: fluxopt's system: only some generators run on a curve, and the rest have none at all. +CURVED = override( + 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. - Left to the emitted declarations the refusal is a dimension error about - `coupling_power`; emitted without the mask it is the silent `power == 0` - that `where:` exists to prevent. + 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. """ - with pytest.raises(LanguageError, match="Mask the link's own variable"): + 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' + ) + + +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.curved': {'dims': ['generator'], 'dtype': 'bool'}, - 'piecewise.coupling.where': 'curved', + '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 = override( + 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 = to_program(schema_of(model).expand('piecewise')).assumptions + walked = assumptions['coupling_power_complete'] + assert walked.predicate.names_read == frozenset({'bp_power'}) + 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) + 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="emitted assumption 'coupling_power_complete' collides"): + schema_of(WALKED, assumptions={'coupling_power_complete': 'bp_power >= 0'}) -def test_the_rewrite_that_refusal_names_leaves_the_walked_row_unbuilt(): - """A mask on the link's own variable takes its row with it, which is what absence through arithmetic does.""" +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, @@ -1077,5 +1153,5 @@ def test_every_assumption_a_block_may_derive_is_a_name_it_reserves(method): block = spec.piecewise['cost_curve'] ragged = CurveMask(block, spec.resolved.piecewise['cost_curve'].where) - derived = set(assumptions_of('cost_curve', block, ragged)) + derived = set(assumptions_of('cost_curve', block, ragged, {})) assert derived <= {f'cost_curve_{what}' for what in ASSUMED}, 'a condition the block derives under no reserved name' diff --git a/tests/typesetting/golden/latex.out b/tests/typesetting/golden/latex.out index 2efed386..bad78dcf 100644 --- a/tests/typesetting/golden/latex.out +++ b/tests/typesetting/golden/latex.out @@ -138,7 +138,7 @@ \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{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} +\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} @@ -195,7 +195,7 @@ \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{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\_complete} && \mathrm{bp\_x}_{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 ad471ccf..7d65bce1 100644 --- a/tests/typesetting/golden/markdown.out +++ b/tests/typesetting/golden/markdown.out @@ -401,7 +401,7 @@ p_{t,g} \le \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\, t \in \mathcal{T},\ 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} +\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 @@ -674,8 +674,8 @@ p_{t,g} \le \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\, t \in \mathcal{T},\ g \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_complete`** +**`bus_curve_p_complete`** ```math -\mathrm{bp\_x}_{g,a} \text{ is defined} \qquad \forall\, g \in \mathcal{G},\ a \in \mathcal{A} +\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 33a4f701..f3d315c2 100644 --- a/tests/typesetting/golden/model.yaml +++ b/tests/typesetting/golden/model.yaml @@ -149,9 +149,10 @@ piecewise: 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 + 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 } diff --git a/tests/typesetting/golden/typst.out b/tests/typesetting/golden/typst.out index 6bf04771..202a7aa9 100644 --- a/tests/typesetting/golden/typst.out +++ b/tests/typesetting/golden/typst.out @@ -125,7 +125,7 @@ $ upright("budgeted") & italic("spend")_(t) & <= upright("budget") & forall t in 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("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) $ + 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)") @@ -179,4 +179,4 @@ $ upright("bounds_do_not_cross") & upright("p")^(upright("min"))_(g) & <= uprigh 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("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_complete") & upright("bp_x")_(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_walk.py b/tests/typesetting/test_walk.py index 35f6dbf6..dab6f9e0 100644 --- a/tests/typesetting/test_walk.py +++ b/tests/typesetting/test_walk.py @@ -1050,7 +1050,9 @@ def test_a_walked_link_prints_as_the_family_of_rows_that_read_one_curve(): 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}'), 'one curve per converter, not per flow' + 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():