From bf03805f0c00a1370dd8b8ccf8ab60603470f940 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 08:40:10 +0000 Subject: [PATCH 1/4] fix(language): a where string names several columns in at's over= and into=, as an expression does The where grammar read one name or arithmetic after a kwarg's `=`, so `at(p, by=r, over=a, into=[b, c])` failed to parse in a where string while the expression grammar took the list. The bracketed name list is now one grammar element both parsers use. Closes #781. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015VUkQfE5dT7mEGoTQXSxN3 --- docs/reference/language/expressions.md | 3 +- src/mathspec/_expression_parser.py | 12 ++++-- src/mathspec/_where_parser.py | 3 +- tests/test_validation.py | 52 +++++++++++++++++++++++++- 4 files changed, 63 insertions(+), 7 deletions(-) diff --git a/docs/reference/language/expressions.md b/docs/reference/language/expressions.md index 8de216dc..1cd2bd90 100644 --- a/docs/reference/language/expressions.md +++ b/docs/reference/language/expressions.md @@ -243,7 +243,8 @@ objective: $$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 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 +expression: `by=`, `over=` and `into=` are all written, each of `over=` and +`into=` names one column or a list of them, `[a, …]`, the read lands on the relation's key, and the predicate carries every dimension the read consumes. ### The right-hand side of a comparison diff --git a/src/mathspec/_expression_parser.py b/src/mathspec/_expression_parser.py index 69974502..c601cd5c 100644 --- a/src/mathspec/_expression_parser.py +++ b/src/mathspec/_expression_parser.py @@ -220,6 +220,13 @@ def with_children(node: ArithmeticNode, recurse: Callable[[ArithmeticNode], Arit # --------------------------------------------------------------------------- +#: A bracketed list of names as a kwarg value, in an expression and in a where +#: string alike. +NAME_LIST = (pp.Suppress('[') + pp.DelimitedList(pp.Regex(NAME)) + pp.Suppress(']')).set_parse_action( + lambda t: NameListNode(tuple(str(x) for x in t)) +) + + def _build_grammar() -> tuple[pp.ParserElement, pp.ParserElement]: """The arithmetic grammar, and the expression grammar that puts one comparison over it. @@ -235,10 +242,7 @@ def _build_grammar() -> tuple[pp.ParserElement, pp.ParserElement]: name = pp.Regex(NAME) quoted = (pp.QuotedString("'") | pp.QuotedString('"')).set_parse_action(lambda t: KeywordNode(str(t[0]))) - name_list = (pp.Suppress('[') + pp.DelimitedList(name) + pp.Suppress(']')).set_parse_action( - lambda t: NameListNode(tuple(str(x) for x in t)) - ) - kwarg = (name + pp.Suppress('=') + (quoted | name_list | arith)).set_parse_action(lambda t: (t[0], t[1])) + kwarg = (name + pp.Suppress('=') + (quoted | NAME_LIST | arith)).set_parse_action(lambda t: (t[0], t[1])) pos_arg = arith arg_list = pp.Optional(pp.DelimitedList(kwarg | pos_arg)) func_call = (name + pp.Suppress('(') + arg_list + pp.Suppress(')')).set_parse_action(_make_func_call) diff --git a/src/mathspec/_where_parser.py b/src/mathspec/_where_parser.py index f94a52f0..785930de 100644 --- a/src/mathspec/_where_parser.py +++ b/src/mathspec/_where_parser.py @@ -23,6 +23,7 @@ from mathspec._expression_parser import ( ARITHMETIC, NAME, + NAME_LIST, ArithmeticNode, KeywordNode, NameNode, @@ -154,7 +155,7 @@ def _build_where_grammar() -> pp.ParserElement: ) comparator = pp.one_of(list(get_args(PredicateOperator))) - kwarg = (name + pp.Suppress('=') + (quoted | ARITHMETIC)).set_parse_action(lambda t: (t[0], t[1])) + kwarg = (name + pp.Suppress('=') + (quoted | NAME_LIST | ARITHMETIC)).set_parse_action(lambda t: (t[0], t[1])) def _call(head: pp.ParserElement) -> pp.ParserElement: """``([, …])`` — the one shape whose operand is a predicate. diff --git a/tests/test_validation.py b/tests/test_validation.py index 84e2ac05..099f9e52 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -16,7 +16,7 @@ from mathspec.errors import DimensionError, LanguageError, SchemaError from mathspec.program import DimensionPosition from mathspec.resolution import Namespace -from mathspec.typesetting import to_markdown +from mathspec.typesetting import to_markdown, typeset_declaration from mathspec.validation import to_spec from tests.fixtures import DISPATCH_MODEL, OPERATOR_PROBES, SMALL_MODEL, varied, where_of @@ -812,6 +812,56 @@ def test_a_shape_the_language_admits(self, where): mask = where_of(where, Namespace(_schema()), 'probe') assert mask is not None, 'the predicate decides some rows, so it is a mask rather than nothing' + @pytest.mark.parametrize( + ('where', 'dims', 'reads'), + [ + pytest.param( + 'at(has_curve, by=cost_of, over=curve, into=[flow, effect])', + ['effect', 'flow'], + {'has_curve', 'cost_of'}, + id='into-names-several-columns', + ), + pytest.param( + 'at(has_cost, by=pair_of, over=[flow, effect], into=curve)', + ['curve'], + {'has_cost', 'pair_of'}, + id='over-names-several-columns', + ), + ], + ) + def test_a_read_names_several_columns_as_an_expression_does(self, where, dims, reads): + """The where grammar took one name after `into=` and `over=`, and the expression grammar a list (#781). + + A piecewise block whose mask is read through a walk into `[flow, effect]` + writes this `where:`, and the load failed on its own assertion. + """ + spec = to_spec( + { + 'dimensions': {name: {'dtype': 'str'} for name in ('curve', 'effect', 'flow')}, + 'relations': { + 'cost_of': {'key': ['flow', 'effect'], 'values': 'curve'}, + 'pair_of': {'key': 'curve', 'values': ['flow', 'effect']}, + }, + 'parameters': { + 'has_curve': {'dims': ['curve'], 'dtype': 'bool'}, + 'has_cost': {'dims': ['flow', 'effect'], 'dtype': 'bool'}, + }, + 'variables': {'x': {'dims': dims}}, + 'constraints': {'k': {'dims': dims, 'where': where, 'expression': 'x >= 0'}}, + 'objective': {'sense': 'minimize', 'expression': 'sum(x)'}, + } + ) + mask = spec.program.constraints['k'].where + assert mask is not None + assert sorted(mask.dims) == dims, 'the read lands on the columns the other end names' + assert mask.names_read == reads, 'a consumer attaches the relation as well as the operand' + assert to_spec(spec.to_yaml()).program == spec.program, 'the where string reads back to the same mask' + for fmt in ('markdown', 'latex', 'typst'): + printed = typeset_declaration(spec, 'k', fmt).replace('\\_', '_') + assert all(name in printed for name in reads), ( + f'{fmt} prints the operand and the relation it is read through' + ) + @pytest.mark.parametrize( ('where', 'fragments'), [ From c9bd5dd8516efb85af84314778755790e719489f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 08:40:38 +0000 Subject: [PATCH 2/4] docs: add the changelog line for #782 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015VUkQfE5dT7mEGoTQXSxN3 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 30f931e2..f79657a9 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 +- 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)) - fix(language): dual(c) is the rate at which the optimal objective rises with the right side of c, so an equality has a sign too ([#751](https://github.com/energy-models/mathspec/pull/751)) - fix(language): a macro formal written inside a list takes the name the call binds to it ([#779](https://github.com/energy-models/mathspec/pull/779)) From 540d10489ab63e9721ef1695ffb676b7a5ad9979 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 09:14:45 +0000 Subject: [PATCH 3/4] feat(language): a piecewise gate reads its binary through a relation, as a link walks one `activity:` takes a mapping with variable, by, over and into, and the weights sum to the binary read through the relation, as `at` reads it. A curve whose coordinate has no row in the relation is ungated; under `absence: zero` only a missing row ungates it. The walk is held to the rules of `at`, and a gate landing outside `dims:` is refused. The walk keys and their check move onto a base both the link and the gate take. The program carries the gate as a `Gate`: its variable, the read, and where a curve reads it, which the expansion, the typesetter and the reserved names all read. Reference page section: 8 sentences, median 16 words, none over 25. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015VUkQfE5dT7mEGoTQXSxN3 --- docs/reference/language/piecewise.md | 71 ++++++++++++++--- schema/mathspec.schema.json | 75 +++++++++++++++++- src/mathspec/lowering.py | 26 +++++-- src/mathspec/piecewise.py | 92 +++++++++++++++++++--- src/mathspec/program.py | 21 +++++- src/mathspec/spec.py | 104 ++++++++++++++++++------- src/mathspec/typesetting/symbols.py | 4 +- src/mathspec/typesetting/walk.py | 24 +++--- src/mathspec/validation.py | 8 +- tests/test_piecewise.py | 109 +++++++++++++++++++++++++++ 10 files changed, 455 insertions(+), 79 deletions(-) diff --git a/docs/reference/language/piecewise.md b/docs/reference/language/piecewise.md index bf7f6313..15f14a4a 100644 --- a/docs/reference/language/piecewise.md +++ b/docs/reference/language/piecewise.md @@ -26,7 +26,7 @@ piecewise: 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 + activity: null # optional: a binary variable that the weights sum to, or a walk to one # a link may be bounded by the curve instead of pinned to it fuel_cap: @@ -45,14 +45,14 @@ piecewise: | _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 | | | -| ---------- | ---------------------------------------------------------------------------------------- | ------------------- | -| `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` | +| Key | | | +| ---------- | --------------------------------------------------------------------------------------------- | ------------------- | +| `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, on `dims:` or through a relation ([below](#activity)) | 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. @@ -84,9 +84,10 @@ period read off a curve that has none, is said by adding that dimension to 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. +An [`activity:`](#activity) gate carries no dimension that `dims:` does not, +or [walks a relation](#a-gate-that-walks-a-relation) onto them. A gate over +fewer dimensions switches every curve it covers: a gate per generator switches +that generator's curve in every snapshot. ### `where` @@ -168,6 +169,52 @@ 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). +#### A gate that walks a relation + +A gate whose binary is over another dimension reads it through a +[relation](relations.md#how-a-relation-is-used), as [`at`](operators.md#at) +does. Write the gate as a mapping with `variable:`, `by:`, `over:` and `into:`. +Here the on/off binary is per status entity, and each converter's curve reads +the status of its entity: + +```yaml +dimensions: + converter: { dtype: str } + status_entity: { dtype: str } + snapshot: { dtype: int } + bp: { dtype: int } +relations: + pw_status_of: { key: converter, values: status_entity } +parameters: + bp_p: { dims: [converter, bp] } + bp_fuel: { dims: [converter, bp] } +variables: + running: { dims: [status_entity, snapshot], domain: binary } + p: { dims: [converter, snapshot] } + fuel: { dims: [converter, snapshot] } +piecewise: + curve: + along: bp + dims: [converter, snapshot] + links: + p: [p, bp_p] + fuel: [fuel, bp_fuel] + method: sos2 + activity: { variable: running, by: pw_status_of, over: status_entity, into: converter } +objective: + sense: minimize + expression: sum(fuel) +``` + +The weights of each curve sum to +`at(running, by=pw_status_of, over=status_entity, into=converter)`. A converter +with no row in `pw_status_of` has no status, and its curve is ungated. Under +`absence: zero` on the gate, a converter whose entity is off the gate's mask is +pinned off, and only a missing row ungates a curve. + +The walk is held to the rules of `at`. The gate lands on dimensions of `dims:`, +and a gate that lands on another dimension is refused. + ### A link that walks a relation A link that names `by:`, `over:` and `into:` reads the curve's weights through a diff --git a/schema/mathspec.schema.json b/schema/mathspec.schema.json index 4c2857d6..bb0faa15 100644 --- a/schema/mathspec.schema.json +++ b/schema/mathspec.schema.json @@ -588,6 +588,76 @@ "title": "ParameterBlock", "type": "object" }, + "PiecewiseActivity": { + "anyOf": [ + { + "additionalProperties": false, + "description": "A piecewise block's gate: the binary the weights sum to, on the block's own dims or read through a relation.\n\nWritten in YAML as the variable's name, and serialised back to it. A gate\nthat names ``by:``, ``over:`` and ``into:`` is written as a mapping, and\nreads the binary through the relation as ``at`` reads it, so a unit's\non/off variable over its own dimension switches the curves it maps to. A\ncurve whose coordinate has no row in the relation is ungated.", + "properties": { + "by": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "By" + }, + "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" + }, + "variable": { + "title": "Variable", + "type": "string" + } + }, + "required": [ + "variable" + ], + "title": "PiecewiseActivity", + "type": "object" + }, + { + "type": "string" + } + ] + }, "PiecewiseBlock": { "additionalProperties": false, "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.", @@ -595,14 +665,13 @@ "activity": { "anyOf": [ { - "type": "string" + "$ref": "#/$defs/PiecewiseActivity" }, { "type": "null" } ], - "default": null, - "title": "Activity" + "default": null }, "along": { "title": "Along", diff --git a/src/mathspec/lowering.py b/src/mathspec/lowering.py index 60dbe7c9..af988590 100644 --- a/src/mathspec/lowering.py +++ b/src/mathspec/lowering.py @@ -20,7 +20,14 @@ 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, declaration_of, lp_domain_refusal, resolve_links, resolve_walks +from mathspec.piecewise import ( + assumptions_of, + declaration_of, + lp_domain_refusal, + resolve_gate, + resolve_links, + resolve_walks, +) from mathspec.program import ( Assumption, BooleanLiteral, @@ -56,7 +63,7 @@ if TYPE_CHECKING: from collections.abc import Mapping - from mathspec.program import Direction, Expression + from mathspec.program import Direction, Expression, Gate from mathspec.spec import AssumptionBlock, Spec @@ -195,16 +202,21 @@ def lower(schema: Spec) -> Program: if (assumption := _assumption(aname, adef, ns, errors)) is not None: assumptions[aname] = assumption - curves: dict[str, tuple[tuple[Expression, ...], dict[str, Direction], Mask | None]] = {} + curves: dict[str, tuple[tuple[Expression, ...], dict[str, Direction], Mask | None, Gate | None]] = {} for pname, pdef in schema.piecewise.items(): links = resolve_links(pname, pdef, ns, errors) walks = resolve_walks(pname, pdef, ns, errors) where = mask_of(resolve_where_text(pdef.where, ns, f"piecewise '{pname}' where", errors)) + gate = None + if pdef.activity is not None: + gate = resolve_gate(pname, pdef, variables[pdef.activity.variable], ns, errors) + if gate is None: + continue 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, walks, where) + curves[pname] = (links, walks, where, gate) if errors: raise SchemaError('\n'.join(errors)) @@ -212,14 +224,14 @@ 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, walks, where) in curves.items(): + for pname, (links, walks, where, gate) in curves.items(): pdef = schema.piecewise[pname] - piecewise[pname] = declaration_of(schema, pname, pdef, links, walks, where) + piecewise[pname] = declaration_of(schema, pname, pdef, links, walks, where, gate) 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' diff --git a/src/mathspec/piecewise.py b/src/mathspec/piecewise.py index fe4464c2..647dc3e1 100644 --- a/src/mathspec/piecewise.py +++ b/src/mathspec/piecewise.py @@ -26,9 +26,24 @@ from mathspec._expression_resolver import ExpressionResolver from mathspec.dimensions import dims_of, pulled_back_dims from mathspec.errors import DimensionError -from mathspec.program import Link, PiecewiseDeclaration, PiecewiseMethod, VariableDeclaration, carries_variable -from mathspec.resolution import resolve_expression_text -from mathspec.spec import AssumptionBlock, Curvature, PiecewiseBlock, PiecewiseLink, Spec, VariableBlock +from mathspec.program import ( + Gate, + Link, + PiecewiseDeclaration, + PiecewiseMethod, + VariableDeclaration, + carries_variable, +) +from mathspec.resolution import mask_of, resolve_expression_text, resolve_where_text +from mathspec.spec import ( + AssumptionBlock, + Curvature, + PiecewiseActivity, + PiecewiseBlock, + PiecewiseLink, + Spec, + VariableBlock, +) if TYPE_CHECKING: from collections.abc import Iterable @@ -391,6 +406,22 @@ def leaves_ungated(gate: VariableBlock | VariableDeclaration | None) -> bool: return gate is not None and gate.where is not None and gate.absence != 'zero' +def gate_text(activity: PiecewiseActivity, gate: VariableBlock | VariableDeclaration) -> tuple[str, str | None]: + """The gate as a convexity row reads it, and the where a curve reads it under, or ``None`` where every curve does. + + A gate on the block's own dims is ungated where it does not exist, as + [`leaves_ungated`][] says. A walked gate is also ungated where the + relation has no row, so it always has a where: the read itself, whose + existence is false where either is missing, or, under ``absence: zero``, + the relation alone, since the variable then reads 0 off its mask. + """ + if not activity.walks: + return activity.variable, activity.variable if leaves_ungated(gate) else None + assert activity.by is not None + read = f'at({activity.variable}, by={activity.by}, over={_columns(_named(activity.over))}, into={_columns(_named(activity.into))})' + return read, activity.by if gate.absence == 'zero' else read + + # --------------------------------------------------------------------------- # the block as lowering types it # --------------------------------------------------------------------------- @@ -435,6 +466,29 @@ def resolve_walks(name: str, pw: PiecewiseBlock, ns: Namespace, errors: list[str return None if failed else walks +def resolve_gate( + name: str, pw: PiecewiseBlock, declared: VariableDeclaration, ns: Namespace, errors: list[str] +) -> Gate | None: + """Block *name*'s gate typed, *declared* being its variable as lowered, or ``None`` once it failed. + + A walked gate is read as ``at`` reads it, so the walk is held to every + rule that call is held to, and refused on the gate the file wrote. Each + refusal is appended to *errors*. + """ + assert pw.activity is not None + context = f"piecewise '{name}' activity" + read_text, exists_text = gate_text(pw.activity, declared) + read = resolve_expression_text(read_text, ns, context, errors, ceiling=1) + if read is None: + return None + if exists_text is None: + return Gate(pw.activity.variable, read) + if not pw.activity.walks: + return Gate(pw.activity.variable, read, declared.where) + exists = mask_of(resolve_where_text(exists_text, ns, context, errors)) + return None if exists is None else Gate(pw.activity.variable, read, exists) + + 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``. @@ -462,8 +516,9 @@ def declaration_of( links: tuple[Expression, ...], walks: dict[str, Direction], where: Mask | None, + gate: Gate | None, ) -> PiecewiseDeclaration: - """Block *name* as the program carries it, with *links*, *walks* and *where* typed, every fit rule decided. + """Block *name* as the program carries it, with *links*, *walks*, *where* and *gate* 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 @@ -472,16 +527,19 @@ def declaration_of( 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 + walk consumes. A walked gate lands inside ``dims:``. 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 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. + not fit its row, a where outside ``dims:``, a mask carrying + part of what a walk reads through, or a walked gate landing + outside ``dims:``. """ ctx = f"piecewise '{name}'" + if gate is not None and pw.activity is not None and pw.activity.walks: + _gate_fits(ctx, pw, dims_of(gate.read, schema, f'{ctx} activity')) 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} @@ -506,10 +564,21 @@ def declaration_of( 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 + pw.along, typed, pw.method, tuple(pw.dims), where, activity=gate, description=pw.description ) +def _gate_fits(ctx: str, block: PiecewiseBlock, landed: frozenset[str]) -> None: + """A walked gate lands on dims of ``dims:``: it switches the curve of one coordinate of them.""" + if stray := sorted(landed - set(block.dims)): + assert block.activity is not None + raise DimensionError( + f"{ctx}: activity '{block.activity.variable}' read through '{block.activity.by}' lands on {stray}, " + f'which dims {block.dims} does not carry. The gate switches the curve of one coordinate of dims:, ' + f'so it is read onto them — walk into a dimension of dims:, or add {stray} to dims:.' + ) + + 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. @@ -784,9 +853,10 @@ def _gate_rows(self) -> tuple[tuple[str, str | None, str], ...]: activity = self.pw.activity if activity is None: return (('', None, '1'),) - if not leaves_ungated(self.schema.variables[activity]): - return (('', None, f'({activity})'),) - return (('', activity, f'({activity})'), (_UNGATED, f'NOT {activity}', '1')) + read, exists = gate_text(activity, self.schema.variables[activity.variable]) + if exists is None: + return (('', None, f'({read})'),) + return (('', exists, f'({read})'), (_UNGATED, f'NOT {_operand(exists)}', '1')) def _segment_lines(self) -> None: """The segment-line form: a row per segment, and the two domain rows. diff --git a/src/mathspec/program.py b/src/mathspec/program.py index 072c55a8..4baba251 100644 --- a/src/mathspec/program.py +++ b/src/mathspec/program.py @@ -58,6 +58,7 @@ 'ExpressionComparison', 'ExpressionDeclaration', 'Footprint', + 'Gate', 'GivenDeclaration', 'GivenTargets', 'GroupSum', @@ -754,6 +755,24 @@ def walks(self) -> bool: return self.by is not None +@dataclass(frozen=True) +class Gate: + """The binary a ``piecewise:`` block's weights sum to, as each curve reads it. + + ``read`` is the gate at one curve: the variable on the block's own frame, + or the variable read through a relation, as ``at`` reads it. ``exists`` + is where a curve reads it, and the curve is ungated elsewhere, its weights + summing to 1; it is ``None`` where every curve reads the gate. A masked + variable exists only on its mask, and a relation has no row at some + coordinates, so both leave curves ungated; ``absence: zero`` reads the + variable as 0 off its mask instead, which pins those curves off. + """ + + variable: str + read: Expression + exists: Mask | None = None + + @dataclass(frozen=True) class PiecewiseDeclaration: """A ``piecewise:`` block as the curve it states, which [`expand`][mathspec.spec.Spec.expand] writes out as rows. @@ -784,7 +803,7 @@ class PiecewiseDeclaration: method: PiecewiseMethod frame: tuple[str, ...] where: Mask | None = None - activity: str | None = None + activity: Gate | None = None description: str | None = None @property diff --git a/src/mathspec/spec.py b/src/mathspec/spec.py index 0cb7f8d3..3262768e 100644 --- a/src/mathspec/spec.py +++ b/src/mathspec/spec.py @@ -625,45 +625,32 @@ def _as_written(self) -> str | dict[str, object]: return written -class PiecewiseLink(_StrictBlock): - """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 - [`Spec.to_yaml`][] reproduces the file. +class _Walk(_StrictBlock): + """The relation a piecewise link or gate is read through, as ``at`` reads an array, where it walks one. - 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. + ``by:``, ``over:`` and ``into:`` are written together or not at all: a + walk states the relation, the columns it consumes and the columns it + produces, and none is defaulted. """ - _label: ClassVar[str] = 'a piecewise link' - - expression: str - values: str - sign: ComparisonOperator = '==' - #: The relation the link reads the curve's weights through, where it walks one. + #: The relation the read walks, where it walks one. by: str | None = None - #: The relation columns the walk consumes, over the block's own dims. + #: The relation columns the walk consumes. over: str | list[str] | None = None - #: The relation columns the walk produces, which index the link's rows. + #: The relation columns the walk produces, which index what the read lands on. 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.""" + """Whether the read walks a relation, rather than standing on the block's own dims.""" return self.by is not None @model_validator(mode='after') - def _check_walk(self) -> PiecewiseLink: + def _check_walk(self) -> Self: 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'{self._label} reads 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.' ) @@ -676,6 +663,34 @@ def _check_walk(self) -> PiecewiseLink: raise ValueError(msg) return self + def _walk_as_written(self) -> dict[str, str | list[str]]: + """``by``, ``over`` and ``into`` as the file wrote them, for a walk's mapping form.""" + assert self.by is not None and self.over is not None and self.into is not None + return {'by': self.by, 'over': self.over, 'into': self.into} + + +class PiecewiseLink(_Walk): + """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 + [`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' + + expression: str + values: str + sign: ComparisonOperator = '==' + @model_validator(mode='before') @classmethod def _from_list(cls, data: object) -> object: @@ -698,17 +713,48 @@ 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, + **self._walk_as_written(), } return written if self.sign == '==' else {**written, 'sign': self.sign} +class PiecewiseActivity(_Walk): + """A piecewise block's gate: the binary the weights sum to, on the block's own dims or read through a relation. + + Written in YAML as the variable's name, and serialised back to it. A gate + that names ``by:``, ``over:`` and ``into:`` is written as a mapping, and + reads the binary through the relation as ``at`` reads it, so a unit's + on/off variable over its own dimension switches the curves it maps to. A + curve whose coordinate has no row in the relation is ungated. + """ + + _label: ClassVar[str] = 'a piecewise activity' + + #: The binary variable the weights sum to. + variable: str + + @model_validator(mode='before') + @classmethod + def _from_name(cls, data: object) -> object: + return {'variable': data} if isinstance(data, str) else data + + @classmethod + @override + def __get_pydantic_json_schema__(cls, core_schema: CoreSchema, handler: GetJsonSchemaHandler) -> dict[str, object]: + """The published schema admits the bare variable name a gate on the block's own dims is written as.""" + return _also_written_as(core_schema, handler, {'type': 'string'}) + + @model_serializer + def _as_written(self) -> str | dict[str, str | list[str]]: + """The variable's name, or the mapping form a walk cannot be written in a name.""" + if not self.walks: + return self.variable + return {'variable': self.variable, **self._walk_as_written()} + + #: How a ``piecewise:`` block restricts its interpolation weights, and what #: each one emits. The key is ``method:`` because that is #: ``linopy.Spec.add_piecewise_formulation``'s (#695); ``sos2`` and ``lp`` are @@ -790,7 +836,7 @@ class PiecewiseBlock(_StrictBlock): #: 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 + activity: PiecewiseActivity | None = None description: str | None = None @property diff --git a/src/mathspec/typesetting/symbols.py b/src/mathspec/typesetting/symbols.py index da15d756..ac6e6f16 100644 --- a/src/mathspec/typesetting/symbols.py +++ b/src/mathspec/typesetting/symbols.py @@ -17,7 +17,7 @@ from mathspec._yaml import read_yaml from mathspec.errors import SchemaError, did_you_mean -from mathspec.piecewise import Emitted, leaves_ungated +from mathspec.piecewise import Emitted from mathspec.program import Dual, Variable, walk from mathspec.sos import Emitted as EmittedSet from mathspec.typesetting.format import NOTATIONS @@ -301,7 +301,7 @@ def _emitted(program: Program) -> set[str]: curves = ( Emitted.of(name, curve).written( curve.method, - ungated=leaves_ungated(program.variables[curve.activity] if curve.activity is not None else None), + ungated=curve.activity is not None and curve.activity.exists is not None, ) for name, curve in program.piecewise.items() ) diff --git a/src/mathspec/typesetting/walk.py b/src/mathspec/typesetting/walk.py index 77f321be..f05cee02 100644 --- a/src/mathspec/typesetting/walk.py +++ b/src/mathspec/typesetting/walk.py @@ -984,21 +984,21 @@ def _breakpoints(self, block: PiecewiseDeclaration, admitted: Mask | None, ctx: def _gate(self, block: PiecewiseDeclaration, ctx: _Context) -> str: """The factor an ``activity:`` puts on the locus, or ``''`` where the block has none. - Where the gate is a variable that does not exist at every coordinate - the curve is built for, the factor is the gate where it exists and 1 - where it does not — the two rows the expansion writes there, because a - variable that does not exist takes its row with it and would leave the - curve unstated rather than ungated. ``absence: zero`` is the other - reading and pins the curve off, which is the factor on its own. + The gate prints as each curve reads it: the variable on the curve's + own frame, or read through its relation. Where some curve does not + read it — a masked variable, or a relation with no row — the factor is + the gate where it is read and 1 elsewhere: the two rows the expansion + writes there, because a gate that is not there takes its row with it + and would leave the curve unstated rather than ungated. ``absence: + zero`` is the other reading and pins the curve off, which is the factor + on its own. """ - if (activity := block.activity) is None: + if (gate := block.activity) is None: return '' - gate = self.program.variables[activity] - symbol = ctx.indexed(self.symbols.name[activity], list(gate.dims)) - mask = gate.where - if mask is None or gate.absence == 'zero': + symbol = self._expression(gate.read, ctx) + if gate.exists is None: return symbol - where = self._predicate(mask.root, ctx, need=_WHERE_PRECEDENCE['and']) + where = self._predicate(gate.exists.root, ctx, need=_WHERE_PRECEDENCE['and']) return self.format.cases( [(symbol, f'{self.format.prose("if ")} {where}'), ('1', self.format.prose('otherwise'))] ) diff --git a/src/mathspec/validation.py b/src/mathspec/validation.py index 052020b8..8e169840 100644 --- a/src/mathspec/validation.py +++ b/src/mathspec/validation.py @@ -344,8 +344,9 @@ def _piecewise_references(schema: Spec) -> Iterator[str]: 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: + if block.activity is None: continue + activity = block.activity.variable if activity not in schema.variables: yield ( f"{context}: activity '{activity}' is not a declared variable. A gate is a binary variable; " @@ -353,11 +354,14 @@ def _piecewise_references(schema: Spec) -> Iterator[str]: ) elif schema.variables[activity].domain != 'binary': yield f"{context}: activity variable '{activity}' must be binary" + elif block.activity.walks: + continue elif stray := [d for d in schema.variables[activity].dims if d not in block.dims]: yield ( 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:.' + f'curve per coordinate of it — add {stray} to dims:, gate with a variable over dims:, or read ' + f'the gate onto dims: through a relation with by, over and into.' ) diff --git a/tests/test_piecewise.py b/tests/test_piecewise.py index c557424c..43c1e341 100644 --- a/tests/test_piecewise.py +++ b/tests/test_piecewise.py @@ -19,6 +19,7 @@ from mathspec.piecewise import Emitted, assumptions_of, expand_piecewise from mathspec.program import Assumption, Variable, assumption_message from mathspec.spec import Curvature +from mathspec.typesetting import typeset_declaration from mathspec.validation import to_spec from tests.fixtures import DISPATCH_MODEL, expanded, raw_of, schema_of, varied @@ -1154,6 +1155,114 @@ 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'] +#: fluxopt's unit commitment: the on/off binary is over the entities that carry a status, and the curve over converters. +COMMITTED = { + 'dimensions': { + 'converter': {'dtype': 'str'}, + 'status_entity': {'dtype': 'str'}, + 'snapshot': {'dtype': 'int'}, + 'bp': {'dtype': 'int'}, + }, + 'relations': {'pw_status_of': {'key': 'converter', 'values': 'status_entity'}}, + 'parameters': {'bp_p': {'dims': ['converter', 'bp']}, 'bp_fuel': {'dims': ['converter', 'bp']}}, + 'variables': { + 'running': {'dims': ['status_entity', 'snapshot'], 'domain': 'binary'}, + 'p': {'dims': ['converter', 'snapshot']}, + 'fuel': {'dims': ['converter', 'snapshot']}, + }, + 'piecewise': { + 'curve': { + 'along': 'bp', + 'dims': ['converter', 'snapshot'], + 'links': {'p': ['p', 'bp_p'], 'fuel': ['fuel', 'bp_fuel']}, + 'method': 'sos2', + 'activity': {'variable': 'running', 'by': 'pw_status_of', 'over': 'status_entity', 'into': 'converter'}, + } + }, + 'objective': {'sense': 'minimize', 'expression': 'sum(fuel)'}, +} + +#: The status binary as the curve of each converter reads it. +RUNNING = 'at(running, by=pw_status_of, over=status_entity, into=converter)' + + +def test_a_gate_read_through_a_relation_switches_the_curves_it_maps_to(): + """The gate was a variable over `dims:` alone, so fluxopt declared a copy of `running` per converter. + + The copy was a binary per gated curve and step, and a constraint whose + only job was to re-index it. + """ + rows = expand_piecewise(schema_of(COMMITTED)).constraints + assert (rows['curve_convexity'].where, rows['curve_convexity'].expression) == ( + RUNNING, + f'sum(curve_lam, over=bp) == ({RUNNING})', + ), 'a converter with a status sums its weights to that status' + assert (rows['curve_convexity_ungated'].where, rows['curve_convexity_ungated'].expression) == ( + f'NOT ({RUNNING})', + 'sum(curve_lam, over=bp) == 1', + ), 'a converter with no row in the relation is ungated' + + +def test_a_gate_read_through_a_relation_reads_back_as_written(): + spec = schema_of(COMMITTED) + assert to_spec(spec.to_yaml()) == spec, 'the mapping form round-trips' + written_out = spec.expand() + assert to_spec(written_out.to_yaml()).program == written_out.program, 'the rows it writes load again' + + +def test_a_gate_read_through_a_relation_under_absence_zero_pins_off_where_the_relation_has_a_row(): + """`absence: zero` reads the status as 0 off its mask, so there the curve is off; only a missing row ungates it.""" + rows = expand_piecewise( + schema_of( + COMMITTED, + **{ + 'parameters.committable': {'dims': ['status_entity'], 'dtype': 'bool'}, + 'variables.running.where': 'committable', + 'variables.running.absence': 'zero', + }, + ) + ).constraints + assert (rows['curve_convexity'].where, rows['curve_convexity_ungated'].where) == ( + 'pw_status_of', + 'NOT pw_status_of', + ), 'the relation alone decides which curves read the gate' + + +def test_a_gate_read_through_a_relation_prints_as_the_read(): + printed = typeset_declaration(schema_of(COMMITTED), 'curve', 'latex') + assert r'\mathit{running}_{\mathrm{pw\_status\_of}(c),t}' in printed, 'the gate is read at each converter' + assert r'\text{otherwise}' in printed, 'and a converter the relation does not map is ungated' + + +@pytest.mark.parametrize( + ('patch', 'match'), + [ + pytest.param( + { + 'dimensions.period': {'dtype': 'int'}, + 'variables.running.dims': ['status_entity', 'snapshot', 'period'], + }, + r"activity 'running' read through 'pw_status_of' lands on \['period'\], which dims " + r"\['converter', 'snapshot'\] does not carry", + id='a-gate-landing-outside-dims', + ), + pytest.param( + {'piecewise.curve.activity': {'variable': 'running', 'by': 'pw_status_of'}}, + r"a piecewise activity reads through a relation with by, over and into together — \['over', 'into'\]", + id='a-walk-missing-its-columns', + ), + pytest.param( + {'piecewise.curve.activity.by': 'bp_p'}, + r"piecewise 'curve' activity: at\(by=bp_p\) does not name a relation", + id='a-walk-through-something-no-relation', + ), + ], +) +def test_a_gate_read_through_a_relation_is_held_to_the_rules_of_at(patch, match): + with pytest.raises(LanguageError, match=match): + schema_of(COMMITTED, **patch) + + #: fluxopt's system: only some generators run on a curve, and the rest have none at all. CURVED = varied( WALKED, From 160265992e1d59b39d009e5e5b328d46626a38ea Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 09:15:19 +0000 Subject: [PATCH 4/4] docs: add the changelog line for #784 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015VUkQfE5dT7mEGoTQXSxN3 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6eb348dc..862fdb28 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 gate reads its binary through a relation, as a link walks one ([#784](https://github.com/energy-models/mathspec/pull/784)) - 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)) - 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))