diff --git a/CHANGELOG.md b/CHANGELOG.md index b3e2626d..ea431cd2 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): an expression that only other files' terms fill is declared with `expression: null`, and prints as dots ([#758](https://github.com/energy-models/mathspec/pull/758)) - feat(language): a named expression may declare the frame it is read over ([#741](https://github.com/energy-models/mathspec/pull/741)) - docs: code examples on the site are readable in light and dark mode, and a diagram shows what mathspec leaves to engines and other tools ([#730](https://github.com/energy-models/mathspec/pull/730)) - docs: the site follows the reader's light or dark setting, and a page shows where it sits in the navigation ([#727](https://github.com/energy-models/mathspec/pull/727)) diff --git a/docs/howto/compose.md b/docs/howto/compose.md index fc7b8fe2..951d8a93 100644 --- a/docs/howto/compose.md +++ b/docs/howto/compose.md @@ -107,18 +107,18 @@ compose as `override(merge({…}), {…})`. ## What a fragment may share -| The entry | What happens | -| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| a dimension or a relation | every fragment may declare it, and the ones that do say the same thing about it | -| a `description` on a shared dimension or relation | it is prose rather than a claim, and the first wording in fragment-name order is carried, whatever order the fragments are passed in | -| any other declaration | one fragment declares it, and a second is refused | -| an entry under `given:` | it is checked against the fragment that introduces the name, then folded into it. Its description fills the declaration where the introducer wrote none | -| a given expression | the definition's body carries no dimension the reader's `dims` do not name | -| a given entry no fragment introduces | it stays under `given:` until a host model provides it | -| a given expression with a `term` | the name is defined as the definition one fragment writes, if any, plus every term by its name, in fragment-name order. Each term stays a named expression. A term that lands on a name no fragment defines, reads or uses is refused | -| `objective` | the terms are summed in fragment-name order, each in parentheses, and the senses agree. The first description in fragment-name order is carried | -| `version` | every fragment is written against the same one | -| `description` at the top of a fragment | it is about the fragment and is not carried. Pass the composed spec's as `description=` | +| The entry | What happens | +| ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| a dimension or a relation | every fragment may declare it, and the ones that do say the same thing about it | +| a `description` on a shared dimension or relation | it is prose rather than a claim, and the first wording in fragment-name order is carried, whatever order the fragments are passed in | +| any other declaration | one fragment declares it, and a second is refused | +| an entry under `given:` | it is checked against the fragment that introduces the name, then folded into it. Its description fills the declaration where the introducer wrote none | +| a given expression | the definition's body carries no dimension the reader's `dims` do not name | +| a given entry no fragment introduces | it stays under `given:` until a host model provides it | +| a given expression with a `term` | the name is defined as the definition one fragment writes plus every term by its name, in fragment-name order. An empty definition, `expression: null`, adds no body. Each term stays a named expression. A term that lands on a name no fragment defines is refused | +| `objective` | the terms are summed in fragment-name order, each in parentheses, and the senses agree. The first description in fragment-name order is carried | +| `version` | every fragment is written against the same one | +| `description` at the top of a fragment | it is about the fragment and is not carried. Pass the composed spec's as `description=` | ## A name two fragments declare diff --git a/docs/reference/language/declarations.md b/docs/reference/language/declarations.md index 74ad2e68..51d8d4a5 100644 --- a/docs/reference/language/declarations.md +++ b/docs/reference/language/declarations.md @@ -270,15 +270,15 @@ given: ``` ```yaml -# balance.yaml reads the sum +# balance.yaml defines the sum as empty, and reads it dimensions: snapshot: { dtype: int } bus: { dtype: str } -given: - expressions: - injection: - dims: [snapshot, bus] - description: what the components put into a bus +expressions: + injection: + dims: [snapshot, bus] + expression: null + description: what the components put into a bus constraints: balance: dims: [snapshot, bus] @@ -293,22 +293,25 @@ The typeset legend lists the entry under _Given_ and names the term, and the math prints the term under _Definitions_ as its own line. [`merge`](../../howto/compose.md#a-library-of-components) defines the name as -the definition one fragment writes under `expressions:`, if any, plus every -term by its name, in fragment-name order, and keeps each term as a named -expression of the composed spec. Nothing declares that the name is a sum: a -term adds to whatever the other files define, as a fragment's objective adds -to the objective, and a later merge adds to the composed definition the same -way. The file that defines the name does not opt in. It reads the name as its -own definition alone, and as the definition plus every term once composed; -whoever composes the files answers for that sum. A term has to land on a name another file -has: one that defines it, reads it with no term of its own, or uses it in its -math. Terms alone are refused, with the near miss named, since `merge` fills -a reading or extends a definition and never invents a name. A definition -written as `cases:` is refused, since it is summed as written: name the cased -body as its own expression, and define the name as that name. A cased term is -added like any other, by its name. The definition keeps its own description, -or takes the first a reader wrote. Two files that both define the name under -`expressions:` are refused as a collision, and the message names `term:`. +the definition one fragment writes under `expressions:` plus every term by its +name, in fragment-name order, and keeps each term as a named expression of the +composed spec. Nothing declares that the name is a sum: a term adds to +whatever the other files define, as a fragment's objective adds to the +objective, and a later merge adds to the composed definition the same way. The +file that defines the name does not opt in. It reads the name as its own +definition alone, and as the definition plus every term once composed; +whoever composes the files answers for that sum. A definition that is only +the terms is written as +[an empty expression](named.md#an-empty-expression), `expression: null`, and +the terms alone fill it. A term has to land on a definition. A name that +another file only reads under `given:`, or only uses in its math, is refused, +with the near miss named, since `merge` extends a definition and never invents +a name. A definition written as `cases:` is refused, since it is summed as +written: name the cased body as its own expression, and define the name as +that name. A cased term is added like any other, by its name. The definition +keeps its own frame and description, or takes the first description a reader +wrote. Two files that both define the name under `expressions:` are refused as +a collision, and the message names `term:`. ## `constraints` diff --git a/docs/reference/language/named.md b/docs/reference/language/named.md index de20662c..9cc0517c 100644 --- a/docs/reference/language/named.md +++ b/docs/reference/language/named.md @@ -81,6 +81,8 @@ Each case prints as one row of the definition, and `otherwise:` as the last: $$\mathit{previous\_status}_{t,g} = \begin{cases} 1 & \text{if } \neg \mathrm{committable}_{g} \cr \mathrm{status}^{\mathrm{initial}}_{g} & \text{if } \mathrm{committable}_{g} \wedge \mathrm{pos}(t) = 0 \cr \mathit{status}_{t - 1,g} & \text{otherwise} \end{cases} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ A named expression carries **exactly one** of `expression:` and `cases:`. +`expression: null` counts as an `expression:`: it is +[an empty expression](#an-empty-expression). | Key | | | ----------- | ---------------------------------------------------------------------------- | @@ -121,6 +123,38 @@ masked variable. `cases:` is not accepted inside a `macros:` template. +## An empty expression + +`expression: null` defines a quantity with no body of its own. Other files add +[terms](declarations.md#a-term-a-file-adds) to it, and +[`merge`](../../howto/compose.md#a-library-of-components) sums them. + +```yaml +dimensions: + snapshot: { dtype: int } + bus: { dtype: str } +expressions: + injection: + dims: [snapshot, bus] + expression: null + description: what the components put into a bus +constraints: + balance: + dims: [snapshot, bus] + expression: injection == 0 +``` + +`dims:` is required, because no body gives the frame. `cases:` and +`otherwise:` are refused beside it. On its own, the file reads the name as it +reads a [given expression](declarations.md#given-expressions): a quantity over +the frame, of degree one, that a `where` does not read. The program holds it +under `given.expressions`, marked `empty`. The definition prints as dots: + +$$\mathit{injection}_{t,b} = \dots \qquad \forall\thinspace t \in \mathcal{T},\enspace b \in \mathcal{B}$$ + +The legend lists it under _Definitions_. It draws no advice. A merge that adds +no term to it keeps it empty. + ## Reported expressions A named expression is either **in the math** or **reported**. The objective diff --git a/docs/reference/notation.md b/docs/reference/notation.md index 030bc436..fddb46df 100644 --- a/docs/reference/notation.md +++ b/docs/reference/notation.md @@ -126,6 +126,7 @@ parameters: | $`\mathit{lcoe}`$ | `lcoe` (scalar) | | $`\mathit{marginal\_price}`$ | `marginal_price` over $`\mathcal{T} \times \mathcal{B}`$ | | $`\mathrm{startup\_cost}`$ | `startup_cost` over $`\mathcal{T} \times \mathcal{G}`$ — what starting a unit in this snapshot costs, which the horizon's edge changes | +| $`\mathit{imports}`$ | `imports` over $`\mathcal{T} \times \mathcal{B}`$, empty here: the terms other files add fill it — what neighbouring areas put into a bus | Upright is what the data supplies — a parameter such as $`\mathrm{p}^{\mathrm{max}}`$, a coordinate map, a label — and italic is what the solver chooses, such as $`p`$. An index is italic too, being what a quantifier chooses, and a set is script. @@ -732,6 +733,21 @@ expressions: \mathit{marginal\_price}_{t,b} = \lambda_{\mathrm{balance},t,b} \qquad \forall\, t \in \mathcal{T},\ b \in \mathcal{B} ``` +#### Empty expression + +an empty expression: the terms other files add fill it, so its body prints as dots + +```yaml +expressions: + imports: + dims: [snapshot, bus] + expression: null +``` + +```math +\mathit{imports}_{t,b} = \dots \qquad \forall\, t \in \mathcal{T},\ b \in \mathcal{B} +``` + ### Shifts #### Cyclic and acyclic shift diff --git a/docs/reference/reading.md b/docs/reference/reading.md index 4ae78559..cd0f38cf 100644 --- a/docs/reference/reading.md +++ b/docs/reference/reading.md @@ -173,7 +173,9 @@ model this one is layered onto. An expression reads a given expression as a A given expression with a `term` is one this file adds to: `program.given.expressions[name].term` is the term: the `Named` node of the entry of `program.expressions` it names. The name is still one the program -reads and does not build. +reads and does not build. An [empty expression](language/named.md#an-empty-expression) +is under `program.given.expressions` too, with `empty` set: the file defines +the name, and the terms other files add are its whole body. ```python layer = to_spec( diff --git a/docs/several-files.md b/docs/several-files.md index 53d8b6ac..5ebdf4c0 100644 --- a/docs/several-files.md +++ b/docs/several-files.md @@ -11,9 +11,9 @@ other files. Do [your first spec](first-spec.md) first. ## The network -Make a file `network.yaml`. It balances every bus, and it reads the injection -at a bus under [`given:`](reference/language/declarations.md#given) rather -than defining it: +Make a file `network.yaml`. It balances every bus. It defines the injection at +a bus as an [empty expression](reference/language/named.md#an-empty-expression), +`expression: null`, which the other files fill: ```yaml title="network.yaml" description: Every bus is balanced in every snapshot. @@ -22,11 +22,11 @@ dimensions: snapshot: { dtype: int, description: dispatch periods } bus: { description: network nodes } -given: - expressions: - injection: - dims: [snapshot, bus] - description: what the components put into a bus, less what they take out +expressions: + injection: + dims: [snapshot, bus] + expression: null + description: what the components put into a bus, less what they take out constraints: balance: @@ -40,12 +40,39 @@ Check the file: python -m mathspec check network.yaml ``` -The check accepts it, and notes the expression it reads: +The check accepts it, and prints nothing. -```text -expression 'injection' is read here and declared elsewhere: the model this one is layered onto provides it. A consumer checks that it does, on the same frame, and refuses the program where it does not. A fragment is composed instead: merge() folds this declaration into the one a sibling introduces. +Print the math of the file on its own: + +```python +import mathspec as ms + +print(ms.to_markdown('network.yaml', legend=False)) ``` +The file does not know what the injection holds, so its definition prints as +dots: + +!!! example "Rendered output" + + Every bus is balanced in every snapshot. + + #### Subject to + + **`balance`** + + ```math + \mathit{injection}_{t,b} = 0 \qquad \forall\, t \in \mathcal{T},\ b \in \mathcal{B} + ``` + + #### Definitions + + **`injection`** + + ```math + \mathit{injection}_{t,b} = \dots \qquad \forall\, t \in \mathcal{T},\ b \in \mathcal{B} + ``` + ## The generators Make a file `generators.yaml`. It says what the fleet puts into a bus as a @@ -105,8 +132,6 @@ expression 'injection' is read here and declared elsewhere, and this file adds a Print the math of the file on its own: ```python -import mathspec as ms - print(ms.to_markdown('generators.yaml', legend=False)) ``` @@ -188,7 +213,7 @@ print(spec.expressions['injection'].expression) ``` The injection is the sum of the two terms by name, in the order of the file -names. Each term stays a named expression of the merged spec: +names, and the empty definition adds nothing. Each term stays a named expression of the merged spec: ```text generation + consumption @@ -218,22 +243,22 @@ print(ms.to_markdown(spec, legend=False)) #### Definitions - **`generation`** + **`injection`** ```math - \mathit{generation}_{t,b} = \sum_{g \in \mathcal{G} \,:\, \mathrm{gen\_bus}(g) = b} \mathit{dispatch}_{t,g} \qquad \forall\, t \in \mathcal{T},\ b \in \mathcal{B} + \mathit{injection}_{t,b} = \mathit{generation}_{t,b} + \mathrm{consumption}_{t,b} \qquad \forall\, t \in \mathcal{T},\ b \in \mathcal{B} ``` - **`consumption`** + **`generation`** ```math - \mathrm{consumption}_{t,b} = -\mathrm{demand}_{t,b} \qquad \forall\, t \in \mathcal{T},\ b \in \mathcal{B} + \mathit{generation}_{t,b} = \sum_{g \in \mathcal{G} \,:\, \mathrm{gen\_bus}(g) = b} \mathit{dispatch}_{t,g} \qquad \forall\, t \in \mathcal{T},\ b \in \mathcal{B} ``` - **`injection`** + **`consumption`** ```math - \mathit{injection}_{t,b} = \mathit{generation}_{t,b} + \mathrm{consumption}_{t,b} \qquad \forall\, t \in \mathcal{T},\ b \in \mathcal{B} + \mathrm{consumption}_{t,b} = -\mathrm{demand}_{t,b} \qquad \forall\, t \in \mathcal{T},\ b \in \mathcal{B} ``` #### Variable domains @@ -370,11 +395,11 @@ Merge the generators and the loads without the network: ms.merge({'generators': 'generators.yaml', 'loads': 'loads.yaml'}) ``` -`merge` refuses it. A term adds to a name another file has, and without the -network no file defines, reads or uses `injection`: +`merge` refuses it. A term adds to a definition another file writes, and +without the network no file defines `injection`: ```text -fragments 'generators' and 'loads' add a term to 'injection', which no fragment defines, reads or uses. A term adds to a name another file has: define it under 'expressions:', read it under 'given: expressions:', or fix the spelling. +fragments 'generators' and 'loads' add a term to 'injection', which no fragment defines. A term adds to a definition another file writes under 'expressions:': define it there, as `expression: null` where the terms are all of it, or fix the spelling. ``` ## Where to next diff --git a/schema/mathspec.schema.json b/schema/mathspec.schema.json index c2242ac6..6db2dc6b 100644 --- a/schema/mathspec.schema.json +++ b/schema/mathspec.schema.json @@ -166,7 +166,7 @@ "anyOf": [ { "additionalProperties": false, - "description": "A named quantity: one arithmetic expression, referenced by the math or read back after a solve.\n\nWritten in YAML as a bare string, or as a mapping once it carries a\n``description:`` \u2014 and serialised back to whichever form it was written in,\nso a round trip through [`Spec.to_yaml`][] reproduces the file::\n\n expressions:\n total_generation: sum(p, over=generator)\n emissions:\n expression: sum(p * rate, over=generator)\n description: CO2 released, the quantity the cap bounds\n\n``dims:`` declares the frame the quantity is read over. A plain entry may\nleave it out, and its body then decides the frame; a body that carries a\ndimension the frame does not name is refused, and one that carries fewer\nis constant along the rest. A quantity whose value varies by region is\nwritten as ``cases:`` over a declared ``dims:``, with an ``otherwise:``\nfor the rest \u2014 see the language reference.", + "description": "A named quantity: one arithmetic expression, referenced by the math or read back after a solve.\n\nWritten in YAML as a bare string, or as a mapping once it carries a\n``description:`` \u2014 and serialised back to whichever form it was written in,\nso a round trip through [`Spec.to_yaml`][] reproduces the file::\n\n expressions:\n total_generation: sum(p, over=generator)\n emissions:\n expression: sum(p * rate, over=generator)\n description: CO2 released, the quantity the cap bounds\n\n``dims:`` declares the frame the quantity is read over. A plain entry may\nleave it out, and its body then decides the frame; a body that carries a\ndimension the frame does not name is refused, and one that carries fewer\nis constant along the rest. A quantity whose value varies by region is\nwritten as ``cases:`` over a declared ``dims:``, with an ``otherwise:``\nfor the rest \u2014 see the language reference.\n\n``expression: null`` over a declared ``dims:`` is an empty expression: a\ndefinition with no body of its own, which the terms other files add fill\nonce [`merge`][mathspec.composition.merge] composes them. Alone, the file\nreads it as it reads a given expression.", "properties": { "cases": { "additionalProperties": { diff --git a/src/mathspec/advising.py b/src/mathspec/advising.py index 21c0163e..a0237ad5 100644 --- a/src/mathspec/advising.py +++ b/src/mathspec/advising.py @@ -53,7 +53,8 @@ def _given(program: Program) -> list[Advice]: A note rather than a refusal: the file is a spec somebody meant, and only the consumer can tell whether the model it is layered onto provides the name. An expression this file adds a term to is completed by `merge` - rather than by a host, so its note says that instead. + rather than by a host, so its note says that instead. An empty expression + draws none: ``expression: null`` already says that other files fill it. """ given = program.given return [ @@ -62,6 +63,7 @@ def _given(program: Program) -> list[Advice]: *( Advice('given', name, _term_note(name) if block.term is not None else _given_note('expression', name)) for name, block in given.expressions.items() + if not block.empty ), *(Advice('given', name, _given_note('row family', name)) for name in given.constraints), ] diff --git a/src/mathspec/composition.py b/src/mathspec/composition.py index 7d138365..509992f6 100644 --- a/src/mathspec/composition.py +++ b/src/mathspec/composition.py @@ -28,13 +28,14 @@ name order, and the senses have to agree. * **A term is added to the definition it names.** A ``given: expressions:`` entry with a ``term:`` names the expression its fragment adds to the name. - The composed spec defines the name as the definition one fragment writes, - if any, plus every term by its name, in the fragments' name order, and keeps - each term as the named expression its fragment declares. A definition - written as ``cases:`` is refused, since it is summed as written. A later - merge adds to the composed definition the same way. Terms that land - on a name no fragment defines, reads or uses are refused: merge fills a - reading or extends a definition, and never invents a name. + The composed spec defines the name as the definition one fragment writes + plus every term by its name, in the fragments' name order, and keeps each + term as the named expression its fragment declares. A definition written + as ``expression: null`` is empty, and the terms alone fill it. A + definition written as ``cases:`` is refused, since it is summed as + written. A later merge adds to the composed definition the same way. + Terms that land on a name no fragment defines are refused: merge extends + a definition, and never invents a name. * **A given declaration is folded** into the declaration that introduces the name, once the reader is checked to say the same as the introducer or less. A given expression's body may carry no dimension its reader does not state, @@ -86,9 +87,7 @@ from pydantic import BaseModel, ValidationError from mathspec._yaml import read_spec -from mathspec.dimensions import dims_of from mathspec.errors import LanguageError, did_you_mean, schema_error -from mathspec.program import Variable, walk from mathspec.spec import GivenBlock, Spec from mathspec.validation import to_spec @@ -317,10 +316,11 @@ def _summed( The definition one fragment writes comes first, in parentheses where it is more than a name, then every term by its name in the fragments' name - order; one body alone is carried as written. A definition written as - ``cases:`` is refused, since it is summed as written and a set of cases is - no one body. The definition keeps its own description, or takes the first - a reader wrote. + order; one body alone is carried as written, and an empty definition adds + no body. A definition written as ``cases:`` is refused, since it is + summed as written and a set of cases is no one body. The definition keeps + its own frame and description, or takes the first description a reader + wrote. """ summed: dict[str, object] = {} for key, reading in readings.items(): @@ -328,20 +328,18 @@ def _summed( terms = _terms(read, key) if not terms: continue - _landed(read, loaded, defined, key, [name for name, _ in terms]) + _landed(read, defined, key, [name for name, _ in terms]) + base = _as_mapping(defined[key]) + if base.get('cases'): + raise LanguageError( + f"fragment '{_author_of(read, 'expressions', key)}' defines {key!r} as `cases:`, and fragment " + f"'{terms[0][0]}' adds a term to it. The definition is summed as written, and a set of cases is no " + f'one body: name the cased body as its own expression, and define {key!r} as that name.' + ) bodies = [term for _, term in terms] - block: dict[str, object] = {} - if key in defined: - base = _as_mapping(defined[key]) - if base.get('cases'): - raise LanguageError( - f"fragment '{_author_of(read, 'expressions', key)}' defines {key!r} as `cases:`, and fragment " - f"'{terms[0][0]}' adds a term to it. The definition is summed as written, and a set of cases is no " - f'one body: name the cased body as its own expression, and define {key!r} as that name.' - ) + if base.get('expression') is not None: bodies.insert(0, cast('str', base['expression'])) - if base.get('description'): - block['description'] = base['description'] + block = {field: base[field] for field in ('dims', 'description') if base.get(field) is not None} block['expression'] = bodies[0] if len(bodies) == 1 else ' + '.join(_summand(body) for body in bodies) if 'description' not in block and entry.get('description'): block['description'] = entry['description'] @@ -350,50 +348,30 @@ def _summed( def _landed( - read: Mapping[str, dict[str, object]], - loaded: Mapping[str, Spec], - defined: Mapping[str, object], - key: str, - contributors: list[str], + read: Mapping[str, dict[str, object]], defined: Mapping[str, object], key: str, contributors: list[str] ) -> None: - """Refuse terms that land on a name no fragment owns. + """Refuse terms that land on a name no fragment defines. - A term adds to a name another file has: a definition under - ``expressions:``, a reading under ``given:`` with no term of its own, or a - use in its math. Terms alone would define a name nothing asked for, which - is what a mistyped name looks like, so the refusal names the near miss - among the names a term could land on, which a term is not. + A term adds to a definition another file writes under ``expressions:``, + empty or not. Terms alone would define a name nothing asked for, which is + what a mistyped name looks like, so the refusal names the near miss among + the definitions a term could land on, which a term is not. """ if key in defined: return - for spec in loaded.values(): - reading = spec.given.expressions.get(key) - if reading is not None and reading.term is None: - return - program = spec.program - bodies = (entry.expression for entry in program.expressions.values()) - if any(isinstance(node, Variable) and node.name == key for node in walk(*program.roots, *bodies)): - return - known = { - name - for sections in read.values() - for name in ( - *_mapping(sections.get('expressions')), - *_mapping(_mapping(sections.get('given')).get('expressions')), - ) - } - {key} terms = { _mapping(entry).get('term') for sections in read.values() for entry in _mapping(_mapping(sections.get('given')).get('expressions')).values() } - known -= terms + known = set(defined) - terms - {key} spelled = ', '.join(f"'{name}'" for name in contributors[:-1]) who = f"fragments {spelled} and '{contributors[-1]}' add" if spelled else f"fragment '{contributors[0]}' adds" near = f' {hint}' if (hint := did_you_mean(key, known, listing=False)) else '' raise LanguageError( - f'{who} a term to {key!r}, which no fragment defines, reads or uses. A term adds to a name another ' - f"file has: define it under 'expressions:', read it under 'given: expressions:', or fix the spelling.{near}" + f'{who} a term to {key!r}, which no fragment defines. A term adds to a definition another file ' + f"writes under 'expressions:': define it there, as `expression: null` where the terms are all of it, " + f'or fix the spelling.{near}' ) @@ -481,11 +459,14 @@ def _definer_frame(loaded: Mapping[str, Spec], key: str) -> frozenset[str]: """The frame of the composed body of *key*: the definition's, where a fragment writes one, with every term's.""" frame: set[str] = set() for spec in loaded.values(): - if key in spec.program.expressions: - frame |= set(spec.program.expressions[key].dims) - given = spec.program.given.expressions.get(key) + program = spec.program + if key in program.expressions: + frame |= set(program.expressions[key].dims) + given = program.given.expressions.get(key) + if given is not None and given.empty: + frame |= set(given.dims) if given is not None and given.term is not None: - frame |= dims_of(given.term, spec, f"Given expression '{key}'") + frame |= set(program.expressions[given.term.name].dims) return frozenset(frame) diff --git a/src/mathspec/lowering.py b/src/mathspec/lowering.py index e7104c73..076b0c9e 100644 --- a/src/mathspec/lowering.py +++ b/src/mathspec/lowering.py @@ -52,6 +52,7 @@ resolve_expression_text, resolve_where_text, ) +from mathspec.spec import GivenExpressionBlock from mathspec.validation import emitted_name_errors, reference_errors if TYPE_CHECKING: @@ -99,6 +100,7 @@ def lower(schema: Spec) -> Program: errors = reference_errors(schema) if errors: raise SchemaError('\n'.join(errors)) + schema, empty = _opened(schema) ns = Namespace(schema) for mname, macro in schema.macros.items(): @@ -261,7 +263,7 @@ def lower(schema: Spec) -> Program: name: GivenDeclaration(tuple(g.dims), g.description) for name, g in schema.given.constraints.items() }, expressions={ - name: GivenDeclaration(tuple(g.dims), g.description, term=terms.get(name)) + name: GivenDeclaration(tuple(g.dims), g.description, term=terms.get(name), empty=name in empty) for name, g in schema.given.expressions.items() }, ), @@ -273,6 +275,27 @@ def lower(schema: Spec) -> Program: return program +def _opened(schema: Spec) -> tuple[Spec, frozenset[str]]: + """*schema* with each empty expression read as a given expression, and the names moved. + + An empty expression has no body to resolve, and the file reads it as it + reads a given expression: a quantity over its frame, of degree one, that + no ``where`` reads. Moved under ``given: expressions:``, every rule a + given expression is held to holds it. It runs once the names are checked + on the file as written, so a refusal names the section the file wrote. + """ + moved = { + name: GivenExpressionBlock(dims=list(block.dims or []), description=block.description) + for name, block in schema.expressions.items() + if block.empty + } + if not moved: + return schema, frozenset() + given = schema.given.model_copy(update={'expressions': {**schema.given.expressions, **moved}}) + kept = {name: block for name, block in schema.expressions.items() if name not in moved} + return schema.model_copy(update={'expressions': kept, 'given': given}), frozenset(moved) + + def _frame_of(name: str, entry: Named, schema: Spec) -> tuple[str, ...]: """The dims an entry is read over: the ``dims:`` it declares, as written, else the body's in declaration order.""" declared = schema.expressions[name].dims diff --git a/src/mathspec/program.py b/src/mathspec/program.py index c85b2601..971f692a 100644 --- a/src/mathspec/program.py +++ b/src/mathspec/program.py @@ -624,6 +624,9 @@ class GivenDeclaration: description: str | None = None #: The [`Named`][] node of the term this program adds to the name, or ``None`` where it only reads it. term: Named | None = None + #: Whether this program defines the name as an empty expression, which + #: the terms other files add fill, rather than reading one another file defines. + empty: bool = False @dataclass(frozen=True) @@ -640,7 +643,8 @@ class GivenTargets: variables: Mapping[str, GivenDeclaration] = Sealed({}) #: Row families the host model provides, by name, read back after the solve. constraints: Mapping[str, GivenDeclaration] = Sealed({}) - #: Named expressions the host model defines, by name. + #: Named expressions the host model defines, by name, and the empty + #: expressions this program defines, which the terms other files add fill. expressions: Mapping[str, GivenDeclaration] = Sealed({}) def __post_init__(self) -> None: diff --git a/src/mathspec/spec.py b/src/mathspec/spec.py index 62ab3c1e..379ba4df 100644 --- a/src/mathspec/spec.py +++ b/src/mathspec/spec.py @@ -464,6 +464,11 @@ class ExpressionBlock(_StrictBlock): is constant along the rest. A quantity whose value varies by region is written as ``cases:`` over a declared ``dims:``, with an ``otherwise:`` for the rest — see the language reference. + + ``expression: null`` over a declared ``dims:`` is an empty expression: a + definition with no body of its own, which the terms other files add fill + once [`merge`][mathspec.composition.merge] composes them. Alone, the file + reads it as it reads a given expression. """ _label: ClassVar[str] = 'a named expression' @@ -483,14 +488,26 @@ class ExpressionBlock(_StrictBlock): def _from_string(cls, data: object) -> object: return {'expression': data} if isinstance(data, str) else data + @property + def empty(self) -> bool: + """Whether the entry is written as ``expression: null``: a definition the terms other files add fill.""" + return 'expression' in self.model_fields_set and self.expression is None + @model_validator(mode='after') def _one_form_or_the_other(self) -> Self: - """One ``expression:``, or ``cases:`` with the ``otherwise:`` and ``dims:`` they need.""" - if bool(self.cases) == (self.expression is not None): + """One ``expression:``, possibly ``null``, or ``cases:`` with the ``otherwise:`` and ``dims:`` they need.""" + if bool(self.cases) == ('expression' in self.model_fields_set): got = 'both' if self.cases else 'neither' msg = ( f'a named expression is one `expression:` or a set of `cases:`, and this has {got}. ' - f'Cases are for a quantity whose value varies by region; one expression is everything else.' + f'Cases are for a quantity whose value varies by region; one expression is everything else, ' + f'and `expression: null` is an empty one, which the terms other files add fill.' + ) + raise ValueError(msg) + if self.empty and self.dims is None: + msg = ( + '`expression: null` needs a `dims:` — an empty expression has no body to give the frame ' + 'it is read over, and the terms that fill it are read over that frame.' ) raise ValueError(msg) if self.cases and self.dims is None: @@ -529,8 +546,7 @@ def _as_written(self) -> str | dict[str, object]: written['cases'] = {name: case.model_dump() for name, case in self.cases.items()} written['otherwise'] = self.otherwise return written - assert self.expression is not None - if self.description is None and self.dims is None: + if self.expression is not None and self.description is None and self.dims is None: return self.expression written = {'dims': list(self.dims)} if self.dims is not None else {} written['expression'] = self.expression @@ -778,14 +794,14 @@ def _without_absence(value: object) -> object: kept = {} for key, before in value.items(): after = _without_absence(before) - if not _is_absent(after) and after != {}: + if not _is_absent(key, after) and after != {}: kept[key] = after return kept -def _is_absent(value: object) -> bool: - """Whether *value* is a null.""" - return value is None +def _is_absent(key: object, value: object) -> bool: + """Whether *value* is a null, but under ``expression:``, where a null is the empty expression rather than an absence.""" + return value is None and key != 'expression' class Spec(_StrictBlock): @@ -896,7 +912,7 @@ def _drop_absence(self, handler: SerializerFunctionWrapHandler) -> dict[str, obj """Absence is not serialised: a null, a mapping that stripping emptied, a section declaring nothing. An empty list stays, being a value rather than an absence (``dims: - []`` is a scalar). On the serializer so that ``model_dump``, + []`` is a scalar), and so does ``expression: null``, the empty expression. On the serializer so that ``model_dump``, [`to_dict`][] and [`to_yaml`][] agree. """ return cast('dict[str, object]', _without_absence(handler(self))) diff --git a/src/mathspec/typesetting/__init__.py b/src/mathspec/typesetting/__init__.py index 6a85ebd8..6cf27a1c 100644 --- a/src/mathspec/typesetting/__init__.py +++ b/src/mathspec/typesetting/__init__.py @@ -211,7 +211,7 @@ def typeset_declaration( givens = { 'parameter': given.parameters, 'variable': given.variables, - 'expression': given.expressions, + 'expression': {name: g for name, g in given.expressions.items() if not g.empty}, 'constraint': given.constraints, } given_kind = next((kind for kind, group in givens.items() if name in group), None) diff --git a/src/mathspec/typesetting/format.py b/src/mathspec/typesetting/format.py index fe888114..370a345d 100644 --- a/src/mathspec/typesetting/format.py +++ b/src/mathspec/typesetting/format.py @@ -62,6 +62,7 @@ 'dual', 'minimize', 'maximize', + 'dots', ] #: Every operator a walk can emit, by the name the walk uses for it, with its @@ -69,7 +70,8 @@ #: so no format can be missing one. ``such_that`` is the colon in #: "∀ t ∈ T : condition", ``times`` sits between sets in the legend, #: ``maps_to`` is the → in a coordinate map, ``curve`` and ``hull`` are the two -#: sets a ``piecewise:`` block states its links lie on, and the three +#: sets a ``piecewise:`` block states its links lie on, ``dots`` is the body +#: of an empty expression, which the terms other files add fill, and the three #: translations are three conventions: plain leaves the vacated position absent, #: ``cyclic_*`` wraps, ``edge_*`` fills it with the value it carries as a #: subscript. @@ -108,6 +110,7 @@ 'dual': (r'\lambda', 'lambda'), 'minimize': (r'\min', 'min'), 'maximize': (r'\max', 'max'), + 'dots': (r'\dots', 'dots.h'), } #: The set form, for the test pinning each format's table against the vocabulary. diff --git a/src/mathspec/typesetting/legend.py b/src/mathspec/typesetting/legend.py index b551c336..96eb104c 100644 --- a/src/mathspec/typesetting/legend.py +++ b/src/mathspec/typesetting/legend.py @@ -180,6 +180,7 @@ def glossaries(self, noticed: Noticed, defined: Iterable[str]) -> list[tuple[str block.description, ) for g, block in program.given.expressions.items() + if not block.empty ), *( self._entry( @@ -192,9 +193,20 @@ def glossaries(self, noticed: Noticed, defined: Iterable[str]) -> list[tuple[str ] shown = set(defined) definitions = [ - self._entry(self.symbols.name[e], f'{fmt.mono(e)}{self._over(list(block.dims))}', block.description) - for e, block in program.expressions.items() - if e in shown + *( + self._entry(self.symbols.name[e], f'{fmt.mono(e)}{self._over(list(block.dims))}', block.description) + for e, block in program.expressions.items() + if e in shown + ), + *( + self._entry( + self.symbols.name[e], + f'{fmt.mono(e)}{self._over(list(block.dims))}, empty here: the terms other files add fill it', + block.description, + ) + for e, block in program.given.expressions.items() + if block.empty + ), ] groups = ( ('Sets', sets), diff --git a/src/mathspec/typesetting/walk.py b/src/mathspec/typesetting/walk.py index 3b9884c9..caeb6a19 100644 --- a/src/mathspec/typesetting/walk.py +++ b/src/mathspec/typesetting/walk.py @@ -709,20 +709,30 @@ def defined(self) -> list[str]: """The named expressions that print under their own symbol: every one, or only the unsubstitutable when inlining. Inlining leaves a name standing only where substitution cannot reach - it — a ``cases`` block, and an entry the objective and constraints - never read, which is a quantity reported back rather than solved for. + it — a ``cases`` block, an entry the objective and constraints never + read, which is a quantity reported back rather than solved for, and an + empty expression, which has no body. The empty ones come last. """ entries = self.program.expressions + empty = [name for name, given in self.program.given.expressions.items() if given.empty] if not self.inline_expressions: - return list(entries) - return [name for name, entry in entries.items() if isinstance(entry.expression, Cases) or not entry.in_math] + return [*entries, *empty] + kept = [name for name, entry in entries.items() if isinstance(entry.expression, Cases) or not entry.in_math] + return [*kept, *empty] def definition(self, name: str) -> Line: - """The line defining one named expression, ``symbol = body`` over its frame.""" - body = self.program.expressions[name].expression - frame = self._frame_of(name) - ctx = self._context(frame) - rendered = self.format.cases(self._arms(body, ctx)) if isinstance(body, Cases) else self._expression(body, ctx) + """The line defining one named expression, ``symbol = body`` over its frame, and ``symbol = …`` where it is empty.""" + if name in self.program.expressions: + body = self.program.expressions[name].expression + frame = self._frame_of(name) + ctx = self._context(frame) + rendered = ( + self.format.cases(self._arms(body, ctx)) if isinstance(body, Cases) else self._expression(body, ctx) + ) + else: + frame = list(self.program.given.expressions[name].dims) + ctx = self._context(frame) + rendered = self._op('dots') return Line( label=name, left=ctx.indexed(self.symbols.name[name], frame), @@ -743,8 +753,9 @@ def line(self, name: str) -> Line: one of them. """ program = self.program + empty = {name for name, given in program.given.expressions.items() if given.empty} kinds = { - 'named expression': (program.expressions, self.definition), + 'named expression': ({*program.expressions, *empty}, self.definition), 'constraint': (program.constraints, self._constraint), 'assumption': (program.assumptions, self._assumption), 'curve': (program.piecewise, self._piecewise), diff --git a/tests/test_several_files_page.py b/tests/test_several_files_page.py index 6fdae6be..19cbe70a 100644 --- a/tests/test_several_files_page.py +++ b/tests/test_several_files_page.py @@ -93,4 +93,4 @@ def test_every_python_step_prints_what_the_page_shows(folder): else: assert printed.getvalue().strip() == (expected or '').strip(), f'step {index} printed otherwise' ran += 1 - assert ran == 6, 'every Python block on the page ran' + assert ran == 7, 'every Python block on the page ran' diff --git a/tests/test_terms.py b/tests/test_terms.py index f0f6f48a..6915fc96 100644 --- a/tests/test_terms.py +++ b/tests/test_terms.py @@ -8,8 +8,9 @@ says what it puts there: a named expression of its own, which the `term:` on its `given: expressions:` entry names. The file reads the name as the whole sum, alone and composed. `merge` defines the name as the definition one -fragment writes, if any, plus every term by name, and keeps each term, so -nothing has to declare that the name is a sum. +fragment writes plus every term by name, and keeps each term, so nothing has +to declare that the name is a sum. A definition the terms are all of is +written `expression: null`. """ from __future__ import annotations @@ -28,7 +29,18 @@ typeset_declaration, ) from mathspec.program import Named, Variable, walk -from tests.fixtures import BALANCE, BUS_DIMS, BUS_FRAME, INJECTION +from tests.fixtures import BALANCE as READER +from tests.fixtures import BUS_DIMS, BUS_FRAME, INJECTION + +#: A balance that defines the injection as empty, for the terms to fill, and reads it. +BALANCE = { + 'dimensions': BUS_DIMS, + 'expressions': {'injection': {'dims': BUS_FRAME, 'expression': None, 'description': INJECTION}}, + 'constraints': {'balance': {'dims': BUS_FRAME, 'expression': 'injection == 0'}}, +} + +#: An empty definition and nothing else, for readers to read. +HUB = {'dimensions': BUS_DIMS, 'expressions': {'injection': {'dims': BUS_FRAME, 'expression': None}}} #: A generator fleet: what it puts in is its term. FLEET = { @@ -160,6 +172,59 @@ def test_the_advice_says_the_file_adds_a_term(): assert 'merge()' in note.text, 'merge completes it, not a host model' +# --------------------------------------------------------------------------- +# an empty expression +# --------------------------------------------------------------------------- + + +def test_an_empty_expression_loads_alone_as_a_quantity_over_its_frame(): + """Alone, the file reads the name as it reads a given expression, so its balance is a row on a quantity.""" + program = to_spec(BALANCE).program + empty = program.given.expressions['injection'] + assert empty.empty + assert empty.dims == ('snapshot', 'bus') + assert 'injection' not in program.expressions, 'an empty expression has no body to hold' + assert program.constraints['balance'].lhs == Variable('injection') + + +def test_an_empty_expression_is_written_back_as_null(): + spec = to_spec(BALANCE) + assert spec.to_dict()['expressions']['injection'] == { + 'dims': BUS_FRAME, + 'expression': None, + 'description': INJECTION, + }, 'the null is the definition, not an absence to drop' + assert 'expression: null' in spec.to_yaml(canonical=True) + assert to_spec(spec.to_yaml()) == spec + + +def test_an_empty_expression_draws_no_advice(): + """The null already says that other files fill it; the note for a reading would say another file defines it.""" + assert not advice(BALANCE), 'the file is explicit about everything it leaves to others' + + +def test_an_empty_expression_no_term_fills_stays_empty_once_composed(): + composed = merge({'hub': HUB, 'balance': READER}) + assert composed.expressions['injection'].empty + assert not composed.given, 'the reading is folded into the empty definition' + + +def test_the_sum_keeps_the_frame_of_its_definition(): + """A term narrower than the definition broadcasts along the rest, as the file that defines the name reads it. + + The composed definition once dropped the `dims:` its fragment wrote, so its frame was the terms' alone. + """ + shed = { + 'dimensions': BUS_DIMS, + 'variables': {'shed': {'dims': ['bus']}}, + 'given': {'expressions': {'injection': {'dims': BUS_FRAME, 'term': 'shedding'}}}, + 'expressions': {'shedding': 'shed'}, + } + composed = merge({'balance': BALANCE, 'shed': shed}) + assert composed.expressions['injection'].dims == BUS_FRAME + assert composed.program.expressions['injection'].dims == ('snapshot', 'bus') + + # --------------------------------------------------------------------------- # merge # --------------------------------------------------------------------------- @@ -201,18 +266,23 @@ def test_a_definition_that_is_more_than_a_name_is_bracketed(): assert composed.expressions['injection'].expression == '(slack - slack / 2) + demand_injection' -def test_the_sum_takes_the_readers_description(): +def test_the_sum_keeps_the_description_of_its_empty_definition(): composed = merge({'fleet': FLEET, 'demand': DEMAND, 'balance': BALANCE}) assert composed.program.expressions['injection'].description == INJECTION +def test_the_sum_takes_the_readers_description_where_the_definition_has_none(): + composed = merge({'hub': HUB, 'fleet': FLEET, 'balance': READER}) + assert composed.program.expressions['injection'].description == INJECTION + + def test_two_readers_that_word_the_sum_apart_give_it_the_first_wording_in_name_order(): """The sum once took the wording of whichever reader was passed first.""" - capped = {**BALANCE, 'given': {'expressions': {'injection': {'dims': BUS_FRAME, 'description': 'a cap'}}}} + capped = {**READER, 'given': {'expressions': {'injection': {'dims': BUS_FRAME, 'description': 'a cap'}}}} capped = {**capped, 'constraints': {'capped': {'dims': BUS_FRAME, 'expression': 'injection <= 10'}}} for fragments in ( - {'capped': capped, 'balance': BALANCE, 'fleet': FLEET}, - {'balance': BALANCE, 'capped': capped, 'fleet': FLEET}, + {'capped': capped, 'balance': READER, 'hub': HUB, 'fleet': FLEET}, + {'balance': READER, 'capped': capped, 'hub': HUB, 'fleet': FLEET}, ): assert merge(fragments).expressions['injection'].description == INJECTION, "the wording of 'balance'" @@ -245,10 +315,23 @@ def test_a_cased_term_is_added_like_any_other(): [ pytest.param( {'fleet': FLEET, 'demand': DEMAND}, - r"fragments 'demand' and 'fleet' add a term to 'injection', which no fragment defines, reads or uses\. " - r'.*or fix the spelling\.$', + r"fragments 'demand' and 'fleet' add a term to 'injection', which no fragment defines\. " + r'.*as `expression: null` where the terms are all of it, or fix the spelling\.$', id='terms-and-nothing-else-with-no-near-miss', ), + pytest.param( + {'balance': READER, 'fleet': FLEET}, + r"fragment 'fleet' adds a term to 'injection', which no fragment defines\.", + id='terms-on-a-reading-and-no-definition', + ), + pytest.param( + { + 'fleet': {**FLEET, 'constraints': {'capped': {'dims': BUS_FRAME, 'expression': 'injection <= 10'}}}, + 'demand': DEMAND, + }, + r"fragments 'demand' and 'fleet' add a term to 'injection', which no fragment defines\.", + id='terms-on-a-use-and-no-definition', + ), pytest.param( { 'balance': BALANCE, @@ -260,17 +343,14 @@ def test_a_cased_term_is_added_like_any_other(): ], ) def test_terms_that_land_on_no_name_are_refused(fragments, message): - """Merge fills a reading or extends a definition; it never invents a name, which is what a typo would ask for.""" + """Merge extends a definition; it never invents a name, which is what a typo would ask for. + + A reading or a use once took the terms as the whole definition. An empty definition is written out now. + """ with pytest.raises(LanguageError, match=message): merge(fragments) -def test_a_term_lands_on_a_name_a_contributor_s_own_math_uses(): - capped = {**FLEET, 'constraints': {'capped': {'dims': BUS_FRAME, 'expression': 'injection <= 10'}}} - composed = merge({'fleet': capped, 'demand': DEMAND}) - assert composed.program.expressions['injection'].in_math - - def test_one_term_alone_is_its_name(): composed = merge({'balance': BALANCE, 'storage': STORAGE}) assert composed.expressions['injection'].expression == 'store_injection' @@ -315,7 +395,7 @@ def test_a_cased_definition_a_term_adds_to_is_refused(): def test_two_readers_that_disagree_about_the_frame_are_refused(): - narrow = {**BALANCE, 'given': {'expressions': {'injection': {'dims': ['bus']}}}} + narrow = {**READER, 'given': {'expressions': {'injection': {'dims': ['bus']}}}} narrow = {**narrow, 'constraints': {'balance': {'dims': ['bus'], 'expression': 'injection == 0'}}} with pytest.raises(LanguageError, match=r"say different things about the given expression 'injection'"): merge({'balance': narrow, 'fleet': FLEET}) @@ -341,6 +421,18 @@ def test_a_definition_over_a_dimension_the_readers_do_not_state_is_refused(): merge({'network': wide, 'demand': DEMAND}) +def test_a_reader_over_less_than_the_empty_definition_is_refused(): + wide = { + 'dimensions': {**BUS_DIMS, 'carrier': {'dtype': 'str'}}, + 'expressions': {'injection': {'dims': [*BUS_FRAME, 'carrier'], 'expression': None}}, + } + with pytest.raises( + LanguageError, + match=r"'balance' reads the given expression 'injection' as .*'hub' introduces it over \['bus', 'carrier', 'snapshot'\]", + ): + merge({'hub': wide, 'balance': READER, 'demand': DEMAND}) + + def test_a_patch_changes_a_term_by_its_name_and_null_drops_it(): doubled = override(DEMAND, {'double': {'expressions': {'demand_injection': '-2 * load'}}}) assert doubled.expressions['demand_injection'].expression == '-2 * load' @@ -358,7 +450,7 @@ def test_the_legend_names_the_term_the_file_adds(): assert 'an expression this file adds `demand_injection` to' in given -@pytest.mark.parametrize('spec', [pytest.param(BALANCE, id='a-reader'), pytest.param(FLEET, id='a-contributor')]) +@pytest.mark.parametrize('spec', [pytest.param(READER, id='a-reader'), pytest.param(FLEET, id='a-contributor')]) def test_a_given_expression_prints_no_line_of_its_own(spec): """The term prints as the definition it is; the name it adds to prints in the legend, with or without a term.""" with pytest.raises( @@ -367,6 +459,19 @@ def test_a_given_expression_prints_no_line_of_its_own(spec): typeset_declaration(spec, 'injection', 'latex') +@pytest.mark.parametrize( + ('fmt', 'dots'), [pytest.param('latex', r'= \dots', id='latex'), pytest.param('typst', '= dots.h', id='typst')] +) +def test_an_empty_expression_prints_as_dots(fmt, dots): + assert dots in typeset_declaration(BALANCE, 'injection', fmt) + + +def test_the_legend_lists_an_empty_expression_under_definitions(): + page = to_markdown(BALANCE) + assert '#### Given' not in page, 'the file defines the name, and reads nothing it does not' + assert '`injection` over' in page.split('#### Definitions')[1] + + def test_the_composed_sum_prints_its_terms_by_name(): composed = merge({'fleet': FLEET, 'demand': DEMAND, 'balance': BALANCE}) assert typeset_declaration(composed, 'injection', 'typst', inline_expressions=False) == ( diff --git a/tests/test_validation.py b/tests/test_validation.py index 7fdf2068..06ad556f 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -1815,7 +1815,18 @@ def test_the_fallback_is_written_as_the_bare_value(self): 'this has both', id='both', ), - pytest.param({'description': 'nothing at all'}, 'this has neither', id='neither'), + pytest.param( + {'description': 'nothing at all'}, + 'this has neither. Cases are for a quantity whose value varies by region; one expression is ' + 'everything else, and `expression: null` is an empty one', + id='neither', + ), + pytest.param( + {'expression': None, 'dims': ['snapshot'], 'cases': OPENING, 'otherwise': 0}, + 'this has both', + id='null-beside-cases', + ), + pytest.param({'expression': None}, '`expression: null` needs a `dims:`', id='null-without-dims'), pytest.param({'cases': OPENING, 'otherwise': 0}, '`cases:` needs a `dims:`', id='no-dims'), pytest.param( {'dims': ['snapshot', 'generator'], 'cases': OPENING}, diff --git a/tests/typesetting/golden/latex.out b/tests/typesetting/golden/latex.out index c69496c8..6312ad4e 100644 --- a/tests/typesetting/golden/latex.out +++ b/tests/typesetting/golden/latex.out @@ -63,6 +63,7 @@ \item[{$\mathit{lcoe}$}] \texttt{lcoe} (scalar) \item[{$\mathit{marginal\_price}$}] \texttt{marginal\_price} over $\mathcal{T} \times \mathcal{B}$ \item[{$\mathrm{startup\_cost}$}] \texttt{startup\_cost} over $\mathcal{T} \times \mathcal{G}$ --- what starting a unit in this snapshot costs, which the horizon's edge changes +\item[{$\mathit{imports}$}] \texttt{imports} over $\mathcal{T} \times \mathcal{B}$, empty here: the terms other files add fill it --- what neighbouring areas put into a bus \end{description} \noindent Upright is what the data supplies --- a parameter such as $\mathrm{p}^{\mathrm{max}}$, a coordinate map, a label --- and italic is what the solver chooses, such as $p$. An index is italic too, being what a quantifier chooses, and a set is script. @@ -145,7 +146,8 @@ \text{spend} && \mathit{spend}_{t} & = \sum_{g \in \mathcal{G}} p_{t,g} \cdot \mathrm{cost}_{g} && \forall\, t \in \mathcal{T} \\ \text{lcoe} && \mathit{lcoe} & = \frac{\sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} \cdot \mathrm{cost}_{g}}{\sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g}} \\ \text{marginal\_price} && \mathit{marginal\_price}_{t,b} & = \lambda_{\mathrm{balance},t,b} && \forall\, t \in \mathcal{T},\ b \in \mathcal{B} \\ -\text{startup\_cost} && \mathrm{startup\_cost}_{t,g} & = \begin{cases} \mathrm{cost}_{g} & \text{if } \mathrm{pos}(t) = 0 \\ \mathrm{cost}_{g} \cdot 2 & \text{if } \mathrm{pos}(t) > 0 \wedge \mathrm{season\_of}(t) = \text{'}\mathrm{winter}\text{'} \\ 0 & \text{otherwise} \end{cases} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +\text{startup\_cost} && \mathrm{startup\_cost}_{t,g} & = \begin{cases} \mathrm{cost}_{g} & \text{if } \mathrm{pos}(t) = 0 \\ \mathrm{cost}_{g} \cdot 2 & \text{if } \mathrm{pos}(t) > 0 \wedge \mathrm{season\_of}(t) = \text{'}\mathrm{winter}\text{'} \\ 0 & \text{otherwise} \end{cases} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ +\text{imports} && \mathit{imports}_{t,b} & = \dots && \forall\, t \in \mathcal{T},\ b \in \mathcal{B} \end{align} \paragraph{Variable domains} diff --git a/tests/typesetting/golden/markdown.out b/tests/typesetting/golden/markdown.out index 9d724f0f..87c34d6b 100644 --- a/tests/typesetting/golden/markdown.out +++ b/tests/typesetting/golden/markdown.out @@ -63,6 +63,7 @@ every character a notation escapes, set as text: link\_to, 100% & \#1 costs \$5 | $`\mathit{lcoe}`$ | `lcoe` (scalar) | | $`\mathit{marginal\_price}`$ | `marginal_price` over $`\mathcal{T} \times \mathcal{B}`$ | | $`\mathrm{startup\_cost}`$ | `startup_cost` over $`\mathcal{T} \times \mathcal{G}`$ — what starting a unit in this snapshot costs, which the horizon's edge changes | +| $`\mathit{imports}`$ | `imports` over $`\mathcal{T} \times \mathcal{B}`$, empty here: the terms other files add fill it — what neighbouring areas put into a bus | Upright is what the data supplies — a parameter such as $`\mathrm{p}^{\mathrm{max}}`$, a coordinate map, a label — and italic is what the solver chooses, such as $`p`$. An index is italic too, being what a quantifier chooses, and a set is script. @@ -424,6 +425,12 @@ p_{t,g} \le \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\, t \in \mathcal{T},\ g \mathrm{startup\_cost}_{t,g} = \begin{cases} \mathrm{cost}_{g} & \text{if } \mathrm{pos}(t) = 0 \\ \mathrm{cost}_{g} \cdot 2 & \text{if } \mathrm{pos}(t) > 0 \wedge \mathrm{season\_of}(t) = \text{'}\mathrm{winter}\text{'} \\ 0 & \text{otherwise} \end{cases} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} ``` +**`imports`** + +```math +\mathit{imports}_{t,b} = \dots \qquad \forall\, t \in \mathcal{T},\ b \in \mathcal{B} +``` + #### Variable domains **`p`** diff --git a/tests/typesetting/golden/model.yaml b/tests/typesetting/golden/model.yaml index ebe77903..35bb773d 100644 --- a/tests/typesetting/golden/model.yaml +++ b/tests/typesetting/golden/model.yaml @@ -152,6 +152,10 @@ expressions: opening: { when: "position(snapshot) == 0", expression: cost } winter: { when: "position(snapshot) > 0 and season_of == 'winter'", expression: cost * 2 } otherwise: 0 + imports: # an empty expression: the terms other files add fill it, so its body prints as dots + description: what neighbouring areas put into a bus + dims: [snapshot, bus] + expression: null constraints: budgeted: # names the plain expression: its symbol prints here, its definition once below diff --git a/tests/typesetting/golden/typst.out b/tests/typesetting/golden/typst.out index cd926e14..c55ea526 100644 --- a/tests/typesetting/golden/typst.out +++ b/tests/typesetting/golden/typst.out @@ -52,6 +52,7 @@ every character a notation escapes, set as text: link\_to, 100% & \#1 costs \$5 / $italic("lcoe")$: `lcoe` (scalar) / $italic("marginal_price")$: `marginal_price` over $cal(T) times cal(B)$ / $upright("startup_cost")$: `startup_cost` over $cal(T) times cal(G)$ --- what starting a unit in this snapshot costs, which the horizon's edge changes +/ $italic("imports")$: `imports` over $cal(T) times cal(B)$, empty here: the terms other files add fill it --- what neighbouring areas put into a bus Upright is what the data supplies --- a parameter such as $upright("p")^(upright("max"))$, a coordinate map, a label --- and italic is what the solver chooses, such as $p$. An index is italic too, being what a quantifier chooses, and a set is script. @@ -131,7 +132,8 @@ $ upright("spend_cap") & upright("spend")^(upright("cap"))_(g) & = upright("cost upright("spend") & italic("spend")_(t) & = sum_(g in cal(G)) p_(t,g) dot upright("cost")_(g) & forall t in cal(T) \ upright("lcoe") & italic("lcoe") & = frac(sum_(t in cal(T), g in cal(G)) p_(t,g) dot upright("cost")_(g), sum_(t in cal(T), g in cal(G)) p_(t,g)) \ upright("marginal_price") & italic("marginal_price")_(t,b) & = lambda_(upright("balance"),t,b) & forall t in cal(T), b in cal(B) \ - upright("startup_cost") & upright("startup_cost")_(t,g) & = cases(upright("cost")_(g) & upright("if ") upright("pos")(t) = 0, upright("cost")_(g) dot 2 & upright("if ") upright("pos")(t) > 0 and upright("season_of")(t) = upright("'winter'"), 0 & upright("otherwise")) & forall t in cal(T), g in cal(G) $ + upright("startup_cost") & upright("startup_cost")_(t,g) & = cases(upright("cost")_(g) & upright("if ") upright("pos")(t) = 0, upright("cost")_(g) dot 2 & upright("if ") upright("pos")(t) > 0 and upright("season_of")(t) = upright("'winter'"), 0 & upright("otherwise")) & forall t in cal(T), g in cal(G) \ + upright("imports") & italic("imports")_(t,b) & = dots.h & forall t in cal(T), b in cal(B) $ == Variable domains #set math.equation(numbering: "(1)") diff --git a/tests/typesetting/test_golden.py b/tests/typesetting/test_golden.py index 06bdaac0..93dabd80 100644 --- a/tests/typesetting/test_golden.py +++ b/tests/typesetting/test_golden.py @@ -142,8 +142,8 @@ def _rendered_trees() -> Iterator[object]: yield assumption.predicate.root if assumption.where is not None: yield assumption.where.root - for name in schema.expressions: - yield program.expressions[name].expression + for entry in program.expressions.values(): + yield entry.expression for curve in program.piecewise.values(): yield from (link.expression for link in curve.links) diff --git a/tests/typesetting/test_walk.py b/tests/typesetting/test_walk.py index c9d98895..d3bd0e8d 100644 --- a/tests/typesetting/test_walk.py +++ b/tests/typesetting/test_walk.py @@ -533,7 +533,7 @@ def test_nothing_the_model_is_given_prints_italic(): """The convention as a property of the whole document, not of a fragment: a rendering path added later reaches the page through its own call.""" schema = to_spec(golden.MODEL) - computed = set(schema.variables) | chosen_expressions(schema.program) + computed = set(schema.variables) | set(schema.program.given.expressions) | chosen_expressions(schema.program) italic = {m.replace(r'\_', '_') for m in re.findall(r'\\mathit\{([^}]*)\}', to_latex(golden.MODEL))} assert italic <= computed, ( f'{sorted(italic - computed)} print italic and are neither chosen by the solver nor read off its ' diff --git a/tools/notation.py b/tools/notation.py index 50510746..cfabd5cd 100644 --- a/tools/notation.py +++ b/tools/notation.py @@ -109,6 +109,7 @@ 'capped': 'Named expression in a condition', 'lcoe': 'Reported expression', 'marginal_price': 'Dual of a constraint', + 'imports': 'Empty expression', }, 'Shifts': { 'ramp': 'Cyclic and acyclic shift',