diff --git a/docs/howto/compose.md b/docs/howto/compose.md index 4bf95a19..ea6b938d 100644 --- a/docs/howto/compose.md +++ b/docs/howto/compose.md @@ -107,17 +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 fragment's wording is carried | -| 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 | -| 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 | -| `objective` | the terms are summed in fragment-name order, each in parentheses, and the senses agree | -| `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 model'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 fragment's wording is carried | +| 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 | +| 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 name a `given:` entry marks `additive` | every fragment's declaration of it is a term: the terms are summed in fragment-name order, each in parentheses, and the marked entry is kept | +| `objective` | the terms are summed in fragment-name order, each in parentheses, and the senses agree. The first description 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 model's as `description=` | ## A name two fragments declare diff --git a/docs/reference/language/declarations.md b/docs/reference/language/declarations.md index fdfaf45d..7bb66543 100644 --- a/docs/reference/language/declarations.md +++ b/docs/reference/language/declarations.md @@ -219,16 +219,17 @@ constraints: expression: injection == 0 ``` -| Field | | | -| ------------- | ------------------------------------------------- | -------------- | -| `dims` | required. The dimensions the expression runs over | | -| `description` | free text | default `null` | +| Field | | | +| ------------- | -------------------------------------------------------------------------------------------- | --------------- | +| `dims` | required. The dimensions the expression runs over | | +| `additive` | `true` where the name is a sum other files add terms to ([a sum](#a-sum-other-files-add-to)) | default `false` | +| `description` | free text | default `null` | There is no body. This file reads the name as it reads a given variable: a quantity over the frame, of degree one. A `where` does not read it, because a mask is built before any variable exists. A name declared under both -`expressions:` and `given: expressions:` is refused. The typeset legend lists a -given expression under _Given_. +`expressions:` and `given: expressions:` is refused, unless the entry is +marked `additive`. The typeset legend lists a given expression under _Given_. [`merge`](../../howto/compose.md#a-library-of-components) folds a given expression into the definition of another fragment. The `dims` are an upper @@ -239,6 +240,63 @@ term carries it. The composed model holds the body to the rules of every place this file reads it: a square of a given expression that is quadratic is refused once folded. +#### A sum other files add to + +`additive: true` says the name is a sum other files add terms to. One file +says it, on its `given:` entry, with the frame. Each file that adds a term +declares it as an ordinary named expression under that name. + +```yaml +# balance.yaml reads the sum +dimensions: + snapshot: { dtype: int } + bus: { dtype: str } +given: + expressions: + injection: + dims: [snapshot, bus] + additive: true + description: what the components put into a bus +variables: + slack: { dims: [snapshot, bus] } +constraints: + balance: + dims: [snapshot, bus] + expression: injection + slack == 0 +``` + +```yaml +# fleet.yaml adds a term +dimensions: + snapshot: { dtype: int } + bus: { dtype: str } + generator: { dtype: str } +relations: + gen_bus: { key: generator, values: bus } +variables: + gen_p: { dims: [snapshot, generator], bounds: { lower: 0 } } +expressions: + injection: sum(gen_p, by=gen_bus, over=generator, into=bus) +``` + +Each file loads alone: the reader over a sum it does not build, and the +contributor over its own term. Other readers state the frame and nothing more. + +A file may carry the marked entry and declare a term too. Then it reads the +sum so far, which alone is its own term. The term is one `expression:`, and +carries no dimension the entry does not state; both are checked at load. The +entry then folds into the definition, and the typeset legend lists it under +_Definitions_, as a sum other files add terms to. + +[`merge`](../../howto/compose.md#a-library-of-components) sums every term of a +marked name, each in parentheses, in fragment-name order, and keeps the marked +entry, so a later merge adds more. The entry's description is the sum's. It +refuses a term written as `cases:`, a term over a dimension the entry does not +state, and a file that declares a term and reads the name without carrying the +marked entry: on its own that file reads its term, and composed it would read +the sum. Two terms of a name no entry marks are refused as a collision. A +marked name no file adds to stays under `given:`. + ## `constraints` One block is one rule. The name of the block is the name of the constraint. diff --git a/docs/reference/reading.md b/docs/reference/reading.md index f3ef47d3..05ef4b14 100644 --- a/docs/reference/reading.md +++ b/docs/reference/reading.md @@ -169,6 +169,9 @@ reads and does not build ([given](language/declarations.md#given)). Every other group is a build instruction. These four are names to look up in the model this one is layered onto. An expression reads a given expression as a `Variable` of that name, over the frame under `program.given.expressions`. +A given expression with `additive` set is a sum other files add terms to. +Where the file also declares a term of it, the name is a definition with +`additive` set under `program.expressions`, and not a given name. ```python layer = to_spec( diff --git a/schema/math-spec.schema.json b/schema/math-spec.schema.json index 3095efeb..519ac1e7 100644 --- a/schema/math-spec.schema.json +++ b/schema/math-spec.schema.json @@ -342,8 +342,13 @@ }, "GivenExpressionBlock": { "additionalProperties": false, - "description": "A named expression this file reads and another file defines.\n\nThe frame is all this file states. This file reads the name as a quantity\nover that frame, affine in the columns, as it reads a given variable: the\nbody is the definer's, and the composed model holds the body to the rules\nof every place this file reads it.", + "description": "A named expression this file reads and another file defines.\n\nThe frame is all this file states. This file reads the name as a quantity\nover that frame, affine in the columns, as it reads a given variable: the\nbody is the definer's, and the composed model holds the body to the rules\nof every place this file reads it.\n\n``additive: true`` says the name is a sum other files add terms to. Each\nof them declares its term as an ordinary named expression under the name,\nand :func:`~math_spec.composition.merge` sums the terms. A file that marks\nthe name may declare a term of its own too, and then reads the sum so far.", "properties": { + "additive": { + "default": false, + "title": "Additive", + "type": "boolean" + }, "description": { "anyOf": [ { diff --git a/src/math_spec/composition.py b/src/math_spec/composition.py index e6fdfe6a..1dcac3db 100644 --- a/src/math_spec/composition.py +++ b/src/math_spec/composition.py @@ -21,6 +21,14 @@ both named. * **The objectives are summed**, each term in parentheses, in the fragments' name order, and the senses have to agree. +* **A sum is summed the same way.** A ``given: expressions:`` entry marked + ``additive: true`` says the name is a sum other files add terms to. Every + fragment's declaration of it is a term, and the composed model declares their + sum and keeps the marked entry, so a later merge adds more. A term may carry + no dimension the entry does not state, and is one ``expression:``. A + fragment that declares a term and reads the name without carrying the marked + entry is refused: on its own it reads its term, and composed it would read + the sum. * **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, @@ -73,6 +81,7 @@ from math_spec._yaml import read_model from math_spec.errors import LanguageError, did_you_mean, schema_error from math_spec.model import GivenBlock, Spec +from math_spec.program import Named, walk from math_spec.validation import to_spec if TYPE_CHECKING: @@ -147,10 +156,15 @@ def merge(fragments: Mapping[str, str | Path | Mapping[str, object] | Spec], des for section in SHARED_SECTIONS: if agreed := _agreed(read, section, _singular(section)): merged[section] = agreed + asked = {name: _mapping(sections.get('given')) for name, sections in read.items()} + readings = _agreed_readings(asked) + sums = {key: entry for key, entry in readings.items() if entry['additive']} for section in OWNED_SECTIONS: - if claimed := _claimed(read, section): + if claimed := _claimed(read, section, sums if section == 'expressions' else {}): merged[section] = claimed - if given := _folded(read, merged, _frames(loaded)): + if summed := _summed_terms(read, loaded, sums): + merged['expressions'] = {**_mapping(merged.get('expressions')), **summed} + if given := _folded(read, merged, loaded, readings): merged['given'] = given if (objective := _summed_objective(read)) is not None: merged['objective'] = objective @@ -212,36 +226,132 @@ def _claims(block: object) -> object: return {key: value for key, value in block.items() if key != 'description'} if isinstance(block, dict) else block -def _claimed(read: Mapping[str, dict[str, object]], section: str) -> dict[str, object]: - """One block of owned declarations, a name claimed twice being the refusal.""" +def _claimed(read: Mapping[str, dict[str, object]], section: str, sums: Mapping[str, object]) -> dict[str, object]: + """One block of owned declarations, a name claimed twice being the refusal. + + The terms of a sum are left to :func:`_summed_terms`. + """ merged: dict[str, object] = {} for name, sections in read.items(): for key, block in _mapping(sections.get(section)).items(): + if key in sums: + continue if key in merged: + hint = ( + ' If each fragment adds a term to one sum, mark the name `additive: true` on a ' + '`given: expressions:` entry in the file that reads it.' + if section == 'expressions' + else '' + ) raise LanguageError( f"fragments '{_author_of(read, section, key)}' and '{name}' both declare the " f'{_singular(section)} {key!r}. Two of the same kind of thing are two rows of a dimension ' f'rather than two fragments: merge the fragment once, and let the data carry both. ' - f'Different math under one spelling is a rename: call one of them something else.' + f'Different math under one spelling is a rename: call one of them something else.{hint}' ) merged[key] = block return merged -def _frames(loaded: Mapping[str, Spec]) -> dict[str, tuple[str, ...]]: - """The frame of every named expression the fragments define, as each fragment's own program reads it. +def _agreed_readings(asked: Mapping[str, dict[str, object]]) -> dict[str, dict[str, object]]: + """Every ``given: expressions:`` entry, the ones two fragments share folded together. + + Two readings of one name agree on the frame, compared as a set. Prose is + not a claim, and the flag is said once for every reader, so the first + description and any reader's flag are carried. + """ + agreed: dict[str, dict[str, object]] = {} + for name, given in asked.items(): + for key, block in _mapping(given.get('expressions')).items(): + entry = cast('dict[str, object]', block) + if key not in agreed: + agreed[key] = dict(entry) + continue + held = agreed[key] + if set(cast('list[str]', held['dims'])) != set(cast('list[str]', entry['dims'])): + raise LanguageError( + f"fragments '{_author_of(asked, 'expressions', key)}' and '{name}' say different things " + f'about the given expression {key!r}: over {held["dims"]} against over {entry["dims"]}. Two ' + f'files read one name over one frame: make the two identical.' + ) + held['additive'] = bool(held.get('additive') or entry.get('additive')) + held['description'] = held.get('description') or entry.get('description') + return agreed + - A definition writes no frame: its body carries one. The body is the same - text in the composition, so the frame the fragment reads is the one a - given declaration is checked against. +def _summed_terms( + read: Mapping[str, dict[str, object]], loaded: Mapping[str, Spec], sums: Mapping[str, dict[str, object]] +) -> dict[str, object]: + """Every marked sum, its terms summed in the fragments' name order, each in parentheses. + + One term is carried as written. A term written as ``cases:``, a term over a + dimension the marked entry does not state, and a fragment that declares a + term and reads the name without carrying the marked entry are refused. """ - return { - name: declaration.dims for spec in loaded.values() for name, declaration in spec.program.expressions.items() - } + summed: dict[str, object] = {} + for key, entry in sums.items(): + terms = [ + (name, _as_mapping(_mapping(sections.get('expressions'))[key])) + for name, sections in sorted(read.items()) + if key in _mapping(sections.get('expressions')) + ] + for name, block in terms: + _one_term(key, entry, name, block, loaded[name]) + bodies = [cast('str', block['expression']) for _, block in terms] + if not bodies: + continue + term: dict[str, object] = { + 'expression': bodies[0] if len(bodies) == 1 else ' + '.join(f'({body})' for body in bodies) + } + said = next((b['description'] for _, b in terms if b.get('description')), None) + if said is not None and not entry.get('description'): + term['description'] = said + summed[key] = term + return summed + + +def _as_mapping(block: object) -> dict[str, object]: + """A named expression as ``to_dict`` wrote it, the one-line form read as its mapping.""" + return cast('dict[str, object]', block) if isinstance(block, dict) else {'expression': block} + + +def _one_term(key: str, entry: Mapping[str, object], name: str, block: Mapping[str, object], spec: Spec) -> None: + """Refuse a term of the sum *key* that cannot be summed as written, or that its own file misreads.""" + if block.get('cases'): + raise LanguageError( + f"fragment '{name}' adds a term to {key!r} written as `cases:`. The terms of a sum are summed as " + f'written, and a set of cases is no one body: name the cased term as its own expression in ' + f"'{name}', and write that name as the term." + ) + stated = cast('list[str]', entry['dims']) + frame = spec.program.expressions[key].dims + if extra := sorted(set(frame) - set(stated)): + raise LanguageError( + f"fragment '{name}' adds a term to {key!r} over {sorted(frame)}, where the sum is over " + f'{sorted(stated)}. A term carries no dimension its sum does not state: add {extra} to the ' + f'dims of the marked `given:` entry, or leave them out of the term.' + ) + marks = key in spec.given.expressions and spec.given.expressions[key].additive + if not marks and _reads(spec, key): + raise LanguageError( + f"fragment '{name}' adds a term to {key!r} and reads it. On its own the fragment reads its term, " + f'and composed it would read the sum of every term: mark {key!r} `additive: true` under ' + f"'given: expressions:' in '{name}' to read the sum, or read it in another file." + ) + + +def _reads(spec: Spec, key: str) -> bool: + """Whether the math or another named expression of *spec* names *key*.""" + program = spec.program + others = [entry.expression for name, entry in program.expressions.items() if name != key] + return any(isinstance(node, Named) and node.name == key for node in walk(*program.roots, *others)) def _folded( - read: Mapping[str, dict[str, object]], merged: Mapping[str, object], frames: Mapping[str, tuple[str, ...]] + read: Mapping[str, dict[str, object]], + merged: Mapping[str, object], + loaded: Mapping[str, Spec], + readings: Mapping[str, dict[str, object]], ) -> dict[str, object]: """The ``given:`` block the composition still carries, once every reading a sibling introduces is spent. @@ -249,17 +359,20 @@ def _folded( Where the sibling is in the composition the expectation is checked and then dropped, so the composed model declares the name once. A given expression is checked against the frame of the definition's body: the body - carries no dimension the reader does not state. + carries no dimension the reader does not state. A marked entry is kept + beside the sum, so the composed model still says the name is one. """ asked = {name: _mapping(sections.get('given')) for name, sections in read.items()} left: dict[str, object] = {} for kind, label in GIVEN_KINDS.items(): introduced = _mapping(merged.get(kind)) - agreed = _agreed(asked, kind, label) + agreed = readings if kind == 'expressions' else _agreed(asked, kind, label) for key, block in agreed.items(): _same_kind(asked, read, merged, kind, key) + if kind == 'expressions' and _mapping(block).get('additive'): + continue if key in introduced and kind == 'expressions': - _within_frame(asked, read, key, block, frames[key]) + _within_frame(asked, read, key, block, _definer_frame(loaded, key)) elif key in introduced and not _says_less(block, introduced[key]): raise LanguageError( f"fragment '{_author_of(asked, kind, key)}' reads the {label} {key!r} as {block!r}, where " @@ -267,11 +380,21 @@ def _folded( f'says the same as the declaration it is folded into, or less: restate the frame as the ' f'introducer declares it, or leave the field out.' ) - if remaining := {key: block for key, block in agreed.items() if key not in introduced}: - left[kind] = remaining + kept = { + key: block + for key, block in agreed.items() + if key not in introduced or (kind == 'expressions' and _mapping(block).get('additive')) + } + if kept: + left[kind] = kept return left +def _definer_frame(loaded: Mapping[str, Spec], key: str) -> tuple[str, ...]: + """The frame of the one definition of *key*, as the fragment that writes it reads it.""" + return next(spec.program.expressions[key].dims for spec in loaded.values() if key in spec.program.expressions) + + #: The given kinds whose names share the flat namespace an expression reads. #: A row family is named only in ``dual()``, apart from it. READ_KINDS = ('parameters', 'variables', 'expressions') @@ -332,7 +455,8 @@ def _summed_objective(read: Mapping[str, dict[str, object]]) -> dict[str, object """Every fragment's objective summed, each term in parentheses, or ``None`` where none declares one. The terms are summed in the fragments' name order, so the order they were - passed in does not reach the expression. The senses have to agree: a sum has + passed in does not reach the expression. The first description in that + order is carried, as a shared dimension's is. The senses have to agree: a sum has one sense, and negating the odd one out would be this function deciding what a model means. """ @@ -347,9 +471,13 @@ def _summed_objective(read: Mapping[str, dict[str, object]]) -> dict[str, object f'one objective and one sense, so write every fragment against the same one: negate the terms ' f'of the odd one out rather than its sense.' ) - terms = [objective['expression'] for _, objective in sorted(declared.items())] + ordered = [objective for _, objective in sorted(declared.items())] + terms = [objective['expression'] for objective in ordered] joined = terms[0] if len(terms) == 1 else ' + '.join(f'({term})' for term in terms) - return {'sense': next(iter(senses.values())), 'expression': joined} + summed: dict[str, object] = {'sense': next(iter(senses.values())), 'expression': joined} + if description := next((o['description'] for o in ordered if o.get('description')), None): + summed['description'] = description + return summed def override( diff --git a/src/math_spec/dimensions.py b/src/math_spec/dimensions.py index 7dcef60b..3240b726 100644 --- a/src/math_spec/dimensions.py +++ b/src/math_spec/dimensions.py @@ -292,6 +292,16 @@ def check_schema(schema: Spec, program: Program) -> None: _check_where_dims(region.when, frame, context) _check_value_dims(region.value, schema, frame, context) + for ename, sum_entry in schema.given.expressions.items(): + if not (sum_entry.additive and ename in program.expressions): + continue + if extra := sorted(set(program.expressions[ename].dims) - set(sum_entry.dims)): + raise DimensionError( + f"Named expression '{ename}' carries {extra}, which the sum it adds a term to does not: " + f"its `given:` entry states {sum_entry.dims}. Add {extra} to the entry's dims, or leave " + f'them out of the term.' + ) + for cname, constraint in program.constraints.items(): frame = frozenset(constraint.dims) context = f"Constraint '{cname}'" diff --git a/src/math_spec/lowering.py b/src/math_spec/lowering.py index 347d8222..89e9f379 100644 --- a/src/math_spec/lowering.py +++ b/src/math_spec/lowering.py @@ -20,6 +20,7 @@ from math_spec.dimensions import check_schema, dims_of from math_spec.errors import SchemaError, prefixed from math_spec.expansion import expand, parse_template +from math_spec.model import defined_sums from math_spec.piecewise import assumptions_of, curve_frame, lp_domain_refusal, resolve_links from math_spec.program import ( Assumption, @@ -197,6 +198,7 @@ def lower(schema: Spec) -> Program: assert assumption is not None and not errors, 'what a method assumes is stated in the language' assumptions[aname] = assumption + sums = defined_sums(schema) program = Program( parameters={ name: ParameterDeclaration(tuple(pdef.dims), pdef.dtype, pdef.description) @@ -220,7 +222,9 @@ def lower(schema: Spec) -> Program: entry.body, _frame_of(name, entry, schema), in_math=name in in_math, - description=schema.expressions[name].description, + description=schema.expressions[name].description + or (schema.given.expressions[name].description if name in sums else None), + additive=name in sums, ) for name, entry in entries.items() }, @@ -236,7 +240,9 @@ 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) for name, g in schema.given.expressions.items() + name: GivenDeclaration(tuple(g.dims), g.description, additive=g.additive) + for name, g in schema.given.expressions.items() + if name not in sums }, ), description=schema.description, diff --git a/src/math_spec/model.py b/src/math_spec/model.py index 3d34f1f3..77979c89 100644 --- a/src/math_spec/model.py +++ b/src/math_spec/model.py @@ -333,11 +333,18 @@ class GivenExpressionBlock(_StrictBlock): over that frame, affine in the columns, as it reads a given variable: the body is the definer's, and the composed model holds the body to the rules of every place this file reads it. + + ``additive: true`` says the name is a sum other files add terms to. Each + of them declares its term as an ordinary named expression under the name, + and :func:`~math_spec.composition.merge` sums the terms. A file that marks + the name may declare a term of its own too, and then reads the sum so far. """ _label: ClassVar[str] = 'a given expression declaration' dims: list[str] + #: Whether the name is a sum other files add terms to. + additive: bool = False description: str | None = None @@ -977,6 +984,15 @@ def _lower(self) -> Spec: return self +def defined_sums(schema: Spec) -> frozenset[str]: + """The names *schema* marks ``additive`` under ``given: expressions:`` and declares a term of itself. + + Such a name is defined in the file, and reads as the sum so far: it is not + a name the file reads from elsewhere, so it joins no given group. + """ + return frozenset(name for name, g in schema.given.expressions.items() if g.additive and name in schema.expressions) + + def _formulations(asked: tuple[str, ...]) -> tuple[Formulation, ...]: """What *asked* names, in :data:`FORMULATIONS` order — all of them where it names none. diff --git a/src/math_spec/program.py b/src/math_spec/program.py index 88278419..f136e893 100644 --- a/src/math_spec/program.py +++ b/src/math_spec/program.py @@ -622,6 +622,8 @@ class GivenDeclaration: dims: tuple[str, ...] description: str | None = None + #: A given expression marked as a sum other files add terms to. + additive: bool = False @dataclass(frozen=True) @@ -713,6 +715,9 @@ class ExpressionDeclaration: dims: tuple[str, ...] in_math: bool description: str | None = None + #: A sum other files add terms to: the file marks the name so under + #: ``given: expressions:``, and this body is the sum so far. + additive: bool = False @dataclass(frozen=True) diff --git a/src/math_spec/resolution.py b/src/math_spec/resolution.py index 7943c48b..40ea113e 100644 --- a/src/math_spec/resolution.py +++ b/src/math_spec/resolution.py @@ -35,6 +35,7 @@ from math_spec.errors import LanguageError, SchemaError, case_context, prefixed from math_spec.exclusivity import overlapping from math_spec.expansion import expand, parse_and_expand +from math_spec.model import defined_sums from math_spec.program import ( BooleanLiteral, Cases, @@ -86,7 +87,9 @@ def __init__(self, schema: Spec) -> None: #: dim-checked against, since macros, named expressions and the dim #: rules read declarations the flat listing below does not carry. self.schema = schema - variables = {**schema.variables, **schema.given.variables, **schema.given.expressions} + defined = defined_sums(schema) + given = {name: g for name, g in schema.given.expressions.items() if name not in defined} + variables = {**schema.variables, **schema.given.variables, **given} parameters = {**schema.parameters, **schema.given.parameters} #: Every name an expression reads as a column: the variables, and the #: given expressions, whose bodies another file holds. diff --git a/src/math_spec/typesetting/legend.py b/src/math_spec/typesetting/legend.py index 1a8d3ff2..cfe38035 100644 --- a/src/math_spec/typesetting/legend.py +++ b/src/math_spec/typesetting/legend.py @@ -171,7 +171,8 @@ def glossaries(self, noticed: Noticed, defined: Iterable[str]) -> list[tuple[str *( self._entry( self.symbols.name[g], - f'{fmt.mono(g)}{self._over(list(block.dims))}, an expression another file defines', + f'{fmt.mono(g)}{self._over(list(block.dims))}, ' + + ('a sum other files add terms to' if block.additive else 'an expression another file defines'), block.description, ) for g, block in program.given.expressions.items() @@ -187,7 +188,12 @@ 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) + self._entry( + self.symbols.name[e], + f'{fmt.mono(e)}{self._over(list(block.dims))}' + + (', a sum other files add terms to' if block.additive else ''), + block.description, + ) for e, block in program.expressions.items() if e in shown ] diff --git a/src/math_spec/validation.py b/src/math_spec/validation.py index 5b7079c0..b052f59a 100644 --- a/src/math_spec/validation.py +++ b/src/math_spec/validation.py @@ -24,7 +24,7 @@ from math_spec._yaml import read_model from math_spec.errors import SchemaError -from math_spec.model import NUMERIC_DTYPES, Spec, side_columns +from math_spec.model import NUMERIC_DTYPES, Spec, defined_sums, side_columns from math_spec.operators import BUILTIN_NAMES from math_spec.piecewise import Emitted as EmittedCurve from math_spec.sos import Emitted as EmittedSet @@ -89,6 +89,7 @@ def reference_errors(schema: Spec) -> list[str]: *_sos_bounds(schema), *_piecewise_references(schema), *_given_constraint_collisions(schema), + *_cased_terms(schema), ] @@ -107,7 +108,7 @@ def _flat_namespace(schema: Spec) -> list[tuple[str, Iterable[str]]]: ('variable', schema.variables), ('given variable', schema.given.variables), ('named expression', schema.expressions), - ('given expression', schema.given.expressions), + ('given expression', {n: g for n, g in schema.given.expressions.items() if n not in defined_sums(schema)}), ('macro', schema.macros), ] @@ -146,6 +147,17 @@ def _given_constraint_collisions(schema: Spec) -> Iterator[str]: ) +def _cased_terms(schema: Spec) -> Iterator[str]: + """A term of a sum is one expression, since the terms are summed as written.""" + for name in sorted(defined_sums(schema)): + if schema.expressions[name].cases: + yield ( + f"Named expression '{name}': a term of a sum is one `expression:`, and this has `cases:`. " + f'The terms are summed as written: name the cased term as its own expression, and write ' + f'that name as the term.' + ) + + def _frame_dimensions(schema: Spec) -> Iterator[str]: """Every frame is a product of distinct, declared dimensions.""" frames = [ diff --git a/tests/test_additive.py b/tests/test_additive.py new file mode 100644 index 00000000..d41c85ca --- /dev/null +++ b/tests/test_additive.py @@ -0,0 +1,254 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""A named expression several fragments add terms to. + +A balance reads what every component puts into a bus, and a component file +says what it puts there. The file that reads the total marks it +`additive: true` on its `given:` entry, and states its frame once. Each +component declares its term as an ordinary named expression under that name, +and `merge` sums the terms. The marked entry stays in the composed model, so a +later merge adds more. +""" + +from __future__ import annotations + +import pytest + +from math_spec import FORMATS, LanguageError, merge, override, to_markdown, to_spec, typeset + +DIMS = {'snapshot': {'dtype': 'int'}, 'bus': {'dtype': 'str'}} +INJECTION = 'what the components put into a bus' + +#: The balance: it says `injection` is a sum over its frame, and adds nothing to it. +BALANCE = { + 'dimensions': DIMS, + 'given': {'expressions': {'injection': {'dims': ['snapshot', 'bus'], 'additive': True, 'description': INJECTION}}}, + 'constraints': {'balance': {'dims': ['snapshot', 'bus'], 'expression': 'injection == 0'}}, +} + +#: A generator fleet: its term is an ordinary named expression. +FLEET = { + 'dimensions': {**DIMS, 'generator': {'dtype': 'str'}}, + 'relations': {'gen_bus': {'key': 'generator', 'values': 'bus'}}, + 'variables': {'gen_p': {'dims': ['snapshot', 'generator'], 'bounds': {'lower': 0}}}, + 'expressions': {'injection': 'sum(gen_p, by=gen_bus, over=generator, into=bus)'}, + 'objective': {'sense': 'minimize', 'expression': 'sum(gen_p)'}, +} + +#: A demand, subtracting what it draws. +DEMAND = { + 'dimensions': DIMS, + 'parameters': {'load': {'dims': ['snapshot', 'bus']}}, + 'expressions': {'injection': '-load'}, +} + +#: A store, added in a second merge. +STORAGE = { + 'dimensions': {**DIMS, 'store': {'dtype': 'str'}}, + 'relations': {'store_bus': {'key': 'store', 'values': 'bus'}}, + 'variables': {'store_p': {'dims': ['snapshot', 'store']}}, + 'expressions': {'injection': 'sum(store_p, by=store_bus, over=store, into=bus)'}, +} + +#: A reader that states the frame and does not say the name is a sum. +CAPPED = { + 'dimensions': DIMS, + 'given': {'expressions': {'injection': {'dims': ['snapshot', 'bus']}}}, + 'constraints': {'capped': {'dims': ['snapshot', 'bus'], 'expression': 'injection <= 10'}}, +} + + +# --------------------------------------------------------------------------- +# one file +# --------------------------------------------------------------------------- + + +def test_a_reader_marks_the_sum_and_loads_on_its_own(): + program = to_spec(BALANCE).program + assert program.given.expressions['injection'].additive + assert program.given.expressions['injection'].dims == ('snapshot', 'bus') + + +def test_a_term_is_an_ordinary_named_expression(): + program = to_spec(FLEET).program + assert not program.expressions['injection'].additive, 'nothing in a plain contributor says it is a term' + + +#: A file that adds a term of its own and reads the sum: it carries both. +HUB = { + **FLEET, + 'given': {'expressions': {'injection': {'dims': ['snapshot', 'bus'], 'additive': True}}}, + 'constraints': {'capped': {'dims': ['snapshot', 'bus'], 'expression': 'injection <= 10'}}, +} + + +def test_a_file_may_add_a_term_and_read_the_sum_when_it_marks_it(): + """The marked entry says the file reads the sum so far, which alone is its own term.""" + program = to_spec(HUB).program + assert program.expressions['injection'].additive, 'the entry folds into the definition' + assert not program.given, 'a name this file defines is not one it reads from elsewhere' + + +def test_a_term_over_a_dimension_the_sum_does_not_state_is_refused_at_load(): + narrow = {**HUB, 'given': {'expressions': {'injection': {'dims': ['bus'], 'additive': True}}}} + narrow = { + **narrow, + 'constraints': {'capped': {'dims': ['bus'], 'expression': 'sum(injection, over=snapshot) <= 10'}}, + } + with pytest.raises(LanguageError, match=r"Named expression 'injection' carries \['snapshot'\]"): + to_spec(narrow) + + +def test_a_cased_term_beside_the_marked_entry_is_refused_at_load(): + cased = { + **DEMAND, + 'given': {'expressions': {'injection': {'dims': ['snapshot', 'bus'], 'additive': True}}}, + 'expressions': { + 'injection': { + 'dims': ['snapshot', 'bus'], + 'cases': {'peak': {'when': 'load > 5', 'expression': '-load'}}, + 'otherwise': '0', + } + }, + } + with pytest.raises(LanguageError, match=r'a term of a sum is one `expression:`'): + to_spec(cased) + + +# --------------------------------------------------------------------------- +# merge +# --------------------------------------------------------------------------- + + +def test_merging_sums_every_term_of_a_marked_name_in_fragment_name_order(): + composed = merge({'fleet': FLEET, 'demand': DEMAND, 'balance': BALANCE}) + assert composed.expressions['injection'].expression == ( + '(-load) + (sum(gen_p, by=gen_bus, over=generator, into=bus))' + ) + assert composed.given.expressions['injection'].additive, 'the marked entry is kept' + assert not composed.program.given, 'and it folds into the definition, so the composed model reads nothing' + assert composed.program.expressions['injection'].additive + + +def test_the_order_the_fragments_are_given_in_does_not_reach_the_sum(): + one = merge({'fleet': FLEET, 'demand': DEMAND, 'balance': BALANCE}) + other = merge({'balance': BALANCE, 'demand': DEMAND, 'fleet': FLEET}) + assert one == other + + +def test_a_composed_model_takes_more_terms_in_a_second_merge(): + """The kept entry is what lets a framework ship a composed model that a project adds to.""" + shipped = merge({'balance': BALANCE, 'demand': DEMAND, 'fleet': FLEET}) + extended = merge({'shipped': shipped, 'storage': STORAGE}) + assert 'store_p' in extended.expressions['injection'].expression + assert 'gen_p' in extended.expressions['injection'].expression + + +def test_the_sum_takes_the_readers_description(): + composed = merge({'fleet': FLEET, 'demand': DEMAND, 'balance': BALANCE}) + assert composed.program.expressions['injection'].description == INJECTION + + +def test_two_terms_and_no_marked_reader_collide_and_the_message_names_the_fix(): + with pytest.raises(LanguageError) as raised: + merge({'fleet': FLEET, 'demand': DEMAND}) + message = str(raised.value) + assert "both declare the expression 'injection'" in message + assert '`additive: true` on a `given: expressions:` entry' in message, 'the collision says how to make it a sum' + + +def test_one_term_and_an_unmarked_reader_is_an_ordinary_fold(): + composed = merge({'fleet': FLEET, 'capped': CAPPED}) + assert not composed.given + assert not composed.program.expressions['injection'].additive + + +def test_one_reader_marks_the_sum_and_another_states_the_frame(): + composed = merge({'balance': BALANCE, 'capped': CAPPED, 'demand': DEMAND, 'fleet': FLEET}) + assert composed.given.expressions['injection'].additive, 'the flag comes from either entry' + assert sorted(composed.constraints) == ['balance', 'capped'] + + +def test_two_readers_that_disagree_about_the_frame_are_refused(): + narrow = {**CAPPED, 'given': {'expressions': {'injection': {'dims': ['bus']}}}} + narrow = {**narrow, 'constraints': {'capped': {'dims': ['bus'], 'expression': 'injection <= 10'}}} + with pytest.raises(LanguageError, match=r"say different things about the given expression 'injection'"): + merge({'balance': BALANCE, 'capped': narrow, 'fleet': FLEET}) + + +def test_a_term_over_a_dimension_the_sum_does_not_state_is_refused_by_merge(): + narrow = { + 'dimensions': DIMS, + 'given': {'expressions': {'injection': {'dims': ['bus'], 'additive': True}}}, + 'variables': {'slack': {'dims': ['bus']}}, + 'constraints': {'balance': {'dims': ['bus'], 'expression': 'injection + slack == 0'}}, + } + with pytest.raises(LanguageError, match=r"'fleet' adds a term to 'injection' over \['bus', 'snapshot'\]"): + merge({'balance': narrow, 'demand': {**DEMAND, 'parameters': {'load': {'dims': ['bus']}}}, 'fleet': FLEET}) + + +@pytest.mark.parametrize( + 'others', + [ + pytest.param({'demand': DEMAND}, id='beside-another-term'), + pytest.param({}, id='as-the-only-term'), + ], +) +def test_a_contributor_that_reads_the_sum_without_the_entry_is_refused(others): + """Alone it reads its own term; composed it would read the sum, so the file would mean two things.""" + reads_itself = {**FLEET, 'constraints': {'capped': {'dims': ['snapshot', 'bus'], 'expression': 'injection <= 10'}}} + with pytest.raises(LanguageError, match=r"'fleet' adds a term to 'injection' and reads it"): + merge({'balance': BALANCE, 'fleet': reads_itself, **others}) + + +def test_a_cased_contributor_is_refused_by_merge(): + cased = { + **DEMAND, + 'expressions': { + 'injection': { + 'dims': ['snapshot', 'bus'], + 'cases': {'peak': {'when': 'load > 5', 'expression': '-load'}}, + 'otherwise': '0', + } + }, + } + assert to_spec(cased), 'nothing in the file alone says it is a term' + with pytest.raises(LanguageError, match=r"'demand' adds a term to 'injection' written as `cases:`"): + merge({'balance': BALANCE, 'demand': cased, 'fleet': FLEET}) + + +def test_a_marked_sum_nothing_adds_to_stays_under_given(): + """Zero would leave the balance a row with no variable, which the language refuses.""" + composed = merge({'balance': BALANCE, 'other': {'dimensions': DIMS}}) + assert composed.program.given.expressions['injection'].additive + + +def test_a_patch_replaces_a_term_rather_than_adding_one(): + """`override` edits what is there; a new term is a new fragment for `merge`.""" + laid = override(DEMAND, {'double': {'expressions': {'injection': '-2 * load'}}}) + assert laid.expressions['injection'].expression == '-2 * load' + + +# --------------------------------------------------------------------------- +# printing +# --------------------------------------------------------------------------- + + +def test_the_reader_s_legend_says_other_files_add_to_it(): + given = to_markdown(BALANCE).split('#### Given')[1] + assert 'a sum other files add terms to' in given + + +def test_the_composed_legend_says_other_files_add_to_it(): + definitions = to_markdown(merge({'fleet': FLEET, 'demand': DEMAND, 'balance': BALANCE})).split('#### Definitions')[ + 1 + ] + assert 'a sum other files add terms to' in definitions + + +@pytest.mark.parametrize('fmt', sorted(FORMATS)) +def test_a_reader_and_a_composition_print_in_every_format(fmt): + assert typeset(BALANCE, fmt), f'{fmt} rendered nothing for the reader' + assert typeset(merge({'fleet': FLEET, 'demand': DEMAND, 'balance': BALANCE}), fmt), f'{fmt}: the composition' diff --git a/tests/test_composition.py b/tests/test_composition.py index c8f3d098..f94884d0 100644 --- a/tests/test_composition.py +++ b/tests/test_composition.py @@ -178,6 +178,15 @@ def test_the_objectives_are_summed_each_term_parenthesised(): ) +def test_a_composed_objective_keeps_the_first_description_a_fragment_gives_it(): + """Prose, as on a shared dimension: the first fragment's wording is carried, and none is lost.""" + said = {**SUPPLY, 'objective': {**SUPPLY['objective'], 'description': 'what running the fleet costs'}} + priced = {**DEMAND, 'objective': {'sense': 'minimize', 'expression': 'sum(dem_load) * 2'}} + composed = merge({'surface': SURFACE, 'supply': said, 'demand': priced}) + assert composed.objective is not None + assert composed.objective.description == 'what running the fleet costs' + + def test_one_fragment_s_objective_is_carried_as_it_was_written(): objective = merge(LIBRARY).objective assert objective is not None