From 0341278e3d6de7c19e30fa5077faafe97fa8b365 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 11:39:59 +0000 Subject: [PATCH 01/21] feat(language): a parameter may declare the value a missing row reads as `default:` on a parameter gives the value a missing row reads wherever a value is read: a coefficient, a term, a divisor, a bound, and a comparison in a where. A bare numeric name in a where still asks whether the data has a row. The exclusivity prover reads the default in a comparison, and the legend prints it. Refs #773 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiDTE4WvKobVxTDxUeWhc9 --- docs/reference/language/absence.md | 11 +++-- docs/reference/language/declarations.md | 36 +++++++++++++++++ docs/reference/language/expressions.md | 6 ++- docs/reference/reading.md | 7 ++++ schema/mathspec.schema.json | 23 ++++++++++- src/mathspec/exclusivity.py | 41 +++++++++++++++---- src/mathspec/lowering.py | 4 +- src/mathspec/program.py | 4 ++ src/mathspec/resolution.py | 8 +++- src/mathspec/spec.py | 44 +++++++++++++++++++- src/mathspec/typesetting/format.py | 15 +++++++ src/mathspec/typesetting/legend.py | 19 ++++++++- src/mathspec/typesetting/walk.py | 12 +----- tests/test_exclusivity.py | 31 ++++++++++++-- tests/test_lowering.py | 5 ++- tests/test_validation.py | 54 +++++++++++++++++++++++++ tests/typesetting/test_symbols.py | 26 ++++++++++++ 17 files changed, 313 insertions(+), 33 deletions(-) diff --git a/docs/reference/language/absence.md b/docs/reference/language/absence.md index 45fa3f367..78aeb3dd5 100644 --- a/docs/reference/language/absence.md +++ b/docs/reference/language/absence.md @@ -34,15 +34,18 @@ The [grammar](expressions.md#where-strings) says what a `where:` may hold. | a label a relation does not map | that label's group membership ([relations](relations.md#the-data-contract)) | Nothing else creates absence. **A missing parameter row is not absence.** It -reads as the value that contributes nothing: `0` as a coefficient, and `false` -in a `where`. +reads as the parameter's [`default:`](declarations.md#a-default) where it +declares one. Otherwise it reads as the value that contributes nothing: `0` as +a coefficient, and `false` in a `where`. A missing row is the only gap a parameter has: a null or NaN value is [refused](declarations.md#parameters) when the data is attached. Where no such value exists, loading is refused. There are four such positions: a divisor, a `bounds:` entry, the whole constant side of a comparison, and a -[`piecewise:`](piecewise.md) breakpoint. For a bound only where the data has -one, supply the bound in the data, where `inf` is a value, or mask the variable. +[`piecewise:`](piecewise.md) breakpoint. A parameter with a `default:` has a +value in the first three. For a bound only where the data has one, declare +`default: .inf`, supply the bound in the data, where `inf` is a value, or mask +the variable. ## How absence travels diff --git a/docs/reference/language/declarations.md b/docs/reference/language/declarations.md index d32c8b4d0..4596990b7 100644 --- a/docs/reference/language/declarations.md +++ b/docs/reference/language/declarations.md @@ -27,6 +27,7 @@ parameters: | ------------- | -------------------------------------------------------------- | --------------- | | `dims` | required. The dimensions it is indexed by. `[]` means a scalar | | | `dtype` | `float`, `int`, `bool`, `str` | default `float` | +| `default` | the value a missing row reads as ([a default](#a-default)) | default `null` | | `description` | free text | default `null` | The column has to match the `dtype`: @@ -47,6 +48,41 @@ parameter is a mask: each selects rows in a a term or a divisor is a load error. A `0` or `1` that is meant to be multiplied by is declared `dtype: int`. +### A default + +`default:` is the value a missing row reads as. + +```yaml +dimensions: + generator: { dtype: str } +parameters: + efficiency: { dims: [generator], default: 1 } + p_nom_max: { dims: [generator], default: .inf } + active: { dims: [generator], dtype: bool, default: true } +``` + +Where the data has no row, every position that reads a value reads the +default: a coefficient, a term, a divisor, a bound, and a comparison in a +`where`. A bare numeric name in a `where` still asks whether the data has a +row, so `where: p_nom_max` selects the rows the data gives. A bare `bool` name +reads its value, so `where: active` reads the default. A +[curve](assumptions.md#what-a-curve-assumes) still needs a row at each +breakpoint. + +| `dtype` | `default:` takes | +| ------- | ------------------------------------------------------- | +| `float` | a number. `.inf`, `inf`, `-.inf` and `-inf` are numbers | +| `int` | an integer | +| `bool` | `true` or `false` | +| `str` | nothing. A label has no default | + +A NaN and a quoted number are refused. `default: null` is no default. Without +a default, a missing row reads as the value that contributes nothing +([absence](absence.md#what-creates-absence)). + +A `given:` parameter has no `default:`. The file that declares the parameter +owns it. The typeset legend prints the default beside the parameter. + ## `variables` A variable is what the solver decides. There is one column per coordinate of diff --git a/docs/reference/language/expressions.md b/docs/reference/language/expressions.md index 66062face..aeb7fee0f 100644 --- a/docs/reference/language/expressions.md +++ b/docs/reference/language/expressions.md @@ -147,7 +147,7 @@ QUOTED ::= "'" chars "'" | '"' chars '"' | `name` (bare) | variable | The variable exists at this coordinate | | `name` (bare) | relation | A row exists, read at the relation's key. A relation may be [partial](relations.md#the-data-contract), and this selects the labels that do map | | `name` (bare) | dimension | A load error. It would be true everywhere | -| `name OP value` | parameter | Element-wise, and a null compares false | +| `name OP value` | parameter | Element-wise. A missing row with no `default:` compares false | | `name OP value` | dimension | A filter on the frame's own coordinate column | | `name OP value`, `name.col OP value` | relation | A filter on a value column, read at the relation's key. Name the column where the key determines several | | `name OP name`, `name.a OP name.b` | two relation columns | Legal where both relations are keyed over the same dimensions and both columns are over one dimension. `ends.bus0 != ends.bus1` excludes a self-loop | @@ -162,6 +162,10 @@ QUOTED ::= "'" chars "'" | '"' chars '"' A bare name that is not declared is a load error. +A missing row reads the parameter's [`default:`](declarations.md#a-default) in +a comparison and in a bare `bool` name. A bare numeric name still asks whether +the data has a row, and a default is not a row. + ### Counting what a predicate admits `count(, over=)` is how many coordinates along that diff --git a/docs/reference/reading.md b/docs/reference/reading.md index c10436455..bed0c6fc5 100644 --- a/docs/reference/reading.md +++ b/docs/reference/reading.md @@ -119,6 +119,13 @@ written = assumption_message('cost_is_never_negative', program.assumptions['cost written # "assumption 'cost_is_never_negative' does not hold for the data attached to 'bp_y' — a negative cost is a gain the objective would chase" ``` +`program.parameters[name].default` is the value a missing row of that +parameter reads as, or `None`. Every position that reads a value reads it: an +expression, a bound, and a comparison in a mask. A `ParameterDefined` on a +numeric parameter asks whether the data has a row, so the default does not +answer it. A `ParameterDefined` on a `bool` parameter reads the value, and so +reads the default. + ## Nodes and masks The node classes live in `mathspec.program`, for `isinstance` tests and field diff --git a/schema/mathspec.schema.json b/schema/mathspec.schema.json index 720bbbad2..2f6926e8b 100644 --- a/schema/mathspec.schema.json +++ b/schema/mathspec.schema.json @@ -549,8 +549,29 @@ }, "ParameterBlock": { "additionalProperties": false, - "description": "A declared parameter with dims and dtype.", + "description": "A declared parameter with dims and dtype, and the value a missing row reads as.", "properties": { + "default": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "enum": [ + "inf", + "-inf" + ] + }, + { + "type": "null" + } + ], + "default": null, + "title": "Default" + }, "description": { "anyOf": [ { diff --git a/src/mathspec/exclusivity.py b/src/mathspec/exclusivity.py index 3f0964cca..aff5d73b3 100644 --- a/src/mathspec/exclusivity.py +++ b/src/mathspec/exclusivity.py @@ -62,7 +62,11 @@ class Undecidable(Exception): # noqa: N818 """A pair this procedure will not reason about. Carries the rewrite.""" -def overlapping(cases: Mapping[str, Predicate], dtypes: Mapping[str, DeclaredDtype]) -> Iterator[str]: +def overlapping( + cases: Mapping[str, Predicate], + dtypes: Mapping[str, DeclaredDtype], + defaults: Mapping[str, bool | float], +) -> Iterator[str]: """One refusal per pair of cases that could both claim a coordinate. Args: @@ -70,6 +74,7 @@ def overlapping(cases: Mapping[str, Predicate], dtypes: Mapping[str, DeclaredDty block's ``otherwise`` is not among them: it claims what the rest leave, so it overlaps nothing by construction. dtypes: The declared dtype of every name a mask compares against. + defaults: The declared ``default:`` of every parameter that has one. Yields: A sentence per pair, naming both cases and either a coordinate they @@ -78,7 +83,7 @@ def overlapping(cases: Mapping[str, Predicate], dtypes: Mapping[str, DeclaredDty """ for (first, left), (second, right) in itertools.combinations(cases.items(), 2): try: - witness = _witness(left, right, dtypes) + witness = _witness(left, right, dtypes, defaults) except Undecidable as exc: yield ( f"cases '{first}' and '{second}' cannot be told apart before the data arrives: {exc}. " @@ -95,10 +100,15 @@ def overlapping(cases: Mapping[str, Predicate], dtypes: Mapping[str, DeclaredDty ) -def _witness(first: Predicate, second: Predicate, dtypes: Mapping[str, DeclaredDtype]) -> str | None: +def _witness( + first: Predicate, + second: Predicate, + dtypes: Mapping[str, DeclaredDtype], + defaults: Mapping[str, bool | float], +) -> str | None: """A coordinate both masks claim, rendered — ``None`` where no cell holds both.""" masks = (Mask(first), Mask(second)) - grid = _Grid.of(masks, dtypes) + grid = _Grid.of(masks, dtypes, defaults) if grid.size > CELL_BUDGET: msg = ( f'{grid.size} regions to check exceeds the budget of {CELL_BUDGET} — ' @@ -171,9 +181,12 @@ class _Grid: domains: dict[Subject, list[Cell]] subjects: dict[int, Subject] + defaults: Mapping[str, bool | float] @classmethod - def of(cls, masks: Iterable[Mask], dtypes: Mapping[str, DeclaredDtype]) -> _Grid: + def of( + cls, masks: Iterable[Mask], dtypes: Mapping[str, DeclaredDtype], defaults: Mapping[str, bool | float] + ) -> _Grid: values: dict[Subject, set[_Literal]] = {} subjects: dict[int, Subject] = {} for mask in masks: @@ -181,7 +194,7 @@ def of(cls, masks: Iterable[Mask], dtypes: Mapping[str, DeclaredDtype]) -> _Grid subject = _subject_of(node) subjects[id(node)] = subject _observe(node, subject, values.setdefault(subject, set()), dtypes) - return cls({s: _cells_for(s, seen, dtypes) for s, seen in values.items()}, subjects) + return cls({s: _cells_for(s, seen, dtypes) for s, seen in values.items()}, subjects, defaults) @property def size(self) -> int: @@ -492,7 +505,7 @@ def _evaluate(node: Predicate, cell: dict[Subject, Cell], grid: _Grid) -> bool: def _atom(node: TypedPredicate, cell: dict[Subject, Cell], grid: _Grid) -> bool: subject = grid.subjects[id(node)] - value = cell[subject] + value = _read(node, cell[subject], grid) match node: case ParameterDefined() | RelationDefined(): if isinstance(value, bool): @@ -523,6 +536,20 @@ def _atom(node: TypedPredicate, cell: dict[Subject, Cell], grid: _Grid) -> bool: assert_never(node) +def _read(node: TypedPredicate, value: Cell, grid: _Grid) -> Cell: + """What *node* reads in a cell where its parameter has no row: the ``default:``, in a comparison. + + A bare name keeps the null. A numeric one asks whether the data has a row. + A ``bool`` one reads its default there, and the cell holding that value is + already in the grid, so the null cell adds no witness either way. + """ + if value is Special.NULL and isinstance(node, ParameterComparison): + default = grid.defaults.get(node.name) + if default is not None: + return default + return value + + def _compare(value: Cell, op: PredicateOperator, literal: _Literal) -> bool: """One atom's truth in one cell. Both sides are already this cell's frame. diff --git a/src/mathspec/lowering.py b/src/mathspec/lowering.py index a826a2975..c27e187b9 100644 --- a/src/mathspec/lowering.py +++ b/src/mathspec/lowering.py @@ -234,7 +234,7 @@ def lower(schema: Spec) -> Program: program = Program( parameters={ - name: ParameterDeclaration(tuple(pdef.dims), pdef.dtype, pdef.description) + name: ParameterDeclaration(tuple(pdef.dims), pdef.dtype, pdef.default, pdef.description) for name, pdef in schema.parameters.items() }, variables=variables, @@ -261,7 +261,7 @@ def lower(schema: Spec) -> Program: }, given=GivenTargets( parameters={ - name: ParameterDeclaration(tuple(g.dims), g.dtype, g.description) + name: ParameterDeclaration(tuple(g.dims), g.dtype, description=g.description) for name, g in schema.given.parameters.items() }, variables={ diff --git a/src/mathspec/program.py b/src/mathspec/program.py index f32a9955d..73cd6c5f8 100644 --- a/src/mathspec/program.py +++ b/src/mathspec/program.py @@ -598,6 +598,10 @@ class ParameterDeclaration: dims: tuple[str, ...] dtype: ParameterDtype = 'float' + #: What a missing row reads as wherever a value is read, or ``None`` for + #: the value that contributes nothing. A bare numeric name in a ``where`` + #: still asks whether the data has a row. + default: bool | float | None = None description: str | None = None diff --git a/src/mathspec/resolution.py b/src/mathspec/resolution.py index 6308650e5..4d2270e86 100644 --- a/src/mathspec/resolution.py +++ b/src/mathspec/resolution.py @@ -74,6 +74,7 @@ class Namespace: '_named', 'bodies', 'constraints', + 'defaults', 'dimensions', 'dtypes', 'leaf_dims', @@ -105,6 +106,11 @@ def __init__(self, schema: Spec) -> None: **{p: pd.dtype for p, pd in parameters.items()}, **{d: dd.dtype for d, dd in schema.dimensions.items()}, } + #: parameter name -> its declared ``default:``, for the parameters that + #: have one; what a where comparison reads where the data has no row. + self.defaults: dict[str, bool | float] = { + p: pd.default for p, pd in schema.parameters.items() if pd.default is not None + } #: relation name -> its columns and key, as declared. self.relations: dict[str, RelationDeclaration] = { n: RelationDeclaration(lk.pairs, lk.key_roles, lk.description) for n, lk in schema.relations.items() @@ -458,7 +464,7 @@ def _named(name: str, block: ExpressionBlock, ns: Namespace, errors: list[str]) fallback = resolve_expression_text(block.otherwise, ns, case_context(name, None), errors, ceiling=None) if len(errors) > found or fallback is None: return None - errors.extend(f'{context}: {problem}' for problem in overlapping(masks, ns.dtypes)) + errors.extend(f'{context}: {problem}' for problem in overlapping(masks, ns.dtypes, ns.defaults)) if len(errors) > found: return None left_over = Region(remainder(region.when for region in regions), fallback) diff --git a/src/mathspec/spec.py b/src/mathspec/spec.py index ae1767f26..d278b573c 100644 --- a/src/mathspec/spec.py +++ b/src/mathspec/spec.py @@ -22,6 +22,7 @@ ValidationError, ValidationInfo, ValidatorFunctionWrapHandler, + WithJsonSchema, field_validator, model_serializer, model_validator, @@ -97,6 +98,12 @@ def _reject_unknown_keys(cls, data: object) -> object: #: term, a divisor, a bound. A label selects and a flag masks; neither is one. NUMERIC_DTYPES: frozenset[ParameterDtype] = frozenset({'float', 'int'}) +#: What ``default:`` is written as. The validator takes ``inf`` and ``-inf`` as +#: the numbers they name, which an editor would otherwise flag as strings. +_DEFAULT_SCHEMA: dict[str, object] = { + 'anyOf': [{'type': 'boolean'}, {'type': 'number'}, {'enum': ['inf', '-inf']}, {'type': 'null'}], +} + #: Every formulation, in the order [`Spec.expand`][] writes them out: a curve #: emits a set, and no set emits a curve. FORMULATIONS: tuple[Formulation, ...] = ('piecewise', 'sos') @@ -207,14 +214,49 @@ class DimensionBlock(_StrictBlock): class ParameterBlock(_StrictBlock): - """A declared parameter with dims and dtype.""" + """A declared parameter with dims and dtype, and the value a missing row reads as.""" _label: ClassVar[str] = 'a parameter declaration' dims: list[str] dtype: ParameterDtype = 'float' + default: Annotated[bool | int | float | None, WithJsonSchema(_DEFAULT_SCHEMA)] = None description: str | None = None + @field_validator('default', mode='before') + @classmethod + def _a_value(cls, v: object) -> object: + """``inf`` is a string to YAML and a number to the expression grammar, so it is taken as the number.""" + if v in ('inf', '-inf'): + return float(cast('str', v)) + if isinstance(v, str): + msg = f'default is the string {v!r}, and a default is a number, true or false. Remove the quotes.' + raise ValueError(msg) + if isinstance(v, float) and math.isnan(v): + msg = 'default is nan, and a parameter has no nan value. Write a number, or leave the key out.' + raise ValueError(msg) + return v + + @model_validator(mode='after') + def _default_fits_the_dtype(self) -> ParameterBlock: + """A default is a value of the column, so it has the column's dtype.""" + v = self.default + if v is None: + return self + if self.dtype == 'str': + msg = f'default: {v!r} on a str parameter. A label has no default: leave the key out.' + raise ValueError(msg) + if self.dtype == 'bool' and not isinstance(v, bool): + msg = f'default: {v!r} on a bool parameter, which takes true or false.' + raise ValueError(msg) + if self.dtype != 'bool' and isinstance(v, bool): + msg = f'default: {str(v).lower()} on a {self.dtype} parameter, which takes a number. Write 1 or 0.' + raise ValueError(msg) + if self.dtype == 'int' and isinstance(v, float): + msg = f'default: {v} on an int parameter, which takes an integer. Write one, or declare dtype: float.' + raise ValueError(msg) + return self + class BoundsBlock(_StrictBlock): """Variable bounds — each side is a finite number, a parameter name, or ``None`` where it is open. diff --git a/src/mathspec/typesetting/format.py b/src/mathspec/typesetting/format.py index ee7913b03..20ffdd2e0 100644 --- a/src/mathspec/typesetting/format.py +++ b/src/mathspec/typesetting/format.py @@ -9,6 +9,7 @@ from __future__ import annotations +import math import re from dataclasses import dataclass from typing import TYPE_CHECKING, ClassVar, Literal, Protocol, get_args @@ -260,6 +261,20 @@ def note(self, text: str) -> str: def document(self, blocks: list[str], *, standalone: bool) -> str: ... +def number(value: float, fmt: Format) -> str: + """*value* as math: an integer without its point, an infinity as its sign, a large or small value in powers of ten.""" + if math.isinf(value): + infinity = fmt.operators['infinity'] + return infinity if value > 0 else f'{fmt.operators["minus"]}{infinity}' + if value == int(value): + return str(int(value)) + mantissa, _, exponent = repr(value).partition('e') + if not exponent: + return mantissa + power = fmt.superscript('10', str(int(exponent))) + return power if mantissa == '1' else f'{mantissa} {fmt.operators["times"]} {power}' + + def aligned_rows(lines: list[Line], fmt: Format, *, gap: str) -> list[str]: """One alignment row per line — label, left, right, condition — *gap* around the relation, trailing empty cells stripped.""" return [ diff --git a/src/mathspec/typesetting/legend.py b/src/mathspec/typesetting/legend.py index 00cad8402..7ddd5c120 100644 --- a/src/mathspec/typesetting/legend.py +++ b/src/mathspec/typesetting/legend.py @@ -25,7 +25,7 @@ WindowSum, walk_regions, ) -from mathspec.typesetting.format import Entry +from mathspec.typesetting.format import Entry, number if TYPE_CHECKING: from collections.abc import Iterable @@ -148,7 +148,11 @@ def glossaries(self, noticed: Noticed, defined: Iterable[str]) -> list[tuple[str for d, block in program.dimensions.items() ] parameters = [ - self._entry(self.symbols.name[p], f'{fmt.mono(p)}{self._over(list(block.dims))}', block.description) + self._entry( + self.symbols.name[p], + f'{fmt.mono(p)}{self._over(list(block.dims))}{self._default(block.default)}', + block.description, + ) for p, block in program.parameters.items() ] variables = [ @@ -217,6 +221,17 @@ def _over(self, dims: list[str]) -> str: product = self.format.joined([self.symbols.set[d] for d in dims], self._op('times')) return f' over {self.format.math(product)}' + def _default(self, value: bool | float | None) -> str: + """What a missing row reads as, where the declaration says; nothing where it is the value that contributes nothing.""" + if value is None: + return '' + shown = ( + self.format.mono(str(value).lower()) + if isinstance(value, bool) + else self.format.math(number(value, self.format)) + ) + return f', {shown} where the data has no row' + def _signature(self, name: str, lk: RelationDeclaration) -> str: """A relation in the legend: a function from its key sets to its value sets, or a relation inside the product.""" diff --git a/src/mathspec/typesetting/walk.py b/src/mathspec/typesetting/walk.py index a34bc3609..4e6e41464 100644 --- a/src/mathspec/typesetting/walk.py +++ b/src/mathspec/typesetting/walk.py @@ -56,7 +56,7 @@ VariableDefined, WindowSum, ) -from mathspec.typesetting.format import Line, OperatorName +from mathspec.typesetting.format import Line, OperatorName, number from mathspec.typesetting.legend import TranslationPolicy, policy_of if TYPE_CHECKING: @@ -305,15 +305,7 @@ def _context(self, frame: Iterable[str] = ()) -> _Context: return _Context(self, bound=tuple(frame)) def _number(self, value: float) -> str: - if value == float('inf'): - return self._op('infinity') - if value == int(value): - return str(int(value)) - mantissa, _, exponent = repr(value).partition('e') - if not exponent: - return mantissa - power = self.format.superscript('10', str(int(exponent))) - return power if mantissa == '1' else f'{mantissa} {self._op("times")} {power}' + return number(value, self.format) # -- arithmetic -------------------------------------------------------- diff --git a/tests/test_exclusivity.py b/tests/test_exclusivity.py index d0f3165d0..5836f5404 100644 --- a/tests/test_exclusivity.py +++ b/tests/test_exclusivity.py @@ -22,6 +22,7 @@ from mathspec.program import And, Mask, Not, Or from mathspec.resolution import Namespace, resolve_where from mathspec.validation import to_spec +from tests.fixtures import varied if TYPE_CHECKING: from mathspec.program import Predicate @@ -57,7 +58,13 @@ def schema() -> Spec: def refusals(schema: Spec, cases: dict[str, str]) -> list[str]: """Resolve each case's `when` against *schema*, then decide every pair.""" namespace = Namespace(schema) - return list(overlapping({name: _mask(when, namespace, name) for name, when in cases.items()}, namespace.dtypes)) + return list( + overlapping( + {name: _mask(when, namespace, name) for name, when in cases.items()}, + namespace.dtypes, + namespace.defaults, + ) + ) def _mask(text: str, namespace: Namespace, name: str) -> Predicate: @@ -311,11 +318,29 @@ def test_a_pair_proved_apart_stays_apart_on_a_finer_grid(self, schema: Spec, see proved = 0 for _ in range(2000): first, second = self._random_mask(rng, atoms), self._random_mask(rng, atoms) - if list(overlapping({'a': first, 'b': second}, dtypes)): + if list(overlapping({'a': first, 'b': second}, dtypes, {})): continue proved += 1 - cells = _Grid.of([Mask(first), Mask(second)], dtypes) + cells = _Grid.of([Mask(first), Mask(second)], dtypes, {}) for point in grid: both = _evaluate(first, point, cells) and _evaluate(second, point, cells) assert not both, f'both cases claim {point} — the cells hid a witness' assert proved > 150, f'only {proved} pairs proved apart; the fuzz is not exercising the check' + + +class TestADefault: + CASES: ClassVar[dict[str, str]] = {'lossless': 'efficiency == 1', 'not_given': 'not efficiency'} + + def test_a_missing_row_compares_as_the_default(self): + """Both cases claim a storage with no `efficiency` row: it is not given, and it reads 1. + + The prover read a missing row as false in every comparison, so it proved + the two apart, and a coordinate the data leaves out had two values. + """ + schema = to_spec(varied(STORAGE, **{'parameters.efficiency': {'dims': ['storage'], 'default': 1}})) + [refusal] = refusals(schema, self.CASES) + assert 'efficiency is absent' in refusal, 'the witness is the missing row' + + def test_without_a_default_a_missing_row_compares_false(self): + schema = to_spec(varied(STORAGE, **{'parameters.efficiency': {'dims': ['storage']}})) + assert refusals(schema, self.CASES) == [], 'a missing row with no default is in neither case' diff --git a/tests/test_lowering.py b/tests/test_lowering.py index e92a72619..847d231e4 100644 --- a/tests/test_lowering.py +++ b/tests/test_lowering.py @@ -1014,7 +1014,10 @@ def test_the_lowered_regions_are_still_proved_apart(): regions = _cases_in(spec.program).regions named = {f'region{i}': r.when.root for i, r in enumerate(regions)} - assert list(overlapping(named, Namespace(spec).dtypes)) == [], 'no two lowered regions can claim one coordinate' + namespace = Namespace(spec) + assert list(overlapping(named, namespace.dtypes, namespace.defaults)) == [], ( + 'no two lowered regions can claim one coordinate' + ) def test_a_cased_expression_is_readable_by_the_name_the_file_wrote(): diff --git a/tests/test_validation.py b/tests/test_validation.py index 77cdd228f..19e77a4f5 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -2289,3 +2289,57 @@ def test_an_infinite_bound_is_refused_with_the_null_that_opens_a_side(side, valu message = _refusal(DISPATCH_MODEL, **{f'variables.p.bounds.{side}': value}) assert f'bounds.{side} is {value}, and a bound is finite' in message assert f'{side}: null' in message, 'the refusal names the spelling of an open side' + + +@pytest.mark.parametrize( + ('dtype', 'written', 'read'), + [ + pytest.param('float', 1, 1, id='a-number'), + pytest.param('float', float('inf'), float('inf'), id='yaml-dot-inf'), + pytest.param('float', 'inf', float('inf'), id='the-expression-spelling-of-inf'), + pytest.param('float', '-inf', float('-inf'), id='the-expression-spelling-of-minus-inf'), + pytest.param('int', 2, 2, id='an-integer'), + pytest.param('bool', True, True, id='a-flag'), + pytest.param('float', None, None, id='null-is-no-default'), + ], +) +def test_a_default_is_a_value_of_the_column(dtype, written, read): + spec = to_spec(varied(SMALL_MODEL, **{'parameters.c': {'dims': ['g'], 'dtype': dtype, 'default': written}})) + assert spec.program.parameters['c'].default == read + + +def test_inf_and_dot_inf_load_as_one_number(): + """YAML reads `inf` as a string and `.inf` as a number; the expression grammar takes both.""" + read = [ + to_spec(parse_yaml(f'dimensions: {{g: {{}}}}\nparameters: {{c: {{dims: [g], default: {spelling}}}}}')) + .program.parameters['c'] + .default + for spelling in ('inf', '.inf') + ] + assert read == [float('inf'), float('inf')] + + +@pytest.mark.parametrize( + ('dtype', 'written', 'fragment'), + [ + pytest.param('float', '1', "default is the string '1'", id='a-quoted-number'), + pytest.param('float', float('nan'), 'default is nan', id='nan'), + pytest.param( + 'float', True, 'default: true on a float parameter, which takes a number', id='a-flag-on-a-number' + ), + pytest.param('int', 1.5, 'default: 1.5 on an int parameter, which takes an integer', id='a-fraction-on-an-int'), + pytest.param('int', float('inf'), 'default: inf on an int parameter', id='an-infinity-on-an-int'), + pytest.param('bool', 1, 'default: 1 on a bool parameter, which takes true or false', id='a-number-on-a-flag'), + pytest.param('str', 'a', "default is the string 'a'", id='a-label'), + pytest.param('str', 1, 'A label has no default', id='a-number-on-a-label'), + ], +) +def test_a_default_that_is_not_a_value_of_the_column_is_refused(dtype, written, fragment): + message = _refusal(**{'parameters.c': {'dims': ['g'], 'dtype': dtype, 'default': written}}) + assert fragment in message + + +def test_a_given_parameter_takes_no_default(): + """The file that declares the parameter owns its default, as it owns a variable's bounds.""" + message = _refusal(**{'given.parameters.d': {'dims': ['g'], 'default': 1}}) + assert "unknown key 'default' in a given parameter declaration" in message diff --git a/tests/typesetting/test_symbols.py b/tests/typesetting/test_symbols.py index 2caa11faf..983c1966f 100644 --- a/tests/typesetting/test_symbols.py +++ b/tests/typesetting/test_symbols.py @@ -6,12 +6,14 @@ from __future__ import annotations +import math from typing import TYPE_CHECKING import pytest from mathspec.errors import SchemaError from mathspec.typesetting import SymbolTable, to_latex, to_markdown, to_typst, typeset +from mathspec.typesetting.format import number from mathspec.validation import to_spec from tests.fixtures import DISPATCH_MODEL, varied from tests.typesetting.fixtures import EVERY_FORMAT, TYPST_SYMBOLS @@ -95,6 +97,30 @@ def test_a_named_expression_has_a_legend_row_exactly_while_its_symbol_prints(nam assert 'what a snapshot costs' not in typeset(DESCRIBED, name, inline_expressions=True) +DEFAULTED = varied( + DISPATCH_MODEL, + **{ + 'parameters.p_max.default': 'inf', + 'parameters.cost.default': 1, + 'parameters.floor': {'dims': [], 'default': '-inf'}, + 'parameters.online': {'dims': ['generator'], 'dtype': 'bool', 'default': True}, + }, +) + + +@EVERY_FORMAT +def test_the_legend_prints_what_a_missing_row_reads_as(name: FormatName, fmt: Format): + out = typeset(DEFAULTED, name) + for shown in ( + fmt.math(number(math.inf, fmt)), + fmt.math(number(-math.inf, fmt)), + fmt.math('1'), + fmt.mono('true'), + ): + assert f'{shown} where the data has no row' in out + assert out.count('where the data has no row') == 4, '`load` declares no default, so its row names none' + + #: The dispatch model with a curve on it, so one model has two readings and one #: table has to spell both. CURVED = varied( From a021ef9941d844a245f1e862be5eb218f8e0fd3d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 11:42:10 +0000 Subject: [PATCH 02/21] test(typesetting): the golden model declares a default, so the legend prints one Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiDTE4WvKobVxTDxUeWhc9 --- docs/reference/notation.md | 8 ++++---- tests/typesetting/golden/latex.out | 4 ++-- tests/typesetting/golden/markdown.out | 4 ++-- tests/typesetting/golden/model.yaml | 4 ++-- tests/typesetting/golden/typst.out | 4 ++-- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/reference/notation.md b/docs/reference/notation.md index 231f3664a..c7aeab79c 100644 --- a/docs/reference/notation.md +++ b/docs/reference/notation.md @@ -47,11 +47,11 @@ relations: gen_bt: { key: generator, values: [bus, technology] } # one table with two value columns, read to both at once parameters: - p_max: { dims: [generator] } + p_max: { dims: [generator], default: .inf } # a default: the legend says what a missing row reads as p_min: { dims: [generator] } cost: { dims: [generator] } load: { dims: [snapshot, bus] } - is_flexible: { dims: [generator], dtype: bool } + is_flexible: { dims: [generator], dtype: bool, default: false } zone_cap: { dims: [zone] } tech_cap: { dims: [bus, technology] } min_up: { dims: [generator], dtype: int } @@ -81,11 +81,11 @@ parameters: | Symbol | Meaning | |---|---| -| $`\mathrm{p}^{\mathrm{max}}`$ | `p_max` over $`\mathcal{G}`$ | +| $`\mathrm{p}^{\mathrm{max}}`$ | `p_max` over $`\mathcal{G}`$, $`\infty`$ where the data has no row | | $`\mathrm{p}^{\mathrm{min}}`$ | `p_min` over $`\mathcal{G}`$ | | $`\mathrm{cost}`$ | `cost` over $`\mathcal{G}`$ | | $`\mathrm{load}`$ | `load` over $`\mathcal{T} \times \mathcal{B}`$ | -| $`\mathrm{is\_flexible}`$ | `is_flexible` over $`\mathcal{G}`$ | +| $`\mathrm{is\_flexible}`$ | `is_flexible` over $`\mathcal{G}`$, `false` where the data has no row | | $`\mathrm{zone\_cap}`$ | `zone_cap` over $`\mathcal{Z}`$ | | $`\mathrm{tech\_cap}`$ | `tech_cap` over $`\mathcal{B} \times \mathcal{E}`$ | | $`\mathrm{min\_up}`$ | `min_up` over $`\mathcal{G}`$ | diff --git a/tests/typesetting/golden/latex.out b/tests/typesetting/golden/latex.out index b6b5e6658..2547e8676 100644 --- a/tests/typesetting/golden/latex.out +++ b/tests/typesetting/golden/latex.out @@ -20,11 +20,11 @@ \paragraph{Parameters} \begin{description} -\item[{$\mathrm{p}^{\mathrm{max}}$}] \texttt{p\_max} over $\mathcal{G}$ +\item[{$\mathrm{p}^{\mathrm{max}}$}] \texttt{p\_max} over $\mathcal{G}$, $\infty$ where the data has no row \item[{$\mathrm{p}^{\mathrm{min}}$}] \texttt{p\_min} over $\mathcal{G}$ \item[{$\mathrm{cost}$}] \texttt{cost} over $\mathcal{G}$ \item[{$\mathrm{load}$}] \texttt{load} over $\mathcal{T} \times \mathcal{B}$ -\item[{$\mathrm{is\_flexible}$}] \texttt{is\_flexible} over $\mathcal{G}$ +\item[{$\mathrm{is\_flexible}$}] \texttt{is\_flexible} over $\mathcal{G}$, \texttt{false} where the data has no row \item[{$\mathrm{zone\_cap}$}] \texttt{zone\_cap} over $\mathcal{Z}$ \item[{$\mathrm{tech\_cap}$}] \texttt{tech\_cap} over $\mathcal{B} \times \mathcal{E}$ \item[{$\mathrm{min\_up}$}] \texttt{min\_up} over $\mathcal{G}$ diff --git a/tests/typesetting/golden/markdown.out b/tests/typesetting/golden/markdown.out index 09d67bd68..9fe822eab 100644 --- a/tests/typesetting/golden/markdown.out +++ b/tests/typesetting/golden/markdown.out @@ -18,11 +18,11 @@ every character a notation escapes, set as text: link\_to, 100% & \#1 costs \$5 | Symbol | Meaning | |---|---| -| $`\mathrm{p}^{\mathrm{max}}`$ | `p_max` over $`\mathcal{G}`$ | +| $`\mathrm{p}^{\mathrm{max}}`$ | `p_max` over $`\mathcal{G}`$, $`\infty`$ where the data has no row | | $`\mathrm{p}^{\mathrm{min}}`$ | `p_min` over $`\mathcal{G}`$ | | $`\mathrm{cost}`$ | `cost` over $`\mathcal{G}`$ | | $`\mathrm{load}`$ | `load` over $`\mathcal{T} \times \mathcal{B}`$ | -| $`\mathrm{is\_flexible}`$ | `is_flexible` over $`\mathcal{G}`$ | +| $`\mathrm{is\_flexible}`$ | `is_flexible` over $`\mathcal{G}`$, `false` where the data has no row | | $`\mathrm{zone\_cap}`$ | `zone_cap` over $`\mathcal{Z}`$ | | $`\mathrm{tech\_cap}`$ | `tech_cap` over $`\mathcal{B} \times \mathcal{E}`$ | | $`\mathrm{min\_up}`$ | `min_up` over $`\mathcal{G}`$ | diff --git a/tests/typesetting/golden/model.yaml b/tests/typesetting/golden/model.yaml index fcdce15e5..7d022adb3 100644 --- a/tests/typesetting/golden/model.yaml +++ b/tests/typesetting/golden/model.yaml @@ -32,11 +32,11 @@ relations: gen_bt: { key: generator, values: [bus, technology] } # one table with two value columns, read to both at once parameters: - p_max: { dims: [generator] } + p_max: { dims: [generator], default: .inf } # a default: the legend says what a missing row reads as p_min: { dims: [generator] } cost: { dims: [generator] } load: { dims: [snapshot, bus] } - is_flexible: { dims: [generator], dtype: bool } + is_flexible: { dims: [generator], dtype: bool, default: false } zone_cap: { dims: [zone] } tech_cap: { dims: [bus, technology] } min_up: { dims: [generator], dtype: int } diff --git a/tests/typesetting/golden/typst.out b/tests/typesetting/golden/typst.out index 8ffd07dd7..8285f0368 100644 --- a/tests/typesetting/golden/typst.out +++ b/tests/typesetting/golden/typst.out @@ -13,11 +13,11 @@ every character a notation escapes, set as text: link\_to, 100% & \#1 costs \$5 / $cal(A)$: index $a$ --- `bp` == Parameters -/ $upright("p")^(upright("max"))$: `p_max` over $cal(G)$ +/ $upright("p")^(upright("max"))$: `p_max` over $cal(G)$, $infinity$ where the data has no row / $upright("p")^(upright("min"))$: `p_min` over $cal(G)$ / $upright("cost")$: `cost` over $cal(G)$ / $upright("load")$: `load` over $cal(T) times cal(B)$ -/ $upright("is_flexible")$: `is_flexible` over $cal(G)$ +/ $upright("is_flexible")$: `is_flexible` over $cal(G)$, `false` where the data has no row / $upright("zone_cap")$: `zone_cap` over $cal(Z)$ / $upright("tech_cap")$: `tech_cap` over $cal(B) times cal(E)$ / $upright("min_up")$: `min_up` over $cal(G)$ From a0a6c3fae4623bf2fb097380e33e9c4caf187d6f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 11:42:48 +0000 Subject: [PATCH 03/21] docs(changelog): name #810 under the upcoming version Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiDTE4WvKobVxTDxUeWhc9 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 724b144f8..de74b7988 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ it releases that version ([RELEASING.md](https://github.com/energy-models/mathsp ## Upcoming version +- feat(language): a parameter may declare the value a missing row reads as ([#810](https://github.com/energy-models/mathspec/pull/810)) - fix(language): a cases block with one case may compare expressions in its when: ([#795](https://github.com/energy-models/mathspec/pull/795)) - feat(language): a parameter value that is null or NaN is refused when the data is attached ([#788](https://github.com/energy-models/mathspec/pull/788)) - fix(language): a where string names several columns in at's over= and into=, as an expression does ([#782](https://github.com/energy-models/mathspec/pull/782)) From 5f63706222bfe16704d8ff2eaaaf03a926c634b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 08:49:06 +0000 Subject: [PATCH 04/21] feat(language): `missing:` says what a missing row means, and a table is complete unless the file says otherwise One key replaces `default:`, `absence:` and the `coverage:` of #671. - A parameter takes `missing: error | absent | neutral | `, default `error`. A value has the parameter's dtype; `inf` and `.inf` are one number. - A relation with `values:` takes `missing: error | absent`, default `error`. A bare relation takes none. - A variable takes `missing: absent | neutral`, the old `absence:`. - A parameter a piecewise block reads takes no `missing:`; the expansion declares it `neutral` where the curve has `points:`. - The exclusivity prover reads a value in a comparison at a missing row. - The PyPSA examples mark their sparse tables `neutral`, and the two partial outage maps `absent`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiDTE4WvKobVxTDxUeWhc9 --- CHANGELOG.md | 2 +- docs/examples/library/composed.md | 4 +- docs/examples/pypsa/carrier.md | 1 + docs/examples/pypsa/generator.md | 6 + docs/examples/pypsa/generator_commitment.md | 1 + docs/examples/pypsa/generator_maintenance.md | 9 +- docs/examples/pypsa/generator_ramping.md | 3 + docs/examples/pypsa/line.md | 8 +- docs/examples/pypsa/link.md | 6 + docs/examples/pypsa/link_commitment.md | 1 + docs/examples/pypsa/link_maintenance.md | 9 +- docs/examples/pypsa/link_ramping.md | 3 + docs/examples/pypsa/process.md | 4 + docs/examples/pypsa/process_commitment.md | 1 + docs/examples/pypsa/process_maintenance.md | 9 +- docs/examples/pypsa/process_ramping.md | 3 + docs/examples/pypsa/security.md | 4 + docs/examples/pypsa/settings.md | 1 + docs/examples/pypsa/storage_unit.md | 10 +- docs/examples/pypsa/store.md | 6 + docs/examples/pypsa/transformer.md | 8 +- docs/reference/glossary.md | 7 +- docs/reference/language/absence.md | 66 +++++---- docs/reference/language/declarations.md | 58 +++++--- docs/reference/language/expressions.md | 8 +- docs/reference/language/index.md | 2 +- docs/reference/language/named.md | 2 +- docs/reference/language/piecewise.md | 11 +- docs/reference/language/relations.md | 36 ++++- docs/reference/notation.md | 4 +- docs/reference/reading.md | 18 ++- examples/pypsa.yaml | 93 +++++++++--- examples/pypsa/carrier.yaml | 1 + examples/pypsa/generator.yaml | 6 + examples/pypsa/generator_commitment.yaml | 1 + examples/pypsa/generator_maintenance.yaml | 9 +- examples/pypsa/generator_ramping.yaml | 3 + examples/pypsa/line.yaml | 8 +- examples/pypsa/link.yaml | 6 + examples/pypsa/link_commitment.yaml | 1 + examples/pypsa/link_maintenance.yaml | 9 +- examples/pypsa/link_ramping.yaml | 3 + examples/pypsa/process.yaml | 4 + examples/pypsa/process_commitment.yaml | 1 + examples/pypsa/process_maintenance.yaml | 9 +- examples/pypsa/process_ramping.yaml | 3 + examples/pypsa/security.yaml | 4 + examples/pypsa/settings.yaml | 1 + examples/pypsa/storage_unit.yaml | 10 +- examples/pypsa/store.yaml | 6 + examples/pypsa/transformer.yaml | 8 +- examples/pypsa_linearized_uc.yaml | 10 +- schema/mathspec.schema.json | 78 +++++++---- src/mathspec/lowering.py | 9 +- src/mathspec/piecewise.py | 16 ++- src/mathspec/program.py | 38 +++-- src/mathspec/resolution.py | 11 +- src/mathspec/spec.py | 132 ++++++++++++++---- src/mathspec/typesetting/legend.py | 10 +- src/mathspec/typesetting/walk.py | 4 +- src/mathspec/validation.py | 20 +++ .../expand/curve-adjacency-points/after.yaml | 4 +- tests/expand/curve-lp-points/after.yaml | 4 +- tests/test_exclusivity.py | 13 +- tests/test_expand.py | 59 +++++++- tests/test_schema.py | 3 +- tests/test_validation.py | 101 +++++++++++--- tests/typesetting/golden/model.yaml | 4 +- tests/typesetting/test_symbols.py | 13 +- 69 files changed, 765 insertions(+), 261 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c45b2797..ed95d47a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ it releases that version ([RELEASING.md](https://github.com/energy-models/mathsp ## Upcoming version -- feat(language): a parameter may declare the value a missing row reads as ([#810](https://github.com/energy-models/mathspec/pull/810)) +- feat(language): `missing:` says what a missing row means, and a table is complete unless the file says otherwise ([#810](https://github.com/energy-models/mathspec/pull/810)) ## 0.2.1 (2026-10-01) diff --git a/docs/examples/library/composed.md b/docs/examples/library/composed.md index 66c8eaaed..c98572f36 100644 --- a/docs/examples/library/composed.md +++ b/docs/examples/library/composed.md @@ -67,13 +67,13 @@ variables: Port_p: dims: [snapshot, port] domain: continuous - absence: undefined + missing: absent description: what a port puts into its bus in a snapshot, negative for a withdrawal Generator_p: dims: [snapshot, generator] bounds: {lower: 0.0, upper: Generator_p_nom} domain: continuous - absence: undefined + missing: absent description: '`Generator-p` — what a generator produces in a snapshot' constraints: Bus_nodal_balance: diff --git a/docs/examples/pypsa/carrier.md b/docs/examples/pypsa/carrier.md index aaa4e5623..1cbf8bd68 100644 --- a/docs/examples/pypsa/carrier.md +++ b/docs/examples/pypsa/carrier.md @@ -25,6 +25,7 @@ parameters: `multi_investment_periods` (`global_constraints.py:219-220`), so data prep feeds no value otherwise dims: [carrier] + missing: neutral Carrier_max_relative_growth: description: >- share of the previous period's additions that may be added on top — the diff --git a/docs/examples/pypsa/generator.md b/docs/examples/pypsa/generator.md index 4e4b82ab0..3025ca4e1 100644 --- a/docs/examples/pypsa/generator.md +++ b/docs/examples/pypsa/generator.md @@ -70,6 +70,7 @@ parameters: Generator_p_nom_mod: description: the module size a build comes in whole numbers of; no value means the build is continuous dims: [generator] + missing: neutral Generator_modules_installed: description: >- how many whole modules a committable build has in place: `Generator_p_nom @@ -100,6 +101,7 @@ parameters: Generator_p_set: description: a given output schedule; a generator without one has no row here dims: [scenario, snapshot, generator] + missing: neutral Generator_p_nom_min: description: least nominal power an extendable generator may be built at dims: [scenario, generator] @@ -112,6 +114,7 @@ parameters: Generator_p_nom_set: description: a given nominal power for an extendable generator; one without a value has no row here dims: [scenario, generator] + missing: neutral Generator_e_sum_min: description: least energy over the horizon; minus infinity where no floor is meant dims: [scenario, generator] @@ -124,15 +127,18 @@ parameters: `co2_emissions` over the generator's efficiency at the snapshot, data prep; a generator of an unweighted carrier has no row dims: [scenario, global_constraint, snapshot, generator] + missing: neutral Generator_operational_limit_weight: description: one where the generator is in the row's set — data prep; one outside it has no row dims: [scenario, global_constraint, generator] + missing: neutral Generator_tech_capacity_weight: description: >- one where the generator is in the row's carrier-and-bus set — data prep; one outside it, or one that does not stand in the row's `investment_period`, has no row dims: [global_constraint, generator] + missing: neutral variables: Generator_p: diff --git a/docs/examples/pypsa/generator_commitment.md b/docs/examples/pypsa/generator_commitment.md index 2384d6f35..2798154fa 100644 --- a/docs/examples/pypsa/generator_commitment.md +++ b/docs/examples/pypsa/generator_commitment.md @@ -70,6 +70,7 @@ parameters: Generator_big_m: description: a bound safely above any feasible output — the build cap at full availability, data prep dims: [scenario, generator] + missing: neutral variables: Generator_status: diff --git a/docs/examples/pypsa/generator_maintenance.md b/docs/examples/pypsa/generator_maintenance.md index de4168778..11e455829 100644 --- a/docs/examples/pypsa/generator_maintenance.md +++ b/docs/examples/pypsa/generator_maintenance.md @@ -50,6 +50,7 @@ parameters: `Generator_maintenance_cover` and `Generator_maintenance_start_blocked`, and the assumptions hold it to the horizon dims: [scenario, generator] + missing: neutral Generator_maintenance_start_blocked: description: >- true where no maintenance event may start, because the snapshots it @@ -66,7 +67,7 @@ variables: continuous, and one exactly where an event covers the snapshot dims: [scenario, snapshot, generator] where: Generator_maintainable AND Generator_active - absence: zero + missing: neutral bounds: lower: 0 upper: 1 @@ -74,7 +75,7 @@ variables: description: "`Generator-maintenance_start` — whether a maintenance event starts in this snapshot" dims: [scenario, snapshot, generator] where: Generator_maintainable AND Generator_active - absence: zero + missing: neutral domain: binary Generator_maintenance_capacity: description: >- @@ -84,7 +85,7 @@ variables: where: >- Generator_maintainable AND Generator_p_nom_extendable AND NOT (Generator_committable AND Generator_p_nom_mod > 0) AND Generator_active - absence: zero + missing: neutral bounds: lower: 0 Generator_maintenance_status: @@ -96,7 +97,7 @@ variables: where: >- Generator_maintainable AND Generator_committable AND NOT (Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0)) AND Generator_active - absence: zero + missing: neutral bounds: lower: 0 diff --git a/docs/examples/pypsa/generator_ramping.md b/docs/examples/pypsa/generator_ramping.md index bcb55e430..d527c74c0 100644 --- a/docs/examples/pypsa/generator_ramping.md +++ b/docs/examples/pypsa/generator_ramping.md @@ -31,9 +31,11 @@ parameters: Generator_ramp_limit_up: description: most a generator may raise its output between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time dims: [scenario, snapshot, generator] + missing: neutral Generator_ramp_limit_down: description: most a generator may lower its output between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time dims: [scenario, snapshot, generator] + missing: neutral Generator_ramp_limit_start_up: description: most output in the snapshot a unit starts, per unit of nominal power dims: [scenario, generator] @@ -46,6 +48,7 @@ parameters: only where the unit came in running; no value means it is unknown, so the unit carries no ramp row at the first snapshot dims: [scenario, generator] + missing: neutral given: parameters: diff --git a/docs/examples/pypsa/line.md b/docs/examples/pypsa/line.md index 7d980d8f8..c17ca2a4b 100644 --- a/docs/examples/pypsa/line.md +++ b/docs/examples/pypsa/line.md @@ -86,15 +86,18 @@ parameters: Line_s_nom_set: description: a given nominal apparent power for an extendable line; one without a value has no row here dims: [scenario, line] + missing: neutral Line_s_set: description: a given flow schedule; a line without one has no row here dims: [scenario, snapshot, line] + missing: neutral Line_cycle_weight: description: >- the line's series impedance, signed by its orientation in the cycle — the cycle basis, data prep; a line in no cycle has no row. PyPSA builds the cycle basis from the first scenario only (`networks.py:1354-1361`) dims: [line, cycle] + missing: neutral Line_loss_max: description: the loss at a line's rating — PyPSA's `r_pu_eff * (s_max_pu * s_nom_max)**2`, data prep dims: [scenario, snapshot, line] @@ -117,6 +120,7 @@ parameters: line outside it, or one that does not stand in the row's `investment_period`, has no row dims: [scenario, global_constraint, line] + missing: neutral Line_expansion_cost_weight: description: >- the line's capital cost where its carrier is in the row's set, times @@ -125,12 +129,14 @@ parameters: line outside the set, or one that does not stand in the row's period, has no row dims: [scenario, global_constraint, line] + missing: neutral Line_tech_capacity_weight: description: >- one where the line is in the row's carrier-and-bus set — data prep; one outside it, or one that does not stand in the row's `investment_period`, has no row dims: [global_constraint, line] + missing: neutral variables: Line_s: @@ -146,7 +152,7 @@ variables: the network is lossless dims: [scenario, snapshot, line] where: transmission_losses AND Line_active - absence: zero + missing: neutral bounds: lower: 0 Line_s_nom_ext: diff --git a/docs/examples/pypsa/link.md b/docs/examples/pypsa/link.md index 77f20182d..ffe1179b7 100644 --- a/docs/examples/pypsa/link.md +++ b/docs/examples/pypsa/link.md @@ -106,6 +106,7 @@ parameters: Link_p_nom_mod: description: the module size a build comes in whole numbers of; no value means the build is continuous dims: [link] + missing: neutral Link_modules_installed: description: >- how many whole modules a committable build has in place: `Link_p_nom @@ -136,6 +137,7 @@ parameters: Link_p_set: description: a given flow schedule; a link without one has no row here dims: [scenario, snapshot, link] + missing: neutral Link_p_nom_min: description: least nominal power an extendable link may be built at dims: [scenario, link] @@ -148,6 +150,7 @@ parameters: Link_p_nom_set: description: a given nominal power for an extendable link; one without a value has no row here dims: [scenario, link] + missing: neutral Link_volume_weight: description: >- the link's length where its carrier is in the row's set, the first @@ -156,6 +159,7 @@ parameters: link outside it, or one that does not stand in the row's `investment_period`, has no row dims: [scenario, global_constraint, link] + missing: neutral Link_expansion_cost_weight: description: >- the link's capital cost where its carrier is in the row's set, times @@ -164,12 +168,14 @@ parameters: link outside the set, or one that does not stand in the row's period, has no row dims: [scenario, global_constraint, link] + missing: neutral Link_tech_capacity_weight: description: >- one where the link is in the row's carrier-and-bus set — data prep; one outside it, or one that does not stand in the row's `investment_period`, has no row dims: [global_constraint, link] + missing: neutral variables: Link_p: diff --git a/docs/examples/pypsa/link_commitment.md b/docs/examples/pypsa/link_commitment.md index 0f76a073c..7f036c9e4 100644 --- a/docs/examples/pypsa/link_commitment.md +++ b/docs/examples/pypsa/link_commitment.md @@ -70,6 +70,7 @@ parameters: Link_big_m: description: a bound safely above any feasible flow — the build cap at full availability, data prep dims: [scenario, link] + missing: neutral variables: Link_status: diff --git a/docs/examples/pypsa/link_maintenance.md b/docs/examples/pypsa/link_maintenance.md index d8b5ed3de..551ac7f80 100644 --- a/docs/examples/pypsa/link_maintenance.md +++ b/docs/examples/pypsa/link_maintenance.md @@ -50,6 +50,7 @@ parameters: `Link_maintenance_cover` and `Link_maintenance_start_blocked`, and the assumptions hold it to the horizon dims: [scenario, link] + missing: neutral Link_maintenance_start_blocked: description: >- true where no maintenance event may start, because the snapshots it @@ -66,7 +67,7 @@ variables: continuous, and one exactly where an event covers the snapshot dims: [scenario, snapshot, link] where: Link_maintainable AND Link_active - absence: zero + missing: neutral bounds: lower: 0 upper: 1 @@ -74,7 +75,7 @@ variables: description: "`Link-maintenance_start` — whether a maintenance event starts in this snapshot" dims: [scenario, snapshot, link] where: Link_maintainable AND Link_active - absence: zero + missing: neutral domain: binary Link_maintenance_capacity: description: >- @@ -84,7 +85,7 @@ variables: where: >- Link_maintainable AND Link_p_nom_extendable AND NOT (Link_committable AND Link_p_nom_mod > 0) AND Link_active - absence: zero + missing: neutral bounds: lower: 0 Link_maintenance_status: @@ -96,7 +97,7 @@ variables: where: >- Link_maintainable AND Link_committable AND NOT (Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0)) AND Link_active - absence: zero + missing: neutral bounds: lower: 0 diff --git a/docs/examples/pypsa/link_ramping.md b/docs/examples/pypsa/link_ramping.md index 0b55db30b..163e2c484 100644 --- a/docs/examples/pypsa/link_ramping.md +++ b/docs/examples/pypsa/link_ramping.md @@ -31,9 +31,11 @@ parameters: Link_ramp_limit_up: description: most a link may raise its flow between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time dims: [scenario, snapshot, link] + missing: neutral Link_ramp_limit_down: description: most a link may lower its flow between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time dims: [scenario, snapshot, link] + missing: neutral Link_ramp_limit_start_up: description: most flow in the snapshot a link starts, per unit of nominal power dims: [scenario, link] @@ -46,6 +48,7 @@ parameters: only where the link came in running; no value means it is unknown, so the link carries no ramp row at the first snapshot dims: [scenario, link] + missing: neutral given: parameters: diff --git a/docs/examples/pypsa/process.md b/docs/examples/pypsa/process.md index 00711b99b..4b55b8efe 100644 --- a/docs/examples/pypsa/process.md +++ b/docs/examples/pypsa/process.md @@ -101,6 +101,7 @@ parameters: Process_p_set: description: a given internal power schedule; a process without one has no row here dims: [scenario, snapshot, process] + missing: neutral Process_p_nom_min: description: least nominal power an extendable process may be built at dims: [scenario, process] @@ -113,9 +114,11 @@ parameters: Process_p_nom_set: description: a given nominal power for an extendable process; one without a value has no row here dims: [scenario, process] + missing: neutral Process_p_nom_mod: description: the module size a build comes in whole numbers of; no value means the build is continuous dims: [process] + missing: neutral Process_modules_installed: description: >- how many whole modules a committable build has in place: `Process_p_nom @@ -149,6 +152,7 @@ parameters: outside it, or one that does not stand in the row's `investment_period`, has no row dims: [global_constraint, process] + missing: neutral variables: Process_p: diff --git a/docs/examples/pypsa/process_commitment.md b/docs/examples/pypsa/process_commitment.md index f2087dc24..5ab2c9079 100644 --- a/docs/examples/pypsa/process_commitment.md +++ b/docs/examples/pypsa/process_commitment.md @@ -70,6 +70,7 @@ parameters: Process_big_m: description: a bound safely above any feasible internal power — the build cap at full availability, data prep dims: [scenario, process] + missing: neutral variables: Process_status: diff --git a/docs/examples/pypsa/process_maintenance.md b/docs/examples/pypsa/process_maintenance.md index 0c223e1b2..4470fe14f 100644 --- a/docs/examples/pypsa/process_maintenance.md +++ b/docs/examples/pypsa/process_maintenance.md @@ -50,6 +50,7 @@ parameters: `Process_maintenance_cover` and `Process_maintenance_start_blocked`, and the assumptions hold it to the horizon dims: [scenario, process] + missing: neutral Process_maintenance_start_blocked: description: >- true where no maintenance event may start, because the snapshots it @@ -66,7 +67,7 @@ variables: continuous, and one exactly where an event covers the snapshot dims: [scenario, snapshot, process] where: Process_maintainable AND Process_active - absence: zero + missing: neutral bounds: lower: 0 upper: 1 @@ -74,7 +75,7 @@ variables: description: "`Process-maintenance_start` — whether a maintenance event starts in this snapshot" dims: [scenario, snapshot, process] where: Process_maintainable AND Process_active - absence: zero + missing: neutral domain: binary Process_maintenance_capacity: description: >- @@ -84,7 +85,7 @@ variables: where: >- Process_maintainable AND Process_p_nom_extendable AND NOT (Process_committable AND Process_p_nom_mod > 0) AND Process_active - absence: zero + missing: neutral bounds: lower: 0 Process_maintenance_status: @@ -96,7 +97,7 @@ variables: where: >- Process_maintainable AND Process_committable AND NOT (Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0)) AND Process_active - absence: zero + missing: neutral bounds: lower: 0 diff --git a/docs/examples/pypsa/process_ramping.md b/docs/examples/pypsa/process_ramping.md index ab830860c..810dd2e28 100644 --- a/docs/examples/pypsa/process_ramping.md +++ b/docs/examples/pypsa/process_ramping.md @@ -31,9 +31,11 @@ parameters: Process_ramp_limit_up: description: most a process may raise its internal power between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time dims: [scenario, snapshot, process] + missing: neutral Process_ramp_limit_down: description: most a process may lower its internal power between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time dims: [scenario, snapshot, process] + missing: neutral Process_ramp_limit_start_up: description: most internal power in the snapshot a process starts, per unit of nominal power dims: [scenario, process] @@ -46,6 +48,7 @@ parameters: only where the process came in running; no value means it is unknown, so the process carries no ramp row at the first snapshot dims: [scenario, process] + missing: neutral given: parameters: diff --git a/docs/examples/pypsa/security.md b/docs/examples/pypsa/security.md index 63c13034c..ac0719c7b 100644 --- a/docs/examples/pypsa/security.md +++ b/docs/examples/pypsa/security.md @@ -30,10 +30,12 @@ relations: description: the line an outage takes out; an outage of a transformer has no row key: outage values: line + missing: absent Outage_transformer: description: the transformer an outage takes out; an outage of a line has no row key: outage values: transformer + missing: absent parameters: Line_BODF: @@ -43,12 +45,14 @@ parameters: only where the line and the outage share a sub-network, -1 at the outaged line itself dims: [line, outage] + missing: neutral Transformer_BODF: description: >- the share of an outaged branch's flow a transformer takes on when that branch goes out, as a line's; a row only where the transformer and the outage share a sub-network dims: [transformer, outage] + missing: neutral given: parameters: diff --git a/docs/examples/pypsa/settings.md b/docs/examples/pypsa/settings.md index ab633b467..223faac7c 100644 --- a/docs/examples/pypsa/settings.md +++ b/docs/examples/pypsa/settings.md @@ -75,6 +75,7 @@ parameters: on a row that names a period without it (`global_constraints.py:375`) dims: [scenario, global_constraint, snapshot] dtype: bool + missing: neutral given: expressions: diff --git a/docs/examples/pypsa/storage_unit.md b/docs/examples/pypsa/storage_unit.md index f02ac6e1b..c92dd564a 100644 --- a/docs/examples/pypsa/storage_unit.md +++ b/docs/examples/pypsa/storage_unit.md @@ -68,6 +68,7 @@ parameters: StorageUnit_p_nom_set: description: a given nominal power for an extendable storage unit; one without a value has no row here dims: [scenario, storage_unit] + missing: neutral StorageUnit_p_nom: description: nominal power dims: [scenario, storage_unit] @@ -154,27 +155,34 @@ parameters: StorageUnit_p_set: description: a given net dispatch schedule; a unit without one has no row here dims: [scenario, snapshot, storage_unit] + missing: neutral StorageUnit_p_dispatch_set: description: a given dispatch schedule; a unit without one has no row here dims: [scenario, snapshot, storage_unit] + missing: neutral StorageUnit_p_store_set: description: a given charging schedule; a unit without one has no row here dims: [scenario, snapshot, storage_unit] + missing: neutral StorageUnit_state_of_charge_set: description: a given charge schedule; a unit without one has no row here dims: [scenario, snapshot, storage_unit] + missing: neutral StorageUnit_primary_energy_weight: description: the constrained attribute per unit of charge depleted — data prep; an unweighted unit has no row dims: [scenario, global_constraint, storage_unit] + missing: neutral StorageUnit_operational_limit_weight: description: one where the storage unit is in the row's set — data prep; one outside it has no row dims: [scenario, global_constraint, storage_unit] + missing: neutral StorageUnit_tech_capacity_weight: description: >- one where the storage unit is in the row's carrier-and-bus set — data prep; one outside it, or one that does not stand in the row's `investment_period`, has no row dims: [global_constraint, storage_unit] + missing: neutral variables: StorageUnit_p_dispatch: @@ -196,7 +204,7 @@ variables: the variable rather than as rows dims: [scenario, snapshot, storage_unit] where: "StorageUnit_inflow > 0 AND StorageUnit_active" - absence: zero + missing: neutral bounds: lower: 0 upper: StorageUnit_inflow diff --git a/docs/examples/pypsa/store.md b/docs/examples/pypsa/store.md index 7c3504361..c637109d7 100644 --- a/docs/examples/pypsa/store.md +++ b/docs/examples/pypsa/store.md @@ -68,6 +68,7 @@ parameters: Store_e_nom_set: description: a given nominal capacity for an extendable store; one without a value has no row here dims: [scenario, store] + missing: neutral Store_e_nom: description: nominal energy capacity dims: [scenario, store] @@ -139,21 +140,26 @@ parameters: Store_e_set: description: a given energy schedule; a store without one has no row here dims: [scenario, snapshot, store] + missing: neutral Store_p_set: description: a given schedule of power delivered; a store without one has no row here dims: [scenario, snapshot, store] + missing: neutral Store_primary_energy_weight: description: the constrained attribute per unit of energy depleted — data prep; an unweighted store has no row dims: [scenario, global_constraint, store] + missing: neutral Store_operational_limit_weight: description: one where the store is in the row's set — data prep; one outside it has no row dims: [scenario, global_constraint, store] + missing: neutral Store_tech_capacity_weight: description: >- one where the store is in the row's carrier-and-bus set — data prep; one outside it, or one that does not stand in the row's `investment_period`, has no row dims: [global_constraint, store] + missing: neutral variables: Store_e: diff --git a/docs/examples/pypsa/transformer.md b/docs/examples/pypsa/transformer.md index c8c87e20f..1b303ccb6 100644 --- a/docs/examples/pypsa/transformer.md +++ b/docs/examples/pypsa/transformer.md @@ -68,9 +68,11 @@ parameters: Transformer_s_nom_set: description: a given nominal apparent power for an extendable transformer; one without a value has no row here dims: [scenario, transformer] + missing: neutral Transformer_s_set: description: a given flow schedule; a transformer without one has no row here dims: [scenario, snapshot, transformer] + missing: neutral Transformer_cycle_weight: description: >- the transformer's effective series reactance, `x` times its tap ratio, @@ -78,6 +80,7 @@ parameters: basis, data prep; a transformer in no cycle has no row. From the first scenario only, as a line's dims: [transformer, cycle] + missing: neutral Transformer_phase_shift_weight: description: >- a fixed transformer's phase shift in radians at each snapshot, signed by @@ -86,6 +89,7 @@ parameters: the variable term never both count a shift. A transformer with no shift or in no cycle has no row dims: [snapshot, transformer, cycle] + missing: neutral Transformer_phase_shift_varying: description: >- whether a transformer's phase shift is a decision — PyPSA's @@ -145,7 +149,7 @@ variables: lossless dims: [scenario, snapshot, transformer] where: transmission_losses AND Transformer_active - absence: zero + missing: neutral bounds: lower: 0 Transformer_phase_shift: @@ -156,7 +160,7 @@ variables: where the shift is fixed dims: [scenario, snapshot, transformer] where: Transformer_phase_shift_varying AND Transformer_active - absence: zero + missing: neutral bounds: lower: Transformer_phase_shift_min upper: Transformer_phase_shift_max diff --git a/docs/reference/glossary.md b/docs/reference/glossary.md index 70b42029e..075e30836 100644 --- a/docs/reference/glossary.md +++ b/docs/reference/glossary.md @@ -82,9 +82,10 @@ on a declaration, and the coordinates it admits. row that reads it is not built ([absence](language/absence.md)). **Missing row** -: A coordinate that a parameter's table has no row for. It is not absence: it -reads as `0` in arithmetic and as false in a `where` -([what creates absence](language/absence.md#what-creates-absence)). +: A coordinate that a parameter's or a relation's table has no row for. Its +`missing:` says what it is: refused (`error`, the default), absence (`absent`), +`0` in arithmetic and false in a `where` (`neutral`), or a value +([a missing row](language/declarations.md#a-missing-row)). ## Kinds of construct diff --git a/docs/reference/language/absence.md b/docs/reference/language/absence.md index 78aeb3dd5..6dcb8edb6 100644 --- a/docs/reference/language/absence.md +++ b/docs/reference/language/absence.md @@ -26,25 +26,34 @@ The [grammar](expressions.md#where-strings) says what a `where:` may hold. ## What creates absence -| Construct | What is absent | -| --------------------------------------------- | --------------------------------------------------------------------------- | -| `where:` on a variable | the variable, at the masked coordinates | -| `where:` on a constraint | the row | -| `shift(x, along=d, offset=n)` without `edge=` | the vacated edge coordinate ([shift](operators.md#shift)) | -| a label a relation does not map | that label's group membership ([relations](relations.md#the-data-contract)) | - -Nothing else creates absence. **A missing parameter row is not absence.** It -reads as the parameter's [`default:`](declarations.md#a-default) where it -declares one. Otherwise it reads as the value that contributes nothing: `0` as -a coefficient, and `false` in a `where`. -A missing row is the only gap a parameter has: a null or NaN value is -[refused](declarations.md#parameters) when the data is attached. - -Where no such value exists, loading is refused. There are four such positions: -a divisor, a `bounds:` entry, the whole constant side of a comparison, and a -[`piecewise:`](piecewise.md) breakpoint. A parameter with a `default:` has a -value in the first three. For a bound only where the data has one, declare -`default: .inf`, supply the bound in the data, where `inf` is a value, or mask +| Construct | What is absent | +| ------------------------------------------------- | --------------------------------------------------------------------------- | +| `where:` on a variable | the variable, at the masked coordinates | +| `where:` on a constraint | the row | +| `shift(x, along=d, offset=n)` without `edge=` | the vacated edge coordinate ([shift](operators.md#shift)) | +| a label a `missing: absent` relation does not map | that label's group membership ([relations](relations.md#the-data-contract)) | +| a missing row of a `missing: absent` parameter | the parameter, at that coordinate | + +Nothing else creates absence. A missing row is the only gap a parameter has: a +null or NaN value is [refused](declarations.md#parameters) when the data is +attached. **The parameter's [`missing:`](declarations.md#a-missing-row) says +what a missing row is:** + +| `missing:` | A missing row | +| ------------------- | ------------------------------------------------------------------------------------------- | +| `error`, by default | is refused when the data is attached | +| `absent` | is absence, by the rules on this page | +| `neutral` | reads as the value that contributes nothing: `0` as a coefficient, and `false` in a `where` | +| a value | reads as that value | + +`neutral` has no value in three positions: a divisor, a `bounds:` entry, and +the whole constant side of a comparison. A missing row there is refused when +the data is attached. A [`piecewise:`](piecewise.md) block owns the parameters +it reads, so they take no `missing:`, and a curve needs a row at each breakpoint +its `points:` admits. Under +`absent`, a missing divisor or constant side is absence and takes the row, and a +missing bound leaves that side of the variable open. For a bound only where the +data has one, declare the parameter `missing: absent` or `missing: .inf`, or mask the variable. ## How absence travels @@ -80,9 +89,10 @@ constraints: expression: x - rel_max * y <= 0 ``` -Where the variable `y` is masked, the row is gone. Where the parameter `rel_max` -has no row, it reads as `0`, and the row stands as `x <= 0`. To drop the row -there instead, write `where: rel_max` on the constraint. +Where the variable `y` is masked, the row is gone. Where a `missing: neutral` +parameter `rel_max` has no row, it reads as `0`, and the row stands as +`x <= 0`. To drop the row there instead, declare `rel_max` `missing: absent`, or +write `where: rel_max` on the constraint. | Operator | An output slot reads | An absent input | | ------------------------------------- | ------------------------------- | ------------------------------------ | @@ -103,10 +113,10 @@ variables: spill: dims: [storage] where: has_inflow - absence: zero # outside the mask spill is 0 and the row stands + missing: neutral # outside the mask spill is 0 and the row stands soc: dims: [storage] - where: has_store # the default, absence: undefined — no row + where: has_store # the default, missing: absent — no row constraints: balance: dims: [storage] @@ -116,11 +126,11 @@ constraints: At a storage with a store and no inflow, `balance` reads `inflow - soc == 0`. At a storage with inflow and no store, there is no row. -`absence: zero` needs a `where:`. It changes nothing inside a summing operator. +`missing: neutral` needs a `where:`. It changes nothing inside a summing operator. ## Rows with no variable terms -A missing parameter row can leave a row with nothing to decide, such as +A `neutral` parameter row can leave a row with nothing to decide, such as `0 == load` at a bus with no generator. Such a row is not built. An expression that names no variable _in the file_ is refused at load. @@ -131,6 +141,6 @@ the solved numbers it reads, by the rules above. A quotient is absent where its divisor is absent, and where its divisor is exactly zero, whether a solve or the data gave that zero. A solved value is read as the engine reads it back, and an engine may read a value within its solver's tolerance of zero as zero. -A divisor parameter with a missing row where the quotient is read is still -refused, because a missing row is not absence. A deleted row has +A divisor parameter with a missing row where the quotient is read is refused, +unless the parameter is `missing: absent` or names a value. A deleted row has [no dual](named.md#reading-a-constraints-dual). diff --git a/docs/reference/language/declarations.md b/docs/reference/language/declarations.md index 2cd6483dc..b9703d41f 100644 --- a/docs/reference/language/declarations.md +++ b/docs/reference/language/declarations.md @@ -27,7 +27,7 @@ parameters: | ------------- | -------------------------------------------------------------- | --------------- | | `dims` | required. The dimensions it is indexed by. `[]` means a scalar | | | `dtype` | `float`, `int`, `bool`, `str` | default `float` | -| `default` | the value a missing row reads as ([a default](#a-default)) | default `null` | +| `missing` | what a missing row means ([a missing row](#a-missing-row)) | default `error` | | `description` | free text | default `null` | The column has to match the `dtype`: @@ -48,40 +48,52 @@ parameter is a mask: each selects rows in a a term or a divisor is a load error. A `0` or `1` that is meant to be multiplied by is declared `dtype: int`. -### A default +### A missing row -`default:` is the value a missing row reads as. +`missing:` says what a coordinate the `dims` reach with no row means. A table +that lost a row in preparation and a table that never had one look the same in +the data, so the file says which was meant. ```yaml dimensions: generator: { dtype: str } parameters: - efficiency: { dims: [generator], default: 1 } - p_nom_max: { dims: [generator], default: .inf } - active: { dims: [generator], dtype: bool, default: true } + cost: { dims: [generator] } # error: every generator has a cost + ramp_limit: { dims: [generator], missing: neutral } # no row means no limit + p_set: { dims: [generator], missing: absent } # no row, no fixing row + efficiency: { dims: [generator], missing: 1 } + p_nom_max: { dims: [generator], missing: .inf } + active: { dims: [generator], dtype: bool, missing: true } ``` -Where the data has no row, every position that reads a value reads the -default: a coefficient, a term, a divisor, a bound, and a comparison in a -`where`. A bare numeric name in a `where` still asks whether the data has a -row, so `where: p_nom_max` selects the rows the data gives. A bare `bool` name -reads its value, so `where: active` reads the default. A -[curve](assumptions.md#what-a-curve-assumes) still needs a row at each -breakpoint. +| `missing:` | A missing row | +| ------------------- | ----------------------------------------------------------------------------------------------------- | +| `error`, by default | is refused when the data is attached, and the refusal names the coordinate | +| `absent` | is [absence](absence.md): it takes the row of a term that reads it, and is one summand fewer in a sum | +| `neutral` | reads as the value that contributes nothing: `0` as a coefficient, and `false` in a `where` | +| a value | reads as that value, wherever a value is read | -| `dtype` | `default:` takes | +A bare numeric name in a `where` asks whether the data has a row under every +reading, so `where: p_nom_max` selects the rows the data gives. A comparison +reads the value: `where: efficiency < 1` is true at a missing row of the +parameter above. Under `absent` and `neutral`, a comparison at a missing row is +false. A bare `bool` name reads its value, so `where: active` reads `true` +there. + +A value has the parameter's dtype: + +| `dtype` | a value | | ------- | ------------------------------------------------------- | | `float` | a number. `.inf`, `inf`, `-.inf` and `-inf` are numbers | -| `int` | an integer | +| `int` | an integer. An integer column cannot hold `inf` | | `bool` | `true` or `false` | -| `str` | nothing. A label has no default | - -A NaN and a quoted number are refused. `default: null` is no default. Without -a default, a missing row reads as the value that contributes nothing -([absence](absence.md#what-creates-absence)). +| `str` | none. A label has no value to fill | -A `given:` parameter has no `default:`. The file that declares the parameter -owns it. The typeset legend prints the default beside the parameter. +A NaN, a quoted number and `missing: null` are refused. A `given:` parameter +has no `missing:`. The file that declares the parameter owns it. A parameter a +[`piecewise:`](piecewise.md) block reads takes no `missing:` either: the block +owns the shape of its curve. The typeset legend prints a value beside the +parameter. ## `variables` @@ -109,7 +121,7 @@ variables: | `where` | which coordinates exist ([absence](absence.md)) | default `null` | | `bounds.lower` / `bounds.upper` | a finite number, or the name of a `float` or `int` parameter. `null` leaves that side open | default `null` | | `domain` | `continuous`, `integer` or `binary`. `binary` carries fixed 0/1 bounds | default `continuous` | -| `absence` | `undefined` or `zero`: what a masked-out coordinate means ([absence](absence.md#what-a-missing-coordinate-means)) | default `undefined` | +| `missing` | `absent` or `neutral`: what a masked-out coordinate means ([absence](absence.md#what-a-missing-coordinate-means)) | default `absent` | | `description` | free text | default `null` | An open side is `null`. A bound is never infinite: `.inf` and `-.inf` are diff --git a/docs/reference/language/expressions.md b/docs/reference/language/expressions.md index aeb7fee0f..6b599180a 100644 --- a/docs/reference/language/expressions.md +++ b/docs/reference/language/expressions.md @@ -147,7 +147,7 @@ QUOTED ::= "'" chars "'" | '"' chars '"' | `name` (bare) | variable | The variable exists at this coordinate | | `name` (bare) | relation | A row exists, read at the relation's key. A relation may be [partial](relations.md#the-data-contract), and this selects the labels that do map | | `name` (bare) | dimension | A load error. It would be true everywhere | -| `name OP value` | parameter | Element-wise. A missing row with no `default:` compares false | +| `name OP value` | parameter | Element-wise. A missing row with no `missing:` value compares false | | `name OP value` | dimension | A filter on the frame's own coordinate column | | `name OP value`, `name.col OP value` | relation | A filter on a value column, read at the relation's key. Name the column where the key determines several | | `name OP name`, `name.a OP name.b` | two relation columns | Legal where both relations are keyed over the same dimensions and both columns are over one dimension. `ends.bus0 != ends.bus1` excludes a self-loop | @@ -162,9 +162,9 @@ QUOTED ::= "'" chars "'" | '"' chars '"' A bare name that is not declared is a load error. -A missing row reads the parameter's [`default:`](declarations.md#a-default) in -a comparison and in a bare `bool` name. A bare numeric name still asks whether -the data has a row, and a default is not a row. +A comparison at a missing row reads the value the parameter's +[`missing:`](declarations.md#a-missing-row) names, as does a bare `bool` name. +Under every reading, a bare numeric name asks whether the data has a row. ### Counting what a predicate admits diff --git a/docs/reference/language/index.md b/docs/reference/language/index.md index db5131161..758405e40 100644 --- a/docs/reference/language/index.md +++ b/docs/reference/language/index.md @@ -53,6 +53,6 @@ names the fix. | 5 | `a + b` carries the dimensions of `a` and of `b` together. A constraint's expression carries exactly its `dims`, and the objective carries none. | [How dimensions combine](expressions.md#how-dimensions-combine) | | 6 | A variable's `where:` deletes the variable at the masked coordinates. A constraint's `where:` deletes the row. | [Absence](absence.md) | | 7 | A deleted variable takes its row with it. Inside a `sum` it is one term fewer, and the row stays. | [Absence](absence.md#how-absence-travels) | -| 8 | A parameter row missing from the table reads as `0` in arithmetic and as false in a `where`. Where `0` would change the meaning, the row is refused. | [Absence](absence.md#what-creates-absence) | +| 8 | A row missing from a table is refused, unless the declaration's `missing:` says it is absent, neutral, or a value. | [Absence](absence.md#what-creates-absence) | | 9 | Two variables may be multiplied in the objective and in a constraint, and nowhere else. `x / y` and `a ** b` need their divisor, base and exponent free of variables. | [Expressions](expressions.md) | | 10 | The operators are `sum`, `sum_back`, `at` and `shift`, plus `dual` in a reported expression. A file cannot add one. | [Operators](operators.md) | diff --git a/docs/reference/language/named.md b/docs/reference/language/named.md index 30bbc682b..21eecb549 100644 --- a/docs/reference/language/named.md +++ b/docs/reference/language/named.md @@ -124,7 +124,7 @@ A named expression carries **exactly one** of `expression:` and `cases:`. A claimed coordinate can still have no value: the `otherwise:` above has none at the first snapshot, where a case claims every unit. To close such a hole, -widen a `when`, give the `shift` an `edge=`, or set `absence: zero` on the +widen a `when`, give the `shift` an `edge=`, or set `missing: neutral` on the masked variable. `cases:` is not accepted inside a `macros:` template. diff --git a/docs/reference/language/piecewise.md b/docs/reference/language/piecewise.md index b48f17716..669b2dbdb 100644 --- a/docs/reference/language/piecewise.md +++ b/docs/reference/language/piecewise.md @@ -70,13 +70,13 @@ variables: ``` Where the gate does not exist, the curve is ungated. To have no curve there -instead, put `absence: zero` on the gate. +instead, put `missing: neutral` on the gate. ### `points` -A values parameter short of a row does not build a shorter curve: the missing -row reads as a breakpoint at the origin. A curve with fewer breakpoints than the -dimension holds says so with `points:`. Name one of the block's own values +A values parameter short of a row does not build a shorter curve: the block's +[assumption](assumptions.md#what-a-curve-assumes) refuses the data. A curve with +fewer breakpoints than the dimension holds says so with `points:`. Name one of the block's own values parameters, and the curve is as long as that parameter has rows: ```yaml @@ -172,6 +172,7 @@ shows a spec before and after. - **Every name written out starts with the name of the block.** The weights of the curve `curve` are `curve_lam`. - **No formulation emits a parameter.** The same data attaches to a spec and its - expansion. + expansion. A curve under `points:` declares the parameters it reads + `missing: neutral`, because its rows read them only where the mask holds. - **The assumptions a `method:` implies become `assumptions:` entries** with the same names. diff --git a/docs/reference/language/relations.md b/docs/reference/language/relations.md index 45f3c6627..63081216b 100644 --- a/docs/reference/language/relations.md +++ b/docs/reference/language/relations.md @@ -28,11 +28,12 @@ The **key** is the combination of dimensions that is unique per row: what that row determines: its bus. With no `values:`, the key is every column, and the table is a **bare relation**. -| Field | | | -| ------------- | ---------------------------------------------------------------- | -------------- | -| `key` | required. The columns that identify a row | | -| `values` | the columns the key determines. Omitted, the key is every column | default none | -| `description` | free text | default `null` | +| Field | | | +| ------------- | ---------------------------------------------------------------- | --------------- | +| `key` | required. The columns that identify a row | | +| `values` | the columns the key determines. Omitted, the key is every column | default none | +| `missing` | `error` or `absent`: what a key the map leaves out means | default `error` | +| `description` | free text | default `null` | A column is named after its dimension. Where two columns share a dimension, the mapping form names them: `{bus0: bus, bus1: bus}`. @@ -62,12 +63,33 @@ column per declared column, named after it. is attached. - **Every value is a label of its dimension.** A value that matches none is refused. -- **A partial map is the rows it has.** A generator in no row sits on no bus, - which is [absence](absence.md). +- **A relation with `values:` has a row for every key coordinate.** A + generator the table leaves out is refused when the data is attached, and the + refusal names it. +- **Under `missing: absent`, a partial map is the rows it has.** A generator in + no row sits on no bus, which is [absence](absence.md): its terms land in no + group. - **A null in any column is refused.** - **Row order carries nothing.** The order is the [dimension's](dimensions.md). +A generator on no bus and a port nobody wired look the same in the data, so +`missing:` says which was meant: + +```yaml +dimensions: + generator: { dtype: str } + bus: { dtype: str } + line: { dtype: str } +relations: + gen_bus: { key: generator, values: bus } # error: every generator is on a bus + line_to: { key: line, values: bus, missing: absent } # an open end is meant +``` + +A label is data, so no value fills a gap in a map, and `neutral` is refused. A +bare relation takes no `missing:`: its rows are its membership, so a pair it +leaves out is not missing. + ## How a relation is used The declaration fixes no direction. A key column a call names at neither end diff --git a/docs/reference/notation.md b/docs/reference/notation.md index c7aeab79c..b9b22d4f5 100644 --- a/docs/reference/notation.md +++ b/docs/reference/notation.md @@ -47,11 +47,11 @@ relations: gen_bt: { key: generator, values: [bus, technology] } # one table with two value columns, read to both at once parameters: - p_max: { dims: [generator], default: .inf } # a default: the legend says what a missing row reads as + p_max: { dims: [generator], missing: .inf } # a value: the legend says what a missing row reads as p_min: { dims: [generator] } cost: { dims: [generator] } load: { dims: [snapshot, bus] } - is_flexible: { dims: [generator], dtype: bool, default: false } + is_flexible: { dims: [generator], dtype: bool, missing: false } zone_cap: { dims: [zone] } tech_cap: { dims: [bus, technology] } min_up: { dims: [generator], dtype: int } diff --git a/docs/reference/reading.md b/docs/reference/reading.md index 79df381b8..5e572d03f 100644 --- a/docs/reference/reading.md +++ b/docs/reference/reading.md @@ -119,12 +119,18 @@ written = assumption_message('cost_is_never_negative', program.assumptions['cost written # "assumption 'cost_is_never_negative' does not hold for the data attached to 'bp_y' — a negative cost is a gain the objective would chase" ``` -`program.parameters[name].default` is the value a missing row of that -parameter reads as, or `None`. Every position that reads a value reads it: an -expression, a bound, and a comparison in a mask. A `ParameterDefined` on a -numeric parameter asks whether the data has a row, so the default does not -answer it. A `ParameterDefined` on a `bool` parameter reads the value, and so -reads the default. +`program.parameters[name].missing` says what a missing row of that parameter +means: `'error'`, `'absent'`, `'neutral'`, or the value it reads as. It is +`None` for a parameter a `piecewise:` block reads, because the block owns its +shape, and for a given parameter, whose declaring file says. The program of +`spec.expand(...)` declares a curve's parameters `'neutral'` where the curve has +`points:`. `program.relations[name].missing` is `'error'` or `'absent'`, and +`None` for a bare relation. Under `'error'` the engine refuses a missing row and +names the coordinate. A value is read wherever a value is read: an expression, +a bound, and a comparison in a mask. A `ParameterDefined` on a numeric +parameter asks whether the data has a row, so a value does not answer it. A +`ParameterDefined` on a `bool` parameter reads the value. Under `'absent'`, a +bound that reads a missing row leaves that side open. ## Nodes and masks diff --git a/examples/pypsa.yaml b/examples/pypsa.yaml index d6e7712ef..6b7f42981 100644 --- a/examples/pypsa.yaml +++ b/examples/pypsa.yaml @@ -108,9 +108,11 @@ parameters: Generator_ramp_limit_up: description: most a generator may raise its output between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time dims: [scenario, snapshot, generator] + missing: neutral Generator_ramp_limit_down: description: most a generator may lower its output between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time dims: [scenario, snapshot, generator] + missing: neutral Generator_ramp_limit_start_up: description: most output in the snapshot a unit starts, per unit of nominal power dims: [scenario, generator] @@ -135,6 +137,7 @@ parameters: only where the unit came in running; no value means it is unknown, so the unit carries no ramp row at the first snapshot dims: [scenario, generator] + missing: neutral Generator_must_stay_up: description: >- true while the up time a unit brought into the horizon still binds — @@ -161,6 +164,7 @@ parameters: Generator_p_nom_mod: description: the module size a build comes in whole numbers of; no value means the build is continuous dims: [generator] + missing: neutral Generator_modules_installed: description: >- how many whole modules a committable build has in place: `Generator_p_nom @@ -171,6 +175,7 @@ parameters: Generator_big_m: description: a bound safely above any feasible output — the build cap at full availability, data prep dims: [scenario, generator] + missing: neutral Generator_p_min_pu_nonneg: description: >- true where none of the generator's own minimums-per-unit is negative — @@ -198,6 +203,7 @@ parameters: `Generator_maintenance_cover` and `Generator_maintenance_start_blocked`, and the assumptions hold it to the horizon dims: [scenario, generator] + missing: neutral Generator_maintenance_start_blocked: description: >- true where no maintenance event may start, because the snapshots it @@ -209,9 +215,11 @@ parameters: Link_ramp_limit_up: description: most a link may raise its flow between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time dims: [scenario, snapshot, link] + missing: neutral Link_ramp_limit_down: description: most a link may lower its flow between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time dims: [scenario, snapshot, link] + missing: neutral Link_p_nom: description: nominal power dims: [scenario, link] @@ -285,6 +293,7 @@ parameters: only where the link came in running; no value means it is unknown, so the link carries no ramp row at the first snapshot dims: [scenario, link] + missing: neutral Link_must_stay_up: description: >- true while the up time a link brought into the horizon still binds — @@ -311,6 +320,7 @@ parameters: Link_p_nom_mod: description: the module size a build comes in whole numbers of; no value means the build is continuous dims: [link] + missing: neutral Link_modules_installed: description: >- how many whole modules a committable build has in place: `Link_p_nom @@ -321,6 +331,7 @@ parameters: Link_big_m: description: a bound safely above any feasible flow — the build cap at full availability, data prep dims: [scenario, link] + missing: neutral Link_p_min_pu_nonneg: description: >- true where none of the link's own minimums-per-unit is negative — @@ -348,6 +359,7 @@ parameters: `Link_maintenance_cover` and `Link_maintenance_start_blocked`, and the assumptions hold it to the horizon dims: [scenario, link] + missing: neutral Link_maintenance_start_blocked: description: >- true where no maintenance event may start, because the snapshots it @@ -403,12 +415,15 @@ parameters: Process_ramp_limit_up: description: most a process may raise its internal power between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time dims: [scenario, snapshot, process] + missing: neutral Process_ramp_limit_down: description: most a process may lower its internal power between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time dims: [scenario, snapshot, process] + missing: neutral Process_p_set: description: a given internal power schedule; a process without one has no row here dims: [scenario, snapshot, process] + missing: neutral Process_p_nom_min: description: least nominal power an extendable process may be built at dims: [scenario, process] @@ -421,6 +436,7 @@ parameters: Process_p_nom_set: description: a given nominal power for an extendable process; one without a value has no row here dims: [scenario, process] + missing: neutral Process_committable: description: whether internal power is gated by an on/off status decision dims: [process] @@ -449,6 +465,7 @@ parameters: only where the process came in running; no value means it is unknown, so the process carries no ramp row at the first snapshot dims: [scenario, process] + missing: neutral Process_must_stay_up: description: >- true while the up time a process brought into the horizon still binds — @@ -475,6 +492,7 @@ parameters: Process_p_nom_mod: description: the module size a build comes in whole numbers of; no value means the build is continuous dims: [process] + missing: neutral Process_modules_installed: description: >- how many whole modules a committable build has in place: `Process_p_nom @@ -485,6 +503,7 @@ parameters: Process_big_m: description: a bound safely above any feasible internal power — the build cap at full availability, data prep dims: [scenario, process] + missing: neutral Process_p_min_pu_nonneg: description: >- true where none of the process's own minimums-per-unit is negative — @@ -512,6 +531,7 @@ parameters: `Process_maintenance_cover` and `Process_maintenance_start_blocked`, and the assumptions hold it to the horizon dims: [scenario, process] + missing: neutral Process_maintenance_start_blocked: description: >- true where no maintenance event may start, because the snapshots it @@ -653,6 +673,7 @@ parameters: `multi_investment_periods` (`global_constraints.py:219-220`), so data prep feeds no value otherwise dims: [carrier] + missing: neutral Carrier_max_relative_growth: description: >- share of the previous period's additions that may be added on top — the @@ -661,9 +682,11 @@ parameters: Generator_p_set: description: a given output schedule; a generator without one has no row here dims: [scenario, snapshot, generator] + missing: neutral Link_p_set: description: a given flow schedule; a link without one has no row here dims: [scenario, snapshot, link] + missing: neutral snapshot_weightings_stores: description: PyPSA's `snapshot_weightings.stores` — hours a snapshot stands for in a storage balance dims: [snapshot] @@ -682,6 +705,7 @@ parameters: Generator_p_nom_set: description: a given nominal power for an extendable generator; one without a value has no row here dims: [scenario, generator] + missing: neutral Generator_e_sum_min: description: least energy over the horizon; minus infinity where no floor is meant dims: [scenario, generator] @@ -700,6 +724,7 @@ parameters: Link_p_nom_set: description: a given nominal power for an extendable link; one without a value has no row here dims: [scenario, link] + missing: neutral StorageUnit_p_nom_min: description: least nominal power an extendable storage unit may be built at dims: [scenario, storage_unit] @@ -712,6 +737,7 @@ parameters: StorageUnit_p_nom_set: description: a given nominal power for an extendable storage unit; one without a value has no row here dims: [scenario, storage_unit] + missing: neutral Store_e_nom_min: description: least nominal capacity an extendable store may be built at dims: [scenario, store] @@ -724,6 +750,7 @@ parameters: Store_e_nom_set: description: a given nominal capacity for an extendable store; one without a value has no row here dims: [scenario, store] + missing: neutral StorageUnit_p_nom: description: nominal power dims: [scenario, storage_unit] @@ -810,15 +837,19 @@ parameters: StorageUnit_p_set: description: a given net dispatch schedule; a unit without one has no row here dims: [scenario, snapshot, storage_unit] + missing: neutral StorageUnit_p_dispatch_set: description: a given dispatch schedule; a unit without one has no row here dims: [scenario, snapshot, storage_unit] + missing: neutral StorageUnit_p_store_set: description: a given charging schedule; a unit without one has no row here dims: [scenario, snapshot, storage_unit] + missing: neutral StorageUnit_state_of_charge_set: description: a given charge schedule; a unit without one has no row here dims: [scenario, snapshot, storage_unit] + missing: neutral Store_e_nom: description: nominal energy capacity dims: [scenario, store] @@ -890,9 +921,11 @@ parameters: Store_e_set: description: a given energy schedule; a store without one has no row here dims: [scenario, snapshot, store] + missing: neutral Store_p_set: description: a given schedule of power delivered; a store without one has no row here dims: [scenario, snapshot, store] + missing: neutral Line_s_nom: description: nominal apparent power dims: [scenario, line] @@ -915,15 +948,18 @@ parameters: Line_s_nom_set: description: a given nominal apparent power for an extendable line; one without a value has no row here dims: [scenario, line] + missing: neutral Line_s_set: description: a given flow schedule; a line without one has no row here dims: [scenario, snapshot, line] + missing: neutral Line_cycle_weight: description: >- the line's series impedance, signed by its orientation in the cycle — the cycle basis, data prep; a line in no cycle has no row. PyPSA builds the cycle basis from the first scenario only (`networks.py:1354-1361`) dims: [line, cycle] + missing: neutral Line_BODF: description: >- the share of an outaged branch's flow a line takes on when that branch @@ -931,6 +967,7 @@ parameters: only where the line and the outage share a sub-network, -1 at the outaged line itself dims: [line, outage] + missing: neutral transmission_losses: description: >- whether the network dissipates transmission losses — PyPSA's @@ -978,9 +1015,11 @@ parameters: Transformer_s_nom_set: description: a given nominal apparent power for an extendable transformer; one without a value has no row here dims: [scenario, transformer] + missing: neutral Transformer_s_set: description: a given flow schedule; a transformer without one has no row here dims: [scenario, snapshot, transformer] + missing: neutral Transformer_cycle_weight: description: >- the transformer's effective series reactance, `x` times its tap ratio, @@ -988,12 +1027,14 @@ parameters: basis, data prep; a transformer in no cycle has no row. From the first scenario only, as a line's dims: [transformer, cycle] + missing: neutral Transformer_BODF: description: >- the share of an outaged branch's flow a transformer takes on when that branch goes out, as a line's; a row only where the transformer and the outage share a sub-network dims: [transformer, outage] + missing: neutral Transformer_phase_shift_weight: description: >- a fixed transformer's phase shift in radians at each snapshot, signed by @@ -1002,6 +1043,7 @@ parameters: the variable term never both count a shift. A transformer with no shift or in no cycle has no row dims: [snapshot, transformer, cycle] + missing: neutral Transformer_phase_shift_varying: description: >- whether a transformer's phase shift is a decision — PyPSA's @@ -1078,27 +1120,34 @@ parameters: on a row that names a period without it (`global_constraints.py:375`) dims: [scenario, global_constraint, snapshot] dtype: bool + missing: neutral Generator_primary_energy_weight: description: >- the constrained attribute per unit of energy at the bus — the carrier's `co2_emissions` over the generator's efficiency at the snapshot, data prep; a generator of an unweighted carrier has no row dims: [scenario, global_constraint, snapshot, generator] + missing: neutral StorageUnit_primary_energy_weight: description: the constrained attribute per unit of charge depleted — data prep; an unweighted unit has no row dims: [scenario, global_constraint, storage_unit] + missing: neutral Store_primary_energy_weight: description: the constrained attribute per unit of energy depleted — data prep; an unweighted store has no row dims: [scenario, global_constraint, store] + missing: neutral Generator_operational_limit_weight: description: one where the generator is in the row's set — data prep; one outside it has no row dims: [scenario, global_constraint, generator] + missing: neutral StorageUnit_operational_limit_weight: description: one where the storage unit is in the row's set — data prep; one outside it has no row dims: [scenario, global_constraint, storage_unit] + missing: neutral Store_operational_limit_weight: description: one where the store is in the row's set — data prep; one outside it has no row dims: [scenario, global_constraint, store] + missing: neutral Line_volume_weight: description: >- the line's length where its carrier is in the row's set, the first @@ -1107,6 +1156,7 @@ parameters: line outside it, or one that does not stand in the row's `investment_period`, has no row dims: [scenario, global_constraint, line] + missing: neutral Link_volume_weight: description: >- the link's length where its carrier is in the row's set, the first @@ -1115,6 +1165,7 @@ parameters: link outside it, or one that does not stand in the row's `investment_period`, has no row dims: [scenario, global_constraint, link] + missing: neutral Line_expansion_cost_weight: description: >- the line's capital cost where its carrier is in the row's set, times @@ -1123,6 +1174,7 @@ parameters: line outside the set, or one that does not stand in the row's period, has no row dims: [scenario, global_constraint, line] + missing: neutral Link_expansion_cost_weight: description: >- the link's capital cost where its carrier is in the row's set, times @@ -1131,42 +1183,49 @@ parameters: link outside the set, or one that does not stand in the row's period, has no row dims: [scenario, global_constraint, link] + missing: neutral Generator_tech_capacity_weight: description: >- one where the generator is in the row's carrier-and-bus set — data prep; one outside it, or one that does not stand in the row's `investment_period`, has no row dims: [global_constraint, generator] + missing: neutral Link_tech_capacity_weight: description: >- one where the link is in the row's carrier-and-bus set — data prep; one outside it, or one that does not stand in the row's `investment_period`, has no row dims: [global_constraint, link] + missing: neutral Line_tech_capacity_weight: description: >- one where the line is in the row's carrier-and-bus set — data prep; one outside it, or one that does not stand in the row's `investment_period`, has no row dims: [global_constraint, line] + missing: neutral StorageUnit_tech_capacity_weight: description: >- one where the storage unit is in the row's carrier-and-bus set — data prep; one outside it, or one that does not stand in the row's `investment_period`, has no row dims: [global_constraint, storage_unit] + missing: neutral Store_tech_capacity_weight: description: >- one where the store is in the row's carrier-and-bus set — data prep; one outside it, or one that does not stand in the row's `investment_period`, has no row dims: [global_constraint, store] + missing: neutral Process_tech_capacity_weight: description: >- one where the process is in the row's carrier-and-bus set — data prep; one outside it, or one that does not stand in the row's `investment_period`, has no row dims: [global_constraint, process] + missing: neutral relations: snapshot_period: @@ -1259,10 +1318,12 @@ relations: description: the line an outage takes out; an outage of a transformer has no row key: outage values: line + missing: absent Outage_transformer: description: the transformer an outage takes out; an outage of a line has no row key: outage values: transformer + missing: absent Generator_maintenance_cover: description: >- the snapshots a maintenance event covers, by the snapshot it starts in — @@ -1326,7 +1387,7 @@ variables: the variable rather than as rows dims: [scenario, snapshot, storage_unit] where: "StorageUnit_inflow > 0 AND StorageUnit_active" - absence: zero + missing: neutral bounds: lower: 0 upper: StorageUnit_inflow @@ -1375,7 +1436,7 @@ variables: continuous, and one exactly where an event covers the snapshot dims: [scenario, snapshot, generator] where: Generator_maintainable AND Generator_active - absence: zero + missing: neutral bounds: lower: 0 upper: 1 @@ -1383,7 +1444,7 @@ variables: description: "`Generator-maintenance_start` — whether a maintenance event starts in this snapshot" dims: [scenario, snapshot, generator] where: Generator_maintainable AND Generator_active - absence: zero + missing: neutral domain: binary Generator_maintenance_capacity: description: >- @@ -1393,7 +1454,7 @@ variables: where: >- Generator_maintainable AND Generator_p_nom_extendable AND NOT (Generator_committable AND Generator_p_nom_mod > 0) AND Generator_active - absence: zero + missing: neutral bounds: lower: 0 Generator_maintenance_status: @@ -1405,7 +1466,7 @@ variables: where: >- Generator_maintainable AND Generator_committable AND NOT (Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0)) AND Generator_active - absence: zero + missing: neutral bounds: lower: 0 Link_n_mod: @@ -1445,7 +1506,7 @@ variables: continuous, and one exactly where an event covers the snapshot dims: [scenario, snapshot, link] where: Link_maintainable AND Link_active - absence: zero + missing: neutral bounds: lower: 0 upper: 1 @@ -1453,7 +1514,7 @@ variables: description: "`Link-maintenance_start` — whether a maintenance event starts in this snapshot" dims: [scenario, snapshot, link] where: Link_maintainable AND Link_active - absence: zero + missing: neutral domain: binary Link_maintenance_capacity: description: >- @@ -1463,7 +1524,7 @@ variables: where: >- Link_maintainable AND Link_p_nom_extendable AND NOT (Link_committable AND Link_p_nom_mod > 0) AND Link_active - absence: zero + missing: neutral bounds: lower: 0 Link_maintenance_status: @@ -1475,7 +1536,7 @@ variables: where: >- Link_maintainable AND Link_committable AND NOT (Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0)) AND Link_active - absence: zero + missing: neutral bounds: lower: 0 Process_n_mod: @@ -1515,7 +1576,7 @@ variables: continuous, and one exactly where an event covers the snapshot dims: [scenario, snapshot, process] where: Process_maintainable AND Process_active - absence: zero + missing: neutral bounds: lower: 0 upper: 1 @@ -1523,7 +1584,7 @@ variables: description: "`Process-maintenance_start` — whether a maintenance event starts in this snapshot" dims: [scenario, snapshot, process] where: Process_maintainable AND Process_active - absence: zero + missing: neutral domain: binary Process_maintenance_capacity: description: >- @@ -1533,7 +1594,7 @@ variables: where: >- Process_maintainable AND Process_p_nom_extendable AND NOT (Process_committable AND Process_p_nom_mod > 0) AND Process_active - absence: zero + missing: neutral bounds: lower: 0 Process_maintenance_status: @@ -1545,7 +1606,7 @@ variables: where: >- Process_maintainable AND Process_committable AND NOT (Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0)) AND Process_active - absence: zero + missing: neutral bounds: lower: 0 Line_s: @@ -1561,7 +1622,7 @@ variables: the network is lossless dims: [scenario, snapshot, line] where: transmission_losses AND Line_active - absence: zero + missing: neutral bounds: lower: 0 Transformer_s: @@ -1578,7 +1639,7 @@ variables: lossless dims: [scenario, snapshot, transformer] where: transmission_losses AND Transformer_active - absence: zero + missing: neutral bounds: lower: 0 Transformer_phase_shift: @@ -1589,7 +1650,7 @@ variables: where the shift is fixed dims: [scenario, snapshot, transformer] where: Transformer_phase_shift_varying AND Transformer_active - absence: zero + missing: neutral bounds: lower: Transformer_phase_shift_min upper: Transformer_phase_shift_max diff --git a/examples/pypsa/carrier.yaml b/examples/pypsa/carrier.yaml index f762b6533..01dfed6f3 100644 --- a/examples/pypsa/carrier.yaml +++ b/examples/pypsa/carrier.yaml @@ -18,6 +18,7 @@ parameters: `multi_investment_periods` (`global_constraints.py:219-220`), so data prep feeds no value otherwise dims: [carrier] + missing: neutral Carrier_max_relative_growth: description: >- share of the previous period's additions that may be added on top — the diff --git a/examples/pypsa/generator.yaml b/examples/pypsa/generator.yaml index 36379bcbd..5434aa8cb 100644 --- a/examples/pypsa/generator.yaml +++ b/examples/pypsa/generator.yaml @@ -63,6 +63,7 @@ parameters: Generator_p_nom_mod: description: the module size a build comes in whole numbers of; no value means the build is continuous dims: [generator] + missing: neutral Generator_modules_installed: description: >- how many whole modules a committable build has in place: `Generator_p_nom @@ -93,6 +94,7 @@ parameters: Generator_p_set: description: a given output schedule; a generator without one has no row here dims: [scenario, snapshot, generator] + missing: neutral Generator_p_nom_min: description: least nominal power an extendable generator may be built at dims: [scenario, generator] @@ -105,6 +107,7 @@ parameters: Generator_p_nom_set: description: a given nominal power for an extendable generator; one without a value has no row here dims: [scenario, generator] + missing: neutral Generator_e_sum_min: description: least energy over the horizon; minus infinity where no floor is meant dims: [scenario, generator] @@ -117,15 +120,18 @@ parameters: `co2_emissions` over the generator's efficiency at the snapshot, data prep; a generator of an unweighted carrier has no row dims: [scenario, global_constraint, snapshot, generator] + missing: neutral Generator_operational_limit_weight: description: one where the generator is in the row's set — data prep; one outside it has no row dims: [scenario, global_constraint, generator] + missing: neutral Generator_tech_capacity_weight: description: >- one where the generator is in the row's carrier-and-bus set — data prep; one outside it, or one that does not stand in the row's `investment_period`, has no row dims: [global_constraint, generator] + missing: neutral variables: Generator_p: diff --git a/examples/pypsa/generator_commitment.yaml b/examples/pypsa/generator_commitment.yaml index 8551031e9..04fea1161 100644 --- a/examples/pypsa/generator_commitment.yaml +++ b/examples/pypsa/generator_commitment.yaml @@ -63,6 +63,7 @@ parameters: Generator_big_m: description: a bound safely above any feasible output — the build cap at full availability, data prep dims: [scenario, generator] + missing: neutral variables: Generator_status: diff --git a/examples/pypsa/generator_maintenance.yaml b/examples/pypsa/generator_maintenance.yaml index 1f8ca404e..6096d8a72 100644 --- a/examples/pypsa/generator_maintenance.yaml +++ b/examples/pypsa/generator_maintenance.yaml @@ -43,6 +43,7 @@ parameters: `Generator_maintenance_cover` and `Generator_maintenance_start_blocked`, and the assumptions hold it to the horizon dims: [scenario, generator] + missing: neutral Generator_maintenance_start_blocked: description: >- true where no maintenance event may start, because the snapshots it @@ -59,7 +60,7 @@ variables: continuous, and one exactly where an event covers the snapshot dims: [scenario, snapshot, generator] where: Generator_maintainable AND Generator_active - absence: zero + missing: neutral bounds: lower: 0 upper: 1 @@ -67,7 +68,7 @@ variables: description: "`Generator-maintenance_start` — whether a maintenance event starts in this snapshot" dims: [scenario, snapshot, generator] where: Generator_maintainable AND Generator_active - absence: zero + missing: neutral domain: binary Generator_maintenance_capacity: description: >- @@ -77,7 +78,7 @@ variables: where: >- Generator_maintainable AND Generator_p_nom_extendable AND NOT (Generator_committable AND Generator_p_nom_mod > 0) AND Generator_active - absence: zero + missing: neutral bounds: lower: 0 Generator_maintenance_status: @@ -89,7 +90,7 @@ variables: where: >- Generator_maintainable AND Generator_committable AND NOT (Generator_p_nom_extendable AND NOT (Generator_p_nom_mod > 0)) AND Generator_active - absence: zero + missing: neutral bounds: lower: 0 diff --git a/examples/pypsa/generator_ramping.yaml b/examples/pypsa/generator_ramping.yaml index c128b6b68..7a697cc5d 100644 --- a/examples/pypsa/generator_ramping.yaml +++ b/examples/pypsa/generator_ramping.yaml @@ -24,9 +24,11 @@ parameters: Generator_ramp_limit_up: description: most a generator may raise its output between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time dims: [scenario, snapshot, generator] + missing: neutral Generator_ramp_limit_down: description: most a generator may lower its output between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time dims: [scenario, snapshot, generator] + missing: neutral Generator_ramp_limit_start_up: description: most output in the snapshot a unit starts, per unit of nominal power dims: [scenario, generator] @@ -39,6 +41,7 @@ parameters: only where the unit came in running; no value means it is unknown, so the unit carries no ramp row at the first snapshot dims: [scenario, generator] + missing: neutral given: parameters: diff --git a/examples/pypsa/line.yaml b/examples/pypsa/line.yaml index dd325ecdd..6380918d1 100644 --- a/examples/pypsa/line.yaml +++ b/examples/pypsa/line.yaml @@ -79,15 +79,18 @@ parameters: Line_s_nom_set: description: a given nominal apparent power for an extendable line; one without a value has no row here dims: [scenario, line] + missing: neutral Line_s_set: description: a given flow schedule; a line without one has no row here dims: [scenario, snapshot, line] + missing: neutral Line_cycle_weight: description: >- the line's series impedance, signed by its orientation in the cycle — the cycle basis, data prep; a line in no cycle has no row. PyPSA builds the cycle basis from the first scenario only (`networks.py:1354-1361`) dims: [line, cycle] + missing: neutral Line_loss_max: description: the loss at a line's rating — PyPSA's `r_pu_eff * (s_max_pu * s_nom_max)**2`, data prep dims: [scenario, snapshot, line] @@ -110,6 +113,7 @@ parameters: line outside it, or one that does not stand in the row's `investment_period`, has no row dims: [scenario, global_constraint, line] + missing: neutral Line_expansion_cost_weight: description: >- the line's capital cost where its carrier is in the row's set, times @@ -118,12 +122,14 @@ parameters: line outside the set, or one that does not stand in the row's period, has no row dims: [scenario, global_constraint, line] + missing: neutral Line_tech_capacity_weight: description: >- one where the line is in the row's carrier-and-bus set — data prep; one outside it, or one that does not stand in the row's `investment_period`, has no row dims: [global_constraint, line] + missing: neutral variables: Line_s: @@ -139,7 +145,7 @@ variables: the network is lossless dims: [scenario, snapshot, line] where: transmission_losses AND Line_active - absence: zero + missing: neutral bounds: lower: 0 Line_s_nom_ext: diff --git a/examples/pypsa/link.yaml b/examples/pypsa/link.yaml index cc46eafe8..df4ceacb6 100644 --- a/examples/pypsa/link.yaml +++ b/examples/pypsa/link.yaml @@ -99,6 +99,7 @@ parameters: Link_p_nom_mod: description: the module size a build comes in whole numbers of; no value means the build is continuous dims: [link] + missing: neutral Link_modules_installed: description: >- how many whole modules a committable build has in place: `Link_p_nom @@ -129,6 +130,7 @@ parameters: Link_p_set: description: a given flow schedule; a link without one has no row here dims: [scenario, snapshot, link] + missing: neutral Link_p_nom_min: description: least nominal power an extendable link may be built at dims: [scenario, link] @@ -141,6 +143,7 @@ parameters: Link_p_nom_set: description: a given nominal power for an extendable link; one without a value has no row here dims: [scenario, link] + missing: neutral Link_volume_weight: description: >- the link's length where its carrier is in the row's set, the first @@ -149,6 +152,7 @@ parameters: link outside it, or one that does not stand in the row's `investment_period`, has no row dims: [scenario, global_constraint, link] + missing: neutral Link_expansion_cost_weight: description: >- the link's capital cost where its carrier is in the row's set, times @@ -157,12 +161,14 @@ parameters: link outside the set, or one that does not stand in the row's period, has no row dims: [scenario, global_constraint, link] + missing: neutral Link_tech_capacity_weight: description: >- one where the link is in the row's carrier-and-bus set — data prep; one outside it, or one that does not stand in the row's `investment_period`, has no row dims: [global_constraint, link] + missing: neutral variables: Link_p: diff --git a/examples/pypsa/link_commitment.yaml b/examples/pypsa/link_commitment.yaml index 9c5cea685..4a76b645f 100644 --- a/examples/pypsa/link_commitment.yaml +++ b/examples/pypsa/link_commitment.yaml @@ -63,6 +63,7 @@ parameters: Link_big_m: description: a bound safely above any feasible flow — the build cap at full availability, data prep dims: [scenario, link] + missing: neutral variables: Link_status: diff --git a/examples/pypsa/link_maintenance.yaml b/examples/pypsa/link_maintenance.yaml index e81eefbf0..72471cad5 100644 --- a/examples/pypsa/link_maintenance.yaml +++ b/examples/pypsa/link_maintenance.yaml @@ -43,6 +43,7 @@ parameters: `Link_maintenance_cover` and `Link_maintenance_start_blocked`, and the assumptions hold it to the horizon dims: [scenario, link] + missing: neutral Link_maintenance_start_blocked: description: >- true where no maintenance event may start, because the snapshots it @@ -59,7 +60,7 @@ variables: continuous, and one exactly where an event covers the snapshot dims: [scenario, snapshot, link] where: Link_maintainable AND Link_active - absence: zero + missing: neutral bounds: lower: 0 upper: 1 @@ -67,7 +68,7 @@ variables: description: "`Link-maintenance_start` — whether a maintenance event starts in this snapshot" dims: [scenario, snapshot, link] where: Link_maintainable AND Link_active - absence: zero + missing: neutral domain: binary Link_maintenance_capacity: description: >- @@ -77,7 +78,7 @@ variables: where: >- Link_maintainable AND Link_p_nom_extendable AND NOT (Link_committable AND Link_p_nom_mod > 0) AND Link_active - absence: zero + missing: neutral bounds: lower: 0 Link_maintenance_status: @@ -89,7 +90,7 @@ variables: where: >- Link_maintainable AND Link_committable AND NOT (Link_p_nom_extendable AND NOT (Link_p_nom_mod > 0)) AND Link_active - absence: zero + missing: neutral bounds: lower: 0 diff --git a/examples/pypsa/link_ramping.yaml b/examples/pypsa/link_ramping.yaml index 4b643a02a..4b6b09721 100644 --- a/examples/pypsa/link_ramping.yaml +++ b/examples/pypsa/link_ramping.yaml @@ -24,9 +24,11 @@ parameters: Link_ramp_limit_up: description: most a link may raise its flow between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time dims: [scenario, snapshot, link] + missing: neutral Link_ramp_limit_down: description: most a link may lower its flow between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time dims: [scenario, snapshot, link] + missing: neutral Link_ramp_limit_start_up: description: most flow in the snapshot a link starts, per unit of nominal power dims: [scenario, link] @@ -39,6 +41,7 @@ parameters: only where the link came in running; no value means it is unknown, so the link carries no ramp row at the first snapshot dims: [scenario, link] + missing: neutral given: parameters: diff --git a/examples/pypsa/process.yaml b/examples/pypsa/process.yaml index 8c16c8a98..0ce672831 100644 --- a/examples/pypsa/process.yaml +++ b/examples/pypsa/process.yaml @@ -94,6 +94,7 @@ parameters: Process_p_set: description: a given internal power schedule; a process without one has no row here dims: [scenario, snapshot, process] + missing: neutral Process_p_nom_min: description: least nominal power an extendable process may be built at dims: [scenario, process] @@ -106,9 +107,11 @@ parameters: Process_p_nom_set: description: a given nominal power for an extendable process; one without a value has no row here dims: [scenario, process] + missing: neutral Process_p_nom_mod: description: the module size a build comes in whole numbers of; no value means the build is continuous dims: [process] + missing: neutral Process_modules_installed: description: >- how many whole modules a committable build has in place: `Process_p_nom @@ -142,6 +145,7 @@ parameters: outside it, or one that does not stand in the row's `investment_period`, has no row dims: [global_constraint, process] + missing: neutral variables: Process_p: diff --git a/examples/pypsa/process_commitment.yaml b/examples/pypsa/process_commitment.yaml index 79c1a12dd..1e2da48f5 100644 --- a/examples/pypsa/process_commitment.yaml +++ b/examples/pypsa/process_commitment.yaml @@ -63,6 +63,7 @@ parameters: Process_big_m: description: a bound safely above any feasible internal power — the build cap at full availability, data prep dims: [scenario, process] + missing: neutral variables: Process_status: diff --git a/examples/pypsa/process_maintenance.yaml b/examples/pypsa/process_maintenance.yaml index 2f1b9de84..181e26109 100644 --- a/examples/pypsa/process_maintenance.yaml +++ b/examples/pypsa/process_maintenance.yaml @@ -43,6 +43,7 @@ parameters: `Process_maintenance_cover` and `Process_maintenance_start_blocked`, and the assumptions hold it to the horizon dims: [scenario, process] + missing: neutral Process_maintenance_start_blocked: description: >- true where no maintenance event may start, because the snapshots it @@ -59,7 +60,7 @@ variables: continuous, and one exactly where an event covers the snapshot dims: [scenario, snapshot, process] where: Process_maintainable AND Process_active - absence: zero + missing: neutral bounds: lower: 0 upper: 1 @@ -67,7 +68,7 @@ variables: description: "`Process-maintenance_start` — whether a maintenance event starts in this snapshot" dims: [scenario, snapshot, process] where: Process_maintainable AND Process_active - absence: zero + missing: neutral domain: binary Process_maintenance_capacity: description: >- @@ -77,7 +78,7 @@ variables: where: >- Process_maintainable AND Process_p_nom_extendable AND NOT (Process_committable AND Process_p_nom_mod > 0) AND Process_active - absence: zero + missing: neutral bounds: lower: 0 Process_maintenance_status: @@ -89,7 +90,7 @@ variables: where: >- Process_maintainable AND Process_committable AND NOT (Process_p_nom_extendable AND NOT (Process_p_nom_mod > 0)) AND Process_active - absence: zero + missing: neutral bounds: lower: 0 diff --git a/examples/pypsa/process_ramping.yaml b/examples/pypsa/process_ramping.yaml index 7bb61fdfb..1ed1a47b9 100644 --- a/examples/pypsa/process_ramping.yaml +++ b/examples/pypsa/process_ramping.yaml @@ -24,9 +24,11 @@ parameters: Process_ramp_limit_up: description: most a process may raise its internal power between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time dims: [scenario, snapshot, process] + missing: neutral Process_ramp_limit_down: description: most a process may lower its internal power between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time dims: [scenario, snapshot, process] + missing: neutral Process_ramp_limit_start_up: description: most internal power in the snapshot a process starts, per unit of nominal power dims: [scenario, process] @@ -39,6 +41,7 @@ parameters: only where the process came in running; no value means it is unknown, so the process carries no ramp row at the first snapshot dims: [scenario, process] + missing: neutral given: parameters: diff --git a/examples/pypsa/security.yaml b/examples/pypsa/security.yaml index 79f9de022..3cfc3a94c 100644 --- a/examples/pypsa/security.yaml +++ b/examples/pypsa/security.yaml @@ -23,10 +23,12 @@ relations: description: the line an outage takes out; an outage of a transformer has no row key: outage values: line + missing: absent Outage_transformer: description: the transformer an outage takes out; an outage of a line has no row key: outage values: transformer + missing: absent parameters: Line_BODF: @@ -36,12 +38,14 @@ parameters: only where the line and the outage share a sub-network, -1 at the outaged line itself dims: [line, outage] + missing: neutral Transformer_BODF: description: >- the share of an outaged branch's flow a transformer takes on when that branch goes out, as a line's; a row only where the transformer and the outage share a sub-network dims: [transformer, outage] + missing: neutral given: parameters: diff --git a/examples/pypsa/settings.yaml b/examples/pypsa/settings.yaml index 5e62069a5..3d74009d1 100644 --- a/examples/pypsa/settings.yaml +++ b/examples/pypsa/settings.yaml @@ -68,6 +68,7 @@ parameters: on a row that names a period without it (`global_constraints.py:375`) dims: [scenario, global_constraint, snapshot] dtype: bool + missing: neutral given: expressions: diff --git a/examples/pypsa/storage_unit.yaml b/examples/pypsa/storage_unit.yaml index 0bcc83d97..6b3cf54a9 100644 --- a/examples/pypsa/storage_unit.yaml +++ b/examples/pypsa/storage_unit.yaml @@ -61,6 +61,7 @@ parameters: StorageUnit_p_nom_set: description: a given nominal power for an extendable storage unit; one without a value has no row here dims: [scenario, storage_unit] + missing: neutral StorageUnit_p_nom: description: nominal power dims: [scenario, storage_unit] @@ -147,27 +148,34 @@ parameters: StorageUnit_p_set: description: a given net dispatch schedule; a unit without one has no row here dims: [scenario, snapshot, storage_unit] + missing: neutral StorageUnit_p_dispatch_set: description: a given dispatch schedule; a unit without one has no row here dims: [scenario, snapshot, storage_unit] + missing: neutral StorageUnit_p_store_set: description: a given charging schedule; a unit without one has no row here dims: [scenario, snapshot, storage_unit] + missing: neutral StorageUnit_state_of_charge_set: description: a given charge schedule; a unit without one has no row here dims: [scenario, snapshot, storage_unit] + missing: neutral StorageUnit_primary_energy_weight: description: the constrained attribute per unit of charge depleted — data prep; an unweighted unit has no row dims: [scenario, global_constraint, storage_unit] + missing: neutral StorageUnit_operational_limit_weight: description: one where the storage unit is in the row's set — data prep; one outside it has no row dims: [scenario, global_constraint, storage_unit] + missing: neutral StorageUnit_tech_capacity_weight: description: >- one where the storage unit is in the row's carrier-and-bus set — data prep; one outside it, or one that does not stand in the row's `investment_period`, has no row dims: [global_constraint, storage_unit] + missing: neutral variables: StorageUnit_p_dispatch: @@ -189,7 +197,7 @@ variables: the variable rather than as rows dims: [scenario, snapshot, storage_unit] where: "StorageUnit_inflow > 0 AND StorageUnit_active" - absence: zero + missing: neutral bounds: lower: 0 upper: StorageUnit_inflow diff --git a/examples/pypsa/store.yaml b/examples/pypsa/store.yaml index d10d8e2b5..11eb5b900 100644 --- a/examples/pypsa/store.yaml +++ b/examples/pypsa/store.yaml @@ -61,6 +61,7 @@ parameters: Store_e_nom_set: description: a given nominal capacity for an extendable store; one without a value has no row here dims: [scenario, store] + missing: neutral Store_e_nom: description: nominal energy capacity dims: [scenario, store] @@ -132,21 +133,26 @@ parameters: Store_e_set: description: a given energy schedule; a store without one has no row here dims: [scenario, snapshot, store] + missing: neutral Store_p_set: description: a given schedule of power delivered; a store without one has no row here dims: [scenario, snapshot, store] + missing: neutral Store_primary_energy_weight: description: the constrained attribute per unit of energy depleted — data prep; an unweighted store has no row dims: [scenario, global_constraint, store] + missing: neutral Store_operational_limit_weight: description: one where the store is in the row's set — data prep; one outside it has no row dims: [scenario, global_constraint, store] + missing: neutral Store_tech_capacity_weight: description: >- one where the store is in the row's carrier-and-bus set — data prep; one outside it, or one that does not stand in the row's `investment_period`, has no row dims: [global_constraint, store] + missing: neutral variables: Store_e: diff --git a/examples/pypsa/transformer.yaml b/examples/pypsa/transformer.yaml index 6f2f30613..5446f38e4 100644 --- a/examples/pypsa/transformer.yaml +++ b/examples/pypsa/transformer.yaml @@ -61,9 +61,11 @@ parameters: Transformer_s_nom_set: description: a given nominal apparent power for an extendable transformer; one without a value has no row here dims: [scenario, transformer] + missing: neutral Transformer_s_set: description: a given flow schedule; a transformer without one has no row here dims: [scenario, snapshot, transformer] + missing: neutral Transformer_cycle_weight: description: >- the transformer's effective series reactance, `x` times its tap ratio, @@ -71,6 +73,7 @@ parameters: basis, data prep; a transformer in no cycle has no row. From the first scenario only, as a line's dims: [transformer, cycle] + missing: neutral Transformer_phase_shift_weight: description: >- a fixed transformer's phase shift in radians at each snapshot, signed by @@ -79,6 +82,7 @@ parameters: the variable term never both count a shift. A transformer with no shift or in no cycle has no row dims: [snapshot, transformer, cycle] + missing: neutral Transformer_phase_shift_varying: description: >- whether a transformer's phase shift is a decision — PyPSA's @@ -138,7 +142,7 @@ variables: lossless dims: [scenario, snapshot, transformer] where: transmission_losses AND Transformer_active - absence: zero + missing: neutral bounds: lower: 0 Transformer_phase_shift: @@ -149,7 +153,7 @@ variables: where the shift is fixed dims: [scenario, snapshot, transformer] where: Transformer_phase_shift_varying AND Transformer_active - absence: zero + missing: neutral bounds: lower: Transformer_phase_shift_min upper: Transformer_phase_shift_max diff --git a/examples/pypsa_linearized_uc.yaml b/examples/pypsa_linearized_uc.yaml index 15c38456b..a0211746c 100644 --- a/examples/pypsa_linearized_uc.yaml +++ b/examples/pypsa_linearized_uc.yaml @@ -89,9 +89,11 @@ parameters: Generator_ramp_limit_up: description: most a generator may raise its output between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time dims: [snapshot, generator] + missing: neutral Generator_ramp_limit_down: description: most a generator may lower its output between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time dims: [snapshot, generator] + missing: neutral Generator_ramp_limit_start_up: description: most output in the snapshot a unit starts, per unit of nominal power dims: [generator] @@ -116,6 +118,7 @@ parameters: only where the unit came in running; no value means it is unknown, so the unit carries no ramp row at the first snapshot dims: [generator] + missing: neutral Generator_must_stay_up: description: >- true while the up time a unit brought into the horizon still binds — @@ -165,6 +168,7 @@ parameters: `Generator_maintenance_cover` and `Generator_maintenance_start_blocked`, and the assumptions hold it to the horizon dims: [generator] + missing: neutral Generator_maintenance_start_blocked: description: >- true where no maintenance event may start, because the snapshots it @@ -243,7 +247,7 @@ variables: continuous, and one exactly where an event covers the snapshot dims: [snapshot, generator] where: Generator_maintainable - absence: zero + missing: neutral bounds: lower: 0 upper: 1 @@ -253,7 +257,7 @@ variables: this snapshot — a binary, which the keyword does not relax dims: [snapshot, generator] where: Generator_maintainable - absence: zero + missing: neutral domain: binary Generator_maintenance_status: description: >- @@ -262,7 +266,7 @@ variables: maintenance may also be off dims: [snapshot, generator] where: Generator_maintainable AND Generator_committable - absence: zero + missing: neutral bounds: lower: 0 diff --git a/schema/mathspec.schema.json b/schema/mathspec.schema.json index 22d658021..cdd4cdd09 100644 --- a/schema/mathspec.schema.json +++ b/schema/mathspec.schema.json @@ -544,29 +544,8 @@ }, "ParameterBlock": { "additionalProperties": false, - "description": "A declared parameter with dims and dtype, and the value a missing row reads as.", + "description": "A declared parameter with dims and dtype, and what a missing row of its data means.", "properties": { - "default": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "number" - }, - { - "enum": [ - "inf", - "-inf" - ] - }, - { - "type": "null" - } - ], - "default": null, - "title": "Default" - }, "description": { "anyOf": [ { @@ -596,6 +575,27 @@ ], "title": "Dtype", "type": "string" + }, + "missing": { + "anyOf": [ + { + "enum": [ + "error", + "absent", + "neutral", + "inf", + "-inf" + ] + }, + { + "type": "boolean" + }, + { + "type": "number" + } + ], + "default": null, + "title": "Missing" } }, "required": [ @@ -752,6 +752,22 @@ ], "title": "Key" }, + "missing": { + "anyOf": [ + { + "enum": [ + "error", + "absent" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Missing" + }, "values": { "anyOf": [ { @@ -828,15 +844,6 @@ "additionalProperties": false, "description": "A declared decision variable.", "properties": { - "absence": { - "default": "undefined", - "enum": [ - "undefined", - "zero" - ], - "title": "Absence", - "type": "string" - }, "bounds": { "$ref": "#/$defs/BoundsBlock", "default": { @@ -873,6 +880,15 @@ "title": "Domain", "type": "string" }, + "missing": { + "default": "absent", + "enum": [ + "absent", + "neutral" + ], + "title": "Missing", + "type": "string" + }, "where": { "anyOf": [ { diff --git a/src/mathspec/lowering.py b/src/mathspec/lowering.py index 52efc9734..14722553f 100644 --- a/src/mathspec/lowering.py +++ b/src/mathspec/lowering.py @@ -141,7 +141,7 @@ def lower(schema: Spec) -> Program: lower=lower_bound, upper=upper_bound, domain=vdef.domain, - absence=vdef.absence, + missing=vdef.missing, description=vdef.description, ) @@ -202,9 +202,12 @@ 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 + owned = {name for block in schema.piecewise.values() for name in block.consumes} program = Program( parameters={ - name: ParameterDeclaration(tuple(pdef.dims), pdef.dtype, pdef.default, pdef.description) + name: ParameterDeclaration( + tuple(pdef.dims), pdef.dtype, None if name in owned else pdef.reading, pdef.description + ) for name, pdef in schema.parameters.items() }, variables=variables, @@ -232,7 +235,7 @@ def lower(schema: Spec) -> Program: }, given=GivenTargets( parameters={ - name: ParameterDeclaration(tuple(g.dims), g.dtype, description=g.description) + name: ParameterDeclaration(tuple(g.dims), g.dtype, None, g.description) for name, g in schema.given.parameters.items() }, variables={ diff --git a/src/mathspec/piecewise.py b/src/mathspec/piecewise.py index e4dafb83f..4a073bcbd 100644 --- a/src/mathspec/piecewise.py +++ b/src/mathspec/piecewise.py @@ -302,9 +302,9 @@ def leaves_ungated(gate: VariableBlock | VariableDeclaration | None) -> bool: """Whether a curve gated by *gate* runs ungated where the gate does not exist, which takes a second convexity row. A masked gate is absent off its mask, and there the curve sums to 1; - ``absence: zero`` reads the gate as 0 there instead, which one row states. + ``missing: neutral`` reads the gate as 0 there instead, which one row states. """ - return gate is not None and gate.where is not None and gate.absence != 'zero' + return gate is not None and gate.where is not None and gate.missing != 'neutral' def curve_frame(schema: Spec, name: str, pw: PiecewiseBlock, links: Iterable[Expression]) -> tuple[str, ...]: @@ -441,7 +441,7 @@ def _gate_rows(self) -> tuple[tuple[str, str | None, str], ...]: reduction, so the right-hand side would take the row with it and leave the weights without the convexity that makes them a curve at all (#1158). - ``absence: zero`` is the other reading and stays one row — the gate is 0 + ``missing: neutral`` is the other reading and stays one row — the gate is 0 where it does not exist, so the curve is pinned off there. """ activity = self.pw.activity @@ -489,6 +489,12 @@ def expand_piecewise(schema: Spec) -> Spec: binaries are what the method *is*, so the spec that comes back carries no set of its own ([`mathspec.sos.emit`][] is where they are spelled). Each block's frame and names are read off the program *schema* lowered to. + + A parameter only curves with ``points:`` consume is read only where their + mask holds, since the weights and the segment rows stand on it, so the + expansion declares it ``missing: neutral``; the ``_complete`` + assumption states where it must carry a row. A parameter a curve over + every breakpoint reads keeps the default, ``error``. """ if not schema.piecewise: return schema @@ -498,6 +504,10 @@ def expand_piecewise(schema: Spec) -> Spec: raw.setdefault('constraints', {}) for name, pw in schema.piecewise.items(): _Block(schema, raw, name, pw, program.piecewise[name]).expand() + ragged = {n for pw in schema.piecewise.values() if pw.points for n in pw.consumes} + ragged -= {n for pw in schema.piecewise.values() if not pw.points for n in pw.consumes} + for parameter in ragged: + raw['parameters'][parameter]['missing'] = 'neutral' raw['piecewise'].clear() for name, pw in schema.piecewise.items(): if pw.method == 'adjacency': diff --git a/src/mathspec/program.py b/src/mathspec/program.py index 3ef170314..6b323723c 100644 --- a/src/mathspec/program.py +++ b/src/mathspec/program.py @@ -63,6 +63,8 @@ 'GroupSum', 'Link', 'Mask', + 'Missing', + 'MissingReading', 'Multiply', 'Named', 'Negate', @@ -90,6 +92,7 @@ 'RelationComparison', 'RelationDeclaration', 'RelationDefined', + 'RelationMissing', 'RelationPairComparison', 'Separability', 'SosDeclaration', @@ -99,10 +102,10 @@ 'TranslatedPredicate', 'TypedPredicate', 'Variable', - 'VariableAbsence', 'VariableDeclaration', 'VariableDefined', 'VariableDomain', + 'VariableMissing', 'WindowSum', 'assumption_message', 'carries_variable', @@ -143,11 +146,21 @@ #: The domain a variable may declare. VariableDomain = Literal['continuous', 'integer', 'binary'] -#: What a masked variable's non-existence *means* where it does not exist. -#: ``undefined`` is the absence rules' default — a term carrying it takes its -#: row. ``zero`` says the quantity *is* zero there, so the term contributes -#: nothing and the row stands. -VariableAbsence = Literal['undefined', 'zero'] +#: What a missing row means. ``error`` refuses the data, ``absent`` takes the +#: row of a term that reads it, and ``neutral`` reads the value that +#: contributes nothing: ``0`` as a coefficient, ``false`` in a ``where``. +MissingReading = Literal['error', 'absent', 'neutral'] + +#: A parameter's ``missing:``: a reading, or the value a missing row reads as. +Missing = MissingReading | bool | float + +#: A relation's ``missing:``. A label the map leaves out is refused, or belongs to no group. +RelationMissing = Literal['error', 'absent'] + +#: A variable's ``missing:``: what a coordinate its ``where`` masks out means. +#: ``absent`` takes the row of a term that reads it; ``neutral`` says the +#: quantity *is* zero there, so the term contributes nothing and the row stands. +VariableMissing = Literal['absent', 'neutral'] #: Which way an objective is optimised (the declaration rules). ObjectiveSense = Literal['minimize', 'maximize'] @@ -447,6 +460,9 @@ class RelationDeclaration: columns: tuple[tuple[str, str], ...] key: tuple[str, ...] + #: What a key the map leaves out means, or ``None`` for a bare relation, + #: whose rows are its membership and so have no gap. + missing: RelationMissing | None = 'error' description: str | None = None @property @@ -598,10 +614,10 @@ class ParameterDeclaration: dims: tuple[str, ...] dtype: ParameterDtype = 'float' - #: What a missing row reads as wherever a value is read, or ``None`` for - #: the value that contributes nothing. A bare numeric name in a ``where`` - #: still asks whether the data has a row. - default: bool | float | None = None + #: What a missing row means, or the value it reads as wherever a value is + #: read; ``None`` for a given parameter, whose declaring file says. A bare + #: numeric name in a ``where`` still asks whether the data has a row. + missing: Missing | None = 'error' description: str | None = None @@ -615,7 +631,7 @@ class VariableDeclaration: #: As [`lower`][], for the other side. upper: Expression | None = None domain: VariableDomain = 'continuous' - absence: VariableAbsence = 'undefined' + missing: VariableMissing = 'absent' description: str | None = None diff --git a/src/mathspec/resolution.py b/src/mathspec/resolution.py index 029c1471e..340786fa8 100644 --- a/src/mathspec/resolution.py +++ b/src/mathspec/resolution.py @@ -104,14 +104,17 @@ def __init__(self, schema: Spec) -> None: **{p: pd.dtype for p, pd in parameters.items()}, **{d: dd.dtype for d, dd in schema.dimensions.items()}, } - #: parameter name -> its declared ``default:``, for the parameters that - #: have one; what a where comparison reads where the data has no row. + #: parameter name -> the value its ``missing:`` reads a missing row as, + #: for the parameters that name one; what a where comparison reads there. self.defaults: dict[str, bool | float] = { - p: pd.default for p, pd in schema.parameters.items() if pd.default is not None + p: pd.missing + for p, pd in schema.parameters.items() + if pd.missing is not None and not isinstance(pd.missing, str) } #: relation name -> its columns and key, as declared. self.relations: dict[str, RelationDeclaration] = { - n: RelationDeclaration(lk.pairs, lk.key_roles, lk.description) for n, lk in schema.relations.items() + n: RelationDeclaration(lk.pairs, lk.key_roles, lk.reading, lk.description) + for n, lk in schema.relations.items() } #: parameter or variable name -> the dims it is read through — #: parameters by their ``dims``, variables by their frame. Stamped onto diff --git a/src/mathspec/spec.py b/src/mathspec/spec.py index 5d2e17967..211a1d7b0 100644 --- a/src/mathspec/spec.py +++ b/src/mathspec/spec.py @@ -32,13 +32,16 @@ from mathspec.errors import did_you_mean, schema_error from mathspec.program import ( DimensionDtype, + Missing, + MissingReading, ObjectiveSense, ParameterDtype, PiecewiseMethod, Program, + RelationMissing, SosType, - VariableAbsence, VariableDomain, + VariableMissing, ) if TYPE_CHECKING: @@ -98,12 +101,26 @@ def _reject_unknown_keys(cls, data: object) -> object: #: term, a divisor, a bound. A label selects and a flag masks; neither is one. NUMERIC_DTYPES: frozenset[ParameterDtype] = frozenset({'float', 'int'}) -#: What ``default:`` is written as. The validator takes ``inf`` and ``-inf`` as -#: the numbers they name, which an editor would otherwise flag as strings. -_DEFAULT_SCHEMA: dict[str, object] = { - 'anyOf': [{'type': 'boolean'}, {'type': 'number'}, {'enum': ['inf', '-inf']}, {'type': 'null'}], +#: What a parameter's ``missing:`` is written as. The validator takes ``inf`` +#: and ``-inf`` as the numbers they name, which an editor would otherwise flag. +_MISSING_SCHEMA: dict[str, object] = { + 'anyOf': [ + {'enum': [*get_args(MissingReading), 'inf', '-inf']}, + {'type': 'boolean'}, + {'type': 'number'}, + ], + 'default': 'error', } +#: The spellings of a missing row, for a refusal to list. +_READINGS = ', '.join(get_args(MissingReading)) + + +def _missing_null(kind: str, rewrite: str) -> ValueError: + """``missing: null`` reads as either the default or an absent row, so it is refused for the word that says which.""" + return ValueError(f'missing: null on {kind} names no reading. {rewrite}') + + #: Every formulation, in the order [`Spec.expand`][] writes them out: a curve #: emits a set, and no set emits a curve. FORMULATIONS: tuple[Formulation, ...] = ('piecewise', 'sos') @@ -167,8 +184,39 @@ class RelationBlock(_StrictBlock): key: str | list[str] | dict[str, str] values: str | list[str] | dict[str, str] | None = None + missing: RelationMissing | None = None description: str | None = None + @field_validator('missing', mode='before') + @classmethod + def _error_or_absent(cls, v: object) -> object: + """A label is data, so no value fills a gap in a map, and neutral reads as absent wherever a map is read.""" + if v is not None and v not in get_args(RelationMissing): + msg = ( + f'missing: {v!r} on a relation, which takes error or absent. A label the map leaves out ' + f'is refused, or belongs to no group.' + ) + raise ValueError(msg) + return v + + @model_validator(mode='after') + def _a_bare_relation_has_no_gap(self) -> RelationBlock: + """A bare relation's rows are its membership, so a key it leaves out is not missing.""" + if self.missing is not None and self.values is None: + msg = ( + f'missing: {self.missing} on a relation with no `values:`. Its rows are its membership, ' + f'so a key it leaves out is not missing: drop the key.' + ) + raise ValueError(msg) + return self + + @property + def reading(self) -> RelationMissing | None: + """What a key the map leaves out means: ``error`` unless the file says, and ``None`` for a bare relation.""" + if self.values is None: + return None + return self.missing or 'error' + @property def pairs(self) -> tuple[tuple[str, str], ...]: """``(role, dimension)`` per column, the key's columns first. @@ -214,46 +262,55 @@ class DimensionBlock(_StrictBlock): class ParameterBlock(_StrictBlock): - """A declared parameter with dims and dtype, and the value a missing row reads as.""" + """A declared parameter with dims and dtype, and what a missing row of its data means.""" _label: ClassVar[str] = 'a parameter declaration' dims: list[str] dtype: ParameterDtype = 'float' - default: Annotated[bool | int | float | None, WithJsonSchema(_DEFAULT_SCHEMA)] = None + missing: Annotated[MissingReading | bool | int | float | None, WithJsonSchema(_MISSING_SCHEMA)] = None description: str | None = None - @field_validator('default', mode='before') + @field_validator('missing', mode='before') @classmethod - def _a_value(cls, v: object) -> object: + def _a_reading_or_a_value(cls, v: object) -> object: """``inf`` is a string to YAML and a number to the expression grammar, so it is taken as the number.""" + if v is None: + raise _missing_null( + 'a parameter', 'Write absent for a row that is not there, or leave the key out for error.' + ) if v in ('inf', '-inf'): return float(cast('str', v)) - if isinstance(v, str): - msg = f'default is the string {v!r}, and a default is a number, true or false. Remove the quotes.' + if isinstance(v, str) and v not in get_args(MissingReading): + msg = f'missing is the string {v!r}. It takes {_READINGS}, or a value: a number, true or false.' raise ValueError(msg) if isinstance(v, float) and math.isnan(v): - msg = 'default is nan, and a parameter has no nan value. Write a number, or leave the key out.' + msg = 'missing is nan, and a parameter has no nan value. Write a number, or one of ' + _READINGS + '.' raise ValueError(msg) return v + @property + def reading(self) -> Missing: + """What a missing row means: what the file wrote, or ``error`` where it wrote nothing.""" + return 'error' if self.missing is None else self.missing + @model_validator(mode='after') - def _default_fits_the_dtype(self) -> ParameterBlock: - """A default is a value of the column, so it has the column's dtype.""" - v = self.default - if v is None: + def _a_value_fits_the_dtype(self) -> ParameterBlock: + """A value fills rows of the column, so it has the column's dtype.""" + v = self.missing + if v is None or isinstance(v, str): return self if self.dtype == 'str': - msg = f'default: {v!r} on a str parameter. A label has no default: leave the key out.' + msg = f'missing: {v!r} on a str parameter. A label has no value to fill: write {_READINGS}.' raise ValueError(msg) if self.dtype == 'bool' and not isinstance(v, bool): - msg = f'default: {v!r} on a bool parameter, which takes true or false.' + msg = f'missing: {v!r} on a bool parameter, which takes true or false.' raise ValueError(msg) if self.dtype != 'bool' and isinstance(v, bool): - msg = f'default: {str(v).lower()} on a {self.dtype} parameter, which takes a number. Write 1 or 0.' + msg = f'missing: {str(v).lower()} on a {self.dtype} parameter, which takes a number. Write 1 or 0.' raise ValueError(msg) if self.dtype == 'int' and isinstance(v, float): - msg = f'default: {v} on an int parameter, which takes an integer. Write one, or declare dtype: float.' + msg = f'missing: {v} on an int parameter, which takes an integer. Write one, or declare dtype: float.' raise ValueError(msg) return self @@ -309,16 +366,30 @@ class VariableBlock(_StrictBlock): where: str | None = None bounds: BoundsBlock = BoundsBlock() domain: VariableDomain = 'continuous' - absence: VariableAbsence = 'undefined' + missing: VariableMissing = 'absent' description: str | None = None + @field_validator('missing', mode='before') + @classmethod + def _absent_or_neutral(cls, v: object) -> object: + """A variable has no data, so the readings of data have nothing to read.""" + if v is None: + raise _missing_null('a variable', 'Write neutral, or leave the key out for absent.') + if v not in get_args(VariableMissing): + msg = ( + f'missing: {v!r} on a variable, which takes absent or neutral. A variable has no data: ' + f'error and a value are readings of a parameter.' + ) + raise ValueError(msg) + return v + @model_validator(mode='after') - def _absence_needs_a_mask(self) -> VariableBlock: - """``absence:`` says what a *missing* coordinate means, so one must be missable.""" - if self.absence != 'undefined' and self.where is None: + def _neutral_needs_a_mask(self) -> VariableBlock: + """``missing:`` says what a masked-out coordinate means, so one must be missable.""" + if self.missing != 'absent' and self.where is None: msg = ( - f'absence: {self.absence} needs a `where:` — a variable with no mask exists at every ' - f'coordinate of its dims, so there is no absence for it to describe. Add the mask, ' + f'missing: {self.missing} needs a `where:` — a variable with no mask exists at every ' + f'coordinate of its dims, so nothing is missing for it to describe. Add the mask, ' f'or drop the key.' ) raise ValueError(msg) @@ -721,6 +792,15 @@ class PiecewiseBlock(_StrictBlock): points: str | None = None description: str | None = None + @property + def consumes(self) -> frozenset[str]: + """The parameters the block reads: each link's values, and the ``points:`` mask. + + The block owns the shape of every one of them, so ``missing:`` is + refused on them at load and lowering reports none. + """ + return frozenset({link.values for link in self.links} | ({self.points} if self.points else set())) + @property def nominated(self) -> str | None: """The block's own values parameter ``points:`` names, so the mask is derived from it — or ``None``.""" diff --git a/src/mathspec/typesetting/legend.py b/src/mathspec/typesetting/legend.py index 095a6f712..b8b8d2740 100644 --- a/src/mathspec/typesetting/legend.py +++ b/src/mathspec/typesetting/legend.py @@ -30,7 +30,7 @@ if TYPE_CHECKING: from collections.abc import Iterable - from mathspec.program import Expression, Mask, Program, RelationDeclaration + from mathspec.program import Expression, Mask, Missing, Program, RelationDeclaration from mathspec.typesetting.format import Format, OperatorName from mathspec.typesetting.symbols import Symbols @@ -150,7 +150,7 @@ def glossaries(self, noticed: Noticed, defined: Iterable[str]) -> list[tuple[str parameters = [ self._entry( self.symbols.name[p], - f'{fmt.mono(p)}{self._over(list(block.dims))}{self._default(block.default)}', + f'{fmt.mono(p)}{self._over(list(block.dims))}{self._default(block.missing)}', block.description, ) for p, block in program.parameters.items() @@ -219,9 +219,9 @@ def _over(self, dims: list[str]) -> str: product = self.format.joined([self.symbols.set[d] for d in dims], self._op('times')) return f' over {self.format.math(product)}' - def _default(self, value: bool | float | None) -> str: - """What a missing row reads as, where the declaration says; nothing where it is the value that contributes nothing.""" - if value is None: + def _default(self, value: Missing | None) -> str: + """The value a missing row reads as, where ``missing:`` names one; nothing for a reading.""" + if value is None or isinstance(value, str): return '' shown = ( self.format.mono(str(value).lower()) diff --git a/src/mathspec/typesetting/walk.py b/src/mathspec/typesetting/walk.py index 3df22e69a..33730926e 100644 --- a/src/mathspec/typesetting/walk.py +++ b/src/mathspec/typesetting/walk.py @@ -930,7 +930,7 @@ def _gate(self, block: PiecewiseDeclaration, ctx: _Context) -> str: the curve is built for, the factor is the gate where it exists and 1 where it does not — the two rows the expansion writes there, because a variable that does not exist takes its row with it and would leave the - curve unstated rather than ungated. ``absence: zero`` is the other + curve unstated rather than ungated. ``missing: neutral`` is the other reading and pins the curve off, which is the factor on its own. """ if (activity := block.activity) is None: @@ -938,7 +938,7 @@ def _gate(self, block: PiecewiseDeclaration, ctx: _Context) -> str: gate = self.program.variables[activity] symbol = ctx.indexed(self.symbols.name[activity], list(gate.dims)) mask = gate.where - if mask is None or gate.absence == 'zero': + if mask is None or gate.missing == 'neutral': return symbol where = self._predicate(mask.root, ctx, need=_WHERE_PRECEDENCE['and']) return self.format.cases( diff --git a/src/mathspec/validation.py b/src/mathspec/validation.py index 3b7424ae6..afded8ef0 100644 --- a/src/mathspec/validation.py +++ b/src/mathspec/validation.py @@ -88,6 +88,7 @@ def reference_errors(schema: Spec) -> list[str]: *_sos_shapes(schema), *_sos_bounds(schema), *_piecewise_references(schema), + *_curve_parameters_declare_no_missing(schema), *_given_constraint_collisions(schema), ] @@ -339,6 +340,25 @@ def _piecewise_references(schema: Spec) -> Iterator[str]: ) +def _curve_parameters_declare_no_missing(schema: Spec) -> Iterator[str]: + """A curve's shape is its block's to state, so the parameters it consumes state no ``missing:``. + + ``points:`` already says how far a curve runs, and a breakpoint it leaves + out is not asked for, so a values parameter is complete over the points + its block admits: neither ``error`` over every coordinate its dims reach + nor a gap any other reading fills. + """ + for block_name, block in schema.piecewise.items(): + for name in sorted(block.consumes): + if name in schema.parameters and schema.parameters[name].missing is not None: + yield ( + f"parameter '{name}': 'missing:' is not for a parameter a piecewise block consumes — " + f"'{block_name}' already owns the shape of its curve. A breakpoint 'points:' leaves out " + f'is not asked for, and every other breakpoint needs a row. Drop the ' + f"'missing:' line, and say how far the curve runs with 'points:'." + ) + + def _collisions(schema: Spec, context: str, by_kind: Iterable[tuple[str, Iterable[str]]]) -> Iterator[str]: """The refusal for each name *context*'s expansion writes that the file already declares, by kind. diff --git a/tests/expand/curve-adjacency-points/after.yaml b/tests/expand/curve-adjacency-points/after.yaml index 0dd6a7d6f..37cc920ee 100644 --- a/tests/expand/curve-adjacency-points/after.yaml +++ b/tests/expand/curve-adjacency-points/after.yaml @@ -2,8 +2,8 @@ dimensions: bp: { dtype: int } parameters: - x_bp: { dims: [bp] } - y_bp: { dims: [bp] } + x_bp: { dims: [bp], missing: neutral } + y_bp: { dims: [bp], missing: neutral } variables: x: { dims: [], bounds: { lower: 0 } } diff --git a/tests/expand/curve-lp-points/after.yaml b/tests/expand/curve-lp-points/after.yaml index 9a24e7b0d..769bdfac1 100644 --- a/tests/expand/curve-lp-points/after.yaml +++ b/tests/expand/curve-lp-points/after.yaml @@ -2,8 +2,8 @@ dimensions: bp: { dtype: int } parameters: - x_bp: { dims: [bp] } - y_bp: { dims: [bp] } + x_bp: { dims: [bp], missing: neutral } + y_bp: { dims: [bp], missing: neutral } variables: x: { dims: [], bounds: { lower: 0 } } diff --git a/tests/test_exclusivity.py b/tests/test_exclusivity.py index 5836f5404..a1801ac33 100644 --- a/tests/test_exclusivity.py +++ b/tests/test_exclusivity.py @@ -328,19 +328,20 @@ def test_a_pair_proved_apart_stays_apart_on_a_finer_grid(self, schema: Spec, see assert proved > 150, f'only {proved} pairs proved apart; the fuzz is not exercising the check' -class TestADefault: +class TestAMissingRow: CASES: ClassVar[dict[str, str]] = {'lossless': 'efficiency == 1', 'not_given': 'not efficiency'} - def test_a_missing_row_compares_as_the_default(self): + def test_a_missing_row_compares_as_the_value_it_reads_as(self): """Both cases claim a storage with no `efficiency` row: it is not given, and it reads 1. The prover read a missing row as false in every comparison, so it proved the two apart, and a coordinate the data leaves out had two values. """ - schema = to_spec(varied(STORAGE, **{'parameters.efficiency': {'dims': ['storage'], 'default': 1}})) + schema = to_spec(varied(STORAGE, **{'parameters.efficiency': {'dims': ['storage'], 'missing': 1}})) [refusal] = refusals(schema, self.CASES) assert 'efficiency is absent' in refusal, 'the witness is the missing row' - def test_without_a_default_a_missing_row_compares_false(self): - schema = to_spec(varied(STORAGE, **{'parameters.efficiency': {'dims': ['storage']}})) - assert refusals(schema, self.CASES) == [], 'a missing row with no default is in neither case' + @pytest.mark.parametrize('reading', ['error', 'absent', 'neutral']) + def test_under_a_reading_a_missing_row_compares_false(self, reading: str): + schema = to_spec(varied(STORAGE, **{'parameters.efficiency': {'dims': ['storage'], 'missing': reading}})) + assert refusals(schema, self.CASES) == [], 'a missing row with no value is in neither case' diff --git a/tests/test_expand.py b/tests/test_expand.py index 49b1a90ed..77ec1a1a1 100644 --- a/tests/test_expand.py +++ b/tests/test_expand.py @@ -11,6 +11,7 @@ from __future__ import annotations +import copy import re from pathlib import Path from typing import TYPE_CHECKING @@ -18,7 +19,8 @@ import pytest from mathspec import piecewise, to_spec -from tests.fixtures import DISPATCH_MODEL, EXAMPLES, schema_of, varied +from mathspec.errors import LanguageError +from tests.fixtures import DISPATCH_MODEL, EXAMPLES, SMALL_MODEL, schema_of, varied from tests.test_sos import CURVE from tools.render_tex import models @@ -122,7 +124,9 @@ def test_an_expansion_declares_exactly_the_parameters_the_file_declared(): schema = schema_of(MASKED) expanded = schema.expand() - assert expanded.parameters == schema.parameters, 'a curve emits no parameter, so the same data attaches to both' + assert {name: (p.dims, p.dtype) for name, p in expanded.parameters.items()} == { + name: (p.dims, p.dtype) for name, p in schema.parameters.items() + }, 'a curve emits no parameter, so the same data attaches to both' assert schema_of(expanded.to_yaml()).to_dict() == expanded.to_dict(), ( 'the expansion is a file like any other, and loading it back changes nothing' ) @@ -176,3 +180,54 @@ def test_a_model_with_no_formulation_expands_to_itself(): spec = schema_of(DISPATCH_MODEL) assert spec.expand() is spec, 'nothing to write out returns the same object, not a copy' + + +#: `fixtures.SMALL_MODEL` plus a two-link curve over its second dimension, so a +#: block's own parameters stand beside ordinary ones in one model. +SMALL_CURVE = { + 'parameters.bx': {'dims': ['h']}, + 'parameters.by': {'dims': ['h']}, + 'variables.s': {'dims': ['g']}, + 'piecewise.curve': {'over': 'h', 'links': [['p', 'bx'], ['s', 'by']], 'method': 'convex'}, +} + + +@pytest.mark.parametrize( + ('points', 'missing'), + [ + pytest.param({'piecewise.curve.points': 'bx'}, 'neutral', id='a-curve-that-says-how-far-it-runs'), + pytest.param({}, 'error', id='a-curve-over-every-breakpoint'), + ], +) +def test_the_expansion_says_what_a_missing_breakpoint_means(points, missing): + """A curve's own parameters answer for no `missing:`, because the block owns their shape. The expansion keeps + no block: its weights stand on `points:` and its assumptions ask for a value only where the mask holds. Left + unwritten, lowering would report `error`, and a consumer attaching the rows would refuse the ragged curve the + block admits; a curve with no `points:` reads every breakpoint.""" + rows = schema_of(varied(SMALL_MODEL, **copy.deepcopy(SMALL_CURVE), **points)).expand('piecewise').program + assert {name: rows.parameters[name].missing for name in ('bx', 'by', 'c')} == { + 'bx': missing, + 'by': missing, + 'c': 'error', + }, "the expansion reads the curve's tables as the block did, and every other parameter as declared" + + +def test_a_parameter_a_curve_owns_answers_for_no_missing(): + """The block's own parameters report `None`: reporting the unwritten `error` would tell a consumer to require + every coordinate the dims reach, which a ragged curve does not carry.""" + program = schema_of(varied(SMALL_MODEL, **copy.deepcopy(SMALL_CURVE), **{'piecewise.curve.points': 'bx'})).program + missing = {name: p.missing for name, p in program.parameters.items()} + assert missing == {'c': 'error', 'k': 'error', 'flag': 'error', 'tag': 'error', 'bx': None, 'by': None}, ( + "the parameters the block consumes answer for no missing; every other parameter's declaration is carried" + ) + + +@pytest.mark.parametrize('missing', ['error', 'neutral', 0]) +def test_missing_on_a_parameter_a_curve_consumes_is_refused(missing): + """`points:` already says how far the curve runs, so any `missing:` there claims what the block decided.""" + with pytest.raises(LanguageError) as caught: + to_spec(varied(SMALL_MODEL, **copy.deepcopy(SMALL_CURVE), **{'parameters.bx.missing': missing})) + message = str(caught.value) + assert "parameter 'bx'" in message, 'the message names the parameter that has to change' + assert "'curve'" in message, 'and the block that already owns the answer' + assert "'points:'" in message, 'and names the rewrite' diff --git a/tests/test_schema.py b/tests/test_schema.py index f6bf0c500..e7ccbc15b 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -58,7 +58,8 @@ def test_no_definition_refers_only_to_itself(): [ pytest.param('ObjectiveBlock', 'sense', spec.ObjectiveSense, id='sense'), pytest.param('VariableBlock', 'domain', spec.VariableDomain, id='domain'), - pytest.param('VariableBlock', 'absence', spec.VariableAbsence, id='absence'), + pytest.param('VariableBlock', 'missing', spec.VariableMissing, id='variable-missing'), + pytest.param('RelationBlock', 'missing', spec.RelationMissing, id='relation-missing'), pytest.param('ParameterBlock', 'dtype', spec.ParameterDtype, id='parameter-dtype'), pytest.param('DimensionBlock', 'dtype', spec.DimensionDtype, id='dimension-dtype'), pytest.param('PiecewiseBlock', 'method', spec.PiecewiseMethod, id='method'), diff --git a/tests/test_validation.py b/tests/test_validation.py index 9e195aa57..8cd146ce9 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -1197,7 +1197,7 @@ class TestRulesDecidedWithoutData: ), pytest.param( {'relations.tag': {'key': 'g', 'dtype': 'str'}}, - ("unknown key 'dtype' in a relation declaration. Valid keys: description, key, values.",), + ("unknown key 'dtype' in a relation declaration. Valid keys: description, key, missing, values.",), id='relation-with-a-dtype-of-its-own', ), pytest.param({'relations.tag': {'key': 'g'}}, ('has 1 column(s)',), id='relation-with-one-column'), @@ -1428,7 +1428,7 @@ class TestRulesDecidedWithoutData: id='a-relation-naming-one-value-column', ), pytest.param( - {'variables.p.absence': 'zero'}, ('absence: zero needs a `where:`',), id='absence-without-a-mask' + {'variables.p.missing': 'neutral'}, ('missing: neutral needs a `where:`',), id='neutral-without-a-mask' ), pytest.param( {'variables.p.bounds.upper': 'c * 2'}, @@ -2313,26 +2313,29 @@ def test_an_infinite_bound_is_refused_with_the_null_that_opens_a_side(side, valu @pytest.mark.parametrize( ('dtype', 'written', 'read'), [ + pytest.param('float', None, 'error', id='left-out-is-error'), + pytest.param('float', 'absent', 'absent', id='absent'), + pytest.param('str', 'neutral', 'neutral', id='neutral-on-a-label'), pytest.param('float', 1, 1, id='a-number'), pytest.param('float', float('inf'), float('inf'), id='yaml-dot-inf'), pytest.param('float', 'inf', float('inf'), id='the-expression-spelling-of-inf'), pytest.param('float', '-inf', float('-inf'), id='the-expression-spelling-of-minus-inf'), pytest.param('int', 2, 2, id='an-integer'), pytest.param('bool', True, True, id='a-flag'), - pytest.param('float', None, None, id='null-is-no-default'), ], ) -def test_a_default_is_a_value_of_the_column(dtype, written, read): - spec = to_spec(varied(SMALL_MODEL, **{'parameters.c': {'dims': ['g'], 'dtype': dtype, 'default': written}})) - assert spec.program.parameters['c'].default == read +def test_a_parameter_says_what_a_missing_row_means(dtype, written, read): + declared = {'dims': ['g'], 'dtype': dtype} | ({} if written is None else {'missing': written}) + spec = to_spec(varied(SMALL_MODEL, **{'parameters.c': declared})) + assert spec.program.parameters['c'].missing == read def test_inf_and_dot_inf_load_as_one_number(): """YAML reads `inf` as a string and `.inf` as a number; the expression grammar takes both.""" read = [ - to_spec(parse_yaml(f'dimensions: {{g: {{}}}}\nparameters: {{c: {{dims: [g], default: {spelling}}}}}')) + to_spec(parse_yaml(f'dimensions: {{g: {{}}}}\nparameters: {{c: {{dims: [g], missing: {spelling}}}}}')) .program.parameters['c'] - .default + .missing for spelling in ('inf', '.inf') ] assert read == [float('inf'), float('inf')] @@ -2341,24 +2344,78 @@ def test_inf_and_dot_inf_load_as_one_number(): @pytest.mark.parametrize( ('dtype', 'written', 'fragment'), [ - pytest.param('float', '1', "default is the string '1'", id='a-quoted-number'), - pytest.param('float', float('nan'), 'default is nan', id='nan'), + pytest.param('float', None, 'missing: null on a parameter names no reading', id='null'), + pytest.param('float', 'zero', "missing is the string 'zero'. It takes error, absent, neutral", id='a-word'), + pytest.param('float', '1', "missing is the string '1'", id='a-quoted-number'), + pytest.param('float', float('nan'), 'missing is nan', id='nan'), pytest.param( - 'float', True, 'default: true on a float parameter, which takes a number', id='a-flag-on-a-number' + 'float', True, 'missing: true on a float parameter, which takes a number', id='a-flag-on-a-number' ), - pytest.param('int', 1.5, 'default: 1.5 on an int parameter, which takes an integer', id='a-fraction-on-an-int'), - pytest.param('int', float('inf'), 'default: inf on an int parameter', id='an-infinity-on-an-int'), - pytest.param('bool', 1, 'default: 1 on a bool parameter, which takes true or false', id='a-number-on-a-flag'), - pytest.param('str', 'a', "default is the string 'a'", id='a-label'), - pytest.param('str', 1, 'A label has no default', id='a-number-on-a-label'), + pytest.param('int', 1.5, 'missing: 1.5 on an int parameter, which takes an integer', id='a-fraction-on-an-int'), + pytest.param('int', float('inf'), 'missing: inf on an int parameter', id='an-infinity-on-an-int'), + pytest.param('bool', 1, 'missing: 1 on a bool parameter, which takes true or false', id='a-number-on-a-flag'), + pytest.param('str', 1, 'A label has no value to fill', id='a-number-on-a-label'), ], ) -def test_a_default_that_is_not_a_value_of_the_column_is_refused(dtype, written, fragment): - message = _refusal(**{'parameters.c': {'dims': ['g'], 'dtype': dtype, 'default': written}}) +def test_a_parameter_missing_that_reads_nothing_is_refused(dtype, written, fragment): + message = _refusal(**{'parameters.c': {'dims': ['g'], 'dtype': dtype, 'missing': written}}) assert fragment in message -def test_a_given_parameter_takes_no_default(): - """The file that declares the parameter owns its default, as it owns a variable's bounds.""" - message = _refusal(**{'given.parameters.d': {'dims': ['g'], 'default': 1}}) - assert "unknown key 'default' in a given parameter declaration" in message +def test_a_given_parameter_has_no_missing(): + """The file that declares the parameter owns what a missing row means, as it owns a variable's bounds.""" + message = _refusal(**{'given.parameters.d': {'dims': ['g'], 'missing': 'neutral'}}) + assert "unknown key 'missing' in a given parameter declaration" in message + + +@pytest.mark.parametrize( + ('written', 'read'), + [ + pytest.param({}, 'error', id='left-out-is-error'), + pytest.param({'missing': 'error'}, 'error', id='error'), + pytest.param({'missing': 'absent'}, 'absent', id='absent'), + ], +) +def test_a_relation_says_what_a_label_it_leaves_out_means(written, read): + spec = to_spec(varied(SMALL_MODEL, **{'relations.lk': {'key': 'g', 'values': 'h', **written}})) + assert spec.program.relations['lk'].missing == read + + +def test_a_bare_relation_has_no_missing_rows(): + """Its rows are its membership: a pair it leaves out is not a gap, so `error` would refuse every sparse set.""" + spec = to_spec(varied(SMALL_MODEL, **{'relations.pair': {'key': ['g', 'h']}})) + assert spec.program.relations['pair'].missing is None + + +@pytest.mark.parametrize( + ('relation', 'fragment'), + [ + pytest.param( + {'key': 'g', 'values': 'h', 'missing': 'neutral'}, + "missing: 'neutral' on a relation, which takes error or absent", + id='neutral', + ), + pytest.param( + {'key': 'g', 'values': 'h', 'missing': 'h1'}, "missing: 'h1' on a relation", id='a-label-as-a-value' + ), + pytest.param( + {'key': ['g', 'h'], 'missing': 'error'}, + 'missing: error on a relation with no `values:`', + id='a-bare-relation', + ), + ], +) +def test_a_relation_missing_that_reads_nothing_is_refused(relation, fragment): + assert fragment in _refusal(**{'relations.lk': relation}) + + +@pytest.mark.parametrize( + ('written', 'fragment'), + [ + pytest.param('error', "missing: 'error' on a variable, which takes absent or neutral", id='error'), + pytest.param(0, 'missing: 0 on a variable, which takes absent or neutral', id='a-value'), + pytest.param(None, 'missing: null on a variable names no reading', id='null'), + ], +) +def test_a_variable_missing_that_reads_data_is_refused(written, fragment): + assert fragment in _refusal(**{'variables.p': {'dims': ['g'], 'where': 'c', 'missing': written}}) diff --git a/tests/typesetting/golden/model.yaml b/tests/typesetting/golden/model.yaml index 7d022adb3..c7bb9e305 100644 --- a/tests/typesetting/golden/model.yaml +++ b/tests/typesetting/golden/model.yaml @@ -32,11 +32,11 @@ relations: gen_bt: { key: generator, values: [bus, technology] } # one table with two value columns, read to both at once parameters: - p_max: { dims: [generator], default: .inf } # a default: the legend says what a missing row reads as + p_max: { dims: [generator], missing: .inf } # a value: the legend says what a missing row reads as p_min: { dims: [generator] } cost: { dims: [generator] } load: { dims: [snapshot, bus] } - is_flexible: { dims: [generator], dtype: bool, default: false } + is_flexible: { dims: [generator], dtype: bool, missing: false } zone_cap: { dims: [zone] } tech_cap: { dims: [bus, technology] } min_up: { dims: [generator], dtype: int } diff --git a/tests/typesetting/test_symbols.py b/tests/typesetting/test_symbols.py index 983c1966f..ec6d701c9 100644 --- a/tests/typesetting/test_symbols.py +++ b/tests/typesetting/test_symbols.py @@ -100,10 +100,11 @@ def test_a_named_expression_has_a_legend_row_exactly_while_its_symbol_prints(nam DEFAULTED = varied( DISPATCH_MODEL, **{ - 'parameters.p_max.default': 'inf', - 'parameters.cost.default': 1, - 'parameters.floor': {'dims': [], 'default': '-inf'}, - 'parameters.online': {'dims': ['generator'], 'dtype': 'bool', 'default': True}, + 'parameters.p_max.missing': 'inf', + 'parameters.cost.missing': 1, + 'parameters.load.missing': 'neutral', + 'parameters.floor': {'dims': [], 'missing': '-inf'}, + 'parameters.online': {'dims': ['generator'], 'dtype': 'bool', 'missing': True}, }, ) @@ -118,7 +119,9 @@ def test_the_legend_prints_what_a_missing_row_reads_as(name: FormatName, fmt: Fo fmt.mono('true'), ): assert f'{shown} where the data has no row' in out - assert out.count('where the data has no row') == 4, '`load` declares no default, so its row names none' + assert out.count('where the data has no row') == 4, ( + '`load` reads a missing row as neutral, which is a reading and not a value, so its row names none' + ) #: The dispatch model with a curve on it, so one model has two readings and one From a0ce903071b52c242b7a721c7e8bf0474593817d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 09:29:04 +0000 Subject: [PATCH 05/21] feat(language): a missing row the data must not have is `refused`, not `error` `refused` completes "a missing row is ___" as `absent` and `neutral` do, and is the word the docs use for data a consumer rejects; "error" is the word for what `to_spec` raises. How a consumer reports a refusal is its own, and it does not build the model from the data. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiDTE4WvKobVxTDxUeWhc9 --- docs/reference/glossary.md | 5 +++-- docs/reference/language/absence.md | 12 +++++----- docs/reference/language/declarations.md | 29 ++++++++++++++----------- docs/reference/language/relations.md | 14 ++++++------ docs/reference/reading.md | 9 ++++---- schema/mathspec.schema.json | 4 ++-- src/mathspec/piecewise.py | 2 +- src/mathspec/program.py | 10 ++++----- src/mathspec/spec.py | 16 +++++++------- src/mathspec/validation.py | 2 +- tests/test_exclusivity.py | 2 +- tests/test_expand.py | 12 +++++----- tests/test_validation.py | 18 +++++++-------- 13 files changed, 70 insertions(+), 65 deletions(-) diff --git a/docs/reference/glossary.md b/docs/reference/glossary.md index 075e30836..987b28ec4 100644 --- a/docs/reference/glossary.md +++ b/docs/reference/glossary.md @@ -83,8 +83,9 @@ row that reads it is not built ([absence](language/absence.md)). **Missing row** : A coordinate that a parameter's or a relation's table has no row for. Its -`missing:` says what it is: refused (`error`, the default), absence (`absent`), -`0` in arithmetic and false in a `where` (`neutral`), or a value +`missing:` says what it is: refused when the data is attached (`refused`, the +default), absence (`absent`), `0` in arithmetic and false in a `where` +(`neutral`), or a value ([a missing row](language/declarations.md#a-missing-row)). ## Kinds of construct diff --git a/docs/reference/language/absence.md b/docs/reference/language/absence.md index 6dcb8edb6..41b76e150 100644 --- a/docs/reference/language/absence.md +++ b/docs/reference/language/absence.md @@ -39,12 +39,12 @@ null or NaN value is [refused](declarations.md#parameters) when the data is attached. **The parameter's [`missing:`](declarations.md#a-missing-row) says what a missing row is:** -| `missing:` | A missing row | -| ------------------- | ------------------------------------------------------------------------------------------- | -| `error`, by default | is refused when the data is attached | -| `absent` | is absence, by the rules on this page | -| `neutral` | reads as the value that contributes nothing: `0` as a coefficient, and `false` in a `where` | -| a value | reads as that value | +| `missing:` | A missing row | +| --------------------- | ------------------------------------------------------------------------------------------- | +| `refused`, by default | is refused when the data is attached | +| `absent` | is absence, by the rules on this page | +| `neutral` | reads as the value that contributes nothing: `0` as a coefficient, and `false` in a `where` | +| a value | reads as that value | `neutral` has no value in three positions: a divisor, a `bounds:` entry, and the whole constant side of a comparison. A missing row there is refused when diff --git a/docs/reference/language/declarations.md b/docs/reference/language/declarations.md index b9703d41f..fb8207aa9 100644 --- a/docs/reference/language/declarations.md +++ b/docs/reference/language/declarations.md @@ -23,12 +23,12 @@ parameters: dims: [] # a scalar ``` -| Field | | | -| ------------- | -------------------------------------------------------------- | --------------- | -| `dims` | required. The dimensions it is indexed by. `[]` means a scalar | | -| `dtype` | `float`, `int`, `bool`, `str` | default `float` | -| `missing` | what a missing row means ([a missing row](#a-missing-row)) | default `error` | -| `description` | free text | default `null` | +| Field | | | +| ------------- | -------------------------------------------------------------- | ----------------- | +| `dims` | required. The dimensions it is indexed by. `[]` means a scalar | | +| `dtype` | `float`, `int`, `bool`, `str` | default `float` | +| `missing` | what a missing row means ([a missing row](#a-missing-row)) | default `refused` | +| `description` | free text | default `null` | The column has to match the `dtype`: @@ -58,7 +58,7 @@ the data, so the file says which was meant. dimensions: generator: { dtype: str } parameters: - cost: { dims: [generator] } # error: every generator has a cost + cost: { dims: [generator] } # refused: every generator has a cost ramp_limit: { dims: [generator], missing: neutral } # no row means no limit p_set: { dims: [generator], missing: absent } # no row, no fixing row efficiency: { dims: [generator], missing: 1 } @@ -66,12 +66,15 @@ parameters: active: { dims: [generator], dtype: bool, missing: true } ``` -| `missing:` | A missing row | -| ------------------- | ----------------------------------------------------------------------------------------------------- | -| `error`, by default | is refused when the data is attached, and the refusal names the coordinate | -| `absent` | is [absence](absence.md): it takes the row of a term that reads it, and is one summand fewer in a sum | -| `neutral` | reads as the value that contributes nothing: `0` as a coefficient, and `false` in a `where` | -| a value | reads as that value, wherever a value is read | +| `missing:` | A missing row | +| --------------------- | ----------------------------------------------------------------------------------------------------- | +| `refused`, by default | is refused when the data is attached, and the refusal names the coordinate | +| `absent` | is [absence](absence.md): it takes the row of a term that reads it, and is one summand fewer in a sum | +| `neutral` | reads as the value that contributes nothing: `0` as a coefficient, and `false` in a `where` | +| a value | reads as that value, wherever a value is read | + +A consumer does not build the model from data with a refused row. How it says +so is its own: it may raise at the first gap, or list every missing coordinate. A bare numeric name in a `where` asks whether the data has a row under every reading, so `where: p_nom_max` selects the rows the data gives. A comparison diff --git a/docs/reference/language/relations.md b/docs/reference/language/relations.md index 63081216b..5d0af41c8 100644 --- a/docs/reference/language/relations.md +++ b/docs/reference/language/relations.md @@ -28,12 +28,12 @@ The **key** is the combination of dimensions that is unique per row: what that row determines: its bus. With no `values:`, the key is every column, and the table is a **bare relation**. -| Field | | | -| ------------- | ---------------------------------------------------------------- | --------------- | -| `key` | required. The columns that identify a row | | -| `values` | the columns the key determines. Omitted, the key is every column | default none | -| `missing` | `error` or `absent`: what a key the map leaves out means | default `error` | -| `description` | free text | default `null` | +| Field | | | +| ------------- | ---------------------------------------------------------------- | ----------------- | +| `key` | required. The columns that identify a row | | +| `values` | the columns the key determines. Omitted, the key is every column | default none | +| `missing` | `refused` or `absent`: what a key the map leaves out means | default `refused` | +| `description` | free text | default `null` | A column is named after its dimension. Where two columns share a dimension, the mapping form names them: `{bus0: bus, bus1: bus}`. @@ -82,7 +82,7 @@ dimensions: bus: { dtype: str } line: { dtype: str } relations: - gen_bus: { key: generator, values: bus } # error: every generator is on a bus + gen_bus: { key: generator, values: bus } # refused: every generator is on a bus line_to: { key: line, values: bus, missing: absent } # an open end is meant ``` diff --git a/docs/reference/reading.md b/docs/reference/reading.md index 5e572d03f..72dd2ec1b 100644 --- a/docs/reference/reading.md +++ b/docs/reference/reading.md @@ -120,13 +120,14 @@ written # "assumption 'cost_is_never_negative' does not hold for the data attac ``` `program.parameters[name].missing` says what a missing row of that parameter -means: `'error'`, `'absent'`, `'neutral'`, or the value it reads as. It is +means: `'refused'`, `'absent'`, `'neutral'`, or the value it reads as. It is `None` for a parameter a `piecewise:` block reads, because the block owns its shape, and for a given parameter, whose declaring file says. The program of `spec.expand(...)` declares a curve's parameters `'neutral'` where the curve has -`points:`. `program.relations[name].missing` is `'error'` or `'absent'`, and -`None` for a bare relation. Under `'error'` the engine refuses a missing row and -names the coordinate. A value is read wherever a value is read: an expression, +`points:`. `program.relations[name].missing` is `'refused'` or `'absent'`, and +`None` for a bare relation. Under `'refused'` the engine does not build the model from a +table with a missing row, and names the coordinate; whether it raises at the +first gap or lists them all is its own. A value is read wherever a value is read: an expression, a bound, and a comparison in a mask. A `ParameterDefined` on a numeric parameter asks whether the data has a row, so a value does not answer it. A `ParameterDefined` on a `bool` parameter reads the value. Under `'absent'`, a diff --git a/schema/mathspec.schema.json b/schema/mathspec.schema.json index cdd4cdd09..30a5bb945 100644 --- a/schema/mathspec.schema.json +++ b/schema/mathspec.schema.json @@ -580,7 +580,7 @@ "anyOf": [ { "enum": [ - "error", + "refused", "absent", "neutral", "inf", @@ -756,7 +756,7 @@ "anyOf": [ { "enum": [ - "error", + "refused", "absent" ], "type": "string" diff --git a/src/mathspec/piecewise.py b/src/mathspec/piecewise.py index 4a073bcbd..b41cb59a0 100644 --- a/src/mathspec/piecewise.py +++ b/src/mathspec/piecewise.py @@ -494,7 +494,7 @@ def expand_piecewise(schema: Spec) -> Spec: mask holds, since the weights and the segment rows stand on it, so the expansion declares it ``missing: neutral``; the ``_complete`` assumption states where it must carry a row. A parameter a curve over - every breakpoint reads keeps the default, ``error``. + every breakpoint reads keeps the default, ``refused``. """ if not schema.piecewise: return schema diff --git a/src/mathspec/program.py b/src/mathspec/program.py index 6b323723c..3970cd013 100644 --- a/src/mathspec/program.py +++ b/src/mathspec/program.py @@ -146,16 +146,16 @@ #: The domain a variable may declare. VariableDomain = Literal['continuous', 'integer', 'binary'] -#: What a missing row means. ``error`` refuses the data, ``absent`` takes the +#: What a missing row means. ``refused`` refuses the data, ``absent`` takes the #: row of a term that reads it, and ``neutral`` reads the value that #: contributes nothing: ``0`` as a coefficient, ``false`` in a ``where``. -MissingReading = Literal['error', 'absent', 'neutral'] +MissingReading = Literal['refused', 'absent', 'neutral'] #: A parameter's ``missing:``: a reading, or the value a missing row reads as. Missing = MissingReading | bool | float #: A relation's ``missing:``. A label the map leaves out is refused, or belongs to no group. -RelationMissing = Literal['error', 'absent'] +RelationMissing = Literal['refused', 'absent'] #: A variable's ``missing:``: what a coordinate its ``where`` masks out means. #: ``absent`` takes the row of a term that reads it; ``neutral`` says the @@ -462,7 +462,7 @@ class RelationDeclaration: key: tuple[str, ...] #: What a key the map leaves out means, or ``None`` for a bare relation, #: whose rows are its membership and so have no gap. - missing: RelationMissing | None = 'error' + missing: RelationMissing | None = 'refused' description: str | None = None @property @@ -617,7 +617,7 @@ class ParameterDeclaration: #: What a missing row means, or the value it reads as wherever a value is #: read; ``None`` for a given parameter, whose declaring file says. A bare #: numeric name in a ``where`` still asks whether the data has a row. - missing: Missing | None = 'error' + missing: Missing | None = 'refused' description: str | None = None diff --git a/src/mathspec/spec.py b/src/mathspec/spec.py index 211a1d7b0..e9dda8621 100644 --- a/src/mathspec/spec.py +++ b/src/mathspec/spec.py @@ -109,7 +109,7 @@ def _reject_unknown_keys(cls, data: object) -> object: {'type': 'boolean'}, {'type': 'number'}, ], - 'default': 'error', + 'default': 'refused', } #: The spellings of a missing row, for a refusal to list. @@ -193,7 +193,7 @@ def _error_or_absent(cls, v: object) -> object: """A label is data, so no value fills a gap in a map, and neutral reads as absent wherever a map is read.""" if v is not None and v not in get_args(RelationMissing): msg = ( - f'missing: {v!r} on a relation, which takes error or absent. A label the map leaves out ' + f'missing: {v!r} on a relation, which takes refused or absent. A label the map leaves out ' f'is refused, or belongs to no group.' ) raise ValueError(msg) @@ -212,10 +212,10 @@ def _a_bare_relation_has_no_gap(self) -> RelationBlock: @property def reading(self) -> RelationMissing | None: - """What a key the map leaves out means: ``error`` unless the file says, and ``None`` for a bare relation.""" + """What a key the map leaves out means: ``refused`` unless the file says, and ``None`` for a bare relation.""" if self.values is None: return None - return self.missing or 'error' + return self.missing or 'refused' @property def pairs(self) -> tuple[tuple[str, str], ...]: @@ -277,7 +277,7 @@ def _a_reading_or_a_value(cls, v: object) -> object: """``inf`` is a string to YAML and a number to the expression grammar, so it is taken as the number.""" if v is None: raise _missing_null( - 'a parameter', 'Write absent for a row that is not there, or leave the key out for error.' + 'a parameter', 'Write absent for a row that is not there, or leave the key out for refused.' ) if v in ('inf', '-inf'): return float(cast('str', v)) @@ -291,8 +291,8 @@ def _a_reading_or_a_value(cls, v: object) -> object: @property def reading(self) -> Missing: - """What a missing row means: what the file wrote, or ``error`` where it wrote nothing.""" - return 'error' if self.missing is None else self.missing + """What a missing row means: what the file wrote, or ``refused`` where it wrote nothing.""" + return 'refused' if self.missing is None else self.missing @model_validator(mode='after') def _a_value_fits_the_dtype(self) -> ParameterBlock: @@ -378,7 +378,7 @@ def _absent_or_neutral(cls, v: object) -> object: if v not in get_args(VariableMissing): msg = ( f'missing: {v!r} on a variable, which takes absent or neutral. A variable has no data: ' - f'error and a value are readings of a parameter.' + f'refused and a value are readings of a parameter.' ) raise ValueError(msg) return v diff --git a/src/mathspec/validation.py b/src/mathspec/validation.py index afded8ef0..616415b97 100644 --- a/src/mathspec/validation.py +++ b/src/mathspec/validation.py @@ -345,7 +345,7 @@ def _curve_parameters_declare_no_missing(schema: Spec) -> Iterator[str]: ``points:`` already says how far a curve runs, and a breakpoint it leaves out is not asked for, so a values parameter is complete over the points - its block admits: neither ``error`` over every coordinate its dims reach + its block admits: neither ``refused`` over every coordinate its dims reach nor a gap any other reading fills. """ for block_name, block in schema.piecewise.items(): diff --git a/tests/test_exclusivity.py b/tests/test_exclusivity.py index a1801ac33..63e337d0d 100644 --- a/tests/test_exclusivity.py +++ b/tests/test_exclusivity.py @@ -341,7 +341,7 @@ def test_a_missing_row_compares_as_the_value_it_reads_as(self): [refusal] = refusals(schema, self.CASES) assert 'efficiency is absent' in refusal, 'the witness is the missing row' - @pytest.mark.parametrize('reading', ['error', 'absent', 'neutral']) + @pytest.mark.parametrize('reading', ['refused', 'absent', 'neutral']) def test_under_a_reading_a_missing_row_compares_false(self, reading: str): schema = to_spec(varied(STORAGE, **{'parameters.efficiency': {'dims': ['storage'], 'missing': reading}})) assert refusals(schema, self.CASES) == [], 'a missing row with no value is in neither case' diff --git a/tests/test_expand.py b/tests/test_expand.py index 77ec1a1a1..1420087e2 100644 --- a/tests/test_expand.py +++ b/tests/test_expand.py @@ -196,33 +196,33 @@ def test_a_model_with_no_formulation_expands_to_itself(): ('points', 'missing'), [ pytest.param({'piecewise.curve.points': 'bx'}, 'neutral', id='a-curve-that-says-how-far-it-runs'), - pytest.param({}, 'error', id='a-curve-over-every-breakpoint'), + pytest.param({}, 'refused', id='a-curve-over-every-breakpoint'), ], ) def test_the_expansion_says_what_a_missing_breakpoint_means(points, missing): """A curve's own parameters answer for no `missing:`, because the block owns their shape. The expansion keeps no block: its weights stand on `points:` and its assumptions ask for a value only where the mask holds. Left - unwritten, lowering would report `error`, and a consumer attaching the rows would refuse the ragged curve the + unwritten, lowering would report `refused`, and a consumer attaching the rows would refuse the ragged curve the block admits; a curve with no `points:` reads every breakpoint.""" rows = schema_of(varied(SMALL_MODEL, **copy.deepcopy(SMALL_CURVE), **points)).expand('piecewise').program assert {name: rows.parameters[name].missing for name in ('bx', 'by', 'c')} == { 'bx': missing, 'by': missing, - 'c': 'error', + 'c': 'refused', }, "the expansion reads the curve's tables as the block did, and every other parameter as declared" def test_a_parameter_a_curve_owns_answers_for_no_missing(): - """The block's own parameters report `None`: reporting the unwritten `error` would tell a consumer to require + """The block's own parameters report `None`: reporting the unwritten `refused` would tell a consumer to require every coordinate the dims reach, which a ragged curve does not carry.""" program = schema_of(varied(SMALL_MODEL, **copy.deepcopy(SMALL_CURVE), **{'piecewise.curve.points': 'bx'})).program missing = {name: p.missing for name, p in program.parameters.items()} - assert missing == {'c': 'error', 'k': 'error', 'flag': 'error', 'tag': 'error', 'bx': None, 'by': None}, ( + assert missing == {'c': 'refused', 'k': 'refused', 'flag': 'refused', 'tag': 'refused', 'bx': None, 'by': None}, ( "the parameters the block consumes answer for no missing; every other parameter's declaration is carried" ) -@pytest.mark.parametrize('missing', ['error', 'neutral', 0]) +@pytest.mark.parametrize('missing', ['refused', 'neutral', 0]) def test_missing_on_a_parameter_a_curve_consumes_is_refused(missing): """`points:` already says how far the curve runs, so any `missing:` there claims what the block decided.""" with pytest.raises(LanguageError) as caught: diff --git a/tests/test_validation.py b/tests/test_validation.py index 8cd146ce9..4f5bf9238 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -2313,7 +2313,7 @@ def test_an_infinite_bound_is_refused_with_the_null_that_opens_a_side(side, valu @pytest.mark.parametrize( ('dtype', 'written', 'read'), [ - pytest.param('float', None, 'error', id='left-out-is-error'), + pytest.param('float', None, 'refused', id='left-out-is-refused'), pytest.param('float', 'absent', 'absent', id='absent'), pytest.param('str', 'neutral', 'neutral', id='neutral-on-a-label'), pytest.param('float', 1, 1, id='a-number'), @@ -2345,7 +2345,7 @@ def test_inf_and_dot_inf_load_as_one_number(): ('dtype', 'written', 'fragment'), [ pytest.param('float', None, 'missing: null on a parameter names no reading', id='null'), - pytest.param('float', 'zero', "missing is the string 'zero'. It takes error, absent, neutral", id='a-word'), + pytest.param('float', 'zero', "missing is the string 'zero'. It takes refused, absent, neutral", id='a-word'), pytest.param('float', '1', "missing is the string '1'", id='a-quoted-number'), pytest.param('float', float('nan'), 'missing is nan', id='nan'), pytest.param( @@ -2371,8 +2371,8 @@ def test_a_given_parameter_has_no_missing(): @pytest.mark.parametrize( ('written', 'read'), [ - pytest.param({}, 'error', id='left-out-is-error'), - pytest.param({'missing': 'error'}, 'error', id='error'), + pytest.param({}, 'refused', id='left-out-is-refused'), + pytest.param({'missing': 'refused'}, 'refused', id='refused'), pytest.param({'missing': 'absent'}, 'absent', id='absent'), ], ) @@ -2382,7 +2382,7 @@ def test_a_relation_says_what_a_label_it_leaves_out_means(written, read): def test_a_bare_relation_has_no_missing_rows(): - """Its rows are its membership: a pair it leaves out is not a gap, so `error` would refuse every sparse set.""" + """Its rows are its membership: a pair it leaves out is not a gap, so `refused` would refuse every sparse set.""" spec = to_spec(varied(SMALL_MODEL, **{'relations.pair': {'key': ['g', 'h']}})) assert spec.program.relations['pair'].missing is None @@ -2392,15 +2392,15 @@ def test_a_bare_relation_has_no_missing_rows(): [ pytest.param( {'key': 'g', 'values': 'h', 'missing': 'neutral'}, - "missing: 'neutral' on a relation, which takes error or absent", + "missing: 'neutral' on a relation, which takes refused or absent", id='neutral', ), pytest.param( {'key': 'g', 'values': 'h', 'missing': 'h1'}, "missing: 'h1' on a relation", id='a-label-as-a-value' ), pytest.param( - {'key': ['g', 'h'], 'missing': 'error'}, - 'missing: error on a relation with no `values:`', + {'key': ['g', 'h'], 'missing': 'refused'}, + 'missing: refused on a relation with no `values:`', id='a-bare-relation', ), ], @@ -2412,7 +2412,7 @@ def test_a_relation_missing_that_reads_nothing_is_refused(relation, fragment): @pytest.mark.parametrize( ('written', 'fragment'), [ - pytest.param('error', "missing: 'error' on a variable, which takes absent or neutral", id='error'), + pytest.param('refused', "missing: 'refused' on a variable, which takes absent or neutral", id='refused'), pytest.param(0, 'missing: 0 on a variable, which takes absent or neutral', id='a-value'), pytest.param(None, 'missing: null on a variable names no reading', id='null'), ], From 7a2449afe2f3f65aef02a4bed29d67e25ce4160e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 09:39:32 +0000 Subject: [PATCH 06/21] fix(language): the schema publishes `refused` as the default of `missing:`, and a relation refuses `missing: null` The parameter and the relation hold `None` until read, and the schema published that `null`. The parameter's readings and its `inf` spellings were one enum; they are two branches now, and the closed-vocabulary test covers the parameter too. A relation took `missing: null` where a parameter and a variable refuse it. The validator that still said "error" is `_refused_or_absent`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiDTE4WvKobVxTDxUeWhc9 --- schema/mathspec.schema.json | 28 ++++++++++++---------------- src/mathspec/spec.py | 20 ++++++++++++++------ tests/test_schema.py | 15 +++++++++++++++ tests/test_validation.py | 3 +++ 4 files changed, 44 insertions(+), 22 deletions(-) diff --git a/schema/mathspec.schema.json b/schema/mathspec.schema.json index 30a5bb945..9add917c9 100644 --- a/schema/mathspec.schema.json +++ b/schema/mathspec.schema.json @@ -582,9 +582,7 @@ "enum": [ "refused", "absent", - "neutral", - "inf", - "-inf" + "neutral" ] }, { @@ -592,9 +590,15 @@ }, { "type": "number" + }, + { + "enum": [ + "inf", + "-inf" + ] } ], - "default": null, + "default": "refused", "title": "Missing" } }, @@ -753,19 +757,11 @@ "title": "Key" }, "missing": { - "anyOf": [ - { - "enum": [ - "refused", - "absent" - ], - "type": "string" - }, - { - "type": "null" - } + "default": "refused", + "enum": [ + "refused", + "absent" ], - "default": null, "title": "Missing" }, "values": { diff --git a/src/mathspec/spec.py b/src/mathspec/spec.py index e9dda8621..304dc6d06 100644 --- a/src/mathspec/spec.py +++ b/src/mathspec/spec.py @@ -105,11 +105,11 @@ def _reject_unknown_keys(cls, data: object) -> object: #: and ``-inf`` as the numbers they name, which an editor would otherwise flag. _MISSING_SCHEMA: dict[str, object] = { 'anyOf': [ - {'enum': [*get_args(MissingReading), 'inf', '-inf']}, + {'enum': list(get_args(MissingReading))}, {'type': 'boolean'}, {'type': 'number'}, + {'enum': ['inf', '-inf']}, ], - 'default': 'refused', } #: The spellings of a missing row, for a refusal to list. @@ -184,14 +184,20 @@ class RelationBlock(_StrictBlock): key: str | list[str] | dict[str, str] values: str | list[str] | dict[str, str] | None = None - missing: RelationMissing | None = None + missing: Annotated[RelationMissing | None, WithJsonSchema({'enum': list(get_args(RelationMissing))})] = Field( + default=None, json_schema_extra={'default': 'refused'} + ) description: str | None = None @field_validator('missing', mode='before') @classmethod - def _error_or_absent(cls, v: object) -> object: + def _refused_or_absent(cls, v: object) -> object: """A label is data, so no value fills a gap in a map, and neutral reads as absent wherever a map is read.""" - if v is not None and v not in get_args(RelationMissing): + if v is None: + raise _missing_null( + 'a relation', 'Write absent for a key the map leaves out, or leave the key out for refused.' + ) + if v not in get_args(RelationMissing): msg = ( f'missing: {v!r} on a relation, which takes refused or absent. A label the map leaves out ' f'is refused, or belongs to no group.' @@ -268,7 +274,9 @@ class ParameterBlock(_StrictBlock): dims: list[str] dtype: ParameterDtype = 'float' - missing: Annotated[MissingReading | bool | int | float | None, WithJsonSchema(_MISSING_SCHEMA)] = None + missing: Annotated[MissingReading | bool | int | float | None, WithJsonSchema(_MISSING_SCHEMA)] = Field( + default=None, json_schema_extra={'default': 'refused'} + ) description: str | None = None @field_validator('missing', mode='before') diff --git a/tests/test_schema.py b/tests/test_schema.py index e7ccbc15b..2b25eef2a 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -60,6 +60,7 @@ def test_no_definition_refers_only_to_itself(): pytest.param('VariableBlock', 'domain', spec.VariableDomain, id='domain'), pytest.param('VariableBlock', 'missing', spec.VariableMissing, id='variable-missing'), pytest.param('RelationBlock', 'missing', spec.RelationMissing, id='relation-missing'), + pytest.param('ParameterBlock', 'missing', spec.MissingReading, id='parameter-missing'), pytest.param('ParameterBlock', 'dtype', spec.ParameterDtype, id='parameter-dtype'), pytest.param('DimensionBlock', 'dtype', spec.DimensionDtype, id='dimension-dtype'), pytest.param('PiecewiseBlock', 'method', spec.PiecewiseMethod, id='method'), @@ -75,6 +76,20 @@ def test_a_closed_vocabulary_is_published_as_an_enum(block, field, alias): assert enum == list(get_args(alias)), f'{block}.{field} stopped publishing its closed vocabulary' +@pytest.mark.parametrize( + ('block', 'default'), + [ + pytest.param('ParameterBlock', 'refused', id='parameter'), + pytest.param('RelationBlock', 'refused', id='relation'), + pytest.param('VariableBlock', 'absent', id='variable'), + ], +) +def test_the_schema_publishes_the_reading_a_file_gets_by_leaving_missing_out(block, default): + """The parameter and the relation hold `None` until read, and the schema published that `null`, not `refused`.""" + published = json.loads(schema.PATH.read_text())['$defs'][block]['properties']['missing'] + assert published['default'] == default + + def test_the_piecewise_method_vocabulary_has_one_home(): """`PiecewiseMethod` types the field and `PIECEWISE_METHODS` says what each emits.""" assert set(get_args(spec.PiecewiseMethod)) == set(spec.PIECEWISE_METHODS), ( diff --git a/tests/test_validation.py b/tests/test_validation.py index 4f5bf9238..b6d94814b 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -2398,6 +2398,9 @@ def test_a_bare_relation_has_no_missing_rows(): pytest.param( {'key': 'g', 'values': 'h', 'missing': 'h1'}, "missing: 'h1' on a relation", id='a-label-as-a-value' ), + pytest.param( + {'key': 'g', 'values': 'h', 'missing': None}, 'missing: null on a relation names no reading', id='null' + ), pytest.param( {'key': ['g', 'h'], 'missing': 'refused'}, 'missing: refused on a relation with no `values:`', From 118cec58b1f9f001186c4ef393dfe8ee34293a5f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 22:33:14 +0000 Subject: [PATCH 07/21] docs(pypsa): a start-up or shut-down ramp limit with no row means no limit The six start-up and shut-down ramp tables kept the default `refused`, but the example reads them as "has a row" tests: `when:` with `otherwise: 1`, and `(ramp_limit_up OR ramp_limit_start_up)` in the big-M masks. Under `refused` both are finiteness tests, so the `otherwise:` was dead and the OR always true. `missing: neutral` gives them the meaning they had on main and the one PyPSA gives a NaN ramp limit. The same two tables in pypsa_linearized_uc.yaml are marked too. The other bare names on a `refused` parameter (`*_nom_max`, `e_sum_min`, `e_sum_max`) are finiteness tests, because PyPSA writes infinity where no cap is meant. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CsaSWMofYqJiKmnSiPycV9 --- docs/examples/pypsa.md | 12 ++++++------ docs/examples/pypsa/generator_ramping.md | 10 ++++++---- docs/examples/pypsa/link_ramping.md | 10 ++++++---- docs/examples/pypsa/process_ramping.md | 10 ++++++---- docs/examples/pypsa_linearized_uc.md | 4 ++-- examples/pypsa.yaml | 18 ++++++++++++------ examples/pypsa/generator_ramping.yaml | 6 ++++-- examples/pypsa/link_ramping.yaml | 6 ++++-- examples/pypsa/process_ramping.yaml | 6 ++++-- examples/pypsa_linearized_uc.yaml | 6 ++++-- 10 files changed, 54 insertions(+), 34 deletions(-) diff --git a/docs/examples/pypsa.md b/docs/examples/pypsa.md index ec2c489ec..33c5e447b 100644 --- a/docs/examples/pypsa.md +++ b/docs/examples/pypsa.md @@ -5430,8 +5430,8 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{com}`$ | `Generator_committable` over $`\mathcal{G}`$ — whether output is gated by an on/off status decision | | $`\mathrm{ru}`$ | `Generator_ramp_limit_up` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — most a generator may raise its output between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time | | $`\mathrm{rd}`$ | `Generator_ramp_limit_down` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — most a generator may lower its output between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time | -| $`\mathrm{ru}^{\mathrm{up}}`$ | `Generator_ramp_limit_start_up` over $`\Xi \times \mathcal{G}`$ — most output in the snapshot a unit starts, per unit of nominal power | -| $`\mathrm{rd}^{\mathrm{dn}}`$ | `Generator_ramp_limit_shut_down` over $`\Xi \times \mathcal{G}`$ — most output in the snapshot before a unit stops, per unit of nominal power | +| $`\mathrm{ru}^{\mathrm{up}}`$ | `Generator_ramp_limit_start_up` over $`\Xi \times \mathcal{G}`$ — most output in the snapshot a unit starts, per unit of nominal power; no value means no limit | +| $`\mathrm{rd}^{\mathrm{dn}}`$ | `Generator_ramp_limit_shut_down` over $`\Xi \times \mathcal{G}`$ — most output in the snapshot before a unit stops, per unit of nominal power; no value means no limit | | $`\mathrm{UT}`$ | `Generator_min_up_time` over $`\Xi \times \mathcal{G}`$ — least snapshots a unit stays on once started | | $`\mathrm{DT}`$ | `Generator_min_down_time` over $`\Xi \times \mathcal{G}`$ — least snapshots a unit stays off once stopped | | $`\mathrm{u}^{0}`$ | `Generator_status_initial` over $`\Xi \times \mathcal{G}`$ — one where the unit was on before the first snapshot, zero where off — PyPSA's `up_time_before > 0`, data prep | @@ -5462,8 +5462,8 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{c}^{f}`$ | `Link_marginal_cost` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — cost of one unit of flow | | $`\mathrm{c}^{f,(2)}`$ | `Link_marginal_cost_quadratic` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — cost of the square of one unit of flow | | $`\mathrm{com}^{f}`$ | `Link_committable` over $`\mathcal{L}`$ — whether flow is gated by an on/off status decision | -| $`\mathrm{ru}^{f,\mathrm{up}}`$ | `Link_ramp_limit_start_up` over $`\Xi \times \mathcal{L}`$ — most flow in the snapshot a link starts, per unit of nominal power | -| $`\mathrm{rd}^{f,\mathrm{dn}}`$ | `Link_ramp_limit_shut_down` over $`\Xi \times \mathcal{L}`$ — most flow in the snapshot before a link stops, per unit of nominal power | +| $`\mathrm{ru}^{f,\mathrm{up}}`$ | `Link_ramp_limit_start_up` over $`\Xi \times \mathcal{L}`$ — most flow in the snapshot a link starts, per unit of nominal power; no value means no limit | +| $`\mathrm{rd}^{f,\mathrm{dn}}`$ | `Link_ramp_limit_shut_down` over $`\Xi \times \mathcal{L}`$ — most flow in the snapshot before a link stops, per unit of nominal power; no value means no limit | | $`\mathrm{UT}^{f}`$ | `Link_min_up_time` over $`\Xi \times \mathcal{L}`$ — least snapshots a link stays on once started | | $`\mathrm{DT}^{f}`$ | `Link_min_down_time` over $`\Xi \times \mathcal{L}`$ — least snapshots a link stays off once stopped | | $`\mathrm{u}^{f,0}`$ | `Link_status_initial` over $`\Xi \times \mathcal{L}`$ — one where the link was on before the first snapshot, zero where off — PyPSA's `up_time_before > 0`, data prep | @@ -5499,8 +5499,8 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{c}^{\mathrm{cap},z}`$ | `Process_capital_cost` over $`\Xi \times \mathcal{J}`$ — cost of one unit of nominal power for the modelled horizon — PyPSA's `periodized_cost`: `overnight_cost` as an annuity over `lifetime` at `discount_rate`, times `nyears`, where it is given, and `capital_cost` where it is not, plus `fom_cost` (`components.py:1126-1147`, `costs.py:102-203`), data prep | | $`\mathrm{z}^{\mathrm{nom,set}}`$ | `Process_p_nom_set` over $`\Xi \times \mathcal{J}`$ — a given nominal power for an extendable process; one without a value has no row here | | $`\mathrm{com}^{z}`$ | `Process_committable` over $`\mathcal{J}`$ — whether internal power is gated by an on/off status decision | -| $`\mathrm{ru}^{z,\mathrm{up}}`$ | `Process_ramp_limit_start_up` over $`\Xi \times \mathcal{J}`$ — most internal power in the snapshot a process starts, per unit of nominal power | -| $`\mathrm{rd}^{z,\mathrm{dn}}`$ | `Process_ramp_limit_shut_down` over $`\Xi \times \mathcal{J}`$ — most internal power in the snapshot before a process stops, per unit of nominal power | +| $`\mathrm{ru}^{z,\mathrm{up}}`$ | `Process_ramp_limit_start_up` over $`\Xi \times \mathcal{J}`$ — most internal power in the snapshot a process starts, per unit of nominal power; no value means no limit | +| $`\mathrm{rd}^{z,\mathrm{dn}}`$ | `Process_ramp_limit_shut_down` over $`\Xi \times \mathcal{J}`$ — most internal power in the snapshot before a process stops, per unit of nominal power; no value means no limit | | $`\mathrm{UT}^{z}`$ | `Process_min_up_time` over $`\Xi \times \mathcal{J}`$ — least snapshots a process stays on once started | | $`\mathrm{DT}^{z}`$ | `Process_min_down_time` over $`\Xi \times \mathcal{J}`$ — least snapshots a process stays off once stopped | | $`\mathrm{u}^{z,0}`$ | `Process_status_initial` over $`\Xi \times \mathcal{J}`$ — one where the process was on before the first snapshot, zero where off — PyPSA's `up_time_before > 0`, data prep | diff --git a/docs/examples/pypsa/generator_ramping.md b/docs/examples/pypsa/generator_ramping.md index af6411b76..4a31d3841 100644 --- a/docs/examples/pypsa/generator_ramping.md +++ b/docs/examples/pypsa/generator_ramping.md @@ -37,11 +37,13 @@ parameters: dims: [scenario, snapshot, generator] missing: neutral Generator_ramp_limit_start_up: - description: most output in the snapshot a unit starts, per unit of nominal power + description: most output in the snapshot a unit starts, per unit of nominal power; no value means no limit dims: [scenario, generator] + missing: neutral Generator_ramp_limit_shut_down: - description: most output in the snapshot before a unit stops, per unit of nominal power + description: most output in the snapshot before a unit stops, per unit of nominal power; no value means no limit dims: [scenario, generator] + missing: neutral Generator_p_init: description: >- the output a unit brought into the horizon — PyPSA's `p_init`, read @@ -264,8 +266,8 @@ assumptions: |---|---| | $`\mathrm{ru}`$ | `Generator_ramp_limit_up` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — most a generator may raise its output between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time | | $`\mathrm{rd}`$ | `Generator_ramp_limit_down` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — most a generator may lower its output between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time | -| $`\mathrm{ru}^{\mathrm{up}}`$ | `Generator_ramp_limit_start_up` over $`\Xi \times \mathcal{G}`$ — most output in the snapshot a unit starts, per unit of nominal power | -| $`\mathrm{rd}^{\mathrm{dn}}`$ | `Generator_ramp_limit_shut_down` over $`\Xi \times \mathcal{G}`$ — most output in the snapshot before a unit stops, per unit of nominal power | +| $`\mathrm{ru}^{\mathrm{up}}`$ | `Generator_ramp_limit_start_up` over $`\Xi \times \mathcal{G}`$ — most output in the snapshot a unit starts, per unit of nominal power; no value means no limit | +| $`\mathrm{rd}^{\mathrm{dn}}`$ | `Generator_ramp_limit_shut_down` over $`\Xi \times \mathcal{G}`$ — most output in the snapshot before a unit stops, per unit of nominal power; no value means no limit | | $`\mathrm{p}^{0}`$ | `Generator_p_init` over $`\Xi \times \mathcal{G}`$ — the output a unit brought into the horizon — PyPSA's `p_init`, read only where the unit came in running; no value means it is unknown, so the unit carries no ramp row at the first snapshot | #### Given diff --git a/docs/examples/pypsa/link_ramping.md b/docs/examples/pypsa/link_ramping.md index 7129d8825..bac06ef80 100644 --- a/docs/examples/pypsa/link_ramping.md +++ b/docs/examples/pypsa/link_ramping.md @@ -37,11 +37,13 @@ parameters: dims: [scenario, snapshot, link] missing: neutral Link_ramp_limit_start_up: - description: most flow in the snapshot a link starts, per unit of nominal power + description: most flow in the snapshot a link starts, per unit of nominal power; no value means no limit dims: [scenario, link] + missing: neutral Link_ramp_limit_shut_down: - description: most flow in the snapshot before a link stops, per unit of nominal power + description: most flow in the snapshot before a link stops, per unit of nominal power; no value means no limit dims: [scenario, link] + missing: neutral Link_p_init: description: >- the flow a link brought into the horizon — PyPSA's `p_init`, read @@ -264,8 +266,8 @@ assumptions: |---|---| | $`\mathrm{ru}^{f}`$ | `Link_ramp_limit_up` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — most a link may raise its flow between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time | | $`\mathrm{rd}^{f}`$ | `Link_ramp_limit_down` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — most a link may lower its flow between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time | -| $`\mathrm{ru}^{f,\mathrm{up}}`$ | `Link_ramp_limit_start_up` over $`\Xi \times \mathcal{L}`$ — most flow in the snapshot a link starts, per unit of nominal power | -| $`\mathrm{rd}^{f,\mathrm{dn}}`$ | `Link_ramp_limit_shut_down` over $`\Xi \times \mathcal{L}`$ — most flow in the snapshot before a link stops, per unit of nominal power | +| $`\mathrm{ru}^{f,\mathrm{up}}`$ | `Link_ramp_limit_start_up` over $`\Xi \times \mathcal{L}`$ — most flow in the snapshot a link starts, per unit of nominal power; no value means no limit | +| $`\mathrm{rd}^{f,\mathrm{dn}}`$ | `Link_ramp_limit_shut_down` over $`\Xi \times \mathcal{L}`$ — most flow in the snapshot before a link stops, per unit of nominal power; no value means no limit | | $`\mathrm{f}^{0}`$ | `Link_p_init` over $`\Xi \times \mathcal{L}`$ — the flow a link brought into the horizon — PyPSA's `p_init`, read only where the link came in running; no value means it is unknown, so the link carries no ramp row at the first snapshot | #### Given diff --git a/docs/examples/pypsa/process_ramping.md b/docs/examples/pypsa/process_ramping.md index 4003e0a15..4e22088af 100644 --- a/docs/examples/pypsa/process_ramping.md +++ b/docs/examples/pypsa/process_ramping.md @@ -37,11 +37,13 @@ parameters: dims: [scenario, snapshot, process] missing: neutral Process_ramp_limit_start_up: - description: most internal power in the snapshot a process starts, per unit of nominal power + description: most internal power in the snapshot a process starts, per unit of nominal power; no value means no limit dims: [scenario, process] + missing: neutral Process_ramp_limit_shut_down: - description: most internal power in the snapshot before a process stops, per unit of nominal power + description: most internal power in the snapshot before a process stops, per unit of nominal power; no value means no limit dims: [scenario, process] + missing: neutral Process_p_init: description: >- the internal power a process brought into the horizon — PyPSA's `p_init`, read @@ -264,8 +266,8 @@ assumptions: |---|---| | $`\mathrm{ru}^{z}`$ | `Process_ramp_limit_up` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — most a process may raise its internal power between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time | | $`\mathrm{rd}^{z}`$ | `Process_ramp_limit_down` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — most a process may lower its internal power between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time | -| $`\mathrm{ru}^{z,\mathrm{up}}`$ | `Process_ramp_limit_start_up` over $`\Xi \times \mathcal{J}`$ — most internal power in the snapshot a process starts, per unit of nominal power | -| $`\mathrm{rd}^{z,\mathrm{dn}}`$ | `Process_ramp_limit_shut_down` over $`\Xi \times \mathcal{J}`$ — most internal power in the snapshot before a process stops, per unit of nominal power | +| $`\mathrm{ru}^{z,\mathrm{up}}`$ | `Process_ramp_limit_start_up` over $`\Xi \times \mathcal{J}`$ — most internal power in the snapshot a process starts, per unit of nominal power; no value means no limit | +| $`\mathrm{rd}^{z,\mathrm{dn}}`$ | `Process_ramp_limit_shut_down` over $`\Xi \times \mathcal{J}`$ — most internal power in the snapshot before a process stops, per unit of nominal power; no value means no limit | | $`\mathrm{z}^{0}`$ | `Process_p_init` over $`\Xi \times \mathcal{J}`$ — the internal power a process brought into the horizon — PyPSA's `p_init`, read only where the process came in running; no value means it is unknown, so the process carries no ramp row at the first snapshot | #### Given diff --git a/docs/examples/pypsa_linearized_uc.md b/docs/examples/pypsa_linearized_uc.md index 1321e2e89..63d05de60 100644 --- a/docs/examples/pypsa_linearized_uc.md +++ b/docs/examples/pypsa_linearized_uc.md @@ -354,8 +354,8 @@ The relaxed class of a plain `n.optimize()`: `linearized_unit_commitment`, state | $`\mathrm{com}`$ | `Generator_committable` over $`\mathcal{G}`$ — whether output is gated by an on/off status decision | | $`\mathrm{ru}`$ | `Generator_ramp_limit_up` over $`\mathcal{T} \times \mathcal{G}`$ — most a generator may raise its output between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time | | $`\mathrm{rd}`$ | `Generator_ramp_limit_down` over $`\mathcal{T} \times \mathcal{G}`$ — most a generator may lower its output between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time | -| $`\mathrm{ru}^{\mathrm{up}}`$ | `Generator_ramp_limit_start_up` over $`\mathcal{G}`$ — most output in the snapshot a unit starts, per unit of nominal power | -| $`\mathrm{rd}^{\mathrm{dn}}`$ | `Generator_ramp_limit_shut_down` over $`\mathcal{G}`$ — most output in the snapshot before a unit stops, per unit of nominal power | +| $`\mathrm{ru}^{\mathrm{up}}`$ | `Generator_ramp_limit_start_up` over $`\mathcal{G}`$ — most output in the snapshot a unit starts, per unit of nominal power; no value means no limit | +| $`\mathrm{rd}^{\mathrm{dn}}`$ | `Generator_ramp_limit_shut_down` over $`\mathcal{G}`$ — most output in the snapshot before a unit stops, per unit of nominal power; no value means no limit | | $`\mathrm{UT}`$ | `Generator_min_up_time` over $`\mathcal{G}`$ — least snapshots a unit stays on once started | | $`\mathrm{DT}`$ | `Generator_min_down_time` over $`\mathcal{G}`$ — least snapshots a unit stays off once stopped | | $`\mathrm{u}^{0}`$ | `Generator_status_initial` over $`\mathcal{G}`$ — one where the unit was on before the first snapshot, zero where off — PyPSA's `up_time_before > 0`, data prep | diff --git a/examples/pypsa.yaml b/examples/pypsa.yaml index 1e2dc1575..85ddc1f58 100644 --- a/examples/pypsa.yaml +++ b/examples/pypsa.yaml @@ -118,11 +118,13 @@ parameters: dims: [scenario, snapshot, generator] missing: neutral Generator_ramp_limit_start_up: - description: most output in the snapshot a unit starts, per unit of nominal power + description: most output in the snapshot a unit starts, per unit of nominal power; no value means no limit dims: [scenario, generator] + missing: neutral Generator_ramp_limit_shut_down: - description: most output in the snapshot before a unit stops, per unit of nominal power + description: most output in the snapshot before a unit stops, per unit of nominal power; no value means no limit dims: [scenario, generator] + missing: neutral Generator_min_up_time: description: least snapshots a unit stays on once started dims: [scenario, generator] @@ -282,11 +284,13 @@ parameters: dims: [link] dtype: bool Link_ramp_limit_start_up: - description: most flow in the snapshot a link starts, per unit of nominal power + description: most flow in the snapshot a link starts, per unit of nominal power; no value means no limit dims: [scenario, link] + missing: neutral Link_ramp_limit_shut_down: - description: most flow in the snapshot before a link stops, per unit of nominal power + description: most flow in the snapshot before a link stops, per unit of nominal power; no value means no limit dims: [scenario, link] + missing: neutral Link_min_up_time: description: least snapshots a link stays on once started dims: [scenario, link] @@ -467,11 +471,13 @@ parameters: dims: [process] dtype: bool Process_ramp_limit_start_up: - description: most internal power in the snapshot a process starts, per unit of nominal power + description: most internal power in the snapshot a process starts, per unit of nominal power; no value means no limit dims: [scenario, process] + missing: neutral Process_ramp_limit_shut_down: - description: most internal power in the snapshot before a process stops, per unit of nominal power + description: most internal power in the snapshot before a process stops, per unit of nominal power; no value means no limit dims: [scenario, process] + missing: neutral Process_min_up_time: description: least snapshots a process stays on once started dims: [scenario, process] diff --git a/examples/pypsa/generator_ramping.yaml b/examples/pypsa/generator_ramping.yaml index 43fd1da96..1df2ec6e5 100644 --- a/examples/pypsa/generator_ramping.yaml +++ b/examples/pypsa/generator_ramping.yaml @@ -30,11 +30,13 @@ parameters: dims: [scenario, snapshot, generator] missing: neutral Generator_ramp_limit_start_up: - description: most output in the snapshot a unit starts, per unit of nominal power + description: most output in the snapshot a unit starts, per unit of nominal power; no value means no limit dims: [scenario, generator] + missing: neutral Generator_ramp_limit_shut_down: - description: most output in the snapshot before a unit stops, per unit of nominal power + description: most output in the snapshot before a unit stops, per unit of nominal power; no value means no limit dims: [scenario, generator] + missing: neutral Generator_p_init: description: >- the output a unit brought into the horizon — PyPSA's `p_init`, read diff --git a/examples/pypsa/link_ramping.yaml b/examples/pypsa/link_ramping.yaml index f53b1d3ae..fcf88204e 100644 --- a/examples/pypsa/link_ramping.yaml +++ b/examples/pypsa/link_ramping.yaml @@ -30,11 +30,13 @@ parameters: dims: [scenario, snapshot, link] missing: neutral Link_ramp_limit_start_up: - description: most flow in the snapshot a link starts, per unit of nominal power + description: most flow in the snapshot a link starts, per unit of nominal power; no value means no limit dims: [scenario, link] + missing: neutral Link_ramp_limit_shut_down: - description: most flow in the snapshot before a link stops, per unit of nominal power + description: most flow in the snapshot before a link stops, per unit of nominal power; no value means no limit dims: [scenario, link] + missing: neutral Link_p_init: description: >- the flow a link brought into the horizon — PyPSA's `p_init`, read diff --git a/examples/pypsa/process_ramping.yaml b/examples/pypsa/process_ramping.yaml index 414024d35..6ba1dee0d 100644 --- a/examples/pypsa/process_ramping.yaml +++ b/examples/pypsa/process_ramping.yaml @@ -30,11 +30,13 @@ parameters: dims: [scenario, snapshot, process] missing: neutral Process_ramp_limit_start_up: - description: most internal power in the snapshot a process starts, per unit of nominal power + description: most internal power in the snapshot a process starts, per unit of nominal power; no value means no limit dims: [scenario, process] + missing: neutral Process_ramp_limit_shut_down: - description: most internal power in the snapshot before a process stops, per unit of nominal power + description: most internal power in the snapshot before a process stops, per unit of nominal power; no value means no limit dims: [scenario, process] + missing: neutral Process_p_init: description: >- the internal power a process brought into the horizon — PyPSA's `p_init`, read diff --git a/examples/pypsa_linearized_uc.yaml b/examples/pypsa_linearized_uc.yaml index ea2006403..46f98296f 100644 --- a/examples/pypsa_linearized_uc.yaml +++ b/examples/pypsa_linearized_uc.yaml @@ -95,11 +95,13 @@ parameters: dims: [snapshot, generator] missing: neutral Generator_ramp_limit_start_up: - description: most output in the snapshot a unit starts, per unit of nominal power + description: most output in the snapshot a unit starts, per unit of nominal power; no value means no limit dims: [generator] + missing: neutral Generator_ramp_limit_shut_down: - description: most output in the snapshot before a unit stops, per unit of nominal power + description: most output in the snapshot before a unit stops, per unit of nominal power; no value means no limit dims: [generator] + missing: neutral Generator_min_up_time: description: least snapshots a unit stays on once started dims: [generator] From 60643468e62877b6cacf300f7dc002ddd425d0d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 22:35:26 +0000 Subject: [PATCH 08/21] fix(language): a spec and its expansion read a curve's parameters alike Lowering reported `None` for every parameter a `piecewise:` block consumes, while the expansion declared the same parameter `neutral`. A row outside the curve that reads the parameter (`r <= by`) so had no reading in the spec and `neutral` in its expansion. `curve_readings` is now the one home of the rule: `neutral` where only curves with `points:` read the parameter, `refused` otherwise. Lowering reports it and the expansion declares it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CsaSWMofYqJiKmnSiPycV9 --- docs/reference/language/declarations.md | 3 +- docs/reference/language/piecewise.md | 5 ++-- docs/reference/reading.md | 8 +++--- src/mathspec/lowering.py | 8 ++---- src/mathspec/piecewise.py | 30 +++++++++++++------- src/mathspec/spec.py | 3 +- tests/test_expand.py | 37 ++++++++++++++++++------- 7 files changed, 61 insertions(+), 33 deletions(-) diff --git a/docs/reference/language/declarations.md b/docs/reference/language/declarations.md index fb8207aa9..31f320d9e 100644 --- a/docs/reference/language/declarations.md +++ b/docs/reference/language/declarations.md @@ -95,7 +95,8 @@ A value has the parameter's dtype: A NaN, a quoted number and `missing: null` are refused. A `given:` parameter has no `missing:`. The file that declares the parameter owns it. A parameter a [`piecewise:`](piecewise.md) block reads takes no `missing:` either: the block -owns the shape of its curve. The typeset legend prints a value beside the +owns the shape of its curve. Such a parameter is `neutral` where only curves +with `points:` read it, and `refused` otherwise, in the curve and outside it. The typeset legend prints a value beside the parameter. ## `variables` diff --git a/docs/reference/language/piecewise.md b/docs/reference/language/piecewise.md index 669b2dbdb..09c5b52d9 100644 --- a/docs/reference/language/piecewise.md +++ b/docs/reference/language/piecewise.md @@ -172,7 +172,8 @@ shows a spec before and after. - **Every name written out starts with the name of the block.** The weights of the curve `curve` are `curve_lam`. - **No formulation emits a parameter.** The same data attaches to a spec and its - expansion. A curve under `points:` declares the parameters it reads - `missing: neutral`, because its rows read them only where the mask holds. + expansion, and reads alike in both. A curve under `points:` declares the + parameters it reads `missing: neutral`, because its rows read them only where + the mask holds. - **The assumptions a `method:` implies become `assumptions:` entries** with the same names. diff --git a/docs/reference/reading.md b/docs/reference/reading.md index 72dd2ec1b..a2aa33b28 100644 --- a/docs/reference/reading.md +++ b/docs/reference/reading.md @@ -121,10 +121,10 @@ written # "assumption 'cost_is_never_negative' does not hold for the data attac `program.parameters[name].missing` says what a missing row of that parameter means: `'refused'`, `'absent'`, `'neutral'`, or the value it reads as. It is -`None` for a parameter a `piecewise:` block reads, because the block owns its -shape, and for a given parameter, whose declaring file says. The program of -`spec.expand(...)` declares a curve's parameters `'neutral'` where the curve has -`points:`. `program.relations[name].missing` is `'refused'` or `'absent'`, and +`None` for a given parameter, whose declaring file says. A parameter that a +`piecewise:` block reads is `'neutral'` where only curves with `points:` read +it, and `'refused'` otherwise. The program of `spec.expand(...)` reports the +same readings. `program.relations[name].missing` is `'refused'` or `'absent'`, and `None` for a bare relation. Under `'refused'` the engine does not build the model from a table with a missing row, and names the coordinate; whether it raises at the first gap or lists them all is its own. A value is read wherever a value is read: an expression, diff --git a/src/mathspec/lowering.py b/src/mathspec/lowering.py index 14722553f..ec14e0d06 100644 --- a/src/mathspec/lowering.py +++ b/src/mathspec/lowering.py @@ -20,7 +20,7 @@ from mathspec.dimensions import check_schema, dims_of from mathspec.errors import SchemaError, did_you_mean, prefixed from mathspec.expansion import expand, parse_template -from mathspec.piecewise import assumptions_of, curve_frame, lp_domain_refusal, resolve_links +from mathspec.piecewise import assumptions_of, curve_frame, curve_readings, lp_domain_refusal, resolve_links from mathspec.program import ( Assumption, BooleanLiteral, @@ -202,12 +202,10 @@ 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 - owned = {name for block in schema.piecewise.values() for name in block.consumes} + readings = curve_readings(schema) program = Program( parameters={ - name: ParameterDeclaration( - tuple(pdef.dims), pdef.dtype, None if name in owned else pdef.reading, pdef.description - ) + name: ParameterDeclaration(tuple(pdef.dims), pdef.dtype, readings.get(name, pdef.reading), pdef.description) for name, pdef in schema.parameters.items() }, variables=variables, diff --git a/src/mathspec/piecewise.py b/src/mathspec/piecewise.py index b41cb59a0..5345b3bee 100644 --- a/src/mathspec/piecewise.py +++ b/src/mathspec/piecewise.py @@ -28,7 +28,7 @@ if TYPE_CHECKING: from collections.abc import Iterable - from mathspec.program import Expression + from mathspec.program import Expression, MissingReading from mathspec.resolution import Namespace @@ -481,6 +481,21 @@ def _segment_lines(self) -> None: ) +def curve_readings(schema: Spec) -> dict[str, MissingReading]: + """What a missing row of each parameter a ``piecewise:`` block consumes means, by name. + + A parameter that only curves with ``points:`` consume is ``neutral``: the + weights and the segment rows stand on the mask, and the ``_complete`` + assumption asks for a row only where it holds, so the table may stop short + of the dimension. A parameter that a curve over every breakpoint consumes is + ``refused``. The spec and its expansion both report these readings, so the + same data reads alike in either, outside the curve too. + """ + ragged = {n for pw in schema.piecewise.values() if pw.points for n in pw.consumes} + full = {n for pw in schema.piecewise.values() if not pw.points for n in pw.consumes} + return {n: 'refused' if n in full else 'neutral' for n in ragged | full} + + def expand_piecewise(schema: Spec) -> Spec: """*schema* with every ``piecewise:`` block written out — *schema* itself where it declares none. @@ -490,11 +505,7 @@ def expand_piecewise(schema: Spec) -> Spec: set of its own ([`mathspec.sos.emit`][] is where they are spelled). Each block's frame and names are read off the program *schema* lowered to. - A parameter only curves with ``points:`` consume is read only where their - mask holds, since the weights and the segment rows stand on it, so the - expansion declares it ``missing: neutral``; the ``_complete`` - assumption states where it must carry a row. A parameter a curve over - every breakpoint reads keeps the default, ``refused``. + Each parameter a block consumes is declared as [`curve_readings`][] reads it. """ if not schema.piecewise: return schema @@ -504,10 +515,9 @@ def expand_piecewise(schema: Spec) -> Spec: raw.setdefault('constraints', {}) for name, pw in schema.piecewise.items(): _Block(schema, raw, name, pw, program.piecewise[name]).expand() - ragged = {n for pw in schema.piecewise.values() if pw.points for n in pw.consumes} - ragged -= {n for pw in schema.piecewise.values() if not pw.points for n in pw.consumes} - for parameter in ragged: - raw['parameters'][parameter]['missing'] = 'neutral' + for parameter, reading in curve_readings(schema).items(): + if reading == 'neutral': + raw['parameters'][parameter]['missing'] = reading raw['piecewise'].clear() for name, pw in schema.piecewise.items(): if pw.method == 'adjacency': diff --git a/src/mathspec/spec.py b/src/mathspec/spec.py index 304dc6d06..798f5c4bc 100644 --- a/src/mathspec/spec.py +++ b/src/mathspec/spec.py @@ -805,7 +805,8 @@ def consumes(self) -> frozenset[str]: """The parameters the block reads: each link's values, and the ``points:`` mask. The block owns the shape of every one of them, so ``missing:`` is - refused on them at load and lowering reports none. + refused on them at load, and ``mathspec.piecewise.curve_readings`` + says what a missing row of each means. """ return frozenset({link.values for link in self.links} | ({self.points} if self.points else set())) diff --git a/tests/test_expand.py b/tests/test_expand.py index 1420087e2..e4255ac0f 100644 --- a/tests/test_expand.py +++ b/tests/test_expand.py @@ -200,10 +200,10 @@ def test_a_model_with_no_formulation_expands_to_itself(): ], ) def test_the_expansion_says_what_a_missing_breakpoint_means(points, missing): - """A curve's own parameters answer for no `missing:`, because the block owns their shape. The expansion keeps + """A curve's own parameters take no `missing:`, because the block owns their shape. The expansion keeps no block: its weights stand on `points:` and its assumptions ask for a value only where the mask holds. Left - unwritten, lowering would report `refused`, and a consumer attaching the rows would refuse the ragged curve the - block admits; a curve with no `points:` reads every breakpoint.""" + unwritten, the expansion would declare `refused`, and a consumer attaching the rows would refuse the ragged curve + the block admits; a curve with no `points:` reads every breakpoint.""" rows = schema_of(varied(SMALL_MODEL, **copy.deepcopy(SMALL_CURVE), **points)).expand('piecewise').program assert {name: rows.parameters[name].missing for name in ('bx', 'by', 'c')} == { 'bx': missing, @@ -212,14 +212,31 @@ def test_the_expansion_says_what_a_missing_breakpoint_means(points, missing): }, "the expansion reads the curve's tables as the block did, and every other parameter as declared" -def test_a_parameter_a_curve_owns_answers_for_no_missing(): - """The block's own parameters report `None`: reporting the unwritten `refused` would tell a consumer to require - every coordinate the dims reach, which a ragged curve does not carry.""" - program = schema_of(varied(SMALL_MODEL, **copy.deepcopy(SMALL_CURVE), **{'piecewise.curve.points': 'bx'})).program - missing = {name: p.missing for name, p in program.parameters.items()} - assert missing == {'c': 'refused', 'k': 'refused', 'flag': 'refused', 'tag': 'refused', 'bx': None, 'by': None}, ( - "the parameters the block consumes answer for no missing; every other parameter's declaration is carried" +@pytest.mark.parametrize( + 'points', + [ + pytest.param({'piecewise.curve.points': 'bx'}, id='a-curve-that-says-how-far-it-runs'), + pytest.param({}, id='a-curve-over-every-breakpoint'), + ], +) +def test_a_spec_and_its_expansion_read_a_missing_row_alike(points): + """A row outside the curve that reads a curve's values parameter, `r <= by`, had no reading in the spec. + + Lowering reported `None` for every parameter a block consumes, and the expansion declared the same parameter + `neutral`, so one file gave two answers for one table, and the spec's program gave a consumer none for the row + outside the curve. The program reports what the expansion declares. + """ + spec = schema_of( + varied( + SMALL_MODEL, + **copy.deepcopy(SMALL_CURVE), + **points, + **{'constraints.cap': {'dims': ['h'], 'expression': 'r <= by'}}, + ) ) + declared = {name: p.missing for name, p in spec.program.parameters.items()} + expanded = {name: p.missing for name, p in spec.expand('piecewise').program.parameters.items()} + assert declared == expanded, 'a spec and its expansion read a missing row of every parameter alike' @pytest.mark.parametrize('missing', ['refused', 'neutral', 0]) From a10775243b9760890539fa6d8b81c555cfa83de7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 22:36:28 +0000 Subject: [PATCH 09/21] fix(language): a curve's completeness assumption no longer says a missing breakpoint reads as zero A curve over every breakpoint now reads its parameters `refused`, so the `_complete` text, the `assumptions_of` docstring and the ragged example said what the code no longer does. The assumption now says that a missing row does not shorten the curve. The expansion fixtures and the generated how-to page follow. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CsaSWMofYqJiKmnSiPycV9 --- docs/howto/see-an-expansion.md | 8 ++++---- examples/piecewise_ragged.yaml | 5 ++--- src/mathspec/piecewise.py | 10 +++++----- tests/expand/curve-activity/after.yaml | 4 ++-- tests/expand/curve-adjacency-points/after.yaml | 4 ++-- tests/expand/curve-adjacency/after.yaml | 4 ++-- tests/expand/curve-convex/after.yaml | 4 ++-- tests/expand/curve-lp-points/after.yaml | 4 ++-- tests/expand/curve-lp/after.yaml | 4 ++-- tests/expand/curve-sos2-piecewise/after.yaml | 4 ++-- tests/expand/curve-sos2/after.yaml | 4 ++-- 11 files changed, 27 insertions(+), 28 deletions(-) diff --git a/docs/howto/see-an-expansion.md b/docs/howto/see-an-expansion.md index e92988ef6..fed8b3d47 100644 --- a/docs/howto/see-an-expansion.md +++ b/docs/howto/see-an-expansion.md @@ -245,8 +245,8 @@ the set out too. holds: x_bp AND y_bp description: >- piecewise 'curve': every breakpoint the curve runs through needs a row in - 'x_bp', 'y_bp' — a missing row is read as a zero rather than as a shorter - curve, so it sits the curve on the origin. Attach the rows, or declare + 'x_bp', 'y_bp' — a missing row does not shorten the + curve. Attach the rows, or declare points: to say how far the curve runs. ``` @@ -352,8 +352,8 @@ the set out too. holds: x_bp AND y_bp description: >- piecewise 'curve': every breakpoint the curve runs through needs a row in - 'x_bp', 'y_bp' — a missing row is read as a zero rather than as a shorter - curve, so it sits the curve on the origin. Attach the rows, or declare + 'x_bp', 'y_bp' — a missing row does not shorten the + curve. Attach the rows, or declare points: to say how far the curve runs. ``` diff --git a/examples/piecewise_ragged.yaml b/examples/piecewise_ragged.yaml index 582a28c21..9ff9a1df5 100644 --- a/examples/piecewise_ragged.yaml +++ b/examples/piecewise_ragged.yaml @@ -6,9 +6,8 @@ description: >- The same least-cost dispatch as `piecewise_lp.yaml`, with curves of different lengths. A large unit is metered at every breakpoint and a small one at the first few, so the breakpoint table is ragged. `points:` is what says how far - each curve runs: without it every curve claims the whole axis, and a - breakpoint with no row is read as a zero rather than as a shorter curve — - which sits the curve on the origin instead of ending it. + each curve runs: without it every curve claims the whole axis, and the data + is refused at a breakpoint with no row. dimensions: snapshot: diff --git a/src/mathspec/piecewise.py b/src/mathspec/piecewise.py index 5345b3bee..a6adc1973 100644 --- a/src/mathspec/piecewise.py +++ b/src/mathspec/piecewise.py @@ -95,9 +95,10 @@ def lp_domain_refusal(name: str, pw: PiecewiseBlock, links: tuple[Expression, .. def assumptions_of(block: str, pw: PiecewiseDeclaration) -> dict[str, AssumptionBlock]: """What *block* assumes of its numbers, by the name the document prints and a refusal quotes. - Every curve assumes its breakpoints are there: a missing parameter row is - not absence, it is a zero, so an undeclared breakpoint sits the curve on - the origin rather than shortening it. A curve has an x-axis only where two + Every curve assumes its breakpoints are there, because a missing row does + not shorten a curve. Under ``points:`` its parameters read ``neutral``, so + a breakpoint missing inside the mask would read as a zero and sit the curve + on the origin. A curve has an x-axis only where two links tie it, so the increasing condition — and the shape it is checked with — exist only there; ``lp`` alone needs a segment to state a line for; a mask must be one run. @@ -115,8 +116,7 @@ def assumptions_of(block: str, pw: PiecewiseDeclaration) -> dict[str, Assumption holds=' AND '.join(dict.fromkeys(link.values for link in pw.links)), where=mask, description=f"piecewise '{block}': every breakpoint the curve runs through needs a row in " - f'{_quoted(link.values for link in pw.links)} — a missing row is read as a zero rather than as a ' - f'shorter curve, so it sits the curve on the origin. ' + f'{_quoted(link.values for link in pw.links)} — a missing row does not shorten the curve. ' + ( f"Attach the rows, or narrow points: '{mask}' to where the curve runs." if mask is not None diff --git a/tests/expand/curve-activity/after.yaml b/tests/expand/curve-activity/after.yaml index 4ec420855..6d38eb95f 100644 --- a/tests/expand/curve-activity/after.yaml +++ b/tests/expand/curve-activity/after.yaml @@ -40,6 +40,6 @@ assumptions: holds: x_bp AND y_bp description: >- piecewise 'curve': every breakpoint the curve runs through needs a row in - 'x_bp', 'y_bp' — a missing row is read as a zero rather than as a shorter - curve, so it sits the curve on the origin. Attach the rows, or declare + 'x_bp', 'y_bp' — a missing row does not shorten the + curve. Attach the rows, or declare points: to say how far the curve runs. diff --git a/tests/expand/curve-adjacency-points/after.yaml b/tests/expand/curve-adjacency-points/after.yaml index 37cc920ee..f380774b3 100644 --- a/tests/expand/curve-adjacency-points/after.yaml +++ b/tests/expand/curve-adjacency-points/after.yaml @@ -42,8 +42,8 @@ assumptions: holds: x_bp AND y_bp description: >- piecewise 'curve': every breakpoint the curve runs through needs a row in - 'x_bp', 'y_bp' — a missing row is read as a zero rather than as a shorter - curve, so it sits the curve on the origin. Attach the rows, or narrow + 'x_bp', 'y_bp' — a missing row does not shorten the + curve. Attach the rows, or narrow points: 'x_bp' to where the curve runs. curve_contiguous: holds: count(x_bp AND NOT shift(x_bp, along=bp, offset=1), over=bp) == 1 diff --git a/tests/expand/curve-adjacency/after.yaml b/tests/expand/curve-adjacency/after.yaml index 317493b6e..6d4eaf669 100644 --- a/tests/expand/curve-adjacency/after.yaml +++ b/tests/expand/curve-adjacency/after.yaml @@ -39,6 +39,6 @@ assumptions: holds: x_bp AND y_bp description: >- piecewise 'curve': every breakpoint the curve runs through needs a row in - 'x_bp', 'y_bp' — a missing row is read as a zero rather than as a shorter - curve, so it sits the curve on the origin. Attach the rows, or declare + 'x_bp', 'y_bp' — a missing row does not shorten the + curve. Attach the rows, or declare points: to say how far the curve runs. diff --git a/tests/expand/curve-convex/after.yaml b/tests/expand/curve-convex/after.yaml index 4d69388a3..4f289f3d0 100644 --- a/tests/expand/curve-convex/after.yaml +++ b/tests/expand/curve-convex/after.yaml @@ -29,8 +29,8 @@ assumptions: holds: x_bp AND y_bp description: >- piecewise 'curve': every breakpoint the curve runs through needs a row in - 'x_bp', 'y_bp' — a missing row is read as a zero rather than as a shorter - curve, so it sits the curve on the origin. Attach the rows, or declare + 'x_bp', 'y_bp' — a missing row does not shorten the + curve. Attach the rows, or declare points: to say how far the curve runs. curve_increasing: where: position(bp) > 0 diff --git a/tests/expand/curve-lp-points/after.yaml b/tests/expand/curve-lp-points/after.yaml index 769bdfac1..287e1f658 100644 --- a/tests/expand/curve-lp-points/after.yaml +++ b/tests/expand/curve-lp-points/after.yaml @@ -32,8 +32,8 @@ assumptions: holds: x_bp AND y_bp description: >- piecewise 'curve': every breakpoint the curve runs through needs a row in - 'x_bp', 'y_bp' — a missing row is read as a zero rather than as a shorter - curve, so it sits the curve on the origin. Attach the rows, or narrow + 'x_bp', 'y_bp' — a missing row does not shorten the + curve. Attach the rows, or narrow points: 'x_bp' to where the curve runs. curve_increasing: where: x_bp AND shift(x_bp, along=bp, offset=1) diff --git a/tests/expand/curve-lp/after.yaml b/tests/expand/curve-lp/after.yaml index 0c7f627bc..2850e9701 100644 --- a/tests/expand/curve-lp/after.yaml +++ b/tests/expand/curve-lp/after.yaml @@ -31,8 +31,8 @@ assumptions: holds: x_bp AND y_bp description: >- piecewise 'curve': every breakpoint the curve runs through needs a row in - 'x_bp', 'y_bp' — a missing row is read as a zero rather than as a shorter - curve, so it sits the curve on the origin. Attach the rows, or declare + 'x_bp', 'y_bp' — a missing row does not shorten the + curve. Attach the rows, or declare points: to say how far the curve runs. curve_increasing: where: position(bp) > 0 diff --git a/tests/expand/curve-sos2-piecewise/after.yaml b/tests/expand/curve-sos2-piecewise/after.yaml index 2efdb329b..9cf9cd246 100644 --- a/tests/expand/curve-sos2-piecewise/after.yaml +++ b/tests/expand/curve-sos2-piecewise/after.yaml @@ -35,6 +35,6 @@ assumptions: holds: x_bp AND y_bp description: >- piecewise 'curve': every breakpoint the curve runs through needs a row in - 'x_bp', 'y_bp' — a missing row is read as a zero rather than as a shorter - curve, so it sits the curve on the origin. Attach the rows, or declare + 'x_bp', 'y_bp' — a missing row does not shorten the + curve. Attach the rows, or declare points: to say how far the curve runs. diff --git a/tests/expand/curve-sos2/after.yaml b/tests/expand/curve-sos2/after.yaml index 317493b6e..6d4eaf669 100644 --- a/tests/expand/curve-sos2/after.yaml +++ b/tests/expand/curve-sos2/after.yaml @@ -39,6 +39,6 @@ assumptions: holds: x_bp AND y_bp description: >- piecewise 'curve': every breakpoint the curve runs through needs a row in - 'x_bp', 'y_bp' — a missing row is read as a zero rather than as a shorter - curve, so it sits the curve on the origin. Attach the rows, or declare + 'x_bp', 'y_bp' — a missing row does not shorten the + curve. Attach the rows, or declare points: to say how far the curve runs. From 52ef2eb5edc88bdbf98b93c5c92d86ee82f45c72 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 22:37:06 +0000 Subject: [PATCH 10/21] docs: the compose how-to and the pypsa page name `missing:`, not `absence:` The compose how-to quotes the message `merge` prints now, taken from a run. The pypsa page's rung table names the spill variable's `missing: neutral`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CsaSWMofYqJiKmnSiPycV9 --- docs/examples/pypsa.md | 2 +- docs/howto/compose.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/examples/pypsa.md b/docs/examples/pypsa.md index 33c5e447b..93f93196a 100644 --- a/docs/examples/pypsa.md +++ b/docs/examples/pypsa.md @@ -132,7 +132,7 @@ def build(): | PyPSA | status | note | | ----------------------------------------------------- | ------ | ------------------------------------------------------------- | | [`StorageUnit-p_dispatch`, `-p_store`, `-state_of_charge`, `Store-e`, `Store-p`](#variable-domains) | done | | -| [`StorageUnit-spill`](#variable-domains) | done | `where: inflow > 0`, `absence: zero`; bounds on the variable, as PyPSA's | +| [`StorageUnit-spill`](#variable-domains) | done | `where: inflow > 0`, `missing: neutral`; bounds on the variable, as PyPSA's | | [`StorageUnit-fix-*`](#storageunit-fix-p_dispatch-lower), [`Store-fix-e-*`](#store-fix-e-lower) | done | | | [`StorageUnit-energy_balance`](#storageunit-energy_balance) | done | the charge carried into a snapshot is a cased quantity — cyclic, opening, carried; `(1-loss)**eh` is prep | | [`StorageUnit-energy_balance`](#storageunit-energy_balance) with efficiencies per snapshot | done | rung 60 | diff --git a/docs/howto/compose.md b/docs/howto/compose.md index 3950adbee..2460476a4 100644 --- a/docs/howto/compose.md +++ b/docs/howto/compose.md @@ -247,7 +247,7 @@ constraints: ``` ```text -fragment 'emissions.yaml' reads the given variable 'Generator_p' as {'dims': ['snapshot', 'generator'], 'domain': 'binary'}, where 'generator.yaml' introduces it as {'dims': ['snapshot', 'generator'], 'bounds': {'lower': 0.0, 'upper': 'Generator_p_nom'}, 'domain': 'continuous', 'absence': 'undefined'}. A given declaration says the same as the declaration it is folded into, or less: restate the frame as the introducer declares it, or leave the field out. +fragment 'emissions.yaml' reads the given variable 'Generator_p' as {'dims': ['snapshot', 'generator'], 'domain': 'binary'}, where 'generator.yaml' introduces it as {'dims': ['snapshot', 'generator'], 'bounds': {'lower': 0.0, 'upper': 'Generator_p_nom'}, 'domain': 'continuous', 'missing': 'absent'}. A given declaration says the same as the declaration it is folded into, or less: restate the frame as the introducer declares it, or leave the field out. ``` Two fragments that both only read a column have to read it the same way, and From bf743d07e33811517270380b31979ec6b7345c89 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 22:38:14 +0000 Subject: [PATCH 11/21] fix(language): an overlap refusal says a parameter has no row, not that it is absent The witness for a missing row read `efficiency is absent`, for a parameter declared `missing: 1`, whose reading is not `absent`. A null cell now reads `has no row`, or `has no value` for a comparison of expressions. The exclusivity docstrings name `missing:`, not a `default:` key that does not exist. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CsaSWMofYqJiKmnSiPycV9 --- src/mathspec/exclusivity.py | 18 +++++++++++++----- tests/test_exclusivity.py | 7 +++++-- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/mathspec/exclusivity.py b/src/mathspec/exclusivity.py index aff5d73b3..cf5f3fd24 100644 --- a/src/mathspec/exclusivity.py +++ b/src/mathspec/exclusivity.py @@ -74,7 +74,8 @@ def overlapping( block's ``otherwise`` is not among them: it claims what the rest leave, so it overlaps nothing by construction. dtypes: The declared dtype of every name a mask compares against. - defaults: The declared ``default:`` of every parameter that has one. + defaults: The value a missing row reads as, for every parameter whose + ``missing:`` names one. Yields: A sentence per pair, naming both cases and either a coordinate they @@ -205,7 +206,7 @@ def cells(self) -> Iterator[dict[Subject, Cell]]: yield dict(zip(self.domains, combination, strict=True)) def witness(self, cell: dict[Subject, Cell]) -> str: - return ', '.join(f'{subject} is {_shown(subject, value)}' for subject, value in cell.items()) + return ', '.join(_clause(subject, value) for subject, value in cell.items()) #: A comparator against its mirror, for a test written with its sides swapped. @@ -469,13 +470,20 @@ def _rank_cells(subject: Subject, positions_seen: set[int]) -> list[Cell]: return cells +def _clause(subject: Subject, value: Cell) -> str: + """*subject* in one cell, in words. A null cell says the data has no row, never a ``missing:`` reading's name.""" + if value is Special.NULL: + return f'{subject} has no value' if subject.kind == 'expression' else f'{subject} has no row' + return f'{subject} is {_shown(subject, value)}' + + def _shown(subject: Subject, value: Cell) -> str: if subject.kind == 'rank': return str(value) if subject.kind == 'relation_pair': return 'equal' if value else 'different' if isinstance(value, Special): - return {Special.NULL: 'absent', Special.OTHER: 'anything else'}.get(value, value.value) + return 'anything else' if value is Special.OTHER else value.value if isinstance(value, bool): return 'true' if value else 'false' return f'{value!r}' @@ -537,10 +545,10 @@ def _atom(node: TypedPredicate, cell: dict[Subject, Cell], grid: _Grid) -> bool: def _read(node: TypedPredicate, value: Cell, grid: _Grid) -> Cell: - """What *node* reads in a cell where its parameter has no row: the ``default:``, in a comparison. + """What *node* reads in a cell where its parameter has no row: in a comparison, the value ``missing:`` names. A bare name keeps the null. A numeric one asks whether the data has a row. - A ``bool`` one reads its default there, and the cell holding that value is + A ``bool`` one reads that value there, and the cell holding that value is already in the grid, so the null cell adds no witness either way. """ if value is Special.NULL and isinstance(node, ParameterComparison): diff --git a/tests/test_exclusivity.py b/tests/test_exclusivity.py index 63e337d0d..a35f0c892 100644 --- a/tests/test_exclusivity.py +++ b/tests/test_exclusivity.py @@ -335,11 +335,14 @@ def test_a_missing_row_compares_as_the_value_it_reads_as(self): """Both cases claim a storage with no `efficiency` row: it is not given, and it reads 1. The prover read a missing row as false in every comparison, so it proved - the two apart, and a coordinate the data leaves out had two values. + the two apart, and a coordinate the data leaves out had two values. The + witness said `efficiency is absent`, the name of a reading this + parameter does not have. """ schema = to_spec(varied(STORAGE, **{'parameters.efficiency': {'dims': ['storage'], 'missing': 1}})) [refusal] = refusals(schema, self.CASES) - assert 'efficiency is absent' in refusal, 'the witness is the missing row' + assert 'efficiency has no row' in refusal, 'the witness is the missing row' + assert 'absent' not in refusal, 'the witness does not use the name of a reading for a missing row' @pytest.mark.parametrize('reading', ['refused', 'absent', 'neutral']) def test_under_a_reading_a_missing_row_compares_false(self, reading: str): From 54010b75b654ec49a8795f25ef14fd0e8d40c58e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 22:39:11 +0000 Subject: [PATCH 12/21] test(language): the soundness fuzz draws the value a missing row reads as The fuzz passed no values, so the prover's reading of a missing row in a comparison never ran there. Each pair now draws one per parameter: none, a literal, a value between literals, or infinity. The infinity test carries its claim in an assertion message. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CsaSWMofYqJiKmnSiPycV9 --- tests/test_exclusivity.py | 16 +++++++++++++--- tests/test_validation.py | 2 +- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/tests/test_exclusivity.py b/tests/test_exclusivity.py index a35f0c892..fc0385626 100644 --- a/tests/test_exclusivity.py +++ b/tests/test_exclusivity.py @@ -259,7 +259,8 @@ class TestSoundness: The claim the check rests on is that its regions cover every value a subject can take, so "no witness among the cells" means "no witness". This walks a concrete grid — several points inside single cells, both - infinities, an absent value, labels the masks never name — and asserts that + infinities, a missing row read as each value `missing:` may name, labels + the masks never name — and asserts that nothing it proved apart has a point claimed by both. Only pairs the check proves apart are walked; a complement pair asserts X and not X and cannot fail. @@ -289,6 +290,13 @@ class TestSoundness: 'storage': [0, 1, 2], } + #: The value a missing row reads as, drawn per pair: `None` is a reading + #: with no value, and a value sits on a literal, between two, or past them. + READINGS: ClassVar[dict[str, list[Any]]] = { + 'capacity': [None, 0.0, 7.0, 10.0, float('inf')], + 'cyclic': [None, True, False], + } + def _random_mask(self, rng: random.Random, atoms: list[Any], depth: int = 0) -> Any: if depth >= 2 or rng.random() < 0.45: atom = rng.choice(atoms) @@ -318,10 +326,12 @@ def test_a_pair_proved_apart_stays_apart_on_a_finer_grid(self, schema: Spec, see proved = 0 for _ in range(2000): first, second = self._random_mask(rng, atoms), self._random_mask(rng, atoms) - if list(overlapping({'a': first, 'b': second}, dtypes, {})): + drawn = {name: rng.choice(values) for name, values in self.READINGS.items()} + defaults = {name: value for name, value in drawn.items() if value is not None} + if list(overlapping({'a': first, 'b': second}, dtypes, defaults)): continue proved += 1 - cells = _Grid.of([Mask(first), Mask(second)], dtypes, {}) + cells = _Grid.of([Mask(first), Mask(second)], dtypes, defaults) for point in grid: both = _evaluate(first, point, cells) and _evaluate(second, point, cells) assert not both, f'both cases claim {point} — the cells hid a witness' diff --git a/tests/test_validation.py b/tests/test_validation.py index b6d94814b..90bbd547a 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -2338,7 +2338,7 @@ def test_inf_and_dot_inf_load_as_one_number(): .missing for spelling in ('inf', '.inf') ] - assert read == [float('inf'), float('inf')] + assert read == [float('inf'), float('inf')], 'the string `inf` and the number `.inf` read as the same infinity' @pytest.mark.parametrize( From 714aa67cd4219a9ad579af5051bf5dec4295fe08 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 22:41:54 +0000 Subject: [PATCH 13/21] feat(typesetting): the legend says what a missing row means for every reading but the default The legend printed a value beside a parameter and left out `absent` and `neutral`, and a relation's `missing:`. It now prints the reading in monospace in the same place: `` `neutral` where the data has no row ``. A map that may leave a key out says so beside each set it joins. `refused` is the default and prints nothing, as an unwritten key does. The golden model marks one parameter of each reading and one partial map; the goldens and the generated pages are regenerated. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CsaSWMofYqJiKmnSiPycV9 --- docs/examples/pypsa.md | 148 +++++++++---------- docs/examples/pypsa/carrier.md | 2 +- docs/examples/pypsa/generator.md | 12 +- docs/examples/pypsa/generator_commitment.md | 2 +- docs/examples/pypsa/generator_maintenance.md | 2 +- docs/examples/pypsa/generator_ramping.md | 10 +- docs/examples/pypsa/line.md | 16 +- docs/examples/pypsa/link.md | 12 +- docs/examples/pypsa/link_commitment.md | 2 +- docs/examples/pypsa/link_maintenance.md | 2 +- docs/examples/pypsa/link_ramping.md | 10 +- docs/examples/pypsa/process.md | 8 +- docs/examples/pypsa/process_commitment.md | 2 +- docs/examples/pypsa/process_maintenance.md | 2 +- docs/examples/pypsa/process_ramping.md | 10 +- docs/examples/pypsa/security.md | 10 +- docs/examples/pypsa/settings.md | 2 +- docs/examples/pypsa/storage_unit.md | 18 +-- docs/examples/pypsa/store.md | 14 +- docs/examples/pypsa/transformer.md | 12 +- docs/examples/pypsa_linearized_uc.md | 12 +- docs/reference/language/declarations.md | 6 +- docs/reference/language/relations.md | 4 +- docs/reference/notation.md | 16 +- src/mathspec/typesetting/legend.py | 24 +-- tests/typesetting/golden/latex.out | 10 +- tests/typesetting/golden/markdown.out | 10 +- tests/typesetting/golden/model.yaml | 6 +- tests/typesetting/golden/typst.out | 10 +- tests/typesetting/test_symbols.py | 15 +- 30 files changed, 213 insertions(+), 196 deletions(-) diff --git a/docs/examples/pypsa.md b/docs/examples/pypsa.md index 93f93196a..44a0a65a5 100644 --- a/docs/examples/pypsa.md +++ b/docs/examples/pypsa.md @@ -5406,10 +5406,10 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathcal{D}`$ | index $`d`$ — `load` with $`\mathrm{Load\_bus}: \mathcal{D} \to \mathcal{N}`$ — demands, each on one bus | | $`\mathcal{S}`$ | index $`s`$ — `storage_unit` with $`\mathrm{StorageUnit\_carrier}: \mathcal{S} \to \mathcal{I},\ \mathrm{StorageUnit\_bus}: \mathcal{S} \to \mathcal{N}`$ — storage units, dispatch and store behind one bus connection | | $`\mathcal{V}`$ | index $`v`$ — `store` with $`\mathrm{Store\_carrier}: \mathcal{V} \to \mathcal{I},\ \mathrm{Store\_bus}: \mathcal{V} \to \mathcal{N}`$ — pure energy stores, each on one bus | -| $`\mathcal{K}`$ | index $`k`$ — `line` with $`\mathrm{Line\_carrier}: \mathcal{K} \to \mathcal{I},\ \mathrm{Line\_bus0}: \mathcal{K} \to \mathcal{N},\ \mathrm{Line\_bus1}: \mathcal{K} \to \mathcal{N},\ \mathrm{Outage\_line}: \mathcal{K}^{\mathrm{out}} \to \mathcal{K}`$ — passive branches, each between two buses, their flow set by impedance | -| $`\mathcal{M}`$ | index $`m`$ — `transformer` with $`\mathrm{Transformer\_bus0}: \mathcal{M} \to \mathcal{N},\ \mathrm{Transformer\_bus1}: \mathcal{M} \to \mathcal{N},\ \mathrm{Outage\_transformer}: \mathcal{K}^{\mathrm{out}} \to \mathcal{M}`$ — passive branches between two buses, their flow set by impedance and tap ratio, with a phase shift fixed or optimised | +| $`\mathcal{K}`$ | index $`k`$ — `line` with $`\mathrm{Line\_carrier}: \mathcal{K} \to \mathcal{I},\ \mathrm{Line\_bus0}: \mathcal{K} \to \mathcal{N},\ \mathrm{Line\_bus1}: \mathcal{K} \to \mathcal{N},\ \mathrm{Outage\_line}: \mathcal{K}^{\mathrm{out}} \to \mathcal{K}`$, `Outage_line` is `absent` where the data has no row — passive branches, each between two buses, their flow set by impedance | +| $`\mathcal{M}`$ | index $`m`$ — `transformer` with $`\mathrm{Transformer\_bus0}: \mathcal{M} \to \mathcal{N},\ \mathrm{Transformer\_bus1}: \mathcal{M} \to \mathcal{N},\ \mathrm{Outage\_transformer}: \mathcal{K}^{\mathrm{out}} \to \mathcal{M}`$, `Outage_transformer` is `absent` where the data has no row — passive branches between two buses, their flow set by impedance and tap ratio, with a phase shift fixed or optimised | | $`\mathcal{C}`$ | index $`c`$ — `cycle` — independent cycles of the passive network graph — the cycle basis, data prep. Each period has its own basis, of the branches that stand in it; a label is a position in that period's basis, so one label names a different cycle in another period | -| $`\mathcal{K}^{\mathrm{out}}`$ | index $`\kappa`$ — `outage` with $`\mathrm{Outage\_line}: \mathcal{K}^{\mathrm{out}} \to \mathcal{K},\ \mathrm{Outage\_transformer}: \mathcal{K}^{\mathrm{out}} \to \mathcal{M}`$ — the passive branches a security-constrained run takes out one at a time — PyPSA's `branch_outages`, each a line or a transformer; none on a plain run | +| $`\mathcal{K}^{\mathrm{out}}`$ | index $`\kappa`$ — `outage` with $`\mathrm{Outage\_line}: \mathcal{K}^{\mathrm{out}} \to \mathcal{K},\ \mathrm{Outage\_transformer}: \mathcal{K}^{\mathrm{out}} \to \mathcal{M}`$, `Outage_line` is `absent` where the data has no row, `Outage_transformer` is `absent` where the data has no row — the passive branches a security-constrained run takes out one at a time — PyPSA's `branch_outages`, each a line or a transformer; none on a plain run | | $`\mathcal{B}`$ | index $`b`$ — `segment` — the cuts a passive branch's loss curve is held above — PyPSA's tangents, as many as its `segments` count, or its secants, as many as its tolerance loop places; none in a lossless run | | $`\mathcal{I}`$ | index $`i`$ — `global_constraint` — PyPSA's `GlobalConstraint` rows, one label per declared limit | | $`\mathcal{Y}`$ | index $`y`$ — `period` with $`\mathrm{snapshot\_period}: \mathcal{T} \to \mathcal{Y}`$ — investment periods — PyPSA's `investment_periods` | @@ -5428,30 +5428,30 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{c}^{(2)}`$ | `Generator_marginal_cost_quadratic` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — cost of the square of one unit of output | | $`\mathrm{sgn}`$ | `Generator_sign` over $`\mathcal{G}`$ — the sign output enters its bus's balance with — PyPSA's `sign`, `1` unless given, `-1` for a unit that draws power. PyPSA refuses one that differs by scenario (`consistency.py:1187`) | | $`\mathrm{com}`$ | `Generator_committable` over $`\mathcal{G}`$ — whether output is gated by an on/off status decision | -| $`\mathrm{ru}`$ | `Generator_ramp_limit_up` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — most a generator may raise its output between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time | -| $`\mathrm{rd}`$ | `Generator_ramp_limit_down` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — most a generator may lower its output between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time | -| $`\mathrm{ru}^{\mathrm{up}}`$ | `Generator_ramp_limit_start_up` over $`\Xi \times \mathcal{G}`$ — most output in the snapshot a unit starts, per unit of nominal power; no value means no limit | -| $`\mathrm{rd}^{\mathrm{dn}}`$ | `Generator_ramp_limit_shut_down` over $`\Xi \times \mathcal{G}`$ — most output in the snapshot before a unit stops, per unit of nominal power; no value means no limit | +| $`\mathrm{ru}`$ | `Generator_ramp_limit_up` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$, `neutral` where the data has no row — most a generator may raise its output between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time | +| $`\mathrm{rd}`$ | `Generator_ramp_limit_down` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$, `neutral` where the data has no row — most a generator may lower its output between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time | +| $`\mathrm{ru}^{\mathrm{up}}`$ | `Generator_ramp_limit_start_up` over $`\Xi \times \mathcal{G}`$, `neutral` where the data has no row — most output in the snapshot a unit starts, per unit of nominal power; no value means no limit | +| $`\mathrm{rd}^{\mathrm{dn}}`$ | `Generator_ramp_limit_shut_down` over $`\Xi \times \mathcal{G}`$, `neutral` where the data has no row — most output in the snapshot before a unit stops, per unit of nominal power; no value means no limit | | $`\mathrm{UT}`$ | `Generator_min_up_time` over $`\Xi \times \mathcal{G}`$ — least snapshots a unit stays on once started | | $`\mathrm{DT}`$ | `Generator_min_down_time` over $`\Xi \times \mathcal{G}`$ — least snapshots a unit stays off once stopped | | $`\mathrm{u}^{0}`$ | `Generator_status_initial` over $`\Xi \times \mathcal{G}`$ — one where the unit was on before the first snapshot, zero where off — PyPSA's `up_time_before > 0`, data prep | -| $`\mathrm{p}^{0}`$ | `Generator_p_init` over $`\Xi \times \mathcal{G}`$ — the output a unit brought into the horizon — PyPSA's `p_init`, read only where the unit came in running; no value means it is unknown, so the unit carries no ramp row at the first snapshot | +| $`\mathrm{p}^{0}`$ | `Generator_p_init` over $`\Xi \times \mathcal{G}`$, `neutral` where the data has no row — the output a unit brought into the horizon — PyPSA's `p_init`, read only where the unit came in running; no value means it is unknown, so the unit carries no ramp row at the first snapshot | | $`\mathrm{hold}`$ | `Generator_must_stay_up` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — true while the up time a unit brought into the horizon still binds — data prep, since `position()` compares against a literal rather than a parameter | | $`\mathrm{rest}`$ | `Generator_must_stay_down` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — true while the down time a unit brought into the horizon still binds — PyPSA's `min_down_time - down_time_before` snapshots, where `down_time_before > 0`, data prep for the same reason | | $`\mathrm{c}^{\mathrm{up}}`$ | `Generator_start_up_cost` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — cost of one start in this snapshot | | $`\mathrm{c}^{\mathrm{dn}}`$ | `Generator_shut_down_cost` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — cost of one stop in this snapshot | | $`\mathrm{c}^{\mathrm{on}}`$ | `Generator_stand_by_cost` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — cost of one snapshot spent on | -| $`\mathrm{p}^{\mathrm{mod}}`$ | `Generator_p_nom_mod` over $`\mathcal{G}`$ — the module size a build comes in whole numbers of; no value means the build is continuous | +| $`\mathrm{p}^{\mathrm{mod}}`$ | `Generator_p_nom_mod` over $`\mathcal{G}`$, `neutral` where the data has no row — the module size a build comes in whole numbers of; no value means the build is continuous | | $`\mathrm{N}^{\mathrm{fix}}`$ | `Generator_modules_installed` over $`\Xi \times \mathcal{G}`$ — how many whole modules a committable build has in place: `Generator_p_nom / Generator_p_nom_mod` where a fixed build is modular, one where it is not, data prep. PyPSA refuses a fixed modular build whose nominal power is not a whole number of modules | -| $`\mathrm{M}`$ | `Generator_big_m` over $`\Xi \times \mathcal{G}`$ — the bound a committed extendable generator's big-M rows release it by — the build cap `p_nom_max` times the highest `p_max_pu`, where the cap is finite and positive. Elsewhere it is `committable_big_m` times the highest `p_max_pu`, and where that keyword is not given, ten times the largest of the peak total load and the component's largest finite `p_nom` and `p_nom_max`, or 1e6 where there is none of them (`components.py:1050-1121`). Below the output a solve wants, it caps that output; data prep | +| $`\mathrm{M}`$ | `Generator_big_m` over $`\Xi \times \mathcal{G}`$, `neutral` where the data has no row — the bound a committed extendable generator's big-M rows release it by — the build cap `p_nom_max` times the highest `p_max_pu`, where the cap is finite and positive. Elsewhere it is `committable_big_m` times the highest `p_max_pu`, and where that keyword is not given, ten times the largest of the peak total load and the component's largest finite `p_nom` and `p_nom_max`, or 1e6 where there is none of them (`components.py:1050-1121`). Below the output a solve wants, it caps that output; data prep | | $`\mathrm{nonneg}`$ | `Generator_p_min_pu_nonneg` over $`\mathcal{G}`$ — true where none of the generator's own minimums-per-unit is negative — PyPSA's per-unit `(p_min_pu >= 0).all()` over every snapshot and scenario, data prep | | $`\mathrm{mnt}`$ | `Generator_maintainable` over $`\mathcal{G}`$ — whether a generator must be taken off for maintenance within the horizon — in any scenario, as PyPSA takes the union over them (`components.py:1016-1019`) | | $`\gamma`$ | `Generator_maintenance_pu` over $`\Xi \times \mathcal{G}`$ — the share of the build a maintenance event takes off | | $`\mathrm{n}^{\mathrm{mnt}}`$ | `Generator_maintenance_events` over $`\Xi \times \mathcal{G}`$ — how many maintenance events the horizon holds | -| $`\tau^{\mathrm{mnt}}`$ | `Generator_maintenance_duration` over $`\Xi \times \mathcal{G}`$ — the hours of generator weightings one maintenance event covers — PyPSA's `maintenance_duration`; no value where the generator is not maintainable. No row reads it: data prep turns it into `Generator_maintenance_cover` and `Generator_maintenance_start_blocked`, and the assumptions hold it to the horizon | +| $`\tau^{\mathrm{mnt}}`$ | `Generator_maintenance_duration` over $`\Xi \times \mathcal{G}`$, `neutral` where the data has no row — the hours of generator weightings one maintenance event covers — PyPSA's `maintenance_duration`; no value where the generator is not maintainable. No row reads it: data prep turns it into `Generator_maintenance_cover` and `Generator_maintenance_start_blocked`, and the assumptions hold it to the horizon | | $`\mathrm{blk}`$ | `Generator_maintenance_start_blocked` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — true where no maintenance event may start, because the snapshots it would cover run past the end of the horizon or into one the generator does not stand in — PyPSA's `active & ~valid`, from `maintenance_duration` and the generator weightings, data prep | -| $`\mathrm{ru}^{f}`$ | `Link_ramp_limit_up` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — most a link may raise its flow between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time | -| $`\mathrm{rd}^{f}`$ | `Link_ramp_limit_down` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — most a link may lower its flow between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time | +| $`\mathrm{ru}^{f}`$ | `Link_ramp_limit_up` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$, `neutral` where the data has no row — most a link may raise its flow between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time | +| $`\mathrm{rd}^{f}`$ | `Link_ramp_limit_down` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$, `neutral` where the data has no row — most a link may lower its flow between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time | | $`\mathrm{f}^{\mathrm{nom}}`$ | `Link_p_nom` over $`\Xi \times \mathcal{L}`$ — nominal power | | $`\mathrm{ext}^{f}`$ | `Link_p_nom_extendable` over $`\mathcal{L}`$ — whether the nominal power is a decision | | $`\underline{\mathrm{f}}`$ | `Link_p_min_pu` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — least flow, per unit of nominal power — negative for a link that carries both ways | @@ -5462,25 +5462,25 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{c}^{f}`$ | `Link_marginal_cost` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — cost of one unit of flow | | $`\mathrm{c}^{f,(2)}`$ | `Link_marginal_cost_quadratic` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — cost of the square of one unit of flow | | $`\mathrm{com}^{f}`$ | `Link_committable` over $`\mathcal{L}`$ — whether flow is gated by an on/off status decision | -| $`\mathrm{ru}^{f,\mathrm{up}}`$ | `Link_ramp_limit_start_up` over $`\Xi \times \mathcal{L}`$ — most flow in the snapshot a link starts, per unit of nominal power; no value means no limit | -| $`\mathrm{rd}^{f,\mathrm{dn}}`$ | `Link_ramp_limit_shut_down` over $`\Xi \times \mathcal{L}`$ — most flow in the snapshot before a link stops, per unit of nominal power; no value means no limit | +| $`\mathrm{ru}^{f,\mathrm{up}}`$ | `Link_ramp_limit_start_up` over $`\Xi \times \mathcal{L}`$, `neutral` where the data has no row — most flow in the snapshot a link starts, per unit of nominal power; no value means no limit | +| $`\mathrm{rd}^{f,\mathrm{dn}}`$ | `Link_ramp_limit_shut_down` over $`\Xi \times \mathcal{L}`$, `neutral` where the data has no row — most flow in the snapshot before a link stops, per unit of nominal power; no value means no limit | | $`\mathrm{UT}^{f}`$ | `Link_min_up_time` over $`\Xi \times \mathcal{L}`$ — least snapshots a link stays on once started | | $`\mathrm{DT}^{f}`$ | `Link_min_down_time` over $`\Xi \times \mathcal{L}`$ — least snapshots a link stays off once stopped | | $`\mathrm{u}^{f,0}`$ | `Link_status_initial` over $`\Xi \times \mathcal{L}`$ — one where the link was on before the first snapshot, zero where off — PyPSA's `up_time_before > 0`, data prep | -| $`\mathrm{f}^{0}`$ | `Link_p_init` over $`\Xi \times \mathcal{L}`$ — the flow a link brought into the horizon — PyPSA's `p_init`, read only where the link came in running; no value means it is unknown, so the link carries no ramp row at the first snapshot | +| $`\mathrm{f}^{0}`$ | `Link_p_init` over $`\Xi \times \mathcal{L}`$, `neutral` where the data has no row — the flow a link brought into the horizon — PyPSA's `p_init`, read only where the link came in running; no value means it is unknown, so the link carries no ramp row at the first snapshot | | $`\mathrm{hold}^{f}`$ | `Link_must_stay_up` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — true while the up time a link brought into the horizon still binds — data prep, since `position()` compares against a literal rather than a parameter | | $`\mathrm{rest}^{f}`$ | `Link_must_stay_down` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — true while the down time a link brought into the horizon still binds — PyPSA's `min_down_time - down_time_before` snapshots, where `down_time_before > 0`, data prep for the same reason | | $`\mathrm{c}^{f,\mathrm{up}}`$ | `Link_start_up_cost` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — cost of one start in this snapshot | | $`\mathrm{c}^{f,\mathrm{dn}}`$ | `Link_shut_down_cost` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — cost of one stop in this snapshot | | $`\mathrm{c}^{f,\mathrm{on}}`$ | `Link_stand_by_cost` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — cost of one snapshot spent on | -| $`\mathrm{f}^{\mathrm{mod}}`$ | `Link_p_nom_mod` over $`\mathcal{L}`$ — the module size a build comes in whole numbers of; no value means the build is continuous | +| $`\mathrm{f}^{\mathrm{mod}}`$ | `Link_p_nom_mod` over $`\mathcal{L}`$, `neutral` where the data has no row — the module size a build comes in whole numbers of; no value means the build is continuous | | $`\mathrm{N}^{f,\mathrm{fix}}`$ | `Link_modules_installed` over $`\Xi \times \mathcal{L}`$ — how many whole modules a committable build has in place: `Link_p_nom / Link_p_nom_mod` where a fixed build is modular, one where it is not, data prep. PyPSA refuses a fixed modular build whose nominal power is not a whole number of modules | -| $`\mathrm{M}^{f}`$ | `Link_big_m` over $`\Xi \times \mathcal{L}`$ — the bound a committed extendable link's big-M rows release it by — the build cap `p_nom_max` times the highest `p_max_pu`, where the cap is finite and positive. Elsewhere it is `committable_big_m` times the highest `p_max_pu`, and where that keyword is not given, ten times the largest of the peak total load and the component's largest finite `p_nom` and `p_nom_max`, or 1e6 where there is none of them (`components.py:1050-1121`). Below the flow a solve wants, it caps that flow; data prep | +| $`\mathrm{M}^{f}`$ | `Link_big_m` over $`\Xi \times \mathcal{L}`$, `neutral` where the data has no row — the bound a committed extendable link's big-M rows release it by — the build cap `p_nom_max` times the highest `p_max_pu`, where the cap is finite and positive. Elsewhere it is `committable_big_m` times the highest `p_max_pu`, and where that keyword is not given, ten times the largest of the peak total load and the component's largest finite `p_nom` and `p_nom_max`, or 1e6 where there is none of them (`components.py:1050-1121`). Below the flow a solve wants, it caps that flow; data prep | | $`\mathrm{nonneg}^{f}`$ | `Link_p_min_pu_nonneg` over $`\mathcal{L}`$ — true where none of the link's own minimums-per-unit is negative — PyPSA's per-unit `(p_min_pu >= 0).all()` over every snapshot and scenario, data prep | | $`\mathrm{mnt}^{f}`$ | `Link_maintainable` over $`\mathcal{L}`$ — whether a link must be taken off for maintenance within the horizon — in any scenario, as PyPSA takes the union over them (`components.py:1016-1019`) | | $`\gamma^{f}`$ | `Link_maintenance_pu` over $`\Xi \times \mathcal{L}`$ — the share of the build a maintenance event takes off | | $`\mathrm{n}^{f,\mathrm{mnt}}`$ | `Link_maintenance_events` over $`\Xi \times \mathcal{L}`$ — how many maintenance events the horizon holds | -| $`\tau^{f,\mathrm{mnt}}`$ | `Link_maintenance_duration` over $`\Xi \times \mathcal{L}`$ — the hours of generator weightings one maintenance event covers — PyPSA's `maintenance_duration`; no value where the link is not maintainable. No row reads it: data prep turns it into `Link_maintenance_cover` and `Link_maintenance_start_blocked`, and the assumptions hold it to the horizon | +| $`\tau^{f,\mathrm{mnt}}`$ | `Link_maintenance_duration` over $`\Xi \times \mathcal{L}`$, `neutral` where the data has no row — the hours of generator weightings one maintenance event covers — PyPSA's `maintenance_duration`; no value where the link is not maintainable. No row reads it: data prep turns it into `Link_maintenance_cover` and `Link_maintenance_start_blocked`, and the assumptions hold it to the horizon | | $`\mathrm{blk}^{f}`$ | `Link_maintenance_start_blocked` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — true where no maintenance event may start, because the snapshots it would cover run past the end of the horizon or into one the link does not stand in — PyPSA's `active & ~valid`, from `maintenance_duration` and the generator weightings, data prep | | $`\mathrm{z}^{\mathrm{nom}}`$ | `Process_p_nom` over $`\Xi \times \mathcal{J}`$ — nominal internal power | | $`\mathrm{ext}^{z}`$ | `Process_p_nom_extendable` over $`\mathcal{J}`$ — whether the nominal internal power is a decision | @@ -5491,33 +5491,33 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{cyc}^{z}`$ | `Process_output_cyclic_delay` over $`\Xi \times \mathcal{R}`$ — whether a delayed port's transfer wraps from the end of its investment period — PyPSA's `cyclic_delay0`, `cyclic_delay1`, …; where it does not, the energy still in transit at each period's first snapshots is lost. Each scenario takes its own, as the delay | | $`\mathrm{c}^{z}`$ | `Process_marginal_cost` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — cost of one unit of internal power | | $`\mathrm{c}^{z,(2)}`$ | `Process_marginal_cost_quadratic` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — cost of the square of one unit of internal power | -| $`\mathrm{ru}^{z}`$ | `Process_ramp_limit_up` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — most a process may raise its internal power between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time | -| $`\mathrm{rd}^{z}`$ | `Process_ramp_limit_down` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — most a process may lower its internal power between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time | -| $`\mathrm{z}^{\mathrm{set}}`$ | `Process_p_set` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — a given internal power schedule; a process without one has no row here | +| $`\mathrm{ru}^{z}`$ | `Process_ramp_limit_up` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$, `neutral` where the data has no row — most a process may raise its internal power between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time | +| $`\mathrm{rd}^{z}`$ | `Process_ramp_limit_down` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$, `neutral` where the data has no row — most a process may lower its internal power between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time | +| $`\mathrm{z}^{\mathrm{set}}`$ | `Process_p_set` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$, `neutral` where the data has no row — a given internal power schedule; a process without one has no row here | | $`\underline{\mathrm{z}}^{\mathrm{nom}}`$ | `Process_p_nom_min` over $`\Xi \times \mathcal{J}`$ — least nominal power an extendable process may be built at | | $`\overline{\mathrm{z}}^{\mathrm{nom}}`$ | `Process_p_nom_max` over $`\Xi \times \mathcal{J}`$ — most nominal power an extendable process may be built at | | $`\mathrm{c}^{\mathrm{cap},z}`$ | `Process_capital_cost` over $`\Xi \times \mathcal{J}`$ — cost of one unit of nominal power for the modelled horizon — PyPSA's `periodized_cost`: `overnight_cost` as an annuity over `lifetime` at `discount_rate`, times `nyears`, where it is given, and `capital_cost` where it is not, plus `fom_cost` (`components.py:1126-1147`, `costs.py:102-203`), data prep | -| $`\mathrm{z}^{\mathrm{nom,set}}`$ | `Process_p_nom_set` over $`\Xi \times \mathcal{J}`$ — a given nominal power for an extendable process; one without a value has no row here | +| $`\mathrm{z}^{\mathrm{nom,set}}`$ | `Process_p_nom_set` over $`\Xi \times \mathcal{J}`$, `neutral` where the data has no row — a given nominal power for an extendable process; one without a value has no row here | | $`\mathrm{com}^{z}`$ | `Process_committable` over $`\mathcal{J}`$ — whether internal power is gated by an on/off status decision | -| $`\mathrm{ru}^{z,\mathrm{up}}`$ | `Process_ramp_limit_start_up` over $`\Xi \times \mathcal{J}`$ — most internal power in the snapshot a process starts, per unit of nominal power; no value means no limit | -| $`\mathrm{rd}^{z,\mathrm{dn}}`$ | `Process_ramp_limit_shut_down` over $`\Xi \times \mathcal{J}`$ — most internal power in the snapshot before a process stops, per unit of nominal power; no value means no limit | +| $`\mathrm{ru}^{z,\mathrm{up}}`$ | `Process_ramp_limit_start_up` over $`\Xi \times \mathcal{J}`$, `neutral` where the data has no row — most internal power in the snapshot a process starts, per unit of nominal power; no value means no limit | +| $`\mathrm{rd}^{z,\mathrm{dn}}`$ | `Process_ramp_limit_shut_down` over $`\Xi \times \mathcal{J}`$, `neutral` where the data has no row — most internal power in the snapshot before a process stops, per unit of nominal power; no value means no limit | | $`\mathrm{UT}^{z}`$ | `Process_min_up_time` over $`\Xi \times \mathcal{J}`$ — least snapshots a process stays on once started | | $`\mathrm{DT}^{z}`$ | `Process_min_down_time` over $`\Xi \times \mathcal{J}`$ — least snapshots a process stays off once stopped | | $`\mathrm{u}^{z,0}`$ | `Process_status_initial` over $`\Xi \times \mathcal{J}`$ — one where the process was on before the first snapshot, zero where off — PyPSA's `up_time_before > 0`, data prep | -| $`\mathrm{z}^{0}`$ | `Process_p_init` over $`\Xi \times \mathcal{J}`$ — the internal power a process brought into the horizon — PyPSA's `p_init`, read only where the process came in running; no value means it is unknown, so the process carries no ramp row at the first snapshot | +| $`\mathrm{z}^{0}`$ | `Process_p_init` over $`\Xi \times \mathcal{J}`$, `neutral` where the data has no row — the internal power a process brought into the horizon — PyPSA's `p_init`, read only where the process came in running; no value means it is unknown, so the process carries no ramp row at the first snapshot | | $`\mathrm{hold}^{z}`$ | `Process_must_stay_up` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — true while the up time a process brought into the horizon still binds — data prep, since `position()` compares against a literal rather than a parameter | | $`\mathrm{rest}^{z}`$ | `Process_must_stay_down` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — true while the down time a process brought into the horizon still binds — PyPSA's `min_down_time - down_time_before` snapshots, where `down_time_before > 0`, data prep for the same reason | | $`\mathrm{c}^{z,\mathrm{up}}`$ | `Process_start_up_cost` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — cost of one start in this snapshot | | $`\mathrm{c}^{z,\mathrm{dn}}`$ | `Process_shut_down_cost` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — cost of one stop in this snapshot | | $`\mathrm{c}^{z,\mathrm{on}}`$ | `Process_stand_by_cost` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — cost of one snapshot spent on | -| $`\mathrm{z}^{\mathrm{mod}}`$ | `Process_p_nom_mod` over $`\mathcal{J}`$ — the module size a build comes in whole numbers of; no value means the build is continuous | +| $`\mathrm{z}^{\mathrm{mod}}`$ | `Process_p_nom_mod` over $`\mathcal{J}`$, `neutral` where the data has no row — the module size a build comes in whole numbers of; no value means the build is continuous | | $`\mathrm{N}^{z,\mathrm{fix}}`$ | `Process_modules_installed` over $`\Xi \times \mathcal{J}`$ — how many whole modules a committable build has in place: `Process_p_nom / Process_p_nom_mod` where a fixed build is modular, one where it is not, data prep. PyPSA refuses a fixed modular build whose nominal power is not a whole number of modules | -| $`\mathrm{M}^{z}`$ | `Process_big_m` over $`\Xi \times \mathcal{J}`$ — the bound a committed extendable process's big-M rows release it by — the build cap `p_nom_max` times the highest `p_max_pu`, where the cap is finite and positive. Elsewhere it is `committable_big_m` times the highest `p_max_pu`, and where that keyword is not given, ten times the largest of the peak total load and the component's largest finite `p_nom` and `p_nom_max`, or 1e6 where there is none of them (`components.py:1050-1121`). Below the internal power a solve wants, it caps that internal power; data prep | +| $`\mathrm{M}^{z}`$ | `Process_big_m` over $`\Xi \times \mathcal{J}`$, `neutral` where the data has no row — the bound a committed extendable process's big-M rows release it by — the build cap `p_nom_max` times the highest `p_max_pu`, where the cap is finite and positive. Elsewhere it is `committable_big_m` times the highest `p_max_pu`, and where that keyword is not given, ten times the largest of the peak total load and the component's largest finite `p_nom` and `p_nom_max`, or 1e6 where there is none of them (`components.py:1050-1121`). Below the internal power a solve wants, it caps that internal power; data prep | | $`\mathrm{nonneg}^{z}`$ | `Process_p_min_pu_nonneg` over $`\mathcal{J}`$ — true where none of the process's own minimums-per-unit is negative — PyPSA's per-unit `(p_min_pu >= 0).all()` over every snapshot and scenario, data prep | | $`\mathrm{mnt}^{z}`$ | `Process_maintainable` over $`\mathcal{J}`$ — whether a process must be taken off for maintenance within the horizon — in any scenario, as PyPSA takes the union over them (`components.py:1016-1019`) | | $`\gamma^{z}`$ | `Process_maintenance_pu` over $`\Xi \times \mathcal{J}`$ — the share of the build a maintenance event takes off | | $`\mathrm{n}^{z,\mathrm{mnt}}`$ | `Process_maintenance_events` over $`\Xi \times \mathcal{J}`$ — how many maintenance events the horizon holds | -| $`\tau^{z,\mathrm{mnt}}`$ | `Process_maintenance_duration` over $`\Xi \times \mathcal{J}`$ — the hours of generator weightings one maintenance event covers — PyPSA's `maintenance_duration`; no value where the process is not maintainable. No row reads it: data prep turns it into `Process_maintenance_cover` and `Process_maintenance_start_blocked`, and the assumptions hold it to the horizon | +| $`\tau^{z,\mathrm{mnt}}`$ | `Process_maintenance_duration` over $`\Xi \times \mathcal{J}`$, `neutral` where the data has no row — the hours of generator weightings one maintenance event covers — PyPSA's `maintenance_duration`; no value where the process is not maintainable. No row reads it: data prep turns it into `Process_maintenance_cover` and `Process_maintenance_start_blocked`, and the assumptions hold it to the horizon | | $`\mathrm{blk}^{z}`$ | `Process_maintenance_start_blocked` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — true where no maintenance event may start, because the snapshots it would cover run past the end of the horizon or into one the process does not stand in — PyPSA's `active & ~valid`, from `maintenance_duration` and the generator weightings, data prep | | $`\mathrm{load}`$ | `Load_p_set` over $`\Xi \times \mathcal{T} \times \mathcal{D}`$ — demand | | $`\mathrm{sgn}^{\mathrm{load}}`$ | `Load_sign` over $`\mathcal{D}`$ — the sign a load's demand enters its bus's balance with — PyPSA's `sign`, `-1` unless given, `1` for a load that feeds its bus. PyPSA refuses one that differs by scenario (`consistency.py:1187`) | @@ -5547,33 +5547,33 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{new}^{e}`$ | `Store_first_active` over $`\mathcal{Y} \times \mathcal{V}`$ — one in the first period a store stands in, zero elsewhere, data prep. PyPSA takes `active.cumsum() == 1`, which also counts a store that has retired in every later period (`global_constraints.py:276`, PyPSA/PyPSA\#1938) | | $`\mathrm{new}^{s}`$ | `Line_first_active` over $`\mathcal{Y} \times \mathcal{K}`$ — one in the first period a line stands in, zero elsewhere, data prep. PyPSA takes `active.cumsum() == 1`, which also counts a line that has retired in every later period (`global_constraints.py:276`, PyPSA/PyPSA\#1938) | | $`\mathrm{new}^{z}`$ | `Process_first_active` over $`\mathcal{Y} \times \mathcal{J}`$ — one in the first period a process stands in, zero elsewhere, data prep. PyPSA takes `active.cumsum() == 1`, which also counts a process that has retired in every later period (`global_constraints.py:276`, PyPSA/PyPSA\#1938) | -| $`\overline{\Delta}`$ | `Carrier_max_growth` over $`\mathcal{I}`$ — most capacity of a carrier that may be added in a period; no value means no limit. The least over the scenarios, as PyPSA takes it (`global_constraints.py:226-230`), data prep. PyPSA reads it only under `multi_investment_periods` (`global_constraints.py:219-220`), so data prep feeds no value otherwise | +| $`\overline{\Delta}`$ | `Carrier_max_growth` over $`\mathcal{I}`$, `neutral` where the data has no row — most capacity of a carrier that may be added in a period; no value means no limit. The least over the scenarios, as PyPSA takes it (`global_constraints.py:226-230`), data prep. PyPSA reads it only under `multi_investment_periods` (`global_constraints.py:219-220`), so data prep feeds no value otherwise | | $`\mathrm{r}`$ | `Carrier_max_relative_growth` over $`\mathcal{I}`$ — share of the previous period's additions that may be added on top — the least over the scenarios, as PyPSA takes it, data prep | -| $`\mathrm{p}^{\mathrm{set}}`$ | `Generator_p_set` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — a given output schedule; a generator without one has no row here | -| $`\mathrm{f}^{\mathrm{set}}`$ | `Link_p_set` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — a given flow schedule; a link without one has no row here | +| $`\mathrm{p}^{\mathrm{set}}`$ | `Generator_p_set` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$, `neutral` where the data has no row — a given output schedule; a generator without one has no row here | +| $`\mathrm{f}^{\mathrm{set}}`$ | `Link_p_set` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$, `neutral` where the data has no row — a given flow schedule; a link without one has no row here | | $`\mathrm{w}^{\mathrm{sto}}`$ | `snapshot_weightings_stores` over $`\mathcal{T}`$ — PyPSA's `snapshot_weightings.stores` — hours a snapshot stands for in a storage balance | | $`\mathrm{w}^{\mathrm{gen}}`$ | `snapshot_weightings_generators` over $`\mathcal{T}`$ — PyPSA's `snapshot_weightings.generators` — hours a snapshot stands for in an energy total | | $`\underline{\mathrm{p}}^{\mathrm{nom}}`$ | `Generator_p_nom_min` over $`\Xi \times \mathcal{G}`$ — least nominal power an extendable generator may be built at | | $`\overline{\mathrm{p}}^{\mathrm{nom}}`$ | `Generator_p_nom_max` over $`\Xi \times \mathcal{G}`$ — most nominal power an extendable generator may be built at | | $`\mathrm{c}^{\mathrm{cap}}`$ | `Generator_capital_cost` over $`\Xi \times \mathcal{G}`$ — cost of one unit of nominal power for the modelled horizon — PyPSA's `periodized_cost`: `overnight_cost` as an annuity over `lifetime` at `discount_rate`, times `nyears`, where it is given, and `capital_cost` where it is not, plus `fom_cost` (`components.py:1126-1147`, `costs.py:102-203`), data prep | -| $`\mathrm{p}^{\mathrm{nom,set}}`$ | `Generator_p_nom_set` over $`\Xi \times \mathcal{G}`$ — a given nominal power for an extendable generator; one without a value has no row here | +| $`\mathrm{p}^{\mathrm{nom,set}}`$ | `Generator_p_nom_set` over $`\Xi \times \mathcal{G}`$, `neutral` where the data has no row — a given nominal power for an extendable generator; one without a value has no row here | | $`\underline{\mathrm{E}}`$ | `Generator_e_sum_min` over $`\Xi \times \mathcal{G}`$ — least energy over the horizon; minus infinity where no floor is meant | | $`\overline{\mathrm{E}}`$ | `Generator_e_sum_max` over $`\Xi \times \mathcal{G}`$ — most energy over the horizon — a fuel or emission budget in energy terms; infinity where no cap is meant | | $`\underline{\mathrm{f}}^{\mathrm{nom}}`$ | `Link_p_nom_min` over $`\Xi \times \mathcal{L}`$ — least nominal power an extendable link may be built at | | $`\overline{\mathrm{f}}^{\mathrm{nom}}`$ | `Link_p_nom_max` over $`\Xi \times \mathcal{L}`$ — most nominal power an extendable link may be built at | | $`\mathrm{c}^{\mathrm{cap},f}`$ | `Link_capital_cost` over $`\Xi \times \mathcal{L}`$ — cost of one unit of nominal power for the modelled horizon — PyPSA's `periodized_cost`: `overnight_cost` as an annuity over `lifetime` at `discount_rate`, times `nyears`, where it is given, and `capital_cost` where it is not, plus `fom_cost` (`components.py:1126-1147`, `costs.py:102-203`), data prep | -| $`\mathrm{f}^{\mathrm{nom,set}}`$ | `Link_p_nom_set` over $`\Xi \times \mathcal{L}`$ — a given nominal power for an extendable link; one without a value has no row here | +| $`\mathrm{f}^{\mathrm{nom,set}}`$ | `Link_p_nom_set` over $`\Xi \times \mathcal{L}`$, `neutral` where the data has no row — a given nominal power for an extendable link; one without a value has no row here | | $`\underline{\mathrm{h}}^{\mathrm{nom}}`$ | `StorageUnit_p_nom_min` over $`\Xi \times \mathcal{S}`$ — least nominal power an extendable storage unit may be built at | | $`\overline{\mathrm{h}}^{\mathrm{nom}}`$ | `StorageUnit_p_nom_max` over $`\Xi \times \mathcal{S}`$ — most nominal power an extendable storage unit may be built at | | $`\mathrm{c}^{\mathrm{cap},h}`$ | `StorageUnit_capital_cost` over $`\Xi \times \mathcal{S}`$ — cost of one unit of nominal power for the modelled horizon — PyPSA's `periodized_cost`: `overnight_cost` as an annuity over `lifetime` at `discount_rate`, times `nyears`, where it is given, and `capital_cost` where it is not, plus `fom_cost` (`components.py:1126-1147`, `costs.py:102-203`), data prep | -| $`\mathrm{h}^{\mathrm{nom,set}}`$ | `StorageUnit_p_nom_set` over $`\Xi \times \mathcal{S}`$ — a given nominal power for an extendable storage unit; one without a value has no row here | +| $`\mathrm{h}^{\mathrm{nom,set}}`$ | `StorageUnit_p_nom_set` over $`\Xi \times \mathcal{S}`$, `neutral` where the data has no row — a given nominal power for an extendable storage unit; one without a value has no row here | | $`\underline{\mathrm{e}}^{\mathrm{nom}}`$ | `Store_e_nom_min` over $`\Xi \times \mathcal{V}`$ — least nominal capacity an extendable store may be built at | | $`\overline{\mathrm{e}}^{\mathrm{nom}}`$ | `Store_e_nom_max` over $`\Xi \times \mathcal{V}`$ — most nominal capacity an extendable store may be built at | | $`\mathrm{c}^{\mathrm{cap},e}`$ | `Store_capital_cost` over $`\Xi \times \mathcal{V}`$ — cost of one unit of nominal capacity for the modelled horizon — PyPSA's `periodized_cost`: `overnight_cost` as an annuity over `lifetime` at `discount_rate`, times `nyears`, where it is given, and `capital_cost` where it is not, plus `fom_cost` (`components.py:1126-1147`, `costs.py:102-203`), data prep | -| $`\mathrm{e}^{\mathrm{nom,set}}`$ | `Store_e_nom_set` over $`\Xi \times \mathcal{V}`$ — a given nominal capacity for an extendable store; one without a value has no row here | +| $`\mathrm{e}^{\mathrm{nom,set}}`$ | `Store_e_nom_set` over $`\Xi \times \mathcal{V}`$, `neutral` where the data has no row — a given nominal capacity for an extendable store; one without a value has no row here | | $`\mathrm{h}^{\mathrm{nom}}`$ | `StorageUnit_p_nom` over $`\Xi \times \mathcal{S}`$ — nominal power | | $`\mathrm{ext}^{h}`$ | `StorageUnit_p_nom_extendable` over $`\mathcal{S}`$ — whether the nominal power is a decision | -| $`\mathrm{h}^{\mathrm{mod}}`$ | `StorageUnit_p_nom_mod` over $`\mathcal{S}`$ — the module size a build comes in whole numbers of; no value means the build is continuous | +| $`\mathrm{h}^{\mathrm{mod}}`$ | `StorageUnit_p_nom_mod` over $`\mathcal{S}`$, `neutral` where the data has no row — the module size a build comes in whole numbers of; no value means the build is continuous | | $`\underline{\mathrm{h}}`$ | `StorageUnit_p_min_pu` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — most storing, per unit of nominal power and negated | | $`\overline{\mathrm{h}}`$ | `StorageUnit_p_max_pu` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — most dispatch, per unit of nominal power | | $`\mathrm{T}^{h}`$ | `StorageUnit_max_hours` over $`\Xi \times \mathcal{S}`$ — energy capacity, as hours of dispatch at nominal power | @@ -5592,13 +5592,13 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{c}^{h,(2)}`$ | `StorageUnit_marginal_cost_quadratic` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — cost of the square of one unit of dispatch; storing is not charged | | $`\mathrm{c}^{\mathrm{soc}}`$ | `StorageUnit_marginal_cost_storage` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — cost of one unit of charge held over one snapshot | | $`\mathrm{c}^{\mathrm{spill}}`$ | `StorageUnit_spill_cost` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — cost of one unit of inflow passed on unused | -| $`\mathrm{h}^{\mathrm{set}}`$ | `StorageUnit_p_set` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — a given net dispatch schedule; a unit without one has no row here | -| $`\mathrm{h}^{+,\mathrm{set}}`$ | `StorageUnit_p_dispatch_set` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — a given dispatch schedule; a unit without one has no row here | -| $`\mathrm{h}^{-,\mathrm{set}}`$ | `StorageUnit_p_store_set` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — a given charging schedule; a unit without one has no row here | -| $`\mathrm{soc}^{\mathrm{set}}`$ | `StorageUnit_state_of_charge_set` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — a given charge schedule; a unit without one has no row here | +| $`\mathrm{h}^{\mathrm{set}}`$ | `StorageUnit_p_set` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$, `neutral` where the data has no row — a given net dispatch schedule; a unit without one has no row here | +| $`\mathrm{h}^{+,\mathrm{set}}`$ | `StorageUnit_p_dispatch_set` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$, `neutral` where the data has no row — a given dispatch schedule; a unit without one has no row here | +| $`\mathrm{h}^{-,\mathrm{set}}`$ | `StorageUnit_p_store_set` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$, `neutral` where the data has no row — a given charging schedule; a unit without one has no row here | +| $`\mathrm{soc}^{\mathrm{set}}`$ | `StorageUnit_state_of_charge_set` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$, `neutral` where the data has no row — a given charge schedule; a unit without one has no row here | | $`\mathrm{e}^{\mathrm{nom}}`$ | `Store_e_nom` over $`\Xi \times \mathcal{V}`$ — nominal energy capacity | | $`\mathrm{ext}^{e}`$ | `Store_e_nom_extendable` over $`\mathcal{V}`$ — whether the nominal energy capacity is a decision | -| $`\mathrm{e}^{\mathrm{mod}}`$ | `Store_e_nom_mod` over $`\mathcal{V}`$ — the module size a build comes in whole numbers of; no value means the build is continuous | +| $`\mathrm{e}^{\mathrm{mod}}`$ | `Store_e_nom_mod` over $`\mathcal{V}`$, `neutral` where the data has no row — the module size a build comes in whole numbers of; no value means the build is continuous | | $`\underline{\mathrm{e}}`$ | `Store_e_min_pu` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$ — least energy held, per unit of nominal capacity — negative for a store that may go short | | $`\overline{\mathrm{e}}`$ | `Store_e_max_pu` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$ — most energy held, per unit of nominal capacity | | $`\mathrm{sgn}^{q}`$ | `Store_sign` over $`\mathcal{V}`$ — the sign the power a store delivers enters its bus's balance with — PyPSA's `sign`, `1` unless given. PyPSA refuses one that differs by scenario (`consistency.py:1187`) | @@ -5612,39 +5612,39 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{c}^{q}`$ | `Store_marginal_cost` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$ — cost of one unit of power delivered | | $`\mathrm{c}^{q,(2)}`$ | `Store_marginal_cost_quadratic` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$ — cost of the square of the net power delivered, so charging costs as much as delivering | | $`\mathrm{c}^{e}`$ | `Store_marginal_cost_storage` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$ — cost of one unit of energy held over one snapshot | -| $`\mathrm{e}^{\mathrm{set}}`$ | `Store_e_set` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$ — a given energy schedule; a store without one has no row here | -| $`\mathrm{q}^{\mathrm{set}}`$ | `Store_p_set` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$ — a given schedule of power delivered; a store without one has no row here | +| $`\mathrm{e}^{\mathrm{set}}`$ | `Store_e_set` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$, `neutral` where the data has no row — a given energy schedule; a store without one has no row here | +| $`\mathrm{q}^{\mathrm{set}}`$ | `Store_p_set` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$, `neutral` where the data has no row — a given schedule of power delivered; a store without one has no row here | | $`\mathrm{s}^{\mathrm{nom}}`$ | `Line_s_nom` over $`\Xi \times \mathcal{K}`$ — nominal apparent power | | $`\mathrm{ext}^{s}`$ | `Line_s_nom_extendable` over $`\mathcal{K}`$ — whether the nominal apparent power is a decision | -| $`\mathrm{s}^{\mathrm{mod}}`$ | `Line_s_nom_mod` over $`\mathcal{K}`$ — the module size a build comes in whole numbers of; no value means the build is continuous | +| $`\mathrm{s}^{\mathrm{mod}}`$ | `Line_s_nom_mod` over $`\mathcal{K}`$, `neutral` where the data has no row — the module size a build comes in whole numbers of; no value means the build is continuous | | $`\overline{\mathrm{s}}`$ | `Line_s_max_pu` over $`\Xi \times \mathcal{T} \times \mathcal{K}`$ — most flow either way, per unit of nominal apparent power | | $`\underline{\mathrm{s}}^{\mathrm{nom}}`$ | `Line_s_nom_min` over $`\Xi \times \mathcal{K}`$ — least nominal apparent power an extendable line may be built at | | $`\overline{\mathrm{s}}^{\mathrm{nom}}`$ | `Line_s_nom_max` over $`\Xi \times \mathcal{K}`$ — most nominal apparent power an extendable line may be built at | | $`\mathrm{c}^{\mathrm{cap},s}`$ | `Line_capital_cost` over $`\Xi \times \mathcal{K}`$ — cost of one unit of nominal apparent power for the modelled horizon — PyPSA's `periodized_cost`: `overnight_cost` as an annuity over `lifetime` at `discount_rate`, times `nyears`, where it is given, and `capital_cost` where it is not, plus `fom_cost` (`components.py:1126-1147`, `costs.py:102-203`), data prep | -| $`\mathrm{s}^{\mathrm{nom,set}}`$ | `Line_s_nom_set` over $`\Xi \times \mathcal{K}`$ — a given nominal apparent power for an extendable line; one without a value has no row here | -| $`\mathrm{s}^{\mathrm{set}}`$ | `Line_s_set` over $`\Xi \times \mathcal{T} \times \mathcal{K}`$ — a given flow schedule; a line without one has no row here | -| $`\overline{\delta}`$ | `Line_v_ang_max` over $`\Xi \times \mathcal{K}`$ — the most the voltage angle difference across a line may be either way, in degrees — PyPSA's `v_ang_max`; infinite, and so no row, by default. A line whose carrier is not AC has no row either. The deprecated `v_ang_min` is ignored, as PyPSA ignores it with a `DeprecationWarning` (`constraints.py:1713-1720`) | +| $`\mathrm{s}^{\mathrm{nom,set}}`$ | `Line_s_nom_set` over $`\Xi \times \mathcal{K}`$, `neutral` where the data has no row — a given nominal apparent power for an extendable line; one without a value has no row here | +| $`\mathrm{s}^{\mathrm{set}}`$ | `Line_s_set` over $`\Xi \times \mathcal{T} \times \mathcal{K}`$, `neutral` where the data has no row — a given flow schedule; a line without one has no row here | +| $`\overline{\delta}`$ | `Line_v_ang_max` over $`\Xi \times \mathcal{K}`$, `neutral` where the data has no row — the most the voltage angle difference across a line may be either way, in degrees — PyPSA's `v_ang_max`; infinite, and so no row, by default. A line whose carrier is not AC has no row either. The deprecated `v_ang_min` is ignored, as PyPSA ignores it with a `DeprecationWarning` (`constraints.py:1713-1720`) | | $`\mathrm{x}^{\mathrm{eff}}`$ | `Line_x_pu_eff` over $`\Xi \times \mathcal{K}`$ — the line's effective series reactance — PyPSA's `x_pu_eff`, `x` over the square of its bus's nominal voltage, data prep | -| $`\mathrm{x}`$ | `Line_cycle_weight` over $`\mathcal{Y} \times \mathcal{K} \times \mathcal{C}`$ — the line's series impedance, signed by its orientation in the cycle — the cycle basis, data prep; a line in no cycle has no row. PyPSA builds the cycle basis from the first scenario only (`networks.py:1356-1363`) | -| $`\beta`$ | `Line_BODF` over $`\mathcal{K} \times \mathcal{K}^{\mathrm{out}}`$ — the share of an outaged branch's flow a line takes on when that branch goes out — PyPSA's `BODF`, from the sub-network's PTDF, data prep; a row only where the line and the outage share a sub-network, -1 at the outaged line itself | +| $`\mathrm{x}`$ | `Line_cycle_weight` over $`\mathcal{Y} \times \mathcal{K} \times \mathcal{C}`$, `neutral` where the data has no row — the line's series impedance, signed by its orientation in the cycle — the cycle basis, data prep; a line in no cycle has no row. PyPSA builds the cycle basis from the first scenario only (`networks.py:1356-1363`) | +| $`\beta`$ | `Line_BODF` over $`\mathcal{K} \times \mathcal{K}^{\mathrm{out}}`$, `neutral` where the data has no row — the share of an outaged branch's flow a line takes on when that branch goes out — PyPSA's `BODF`, from the sub-network's PTDF, data prep; a row only where the line and the outage share a sub-network, -1 at the outaged line itself | | $`\mathrm{lossy}`$ | `transmission_losses` (scalar) — whether the network dissipates transmission losses — PyPSA's `transmission_losses` read as a flag; its mode, tangents or secants, only decides how data prep fills the `segment` axis, the rows are the same; false with no segments is a lossless run. A security-constrained run over a network with passive branches builds no loss: PyPSA does not hand the keyword to `create_model` (`abstract.py:437-441`) but to the solver (`:491`), so data prep feeds false there | | $`\overline{\ell}`$ | `Line_loss_max` over $`\Xi \times \mathcal{T} \times \mathcal{K}`$ — the loss at a line's rating — PyPSA's `r_pu_eff * (s_max_pu * s_nom_max)**2`, data prep | | $`\mathrm{a}`$ | `Line_loss_slope` over $`\Xi \times \mathcal{T} \times \mathcal{K} \times \mathcal{B}`$ — the slope of a cut to the loss curve — a tangent's `2 * r_pu_eff * p_k` at its segment's flow, a secant's `r_pu_eff * (p_k + p_k+1)` between consecutive breakpoints, data prep | | $`\mathrm{b}`$ | `Line_loss_offset` over $`\Xi \times \mathcal{T} \times \mathcal{K} \times \mathcal{B}`$ — where that cut meets the loss axis — a tangent's `loss_k - slope_k * p_k`, a secant's `-r_pu_eff * p_k * p_k+1`, negative, data prep | | $`\sigma^{\mathrm{nom}}`$ | `Transformer_s_nom` over $`\Xi \times \mathcal{M}`$ — nominal apparent power | | $`\mathrm{ext}^{\sigma}`$ | `Transformer_s_nom_extendable` over $`\mathcal{M}`$ — whether the nominal apparent power is a decision | -| $`\sigma^{\mathrm{mod}}`$ | `Transformer_s_nom_mod` over $`\mathcal{M}`$ — the module size a build comes in whole numbers of; no value means the build is continuous | +| $`\sigma^{\mathrm{mod}}`$ | `Transformer_s_nom_mod` over $`\mathcal{M}`$, `neutral` where the data has no row — the module size a build comes in whole numbers of; no value means the build is continuous | | $`\overline{\sigma}`$ | `Transformer_s_max_pu` over $`\Xi \times \mathcal{T} \times \mathcal{M}`$ — most flow either way, per unit of nominal apparent power | | $`\underline{\sigma}^{\mathrm{nom}}`$ | `Transformer_s_nom_min` over $`\Xi \times \mathcal{M}`$ — least nominal apparent power an extendable transformer may be built at | | $`\overline{\sigma}^{\mathrm{nom}}`$ | `Transformer_s_nom_max` over $`\Xi \times \mathcal{M}`$ — most nominal apparent power an extendable transformer may be built at | | $`\mathrm{c}^{\mathrm{cap},\sigma}`$ | `Transformer_capital_cost` over $`\Xi \times \mathcal{M}`$ — cost of one unit of nominal apparent power for the modelled horizon — PyPSA's `periodized_cost`: `overnight_cost` as an annuity over `lifetime` at `discount_rate`, times `nyears`, where it is given, and `capital_cost` where it is not, plus `fom_cost` (`components.py:1126-1147`, `costs.py:102-203`), data prep | -| $`\sigma^{\mathrm{nom,set}}`$ | `Transformer_s_nom_set` over $`\Xi \times \mathcal{M}`$ — a given nominal apparent power for an extendable transformer; one without a value has no row here | -| $`\sigma^{\mathrm{set}}`$ | `Transformer_s_set` over $`\Xi \times \mathcal{T} \times \mathcal{M}`$ — a given flow schedule; a transformer without one has no row here | -| $`\overline{\delta}^{\sigma}`$ | `Transformer_v_ang_max` over $`\Xi \times \mathcal{M}`$ — the most the voltage angle difference across a transformer, its phase shift included, may be either way, in degrees — PyPSA's `v_ang_max`; infinite, and so no row, by default. The deprecated `v_ang_min` is ignored, as a line's | +| $`\sigma^{\mathrm{nom,set}}`$ | `Transformer_s_nom_set` over $`\Xi \times \mathcal{M}`$, `neutral` where the data has no row — a given nominal apparent power for an extendable transformer; one without a value has no row here | +| $`\sigma^{\mathrm{set}}`$ | `Transformer_s_set` over $`\Xi \times \mathcal{T} \times \mathcal{M}`$, `neutral` where the data has no row — a given flow schedule; a transformer without one has no row here | +| $`\overline{\delta}^{\sigma}`$ | `Transformer_v_ang_max` over $`\Xi \times \mathcal{M}`$, `neutral` where the data has no row — the most the voltage angle difference across a transformer, its phase shift included, may be either way, in degrees — PyPSA's `v_ang_max`; infinite, and so no row, by default. The deprecated `v_ang_min` is ignored, as a line's | | $`\mathrm{x}^{\mathrm{eff},\sigma}`$ | `Transformer_x_pu_eff` over $`\Xi \times \mathcal{M}`$ — the transformer's effective series reactance — PyPSA's `x_pu_eff`, `x` over its `s_nom` times its tap ratio, data prep | -| $`\mathrm{x}^{\sigma}`$ | `Transformer_cycle_weight` over $`\mathcal{Y} \times \mathcal{M} \times \mathcal{C}`$ — the transformer's effective series reactance, `x` times its tap ratio, signed by its orientation in the cycle — PyPSA's `x_pu_eff`, the cycle basis, data prep; a transformer in no cycle has no row. From the first scenario only, as a line's | -| $`\beta^{\sigma}`$ | `Transformer_BODF` over $`\mathcal{M} \times \mathcal{K}^{\mathrm{out}}`$ — the share of an outaged branch's flow a transformer takes on when that branch goes out, as a line's; a row only where the transformer and the outage share a sub-network | -| $`\vartheta`$ | `Transformer_phase_shift_weight` over $`\mathcal{T} \times \mathcal{M} \times \mathcal{C}`$ — a fixed transformer's phase shift in radians at each snapshot, signed by its orientation in the cycle — a constant added to the cycle sum, data prep; zero for a varying transformer, whose shift is a decision instead, so the constant and the variable term never both count a shift. A transformer with no shift or in no cycle of its snapshot's period has no row | +| $`\mathrm{x}^{\sigma}`$ | `Transformer_cycle_weight` over $`\mathcal{Y} \times \mathcal{M} \times \mathcal{C}`$, `neutral` where the data has no row — the transformer's effective series reactance, `x` times its tap ratio, signed by its orientation in the cycle — PyPSA's `x_pu_eff`, the cycle basis, data prep; a transformer in no cycle has no row. From the first scenario only, as a line's | +| $`\beta^{\sigma}`$ | `Transformer_BODF` over $`\mathcal{M} \times \mathcal{K}^{\mathrm{out}}`$, `neutral` where the data has no row — the share of an outaged branch's flow a transformer takes on when that branch goes out, as a line's; a row only where the transformer and the outage share a sub-network | +| $`\vartheta`$ | `Transformer_phase_shift_weight` over $`\mathcal{T} \times \mathcal{M} \times \mathcal{C}`$, `neutral` where the data has no row — a fixed transformer's phase shift in radians at each snapshot, signed by its orientation in the cycle — a constant added to the cycle sum, data prep; zero for a varying transformer, whose shift is a decision instead, so the constant and the variable term never both count a shift. A transformer with no shift or in no cycle of its snapshot's period has no row | | $`\mathrm{Transformer\_phase\_shift\_varying}`$ | `Transformer_phase_shift_varying` over $`\mathcal{M}`$ — whether a transformer's phase shift is a decision — PyPSA's `phase_shift_min < phase_shift_max`, read as a flag in data prep; false is a fixed shift carried by `phase_shift`. The shift parameters carry no scenario: only a cycle row and an angle row read them, PyPSA fails on a transformer in a cycle on a network with scenarios (`constraints.py:1660`), and builds no angle row on one | | $`\mathrm{Transformer\_phase\_shift\_min}`$ | `Transformer_phase_shift_min` over $`\mathcal{M}`$ — the least a varying transformer's phase shift may take, in degrees — PyPSA's `phase_shift_min`; where it is below `phase_shift_max` the shift is a decision, otherwise the transformer keeps its fixed `phase_shift` | | $`\mathrm{Transformer\_phase\_shift\_max}`$ | `Transformer_phase_shift_max` over $`\mathcal{M}`$ — the most a varying transformer's phase shift may take, in degrees — PyPSA's `phase_shift_max`; equal to `phase_shift_min` for a fixed transformer | @@ -5656,23 +5656,23 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{type}`$ | `GlobalConstraint_type` over $`\mathcal{I}`$ — which formula the row takes — `primary_energy`, `operational_limit`, `transmission_volume_expansion_limit`, `transmission_expansion_cost_limit` or `tech_capacity_expansion_limit` | | $`\mathrm{sense}`$ | `GlobalConstraint_sense` over $`\Xi \times \mathcal{I}`$ — which way the row binds in each scenario — `<=`, `>=` or `==`; PyPSA reads a row's sense per scenario (`global_constraints.py:556`, `:748`, `:860`) | | $`\mathrm{K}`$ | `GlobalConstraint_constant` over $`\Xi \times \mathcal{I}`$ — the constant the total is held against; what a variable cannot carry — an initial charge, times its period's years for each counted period where the storage reopens per period, or a non-extendable build — is folded in here by data prep. PyPSA reads it per scenario (`global_constraints.py:557`, `:749`, `:861`) | -| $`\mathrm{in}`$ | `GlobalConstraint_counts_snapshot` over $`\Xi \times \mathcal{I} \times \mathcal{T}`$ — whether a row counts a snapshot in a scenario — PyPSA's `investment_period`: every snapshot where the row names none, and only that period's where it names one, data prep. A row that names a period the run does not model has no label here, as PyPSA skips it (`global_constraints.py:377`); PyPSA reads the column only under `multi_investment_periods`, and fails on a row that names a period without it (`global_constraints.py:375`) | -| $`\mathrm{a}`$ | `Generator_primary_energy_weight` over $`\Xi \times \mathcal{I} \times \mathcal{T} \times \mathcal{G}`$ — the constrained attribute per unit of energy at the bus — the carrier's `co2_emissions` over the generator's efficiency at the snapshot, data prep; a generator of an unweighted carrier has no row | -| $`\mathrm{a}^{h}`$ | `StorageUnit_primary_energy_weight` over $`\Xi \times \mathcal{I} \times \mathcal{S}`$ — the constrained attribute per unit of charge depleted — data prep; an unweighted unit has no row | -| $`\mathrm{a}^{e}`$ | `Store_primary_energy_weight` over $`\Xi \times \mathcal{I} \times \mathcal{V}`$ — the constrained attribute per unit of energy depleted — data prep; an unweighted store has no row | -| $`\mathrm{b}`$ | `Generator_operational_limit_weight` over $`\Xi \times \mathcal{I} \times \mathcal{G}`$ — one where the generator is in the row's set — data prep; one outside it has no row | -| $`\mathrm{b}^{h}`$ | `StorageUnit_operational_limit_weight` over $`\Xi \times \mathcal{I} \times \mathcal{S}`$ — one where the storage unit is in the row's set — data prep; one outside it has no row | -| $`\mathrm{b}^{e}`$ | `Store_operational_limit_weight` over $`\Xi \times \mathcal{I} \times \mathcal{V}`$ — one where the store is in the row's set — data prep; one outside it has no row | -| $`\mathrm{len}`$ | `Line_volume_weight` over $`\Xi \times \mathcal{I} \times \mathcal{K}`$ — the line's length where its carrier is in the row's set, the first scenario's length as PyPSA reads it (`global_constraints.py:835-836`) — data prep; a line outside it, or one that does not stand in the row's `investment_period`, has no row | -| $`\mathrm{len}^{f}`$ | `Link_volume_weight` over $`\Xi \times \mathcal{I} \times \mathcal{L}`$ — the link's length where its carrier is in the row's set, the first scenario's length as PyPSA reads it (`global_constraints.py:835-836`) — data prep; a link outside it, or one that does not stand in the row's `investment_period`, has no row | -| $`\mathrm{cc}`$ | `Line_expansion_cost_weight` over $`\Xi \times \mathcal{I} \times \mathcal{K}`$ — the line's capital cost where its carrier is in the row's set, times the objective weights of the periods it stands in where the row names no `investment_period` under `multi_investment_periods` — data prep; a line outside the set, or one that does not stand in the row's period, has no row. The capital cost is PyPSA's `capital_cost` property, which is `Line_capital_cost` without `fom_cost` (`components.py:1151-1169`, `global_constraints.py:935`) | -| $`\mathrm{cc}^{f}`$ | `Link_expansion_cost_weight` over $`\Xi \times \mathcal{I} \times \mathcal{L}`$ — the link's capital cost where its carrier is in the row's set, times the objective weights of the periods it stands in where the row names no `investment_period` under `multi_investment_periods` — data prep; a link outside the set, or one that does not stand in the row's period, has no row. The capital cost is PyPSA's `capital_cost` property, which is `Link_capital_cost` without `fom_cost` (`components.py:1151-1169`, `global_constraints.py:935`) | -| $`\mathrm{m}`$ | `Generator_tech_capacity_weight` over $`\mathcal{I} \times \mathcal{G}`$ — one where the generator is in the row's carrier-and-bus set — data prep; one outside it, or one that does not stand in the row's `investment_period`, has no row | -| $`\mathrm{m}^{f}`$ | `Link_tech_capacity_weight` over $`\mathcal{I} \times \mathcal{L}`$ — one where the link is in the row's carrier-and-bus set — data prep; one outside it, or one that does not stand in the row's `investment_period`, has no row | -| $`\mathrm{m}^{l}`$ | `Line_tech_capacity_weight` over $`\mathcal{I} \times \mathcal{K}`$ — one where the line is in the row's carrier-and-bus set — data prep; one outside it, or one that does not stand in the row's `investment_period`, has no row | -| $`\mathrm{m}^{h}`$ | `StorageUnit_tech_capacity_weight` over $`\mathcal{I} \times \mathcal{S}`$ — one where the storage unit is in the row's carrier-and-bus set — data prep; one outside it, or one that does not stand in the row's `investment_period`, has no row | -| $`\mathrm{m}^{e}`$ | `Store_tech_capacity_weight` over $`\mathcal{I} \times \mathcal{V}`$ — one where the store is in the row's carrier-and-bus set — data prep; one outside it, or one that does not stand in the row's `investment_period`, has no row | -| $`\mathrm{m}^{z}`$ | `Process_tech_capacity_weight` over $`\mathcal{I} \times \mathcal{J}`$ — one where the process is in the row's carrier-and-bus set — data prep; one outside it, or one that does not stand in the row's `investment_period`, has no row | +| $`\mathrm{in}`$ | `GlobalConstraint_counts_snapshot` over $`\Xi \times \mathcal{I} \times \mathcal{T}`$, `neutral` where the data has no row — whether a row counts a snapshot in a scenario — PyPSA's `investment_period`: every snapshot where the row names none, and only that period's where it names one, data prep. A row that names a period the run does not model has no label here, as PyPSA skips it (`global_constraints.py:377`); PyPSA reads the column only under `multi_investment_periods`, and fails on a row that names a period without it (`global_constraints.py:375`) | +| $`\mathrm{a}`$ | `Generator_primary_energy_weight` over $`\Xi \times \mathcal{I} \times \mathcal{T} \times \mathcal{G}`$, `neutral` where the data has no row — the constrained attribute per unit of energy at the bus — the carrier's `co2_emissions` over the generator's efficiency at the snapshot, data prep; a generator of an unweighted carrier has no row | +| $`\mathrm{a}^{h}`$ | `StorageUnit_primary_energy_weight` over $`\Xi \times \mathcal{I} \times \mathcal{S}`$, `neutral` where the data has no row — the constrained attribute per unit of charge depleted — data prep; an unweighted unit has no row | +| $`\mathrm{a}^{e}`$ | `Store_primary_energy_weight` over $`\Xi \times \mathcal{I} \times \mathcal{V}`$, `neutral` where the data has no row — the constrained attribute per unit of energy depleted — data prep; an unweighted store has no row | +| $`\mathrm{b}`$ | `Generator_operational_limit_weight` over $`\Xi \times \mathcal{I} \times \mathcal{G}`$, `neutral` where the data has no row — one where the generator is in the row's set — data prep; one outside it has no row | +| $`\mathrm{b}^{h}`$ | `StorageUnit_operational_limit_weight` over $`\Xi \times \mathcal{I} \times \mathcal{S}`$, `neutral` where the data has no row — one where the storage unit is in the row's set — data prep; one outside it has no row | +| $`\mathrm{b}^{e}`$ | `Store_operational_limit_weight` over $`\Xi \times \mathcal{I} \times \mathcal{V}`$, `neutral` where the data has no row — one where the store is in the row's set — data prep; one outside it has no row | +| $`\mathrm{len}`$ | `Line_volume_weight` over $`\Xi \times \mathcal{I} \times \mathcal{K}`$, `neutral` where the data has no row — the line's length where its carrier is in the row's set, the first scenario's length as PyPSA reads it (`global_constraints.py:835-836`) — data prep; a line outside it, or one that does not stand in the row's `investment_period`, has no row | +| $`\mathrm{len}^{f}`$ | `Link_volume_weight` over $`\Xi \times \mathcal{I} \times \mathcal{L}`$, `neutral` where the data has no row — the link's length where its carrier is in the row's set, the first scenario's length as PyPSA reads it (`global_constraints.py:835-836`) — data prep; a link outside it, or one that does not stand in the row's `investment_period`, has no row | +| $`\mathrm{cc}`$ | `Line_expansion_cost_weight` over $`\Xi \times \mathcal{I} \times \mathcal{K}`$, `neutral` where the data has no row — the line's capital cost where its carrier is in the row's set, times the objective weights of the periods it stands in where the row names no `investment_period` under `multi_investment_periods` — data prep; a line outside the set, or one that does not stand in the row's period, has no row. The capital cost is PyPSA's `capital_cost` property, which is `Line_capital_cost` without `fom_cost` (`components.py:1151-1169`, `global_constraints.py:935`) | +| $`\mathrm{cc}^{f}`$ | `Link_expansion_cost_weight` over $`\Xi \times \mathcal{I} \times \mathcal{L}`$, `neutral` where the data has no row — the link's capital cost where its carrier is in the row's set, times the objective weights of the periods it stands in where the row names no `investment_period` under `multi_investment_periods` — data prep; a link outside the set, or one that does not stand in the row's period, has no row. The capital cost is PyPSA's `capital_cost` property, which is `Link_capital_cost` without `fom_cost` (`components.py:1151-1169`, `global_constraints.py:935`) | +| $`\mathrm{m}`$ | `Generator_tech_capacity_weight` over $`\mathcal{I} \times \mathcal{G}`$, `neutral` where the data has no row — one where the generator is in the row's carrier-and-bus set — data prep; one outside it, or one that does not stand in the row's `investment_period`, has no row | +| $`\mathrm{m}^{f}`$ | `Link_tech_capacity_weight` over $`\mathcal{I} \times \mathcal{L}`$, `neutral` where the data has no row — one where the link is in the row's carrier-and-bus set — data prep; one outside it, or one that does not stand in the row's `investment_period`, has no row | +| $`\mathrm{m}^{l}`$ | `Line_tech_capacity_weight` over $`\mathcal{I} \times \mathcal{K}`$, `neutral` where the data has no row — one where the line is in the row's carrier-and-bus set — data prep; one outside it, or one that does not stand in the row's `investment_period`, has no row | +| $`\mathrm{m}^{h}`$ | `StorageUnit_tech_capacity_weight` over $`\mathcal{I} \times \mathcal{S}`$, `neutral` where the data has no row — one where the storage unit is in the row's carrier-and-bus set — data prep; one outside it, or one that does not stand in the row's `investment_period`, has no row | +| $`\mathrm{m}^{e}`$ | `Store_tech_capacity_weight` over $`\mathcal{I} \times \mathcal{V}`$, `neutral` where the data has no row — one where the store is in the row's carrier-and-bus set — data prep; one outside it, or one that does not stand in the row's `investment_period`, has no row | +| $`\mathrm{m}^{z}`$ | `Process_tech_capacity_weight` over $`\mathcal{I} \times \mathcal{J}`$, `neutral` where the data has no row — one where the process is in the row's carrier-and-bus set — data prep; one outside it, or one that does not stand in the row's `investment_period`, has no row | #### Variables diff --git a/docs/examples/pypsa/carrier.md b/docs/examples/pypsa/carrier.md index 1cbf8bd68..ebd1b9217 100644 --- a/docs/examples/pypsa/carrier.md +++ b/docs/examples/pypsa/carrier.md @@ -73,7 +73,7 @@ constraints: | Symbol | Meaning | |---|---| -| $`\overline{\Delta}`$ | `Carrier_max_growth` over $`\mathcal{I}`$ — most capacity of a carrier that may be added in a period; no value means no limit. The least over the scenarios, as PyPSA takes it (`global_constraints.py:226-230`), data prep. PyPSA reads it only under `multi_investment_periods` (`global_constraints.py:219-220`), so data prep feeds no value otherwise | +| $`\overline{\Delta}`$ | `Carrier_max_growth` over $`\mathcal{I}`$, `neutral` where the data has no row — most capacity of a carrier that may be added in a period; no value means no limit. The least over the scenarios, as PyPSA takes it (`global_constraints.py:226-230`), data prep. PyPSA reads it only under `multi_investment_periods` (`global_constraints.py:219-220`), so data prep feeds no value otherwise | | $`\mathrm{r}`$ | `Carrier_max_relative_growth` over $`\mathcal{I}`$ — share of the previous period's additions that may be added on top — the least over the scenarios, as PyPSA takes it, data prep | #### Given diff --git a/docs/examples/pypsa/generator.md b/docs/examples/pypsa/generator.md index 49c8beb0d..2a4a71277 100644 --- a/docs/examples/pypsa/generator.md +++ b/docs/examples/pypsa/generator.md @@ -319,22 +319,22 @@ assumptions: | $`\mathrm{c}`$ | `Generator_marginal_cost` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — cost of one unit of output | | $`\mathrm{c}^{(2)}`$ | `Generator_marginal_cost_quadratic` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — cost of the square of one unit of output | | $`\mathrm{sgn}`$ | `Generator_sign` over $`\mathcal{G}`$ — the sign output enters its bus's balance with — PyPSA's `sign`, `1` unless given, `-1` for a unit that draws power. PyPSA refuses one that differs by scenario (`consistency.py:1187`) | -| $`\mathrm{p}^{\mathrm{mod}}`$ | `Generator_p_nom_mod` over $`\mathcal{G}`$ — the module size a build comes in whole numbers of; no value means the build is continuous | +| $`\mathrm{p}^{\mathrm{mod}}`$ | `Generator_p_nom_mod` over $`\mathcal{G}`$, `neutral` where the data has no row — the module size a build comes in whole numbers of; no value means the build is continuous | | $`\mathrm{N}^{\mathrm{fix}}`$ | `Generator_modules_installed` over $`\Xi \times \mathcal{G}`$ — how many whole modules a committable build has in place: `Generator_p_nom / Generator_p_nom_mod` where a fixed build is modular, one where it is not, data prep. PyPSA refuses a fixed modular build whose nominal power is not a whole number of modules | | $`\mathrm{nonneg}`$ | `Generator_p_min_pu_nonneg` over $`\mathcal{G}`$ — true where none of the generator's own minimums-per-unit is negative — PyPSA's per-unit `(p_min_pu >= 0).all()` over every snapshot and scenario, data prep | | $`\mathrm{on}`$ | `Generator_active` over $`\mathcal{T} \times \mathcal{G}`$ — whether a generator stands in a snapshot's period — PyPSA's `active`, from build year and lifetime, data prep | | $`\mathrm{W}`$ | `Generator_capital_weight` over $`\mathcal{G}`$ — the sum of period weights a generator stands in — PyPSA's `active * period_weighting`, summed, data prep | | $`\mathrm{new}`$ | `Generator_first_active` over $`\mathcal{Y} \times \mathcal{G}`$ — one in the first period a generator stands in, zero elsewhere, data prep. PyPSA takes `active.cumsum() == 1`, which also counts a generator that has retired in every later period (`global_constraints.py:276`, PyPSA/PyPSA\#1938) | -| $`\mathrm{p}^{\mathrm{set}}`$ | `Generator_p_set` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — a given output schedule; a generator without one has no row here | +| $`\mathrm{p}^{\mathrm{set}}`$ | `Generator_p_set` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$, `neutral` where the data has no row — a given output schedule; a generator without one has no row here | | $`\underline{\mathrm{p}}^{\mathrm{nom}}`$ | `Generator_p_nom_min` over $`\Xi \times \mathcal{G}`$ — least nominal power an extendable generator may be built at | | $`\overline{\mathrm{p}}^{\mathrm{nom}}`$ | `Generator_p_nom_max` over $`\Xi \times \mathcal{G}`$ — most nominal power an extendable generator may be built at | | $`\mathrm{c}^{\mathrm{cap}}`$ | `Generator_capital_cost` over $`\Xi \times \mathcal{G}`$ — cost of one unit of nominal power for the modelled horizon — PyPSA's `periodized_cost`: `overnight_cost` as an annuity over `lifetime` at `discount_rate`, times `nyears`, where it is given, and `capital_cost` where it is not, plus `fom_cost` (`components.py:1126-1147`, `costs.py:102-203`), data prep | -| $`\mathrm{p}^{\mathrm{nom,set}}`$ | `Generator_p_nom_set` over $`\Xi \times \mathcal{G}`$ — a given nominal power for an extendable generator; one without a value has no row here | +| $`\mathrm{p}^{\mathrm{nom,set}}`$ | `Generator_p_nom_set` over $`\Xi \times \mathcal{G}`$, `neutral` where the data has no row — a given nominal power for an extendable generator; one without a value has no row here | | $`\underline{\mathrm{E}}`$ | `Generator_e_sum_min` over $`\Xi \times \mathcal{G}`$ — least energy over the horizon; minus infinity where no floor is meant | | $`\overline{\mathrm{E}}`$ | `Generator_e_sum_max` over $`\Xi \times \mathcal{G}`$ — most energy over the horizon — a fuel or emission budget in energy terms; infinity where no cap is meant | -| $`\mathrm{a}`$ | `Generator_primary_energy_weight` over $`\Xi \times \mathcal{L} \times \mathcal{T} \times \mathcal{G}`$ — the constrained attribute per unit of energy at the bus — the carrier's `co2_emissions` over the generator's efficiency at the snapshot, data prep; a generator of an unweighted carrier has no row | -| $`\mathrm{b}`$ | `Generator_operational_limit_weight` over $`\Xi \times \mathcal{L} \times \mathcal{G}`$ — one where the generator is in the row's set — data prep; one outside it has no row | -| $`\mathrm{m}`$ | `Generator_tech_capacity_weight` over $`\mathcal{L} \times \mathcal{G}`$ — one where the generator is in the row's carrier-and-bus set — data prep; one outside it, or one that does not stand in the row's `investment_period`, has no row | +| $`\mathrm{a}`$ | `Generator_primary_energy_weight` over $`\Xi \times \mathcal{L} \times \mathcal{T} \times \mathcal{G}`$, `neutral` where the data has no row — the constrained attribute per unit of energy at the bus — the carrier's `co2_emissions` over the generator's efficiency at the snapshot, data prep; a generator of an unweighted carrier has no row | +| $`\mathrm{b}`$ | `Generator_operational_limit_weight` over $`\Xi \times \mathcal{L} \times \mathcal{G}`$, `neutral` where the data has no row — one where the generator is in the row's set — data prep; one outside it has no row | +| $`\mathrm{m}`$ | `Generator_tech_capacity_weight` over $`\mathcal{L} \times \mathcal{G}`$, `neutral` where the data has no row — one where the generator is in the row's carrier-and-bus set — data prep; one outside it, or one that does not stand in the row's `investment_period`, has no row | #### Variables diff --git a/docs/examples/pypsa/generator_commitment.md b/docs/examples/pypsa/generator_commitment.md index 5420300c6..f54e269e8 100644 --- a/docs/examples/pypsa/generator_commitment.md +++ b/docs/examples/pypsa/generator_commitment.md @@ -312,7 +312,7 @@ constraints: | $`\mathrm{c}^{\mathrm{up}}`$ | `Generator_start_up_cost` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — cost of one start in this snapshot | | $`\mathrm{c}^{\mathrm{dn}}`$ | `Generator_shut_down_cost` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — cost of one stop in this snapshot | | $`\mathrm{c}^{\mathrm{on}}`$ | `Generator_stand_by_cost` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — cost of one snapshot spent on | -| $`\mathrm{M}`$ | `Generator_big_m` over $`\Xi \times \mathcal{G}`$ — the bound a committed extendable generator's big-M rows release it by — the build cap `p_nom_max` times the highest `p_max_pu`, where the cap is finite and positive. Elsewhere it is `committable_big_m` times the highest `p_max_pu`, and where that keyword is not given, ten times the largest of the peak total load and the component's largest finite `p_nom` and `p_nom_max`, or 1e6 where there is none of them (`components.py:1050-1121`). Below the output a solve wants, it caps that output; data prep | +| $`\mathrm{M}`$ | `Generator_big_m` over $`\Xi \times \mathcal{G}`$, `neutral` where the data has no row — the bound a committed extendable generator's big-M rows release it by — the build cap `p_nom_max` times the highest `p_max_pu`, where the cap is finite and positive. Elsewhere it is `committable_big_m` times the highest `p_max_pu`, and where that keyword is not given, ten times the largest of the peak total load and the component's largest finite `p_nom` and `p_nom_max`, or 1e6 where there is none of them (`components.py:1050-1121`). Below the output a solve wants, it caps that output; data prep | #### Variables diff --git a/docs/examples/pypsa/generator_maintenance.md b/docs/examples/pypsa/generator_maintenance.md index 95b338c3e..807918097 100644 --- a/docs/examples/pypsa/generator_maintenance.md +++ b/docs/examples/pypsa/generator_maintenance.md @@ -247,7 +247,7 @@ assumptions: | $`\mathrm{mnt}`$ | `Generator_maintainable` over $`\mathcal{G}`$ — whether a generator must be taken off for maintenance within the horizon — in any scenario, as PyPSA takes the union over them (`components.py:1016-1019`) | | $`\gamma`$ | `Generator_maintenance_pu` over $`\Xi \times \mathcal{G}`$ — the share of the build a maintenance event takes off | | $`\mathrm{n}^{\mathrm{mnt}}`$ | `Generator_maintenance_events` over $`\Xi \times \mathcal{G}`$ — how many maintenance events the horizon holds | -| $`\tau^{\mathrm{mnt}}`$ | `Generator_maintenance_duration` over $`\Xi \times \mathcal{G}`$ — the hours of generator weightings one maintenance event covers — PyPSA's `maintenance_duration`; no value where the generator is not maintainable. No row reads it: data prep turns it into `Generator_maintenance_cover` and `Generator_maintenance_start_blocked`, and the assumptions hold it to the horizon | +| $`\tau^{\mathrm{mnt}}`$ | `Generator_maintenance_duration` over $`\Xi \times \mathcal{G}`$, `neutral` where the data has no row — the hours of generator weightings one maintenance event covers — PyPSA's `maintenance_duration`; no value where the generator is not maintainable. No row reads it: data prep turns it into `Generator_maintenance_cover` and `Generator_maintenance_start_blocked`, and the assumptions hold it to the horizon | | $`\mathrm{blk}`$ | `Generator_maintenance_start_blocked` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — true where no maintenance event may start, because the snapshots it would cover run past the end of the horizon or into one the generator does not stand in — PyPSA's `active & ~valid`, from `maintenance_duration` and the generator weightings, data prep | #### Variables diff --git a/docs/examples/pypsa/generator_ramping.md b/docs/examples/pypsa/generator_ramping.md index 4a31d3841..2d371730b 100644 --- a/docs/examples/pypsa/generator_ramping.md +++ b/docs/examples/pypsa/generator_ramping.md @@ -264,11 +264,11 @@ assumptions: | Symbol | Meaning | |---|---| -| $`\mathrm{ru}`$ | `Generator_ramp_limit_up` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — most a generator may raise its output between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time | -| $`\mathrm{rd}`$ | `Generator_ramp_limit_down` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — most a generator may lower its output between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time | -| $`\mathrm{ru}^{\mathrm{up}}`$ | `Generator_ramp_limit_start_up` over $`\Xi \times \mathcal{G}`$ — most output in the snapshot a unit starts, per unit of nominal power; no value means no limit | -| $`\mathrm{rd}^{\mathrm{dn}}`$ | `Generator_ramp_limit_shut_down` over $`\Xi \times \mathcal{G}`$ — most output in the snapshot before a unit stops, per unit of nominal power; no value means no limit | -| $`\mathrm{p}^{0}`$ | `Generator_p_init` over $`\Xi \times \mathcal{G}`$ — the output a unit brought into the horizon — PyPSA's `p_init`, read only where the unit came in running; no value means it is unknown, so the unit carries no ramp row at the first snapshot | +| $`\mathrm{ru}`$ | `Generator_ramp_limit_up` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$, `neutral` where the data has no row — most a generator may raise its output between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time | +| $`\mathrm{rd}`$ | `Generator_ramp_limit_down` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$, `neutral` where the data has no row — most a generator may lower its output between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time | +| $`\mathrm{ru}^{\mathrm{up}}`$ | `Generator_ramp_limit_start_up` over $`\Xi \times \mathcal{G}`$, `neutral` where the data has no row — most output in the snapshot a unit starts, per unit of nominal power; no value means no limit | +| $`\mathrm{rd}^{\mathrm{dn}}`$ | `Generator_ramp_limit_shut_down` over $`\Xi \times \mathcal{G}`$, `neutral` where the data has no row — most output in the snapshot before a unit stops, per unit of nominal power; no value means no limit | +| $`\mathrm{p}^{0}`$ | `Generator_p_init` over $`\Xi \times \mathcal{G}`$, `neutral` where the data has no row — the output a unit brought into the horizon — PyPSA's `p_init`, read only where the unit came in running; no value means it is unknown, so the unit carries no ramp row at the first snapshot | #### Given diff --git a/docs/examples/pypsa/line.md b/docs/examples/pypsa/line.md index 9f3adc473..c6fbd98e4 100644 --- a/docs/examples/pypsa/line.md +++ b/docs/examples/pypsa/line.md @@ -359,22 +359,22 @@ constraints: | $`\mathrm{new}^{s}`$ | `Line_first_active` over $`\mathcal{Y} \times \mathcal{K}`$ — one in the first period a line stands in, zero elsewhere, data prep. PyPSA takes `active.cumsum() == 1`, which also counts a line that has retired in every later period (`global_constraints.py:276`, PyPSA/PyPSA\#1938) | | $`\mathrm{s}^{\mathrm{nom}}`$ | `Line_s_nom` over $`\Xi \times \mathcal{K}`$ — nominal apparent power | | $`\mathrm{ext}^{s}`$ | `Line_s_nom_extendable` over $`\mathcal{K}`$ — whether the nominal apparent power is a decision | -| $`\mathrm{s}^{\mathrm{mod}}`$ | `Line_s_nom_mod` over $`\mathcal{K}`$ — the module size a build comes in whole numbers of; no value means the build is continuous | +| $`\mathrm{s}^{\mathrm{mod}}`$ | `Line_s_nom_mod` over $`\mathcal{K}`$, `neutral` where the data has no row — the module size a build comes in whole numbers of; no value means the build is continuous | | $`\overline{\mathrm{s}}`$ | `Line_s_max_pu` over $`\Xi \times \mathcal{T} \times \mathcal{K}`$ — most flow either way, per unit of nominal apparent power | | $`\underline{\mathrm{s}}^{\mathrm{nom}}`$ | `Line_s_nom_min` over $`\Xi \times \mathcal{K}`$ — least nominal apparent power an extendable line may be built at | | $`\overline{\mathrm{s}}^{\mathrm{nom}}`$ | `Line_s_nom_max` over $`\Xi \times \mathcal{K}`$ — most nominal apparent power an extendable line may be built at | | $`\mathrm{c}^{\mathrm{cap},s}`$ | `Line_capital_cost` over $`\Xi \times \mathcal{K}`$ — cost of one unit of nominal apparent power for the modelled horizon — PyPSA's `periodized_cost`: `overnight_cost` as an annuity over `lifetime` at `discount_rate`, times `nyears`, where it is given, and `capital_cost` where it is not, plus `fom_cost` (`components.py:1126-1147`, `costs.py:102-203`), data prep | -| $`\mathrm{s}^{\mathrm{nom,set}}`$ | `Line_s_nom_set` over $`\Xi \times \mathcal{K}`$ — a given nominal apparent power for an extendable line; one without a value has no row here | -| $`\mathrm{s}^{\mathrm{set}}`$ | `Line_s_set` over $`\Xi \times \mathcal{T} \times \mathcal{K}`$ — a given flow schedule; a line without one has no row here | -| $`\overline{\delta}`$ | `Line_v_ang_max` over $`\Xi \times \mathcal{K}`$ — the most the voltage angle difference across a line may be either way, in degrees — PyPSA's `v_ang_max`; infinite, and so no row, by default. A line whose carrier is not AC has no row either. The deprecated `v_ang_min` is ignored, as PyPSA ignores it with a `DeprecationWarning` (`constraints.py:1713-1720`) | +| $`\mathrm{s}^{\mathrm{nom,set}}`$ | `Line_s_nom_set` over $`\Xi \times \mathcal{K}`$, `neutral` where the data has no row — a given nominal apparent power for an extendable line; one without a value has no row here | +| $`\mathrm{s}^{\mathrm{set}}`$ | `Line_s_set` over $`\Xi \times \mathcal{T} \times \mathcal{K}`$, `neutral` where the data has no row — a given flow schedule; a line without one has no row here | +| $`\overline{\delta}`$ | `Line_v_ang_max` over $`\Xi \times \mathcal{K}`$, `neutral` where the data has no row — the most the voltage angle difference across a line may be either way, in degrees — PyPSA's `v_ang_max`; infinite, and so no row, by default. A line whose carrier is not AC has no row either. The deprecated `v_ang_min` is ignored, as PyPSA ignores it with a `DeprecationWarning` (`constraints.py:1713-1720`) | | $`\mathrm{x}^{\mathrm{eff}}`$ | `Line_x_pu_eff` over $`\Xi \times \mathcal{K}`$ — the line's effective series reactance — PyPSA's `x_pu_eff`, `x` over the square of its bus's nominal voltage, data prep | -| $`\mathrm{x}`$ | `Line_cycle_weight` over $`\mathcal{Y} \times \mathcal{K} \times \mathcal{C}`$ — the line's series impedance, signed by its orientation in the cycle — the cycle basis, data prep; a line in no cycle has no row. PyPSA builds the cycle basis from the first scenario only (`networks.py:1356-1363`) | +| $`\mathrm{x}`$ | `Line_cycle_weight` over $`\mathcal{Y} \times \mathcal{K} \times \mathcal{C}`$, `neutral` where the data has no row — the line's series impedance, signed by its orientation in the cycle — the cycle basis, data prep; a line in no cycle has no row. PyPSA builds the cycle basis from the first scenario only (`networks.py:1356-1363`) | | $`\overline{\ell}`$ | `Line_loss_max` over $`\Xi \times \mathcal{T} \times \mathcal{K}`$ — the loss at a line's rating — PyPSA's `r_pu_eff * (s_max_pu * s_nom_max)**2`, data prep | | $`\mathrm{a}`$ | `Line_loss_slope` over $`\Xi \times \mathcal{T} \times \mathcal{K} \times \mathcal{E}`$ — the slope of a cut to the loss curve — a tangent's `2 * r_pu_eff * p_k` at its segment's flow, a secant's `r_pu_eff * (p_k + p_k+1)` between consecutive breakpoints, data prep | | $`\mathrm{b}`$ | `Line_loss_offset` over $`\Xi \times \mathcal{T} \times \mathcal{K} \times \mathcal{E}`$ — where that cut meets the loss axis — a tangent's `loss_k - slope_k * p_k`, a secant's `-r_pu_eff * p_k * p_k+1`, negative, data prep | -| $`\mathrm{len}`$ | `Line_volume_weight` over $`\Xi \times \mathcal{G} \times \mathcal{K}`$ — the line's length where its carrier is in the row's set, the first scenario's length as PyPSA reads it (`global_constraints.py:835-836`) — data prep; a line outside it, or one that does not stand in the row's `investment_period`, has no row | -| $`\mathrm{cc}`$ | `Line_expansion_cost_weight` over $`\Xi \times \mathcal{G} \times \mathcal{K}`$ — the line's capital cost where its carrier is in the row's set, times the objective weights of the periods it stands in where the row names no `investment_period` under `multi_investment_periods` — data prep; a line outside the set, or one that does not stand in the row's period, has no row. The capital cost is PyPSA's `capital_cost` property, which is `Line_capital_cost` without `fom_cost` (`components.py:1151-1169`, `global_constraints.py:935`) | -| $`\mathrm{m}^{l}`$ | `Line_tech_capacity_weight` over $`\mathcal{G} \times \mathcal{K}`$ — one where the line is in the row's carrier-and-bus set — data prep; one outside it, or one that does not stand in the row's `investment_period`, has no row | +| $`\mathrm{len}`$ | `Line_volume_weight` over $`\Xi \times \mathcal{G} \times \mathcal{K}`$, `neutral` where the data has no row — the line's length where its carrier is in the row's set, the first scenario's length as PyPSA reads it (`global_constraints.py:835-836`) — data prep; a line outside it, or one that does not stand in the row's `investment_period`, has no row | +| $`\mathrm{cc}`$ | `Line_expansion_cost_weight` over $`\Xi \times \mathcal{G} \times \mathcal{K}`$, `neutral` where the data has no row — the line's capital cost where its carrier is in the row's set, times the objective weights of the periods it stands in where the row names no `investment_period` under `multi_investment_periods` — data prep; a line outside the set, or one that does not stand in the row's period, has no row. The capital cost is PyPSA's `capital_cost` property, which is `Line_capital_cost` without `fom_cost` (`components.py:1151-1169`, `global_constraints.py:935`) | +| $`\mathrm{m}^{l}`$ | `Line_tech_capacity_weight` over $`\mathcal{G} \times \mathcal{K}`$, `neutral` where the data has no row — one where the line is in the row's carrier-and-bus set — data prep; one outside it, or one that does not stand in the row's `investment_period`, has no row | #### Variables diff --git a/docs/examples/pypsa/link.md b/docs/examples/pypsa/link.md index 54424cc3a..979ecfbd3 100644 --- a/docs/examples/pypsa/link.md +++ b/docs/examples/pypsa/link.md @@ -367,20 +367,20 @@ assumptions: | $`\mathrm{cyc}^{f}`$ | `Link_output_cyclic_delay` over $`\Xi \times \mathcal{O}`$ — whether a delayed port's flow wraps from the end of its investment period — PyPSA's `cyclic_delay`, `cyclic_delay2`, …; where it does not, the flow still in transit at each period's first snapshots is lost. Each scenario takes its own, as the delay | | $`\mathrm{c}^{f}`$ | `Link_marginal_cost` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — cost of one unit of flow | | $`\mathrm{c}^{f,(2)}`$ | `Link_marginal_cost_quadratic` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — cost of the square of one unit of flow | -| $`\mathrm{f}^{\mathrm{mod}}`$ | `Link_p_nom_mod` over $`\mathcal{L}`$ — the module size a build comes in whole numbers of; no value means the build is continuous | +| $`\mathrm{f}^{\mathrm{mod}}`$ | `Link_p_nom_mod` over $`\mathcal{L}`$, `neutral` where the data has no row — the module size a build comes in whole numbers of; no value means the build is continuous | | $`\mathrm{N}^{f,\mathrm{fix}}`$ | `Link_modules_installed` over $`\Xi \times \mathcal{L}`$ — how many whole modules a committable build has in place: `Link_p_nom / Link_p_nom_mod` where a fixed build is modular, one where it is not, data prep. PyPSA refuses a fixed modular build whose nominal power is not a whole number of modules | | $`\mathrm{nonneg}^{f}`$ | `Link_p_min_pu_nonneg` over $`\mathcal{L}`$ — true where none of the link's own minimums-per-unit is negative — PyPSA's per-unit `(p_min_pu >= 0).all()` over every snapshot and scenario, data prep | | $`\mathrm{on}^{f}`$ | `Link_active` over $`\mathcal{T} \times \mathcal{L}`$ — whether a link stands in a snapshot's period — PyPSA's `active`, data prep | | $`\mathrm{W}^{f}`$ | `Link_capital_weight` over $`\mathcal{L}`$ — the sum of period weights a link stands in — PyPSA's `active * period_weighting`, summed, data prep | | $`\mathrm{new}^{f}`$ | `Link_first_active` over $`\mathcal{Y} \times \mathcal{L}`$ — one in the first period a link stands in, zero elsewhere, data prep. PyPSA takes `active.cumsum() == 1`, which also counts a link that has retired in every later period (`global_constraints.py:276`, PyPSA/PyPSA\#1938) | -| $`\mathrm{f}^{\mathrm{set}}`$ | `Link_p_set` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — a given flow schedule; a link without one has no row here | +| $`\mathrm{f}^{\mathrm{set}}`$ | `Link_p_set` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$, `neutral` where the data has no row — a given flow schedule; a link without one has no row here | | $`\underline{\mathrm{f}}^{\mathrm{nom}}`$ | `Link_p_nom_min` over $`\Xi \times \mathcal{L}`$ — least nominal power an extendable link may be built at | | $`\overline{\mathrm{f}}^{\mathrm{nom}}`$ | `Link_p_nom_max` over $`\Xi \times \mathcal{L}`$ — most nominal power an extendable link may be built at | | $`\mathrm{c}^{\mathrm{cap},f}`$ | `Link_capital_cost` over $`\Xi \times \mathcal{L}`$ — cost of one unit of nominal power for the modelled horizon — PyPSA's `periodized_cost`: `overnight_cost` as an annuity over `lifetime` at `discount_rate`, times `nyears`, where it is given, and `capital_cost` where it is not, plus `fom_cost` (`components.py:1126-1147`, `costs.py:102-203`), data prep | -| $`\mathrm{f}^{\mathrm{nom,set}}`$ | `Link_p_nom_set` over $`\Xi \times \mathcal{L}`$ — a given nominal power for an extendable link; one without a value has no row here | -| $`\mathrm{len}^{f}`$ | `Link_volume_weight` over $`\Xi \times \mathcal{G} \times \mathcal{L}`$ — the link's length where its carrier is in the row's set, the first scenario's length as PyPSA reads it (`global_constraints.py:835-836`) — data prep; a link outside it, or one that does not stand in the row's `investment_period`, has no row | -| $`\mathrm{cc}^{f}`$ | `Link_expansion_cost_weight` over $`\Xi \times \mathcal{G} \times \mathcal{L}`$ — the link's capital cost where its carrier is in the row's set, times the objective weights of the periods it stands in where the row names no `investment_period` under `multi_investment_periods` — data prep; a link outside the set, or one that does not stand in the row's period, has no row. The capital cost is PyPSA's `capital_cost` property, which is `Link_capital_cost` without `fom_cost` (`components.py:1151-1169`, `global_constraints.py:935`) | -| $`\mathrm{m}^{f}`$ | `Link_tech_capacity_weight` over $`\mathcal{G} \times \mathcal{L}`$ — one where the link is in the row's carrier-and-bus set — data prep; one outside it, or one that does not stand in the row's `investment_period`, has no row | +| $`\mathrm{f}^{\mathrm{nom,set}}`$ | `Link_p_nom_set` over $`\Xi \times \mathcal{L}`$, `neutral` where the data has no row — a given nominal power for an extendable link; one without a value has no row here | +| $`\mathrm{len}^{f}`$ | `Link_volume_weight` over $`\Xi \times \mathcal{G} \times \mathcal{L}`$, `neutral` where the data has no row — the link's length where its carrier is in the row's set, the first scenario's length as PyPSA reads it (`global_constraints.py:835-836`) — data prep; a link outside it, or one that does not stand in the row's `investment_period`, has no row | +| $`\mathrm{cc}^{f}`$ | `Link_expansion_cost_weight` over $`\Xi \times \mathcal{G} \times \mathcal{L}`$, `neutral` where the data has no row — the link's capital cost where its carrier is in the row's set, times the objective weights of the periods it stands in where the row names no `investment_period` under `multi_investment_periods` — data prep; a link outside the set, or one that does not stand in the row's period, has no row. The capital cost is PyPSA's `capital_cost` property, which is `Link_capital_cost` without `fom_cost` (`components.py:1151-1169`, `global_constraints.py:935`) | +| $`\mathrm{m}^{f}`$ | `Link_tech_capacity_weight` over $`\mathcal{G} \times \mathcal{L}`$, `neutral` where the data has no row — one where the link is in the row's carrier-and-bus set — data prep; one outside it, or one that does not stand in the row's `investment_period`, has no row | #### Variables diff --git a/docs/examples/pypsa/link_commitment.md b/docs/examples/pypsa/link_commitment.md index ca3b63afd..1b8505c2d 100644 --- a/docs/examples/pypsa/link_commitment.md +++ b/docs/examples/pypsa/link_commitment.md @@ -312,7 +312,7 @@ constraints: | $`\mathrm{c}^{f,\mathrm{up}}`$ | `Link_start_up_cost` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — cost of one start in this snapshot | | $`\mathrm{c}^{f,\mathrm{dn}}`$ | `Link_shut_down_cost` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — cost of one stop in this snapshot | | $`\mathrm{c}^{f,\mathrm{on}}`$ | `Link_stand_by_cost` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — cost of one snapshot spent on | -| $`\mathrm{M}^{f}`$ | `Link_big_m` over $`\Xi \times \mathcal{L}`$ — the bound a committed extendable link's big-M rows release it by — the build cap `p_nom_max` times the highest `p_max_pu`, where the cap is finite and positive. Elsewhere it is `committable_big_m` times the highest `p_max_pu`, and where that keyword is not given, ten times the largest of the peak total load and the component's largest finite `p_nom` and `p_nom_max`, or 1e6 where there is none of them (`components.py:1050-1121`). Below the flow a solve wants, it caps that flow; data prep | +| $`\mathrm{M}^{f}`$ | `Link_big_m` over $`\Xi \times \mathcal{L}`$, `neutral` where the data has no row — the bound a committed extendable link's big-M rows release it by — the build cap `p_nom_max` times the highest `p_max_pu`, where the cap is finite and positive. Elsewhere it is `committable_big_m` times the highest `p_max_pu`, and where that keyword is not given, ten times the largest of the peak total load and the component's largest finite `p_nom` and `p_nom_max`, or 1e6 where there is none of them (`components.py:1050-1121`). Below the flow a solve wants, it caps that flow; data prep | #### Variables diff --git a/docs/examples/pypsa/link_maintenance.md b/docs/examples/pypsa/link_maintenance.md index c7594c8e0..ba292b2f6 100644 --- a/docs/examples/pypsa/link_maintenance.md +++ b/docs/examples/pypsa/link_maintenance.md @@ -247,7 +247,7 @@ assumptions: | $`\mathrm{mnt}^{f}`$ | `Link_maintainable` over $`\mathcal{L}`$ — whether a link must be taken off for maintenance within the horizon — in any scenario, as PyPSA takes the union over them (`components.py:1016-1019`) | | $`\gamma^{f}`$ | `Link_maintenance_pu` over $`\Xi \times \mathcal{L}`$ — the share of the build a maintenance event takes off | | $`\mathrm{n}^{f,\mathrm{mnt}}`$ | `Link_maintenance_events` over $`\Xi \times \mathcal{L}`$ — how many maintenance events the horizon holds | -| $`\tau^{f,\mathrm{mnt}}`$ | `Link_maintenance_duration` over $`\Xi \times \mathcal{L}`$ — the hours of generator weightings one maintenance event covers — PyPSA's `maintenance_duration`; no value where the link is not maintainable. No row reads it: data prep turns it into `Link_maintenance_cover` and `Link_maintenance_start_blocked`, and the assumptions hold it to the horizon | +| $`\tau^{f,\mathrm{mnt}}`$ | `Link_maintenance_duration` over $`\Xi \times \mathcal{L}`$, `neutral` where the data has no row — the hours of generator weightings one maintenance event covers — PyPSA's `maintenance_duration`; no value where the link is not maintainable. No row reads it: data prep turns it into `Link_maintenance_cover` and `Link_maintenance_start_blocked`, and the assumptions hold it to the horizon | | $`\mathrm{blk}^{f}`$ | `Link_maintenance_start_blocked` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — true where no maintenance event may start, because the snapshots it would cover run past the end of the horizon or into one the link does not stand in — PyPSA's `active & ~valid`, from `maintenance_duration` and the generator weightings, data prep | #### Variables diff --git a/docs/examples/pypsa/link_ramping.md b/docs/examples/pypsa/link_ramping.md index bac06ef80..c28691789 100644 --- a/docs/examples/pypsa/link_ramping.md +++ b/docs/examples/pypsa/link_ramping.md @@ -264,11 +264,11 @@ assumptions: | Symbol | Meaning | |---|---| -| $`\mathrm{ru}^{f}`$ | `Link_ramp_limit_up` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — most a link may raise its flow between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time | -| $`\mathrm{rd}^{f}`$ | `Link_ramp_limit_down` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — most a link may lower its flow between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time | -| $`\mathrm{ru}^{f,\mathrm{up}}`$ | `Link_ramp_limit_start_up` over $`\Xi \times \mathcal{L}`$ — most flow in the snapshot a link starts, per unit of nominal power; no value means no limit | -| $`\mathrm{rd}^{f,\mathrm{dn}}`$ | `Link_ramp_limit_shut_down` over $`\Xi \times \mathcal{L}`$ — most flow in the snapshot before a link stops, per unit of nominal power; no value means no limit | -| $`\mathrm{f}^{0}`$ | `Link_p_init` over $`\Xi \times \mathcal{L}`$ — the flow a link brought into the horizon — PyPSA's `p_init`, read only where the link came in running; no value means it is unknown, so the link carries no ramp row at the first snapshot | +| $`\mathrm{ru}^{f}`$ | `Link_ramp_limit_up` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$, `neutral` where the data has no row — most a link may raise its flow between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time | +| $`\mathrm{rd}^{f}`$ | `Link_ramp_limit_down` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$, `neutral` where the data has no row — most a link may lower its flow between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time | +| $`\mathrm{ru}^{f,\mathrm{up}}`$ | `Link_ramp_limit_start_up` over $`\Xi \times \mathcal{L}`$, `neutral` where the data has no row — most flow in the snapshot a link starts, per unit of nominal power; no value means no limit | +| $`\mathrm{rd}^{f,\mathrm{dn}}`$ | `Link_ramp_limit_shut_down` over $`\Xi \times \mathcal{L}`$, `neutral` where the data has no row — most flow in the snapshot before a link stops, per unit of nominal power; no value means no limit | +| $`\mathrm{f}^{0}`$ | `Link_p_init` over $`\Xi \times \mathcal{L}`$, `neutral` where the data has no row — the flow a link brought into the horizon — PyPSA's `p_init`, read only where the link came in running; no value means it is unknown, so the link carries no ramp row at the first snapshot | #### Given diff --git a/docs/examples/pypsa/process.md b/docs/examples/pypsa/process.md index 728f3316d..67d5b5116 100644 --- a/docs/examples/pypsa/process.md +++ b/docs/examples/pypsa/process.md @@ -333,18 +333,18 @@ assumptions: | $`\mathrm{cyc}^{z}`$ | `Process_output_cyclic_delay` over $`\Xi \times \mathcal{R}`$ — whether a delayed port's transfer wraps from the end of its investment period — PyPSA's `cyclic_delay0`, `cyclic_delay1`, …; where it does not, the energy still in transit at each period's first snapshots is lost. Each scenario takes its own, as the delay | | $`\mathrm{c}^{z}`$ | `Process_marginal_cost` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — cost of one unit of internal power | | $`\mathrm{c}^{z,(2)}`$ | `Process_marginal_cost_quadratic` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — cost of the square of one unit of internal power | -| $`\mathrm{z}^{\mathrm{set}}`$ | `Process_p_set` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — a given internal power schedule; a process without one has no row here | +| $`\mathrm{z}^{\mathrm{set}}`$ | `Process_p_set` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$, `neutral` where the data has no row — a given internal power schedule; a process without one has no row here | | $`\underline{\mathrm{z}}^{\mathrm{nom}}`$ | `Process_p_nom_min` over $`\Xi \times \mathcal{J}`$ — least nominal power an extendable process may be built at | | $`\overline{\mathrm{z}}^{\mathrm{nom}}`$ | `Process_p_nom_max` over $`\Xi \times \mathcal{J}`$ — most nominal power an extendable process may be built at | | $`\mathrm{c}^{\mathrm{cap},z}`$ | `Process_capital_cost` over $`\Xi \times \mathcal{J}`$ — cost of one unit of nominal power for the modelled horizon — PyPSA's `periodized_cost`: `overnight_cost` as an annuity over `lifetime` at `discount_rate`, times `nyears`, where it is given, and `capital_cost` where it is not, plus `fom_cost` (`components.py:1126-1147`, `costs.py:102-203`), data prep | -| $`\mathrm{z}^{\mathrm{nom,set}}`$ | `Process_p_nom_set` over $`\Xi \times \mathcal{J}`$ — a given nominal power for an extendable process; one without a value has no row here | -| $`\mathrm{z}^{\mathrm{mod}}`$ | `Process_p_nom_mod` over $`\mathcal{J}`$ — the module size a build comes in whole numbers of; no value means the build is continuous | +| $`\mathrm{z}^{\mathrm{nom,set}}`$ | `Process_p_nom_set` over $`\Xi \times \mathcal{J}`$, `neutral` where the data has no row — a given nominal power for an extendable process; one without a value has no row here | +| $`\mathrm{z}^{\mathrm{mod}}`$ | `Process_p_nom_mod` over $`\mathcal{J}`$, `neutral` where the data has no row — the module size a build comes in whole numbers of; no value means the build is continuous | | $`\mathrm{N}^{z,\mathrm{fix}}`$ | `Process_modules_installed` over $`\Xi \times \mathcal{J}`$ — how many whole modules a committable build has in place: `Process_p_nom / Process_p_nom_mod` where a fixed build is modular, one where it is not, data prep. PyPSA refuses a fixed modular build whose nominal power is not a whole number of modules | | $`\mathrm{nonneg}^{z}`$ | `Process_p_min_pu_nonneg` over $`\mathcal{J}`$ — true where none of the process's own minimums-per-unit is negative — PyPSA's per-unit `(p_min_pu >= 0).all()` over every snapshot and scenario, data prep | | $`\mathrm{on}^{z}`$ | `Process_active` over $`\mathcal{T} \times \mathcal{J}`$ — whether a process stands in a snapshot's period — PyPSA's `active`, data prep | | $`\mathrm{W}^{z}`$ | `Process_capital_weight` over $`\mathcal{J}`$ — the sum of period weights a process stands in — PyPSA's `active * period_weighting`, summed, data prep | | $`\mathrm{new}^{z}`$ | `Process_first_active` over $`\mathcal{Y} \times \mathcal{J}`$ — one in the first period a process stands in, zero elsewhere, data prep. PyPSA takes `active.cumsum() == 1`, which also counts a process that has retired in every later period (`global_constraints.py:276`, PyPSA/PyPSA\#1938) | -| $`\mathrm{m}^{z}`$ | `Process_tech_capacity_weight` over $`\mathcal{G} \times \mathcal{J}`$ — one where the process is in the row's carrier-and-bus set — data prep; one outside it, or one that does not stand in the row's `investment_period`, has no row | +| $`\mathrm{m}^{z}`$ | `Process_tech_capacity_weight` over $`\mathcal{G} \times \mathcal{J}`$, `neutral` where the data has no row — one where the process is in the row's carrier-and-bus set — data prep; one outside it, or one that does not stand in the row's `investment_period`, has no row | #### Variables diff --git a/docs/examples/pypsa/process_commitment.md b/docs/examples/pypsa/process_commitment.md index cc9aab2c3..f16ebb159 100644 --- a/docs/examples/pypsa/process_commitment.md +++ b/docs/examples/pypsa/process_commitment.md @@ -312,7 +312,7 @@ constraints: | $`\mathrm{c}^{z,\mathrm{up}}`$ | `Process_start_up_cost` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — cost of one start in this snapshot | | $`\mathrm{c}^{z,\mathrm{dn}}`$ | `Process_shut_down_cost` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — cost of one stop in this snapshot | | $`\mathrm{c}^{z,\mathrm{on}}`$ | `Process_stand_by_cost` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — cost of one snapshot spent on | -| $`\mathrm{M}^{z}`$ | `Process_big_m` over $`\Xi \times \mathcal{J}`$ — the bound a committed extendable process's big-M rows release it by — the build cap `p_nom_max` times the highest `p_max_pu`, where the cap is finite and positive. Elsewhere it is `committable_big_m` times the highest `p_max_pu`, and where that keyword is not given, ten times the largest of the peak total load and the component's largest finite `p_nom` and `p_nom_max`, or 1e6 where there is none of them (`components.py:1050-1121`). Below the internal power a solve wants, it caps that internal power; data prep | +| $`\mathrm{M}^{z}`$ | `Process_big_m` over $`\Xi \times \mathcal{J}`$, `neutral` where the data has no row — the bound a committed extendable process's big-M rows release it by — the build cap `p_nom_max` times the highest `p_max_pu`, where the cap is finite and positive. Elsewhere it is `committable_big_m` times the highest `p_max_pu`, and where that keyword is not given, ten times the largest of the peak total load and the component's largest finite `p_nom` and `p_nom_max`, or 1e6 where there is none of them (`components.py:1050-1121`). Below the internal power a solve wants, it caps that internal power; data prep | #### Variables diff --git a/docs/examples/pypsa/process_maintenance.md b/docs/examples/pypsa/process_maintenance.md index 28d366c3a..a13354e35 100644 --- a/docs/examples/pypsa/process_maintenance.md +++ b/docs/examples/pypsa/process_maintenance.md @@ -247,7 +247,7 @@ assumptions: | $`\mathrm{mnt}^{z}`$ | `Process_maintainable` over $`\mathcal{J}`$ — whether a process must be taken off for maintenance within the horizon — in any scenario, as PyPSA takes the union over them (`components.py:1016-1019`) | | $`\gamma^{z}`$ | `Process_maintenance_pu` over $`\Xi \times \mathcal{J}`$ — the share of the build a maintenance event takes off | | $`\mathrm{n}^{z,\mathrm{mnt}}`$ | `Process_maintenance_events` over $`\Xi \times \mathcal{J}`$ — how many maintenance events the horizon holds | -| $`\tau^{z,\mathrm{mnt}}`$ | `Process_maintenance_duration` over $`\Xi \times \mathcal{J}`$ — the hours of generator weightings one maintenance event covers — PyPSA's `maintenance_duration`; no value where the process is not maintainable. No row reads it: data prep turns it into `Process_maintenance_cover` and `Process_maintenance_start_blocked`, and the assumptions hold it to the horizon | +| $`\tau^{z,\mathrm{mnt}}`$ | `Process_maintenance_duration` over $`\Xi \times \mathcal{J}`$, `neutral` where the data has no row — the hours of generator weightings one maintenance event covers — PyPSA's `maintenance_duration`; no value where the process is not maintainable. No row reads it: data prep turns it into `Process_maintenance_cover` and `Process_maintenance_start_blocked`, and the assumptions hold it to the horizon | | $`\mathrm{blk}^{z}`$ | `Process_maintenance_start_blocked` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — true where no maintenance event may start, because the snapshots it would cover run past the end of the horizon or into one the process does not stand in — PyPSA's `active & ~valid`, from `maintenance_duration` and the generator weightings, data prep | #### Variables diff --git a/docs/examples/pypsa/process_ramping.md b/docs/examples/pypsa/process_ramping.md index 4e22088af..0953c4cb2 100644 --- a/docs/examples/pypsa/process_ramping.md +++ b/docs/examples/pypsa/process_ramping.md @@ -264,11 +264,11 @@ assumptions: | Symbol | Meaning | |---|---| -| $`\mathrm{ru}^{z}`$ | `Process_ramp_limit_up` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — most a process may raise its internal power between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time | -| $`\mathrm{rd}^{z}`$ | `Process_ramp_limit_down` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — most a process may lower its internal power between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time | -| $`\mathrm{ru}^{z,\mathrm{up}}`$ | `Process_ramp_limit_start_up` over $`\Xi \times \mathcal{J}`$ — most internal power in the snapshot a process starts, per unit of nominal power; no value means no limit | -| $`\mathrm{rd}^{z,\mathrm{dn}}`$ | `Process_ramp_limit_shut_down` over $`\Xi \times \mathcal{J}`$ — most internal power in the snapshot before a process stops, per unit of nominal power; no value means no limit | -| $`\mathrm{z}^{0}`$ | `Process_p_init` over $`\Xi \times \mathcal{J}`$ — the internal power a process brought into the horizon — PyPSA's `p_init`, read only where the process came in running; no value means it is unknown, so the process carries no ramp row at the first snapshot | +| $`\mathrm{ru}^{z}`$ | `Process_ramp_limit_up` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$, `neutral` where the data has no row — most a process may raise its internal power between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time | +| $`\mathrm{rd}^{z}`$ | `Process_ramp_limit_down` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$, `neutral` where the data has no row — most a process may lower its internal power between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time | +| $`\mathrm{ru}^{z,\mathrm{up}}`$ | `Process_ramp_limit_start_up` over $`\Xi \times \mathcal{J}`$, `neutral` where the data has no row — most internal power in the snapshot a process starts, per unit of nominal power; no value means no limit | +| $`\mathrm{rd}^{z,\mathrm{dn}}`$ | `Process_ramp_limit_shut_down` over $`\Xi \times \mathcal{J}`$, `neutral` where the data has no row — most internal power in the snapshot before a process stops, per unit of nominal power; no value means no limit | +| $`\mathrm{z}^{0}`$ | `Process_p_init` over $`\Xi \times \mathcal{J}`$, `neutral` where the data has no row — the internal power a process brought into the horizon — PyPSA's `p_init`, read only where the process came in running; no value means it is unknown, so the process carries no ramp row at the first snapshot | #### Given diff --git a/docs/examples/pypsa/security.md b/docs/examples/pypsa/security.md index ac0719c7b..cad78d4a5 100644 --- a/docs/examples/pypsa/security.md +++ b/docs/examples/pypsa/security.md @@ -174,16 +174,16 @@ constraints: |---|---| | $`\Xi`$ | index $`\xi`$ — `scenario` — the futures dispatch is chosen in, each with a weight | | $`\mathcal{T}`$ | index $`t`$ — `snapshot` — dispatch periods | -| $`\mathcal{K}`$ | index $`k`$ — `line` with $`\mathrm{Outage\_line}: \mathcal{K}^{\mathrm{out}} \to \mathcal{K}`$ — passive branches, each between two buses, their flow set by impedance | -| $`\mathcal{M}`$ | index $`m`$ — `transformer` with $`\mathrm{Outage\_transformer}: \mathcal{K}^{\mathrm{out}} \to \mathcal{M}`$ — passive branches between two buses, their flow set by impedance and tap ratio, with a phase shift fixed or optimised | -| $`\mathcal{K}^{\mathrm{out}}`$ | index $`\kappa`$ — `outage` with $`\mathrm{Outage\_line}: \mathcal{K}^{\mathrm{out}} \to \mathcal{K},\ \mathrm{Outage\_transformer}: \mathcal{K}^{\mathrm{out}} \to \mathcal{M}`$ — the passive branches a security-constrained run takes out one at a time — PyPSA's `branch_outages`, each a line or a transformer; none on a plain run | +| $`\mathcal{K}`$ | index $`k`$ — `line` with $`\mathrm{Outage\_line}: \mathcal{K}^{\mathrm{out}} \to \mathcal{K}`$, `Outage_line` is `absent` where the data has no row — passive branches, each between two buses, their flow set by impedance | +| $`\mathcal{M}`$ | index $`m`$ — `transformer` with $`\mathrm{Outage\_transformer}: \mathcal{K}^{\mathrm{out}} \to \mathcal{M}`$, `Outage_transformer` is `absent` where the data has no row — passive branches between two buses, their flow set by impedance and tap ratio, with a phase shift fixed or optimised | +| $`\mathcal{K}^{\mathrm{out}}`$ | index $`\kappa`$ — `outage` with $`\mathrm{Outage\_line}: \mathcal{K}^{\mathrm{out}} \to \mathcal{K},\ \mathrm{Outage\_transformer}: \mathcal{K}^{\mathrm{out}} \to \mathcal{M}`$, `Outage_line` is `absent` where the data has no row, `Outage_transformer` is `absent` where the data has no row — the passive branches a security-constrained run takes out one at a time — PyPSA's `branch_outages`, each a line or a transformer; none on a plain run | #### Parameters | Symbol | Meaning | |---|---| -| $`\beta`$ | `Line_BODF` over $`\mathcal{K} \times \mathcal{K}^{\mathrm{out}}`$ — the share of an outaged branch's flow a line takes on when that branch goes out — PyPSA's `BODF`, from the sub-network's PTDF, data prep; a row only where the line and the outage share a sub-network, -1 at the outaged line itself | -| $`\beta^{\sigma}`$ | `Transformer_BODF` over $`\mathcal{M} \times \mathcal{K}^{\mathrm{out}}`$ — the share of an outaged branch's flow a transformer takes on when that branch goes out, as a line's; a row only where the transformer and the outage share a sub-network | +| $`\beta`$ | `Line_BODF` over $`\mathcal{K} \times \mathcal{K}^{\mathrm{out}}`$, `neutral` where the data has no row — the share of an outaged branch's flow a line takes on when that branch goes out — PyPSA's `BODF`, from the sub-network's PTDF, data prep; a row only where the line and the outage share a sub-network, -1 at the outaged line itself | +| $`\beta^{\sigma}`$ | `Transformer_BODF` over $`\mathcal{M} \times \mathcal{K}^{\mathrm{out}}`$, `neutral` where the data has no row — the share of an outaged branch's flow a transformer takes on when that branch goes out, as a line's; a row only where the transformer and the outage share a sub-network | #### Given diff --git a/docs/examples/pypsa/settings.md b/docs/examples/pypsa/settings.md index 4b1667745..e19c5f95f 100644 --- a/docs/examples/pypsa/settings.md +++ b/docs/examples/pypsa/settings.md @@ -181,7 +181,7 @@ objective: | $`\mathrm{w}^{\mathrm{sto}}`$ | `snapshot_weightings_stores` over $`\mathcal{T}`$ — PyPSA's `snapshot_weightings.stores` — hours a snapshot stands for in a storage balance | | $`\mathrm{w}^{\mathrm{gen}}`$ | `snapshot_weightings_generators` over $`\mathcal{T}`$ — PyPSA's `snapshot_weightings.generators` — hours a snapshot stands for in an energy total | | $`\mathrm{lossy}`$ | `transmission_losses` (scalar) — whether the network dissipates transmission losses — PyPSA's `transmission_losses` read as a flag; its mode, tangents or secants, only decides how data prep fills the `segment` axis, the rows are the same; false with no segments is a lossless run. A security-constrained run over a network with passive branches builds no loss: PyPSA does not hand the keyword to `create_model` (`abstract.py:437-441`) but to the solver (`:491`), so data prep feeds false there | -| $`\mathrm{in}`$ | `GlobalConstraint_counts_snapshot` over $`\Xi \times \mathcal{G} \times \mathcal{T}`$ — whether a row counts a snapshot in a scenario — PyPSA's `investment_period`: every snapshot where the row names none, and only that period's where it names one, data prep. A row that names a period the run does not model has no label here, as PyPSA skips it (`global_constraints.py:377`); PyPSA reads the column only under `multi_investment_periods`, and fails on a row that names a period without it (`global_constraints.py:375`) | +| $`\mathrm{in}`$ | `GlobalConstraint_counts_snapshot` over $`\Xi \times \mathcal{G} \times \mathcal{T}`$, `neutral` where the data has no row — whether a row counts a snapshot in a scenario — PyPSA's `investment_period`: every snapshot where the row names none, and only that period's where it names one, data prep. A row that names a period the run does not model has no label here, as PyPSA skips it (`global_constraints.py:377`); PyPSA reads the column only under `multi_investment_periods`, and fails on a row that names a period without it (`global_constraints.py:375`) | #### Given diff --git a/docs/examples/pypsa/storage_unit.md b/docs/examples/pypsa/storage_unit.md index 41b21dfa9..073dee023 100644 --- a/docs/examples/pypsa/storage_unit.md +++ b/docs/examples/pypsa/storage_unit.md @@ -525,10 +525,10 @@ assumptions: | $`\underline{\mathrm{h}}^{\mathrm{nom}}`$ | `StorageUnit_p_nom_min` over $`\Xi \times \mathcal{S}`$ — least nominal power an extendable storage unit may be built at | | $`\overline{\mathrm{h}}^{\mathrm{nom}}`$ | `StorageUnit_p_nom_max` over $`\Xi \times \mathcal{S}`$ — most nominal power an extendable storage unit may be built at | | $`\mathrm{c}^{\mathrm{cap},h}`$ | `StorageUnit_capital_cost` over $`\Xi \times \mathcal{S}`$ — cost of one unit of nominal power for the modelled horizon — PyPSA's `periodized_cost`: `overnight_cost` as an annuity over `lifetime` at `discount_rate`, times `nyears`, where it is given, and `capital_cost` where it is not, plus `fom_cost` (`components.py:1126-1147`, `costs.py:102-203`), data prep | -| $`\mathrm{h}^{\mathrm{nom,set}}`$ | `StorageUnit_p_nom_set` over $`\Xi \times \mathcal{S}`$ — a given nominal power for an extendable storage unit; one without a value has no row here | +| $`\mathrm{h}^{\mathrm{nom,set}}`$ | `StorageUnit_p_nom_set` over $`\Xi \times \mathcal{S}`$, `neutral` where the data has no row — a given nominal power for an extendable storage unit; one without a value has no row here | | $`\mathrm{h}^{\mathrm{nom}}`$ | `StorageUnit_p_nom` over $`\Xi \times \mathcal{S}`$ — nominal power | | $`\mathrm{ext}^{h}`$ | `StorageUnit_p_nom_extendable` over $`\mathcal{S}`$ — whether the nominal power is a decision | -| $`\mathrm{h}^{\mathrm{mod}}`$ | `StorageUnit_p_nom_mod` over $`\mathcal{S}`$ — the module size a build comes in whole numbers of; no value means the build is continuous | +| $`\mathrm{h}^{\mathrm{mod}}`$ | `StorageUnit_p_nom_mod` over $`\mathcal{S}`$, `neutral` where the data has no row — the module size a build comes in whole numbers of; no value means the build is continuous | | $`\underline{\mathrm{h}}`$ | `StorageUnit_p_min_pu` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — most storing, per unit of nominal power and negated | | $`\overline{\mathrm{h}}`$ | `StorageUnit_p_max_pu` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — most dispatch, per unit of nominal power | | $`\mathrm{T}^{h}`$ | `StorageUnit_max_hours` over $`\Xi \times \mathcal{S}`$ — energy capacity, as hours of dispatch at nominal power | @@ -547,13 +547,13 @@ assumptions: | $`\mathrm{c}^{h,(2)}`$ | `StorageUnit_marginal_cost_quadratic` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — cost of the square of one unit of dispatch; storing is not charged | | $`\mathrm{c}^{\mathrm{soc}}`$ | `StorageUnit_marginal_cost_storage` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — cost of one unit of charge held over one snapshot | | $`\mathrm{c}^{\mathrm{spill}}`$ | `StorageUnit_spill_cost` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — cost of one unit of inflow passed on unused | -| $`\mathrm{h}^{\mathrm{set}}`$ | `StorageUnit_p_set` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — a given net dispatch schedule; a unit without one has no row here | -| $`\mathrm{h}^{+,\mathrm{set}}`$ | `StorageUnit_p_dispatch_set` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — a given dispatch schedule; a unit without one has no row here | -| $`\mathrm{h}^{-,\mathrm{set}}`$ | `StorageUnit_p_store_set` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — a given charging schedule; a unit without one has no row here | -| $`\mathrm{soc}^{\mathrm{set}}`$ | `StorageUnit_state_of_charge_set` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — a given charge schedule; a unit without one has no row here | -| $`\mathrm{a}^{h}`$ | `StorageUnit_primary_energy_weight` over $`\Xi \times \mathcal{G} \times \mathcal{S}`$ — the constrained attribute per unit of charge depleted — data prep; an unweighted unit has no row | -| $`\mathrm{b}^{h}`$ | `StorageUnit_operational_limit_weight` over $`\Xi \times \mathcal{G} \times \mathcal{S}`$ — one where the storage unit is in the row's set — data prep; one outside it has no row | -| $`\mathrm{m}^{h}`$ | `StorageUnit_tech_capacity_weight` over $`\mathcal{G} \times \mathcal{S}`$ — one where the storage unit is in the row's carrier-and-bus set — data prep; one outside it, or one that does not stand in the row's `investment_period`, has no row | +| $`\mathrm{h}^{\mathrm{set}}`$ | `StorageUnit_p_set` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$, `neutral` where the data has no row — a given net dispatch schedule; a unit without one has no row here | +| $`\mathrm{h}^{+,\mathrm{set}}`$ | `StorageUnit_p_dispatch_set` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$, `neutral` where the data has no row — a given dispatch schedule; a unit without one has no row here | +| $`\mathrm{h}^{-,\mathrm{set}}`$ | `StorageUnit_p_store_set` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$, `neutral` where the data has no row — a given charging schedule; a unit without one has no row here | +| $`\mathrm{soc}^{\mathrm{set}}`$ | `StorageUnit_state_of_charge_set` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$, `neutral` where the data has no row — a given charge schedule; a unit without one has no row here | +| $`\mathrm{a}^{h}`$ | `StorageUnit_primary_energy_weight` over $`\Xi \times \mathcal{G} \times \mathcal{S}`$, `neutral` where the data has no row — the constrained attribute per unit of charge depleted — data prep; an unweighted unit has no row | +| $`\mathrm{b}^{h}`$ | `StorageUnit_operational_limit_weight` over $`\Xi \times \mathcal{G} \times \mathcal{S}`$, `neutral` where the data has no row — one where the storage unit is in the row's set — data prep; one outside it has no row | +| $`\mathrm{m}^{h}`$ | `StorageUnit_tech_capacity_weight` over $`\mathcal{G} \times \mathcal{S}`$, `neutral` where the data has no row — one where the storage unit is in the row's carrier-and-bus set — data prep; one outside it, or one that does not stand in the row's `investment_period`, has no row | #### Variables diff --git a/docs/examples/pypsa/store.md b/docs/examples/pypsa/store.md index 1f51f3094..02ea2701f 100644 --- a/docs/examples/pypsa/store.md +++ b/docs/examples/pypsa/store.md @@ -420,10 +420,10 @@ assumptions: | $`\underline{\mathrm{e}}^{\mathrm{nom}}`$ | `Store_e_nom_min` over $`\Xi \times \mathcal{V}`$ — least nominal capacity an extendable store may be built at | | $`\overline{\mathrm{e}}^{\mathrm{nom}}`$ | `Store_e_nom_max` over $`\Xi \times \mathcal{V}`$ — most nominal capacity an extendable store may be built at | | $`\mathrm{c}^{\mathrm{cap},e}`$ | `Store_capital_cost` over $`\Xi \times \mathcal{V}`$ — cost of one unit of nominal capacity for the modelled horizon — PyPSA's `periodized_cost`: `overnight_cost` as an annuity over `lifetime` at `discount_rate`, times `nyears`, where it is given, and `capital_cost` where it is not, plus `fom_cost` (`components.py:1126-1147`, `costs.py:102-203`), data prep | -| $`\mathrm{e}^{\mathrm{nom,set}}`$ | `Store_e_nom_set` over $`\Xi \times \mathcal{V}`$ — a given nominal capacity for an extendable store; one without a value has no row here | +| $`\mathrm{e}^{\mathrm{nom,set}}`$ | `Store_e_nom_set` over $`\Xi \times \mathcal{V}`$, `neutral` where the data has no row — a given nominal capacity for an extendable store; one without a value has no row here | | $`\mathrm{e}^{\mathrm{nom}}`$ | `Store_e_nom` over $`\Xi \times \mathcal{V}`$ — nominal energy capacity | | $`\mathrm{ext}^{e}`$ | `Store_e_nom_extendable` over $`\mathcal{V}`$ — whether the nominal energy capacity is a decision | -| $`\mathrm{e}^{\mathrm{mod}}`$ | `Store_e_nom_mod` over $`\mathcal{V}`$ — the module size a build comes in whole numbers of; no value means the build is continuous | +| $`\mathrm{e}^{\mathrm{mod}}`$ | `Store_e_nom_mod` over $`\mathcal{V}`$, `neutral` where the data has no row — the module size a build comes in whole numbers of; no value means the build is continuous | | $`\underline{\mathrm{e}}`$ | `Store_e_min_pu` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$ — least energy held, per unit of nominal capacity — negative for a store that may go short | | $`\overline{\mathrm{e}}`$ | `Store_e_max_pu` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$ — most energy held, per unit of nominal capacity | | $`\mathrm{sgn}^{q}`$ | `Store_sign` over $`\mathcal{V}`$ — the sign the power a store delivers enters its bus's balance with — PyPSA's `sign`, `1` unless given. PyPSA refuses one that differs by scenario (`consistency.py:1187`) | @@ -437,11 +437,11 @@ assumptions: | $`\mathrm{c}^{q}`$ | `Store_marginal_cost` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$ — cost of one unit of power delivered | | $`\mathrm{c}^{q,(2)}`$ | `Store_marginal_cost_quadratic` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$ — cost of the square of the net power delivered, so charging costs as much as delivering | | $`\mathrm{c}^{e}`$ | `Store_marginal_cost_storage` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$ — cost of one unit of energy held over one snapshot | -| $`\mathrm{e}^{\mathrm{set}}`$ | `Store_e_set` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$ — a given energy schedule; a store without one has no row here | -| $`\mathrm{q}^{\mathrm{set}}`$ | `Store_p_set` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$ — a given schedule of power delivered; a store without one has no row here | -| $`\mathrm{a}^{e}`$ | `Store_primary_energy_weight` over $`\Xi \times \mathcal{G} \times \mathcal{V}`$ — the constrained attribute per unit of energy depleted — data prep; an unweighted store has no row | -| $`\mathrm{b}^{e}`$ | `Store_operational_limit_weight` over $`\Xi \times \mathcal{G} \times \mathcal{V}`$ — one where the store is in the row's set — data prep; one outside it has no row | -| $`\mathrm{m}^{e}`$ | `Store_tech_capacity_weight` over $`\mathcal{G} \times \mathcal{V}`$ — one where the store is in the row's carrier-and-bus set — data prep; one outside it, or one that does not stand in the row's `investment_period`, has no row | +| $`\mathrm{e}^{\mathrm{set}}`$ | `Store_e_set` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$, `neutral` where the data has no row — a given energy schedule; a store without one has no row here | +| $`\mathrm{q}^{\mathrm{set}}`$ | `Store_p_set` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$, `neutral` where the data has no row — a given schedule of power delivered; a store without one has no row here | +| $`\mathrm{a}^{e}`$ | `Store_primary_energy_weight` over $`\Xi \times \mathcal{G} \times \mathcal{V}`$, `neutral` where the data has no row — the constrained attribute per unit of energy depleted — data prep; an unweighted store has no row | +| $`\mathrm{b}^{e}`$ | `Store_operational_limit_weight` over $`\Xi \times \mathcal{G} \times \mathcal{V}`$, `neutral` where the data has no row — one where the store is in the row's set — data prep; one outside it has no row | +| $`\mathrm{m}^{e}`$ | `Store_tech_capacity_weight` over $`\mathcal{G} \times \mathcal{V}`$, `neutral` where the data has no row — one where the store is in the row's carrier-and-bus set — data prep; one outside it, or one that does not stand in the row's `investment_period`, has no row | #### Variables diff --git a/docs/examples/pypsa/transformer.md b/docs/examples/pypsa/transformer.md index c391a14bf..e2679638f 100644 --- a/docs/examples/pypsa/transformer.md +++ b/docs/examples/pypsa/transformer.md @@ -371,17 +371,17 @@ constraints: | $`\mathrm{W}^{\sigma}`$ | `Transformer_capital_weight` over $`\mathcal{M}`$ — the sum of period weights a transformer stands in — PyPSA's `active * period_weighting`, summed, data prep | | $`\sigma^{\mathrm{nom}}`$ | `Transformer_s_nom` over $`\Xi \times \mathcal{M}`$ — nominal apparent power | | $`\mathrm{ext}^{\sigma}`$ | `Transformer_s_nom_extendable` over $`\mathcal{M}`$ — whether the nominal apparent power is a decision | -| $`\sigma^{\mathrm{mod}}`$ | `Transformer_s_nom_mod` over $`\mathcal{M}`$ — the module size a build comes in whole numbers of; no value means the build is continuous | +| $`\sigma^{\mathrm{mod}}`$ | `Transformer_s_nom_mod` over $`\mathcal{M}`$, `neutral` where the data has no row — the module size a build comes in whole numbers of; no value means the build is continuous | | $`\overline{\sigma}`$ | `Transformer_s_max_pu` over $`\Xi \times \mathcal{T} \times \mathcal{M}`$ — most flow either way, per unit of nominal apparent power | | $`\underline{\sigma}^{\mathrm{nom}}`$ | `Transformer_s_nom_min` over $`\Xi \times \mathcal{M}`$ — least nominal apparent power an extendable transformer may be built at | | $`\overline{\sigma}^{\mathrm{nom}}`$ | `Transformer_s_nom_max` over $`\Xi \times \mathcal{M}`$ — most nominal apparent power an extendable transformer may be built at | | $`\mathrm{c}^{\mathrm{cap},\sigma}`$ | `Transformer_capital_cost` over $`\Xi \times \mathcal{M}`$ — cost of one unit of nominal apparent power for the modelled horizon — PyPSA's `periodized_cost`: `overnight_cost` as an annuity over `lifetime` at `discount_rate`, times `nyears`, where it is given, and `capital_cost` where it is not, plus `fom_cost` (`components.py:1126-1147`, `costs.py:102-203`), data prep | -| $`\sigma^{\mathrm{nom,set}}`$ | `Transformer_s_nom_set` over $`\Xi \times \mathcal{M}`$ — a given nominal apparent power for an extendable transformer; one without a value has no row here | -| $`\sigma^{\mathrm{set}}`$ | `Transformer_s_set` over $`\Xi \times \mathcal{T} \times \mathcal{M}`$ — a given flow schedule; a transformer without one has no row here | -| $`\overline{\delta}^{\sigma}`$ | `Transformer_v_ang_max` over $`\Xi \times \mathcal{M}`$ — the most the voltage angle difference across a transformer, its phase shift included, may be either way, in degrees — PyPSA's `v_ang_max`; infinite, and so no row, by default. The deprecated `v_ang_min` is ignored, as a line's | +| $`\sigma^{\mathrm{nom,set}}`$ | `Transformer_s_nom_set` over $`\Xi \times \mathcal{M}`$, `neutral` where the data has no row — a given nominal apparent power for an extendable transformer; one without a value has no row here | +| $`\sigma^{\mathrm{set}}`$ | `Transformer_s_set` over $`\Xi \times \mathcal{T} \times \mathcal{M}`$, `neutral` where the data has no row — a given flow schedule; a transformer without one has no row here | +| $`\overline{\delta}^{\sigma}`$ | `Transformer_v_ang_max` over $`\Xi \times \mathcal{M}`$, `neutral` where the data has no row — the most the voltage angle difference across a transformer, its phase shift included, may be either way, in degrees — PyPSA's `v_ang_max`; infinite, and so no row, by default. The deprecated `v_ang_min` is ignored, as a line's | | $`\mathrm{x}^{\mathrm{eff},\sigma}`$ | `Transformer_x_pu_eff` over $`\Xi \times \mathcal{M}`$ — the transformer's effective series reactance — PyPSA's `x_pu_eff`, `x` over its `s_nom` times its tap ratio, data prep | -| $`\mathrm{x}^{\sigma}`$ | `Transformer_cycle_weight` over $`\mathcal{Y} \times \mathcal{M} \times \mathcal{C}`$ — the transformer's effective series reactance, `x` times its tap ratio, signed by its orientation in the cycle — PyPSA's `x_pu_eff`, the cycle basis, data prep; a transformer in no cycle has no row. From the first scenario only, as a line's | -| $`\vartheta`$ | `Transformer_phase_shift_weight` over $`\mathcal{T} \times \mathcal{M} \times \mathcal{C}`$ — a fixed transformer's phase shift in radians at each snapshot, signed by its orientation in the cycle — a constant added to the cycle sum, data prep; zero for a varying transformer, whose shift is a decision instead, so the constant and the variable term never both count a shift. A transformer with no shift or in no cycle of its snapshot's period has no row | +| $`\mathrm{x}^{\sigma}`$ | `Transformer_cycle_weight` over $`\mathcal{Y} \times \mathcal{M} \times \mathcal{C}`$, `neutral` where the data has no row — the transformer's effective series reactance, `x` times its tap ratio, signed by its orientation in the cycle — PyPSA's `x_pu_eff`, the cycle basis, data prep; a transformer in no cycle has no row. From the first scenario only, as a line's | +| $`\vartheta`$ | `Transformer_phase_shift_weight` over $`\mathcal{T} \times \mathcal{M} \times \mathcal{C}`$, `neutral` where the data has no row — a fixed transformer's phase shift in radians at each snapshot, signed by its orientation in the cycle — a constant added to the cycle sum, data prep; zero for a varying transformer, whose shift is a decision instead, so the constant and the variable term never both count a shift. A transformer with no shift or in no cycle of its snapshot's period has no row | | $`\mathrm{Transformer\_phase\_shift\_varying}`$ | `Transformer_phase_shift_varying` over $`\mathcal{M}`$ — whether a transformer's phase shift is a decision — PyPSA's `phase_shift_min < phase_shift_max`, read as a flag in data prep; false is a fixed shift carried by `phase_shift`. The shift parameters carry no scenario: only a cycle row and an angle row read them, PyPSA fails on a transformer in a cycle on a network with scenarios (`constraints.py:1660`), and builds no angle row on one | | $`\mathrm{Transformer\_phase\_shift\_min}`$ | `Transformer_phase_shift_min` over $`\mathcal{M}`$ — the least a varying transformer's phase shift may take, in degrees — PyPSA's `phase_shift_min`; where it is below `phase_shift_max` the shift is a decision, otherwise the transformer keeps its fixed `phase_shift` | | $`\mathrm{Transformer\_phase\_shift\_max}`$ | `Transformer_phase_shift_max` over $`\mathcal{M}`$ — the most a varying transformer's phase shift may take, in degrees — PyPSA's `phase_shift_max`; equal to `phase_shift_min` for a fixed transformer | diff --git a/docs/examples/pypsa_linearized_uc.md b/docs/examples/pypsa_linearized_uc.md index 63d05de60..612568ab8 100644 --- a/docs/examples/pypsa_linearized_uc.md +++ b/docs/examples/pypsa_linearized_uc.md @@ -352,14 +352,14 @@ The relaxed class of a plain `n.optimize()`: `linearized_unit_commitment`, state | $`\mathrm{load}`$ | `Load_p_set` over $`\mathcal{T} \times \mathcal{D}`$ — demand | | $`\mathrm{sgn}^{\mathrm{load}}`$ | `Load_sign` over $`\mathcal{D}`$ — the sign a load's demand enters its bus's balance with — PyPSA's `sign`, `-1` unless given, `1` for a load that feeds its bus | | $`\mathrm{com}`$ | `Generator_committable` over $`\mathcal{G}`$ — whether output is gated by an on/off status decision | -| $`\mathrm{ru}`$ | `Generator_ramp_limit_up` over $`\mathcal{T} \times \mathcal{G}`$ — most a generator may raise its output between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time | -| $`\mathrm{rd}`$ | `Generator_ramp_limit_down` over $`\mathcal{T} \times \mathcal{G}`$ — most a generator may lower its output between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time | -| $`\mathrm{ru}^{\mathrm{up}}`$ | `Generator_ramp_limit_start_up` over $`\mathcal{G}`$ — most output in the snapshot a unit starts, per unit of nominal power; no value means no limit | -| $`\mathrm{rd}^{\mathrm{dn}}`$ | `Generator_ramp_limit_shut_down` over $`\mathcal{G}`$ — most output in the snapshot before a unit stops, per unit of nominal power; no value means no limit | +| $`\mathrm{ru}`$ | `Generator_ramp_limit_up` over $`\mathcal{T} \times \mathcal{G}`$, `neutral` where the data has no row — most a generator may raise its output between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time | +| $`\mathrm{rd}`$ | `Generator_ramp_limit_down` over $`\mathcal{T} \times \mathcal{G}`$, `neutral` where the data has no row — most a generator may lower its output between snapshots, per unit of nominal power; no value means no limit — read at the later of the two snapshots, so the limit may change over time | +| $`\mathrm{ru}^{\mathrm{up}}`$ | `Generator_ramp_limit_start_up` over $`\mathcal{G}`$, `neutral` where the data has no row — most output in the snapshot a unit starts, per unit of nominal power; no value means no limit | +| $`\mathrm{rd}^{\mathrm{dn}}`$ | `Generator_ramp_limit_shut_down` over $`\mathcal{G}`$, `neutral` where the data has no row — most output in the snapshot before a unit stops, per unit of nominal power; no value means no limit | | $`\mathrm{UT}`$ | `Generator_min_up_time` over $`\mathcal{G}`$ — least snapshots a unit stays on once started | | $`\mathrm{DT}`$ | `Generator_min_down_time` over $`\mathcal{G}`$ — least snapshots a unit stays off once stopped | | $`\mathrm{u}^{0}`$ | `Generator_status_initial` over $`\mathcal{G}`$ — one where the unit was on before the first snapshot, zero where off — PyPSA's `up_time_before > 0`, data prep | -| $`\mathrm{p}^{0}`$ | `Generator_p_init` over $`\mathcal{G}`$ — the output a unit brought into the horizon — PyPSA's `p_init`, read only where the unit came in running; no value means it is unknown, so the unit carries no ramp row at the first snapshot | +| $`\mathrm{p}^{0}`$ | `Generator_p_init` over $`\mathcal{G}`$, `neutral` where the data has no row — the output a unit brought into the horizon — PyPSA's `p_init`, read only where the unit came in running; no value means it is unknown, so the unit carries no ramp row at the first snapshot | | $`\mathrm{hold}`$ | `Generator_must_stay_up` over $`\mathcal{T} \times \mathcal{G}`$ — true while the up time a unit brought into the horizon still binds — data prep, since `position()` compares against a literal rather than a parameter | | $`\mathrm{rest}`$ | `Generator_must_stay_down` over $`\mathcal{T} \times \mathcal{G}`$ — true while the down time a unit brought into the horizon still binds — PyPSA's `min_down_time - down_time_before` snapshots, where `down_time_before > 0`, data prep for the same reason | | $`\mathrm{c}^{\mathrm{up}}`$ | `Generator_start_up_cost` over $`\mathcal{T} \times \mathcal{G}`$ — cost of one start in this snapshot | @@ -368,7 +368,7 @@ The relaxed class of a plain `n.optimize()`: `linearized_unit_commitment`, state | $`\mathrm{mnt}`$ | `Generator_maintainable` over $`\mathcal{G}`$ — whether a generator must be taken off for maintenance within the horizon | | $`\gamma`$ | `Generator_maintenance_pu` over $`\mathcal{G}`$ — the share of the build a maintenance event takes off | | $`\mathrm{n}^{\mathrm{mnt}}`$ | `Generator_maintenance_events` over $`\mathcal{G}`$ — how many maintenance events the horizon holds | -| $`\tau^{\mathrm{mnt}}`$ | `Generator_maintenance_duration` over $`\mathcal{G}`$ — the hours of generator weightings one maintenance event covers — PyPSA's `maintenance_duration`; no value where the generator is not maintainable. No row reads it: data prep turns it into `Generator_maintenance_cover` and `Generator_maintenance_start_blocked`, and the assumptions hold it to the horizon | +| $`\tau^{\mathrm{mnt}}`$ | `Generator_maintenance_duration` over $`\mathcal{G}`$, `neutral` where the data has no row — the hours of generator weightings one maintenance event covers — PyPSA's `maintenance_duration`; no value where the generator is not maintainable. No row reads it: data prep turns it into `Generator_maintenance_cover` and `Generator_maintenance_start_blocked`, and the assumptions hold it to the horizon | | $`\mathrm{blk}`$ | `Generator_maintenance_start_blocked` over $`\mathcal{T} \times \mathcal{G}`$ — true where no maintenance event may start, because the snapshots it would cover run past the end of the horizon or into one the generator does not stand in — PyPSA's `active & ~valid`, from `maintenance_duration` and the generator weightings, data prep | #### Variables diff --git a/docs/reference/language/declarations.md b/docs/reference/language/declarations.md index 31f320d9e..353202b48 100644 --- a/docs/reference/language/declarations.md +++ b/docs/reference/language/declarations.md @@ -96,8 +96,10 @@ A NaN, a quoted number and `missing: null` are refused. A `given:` parameter has no `missing:`. The file that declares the parameter owns it. A parameter a [`piecewise:`](piecewise.md) block reads takes no `missing:` either: the block owns the shape of its curve. Such a parameter is `neutral` where only curves -with `points:` read it, and `refused` otherwise, in the curve and outside it. The typeset legend prints a value beside the -parameter. +with `points:` read it, and `refused` otherwise, in the curve and outside it. + +The typeset legend prints what a missing row means beside the parameter, such +as `` `neutral` where the data has no row ``. It prints nothing for `refused`. ## `variables` diff --git a/docs/reference/language/relations.md b/docs/reference/language/relations.md index 5d0af41c8..fe21ff740 100644 --- a/docs/reference/language/relations.md +++ b/docs/reference/language/relations.md @@ -88,7 +88,9 @@ relations: A label is data, so no value fills a gap in a map, and `neutral` is refused. A bare relation takes no `missing:`: its rows are its membership, so a pair it -leaves out is not missing. +leaves out is not missing. The typeset legend prints +`` `line_to` is `absent` where the data has no row `` beside each set the map +joins. It prints nothing for `refused`. ## How a relation is used diff --git a/docs/reference/notation.md b/docs/reference/notation.md index b9b22d4f5..c9e296146 100644 --- a/docs/reference/notation.md +++ b/docs/reference/notation.md @@ -39,7 +39,7 @@ dimensions: relations: gen_bus: { key: generator, values: bus } zone_of: { key: bus, values: zone } - area_of: { key: bus, values: zone } # a second map into the same set, to compare against + area_of: { key: bus, values: zone, missing: absent } # a second map into the same set, to compare against; a bus may be in no area season_of: { key: snapshot, values: season } gen_zone: { key: [generator, snapshot], values: zone } # a map keyed by two dimensions: a call consumes one and joins on the other rep_of: { key: snapshot, values: { rep: snapshot } } # a map into its own dimension: the representative snapshot @@ -49,11 +49,11 @@ relations: parameters: p_max: { dims: [generator], missing: .inf } # a value: the legend says what a missing row reads as p_min: { dims: [generator] } - cost: { dims: [generator] } + cost: { dims: [generator], missing: neutral } # a reading: the legend names it load: { dims: [snapshot, bus] } is_flexible: { dims: [generator], dtype: bool, missing: false } zone_cap: { dims: [zone] } - tech_cap: { dims: [bus, technology] } + tech_cap: { dims: [bus, technology], missing: absent } min_up: { dims: [generator], dtype: int } eta: { dims: [generator] } # a Greek name that is *given*, so the rule wins and it prints as the word lead: { dims: [generator], dtype: int } @@ -71,8 +71,8 @@ parameters: |---|---| | $`\mathcal{T}`$ | index $`t`$ — `snapshot` (`int` coordinates) with $`\mathrm{season\_of}: \mathcal{T} \to \mathcal{S},\ \mathrm{gen\_zone}: \mathcal{G} \times \mathcal{T} \to \mathcal{Z},\ \mathrm{rep\_of}: \mathcal{T} \to \mathcal{T}`$ | | $`\mathcal{G}`$ | index $`g`$ — `generator` with $`\mathrm{gen\_bus}: \mathcal{G} \to \mathcal{B},\ \mathrm{gen\_zone}: \mathcal{G} \times \mathcal{T} \to \mathcal{Z},\ \mathrm{connection} \subseteq \mathcal{G} \times \mathcal{B},\ \mathrm{gen\_bt}: \mathcal{G} \to \mathcal{B} \times \mathcal{E}`$ | -| $`\mathcal{B}`$ | index $`b`$ — `bus` with $`\mathrm{gen\_bus}: \mathcal{G} \to \mathcal{B},\ \mathrm{zone\_of}: \mathcal{B} \to \mathcal{Z},\ \mathrm{area\_of}: \mathcal{B} \to \mathcal{Z},\ \mathrm{connection} \subseteq \mathcal{G} \times \mathcal{B},\ \mathrm{gen\_bt}: \mathcal{G} \to \mathcal{B} \times \mathcal{E}`$ | -| $`\mathcal{Z}`$ | index $`z`$ — `zone` with $`\mathrm{zone\_of}: \mathcal{B} \to \mathcal{Z},\ \mathrm{area\_of}: \mathcal{B} \to \mathcal{Z},\ \mathrm{gen\_zone}: \mathcal{G} \times \mathcal{T} \to \mathcal{Z}`$ | +| $`\mathcal{B}`$ | index $`b`$ — `bus` with $`\mathrm{gen\_bus}: \mathcal{G} \to \mathcal{B},\ \mathrm{zone\_of}: \mathcal{B} \to \mathcal{Z},\ \mathrm{area\_of}: \mathcal{B} \to \mathcal{Z},\ \mathrm{connection} \subseteq \mathcal{G} \times \mathcal{B},\ \mathrm{gen\_bt}: \mathcal{G} \to \mathcal{B} \times \mathcal{E}`$, `area_of` is `absent` where the data has no row | +| $`\mathcal{Z}`$ | index $`z`$ — `zone` with $`\mathrm{zone\_of}: \mathcal{B} \to \mathcal{Z},\ \mathrm{area\_of}: \mathcal{B} \to \mathcal{Z},\ \mathrm{gen\_zone}: \mathcal{G} \times \mathcal{T} \to \mathcal{Z}`$, `area_of` is `absent` where the data has no row | | $`\mathcal{S}`$ | index $`s`$ — `season` with $`\mathrm{season\_of}: \mathcal{T} \to \mathcal{S}`$ | | $`\mathcal{E}`$ | index $`e`$ — `technology` with $`\mathrm{gen\_bt}: \mathcal{G} \to \mathcal{B} \times \mathcal{E}`$ | | $`\mathcal{A}`$ | index $`a`$ — `bp` | @@ -83,11 +83,11 @@ parameters: |---|---| | $`\mathrm{p}^{\mathrm{max}}`$ | `p_max` over $`\mathcal{G}`$, $`\infty`$ where the data has no row | | $`\mathrm{p}^{\mathrm{min}}`$ | `p_min` over $`\mathcal{G}`$ | -| $`\mathrm{cost}`$ | `cost` over $`\mathcal{G}`$ | +| $`\mathrm{cost}`$ | `cost` over $`\mathcal{G}`$, `neutral` where the data has no row | | $`\mathrm{load}`$ | `load` over $`\mathcal{T} \times \mathcal{B}`$ | | $`\mathrm{is\_flexible}`$ | `is_flexible` over $`\mathcal{G}`$, `false` where the data has no row | | $`\mathrm{zone\_cap}`$ | `zone_cap` over $`\mathcal{Z}`$ | -| $`\mathrm{tech\_cap}`$ | `tech_cap` over $`\mathcal{B} \times \mathcal{E}`$ | +| $`\mathrm{tech\_cap}`$ | `tech_cap` over $`\mathcal{B} \times \mathcal{E}`$, `absent` where the data has no row | | $`\mathrm{min\_up}`$ | `min_up` over $`\mathcal{G}`$ | | $`\mathrm{eta}`$ | `eta` over $`\mathcal{G}`$ | | $`\mathrm{lead}`$ | `lead` over $`\mathcal{G}`$ | @@ -96,7 +96,7 @@ parameters: | $`\mathrm{bp\_x}`$ | `bp_x` over $`\mathcal{G} \times \mathcal{A}`$ | | $`\mathrm{bp\_y}`$ | `bp_y` over $`\mathcal{G} \times \mathcal{A}`$ | | $`\mathrm{bp\_heat}`$ | `bp_heat` over $`\mathcal{G} \times \mathcal{A}`$ | -| $`\mathrm{bp\_run}`$ | `bp_run` over $`\mathcal{G} \times \mathcal{A}`$ | +| $`\mathrm{bp\_run}`$ | `bp_run` over $`\mathcal{G} \times \mathcal{A}`$, `neutral` where the data has no row | #### Variables diff --git a/src/mathspec/typesetting/legend.py b/src/mathspec/typesetting/legend.py index b8b8d2740..6e9c214d7 100644 --- a/src/mathspec/typesetting/legend.py +++ b/src/mathspec/typesetting/legend.py @@ -150,7 +150,7 @@ def glossaries(self, noticed: Noticed, defined: Iterable[str]) -> list[tuple[str parameters = [ self._entry( self.symbols.name[p], - f'{fmt.mono(p)}{self._over(list(block.dims))}{self._default(block.missing)}', + f'{fmt.mono(p)}{self._over(list(block.dims))}{self._missing(block.missing)}', block.description, ) for p, block in program.parameters.items() @@ -219,15 +219,14 @@ def _over(self, dims: list[str]) -> str: product = self.format.joined([self.symbols.set[d] for d in dims], self._op('times')) return f' over {self.format.math(product)}' - def _default(self, value: Missing | None) -> str: - """The value a missing row reads as, where ``missing:`` names one; nothing for a reading.""" - if value is None or isinstance(value, str): + def _missing(self, value: Missing | None) -> str: + """What a missing row means: the reading or the value ``missing:`` names, and nothing for ``refused``, the default.""" + if value is None or value == 'refused': return '' - shown = ( - self.format.mono(str(value).lower()) - if isinstance(value, bool) - else self.format.math(number(value, self.format)) - ) + if isinstance(value, str | bool): + shown = self.format.mono(str(value).lower()) + else: + shown = self.format.math(number(value, self.format)) return f', {shown} where the data has no row' def _signature(self, name: str, lk: RelationDeclaration) -> str: @@ -241,7 +240,7 @@ def product(roles: Iterable[str]) -> str: return f'{self.format.upright(name)} {self._op("subset_of")} {product(lk.roles)}' def _coords(self, dim: str, noticed: Noticed) -> str: - """The dimension's carried structure: each relation with a column over it, as the map or relation it is. + """The dimension's carried structure: each relation with a column over it, as the map or relation it is, and each map that may leave a key out. The dtype is named only where an equation compared the index against a number, the one place "position 3" and "the coordinate 3" are both @@ -254,6 +253,11 @@ def _coords(self, dim: str, noticed: Noticed) -> str: if carried: maps = self.format.joined([self._signature(c, lk) for c, lk in carried.items()], '') clauses.append(f' with {self.format.math(maps)}') + clauses.extend( + f', {self.format.mono(c)} is {self.format.mono("absent")} where the data has no row' + for c, lk in carried.items() + if lk.missing == 'absent' + ) return ''.join(clauses) def convention_notes(self) -> list[str]: diff --git a/tests/typesetting/golden/latex.out b/tests/typesetting/golden/latex.out index 2547e8676..1b601d7f1 100644 --- a/tests/typesetting/golden/latex.out +++ b/tests/typesetting/golden/latex.out @@ -11,8 +11,8 @@ \begin{description} \item[{$\mathcal{T}$}] index $t$ --- \texttt{snapshot} (\texttt{int} coordinates) with $\mathrm{season\_of}: \mathcal{T} \to \mathcal{S},\ \mathrm{gen\_zone}: \mathcal{G} \times \mathcal{T} \to \mathcal{Z},\ \mathrm{rep\_of}: \mathcal{T} \to \mathcal{T}$ \item[{$\mathcal{G}$}] index $g$ --- \texttt{generator} with $\mathrm{gen\_bus}: \mathcal{G} \to \mathcal{B},\ \mathrm{gen\_zone}: \mathcal{G} \times \mathcal{T} \to \mathcal{Z},\ \mathrm{connection} \subseteq \mathcal{G} \times \mathcal{B},\ \mathrm{gen\_bt}: \mathcal{G} \to \mathcal{B} \times \mathcal{E}$ -\item[{$\mathcal{B}$}] index $b$ --- \texttt{bus} with $\mathrm{gen\_bus}: \mathcal{G} \to \mathcal{B},\ \mathrm{zone\_of}: \mathcal{B} \to \mathcal{Z},\ \mathrm{area\_of}: \mathcal{B} \to \mathcal{Z},\ \mathrm{connection} \subseteq \mathcal{G} \times \mathcal{B},\ \mathrm{gen\_bt}: \mathcal{G} \to \mathcal{B} \times \mathcal{E}$ -\item[{$\mathcal{Z}$}] index $z$ --- \texttt{zone} with $\mathrm{zone\_of}: \mathcal{B} \to \mathcal{Z},\ \mathrm{area\_of}: \mathcal{B} \to \mathcal{Z},\ \mathrm{gen\_zone}: \mathcal{G} \times \mathcal{T} \to \mathcal{Z}$ +\item[{$\mathcal{B}$}] index $b$ --- \texttt{bus} with $\mathrm{gen\_bus}: \mathcal{G} \to \mathcal{B},\ \mathrm{zone\_of}: \mathcal{B} \to \mathcal{Z},\ \mathrm{area\_of}: \mathcal{B} \to \mathcal{Z},\ \mathrm{connection} \subseteq \mathcal{G} \times \mathcal{B},\ \mathrm{gen\_bt}: \mathcal{G} \to \mathcal{B} \times \mathcal{E}$, \texttt{area\_of} is \texttt{absent} where the data has no row +\item[{$\mathcal{Z}$}] index $z$ --- \texttt{zone} with $\mathrm{zone\_of}: \mathcal{B} \to \mathcal{Z},\ \mathrm{area\_of}: \mathcal{B} \to \mathcal{Z},\ \mathrm{gen\_zone}: \mathcal{G} \times \mathcal{T} \to \mathcal{Z}$, \texttt{area\_of} is \texttt{absent} where the data has no row \item[{$\mathcal{S}$}] index $s$ --- \texttt{season} with $\mathrm{season\_of}: \mathcal{T} \to \mathcal{S}$ \item[{$\mathcal{E}$}] index $e$ --- \texttt{technology} with $\mathrm{gen\_bt}: \mathcal{G} \to \mathcal{B} \times \mathcal{E}$ \item[{$\mathcal{A}$}] index $a$ --- \texttt{bp} @@ -22,11 +22,11 @@ \begin{description} \item[{$\mathrm{p}^{\mathrm{max}}$}] \texttt{p\_max} over $\mathcal{G}$, $\infty$ where the data has no row \item[{$\mathrm{p}^{\mathrm{min}}$}] \texttt{p\_min} over $\mathcal{G}$ -\item[{$\mathrm{cost}$}] \texttt{cost} over $\mathcal{G}$ +\item[{$\mathrm{cost}$}] \texttt{cost} over $\mathcal{G}$, \texttt{neutral} where the data has no row \item[{$\mathrm{load}$}] \texttt{load} over $\mathcal{T} \times \mathcal{B}$ \item[{$\mathrm{is\_flexible}$}] \texttt{is\_flexible} over $\mathcal{G}$, \texttt{false} where the data has no row \item[{$\mathrm{zone\_cap}$}] \texttt{zone\_cap} over $\mathcal{Z}$ -\item[{$\mathrm{tech\_cap}$}] \texttt{tech\_cap} over $\mathcal{B} \times \mathcal{E}$ +\item[{$\mathrm{tech\_cap}$}] \texttt{tech\_cap} over $\mathcal{B} \times \mathcal{E}$, \texttt{absent} where the data has no row \item[{$\mathrm{min\_up}$}] \texttt{min\_up} over $\mathcal{G}$ \item[{$\mathrm{eta}$}] \texttt{eta} over $\mathcal{G}$ \item[{$\mathrm{lead}$}] \texttt{lead} over $\mathcal{G}$ @@ -35,7 +35,7 @@ \item[{$\mathrm{bp\_x}$}] \texttt{bp\_x} over $\mathcal{G} \times \mathcal{A}$ \item[{$\mathrm{bp\_y}$}] \texttt{bp\_y} over $\mathcal{G} \times \mathcal{A}$ \item[{$\mathrm{bp\_heat}$}] \texttt{bp\_heat} over $\mathcal{G} \times \mathcal{A}$ -\item[{$\mathrm{bp\_run}$}] \texttt{bp\_run} over $\mathcal{G} \times \mathcal{A}$ +\item[{$\mathrm{bp\_run}$}] \texttt{bp\_run} over $\mathcal{G} \times \mathcal{A}$, \texttt{neutral} where the data has no row \end{description} \paragraph{Variables} diff --git a/tests/typesetting/golden/markdown.out b/tests/typesetting/golden/markdown.out index 9fe822eab..aee45e136 100644 --- a/tests/typesetting/golden/markdown.out +++ b/tests/typesetting/golden/markdown.out @@ -8,8 +8,8 @@ every character a notation escapes, set as text: link\_to, 100% & \#1 costs \$5 |---|---| | $`\mathcal{T}`$ | index $`t`$ — `snapshot` (`int` coordinates) with $`\mathrm{season\_of}: \mathcal{T} \to \mathcal{S},\ \mathrm{gen\_zone}: \mathcal{G} \times \mathcal{T} \to \mathcal{Z},\ \mathrm{rep\_of}: \mathcal{T} \to \mathcal{T}`$ | | $`\mathcal{G}`$ | index $`g`$ — `generator` with $`\mathrm{gen\_bus}: \mathcal{G} \to \mathcal{B},\ \mathrm{gen\_zone}: \mathcal{G} \times \mathcal{T} \to \mathcal{Z},\ \mathrm{connection} \subseteq \mathcal{G} \times \mathcal{B},\ \mathrm{gen\_bt}: \mathcal{G} \to \mathcal{B} \times \mathcal{E}`$ | -| $`\mathcal{B}`$ | index $`b`$ — `bus` with $`\mathrm{gen\_bus}: \mathcal{G} \to \mathcal{B},\ \mathrm{zone\_of}: \mathcal{B} \to \mathcal{Z},\ \mathrm{area\_of}: \mathcal{B} \to \mathcal{Z},\ \mathrm{connection} \subseteq \mathcal{G} \times \mathcal{B},\ \mathrm{gen\_bt}: \mathcal{G} \to \mathcal{B} \times \mathcal{E}`$ | -| $`\mathcal{Z}`$ | index $`z`$ — `zone` with $`\mathrm{zone\_of}: \mathcal{B} \to \mathcal{Z},\ \mathrm{area\_of}: \mathcal{B} \to \mathcal{Z},\ \mathrm{gen\_zone}: \mathcal{G} \times \mathcal{T} \to \mathcal{Z}`$ | +| $`\mathcal{B}`$ | index $`b`$ — `bus` with $`\mathrm{gen\_bus}: \mathcal{G} \to \mathcal{B},\ \mathrm{zone\_of}: \mathcal{B} \to \mathcal{Z},\ \mathrm{area\_of}: \mathcal{B} \to \mathcal{Z},\ \mathrm{connection} \subseteq \mathcal{G} \times \mathcal{B},\ \mathrm{gen\_bt}: \mathcal{G} \to \mathcal{B} \times \mathcal{E}`$, `area_of` is `absent` where the data has no row | +| $`\mathcal{Z}`$ | index $`z`$ — `zone` with $`\mathrm{zone\_of}: \mathcal{B} \to \mathcal{Z},\ \mathrm{area\_of}: \mathcal{B} \to \mathcal{Z},\ \mathrm{gen\_zone}: \mathcal{G} \times \mathcal{T} \to \mathcal{Z}`$, `area_of` is `absent` where the data has no row | | $`\mathcal{S}`$ | index $`s`$ — `season` with $`\mathrm{season\_of}: \mathcal{T} \to \mathcal{S}`$ | | $`\mathcal{E}`$ | index $`e`$ — `technology` with $`\mathrm{gen\_bt}: \mathcal{G} \to \mathcal{B} \times \mathcal{E}`$ | | $`\mathcal{A}`$ | index $`a`$ — `bp` | @@ -20,11 +20,11 @@ every character a notation escapes, set as text: link\_to, 100% & \#1 costs \$5 |---|---| | $`\mathrm{p}^{\mathrm{max}}`$ | `p_max` over $`\mathcal{G}`$, $`\infty`$ where the data has no row | | $`\mathrm{p}^{\mathrm{min}}`$ | `p_min` over $`\mathcal{G}`$ | -| $`\mathrm{cost}`$ | `cost` over $`\mathcal{G}`$ | +| $`\mathrm{cost}`$ | `cost` over $`\mathcal{G}`$, `neutral` where the data has no row | | $`\mathrm{load}`$ | `load` over $`\mathcal{T} \times \mathcal{B}`$ | | $`\mathrm{is\_flexible}`$ | `is_flexible` over $`\mathcal{G}`$, `false` where the data has no row | | $`\mathrm{zone\_cap}`$ | `zone_cap` over $`\mathcal{Z}`$ | -| $`\mathrm{tech\_cap}`$ | `tech_cap` over $`\mathcal{B} \times \mathcal{E}`$ | +| $`\mathrm{tech\_cap}`$ | `tech_cap` over $`\mathcal{B} \times \mathcal{E}`$, `absent` where the data has no row | | $`\mathrm{min\_up}`$ | `min_up` over $`\mathcal{G}`$ | | $`\mathrm{eta}`$ | `eta` over $`\mathcal{G}`$ | | $`\mathrm{lead}`$ | `lead` over $`\mathcal{G}`$ | @@ -33,7 +33,7 @@ every character a notation escapes, set as text: link\_to, 100% & \#1 costs \$5 | $`\mathrm{bp\_x}`$ | `bp_x` over $`\mathcal{G} \times \mathcal{A}`$ | | $`\mathrm{bp\_y}`$ | `bp_y` over $`\mathcal{G} \times \mathcal{A}`$ | | $`\mathrm{bp\_heat}`$ | `bp_heat` over $`\mathcal{G} \times \mathcal{A}`$ | -| $`\mathrm{bp\_run}`$ | `bp_run` over $`\mathcal{G} \times \mathcal{A}`$ | +| $`\mathrm{bp\_run}`$ | `bp_run` over $`\mathcal{G} \times \mathcal{A}`$, `neutral` where the data has no row | #### Variables diff --git a/tests/typesetting/golden/model.yaml b/tests/typesetting/golden/model.yaml index c7bb9e305..851a06848 100644 --- a/tests/typesetting/golden/model.yaml +++ b/tests/typesetting/golden/model.yaml @@ -24,7 +24,7 @@ dimensions: relations: gen_bus: { key: generator, values: bus } zone_of: { key: bus, values: zone } - area_of: { key: bus, values: zone } # a second map into the same set, to compare against + area_of: { key: bus, values: zone, missing: absent } # a second map into the same set, to compare against; a bus may be in no area season_of: { key: snapshot, values: season } gen_zone: { key: [generator, snapshot], values: zone } # a map keyed by two dimensions: a call consumes one and joins on the other rep_of: { key: snapshot, values: { rep: snapshot } } # a map into its own dimension: the representative snapshot @@ -34,11 +34,11 @@ relations: parameters: p_max: { dims: [generator], missing: .inf } # a value: the legend says what a missing row reads as p_min: { dims: [generator] } - cost: { dims: [generator] } + cost: { dims: [generator], missing: neutral } # a reading: the legend names it load: { dims: [snapshot, bus] } is_flexible: { dims: [generator], dtype: bool, missing: false } zone_cap: { dims: [zone] } - tech_cap: { dims: [bus, technology] } + tech_cap: { dims: [bus, technology], missing: absent } min_up: { dims: [generator], dtype: int } eta: { dims: [generator] } # a Greek name that is *given*, so the rule wins and it prints as the word lead: { dims: [generator], dtype: int } diff --git a/tests/typesetting/golden/typst.out b/tests/typesetting/golden/typst.out index 8285f0368..7e2046e6e 100644 --- a/tests/typesetting/golden/typst.out +++ b/tests/typesetting/golden/typst.out @@ -6,8 +6,8 @@ every character a notation escapes, set as text: link\_to, 100% & \#1 costs \$5 == Sets / $cal(T)$: index $t$ --- `snapshot` (`int` coordinates) with $upright("season_of"): cal(T) arrow.r cal(S), upright("gen_zone"): cal(G) times cal(T) arrow.r cal(Z), upright("rep_of"): cal(T) arrow.r cal(T)$ / $cal(G)$: index $g$ --- `generator` with $upright("gen_bus"): cal(G) arrow.r cal(B), upright("gen_zone"): cal(G) times cal(T) arrow.r cal(Z), upright("connection") subset.eq cal(G) times cal(B), upright("gen_bt"): cal(G) arrow.r cal(B) times cal(E)$ -/ $cal(B)$: index $b$ --- `bus` with $upright("gen_bus"): cal(G) arrow.r cal(B), upright("zone_of"): cal(B) arrow.r cal(Z), upright("area_of"): cal(B) arrow.r cal(Z), upright("connection") subset.eq cal(G) times cal(B), upright("gen_bt"): cal(G) arrow.r cal(B) times cal(E)$ -/ $cal(Z)$: index $z$ --- `zone` with $upright("zone_of"): cal(B) arrow.r cal(Z), upright("area_of"): cal(B) arrow.r cal(Z), upright("gen_zone"): cal(G) times cal(T) arrow.r cal(Z)$ +/ $cal(B)$: index $b$ --- `bus` with $upright("gen_bus"): cal(G) arrow.r cal(B), upright("zone_of"): cal(B) arrow.r cal(Z), upright("area_of"): cal(B) arrow.r cal(Z), upright("connection") subset.eq cal(G) times cal(B), upright("gen_bt"): cal(G) arrow.r cal(B) times cal(E)$, `area_of` is `absent` where the data has no row +/ $cal(Z)$: index $z$ --- `zone` with $upright("zone_of"): cal(B) arrow.r cal(Z), upright("area_of"): cal(B) arrow.r cal(Z), upright("gen_zone"): cal(G) times cal(T) arrow.r cal(Z)$, `area_of` is `absent` where the data has no row / $cal(S)$: index $s$ --- `season` with $upright("season_of"): cal(T) arrow.r cal(S)$ / $cal(E)$: index $e$ --- `technology` with $upright("gen_bt"): cal(G) arrow.r cal(B) times cal(E)$ / $cal(A)$: index $a$ --- `bp` @@ -15,11 +15,11 @@ every character a notation escapes, set as text: link\_to, 100% & \#1 costs \$5 == Parameters / $upright("p")^(upright("max"))$: `p_max` over $cal(G)$, $infinity$ where the data has no row / $upright("p")^(upright("min"))$: `p_min` over $cal(G)$ -/ $upright("cost")$: `cost` over $cal(G)$ +/ $upright("cost")$: `cost` over $cal(G)$, `neutral` where the data has no row / $upright("load")$: `load` over $cal(T) times cal(B)$ / $upright("is_flexible")$: `is_flexible` over $cal(G)$, `false` where the data has no row / $upright("zone_cap")$: `zone_cap` over $cal(Z)$ -/ $upright("tech_cap")$: `tech_cap` over $cal(B) times cal(E)$ +/ $upright("tech_cap")$: `tech_cap` over $cal(B) times cal(E)$, `absent` where the data has no row / $upright("min_up")$: `min_up` over $cal(G)$ / $upright("eta")$: `eta` over $cal(G)$ / $upright("lead")$: `lead` over $cal(G)$ @@ -28,7 +28,7 @@ every character a notation escapes, set as text: link\_to, 100% & \#1 costs \$5 / $upright("bp_x")$: `bp_x` over $cal(G) times cal(A)$ / $upright("bp_y")$: `bp_y` over $cal(G) times cal(A)$ / $upright("bp_heat")$: `bp_heat` over $cal(G) times cal(A)$ -/ $upright("bp_run")$: `bp_run` over $cal(G) times cal(A)$ +/ $upright("bp_run")$: `bp_run` over $cal(G) times cal(A)$, `neutral` where the data has no row == Variables / $p$: `p` over $cal(T) times cal(G)$ diff --git a/tests/typesetting/test_symbols.py b/tests/typesetting/test_symbols.py index ec6d701c9..e06509ac3 100644 --- a/tests/typesetting/test_symbols.py +++ b/tests/typesetting/test_symbols.py @@ -100,27 +100,36 @@ def test_a_named_expression_has_a_legend_row_exactly_while_its_symbol_prints(nam DEFAULTED = varied( DISPATCH_MODEL, **{ + 'dimensions.bus': {'dtype': 'str'}, + 'relations.gen_bus': {'key': 'generator', 'values': 'bus', 'missing': 'absent'}, 'parameters.p_max.missing': 'inf', 'parameters.cost.missing': 1, 'parameters.load.missing': 'neutral', 'parameters.floor': {'dims': [], 'missing': '-inf'}, 'parameters.online': {'dims': ['generator'], 'dtype': 'bool', 'missing': True}, + 'parameters.p_set': {'dims': ['generator'], 'missing': 'absent'}, + 'parameters.ramp': {'dims': ['generator']}, }, ) @EVERY_FORMAT -def test_the_legend_prints_what_a_missing_row_reads_as(name: FormatName, fmt: Format): +def test_the_legend_prints_what_a_missing_row_means(name: FormatName, fmt: Format): + """The legend printed a value and left out a reading, so `absent`, `neutral` and a partial map looked complete.""" out = typeset(DEFAULTED, name) for shown in ( fmt.math(number(math.inf, fmt)), fmt.math(number(-math.inf, fmt)), fmt.math('1'), fmt.mono('true'), + fmt.mono('neutral'), + fmt.mono('absent'), + f'{fmt.mono("gen_bus")} is {fmt.mono("absent")}', ): assert f'{shown} where the data has no row' in out - assert out.count('where the data has no row') == 4, ( - '`load` reads a missing row as neutral, which is a reading and not a value, so its row names none' + assert out.count('where the data has no row') == 8, ( + 'six parameters and the map in each of its two sets say what a missing row means; ' + '`ramp` keeps the default, refused, and says nothing' ) From df65c48839f4ed571e70c7e01f5f1436dedc080a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 22:42:22 +0000 Subject: [PATCH 14/21] docs(changelog): the #810 line names what breaks The line takes the PR's new title, `feat(language)!:`, and says that a table short of a row is refused unless the file says otherwise, and that a variable's `absence:` is now `missing:`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CsaSWMofYqJiKmnSiPycV9 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8bca45ce..f848b3a1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ it releases that version ([RELEASING.md](https://github.com/energy-models/mathsp ## Upcoming version -- feat(language): `missing:` says what a missing row means, and a table is complete unless the file says otherwise ([#810](https://github.com/energy-models/mathspec/pull/810)) +- feat(language)!: `missing:` says what a missing row means, a table short of a row is refused unless the file says otherwise, and a variable's `absence:` is now `missing:` ([#810](https://github.com/energy-models/mathspec/pull/810)) - docs(pypsa): the pypsa page lists six more refusals and eight data-prep steps, and a capital cost says where fom_cost and overnight_cost enter ([#816](https://github.com/energy-models/mathspec/pull/816)) - docs(pypsa): a committable unit built in a later period starts from off, and the big M follows pypsa for an infinite build cap ([#817](https://github.com/energy-models/mathspec/pull/817)) - docs(pypsa): a line, a transformer, a storage unit and a store may be built in whole modules, and a unit that is not active counts no modules ([#815](https://github.com/energy-models/mathspec/pull/815)) From 03ba73929f7b7a31e1582d90e5f657e5bfde1124 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 22:54:50 +0000 Subject: [PATCH 15/21] feat(language)!: a curve's parameter takes `missing:`, and declares it where a row outside the curve reads it The loader refused `missing:` on a parameter a `piecewise:` block reads, and the reading came from the curve. A row outside the curve that read the same table had no reading the file could set. Now: - A curve's parameter takes `missing:` like any other parameter. - A parameter a curve reads and a declaration outside every curve reads too, with no `missing:`, is refused at load. The message names the parameter, the curve, the outside reader and the four readings. - A values parameter that only curves with `points:` read, with no `missing:`, reads `neutral`: the curve stops where its mask stops, so its table has no rows past it and the curve reads none. `piecewise.ragged` names these, and lowering and the expansion both read it. - A boolean `points:` mask is no longer inferred `neutral`. A full table of `true` and `false` is a mask as well, so it reads the default, `refused`, unless the file says otherwise. `program.names_under` is public, as lowering now reads it too. The reading page declares `bp_y` `missing: refused`, because an assumption reads it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CsaSWMofYqJiKmnSiPycV9 --- docs/reference/language/absence.md | 10 ++-- docs/reference/language/declarations.md | 5 +- docs/reference/language/piecewise.md | 27 ++++++++- docs/reference/notation.md | 2 +- docs/reference/reading.md | 9 ++- src/mathspec/lowering.py | 55 ++++++++++++++++-- src/mathspec/piecewise.py | 32 +++++----- src/mathspec/program.py | 5 +- src/mathspec/spec.py | 7 +-- src/mathspec/validation.py | 20 ------- tests/test_expand.py | 77 +++++++++++++++++++------ tests/typesetting/golden/latex.out | 2 +- tests/typesetting/golden/markdown.out | 2 +- tests/typesetting/golden/typst.out | 2 +- 14 files changed, 167 insertions(+), 88 deletions(-) diff --git a/docs/reference/language/absence.md b/docs/reference/language/absence.md index 41b76e150..dc208bfd3 100644 --- a/docs/reference/language/absence.md +++ b/docs/reference/language/absence.md @@ -48,11 +48,11 @@ what a missing row is:** `neutral` has no value in three positions: a divisor, a `bounds:` entry, and the whole constant side of a comparison. A missing row there is refused when -the data is attached. A [`piecewise:`](piecewise.md) block owns the parameters -it reads, so they take no `missing:`, and a curve needs a row at each breakpoint -its `points:` admits. Under -`absent`, a missing divisor or constant side is absence and takes the row, and a -missing bound leaves that side of the variable open. For a bound only where the +the data is attached. A curve needs a row at each breakpoint its `points:` +admits. [Missing breakpoints](piecewise.md#missing-breakpoints) says what a +missing row of a table a curve reads means. Under `absent`, a missing divisor +or constant side is absence and takes the row, and a missing bound leaves that +side of the variable open. For a bound only where the data has one, declare the parameter `missing: absent` or `missing: .inf`, or mask the variable. diff --git a/docs/reference/language/declarations.md b/docs/reference/language/declarations.md index 353202b48..2c0a3f61e 100644 --- a/docs/reference/language/declarations.md +++ b/docs/reference/language/declarations.md @@ -94,9 +94,8 @@ A value has the parameter's dtype: A NaN, a quoted number and `missing: null` are refused. A `given:` parameter has no `missing:`. The file that declares the parameter owns it. A parameter a -[`piecewise:`](piecewise.md) block reads takes no `missing:` either: the block -owns the shape of its curve. Such a parameter is `neutral` where only curves -with `points:` read it, and `refused` otherwise, in the curve and outside it. +[`piecewise:`](piecewise.md#missing-breakpoints) block reads takes `missing:` +too, and declares it where a declaration outside the curve reads it. The typeset legend prints what a missing row means beside the parameter, such as `` `neutral` where the data has no row ``. It prints nothing for `refused`. diff --git a/docs/reference/language/piecewise.md b/docs/reference/language/piecewise.md index 09c5b52d9..b79d249b5 100644 --- a/docs/reference/language/piecewise.md +++ b/docs/reference/language/piecewise.md @@ -93,6 +93,28 @@ A row missing from `bp_y` is still refused. Where the length is its own data, name a boolean parameter instead. The marked breakpoints are one consecutive run, anywhere on the axis. +### Missing breakpoints + +A parameter a block reads takes [`missing:`](declarations.md#a-missing-row) +like any other parameter. Where the file writes none: + +- **A values parameter that only curves with `points:` read is `neutral`.** The + curve stops where the mask stops, so the table has no rows past it by design, + and the curve reads none of them. +- **Every other parameter a block reads is `refused`**, the default. This is a + values parameter of a curve with no `points:`, and a boolean `points:` mask. + A mask that leaves out a breakpoint with no row declares `missing: neutral`. +- **A parameter that a declaration outside every curve also reads is refused at + load.** The curve says nothing about what a missing row means there, so the + file says it: + +```text +parameter 'bp_y': piecewise 'cost_curve' reads it as breakpoints and constraint 'cost_cap' reads it too, so the file says what a missing row of it means. Declare missing: refused, absent, neutral, or a value of its dtype. +``` + +The assumptions a `method:` implies read the parameters as the curve does, and +are not outside it. An `assumptions:` entry the file writes is. + ### `method` `method` says how the weights are restricted once they exist. @@ -172,8 +194,7 @@ shows a spec before and after. - **Every name written out starts with the name of the block.** The weights of the curve `curve` are `curve_lam`. - **No formulation emits a parameter.** The same data attaches to a spec and its - expansion, and reads alike in both. A curve under `points:` declares the - parameters it reads `missing: neutral`, because its rows read them only where - the mask holds. + expansion, and reads alike in both. The expansion writes `missing: neutral` on + each values parameter that [reads as `neutral`](#missing-breakpoints). - **The assumptions a `method:` implies become `assumptions:` entries** with the same names. diff --git a/docs/reference/notation.md b/docs/reference/notation.md index c9e296146..889963b6d 100644 --- a/docs/reference/notation.md +++ b/docs/reference/notation.md @@ -96,7 +96,7 @@ parameters: | $`\mathrm{bp\_x}`$ | `bp_x` over $`\mathcal{G} \times \mathcal{A}`$ | | $`\mathrm{bp\_y}`$ | `bp_y` over $`\mathcal{G} \times \mathcal{A}`$ | | $`\mathrm{bp\_heat}`$ | `bp_heat` over $`\mathcal{G} \times \mathcal{A}`$ | -| $`\mathrm{bp\_run}`$ | `bp_run` over $`\mathcal{G} \times \mathcal{A}`$, `neutral` where the data has no row | +| $`\mathrm{bp\_run}`$ | `bp_run` over $`\mathcal{G} \times \mathcal{A}`$ | #### Variables diff --git a/docs/reference/reading.md b/docs/reference/reading.md index a2aa33b28..4c8794c4c 100644 --- a/docs/reference/reading.md +++ b/docs/reference/reading.md @@ -40,7 +40,7 @@ dimensions: bp: { dtype: int } parameters: bp_x: { dims: [generator, bp] } - bp_y: { dims: [generator, bp] } + bp_y: { dims: [generator, bp], missing: refused } # the assumption below reads it too variables: p: dims: [generator] @@ -121,10 +121,9 @@ written # "assumption 'cost_is_never_negative' does not hold for the data attac `program.parameters[name].missing` says what a missing row of that parameter means: `'refused'`, `'absent'`, `'neutral'`, or the value it reads as. It is -`None` for a given parameter, whose declaring file says. A parameter that a -`piecewise:` block reads is `'neutral'` where only curves with `points:` read -it, and `'refused'` otherwise. The program of `spec.expand(...)` reports the -same readings. `program.relations[name].missing` is `'refused'` or `'absent'`, and +`None` for a given parameter, whose declaring file says. A values parameter +that only curves with `points:` read, with no `missing:` written, is +`'neutral'`. The program of `spec.expand(...)` reports the same readings. `program.relations[name].missing` is `'refused'` or `'absent'`, and `None` for a bare relation. Under `'refused'` the engine does not build the model from a table with a missing row, and names the coordinate; whether it raises at the first gap or lists them all is its own. A value is read wherever a value is read: an expression, diff --git a/src/mathspec/lowering.py b/src/mathspec/lowering.py index ec14e0d06..f5654d57a 100644 --- a/src/mathspec/lowering.py +++ b/src/mathspec/lowering.py @@ -20,7 +20,7 @@ from mathspec.dimensions import check_schema, dims_of from mathspec.errors import SchemaError, did_you_mean, prefixed from mathspec.expansion import expand, parse_template -from mathspec.piecewise import assumptions_of, curve_frame, curve_readings, lp_domain_refusal, resolve_links +from mathspec.piecewise import assumptions_of, curve_frame, lp_domain_refusal, ragged, resolve_links from mathspec.program import ( Assumption, BooleanLiteral, @@ -41,6 +41,7 @@ SosDeclaration, VariableDeclaration, VariableDefined, + names_under, variables_of, walk, ) @@ -55,7 +56,7 @@ from mathspec.validation import emitted_name_errors, reference_errors if TYPE_CHECKING: - from collections.abc import Iterable, Mapping + from collections.abc import Iterable, Iterator, Mapping from mathspec.program import Expression from mathspec.spec import AssumptionBlock, Spec @@ -202,10 +203,12 @@ 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 - readings = curve_readings(schema) + shortened = ragged(schema) program = Program( parameters={ - name: ParameterDeclaration(tuple(pdef.dims), pdef.dtype, readings.get(name, pdef.reading), pdef.description) + name: ParameterDeclaration( + tuple(pdef.dims), pdef.dtype, 'neutral' if name in shortened else pdef.reading, pdef.description + ) for name, pdef in schema.parameters.items() }, variables=variables, @@ -248,12 +251,54 @@ def lower(schema: Spec) -> Program: ), description=schema.description, ) - if errors := emitted_name_errors(schema, program): + if errors := [*emitted_name_errors(schema, program), *_curve_parameters_read_outside(schema, program)]: raise SchemaError('\n'.join(errors)) check_schema(schema, program) return program +def _curve_parameters_read_outside(schema: Spec, program: Program) -> Iterator[str]: + """A refusal for each parameter a curve reads that a declaration outside every curve reads too, with no ``missing:``. + + The curve decides what a missing breakpoint means only for its own rows, + so the file says what a missing row means where something else reads it. + The assumptions a method implies are the curve's, and are not outside it. + """ + curves = {name: block for block, pw in schema.piecewise.items() for name in sorted(pw.consumes)} + unwritten = {n for n in curves if n in schema.parameters and schema.parameters[n].missing is None} + if not unwritten: + return + readers: dict[str, str] = {} + for label, names in _readers(schema, program): + for name in sorted(names & unwritten): + readers.setdefault(name, label) + for name, reader in readers.items(): + yield ( + f"parameter '{name}': piecewise '{curves[name]}' reads it as breakpoints and {reader} reads it too, " + f'so the file says what a missing row of it means. Declare missing: refused, absent, neutral, or a ' + f'value of its dtype.' + ) + + +def _readers(schema: Spec, program: Program) -> Iterator[tuple[str, frozenset[str]]]: + """Each declaration outside the curves, labelled as a refusal names it, with the data it reads.""" + for name, variable in program.variables.items(): + bounds = [b for b in (variable.lower, variable.upper) if b is not None] + where = variable.where.names_read if variable.where is not None else frozenset() + yield f"variable '{name}'", names_under(*bounds) | where + for name, constraint in program.constraints.items(): + where = constraint.where.names_read if constraint.where is not None else frozenset() + yield f"constraint '{name}'", names_under(constraint.lhs, constraint.rhs) | where + if program.objective is not None: + yield 'the objective', names_under(program.objective.expression) + for name, entry in program.expressions.items(): + yield f"named expression '{name}'", names_under(entry.expression) + for name in schema.assumptions: + assumption = program.assumptions[name] + where = assumption.where.names_read if assumption.where is not None else frozenset() + yield f"assumption '{name}'", assumption.predicate.names_read | where + + def _terms(entries: Iterable[str], schema: Spec, ns: Namespace, errors: list[str]) -> list[Named]: """Every entry with ``adds_to:`` that loads as a term, with a refusal in *errors* for each other. diff --git a/src/mathspec/piecewise.py b/src/mathspec/piecewise.py index a6adc1973..0e037f51f 100644 --- a/src/mathspec/piecewise.py +++ b/src/mathspec/piecewise.py @@ -28,7 +28,7 @@ if TYPE_CHECKING: from collections.abc import Iterable - from mathspec.program import Expression, MissingReading + from mathspec.program import Expression from mathspec.resolution import Namespace @@ -481,19 +481,20 @@ def _segment_lines(self) -> None: ) -def curve_readings(schema: Spec) -> dict[str, MissingReading]: - """What a missing row of each parameter a ``piecewise:`` block consumes means, by name. +def ragged(schema: Spec) -> frozenset[str]: + """The values parameters a missing row of which is a breakpoint the curve does not run through. - A parameter that only curves with ``points:`` consume is ``neutral``: the - weights and the segment rows stand on the mask, and the ``_complete`` - assumption asks for a row only where it holds, so the table may stop short - of the dimension. A parameter that a curve over every breakpoint consumes is - ``refused``. The spec and its expansion both report these readings, so the - same data reads alike in either, outside the curve too. + ``points:`` says the curve stops short of the dimension, and its weights + and segment rows read a values parameter only where the mask holds, so a + row is missing outside the mask by design: ``refused`` would refuse the + curve the block declares. Every other reading reads alike there, and + ``neutral`` is the one written. A parameter a curve over every breakpoint + also reads, a ``points:`` mask that is not a values parameter, and a + parameter whose ``missing:`` the file wrote are not among them. """ - ragged = {n for pw in schema.piecewise.values() if pw.points for n in pw.consumes} - full = {n for pw in schema.piecewise.values() if not pw.points for n in pw.consumes} - return {n: 'refused' if n in full else 'neutral' for n in ragged | full} + shortened = {link.values for pw in schema.piecewise.values() if pw.points for link in pw.links} + complete = {n for pw in schema.piecewise.values() if not pw.points for n in pw.consumes} + return frozenset(n for n in shortened - complete if n in schema.parameters and schema.parameters[n].missing is None) def expand_piecewise(schema: Spec) -> Spec: @@ -505,7 +506,7 @@ def expand_piecewise(schema: Spec) -> Spec: set of its own ([`mathspec.sos.emit`][] is where they are spelled). Each block's frame and names are read off the program *schema* lowered to. - Each parameter a block consumes is declared as [`curve_readings`][] reads it. + Each parameter in [`ragged`][] is declared ``missing: neutral``. """ if not schema.piecewise: return schema @@ -515,9 +516,8 @@ def expand_piecewise(schema: Spec) -> Spec: raw.setdefault('constraints', {}) for name, pw in schema.piecewise.items(): _Block(schema, raw, name, pw, program.piecewise[name]).expand() - for parameter, reading in curve_readings(schema).items(): - if reading == 'neutral': - raw['parameters'][parameter]['missing'] = reading + for parameter in ragged(schema): + raw['parameters'][parameter]['missing'] = 'neutral' raw['piecewise'].clear() for name, pw in schema.piecewise.items(): if pw.method == 'adjacency': diff --git a/src/mathspec/program.py b/src/mathspec/program.py index 3970cd013..3b1c7cd87 100644 --- a/src/mathspec/program.py +++ b/src/mathspec/program.py @@ -111,6 +111,7 @@ 'carries_variable', 'children', 'is_quadratic', + 'names_under', 'parameters_of', 'variables_of', 'walk', @@ -1439,7 +1440,7 @@ def _atom_names(atom: TypedPredicate) -> frozenset[str]: case RelationPairComparison(): return frozenset({atom.name, atom.other}) case ExpressionComparison(): - return _names_under(atom.left, atom.right) + return names_under(atom.left, atom.right) case CountComparison(): return atom.predicate.names_read case TranslatedPredicate(): @@ -1452,7 +1453,7 @@ def _atom_names(atom: TypedPredicate) -> frozenset[str]: assert_never(atom) -def _names_under(*expressions: Expression) -> frozenset[str]: +def names_under(*expressions: Expression) -> frozenset[str]: """Every parameter and relation the data has to supply for *expressions* — what a mask's ``names_read`` promises. [`parameters_of`][] alone misses the data an operator reads beside its diff --git a/src/mathspec/spec.py b/src/mathspec/spec.py index 798f5c4bc..a33a27dc3 100644 --- a/src/mathspec/spec.py +++ b/src/mathspec/spec.py @@ -802,12 +802,7 @@ class PiecewiseBlock(_StrictBlock): @property def consumes(self) -> frozenset[str]: - """The parameters the block reads: each link's values, and the ``points:`` mask. - - The block owns the shape of every one of them, so ``missing:`` is - refused on them at load, and ``mathspec.piecewise.curve_readings`` - says what a missing row of each means. - """ + """The parameters the block reads: each link's values, and the ``points:`` mask.""" return frozenset({link.values for link in self.links} | ({self.points} if self.points else set())) @property diff --git a/src/mathspec/validation.py b/src/mathspec/validation.py index 616415b97..3b7424ae6 100644 --- a/src/mathspec/validation.py +++ b/src/mathspec/validation.py @@ -88,7 +88,6 @@ def reference_errors(schema: Spec) -> list[str]: *_sos_shapes(schema), *_sos_bounds(schema), *_piecewise_references(schema), - *_curve_parameters_declare_no_missing(schema), *_given_constraint_collisions(schema), ] @@ -340,25 +339,6 @@ def _piecewise_references(schema: Spec) -> Iterator[str]: ) -def _curve_parameters_declare_no_missing(schema: Spec) -> Iterator[str]: - """A curve's shape is its block's to state, so the parameters it consumes state no ``missing:``. - - ``points:`` already says how far a curve runs, and a breakpoint it leaves - out is not asked for, so a values parameter is complete over the points - its block admits: neither ``refused`` over every coordinate its dims reach - nor a gap any other reading fills. - """ - for block_name, block in schema.piecewise.items(): - for name in sorted(block.consumes): - if name in schema.parameters and schema.parameters[name].missing is not None: - yield ( - f"parameter '{name}': 'missing:' is not for a parameter a piecewise block consumes — " - f"'{block_name}' already owns the shape of its curve. A breakpoint 'points:' leaves out " - f'is not asked for, and every other breakpoint needs a row. Drop the ' - f"'missing:' line, and say how far the curve runs with 'points:'." - ) - - def _collisions(schema: Spec, context: str, by_kind: Iterable[tuple[str, Iterable[str]]]) -> Iterator[str]: """The refusal for each name *context*'s expansion writes that the file already declares, by kind. diff --git a/tests/test_expand.py b/tests/test_expand.py index e4255ac0f..0acc49087 100644 --- a/tests/test_expand.py +++ b/tests/test_expand.py @@ -220,31 +220,70 @@ def test_the_expansion_says_what_a_missing_breakpoint_means(points, missing): ], ) def test_a_spec_and_its_expansion_read_a_missing_row_alike(points): - """A row outside the curve that reads a curve's values parameter, `r <= by`, had no reading in the spec. + """Lowering reported `None` for every parameter a block consumes, and the expansion declared it `neutral`.""" + spec = schema_of(varied(SMALL_MODEL, **copy.deepcopy(SMALL_CURVE), **points)) + declared = {name: p.missing for name, p in spec.program.parameters.items()} + expanded = {name: p.missing for name, p in spec.expand('piecewise').program.parameters.items()} + assert declared == expanded, 'a spec and its expansion read a missing row of every parameter alike' + + +#: A row outside the curve that reads one of its values parameters. +READ_OUTSIDE = {'constraints.cap': {'dims': ['h'], 'expression': 'r <= by'}} + + +@pytest.mark.parametrize( + 'points', + [ + pytest.param({'piecewise.curve.points': 'bx'}, id='a-curve-that-says-how-far-it-runs'), + pytest.param({}, id='a-curve-over-every-breakpoint'), + ], +) +def test_a_curve_parameter_read_outside_the_curve_declares_missing(points): + """`r <= by` read a curve's values parameter with no `missing:` the author could set. - Lowering reported `None` for every parameter a block consumes, and the expansion declared the same parameter - `neutral`, so one file gave two answers for one table, and the spec's program gave a consumer none for the row - outside the curve. The program reports what the expansion declares. + The loader refused `missing:` on it, and the reading came from the curve: `None` from lowering, then + `neutral` inferred by the expansion. Nothing in the file said what a missing row of `by` means in `cap`. """ - spec = schema_of( + with pytest.raises(LanguageError) as caught: + to_spec(varied(SMALL_MODEL, **copy.deepcopy(SMALL_CURVE), **READ_OUTSIDE, **points)) + message = str(caught.value) + assert "parameter 'by'" in message, 'the message names the parameter that has to change' + assert "piecewise 'curve'" in message, 'and the curve that reads it' + assert "constraint 'cap'" in message, 'and the declaration outside the curve that reads it too' + assert 'missing: refused, absent, neutral, or a value' in message, 'and names the rewrite' + + +@pytest.mark.parametrize('missing', ['refused', 'absent', 'neutral', 0]) +def test_a_curve_parameter_takes_the_missing_it_declares(missing): + """`missing:` on a curve's parameter was refused at load, so a row outside the curve could not say what it reads.""" + spec = to_spec( varied( SMALL_MODEL, **copy.deepcopy(SMALL_CURVE), - **points, - **{'constraints.cap': {'dims': ['h'], 'expression': 'r <= by'}}, + **READ_OUTSIDE, + **{'piecewise.curve.points': 'bx', 'parameters.by.missing': missing}, ) ) - declared = {name: p.missing for name, p in spec.program.parameters.items()} - expanded = {name: p.missing for name, p in spec.expand('piecewise').program.parameters.items()} - assert declared == expanded, 'a spec and its expansion read a missing row of every parameter alike' + assert spec.program.parameters['by'].missing == missing + assert spec.expand('piecewise').program.parameters['by'].missing == missing -@pytest.mark.parametrize('missing', ['refused', 'neutral', 0]) -def test_missing_on_a_parameter_a_curve_consumes_is_refused(missing): - """`points:` already says how far the curve runs, so any `missing:` there claims what the block decided.""" - with pytest.raises(LanguageError) as caught: - to_spec(varied(SMALL_MODEL, **copy.deepcopy(SMALL_CURVE), **{'parameters.bx.missing': missing})) - message = str(caught.value) - assert "parameter 'bx'" in message, 'the message names the parameter that has to change' - assert "'curve'" in message, 'and the block that already owns the answer' - assert "'points:'" in message, 'and names the rewrite' +def test_a_boolean_points_mask_reads_a_missing_row_as_its_declaration_says(): + """The expansion declared a boolean `points:` mask `neutral`, so a missing row was a breakpoint left out. + + The curve does not decide that: a table of `true` and `false` at every breakpoint is a mask as well. The mask + reads the default, `refused`, unless the file says otherwise. + """ + spec = schema_of( + varied( + SMALL_MODEL, + **copy.deepcopy(SMALL_CURVE), + **{'parameters.run': {'dims': ['h'], 'dtype': 'bool'}, 'piecewise.curve.points': 'run'}, + ) + ) + for program in (spec.program, spec.expand('piecewise').program): + assert {name: program.parameters[name].missing for name in ('bx', 'by', 'run')} == { + 'bx': 'neutral', + 'by': 'neutral', + 'run': 'refused', + }, 'a values table under points: stops short of the dimension, and the mask reads as declared' diff --git a/tests/typesetting/golden/latex.out b/tests/typesetting/golden/latex.out index 1b601d7f1..3c6cd2175 100644 --- a/tests/typesetting/golden/latex.out +++ b/tests/typesetting/golden/latex.out @@ -35,7 +35,7 @@ \item[{$\mathrm{bp\_x}$}] \texttt{bp\_x} over $\mathcal{G} \times \mathcal{A}$ \item[{$\mathrm{bp\_y}$}] \texttt{bp\_y} over $\mathcal{G} \times \mathcal{A}$ \item[{$\mathrm{bp\_heat}$}] \texttt{bp\_heat} over $\mathcal{G} \times \mathcal{A}$ -\item[{$\mathrm{bp\_run}$}] \texttt{bp\_run} over $\mathcal{G} \times \mathcal{A}$, \texttt{neutral} where the data has no row +\item[{$\mathrm{bp\_run}$}] \texttt{bp\_run} over $\mathcal{G} \times \mathcal{A}$ \end{description} \paragraph{Variables} diff --git a/tests/typesetting/golden/markdown.out b/tests/typesetting/golden/markdown.out index aee45e136..d7b48f75f 100644 --- a/tests/typesetting/golden/markdown.out +++ b/tests/typesetting/golden/markdown.out @@ -33,7 +33,7 @@ every character a notation escapes, set as text: link\_to, 100% & \#1 costs \$5 | $`\mathrm{bp\_x}`$ | `bp_x` over $`\mathcal{G} \times \mathcal{A}`$ | | $`\mathrm{bp\_y}`$ | `bp_y` over $`\mathcal{G} \times \mathcal{A}`$ | | $`\mathrm{bp\_heat}`$ | `bp_heat` over $`\mathcal{G} \times \mathcal{A}`$ | -| $`\mathrm{bp\_run}`$ | `bp_run` over $`\mathcal{G} \times \mathcal{A}`$, `neutral` where the data has no row | +| $`\mathrm{bp\_run}`$ | `bp_run` over $`\mathcal{G} \times \mathcal{A}`$ | #### Variables diff --git a/tests/typesetting/golden/typst.out b/tests/typesetting/golden/typst.out index 7e2046e6e..a5ee587d0 100644 --- a/tests/typesetting/golden/typst.out +++ b/tests/typesetting/golden/typst.out @@ -28,7 +28,7 @@ every character a notation escapes, set as text: link\_to, 100% & \#1 costs \$5 / $upright("bp_x")$: `bp_x` over $cal(G) times cal(A)$ / $upright("bp_y")$: `bp_y` over $cal(G) times cal(A)$ / $upright("bp_heat")$: `bp_heat` over $cal(G) times cal(A)$ -/ $upright("bp_run")$: `bp_run` over $cal(G) times cal(A)$, `neutral` where the data has no row +/ $upright("bp_run")$: `bp_run` over $cal(G) times cal(A)$ == Variables / $p$: `p` over $cal(T) times cal(G)$ From 475ac3e50c05b73ca13a6c181e0830ba309e2e03 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 22:57:29 +0000 Subject: [PATCH 16/21] feat(typesetting): the legend says what a masked-out variable means where it is not the default A variable's `missing: neutral` now prints beside the variable, in the slot a parameter's reading uses: `` `neutral` where the mask leaves it out ``. The default, `absent`, prints nothing. The golden model marks `headroom`, and the goldens and generated pages are regenerated. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CsaSWMofYqJiKmnSiPycV9 --- docs/examples/pypsa.md | 32 ++++++++++---------- docs/examples/pypsa/generator_maintenance.md | 8 ++--- docs/examples/pypsa/line.md | 2 +- docs/examples/pypsa/link_maintenance.md | 8 ++--- docs/examples/pypsa/process_maintenance.md | 8 ++--- docs/examples/pypsa/storage_unit.md | 2 +- docs/examples/pypsa/transformer.md | 4 +-- docs/examples/pypsa_linearized_uc.md | 6 ++-- docs/reference/language/absence.md | 2 ++ docs/reference/notation.md | 5 +-- src/mathspec/typesetting/legend.py | 23 ++++++++++---- tests/typesetting/golden/latex.out | 2 +- tests/typesetting/golden/markdown.out | 2 +- tests/typesetting/golden/model.yaml | 3 +- tests/typesetting/golden/typst.out | 2 +- tests/typesetting/test_symbols.py | 20 ++++++++++++ 16 files changed, 82 insertions(+), 47 deletions(-) diff --git a/docs/examples/pypsa.md b/docs/examples/pypsa.md index 44a0a65a5..68c61d78e 100644 --- a/docs/examples/pypsa.md +++ b/docs/examples/pypsa.md @@ -5684,38 +5684,38 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`h^{+}`$ | `StorageUnit_p_dispatch` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — `StorageUnit-p_dispatch` — power delivered to the bus | | $`h^{-}`$ | `StorageUnit_p_store` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — `StorageUnit-p_store` — power drawn from the bus into charge | | $`\mathit{soc}`$ | `StorageUnit_state_of_charge` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — `StorageUnit-state_of_charge` — energy held at the end of a snapshot | -| $`\mathit{spill}`$ | `StorageUnit_spill` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — `StorageUnit-spill` — inflow passed on unused. Zero where there is no inflow, so the balance keeps its row there; the bounds are PyPSA's, on the variable rather than as rows | +| $`\mathit{spill}`$ | `StorageUnit_spill` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$, `neutral` where the mask leaves it out — `StorageUnit-spill` — inflow passed on unused. Zero where there is no inflow, so the balance keeps its row there; the bounds are PyPSA's, on the variable rather than as rows | | $`e`$ | `Store_e` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$ — `Store-e` — energy held at the end of a snapshot | | $`q`$ | `Store_p` over $`\Xi \times \mathcal{T} \times \mathcal{V}`$ — `Store-p` — power delivered to the bus; charging is negative | | $`N`$ | `Generator_n_mod` over $`\mathcal{G}`$ — `Generator-n_mod` — how many modules of an extendable modular build; none for a build that stands in no snapshot | | $`u`$ | `Generator_status` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — `Generator-status` — how much of a committable unit is on: an integer the rows below cap at one, or at the module count where the build is modular | | $`\mathit{up}`$ | `Generator_start_up` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — `Generator-start_up` — how much of a committable unit turns on this snapshot, capped as the status is | | $`\mathit{dn}`$ | `Generator_shut_down` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — `Generator-shut_down` — how much of a committable unit turns off this snapshot, capped as the status is | -| $`\mu`$ | `Generator_maintenance` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — `Generator-maintenance` — whether a maintainable generator is in maintenance: continuous, and one exactly where an event covers the snapshot | -| $`\mu^{\mathrm{up}}`$ | `Generator_maintenance_start` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — `Generator-maintenance_start` — whether a maintenance event starts in this snapshot | -| $`\mu^{\mathrm{nom}}`$ | `Generator_maintenance_capacity` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — `Generator-maintenance_capacity` — the chosen build while in maintenance, zero otherwise: the product the `maintcap` rows linearize | -| $`\mu^{u}`$ | `Generator_maintenance_status` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — `Generator-maintenance_status` — the status while in maintenance, zero otherwise: the product the `maint-status` rows linearize, so a unit in maintenance may also be off | +| $`\mu`$ | `Generator_maintenance` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$, `neutral` where the mask leaves it out — `Generator-maintenance` — whether a maintainable generator is in maintenance: continuous, and one exactly where an event covers the snapshot | +| $`\mu^{\mathrm{up}}`$ | `Generator_maintenance_start` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$, `neutral` where the mask leaves it out — `Generator-maintenance_start` — whether a maintenance event starts in this snapshot | +| $`\mu^{\mathrm{nom}}`$ | `Generator_maintenance_capacity` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$, `neutral` where the mask leaves it out — `Generator-maintenance_capacity` — the chosen build while in maintenance, zero otherwise: the product the `maintcap` rows linearize | +| $`\mu^{u}`$ | `Generator_maintenance_status` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$, `neutral` where the mask leaves it out — `Generator-maintenance_status` — the status while in maintenance, zero otherwise: the product the `maint-status` rows linearize, so a unit in maintenance may also be off | | $`N^{f}`$ | `Link_n_mod` over $`\mathcal{L}`$ — `Link-n_mod` — how many modules of an extendable modular build; none for a build that stands in no snapshot | | $`u^{f}`$ | `Link_status` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — `Link-status` — how much of a committable link is on: an integer the rows below cap at one, or at the module count where the build is modular | | $`\mathit{up}^{f}`$ | `Link_start_up` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — `Link-start_up` — how much of a committable link turns on this snapshot, capped as the status is | | $`\mathit{dn}^{f}`$ | `Link_shut_down` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — `Link-shut_down` — how much of a committable link turns off this snapshot, capped as the status is | -| $`\mu^{f}`$ | `Link_maintenance` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — `Link-maintenance` — whether a maintainable link is in maintenance: continuous, and one exactly where an event covers the snapshot | -| $`\mu^{f,\mathrm{up}}`$ | `Link_maintenance_start` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — `Link-maintenance_start` — whether a maintenance event starts in this snapshot | -| $`\mu^{f,\mathrm{nom}}`$ | `Link_maintenance_capacity` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — `Link-maintenance_capacity` — the chosen build while in maintenance, zero otherwise: the product the `maintcap` rows linearize | -| $`\mu^{f,u}`$ | `Link_maintenance_status` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — `Link-maintenance_status` — the status while in maintenance, zero otherwise: the product the `maint-status` rows linearize, so a unit in maintenance may also be off | +| $`\mu^{f}`$ | `Link_maintenance` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$, `neutral` where the mask leaves it out — `Link-maintenance` — whether a maintainable link is in maintenance: continuous, and one exactly where an event covers the snapshot | +| $`\mu^{f,\mathrm{up}}`$ | `Link_maintenance_start` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$, `neutral` where the mask leaves it out — `Link-maintenance_start` — whether a maintenance event starts in this snapshot | +| $`\mu^{f,\mathrm{nom}}`$ | `Link_maintenance_capacity` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$, `neutral` where the mask leaves it out — `Link-maintenance_capacity` — the chosen build while in maintenance, zero otherwise: the product the `maintcap` rows linearize | +| $`\mu^{f,u}`$ | `Link_maintenance_status` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$, `neutral` where the mask leaves it out — `Link-maintenance_status` — the status while in maintenance, zero otherwise: the product the `maint-status` rows linearize, so a unit in maintenance may also be off | | $`N^{z}`$ | `Process_n_mod` over $`\mathcal{J}`$ — `Process-n_mod` — how many modules of an extendable modular build; none for a build that stands in no snapshot | | $`u^{z}`$ | `Process_status` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — `Process-status` — how much of a committable process is on: an integer the rows below cap at one, or at the module count where the build is modular | | $`\mathit{up}^{z}`$ | `Process_start_up` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — `Process-start_up` — how much of a committable process turns on this snapshot, capped as the status is | | $`\mathit{dn}^{z}`$ | `Process_shut_down` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — `Process-shut_down` — how much of a committable process turns off this snapshot, capped as the status is | -| $`\mu^{z}`$ | `Process_maintenance` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — `Process-maintenance` — whether a maintainable process is in maintenance: continuous, and one exactly where an event covers the snapshot | -| $`\mu^{z,\mathrm{up}}`$ | `Process_maintenance_start` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — `Process-maintenance_start` — whether a maintenance event starts in this snapshot | -| $`\mu^{z,\mathrm{nom}}`$ | `Process_maintenance_capacity` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — `Process-maintenance_capacity` — the chosen build while in maintenance, zero otherwise: the product the `maintcap` rows linearize | -| $`\mu^{z,u}`$ | `Process_maintenance_status` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — `Process-maintenance_status` — the status while in maintenance, zero otherwise: the product the `maint-status` rows linearize, so a unit in maintenance may also be off | +| $`\mu^{z}`$ | `Process_maintenance` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$, `neutral` where the mask leaves it out — `Process-maintenance` — whether a maintainable process is in maintenance: continuous, and one exactly where an event covers the snapshot | +| $`\mu^{z,\mathrm{up}}`$ | `Process_maintenance_start` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$, `neutral` where the mask leaves it out — `Process-maintenance_start` — whether a maintenance event starts in this snapshot | +| $`\mu^{z,\mathrm{nom}}`$ | `Process_maintenance_capacity` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$, `neutral` where the mask leaves it out — `Process-maintenance_capacity` — the chosen build while in maintenance, zero otherwise: the product the `maintcap` rows linearize | +| $`\mu^{z,u}`$ | `Process_maintenance_status` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$, `neutral` where the mask leaves it out — `Process-maintenance_status` — the status while in maintenance, zero otherwise: the product the `maint-status` rows linearize, so a unit in maintenance may also be off | | $`s`$ | `Line_s` over $`\Xi \times \mathcal{T} \times \mathcal{K}`$ — `Line-s` — PyPSA's `p0`, the flow measured at the `Line_bus0` end: a positive value withdraws there and injects at `Line_bus1`, lossless | -| $`\ell`$ | `Line_loss` over $`\Xi \times \mathcal{T} \times \mathcal{K}`$ — `Line-loss` — what a line dissipates carrying its flow, pushed down by the cost and held up by the cuts; absent, and zero in the balance, where the network is lossless | +| $`\ell`$ | `Line_loss` over $`\Xi \times \mathcal{T} \times \mathcal{K}`$, `neutral` where the mask leaves it out — `Line-loss` — what a line dissipates carrying its flow, pushed down by the cost and held up by the cuts; absent, and zero in the balance, where the network is lossless | | $`\sigma`$ | `Transformer_s` over $`\Xi \times \mathcal{T} \times \mathcal{M}`$ — `Transformer-s` — PyPSA's `p0`, the flow measured at the `Transformer_bus0` end: a positive value withdraws there and injects at `Transformer_bus1`, lossless | -| $`\ell^{\sigma}`$ | `Transformer_loss` over $`\Xi \times \mathcal{T} \times \mathcal{M}`$ — `Transformer-loss` — what a transformer dissipates carrying its flow, as a line does; absent, and zero in the balance, where the network is lossless | -| $`\mathit{Transformer\_phase\_shift}`$ | `Transformer_phase_shift` over $`\Xi \times \mathcal{T} \times \mathcal{M}`$ — `Transformer-phase_shift` — a phase-shifting transformer's voltage angle shift in degrees, chosen per snapshot to redistribute the flows around its cycles without moving active power; absent, and zero in the cycle sum, where the shift is fixed | +| $`\ell^{\sigma}`$ | `Transformer_loss` over $`\Xi \times \mathcal{T} \times \mathcal{M}`$, `neutral` where the mask leaves it out — `Transformer-loss` — what a transformer dissipates carrying its flow, as a line does; absent, and zero in the balance, where the network is lossless | +| $`\mathit{Transformer\_phase\_shift}`$ | `Transformer_phase_shift` over $`\Xi \times \mathcal{T} \times \mathcal{M}`$, `neutral` where the mask leaves it out — `Transformer-phase_shift` — a phase-shifting transformer's voltage angle shift in degrees, chosen per snapshot to redistribute the flows around its cycles without moving active power; absent, and zero in the cycle sum, where the shift is fixed | | $`S`$ | `Line_s_nom_ext` over $`\mathcal{K}`$ — `Line-s_nom` — nominal apparent power where it is a decision; the parameter of the same PyPSA name carries the fixed regime | | $`N^{s}`$ | `Line_n_mod` over $`\mathcal{K}`$ — `Line-n_mod` — how many modules of an extendable modular build; none for a build that stands in no snapshot | | $`P`$ | `Generator_p_nom_ext` over $`\mathcal{G}`$ — `Generator-p_nom` — nominal power where it is a decision; the parameter of the same PyPSA name carries the fixed regime | diff --git a/docs/examples/pypsa/generator_maintenance.md b/docs/examples/pypsa/generator_maintenance.md index 807918097..9f0095b50 100644 --- a/docs/examples/pypsa/generator_maintenance.md +++ b/docs/examples/pypsa/generator_maintenance.md @@ -254,10 +254,10 @@ assumptions: | Symbol | Meaning | |---|---| -| $`\mu`$ | `Generator_maintenance` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — `Generator-maintenance` — whether a maintainable generator is in maintenance: continuous, and one exactly where an event covers the snapshot | -| $`\mu^{\mathrm{up}}`$ | `Generator_maintenance_start` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — `Generator-maintenance_start` — whether a maintenance event starts in this snapshot | -| $`\mu^{\mathrm{nom}}`$ | `Generator_maintenance_capacity` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — `Generator-maintenance_capacity` — the chosen build while in maintenance, zero otherwise: the product the `maintcap` rows linearize | -| $`\mu^{u}`$ | `Generator_maintenance_status` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$ — `Generator-maintenance_status` — the status while in maintenance, zero otherwise: the product the `maint-status` rows linearize, so a unit in maintenance may also be off | +| $`\mu`$ | `Generator_maintenance` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$, `neutral` where the mask leaves it out — `Generator-maintenance` — whether a maintainable generator is in maintenance: continuous, and one exactly where an event covers the snapshot | +| $`\mu^{\mathrm{up}}`$ | `Generator_maintenance_start` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$, `neutral` where the mask leaves it out — `Generator-maintenance_start` — whether a maintenance event starts in this snapshot | +| $`\mu^{\mathrm{nom}}`$ | `Generator_maintenance_capacity` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$, `neutral` where the mask leaves it out — `Generator-maintenance_capacity` — the chosen build while in maintenance, zero otherwise: the product the `maintcap` rows linearize | +| $`\mu^{u}`$ | `Generator_maintenance_status` over $`\Xi \times \mathcal{T} \times \mathcal{G}`$, `neutral` where the mask leaves it out — `Generator-maintenance_status` — the status while in maintenance, zero otherwise: the product the `maint-status` rows linearize, so a unit in maintenance may also be off | #### Given diff --git a/docs/examples/pypsa/line.md b/docs/examples/pypsa/line.md index c6fbd98e4..0ec597996 100644 --- a/docs/examples/pypsa/line.md +++ b/docs/examples/pypsa/line.md @@ -381,7 +381,7 @@ constraints: | Symbol | Meaning | |---|---| | $`s`$ | `Line_s` over $`\Xi \times \mathcal{T} \times \mathcal{K}`$ — `Line-s` — PyPSA's `p0`, the flow measured at the `Line_bus0` end: a positive value withdraws there and injects at `Line_bus1`, lossless | -| $`\ell`$ | `Line_loss` over $`\Xi \times \mathcal{T} \times \mathcal{K}`$ — `Line-loss` — what a line dissipates carrying its flow, pushed down by the cost and held up by the cuts; absent, and zero in the balance, where the network is lossless | +| $`\ell`$ | `Line_loss` over $`\Xi \times \mathcal{T} \times \mathcal{K}`$, `neutral` where the mask leaves it out — `Line-loss` — what a line dissipates carrying its flow, pushed down by the cost and held up by the cuts; absent, and zero in the balance, where the network is lossless | | $`S`$ | `Line_s_nom_ext` over $`\mathcal{K}`$ — `Line-s_nom` — nominal apparent power where it is a decision; the parameter of the same PyPSA name carries the fixed regime | | $`N^{s}`$ | `Line_n_mod` over $`\mathcal{K}`$ — `Line-n_mod` — how many modules of an extendable modular build; none for a build that stands in no snapshot | diff --git a/docs/examples/pypsa/link_maintenance.md b/docs/examples/pypsa/link_maintenance.md index ba292b2f6..f4630b47f 100644 --- a/docs/examples/pypsa/link_maintenance.md +++ b/docs/examples/pypsa/link_maintenance.md @@ -254,10 +254,10 @@ assumptions: | Symbol | Meaning | |---|---| -| $`\mu^{f}`$ | `Link_maintenance` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — `Link-maintenance` — whether a maintainable link is in maintenance: continuous, and one exactly where an event covers the snapshot | -| $`\mu^{f,\mathrm{up}}`$ | `Link_maintenance_start` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — `Link-maintenance_start` — whether a maintenance event starts in this snapshot | -| $`\mu^{f,\mathrm{nom}}`$ | `Link_maintenance_capacity` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — `Link-maintenance_capacity` — the chosen build while in maintenance, zero otherwise: the product the `maintcap` rows linearize | -| $`\mu^{f,u}`$ | `Link_maintenance_status` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$ — `Link-maintenance_status` — the status while in maintenance, zero otherwise: the product the `maint-status` rows linearize, so a unit in maintenance may also be off | +| $`\mu^{f}`$ | `Link_maintenance` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$, `neutral` where the mask leaves it out — `Link-maintenance` — whether a maintainable link is in maintenance: continuous, and one exactly where an event covers the snapshot | +| $`\mu^{f,\mathrm{up}}`$ | `Link_maintenance_start` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$, `neutral` where the mask leaves it out — `Link-maintenance_start` — whether a maintenance event starts in this snapshot | +| $`\mu^{f,\mathrm{nom}}`$ | `Link_maintenance_capacity` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$, `neutral` where the mask leaves it out — `Link-maintenance_capacity` — the chosen build while in maintenance, zero otherwise: the product the `maintcap` rows linearize | +| $`\mu^{f,u}`$ | `Link_maintenance_status` over $`\Xi \times \mathcal{T} \times \mathcal{L}`$, `neutral` where the mask leaves it out — `Link-maintenance_status` — the status while in maintenance, zero otherwise: the product the `maint-status` rows linearize, so a unit in maintenance may also be off | #### Given diff --git a/docs/examples/pypsa/process_maintenance.md b/docs/examples/pypsa/process_maintenance.md index a13354e35..650e6c95d 100644 --- a/docs/examples/pypsa/process_maintenance.md +++ b/docs/examples/pypsa/process_maintenance.md @@ -254,10 +254,10 @@ assumptions: | Symbol | Meaning | |---|---| -| $`\mu^{z}`$ | `Process_maintenance` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — `Process-maintenance` — whether a maintainable process is in maintenance: continuous, and one exactly where an event covers the snapshot | -| $`\mu^{z,\mathrm{up}}`$ | `Process_maintenance_start` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — `Process-maintenance_start` — whether a maintenance event starts in this snapshot | -| $`\mu^{z,\mathrm{nom}}`$ | `Process_maintenance_capacity` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — `Process-maintenance_capacity` — the chosen build while in maintenance, zero otherwise: the product the `maintcap` rows linearize | -| $`\mu^{z,u}`$ | `Process_maintenance_status` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ — `Process-maintenance_status` — the status while in maintenance, zero otherwise: the product the `maint-status` rows linearize, so a unit in maintenance may also be off | +| $`\mu^{z}`$ | `Process_maintenance` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$, `neutral` where the mask leaves it out — `Process-maintenance` — whether a maintainable process is in maintenance: continuous, and one exactly where an event covers the snapshot | +| $`\mu^{z,\mathrm{up}}`$ | `Process_maintenance_start` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$, `neutral` where the mask leaves it out — `Process-maintenance_start` — whether a maintenance event starts in this snapshot | +| $`\mu^{z,\mathrm{nom}}`$ | `Process_maintenance_capacity` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$, `neutral` where the mask leaves it out — `Process-maintenance_capacity` — the chosen build while in maintenance, zero otherwise: the product the `maintcap` rows linearize | +| $`\mu^{z,u}`$ | `Process_maintenance_status` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$, `neutral` where the mask leaves it out — `Process-maintenance_status` — the status while in maintenance, zero otherwise: the product the `maint-status` rows linearize, so a unit in maintenance may also be off | #### Given diff --git a/docs/examples/pypsa/storage_unit.md b/docs/examples/pypsa/storage_unit.md index 073dee023..7b6d9ffa5 100644 --- a/docs/examples/pypsa/storage_unit.md +++ b/docs/examples/pypsa/storage_unit.md @@ -562,7 +562,7 @@ assumptions: | $`h^{+}`$ | `StorageUnit_p_dispatch` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — `StorageUnit-p_dispatch` — power delivered to the bus | | $`h^{-}`$ | `StorageUnit_p_store` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — `StorageUnit-p_store` — power drawn from the bus into charge | | $`\mathit{soc}`$ | `StorageUnit_state_of_charge` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — `StorageUnit-state_of_charge` — energy held at the end of a snapshot | -| $`\mathit{spill}`$ | `StorageUnit_spill` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$ — `StorageUnit-spill` — inflow passed on unused. Zero where there is no inflow, so the balance keeps its row there; the bounds are PyPSA's, on the variable rather than as rows | +| $`\mathit{spill}`$ | `StorageUnit_spill` over $`\Xi \times \mathcal{T} \times \mathcal{S}`$, `neutral` where the mask leaves it out — `StorageUnit-spill` — inflow passed on unused. Zero where there is no inflow, so the balance keeps its row there; the bounds are PyPSA's, on the variable rather than as rows | | $`H`$ | `StorageUnit_p_nom_ext` over $`\mathcal{S}`$ — `StorageUnit-p_nom` — nominal power where it is a decision; the parameter of the same PyPSA name carries the fixed regime | | $`N^{h}`$ | `StorageUnit_n_mod` over $`\mathcal{S}`$ — `StorageUnit-n_mod` — how many modules of an extendable modular build; none for a build that stands in no snapshot | diff --git a/docs/examples/pypsa/transformer.md b/docs/examples/pypsa/transformer.md index e2679638f..c069fe0ab 100644 --- a/docs/examples/pypsa/transformer.md +++ b/docs/examples/pypsa/transformer.md @@ -396,8 +396,8 @@ constraints: | Symbol | Meaning | |---|---| | $`\sigma`$ | `Transformer_s` over $`\Xi \times \mathcal{T} \times \mathcal{M}`$ — `Transformer-s` — PyPSA's `p0`, the flow measured at the `Transformer_bus0` end: a positive value withdraws there and injects at `Transformer_bus1`, lossless | -| $`\ell^{\sigma}`$ | `Transformer_loss` over $`\Xi \times \mathcal{T} \times \mathcal{M}`$ — `Transformer-loss` — what a transformer dissipates carrying its flow, as a line does; absent, and zero in the balance, where the network is lossless | -| $`\mathit{Transformer\_phase\_shift}`$ | `Transformer_phase_shift` over $`\Xi \times \mathcal{T} \times \mathcal{M}`$ — `Transformer-phase_shift` — a phase-shifting transformer's voltage angle shift in degrees, chosen per snapshot to redistribute the flows around its cycles without moving active power; absent, and zero in the cycle sum, where the shift is fixed | +| $`\ell^{\sigma}`$ | `Transformer_loss` over $`\Xi \times \mathcal{T} \times \mathcal{M}`$, `neutral` where the mask leaves it out — `Transformer-loss` — what a transformer dissipates carrying its flow, as a line does; absent, and zero in the balance, where the network is lossless | +| $`\mathit{Transformer\_phase\_shift}`$ | `Transformer_phase_shift` over $`\Xi \times \mathcal{T} \times \mathcal{M}`$, `neutral` where the mask leaves it out — `Transformer-phase_shift` — a phase-shifting transformer's voltage angle shift in degrees, chosen per snapshot to redistribute the flows around its cycles without moving active power; absent, and zero in the cycle sum, where the shift is fixed | | $`\Sigma`$ | `Transformer_s_nom_ext` over $`\mathcal{M}`$ — `Transformer-s_nom` — nominal apparent power where it is a decision; the parameter of the same PyPSA name carries the fixed regime | | $`N^{\sigma}`$ | `Transformer_n_mod` over $`\mathcal{M}`$ — `Transformer-n_mod` — how many modules of an extendable modular build; none for a build that stands in no snapshot | diff --git a/docs/examples/pypsa_linearized_uc.md b/docs/examples/pypsa_linearized_uc.md index 612568ab8..6912486b5 100644 --- a/docs/examples/pypsa_linearized_uc.md +++ b/docs/examples/pypsa_linearized_uc.md @@ -380,9 +380,9 @@ The relaxed class of a plain `n.optimize()`: `linearized_unit_commitment`, state | $`u`$ | `Generator_status` over $`\mathcal{T} \times \mathcal{G}`$ — `Generator-status` — how much of a committable unit is on, a share in \[0, 1\] rather than a binary: the relaxation `linearized_unit_commitment` solves | | $`\mathit{up}`$ | `Generator_start_up` over $`\mathcal{T} \times \mathcal{G}`$ — `Generator-start_up` — how much of a committable unit turns on this snapshot | | $`\mathit{dn}`$ | `Generator_shut_down` over $`\mathcal{T} \times \mathcal{G}`$ — `Generator-shut_down` — how much of a committable unit turns off this snapshot | -| $`\mu`$ | `Generator_maintenance` over $`\mathcal{T} \times \mathcal{G}`$ — `Generator-maintenance` — whether a maintainable generator is in maintenance: continuous, and one exactly where an event covers the snapshot | -| $`\mu^{\mathrm{up}}`$ | `Generator_maintenance_start` over $`\mathcal{T} \times \mathcal{G}`$ — `Generator-maintenance_start` — whether a maintenance event starts in this snapshot — a binary, which the keyword does not relax | -| $`\mu^{u}`$ | `Generator_maintenance_status` over $`\mathcal{T} \times \mathcal{G}`$ — `Generator-maintenance_status` — the status while in maintenance, zero otherwise: the product the `maint-status` rows linearize, so a unit in maintenance may also be off | +| $`\mu`$ | `Generator_maintenance` over $`\mathcal{T} \times \mathcal{G}`$, `neutral` where the mask leaves it out — `Generator-maintenance` — whether a maintainable generator is in maintenance: continuous, and one exactly where an event covers the snapshot | +| $`\mu^{\mathrm{up}}`$ | `Generator_maintenance_start` over $`\mathcal{T} \times \mathcal{G}`$, `neutral` where the mask leaves it out — `Generator-maintenance_start` — whether a maintenance event starts in this snapshot — a binary, which the keyword does not relax | +| $`\mu^{u}`$ | `Generator_maintenance_status` over $`\mathcal{T} \times \mathcal{G}`$, `neutral` where the mask leaves it out — `Generator-maintenance_status` — the status while in maintenance, zero otherwise: the product the `maint-status` rows linearize, so a unit in maintenance may also be off | #### Definitions diff --git a/docs/reference/language/absence.md b/docs/reference/language/absence.md index dc208bfd3..6d3f4df89 100644 --- a/docs/reference/language/absence.md +++ b/docs/reference/language/absence.md @@ -127,6 +127,8 @@ At a storage with a store and no inflow, `balance` reads `inflow - soc == 0`. At a storage with inflow and no store, there is no row. `missing: neutral` needs a `where:`. It changes nothing inside a summing operator. +The typeset legend prints `` `neutral` where the mask leaves it out `` beside +the variable. ## Rows with no variable terms diff --git a/docs/reference/notation.md b/docs/reference/notation.md index 889963b6d..726df8c20 100644 --- a/docs/reference/notation.md +++ b/docs/reference/notation.md @@ -110,7 +110,7 @@ parameters: | $`\mathit{units}`$ | `units` over $`\mathcal{G}`$ | | $`\mathit{spare}`$ | `spare` over $`\mathcal{G}`$ | | $`\mathit{reserve}`$ | `reserve` (scalar) | -| $`\mathit{headroom}`$ | `headroom` (scalar) | +| $`\mathit{headroom}`$ | `headroom` (scalar), `neutral` where the mask leaves it out | | $`\mathit{weight}`$ | `weight` over $`\mathcal{T} \times \mathcal{G}`$ | | $`\mathit{fuel}`$ | `fuel` over $`\mathcal{T} \times \mathcal{G}`$ | | $`\mathit{heat}`$ | `heat` over $`\mathcal{T} \times \mathcal{G}`$ | @@ -262,13 +262,14 @@ variables: #### Scalar variable with a condition -scalar too, but masked, so the condition stands with no set beside it +scalar too, but masked, so the condition stands with no set beside it; neutral, so the legend names the reading ```yaml variables: headroom: dims: [] where: "budget" + missing: neutral bounds: { lower: 0 } ``` diff --git a/src/mathspec/typesetting/legend.py b/src/mathspec/typesetting/legend.py index 6e9c214d7..c58457791 100644 --- a/src/mathspec/typesetting/legend.py +++ b/src/mathspec/typesetting/legend.py @@ -121,6 +121,13 @@ def masks(mask: Mask) -> None: return Noticed(frozenset(policies), grouped, frozenset(positions), frozenset(numeric)) +#: Where a parameter's or a relation's ``missing:`` applies, as the legend says it. +_NO_ROW = 'where the data has no row' + +#: Where a variable's ``missing:`` applies, as the legend says it. +_MASKED_OUT = 'where the mask leaves it out' + + @dataclass(frozen=True) class Legend: """The glossary and the notes, spelled with one program's symbols in one format.""" @@ -156,7 +163,11 @@ def glossaries(self, noticed: Noticed, defined: Iterable[str]) -> list[tuple[str for p, block in program.parameters.items() ] variables = [ - self._entry(self.symbols.name[v], f'{fmt.mono(v)}{self._over(list(block.dims))}', block.description) + self._entry( + self.symbols.name[v], + f'{fmt.mono(v)}{self._over(list(block.dims))}{self._missing(block.missing, "absent", _MASKED_OUT)}', + block.description, + ) for v, block in program.variables.items() ] given = [ @@ -219,15 +230,15 @@ def _over(self, dims: list[str]) -> str: product = self.format.joined([self.symbols.set[d] for d in dims], self._op('times')) return f' over {self.format.math(product)}' - def _missing(self, value: Missing | None) -> str: - """What a missing row means: the reading or the value ``missing:`` names, and nothing for ``refused``, the default.""" - if value is None or value == 'refused': + def _missing(self, value: Missing | None, default: str = 'refused', where: str = _NO_ROW) -> str: + """What *where* means: the reading or the value ``missing:`` names, and nothing for the *default* reading.""" + if value is None or value == default: return '' if isinstance(value, str | bool): shown = self.format.mono(str(value).lower()) else: shown = self.format.math(number(value, self.format)) - return f', {shown} where the data has no row' + return f', {shown} {where}' def _signature(self, name: str, lk: RelationDeclaration) -> str: """A relation in the legend: a function from its key sets to its value sets, or a relation inside the product.""" @@ -254,7 +265,7 @@ def _coords(self, dim: str, noticed: Noticed) -> str: maps = self.format.joined([self._signature(c, lk) for c, lk in carried.items()], '') clauses.append(f' with {self.format.math(maps)}') clauses.extend( - f', {self.format.mono(c)} is {self.format.mono("absent")} where the data has no row' + f', {self.format.mono(c)} is {self.format.mono("absent")} {_NO_ROW}' for c, lk in carried.items() if lk.missing == 'absent' ) diff --git a/tests/typesetting/golden/latex.out b/tests/typesetting/golden/latex.out index 3c6cd2175..3e115a959 100644 --- a/tests/typesetting/golden/latex.out +++ b/tests/typesetting/golden/latex.out @@ -48,7 +48,7 @@ \item[{$\mathit{units}$}] \texttt{units} over $\mathcal{G}$ \item[{$\mathit{spare}$}] \texttt{spare} over $\mathcal{G}$ \item[{$\mathit{reserve}$}] \texttt{reserve} (scalar) -\item[{$\mathit{headroom}$}] \texttt{headroom} (scalar) +\item[{$\mathit{headroom}$}] \texttt{headroom} (scalar), \texttt{neutral} where the mask leaves it out \item[{$\mathit{weight}$}] \texttt{weight} over $\mathcal{T} \times \mathcal{G}$ \item[{$\mathit{fuel}$}] \texttt{fuel} over $\mathcal{T} \times \mathcal{G}$ \item[{$\mathit{heat}$}] \texttt{heat} over $\mathcal{T} \times \mathcal{G}$ diff --git a/tests/typesetting/golden/markdown.out b/tests/typesetting/golden/markdown.out index d7b48f75f..072388fbc 100644 --- a/tests/typesetting/golden/markdown.out +++ b/tests/typesetting/golden/markdown.out @@ -47,7 +47,7 @@ every character a notation escapes, set as text: link\_to, 100% & \#1 costs \$5 | $`\mathit{units}`$ | `units` over $`\mathcal{G}`$ | | $`\mathit{spare}`$ | `spare` over $`\mathcal{G}`$ | | $`\mathit{reserve}`$ | `reserve` (scalar) | -| $`\mathit{headroom}`$ | `headroom` (scalar) | +| $`\mathit{headroom}`$ | `headroom` (scalar), `neutral` where the mask leaves it out | | $`\mathit{weight}`$ | `weight` over $`\mathcal{T} \times \mathcal{G}`$ | | $`\mathit{fuel}`$ | `fuel` over $`\mathcal{T} \times \mathcal{G}`$ | | $`\mathit{heat}`$ | `heat` over $`\mathcal{T} \times \mathcal{G}`$ | diff --git a/tests/typesetting/golden/model.yaml b/tests/typesetting/golden/model.yaml index 851a06848..9b95580e2 100644 --- a/tests/typesetting/golden/model.yaml +++ b/tests/typesetting/golden/model.yaml @@ -75,9 +75,10 @@ variables: reserve: # an empty dims: a scalar declaration, whose line carries no quantifier dims: [] bounds: { lower: 0 } - headroom: # scalar too, but masked, so the condition stands with no set beside it + headroom: # scalar too, but masked, so the condition stands with no set beside it; neutral, so the legend names the reading dims: [] where: "budget" + missing: neutral bounds: { lower: 0 } weight: # the family a sos runs along dims: [snapshot, generator] diff --git a/tests/typesetting/golden/typst.out b/tests/typesetting/golden/typst.out index a5ee587d0..c79ca66b1 100644 --- a/tests/typesetting/golden/typst.out +++ b/tests/typesetting/golden/typst.out @@ -39,7 +39,7 @@ every character a notation escapes, set as text: link\_to, 100% & \#1 costs \$5 / $italic("units")$: `units` over $cal(G)$ / $italic("spare")$: `spare` over $cal(G)$ / $italic("reserve")$: `reserve` (scalar) -/ $italic("headroom")$: `headroom` (scalar) +/ $italic("headroom")$: `headroom` (scalar), `neutral` where the mask leaves it out / $italic("weight")$: `weight` over $cal(T) times cal(G)$ / $italic("fuel")$: `fuel` over $cal(T) times cal(G)$ / $italic("heat")$: `heat` over $cal(T) times cal(G)$ diff --git a/tests/typesetting/test_symbols.py b/tests/typesetting/test_symbols.py index e06509ac3..6eabf2123 100644 --- a/tests/typesetting/test_symbols.py +++ b/tests/typesetting/test_symbols.py @@ -133,6 +133,26 @@ def test_the_legend_prints_what_a_missing_row_means(name: FormatName, fmt: Forma ) +#: The dispatch model with a masked variable of each reading. +MASKED = varied( + DISPATCH_MODEL, + **{ + 'parameters.on': {'dims': ['generator'], 'dtype': 'bool', 'missing': 'neutral'}, + 'variables.spill': {'dims': ['snapshot', 'generator'], 'where': 'on', 'missing': 'neutral'}, + 'variables.store': {'dims': ['snapshot', 'generator'], 'where': 'on'}, + }, +) + + +@EVERY_FORMAT +def test_the_legend_prints_what_a_masked_out_variable_means(name: FormatName, fmt: Format): + """The legend printed a parameter's reading and not a variable's, so `missing: neutral` on a variable was unseen.""" + out = typeset(MASKED, name) + [row] = [line for line in out.splitlines() if fmt.mono('spill') in line and ' over ' in line] + assert f'{fmt.mono("spill")} over ' in row and f', {fmt.mono("neutral")} where the mask leaves it out' in row, row + assert out.count('where the mask leaves it out') == 1, '`store` keeps the default, absent, and says nothing' + + #: The dispatch model with a curve on it, so one model has two readings and one #: table has to spell both. CURVED = varied( From daf87d9b4fbe50537960589ece0be3c7425e9541 Mon Sep 17 00:00:00 2001 From: Fabian Date: Tue, 6 Oct 2026 14:27:03 +0200 Subject: [PATCH 17/21] fix(language)!: a default `missing:` written out is the one left out, a patch may change a relation's, and a curve under `points:` declares its tables A parameter's and a relation's unwritten `missing:` is held as `refused`, so merge, override and the canonical text read both spellings as one. Fragments agree on a relation's `missing:`; a patch may change it, alone or restated. A values parameter of a curve with `points:` that reads `refused` is refused at load; the inferred `neutral` and the outside-reader refusal are gone. --- docs/howto/compose.md | 7 ++ docs/reference/language/declarations.md | 3 +- docs/reference/language/piecewise.md | 31 +++---- docs/reference/notation.md | 8 +- docs/reference/reading.md | 7 +- examples/piecewise_ragged.yaml | 2 + src/mathspec/composition.py | 22 ++++- src/mathspec/lowering.py | 56 +---------- src/mathspec/piecewise.py | 40 ++++---- src/mathspec/program.py | 5 +- src/mathspec/resolution.py | 6 +- src/mathspec/spec.py | 42 +++++---- .../expand/curve-adjacency-points/before.yaml | 4 +- tests/expand/curve-lp-points/before.yaml | 4 +- tests/test_composition.py | 77 ++++++++++++++- tests/test_expand.py | 93 +++---------------- tests/test_piecewise.py | 11 ++- tests/test_validation.py | 26 +++++- tests/typesetting/golden/latex.out | 4 +- tests/typesetting/golden/markdown.out | 4 +- tests/typesetting/golden/model.yaml | 4 +- tests/typesetting/golden/typst.out | 4 +- tests/typesetting/test_walk.py | 6 +- 23 files changed, 248 insertions(+), 218 deletions(-) diff --git a/docs/howto/compose.md b/docs/howto/compose.md index b7b4a35ad..7fe945150 100644 --- a/docs/howto/compose.md +++ b/docs/howto/compose.md @@ -202,6 +202,7 @@ which each component pins at its own port. | 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 the list is carried | | `ordered: true` on a shared dimension | it is a claim about the dimension rather than the dimension, so the dimension is ordered if one fragment says so | +| `missing:` on a shared relation | it is a claim about the data, and the fragments that declare the relation say the same one. `missing: refused` is the same as no `missing:` | | 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 | @@ -359,6 +360,7 @@ Given variable 'Generator_p' collides with the variable of the same name. Names | `null` under a section's name | it is refused | | a dimension or a relation | it is added, or restated as the base declares it | | `ordered:` on a restated dimension | `true` makes the dimension ordered; `false` over an ordered one is refused | +| `missing:` on a relation | it changes what a key the map leaves out means, as on a parameter | | an entry under one kind of `given:` | it is edited, added or removed like any declaration, and the other kinds stay | | `version`, `description` | the patch's value replaces the base's | | a field an earlier patch writes | the later patch's value replaces it | @@ -397,6 +399,11 @@ already step along it: patch 'unordered.yaml' says the dimension 'snapshot' is not ordered, where its base declares it ordered. A construct in the base may step along it, and a patch adds the claim of order but never withdraws it: leave `ordered` out of the patch. ``` +`missing:` is a claim about the data, not the coordinate space. A patch may +change it on a relation as on a parameter, and may name it alone: +`relations: {gen_bus: {missing: absent}}`. `null` puts the default, `refused`, +back. + ## A section set to `null` A `null` removes the declaration it names. A section holds declarations rather diff --git a/docs/reference/language/declarations.md b/docs/reference/language/declarations.md index cd43ffa6a..5ed0e7da0 100644 --- a/docs/reference/language/declarations.md +++ b/docs/reference/language/declarations.md @@ -95,7 +95,8 @@ A value has the parameter's dtype: A NaN, a quoted number and `missing: null` are refused. A `given:` parameter has no `missing:`. The file that declares the parameter owns it. A parameter a [`piecewise:`](piecewise.md#missing-breakpoints) block reads takes `missing:` -too, and declares it where a declaration outside the curve reads it. +too. A values parameter of a curve with `points:` declares one other than +`refused`. The typeset legend prints what a missing row means beside the parameter, such as `` `neutral` where the data has no row ``. It prints nothing for `refused`. diff --git a/docs/reference/language/piecewise.md b/docs/reference/language/piecewise.md index 55e4b496d..7f3d9b7bc 100644 --- a/docs/reference/language/piecewise.md +++ b/docs/reference/language/piecewise.md @@ -90,31 +90,29 @@ piecewise: - [op_cost, bp_y] ``` -A row missing from `bp_y` is still refused. Where the length is its own data, +Each values parameter declares `missing: neutral`, because its table stops +where the curve stops ([below](#missing-breakpoints)). A row missing from `bp_y` +where `bp_x` has one is still refused. Where the length is its own data, name a boolean parameter instead. The marked breakpoints are one consecutive run, anywhere on the axis. ### Missing breakpoints A parameter a block reads takes [`missing:`](declarations.md#a-missing-row) -like any other parameter. Where the file writes none: - -- **A values parameter that only curves with `points:` read is `neutral`.** The - curve stops where the mask stops, so the table has no rows past it by design, - and the curve reads none of them. -- **Every other parameter a block reads is `refused`**, the default. This is a - values parameter of a curve with no `points:`, and a boolean `points:` mask. - A mask that leaves out a breakpoint with no row declares `missing: neutral`. -- **A parameter that a declaration outside every curve also reads is refused at - load.** The curve says nothing about what a missing row means there, so the - file says it: +like any other parameter, and reads it alike inside and outside the curve. +**A values parameter of a curve with `points:` is not `refused`.** The curve +stops where the mask stops, so the table has no rows past it by design. +`refused` says that every row is there: the curve never runs short, and a +mask that names the table marks every breakpoint. The load is refused: ```text -parameter 'bp_y': piecewise 'cost_curve' reads it as breakpoints and constraint 'cost_cap' reads it too, so the file says what a missing row of it means. Declare missing: refused, absent, neutral, or a value of its dtype. +parameter 'bp_y' is refused where a row is missing, and piecewise 'cost_curve' reads it under points: 'bp_x', which stops the curve where its rows stop. Declare missing: neutral, absent, or a value of its dtype. ``` -The assumptions a `method:` implies read the parameters as the curve does, and -are not outside it. An `assumptions:` entry the file writes is. +Declare `missing: neutral` on each values parameter of the block. The +`_complete` assumption still refuses a missing row inside the mask. A +values parameter of a curve with no `points:`, and a boolean `points:` mask, +read the default, `refused`. ### `method` @@ -196,7 +194,6 @@ shows a spec before and after. - **Every name written out starts with the name of the block.** The weights of the curve `curve` are `curve_lam`. - **No formulation emits a parameter.** The same data attaches to a spec and its - expansion, and reads alike in both. The expansion writes `missing: neutral` on - each values parameter that [reads as `neutral`](#missing-breakpoints). + expansion, and reads alike in both. - **The assumptions a `method:` implies become `assumptions:` entries** with the same names. diff --git a/docs/reference/notation.md b/docs/reference/notation.md index 69e7cdabb..aa687e273 100644 --- a/docs/reference/notation.md +++ b/docs/reference/notation.md @@ -59,8 +59,8 @@ parameters: lead: { dims: [generator], dtype: int } budget: { dims: [] } # scalar: the legend says so rather than printing an empty product growth: { dims: [] } # the base of a power; the exponent is `lead`, a column - bp_x: { dims: [generator, bp] } # the x-axis of every curve below, and what a derived mask is read from - bp_y: { dims: [generator, bp] } + bp_x: { dims: [generator, bp], missing: neutral } # the x-axis of every curve below, and what a derived mask is read from + bp_y: { dims: [generator, bp], missing: neutral } bp_heat: { dims: [generator, bp] } bp_run: { dims: [generator, bp], dtype: bool } # how far each curve runs, so a block has a mask to print ``` @@ -93,8 +93,8 @@ parameters: | $`\mathrm{lead}`$ | `lead` over $`\mathcal{G}`$ | | $`\mathrm{budget}`$ | `budget` (scalar) | | $`\mathrm{growth}`$ | `growth` (scalar) | -| $`\mathrm{bp\_x}`$ | `bp_x` over $`\mathcal{G} \times \mathcal{A}`$ | -| $`\mathrm{bp\_y}`$ | `bp_y` over $`\mathcal{G} \times \mathcal{A}`$ | +| $`\mathrm{bp\_x}`$ | `bp_x` over $`\mathcal{G} \times \mathcal{A}`$, `neutral` where the data has no row | +| $`\mathrm{bp\_y}`$ | `bp_y` over $`\mathcal{G} \times \mathcal{A}`$, `neutral` where the data has no row | | $`\mathrm{bp\_heat}`$ | `bp_heat` over $`\mathcal{G} \times \mathcal{A}`$ | | $`\mathrm{bp\_run}`$ | `bp_run` over $`\mathcal{G} \times \mathcal{A}`$ | diff --git a/docs/reference/reading.md b/docs/reference/reading.md index 0bfcd3020..40b107f60 100644 --- a/docs/reference/reading.md +++ b/docs/reference/reading.md @@ -40,7 +40,7 @@ dimensions: bp: { dtype: int, ordered: true } parameters: bp_x: { dims: [generator, bp] } - bp_y: { dims: [generator, bp], missing: refused } # the assumption below reads it too + bp_y: { dims: [generator, bp] } variables: p: dims: [generator] @@ -121,9 +121,8 @@ written # "assumption 'cost_is_never_negative' does not hold for the data attac `program.parameters[name].missing` says what a missing row of that parameter means: `'refused'`, `'absent'`, `'neutral'`, or the value it reads as. It is -`None` for a given parameter, whose declaring file says. A values parameter -that only curves with `points:` read, with no `missing:` written, is -`'neutral'`. The program of `spec.expand(...)` reports the same readings. `program.relations[name].missing` is `'refused'` or `'absent'`, and +`None` for a given parameter, whose declaring file says. The program of +`spec.expand(...)` reports the same readings. `program.relations[name].missing` is `'refused'` or `'absent'`, and `None` for a bare relation. Under `'refused'` the engine does not build the model from a table with a missing row, and names the coordinate; whether it raises at the first gap or lists them all is its own. A value is read wherever a value is read: an expression, diff --git a/examples/piecewise_ragged.yaml b/examples/piecewise_ragged.yaml index de512d964..f6804da18 100644 --- a/examples/piecewise_ragged.yaml +++ b/examples/piecewise_ragged.yaml @@ -37,9 +37,11 @@ parameters: bp_x: description: breakpoint dispatch levels, bound where `metered` is true dims: [generator, bp] + missing: neutral bp_y: description: cost at each breakpoint, bound where `metered` is true dims: [generator, bp] + missing: neutral variables: dispatch: diff --git a/src/mathspec/composition.py b/src/mathspec/composition.py index d69680684..3e5292337 100644 --- a/src/mathspec/composition.py +++ b/src/mathspec/composition.py @@ -27,6 +27,9 @@ descriptions of one dimension agree, and the first one given is carried. ``ordered`` is a claim about the space, not the space, so a dimension one fragment declares ordered is ordered. + A relation's ``missing:`` is a claim about the data, and the fragments + agree on it: ``absent`` and ``refused`` change the result differently, so + neither folds into the other. ``missing: refused`` is the same as none. * **Every other declaration is owned.** A name two fragments declare is refused, both named. * **One fragment sets the objective.** A second one is refused, both named. @@ -80,6 +83,8 @@ ``relations`` entry may be added or restated as the schema reads its base, never changed and never removed. A restated dimension may add ``ordered: true``, and may not write it false over a base that makes it. + A relation's ``missing:`` is a claim about the data, not the space: a + patch may change it as it changes a parameter's, and may name it alone. * ``null`` **makes what it names absent.** A declaration set to ``null`` is removed, and a removal of what the base does not declare is refused. A field set to ``null`` is dropped, and takes its default when the result loads: @@ -842,7 +847,9 @@ def _shared(declared: dict[str, object], patch: dict[str, object], section: str, reading of the coordinate space as one that names another value, and a field written at its default is no change. A dimension's ``ordered`` folds by [`_joined`][], so a patch may add the claim; writing it false over a - base that makes it is the one narrowing [`_joined`][] cannot see. + base that makes it is the one narrowing [`_joined`][] cannot see. A + relation's ``missing:`` is laid over as a field and left out of the + comparison, being a claim about the data. """ out = dict(declared) singular = _singular(section) @@ -856,6 +863,11 @@ def _shared(declared: dict[str, object], patch: dict[str, object], section: str, if key not in out: out[key] = block continue + if section == 'relations' and 'missing' in _mapping(block): + out[key] = _reread(out[key], _mapping(block)) + block = _without(block, 'missing') + if not block: + continue base, laid = _declared(section, out[key]), _declared(section, block) if base.get('ordered') and _mapping(block).get('ordered') is False: raise LanguageError( @@ -863,7 +875,7 @@ def _shared(declared: dict[str, object], patch: dict[str, object], section: str, f'ordered. A construct in the base may step along it, and a patch adds the claim of order ' f'but never withdraws it: leave `ordered` out of the patch.' ) - if (joined := _joined(section, base, laid, lambda written: written)) is None: + if (joined := _joined(section, base, laid, lambda written: _without(written, 'missing'))) is None: raise LanguageError( f"patch '{name}' declares the {singular} '{key}' as {block!r}, where its base " f'declares {out[key]!r}. A patch adjusts the math, not the coordinate space the math is ' @@ -874,6 +886,12 @@ def _shared(declared: dict[str, object], patch: dict[str, object], section: str, return out +def _reread(declared: object, patch: dict[str, object]) -> dict[str, object]: + """*declared* reading a missing key as *patch* says, where ``null`` puts back the default.""" + kept = _mapping(_without(declared, 'missing')) + return kept if patch['missing'] is None else {**kept, 'missing': patch['missing']} + + def _owned( declared: dict[str, object], patch: dict[str, object], label: str, cls: type[BaseModel], name: str ) -> dict[str, object]: diff --git a/src/mathspec/lowering.py b/src/mathspec/lowering.py index e83f5a52e..fa1ca962c 100644 --- a/src/mathspec/lowering.py +++ b/src/mathspec/lowering.py @@ -20,7 +20,7 @@ from mathspec.dimensions import check_schema, dims_of from mathspec.errors import SchemaError, did_you_mean, prefixed from mathspec.expansion import expand, parse_template -from mathspec.piecewise import assumptions_of, curve_frame, lp_domain_refusal, ragged, resolve_links +from mathspec.piecewise import assumptions_of, curve_frame, lp_domain_refusal, refused_under_points, resolve_links from mathspec.program import ( Assumption, BooleanLiteral, @@ -42,7 +42,6 @@ SosDeclaration, VariableDeclaration, VariableDefined, - names_under, variables_of, walk, ) @@ -58,7 +57,7 @@ from mathspec.validation import emitted_name_errors, reference_errors if TYPE_CHECKING: - from collections.abc import Iterable, Iterator, Mapping + from collections.abc import Iterable, Mapping from mathspec.program import Expression from mathspec.spec import AssumptionBlock, Spec @@ -216,12 +215,9 @@ 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 - shortened = ragged(schema) program = Program( parameters={ - name: ParameterDeclaration( - tuple(pdef.dims), pdef.dtype, 'neutral' if name in shortened else pdef.reading, pdef.description - ) + name: ParameterDeclaration(tuple(pdef.dims), pdef.dtype, pdef.missing, pdef.description) for name, pdef in schema.parameters.items() }, variables=variables, @@ -267,56 +263,12 @@ def lower(schema: Spec) -> Program: ), description=schema.description, ) - if errors := [*emitted_name_errors(schema, program), *_curve_parameters_read_outside(schema, program)]: + if errors := [*emitted_name_errors(schema, program), *refused_under_points(schema)]: raise SchemaError('\n'.join(errors)) check_schema(schema, program) return program -def _curve_parameters_read_outside(schema: Spec, program: Program) -> Iterator[str]: - """A refusal for each parameter a curve reads that a declaration outside every curve reads too, with no ``missing:``. - - The curve decides what a missing breakpoint means only for its own rows, - so the file says what a missing row means where something else reads it. - The assumptions a method implies are the curve's, and are not outside it. - """ - curves = {name: block for block, pw in schema.piecewise.items() for name in sorted(pw.consumes)} - unwritten = {n for n in curves if n in schema.parameters and schema.parameters[n].missing is None} - if not unwritten: - return - readers: dict[str, str] = {} - for label, names in _readers(schema, program): - for name in sorted(names & unwritten): - readers.setdefault(name, label) - for name, reader in readers.items(): - yield ( - f"parameter '{name}': piecewise '{curves[name]}' reads it as breakpoints and {reader} reads it too, " - f'so the file says what a missing row of it means. Declare missing: refused, absent, neutral, or a ' - f'value of its dtype.' - ) - - -def _readers(schema: Spec, program: Program) -> Iterator[tuple[str, frozenset[str]]]: - """Each declaration outside the curves, labelled as a refusal names it, with the data it reads.""" - for name, variable in program.variables.items(): - bounds = [b for b in (variable.lower, variable.upper) if b is not None] - where = variable.where.names_read if variable.where is not None else frozenset() - yield f"variable '{name}'", names_under(*bounds) | where - for name, constraint in program.constraints.items(): - where = constraint.where.names_read if constraint.where is not None else frozenset() - yield f"constraint '{name}'", names_under(constraint.lhs, constraint.rhs) | where - if program.objective is not None: - yield 'the objective', names_under(program.objective.expression) - for name, entry in program.expressions.items(): - yield f"named expression '{name}'", names_under(entry.expression) - for name, mask in program.masks.items(): - yield f"mask '{name}'", mask.where.names_read - for name in schema.assumptions: - assumption = program.assumptions[name] - where = assumption.where.names_read if assumption.where is not None else frozenset() - yield f"assumption '{name}'", assumption.predicate.names_read | where - - def _terms(entries: Iterable[str], schema: Spec, ns: Namespace, errors: list[str]) -> list[NamedExpression]: """Every entry with ``adds_to:`` that loads as a term, with a refusal in *errors* for each other. diff --git a/src/mathspec/piecewise.py b/src/mathspec/piecewise.py index 0e037f51f..d9de03f43 100644 --- a/src/mathspec/piecewise.py +++ b/src/mathspec/piecewise.py @@ -26,7 +26,7 @@ from mathspec.spec import AssumptionBlock, Curvature, PiecewiseBlock, Spec, VariableBlock if TYPE_CHECKING: - from collections.abc import Iterable + from collections.abc import Iterable, Iterator from mathspec.program import Expression from mathspec.resolution import Namespace @@ -96,9 +96,9 @@ def assumptions_of(block: str, pw: PiecewiseDeclaration) -> dict[str, Assumption """What *block* assumes of its numbers, by the name the document prints and a refusal quotes. Every curve assumes its breakpoints are there, because a missing row does - not shorten a curve. Under ``points:`` its parameters read ``neutral``, so - a breakpoint missing inside the mask would read as a zero and sit the curve - on the origin. A curve has an x-axis only where two + not shorten a curve. Under ``points:`` its tables are not ``refused``, so + a breakpoint missing inside the mask would read as their ``missing:`` and, + under ``neutral``, sit the curve on the origin. A curve has an x-axis only where two links tie it, so the increasing condition — and the shape it is checked with — exist only there; ``lp`` alone needs a segment to state a line for; a mask must be one run. @@ -481,20 +481,24 @@ def _segment_lines(self) -> None: ) -def ragged(schema: Spec) -> frozenset[str]: - """The values parameters a missing row of which is a breakpoint the curve does not run through. +def refused_under_points(schema: Spec) -> Iterator[str]: + """A refusal for each values parameter of a curve with ``points:`` that reads a missing row as ``refused``. - ``points:`` says the curve stops short of the dimension, and its weights - and segment rows read a values parameter only where the mask holds, so a - row is missing outside the mask by design: ``refused`` would refuse the - curve the block declares. Every other reading reads alike there, and - ``neutral`` is the one written. A parameter a curve over every breakpoint - also reads, a ``points:`` mask that is not a values parameter, and a - parameter whose ``missing:`` the file wrote are not among them. + ``points:`` says the curve stops short of the dimension, so its tables + have no row past the mask by design. ``refused`` says every row is + there: the curve never runs short, and a mask that names the table marks + every breakpoint. """ - shortened = {link.values for pw in schema.piecewise.values() if pw.points for link in pw.links} - complete = {n for pw in schema.piecewise.values() if not pw.points for n in pw.consumes} - return frozenset(n for n in shortened - complete if n in schema.parameters and schema.parameters[n].missing is None) + for block, pw in schema.piecewise.items(): + if pw.points is None: + continue + for name in dict.fromkeys(link.values for link in pw.links): + if schema.parameters[name].missing == 'refused': + yield ( + f"parameter '{name}' is refused where a row is missing, and piecewise '{block}' reads it under " + f"points: '{pw.points}', which stops the curve where its rows stop. Declare missing: neutral, " + f'absent, or a value of its dtype.' + ) def expand_piecewise(schema: Spec) -> Spec: @@ -505,8 +509,6 @@ def expand_piecewise(schema: Spec) -> Spec: binaries are what the method *is*, so the spec that comes back carries no set of its own ([`mathspec.sos.emit`][] is where they are spelled). Each block's frame and names are read off the program *schema* lowered to. - - Each parameter in [`ragged`][] is declared ``missing: neutral``. """ if not schema.piecewise: return schema @@ -516,8 +518,6 @@ def expand_piecewise(schema: Spec) -> Spec: raw.setdefault('constraints', {}) for name, pw in schema.piecewise.items(): _Block(schema, raw, name, pw, program.piecewise[name]).expand() - for parameter in ragged(schema): - raw['parameters'][parameter]['missing'] = 'neutral' raw['piecewise'].clear() for name, pw in schema.piecewise.items(): if pw.method == 'adjacency': diff --git a/src/mathspec/program.py b/src/mathspec/program.py index 3b3c25642..65c760b3d 100644 --- a/src/mathspec/program.py +++ b/src/mathspec/program.py @@ -114,7 +114,6 @@ 'carries_variable', 'children', 'is_quadratic', - 'names_under', 'parameters_of', 'variables_of', 'walk', @@ -1560,7 +1559,7 @@ def _atom_names(atom: TypedPredicate) -> frozenset[str]: case RelationPairComparison(): return frozenset({atom.name, atom.other}) case ExpressionComparison(): - return names_under(atom.left, atom.right) + return _names_under(atom.left, atom.right) case CountComparison(): return atom.predicate.names_read case TranslatedPredicate(): @@ -1573,7 +1572,7 @@ def _atom_names(atom: TypedPredicate) -> frozenset[str]: assert_never(atom) -def names_under(*expressions: Expression) -> frozenset[str]: +def _names_under(*expressions: Expression) -> frozenset[str]: """Every parameter and relation the data has to supply for *expressions* — what a mask's ``names_read`` promises. [`parameters_of`][] alone misses the data an operator reads beside its diff --git a/src/mathspec/resolution.py b/src/mathspec/resolution.py index 2163a37f5..d044e43f5 100644 --- a/src/mathspec/resolution.py +++ b/src/mathspec/resolution.py @@ -118,13 +118,11 @@ def __init__(self, schema: Spec) -> None: #: parameter name -> the value its ``missing:`` reads a missing row as, #: for the parameters that name one; what a where comparison reads there. self.defaults: dict[str, bool | float] = { - p: pd.missing - for p, pd in schema.parameters.items() - if pd.missing is not None and not isinstance(pd.missing, str) + p: pd.missing for p, pd in schema.parameters.items() if not isinstance(pd.missing, str) } #: relation name -> its columns and key, as declared. self.relations: dict[str, RelationDeclaration] = { - n: RelationDeclaration(lk.pairs, lk.key_roles, lk.reading, lk.description) + n: RelationDeclaration(lk.pairs, lk.key_roles, lk.missing, lk.description) for n, lk in schema.relations.items() } #: parameter or variable name -> the dims it is read through — diff --git a/src/mathspec/spec.py b/src/mathspec/spec.py index 5eea8e1fa..8d4d01190 100644 --- a/src/mathspec/spec.py +++ b/src/mathspec/spec.py @@ -32,7 +32,6 @@ from mathspec.errors import did_you_mean, schema_error from mathspec.program import ( DimensionDtype, - Missing, MissingReading, ObjectiveSense, ParameterDtype, @@ -116,6 +115,13 @@ def _reject_unknown_keys(cls, data: object) -> object: _READINGS = ', '.join(get_args(MissingReading)) +def _without_refused(written: dict[str, object]) -> dict[str, object]: + """*written* with ``missing: refused`` left out, so the default written out and left out write one text.""" + if written.get('missing') == 'refused': + written.pop('missing') + return written + + def _missing_null(kind: str, rewrite: str) -> ValueError: """``missing: null`` reads as either the default or an absent row, so it is refused for the word that says which.""" return ValueError(f'missing: null on {kind} names no reading. {rewrite}') @@ -186,6 +192,8 @@ class RelationBlock(_StrictBlock): key: str | list[str] | dict[str, str] values: str | list[str] | dict[str, str] | None = None + #: What a key the map leaves out means: ``refused`` where the file writes + #: nothing, and ``None`` for a bare relation, whose rows are its membership. missing: Annotated[RelationMissing | None, WithJsonSchema({'enum': list(get_args(RelationMissing))})] = Field( default=None, json_schema_extra={'default': 'refused'} ) @@ -209,21 +217,25 @@ def _refused_or_absent(cls, v: object) -> object: @model_validator(mode='after') def _a_bare_relation_has_no_gap(self) -> RelationBlock: - """A bare relation's rows are its membership, so a key it leaves out is not missing.""" + """A bare relation's rows are its membership, so a key it leaves out is not missing. + + A map left unwritten reads ``refused``, held as the value, so the spec + that writes the default and the one that leaves it out are one spec. + """ if self.missing is not None and self.values is None: msg = ( f'missing: {self.missing} on a relation with no `values:`. Its rows are its membership, ' f'so a key it leaves out is not missing: drop the key.' ) raise ValueError(msg) + if self.values is not None and self.missing is None: + self.missing = 'refused' return self - @property - def reading(self) -> RelationMissing | None: - """What a key the map leaves out means: ``refused`` unless the file says, and ``None`` for a bare relation.""" - if self.values is None: - return None - return self.missing or 'refused' + @model_serializer(mode='wrap') + def _as_written(self, handler: SerializerFunctionWrapHandler) -> dict[str, object]: + """``missing`` is written where it is not ``refused``: refused is what leaving it out says.""" + return _without_refused(cast('dict[str, object]', handler(self))) @property def pairs(self) -> tuple[tuple[str, str], ...]: @@ -288,9 +300,7 @@ class ParameterBlock(_StrictBlock): dims: list[str] dtype: ParameterDtype = 'float' - missing: Annotated[MissingReading | bool | int | float | None, WithJsonSchema(_MISSING_SCHEMA)] = Field( - default=None, json_schema_extra={'default': 'refused'} - ) + missing: Annotated[MissingReading | bool | int | float, WithJsonSchema(_MISSING_SCHEMA)] = 'refused' description: str | None = None @field_validator('missing', mode='before') @@ -311,16 +321,16 @@ def _a_reading_or_a_value(cls, v: object) -> object: raise ValueError(msg) return v - @property - def reading(self) -> Missing: - """What a missing row means: what the file wrote, or ``refused`` where it wrote nothing.""" - return 'refused' if self.missing is None else self.missing + @model_serializer(mode='wrap') + def _as_written(self, handler: SerializerFunctionWrapHandler) -> dict[str, object]: + """``missing`` is written where it is not ``refused``: refused is what leaving it out says.""" + return _without_refused(cast('dict[str, object]', handler(self))) @model_validator(mode='after') def _a_value_fits_the_dtype(self) -> ParameterBlock: """A value fills rows of the column, so it has the column's dtype.""" v = self.missing - if v is None or isinstance(v, str): + if isinstance(v, str): return self if self.dtype == 'str': msg = f'missing: {v!r} on a str parameter. A label has no value to fill: write {_READINGS}.' diff --git a/tests/expand/curve-adjacency-points/before.yaml b/tests/expand/curve-adjacency-points/before.yaml index 6573f78b4..7e4717d48 100644 --- a/tests/expand/curve-adjacency-points/before.yaml +++ b/tests/expand/curve-adjacency-points/before.yaml @@ -2,8 +2,8 @@ dimensions: bp: { dtype: int, ordered: true } parameters: - x_bp: { dims: [bp] } - y_bp: { dims: [bp] } + x_bp: { dims: [bp], missing: neutral } + y_bp: { dims: [bp], missing: neutral } variables: x: { dims: [], bounds: { lower: 0 } } diff --git a/tests/expand/curve-lp-points/before.yaml b/tests/expand/curve-lp-points/before.yaml index 8c9210046..50bfa949d 100644 --- a/tests/expand/curve-lp-points/before.yaml +++ b/tests/expand/curve-lp-points/before.yaml @@ -2,8 +2,8 @@ dimensions: bp: { dtype: int, ordered: true } parameters: - x_bp: { dims: [bp] } - y_bp: { dims: [bp] } + x_bp: { dims: [bp], missing: neutral } + y_bp: { dims: [bp], missing: neutral } variables: x: { dims: [], bounds: { lower: 0 } } diff --git a/tests/test_composition.py b/tests/test_composition.py index feb0b4593..0aa88b10e 100644 --- a/tests/test_composition.py +++ b/tests/test_composition.py @@ -25,7 +25,7 @@ import mathspec.spec as spec_module from mathspec import LanguageError, merge, override, to_markdown, to_spec from mathspec.canonical import canonical_yaml -from tests.fixtures import DISPATCH_MODEL, varied +from tests.fixtures import DISPATCH_MODEL, SMALL_MODEL, varied if TYPE_CHECKING: from pathlib import Path @@ -223,6 +223,40 @@ def test_a_dimension_one_fragment_declares_ordered_is_ordered_in_the_composition assert merge(fragments).dimensions['snapshot'].ordered, 'ordered is a claim one fragment adds to the space' +def _mapped(missing: str | None) -> dict[str, object]: + """A fragment that declares only `port_bus`, with *missing* written, or no `missing:` where it is ``None``.""" + relation = {'key': 'port', 'values': 'bus'} | ({} if missing is None else {'missing': missing}) + return {'dimensions': {'port': {'dtype': 'str'}, 'bus': {'dtype': 'str'}}, 'relations': {'port_bus': relation}} + + +@pytest.mark.parametrize( + ('fragments', 'missing'), + [ + pytest.param([SURFACE, _mapped('refused')], 'refused', id='the-default-written-out'), + pytest.param( + [varied(SURFACE, **{'relations.port_bus.missing': 'absent'}), _mapped('absent')], 'absent', id='both-absent' + ), + ], +) +def test_two_fragments_that_say_one_missing_agree(fragments, missing): + """A written `missing: refused` was refused against an omitted one, as two fragments saying different things.""" + assert merge(fragments).relations['port_bus'].missing == missing + + +@pytest.mark.parametrize( + 'fragments', + [ + pytest.param([SURFACE, _mapped('absent')], id='refused-against-absent'), + pytest.param([_mapped('absent'), SURFACE], id='absent-against-refused'), + ], +) +def test_two_fragments_that_read_a_missing_key_apart_are_refused(fragments): + """Neither reading settles the other: a key left out is refused under one, and belongs to no group under the other.""" + with pytest.raises(LanguageError, match=r"say different things about the relation 'port_bus'") as raised: + merge(fragments) + assert 'make the two identical' in str(raised.value), 'the refusal names the rewrite' + + @pytest.mark.parametrize( ('owner', 'carried'), [ @@ -560,6 +594,47 @@ def test_a_patch_that_rewrites_a_dimension_is_refused(patch, says): assert says in str(raised.value), 'the refusal names the rewrite rather than only what is wrong' +#: `SURFACE` with `port_bus` declared `missing: absent`. +ABSENT_SURFACE = varied(SURFACE, **{'relations.port_bus.missing': 'absent'}) + + +@pytest.mark.parametrize( + ('base', 'relation', 'missing'), + [ + pytest.param(SURFACE, {'missing': 'absent'}, 'absent', id='the-reading-alone'), + pytest.param(SURFACE, {'key': 'port', 'values': 'bus', 'missing': 'absent'}, 'absent', id='restated-absent'), + pytest.param(ABSENT_SURFACE, {'missing': 'refused'}, 'refused', id='back-to-the-default'), + pytest.param(SURFACE, {'key': 'port', 'values': 'bus', 'missing': 'refused'}, 'refused', id='default-written'), + pytest.param(ABSENT_SURFACE, {'key': 'port', 'values': 'bus'}, 'absent', id='restated-without-one'), + ], +) +def test_a_patch_changes_what_a_key_the_map_leaves_out_means(base, relation, missing): + """`missing:` is a claim about the data, not the coordinate space, and a patch that wrote it was refused as one.""" + assert override(base, [{'relations': {'port_bus': relation}}]).relations['port_bus'].missing == missing + + +def test_a_patch_changes_what_a_missing_row_of_a_parameter_means(): + assert ( + override(DISPATCH_MODEL, [{'parameters': {'cost': {'missing': 'neutral'}}}]).parameters['cost'].missing + == 'neutral' + ) + + +@pytest.mark.parametrize( + ('section', 'name', 'declared'), + [ + pytest.param('parameters', 'c', {'dims': ['g']}, id='a-parameter'), + pytest.param('relations', 'lk', {'key': 'g', 'values': 'h'}, id='a-relation'), + ], +) +def test_the_default_missing_written_out_is_the_spec_that_leaves_it_out(section, name, declared): + """`missing: refused` loaded to a different spec than no `missing:`, and wrote a different canonical text.""" + omitted = to_spec(varied(SMALL_MODEL, **{f'{section}.{name}': declared})) + written = to_spec(varied(SMALL_MODEL, **{f'{section}.{name}': {**declared, 'missing': 'refused'}})) + assert written == omitted, 'the default written out is the default' + assert canonical_yaml(written) == canonical_yaml(omitted), 'so both spellings write one text' + + @pytest.mark.parametrize( 'patch', [ diff --git a/tests/test_expand.py b/tests/test_expand.py index bd749bb64..c080c0a1b 100644 --- a/tests/test_expand.py +++ b/tests/test_expand.py @@ -19,7 +19,6 @@ import pytest from mathspec import piecewise, to_spec -from mathspec.errors import LanguageError from tests.fixtures import DISPATCH_MODEL, EXAMPLES, SMALL_MODEL, schema_of, varied from tests.test_sos import CURVE from tools.render_tex import models @@ -32,6 +31,8 @@ MASKED = varied( CURVE, **{ + 'parameters.bp_x.missing': 'neutral', + 'parameters.bp_y.missing': 'neutral', 'piecewise.cost_curve.method': 'lp', 'piecewise.cost_curve.points': 'bp_x', 'piecewise.cost_curve.links': [['p', 'bp_x'], ['op_cost', 'bp_y', '>=']], @@ -192,30 +193,18 @@ def test_a_model_with_no_formulation_expands_to_itself(): } -@pytest.mark.parametrize( - ('points', 'missing'), - [ - pytest.param({'piecewise.curve.points': 'bx'}, 'neutral', id='a-curve-that-says-how-far-it-runs'), - pytest.param({}, 'refused', id='a-curve-over-every-breakpoint'), - ], -) -def test_the_expansion_says_what_a_missing_breakpoint_means(points, missing): - """A curve's own parameters take no `missing:`, because the block owns their shape. The expansion keeps - no block: its weights stand on `points:` and its assumptions ask for a value only where the mask holds. Left - unwritten, the expansion would declare `refused`, and a consumer attaching the rows would refuse the ragged curve - the block admits; a curve with no `points:` reads every breakpoint.""" - rows = schema_of(varied(SMALL_MODEL, **copy.deepcopy(SMALL_CURVE), **points)).expand('piecewise').program - assert {name: rows.parameters[name].missing for name in ('bx', 'by', 'c')} == { - 'bx': missing, - 'by': missing, - 'c': 'refused', - }, "the expansion reads the curve's tables as the block did, and every other parameter as declared" +#: A curve under `points:` names what a missing row of each values table means. +UNDER_POINTS = {'piecewise.curve.points': 'bx', 'parameters.bx.missing': 'neutral', 'parameters.by.missing': 'neutral'} @pytest.mark.parametrize( 'points', [ - pytest.param({'piecewise.curve.points': 'bx'}, id='a-curve-that-says-how-far-it-runs'), + pytest.param(UNDER_POINTS, id='a-curve-that-says-how-far-it-runs'), + pytest.param( + {**UNDER_POINTS, 'parameters.run': {'dims': ['h'], 'dtype': 'bool'}, 'piecewise.curve.points': 'run'}, + id='a-boolean-mask', + ), pytest.param({}, id='a-curve-over-every-breakpoint'), ], ) @@ -227,71 +216,19 @@ def test_a_spec_and_its_expansion_read_a_missing_row_alike(points): assert declared == expanded, 'a spec and its expansion read a missing row of every parameter alike' -#: A row outside the curve that reads one of its values parameters. -READ_OUTSIDE = {'constraints.cap': {'dims': ['h'], 'expression': 'r <= by'}} - - -@pytest.mark.parametrize( - 'points', - [ - pytest.param({'piecewise.curve.points': 'bx'}, id='a-curve-that-says-how-far-it-runs'), - pytest.param({}, id='a-curve-over-every-breakpoint'), - ], -) -@pytest.mark.parametrize( - ('outside', 'reader'), - [ - pytest.param(READ_OUTSIDE, "constraint 'cap'", id='a-constraint'), - pytest.param({'masks.steep': 'by > 1'}, "mask 'steep'", id='a-mask-no-where-names'), - ], -) -def test_a_curve_parameter_read_outside_the_curve_declares_missing(points, outside, reader): - """`r <= by` read a curve's values parameter with no `missing:` the author could set. - - The loader refused `missing:` on it, and the reading came from the curve: `None` from lowering, then - `neutral` inferred by the expansion. Nothing in the file said what a missing row of `by` means in `cap`. - A mask no `where` names was not asked at all, although another file reads it under `given: masks:`. - """ - with pytest.raises(LanguageError) as caught: - to_spec(varied(SMALL_MODEL, **copy.deepcopy(SMALL_CURVE), **outside, **points)) - message = str(caught.value) - assert "parameter 'by'" in message, 'the message names the parameter that has to change' - assert "piecewise 'curve'" in message, 'and the curve that reads it' - assert reader in message, 'and the declaration outside the curve that reads it too' - assert 'missing: refused, absent, neutral, or a value' in message, 'and names the rewrite' - - -@pytest.mark.parametrize('missing', ['refused', 'absent', 'neutral', 0]) +@pytest.mark.parametrize('missing', ['absent', 'neutral', 0]) def test_a_curve_parameter_takes_the_missing_it_declares(missing): """`missing:` on a curve's parameter was refused at load, so a row outside the curve could not say what it reads.""" spec = to_spec( varied( SMALL_MODEL, **copy.deepcopy(SMALL_CURVE), - **READ_OUTSIDE, - **{'piecewise.curve.points': 'bx', 'parameters.by.missing': missing}, + **{ + **UNDER_POINTS, + 'constraints.cap': {'dims': ['h'], 'expression': 'r <= by'}, + 'parameters.by.missing': missing, + }, ) ) assert spec.program.parameters['by'].missing == missing assert spec.expand('piecewise').program.parameters['by'].missing == missing - - -def test_a_boolean_points_mask_reads_a_missing_row_as_its_declaration_says(): - """The expansion declared a boolean `points:` mask `neutral`, so a missing row was a breakpoint left out. - - The curve does not decide that: a table of `true` and `false` at every breakpoint is a mask as well. The mask - reads the default, `refused`, unless the file says otherwise. - """ - spec = schema_of( - varied( - SMALL_MODEL, - **copy.deepcopy(SMALL_CURVE), - **{'parameters.run': {'dims': ['h'], 'dtype': 'bool'}, 'piecewise.curve.points': 'run'}, - ) - ) - for program in (spec.program, spec.expand('piecewise').program): - assert {name: program.parameters[name].missing for name in ('bx', 'by', 'run')} == { - 'bx': 'neutral', - 'by': 'neutral', - 'run': 'refused', - }, 'a values table under points: stops short of the dimension, and the mask reads as declared' diff --git a/tests/test_piecewise.py b/tests/test_piecewise.py index 4c2cfae75..300a70eed 100644 --- a/tests/test_piecewise.py +++ b/tests/test_piecewise.py @@ -70,8 +70,10 @@ 'variables.running': {'dims': ['snapshot'], 'domain': 'binary'}, }, ) +#: The values tables of a curve under ``points:``, which stop where the curve stops. +RAGGED = {'parameters.bp_x.missing': 'neutral', 'parameters.bp_y.missing': 'neutral'} #: The ``lp`` curve masked by one of its own values-parameters, so every check a block can carry is on it. -LP_MASKED = varied(LP, **{'piecewise.cost_curve.points': 'bp_x'}) +LP_MASKED = varied(LP, **RAGGED, **{'piecewise.cost_curve.points': 'bp_x'}) #: Two dims in the frame, so the emitted ``dims`` has an order to get wrong. TWO_DIM = varied( raw_of(NONCONVEX_YAML), @@ -575,7 +577,11 @@ def test_a_masked_lp_curve_sits_its_rows_on_predicates_rather_than_on_parameters def test_a_file_supplied_mask_is_what_the_contiguity_condition_reads(): """A ``points:`` naming a parameter the file declared is bound like any other, and the mask check names it.""" program = expanded( - varied(LP, **{'parameters.reach': {'dims': ['bp'], 'dtype': 'bool'}, 'piecewise.cost_curve.points': 'reach'}), + varied( + LP, + **RAGGED, + **{'parameters.reach': {'dims': ['bp'], 'dtype': 'bool'}, 'piecewise.cost_curve.points': 'reach'}, + ), 'piecewise', ).program @@ -603,6 +609,7 @@ def test_a_gap_is_explained_by_the_rows_the_method_writes(method, reason): ) spec = schema_of( NONCONVEX_YAML, + **RAGGED, **{ 'piecewise.cost_curve.method': method, 'piecewise.cost_curve.points': 'bp_x', diff --git a/tests/test_validation.py b/tests/test_validation.py index 5a52c8eba..82829bf8a 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -8,11 +8,12 @@ import copy import re +from pathlib import Path from typing import TYPE_CHECKING, Any import pytest -from mathspec._yaml import parse_yaml +from mathspec._yaml import parse_yaml, read_yaml from mathspec.errors import DimensionError, LanguageError, SchemaError from mathspec.program import DimensionPosition from mathspec.resolution import Namespace @@ -2384,6 +2385,29 @@ def test_a_parameter_missing_that_reads_nothing_is_refused(dtype, written, fragm assert fragment in message +#: A curve under `points: x_bp`, both values tables declared `missing: neutral`. +CURVE_UNDER_POINTS = Path(__file__).parent / 'expand' / 'curve-lp-points' / 'before.yaml' + + +@pytest.mark.parametrize('parameter', ['x_bp', 'y_bp']) +@pytest.mark.parametrize( + 'declared', + [ + pytest.param({'dims': ['bp']}, id='left-out'), + pytest.param({'dims': ['bp'], 'missing': 'refused'}, id='refused-written'), + ], +) +def test_a_values_table_under_points_is_not_refused(parameter, declared): + """`points: x_bp` with `x_bp` declared `refused` loaded, and named a mask every breakpoint is in. + + `refused` says the table has a row at every breakpoint, so the curve never ran short of the dimension, and the + refusal of a curve parameter read outside the curve suggested that reading. + """ + message = _refusal(read_yaml(CURVE_UNDER_POINTS), **{f'parameters.{parameter}': declared}) + assert f"parameter '{parameter}' is refused where a row is missing, and piecewise 'curve'" in message + assert 'Declare missing: neutral, absent, or a value of its dtype' in message, 'the refusal names the rewrite' + + def test_a_given_parameter_has_no_missing(): """The file that declares the parameter owns what a missing row means, as it owns a variable's bounds.""" message = _refusal(**{'given.parameters.d': {'dims': ['g'], 'missing': 'neutral'}}) diff --git a/tests/typesetting/golden/latex.out b/tests/typesetting/golden/latex.out index c8e11068d..f5b0bbaeb 100644 --- a/tests/typesetting/golden/latex.out +++ b/tests/typesetting/golden/latex.out @@ -32,8 +32,8 @@ \item[{$\mathrm{lead}$}] \texttt{lead} over $\mathcal{G}$ \item[{$\mathrm{budget}$}] \texttt{budget} (scalar) \item[{$\mathrm{growth}$}] \texttt{growth} (scalar) -\item[{$\mathrm{bp\_x}$}] \texttt{bp\_x} over $\mathcal{G} \times \mathcal{A}$ -\item[{$\mathrm{bp\_y}$}] \texttt{bp\_y} over $\mathcal{G} \times \mathcal{A}$ +\item[{$\mathrm{bp\_x}$}] \texttt{bp\_x} over $\mathcal{G} \times \mathcal{A}$, \texttt{neutral} where the data has no row +\item[{$\mathrm{bp\_y}$}] \texttt{bp\_y} over $\mathcal{G} \times \mathcal{A}$, \texttt{neutral} where the data has no row \item[{$\mathrm{bp\_heat}$}] \texttt{bp\_heat} over $\mathcal{G} \times \mathcal{A}$ \item[{$\mathrm{bp\_run}$}] \texttt{bp\_run} over $\mathcal{G} \times \mathcal{A}$ \end{description} diff --git a/tests/typesetting/golden/markdown.out b/tests/typesetting/golden/markdown.out index 06446602f..64a4e157c 100644 --- a/tests/typesetting/golden/markdown.out +++ b/tests/typesetting/golden/markdown.out @@ -30,8 +30,8 @@ every character a notation escapes, set as text: link\_to, 100% & \#1 costs \$5 | $`\mathrm{lead}`$ | `lead` over $`\mathcal{G}`$ | | $`\mathrm{budget}`$ | `budget` (scalar) | | $`\mathrm{growth}`$ | `growth` (scalar) | -| $`\mathrm{bp\_x}`$ | `bp_x` over $`\mathcal{G} \times \mathcal{A}`$ | -| $`\mathrm{bp\_y}`$ | `bp_y` over $`\mathcal{G} \times \mathcal{A}`$ | +| $`\mathrm{bp\_x}`$ | `bp_x` over $`\mathcal{G} \times \mathcal{A}`$, `neutral` where the data has no row | +| $`\mathrm{bp\_y}`$ | `bp_y` over $`\mathcal{G} \times \mathcal{A}`$, `neutral` where the data has no row | | $`\mathrm{bp\_heat}`$ | `bp_heat` over $`\mathcal{G} \times \mathcal{A}`$ | | $`\mathrm{bp\_run}`$ | `bp_run` over $`\mathcal{G} \times \mathcal{A}`$ | diff --git a/tests/typesetting/golden/model.yaml b/tests/typesetting/golden/model.yaml index 69604d4ac..6c0ea887a 100644 --- a/tests/typesetting/golden/model.yaml +++ b/tests/typesetting/golden/model.yaml @@ -44,8 +44,8 @@ parameters: lead: { dims: [generator], dtype: int } budget: { dims: [] } # scalar: the legend says so rather than printing an empty product growth: { dims: [] } # the base of a power; the exponent is `lead`, a column - bp_x: { dims: [generator, bp] } # the x-axis of every curve below, and what a derived mask is read from - bp_y: { dims: [generator, bp] } + bp_x: { dims: [generator, bp], missing: neutral } # the x-axis of every curve below, and what a derived mask is read from + bp_y: { dims: [generator, bp], missing: neutral } bp_heat: { dims: [generator, bp] } bp_run: { dims: [generator, bp], dtype: bool } # how far each curve runs, so a block has a mask to print diff --git a/tests/typesetting/golden/typst.out b/tests/typesetting/golden/typst.out index c389ee141..93f3eb4ad 100644 --- a/tests/typesetting/golden/typst.out +++ b/tests/typesetting/golden/typst.out @@ -25,8 +25,8 @@ every character a notation escapes, set as text: link\_to, 100% & \#1 costs \$5 / $upright("lead")$: `lead` over $cal(G)$ / $upright("budget")$: `budget` (scalar) / $upright("growth")$: `growth` (scalar) -/ $upright("bp_x")$: `bp_x` over $cal(G) times cal(A)$ -/ $upright("bp_y")$: `bp_y` over $cal(G) times cal(A)$ +/ $upright("bp_x")$: `bp_x` over $cal(G) times cal(A)$, `neutral` where the data has no row +/ $upright("bp_y")$: `bp_y` over $cal(G) times cal(A)$, `neutral` where the data has no row / $upright("bp_heat")$: `bp_heat` over $cal(G) times cal(A)$ / $upright("bp_run")$: `bp_run` over $cal(G) times cal(A)$ diff --git a/tests/typesetting/test_walk.py b/tests/typesetting/test_walk.py index beadadb46..de86a1ba1 100644 --- a/tests/typesetting/test_walk.py +++ b/tests/typesetting/test_walk.py @@ -1020,7 +1020,11 @@ def test_a_condition_a_method_states_is_a_line_that_may_be_asked_for_before_it_i id='a-bounded-link-states-one-side-of-the-curve', ), pytest.param( - {'piecewise.curve.points': 'reaches'}, + { + 'piecewise.curve.points': 'reaches', + 'parameters.bp_x.missing': 'neutral', + 'parameters.bp_y.missing': 'neutral', + }, r'\mathrm{pwl}_{b \in \mathcal{B} \,:\, \mathrm{reaches}_{b}}', id='points-narrows-the-breakpoints-to-the-ones-it-admits', ), From 3d53073690029505ed131dcd3a78785b7aef8d45 Mon Sep 17 00:00:00 2001 From: Fabian Date: Tue, 6 Oct 2026 14:27:29 +0200 Subject: [PATCH 18/21] fix(language): a str parameter refuses `missing: neutral`, as a relation does A label has no neutral value, so `neutral` on a str parameter is refused at load, naming refused or absent. The declarations page's comparison example reads `efficiency <= 1`, which holds at a row that reads 1, and a given parameter's docstring no longer says a declaration holds only frame and dtype. --- docs/reference/language/declarations.md | 20 ++++++++++---------- schema/mathspec.schema.json | 2 +- src/mathspec/spec.py | 17 ++++++++++------- tests/test_validation.py | 8 +++++++- 4 files changed, 28 insertions(+), 19 deletions(-) diff --git a/docs/reference/language/declarations.md b/docs/reference/language/declarations.md index 5ed0e7da0..473e73a9a 100644 --- a/docs/reference/language/declarations.md +++ b/docs/reference/language/declarations.md @@ -78,19 +78,19 @@ so is its own: it may raise at the first gap, or list every missing coordinate. A bare numeric name in a `where` asks whether the data has a row under every reading, so `where: p_nom_max` selects the rows the data gives. A comparison -reads the value: `where: efficiency < 1` is true at a missing row of the -parameter above. Under `absent` and `neutral`, a comparison at a missing row is -false. A bare `bool` name reads its value, so `where: active` reads `true` -there. +reads the value: `where: efficiency <= 1` is true at a missing row of +`efficiency` above, which reads `1` there. Under `absent` and `neutral`, a +comparison at a missing row is false. A bare `bool` name reads its value, so +`where: active` reads `true` there. A value has the parameter's dtype: -| `dtype` | a value | -| ------- | ------------------------------------------------------- | -| `float` | a number. `.inf`, `inf`, `-.inf` and `-inf` are numbers | -| `int` | an integer. An integer column cannot hold `inf` | -| `bool` | `true` or `false` | -| `str` | none. A label has no value to fill | +| `dtype` | a value | +| ------- | -------------------------------------------------------- | +| `float` | a number. `.inf`, `inf`, `-.inf` and `-inf` are numbers | +| `int` | an integer. An integer column cannot hold `inf` | +| `bool` | `true` or `false` | +| `str` | none. A label has no value to fill, and no `neutral` one | A NaN, a quoted number and `missing: null` are refused. A `given:` parameter has no `missing:`. The file that declares the parameter owns it. A parameter a diff --git a/schema/mathspec.schema.json b/schema/mathspec.schema.json index 46741545d..7343eafce 100644 --- a/schema/mathspec.schema.json +++ b/schema/mathspec.schema.json @@ -427,7 +427,7 @@ }, "GivenParameterBlock": { "additionalProperties": false, - "description": "Data this file reads and another file declares.\n\nIt says what a [`ParameterBlock`][] says, because the frame and the\ndtype are all a parameter declaration holds: a where compares against the\ndtype, and the dim rules read the frame.", + "description": "Data this file reads and another file declares.\n\nIt says the frame and the dtype of a [`ParameterBlock`][], which are what\nthis file reads: a where compares against the dtype, and the dim rules read\nthe frame. What a missing row means is the declaring file's ``missing:``.", "properties": { "description": { "anyOf": [ diff --git a/src/mathspec/spec.py b/src/mathspec/spec.py index 8d4d01190..05ba1673f 100644 --- a/src/mathspec/spec.py +++ b/src/mathspec/spec.py @@ -328,13 +328,16 @@ def _as_written(self, handler: SerializerFunctionWrapHandler) -> dict[str, objec @model_validator(mode='after') def _a_value_fits_the_dtype(self) -> ParameterBlock: - """A value fills rows of the column, so it has the column's dtype.""" + """A value fills rows of the column, so it has the column's dtype, and a label has no neutral value.""" v = self.missing + if self.dtype == 'str' and v not in ('refused', 'absent'): + msg = ( + f'missing: {v} on a str parameter, which takes refused or absent. A label has no value to fill, ' + f'and no neutral one.' + ) + raise ValueError(msg) if isinstance(v, str): return self - if self.dtype == 'str': - msg = f'missing: {v!r} on a str parameter. A label has no value to fill: write {_READINGS}.' - raise ValueError(msg) if self.dtype == 'bool' and not isinstance(v, bool): msg = f'missing: {v!r} on a bool parameter, which takes true or false.' raise ValueError(msg) @@ -431,9 +434,9 @@ def _neutral_needs_a_mask(self) -> VariableBlock: class GivenParameterBlock(_StrictBlock): """Data this file reads and another file declares. - It says what a [`ParameterBlock`][] says, because the frame and the - dtype are all a parameter declaration holds: a where compares against the - dtype, and the dim rules read the frame. + It says the frame and the dtype of a [`ParameterBlock`][], which are what + this file reads: a where compares against the dtype, and the dim rules read + the frame. What a missing row means is the declaring file's ``missing:``. """ _label: ClassVar[str] = 'a given parameter declaration' diff --git a/tests/test_validation.py b/tests/test_validation.py index 82829bf8a..c700eb8fb 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -2338,7 +2338,7 @@ def test_an_infinite_bound_is_refused_with_the_null_that_opens_a_side(side, valu [ pytest.param('float', None, 'refused', id='left-out-is-refused'), pytest.param('float', 'absent', 'absent', id='absent'), - pytest.param('str', 'neutral', 'neutral', id='neutral-on-a-label'), + pytest.param('str', 'absent', 'absent', id='absent-on-a-label'), pytest.param('float', 1, 1, id='a-number'), pytest.param('float', float('inf'), float('inf'), id='yaml-dot-inf'), pytest.param('float', 'inf', float('inf'), id='the-expression-spelling-of-inf'), @@ -2378,6 +2378,12 @@ def test_inf_and_dot_inf_load_as_one_number(): pytest.param('int', float('inf'), 'missing: inf on an int parameter', id='an-infinity-on-an-int'), pytest.param('bool', 1, 'missing: 1 on a bool parameter, which takes true or false', id='a-number-on-a-flag'), pytest.param('str', 1, 'A label has no value to fill', id='a-number-on-a-label'), + pytest.param( + 'str', + 'neutral', + 'missing: neutral on a str parameter, which takes refused or absent', + id='neutral-on-a-label', + ), ], ) def test_a_parameter_missing_that_reads_nothing_is_refused(dtype, written, fragment): From b002cc49ced448fd9a995fde3466e9ace8a2901c Mon Sep 17 00:00:00 2001 From: Fabian Date: Tue, 6 Oct 2026 14:27:29 +0200 Subject: [PATCH 19/21] docs: a relation is total unless it declares `missing: absent` The linear-maps page and the operator table said a relation is partial where a key tuple has no row; under the `refused` default it is total. --- docs/about/relations-as-linear-maps.md | 11 +++++++---- docs/reference/language/expressions.md | 2 +- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/about/relations-as-linear-maps.md b/docs/about/relations-as-linear-maps.md index e95304655..22d202299 100644 --- a/docs/about/relations-as-linear-maps.md +++ b/docs/about/relations-as-linear-maps.md @@ -16,7 +16,7 @@ dimensions: generator: { dtype: str } zone: { dtype: str } relations: - gen_zone: { key: [generator, snapshot], values: zone } + gen_zone: { key: [generator, snapshot], values: zone, missing: absent } parameters: zone_cap: { dims: [snapshot, zone] } variables: @@ -47,8 +47,10 @@ $`f: K \to V`$, and \mathbf{1}_R(k, v) = [\, f(k) = v \,]. ``` -The function is partial where a key tuple has no row. A bare relation is a -subset and nothing more. Above, `gen_zone` is the graph of +The function is total by default: a key tuple with no row is +[refused](../reference/language/relations.md#the-data-contract) when the data +is attached. Under `missing: absent`, as above, it is partial, and a key tuple +with no row is off its domain. A bare relation is a subset and nothing more. Above, `gen_zone` is the graph of $`f: \mathcal{G} \times \mathcal{T} \to \mathcal{Z}`$. The [data contract](../reference/language/relations.md#the-data-contract) makes it one: the loader checks one row per key tuple when the data is attached. @@ -182,7 +184,8 @@ consumer builds is not the matrix. undefined where $`f`$ is, so `at` is absent there and [absence spreads](../reference/language/absence.md#how-absence-travels) to the row. `where: gen_zone` on `looked_up` writes the domain of $`f`$ on the page, so a - reader sees which rows exist without opening the data. + reader sees which rows exist without opening the data. Under the default, + `refused`, the domain is every key tuple and there is nothing off it. ## Partitions and tests diff --git a/docs/reference/language/expressions.md b/docs/reference/language/expressions.md index d39626f2f..9d3bc5e02 100644 --- a/docs/reference/language/expressions.md +++ b/docs/reference/language/expressions.md @@ -147,7 +147,7 @@ QUOTED ::= "'" chars "'" | '"' chars '"' | ----------------------------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `name` (bare) | parameter | The value is defined here. A `bool` is its own answer. A `str` is defined wherever the table has a row. A number has to have a row and be finite, and `0.0` is a row: write `inflow != 0` for non-zero | | `name` (bare) | variable | The variable exists at this coordinate | -| `name` (bare) | relation | A row exists, read at the relation's key. A relation may be [partial](relations.md#the-data-contract), and this selects the labels that do map | +| `name` (bare) | relation | A row exists, read at the relation's key. A `missing: absent` relation is [partial](relations.md#the-data-contract), and this selects the labels that do map | | `name` (bare) | dimension | A load error. It would be true everywhere | | `name` (bare) | mask | The mask's predicate ([masks](named.md#masks)). A given mask is true or false at each coordinate, as a `bool` parameter is | | `name OP value` | parameter | Element-wise. A missing row with no `missing:` value compares false | From 500fe31c01180f497ac63ad248d098bb4d3ce2c6 Mon Sep 17 00:00:00 2001 From: Fabian Date: Tue, 6 Oct 2026 14:40:14 +0200 Subject: [PATCH 20/21] docs(pypsa): the CVaR columns from #849 read `missing: neutral`, and so do the risk preference's two scalars #849 wrote `absence: zero`, which this branch renames. `CVaR_omega` and `CVaR_alpha` have a row only where a risk preference is set, so under the `refused` default the bare name `where: CVaR_omega` would always hold. --- docs/examples/pypsa.md | 12 ++++++------ docs/examples/pypsa/cost.md | 17 +++++++++-------- docs/examples/pypsa/settings.md | 3 ++- examples/pypsa.yaml | 10 ++++++---- examples/pypsa/cost.yaml | 9 +++++---- examples/pypsa/settings.yaml | 1 + tests/test_pypsa_references.py | 2 +- 7 files changed, 30 insertions(+), 24 deletions(-) diff --git a/docs/examples/pypsa.md b/docs/examples/pypsa.md index 18aa71c20..4e260da72 100644 --- a/docs/examples/pypsa.md +++ b/docs/examples/pypsa.md @@ -5307,7 +5307,7 @@ file stated the CVaR columns always, and the rows and the refusal only where `omega > 0`, so this rung got the columns and neither the rows nor the refusal. Now the columns, the rows and the refusal stand where `CVaR_omega` has a row, and `0.0` is a row. Data prep writes it only where a risk -preference is set. The columns take `absence: zero`, so the objective term +preference is set. The columns take `missing: neutral`, so the objective term `CVaR_omega * CVaR` stands without them. | PyPSA | status | note | @@ -5581,8 +5581,8 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{sgn}^{\mathrm{load}}`$ | `Load_sign` over $`\mathcal{D}`$ — the sign a load's demand enters its bus's balance with — PyPSA's `sign`, `-1` unless given, `1` for a load that feeds its bus. PyPSA refuses one that differs by scenario (`constants.py:43`) | | $`\mathrm{on}^{\mathrm{load}}`$ | `Load_active` over $`\mathcal{D}`$ — whether a load stands in the model — PyPSA's `active`. A load has no build year and no lifetime, so the flag holds in every snapshot. PyPSA refuses one that differs by scenario (`constants.py:51`) | | $`\pi`$ | `scenario_weight` over $`\Xi`$ — PyPSA's `scenario_weightings.weight` — the probability of a future | -| $`\omega`$ | `CVaR_omega` (scalar) — PyPSA's `risk_preference['omega']` — the share of operating cost priced at the tail rather than in expectation. Data prep writes a row only where a risk preference is set. With none there are no CVaR variables or rows; with `omega = 0` PyPSA builds them, and the optimum is the risk-neutral one | -| $`\alpha`$ | `CVaR_alpha` (scalar) — PyPSA's `risk_preference['alpha']` — the confidence level; the tail holds the other `1 - alpha` of the probability | +| $`\omega`$ | `CVaR_omega` (scalar), `neutral` where the data has no row — PyPSA's `risk_preference['omega']` — the share of operating cost priced at the tail rather than in expectation. Data prep writes a row only where a risk preference is set. With none there are no CVaR variables or rows; with `omega = 0` PyPSA builds them, and the optimum is the risk-neutral one | +| $`\alpha`$ | `CVaR_alpha` (scalar), `neutral` where the data has no row — PyPSA's `risk_preference['alpha']` — the confidence level; the tail holds the other `1 - alpha` of the probability. Data prep writes a row where it writes `CVaR_omega` | | $`\mathrm{w}^{y}`$ | `period_weight_objective` over $`\mathcal{Y}`$ — PyPSA's `investment_period_weightings.objective` — what a period's cost weighs; PyPSA reads it only under `multi_investment_periods`, so data prep feeds one otherwise, whatever the column holds (`optimize.py:205-207`, `:264-266`) | | $`\mathrm{w}^{\mathrm{yr}}`$ | `period_weight_years` over $`\mathcal{Y}`$ — PyPSA's `investment_period_weightings.years` — what a period's energy weighs in a `primary_energy` or `operational_limit` row; PyPSA reads it only under `multi_investment_periods`, so data prep feeds one otherwise | | $`\mathrm{on}`$ | `Generator_active` over $`\mathcal{T} \times \mathcal{G}`$ — whether a generator stands in a snapshot's period — PyPSA's `active`, from build year and lifetime, data prep | @@ -5785,9 +5785,9 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`N^{h}`$ | `StorageUnit_n_mod` over $`\mathcal{S}`$ — `StorageUnit-n_mod` — how many modules of an extendable modular build; none for a build that stands in no snapshot | | $`E`$ | `Store_e_nom_ext` over $`\mathcal{V}`$ — `Store-e_nom` — nominal capacity where it is a decision; the parameter of the same PyPSA name carries the fixed regime | | $`N^{e}`$ | `Store_n_mod` over $`\mathcal{V}`$ — `Store-n_mod` — how many modules of an extendable modular build; none for a build that stands in no snapshot | -| $`a`$ | `CVaR_a` over $`\Xi`$ — `CVaR-a` — how far a scenario's operating cost exceeds the tail's start; nothing where it does not | -| $`\theta`$ | `CVaR_theta` (scalar) — `CVaR-theta` — where the tail starts, the value at risk | -| $`CVaR`$ | `CVaR` (scalar) — `CVaR` — the tail's average cost, what the objective prices at `omega` | +| $`a`$ | `CVaR_a` over $`\Xi`$, `neutral` where the mask leaves it out — `CVaR-a` — how far a scenario's operating cost exceeds the tail's start; nothing where it does not | +| $`\theta`$ | `CVaR_theta` (scalar), `neutral` where the mask leaves it out — `CVaR-theta` — where the tail starts, the value at risk | +| $`CVaR`$ | `CVaR` (scalar), `neutral` where the mask leaves it out — `CVaR` — the tail's average cost, what the objective prices at `omega` | #### Definitions diff --git a/docs/examples/pypsa/cost.md b/docs/examples/pypsa/cost.md index ecfe6f2e5..00c086e4e 100644 --- a/docs/examples/pypsa/cost.md +++ b/docs/examples/pypsa/cost.md @@ -15,27 +15,28 @@ dimensions: parameters: CVaR_alpha: - description: PyPSA's `risk_preference['alpha']` — the confidence level; the tail holds the other `1 - alpha` of the probability + description: PyPSA's `risk_preference['alpha']` — the confidence level; the tail holds the other `1 - alpha` of the probability. Data prep writes a row where it writes `CVaR_omega` dims: [] + missing: neutral variables: CVaR_a: description: "`CVaR-a` — how far a scenario's operating cost exceeds the tail's start; nothing where it does not" dims: [scenario] where: CVaR_omega - absence: zero + missing: neutral bounds: lower: 0 CVaR_theta: description: "`CVaR-theta` — where the tail starts, the value at risk" dims: [] where: CVaR_omega - absence: zero + missing: neutral CVaR: description: "`CVaR` — the tail's average cost, what the objective prices at `omega`" dims: [] where: CVaR_omega - absence: zero + missing: neutral given: parameters: @@ -73,15 +74,15 @@ constraints: | Symbol | Meaning | |---|---| -| $`\alpha`$ | `CVaR_alpha` (scalar) — PyPSA's `risk_preference['alpha']` — the confidence level; the tail holds the other `1 - alpha` of the probability | +| $`\alpha`$ | `CVaR_alpha` (scalar), `neutral` where the data has no row — PyPSA's `risk_preference['alpha']` — the confidence level; the tail holds the other `1 - alpha` of the probability. Data prep writes a row where it writes `CVaR_omega` | #### Variables | Symbol | Meaning | |---|---| -| $`a`$ | `CVaR_a` over $`\Xi`$ — `CVaR-a` — how far a scenario's operating cost exceeds the tail's start; nothing where it does not | -| $`\theta`$ | `CVaR_theta` (scalar) — `CVaR-theta` — where the tail starts, the value at risk | -| $`CVaR`$ | `CVaR` (scalar) — `CVaR` — the tail's average cost, what the objective prices at `omega` | +| $`a`$ | `CVaR_a` over $`\Xi`$, `neutral` where the mask leaves it out — `CVaR-a` — how far a scenario's operating cost exceeds the tail's start; nothing where it does not | +| $`\theta`$ | `CVaR_theta` (scalar), `neutral` where the mask leaves it out — `CVaR-theta` — where the tail starts, the value at risk | +| $`CVaR`$ | `CVaR` (scalar), `neutral` where the mask leaves it out — `CVaR` — the tail's average cost, what the objective prices at `omega` | #### Given diff --git a/docs/examples/pypsa/settings.md b/docs/examples/pypsa/settings.md index beee2e3e4..f2d61ba6b 100644 --- a/docs/examples/pypsa/settings.md +++ b/docs/examples/pypsa/settings.md @@ -41,6 +41,7 @@ parameters: CVaR_omega: description: PyPSA's `risk_preference['omega']` — the share of operating cost priced at the tail rather than in expectation. Data prep writes a row only where a risk preference is set. With none there are no CVaR variables or rows; with `omega = 0` PyPSA builds them, and the optimum is the risk-neutral one dims: [] + missing: neutral period_weight_objective: description: >- PyPSA's `investment_period_weightings.objective` — what a period's cost @@ -169,7 +170,7 @@ objective: |---|---| | $`\mathrm{w}`$ | `snapshot_weightings_objective` over $`\mathcal{T}`$ — PyPSA's `snapshot_weightings.objective` — hours a snapshot stands for in the cost | | $`\pi`$ | `scenario_weight` over $`\Xi`$ — PyPSA's `scenario_weightings.weight` — the probability of a future | -| $`\omega`$ | `CVaR_omega` (scalar) — PyPSA's `risk_preference['omega']` — the share of operating cost priced at the tail rather than in expectation. Data prep writes a row only where a risk preference is set. With none there are no CVaR variables or rows; with `omega = 0` PyPSA builds them, and the optimum is the risk-neutral one | +| $`\omega`$ | `CVaR_omega` (scalar), `neutral` where the data has no row — PyPSA's `risk_preference['omega']` — the share of operating cost priced at the tail rather than in expectation. Data prep writes a row only where a risk preference is set. With none there are no CVaR variables or rows; with `omega = 0` PyPSA builds them, and the optimum is the risk-neutral one | | $`\mathrm{w}^{y}`$ | `period_weight_objective` over $`\mathcal{Y}`$ — PyPSA's `investment_period_weightings.objective` — what a period's cost weighs; PyPSA reads it only under `multi_investment_periods`, so data prep feeds one otherwise, whatever the column holds (`optimize.py:205-207`, `:264-266`) | | $`\mathrm{w}^{\mathrm{yr}}`$ | `period_weight_years` over $`\mathcal{Y}`$ — PyPSA's `investment_period_weightings.years` — what a period's energy weighs in a `primary_energy` or `operational_limit` row; PyPSA reads it only under `multi_investment_periods`, so data prep feeds one otherwise | | $`\mathrm{w}^{\mathrm{sto}}`$ | `snapshot_weightings_stores` over $`\mathcal{T}`$ — PyPSA's `snapshot_weightings.stores` — hours a snapshot stands for in a storage balance | diff --git a/examples/pypsa.yaml b/examples/pypsa.yaml index ff8619928..84c131473 100644 --- a/examples/pypsa.yaml +++ b/examples/pypsa.yaml @@ -601,9 +601,11 @@ parameters: CVaR_omega: description: PyPSA's `risk_preference['omega']` — the share of operating cost priced at the tail rather than in expectation. Data prep writes a row only where a risk preference is set. With none there are no CVaR variables or rows; with `omega = 0` PyPSA builds them, and the optimum is the risk-neutral one dims: [] + missing: neutral CVaR_alpha: - description: PyPSA's `risk_preference['alpha']` — the confidence level; the tail holds the other `1 - alpha` of the probability + description: PyPSA's `risk_preference['alpha']` — the confidence level; the tail holds the other `1 - alpha` of the probability. Data prep writes a row where it writes `CVaR_omega` dims: [] + missing: neutral period_weight_objective: description: >- PyPSA's `investment_period_weightings.objective` — what a period's cost @@ -1836,19 +1838,19 @@ variables: description: "`CVaR-a` — how far a scenario's operating cost exceeds the tail's start; nothing where it does not" dims: [scenario] where: CVaR_omega - absence: zero + missing: neutral bounds: lower: 0 CVaR_theta: description: "`CVaR-theta` — where the tail starts, the value at risk" dims: [] where: CVaR_omega - absence: zero + missing: neutral CVaR: description: "`CVaR` — the tail's average cost, what the objective prices at `omega`" dims: [] where: CVaR_omega - absence: zero + missing: neutral expressions: Generator_previous_status: diff --git a/examples/pypsa/cost.yaml b/examples/pypsa/cost.yaml index 081e138f5..90de34271 100644 --- a/examples/pypsa/cost.yaml +++ b/examples/pypsa/cost.yaml @@ -8,27 +8,28 @@ dimensions: parameters: CVaR_alpha: - description: PyPSA's `risk_preference['alpha']` — the confidence level; the tail holds the other `1 - alpha` of the probability + description: PyPSA's `risk_preference['alpha']` — the confidence level; the tail holds the other `1 - alpha` of the probability. Data prep writes a row where it writes `CVaR_omega` dims: [] + missing: neutral variables: CVaR_a: description: "`CVaR-a` — how far a scenario's operating cost exceeds the tail's start; nothing where it does not" dims: [scenario] where: CVaR_omega - absence: zero + missing: neutral bounds: lower: 0 CVaR_theta: description: "`CVaR-theta` — where the tail starts, the value at risk" dims: [] where: CVaR_omega - absence: zero + missing: neutral CVaR: description: "`CVaR` — the tail's average cost, what the objective prices at `omega`" dims: [] where: CVaR_omega - absence: zero + missing: neutral given: parameters: diff --git a/examples/pypsa/settings.yaml b/examples/pypsa/settings.yaml index 261eb8da4..f97757a4a 100644 --- a/examples/pypsa/settings.yaml +++ b/examples/pypsa/settings.yaml @@ -34,6 +34,7 @@ parameters: CVaR_omega: description: PyPSA's `risk_preference['omega']` — the share of operating cost priced at the tail rather than in expectation. Data prep writes a row only where a risk preference is set. With none there are no CVaR variables or rows; with `omega = 0` PyPSA builds them, and the optimum is the risk-neutral one dims: [] + missing: neutral period_weight_objective: description: >- PyPSA's `investment_period_weightings.objective` — what a period's cost diff --git a/tests/test_pypsa_references.py b/tests/test_pypsa_references.py index 63e02f579..622dbbd99 100644 --- a/tests/test_pypsa_references.py +++ b/tests/test_pypsa_references.py @@ -212,7 +212,7 @@ def test_the_cvar_blocks_stand_exactly_where_a_risk_preference_is_set(section: s """ mask = getattr(BASE.program, section)[name].where assert mask is not None, 'the block stands only where a risk preference is set' - grid = _Grid({}, {id(atom): _subject_of(atom) for atom in mask.atoms}) + grid = _Grid({}, {id(atom): _subject_of(atom) for atom in mask.atoms}, {}) stands = { omega: _evaluate(mask.root, {Subject('param', 'CVaR_omega'): omega}, grid) for omega in (Special.NULL, 0.0, 0.3) } From 48f43e9a7a263f47f2909d82c0cfa5b18bea2a05 Mon Sep 17 00:00:00 2001 From: Fabian Date: Tue, 6 Oct 2026 15:10:47 +0200 Subject: [PATCH 21/21] fix(language): a bare int or str parameter under missing: refused is refused at load, and a missing refusal quotes YAML --- docs/reference/language/declarations.md | 2 +- docs/reference/language/expressions.md | 38 +++++++++---------- docs/reference/language/piecewise.md | 8 +++- src/mathspec/_where_resolver.py | 13 +++++++ src/mathspec/spec.py | 17 ++++++--- tests/test_composition.py | 20 +++++++--- tests/test_dimensions.py | 2 +- tests/test_exclusivity.py | 2 +- tests/test_validation.py | 49 +++++++++++++++++++++++-- 9 files changed, 113 insertions(+), 38 deletions(-) diff --git a/docs/reference/language/declarations.md b/docs/reference/language/declarations.md index 473e73a9a..fd8792e44 100644 --- a/docs/reference/language/declarations.md +++ b/docs/reference/language/declarations.md @@ -77,7 +77,7 @@ A consumer does not build the model from data with a refused row. How it says so is its own: it may raise at the first gap, or list every missing coordinate. A bare numeric name in a `where` asks whether the data has a row under every -reading, so `where: p_nom_max` selects the rows the data gives. A comparison +reading, so `where: p_nom_max` selects the rows the data gives. Under `refused`, every coordinate has a row, so a bare `float` name asks only "is it finite?", and a bare `int` or `str` name is a load error. A comparison reads the value: `where: efficiency <= 1` is true at a missing row of `efficiency` above, which reads `1` there. Under `absent` and `neutral`, a comparison at a missing row is false. A bare `bool` name reads its value, so diff --git a/docs/reference/language/expressions.md b/docs/reference/language/expressions.md index 9d3bc5e02..4326f0917 100644 --- a/docs/reference/language/expressions.md +++ b/docs/reference/language/expressions.md @@ -143,25 +143,25 @@ COLUMNS ::= NAME "[" NAME { "," NAME } "]" QUOTED ::= "'" chars "'" | '"' chars '"' ``` -| Written as | Names a… | Meaning | -| ----------------------------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `name` (bare) | parameter | The value is defined here. A `bool` is its own answer. A `str` is defined wherever the table has a row. A number has to have a row and be finite, and `0.0` is a row: write `inflow != 0` for non-zero | -| `name` (bare) | variable | The variable exists at this coordinate | -| `name` (bare) | relation | A row exists, read at the relation's key. A `missing: absent` relation is [partial](relations.md#the-data-contract), and this selects the labels that do map | -| `name` (bare) | dimension | A load error. It would be true everywhere | -| `name` (bare) | mask | The mask's predicate ([masks](named.md#masks)). A given mask is true or false at each coordinate, as a `bool` parameter is | -| `name OP value` | parameter | Element-wise. A missing row with no `missing:` value compares false | -| `name OP value` | dimension | A filter on the frame's own coordinate column | -| `name OP value`, `name[col] OP value` | relation | A filter on a value column, read at the relation's key. Name the column where the key determines several. A comparison reads one column | -| `name OP name`, `name[a] OP name[b]` | two relation columns | Legal where both relations are keyed over the same dimensions and both columns are over one dimension. `ends[bus0] != ends[bus1]` excludes a self-loop | -| `expression OP expression` | arithmetic over parameters | Coordinate by coordinate, over every dimension either side carries ([arithmetic in a comparison](#arithmetic-in-a-comparison)). A side with no value at a coordinate compares false | -| `position(name) OP i` | dimension | Where the row sits along the dimension's own order. `0` is first, and a negative number counts from the end | -| `position(name, within=relation[c])` | dimension | The same, counted within each group the relation makes | -| `count(where_expr, over=name) OP i` | a predicate | How many coordinates along the dimension the predicate admits ([counting what a predicate admits](#counting-what-a-predicate-admits)) | -| `shift(where_expr, along=name, offset=i)` | a predicate | The predicate read `i` coordinates back, and false where that vacates | -| `at(where_expr, by=relation[c])` | a predicate | The predicate read through the relation ([reading a predicate through a relation](#reading-a-predicate-through-a-relation)), and false where the relation has no row | -| `AND` `OR` `NOT` | — | Case-insensitive. `NOT` binds tighter than `AND`, and `AND` tighter than `OR` | -| `True` / `False` | — | `True` is the same as no `where`; `False` gives a declaration with no rows | +| Written as | Names a… | Meaning | +| ----------------------------------------- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `name` (bare) | parameter | The value is defined here. A `bool` is its own answer. A `str` is defined wherever the table has a row. A number has to have a row and be finite, and `0.0` is a row: write `inflow != 0` for non-zero. An `int` or `str` under `missing: refused` is a load error: it has a row at every coordinate | +| `name` (bare) | variable | The variable exists at this coordinate | +| `name` (bare) | relation | A row exists, read at the relation's key. A `missing: absent` relation is [partial](relations.md#the-data-contract), and this selects the labels that do map | +| `name` (bare) | dimension | A load error. It would be true everywhere | +| `name` (bare) | mask | The mask's predicate ([masks](named.md#masks)). A given mask is true or false at each coordinate, as a `bool` parameter is | +| `name OP value` | parameter | Element-wise. A missing row with no `missing:` value compares false | +| `name OP value` | dimension | A filter on the frame's own coordinate column | +| `name OP value`, `name[col] OP value` | relation | A filter on a value column, read at the relation's key. Name the column where the key determines several. A comparison reads one column | +| `name OP name`, `name[a] OP name[b]` | two relation columns | Legal where both relations are keyed over the same dimensions and both columns are over one dimension. `ends[bus0] != ends[bus1]` excludes a self-loop | +| `expression OP expression` | arithmetic over parameters | Coordinate by coordinate, over every dimension either side carries ([arithmetic in a comparison](#arithmetic-in-a-comparison)). A side with no value at a coordinate compares false | +| `position(name) OP i` | dimension | Where the row sits along the dimension's own order. `0` is first, and a negative number counts from the end | +| `position(name, within=relation[c])` | dimension | The same, counted within each group the relation makes | +| `count(where_expr, over=name) OP i` | a predicate | How many coordinates along the dimension the predicate admits ([counting what a predicate admits](#counting-what-a-predicate-admits)) | +| `shift(where_expr, along=name, offset=i)` | a predicate | The predicate read `i` coordinates back, and false where that vacates | +| `at(where_expr, by=relation[c])` | a predicate | The predicate read through the relation ([reading a predicate through a relation](#reading-a-predicate-through-a-relation)), and false where the relation has no row | +| `AND` `OR` `NOT` | — | Case-insensitive. `NOT` binds tighter than `AND`, and `AND` tighter than `OR` | +| `True` / `False` | — | `True` is the same as no `where`; `False` gives a declaration with no rows | A bare name that is not declared is a load error. diff --git a/docs/reference/language/piecewise.md b/docs/reference/language/piecewise.md index 7f3d9b7bc..b9cc22fed 100644 --- a/docs/reference/language/piecewise.md +++ b/docs/reference/language/piecewise.md @@ -109,8 +109,12 @@ mask that names the table marks every breakpoint. The load is refused: parameter 'bp_y' is refused where a row is missing, and piecewise 'cost_curve' reads it under points: 'bp_x', which stops the curve where its rows stop. Declare missing: neutral, absent, or a value of its dtype. ``` -Declare `missing: neutral` on each values parameter of the block. The -`_complete` assumption still refuses a missing row inside the mask. A +Declare `missing: neutral` or `absent` on each values parameter of the block. +The two build the same curve: the curve reads a table only inside the mask, +and there the `_complete` assumption still refuses a missing row. They +differ where an expression outside the curve reads the table: `neutral` reads +`0` there, and `absent` drops the term. Use `absent` where no number stands +for a breakpoint the curve does not have, and `neutral` where `0` does. A values parameter of a curve with no `points:`, and a boolean `points:` mask, read the default, `refused`. diff --git a/src/mathspec/_where_resolver.py b/src/mathspec/_where_resolver.py index 8fb2f08ad..bf2366e1d 100644 --- a/src/mathspec/_where_resolver.py +++ b/src/mathspec/_where_resolver.py @@ -151,6 +151,19 @@ def _where_name(self, node: NameNode) -> Predicate | UnresolvedWhereNode: return node match kind: case 'parameter': + declared = ns.schema.parameters.get(node.name) + if declared is not None and declared.missing == 'refused' and declared.dtype in ('int', 'str'): + rewrite = ( + f'Declare `missing: absent`, or compare the label: where: "{node.name} == \'...\'".' + if declared.dtype == 'str' + else f'Declare `missing: neutral` or `absent`, or compare the value: where: "{node.name} > 0".' + ) + self.errors.append( + f"{context}: '{node.name}' is declared dtype: {declared.dtype} and missing: refused, so it " + f'has a row at every coordinate and the bare name is true at every coordinate — the mask ' + f'has no effect. {rewrite}' + ) + return node return ParameterDefined(node.name, ns.leaf_dims[node.name]) case 'dimension': self.errors.append( diff --git a/src/mathspec/spec.py b/src/mathspec/spec.py index 05ba1673f..d27d5a85d 100644 --- a/src/mathspec/spec.py +++ b/src/mathspec/spec.py @@ -122,6 +122,11 @@ def _without_refused(written: dict[str, object]) -> dict[str, object]: return written +def _as_yaml(v: object) -> str: + """*v* as the file spells it, so a refusal quotes the line the author wrote.""" + return str(v).lower() if isinstance(v, bool) else str(v) + + def _missing_null(kind: str, rewrite: str) -> ValueError: """``missing: null`` reads as either the default or an absent row, so it is refused for the word that says which.""" return ValueError(f'missing: null on {kind} names no reading. {rewrite}') @@ -209,7 +214,7 @@ def _refused_or_absent(cls, v: object) -> object: ) if v not in get_args(RelationMissing): msg = ( - f'missing: {v!r} on a relation, which takes refused or absent. A label the map leaves out ' + f'missing: {_as_yaml(v)} on a relation, which takes refused or absent. A label the map leaves out ' f'is refused, or belongs to no group.' ) raise ValueError(msg) @@ -332,20 +337,20 @@ def _a_value_fits_the_dtype(self) -> ParameterBlock: v = self.missing if self.dtype == 'str' and v not in ('refused', 'absent'): msg = ( - f'missing: {v} on a str parameter, which takes refused or absent. A label has no value to fill, ' + f'missing: {_as_yaml(v)} on a str parameter, which takes refused or absent. A label has no value to fill, ' f'and no neutral one.' ) raise ValueError(msg) if isinstance(v, str): return self if self.dtype == 'bool' and not isinstance(v, bool): - msg = f'missing: {v!r} on a bool parameter, which takes true or false.' + msg = f'missing: {_as_yaml(v)} on a bool parameter, which takes true or false.' raise ValueError(msg) if self.dtype != 'bool' and isinstance(v, bool): - msg = f'missing: {str(v).lower()} on a {self.dtype} parameter, which takes a number. Write 1 or 0.' + msg = f'missing: {_as_yaml(v)} on a {self.dtype} parameter, which takes a number. Write 1 or 0.' raise ValueError(msg) if self.dtype == 'int' and isinstance(v, float): - msg = f'missing: {v} on an int parameter, which takes an integer. Write one, or declare dtype: float.' + msg = f'missing: {_as_yaml(v)} on an int parameter, which takes an integer. Write one, or declare dtype: float.' raise ValueError(msg) return self @@ -412,7 +417,7 @@ def _absent_or_neutral(cls, v: object) -> object: raise _missing_null('a variable', 'Write neutral, or leave the key out for absent.') if v not in get_args(VariableMissing): msg = ( - f'missing: {v!r} on a variable, which takes absent or neutral. A variable has no data: ' + f'missing: {_as_yaml(v)} on a variable, which takes absent or neutral. A variable has no data: ' f'refused and a value are readings of a parameter.' ) raise ValueError(msg) diff --git a/tests/test_composition.py b/tests/test_composition.py index 0aa88b10e..fdae211be 100644 --- a/tests/test_composition.py +++ b/tests/test_composition.py @@ -604,6 +604,7 @@ def test_a_patch_that_rewrites_a_dimension_is_refused(patch, says): pytest.param(SURFACE, {'missing': 'absent'}, 'absent', id='the-reading-alone'), pytest.param(SURFACE, {'key': 'port', 'values': 'bus', 'missing': 'absent'}, 'absent', id='restated-absent'), pytest.param(ABSENT_SURFACE, {'missing': 'refused'}, 'refused', id='back-to-the-default'), + pytest.param(ABSENT_SURFACE, {'missing': None}, 'refused', id='null-puts-back-the-default'), pytest.param(SURFACE, {'key': 'port', 'values': 'bus', 'missing': 'refused'}, 'refused', id='default-written'), pytest.param(ABSENT_SURFACE, {'key': 'port', 'values': 'bus'}, 'absent', id='restated-without-one'), ], @@ -613,11 +614,20 @@ def test_a_patch_changes_what_a_key_the_map_leaves_out_means(base, relation, mis assert override(base, [{'relations': {'port_bus': relation}}]).relations['port_bus'].missing == missing -def test_a_patch_changes_what_a_missing_row_of_a_parameter_means(): - assert ( - override(DISPATCH_MODEL, [{'parameters': {'cost': {'missing': 'neutral'}}}]).parameters['cost'].missing - == 'neutral' - ) +@pytest.mark.parametrize( + ('base', 'written', 'missing'), + [ + pytest.param(DISPATCH_MODEL, 'neutral', 'neutral', id='a-reading'), + pytest.param( + varied(DISPATCH_MODEL, **{'parameters.cost.missing': 'absent'}), + None, + 'refused', + id='null-puts-back-the-default', + ), + ], +) +def test_a_patch_changes_what_a_missing_row_of_a_parameter_means(base, written, missing): + assert override(base, [{'parameters': {'cost': {'missing': written}}}]).parameters['cost'].missing == missing @pytest.mark.parametrize( diff --git a/tests/test_dimensions.py b/tests/test_dimensions.py index 845b7e775..35cd76b62 100644 --- a/tests/test_dimensions.py +++ b/tests/test_dimensions.py @@ -629,7 +629,7 @@ def test_the_frame_check_and_the_reading_walk_the_same_leaves(namespace): ('predicate', 'expected'), [ pytest.param('p_max > 0', {'p_max'}, id='a-parameter-comparison-names-the-parameter'), - pytest.param('spinup', {'spinup'}, id='a-parameter-bare-names-the-parameter'), + pytest.param('cost', {'cost'}, id='a-parameter-bare-names-the-parameter'), pytest.param('p', {'p'}, id='a-variable-bare-names-the-variable'), pytest.param('snap_bus == "b1"', {'snap_bus'}, id='a-relation-comparison-names-the-relation'), pytest.param('gen_bus', {'gen_bus'}, id='a-relation-bare-names-the-relation'), diff --git a/tests/test_exclusivity.py b/tests/test_exclusivity.py index 3fa4b6b19..5018d6a72 100644 --- a/tests/test_exclusivity.py +++ b/tests/test_exclusivity.py @@ -40,7 +40,7 @@ 'parameters': { 'cyclic': {'dims': ['storage'], 'dtype': 'bool'}, 'committable': {'dims': ['storage'], 'dtype': 'bool'}, - 'kind': {'dims': ['storage'], 'dtype': 'str'}, + 'kind': {'dims': ['storage'], 'dtype': 'str', 'missing': 'absent'}, 'soc_initial': {'dims': ['storage']}, 'capacity': {'dims': ['storage']}, 'age': {'dims': ['storage'], 'dtype': 'int'}, diff --git a/tests/test_validation.py b/tests/test_validation.py index c700eb8fb..8bb76fb47 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -1579,6 +1579,27 @@ class TestRulesDecidedWithoutData: ('a bare dimension name is true at every coordinate',), id='where-a-bare-dimension', ), + pytest.param( + {'parameters.n': {'dims': ['g'], 'dtype': 'int'}, 'variables.p.where': 'n'}, + ( + "'n' is declared dtype: int and missing: refused", + 'Declare `missing: neutral` or `absent`, or compare the value', + ), + id='where-a-bare-int-parameter-under-refused', + ), + pytest.param( + {'variables.p.where': 'tag'}, + ( + "'tag' is declared dtype: str and missing: refused", + 'Declare `missing: absent`, or compare the label', + ), + id='where-a-bare-str-parameter-under-refused', + ), + pytest.param( + {'parameters.n': {'dims': ['g'], 'dtype': 'int'}, 'masks.held': 'NOT n'}, + ("'n' is declared dtype: int and missing: refused",), + id='a-mask-reading-a-bare-int-parameter-under-refused', + ), pytest.param( { 'macros.scaled': {'args': ['x'], 'kwargs': ['n'], 'template': 'x * n'}, @@ -2414,6 +2435,25 @@ def test_a_values_table_under_points_is_not_refused(parameter, declared): assert 'Declare missing: neutral, absent, or a value of its dtype' in message, 'the refusal names the rewrite' +@pytest.mark.parametrize( + 'patch', + [ + pytest.param({'parameters.n': {'dims': ['g']}}, id='a-float-may-hold-inf'), + pytest.param({'parameters.n': {'dims': ['g'], 'dtype': 'bool'}}, id='a-flag-reads-its-value'), + pytest.param( + {'parameters.n': {'dims': ['g'], 'dtype': 'int', 'missing': 'neutral'}}, id='an-int-under-neutral' + ), + pytest.param({'parameters.n': {'dims': ['g'], 'dtype': 'int', 'missing': 0}}, id='an-int-under-a-value'), + pytest.param({'parameters.n': {'dims': ['g'], 'dtype': 'str', 'missing': 'absent'}}, id='a-label-under-absent'), + pytest.param({'given.parameters.n': {'dims': ['g'], 'dtype': 'int'}}, id='a-given-int'), + ], +) +def test_a_bare_parameter_whose_answer_needs_the_data_loads(patch): + """The declaring file owns a given parameter's `missing:`, so this file cannot decide its rows.""" + spec = to_spec(varied(SMALL_MODEL, **patch, **{'variables.p.where': 'n'})) + assert spec.program.variables['p'].where is not None, 'the bare name stays a mask' + + def test_a_given_parameter_has_no_missing(): """The file that declares the parameter owns what a missing row means, as it owns a variable's bounds.""" message = _refusal(**{'given.parameters.d': {'dims': ['g'], 'missing': 'neutral'}}) @@ -2444,11 +2484,14 @@ def test_a_bare_relation_has_no_missing_rows(): [ pytest.param( {'key': 'g', 'values': 'h', 'missing': 'neutral'}, - "missing: 'neutral' on a relation, which takes refused or absent", + 'missing: neutral on a relation, which takes refused or absent', id='neutral', ), pytest.param( - {'key': 'g', 'values': 'h', 'missing': 'h1'}, "missing: 'h1' on a relation", id='a-label-as-a-value' + {'key': 'g', 'values': 'h', 'missing': 'h1'}, 'missing: h1 on a relation', id='a-label-as-a-value' + ), + pytest.param( + {'key': 'g', 'values': 'h', 'missing': True}, 'missing: true on a relation', id='a-flag-as-a-value' ), pytest.param( {'key': 'g', 'values': 'h', 'missing': None}, 'missing: null on a relation names no reading', id='null' @@ -2467,7 +2510,7 @@ def test_a_relation_missing_that_reads_nothing_is_refused(relation, fragment): @pytest.mark.parametrize( ('written', 'fragment'), [ - pytest.param('refused', "missing: 'refused' on a variable, which takes absent or neutral", id='refused'), + pytest.param('refused', 'missing: refused on a variable, which takes absent or neutral', id='refused'), pytest.param(0, 'missing: 0 on a variable, which takes absent or neutral', id='a-value'), pytest.param(None, 'missing: null on a variable names no reading', id='null'), ],