From 4337547b4ac695b7f3ffbdf70e3c6abb48d70998 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 07:55:04 +0000 Subject: [PATCH 1/3] feat(language): a model declares what it assumes of its data, and the typeset math prints it An `assumptions:` block holds predicates in the where grammar; each holds at every coordinate of the frame its two masks name, a missing row reading as false. The program carries each as `AssumptionDeclaration(holds, where)` with `assumption_message` for the consumer that binds the data. A where may now compare two parameters coordinate by coordinate, which the block's own examples need. The typesetter prints an Assumptions section: every declared entry, then what each `piecewise:` block assumes of its breakpoints, labelled by the block. Docs sentence lengths (n / median / over 25): declarations.md 71 / 13 / 9, piecewise.md 75 / 16 / 13, expressions.md 133 / 15 / 23. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013HceuCYNepeQX8SdZtiMf1 --- docs/about/limits.md | 22 +-- docs/examples/commitment.md | 13 ++ docs/reference/language/declarations.md | 68 ++++++++- docs/reference/language/expressions.md | 9 +- docs/reference/language/file.md | 5 +- docs/reference/language/index.md | 4 +- docs/reference/language/piecewise.md | 19 +++ docs/reference/language/reading.md | 5 + docs/reference/notation.md | 115 +++++++++++++++ docs/reference/typeset.md | 7 +- examples/commitment.yaml | 5 + schema/math-spec.schema.json | 56 +++++++- src/math_spec/dimensions.py | 3 +- src/math_spec/exclusivity.py | 26 +++- src/math_spec/lowering.py | 3 + src/math_spec/model.py | 53 ++++++- src/math_spec/program.py | 54 ++++++- src/math_spec/resolution.py | 49 +++++-- src/math_spec/typesetting/__init__.py | 17 ++- src/math_spec/typesetting/format.py | 4 + src/math_spec/typesetting/latex.py | 3 + src/math_spec/typesetting/typst.py | 3 + src/math_spec/typesetting/walk.py | 184 ++++++++++++++++++++---- src/math_spec/validation.py | 51 ++++++- tests/test_exclusivity.py | 21 +++ tests/test_lowering.py | 41 ++++++ tests/test_validation.py | 81 ++++++++++- tests/typesetting/golden/latex.out | 37 ++++- tests/typesetting/golden/markdown.out | 126 ++++++++++++++++ tests/typesetting/golden/model.yaml | 34 +++++ tests/typesetting/golden/typst.out | 36 ++++- tests/typesetting/test_declaration.py | 14 +- tests/typesetting/test_golden.py | 6 +- tests/typesetting/test_walk.py | 48 +++++++ tools/gallery.py | 8 ++ tools/notation.py | 10 +- 36 files changed, 1150 insertions(+), 90 deletions(-) diff --git a/docs/about/limits.md b/docs/about/limits.md index 59ecbe90..3b686c7c 100644 --- a/docs/about/limits.md +++ b/docs/about/limits.md @@ -146,17 +146,17 @@ lets an engine build the model one chunk of rows at a time. What has been asked for and refused, with the reason and what to write instead. That another tool has a feature is not by itself a reason to add it. -| Request | Why refused | Instead | -| ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Resampling, clustering, file IO, unit conversion | not math | do it in data preparation, and pass a parameter | -| Unit checking at load | a `unit: MW` on a parameter is a claim that nothing checks against the column. A clean pass would only mean that no two annotated operands disagreed. It would also need a grammar of units that the language then has to maintain ([#125](https://github.com/fluxopt/lpspec/issues/125)) | convert to one unit system in data preparation, and name it in the `description:` | -| Array operations such as `merge` and `reindex` | there is no end to them | data preparation | -| Helpers for one domain, such as `reduce_carrier_dim` | writes one field's vocabulary into the language | a component library of macros over the operators that exist | -| A vocabulary for tracked metrics: `impacts:`, `effects:`, a `costs` axis | a named expression already does this | an `impact` dimension and one named expression. Cap it with a constraint, whose dual is the shadow price; weight it in the objective; read it back after the solve ([#124](https://github.com/fluxopt/lpspec/issues/124)) | -| `**` with a variable in the base or the exponent | the exponent would decide the degree, and `to_spec` reads no data. `p ** n` is linear at `n = 1`, quadratic at `n = 2`, and refused at `n = 3` | `x * x` for a square. `**` over parameters and numbers is allowed ([#1175](https://github.com/fluxopt/lpspec/issues/1175)) | -| Normalisation, `x / sum(x)` | dividing by a variable is not a polynomial, and no solver takes it | write the ratio as a constraint, or fix the denominator | -| An `if`, a loop, or declarations that depend on the data | `to_spec` could no longer read the file without the data | `where:` masks and `dims:` dimensions. A tool may loop over models | -| A Python API for building models | the model is the file you review and diff | YAML, or a `dict` with the same keys ([below](#composition-component-libraries)) | +| Request | Why refused | Instead | +| ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Resampling, clustering, file IO, unit conversion | not math | do it in data preparation, and pass a parameter | +| Unit checking at load | a `unit: MW` on a parameter is a claim that nothing checks against the column. A clean pass would only mean that no two annotated operands disagreed. It would also need a grammar of units that the language then has to maintain ([#125](https://github.com/fluxopt/lpspec/issues/125)). A range is different: `assumptions:` states one, and the engine checks it against every row | convert to one unit system in data preparation, and name it in the `description:` | +| Array operations such as `merge` and `reindex` | there is no end to them | data preparation | +| Helpers for one domain, such as `reduce_carrier_dim` | writes one field's vocabulary into the language | a component library of macros over the operators that exist | +| A vocabulary for tracked metrics: `impacts:`, `effects:`, a `costs` axis | a named expression already does this | an `impact` dimension and one named expression. Cap it with a constraint, whose dual is the shadow price; weight it in the objective; read it back after the solve ([#124](https://github.com/fluxopt/lpspec/issues/124)) | +| `**` with a variable in the base or the exponent | the exponent would decide the degree, and `to_spec` reads no data. `p ** n` is linear at `n = 1`, quadratic at `n = 2`, and refused at `n = 3` | `x * x` for a square. `**` over parameters and numbers is allowed ([#1175](https://github.com/fluxopt/lpspec/issues/1175)) | +| Normalisation, `x / sum(x)` | dividing by a variable is not a polynomial, and no solver takes it | write the ratio as a constraint, or fix the denominator | +| An `if`, a loop, or declarations that depend on the data | `to_spec` could no longer read the file without the data | `where:` masks and `dims:` dimensions. A tool may loop over models | +| A Python API for building models | the model is the file you review and diff | YAML, or a `dict` with the same keys ([below](#composition-component-libraries)) | ## Composition (component libraries) diff --git a/docs/examples/commitment.md b/docs/examples/commitment.md index 5906e2a4..8fd37e80 100644 --- a/docs/examples/commitment.md +++ b/docs/examples/commitment.md @@ -83,6 +83,11 @@ constraints: p - shift(p, over=snapshot, offset=1, edge=0) <= ramp_limit * previous_status + start_up_limit * (1 - previous_status) +assumptions: + floor_below_capacity: + description: a floor above the capacity leaves `upper` and `lower` no output to agree on + holds: "p_min <= p_max" + objective: sense: minimize expression: sum(p * cost) @@ -182,6 +187,14 @@ p_{t,g} - p_{t \boxminus_{0} 1,g} \le \mathrm{ramp\_limit}_{g} \cdot \mathit{pre ```math \mathit{status}_{t,g} \in \{0, 1\} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} ``` + +#### Assumptions + +**`floor_below_capacity`** + +```math +\mathrm{p}^{\mathrm{min}}_{g} \le \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\, g \in \mathcal{G} +``` Regenerate with `pixi run python -m tools.gallery`. diff --git a/docs/reference/language/declarations.md b/docs/reference/language/declarations.md index a2fe9259..510760ce 100644 --- a/docs/reference/language/declarations.md +++ b/docs/reference/language/declarations.md @@ -5,7 +5,8 @@ SPDX-License-Identifier: CC-BY-4.0 # Parameters, variables, constraints and the objective -These four blocks carry the math. Each takes an optional `description:`. +These four blocks carry the math, and a fifth, `assumptions`, says what the +math takes for granted about its data. Each takes an optional `description:`. A description is free text with no length limit. The parser throws a `#` comment away, but keeps a description, so a renderer or a checker can print it. The @@ -210,3 +211,68 @@ different models. A second objective cannot be written, because the schema holds one block. To pursue several goals, weight them into one expression. + +## `assumptions` + +An assumption is a claim about the data: a predicate that every coordinate +has to satisfy before the model is built. The language decides nothing about +the numbers, so the tool that binds the data checks each assumption and refuses +the data where one does not hold. The [typeset](../typeset.md) document prints +every assumption under its own heading, so the math a reader checks carries what +the model assumes of its inputs. + +```yaml +dimensions: + generator: { dtype: str } +parameters: + p_min: { dims: [generator] } + p_max: { dims: [generator] } + efficiency: { dims: [generator] } +variables: + p: { dims: [generator], bounds: { lower: p_min, upper: p_max } } +assumptions: + efficiency_is_a_fraction: "efficiency > 0 AND efficiency <= 1" + bounds_do_not_cross: + holds: "p_min <= p_max" + where: "p_min" + description: a unit with no minimum is unconstrained below +``` + +```math +\mathrm{efficiency}_{g} > 0 \wedge \mathrm{efficiency}_{g} \le 1 \qquad \forall\, g \in \mathcal{G} +``` + +```math +\mathrm{p}^{\mathrm{min}}_{g} \le \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\, g \in \mathcal{G} \,:\, \mathrm{p}^{\mathrm{min}}_{g} \text{ is defined} +``` + +| Field | | | +| ------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -------------- | +| `holds` | required. A [`where` string](expressions.md#where-strings) over parameters, dimensions and lookups. A bare string is read as `holds` | | +| `where` | which coordinates are checked, in the same grammar | default `null` | +| `description` | free text | default `null` | + +Three rules say what an assumption means: + +- **It holds at every coordinate of its frame.** The frame is the product of + every dimension that `holds` and `where` name, so `p_min <= p_max` over one + dimension is checked once per generator, and `budget > 0` over none is + checked once. There is no `dims:` to declare, because a predicate widens + nothing. +- **A missing row reads as false**, as it does in every `where` + ([absence](absence.md#what-creates-absence)). So `efficiency > 0` refuses a + generator whose row is missing. Where a parameter is supplied only for the + units it applies to, say so in `where:`, as `bounds_do_not_cross` does: the + assumption is checked where `p_min` is defined and nowhere else. +- **Two parameters may be compared.** `p_min <= p_max` reads the two coordinate + by coordinate, the narrower one at every coordinate of the wider. Both are + numbers, or both share a dtype. A number against a label is refused. + +A predicate that names a variable is refused, because an assumption is about +the data and a variable is what the solver decides from it. State a rule about +a decision as a constraint. A predicate that folds to `True` or `False` is +refused too: the first assumes nothing, and the second admits no data. + +A `piecewise:` block assumes things of its breakpoints that no file writes, +such as a strictly increasing x-axis. Those print under the same heading, +labelled by the block ([what a curve assumes](piecewise.md#what-a-curve-assumes)). diff --git a/docs/reference/language/expressions.md b/docs/reference/language/expressions.md index 320bf4d4..e1cd787f 100644 --- a/docs/reference/language/expressions.md +++ b/docs/reference/language/expressions.md @@ -174,6 +174,7 @@ QUOTED ::= "'" chars "'" | '"' chars '"' | `name OP value` | parameter | Element-wise, and a null compares false. The right-hand side is a literal, or a bare name read as a string label | | `name OP value` | dimension | A filter on the frame's own coordinate column | | `name OP value` | lookup | A filter on the lookup's value, so the `over` dimension has to be in the frame. A null compares false | +| `name OP name` | two parameters | Coordinate by coordinate, the narrower one read at every coordinate of the wider. Both are numbers, or both share a dtype. A null on either side compares false | | `name OP name` | two lookups | Legal only where both lookups are over the same dimension and into the same dimension. `from != to` excludes a self-loop | | `position(name) OP i` | dimension | Where the row sits along the dimension's own order. `0` is first, and a negative number counts from the end | | `position(name, by=lookup) OP i` | a dimension and a lookup over it | The same, counted within each group the lookup makes | @@ -211,10 +212,10 @@ so `node >= 'b'` means the same however the nodes were listed. A label the dimension does not carry compares equal to nothing, so the mask is false there rather than an error. -Comparing two parameters, or two dimensions, is not in the language. Precompute a -boolean parameter instead. Two lookups are the exception, where both lookups -share both ends: over one dimension they are two columns of one table, and into -one dimension they draw from one label set. +Two parameters compare coordinate by coordinate, as `p_min <= p_max` does, and +so do two lookups that share both ends: over one dimension they are two columns +of one table, and into one dimension they draw from one label set. Comparing two +dimensions is not in the language. Precompute a boolean parameter instead. ### `position()` diff --git a/docs/reference/language/file.md b/docs/reference/language/file.md index 30b1ce16..b780944d 100644 --- a/docs/reference/language/file.md +++ b/docs/reference/language/file.md @@ -5,8 +5,8 @@ SPDX-License-Identifier: CC-BY-4.0 # File shape -A model file is a YAML mapping with **ten declaration keys**, plus `version` -and `description`. Any subset of the ten is accepted. +A model file is a YAML mapping with **eleven declaration keys**, plus `version` +and `description`. Any subset of the eleven is accepted. | Key | | | ------------- | ------------------------------------------------------------------------------------------------------------------- | @@ -20,6 +20,7 @@ and `description`. Any subset of the ten is accepted. | `macros` | templates that take arguments ([macros](expressions.md#macros)) | | `piecewise` | piecewise-linear curves ([piecewise](piecewise.md)) | | `sos` | special-ordered sets ([sos](piecewise.md#sos)) | +| `assumptions` | what the model assumes of its data ([assumptions](declarations.md#assumptions)) | A file with no `objective` is a **feasibility problem**: it asks whether the constraints can all be met. It loads and solves like any other model, and the diff --git a/docs/reference/language/index.md b/docs/reference/language/index.md index 2430fbe8..1b4d96e1 100644 --- a/docs/reference/language/index.md +++ b/docs/reference/language/index.md @@ -46,7 +46,7 @@ message that names the fix. These ten rules are what it checks. | # | Rule | | | --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | -| 1 | A file has ten declaration keys, plus `version` and `description`. A key the schema does not know is refused, with the nearest valid key named: `boundz` → `bounds`. | [File shape](file.md) | +| 1 | A file has eleven declaration keys, plus `version` and `description`. A key the schema does not know is refused, with the nearest valid key named: `boundz` → `bounds`. | [File shape](file.md) | | 2 | Everything that can be checked without data is checked when the file loads. | [Errors](errors.md) | | 3 | Every name is declared once. A parameter and a dimension both called `snapshot` is refused, and the message names both lines. | [Names](expressions.md#name-resolution) | | 4 | Where a name may stand depends on what it is. A dimension may follow `over=`, and may not be multiplied: `p * snapshot` is refused, because `snapshot` is an axis and not a column of numbers. | [Names](expressions.md#name-resolution) | @@ -63,7 +63,7 @@ message that names the fix. These ten rules are what it checks. | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | [File shape](file.md) | the ten keys, `version`, `description`, and how the YAML is read | | [Dimensions and lookups](dimensions.md) | the axes, and the maps from one axis onto another | -| [Parameters, variables, constraints and the objective](declarations.md) | the four blocks that carry the math | +| [Parameters, variables, constraints and the objective](declarations.md) | the four blocks that carry the math, and the assumptions the data is held to | | [Expressions](expressions.md) | the arithmetic grammar and the `where` grammar, where each kind of name may stand, and how dimensions combine | | [Reported expressions](reported.md) | named quantities that no constraint or objective uses, which you read back after a solve | | [Operators](operators.md) | `sum`, `sum_back`, `at` and `shift` | diff --git a/docs/reference/language/piecewise.md b/docs/reference/language/piecewise.md index f6b01992..8a274a41 100644 --- a/docs/reference/language/piecewise.md +++ b/docs/reference/language/piecewise.md @@ -183,6 +183,25 @@ stating lines rather than weights: along the end segments. They are the same rows that `linopy`'s own `lp` method emits. +### What a curve assumes + +A method assumes things of the breakpoints that no file writes. `convex` and +`lp` sort by the first link's values, so those have to increase along `over` +within each curve, and each method is exact only for the shape named above. +`lp` needs at least two breakpoints per curve, and a `points:` mask has to +admit one consecutive run. The engine checks each of these when the data +binds. The [typeset](../typeset.md) document prints them under the +[assumptions](declarations.md#assumptions), labelled by the block, beside the +assumptions the file writes itself: + +```math +\mathrm{bp\_x}_{g,b - 1} < \mathrm{bp\_x}_{g,b} \qquad \forall\, g \in \mathcal{G},\ b \in \mathcal{B} +``` + +```math +\mathrm{bp\_y}_{g,b} \text{ is a convex function of } \mathrm{bp\_x}_{g,b} \text{ along } b \qquad \forall\, g \in \mathcal{G} +``` + ### Writing the curve out by hand `links:` is a list, so the number of expressions a block ties is written in the diff --git a/docs/reference/language/reading.md b/docs/reference/language/reading.md index 3b6abcc6..f72c4777 100644 --- a/docs/reference/language/reading.md +++ b/docs/reference/language/reading.md @@ -94,6 +94,11 @@ parameters it is about. The engine, which has the numbers, runs the check, and says how a parameter is filled, and `None` means the engine binds it from its data. +`program.assumptions` keeps each `assumptions:` entry as two masks, `holds` and +`where`, under the name the file wrote. The engine checks `holds` at every +coordinate of the product of the dimensions the two masks name that `where` +admits, and `assumption_message` gives it the sentence to raise. + ## Nodes and masks You never build a node yourself. The node classes are exported so that you can diff --git a/docs/reference/notation.md b/docs/reference/notation.md index 10d99b65..b8409e21 100644 --- a/docs/reference/notation.md +++ b/docs/reference/notation.md @@ -42,6 +42,7 @@ dimensions: zone: { dtype: str } season: { dtype: str } technology: { dtype: str } + bp: { dtype: int } lookups: gen_bus: { over: generator, into: bus } @@ -63,6 +64,10 @@ 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] } # a curve's breakpoints, one curve per generator + bp_y: { dims: [generator, bp] } + fuel_bp: { dims: [bp] } # a second curve's breakpoints, one curve for every generator + heat_bp: { dims: [bp] } ``` #### Sets @@ -75,6 +80,7 @@ parameters: | $`\mathcal{Z}`$ | index $`z`$ — `zone` | | $`\mathcal{S}`$ | index $`s`$ — `season` | | $`\mathcal{E}`$ | index $`e`$ — `technology` | +| $`\mathcal{A}`$ | index $`a`$ — `bp` | #### Parameters @@ -92,6 +98,13 @@ 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{fuel\_bp}`$ | `fuel_bp` over $`\mathcal{A}`$ | +| $`\mathrm{heat}^{\mathrm{bp}}`$ | `heat_bp` over $`\mathcal{A}`$ | +| $`\mathrm{cost}^{\mathrm{curve,points}}`$ | `cost_curve_points` over $`\mathcal{G} \times \mathcal{A}`$ — where 'bp\_x' has a row, and so where the curve runs | +| $`\mathrm{cost}^{\mathrm{curve,starts}}`$ | `cost_curve_starts` over $`\mathcal{G} \times \mathcal{A}`$ — the first breakpoint of each curve | +| $`\mathrm{cost}^{\mathrm{curve,ends}}`$ | `cost_curve_ends` over $`\mathcal{G} \times \mathcal{A}`$ — the last breakpoint of each curve | #### Variables @@ -107,6 +120,8 @@ parameters: | $`\mathit{reserve}`$ | `reserve` (scalar) | | $`\mathit{headroom}`$ | `headroom` (scalar) | | $`\mathit{weight}`$ | `weight` over $`\mathcal{T} \times \mathcal{G}`$ | +| $`\mathit{op\_cost}`$ | `op_cost` over $`\mathcal{T} \times \mathcal{G}`$ | +| $`\mathit{heat}`$ | `heat` over $`\mathcal{T} \times \mathcal{G}`$ | #### Definitions @@ -780,6 +795,86 @@ weight: 0 \le \mathit{weight}_{t,g} \le 1 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} ``` +#### `op_cost` + +what a curve bounds below + +```yaml +op_cost: + dims: [snapshot, generator] + bounds: { lower: 0 } +``` + +```math +\mathit{op\_cost}_{t,g} \ge 0 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +``` + +#### `heat` + +what a second curve bounds above + +```yaml +heat: + dims: [snapshot, generator] + bounds: { lower: 0 } +``` + +```math +\mathit{heat}_{t,g} \ge 0 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +``` + +### Assumptions + +#### `capacity_is_nonnegative` + +one comparison, so the line aligns on its relation + +```yaml +capacity_is_nonnegative: p_max >= 0 +``` + +```math +\mathrm{p}^{\mathrm{max}}_{g} \ge 0 \qquad \forall\, g \in \mathcal{G} +``` + +#### `bounds_are_ordered` + +two parameters compared coordinate by coordinate, checked where the narrower one is defined + +```yaml +bounds_are_ordered: + holds: "p_min <= p_max" + where: "p_min" +``` + +```math +\mathrm{p}^{\mathrm{min}}_{g} \le \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\, g \in \mathcal{G} \,:\, \mathrm{p}^{\mathrm{min}}_{g} \text{ is defined} +``` + +#### `budget_is_positive` + +a scalar, so no quantifier + +```yaml +budget_is_positive: budget > 0 +``` + +```math +\mathrm{budget} > 0 +``` + +#### `flexible_units_are_cheap` + +a compound predicate, which no one relation aligns + +```yaml +flexible_units_are_cheap: "NOT is_flexible OR cost <= budget" +``` + +```math +\neg \mathrm{is\_flexible}_{g} \vee \mathrm{cost}_{g} \le \mathrm{budget} \qquad \forall\, g \in \mathcal{G} +``` + ### Curves, as what they expand to A curve is sugar: what prints is the formulation it expands to, which is the math the solver receives. One row per `method:`, each from the model named under it, so the symbols in this section are that model's. @@ -920,6 +1015,14 @@ p_{t,g} = \sum_{b \in \mathcal{B}} \lambda_{t,g,b} \cdot \mathrm{x}_{g,b} \qquad 0 \le \lambda_{t,g,b} \le 1 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G},\ b \in \mathcal{B} ``` +```math +\mathrm{x}_{g,b - 1} < \mathrm{x}_{g,b} \qquad \forall\, g \in \mathcal{G},\ b \in \mathcal{B} +``` + +```math +\mathrm{y}_{g,b} \text{ is a convex or concave function of } \mathrm{x}_{g,b} \text{ along } b \qquad \forall\, g \in \mathcal{G} +``` + #### `cost_curve` **`method: lp`** — no weights at all — one row per segment line, plus the two rows holding the domain, in `examples/piecewise_lp.yaml`. @@ -955,6 +1058,18 @@ p_{t,g} \ge \mathrm{x}_{g,b} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal p_{t,g} \le \mathrm{x}_{g,b} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G},\ b \in \mathcal{B} \,:\, \mathrm{pos}(b) = \lvert \mathcal{B} \rvert - 1 ``` +```math +\mathrm{x}_{g,b - 1} < \mathrm{x}_{g,b} \qquad \forall\, g \in \mathcal{G},\ b \in \mathcal{B} +``` + +```math +\mathrm{y}_{g,b} \text{ is a convex function of } \mathrm{x}_{g,b} \text{ along } b \qquad \forall\, g \in \mathcal{G} +``` + +```math +\lvert \mathcal{B} \rvert \ge 2 +``` + ### Sets carried to the solver #### `adjacent` diff --git a/docs/reference/typeset.md b/docs/reference/typeset.md index 29c0f7b9..27164bef 100644 --- a/docs/reference/typeset.md +++ b/docs/reference/typeset.md @@ -74,6 +74,9 @@ a flag. symbol table touches it. - A `piecewise:` block prints as the variables and constraints it expands into, because the expansion is the math the solver receives. +- An `assumptions:` block prints under its own heading, after the variable + domains, and so does what each `piecewise:` block assumes of its breakpoints + ([assumptions](language/declarations.md#assumptions)). - Inlining reaches only an expression that the math reads. A `cases:` block has no single body to substitute, and a [reported entry](language/reported.md) is read by nothing, so both keep their definition line under either setting. @@ -86,7 +89,7 @@ a flag. ## Printing one declaration on its own `typeset_declaration` returns the line the document prints for one named -expression, constraint or variable, with its quantifier and without a document, +expression, constraint, assumption or variable, with its quantifier and without a document, a label, a number or math delimiters. Use it for a docstring, a table cell or a comment beside the value it computes: @@ -114,7 +117,7 @@ A line on its own has no _Definitions_ section beside it, so the plain named expressions it uses are substituted unless you say otherwise. A cased expression prints by symbol, and a second call with its name prints its block. -A name that is none of the three kinds is refused with the near miss. A name that +A name that is none of the four kinds is refused with the near miss. A name that is both a constraint and a variable is refused too, because one line can print only one of them. Constraints sit outside the [flat namespace](language/expressions.md#name-resolution), so a model may use one diff --git a/examples/commitment.yaml b/examples/commitment.yaml index 21fb82e8..c6b094fb 100644 --- a/examples/commitment.yaml +++ b/examples/commitment.yaml @@ -67,6 +67,11 @@ constraints: p - shift(p, over=snapshot, offset=1, edge=0) <= ramp_limit * previous_status + start_up_limit * (1 - previous_status) +assumptions: + floor_below_capacity: + description: a floor above the capacity leaves `upper` and `lower` no output to agree on + holds: "p_min <= p_max" + objective: sense: minimize expression: sum(p * cost) diff --git a/schema/math-spec.schema.json b/schema/math-spec.schema.json index 5465841e..1a6e59a9 100644 --- a/schema/math-spec.schema.json +++ b/schema/math-spec.schema.json @@ -1,5 +1,51 @@ { "$defs": { + "AssumptionBlock": { + "anyOf": [ + { + "additionalProperties": false, + "description": "What the model assumes of its data: a predicate every coordinate has to satisfy.\n\nWritten in YAML as a bare where string, or as a mapping once it carries a\n``where:`` or a ``description:``, and serialised back to whichever form it\nwas written in::\n\n assumptions:\n efficiency_is_a_fraction: \"efficiency > 0 AND efficiency <= 1\"\n bounds_do_not_cross:\n holds: \"p_min <= p_max\"\n where: \"p_min\"\n description: a unit with no minimum is unconstrained below\n\nThe language decides nothing about the numbers, so the consumer binding\nthe data checks it, and refuses the data where it does not hold.", + "properties": { + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "holds": { + "title": "Holds", + "type": "string" + }, + "where": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Where" + } + }, + "required": [ + "holds" + ], + "title": "AssumptionBlock", + "type": "object" + }, + { + "type": "string" + } + ] + }, "BoundsBlock": { "additionalProperties": false, "description": "Variable bounds \u2014 each side is a number or parameter name.\n\nAn omitted bound leaves the variable unbounded on that side, not\nimplicitly non-negative.", @@ -601,8 +647,16 @@ }, "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, - "description": "The declared math \u2014 one YAML file, or one dict, validated. Nothing here has seen data.\n\nA ``Spec`` that exists has passed the whole language: constructing one by\nany route \u2014 ``to_spec``, :meth:`model_validate`, the constructor \u2014 runs\nevery load-time check, expansion and expression pass included, and raises\n:class:`~math_spec.errors.LanguageError` on a model the language refuses.\nHolding one is the proof, so nothing downstream checks it again.\n\nThe API is the ten declaration sections plus ``version`` and\n``description``, and two ways back out: :meth:`to_dict` for the model as\ndata, :meth:`to_yaml` for the file a reviewer reads. Everything else on\nthis class is pydantic's, not a contract this package keeps.", + "description": "The declared math \u2014 one YAML file, or one dict, validated. Nothing here has seen data.\n\nA ``Spec`` that exists has passed the whole language: constructing one by\nany route \u2014 ``to_spec``, :meth:`model_validate`, the constructor \u2014 runs\nevery load-time check, expansion and expression pass included, and raises\n:class:`~math_spec.errors.LanguageError` on a model the language refuses.\nHolding one is the proof, so nothing downstream checks it again.\n\nThe API is the eleven declaration sections plus ``version`` and\n``description``, and two ways back out: :meth:`to_dict` for the model as\ndata, :meth:`to_yaml` for the file a reviewer reads. Everything else on\nthis class is pydantic's, not a contract this package keeps.", "properties": { + "assumptions": { + "additionalProperties": { + "$ref": "#/$defs/AssumptionBlock" + }, + "default": {}, + "title": "Assumptions", + "type": "object" + }, "constraints": { "additionalProperties": { "$ref": "#/$defs/ConstraintBlock" diff --git a/src/math_spec/dimensions.py b/src/math_spec/dimensions.py index 3c07aed5..ecbe8e35 100644 --- a/src/math_spec/dimensions.py +++ b/src/math_spec/dimensions.py @@ -48,6 +48,7 @@ Mask, ParameterComparisonNode, ParameterDefinedNode, + ParameterPairComparisonNode, VariableDefinedNode, ) @@ -514,7 +515,7 @@ def _check_where_dims( if not (outside := sorted(Mask(atom).dims - frame)): continue match atom: - case ParameterDefinedNode() | ParameterComparisonNode(): + case ParameterDefinedNode() | ParameterComparisonNode() | ParameterPairComparisonNode(): noun = 'parameter' case VariableDefinedNode(): noun = 'variable' diff --git a/src/math_spec/exclusivity.py b/src/math_spec/exclusivity.py index 450aad92..1ac64b81 100644 --- a/src/math_spec/exclusivity.py +++ b/src/math_spec/exclusivity.py @@ -34,6 +34,7 @@ OrNode, ParameterComparisonNode, ParameterDefinedNode, + ParameterPairComparisonNode, TypedPredicateNode, VariableDefinedNode, ) @@ -136,7 +137,7 @@ class Subject: a rank is further split by the ``by=`` lookup it is counted within. """ - kind: Literal['param', 'dim', 'rank', 'lookup', 'lookup_pair', 'variable'] + kind: Literal['param', 'param_pair', 'dim', 'rank', 'lookup', 'lookup_pair', 'variable'] name: str qualifier: str | None = None @@ -144,7 +145,7 @@ def __str__(self) -> str: if self.kind == 'rank': within = f' within {self.qualifier}' if self.qualifier else '' return f'the position of {self.name}{within}' - if self.kind == 'lookup_pair': + if self.kind in ('lookup_pair', 'param_pair'): return f'{self.name} vs {self.qualifier}' return self.name @@ -187,8 +188,12 @@ def _observe(node: TypedPredicateNode, subject: Subject, values: set[Any], dtype """Record what *node* says about its subject: a position, or a literal. ``position()`` converts the dimension to an integer, so an ordering over a - rank is an ordering of integers and every comparator is admitted there. + rank is an ordering of integers and every comparator is admitted there. A + parameter pair names no literal: its subject is the order between the two + columns, whose cells :func:`_cells_for` fixes. """ + if isinstance(node, ParameterPairComparisonNode): + return if isinstance(node, DimensionPositionNode): values.add(node.position) elif isinstance(node, LookupPairComparisonNode): @@ -224,6 +229,8 @@ def _subject_of(node: TypedPredicateNode) -> Subject: return Subject('lookup', name) case LookupPairComparisonNode(name=name, other=other): return Subject('lookup_pair', name, other) + case ParameterPairComparisonNode(name=name, other=other): + return Subject('param_pair', name, other) case _: assert_never(node) @@ -238,6 +245,8 @@ def _cells_for(subject: Subject, values: set[Any], dtypes: Mapping[str, Declared return _rank_cells(subject, cast('set[int]', values)) if subject.kind in ('lookup_pair', 'variable'): return [True, False] + if subject.kind == 'param_pair': + return [*_PAIR_ORDERS, Special.NULL] dtype = dtypes.get(subject.name) if dtype == 'bool': if values: @@ -359,11 +368,18 @@ def _rank_cells(subject: Subject, positions_seen: set[int]) -> list[Cell]: return cells +#: The order the left side of a parameter pair stands in to the right: the sign +#: of their difference, which every comparator reads against ``0``. +_PAIR_ORDERS: tuple[int, ...] = (-1, 0, 1) + + def _shown(subject: Subject, value: Cell) -> str: if subject.kind == 'rank': return str(value) if subject.kind == 'lookup_pair': return 'equal' if value else 'different' + if subject.kind == 'param_pair' and isinstance(value, int): + return {-1: 'less', 0: 'equal', 1: 'greater'}[value] if isinstance(value, Special): return {Special.NULL: 'absent', Special.OTHER: 'anything else'}.get(value, value.value) if isinstance(value, bool): @@ -405,6 +421,10 @@ def _atom(node: TypedPredicateNode, cell: dict[Subject, Cell], grid: _Grid) -> b return bool(value) case LookupPairComparisonNode(op=op): return bool(value) if op == '==' else not value + case ParameterPairComparisonNode(op=op): + if value is Special.NULL: + return False + return _compare(value, op, 0) case DimensionPositionNode(op=op, position=position): return _compare(value, op, position) case ParameterComparisonNode(op=op, value=literal) | LookupComparisonNode(op=op, value=literal): diff --git a/src/math_spec/lowering.py b/src/math_spec/lowering.py index d1df7af4..3a89455f 100644 --- a/src/math_spec/lowering.py +++ b/src/math_spec/lowering.py @@ -171,6 +171,9 @@ def lower_program(expanded: _ExpandedSpec) -> program.Program: dimensions=dimensions, sos=sos, piecewise={name: declaration_of(ex) for name, ex in expanded.expanded_piecewise.items()}, + assumptions={ + name: program.AssumptionDeclaration(holds, where) for name, (holds, where) in resolved.assumptions.items() + }, named_expressions=expressions, ) diff --git a/src/math_spec/model.py b/src/math_spec/model.py index b7dadb76..acfca728 100644 --- a/src/math_spec/model.py +++ b/src/math_spec/model.py @@ -426,6 +426,56 @@ def _as_written(self) -> str | dict[str, Any]: return {'expression': self.expression, 'description': self.description} +class AssumptionBlock(_StrictBlock): + """What the model assumes of its data: a predicate every coordinate has to satisfy. + + Written in YAML as a bare where string, or as a mapping once it carries a + ``where:`` or a ``description:``, and serialised back to whichever form it + was written in:: + + assumptions: + efficiency_is_a_fraction: "efficiency > 0 AND efficiency <= 1" + bounds_do_not_cross: + holds: "p_min <= p_max" + where: "p_min" + description: a unit with no minimum is unconstrained below + + The language decides nothing about the numbers, so the consumer binding + the data checks it, and refuses the data where it does not hold. + """ + + _label: ClassVar[str] = 'an assumption declaration' + + #: The predicate, in the where grammar. It holds at every coordinate of + #: its own frame that ``where`` admits. + holds: str + #: Which coordinates are checked, in the same grammar; absent means every one. + where: str | None = None + description: str | None = None + + @model_validator(mode='before') + @classmethod + def _from_string(cls, data: Any) -> Any: + return {'holds': data} if isinstance(data, str) else data + + @classmethod + @override + def __get_pydantic_json_schema__(cls, core_schema: CoreSchema, handler: GetJsonSchemaHandler) -> JsonSchemaValue: + """The published schema admits the bare string the one-line form is written as.""" + return _also_written_as(core_schema, handler, {'type': 'string'}) + + @model_serializer + def _as_written(self) -> str | dict[str, Any]: + if self.where is None and self.description is None: + return self.holds + written: dict[str, Any] = {'holds': self.holds} + if self.where is not None: + written['where'] = self.where + if self.description is not None: + written['description'] = self.description + return written + + class PiecewiseLink(_StrictBlock): """One link of a piecewise block: an expression pinned to a values curve. @@ -643,7 +693,7 @@ class Spec(_StrictBlock): :class:`~math_spec.errors.LanguageError` on a model the language refuses. Holding one is the proof, so nothing downstream checks it again. - The API is the ten declaration sections plus ``version`` and + The API is the eleven declaration sections plus ``version`` and ``description``, and two ways back out: :meth:`to_dict` for the model as data, :meth:`to_yaml` for the file a reviewer reads. Everything else on this class is pydantic's, not a contract this package keeps. @@ -674,6 +724,7 @@ class Spec(_StrictBlock): macros: dict[str, MacroBlock] = {} piecewise: dict[str, PiecewiseBlock] = {} sos: dict[str, SosBlock] = {} + assumptions: dict[str, AssumptionBlock] = {} def lookups_of(self, dimension: str) -> dict[str, str]: """The lookups over *dimension*: name -> the dim they map into.""" diff --git a/src/math_spec/program.py b/src/math_spec/program.py index 5478a31a..9aa8a44c 100644 --- a/src/math_spec/program.py +++ b/src/math_spec/program.py @@ -39,6 +39,7 @@ 'QUADRATIC_POSITIONS', 'Add', 'AndNode', + 'AssumptionDeclaration', 'At', 'AtLeastTwo', 'BooleanLiteralNode', @@ -83,6 +84,7 @@ 'ParameterDeclaration', 'ParameterDefinedNode', 'ParameterDtype', + 'ParameterPairComparisonNode', 'PiecewiseDeclaration', 'Power', 'PredicateOperator', @@ -102,6 +104,7 @@ 'VariableDomain', 'WhereNode', 'Window', + 'assumption_message', 'carries_variable', 'check_message', 'children', @@ -657,6 +660,32 @@ class ConstraintDeclaration: where: Mask | None = None +@dataclass(frozen=True) +class AssumptionDeclaration: + """An ``assumptions:`` entry: what the file assumes of its data, kept for the consumer holding it to check. + + ``holds`` is true at every coordinate of its frame — the product of every + dim the two masks name — that ``where`` admits, a missing row reading as + false as it does in any mask. The language decides nothing about the + numbers, so a consumer binding data checks, and refuses the data with + :func:`assumption_message` at the first coordinate where ``holds`` is + false. + """ + + holds: Mask + where: Mask | None = None + + +def assumption_message(name: str, assumption: AssumptionDeclaration) -> str: + """The sentence a consumer raises when the data bound to *assumption*, called *name*, fails it. + + The language's own wording, so every consumer refuses in the same words; + a consumer appends the coordinates it saw. + """ + read = ', '.join(f"'{n}'" for n in sorted(assumption.holds.names_read)) + return f"assumption '{name}' does not hold for the data bound to {read}" + + @dataclass(frozen=True) class SosDeclaration: """One special-ordered set per coordinate of the variable's ``dims`` minus ``over``. @@ -851,6 +880,9 @@ class Program: #: Each ``piecewise:`` block the file wrote, as facts — see #: :class:`PiecewiseDeclaration`. piecewise: Mapping[str, PiecewiseDeclaration] = Sealed({}) + #: What the file assumes of its data, by the name it wrote — see + #: :class:`AssumptionDeclaration`. A consumer binding data checks each. + assumptions: Mapping[str, AssumptionDeclaration] = Sealed({}) #: Declared ``expressions:``, lowered, each saying whether the math reads #: it. None builds a row of its own — one the math reads is inlined where #: it is read — but all are lowered with the program, so a file whose @@ -1043,6 +1075,20 @@ class ParameterComparisonNode: dims: tuple[str, ...] +@dataclass(frozen=True) +class ParameterPairComparisonNode: + """Compare two parameters, coordinate by coordinate — ``p_min <= p_max``. + + ``dims`` is the union of both parameters' dims: the narrower one is read + at every coordinate of the wider, as a product of the two would be. + """ + + name: str + other: str + op: PredicateOperator + dims: tuple[str, ...] + + @dataclass(frozen=True) class DimensionComparisonNode: """Compare a dimension's own coordinates against a literal.""" @@ -1124,6 +1170,7 @@ class OrNode: | ParameterDefinedNode | VariableDefinedNode | ParameterComparisonNode + | ParameterPairComparisonNode | DimensionComparisonNode | LookupComparisonNode | LookupPairComparisonNode @@ -1138,6 +1185,7 @@ class OrNode: #: decide about them. TypedPredicateNode = ( ParameterComparisonNode + | ParameterPairComparisonNode | ParameterDefinedNode | VariableDefinedNode | DimensionComparisonNode @@ -1198,7 +1246,7 @@ def _atom_dims(atom: TypedPredicateNode) -> frozenset[str]: a branch, rather than a wrong dim set at the first model to use it. """ match atom: - case ParameterComparisonNode() | ParameterDefinedNode() | VariableDefinedNode(): + case ParameterComparisonNode() | ParameterPairComparisonNode() | ParameterDefinedNode() | VariableDefinedNode(): return frozenset(atom.dims) case DimensionComparisonNode() | DimensionPositionNode(): return frozenset({atom.name}) @@ -1212,7 +1260,7 @@ def _atom_names(atom: TypedPredicateNode) -> frozenset[str]: """One leaf's declarations, its dimension apart — the rule :attr:`Mask.names_read` is the union of. A comparison on a dimension names no declaration — a coordinate is not - data to feed — and a lookup pair names both maps it compares. + data to feed — and a pair comparison names both sides it compares. ``assert_never``-closed for the reason :func:`_atom_dims` is: a predicate node added without a reading is a type error at this one branch rather than a name silently dropped at the first model to use it. @@ -1226,7 +1274,7 @@ def _atom_names(atom: TypedPredicateNode) -> frozenset[str]: | LookupDefinedNode() ): return frozenset({atom.name}) - case LookupPairComparisonNode(): + case LookupPairComparisonNode() | ParameterPairComparisonNode(): return frozenset({atom.name, atom.other}) case DimensionComparisonNode() | DimensionPositionNode(): return frozenset() diff --git a/src/math_spec/resolution.py b/src/math_spec/resolution.py index 4af355b0..51c5cc8f 100644 --- a/src/math_spec/resolution.py +++ b/src/math_spec/resolution.py @@ -13,7 +13,7 @@ import datetime import re -from dataclasses import dataclass, replace +from dataclasses import dataclass, field, replace from functools import cached_property from typing import TYPE_CHECKING, Literal, NamedTuple, assert_never, cast @@ -73,6 +73,7 @@ OrNode, ParameterComparisonNode, ParameterDefinedNode, + ParameterPairComparisonNode, TypedPredicateNode, VariableDefinedNode, WhereNode, @@ -206,6 +207,13 @@ class ResolvedConstraint(NamedTuple): where: Mask | None +class Assumption(NamedTuple): + """One assumption's typed halves: what holds, and where it is checked.""" + + holds: Mask + where: Mask | None + + @dataclass(frozen=True) class Resolved: """Every expression and where string of one schema, typed once at load. @@ -227,12 +235,15 @@ class Resolved: constraints: Each constraint's comparison and ``where``. objective: The objective's expression, ``None`` where the file declares none. + assumptions: Each assumption's predicate and the mask it is checked + under. """ expressions: dict[str, CasesNode | DefinitionNode] variables: dict[str, Mask | None] constraints: dict[str, ResolvedConstraint] objective: ArithmeticNode | None + assumptions: dict[str, Assumption] = field(default_factory=dict) @cached_property def read_by_the_math(self) -> frozenset[str]: @@ -706,7 +717,7 @@ def _position(self, node: UnresolvedPositionNode) -> DimensionPositionNode | Unr return DimensionPositionNode(node.dimension, node.op, node.position, node.by) def _comparison(self, node: UnresolvedComparisonNode) -> WhereNode | UnresolvedWhereNode: - """``name literal``, or the one structural form ``lookup lookup``.""" + """``name literal``, or the two pair forms: ``lookup lookup`` and ``parameter parameter``.""" ns, context = self.ns, self.context value = node.value if not node.quoted and isinstance(value, str) and (rhs_kind := ns.kind(value)) is not None: @@ -715,7 +726,13 @@ def _comparison(self, node: UnresolvedComparisonNode) -> WhereNode | UnresolvedW self.errors.append(refusal) return node return LookupPairComparisonNode(node.name, value, ns.over_of(node.name), node.op) - self.errors.append(_declared_rhs_error(context, node, value, rhs_kind)) + if rhs_kind == 'parameter' and ns.kind(node.name) == 'parameter': + if (refusal := _parameter_pair_error(context, node, value, ns)) is not None: + self.errors.append(refusal) + return node + dims = (*ns.leaf_dims[node.name], *(d for d in ns.leaf_dims[value] if d not in ns.leaf_dims[node.name])) + return ParameterPairComparisonNode(node.name, value, node.op, dims) + self.errors.append(_declared_rhs_error(context, node, value, rhs_kind, ns)) return node kind = ns.kind(node.name) @@ -853,15 +870,14 @@ def _literal(value: ArithmeticNode) -> NumberNode | None: return None -def _declared_rhs_error(context: str, node: UnresolvedComparisonNode, value: str, kind: str) -> str: +def _declared_rhs_error(context: str, node: UnresolvedComparisonNode, value: str, kind: str, ns: Namespace) -> str: """Why the right-hand side of a where-comparison may not name a declaration.""" comparison = f"'{node.name} {node.op} {value}'" if kind == 'parameter': return ( - f'{context}: {comparison} compares two parameters, which is not in the ' - f'language — a where-comparison tests one parameter or dimension against ' - f'a literal. Precompute the comparison as a boolean parameter in data ' - f'prep and test that.' + f'{context}: {comparison} compares {node.name!r}, which is {_declared_as(ns, node.name)}, against ' + f'parameter {value!r}. Two parameters may be compared, and nothing else may be compared against ' + f'one — test a parameter, or a literal.' ) if kind == 'variable': return ( @@ -883,6 +899,23 @@ def _declared_rhs_error(context: str, node: UnresolvedComparisonNode, value: str ) +def _parameter_pair_error(context: str, node: UnresolvedComparisonNode, other: str, ns: Namespace) -> str | None: + """Why two parameters may not be compared, or ``None`` where they may. + + Two numbers compare, as do two labels and two flags; a number against a + label or a flag compares nothing, silently, whatever the data library + makes of it. + """ + left, right = ns.dtypes[node.name], ns.dtypes[other] + if left == right or {left, right} <= NUMERIC_DTYPES: + return None + return ( + f"{context}: '{node.name} {node.op} {other}' compares a {left} parameter against a {right} one, " + f'and the two have no value in common to compare. Declare both as numbers, or both as the same ' + f'dtype, or precompute the test as a boolean parameter.' + ) + + def _lookup_pair_error(context: str, node: UnresolvedComparisonNode, other: str, ns: Namespace) -> str | None: """Why two lookups may not be compared, or ``None`` where they may. diff --git a/src/math_spec/typesetting/__init__.py b/src/math_spec/typesetting/__init__.py index 0575fe39..0b186589 100644 --- a/src/math_spec/typesetting/__init__.py +++ b/src/math_spec/typesetting/__init__.py @@ -158,7 +158,7 @@ def typeset_declaration( """Render one declaration as the bare line the document prints for it. The line the whole-model render prints for it — a named expression's - definition, a constraint, or a variable's domain, quantifier included — + definition, a constraint, an assumption, or a variable's domain, quantifier included — with no document, label, equation number or math delimiters around it, for a math context the caller lays out: a docstring, a table cell. A line on its own has no Definitions section beside it, so the plain named @@ -167,7 +167,7 @@ def typeset_declaration( Args: model: Anything :func:`math_spec.to_spec` accepts. - name: A named expression, constraint or variable the model declares. + name: A named expression, constraint, assumption or variable the model declares. fmt: What spells the math — a key of :data:`FORMATS`. symbols: How names print; see :func:`typeset`. inline_expressions: Substitute the plain named expressions the line uses, so it @@ -181,17 +181,24 @@ def typeset_declaration( Raises: ValueError: *fmt* names no format. LanguageError: A model that does not compile; it does not print. - SchemaError: *name* is declared as none of the three, or as two — a + SchemaError: *name* is declared as none of the four, or as two — a constraint may share a variable's name; or a symbol table entry names nothing in the model. """ walk = _walk(model, fmt, symbols, inline_expressions=inline_expressions) schema = walk.schema - kinds = {'named expression': schema.expressions, 'constraint': schema.constraints, 'variable': schema.variables} + kinds = { + 'named expression': schema.expressions, + 'constraint': schema.constraints, + 'assumption': schema.assumptions, + 'variable': schema.variables, + } found = [kind for kind, group in kinds.items() if name in group] if not found: everything = {n for group in kinds.values() for n in group} - msg = f"'{name}' is not a named expression, constraint or variable. {did_you_mean(name, everything)}" + msg = ( + f"'{name}' is not a named expression, constraint, assumption or variable. {did_you_mean(name, everything)}" + ) raise SchemaError(msg) if len(found) > 1: msg = f"'{name}' is both a {found[0]} and a {found[1]}, and one line prints one of them — rename one." diff --git a/src/math_spec/typesetting/format.py b/src/math_spec/typesetting/format.py index 489bee45..1e014c18 100644 --- a/src/math_spec/typesetting/format.py +++ b/src/math_spec/typesetting/format.py @@ -221,6 +221,10 @@ def cardinality(self, inner: str) -> str: def fraction(self, numerator: str, denominator: str) -> str: ... + def set_of(self, members: str, condition: str) -> str: + """A set by comprehension: ``{ k ∈ K : condition }``.""" + ... + def summation(self, domain: str, body: str) -> str: ... def cases(self, arms: list[tuple[str, str]]) -> str: diff --git a/src/math_spec/typesetting/latex.py b/src/math_spec/typesetting/latex.py index d09c3ff2..3e3296f5 100644 --- a/src/math_spec/typesetting/latex.py +++ b/src/math_spec/typesetting/latex.py @@ -98,6 +98,9 @@ def cardinality(self, inner: str) -> str: def fraction(self, numerator: str, denominator: str) -> str: return rf'\frac{{{numerator}}}{{{denominator}}}' + def set_of(self, members: str, condition: str) -> str: + return rf'\{{ {members} {self.operators["such_that"]} {condition} \}}' + def cases(self, arms: list[tuple[str, str]]) -> str: rows = self.cases_row.join(f'{value} & {condition}' for value, condition in arms) return rf'\begin{{cases}} {rows} \end{{cases}}' diff --git a/src/math_spec/typesetting/typst.py b/src/math_spec/typesetting/typst.py index 55c34c7f..d83a1aeb 100644 --- a/src/math_spec/typesetting/typst.py +++ b/src/math_spec/typesetting/typst.py @@ -106,6 +106,9 @@ def cardinality(self, inner: str) -> str: def fraction(self, numerator: str, denominator: str) -> str: return f'frac({numerator}, {denominator})' + def set_of(self, members: str, condition: str) -> str: + return f'{{{members} {self.operators["such_that"]} {condition}}}' + def cases(self, arms: list[tuple[str, str]]) -> str: return 'cases({})'.format(self.cases_row.join(f'{value} & {condition}' for value, condition in arms)) diff --git a/src/math_spec/typesetting/walk.py b/src/math_spec/typesetting/walk.py index 4569052f..8c1804c8 100644 --- a/src/math_spec/typesetting/walk.py +++ b/src/math_spec/typesetting/walk.py @@ -34,11 +34,17 @@ VariableNode, ) from math_spec.dimensions import dims_of +from math_spec.piecewise import declaration_of from math_spec.program import ( AndNode, + AtLeastTwo, BooleanLiteralNode, + Check, + Contiguous, + Curved, DimensionComparisonNode, DimensionPositionNode, + Increasing, LookupComparisonNode, LookupDefinedNode, LookupPairComparisonNode, @@ -47,6 +53,7 @@ OrNode, ParameterComparisonNode, ParameterDefinedNode, + ParameterPairComparisonNode, PredicateOperator, VariableDefinedNode, WhereNode, @@ -57,7 +64,7 @@ import datetime from collections.abc import Iterable - from math_spec.model import SosBlock, _ExpandedSpec + from math_spec.model import ExpandedPiecewise, SosBlock, _ExpandedSpec from math_spec.typesetting.format import Format from math_spec.typesetting.symbols import Symbols @@ -71,6 +78,17 @@ #: a comparison sits above the connectives and is bracketed under none. _WHERE_PRECEDENCE = {'or': 0, 'and': 1, 'comparison': 2, 'not': 3} +#: The predicates that are one relation between two sides, which a line may +#: align on the way it aligns a constraint. +RelationNode = ( + ParameterComparisonNode + | ParameterPairComparisonNode + | DimensionComparisonNode + | DimensionPositionNode + | LookupComparisonNode + | LookupPairComparisonNode +) + _PREDICATES: dict[PredicateOperator, OperatorName] = { '==': 'equal', '!=': 'ne', @@ -342,6 +360,10 @@ def _arithmetic(self, node: ArithmeticNode, ctx: _Context) -> tuple[str, int]: assert_never(node) + def _parameter(self, name: str, ctx: _Context) -> str: + """A parameter's symbol, indexed by its own dims.""" + return ctx.indexed(self.symbols.name[name], list(self.schema.parameters[name].dims)) + def _dual(self, node: DualNode, ctx: _Context) -> str: """λ subscripted by the constraint's symbol, then the indices of the constraint's own frame.""" frame = self._sorted(dims_of(node, self.schema, 'a dual')) @@ -519,33 +541,9 @@ def _where(self, node: WhereNode, ctx: _Context) -> tuple[str, int]: comparison, ) - if isinstance(node, ParameterComparisonNode): - left = ctx.indexed(self.symbols.name[node.name], list(node.dims)) - return f'{left} {self._op(_PREDICATES[node.op])} {self._literal(node.value)}', comparison - - if isinstance(node, DimensionComparisonNode): - if isinstance(node.value, int | float): - self.noticed.numeric_coordinates.add(node.name) - return ( - f'{ctx.subscript(node.name)} {self._op(_PREDICATES[node.op])} {self._literal(node.value)}', - comparison, - ) - - if isinstance(node, DimensionPositionNode): - grouping = None if node.by is None else self._lookup(node.by, ctx.subscript(node.name)) - place = self._position(ctx.subscript(node.name), grouping) - ordinal = self._ordinal(node.name, node.position, grouping) - return f'{place} {self._op(_PREDICATES[node.op])} {ordinal}', comparison - - if isinstance(node, LookupComparisonNode): - applied = self._lookup(node.name, ctx.subscript(node.over)) - return f'{applied} {self._op(_PREDICATES[node.op])} {self._literal(node.value)}', comparison - - if isinstance(node, LookupPairComparisonNode): - index = ctx.subscript(node.over) - left = self._lookup(node.name, index) - right = self._lookup(node.other, index) - return f'{left} {self._op(_PREDICATES[node.op])} {right}', comparison + if isinstance(node, RelationNode): + left, right = self._relation(node, ctx) + return f'{left} {right}', comparison if isinstance(node, LookupDefinedNode): applied = self._lookup(node.name, ctx.subscript(node.over)) @@ -569,6 +567,33 @@ def _where(self, node: WhereNode, ctx: _Context) -> tuple[str, int]: assert_never(node) + def _relation(self, node: RelationNode, ctx: _Context) -> tuple[str, str]: + """A comparison as its two sides, the relation symbol leading the right. + + Split so that a line whose whole predicate is one comparison aligns + on the relation as a constraint does. + """ + if isinstance(node, ParameterComparisonNode): + left, right = self._parameter(node.name, ctx), self._literal(node.value) + elif isinstance(node, ParameterPairComparisonNode): + left, right = self._parameter(node.name, ctx), self._parameter(node.other, ctx) + elif isinstance(node, DimensionComparisonNode): + if isinstance(node.value, int | float): + self.noticed.numeric_coordinates.add(node.name) + left, right = ctx.subscript(node.name), self._literal(node.value) + elif isinstance(node, DimensionPositionNode): + grouping = None if node.by is None else self._lookup(node.by, ctx.subscript(node.name)) + left = self._position(ctx.subscript(node.name), grouping) + right = self._ordinal(node.name, node.position, grouping) + elif isinstance(node, LookupComparisonNode): + left, right = self._lookup(node.name, ctx.subscript(node.over)), self._literal(node.value) + elif isinstance(node, LookupPairComparisonNode): + index = ctx.subscript(node.over) + left, right = self._lookup(node.name, index), self._lookup(node.other, index) + else: + assert_never(node) + return left, f'{self._op(_PREDICATES[node.op])} {right}' + def _literal(self, value: float | str | datetime.date) -> str: return self._number(value) if isinstance(value, int | float) else self.format.quoted(str(value)) @@ -618,6 +643,7 @@ def equations(self) -> tuple[list[tuple[str, list[Line]]], Noticed]: ('Subject to', self._constraints()), ('Definitions', self._definitions()), ('Variable domains', self._variables()), + ('Assumptions', self._assumptions()), ] return sections, self.noticed @@ -692,15 +718,17 @@ def definition(self, name: str) -> Line: ) def line(self, name: str) -> Line: - """The one line *name* prints as: a named expression's definition, a constraint, or a variable's domain. + """The one line *name* prints as: a named expression's definition, a constraint, an assumption, or a variable's domain. - *name* is one of the three; :func:`~math_spec.typesetting.typeset_declaration` + *name* is one of the four; :func:`~math_spec.typesetting.typeset_declaration` refuses the rest, and a constraint sharing a variable's name. """ if name in self.schema.expressions: return self.definition(name) if name in self.schema.constraints: return self._constraint(name) + if name in self.schema.assumptions: + return self._assumption(name) return self._variable(name) def _arms(self, node: CasesNode, ctx: _Context) -> list[tuple[str, str]]: @@ -772,6 +800,102 @@ def _sos(self, name: str, block: SosBlock, ctx: _Context) -> Line: condition=self._quantifier([d for d in dims if d != block.over], ''), ) + # -- assumptions ------------------------------------------------------- + + def _assumptions(self) -> list[Line]: + """What the model assumes of its data: every ``assumptions:`` entry, then what each curve assumes of its breakpoints. + + A ``piecewise:`` block's checks are the language's own + (:func:`~math_spec.piecewise.declaration_of`), so they print here + beside the author's, under the block's name: the reader sees every + condition the data is held to, whether the file wrote it or a method + implied it. + """ + lines = [self._assumption(name) for name in self.schema.assumptions] + for name, expanded in self.schema.expanded_piecewise.items(): + lines += [self._check(name, expanded, check) for check in declaration_of(expanded).checks] + return lines + + def _assumption(self, name: str) -> Line: + """One declared assumption: the predicate, quantified over the frame both its masks name, under its ``where``.""" + holds, where = self.schema.resolved.assumptions[name] + frame = self._sorted(holds.dims | (where.dims if where is not None else frozenset())) + ctx = self._context(frame) + if isinstance(holds.root, RelationNode): + left, right = self._relation(holds.root, ctx) + else: + left, right = self._predicate(holds.root, ctx), '' + return Line(label=name, left=left, right=right, condition=self._quantifier(frame, self._condition(ctx, where))) + + def _check(self, block: str, expanded: ExpandedPiecewise, check: Check) -> Line: + """One condition a curve puts on its breakpoints, as the line a reader checks the data against. + + The strictly increasing x-axis is an inequality between neighbours, + printed with the plain translation whose vacated first row is absent. + The shape a method is exact for is prose, as a paper writes it, since + "convex or concave" is no one inequality. The two conditions on a + ``points:`` mask are stated of the set the mask admits. + """ + over = expanded.block.over + match check: + case Increasing(parameter, _): + frame = self._sorted(frozenset(self.schema.parameters[parameter].dims)) + ctx = self._context(frame) + previous = ctx.translated(over, _Step(1, 'plain')) + condition = '' + if (mask := expanded.points) is not None: + admitted = ParameterDefinedNode(mask, tuple(self.schema.parameters[mask].dims)) + condition = self.format.joined( + [self._predicate(admitted, ctx), self._predicate(admitted, previous)], self._op('and') + ) + return Line( + label=f'{block} increasing', + left=self._parameter(parameter, previous), + right=f'{self._op("lt")} {self._parameter(parameter, ctx)}', + condition=self._quantifier(frame, condition), + ) + case Curved(x, y, _, curvature): + dims = frozenset(self.schema.parameters[x].dims) | frozenset(self.schema.parameters[y].dims) + ctx = self._context(self._sorted(dims)) + shape = 'convex or concave' if curvature == 'either' else curvature + return Line( + label=f'{block} curvature', + left=self._parameter(y, ctx), + right=( + f'{self.format.prose(f" is a {shape} function of ")} {self._parameter(x, ctx)} ' + f'{self.format.prose(" along ")} {self.symbols.index[over]}' + ), + condition=self._quantifier(self._sorted(dims - {over}), ''), + ) + case AtLeastTwo(_, mask): + members, frame = self.symbols.set[over], self._sorted(frozenset()) + if mask is not None: + members, frame = self._admitted(mask, over) + return Line( + label=f'{block} breakpoints', + left=self.format.cardinality(members), + right=f'{self._op("ge")} {self._number(2)}', + condition=self._quantifier(frame, ''), + ) + case Contiguous(mask, _): + members, frame = self._admitted(mask, over) + return Line( + label=f'{block} points', + left=members, + right=self.format.prose(' is one run of consecutive breakpoints'), + condition=self._quantifier(frame, ''), + ) + case _: + assert_never(check) + + def _admitted(self, mask: str, over: str) -> tuple[str, list[str]]: + """The breakpoints *mask* admits along *over* as a set, and the frame that set is one of per curve.""" + dims = frozenset(self.schema.parameters[mask].dims) + frame = self._sorted(dims - {over}) + ctx = self._context([*frame, over]) + admitted = self._predicate(ParameterDefinedNode(mask, tuple(self.schema.parameters[mask].dims)), ctx) + return self.format.set_of(self._membership(over), admitted), frame + def _bound(self, ctx: _Context, value: float | str) -> str: if isinstance(value, str): return ctx.indexed(self.symbols.name[value], list(self.schema.parameters[value].dims)) diff --git a/src/math_spec/validation.py b/src/math_spec/validation.py index f8316600..d3c92334 100644 --- a/src/math_spec/validation.py +++ b/src/math_spec/validation.py @@ -37,8 +37,9 @@ from math_spec.expansion import expand, parse_and_expand, parse_template from math_spec.model import Spec from math_spec.operators import BUILTINS, unknown_operator_message -from math_spec.program import BooleanLiteralNode +from math_spec.program import BooleanLiteralNode, Mask, VariableDefinedNode from math_spec.resolution import ( + Assumption, Namespace, Resolved, ResolvedConstraint, @@ -51,7 +52,7 @@ if TYPE_CHECKING: from pathlib import Path - from math_spec.model import ExpressionBlock + from math_spec.model import AssumptionBlock, ExpressionBlock from math_spec.program import WhereNode @@ -161,10 +162,15 @@ def validate_expressions(schema: Spec) -> Resolved: schema.objective.expression, schema, ns, 'The objective', errors, comparison=False, ceiling=2 ) + assumptions: dict[str, Assumption] = {} + for aname, adef in schema.assumptions.items(): + if (assumption := _assumption(aname, adef, ns, errors)) is not None: + assumptions[aname] = assumption + if errors: raise SchemaError(_once(errors)) - resolved = Resolved(expressions, variables, constraints, objective) + resolved = Resolved(expressions, variables, constraints, objective, assumptions) check_schema(schema, resolved) return resolved @@ -206,6 +212,43 @@ def _named( return CasesNode(name, (*arms, CaseArm('otherwise', None, fallback))) +def _assumption(name: str, block: AssumptionBlock, ns: Namespace, errors: list[str]) -> Assumption | None: + """One ``assumptions:`` entry typed, or ``None`` once anything in it failed. + + A predicate the connectives decide is refused: one that folds to true + assumes nothing, and one that folds to false refuses every dataset. A + variable is refused too, since an assumption is about the data and a + variable is what the solver decides from it. + """ + context = f"Assumption '{name}'" + found = len(errors) + holds = resolve_where_text(block.holds, ns, context, errors) + where = resolve_where_text(block.where, ns, f'{context}, where', errors) + if isinstance(holds, BooleanLiteralNode): + errors.append( + f'{context}: the predicate {block.holds!r} folds to {holds.value}, so it ' + + ( + 'assumes nothing of the data. Delete it, or name a parameter it constrains.' + if holds.value + else 'holds on no data at all. Delete it, or write the predicate the data can satisfy.' + ) + ) + for mask, part in ((holds, 'assumes'), (where, 'is checked where')): + if mask is None or isinstance(mask, BooleanLiteralNode): + continue + errors.extend( + f"{context}: variable '{atom.name}' stands in what the assumption {part}, and an assumption is " + f'about the data — a variable is what the solver decides from it. Name a parameter, or state the ' + f'rule as a constraint.' + for atom in Mask(mask).atoms + if isinstance(atom, VariableDefinedNode) + ) + if len(errors) > found: + return None + assert holds is not None, 'a where string that read to nothing appended an error' + return Assumption(Mask(holds), mask_of(where)) + + def _prefixed(context: str, e: ValueError) -> str: """*e* under *context*, once — expansion errors already carry it.""" return str(e) if str(e).startswith(context) else f'{context}: {e}' @@ -299,7 +342,7 @@ def _check_expression( f'Got: {expression!r}\n' f'A constraint is a claim about a decision, and a comparison of numbers and parameters ' f'is settled before the solve — no consumer builds a row for it. Name the variable it should ' - f'bound, or drop the declaration and check the fact where the data is prepared.' + f'bound, or state the fact under `assumptions:`, where the consumer binding the data checks it.' ) return None return resolved diff --git a/tests/test_exclusivity.py b/tests/test_exclusivity.py index b91cad3f..8e08c40f 100644 --- a/tests/test_exclusivity.py +++ b/tests/test_exclusivity.py @@ -60,6 +60,27 @@ def refusals(schema: Spec, cases: dict[str, str]) -> list[str]: return list(overlapping({name: _mask(when, namespace, name) for name, when in cases.items()}, namespace.dtypes)) +def test_two_parameters_compared_are_one_subject_ordered_three_ways(schema): + """The order between two columns is less, equal or greater, so `<` and `>=` are apart and `<=` meets `>=` at equality.""" + assert refusals(schema, {'below': 'soc_initial < capacity', 'above': 'soc_initial >= capacity'}) == [], ( + 'the two orders share no cell' + ) + [refusal] = refusals(schema, {'below': 'soc_initial <= capacity', 'above': 'soc_initial >= capacity'}) + assert 'soc_initial vs capacity is equal' in refusal, 'the witness names the order both cases claim' + + +def test_a_parameter_pair_with_a_row_missing_compares_false_under_every_comparator(schema): + """`NOT (a < b)` and `NOT (a >= b)` are apart wherever both rows exist, and both claim a coordinate where one is missing. + + A missing row read as the smallest value instead would put the two apart + everywhere, and the data would then give one coordinate two values. + """ + [refusal] = refusals( + schema, {'not_below': 'NOT (soc_initial < capacity)', 'not_above': 'NOT (soc_initial >= capacity)'} + ) + assert 'soc_initial vs capacity is absent' in refusal, 'the one cell both claim is the missing row' + + def _mask(text: str, namespace: Namespace, name: str) -> WhereNode: """Resolved but not folded, which is the shape a case's `when` reaches the prover in.""" errors: list[str] = [] diff --git a/tests/test_lowering.py b/tests/test_lowering.py index f743319f..4c81cc06 100644 --- a/tests/test_lowering.py +++ b/tests/test_lowering.py @@ -25,6 +25,7 @@ QUADRATIC_POSITIONS, Add, AndNode, + AssumptionDeclaration, At, BooleanLiteralNode, Cases, @@ -45,6 +46,7 @@ Parameter, ParameterComparisonNode, ParameterDefinedNode, + ParameterPairComparisonNode, Power, Program, Region, @@ -52,6 +54,7 @@ Translate, Variable, Window, + assumption_message, children, divisor_parameters, fan_in, @@ -392,6 +395,44 @@ def test_a_constraint_where_is_a_mask_like_a_variable_s(): assert c.where == Mask(ParameterComparisonNode('load', '>', 0.0, ('snapshot',))) +def test_an_assumption_lowers_to_its_predicate_and_the_mask_it_is_checked_under(): + """What the file assumes of its data reaches the consumer as two masks under the name the file wrote.""" + program = to_program( + override( + DISPATCH_MODEL, + assumptions={'capacity': 'p_max >= 0', 'ordered': {'holds': 'cost <= p_max', 'where': 'cost'}}, + ) + ) + + assert program.assumptions['capacity'] == AssumptionDeclaration( + Mask(ParameterComparisonNode('p_max', '>=', 0.0, ('generator',))) + ), 'an assumption without a where is checked at every coordinate its predicate names' + assert program.assumptions['ordered'] == AssumptionDeclaration( + Mask(ParameterPairComparisonNode('cost', 'p_max', '<=', ('generator',))), + Mask(ParameterDefinedNode('cost', ('generator',))), + ) + assert assumption_message('ordered', program.assumptions['ordered']) == ( + "assumption 'ordered' does not hold for the data bound to 'cost', 'p_max'" + ), 'the sentence names every parameter the predicate reads, sorted, for the consumer to append what it saw' + + +def test_a_parameter_pair_carries_the_union_of_both_dims_left_first(): + """`p_max <= floor` over `[generator]` and `[snapshot, generator]` is read at every coordinate of both.""" + program = to_program( + override( + DISPATCH_MODEL, + **{'parameters.floor': {'dims': ['snapshot', 'generator']}, 'assumptions': {'a': 'p_max <= floor'}}, + ) + ) + holds = program.assumptions['a'].holds + + assert holds.root == ParameterPairComparisonNode('p_max', 'floor', '<=', ('generator', 'snapshot')), ( + "the left parameter's dims first, then what the right one adds" + ) + assert holds.dims == frozenset({'generator', 'snapshot'}) + assert holds.names_read == frozenset({'p_max', 'floor'}), 'a pair names both sides it compares' + + def test_a_power_lowers_to_a_node_of_its_own(dispatch_schema): lowered = _Lowering(dispatch_schema, 't').expr(resolved('cost ** cost', dispatch_schema)) assert isinstance(lowered, Power), 'a variable-free power has a plan node of its own' diff --git a/tests/test_validation.py b/tests/test_validation.py index 2fcc591a..43704133 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -715,7 +715,9 @@ class TestRulesDecidedWithoutData: id='by-the-same-target-twice', ), pytest.param( - {'variables.p.where': 'c > flag'}, ('compares two parameters',), id='where-against-a-parameter' + {'variables.p.where': 'c > flag'}, + ('compares a float parameter against a bool one', 'Declare both as numbers'), + id='where-against-a-parameter-of-another-dtype', ), pytest.param( {'variables.p.where': 'c > q'}, @@ -745,6 +747,83 @@ def test_a_rule_decided_without_data(self, patch, fragments): assert fragment in message +class TestAssumptions: + """What an `assumptions:` entry may say, decided with no data bound.""" + + @pytest.mark.parametrize( + ('patch', 'fragments'), + [ + pytest.param( + {'assumptions': {'a': 'True'}}, + ("Assumption 'a'", 'folds to True', 'assumes nothing of the data'), + id='a-predicate-that-is-always-true', + ), + pytest.param( + {'assumptions': {'a': 'c > 0 AND False'}}, + ('folds to False', 'holds on no data at all'), + id='a-predicate-that-is-always-false', + ), + pytest.param( + {'assumptions': {'a': 'p'}}, + ("variable 'p' stands in what the assumption assumes", 'an assumption is about the data'), + id='a-variable-in-the-predicate', + ), + pytest.param( + {'assumptions': {'a': {'holds': 'c > 0', 'where': 'q'}}}, + ("variable 'q' stands in what the assumption is checked where",), + id='a-variable-in-the-where', + ), + pytest.param({'assumptions': {'a': 'nope > 0'}}, ("'nope' not found",), id='an-unknown-name'), + pytest.param({'assumptions': {'a': 'c >'}}, ('Failed to parse where string',), id='a-malformed-predicate'), + pytest.param( + {'assumptions': {'a': 'c > tag'}}, + ('compares a float parameter against a str one', 'Declare both as numbers'), + id='two-parameters-of-different-dtypes', + ), + pytest.param( + {'assumptions': {'a': 'c > p'}}, ('compares against variable',), id='a-parameter-against-a-variable' + ), + pytest.param( + {'assumptions': {'a': {'holds': 'c > 0', 'wher': 'flag'}}}, + ("unknown key 'wher' in an assumption declaration", "Did you mean 'where'?"), + id='a-misspelt-key', + ), + ], + ) + def test_a_bad_assumption_is_refused_at_load(self, patch, fragments): + message = _refusal(**patch) + for fragment in fragments: + assert fragment in message + + @pytest.mark.parametrize( + ('patch', 'holds'), + [ + pytest.param({}, 'c >= 0', id='one-parameter-against-a-number'), + pytest.param({}, 'c <= k', id='two-numbers-of-different-dims'), + pytest.param({'parameters.n': {'dims': ['g'], 'dtype': 'int'}}, 'n < c', id='an-int-against-a-float'), + pytest.param({'parameters.tag2': {'dims': ['g'], 'dtype': 'str'}}, 'tag != tag2', id='two-labels'), + pytest.param({'parameters.flag2': {'dims': ['g'], 'dtype': 'bool'}}, 'flag == flag2', id='two-flags'), + pytest.param({}, "tag == 'gas'", id='a-label-against-a-literal'), + pytest.param({}, 'NOT flag OR c > 0', id='a-compound-predicate'), + pytest.param({}, 'k > 0', id='a-scalar'), + ], + ) + def test_an_assumption_about_the_data_loads(self, patch, holds): + spec = _schema(**patch, assumptions={'a': holds}) + assert list(spec.assumptions) == ['a'] + + def test_an_assumption_round_trips_in_the_form_it_was_written(self): + """A bare string stays a bare string, and a mapping stays a mapping, so `to_yaml` reproduces the file.""" + spec = _schema( + assumptions={'bare': 'c > 0', 'masked': {'holds': 'c <= k', 'where': 'flag', 'description': 'why'}} + ) + assert to_spec(spec.to_dict()) == spec + assert spec.to_dict()['assumptions'] == { + 'bare': 'c > 0', + 'masked': {'holds': 'c <= k', 'where': 'flag', 'description': 'why'}, + }, 'each entry is written back in the form it arrived in' + + class TestTheFrontDoor: def test_a_list_of_models_is_not_a_model(self): """Composition is Python's, not the file's (#30) — and the refusal is the package's own, so the CLI's one except catches it.""" diff --git a/tests/typesetting/golden/latex.out b/tests/typesetting/golden/latex.out index 4b434197..e5a24467 100644 --- a/tests/typesetting/golden/latex.out +++ b/tests/typesetting/golden/latex.out @@ -15,6 +15,7 @@ \item[{$\mathcal{Z}$}] index $z$ --- \texttt{zone} \item[{$\mathcal{S}$}] index $s$ --- \texttt{season} \item[{$\mathcal{E}$}] index $e$ --- \texttt{technology} +\item[{$\mathcal{A}$}] index $a$ --- \texttt{bp} \end{description} \paragraph{Parameters} @@ -31,6 +32,13 @@ \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{fuel\_bp}$}] \texttt{fuel\_bp} over $\mathcal{A}$ +\item[{$\mathrm{heat}^{\mathrm{bp}}$}] \texttt{heat\_bp} over $\mathcal{A}$ +\item[{$\mathrm{cost}^{\mathrm{curve,points}}$}] \texttt{cost\_curve\_points} over $\mathcal{G} \times \mathcal{A}$ --- where 'bp\_x' has a row, and so where the curve runs +\item[{$\mathrm{cost}^{\mathrm{curve,starts}}$}] \texttt{cost\_curve\_starts} over $\mathcal{G} \times \mathcal{A}$ --- the first breakpoint of each curve +\item[{$\mathrm{cost}^{\mathrm{curve,ends}}$}] \texttt{cost\_curve\_ends} over $\mathcal{G} \times \mathcal{A}$ --- the last breakpoint of each curve \end{description} \paragraph{Variables} @@ -45,6 +53,8 @@ \item[{$\mathit{reserve}$}] \texttt{reserve} (scalar) \item[{$\mathit{headroom}$}] \texttt{headroom} (scalar) \item[{$\mathit{weight}$}] \texttt{weight} over $\mathcal{T} \times \mathcal{G}$ +\item[{$\mathit{op\_cost}$}] \texttt{op\_cost} over $\mathcal{T} \times \mathcal{G}$ +\item[{$\mathit{heat}$}] \texttt{heat} over $\mathcal{T} \times \mathcal{G}$ \end{description} \paragraph{Definitions} @@ -105,7 +115,13 @@ \text{ceiling} && \theta_{b} & \le \infty && \forall\, b \in \mathcal{B} \\ \text{always} && \mathit{spill}_{t} & \ge 0 && \forall\, t \in \mathcal{T} \\ \text{redundant} && \mathit{spill}_{t} & \ge 0 && \forall\, t \in \mathcal{T} \,:\, \mathit{spill}_{t} \text{ exists} \\ -\text{never} && \mathit{slack}_{t} & \ge 0 && \forall\, t \in \mathcal{T} \,:\, \bot +\text{never} && \mathit{slack}_{t} & \ge 0 && \forall\, t \in \mathcal{T} \,:\, \bot \\ +\text{cost\_curve\_chord} && \mathit{op\_cost}_{t,g} \cdot \left( \mathrm{bp\_x}_{g,a} - \mathrm{bp\_x}_{g,a \boxminus_{0} 1} \right) & \ge \left( \mathrm{bp\_y}_{g,a} - \mathrm{bp\_y}_{g,a \boxminus_{0} 1} \right) \cdot \left( p_{t,g} - \mathrm{bp\_x}_{g,a} \right) + \mathrm{bp\_y}_{g,a} \cdot \left( \mathrm{bp\_x}_{g,a} - \mathrm{bp\_x}_{g,a \boxminus_{0} 1} \right) && \forall\, t \in \mathcal{T},\ g \in \mathcal{G},\ a \in \mathcal{A} \,:\, \mathrm{cost}^{\mathrm{curve,points}}_{g,a} \wedge \neg \mathrm{cost}^{\mathrm{curve,starts}}_{g,a} \\ +\text{cost\_curve\_domain\_lo} && p_{t,g} & \ge \mathrm{bp\_x}_{g,a} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G},\ a \in \mathcal{A} \,:\, \mathrm{cost}^{\mathrm{curve,starts}}_{g,a} \\ +\text{cost\_curve\_domain\_hi} && p_{t,g} & \le \mathrm{bp\_x}_{g,a} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G},\ a \in \mathcal{A} \,:\, \mathrm{cost}^{\mathrm{curve,ends}}_{g,a} \\ +\text{heat\_curve\_chord} && \mathit{heat}_{t,g} \cdot \left( \mathrm{fuel\_bp}_{a} - \mathrm{fuel\_bp}_{a \boxminus_{0} 1} \right) & \le \left( \mathrm{heat}^{\mathrm{bp}}_{a} - \mathrm{heat}^{\mathrm{bp}}_{a \boxminus_{0} 1} \right) \cdot \left( p_{t,g} - \mathrm{fuel\_bp}_{a} \right) + \mathrm{heat}^{\mathrm{bp}}_{a} \cdot \left( \mathrm{fuel\_bp}_{a} - \mathrm{fuel\_bp}_{a \boxminus_{0} 1} \right) && \forall\, t \in \mathcal{T},\ g \in \mathcal{G},\ a \in \mathcal{A} \,:\, \mathrm{pos}(a) \neq 0 \\ +\text{heat\_curve\_domain\_lo} && p_{t,g} & \ge \mathrm{fuel\_bp}_{a} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G},\ a \in \mathcal{A} \,:\, \mathrm{pos}(a) = 0 \\ +\text{heat\_curve\_domain\_hi} && p_{t,g} & \le \mathrm{fuel\_bp}_{a} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G},\ a \in \mathcal{A} \,:\, \mathrm{pos}(a) = \lvert \mathcal{A} \rvert - 1 \end{align} \paragraph{Definitions} @@ -128,7 +144,24 @@ \text{reserve} && \mathit{reserve} & \ge 0 \\ \text{headroom} && \mathit{headroom} & \ge 0 && \text{where } \mathrm{budget} \text{ is defined} \\ \text{weight} && 0 \le \mathit{weight}_{t,g} & \le 1 && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ -\text{weight sos} && \left( \mathit{weight}_{t,g} \right)_{g \in \mathcal{G}} & \in \mathrm{SOS}2 && \forall\, t \in \mathcal{T} +\text{weight sos} && \left( \mathit{weight}_{t,g} \right)_{g \in \mathcal{G}} & \in \mathrm{SOS}2 && \forall\, t \in \mathcal{T} \\ +\text{op\_cost} && \mathit{op\_cost}_{t,g} & \ge 0 && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ +\text{heat} && \mathit{heat}_{t,g} & \ge 0 && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +\end{align} + +\paragraph{Assumptions} +\begin{align} +\text{capacity\_is\_nonnegative} && \mathrm{p}^{\mathrm{max}}_{g} & \ge 0 && \forall\, g \in \mathcal{G} \\ +\text{bounds\_are\_ordered} && \mathrm{p}^{\mathrm{min}}_{g} & \le \mathrm{p}^{\mathrm{max}}_{g} && \forall\, g \in \mathcal{G} \,:\, \mathrm{p}^{\mathrm{min}}_{g} \text{ is defined} \\ +\text{budget\_is\_positive} && \mathrm{budget} & > 0 \\ +\text{flexible\_units\_are\_cheap} && \neg \mathrm{is\_flexible}_{g} \vee \mathrm{cost}_{g} \le \mathrm{budget} & && \forall\, g \in \mathcal{G} \\ +\text{cost\_curve increasing} && \mathrm{bp\_x}_{g,a - 1} & < \mathrm{bp\_x}_{g,a} && \forall\, g \in \mathcal{G},\ a \in \mathcal{A} \,:\, \mathrm{cost}^{\mathrm{curve,points}}_{g,a} \wedge \mathrm{cost}^{\mathrm{curve,points}}_{g,a - 1} \\ +\text{cost\_curve curvature} && \mathrm{bp\_y}_{g,a} & \text{ is a convex function of } \mathrm{bp\_x}_{g,a} \text{ along } a && \forall\, g \in \mathcal{G} \\ +\text{cost\_curve breakpoints} && \lvert \{ a \in \mathcal{A} \,:\, \mathrm{cost}^{\mathrm{curve,points}}_{g,a} \} \rvert & \ge 2 && \forall\, g \in \mathcal{G} \\ +\text{cost\_curve points} && \{ a \in \mathcal{A} \,:\, \mathrm{cost}^{\mathrm{curve,points}}_{g,a} \} & \text{ is one run of consecutive breakpoints} && \forall\, g \in \mathcal{G} \\ +\text{heat\_curve increasing} && \mathrm{fuel\_bp}_{a - 1} & < \mathrm{fuel\_bp}_{a} && \forall\, a \in \mathcal{A} \\ +\text{heat\_curve curvature} && \mathrm{heat}^{\mathrm{bp}}_{a} & \text{ is a concave function of } \mathrm{fuel\_bp}_{a} \text{ along } a \\ +\text{heat\_curve breakpoints} && \lvert \mathcal{A} \rvert & \ge 2 \end{align} \end{document} diff --git a/tests/typesetting/golden/markdown.out b/tests/typesetting/golden/markdown.out index fa7ee0a5..b3fec5de 100644 --- a/tests/typesetting/golden/markdown.out +++ b/tests/typesetting/golden/markdown.out @@ -12,6 +12,7 @@ every character a notation escapes, set as text: link\_to, 100% & \#1 costs \$5 | $`\mathcal{Z}`$ | index $`z`$ — `zone` | | $`\mathcal{S}`$ | index $`s`$ — `season` | | $`\mathcal{E}`$ | index $`e`$ — `technology` | +| $`\mathcal{A}`$ | index $`a`$ — `bp` | #### Parameters @@ -29,6 +30,13 @@ 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{fuel\_bp}`$ | `fuel_bp` over $`\mathcal{A}`$ | +| $`\mathrm{heat}^{\mathrm{bp}}`$ | `heat_bp` over $`\mathcal{A}`$ | +| $`\mathrm{cost}^{\mathrm{curve,points}}`$ | `cost_curve_points` over $`\mathcal{G} \times \mathcal{A}`$ — where 'bp\_x' has a row, and so where the curve runs | +| $`\mathrm{cost}^{\mathrm{curve,starts}}`$ | `cost_curve_starts` over $`\mathcal{G} \times \mathcal{A}`$ — the first breakpoint of each curve | +| $`\mathrm{cost}^{\mathrm{curve,ends}}`$ | `cost_curve_ends` over $`\mathcal{G} \times \mathcal{A}`$ — the last breakpoint of each curve | #### Variables @@ -44,6 +52,8 @@ every character a notation escapes, set as text: link\_to, 100% & \#1 costs \$5 | $`\mathit{reserve}`$ | `reserve` (scalar) | | $`\mathit{headroom}`$ | `headroom` (scalar) | | $`\mathit{weight}`$ | `weight` over $`\mathcal{T} \times \mathcal{G}`$ | +| $`\mathit{op\_cost}`$ | `op_cost` over $`\mathcal{T} \times \mathcal{G}`$ | +| $`\mathit{heat}`$ | `heat` over $`\mathcal{T} \times \mathcal{G}`$ | #### Definitions @@ -256,6 +266,42 @@ p_{t,g} \le \mathrm{eta}_{g} \cdot \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\ \mathit{slack}_{t} \ge 0 \qquad \forall\, t \in \mathcal{T} \,:\, \bot ``` +**`cost_curve_chord`** + +```math +\mathit{op\_cost}_{t,g} \cdot \left( \mathrm{bp\_x}_{g,a} - \mathrm{bp\_x}_{g,a \boxminus_{0} 1} \right) \ge \left( \mathrm{bp\_y}_{g,a} - \mathrm{bp\_y}_{g,a \boxminus_{0} 1} \right) \cdot \left( p_{t,g} - \mathrm{bp\_x}_{g,a} \right) + \mathrm{bp\_y}_{g,a} \cdot \left( \mathrm{bp\_x}_{g,a} - \mathrm{bp\_x}_{g,a \boxminus_{0} 1} \right) \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G},\ a \in \mathcal{A} \,:\, \mathrm{cost}^{\mathrm{curve,points}}_{g,a} \wedge \neg \mathrm{cost}^{\mathrm{curve,starts}}_{g,a} +``` + +**`cost_curve_domain_lo`** + +```math +p_{t,g} \ge \mathrm{bp\_x}_{g,a} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G},\ a \in \mathcal{A} \,:\, \mathrm{cost}^{\mathrm{curve,starts}}_{g,a} +``` + +**`cost_curve_domain_hi`** + +```math +p_{t,g} \le \mathrm{bp\_x}_{g,a} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G},\ a \in \mathcal{A} \,:\, \mathrm{cost}^{\mathrm{curve,ends}}_{g,a} +``` + +**`heat_curve_chord`** + +```math +\mathit{heat}_{t,g} \cdot \left( \mathrm{fuel\_bp}_{a} - \mathrm{fuel\_bp}_{a \boxminus_{0} 1} \right) \le \left( \mathrm{heat}^{\mathrm{bp}}_{a} - \mathrm{heat}^{\mathrm{bp}}_{a \boxminus_{0} 1} \right) \cdot \left( p_{t,g} - \mathrm{fuel\_bp}_{a} \right) + \mathrm{heat}^{\mathrm{bp}}_{a} \cdot \left( \mathrm{fuel\_bp}_{a} - \mathrm{fuel\_bp}_{a \boxminus_{0} 1} \right) \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G},\ a \in \mathcal{A} \,:\, \mathrm{pos}(a) \neq 0 +``` + +**`heat_curve_domain_lo`** + +```math +p_{t,g} \ge \mathrm{fuel\_bp}_{a} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G},\ a \in \mathcal{A} \,:\, \mathrm{pos}(a) = 0 +``` + +**`heat_curve_domain_hi`** + +```math +p_{t,g} \le \mathrm{fuel\_bp}_{a} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G},\ a \in \mathcal{A} \,:\, \mathrm{pos}(a) = \lvert \mathcal{A} \rvert - 1 +``` + #### Definitions **`spend`** @@ -349,3 +395,83 @@ p_{t,g} \le \mathrm{eta}_{g} \cdot \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\ ```math \left( \mathit{weight}_{t,g} \right)_{g \in \mathcal{G}} \in \mathrm{SOS}2 \qquad \forall\, t \in \mathcal{T} ``` + +**`op_cost`** + +```math +\mathit{op\_cost}_{t,g} \ge 0 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +``` + +**`heat`** + +```math +\mathit{heat}_{t,g} \ge 0 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +``` + +#### Assumptions + +**`capacity_is_nonnegative`** + +```math +\mathrm{p}^{\mathrm{max}}_{g} \ge 0 \qquad \forall\, g \in \mathcal{G} +``` + +**`bounds_are_ordered`** + +```math +\mathrm{p}^{\mathrm{min}}_{g} \le \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\, g \in \mathcal{G} \,:\, \mathrm{p}^{\mathrm{min}}_{g} \text{ is defined} +``` + +**`budget_is_positive`** + +```math +\mathrm{budget} > 0 +``` + +**`flexible_units_are_cheap`** + +```math +\neg \mathrm{is\_flexible}_{g} \vee \mathrm{cost}_{g} \le \mathrm{budget} \qquad \forall\, g \in \mathcal{G} +``` + +**`cost_curve increasing`** + +```math +\mathrm{bp\_x}_{g,a - 1} < \mathrm{bp\_x}_{g,a} \qquad \forall\, g \in \mathcal{G},\ a \in \mathcal{A} \,:\, \mathrm{cost}^{\mathrm{curve,points}}_{g,a} \wedge \mathrm{cost}^{\mathrm{curve,points}}_{g,a - 1} +``` + +**`cost_curve curvature`** + +```math +\mathrm{bp\_y}_{g,a} \text{ is a convex function of } \mathrm{bp\_x}_{g,a} \text{ along } a \qquad \forall\, g \in \mathcal{G} +``` + +**`cost_curve breakpoints`** + +```math +\lvert \{ a \in \mathcal{A} \,:\, \mathrm{cost}^{\mathrm{curve,points}}_{g,a} \} \rvert \ge 2 \qquad \forall\, g \in \mathcal{G} +``` + +**`cost_curve points`** + +```math +\{ a \in \mathcal{A} \,:\, \mathrm{cost}^{\mathrm{curve,points}}_{g,a} \} \text{ is one run of consecutive breakpoints} \qquad \forall\, g \in \mathcal{G} +``` + +**`heat_curve increasing`** + +```math +\mathrm{fuel\_bp}_{a - 1} < \mathrm{fuel\_bp}_{a} \qquad \forall\, a \in \mathcal{A} +``` + +**`heat_curve curvature`** + +```math +\mathrm{heat}^{\mathrm{bp}}_{a} \text{ is a concave function of } \mathrm{fuel\_bp}_{a} \text{ along } a +``` + +**`heat_curve breakpoints`** + +```math +\lvert \mathcal{A} \rvert \ge 2 +``` diff --git a/tests/typesetting/golden/model.yaml b/tests/typesetting/golden/model.yaml index e1ebe59d..863c3985 100644 --- a/tests/typesetting/golden/model.yaml +++ b/tests/typesetting/golden/model.yaml @@ -19,6 +19,7 @@ dimensions: zone: { dtype: str } season: { dtype: str } technology: { dtype: str } + bp: { dtype: int } lookups: gen_bus: { over: generator, into: bus } @@ -40,6 +41,10 @@ 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] } # a curve's breakpoints, one curve per generator + bp_y: { dims: [generator, bp] } + fuel_bp: { dims: [bp] } # a second curve's breakpoints, one curve for every generator + heat_bp: { dims: [bp] } variables: p: # both bounds, and a where with all three connectives @@ -74,6 +79,27 @@ variables: weight: # the family a sos runs along dims: [snapshot, generator] bounds: { lower: 0, upper: 1 } + op_cost: # what a curve bounds below + dims: [snapshot, generator] + bounds: { lower: 0 } + heat: # what a second curve bounds above + dims: [snapshot, generator] + bounds: { lower: 0 } + +piecewise: + cost_curve: # a curve as its segment lines, masked by its own breakpoints: every check a curve can carry prints under Assumptions + over: bp + points: bp_x + method: lp + links: + - [p, bp_x] + - [op_cost, bp_y, ">="] + heat_curve: # the same method over every breakpoint, so the count is the set's own size and the curve bends the other way + over: bp + method: lp + links: + - [p, fuel_bp] + - [heat, heat_bp, "<="] sos: adjacent: # at most two adjacent members nonzero, one set per snapshot @@ -199,6 +225,14 @@ constraints: where: "false" expression: slack >= 0 +assumptions: + capacity_is_nonnegative: p_max >= 0 # one comparison, so the line aligns on its relation + bounds_are_ordered: # two parameters compared coordinate by coordinate, checked where the narrower one is defined + holds: "p_min <= p_max" + where: "p_min" + budget_is_positive: budget > 0 # a scalar, so no quantifier + flexible_units_are_cheap: "NOT is_flexible OR cost <= budget" # a compound predicate, which no one relation aligns + objective: # a sense, a product of two variables, a power over two parameters, a power of one of those, and the summations a scalar objective spells out beside two scalar terms sense: maximize expression: sum(p * cost) + sum(p * p * cost) + sum(p * cost * growth ** lead) + sum(p * (growth ** lead) ** 2) + sum(p * p_max) - reserve + -headroom diff --git a/tests/typesetting/golden/typst.out b/tests/typesetting/golden/typst.out index 7f286e31..6d381ea4 100644 --- a/tests/typesetting/golden/typst.out +++ b/tests/typesetting/golden/typst.out @@ -10,6 +10,7 @@ every character a notation escapes, set as text: link\_to, 100% & \#1 costs \$5 / $cal(Z)$: index $z$ --- `zone` / $cal(S)$: index $s$ --- `season` / $cal(E)$: index $e$ --- `technology` +/ $cal(A)$: index $a$ --- `bp` == Parameters / $upright("p")^(upright("max"))$: `p_max` over $cal(G)$ @@ -24,6 +25,13 @@ 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("fuel_bp")$: `fuel_bp` over $cal(A)$ +/ $upright("heat")^(upright("bp"))$: `heat_bp` over $cal(A)$ +/ $upright("cost")^(upright("curve,points"))$: `cost_curve_points` over $cal(G) times cal(A)$ --- where 'bp\_x' has a row, and so where the curve runs +/ $upright("cost")^(upright("curve,starts"))$: `cost_curve_starts` over $cal(G) times cal(A)$ --- the first breakpoint of each curve +/ $upright("cost")^(upright("curve,ends"))$: `cost_curve_ends` over $cal(G) times cal(A)$ --- the last breakpoint of each curve == Variables / $p$: `p` over $cal(T) times cal(G)$ @@ -36,6 +44,8 @@ every character a notation escapes, set as text: link\_to, 100% & \#1 costs \$5 / $italic("reserve")$: `reserve` (scalar) / $italic("headroom")$: `headroom` (scalar) / $italic("weight")$: `weight` over $cal(T) times cal(G)$ +/ $italic("op_cost")$: `op_cost` over $cal(T) times cal(G)$ +/ $italic("heat")$: `heat` over $cal(T) times cal(G)$ == Definitions / $italic("spend")$: `spend` over $cal(T)$ --- what a snapshot's dispatch costs @@ -92,7 +102,13 @@ $ upright("budgeted") & italic("spend")_(t) & <= upright("budget") & forall t in upright("ceiling") & theta_(b) & <= infinity & forall b in cal(B) \ upright("always") & italic("spill")_(t) & >= 0 & forall t in cal(T) \ upright("redundant") & italic("spill")_(t) & >= 0 & forall t in cal(T) colon italic("spill")_(t) upright(" exists") \ - upright("never") & italic("slack")_(t) & >= 0 & forall t in cal(T) colon bot $ + upright("never") & italic("slack")_(t) & >= 0 & forall t in cal(T) colon bot \ + upright("cost_curve_chord") & italic("op_cost")_(t,g) dot (upright("bp_x")_(g,a) - upright("bp_x")_(g,a minus.square_(0) 1)) & >= (upright("bp_y")_(g,a) - upright("bp_y")_(g,a minus.square_(0) 1)) dot (p_(t,g) - upright("bp_x")_(g,a)) + upright("bp_y")_(g,a) dot (upright("bp_x")_(g,a) - upright("bp_x")_(g,a minus.square_(0) 1)) & forall t in cal(T), g in cal(G), a in cal(A) colon upright("cost")^(upright("curve,points"))_(g,a) and not upright("cost")^(upright("curve,starts"))_(g,a) \ + upright("cost_curve_domain_lo") & p_(t,g) & >= upright("bp_x")_(g,a) & forall t in cal(T), g in cal(G), a in cal(A) colon upright("cost")^(upright("curve,starts"))_(g,a) \ + upright("cost_curve_domain_hi") & p_(t,g) & <= upright("bp_x")_(g,a) & forall t in cal(T), g in cal(G), a in cal(A) colon upright("cost")^(upright("curve,ends"))_(g,a) \ + upright("heat_curve_chord") & italic("heat")_(t,g) dot (upright("fuel_bp")_(a) - upright("fuel_bp")_(a minus.square_(0) 1)) & <= (upright("heat")^(upright("bp"))_(a) - upright("heat")^(upright("bp"))_(a minus.square_(0) 1)) dot (p_(t,g) - upright("fuel_bp")_(a)) + upright("heat")^(upright("bp"))_(a) dot (upright("fuel_bp")_(a) - upright("fuel_bp")_(a minus.square_(0) 1)) & forall t in cal(T), g in cal(G), a in cal(A) colon upright("pos")(a) != 0 \ + upright("heat_curve_domain_lo") & p_(t,g) & >= upright("fuel_bp")_(a) & forall t in cal(T), g in cal(G), a in cal(A) colon upright("pos")(a) = 0 \ + upright("heat_curve_domain_hi") & p_(t,g) & <= upright("fuel_bp")_(a) & forall t in cal(T), g in cal(G), a in cal(A) colon upright("pos")(a) = abs(cal(A)) - 1 $ == Definitions #set math.equation(numbering: "(1)") @@ -113,4 +129,20 @@ $ upright("p") & upright("p")^(upright("min"))_(g) <= p_(t,g) & <= upright("p")^ upright("reserve") & italic("reserve") & >= 0 \ upright("headroom") & italic("headroom") & >= 0 & upright("where ") upright("budget") upright(" is defined") \ upright("weight") & 0 <= italic("weight")_(t,g) & <= 1 & forall t in cal(T), g in cal(G) \ - upright("weight sos") & (italic("weight")_(t,g))_(g in cal(G)) & in upright("SOS")2 & forall t in cal(T) $ + upright("weight sos") & (italic("weight")_(t,g))_(g in cal(G)) & in upright("SOS")2 & forall t in cal(T) \ + upright("op_cost") & italic("op_cost")_(t,g) & >= 0 & forall t in cal(T), g in cal(G) \ + upright("heat") & italic("heat")_(t,g) & >= 0 & forall t in cal(T), g in cal(G) $ + +== Assumptions +#set math.equation(numbering: "(1)") +$ upright("capacity_is_nonnegative") & upright("p")^(upright("max"))_(g) & >= 0 & forall g in cal(G) \ + upright("bounds_are_ordered") & upright("p")^(upright("min"))_(g) & <= upright("p")^(upright("max"))_(g) & forall g in cal(G) colon upright("p")^(upright("min"))_(g) upright(" is defined") \ + upright("budget_is_positive") & upright("budget") & > 0 \ + upright("flexible_units_are_cheap") & not upright("is_flexible")_(g) or upright("cost")_(g) <= upright("budget") & & forall g in cal(G) \ + upright("cost_curve increasing") & upright("bp_x")_(g,a - 1) & < upright("bp_x")_(g,a) & forall g in cal(G), a in cal(A) colon upright("cost")^(upright("curve,points"))_(g,a) and upright("cost")^(upright("curve,points"))_(g,a - 1) \ + upright("cost_curve curvature") & upright("bp_y")_(g,a) & upright(" is a convex function of ") upright("bp_x")_(g,a) upright(" along ") a & forall g in cal(G) \ + upright("cost_curve breakpoints") & abs({a in cal(A) colon upright("cost")^(upright("curve,points"))_(g,a)}) & >= 2 & forall g in cal(G) \ + upright("cost_curve points") & {a in cal(A) colon upright("cost")^(upright("curve,points"))_(g,a)} & upright(" is one run of consecutive breakpoints") & forall g in cal(G) \ + upright("heat_curve increasing") & upright("fuel_bp")_(a - 1) & < upright("fuel_bp")_(a) & forall a in cal(A) \ + upright("heat_curve curvature") & upright("heat")^(upright("bp"))_(a) & upright(" is a concave function of ") upright("fuel_bp")_(a) upright(" along ") a \ + upright("heat_curve breakpoints") & abs(cal(A)) & >= 2 $ diff --git a/tests/typesetting/test_declaration.py b/tests/typesetting/test_declaration.py index d1872884..908a19e7 100644 --- a/tests/typesetting/test_declaration.py +++ b/tests/typesetting/test_declaration.py @@ -127,15 +127,25 @@ def test_a_body_naming_another_expression_inlines_it_on_its_own_and_names_it_in_ @pytest.mark.parametrize( ('name', 'match'), [ - pytest.param('spent', r"'spent' is not a named expression, constraint or variable.*spend", id='a-near-miss'), + pytest.param( + 'spent', r"'spent' is not a named expression, constraint, assumption or variable.*spend", id='a-near-miss' + ), pytest.param('objective', r"'objective' is not a named expression", id='the-objective-has-no-name'), ], ) -def test_a_name_declared_as_none_of_the_three_is_refused(name: str, match: str): +def test_a_name_declared_as_none_of_the_four_is_refused(name: str, match: str): with pytest.raises(SchemaError, match=match): typeset_declaration(PLAIN, name, 'latex') +def test_an_assumption_prints_as_the_line_the_document_prints_for_it(): + """The frame is every dim either mask names, so a `where` over another dim widens the quantifier.""" + model = override(PLAIN, assumptions={'affordable': {'holds': 'cost <= 10', 'where': 'load > 0'}}) + assert typeset_declaration(model, 'affordable', 'latex') == ( + r'\mathrm{cost}_{g} \le 10 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{load}_{t} > 0' + ) + + def test_a_name_shared_by_a_constraint_and_a_variable_is_refused_rather_than_guessed(): """Constraints sit outside the flat namespace, so the model admits the pair; one line prints one of them.""" model = override(PLAIN, **{'constraints.p': {'dims': ['snapshot', 'generator'], 'expression': 'p <= 1'}}) diff --git a/tests/typesetting/test_golden.py b/tests/typesetting/test_golden.py index e93b0712..66f9f2e6 100644 --- a/tests/typesetting/test_golden.py +++ b/tests/typesetting/test_golden.py @@ -129,6 +129,10 @@ def _rendered_trees() -> Iterator[object]: for mask in resolved.variables.values(): if mask is not None: yield mask.root + for holds, where in resolved.assumptions.values(): + yield holds.root + if where is not None: + yield where.root yield from resolved.expressions.values() @@ -221,7 +225,7 @@ def test_the_golden_model_reaches_every_line_of_the_walk(tmp_path: Path): 'to_latex(model)\n' 'to_latex(model, inline_expressions=True)\n' 'spec = to_spec(model)\n' - 'for name in (*spec.expressions, *spec.constraints, *spec.variables):\n' + 'for name in (*spec.expressions, *spec.constraints, *spec.assumptions, *spec.variables):\n' " typeset_declaration(model, name, 'latex')\n" ) subprocess.run( diff --git a/tests/typesetting/test_walk.py b/tests/typesetting/test_walk.py index 0db831e7..0788c826 100644 --- a/tests/typesetting/test_walk.py +++ b/tests/typesetting/test_walk.py @@ -18,6 +18,7 @@ from math_spec.typesetting.symbols import Symbols, _derive_name_symbol, chosen_expressions from math_spec.validation import to_spec from tests.fixtures import DISPATCH_MODEL, OPERATOR_PROBES, override +from tests.test_piecewise import LP, LP_MASKED from tests.typesetting import golden from tests.typesetting.fixtures import EVERY_FORMAT, LATEX @@ -759,3 +760,50 @@ def test_a_string_value_in_a_where_prints_as_a_quoted_label(name: FormatName, fm assert fmt.quoted('gas_ccgt') in text unquoted = text.replace(fmt.quoted('gas_ccgt'), '') assert fmt.prose('gas_ccgt') not in unquoted, 'a string value is data, never words inside math' + + +@EVERY_FORMAT +def test_an_assumption_prints_under_its_own_heading_quantified_over_its_frame(name: FormatName, fmt: Format): + """What the data is held to prints last, after what the solver decides, one line per entry. + + A predicate that is one comparison splits on its relation as a constraint + does; its `where` sits on the quantifier as a constraint's does. + """ + model = override( + DISPATCH_MODEL, + assumptions={'ordered': {'holds': 'cost <= p_max', 'where': 'cost'}, 'positive': 'cost > 0'}, + ) + text = typeset(model, name, legend=False) + assert text.index('Variable domains') < text.index('Assumptions'), 'the section comes after the domains' + cost = fmt.subscript(fmt.upright('cost'), ['g']) + p_max = fmt.subscript(fmt.superscript(fmt.upright('p'), fmt.upright('max')), ['g']) + section = text[text.index('Assumptions') :] + [ordered] = [line for line in section.splitlines() if f'{fmt.operators["le"]} {p_max}' in line] + assert cost in ordered and ordered.index(cost) < ordered.index(fmt.operators['le']), 'the relation splits the line' + assert fmt.operators['such_that'] in ordered and fmt.prose(' is defined') in ordered, ( + 'the where is the condition on the quantifier' + ) + assert f'{fmt.operators["gt"]} 0' in section + + +@EVERY_FORMAT +def test_a_curve_prints_what_it_assumes_of_its_breakpoints(name: FormatName, fmt: Format): + """The checks a `piecewise:` block carries print beside the author's, under the block's name.""" + text = typeset(LP_MASKED, name, legend=False) + section = text[text.index('Assumptions') :] + for kind in ('increasing', 'curvature', 'breakpoints', 'points'): + assert f'curve {kind}' in section, 'every check the block carries prints as a line labelled by the block' + bp_x = fmt.upright('bp_x') + [increasing] = [line for line in section.splitlines() if fmt.subscript(bp_x, ['b - 1']) in line] + assert f'{fmt.operators["lt"]} {fmt.subscript(bp_x, ["b"])}' in increasing, ( + 'strictly increasing breakpoints are an inequality between neighbours' + ) + assert fmt.prose(' is a convex function of ') in section, 'the shape lp is exact for, as a paper writes it' + assert f'{fmt.operators["ge"]} 2' in section + + unmasked = typeset(LP, name, legend=False) + unmasked = unmasked[unmasked.index('Assumptions') :] + assert fmt.cardinality(fmt.script('B')) in unmasked and f'{fmt.operators["ge"]} 2' in unmasked, ( + 'with no mask the count is the size of the breakpoint set itself' + ) + assert 'curve points' not in unmasked, 'nothing to be contiguous without a mask' diff --git a/tools/gallery.py b/tools/gallery.py index 14a4a1ec..fc376145 100644 --- a/tools/gallery.py +++ b/tools/gallery.py @@ -111,6 +111,7 @@ def declared_block(path: Path) -> str: equation = equations(_section(page, 'Subject to')) definition = equations(_section(page[page.index('#### Objective') :], 'Definitions')) if model.expressions else {} domains = _section(page, 'Variable domains').strip() + assumption = equations(_section(page, 'Assumptions')) if model.assumptions else {} parts = [legend, f'### Objective\n\n```yaml\n{declaration(text, "objective")}\n```\n\n{objective}'] for name, block in model.constraints.items(): parts.append( @@ -124,6 +125,13 @@ def declared_block(path: Path) -> str: for name in model.expressions ) parts.append(domains) + parts.extend( + f'### `{_stands_for(name, block.description)}`\n\n' + f'`{name}`\n\n' + f'```yaml\n{declaration(text, "assumptions", name)}\n```\n\n' + f'{assumption[name]}' + for name, block in model.assumptions.items() + ) return '\n\n'.join(parts) diff --git a/tools/notation.py b/tools/notation.py index de0b0af0..07aed0c0 100644 --- a/tools/notation.py +++ b/tools/notation.py @@ -55,6 +55,7 @@ 'constraints': 'Constraints', 'expressions': 'Definitions', 'variables': 'Variable domains', + 'assumptions': 'Assumptions', 'piecewise': 'Curves, as what they expand to', 'sos': 'Sets carried to the solver', } @@ -262,15 +263,16 @@ def _labels(declaration: Declaration, printed: dict[str, str]) -> list[str]: Two blocks do not print under their own name. A ``sos:`` block restricts a variable, so its line sits with that variable; a ``piecewise:`` block is sugar, and what prints is the rows and columns it expands to — every one of - which the expander names after the block. A declaration printing nothing is - an error rather than an empty row: it means the walk stopped rendering - something the file still declares. + which the expander names after the block — and, under Assumptions, what + the block assumes of its breakpoints, labelled `` ``. A + declaration printing nothing is an error rather than an empty row: it + means the walk stopped rendering something the file still declares. """ if declaration.name in printed: return [declaration.name] if (variable := declaration.field('variable')) and f'{variable} sos' in printed: return [f'{variable} sos'] - expanded = [label for label in printed if label.startswith(f'{declaration.name}_')] + expanded = [label for label in printed if label.startswith((f'{declaration.name}_', f'{declaration.name} '))] assert expanded, f'{declaration.name} declares math and the walk printed none of it' return expanded From f1ae45c56a0d3f6ff774f1a1280f5003505a254c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 07:57:47 +0000 Subject: [PATCH 2/3] test(typesetting): the line census exempts the closed-union guard over a curve's checks Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013HceuCYNepeQX8SdZtiMf1 --- tests/typesetting/test_golden.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/typesetting/test_golden.py b/tests/typesetting/test_golden.py index 66f9f2e6..3d770c95 100644 --- a/tests/typesetting/test_golden.py +++ b/tests/typesetting/test_golden.py @@ -190,7 +190,8 @@ def test_the_golden_model_calls_every_operator_in_the_language(): #: What the fixture cannot reach, by the source text of the line. The guards #: are what the walk raises when resolution hands it something it types away, -#: so a model reaching one is a bug upstream. The absent objective is the arm a +#: so a model reaching one is a bug upstream; the closing arm over a curve's +#: checks is the same guard on the closed ``Check`` union. The absent objective is the arm a #: *different* model takes — a file declares at most one — and #: `test_a_model_with_no_objective_prints_the_rest` covers it. UNREACHABLE = { @@ -200,6 +201,8 @@ def test_the_golden_model_calls_every_operator_in_the_language(): "msg = f'{context}: expected a comparison, got {type(node).__name__}'", 'raise AssertionError(msg)', 'assert_never(node)', + 'case _:', + 'assert_never(check)', 'if block is None:', 'return []', } From 5e29391bd979a99f5411b56798608f647e0d7f9a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 08:38:33 +0000 Subject: [PATCH 3/3] feat(language): a where may compare arithmetic over parameters Either side of a comparison in a where string may be an expression, read as an expression is: macros and named expressions expand, every operator keeps its rule, and a variable or a dual is refused. Resolution types it as `ArithmeticComparisonNode` over the core syntax tree for the spec-side readers; lowering rebuilds every mask with `ExpressionComparisonNode` over program expressions, so a program's masks are program vocabulary throughout. The expression form stands aside for the plain shapes, so `p > 0` keeps its node. Docs sentence lengths (n / median / over 25): expressions.md 139 / 16 / 27, reading.md 68 / 15 / 15. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013HceuCYNepeQX8SdZtiMf1 --- docs/reference/language/expressions.md | 42 ++++++++++- docs/reference/language/reading.md | 5 ++ docs/reference/notation.md | 73 +++++++++++++++++++ src/math_spec/_expression_parser.py | 15 ++-- src/math_spec/_where_parser.py | 81 +++++++++++++++++++-- src/math_spec/dimensions.py | 14 ++-- src/math_spec/exclusivity.py | 15 +++- src/math_spec/lowering.py | 30 ++++++-- src/math_spec/program.py | 88 ++++++++++++++++++++++- src/math_spec/resolution.py | 69 +++++++++++++++++- src/math_spec/typesetting/walk.py | 10 +++ tests/test_lowering.py | 35 +++++++++ tests/test_parser.py | 30 ++++++++ tests/test_validation.py | 99 ++++++++++++++++++++++++-- tests/typesetting/golden/latex.out | 8 ++- tests/typesetting/golden/markdown.out | 31 ++++++++ tests/typesetting/golden/model.yaml | 17 +++++ tests/typesetting/golden/typst.out | 10 ++- tests/typesetting/test_golden.py | 9 ++- tests/typesetting/test_walk.py | 10 +++ 20 files changed, 652 insertions(+), 39 deletions(-) diff --git a/docs/reference/language/expressions.md b/docs/reference/language/expressions.md index 320bf4d4..7efb3a0f 100644 --- a/docs/reference/language/expressions.md +++ b/docs/reference/language/expressions.md @@ -157,10 +157,11 @@ A `where:` is a boolean mask, and true means "this coordinate exists". ```text where_expr ::= atom | "NOT" where_expr | where_expr ("AND"|"OR") where_expr | "(" where_expr ")" -atom ::= NAME | NAME COMPARATOR value | POSITION COMPARATOR INTEGER - | "True" | "False" +atom ::= NAME | NAME COMPARATOR value | expression COMPARATOR expression + | POSITION COMPARATOR INTEGER | "True" | "False" COMPARATOR ::= "<=" | ">=" | "==" | "!=" | "<" | ">" value ::= NUMBER | QUOTED | NAME_OR_STRING +expression ::= the arithmetic grammar above, with no variable and no dual in it POSITION ::= "position" "(" NAME [ "," "by" "=" NAME ] ")" QUOTED ::= "'" chars "'" | '"' chars '"' ``` @@ -175,6 +176,7 @@ QUOTED ::= "'" chars "'" | '"' chars '"' | `name OP value` | dimension | A filter on the frame's own coordinate column | | `name OP value` | lookup | A filter on the lookup's value, so the `over` dimension has to be in the frame. A null compares false | | `name OP name` | two lookups | Legal only where both lookups are over the same dimension and into the same dimension. `from != to` excludes a self-loop | +| `expression OP expression` | arithmetic over parameters | Coordinate by coordinate, over every dimension either side carries. A macro and a named expression expand as in an expression, and every operator keeps its rule, so a `shift` names its `edge=`. A side with no value at a coordinate compares false | | `position(name) OP i` | dimension | Where the row sits along the dimension's own order. `0` is first, and a negative number counts from the end | | `position(name, by=lookup) OP i` | a dimension and a lookup over it | The same, counted within each group the lookup makes | | `AND` `OR` `NOT` | — | Case-insensitive. `NOT` binds tighter than `AND`, and `AND` tighter than `OR` | @@ -216,6 +218,42 @@ boolean parameter instead. Two lookups are the exception, where both lookups share both ends: over one dimension they are two columns of one table, and into one dimension they draw from one label set. +### Arithmetic in a comparison + +Either side of a comparison may be an expression: `p_min <= 0.5 * p_max`, +`sum(p_max, over=generator) >= peak`, `p_max <= at(bus_cap, by=bus_of)`. The +side is read exactly as an [expression](#expressions) is, so a macro and a +named expression expand into it and every operator keeps its own rule. Two +things an expression may carry are refused here, because a mask is built before +either exists: a variable, and a `dual()`. + +A comparison of expressions is checked over every dimension either side +carries. A side whose value is absent at a coordinate compares false there, as +a null does in every other comparison; under a summing operator the absent +term is one fewer. A `shift` says what its vacated positions hold, as it does +everywhere, so a comparison against the previous row names an `edge=` and a +`position()` term keeps the first row out: + +```yaml +dimensions: + snapshot: { dtype: int } +parameters: + load: { dims: [snapshot] } + ramp: { dims: [] } +variables: + shed: { dims: [snapshot], bounds: { lower: 0 } } +constraints: + shed_when_load_jumps: + dims: [snapshot] + where: "load - shift(load, over=snapshot, offset=1, edge=0) > ramp AND position(snapshot) > 0" + expression: shed >= load - ramp +``` + +A case `when:` that compares expressions cannot be proved apart from its +neighbours before the data arrives, so it is refused there with the rewrite: +compare one parameter against a literal, or precompute the test as a boolean +parameter. + ### `position()` `position(dim)` is where the row sits along the dimension's own order, which is diff --git a/docs/reference/language/reading.md b/docs/reference/language/reading.md index 3b6abcc6..75792da9 100644 --- a/docs/reference/language/reading.md +++ b/docs/reference/language/reading.md @@ -109,6 +109,11 @@ questions that every engine would otherwise work out for itself: - `.atoms` gives its leaves, with the connectives removed. - `.dims` gives the dimensions the mask is read at. +A comparison of expressions arrives as an `ExpressionComparisonNode`, whose two +sides are program expressions like a constraint's, and whose `dims` are every +dimension either side carries. Its `names_read` are every parameter and lookup +the sides read, the lookup a grouping joins through included. + A predicate you build yourself answers the same four questions: wrap it in `Mask`, or build it there with `~`, `&` and `|`. A mask folds as it is built: a double negation cancels, and a `True` or `False` is absorbed rather than buried in the diff --git a/docs/reference/notation.md b/docs/reference/notation.md index 10d99b65..ad006bea 100644 --- a/docs/reference/notation.md +++ b/docs/reference/notation.md @@ -112,6 +112,7 @@ parameters: | Symbol | Meaning | |---|---| +| $`\mathrm{spend}^{\mathrm{cap}}`$ | `spend_cap` over $`\mathcal{G}`$ | | $`\mathit{spend}`$ | `spend` over $`\mathcal{T}`$ — what a snapshot's dispatch costs | | $`\mathit{lcoe}`$ | `lcoe` (scalar) | | $`\mathit{marginal\_price}`$ | `marginal_price` over $`\mathcal{T} \times \mathcal{B}`$ | @@ -580,8 +581,80 @@ never: \mathit{slack}_{t} \ge 0 \qquad \forall\, t \in \mathcal{T} \,:\, \bot ``` +#### `margin` + +a mask comparing two expressions, which prints as the arithmetic it is + +```yaml +margin: + dims: [snapshot, generator] + where: "p_max - p_min > cost / 2" + expression: p <= p_max +``` + +```math +p_{t,g} \le \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{p}^{\mathrm{max}}_{g} - \mathrm{p}^{\mathrm{min}}_{g} > \frac{\mathrm{cost}_{g}}{2} +``` + +#### `ramped` + +a translation under a comparison names its edge, a pullback reads through a lookup, and the position keeps the vacated row out + +```yaml +ramped: + dims: [snapshot, bus] + where: "load - shift(load, over=snapshot, offset=1, edge=0) <= at(zone_cap, by=zone_of) AND position(snapshot) > 0" + expression: slack <= load +``` + +```math +\mathit{slack}_{t} \le \mathrm{load}_{t,b} \qquad \forall\, t \in \mathcal{T},\ b \in \mathcal{B} \,:\, \mathrm{load}_{t,b} - \mathrm{load}_{t \boxminus_{0} 1,b} \le \mathrm{zone\_cap}_{\mathrm{zone\_of}(b)} \wedge \mathrm{pos}(t) > 0 +``` + +#### `covered` + +a reduction on a side of a scalar mask, so nothing is left to quantify + +```yaml +covered: + dims: [] + where: "sum(p_max, over=generator) >= budget" + expression: sum(p) <= budget +``` + +```math +\sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} \le \mathrm{budget} \qquad \text{where } \sum_{g \in \mathcal{G}} \mathrm{p}^{\mathrm{max}}_{g} \ge \mathrm{budget} +``` + +#### `capped` + +an expressions: entry on a side, read by the name the file gave it + +```yaml +capped: + dims: [snapshot, generator] + where: "spend_cap > 0 OR NOT is_flexible" + expression: p <= p_max +``` + +```math +p_{t,g} \le \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{spend}^{\mathrm{cap}}_{g} > 0 \vee \neg \mathrm{is\_flexible}_{g} +``` + ### Definitions +#### `spend_cap` + +a data-only entry, so a where may compare it + +```yaml +spend_cap: cost * 2 +``` + +```math +\mathrm{spend}^{\mathrm{cap}}_{g} = \mathrm{cost}_{g} \cdot 2 \qquad \forall\, g \in \mathcal{G} +``` + #### `spend` a plain named expression: its symbol prints where it is used, its body once as a definition diff --git a/src/math_spec/_expression_parser.py b/src/math_spec/_expression_parser.py index 642a9f6a..c90dfb99 100644 --- a/src/math_spec/_expression_parser.py +++ b/src/math_spec/_expression_parser.py @@ -349,8 +349,12 @@ def with_children(node: ArithmeticNode, recurse: Callable[[ArithmeticNode], Arit # --------------------------------------------------------------------------- -def _build_grammar() -> pp.ParserElement: - """``inf`` is a ``pp.Keyword`` rather than a ``pp.Literal``, which would match the prefix of ``inflow``.""" +def _build_grammar() -> tuple[pp.ParserElement, pp.ParserElement]: + """The arithmetic grammar, and the expression grammar that puts one comparison over it. + + ``inf`` is a ``pp.Keyword`` rather than a ``pp.Literal``, which would + match the prefix of ``inflow``. + """ arith = pp.Forward() inf_literal = (pp.Keyword('.inf') | pp.Keyword('inf')).set_parse_action(lambda: NumberNode(float('inf'))) @@ -386,9 +390,10 @@ def _build_grammar() -> pp.ParserElement: arith <<= add_sub comparator = pp.one_of(list(get_args(ComparisonOperator))) - return (arith + pp.Optional(comparator + arith)).set_parse_action( + expression = (arith + pp.Optional(comparator + arith)).set_parse_action( lambda t: ComparisonNode(t[1], t[0], t[2]) if len(t) == 3 else t[0] ) + return arith, expression def _make_func_call(tokens: pp.ParseResults) -> FunctionCallNode: @@ -421,7 +426,9 @@ def _make_power(tokens: pp.ParseResults) -> Any: return items[0] if len(items) == 1 else BinaryOperatorNode('**', items[0], items[2]) -_GRAMMAR = _build_grammar() +#: The arithmetic half on its own, for the where grammar to put a predicate's +#: comparator over — one grammar for what a side may say, wherever it stands. +ARITHMETIC, _GRAMMAR = _build_grammar() #: How deep a tree the language admits. Every pass over an expression recurses, diff --git a/src/math_spec/_where_parser.py b/src/math_spec/_where_parser.py index ea873445..51e65add 100644 --- a/src/math_spec/_where_parser.py +++ b/src/math_spec/_where_parser.py @@ -15,12 +15,23 @@ import pyparsing as pp -from math_spec._expression_parser import NAME, REAL, parse_text +from math_spec._expression_parser import ( + ARITHMETIC, + NAME, + REAL, + FunctionCallNode, + NameNode, + NumberNode, + UnaryOperatorNode, + children, + parse_text, +) from math_spec.program import AndNode, BooleanLiteralNode, NotNode, OrNode, PredicateOperator, where_children if TYPE_CHECKING: from collections.abc import Callable + from math_spec._expression_parser import ArithmeticNode from math_spec.program import WhereNode # --------------------------------------------------------------------------- @@ -49,6 +60,19 @@ class UnresolvedComparisonNode: quoted: bool = False +@dataclass(frozen=True) +class UnresolvedExpressionComparisonNode: + """``expression expression``, both sides still the bare parse — ``resolution.py`` types and judges them. + + The grammar reaches for this only where a side is more than one name or + literal, so the simpler forms keep their own nodes and their own rules. + """ + + left: ArithmeticNode + op: PredicateOperator + right: ArithmeticNode + + @dataclass(frozen=True) class UnresolvedPositionNode: """``position(dim[, by=lookup]) i`` before the names are checked; ``resolution.py`` types it.""" @@ -61,7 +85,9 @@ class UnresolvedPositionNode: #: What resolution rewrites away on the where side — the three nodes whose #: left-hand side is still a name the schema has not been asked about. -UnresolvedWhereNode = UnresolvedNameNode | UnresolvedComparisonNode | UnresolvedPositionNode +UnresolvedWhereNode = ( + UnresolvedNameNode | UnresolvedComparisonNode | UnresolvedExpressionComparisonNode | UnresolvedPositionNode +) # --------------------------------------------------------------------------- @@ -82,6 +108,31 @@ def _position_comparison(tokens: pp.ParseResults) -> UnresolvedPositionNode: return UnresolvedPositionNode(str(dimension), op, at, None if by is None else str(by)) +def _is_plain(node: ArithmeticNode) -> bool: + """Whether *node* is one name or one signed number — a side the simpler comparison forms own.""" + if isinstance(node, UnaryOperatorNode): + return isinstance(node.operand, NumberNode) + return isinstance(node, NameNode | NumberNode) + + +def _reads_arithmetic(tokens: pp.ParseResults) -> bool: + """Whether a comparison needs the expression form at all. + + Two plain sides are ``name literal`` or ``name name``, and a + ``position(...)`` call against a plain side is the position form; each of + those has a node of its own, so this form stands aside for them. + """ + left, _, right = tokens + if _is_plain(left) and _is_plain(right): + return False + return not (isinstance(left, FunctionCallNode) and left.name == 'position' and _is_plain(right)) + + +def _expression_comparison(tokens: pp.ParseResults) -> UnresolvedExpressionComparisonNode: + left, op, right = tokens + return UnresolvedExpressionComparisonNode(left, op, right) + + def _comparison(tokens: pp.ParseResults) -> UnresolvedComparisonNode: """``name literal`` off the tokens the grammar captured, the quoted marker turned into a flag.""" name, op, value = tokens @@ -93,8 +144,11 @@ def _build_where_grammar() -> pp.ParserElement: """Build the pyparsing grammar for where strings. Both quote characters are accepted because YAML already owns one of them. - ``NOT`` binds tightest, then ``AND``, then ``OR``. ``position(...)`` leads - the alternation, since ``position`` would otherwise be read as a bare name. + ``NOT`` binds tightest, then ``AND``, then ``OR``. The three comparison + forms are matched longest-first, so ``p > 2 * q`` is not cut short at + ``p > 2``; the expression form stands aside for the two plain shapes + (:func:`_reads_arithmetic`), so ``p > 0`` keeps the node its dtype rule + is written for. """ where_expr = pp.Forward() @@ -121,14 +175,16 @@ def _build_where_grammar() -> pp.ParserElement: position_comparison = (position_call + comparator + position).set_parse_action(_position_comparison) comparison = (name + comparator + (number | quoted | name)).set_parse_action(_comparison) + expression_comparison = ( + (ARITHMETIC + comparator + ARITHMETIC).add_condition(_reads_arithmetic).add_parse_action(_expression_comparison) + ) # pyrefly: ignore[implicit-any-lambda] existence = name.copy().set_parse_action(lambda t: UnresolvedNameNode(t[0])) atom = ( true_lit | false_lit - | position_comparison - | comparison + | (position_comparison ^ comparison ^ expression_comparison) | existence | (pp.Suppress('(') + where_expr + pp.Suppress(')')) ) @@ -192,6 +248,17 @@ def _named_rewrite(text: str, loc: int) -> str | None: ) +def _nested(node: Any) -> tuple[Any, ...]: + """What a where string nests through: a connective's operands, and the arithmetic under a comparison of expressions.""" + if isinstance(node, UnresolvedExpressionComparisonNode): + return (node.left, node.right) + if isinstance(node, UnresolvedWhereNode): + return () + if isinstance(node, AndNode | OrNode | NotNode | BooleanLiteralNode): + return where_children(node) + return children(node) + + @lru_cache(maxsize=4096) def parse_where(text: str) -> WhereNode | UnresolvedWhereNode: """Parse a where string into an AST, its leaves still unresolved. @@ -208,5 +275,5 @@ def parse_where(text: str) -> WhereNode | UnresolvedWhereNode: """ return cast( 'WhereNode | UnresolvedWhereNode', - parse_text(_WHERE_GRAMMAR, text, 'where string', _named_rewrite, where_children, _DEEP_REWRITE), + parse_text(_WHERE_GRAMMAR, text, 'where string', _named_rewrite, _nested, _DEEP_REWRITE), ) diff --git a/src/math_spec/dimensions.py b/src/math_spec/dimensions.py index 3c07aed5..48b7a4cc 100644 --- a/src/math_spec/dimensions.py +++ b/src/math_spec/dimensions.py @@ -40,8 +40,10 @@ from math_spec.errors import DimensionError from math_spec.operators import BUILTINS from math_spec.program import ( + ArithmeticComparisonNode, DimensionComparisonNode, DimensionPositionNode, + ExpressionComparisonNode, LookupComparisonNode, LookupDefinedNode, LookupPairComparisonNode, @@ -515,17 +517,19 @@ def _check_where_dims( continue match atom: case ParameterDefinedNode() | ParameterComparisonNode(): - noun = 'parameter' + leaf = f"where-parameter '{atom.name}'" case VariableDefinedNode(): - noun = 'variable' + leaf = f"where-variable '{atom.name}'" case DimensionComparisonNode() | DimensionPositionNode(): - noun = 'dimension' + leaf = f"where-dimension '{atom.name}'" case LookupComparisonNode() | LookupPairComparisonNode() | LookupDefinedNode(): - noun = 'lookup' + leaf = f"where-lookup '{atom.name}'" + case ArithmeticComparisonNode() | ExpressionComparisonNode(): + leaf = 'a where-comparison of expressions' case _: assert_never(atom) raise DimensionError( - f"{context}: where-{noun} '{atom.name}' reads dims {outside} outside the frame {sorted(frame)}. " + f'{context}: {leaf} reads dims {outside} outside the frame {sorted(frame)}. ' f'Reducing a mask over an unlisted dim would silently widen it — add the dim to dims:, ' f'or test a name the frame carries.' ) diff --git a/src/math_spec/exclusivity.py b/src/math_spec/exclusivity.py index 450aad92..42ac7e37 100644 --- a/src/math_spec/exclusivity.py +++ b/src/math_spec/exclusivity.py @@ -23,9 +23,11 @@ from math_spec.program import ( AndNode, + ArithmeticComparisonNode, BooleanLiteralNode, DimensionComparisonNode, DimensionPositionNode, + ExpressionComparisonNode, LookupComparisonNode, LookupDefinedNode, LookupPairComparisonNode, @@ -136,7 +138,7 @@ class Subject: a rank is further split by the ``by=`` lookup it is counted within. """ - kind: Literal['param', 'dim', 'rank', 'lookup', 'lookup_pair', 'variable'] + kind: Literal['param', 'expression', 'dim', 'rank', 'lookup', 'lookup_pair', 'variable'] name: str qualifier: str | None = None @@ -189,6 +191,12 @@ def _observe(node: TypedPredicateNode, subject: Subject, values: set[Any], dtype ``position()`` converts the dimension to an integer, so an ordering over a rank is an ordering of integers and every comparator is admitted there. """ + if isinstance(node, ArithmeticComparisonNode | ExpressionComparisonNode): + msg = ( + 'it compares expressions, whose values only the data decides — compare one parameter against a ' + 'literal, or precompute the test as a boolean parameter and test that' + ) + raise Undecidable(msg) if isinstance(node, DimensionPositionNode): values.add(node.position) elif isinstance(node, LookupPairComparisonNode): @@ -224,6 +232,8 @@ def _subject_of(node: TypedPredicateNode) -> Subject: return Subject('lookup', name) case LookupPairComparisonNode(name=name, other=other): return Subject('lookup_pair', name, other) + case ArithmeticComparisonNode() | ExpressionComparisonNode(): + return Subject('expression', 'a comparison of expressions') case _: assert_never(node) @@ -405,6 +415,9 @@ def _atom(node: TypedPredicateNode, cell: dict[Subject, Cell], grid: _Grid) -> b return bool(value) case LookupPairComparisonNode(op=op): return bool(value) if op == '==' else not value + case ArithmeticComparisonNode() | ExpressionComparisonNode(): + msg = 'a comparison of expressions is refused as undecidable before any cell is read' + raise AssertionError(msg) case DimensionPositionNode(op=op, position=position): return _compare(value, op, position) case ParameterComparisonNode(op=op, value=literal) | LookupComparisonNode(op=op, value=literal): diff --git a/src/math_spec/lowering.py b/src/math_spec/lowering.py index d1df7af4..4d6bee23 100644 --- a/src/math_spec/lowering.py +++ b/src/math_spec/lowering.py @@ -113,7 +113,7 @@ def lower_program(expanded: _ExpandedSpec) -> program.Program: lower, upper = _bound_expression(vdef.bounds.lower), _bound_expression(vdef.bounds.upper) variables[vname] = program.VariableDeclaration( tuple(vdef.dims), - where=resolved.variables[vname], + where=_Lowering(expanded, f"variable '{vname}'").mask(resolved.variables[vname]), lower=lower, upper=upper, domain=domain, @@ -129,7 +129,7 @@ def lower_program(expanded: _ExpandedSpec) -> program.Program: lhs=lowering.expr(expression.left), sense=expression.op, rhs=lowering.expr(expression.right), - where=where, + where=lowering.mask(where), ) objective = None @@ -249,13 +249,35 @@ def _cases(self, node: CasesNode) -> program.Cases: Every ``when`` arrives folded from resolution, and an arm that folded to a literal was refused at load — so no literal reaches a region. """ - stated = [program.Mask(arm.when) for arm in node.arms if arm.when is not None] + stated = [self._where(arm.when) for arm in node.arms if arm.when is not None] regions = [] for arm in node.arms: - when = program.Mask(arm.when) if arm.when is not None else _none_of(stated) + when = self._where(arm.when) if arm.when is not None else _none_of(stated) regions.append(program.Region(when, self.expr(arm.value))) return program.Cases(tuple(regions)) + def mask(self, mask: program.Mask | None) -> program.Mask | None: + """*mask* with every comparison of expressions lowered, so a program's masks are program vocabulary throughout. + + Every other predicate node is already the program's own and passes + through; a mask holding none comes back equal to the one handed in. + """ + return None if mask is None else self._where(mask.root) + + def _where(self, node: program.WhereNode) -> program.Mask: + return program.Mask(self._predicate(node)) + + def _predicate(self, node: program.WhereNode) -> program.WhereNode: + if isinstance(node, program.ArithmeticComparisonNode): + return program.ExpressionComparisonNode(self.expr(node.left), node.op, self.expr(node.right), node.dims) + if isinstance(node, program.NotNode): + return program.NotNode(self._predicate(node.operand)) + if isinstance(node, program.AndNode): + return program.AndNode(self._predicate(node.left), self._predicate(node.right)) + if isinstance(node, program.OrNode): + return program.OrNode(self._predicate(node.left), self._predicate(node.right)) + return node + def sum(self, node: FunctionCallNode) -> program.ExpressionNode: """``sum(x)``, ``sum(x, over=d)`` or ``sum(x, by=lookup)``. diff --git a/src/math_spec/program.py b/src/math_spec/program.py index 5478a31a..604af125 100644 --- a/src/math_spec/program.py +++ b/src/math_spec/program.py @@ -25,7 +25,7 @@ from typing import TYPE_CHECKING, Literal, NamedTuple, assert_never, get_args import math_spec.model as _model -from math_spec._expression_parser import ComparisonOperator +from math_spec._expression_parser import ComparisonOperator, LookupNode, ParameterNode, nodes from math_spec._sealed import Sealed from math_spec.errors import did_you_mean @@ -33,12 +33,15 @@ import datetime from collections.abc import Iterator + from math_spec._expression_parser import ArithmeticNode + #: What ``math_spec.program`` promises a consumer, sorted. __all__ = [ 'QUADRATIC_POSITIONS', 'Add', 'AndNode', + 'ArithmeticComparisonNode', 'At', 'AtLeastTwo', 'BooleanLiteralNode', @@ -58,6 +61,7 @@ 'Divide', 'Dual', 'Expression', + 'ExpressionComparisonNode', 'ExpressionDeclaration', 'ExpressionNode', 'FanIn', @@ -1043,6 +1047,38 @@ class ParameterComparisonNode: dims: tuple[str, ...] +@dataclass(frozen=True) +class ExpressionComparisonNode: + """Compare two variable-free expressions, coordinate by coordinate — ``p_min <= 0.5 * p_max``. + + ``dims`` is every dim either side carries. A side whose value is absent at + a coordinate — a parameter row missing, a translation that vacated it — + makes the comparison false there, as a null does in every other + comparison; under a summing operator the absent term is one fewer. + """ + + left: ExpressionNode + op: PredicateOperator + right: ExpressionNode + dims: tuple[str, ...] + + +@dataclass(frozen=True) +class ArithmeticComparisonNode: + """The same comparison as resolution types it, its sides in the core syntax tree. + + What the spec-side readers walk — the typesetter, the dim rules, the + exclusivity check. :func:`~math_spec.lowering.lower_program` rebuilds + every mask with an :class:`ExpressionComparisonNode` in its place, so a + program never carries one. + """ + + left: ArithmeticNode + op: PredicateOperator + right: ArithmeticNode + dims: tuple[str, ...] + + @dataclass(frozen=True) class DimensionComparisonNode: """Compare a dimension's own coordinates against a literal.""" @@ -1124,6 +1160,8 @@ class OrNode: | ParameterDefinedNode | VariableDefinedNode | ParameterComparisonNode + | ExpressionComparisonNode + | ArithmeticComparisonNode | DimensionComparisonNode | LookupComparisonNode | LookupPairComparisonNode @@ -1138,6 +1176,8 @@ class OrNode: #: decide about them. TypedPredicateNode = ( ParameterComparisonNode + | ExpressionComparisonNode + | ArithmeticComparisonNode | ParameterDefinedNode | VariableDefinedNode | DimensionComparisonNode @@ -1198,7 +1238,13 @@ def _atom_dims(atom: TypedPredicateNode) -> frozenset[str]: a branch, rather than a wrong dim set at the first model to use it. """ match atom: - case ParameterComparisonNode() | ParameterDefinedNode() | VariableDefinedNode(): + case ( + ParameterComparisonNode() + | ExpressionComparisonNode() + | ArithmeticComparisonNode() + | ParameterDefinedNode() + | VariableDefinedNode() + ): return frozenset(atom.dims) case DimensionComparisonNode() | DimensionPositionNode(): return frozenset({atom.name}) @@ -1212,7 +1258,8 @@ def _atom_names(atom: TypedPredicateNode) -> frozenset[str]: """One leaf's declarations, its dimension apart — the rule :attr:`Mask.names_read` is the union of. A comparison on a dimension names no declaration — a coordinate is not - data to feed — and a lookup pair names both maps it compares. + data to feed — a lookup pair names both maps it compares, and a comparison + of expressions names every parameter and lookup its sides read. ``assert_never``-closed for the reason :func:`_atom_dims` is: a predicate node added without a reading is a type error at this one branch rather than a name silently dropped at the first model to use it. @@ -1228,12 +1275,47 @@ def _atom_names(atom: TypedPredicateNode) -> frozenset[str]: return frozenset({atom.name}) case LookupPairComparisonNode(): return frozenset({atom.name, atom.other}) + case ExpressionComparisonNode(): + return _names_under(atom.left, atom.right) + case ArithmeticComparisonNode(): + return frozenset( + name + for node in nodes(atom.left, atom.right) + for name in ( + (node.name,) + if isinstance(node, ParameterNode) + else node.names + if isinstance(node, LookupNode) + else () + ) + ) case DimensionComparisonNode() | DimensionPositionNode(): return frozenset() case _: assert_never(atom) +def _names_under(*expressions: ExpressionNode) -> frozenset[str]: + """Every parameter and lookup the data has to supply for *expressions* — what a mask's ``names_read`` promises. + + :func:`parameters_of` alone misses the data an operator reads beside its + operand: the lookup a grouping or a pullback joins through, the one a + translation is partitioned by, and the parameter a named offset or width + is read from. + """ + names: set[str] = set(parameters_of(*expressions)) + for node in walk(*expressions): + if isinstance(node, (GroupSum, At)): + names.update(node.coordinate) + elif isinstance(node, (Translate, Window)): + if node.partition is not None: + names.add(node.partition) + amount = node.offset if isinstance(node, Translate) else node.width + if isinstance(amount, str): + names.add(amount) + return frozenset(names) + + def _conjuncts(where: WhereNode) -> tuple[WhereNode, ...]: """The flatten rule behind :attr:`Mask.conjuncts` — the one home of the split. diff --git a/src/math_spec/resolution.py b/src/math_spec/resolution.py index 4af355b0..b1bb1c9b 100644 --- a/src/math_spec/resolution.py +++ b/src/math_spec/resolution.py @@ -17,6 +17,7 @@ from functools import cached_property from typing import TYPE_CHECKING, Literal, NamedTuple, assert_never, cast +import math_spec.degree as degree from math_spec._expression_parser import ( ArithmeticNode, BinaryOperatorNode, @@ -45,13 +46,15 @@ ) from math_spec._where_parser import ( UnresolvedComparisonNode, + UnresolvedExpressionComparisonNode, UnresolvedNameNode, UnresolvedPositionNode, UnresolvedWhereNode, parse_where, ) +from math_spec.dimensions import dims_of from math_spec.errors import LanguageError, did_you_mean -from math_spec.expansion import parse_and_expand +from math_spec.expansion import expand, parse_and_expand from math_spec.model import NUMERIC_DTYPES from math_spec.operators import ( BUILTINS, @@ -62,6 +65,7 @@ ) from math_spec.program import ( AndNode, + ArithmeticComparisonNode, BooleanLiteralNode, DimensionComparisonNode, DimensionPositionNode, @@ -96,7 +100,7 @@ class Namespace: A name has one kind: model.py refuses one declared under two sections. """ - __slots__ = ('constraints', 'dimensions', 'dtypes', 'leaf_dims', 'lookups', 'parameters', 'variables') + __slots__ = ('constraints', 'dimensions', 'dtypes', 'leaf_dims', 'lookups', 'parameters', 'schema', 'variables') def __init__( self, @@ -107,7 +111,12 @@ def __init__( dtypes: Mapping[str, DeclaredDtype], leaf_dims: Mapping[str, tuple[str, ...]], constraints: Iterable[str], + schema: Spec, ) -> None: + #: The schema the names come from — what a where comparison's sides + #: are expanded and dim-checked against, since those read operators + #: and named expressions that the flat listing above cannot answer for. + self.schema = schema self.variables = frozenset(variables) self.parameters = frozenset(parameters) self.dimensions = frozenset(dimensions) @@ -146,6 +155,7 @@ def of(cls, schema: Spec) -> Namespace: **{v: tuple(vd.dims) for v, vd in schema.variables.items()}, }, schema.constraints, + schema, ) def kind(self, name: str) -> DeclarationKind | None: @@ -634,6 +644,8 @@ def where(self, node: WhereNode | UnresolvedWhereNode) -> WhereNode | Unresolved return self._position(node) if isinstance(node, UnresolvedComparisonNode): return self._comparison(node) + if isinstance(node, UnresolvedExpressionComparisonNode): + return self._expression_comparison(node) if isinstance(node, NotNode): return NotNode(self._child(node.operand)) if isinstance(node, AndNode): @@ -706,9 +718,16 @@ def _position(self, node: UnresolvedPositionNode) -> DimensionPositionNode | Unr return DimensionPositionNode(node.dimension, node.op, node.position, node.by) def _comparison(self, node: UnresolvedComparisonNode) -> WhereNode | UnresolvedWhereNode: - """``name literal``, or the one structural form ``lookup lookup``.""" + """``name literal``, or the one structural form ``lookup lookup``. + + A side that names an ``expressions:`` entry is arithmetic, whatever + the grammar first read it as, and takes the expression path. + """ ns, context = self.ns, self.context value = node.value + if node.name in ns.schema.expressions or (not node.quoted and value in ns.schema.expressions): + right: ArithmeticNode = NameNode(value) if isinstance(value, str) else NumberNode(value) + return self._expression_comparison(UnresolvedExpressionComparisonNode(NameNode(node.name), node.op, right)) if not node.quoted and isinstance(value, str) and (rhs_kind := ns.kind(value)) is not None: if rhs_kind == 'lookup' and ns.kind(node.name) == 'lookup': if (refusal := _lookup_pair_error(context, node, value, ns)) is not None: @@ -744,6 +763,50 @@ def _comparison(self, node: UnresolvedComparisonNode) -> WhereNode | UnresolvedW ) return node + def _expression_comparison( + self, node: UnresolvedExpressionComparisonNode + ) -> ArithmeticComparisonNode | UnresolvedExpressionComparisonNode: + """``expression expression``: each side expanded, typed and held to what a mask may read. + + A side is read as an expression is — macros and named expressions + expand, every operator and dim rule applies — except that it names no + variable and no dual, since a mask is built before either exists. + """ + ns, context = self.ns, self.context + found = len(self.errors) + sides = [] + for side in (node.left, node.right): + try: + expanded = expand(side, ns.schema, context) + except ValueError as e: + self.errors.append(str(e) if str(e).startswith(context) else f'{context}: {e}') + continue + sides.append(self._arith(expanded)) + if len(self.errors) > found: + return node + dims: set[str] = set() + for side in sides: + if degree.carries_variable(side): + self.errors.append( + f'{context}: a where compares expressions, and one side names a variable. A where mask ' + f'is built before variables exist — it may test parameters and dimension coordinates only.' + ) + elif degree.calls_dual(side): + self.errors.append( + f'{context}: a where compares expressions, and one side reads a dual, which only a solve ' + f'produces. A mask is built before it — test the data instead.' + ) + else: + try: + degree.check_expression(side, context, ceiling=1) + dims |= dims_of(side, ns.schema, context) + except LanguageError as e: + self.errors.append(str(e)) + if len(self.errors) > found: + return node + left, right = sides + return ArithmeticComparisonNode(left, node.op, right, tuple(d for d in ns.schema.dimensions if d in dims)) + def _typed_literal( self, node: UnresolvedComparisonNode, dtype: DeclaredDtype ) -> float | str | datetime.date | None: diff --git a/src/math_spec/typesetting/walk.py b/src/math_spec/typesetting/walk.py index 4569052f..b36cdfb6 100644 --- a/src/math_spec/typesetting/walk.py +++ b/src/math_spec/typesetting/walk.py @@ -36,9 +36,11 @@ from math_spec.dimensions import dims_of from math_spec.program import ( AndNode, + ArithmeticComparisonNode, BooleanLiteralNode, DimensionComparisonNode, DimensionPositionNode, + ExpressionComparisonNode, LookupComparisonNode, LookupDefinedNode, LookupPairComparisonNode, @@ -523,6 +525,14 @@ def _where(self, node: WhereNode, ctx: _Context) -> tuple[str, int]: left = ctx.indexed(self.symbols.name[node.name], list(node.dims)) return f'{left} {self._op(_PREDICATES[node.op])} {self._literal(node.value)}', comparison + if isinstance(node, ArithmeticComparisonNode): + left, right = self._expression(node.left, ctx), self._expression(node.right, ctx) + return f'{left} {self._op(_PREDICATES[node.op])} {right}', comparison + + if isinstance(node, ExpressionComparisonNode): + msg = 'a lowered comparison reached the typesetter; it prints the resolved tree, which lowering rebuilds.' + raise AssertionError(msg) + if isinstance(node, DimensionComparisonNode): if isinstance(node.value, int | float): self.noticed.numeric_coordinates.add(node.name) diff --git a/tests/test_lowering.py b/tests/test_lowering.py index f743319f..ec5b480e 100644 --- a/tests/test_lowering.py +++ b/tests/test_lowering.py @@ -33,6 +33,7 @@ DimensionDeclaration, Divide, Dual, + ExpressionComparisonNode, ExpressionNode, Footprint, GroupSum, @@ -392,6 +393,40 @@ def test_a_constraint_where_is_a_mask_like_a_variable_s(): assert c.where == Mask(ParameterComparisonNode('load', '>', 0.0, ('snapshot',))) +def test_a_comparison_of_expressions_lowers_to_program_expressions_on_both_sides(): + """The resolved tree holds the core syntax tree; the program holds the vocabulary a consumer reads, and every mask is rebuilt so.""" + program = to_program( + override( + SHAPES_MODEL, + **{ + 'parameters.zc': {'dims': ['z']}, + 'variables.p.where': 'c <= 0.5 * k', + 'constraints.w': { + 'dims': ['g'], + 'where': 'c <= at(zc, by=lk2) + sum_back(c, over=g, within=2, by=lk2)', + 'expression': 'p <= c', + }, + }, + ) + ) + where = program.variables['p'].where + assert where is not None + assert where.root == ExpressionComparisonNode( + Parameter('c'), '<=', Multiply(Constant(0.5), Parameter('k')), ('g',) + ), 'the sides are lowered as a constraint side is, and the dims are what either side carries' + mask = program.constraints['w'].where + assert mask is not None and isinstance(mask.root, ExpressionComparisonNode) + assert isinstance(mask.root.right, Add) and isinstance(mask.root.right.left, At) + assert mask.names_read == frozenset({'c', 'zc', 'lk2'}), ( + 'the lookup a pullback and a partition read through is data the consumer binds too' + ) + + +def test_a_mask_with_no_arithmetic_is_the_same_mask_after_lowering(dispatch_program): + """Every other predicate node is already the program's own, so lowering hands it through unchanged.""" + assert dispatch_program.variables['p'].where == Mask(P_MAX_POSITIVE) + + def test_a_power_lowers_to_a_node_of_its_own(dispatch_schema): lowered = _Lowering(dispatch_schema, 't').expr(resolved('cost ** cost', dispatch_schema)) assert isinstance(lowered, Power), 'a variable-free power has a plan node of its own' diff --git a/tests/test_parser.py b/tests/test_parser.py index 3748a75f..a104af82 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -27,6 +27,7 @@ ) from math_spec._where_parser import ( UnresolvedComparisonNode, + UnresolvedExpressionComparisonNode, UnresolvedNameNode, UnresolvedPositionNode, parse_where, @@ -257,6 +258,35 @@ def test_a_where_string_parses_to_its_node(text, node_type, attrs): assert getattr(node, attr) == expected +@pytest.mark.parametrize( + ('text', 'node_type'), + [ + pytest.param('p > 0', UnresolvedComparisonNode, id='a-name-against-a-literal'), + pytest.param('x >= -1', UnresolvedComparisonNode, id='a-name-against-a-signed-literal'), + pytest.param('a == b', UnresolvedComparisonNode, id='a-name-against-a-name'), + pytest.param('position(t) == -1', UnresolvedPositionNode, id='a-position'), + pytest.param('p > 0.5 * q', UnresolvedExpressionComparisonNode, id='arithmetic-on-the-right'), + pytest.param('(a + b) <= c', UnresolvedExpressionComparisonNode, id='a-bracketed-sum-on-the-left'), + pytest.param('-p < 1', UnresolvedExpressionComparisonNode, id='a-negated-name'), + pytest.param('sum(p, over=g) >= k', UnresolvedExpressionComparisonNode, id='a-reduction'), + pytest.param('position(t) + 1 == 0', UnresolvedExpressionComparisonNode, id='position-inside-arithmetic'), + ], +) +def test_a_comparison_takes_the_expression_form_only_past_the_plain_shapes(text, node_type): + """`p > 0` keeps the node its dtype rule is written for; `p > 2 * q` is not cut short at `p > 2`.""" + assert isinstance(parse_where(text), node_type) + + +def test_a_bracketed_predicate_is_still_a_predicate(): + """`(a > 0) AND b` groups a comparison; only `(a + b) <= c` brackets arithmetic.""" + assert isinstance(parse_where('(a > 0) AND b'), AndNode) + + +def test_a_where_side_is_held_to_the_depth_an_expression_is(): + with pytest.raises(SchemaError, match='nests 121 deep'): + parse_where(' + '.join(['p'] * 120) + ' > 0') + + def test_and_binds_tighter_than_or(): assert parse_where('a OR b AND c') == OrNode( UnresolvedNameNode('a'), AndNode(UnresolvedNameNode('b'), UnresolvedNameNode('c')) diff --git a/tests/test_validation.py b/tests/test_validation.py index 2fcc591a..6e177c4c 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -134,9 +134,9 @@ def test_a_nonlinear_entry_is_refused_where_the_math_reads_it(self, patch, fragm is a reported quantity. The constraint and the objective read it and hit the divisor ban at their own ceiling, which is the whole point of grading rather than banning at declaration. The piecewise-link - position is `test_a_link_reading_a_nonlinear_entry_is_refused`; a bound - and a where, which reference no expression at all, are - `test_a_bound_or_where_cannot_name_an_expression`. + position is `test_a_link_reading_a_nonlinear_entry_is_refused`; a bound, + which references no expression at all, and a where, which reads one + as arithmetic, are `test_a_bound_or_where_cannot_name_an_expression`. """ with pytest.raises(LanguageError) as exc: _schema(**_NONLINEAR_ENTRY, **patch) @@ -153,13 +153,13 @@ def test_a_nonlinear_entry_is_refused_where_the_math_reads_it(self, patch, fragm ), pytest.param( {'constraints': {'cap': {'dims': ['g'], 'where': 'bad > 0', 'expression': 'p <= c'}}}, - "'bad' not found", + 'a where compares expressions, and one side names a variable', id='where', ), ], ) def test_a_bound_or_where_cannot_name_an_expression(self, patch, fragment): - """A bound and a where reference parameters/variables, never a named expression, so the name fails to resolve whatever the entry's body.""" + """A bound names a parameter and nothing else; a where reads the entry as arithmetic, and a body carrying a variable is refused there.""" with pytest.raises(LanguageError) as exc: _schema(**_NONLINEAR_ENTRY, **patch) assert fragment in str(exc.value) @@ -745,6 +745,95 @@ def test_a_rule_decided_without_data(self, patch, fragments): assert fragment in message +class TestArithmeticInAWhere: + """What a comparison of expressions may say in a where, decided with no data bound.""" + + @pytest.mark.parametrize( + ('patch', 'where'), + [ + pytest.param({}, 'c <= 0.5 * k', id='arithmetic-on-a-side'), + pytest.param({'macros.half': {'args': ['x'], 'template': 'x / 2'}}, 'c <= half(k)', id='a-macro'), + pytest.param({'expressions.e': 'c * 2'}, 'e > 0', id='a-named-expression-on-the-left'), + pytest.param({'expressions.e': 'c * 2'}, 'k < e', id='a-named-expression-on-the-right'), + pytest.param({'parameters.d': {'dims': ['h']}}, 'c <= at(d, by=lk)', id='a-pullback-through-a-lookup'), + pytest.param( + {}, 'c - shift(c, over=g, offset=1, edge=0) <= k AND position(g) > 0', id='a-translation-with-its-edge' + ), + ], + ) + def test_a_where_comparing_expressions_loads(self, patch, where): + spec = _schema(**patch, **{'variables.p.where': where}) + assert spec.variables['p'].where == where + + def test_a_reduction_on_a_side_leaves_the_frame_it_reduced(self): + spec = _schema( + constraints={'t': {'dims': [], 'where': 'sum(c, over=g) >= k', 'expression': 'sum(p, over=g) <= k'}} + ) + assert list(spec.constraints) == ['t'] + + @pytest.mark.parametrize( + ('patch', 'fragments'), + [ + pytest.param( + {'variables.p.where': 'c > 2 * q'}, + ('one side names a variable', 'built before variables exist'), + id='a-variable-inside-arithmetic', + ), + pytest.param( + {'constraints': {'x': {'dims': ['g'], 'expression': 'p <= c'}}, 'variables.p.where': 'dual(x) * 2 > 0'}, + ('one side reads a dual', 'test the data instead'), + id='a-dual-inside-arithmetic', + ), + pytest.param( + {'variables.p.where': 'tag * 2 > 0'}, + ("'tag' is declared dtype: str, and an expression is arithmetic",), + id='a-label-inside-arithmetic', + ), + pytest.param( + {'variables.p.where': 'c / (k + 1) > 0'}, + ('a divisor must be a single Constant/Parameter factor',), + id='a-divisor-that-adds', + ), + pytest.param( + {'variables.p.where': 'shift(c, over=g, offset=1) <= k'}, + ('shift() over a variable-free expression leaves vacated positions with no value',), + id='a-translation-with-no-edge', + ), + pytest.param( + {'variables.p.where': 'c * nope > 0'}, + ("'nope' not found",), + id='an-unknown-name-inside-arithmetic', + ), + pytest.param( + {'parameters.d': {'dims': ['h']}, 'variables.p.where': 'c > d * 2'}, + ("a where-comparison of expressions reads dims ['h'] outside the frame ['g']",), + id='a-side-outside-the-frame', + ), + ], + ) + def test_a_bad_comparison_of_expressions_is_refused_at_load(self, patch, fragments): + message = _refusal(**patch) + for fragment in fragments: + assert fragment in message + + def test_a_case_comparing_expressions_is_refused_as_undecidable(self): + """Two cases split by arithmetic cannot be proved apart without the numbers, and the rewrite is named.""" + message = _refusal( + expressions={ + 'e': { + 'dims': ['g'], + 'cases': { + 'wide': {'when': 'c > 2 * k', 'expression': 'c'}, + 'narrow': {'when': 'c <= 2 * k', 'expression': 'k'}, + }, + 'otherwise': 0, + } + } + ) + assert 'cannot be told apart before the data arrives: it compares expressions' in message + assert 'precompute the test as a boolean parameter' in message + + class TestTheFrontDoor: def test_a_list_of_models_is_not_a_model(self): """Composition is Python's, not the file's (#30) — and the refusal is the package's own, so the CLI's one except catches it.""" diff --git a/tests/typesetting/golden/latex.out b/tests/typesetting/golden/latex.out index 4b434197..ba3bc86b 100644 --- a/tests/typesetting/golden/latex.out +++ b/tests/typesetting/golden/latex.out @@ -49,6 +49,7 @@ \paragraph{Definitions} \begin{description} +\item[{$\mathrm{spend}^{\mathrm{cap}}$}] \texttt{spend\_cap} over $\mathcal{G}$ \item[{$\mathit{spend}$}] \texttt{spend} over $\mathcal{T}$ --- what a snapshot's dispatch costs \item[{$\mathit{lcoe}$}] \texttt{lcoe} (scalar) \item[{$\mathit{marginal\_price}$}] \texttt{marginal\_price} over $\mathcal{T} \times \mathcal{B}$ @@ -105,11 +106,16 @@ \text{ceiling} && \theta_{b} & \le \infty && \forall\, b \in \mathcal{B} \\ \text{always} && \mathit{spill}_{t} & \ge 0 && \forall\, t \in \mathcal{T} \\ \text{redundant} && \mathit{spill}_{t} & \ge 0 && \forall\, t \in \mathcal{T} \,:\, \mathit{spill}_{t} \text{ exists} \\ -\text{never} && \mathit{slack}_{t} & \ge 0 && \forall\, t \in \mathcal{T} \,:\, \bot +\text{never} && \mathit{slack}_{t} & \ge 0 && \forall\, t \in \mathcal{T} \,:\, \bot \\ +\text{margin} && p_{t,g} & \le \mathrm{p}^{\mathrm{max}}_{g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{p}^{\mathrm{max}}_{g} - \mathrm{p}^{\mathrm{min}}_{g} > \frac{\mathrm{cost}_{g}}{2} \\ +\text{ramped} && \mathit{slack}_{t} & \le \mathrm{load}_{t,b} && \forall\, t \in \mathcal{T},\ b \in \mathcal{B} \,:\, \mathrm{load}_{t,b} - \mathrm{load}_{t \boxminus_{0} 1,b} \le \mathrm{zone\_cap}_{\mathrm{zone\_of}(b)} \wedge \mathrm{pos}(t) > 0 \\ +\text{covered} && \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} & \le \mathrm{budget} && \text{where } \sum_{g \in \mathcal{G}} \mathrm{p}^{\mathrm{max}}_{g} \ge \mathrm{budget} \\ +\text{capped} && p_{t,g} & \le \mathrm{p}^{\mathrm{max}}_{g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{spend}^{\mathrm{cap}}_{g} > 0 \vee \neg \mathrm{is\_flexible}_{g} \end{align} \paragraph{Definitions} \begin{align} +\text{spend\_cap} && \mathrm{spend}^{\mathrm{cap}}_{g} & = \mathrm{cost}_{g} \cdot 2 && \forall\, g \in \mathcal{G} \\ \text{spend} && \mathit{spend}_{t} & = \sum_{g \in \mathcal{G}} p_{t,g} \cdot \mathrm{cost}_{g} && \forall\, t \in \mathcal{T} \\ \text{lcoe} && \mathit{lcoe} & = \frac{\sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} \cdot \mathrm{cost}_{g}}{\sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g}} \\ \text{marginal\_price} && \mathit{marginal\_price}_{t,b} & = \lambda_{\mathrm{balance},t,b} && \forall\, t \in \mathcal{T},\ b \in \mathcal{B} \\ diff --git a/tests/typesetting/golden/markdown.out b/tests/typesetting/golden/markdown.out index fa7ee0a5..5b2ec5cc 100644 --- a/tests/typesetting/golden/markdown.out +++ b/tests/typesetting/golden/markdown.out @@ -49,6 +49,7 @@ every character a notation escapes, set as text: link\_to, 100% & \#1 costs \$5 | Symbol | Meaning | |---|---| +| $`\mathrm{spend}^{\mathrm{cap}}`$ | `spend_cap` over $`\mathcal{G}`$ | | $`\mathit{spend}`$ | `spend` over $`\mathcal{T}`$ — what a snapshot's dispatch costs | | $`\mathit{lcoe}`$ | `lcoe` (scalar) | | $`\mathit{marginal\_price}`$ | `marginal_price` over $`\mathcal{T} \times \mathcal{B}`$ | @@ -256,8 +257,38 @@ p_{t,g} \le \mathrm{eta}_{g} \cdot \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\ \mathit{slack}_{t} \ge 0 \qquad \forall\, t \in \mathcal{T} \,:\, \bot ``` +**`margin`** + +```math +p_{t,g} \le \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{p}^{\mathrm{max}}_{g} - \mathrm{p}^{\mathrm{min}}_{g} > \frac{\mathrm{cost}_{g}}{2} +``` + +**`ramped`** + +```math +\mathit{slack}_{t} \le \mathrm{load}_{t,b} \qquad \forall\, t \in \mathcal{T},\ b \in \mathcal{B} \,:\, \mathrm{load}_{t,b} - \mathrm{load}_{t \boxminus_{0} 1,b} \le \mathrm{zone\_cap}_{\mathrm{zone\_of}(b)} \wedge \mathrm{pos}(t) > 0 +``` + +**`covered`** + +```math +\sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} \le \mathrm{budget} \qquad \text{where } \sum_{g \in \mathcal{G}} \mathrm{p}^{\mathrm{max}}_{g} \ge \mathrm{budget} +``` + +**`capped`** + +```math +p_{t,g} \le \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{spend}^{\mathrm{cap}}_{g} > 0 \vee \neg \mathrm{is\_flexible}_{g} +``` + #### Definitions +**`spend_cap`** + +```math +\mathrm{spend}^{\mathrm{cap}}_{g} = \mathrm{cost}_{g} \cdot 2 \qquad \forall\, g \in \mathcal{G} +``` + **`spend`** ```math diff --git a/tests/typesetting/golden/model.yaml b/tests/typesetting/golden/model.yaml index e1ebe59d..ee738311 100644 --- a/tests/typesetting/golden/model.yaml +++ b/tests/typesetting/golden/model.yaml @@ -82,6 +82,7 @@ sos: type: 2 expressions: + spend_cap: cost * 2 # a data-only entry, so a where may compare it spend: # a plain named expression: its symbol prints where it is used, its body once as a definition description: what a snapshot's dispatch costs expression: sum(p * cost, over=generator) @@ -198,6 +199,22 @@ constraints: dims: [snapshot] where: "false" expression: slack >= 0 + margin: # a mask comparing two expressions, which prints as the arithmetic it is + dims: [snapshot, generator] + where: "p_max - p_min > cost / 2" + expression: p <= p_max + ramped: # a translation under a comparison names its edge, a pullback reads through a lookup, and the position keeps the vacated row out + dims: [snapshot, bus] + where: "load - shift(load, over=snapshot, offset=1, edge=0) <= at(zone_cap, by=zone_of) AND position(snapshot) > 0" + expression: slack <= load + covered: # a reduction on a side of a scalar mask, so nothing is left to quantify + dims: [] + where: "sum(p_max, over=generator) >= budget" + expression: sum(p) <= budget + capped: # an expressions: entry on a side, read by the name the file gave it + dims: [snapshot, generator] + where: "spend_cap > 0 OR NOT is_flexible" + expression: p <= p_max objective: # a sense, a product of two variables, a power over two parameters, a power of one of those, and the summations a scalar objective spells out beside two scalar terms sense: maximize diff --git a/tests/typesetting/golden/typst.out b/tests/typesetting/golden/typst.out index 7f286e31..9aa19a14 100644 --- a/tests/typesetting/golden/typst.out +++ b/tests/typesetting/golden/typst.out @@ -38,6 +38,7 @@ every character a notation escapes, set as text: link\_to, 100% & \#1 costs \$5 / $italic("weight")$: `weight` over $cal(T) times cal(G)$ == Definitions +/ $upright("spend")^(upright("cap"))$: `spend_cap` over $cal(G)$ / $italic("spend")$: `spend` over $cal(T)$ --- what a snapshot's dispatch costs / $italic("lcoe")$: `lcoe` (scalar) / $italic("marginal_price")$: `marginal_price` over $cal(T) times cal(B)$ @@ -92,11 +93,16 @@ $ upright("budgeted") & italic("spend")_(t) & <= upright("budget") & forall t in upright("ceiling") & theta_(b) & <= infinity & forall b in cal(B) \ upright("always") & italic("spill")_(t) & >= 0 & forall t in cal(T) \ upright("redundant") & italic("spill")_(t) & >= 0 & forall t in cal(T) colon italic("spill")_(t) upright(" exists") \ - upright("never") & italic("slack")_(t) & >= 0 & forall t in cal(T) colon bot $ + upright("never") & italic("slack")_(t) & >= 0 & forall t in cal(T) colon bot \ + upright("margin") & p_(t,g) & <= upright("p")^(upright("max"))_(g) & forall t in cal(T), g in cal(G) colon upright("p")^(upright("max"))_(g) - upright("p")^(upright("min"))_(g) > frac(upright("cost")_(g), 2) \ + upright("ramped") & italic("slack")_(t) & <= upright("load")_(t,b) & forall t in cal(T), b in cal(B) colon upright("load")_(t,b) - upright("load")_(t minus.square_(0) 1,b) <= upright("zone_cap")_(upright("zone_of")(b)) and upright("pos")(t) > 0 \ + upright("covered") & sum_(t in cal(T), g in cal(G)) p_(t,g) & <= upright("budget") & upright("where ") sum_(g in cal(G)) upright("p")^(upright("max"))_(g) >= upright("budget") \ + upright("capped") & p_(t,g) & <= upright("p")^(upright("max"))_(g) & forall t in cal(T), g in cal(G) colon upright("spend")^(upright("cap"))_(g) > 0 or not upright("is_flexible")_(g) $ == Definitions #set math.equation(numbering: "(1)") -$ upright("spend") & italic("spend")_(t) & = sum_(g in cal(G)) p_(t,g) dot upright("cost")_(g) & forall t in cal(T) \ +$ upright("spend_cap") & upright("spend")^(upright("cap"))_(g) & = upright("cost")_(g) dot 2 & forall g in cal(G) \ + upright("spend") & italic("spend")_(t) & = sum_(g in cal(G)) p_(t,g) dot upright("cost")_(g) & forall t in cal(T) \ upright("lcoe") & italic("lcoe") & = frac(sum_(t in cal(T), g in cal(G)) p_(t,g) dot upright("cost")_(g), sum_(t in cal(T), g in cal(G)) p_(t,g)) \ upright("marginal_price") & italic("marginal_price")_(t,b) & = lambda_(upright("balance"),t,b) & forall t in cal(T), b in cal(B) \ upright("startup_cost") & upright("startup_cost")_(t,g) & = cases(upright("cost")_(g) & upright("if ") upright("pos")(t) = 0, upright("cost")_(g) dot 2 & upright("if ") upright("pos")(t) > 0 and upright("season_of")(t) = upright("'winter'"), 0 & upright("otherwise")) & forall t in cal(T), g in cal(G) $ diff --git a/tests/typesetting/test_golden.py b/tests/typesetting/test_golden.py index e93b0712..c40e8af1 100644 --- a/tests/typesetting/test_golden.py +++ b/tests/typesetting/test_golden.py @@ -132,17 +132,20 @@ def _rendered_trees() -> Iterator[object]: yield from resolved.expressions.values() -#: What resolution never hands the walk: the three nodes it types away, and the -#: three an expression only carries before names are resolved. The walk raises on +#: What resolution never hands the walk: the four nodes it types away, the three +#: an expression only carries before names are resolved, and the lowered form of +#: a comparison of expressions, which only a program carries. The walk raises on #: each rather than rendering it, so a fixture reaching one would be a bug in #: resolution rather than a case worth committing output for. UNRESOLVED = { 'UnresolvedNameNode', 'UnresolvedComparisonNode', + 'UnresolvedExpressionComparisonNode', 'UnresolvedPositionNode', 'NameNode', 'NameListNode', 'KeywordNode', + 'ExpressionComparisonNode', } #: A dataclass the walk steps *through* rather than renders: an arm has no @@ -192,6 +195,8 @@ def test_the_golden_model_calls_every_operator_in_the_language(): UNREACHABLE = { 'if isinstance(node, UnresolvedNode | KwargNode):', "msg = f'{type(node).__name__} reached the typesetter; resolve the expression first.'", + 'if isinstance(node, ExpressionComparisonNode):', + "msg = 'a lowered comparison reached the typesetter; it prints the resolved tree, which lowering rebuilds.'", 'if not isinstance(node, ComparisonNode):', "msg = f'{context}: expected a comparison, got {type(node).__name__}'", 'raise AssertionError(msg)', diff --git a/tests/typesetting/test_walk.py b/tests/typesetting/test_walk.py index 0db831e7..1a634a71 100644 --- a/tests/typesetting/test_walk.py +++ b/tests/typesetting/test_walk.py @@ -759,3 +759,13 @@ def test_a_string_value_in_a_where_prints_as_a_quoted_label(name: FormatName, fm assert fmt.quoted('gas_ccgt') in text unquoted = text.replace(fmt.quoted('gas_ccgt'), '') assert fmt.prose('gas_ccgt') not in unquoted, 'a string value is data, never words inside math' + + +@EVERY_FORMAT +def test_a_comparison_of_expressions_prints_as_the_arithmetic_it_is(name: FormatName, fmt: Format): + """`cost <= p_max / 2` on a quantifier renders each side as an expression, around the relation.""" + model = override(DISPATCH_MODEL, **{'variables.p.where': 'cost <= p_max / 2'}) + text = typeset(model, name, legend=False) + p_max = fmt.subscript(fmt.superscript(fmt.upright('p'), fmt.upright('max')), ['g']) + cost = fmt.subscript(fmt.upright('cost'), ['g']) + assert f'{cost} {fmt.operators["le"]} {fmt.fraction(p_max, "2")}' in text