diff --git a/.prettierignore b/.prettierignore index 66add10f..ce50c8b3 100644 --- a/.prettierignore +++ b/.prettierignore @@ -29,6 +29,10 @@ docs/examples/pypsa_linearized_uc.md docs/examples/pypsa_losses.md docs/examples/pypsa_stochastic.md docs/examples/pypsa_multi_period.md +docs/examples/library/surface.md +docs/examples/library/generator.md +docs/examples/library/load.md +docs/examples/library/composed.md # The PyPSA reference scripts write this file; prettier would reformat what # they stamp, and the two would fight over it exactly as above. diff --git a/docs/about/limits.md b/docs/about/limits.md index 106b75ba..3b9a1181 100644 --- a/docs/about/limits.md +++ b/docs/about/limits.md @@ -12,7 +12,7 @@ or keyword. For the rules a model itself has to obey, read ## How a new construct enters -A request for something new is one of three kinds, and the kind decides what it +A request for something new is one of four kinds, and the kind decides what it costs to add. - **A macro** is a template with arguments, written in the file under `macros:`. @@ -31,8 +31,14 @@ costs to add. expansion, and [`spec.expand()`](../reference/language/piecewise.md#writing-a-formulation-out) needs no source a reader has to supply. - -A request that is none of the three is refused, and the +- **A declaration section** is a block of declarations of one kind, such as + `variables:` or `given:`. One enters where it states something no section + states, where a file decides it without data, and where the typesetter prints + it. `given:` entered on all three. No other section says that a column + belongs to another file, and that is what lets a component file load and + print on its own. + +A request that is none of the four is refused, and the [table of refusals](#deliberate-non-primitives) records it with what to write instead. @@ -143,16 +149,32 @@ That another tool has a feature is not by itself a reason to add it. ## Composition (component libraries) -A component library is a set of templates, such as a boiler, a battery and a -line, that agree on how ports and flows are named. You merge the templates you -need into one file, wire the components together with a connectivity table in -the data, and close the system with one `sum(by=)` balance. +A component library is a set of fragments, one file per component type, such +as a boiler, a battery and a line. The fragments agree on how ports and flows +are named. You merge the fragments you need into one file, wire the components together +with a connectivity table in the data, and close the system with one `sum(by=)` +balance. The topology is data. Adding a second battery is a row in a table, so the file grows with the number of component _types_. -Merging happens before `to_spec`. Every function here takes a `dict` as well as -a path, so a model assembled in Python is checked exactly as a file is, and -`Spec.to_yaml()` writes the file a reviewer reads. A `dict` may hold only what a -file may hold. A built-in merge, and namespaces so that two templates can each -declare a `p`, are both things a library does before it hands over a `dict`. +A fragment reads the [coupling surface](../examples/library/surface.md) it is +written against, and declares that +column under +[`given: variables:`](../reference/language/declarations.md#given). So it +loads on its own, and prints as math on its own. + +Composition happens before `to_spec`, and two verbs do it. `merge` composes +fragments as peers: a name two of them declare is refused, and a given +declaration is folded into the fragment that introduces the name. `override` +lays a patch over a base, one field at a time. A project that extends a model +it does not own writes a patch instead of a copy. It refuses a patch +that lands on nothing, two patches that write one field, and a dimension or a +relation redeclared under the math. The recipe for both is in +[compose a model from several files](../howto/compose.md). + +Every function here takes a `dict` as well as a path, so a model assembled in +Python is checked exactly as a file is, and `Spec.to_yaml()` writes the file a +reviewer reads. A `dict` may hold only what a file may hold. Namespaces, so +that two fragments can each declare a `p`, are a library's business before it +hands over a `dict`. diff --git a/docs/examples/index.md b/docs/examples/index.md index e446a64c..b72748c4 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -17,6 +17,9 @@ Every model is a file under `examples/` in the repository. - [PyPSA in one file](pypsa.md) states the model `n.optimize()` builds, one declaration at a time. PyPSA's name for each row sits beside the YAML and the equation. +- [A component library](library/index.md) is several files that compose into + one model. Each file reads the coupling surface and prints on its own, and the + composed page shows what `merge` returns. The math on these pages is printed by the typesetter from the file above it. See [Typeset the math](../reference/typeset.md) to print your own. diff --git a/docs/examples/library/composed.md b/docs/examples/library/composed.md new file mode 100644 index 00000000..188dd10f --- /dev/null +++ b/docs/examples/library/composed.md @@ -0,0 +1,261 @@ + + +# The composed model + +What [the surface](surface.md), [generators](generator.md) and +[loads](load.md) make together: + +```python +import math_spec as ms + +model = ms.merge({'surface': 'surface.yaml', 'generator': 'generator.yaml', 'load': 'load.yaml'}) +spec = ms.to_spec(model) +``` + +The file below is `model`, the mapping `merge` returns, written as YAML. No +fragment holds it, and nothing in the repository commits it. `Port_p` is one +declaration here. Each component fragment read it under `given:`, and merging +folded those readings into the surface's own declaration. + +The objective is the generator's, carried as it was written, since no other +fragment prices anything. A second priced fragment would add its term to this +one, each term in parentheses. + +The math under the file has a tab per formulation. **As composed** is the model +above. **With commitment** lays `variants/commitment.yaml` over it with +[`override`](../../howto/compose.md#a-base-and-its-patches), which makes the +generator a committed unit: + +```python +spec = ms.to_spec(ms.override(model, {'commitment': 'variants/commitment.yaml'})) +``` + +A patch is refused on its own, since it edits declarations it does not +declare. So the model it lands on is the only place its math exists, and the +tab prints the patch beside that math. + + +```yaml +dimensions: + snapshot: {dtype: datetime, description: dispatch periods} + bus: {dtype: str, description: network nodes} + port: {dtype: str, description: 'the connections components make, one label per connection'} + generator: {dtype: str, description: 'generating units, each on one port'} + load: {dtype: str, description: 'demands, each on one port'} +relations: + Port_bus: {key: port, values: bus} + Generator_port: {key: generator, values: port} + Load_port: {key: load, values: port} +parameters: + Generator_p_nom: + dims: [generator] + description: nominal power + Generator_marginal_cost: + dims: [generator] + description: cost of one unit of output + Load_p_set: + dims: [snapshot, load] + description: '`Load-p_set` — what a load takes in a snapshot' +variables: + Port_p: + dims: [snapshot, port] + description: what a port puts into its bus in a snapshot, negative for a withdrawal + Generator_p: + dims: [snapshot, generator] + bounds: {lower: 0, upper: Generator_p_nom} + description: '`Generator-p` — what a generator produces in a snapshot' +constraints: + Bus_nodal_balance: + description: '`Bus-nodal_balance` — what the ports on a bus put in nets to nothing' + dims: [snapshot, bus] + expression: sum(Port_p, by=Port_bus, over=port, into=bus) == 0 + Generator_injection: + description: 'what a generator produces is what its port injects. No PyPSA row stands for this: PyPSA + writes the generator into the balance instead' + dims: [snapshot, generator] + expression: at(Port_p, by=Generator_port, over=port, into=generator) == Generator_p + Load_withdrawal: + description: 'what a load takes is what its port withdraws. No PyPSA row stands for this: PyPSA writes + the load into the balance instead' + dims: [snapshot, load] + expression: at(Port_p, by=Load_port, over=port, into=load) == -Load_p_set +objective: {sense: minimize, expression: sum(Generator_p * Generator_marginal_cost)} +``` + +=== "As composed" + + #### Sets + + | Symbol | Meaning | + |---|---| + | $`\mathcal{T}`$ | index $`t`$ — `snapshot` — dispatch periods | + | $`\mathcal{N}`$ | index $`n`$ — `bus` with $`\mathrm{Port\_bus}: \mathcal{J} \to \mathcal{N}`$ — network nodes | + | $`\mathcal{J}`$ | index $`j`$ — `port` with $`\mathrm{Port\_bus}: \mathcal{J} \to \mathcal{N},\ \mathrm{Generator\_port}: \mathcal{G} \to \mathcal{J},\ \mathrm{Load\_port}: \mathcal{D} \to \mathcal{J}`$ — the connections components make, one label per connection | + | $`\mathcal{G}`$ | index $`g`$ — `generator` with $`\mathrm{Generator\_port}: \mathcal{G} \to \mathcal{J}`$ — generating units, each on one port | + | $`\mathcal{D}`$ | index $`d`$ — `load` with $`\mathrm{Load\_port}: \mathcal{D} \to \mathcal{J}`$ — demands, each on one port | + + #### Parameters + + | Symbol | Meaning | + |---|---| + | $`\mathrm{p}^{\mathrm{nom}}`$ | `Generator_p_nom` over $`\mathcal{G}`$ — nominal power | + | $`\mathrm{c}`$ | `Generator_marginal_cost` over $`\mathcal{G}`$ — cost of one unit of output | + | $`\mathrm{load}`$ | `Load_p_set` over $`\mathcal{T} \times \mathcal{D}`$ — `Load-p_set` — what a load takes in a snapshot | + + #### Variables + + | Symbol | Meaning | + |---|---| + | $`f`$ | `Port_p` over $`\mathcal{T} \times \mathcal{J}`$ — what a port puts into its bus in a snapshot, negative for a withdrawal | + | $`p`$ | `Generator_p` over $`\mathcal{T} \times \mathcal{G}`$ — `Generator-p` — what a generator produces in a snapshot | + + #### Objective + + ```math + \min \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} \cdot \mathrm{c}_{g} + ``` + + #### Subject to + + **`Bus_nodal_balance`** + + ```math + \sum_{j \in \mathcal{J} \,:\, \mathrm{Port\_bus}(j) = n} f_{t,j} = 0 \qquad \forall\, t \in \mathcal{T},\ n \in \mathcal{N} + ``` + + **`Generator_injection`** + + ```math + f_{t,\mathrm{Generator\_port}(g)} = p_{t,g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} + ``` + + **`Load_withdrawal`** + + ```math + f_{t,\mathrm{Load\_port}(d)} = -\mathrm{load}_{t,d} \qquad \forall\, t \in \mathcal{T},\ d \in \mathcal{D} + ``` + + #### Variable domains + + **`Port_p`** + + ```math + f_{t,j} \in \mathbb{R} \qquad \forall\, t \in \mathcal{T},\ j \in \mathcal{J} + ``` + + **`Generator_p`** + + ```math + 0 \le p_{t,g} \le \mathrm{p}^{\mathrm{nom}}_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} + ``` + +=== "With commitment" + + ```yaml title="variants/commitment.yaml" + parameters: + Generator_p_min_pu: { dims: [generator], description: "least output, per unit of nominal power" } + variables: + Generator_status: + dims: [snapshot, generator] + domain: binary + description: "`Generator-status` — whether a unit is on in a snapshot" + Generator_p: { bounds: { upper: .inf } } + constraints: + Generator_com_p_upper: + description: "`Generator-com-p-upper` — a committed unit outputs at most its nominal power; off, at most nothing" + dims: [snapshot, generator] + expression: Generator_p <= Generator_p_nom * Generator_status + Generator_com_p_lower: + description: "`Generator-com-p-lower` — a committed unit outputs at least its minimum; off, at least nothing" + dims: [snapshot, generator] + expression: Generator_p >= Generator_p_min_pu * Generator_p_nom * Generator_status + ``` + + #### Sets + + | Symbol | Meaning | + |---|---| + | $`\mathcal{T}`$ | index $`t`$ — `snapshot` — dispatch periods | + | $`\mathcal{N}`$ | index $`n`$ — `bus` with $`\mathrm{Port\_bus}: \mathcal{J} \to \mathcal{N}`$ — network nodes | + | $`\mathcal{J}`$ | index $`j`$ — `port` with $`\mathrm{Port\_bus}: \mathcal{J} \to \mathcal{N},\ \mathrm{Generator\_port}: \mathcal{G} \to \mathcal{J},\ \mathrm{Load\_port}: \mathcal{D} \to \mathcal{J}`$ — the connections components make, one label per connection | + | $`\mathcal{G}`$ | index $`g`$ — `generator` with $`\mathrm{Generator\_port}: \mathcal{G} \to \mathcal{J}`$ — generating units, each on one port | + | $`\mathcal{D}`$ | index $`d`$ — `load` with $`\mathrm{Load\_port}: \mathcal{D} \to \mathcal{J}`$ — demands, each on one port | + + #### Parameters + + | Symbol | Meaning | + |---|---| + | $`\mathrm{p}^{\mathrm{nom}}`$ | `Generator_p_nom` over $`\mathcal{G}`$ — nominal power | + | $`\mathrm{c}`$ | `Generator_marginal_cost` over $`\mathcal{G}`$ — cost of one unit of output | + | $`\mathrm{load}`$ | `Load_p_set` over $`\mathcal{T} \times \mathcal{D}`$ — `Load-p_set` — what a load takes in a snapshot | + | $`\underline{\mathrm{p}}`$ | `Generator_p_min_pu` over $`\mathcal{G}`$ — least output, per unit of nominal power | + + #### Variables + + | Symbol | Meaning | + |---|---| + | $`f`$ | `Port_p` over $`\mathcal{T} \times \mathcal{J}`$ — what a port puts into its bus in a snapshot, negative for a withdrawal | + | $`p`$ | `Generator_p` over $`\mathcal{T} \times \mathcal{G}`$ — `Generator-p` — what a generator produces in a snapshot | + | $`u`$ | `Generator_status` over $`\mathcal{T} \times \mathcal{G}`$ — `Generator-status` — whether a unit is on in a snapshot | + + #### Objective + + ```math + \min \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} \cdot \mathrm{c}_{g} + ``` + + #### Subject to + + **`Bus_nodal_balance`** + + ```math + \sum_{j \in \mathcal{J} \,:\, \mathrm{Port\_bus}(j) = n} f_{t,j} = 0 \qquad \forall\, t \in \mathcal{T},\ n \in \mathcal{N} + ``` + + **`Generator_injection`** + + ```math + f_{t,\mathrm{Generator\_port}(g)} = p_{t,g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} + ``` + + **`Load_withdrawal`** + + ```math + f_{t,\mathrm{Load\_port}(d)} = -\mathrm{load}_{t,d} \qquad \forall\, t \in \mathcal{T},\ d \in \mathcal{D} + ``` + + **`Generator_com_p_upper`** + + ```math + p_{t,g} \le \mathrm{p}^{\mathrm{nom}}_{g} \cdot u_{t,g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} + ``` + + **`Generator_com_p_lower`** + + ```math + p_{t,g} \ge \underline{\mathrm{p}}_{g} \cdot \mathrm{p}^{\mathrm{nom}}_{g} \cdot u_{t,g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} + ``` + + #### Variable domains + + **`Port_p`** + + ```math + f_{t,j} \in \mathbb{R} \qquad \forall\, t \in \mathcal{T},\ j \in \mathcal{J} + ``` + + **`Generator_p`** + + ```math + p_{t,g} \ge 0 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} + ``` + + **`Generator_status`** + + ```math + u_{t,g} \in \{0, 1\} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} + ``` + diff --git a/docs/examples/library/generator.md b/docs/examples/library/generator.md new file mode 100644 index 00000000..e2e73269 --- /dev/null +++ b/docs/examples/library/generator.md @@ -0,0 +1,114 @@ + + +# Generators + +PyPSA's `Generator`, as one fragment. It owns its dimension, its relation into +`port`, its parameters, its column and its cost. It reads `Port_p` from +[the surface](surface.md) under +[`given`](../../reference/language/declarations.md#given). `Generator_port` +stands where PyPSA writes `Generator_bus`. + +The constraint is what makes the library composable. +`at(Port_p, by=Generator_port, over=port, into=generator)` pins the flow at +this component's own port rather than adding a term to the balance, so the +balance does not grow. + +The file is cut to what a dispatch model needs. A fixed build, no availability +profile and no ramp limits are three declarations PyPSA carries and this file +does not. [The PyPSA rungs](../pypsa.md) state them in full. + +The math below is what this file prints on its own, with `Port_p` under +*Given* in the legend. When it merges with the surface, `Port_p` is one +declaration again. + + +```yaml +description: >- + PyPSA's `Generator`, wired to a port rather than straight to a bus, and cut + to what a dispatch model needs: a fixed build, no availability profile, no + ramp limits. +dimensions: + snapshot: { dtype: datetime, description: dispatch periods } + port: { dtype: str, description: "the connections components make, one label per connection" } + generator: { dtype: str, description: "generating units, each on one port" } +relations: + Generator_port: { key: generator, values: port } +given: + variables: + Port_p: + dims: [snapshot, port] + description: the surface introduces this flow, and this file pins it at its own ports +parameters: + Generator_p_nom: { dims: [generator], description: nominal power } + Generator_marginal_cost: { dims: [generator], description: cost of one unit of output } +variables: + Generator_p: + dims: [snapshot, generator] + bounds: { lower: 0, upper: Generator_p_nom } + description: "`Generator-p` — what a generator produces in a snapshot" +constraints: + Generator_injection: + description: >- + what a generator produces is what its port injects. No PyPSA row stands + for this: PyPSA writes the generator into the balance instead + dims: [snapshot, generator] + expression: at(Port_p, by=Generator_port, over=port, into=generator) == Generator_p +objective: + sense: minimize + expression: sum(Generator_p * Generator_marginal_cost) +``` + +PyPSA's `Generator`, wired to a port rather than straight to a bus, and cut to what a dispatch model needs: a fixed build, no availability profile, no ramp limits. + +#### Sets + +| Symbol | Meaning | +|---|---| +| $`\mathcal{T}`$ | index $`t`$ — `snapshot` — dispatch periods | +| $`\mathcal{J}`$ | index $`j`$ — `port` with $`\mathrm{Generator\_port}: \mathcal{G} \to \mathcal{J}`$ — the connections components make, one label per connection | +| $`\mathcal{G}`$ | index $`g`$ — `generator` with $`\mathrm{Generator\_port}: \mathcal{G} \to \mathcal{J}`$ — generating units, each on one port | + +#### Parameters + +| Symbol | Meaning | +|---|---| +| $`\mathrm{p}^{\mathrm{nom}}`$ | `Generator_p_nom` over $`\mathcal{G}`$ — nominal power | +| $`\mathrm{c}`$ | `Generator_marginal_cost` over $`\mathcal{G}`$ — cost of one unit of output | + +#### Variables + +| Symbol | Meaning | +|---|---| +| $`p`$ | `Generator_p` over $`\mathcal{T} \times \mathcal{G}`$ — `Generator-p` — what a generator produces in a snapshot | + +#### Given + +| Symbol | Meaning | +|---|---| +| $`f`$ | `Port_p` over $`\mathcal{T} \times \mathcal{J}`$ — the surface introduces this flow, and this file pins it at its own ports | + +#### Objective + +```math +\min \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} \cdot \mathrm{c}_{g} +``` + +#### Subject to + +**`Generator_injection`** + +```math +f_{t,\mathrm{Generator\_port}(g)} = p_{t,g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +``` + +#### Variable domains + +**`Generator_p`** + +```math +0 \le p_{t,g} \le \mathrm{p}^{\mathrm{nom}}_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +``` + diff --git a/docs/examples/library/index.md b/docs/examples/library/index.md new file mode 100644 index 00000000..3c00ccc7 --- /dev/null +++ b/docs/examples/library/index.md @@ -0,0 +1,65 @@ + + +# A component library + +Several files each say part of a model and compose into one. The surface +declares what the components share. Each component file declares its own math +against the surface, and [`merge`](../../howto/compose.md) makes the model. +Every file here loads and prints on its own, so the unit you pick from is the +unit you can read. + +The names are PyPSA's, spelled `Component_attribute` as +[the PyPSA rungs](../pypsa.md) spell them, and the math prints in the symbols +those pages use. The model is cut to dispatch: one build, no availability +profile, no ramp limits. + +## The layout + +```text +examples/library/ + surface.yaml one flow per port, one balance per bus + generator.yaml PyPSA's Generator + load.yaml PyPSA's Load + variants/ + commitment.yaml a patch over the composition, not a peer +``` + +| Page | What it shows | +| ---------------------------------- | -------------------------------------------------------------- | +| [The coupling surface](surface.md) | the surface, and the sign convention | +| [Generators](generator.md) | a file that reads `Port_p` and prices its output | +| [Loads](load.md) | a file with no variable of its own | +| [The composed model](composed.md) | what `merge` returns, and the math it prints with each variant | + +## Rules of the layout + +- **One file per thing you would pick on its own.** `merge` takes a whole + fragment or none of it, so a model with no storage never mentions storage. +- **Every component file is written against one surface.** +- **Every name carries the component class it belongs to.** `merge` does not + rename. `Generator_` and `Load_` keep the files apart, and the surface owns + `Port_`, `port` and `bus`. +- **A fragment is what a system has. A patch is how a component is + formulated.** A second kind of component is a peer, and `merge` composes it. + A different formulation of one component edits declarations that already + exist, and `override` lays it over the composition. + +## The variant + +`variants/commitment.yaml` makes the generator a committed unit. It adds a +binary, lifts the upper bound the capacity gave `Generator_p`, and caps output +with a constraint instead. + +It edits `Generator_p`, which `generator.yaml` introduces, and names +`Generator_p_nom`, which `generator.yaml` declares. So it is not a model, and +`to_spec` refuses it on its own. It is laid over the composition: + +```python +ms.override(ms.merge(fragments), {'commitment': 'variants/commitment.yaml'}) +``` + +The [composed model](composed.md) carries the patch and the math it makes, in a +tab of its own. That tab is the only place the patch can be read as math. diff --git a/docs/examples/library/load.md b/docs/examples/library/load.md new file mode 100644 index 00000000..f4fc24fa --- /dev/null +++ b/docs/examples/library/load.md @@ -0,0 +1,69 @@ + + +# Loads + +PyPSA's `Load`, and the fragment that shows what a file may leave out. It +declares no variable and no objective. `Load_p_set` is data, and the only thing +the file says is what the load's port withdraws. + +The minus sign is the whole of its relation to the sign convention. A +withdrawal is a negative injection. + + +```yaml +description: PyPSA's `Load`, wired to a port rather than straight to a bus. What it takes is data, so it decides nothing. +dimensions: + snapshot: { dtype: datetime, description: dispatch periods } + port: { dtype: str, description: "the connections components make, one label per connection" } + load: { dtype: str, description: "demands, each on one port" } +relations: + Load_port: { key: load, values: port } +given: + variables: + Port_p: + dims: [snapshot, port] + description: the surface introduces this flow, and this file pins it at its own ports +parameters: + Load_p_set: { dims: [snapshot, load], description: "`Load-p_set` — what a load takes in a snapshot" } +constraints: + Load_withdrawal: + description: >- + what a load takes is what its port withdraws. No PyPSA row stands for + this: PyPSA writes the load into the balance instead + dims: [snapshot, load] + expression: at(Port_p, by=Load_port, over=port, into=load) == -Load_p_set +``` + +PyPSA's `Load`, wired to a port rather than straight to a bus. What it takes is data, so it decides nothing. + +#### Sets + +| Symbol | Meaning | +|---|---| +| $`\mathcal{T}`$ | index $`t`$ — `snapshot` — dispatch periods | +| $`\mathcal{J}`$ | index $`j`$ — `port` with $`\mathrm{Load\_port}: \mathcal{D} \to \mathcal{J}`$ — the connections components make, one label per connection | +| $`\mathcal{D}`$ | index $`d`$ — `load` with $`\mathrm{Load\_port}: \mathcal{D} \to \mathcal{J}`$ — demands, each on one port | + +#### Parameters + +| Symbol | Meaning | +|---|---| +| $`\mathrm{load}`$ | `Load_p_set` over $`\mathcal{T} \times \mathcal{D}`$ — `Load-p_set` — what a load takes in a snapshot | + +#### Given + +| Symbol | Meaning | +|---|---| +| $`f`$ | `Port_p` over $`\mathcal{T} \times \mathcal{J}`$ — the surface introduces this flow, and this file pins it at its own ports | + +#### Subject to + +**`Load_withdrawal`** + +```math +f_{t,\mathrm{Load\_port}(d)} = -\mathrm{load}_{t,d} \qquad \forall\, t \in \mathcal{T},\ d \in \mathcal{D} +``` + diff --git a/docs/examples/library/surface.md b/docs/examples/library/surface.md new file mode 100644 index 00000000..f0525c91 --- /dev/null +++ b/docs/examples/library/surface.md @@ -0,0 +1,77 @@ + + +# The coupling surface + +The surface every other file in the library is written against. It declares one +`Port_p` per port, one balance per bus, and the relation that says which bus a +port sits on. Nothing in it names a component class, so it is the one file that +does not change when a component class is added. + +PyPSA gives each component class a bus column and sums the classes into +`Bus-nodal_balance`. Here a component is wired to a port and the port to a bus, +so the balance sums ports and stays as written however many fragments merge. +The [how-to guide](../../howto/compose.md) shows the same shape with fewer +names. + +A flow is positive where the port injects into its bus. Every component reads +that convention, and no component restates it. + + +```yaml +description: >- + The coupling surface every component in this library is written against: one + flow per port, and one balance per bus. A component is wired to a port, the + port to a bus, and the balance names no component class. A flow is positive + where the port injects into its bus. +dimensions: + snapshot: { dtype: datetime, description: dispatch periods } + bus: { dtype: str, description: network nodes } + port: { dtype: str, description: "the connections components make, one label per connection" } +relations: + Port_bus: { key: port, values: bus } +variables: + Port_p: + dims: [snapshot, port] + description: what a port puts into its bus in a snapshot, negative for a withdrawal +constraints: + Bus_nodal_balance: + description: "`Bus-nodal_balance` — what the ports on a bus put in nets to nothing" + dims: [snapshot, bus] + expression: sum(Port_p, by=Port_bus, over=port, into=bus) == 0 +``` + +The coupling surface every component in this library is written against: one flow per port, and one balance per bus. A component is wired to a port, the port to a bus, and the balance names no component class. A flow is positive where the port injects into its bus. + +#### Sets + +| Symbol | Meaning | +|---|---| +| $`\mathcal{T}`$ | index $`t`$ — `snapshot` — dispatch periods | +| $`\mathcal{N}`$ | index $`n`$ — `bus` with $`\mathrm{Port\_bus}: \mathcal{J} \to \mathcal{N}`$ — network nodes | +| $`\mathcal{J}`$ | index $`j`$ — `port` with $`\mathrm{Port\_bus}: \mathcal{J} \to \mathcal{N}`$ — the connections components make, one label per connection | + +#### Variables + +| Symbol | Meaning | +|---|---| +| $`f`$ | `Port_p` over $`\mathcal{T} \times \mathcal{J}`$ — what a port puts into its bus in a snapshot, negative for a withdrawal | + +#### Subject to + +**`Bus_nodal_balance`** + +```math +\sum_{j \in \mathcal{J} \,:\, \mathrm{Port\_bus}(j) = n} f_{t,j} = 0 \qquad \forall\, t \in \mathcal{T},\ n \in \mathcal{N} +``` + +#### Variable domains + +**`Port_p`** + +```math +f_{t,j} \in \mathbb{R} \qquad \forall\, t \in \mathcal{T},\ j \in \mathcal{J} +``` + diff --git a/docs/howto/compose.md b/docs/howto/compose.md new file mode 100644 index 00000000..fc9f92aa --- /dev/null +++ b/docs/howto/compose.md @@ -0,0 +1,287 @@ + + +# Compose a model from several files + +Build one model out of files that each say part of it. `merge` composes +**fragments**: the files of a component library, each owning part of the math. +`override` lays **patches** over a **base**: the model a framework ships, and +the change a project makes to it. Each hands back one mapping, which +[`to_spec`](../reference/language/errors.md#what-to_spec-checks) loads like any +file, and the two compose as `override(merge({…}), {…})`. + +## A library of components + +1. **Write the coupling surface as a model.** One flow per port, one balance + per bus. Nothing in it names a component class. + + ```yaml title="surface.yaml" + dimensions: + snapshot: { dtype: int } + bus: { dtype: str } + port: { dtype: str } + relations: + Port_bus: { key: port, values: bus } + variables: + Port_p: + dims: [snapshot, port] + description: what a port puts into its bus + constraints: + Bus_balance: + dims: [snapshot, bus] + expression: sum(Port_p, by=Port_bus, over=port, into=bus) == 0 + ``` + +2. **Write each component file against that surface.** It declares its own + dimension, its own math, and one relation into `port`. It names `Port_p` + under [`given`](../reference/language/declarations.md#given), because the + surface introduces that column and this file only reads it. + + ```yaml title="generator.yaml" + dimensions: + snapshot: { dtype: int } + port: { dtype: str } + generator: { dtype: str } + relations: + Generator_port: { key: generator, values: port } + given: + variables: + Port_p: { dims: [snapshot, port] } + parameters: + Generator_p_nom: { dims: [generator] } + Generator_marginal_cost: { dims: [generator] } + variables: + Generator_p: { dims: [snapshot, generator], bounds: { lower: 0, upper: Generator_p_nom } } + constraints: + Generator_injection: + dims: [snapshot, generator] + expression: at(Port_p, by=Generator_port, over=port, into=generator) == Generator_p + objective: + sense: minimize + expression: sum(Generator_p * Generator_marginal_cost) + ``` + + ```yaml title="load.yaml" + dimensions: + snapshot: { dtype: int } + port: { dtype: str } + load: { dtype: str } + relations: + Load_port: { key: load, values: port } + given: + variables: + Port_p: { dims: [snapshot, port] } + parameters: + Load_p_set: { dims: [snapshot, load] } + constraints: + Load_withdrawal: + dims: [snapshot, load] + expression: at(Port_p, by=Load_port, over=port, into=load) == -Load_p_set + ``` + + Each file loads on its own and prints as math on its own. + +3. **Merge the files you need.** Each fragment is given a name, and that name + is what a refusal calls it. The order the fragments are given in does not + change the model. + + ```python + import math_spec as ms + + model = ms.merge({'surface': 'surface.yaml', 'generator': 'generator.yaml', 'load': 'load.yaml'}) + spec = ms.to_spec(model) + ``` + + `merge` folds each given declaration into the declaration that introduces + it, so `spec` declares `Port_p` once and carries no `given:`. The objectives + of the fragments are summed, each term in parentheses, in the order the + fragment names sort in. + +4. **Add a component class without touching the balance.** A component file + pins the flow at its own port rather than adding a term to the balance, so + `Bus_balance` is written once and stays as it is however many files are + merged. What grows is the data: which ports exist, and which bus each one + sits on. + +## What a fragment may share + +| The entry | What happens | +| ----------------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| a dimension or a relation | every fragment may declare it, and the ones that do say the same thing about it | +| a `description` on a shared dimension or relation | it is prose rather than a claim, and the first fragment's wording is carried | +| any other declaration | one fragment declares it, and a second is refused | +| an entry under `given: variables:` or `given: constraints:` | it is checked against the fragment that introduces the name, then folded into it | +| a given entry no fragment introduces | it stays under `given:` for a consumer to bind | +| `objective` | the terms are summed in fragment-name order, each in parentheses, and the senses agree | +| `version` | the fragments that write one say the same one, and a composition nothing pins writes none | +| `description` at the top of a fragment | it is about the fragment and is not carried. Pass the composed model's as `description=` | + +## A name two fragments declare + +Fragments own their math, so a name two of them declare is refused, both +named. Here two files each say what a generator fleet is: + +```text +fragments 'gas' and 'coal' both declare the parameter 'Generator_p_nom'. Two of the same kind of thing are two rows of a dimension rather than two fragments: merge the fragment once, and let the data carry both. Different math under one spelling is a rename: call one of them something else. +``` + +## A column read one way and introduced another + +What a fragment states about a column it reads has to agree with the fragment +that introduces the column. The reader may say less, such as the frame with no +`domain`, and may not say something else: + +```text +fragment 'generator' reads the given variable 'Port_p' as {'dims': ['snapshot', 'generator']}, where 'surface' introduces it as {'dims': ['snapshot', 'port'], 'description': 'what a port puts into its bus'}. A given declaration says the same as the declaration it is folded into, or less: restate the frame as the introducer declares it, or leave the field out. +``` + +Two fragments that both only read a column have to read it the same way, and +a difference is refused as it is for a dimension. + +## A name one fragment both builds and reads + +A fragment reads what another file builds. A fragment that declares a name and +reads it as well is a file `to_spec` refuses on its own. So `merge` refuses it +too, rather than folding the reading away: + +```text +fragment 'generator' declares the variable 'Generator_p' and reads it under 'given: variables:' as well. A given declaration is what one file expects of another, and this fragment builds the name itself: drop the given entry, or move the declaration to the fragment this one reads it from. +``` + +## A base and its patches + +1. **Write the base as a model**, and each patch as the change it makes. A + patch names only the fields it changes. A declaration a patch does not name + stays as the base wrote it. + + ```yaml title="base.yaml" + dimensions: + snapshot: { dtype: int } + generator: { dtype: str } + parameters: + capacity: { dims: [generator] } + cost: { dims: [generator] } + load: { dims: [snapshot] } + variables: + dispatch: { dims: [snapshot, generator], bounds: { lower: 0, upper: capacity } } + constraints: + power_balance: + dims: [snapshot] + expression: sum(dispatch, over=generator) == load + objective: + sense: minimize + expression: sum(dispatch * cost) + ``` + + ```yaml title="operate.yaml" + variables: + dispatch: { where: "capacity > 0" } + ``` + + ```yaml title="carbon.yaml" + parameters: + emission_rate: { dims: [generator] } + constraints: + emission_cap: + dims: [] + expression: sum(dispatch * emission_rate) <= 1000 + ``` + +2. **Lay the patches on the base.** Each patch is given a name, and that name + is what a refusal calls it. The patches must write different fields, so the + order they are given in cannot change the model. + + ```python + import math_spec as ms + + model = ms.override('base.yaml', {'carbon': 'carbon.yaml', 'operate': 'operate.yaml'}) + spec = ms.to_spec(model) + ``` + + `spec` declares `emission_cap` beside `power_balance`, and `dispatch` carries + the mask `capacity > 0`. + +3. **Remove a declaration with `null`.** A patch that does not mention a + declaration leaves it alone, so removal needs a marker of its own. + + ```yaml title="feasibility.yaml" + constraints: + emission_cap: null + objective: null + ``` + + The marker is the declaration itself. Deeper down, `null` is a value the + schema takes: `dispatch: { where: null }` gives that variable no mask, and + leaves the variable in place. Higher up, `constraints: null` is refused, + because a section is not a declaration and nulling it removes nothing. + +4. **Nest the calls where one patch refines another.** The second call lays + its patch on the first call's result, so the order is on the page. + + ```python + model = ms.override(ms.override('base.yaml', {'pathway': 'pathway.yaml'}), {'project': 'project.yaml'}) + ``` + +## What a patch may say + +| The entry | What happens | +| ----------------------------------------------------------- | ----------------------------------------------------------------------------- | +| some fields of a declaration | those fields change, and the rest of the declaration stays | +| a whole declaration under a new name | it is added | +| `null` under a declaration's name | it is removed | +| `null` under a section's name | it is refused | +| a dimension or a relation | it is added, or restated word for word as the base declares it | +| an entry under `given: variables:` or `given: constraints:` | it is edited, added or removed like any declaration, and the other kind stays | +| `version`, `description` | the patch's value replaces the base's | + +## A partial entry on a missing name + +An entry naming some fields has to land on a declaration the base has. A +mistyped name is refused rather than read as a new declaration: + +```text +patch 'project' edits the constraint 'power_balnce', which its base does not declare. Did you mean 'power_balance'? A patch creates a declaration only by writing it whole, and this one is not: a constraint needs `expression`. +``` + +To add a constraint, write the whole constraint. To change one, spell its name +as the base spells it. + +## Two patches on one field + +Two patches writing one field is refused, both named: + +```text +patches 'pathway' and 'project': both write variables.dispatch.bounds.upper. Patches laid on one base are disjoint, so nothing decides which of two writes wins. Write the change in one patch, or lay one patch on the result of the other: override(override(base, {'pathway': …}), {'project': …}). +``` + +## A dimension redeclared + +A patch may add a dimension or a relation, and may restate one the base +declares. The restatement is word for word: half a declaration is a second +reading of the same name. Changing one under the expressions already written +over it is refused, and so is removing one: + +```text +patch 'relabelled' declares the dimension 'snapshot' as {'dtype': 'str'}, where its base declares {'dtype': 'int'}. A patch adjusts the math, not the coordinate space the math is already written over: restate the declaration word for word, leave it out, or give the patch a dimension of its own under a name of its own. +``` + +## A section set to `null` + +A `null` removes the declaration it names. A section holds declarations rather +than being one, so nulling a section is refused rather than read as emptying +it: + +```text +patch 'project' sets 'constraints' to null, which removes nothing: the removal marker names one declaration, and a section is not one. Remove the declarations one at a time, each under its own name, or leave the section out of the patch. +``` + +## A stale removal + +A removal says what the base has, so a removal of a declaration the base does +not have is refused with the near miss: + +```text +patch 'stale' removes the constraint 'power_balnce', which its base does not declare. A removal is a claim about what is there, so a stale one is a patch that no longer describes the model it lands on. Did you mean 'power_balance'? +``` diff --git a/docs/reference/language/declarations.md b/docs/reference/language/declarations.md index f3bb9f71..04869dab 100644 --- a/docs/reference/language/declarations.md +++ b/docs/reference/language/declarations.md @@ -5,8 +5,9 @@ SPDX-License-Identifier: CC-BY-4.0 # Parameters, variables, constraints and the objective -These four blocks carry the math. Each takes an optional `description:`, free -text that the [typeset](../typeset.md#descriptions) legend prints. +These four blocks carry the math, and `given:` names what the math reads from +another file. Each takes an optional `description:`, free text that the +[typeset](../typeset.md#descriptions) legend prints. ## `parameters` @@ -84,6 +85,87 @@ a bound parameter are a subset of the variable's. Equal bounds pin a variable ([fix a quantity](../../howto/pin-a-variable.md)). A pinned variable is still a variable. +## `given` + +`given:` holds what this file reads and does not build: columns under +`variables:`, row families under `constraints:`. It takes those two keys and no +other. A file with a `given:` block loads and prints on its own. + +### `given: variables` + +A given variable is a column this file reads and another file introduces. + +```yaml +dimensions: + snapshot: { dtype: int } + port: { dtype: str } + generator: { dtype: str } +relations: + gen_port: { key: generator, values: port } +variables: + gen_p: { dims: [snapshot, generator], bounds: { lower: 0 } } +given: + variables: + flow: + dims: [snapshot, port] + description: what a port puts into its bus +constraints: + gen_injects: + dims: [snapshot, generator] + expression: at(flow, by=gen_port, over=port, into=generator) == gen_p +``` + +| Field | | | +| ------------- | ------------------------------------------------- | -------------------- | +| `dims` | required. The dimensions the column is indexed by | | +| `domain` | `continuous`, `integer` or `binary` | default `continuous` | +| `description` | free text | default `null` | + +There is no `bounds` and no `where`. The file that introduces the column owns +both. + +An expression reads a given variable as it reads any other. A name declared +under both `variables:` and `given: variables:` is refused. The typeset legend +lists a given variable under _Given_, and prints no domain line for it. + +[`merge`](../../howto/compose.md#a-library-of-components) folds a given +declaration into the declaration of another fragment that introduces the name, +so a composed library carries none of them. The folded declaration is the +introducer's, and what the reader states has to say the same or less. + +Where nothing in this language introduces the column, the program carries the +declaration for a consumer to bind +([what a program does not build](../reading.md#what-a-program-does-not-build)). + +### `given: constraints` + +A given constraint is a row family that another model builds. This file reads +its dual. + +```yaml +dimensions: + snapshot: { dtype: int } + bus: { dtype: str } +given: + constraints: + balance: + dims: [snapshot, bus] + description: the host model clears each bus +expressions: + price: + expression: dual(balance) +``` + +| Field | | | +| ------------- | ------------------------------------------------- | -------------- | +| `dims` | required. The dimensions the row family runs over | | +| `description` | free text | default `null` | + +There is no `expression` and no `sense`. +`dual(name)` is the only place a given row family may be named, and the frame +gives the reported expression its dimensions. A name declared under both +`constraints:` and `given: constraints:` is refused. + ## `constraints` One block is one rule. The name of the block is the name of the constraint. diff --git a/docs/reference/language/errors.md b/docs/reference/language/errors.md index 0ea141d0..fe068674 100644 --- a/docs/reference/language/errors.md +++ b/docs/reference/language/errors.md @@ -34,6 +34,7 @@ loads. | `kind` | The file has… | The advice says… | | --------------- | --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | | `never-an-axis` | a dimension nothing is indexed by, nothing aggregates into and no relation targets | remove it, or keep it knowingly if its declarations are still to come | +| `given` | a column or a row family it reads and does not build ([given](declarations.md#given)) | a consumer binds it to the model this one is layered onto | | `unbounded` | a variable that no constraint uses, whose objective term pushes it towards a bound it does not have | give it a finite bound, or the constraint that was meant to define it | ```text diff --git a/docs/reference/language/file.md b/docs/reference/language/file.md index 2e52c196..b9267dc7 100644 --- a/docs/reference/language/file.md +++ b/docs/reference/language/file.md @@ -14,6 +14,7 @@ A model file is a YAML mapping with **eleven declaration keys**, plus | `relations` | named relations between dimensions ([relations](relations.md)) | | `parameters` | the data the model expects ([declarations](declarations.md)) | | `variables` | what the solver decides | +| `given` | what this file reads but does not build ([given](declarations.md#given)) | | `constraints` | the rules those decisions obey | | `objective` | what is minimised or maximised | | `expressions` | named quantities, reusable in the math and readable after a solve ([named expressions](named.md)) | diff --git a/docs/reference/reading.md b/docs/reference/reading.md index 9080d3b4..ef9837ae 100644 --- a/docs/reference/reading.md +++ b/docs/reference/reading.md @@ -166,6 +166,38 @@ A predicate you build yourself answers the same four questions: wrap it in so a boolean literal stands at a mask's root or nowhere. A `Region`'s `when` arrives as a `Mask` too. The node classes live in `math_spec.program`. +## What a program does not build + +`program.given.variables` and `program.given.constraints` name what the model +reads and does not build ([given](language/declarations.md#given)). Every +other group is a build instruction. These two are names to look up in the +model this one is layered onto. + +```python +layer = to_program( + { + 'dimensions': {'snapshot': {'dtype': 'int'}, 'bus': {'dtype': 'str'}}, + 'given': { + 'variables': {'p': {'dims': ['snapshot', 'bus']}}, + 'constraints': {'balance': {'dims': ['snapshot', 'bus']}}, + }, + 'parameters': {'rate': {'dims': ['bus']}}, + 'constraints': {'cap': {'dims': [], 'expression': 'sum(p * rate) <= 100'}}, + 'expressions': {'price': {'expression': 'dual(balance)'}}, + } +) + +sorted(layer.variables) # [] +sorted(layer.given.variables) # ['p'] +layer.given.constraints['balance'].dims # ('snapshot', 'bus') +``` + +A consumer that builds the program binds each name to a column or a row family +the host model holds. It checks that the frame matches, and refuses what it +cannot bind. A consumer with no host refuses a program whose two groups are not +both empty. `advice` returns one note of kind `given` per name +([what `advice` warns about](language/errors.md#what-advice-warns-about)). + ## Asking what a program uses `program.footprint` says which of the language's constructs one model uses. diff --git a/examples/library/generator.yaml b/examples/library/generator.yaml new file mode 100644 index 00000000..fac446f3 --- /dev/null +++ b/examples/library/generator.yaml @@ -0,0 +1,37 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +description: >- + PyPSA's `Generator`, wired to a port rather than straight to a bus, and cut + to what a dispatch model needs: a fixed build, no availability profile, no + ramp limits. +dimensions: + snapshot: { dtype: datetime, description: dispatch periods } + port: { dtype: str, description: "the connections components make, one label per connection" } + generator: { dtype: str, description: "generating units, each on one port" } +relations: + Generator_port: { key: generator, values: port } +given: + variables: + Port_p: + dims: [snapshot, port] + description: the surface introduces this flow, and this file pins it at its own ports +parameters: + Generator_p_nom: { dims: [generator], description: nominal power } + Generator_marginal_cost: { dims: [generator], description: cost of one unit of output } +variables: + Generator_p: + dims: [snapshot, generator] + bounds: { lower: 0, upper: Generator_p_nom } + description: "`Generator-p` — what a generator produces in a snapshot" +constraints: + Generator_injection: + description: >- + what a generator produces is what its port injects. No PyPSA row stands + for this: PyPSA writes the generator into the balance instead + dims: [snapshot, generator] + expression: at(Port_p, by=Generator_port, over=port, into=generator) == Generator_p +objective: + sense: minimize + expression: sum(Generator_p * Generator_marginal_cost) diff --git a/examples/library/load.yaml b/examples/library/load.yaml new file mode 100644 index 00000000..afec066a --- /dev/null +++ b/examples/library/load.yaml @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +description: PyPSA's `Load`, wired to a port rather than straight to a bus. What it takes is data, so it decides nothing. +dimensions: + snapshot: { dtype: datetime, description: dispatch periods } + port: { dtype: str, description: "the connections components make, one label per connection" } + load: { dtype: str, description: "demands, each on one port" } +relations: + Load_port: { key: load, values: port } +given: + variables: + Port_p: + dims: [snapshot, port] + description: the surface introduces this flow, and this file pins it at its own ports +parameters: + Load_p_set: { dims: [snapshot, load], description: "`Load-p_set` — what a load takes in a snapshot" } +constraints: + Load_withdrawal: + description: >- + what a load takes is what its port withdraws. No PyPSA row stands for + this: PyPSA writes the load into the balance instead + dims: [snapshot, load] + expression: at(Port_p, by=Load_port, over=port, into=load) == -Load_p_set diff --git a/examples/library/surface.yaml b/examples/library/surface.yaml new file mode 100644 index 00000000..199c4d52 --- /dev/null +++ b/examples/library/surface.yaml @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +description: >- + The coupling surface every component in this library is written against: one + flow per port, and one balance per bus. A component is wired to a port, the + port to a bus, and the balance names no component class. A flow is positive + where the port injects into its bus. +dimensions: + snapshot: { dtype: datetime, description: dispatch periods } + bus: { dtype: str, description: network nodes } + port: { dtype: str, description: "the connections components make, one label per connection" } +relations: + Port_bus: { key: port, values: bus } +variables: + Port_p: + dims: [snapshot, port] + description: what a port puts into its bus in a snapshot, negative for a withdrawal +constraints: + Bus_nodal_balance: + description: "`Bus-nodal_balance` — what the ports on a bus put in nets to nothing" + dims: [snapshot, bus] + expression: sum(Port_p, by=Port_bus, over=port, into=bus) == 0 diff --git a/examples/library/variants/commitment.yaml b/examples/library/variants/commitment.yaml new file mode 100644 index 00000000..546e1bec --- /dev/null +++ b/examples/library/variants/commitment.yaml @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +parameters: + Generator_p_min_pu: { dims: [generator], description: "least output, per unit of nominal power" } +variables: + Generator_status: + dims: [snapshot, generator] + domain: binary + description: "`Generator-status` — whether a unit is on in a snapshot" + Generator_p: { bounds: { upper: .inf } } +constraints: + Generator_com_p_upper: + description: "`Generator-com-p-upper` — a committed unit outputs at most its nominal power; off, at most nothing" + dims: [snapshot, generator] + expression: Generator_p <= Generator_p_nom * Generator_status + Generator_com_p_lower: + description: "`Generator-com-p-lower` — a committed unit outputs at least its minimum; off, at least nothing" + dims: [snapshot, generator] + expression: Generator_p >= Generator_p_min_pu * Generator_p_nom * Generator_status diff --git a/examples/symbols/library.yaml b/examples/symbols/library.yaml new file mode 100644 index 00000000..f251797c --- /dev/null +++ b/examples/symbols/library.yaml @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +# How `examples/library/` prints. The names are PyPSA's, so a fragment reads +# beside `n.model`; these symbols are the ones `pypsa.yaml` uses. One table +# serves every fragment, the model they compose and the variants laid over it. +# Each page takes the cut of it that its own model declares, because a table +# naming anything else is refused. +notation: latex +dimensions: + snapshot: { index: t, set: '\mathcal{T}' } + bus: { index: n, set: '\mathcal{N}' } + port: { index: j, set: '\mathcal{J}' } + generator: { index: g, set: '\mathcal{G}' } + load: { index: d, set: '\mathcal{D}' } +names: + Port_p: f + Generator_p: p + Generator_p_nom: '\mathrm{p}^{\mathrm{nom}}' + Generator_marginal_cost: '\mathrm{c}' + Generator_p_min_pu: '\underline{\mathrm{p}}' + Generator_status: u + Load_p_set: '\mathrm{load}' diff --git a/mkdocs.yml b/mkdocs.yml index 5fc61277..2477f082 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -42,6 +42,7 @@ nav: - Fix a quantity that is data in one model and a decision in another: howto/pin-a-variable.md - Write a piecewise curve out by hand: howto/curve-by-hand.md - See what a curve or a set expands to: howto/see-an-expansion.md + - Compose a model from several files: howto/compose.md - Reference: - Language: - reference/language/index.md @@ -70,6 +71,12 @@ nav: - PyPSA, the lossy lines: examples/pypsa_losses.md - PyPSA, the two-stage class: examples/pypsa_stochastic.md - PyPSA, the multi-period class: examples/pypsa_multi_period.md + - A component library: + - examples/library/index.md + - The coupling surface: examples/library/surface.md + - Generators: examples/library/generator.md + - Loads: examples/library/load.md + - The composed model: examples/library/composed.md # Everything a reader does not need in order to write a model: the design # arguments, how to contribute, what changed. - About: diff --git a/pyproject.toml b/pyproject.toml index b5bf42f3..bdb29c5b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -199,12 +199,13 @@ extend-exclude = ["CHANGELOG.md"] [tool.typos.default.extend-words] # Each of these is deliberate, and none is reachable by fixing a spelling. # -# `wher` and `generatr` are misspellings *on purpose*: they are the input to the -# did-you-mean suggester, so correcting them deletes the case. `missable` is the +# `wher`, `generatr` and `balnce` are misspellings *on purpose*: they are the +# input to the did-you-mean suggester, so correcting them deletes the case. `missable` is the # word the sentence wants — a coordinate that can go missing — and is not # "miscible". `unparseable` is the accepted variant and is the name of a test. wher = "wher" generatr = "generatr" +balnce = "balnce" missable = "missable" unparseable = "unparseable" # `NAMEs` in scripts/setup-release-app.sh is a placeholder plus a plural, in a diff --git a/schema/math-spec.schema.json b/schema/math-spec.schema.json index d9c8e533..b985a191 100644 --- a/schema/math-spec.schema.json +++ b/schema/math-spec.schema.json @@ -262,6 +262,100 @@ "title": "ExpressionCase", "type": "object" }, + "GivenBlock": { + "additionalProperties": false, + "description": "What this file reads and does not build, by kind. Closed at the two kinds.", + "properties": { + "constraints": { + "additionalProperties": { + "$ref": "#/$defs/GivenConstraintBlock" + }, + "default": {}, + "title": "Constraints", + "type": "object" + }, + "variables": { + "additionalProperties": { + "$ref": "#/$defs/GivenVariableBlock" + }, + "default": {}, + "title": "Variables", + "type": "object" + } + }, + "title": "GivenBlock", + "type": "object" + }, + "GivenConstraintBlock": { + "additionalProperties": false, + "description": "A row family this file reads the dual of and another model builds.\n\nThe frame says how many duals there are and what indexes them, which is\nwhat ``dual()`` needs. There is no ``expression:``, because nothing here\nbuilds a row.", + "properties": { + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "dims": { + "items": { + "type": "string" + }, + "title": "Dims", + "type": "array" + } + }, + "required": [ + "dims" + ], + "title": "GivenConstraintBlock", + "type": "object" + }, + "GivenVariableBlock": { + "additionalProperties": false, + "description": "A column this file reads and another file introduces.\n\nThe frame and the domain are all this file states. The file that introduces\nthe column owns its bounds and its mask.", + "properties": { + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "dims": { + "items": { + "type": "string" + }, + "title": "Dims", + "type": "array" + }, + "domain": { + "default": "continuous", + "enum": [ + "continuous", + "integer", + "binary" + ], + "title": "Domain", + "type": "string" + } + }, + "required": [ + "dims" + ], + "title": "GivenVariableBlock", + "type": "object" + }, "MacroBlock": { "additionalProperties": false, "description": "A parameterised expression template, defined in the YAML itself.\n\nLanguage, not code: formals (``args`` positional, ``kwargs`` keyword)\nshadow model names inside the template, and every call site expands into\ncore AST before either backend sees the expression.", @@ -716,6 +810,13 @@ "title": "Expressions", "type": "object" }, + "given": { + "$ref": "#/$defs/GivenBlock", + "default": { + "constraints": {}, + "variables": {} + } + }, "macros": { "additionalProperties": { "$ref": "#/$defs/MacroBlock" diff --git a/src/math_spec/__init__.py b/src/math_spec/__init__.py index af4b52d7..a20ec393 100644 --- a/src/math_spec/__init__.py +++ b/src/math_spec/__init__.py @@ -12,6 +12,7 @@ from math_spec import program from math_spec.advice import advice +from math_spec.composition import merge, override from math_spec.errors import ( ADVICE_KINDS, Advice, @@ -74,6 +75,8 @@ 'call_shape_error', 'did_you_mean', 'edge_error', + 'merge', + 'override', 'program', 'schema_error', 'to_latex', diff --git a/src/math_spec/advice.py b/src/math_spec/advice.py index ee3406e5..b14b7d15 100644 --- a/src/math_spec/advice.py +++ b/src/math_spec/advice.py @@ -33,11 +33,31 @@ def advice(model: str | Path | Mapping[str, object] | Spec | Program) -> tuple[A answer alike. Returns: - The never-an-axis advice in declaration order, then the unboundedness - advice; ``str()`` of each is its sentence. + The never-an-axis advice in declaration order, then one note per + declaration the program reads and does not build, then the + unboundedness advice; ``str()`` of each is its sentence. """ program = to_program(model) - return tuple(_never_an_axis(program) + unbounded_notes(program)) + return tuple(_never_an_axis(program) + _given(program) + unbounded_notes(program)) + + +def _given(program: Program) -> list[Advice]: + """One note per declaration the program reads and does not build. + + A note rather than a refusal: the file is a model somebody meant, and only + the consumer can tell whether it holds a host to bind the name to. + """ + return [ + Advice( + 'given', + name, + f"{kind} '{name}' is read here and built elsewhere: a consumer binds it to the model this " + f'one is layered onto, checks the frame, and refuses where it cannot bind it. A fragment is ' + f'composed instead: merge() folds this declaration into the one a sibling introduces.', + ) + for kind, group in (('variable', program.given.variables), ('row family', program.given.constraints)) + for name in group + ] def _never_an_axis(program: Program) -> list[Advice]: @@ -45,10 +65,18 @@ def _never_an_axis(program: Program) -> list[Advice]: A dimension a relation has a column over is reached: its members are the labels that column is checked against, and a ``where`` selects on them, - so it is in use even where nothing is indexed by it. + so it is in use even where nothing is indexed by it. A dimension only a + given declaration indexes is reached too: the column exists, in another + file. """ reached: set[str] = set() - for declaration in (*program.parameters.values(), *program.variables.values(), *program.constraints.values()): + for declaration in ( + *program.parameters.values(), + *program.variables.values(), + *program.constraints.values(), + *program.given.variables.values(), + *program.given.constraints.values(), + ): reached.update(declaration.dims) reached |= _produced_axes(program) reached |= {dim for lk in program.relations.values() for dim in lk.dims} diff --git a/src/math_spec/boundedness.py b/src/math_spec/boundedness.py index 1102abe8..e8206951 100644 --- a/src/math_spec/boundedness.py +++ b/src/math_spec/boundedness.py @@ -78,7 +78,7 @@ def unbounded_notes(program: Program) -> list[Advice]: minimize = program.objective.sense == 'minimize' notes: list[Advice] = [] for vname, sign in signs.items(): - if sign is None or vname in constrained: + if sign is None or vname in constrained or vname in program.given.variables: continue side: BoundSide = 'lower' if minimize == (sign == '+') else 'upper' if _is_open(program.variables[vname], side): diff --git a/src/math_spec/composition.py b/src/math_spec/composition.py new file mode 100644 index 00000000..c59b58e6 --- /dev/null +++ b/src/math_spec/composition.py @@ -0,0 +1,620 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Several files into one model, before any of them is validated. + +Two verbs, and they answer different questions. :func:`merge` composes +**peers**: fragments that each own part of the math, where a name two of them +declare is a collision and the order they are given in means nothing. +:func:`override` lays **patches** over a **base**: what a framework ships and a +project extends, where a name the patch declares is the point. They compose as +``override(merge({...}), {...})``, which builds the model and then configures +the run. + +What :func:`merge` does with each section: + +* **A dimension or a relation every fragment may declare**, and the ones that + do have to say the same thing about it. Prose is not a claim, so two + descriptions of one dimension agree, and the first fragment's is carried. +* **Every other declaration is owned.** A name two fragments declare is refused, + both named. +* **The objectives are summed**, each term in parentheses, in the fragments' + name order, and the senses have to agree. +* **A given declaration is folded** into the declaration that introduces the + name, once the reader is checked to say the same as the introducer or less. + Two fragments that both only read a name have to read it the same way, and a + fragment that declares a name and reads it as well is refused. What no + fragment introduces stays under ``given:`` for a consumer to bind. + +A patch says only what it changes, because declarations are laid over a field +at a time:: + + constraints: + ramp: {dims: [snapshot, generator, investment_period]} + +A patch is not a :class:`~math_spec.model.Spec`. It is read before validation, +so it may carry ``null`` where a declaration would go and may name what only +its base declares. Nothing here resolves a name or checks a dim: the laid +mapping goes through :func:`~math_spec.validation.to_spec` like any other file. + +What a patch may say, and what is refused: + +* **A partial entry edits, and a whole one creates.** An entry that does not + validate as a declaration on its own has to land on one the base declares, + and a miss is refused with the near miss named. +* **Sibling patches are disjoint.** Two patches writing one field is refused, + both named, so the order they are given in never decides a model. Layering + is written out as ``override(override(base, …), …)``. +* **A patch adjusts the math, not the coordinate space.** A ``dimensions`` or + ``relations`` entry may be added or restated word for word, never changed and + never removed. +* **A declaration set to** ``null`` **is removed**, and a removal of what the + base does not declare is refused. The marker is positional: ``constraints: + {ramp: null}`` removes the constraint, where ``variables: {p: {where: null}}`` + sets that variable's mask to none, which is a value the schema takes. A whole + section set to ``null`` is refused, because it removes nothing. +* **``given:`` is laid over one kind at a time**, by the same rules as any + owned section. +""" + +from __future__ import annotations + +from copy import deepcopy +from typing import TYPE_CHECKING, cast, get_args, overload + +from pydantic import BaseModel, ValidationError + +from math_spec._yaml import read_model +from math_spec.errors import LanguageError, did_you_mean, schema_error +from math_spec.model import GivenBlock, Spec + +if TYPE_CHECKING: + from collections.abc import Iterable, Mapping + from pathlib import Path + +#: The declarations that are the coordinate space rather than the math. A patch +#: may add one, and may restate one its base already declares word for word; it +#: may not say something else about it, and it may not remove it. +SHARED_SECTIONS = ('dimensions', 'relations') + +#: The declarations a patch edits, creates or removes. +OWNED_SECTIONS = ('parameters', 'variables', 'constraints', 'expressions', 'macros', 'piecewise', 'sos') + +#: What ``given:`` holds, by the key each kind sits under and what one entry of +#: it is called. The key is the introducing section's name too, which is what +#: lets :func:`merge` fold a given declaration into the one that introduces it. +GIVEN_KINDS = {'variables': 'given variable', 'constraints': 'given constraint'} + +#: Every section keyed by declaration name. ``objective`` is one declaration +#: rather than a mapping of them, and is laid over field by field beside these. +SECTIONS = (*SHARED_SECTIONS, *OWNED_SECTIONS, 'given') + +#: What one entry is called where dropping the key's last letter does not say it. +IRREGULAR = { + 'piecewise': 'piecewise curve', + 'sos': 'special-ordered set', + 'objective': 'objective', +} + + +def merge( + fragments: Mapping[str, str | Path | dict[str, object] | Spec], description: str | None = None +) -> dict[str, object]: + """*fragments* composed as peers, each owning the math it declares. + + Args: + fragments: What each fragment is called, to the fragment: a YAML path, + YAML text, a mapping, or a loaded :class:`~math_spec.model.Spec`. + The name is what an error calls it. The order they are given in + does not reach the result. + description: What the composed model is. A fragment's own + ``description`` is about the fragment, and is not carried. + + Returns: + One mapping, ready for :func:`~math_spec.validation.to_spec`. Nothing + in it has been resolved, name-checked or lowered, and it shares no + object with any fragment. A given declaration a sibling introduces is + folded away; one nothing introduces stays under ``given:``. + + Raises: + LanguageError: Two fragments declare one name; two fragments say + different things about one dimension, relation or given + declaration; a fragment reads a name as something other than what + its sibling introduces; a fragment declares a name and reads it as + well; two fragments pin different language versions; or their + objectives run opposite ways. + FileNotFoundError: A ``str`` with no newline that names no file. + """ + read = {name: deepcopy(_declarations(fragment)) for name, fragment in fragments.items()} + merged: dict[str, object] = {} + if (version := _one_version(read)) is not None: + merged['version'] = version + if description is not None: + merged['description'] = description + for section in SHARED_SECTIONS: + if agreed := _agreed(read, section, _singular(section)): + merged[section] = agreed + for section in OWNED_SECTIONS: + if claimed := _claimed(read, section): + merged[section] = claimed + if given := _folded(read, merged): + merged['given'] = given + if (objective := _summed_objective(read)) is not None: + merged['objective'] = objective + return merged + + +def _mapping(value: object) -> dict[str, object]: + """*value* as a mapping, empty where it is absent or ``None`` — one section or kind of a raw fragment.""" + if not value: + return {} + assert isinstance(value, dict), 'a raw fragment declares a section as a mapping' + return value + + +def _one_version(read: Mapping[str, dict[str, object]]) -> int | None: + """The language version the fragments are written against, or ``None`` where none of them writes one. + + A fragment that writes none is version 0, which is the schema's default, so + a composition of such fragments claims no version rather than writing the + default out as though a file had asked for it. + """ + declared: dict[str, int] = {} + for name, sections in read.items(): + if 'version' in sections: + version = sections['version'] + assert isinstance(version, int), 'a language version is an integer in a raw fragment' + declared[name] = version + if len(set(declared.values())) > 1: + spelled = ', '.join(f"'{name}' says {version}" for name, version in sorted(declared.items())) + raise LanguageError( + f'the fragments are written against different language versions: {spelled}. One model has ' + f'one version, so write the same one in each, or leave it out of the fragments that do not pin it.' + ) + return next(iter(declared.values()), None) + + +def _author_of(read: Mapping[str, dict[str, object]], section: str, key: str) -> str: + """The first fragment declaring *key* under *section*, for a message that names both sides.""" + return next(name for name, sections in read.items() if key in _mapping(sections.get(section))) + + +def _agreed(read: Mapping[str, dict[str, object]], section: str, label: str) -> dict[str, object]: + """One block every fragment may declare, peers that say the same thing folded together. + + Equality of the claims rather than "the same or less": between peers + neither declaration is the one being restated, so a field only one of them + writes is a difference nothing settles. + """ + merged: dict[str, object] = {} + for name, sections in read.items(): + for key, block in _mapping(sections.get(section)).items(): + if key in merged and _claims(merged[key]) != _claims(block): + raise LanguageError( + f"fragments '{_author_of(read, section, key)}' and '{name}' say different things about " + f'the {label} {key!r}: {merged[key]!r} against {block!r}. A declaration two fragments ' + f'share is one both say the same thing about: make the two identical, or give one of ' + f'them a name of its own.' + ) + merged.setdefault(key, block) + return merged + + +@overload +def _claims(block: dict[str, object]) -> dict[str, object]: ... +@overload +def _claims(block: object) -> object: ... +def _claims(block: object) -> object: + """*block* without its prose, which is what the declaration says rather than a remark about it.""" + if isinstance(block, dict): + return {key: value for key, value in block.items() if key != 'description'} + return block + + +def _claimed(read: Mapping[str, dict[str, object]], section: str) -> dict[str, object]: + """One block of owned declarations, a name claimed twice being the refusal.""" + merged: dict[str, object] = {} + for name, sections in read.items(): + for key, block in _mapping(sections.get(section)).items(): + if key in merged: + raise LanguageError( + f"fragments '{_author_of(read, section, key)}' and '{name}' both declare the " + f'{_singular(section)} {key!r}. Two of the same kind of thing are two rows of a dimension ' + f'rather than two fragments: merge the fragment once, and let the data carry both. ' + f'Different math under one spelling is a rename: call one of them something else.' + ) + merged[key] = block + return merged + + +def _folded(read: Mapping[str, dict[str, object]], merged: Mapping[str, object]) -> dict[str, object]: + """The ``given:`` block the composition still carries, once every reading a sibling introduces is spent. + + A given declaration is what a fragment expects of a name a sibling owns. + Where the sibling is in the composition the expectation is checked and + then dropped, so the composed model declares the name once. + """ + asked = {name: _mapping(sections.get('given')) for name, sections in read.items()} + _reads_only_what_it_does_not_build(read, asked) + left: dict[str, object] = {} + for kind, label in GIVEN_KINDS.items(): + cls = _entry_class(GivenBlock, kind) + introduced = _mapping(merged.get(kind)) + agreed = _agreed(asked, kind, label) + for key, block in agreed.items(): + if key in introduced and not _says_less(cls, block, introduced[key]): + raise LanguageError( + f"fragment '{_author_of(asked, kind, key)}' reads the {label} {key!r} as {block!r}, where " + f"'{_author_of(read, kind, key)}' introduces it as {introduced[key]!r}. A given declaration " + f'says the same as the declaration it is folded into, or less: restate the frame as the ' + f'introducer declares it, or leave the field out.' + ) + if remaining := {key: block for key, block in agreed.items() if key not in introduced}: + left[kind] = remaining + return left + + +def _reads_only_what_it_does_not_build( + read: Mapping[str, dict[str, object]], asked: Mapping[str, dict[str, object]] +) -> None: + """Refuse a fragment that declares a name and reads it under ``given:`` too. + + :func:`~math_spec.validation.to_spec` refuses such a file, so folding the + reading away silently would put a fragment that loads nowhere on its own + into a composition that loads. + """ + for name, given in asked.items(): + for kind in GIVEN_KINDS: + built = _mapping(read[name].get(kind)) + for key in _mapping(given.get(kind)): + if key in built: + raise LanguageError( + f"fragment '{name}' declares the {_singular(kind)} {key!r} and reads it under " + f"'given: {kind}:' as well. A given declaration is what one file expects of another, " + f'and this fragment builds the name itself: drop the given entry, or move the ' + f'declaration to the fragment this one reads it from.' + ) + + +def _says_less(cls: type[BaseModel], reader: object, introducer: object) -> bool: + """Whether every claim *reader* makes is one *introducer* makes too, a field left to its default counting as said.""" + fields = cls.model_fields + assert isinstance(reader, dict), 'a given block is a mapping in a raw fragment' + assert isinstance(introducer, dict), 'an introduced block is a mapping in a raw fragment' + claims: dict[str, object] = reader + return all( + introducer.get(key, fields[key].default if key in fields else None) == value + for key, value in _claims(claims).items() + ) + + +def _summed_objective(read: Mapping[str, dict[str, object]]) -> dict[str, object] | None: + """Every fragment's objective summed, each term in parentheses, or ``None`` where none declares one. + + The terms are summed in the fragments' name order, so the order they were + passed in does not reach the expression. The senses have to agree: a sum has + one sense, and negating the odd one out would be this function deciding what + a model means. + """ + declared: dict[str, dict[str, object]] = {} + for name, sections in read.items(): + objective = sections.get('objective') + if objective: + assert isinstance(objective, dict), 'an objective is a mapping in a raw fragment' + declared[name] = objective + if not declared: + return None + senses = {name: objective.get('sense', 'minimize') for name, objective in declared.items()} + if len(set(senses.values())) > 1: + spelled = ', '.join(f"'{name}' {sense}s" for name, sense in sorted(senses.items())) + raise LanguageError( + f'the fragments disagree about which way the objective runs: {spelled}. A composed model has ' + f'one objective and one sense, so write every fragment against the same one: negate the terms ' + f'of the odd one out rather than its sense.' + ) + terms = [objective['expression'] for _, objective in sorted(declared.items())] + joined = terms[0] if len(terms) == 1 else ' + '.join(f'({term})' for term in terms) + return {'sense': next(iter(senses.values())), 'expression': joined} + + +def override( + base: str | Path | dict[str, object] | Spec, + patches: Mapping[str, str | Path | dict[str, object] | Spec], +) -> dict[str, object]: + """*base* with each patch laid over it, and nothing laid over another patch. + + Args: + base: The model being extended: a YAML path, YAML text, a mapping, or a + loaded :class:`~math_spec.model.Spec`. + patches: What each patch is called, to the patch. The name is what an + error calls it. The patches must write disjoint fields, so the + order they are given in cannot change the result. + + Returns: + One mapping, ready for :func:`~math_spec.validation.to_spec`. Nothing + in it has been resolved, name-checked or lowered, and it shares no + object with *base* or any patch. + + Raises: + LanguageError: A patch edits or removes a declaration its base does not + declare; a patch creates one that is not whole; a patch redeclares + or removes a dimension or a relation; a patch sets a whole section + to ``null``; or two patches write one field. + FileNotFoundError: A ``str`` with no newline that names no file. + """ + read = {name: _declarations(patch) for name, patch in patches.items()} + _disjoint(read) + + result = deepcopy(_declarations(base)) + for name, patch in read.items(): + result = _lay_over(result, deepcopy(patch), name) + return result + + +def _declarations(source: str | Path | dict[str, object] | Spec) -> dict[str, object]: + """A fragment, a base or a patch as the mapping it declares, whatever shape it arrived in. + + Deliberately not :func:`~math_spec.validation.to_spec`: a patch carrying a + ``null`` or naming only the field it changes is not a model, and validating + it here would refuse the files this module exists to read. + """ + if isinstance(source, Spec): + return source.to_dict() + if isinstance(source, dict): + return source + return read_model(source) + + +def _singular(section: str) -> str: + """What one entry in *section* is called, ``sos`` and ``piecewise`` not being plurals.""" + return IRREGULAR.get(section, section[:-1]) + + +def _entry_class(owner: type[BaseModel], field: str) -> type[BaseModel]: + """The schema's own class for one entry under *field* of *owner*. + + Read off the annotation rather than listed here, so a section added to the + schema cannot be laid over by a rule that does not know what it is made of. + """ + annotation = owner.model_fields[field].annotation + inner = [arg for arg in get_args(annotation) if arg is not type(None)] + return cast('type[BaseModel]', inner[-1] if inner else annotation) + + +def _whole(cls: type[BaseModel], block: object) -> bool: + """Whether *block* is a declaration on its own, which is what lets a patch create one.""" + try: + cls.model_validate(block) + except ValidationError: + return False + return True + + +def _incomplete(label: str, cls: type[BaseModel], block: object) -> str: + """What *block* is short of, in the schema's own words rather than a second list.""" + fields = cls.model_fields + present = _mapping(block) + missing = sorted(name for name, field in fields.items() if field.is_required() and name not in present) + if missing: + return f'{_a(label)} needs {_and_list(missing)}' + try: + cls.model_validate(block) + except ValidationError as e: + return str(schema_error(e)) + raise AssertionError(f'{_a(label)} asked what it is short of is whole: {block!r}') + + +def _a(noun: str) -> str: + """*noun* under the article that reads: an objective, a constraint.""" + return f'an {noun}' if noun[0] in 'aeiou' else f'a {noun}' + + +def _and_list(names: Iterable[str]) -> str: + """``a``, ``a and b``, ``a, b and c``: the field names a message ends on.""" + spelled = [f'`{name}`' for name in names] + if len(spelled) == 1: + return spelled[0] + return f'{", ".join(spelled[:-1])} and {spelled[-1]}' + + +def _writes(patch: Mapping[str, object]) -> list[tuple[str, ...]]: + """Every field *patch* writes, as a path. + + A removal is the declaration's own path, so it overlaps every edit inside + that declaration: removing and editing one declaration is two patches + disagreeing, whichever order they would have been laid in. + """ + paths: list[tuple[str, ...]] = [] + for key, value in patch.items(): + if key in SECTIONS: + for name, block in _mapping(value).items(): + paths.extend(_leaves((key, name), block)) + elif key == 'objective': + paths.extend(_leaves(('objective',), value)) + else: + paths.append((key,)) + return paths + + +def _leaves(prefix: tuple[str, ...], value: object) -> list[tuple[str, ...]]: + """The paths *value* writes under *prefix*, a mapping being walked into and anything else a leaf.""" + if isinstance(value, dict) and value: + mapping: dict[str, object] = value + return [leaf for key, inner in mapping.items() for leaf in _leaves((*prefix, key), inner)] + return [prefix] + + +def _disjoint(read: Mapping[str, dict[str, object]]) -> None: + """Refuse two patches that write one field, which is the only way order could matter.""" + claimed: dict[tuple[str, ...], str] = {} + for name, patch in read.items(): + for path in _writes(patch): + for other, owner in claimed.items(): + if path[: len(other)] == other or other[: len(path)] == path: + raise LanguageError(_overlap_message(owner, other, name, path)) + claimed[path] = name + + +def _overlap_message(owner: str, claimed: tuple[str, ...], name: str, path: tuple[str, ...]) -> str: + """The refusal for two patches writing one field, naming both and the rewrite.""" + where = f"'{owner}' writes {'.'.join(claimed)} and '{name}' writes {'.'.join(path)}" + if claimed == path: + where = f'both write {".".join(path)}' + return ( + f"patches '{owner}' and '{name}': {where}. Patches laid on one base are disjoint, so nothing " + f'decides which of two writes wins. Write the change in one patch, or lay one patch on the ' + f"result of the other: override(override(base, {{'{owner}': …}}), {{'{name}': …}})." + ) + + +def _lay_over(base: dict[str, object], patch: dict[str, object], name: str) -> dict[str, object]: + """One patch over one base, a section at a time, the base left as it was.""" + laid = dict(base) + for key, value in patch.items(): + if key == 'given': + laid[key] = _given(_mapping(laid.get(key)), _section(value, key, name), name) + elif key in SHARED_SECTIONS: + laid[key] = _shared(_mapping(laid.get(key)), _section(value, key, name), key, name) + elif key in OWNED_SECTIONS: + block = _section(value, key, name) + laid[key] = _owned(_mapping(laid.get(key)), block, _singular(key), _entry_class(Spec, key), name) + elif key == 'objective': + laid = _objective(laid, value, name) + else: + laid[key] = value + return laid + + +def _section(value: object, where: str, name: str) -> dict[str, object]: + """The block a patch writes under one section, a ``null`` section being refused rather than read as empty. + + A section is not a declaration, so the removal marker does not reach it. An + empty mapping laid over a base says nothing either, and this is the spelling + a writer reaches for when they mean to empty the section. + """ + if value is None: + raise LanguageError( + f"patch '{name}' sets '{where}' to null, which removes nothing: the removal marker names one " + f'declaration, and a section is not one. Remove the declarations one at a time, each under its ' + f'own name, or leave the section out of the patch.' + ) + assert isinstance(value, dict), f'{where}: is a mapping in a raw patch' + return value + + +def _given(declared: dict[str, object], patch: dict[str, object], name: str) -> dict[str, object]: + """The ``given:`` block, one kind laid over at a time, so naming the columns keeps the row families. + + A kind the block does not have is carried as written, and the closed + schema refuses it at load. + """ + out = dict(declared) + for kind, block in patch.items(): + if kind in GIVEN_KINDS: + cls = _entry_class(GivenBlock, kind) + entries = _section(block, f'given: {kind}:', name) + out[kind] = _owned(_mapping(out.get(kind)), entries, GIVEN_KINDS[kind], cls, name) + else: + out[kind] = block + return out + + +def _shared(declared: dict[str, object], patch: dict[str, object], section: str, name: str) -> dict[str, object]: + """One ``dimensions`` or ``relations`` block: a patch adds one or restates one, never changes or drops it. + + The restatement is compared for equality rather than field by field: a + patch that names half a declaration is as much a second reading of the + coordinate space as one that names another value. + """ + out = dict(declared) + singular = _singular(section) + for key, block in patch.items(): + if block is None: + raise LanguageError( + f"patch '{name}' removes the {singular} '{key}'. The coordinate space is what the math is " + f'written over, and a patch adjusts the math rather than the space: leave the {singular} out ' + f'of the patch, and remove the declarations written over it one at a time.' + ) + if key not in out: + out[key] = block + elif out[key] != block: + raise LanguageError( + f"patch '{name}' declares the {singular} '{key}' as {block!r}, where its base " + f'declares {out[key]!r}. A patch adjusts the math, not the coordinate space the math is ' + f'already written over: restate the declaration word for word, leave it out, or give the ' + f'patch {_a(singular)} of its own under a name of its own.' + ) + return out + + +def _owned( + declared: dict[str, object], patch: dict[str, object], label: str, cls: type[BaseModel], name: str +) -> dict[str, object]: + """One section of the math, each entry editing what is there or creating what is whole.""" + out = dict(declared) + for key, block in patch.items(): + if block is None: + _removed(out, key, label, name) + elif key in out: + out[key] = _field_by_field(out[key], block) + elif _whole(cls, block): + out[key] = block + else: + raise LanguageError( + f"patch '{name}' edits the {label} '{key}', which its base does not declare. " + f'{did_you_mean(key, list(out))} A patch creates a declaration only by writing it whole, ' + f'and this one is not: {_incomplete(label, cls, block)}.' + ) + return out + + +def _removed(out: dict[str, object], key: str, label: str, name: str) -> None: + """Delete what the patch nulled, refusing a removal its base cannot satisfy.""" + if key not in out: + raise LanguageError( + f"patch '{name}' removes the {label} '{key}', which its base does not declare. " + f'A removal is a claim about what is there, so a stale one is a patch that no longer describes ' + f'the model it lands on. ' + did_you_mean(key, list(out)) + ) + del out[key] + + +def _objective(laid: dict[str, object], patch: object, name: str) -> dict[str, object]: + """The one declaration that is not keyed by a name, laid over by the same three rules.""" + out = dict(laid) + standing = out.get('objective') + cls = _entry_class(Spec, 'objective') + if patch is None: + if standing is None: + raise LanguageError( + f"patch '{name}' removes the objective, which its base does not declare. A removal is a " + f'claim about what is there, and a model with no objective is already the feasibility ' + f'problem this patch is asking for.' + ) + del out['objective'] + elif standing is not None: + out['objective'] = _field_by_field(standing, patch) + elif _whole(cls, patch): + out['objective'] = patch + else: + raise LanguageError( + f"patch '{name}' edits the objective, which its base does not declare. A patch creates the " + f'objective only by writing it whole, and this one is not: {_incomplete("objective", cls, patch)}.' + ) + return out + + +def _field_by_field(under: object, over: object) -> object: + """*over* laid on *under*: mappings merge, everything else replaces. + + ``None`` replaces here rather than removing. Removal is the + declaration-level marker and reaches no deeper, so ``where: null`` is the + mask the schema already lets a file write. + """ + if isinstance(under, dict) and isinstance(over, dict): + merged: dict[str, object] = dict(under) + for key, value in over.items(): + merged[key] = _field_by_field(merged.get(key), value) + return merged + return over diff --git a/src/math_spec/dimensions.py b/src/math_spec/dimensions.py index 5a5f5cbd..12490869 100644 --- a/src/math_spec/dimensions.py +++ b/src/math_spec/dimensions.py @@ -97,14 +97,14 @@ def _dims( return frozenset(schema.parameters[node.name].dims) if isinstance(node, VariableNode): - return frozenset(schema.variables[node.name].dims) + return frozenset({**schema.variables, **schema.given.variables}[node.name].dims) if isinstance(node, UnresolvedNode | KwargNode): msg = f'{type(node).__name__} reached the dim checker; resolve the expression first.' raise AssertionError(msg) if isinstance(node, DualNode): - return frozenset(schema.constraints[node.constraint].dims) + return frozenset({**schema.constraints, **schema.given.constraints}[node.constraint].dims) if isinstance(node, FunctionCallNode): return _dims_call(node, schema, context) diff --git a/src/math_spec/errors.py b/src/math_spec/errors.py index 2c7ad7ad..7b8ca6db 100644 --- a/src/math_spec/errors.py +++ b/src/math_spec/errors.py @@ -18,7 +18,7 @@ #: Which pass an :class:`Advice` comes from. Closed, like the operator set: a #: consumer filtering on it can enumerate every value. -AdviceKind = Literal['never-an-axis', 'unbounded'] +AdviceKind = Literal['never-an-axis', 'given', 'unbounded'] ADVICE_KINDS = frozenset(get_args(AdviceKind)) diff --git a/src/math_spec/lowering.py b/src/math_spec/lowering.py index 107bfd45..63167a06 100644 --- a/src/math_spec/lowering.py +++ b/src/math_spec/lowering.py @@ -156,6 +156,10 @@ def lower_program(expanded: Spec) -> program.Program: expressions[name] = program.ExpressionDeclaration( _Lowering(expanded, f"named expression '{name}'").expr(ast), in_math=name in resolved.read_by_the_math ) + given = program.GivenTargets( + variables={name: program.GivenDeclaration(tuple(g.dims)) for name, g in expanded.given.variables.items()}, + constraints={name: program.GivenDeclaration(tuple(g.dims)) for name, g in expanded.given.constraints.items()}, + ) return program.Program( parameters=parameters, variables=variables, @@ -167,6 +171,7 @@ def lower_program(expanded: Spec) -> program.Program: piecewise={name: declaration_of(pw) for name, pw in expanded._expanded_piecewise.items()}, assumptions=_assumptions(expanded), expressions=expressions, + given=given, ) diff --git a/src/math_spec/model.py b/src/math_spec/model.py index bfc28102..63472e44 100644 --- a/src/math_spec/model.py +++ b/src/math_spec/model.py @@ -318,6 +318,49 @@ def _absence_needs_a_mask(self) -> VariableBlock: return self +class GivenVariableBlock(_StrictBlock): + """A column this file reads and another file introduces. + + The frame and the domain are all this file states. The file that introduces + the column owns its bounds and its mask. + """ + + _label: ClassVar[str] = 'a given variable declaration' + + dims: list[str] + domain: VariableDomain = 'continuous' + description: str | None = None + + +class GivenConstraintBlock(_StrictBlock): + """A row family this file reads the dual of and another model builds. + + The frame says how many duals there are and what indexes them, which is + what ``dual()`` needs. There is no ``expression:``, because nothing here + builds a row. + """ + + _label: ClassVar[str] = 'a given constraint declaration' + + dims: list[str] + description: str | None = None + + +class GivenBlock(_StrictBlock): + """What this file reads and does not build, by kind. Closed at the two kinds.""" + + _label: ClassVar[str] = 'a given block' + + #: Columns another file introduces (:class:`GivenVariableBlock`). + variables: dict[str, GivenVariableBlock] = {} + #: Row families another model builds (:class:`GivenConstraintBlock`). + constraints: dict[str, GivenConstraintBlock] = {} + + def __bool__(self) -> bool: + """Whether the file reads anything it does not build.""" + return bool(self.variables or self.constraints) + + class ConstraintBlock(_StrictBlock): """A declared constraint: one rule, over one frame.""" @@ -778,6 +821,10 @@ class is pydantic's, not a contract this package keeps. relations: dict[str, RelationBlock] = {} parameters: dict[str, ParameterBlock] = {} variables: dict[str, VariableBlock] = {} + #: What this file reads and does not build (:class:`GivenBlock`): columns + #: under ``variables:``, row families under ``constraints:``. Empty in a + #: file that stands alone. + given: GivenBlock = GivenBlock() constraints: dict[str, ConstraintBlock] = {} objective: ObjectiveBlock | None = None expressions: dict[str, ExpressionBlock] = {} @@ -916,13 +963,16 @@ def _names_are_names(self) -> Spec: Read off the model's own mappings rather than a list of sections, so a section added later cannot be forgotten here — every mapping a Spec - carries is keyed by a declaration name. + carries is keyed by a declaration name. ``given:`` nests its two + mappings one level down, so they are read off :class:`GivenBlock` the + same way. """ + sections = [*self, *((f'given: {kind}', group) for kind, group in self.given)] errors = [ f'{section}: {name!r} is not a name. A declaration is named the way an expression ' f'writes it — a letter or an underscore, then letters, digits or underscores — so ' f'nothing can refer to this one. Rename it.' - for section, value in self + for section, value in sections if isinstance(value, dict) for name in value if not re.fullmatch(NAME, name) @@ -943,11 +993,25 @@ def _validate_references(self) -> Spec: *self._sos_shapes(), *self._sos_bounds(), *self._sos_emitted_names(), + *self._given_constraint_collisions(), ] if errors: raise ValueError('\n'.join(errors)) return self + def _given_constraint_collisions(self) -> Iterator[str]: + """A row family is either built here or given, never both. + + Constraint names sit outside the flat namespace :meth:`_name_collisions` + walks, so this is the one place the two constraint sections meet. + """ + for name in self.given.constraints: + if name in self.constraints: + yield ( + f"Given constraint '{name}' is also declared under 'constraints:'. A row family is " + f'either built by this file or given to it — drop one of the two.' + ) + def _name_collisions(self) -> Iterator[str]: """A name is declared once, and never as a built-in operator.""" kinds: list[tuple[str, Iterable[str]]] = [ @@ -955,6 +1019,7 @@ def _name_collisions(self) -> Iterator[str]: ('relation', self.relations), ('parameter', self.parameters), ('variable', self.variables), + ('given variable', self.given.variables), ('named expression', self.expressions), ('macro', self.macros), ] @@ -980,6 +1045,8 @@ def _frame_dimensions(self) -> Iterator[str]: frames = [ *(('Parameter', name, p.dims) for name, p in self.parameters.items()), *(('Variable', name, v.dims) for name, v in self.variables.items()), + *(('Given variable', name, g.dims) for name, g in self.given.variables.items()), + *(('Given constraint', name, g.dims) for name, g in self.given.constraints.items()), *(('Constraint', name, c.dims) for name, c in self.constraints.items()), *(('Named expression', name, e.dims or []) for name, e in self.expressions.items()), ] diff --git a/src/math_spec/program.py b/src/math_spec/program.py index f33a74d1..27e10033 100644 --- a/src/math_spec/program.py +++ b/src/math_spec/program.py @@ -63,6 +63,8 @@ 'ExpressionDeclaration', 'FanIn', 'Footprint', + 'GivenDeclaration', + 'GivenTargets', 'GroupSum', 'Holds', 'Mask', @@ -623,6 +625,40 @@ class VariableDeclaration: absence: VariableAbsence = 'undefined' +@dataclass(frozen=True) +class GivenDeclaration: + """A column or a row family this program reads and does not build. + + The frame is the whole declaration. A consumer looks the name up in the + model this one is layered onto, checks the frame against what it finds, + and refuses what it cannot bind. + """ + + dims: tuple[str, ...] + + +@dataclass(frozen=True) +class GivenTargets: + """What a program reads and does not build, by kind. + + Both groups are empty in a program built from one whole model. Both are + sealed at construction, like every group of :class:`Program`. + """ + + #: Columns to bind, by name. + variables: Mapping[str, GivenDeclaration] = Sealed({}) + #: Row families to bind, by name, read back after the solve. + constraints: Mapping[str, GivenDeclaration] = Sealed({}) + + def __post_init__(self) -> None: + for f in fields(self): + object.__setattr__(self, f.name, Sealed(getattr(self, f.name))) + + def __bool__(self) -> bool: + """Whether the program reads anything it does not build.""" + return bool(self.variables or self.constraints) + + @dataclass(frozen=True) class ConstraintDeclaration: """``lhs sense rhs`` for each coord combination of ``dims``. @@ -849,6 +885,10 @@ class Program: #: named expression is outside the language is refused by every verb that #: reads the file rather than only by the one that reads the expression. expressions: Mapping[str, ExpressionDeclaration] = Sealed({}) + #: What this program reads and does not build (:class:`GivenTargets`). A + #: consumer binds each name to what the model it is layered onto holds; + #: nothing here emits a column or a row. + given: GivenTargets = GivenTargets() def __post_init__(self) -> None: """Seal every group, so a program handed out cannot be written to.""" diff --git a/src/math_spec/resolution.py b/src/math_spec/resolution.py index c9e883c3..fd4b84c3 100644 --- a/src/math_spec/resolution.py +++ b/src/math_spec/resolution.py @@ -117,13 +117,14 @@ def __init__(self, schema: Spec) -> None: #: dim-checked against, since macros, named expressions and the dim #: rules read declarations the flat listing below does not carry. self.schema = schema - self.variables = frozenset(schema.variables) + variables = {**schema.variables, **schema.given.variables} + self.variables = frozenset(variables) self.parameters = frozenset(schema.parameters) self.dimensions = frozenset(schema.dimensions) #: The declared constraint names, off the flat namespace: a bare name #: never reaches them, so a model may name a constraint after a variable. #: Consulted only in ``dual()``'s argument position. - self.constraints = frozenset(schema.constraints) + self.constraints = frozenset({**schema.constraints, **schema.given.constraints}) #: name -> declared dtype, for dimensions, parameters and relations alike; #: what a where comparison checks its literal against. self.dtypes: dict[str, DeclaredDtype] = { @@ -139,7 +140,7 @@ def __init__(self, schema: Spec) -> None: #: each leaf a where names, the way a relation leaf carries ``over``. self.leaf_dims: dict[str, tuple[str, ...]] = { **{p: tuple(pd.dims) for p, pd in schema.parameters.items()}, - **{v: tuple(vd.dims) for v, vd in schema.variables.items()}, + **{v: tuple(vd.dims) for v, vd in variables.items()}, } def kind(self, name: str) -> DeclarationKind | None: @@ -181,7 +182,8 @@ def unknown_constraint(self, name: str, context: str, *, formals: Iterable[str] return ( f"{context}: dual({name}): '{name}' is not a declared constraint{also}.\n" f' Constraints: {sorted(self.constraints)}\n' - f"Check for typos, or declare '{name}' under 'constraints:'." + f"Check for typos, or declare '{name}': under 'constraints:' if this file builds the row, " + f"or under 'given: constraints:' if it reads the dual of a row another model builds." ) diff --git a/src/math_spec/typesetting/__init__.py b/src/math_spec/typesetting/__init__.py index ab8c6e5b..501f69b6 100644 --- a/src/math_spec/typesetting/__init__.py +++ b/src/math_spec/typesetting/__init__.py @@ -195,7 +195,8 @@ def typeset_declaration( ValueError: *fmt* names no format. LanguageError: A model that does not compile; it does not print. 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 + constraint may share a variable's name — or under ``given:``, which + prints in the legend rather than as a line; or a symbol table entry names nothing in the model. """ walk = _walk(model, fmt, symbols, inline_expressions=inline_expressions) @@ -209,6 +210,15 @@ def typeset_declaration( } found = [kind for kind, group in kinds.items() if name in group] if not found: + givens = {'variable': schema.given.variables, 'constraint': schema.given.constraints} + given_kind = next((kind for kind, group in givens.items() if name in group), None) + if given_kind is not None: + msg = ( + f"'{name}' is a given {given_kind}, and a given declaration prints no line of its own — " + f"this file reads it and does not build it. It prints in the legend, under 'Given', " + f'so call typeset() for the whole model.' + ) + raise SchemaError(msg) everything = {n for group in kinds.values() for n in group} msg = ( f"'{name}' is not a named expression, constraint, assumption, curve or variable. " diff --git a/src/math_spec/typesetting/symbols.py b/src/math_spec/typesetting/symbols.py index 2d58fe30..490c3494 100644 --- a/src/math_spec/typesetting/symbols.py +++ b/src/math_spec/typesetting/symbols.py @@ -43,22 +43,22 @@ ) # fmt: skip -def _word(name: str, fmt: Format, *, given: bool) -> str: - r"""One name as one symbol: upright where *given*, italic where chosen. +def _word(name: str, fmt: Format, *, upright: bool) -> str: + r"""One name as one symbol: *upright* where the data supplies it, italic where chosen. A Greek name is set as the letter only where chosen. Upright lower-case Greek needs ``upgreek``, which the two-package preamble and GitHub's - MathJax both lack, so a given ``eta`` prints as ``\mathrm{eta}``; a table + MathJax both lack, so an upright ``eta`` prints as ``\mathrm{eta}``; a table entry is how an author who loads ``upgreek`` writes ``\upeta``. """ - if given: + if upright: return fmt.upright(name) if name in _GREEK: return fmt.greek(name) return name if len(name) == 1 else fmt.italic(name) -def _derive_name_symbol(name: str, declared: frozenset[str], fmt: Format, *, given: bool = False) -> str: +def _derive_name_symbol(name: str, declared: frozenset[str], fmt: Format, *, upright: bool = False) -> str: r"""``p`` → ``p``; ``load`` → ``\mathit{load}``; ``p_max`` → ``p^{\mathrm{max}}``. An underscore is a qualifier, landing in the superscript, only where its @@ -68,8 +68,8 @@ def _derive_name_symbol(name: str, declared: frozenset[str], fmt: Format, *, giv """ head, _, tail = name.partition('_') if tail and (len(head) == 1 or head in _GREEK or head in declared): - return fmt.superscript(_word(head, fmt, given=given), fmt.upright(tail.replace('_', ','))) - return _word(name, fmt, given=given) + return fmt.superscript(_word(head, fmt, upright=upright), fmt.upright(tail.replace('_', ','))) + return _word(name, fmt, upright=upright) def chosen_expressions(schema: Spec) -> frozenset[str]: @@ -108,8 +108,8 @@ def __init__(self, schema: Spec, fmt: Format, table: SymbolTable) -> None: f'and nothing translates between notations — write a {fmt.notation} table.' ) raise SchemaError(msg) - chosen = frozenset(schema.variables) | chosen_expressions(schema) - names = (*schema.parameters, *schema.variables, *schema.expressions) + chosen = frozenset(schema.variables) | frozenset(schema.given.variables) | chosen_expressions(schema) + names = (*schema.parameters, *schema.variables, *schema.given.variables, *schema.expressions) declared = frozenset(names) #: Names the table spelled; the convention note quotes only derived symbols. @@ -117,7 +117,7 @@ def __init__(self, schema: Spec, fmt: Format, table: SymbolTable) -> None: self.name: dict[str, str] = { name: table.names[name] if name in table.names - else _derive_name_symbol(name, declared, fmt, given=name not in chosen) + else _derive_name_symbol(name, declared, fmt, upright=name not in chosen) for name in names } spoken_for = {s for s in self.name.values() if len(s) == 1} @@ -128,8 +128,8 @@ def __init__(self, schema: Spec, fmt: Format, table: SymbolTable) -> None: #: an entry in :attr:`name`. Given structure, so upright unless a table #: overrides it. self.constraint: dict[str, str] = { - name: table.names[name] if name in table.names else _derive_name_symbol(name, declared, fmt, given=True) - for name in schema.constraints + name: table.names[name] if name in table.names else _derive_name_symbol(name, declared, fmt, upright=True) + for name in (*schema.constraints, *schema.given.constraints) } self.index: dict[str, str] = {} @@ -259,7 +259,14 @@ def checked_against(self, schema: Spec) -> SymbolTable: def _declared(schema: Spec) -> set[str]: """Every name *schema* declares that a table entry may spell.""" - return set(schema.parameters) | set(schema.variables) | set(schema.expressions) | set(schema.constraints) + return ( + set(schema.parameters) + | set(schema.variables) + | set(schema.given.variables) + | set(schema.expressions) + | set(schema.constraints) + | set(schema.given.constraints) + ) def _section(raw: Mapping[str, object], name: str) -> Mapping[str, object]: diff --git a/src/math_spec/typesetting/walk.py b/src/math_spec/typesetting/walk.py index 36190bc4..bf1d0249 100644 --- a/src/math_spec/typesetting/walk.py +++ b/src/math_spec/typesetting/walk.py @@ -364,7 +364,8 @@ def _arithmetic(self, node: ArithmeticNode, ctx: _Context) -> tuple[str, int]: return ctx.indexed(self.symbols.name[node.name], list(self.schema.parameters[node.name].dims)), _ATOM if isinstance(node, VariableNode): - return ctx.indexed(self.symbols.name[node.name], list(self.schema.variables[node.name].dims)), _ATOM + frames = {**self.schema.variables, **self.schema.given.variables} + return ctx.indexed(self.symbols.name[node.name], list(frames[node.name].dims)), _ATOM if isinstance(node, UnaryOperatorNode): if node.op == '+': @@ -1030,6 +1031,20 @@ def glossaries(self, noticed: Noticed) -> list[Glossary]: self._entry(self.symbols.name[v], f'{fmt.mono(v)}{self._over(list(block.dims))}', block.description) for v, block in self.schema.variables.items() ] + given = [ + *( + self._entry(self.symbols.name[g], f'{fmt.mono(g)}{self._over(list(block.dims))}', block.description) + for g, block in self.schema.given.variables.items() + ), + *( + self._entry( + self.symbols.constraint[g], + f'{fmt.mono(g)}{self._over(list(block.dims))}, a row family this file reads the dual of', + block.description, + ) + for g, block in self.schema.given.constraints.items() + ), + ] definitions = [ self._entry(self.symbols.name[e], f'{fmt.mono(e)}{self._over(self.frames[e])}', block.description) for e, block in self.schema.expressions.items() @@ -1039,6 +1054,7 @@ def glossaries(self, noticed: Noticed) -> list[Glossary]: Glossary('Sets', sets), Glossary('Parameters', parameters), Glossary('Variables', variables), + Glossary('Given', given), Glossary('Definitions', definitions), ) return [group for group in groups if group.entries] diff --git a/tests/fixtures.py b/tests/fixtures.py index 95831166..e1bcbc2d 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -28,7 +28,7 @@ OPERATOR_PROBES = sorted((EXAMPLES / 'operators').glob('*.yaml')) #: The shape of ``examples/dispatch.yaml`` as a dict a test can vary with -#: :func:`override`: no ``where:``, the constraint named ``balance``, and short +#: :func:`varied`: no ``where:``, the constraint named ``balance``, and short #: names, so a test that prints it asserts on the math rather than on the #: example's own vocabulary. DISPATCH_MODEL: dict[str, Any] = { @@ -60,10 +60,10 @@ } -def override(base: dict[str, Any], **patch: Any) -> dict[str, Any]: +def varied(base: dict[str, Any], **patch: Any) -> dict[str, Any]: """A deep copy of ``base`` with dotted paths replaced, missing parents created. - ``override(DISPATCH_MODEL, **{'variables.p.where': 'p_max > 0'})``. + ``varied(DISPATCH_MODEL, **{'variables.p.where': 'p_max > 0'})``. """ raw = copy.deepcopy(base) for dotted, value in patch.items(): @@ -76,12 +76,12 @@ def override(base: dict[str, Any], **patch: Any) -> dict[str, Any]: def schema_of(source: str | Path | dict[str, Any], **patch: Any) -> Spec: - """A ``Spec`` from a YAML path, YAML text, or a raw dict, ``**patch`` applied by :func:`override`. + """A ``Spec`` from a YAML path, YAML text, or a raw dict, ``**patch`` applied by :func:`varied`. ``Path`` means a file, ``str`` means the YAML itself. """ raw = raw_of(source) - return to_spec(override(raw, **patch) if patch else raw) + return to_spec(varied(raw, **patch) if patch else raw) def raw_of(source: str | Path | dict[str, Any]) -> dict[str, Any]: diff --git a/tests/test_advice.py b/tests/test_advice.py index 37232fd1..2105c5ef 100644 --- a/tests/test_advice.py +++ b/tests/test_advice.py @@ -17,20 +17,20 @@ import pytest from math_spec import ADVICE_KINDS, advice, to_program, to_spec -from tests.fixtures import SMALL_MODEL, override +from tests.fixtures import SMALL_MODEL, varied if TYPE_CHECKING: from pathlib import Path #: ``h`` is the target of ``lk`` and nothing else reaches it; ``g`` is an axis. -TARGET_ONLY = override( +TARGET_ONLY = varied( SMALL_MODEL, variables={'p': {'dims': ['g']}}, objective={'sense': 'minimize', 'expression': 'sum(p * c)'}, ) #: The same with the relation gone, so nothing reaches ``h`` at all. -UNREACHED = override(TARGET_ONLY, relations={}) +UNREACHED = varied(TARGET_ONLY, relations={}) def test_a_dimension_nothing_reaches_is_named(): @@ -51,14 +51,14 @@ def test_a_dimension_nothing_reaches_is_named(): ], ) def test_a_dimension_something_reaches_is_in_use(patch): - assert not advice(override(TARGET_ONLY, **patch)), ( + assert not advice(varied(TARGET_ONLY, **patch)), ( 'a dimension a relation targets, a declaration indexes or a grouping lands on is in use' ) #: A model with one note of each kind: nothing reaches `h`, and `p` is driven #: down by the objective with an open lower bound and no constraint on it. -BOTH_KINDS = override(UNREACHED, **{'objective.expression': 'sum(p)', 'variables.p.bounds': {'lower': -float('inf')}}) +BOTH_KINDS = varied(UNREACHED, **{'objective.expression': 'sum(p)', 'variables.p.bounds': {'lower': -float('inf')}}) def test_both_kinds_of_note_come_through_the_one_door(): @@ -66,7 +66,29 @@ def test_both_kinds_of_note_come_through_the_one_door(): assert [(n.kind, n.subject) for n in notes] == [('never-an-axis', 'h'), ('unbounded', 'p')], ( 'the never-an-axis advice comes first, then the unboundedness advice' ) - assert {n.kind for n in notes} == ADVICE_KINDS, 'every kind a consumer can pin against is one this file produces' + + +#: A model whose only note is the third kind: `flow` is a column this file +#: reads and whatever it is layered onto builds. `p` is bounded on both sides +#: and every dimension is indexed, so neither other pass has anything to say. +READS_A_COLUMN = { + 'dimensions': {'g': {'dtype': 'str'}}, + 'given': {'variables': {'flow': {'dims': ['g']}}}, + 'variables': {'p': {'dims': ['g'], 'bounds': {'lower': 0, 'upper': 1}}}, + 'constraints': {'tie': {'dims': ['g'], 'expression': 'p == flow'}}, +} + + +def test_a_column_read_and_not_built_is_advised(): + (note,) = advice(READS_A_COLUMN) + assert (note.kind, note.subject) == ('given', 'flow') + assert 'binds it to the model' in str(note), 'the note says whose job the column is' + assert 'merge()' in str(note), 'and names the verb that folds the reading away where a sibling builds it' + + +def test_every_kind_a_consumer_can_pin_against_is_produced_here(): + kinds = {note.kind for note in (*advice(BOTH_KINDS), *advice(READS_A_COLUMN))} + assert kinds == ADVICE_KINDS, 'every kind a consumer can pin against is one these fixtures produce' def _written(model: dict, tmp_path: Path) -> Path: diff --git a/tests/test_boundedness.py b/tests/test_boundedness.py index addea840..dd8ab458 100644 --- a/tests/test_boundedness.py +++ b/tests/test_boundedness.py @@ -15,9 +15,9 @@ from math_spec.boundedness import unbounded_notes from math_spec.lowering import to_program from math_spec.operators import BUILTIN_NAMES -from tests.fixtures import SMALL_MODEL, override, schema_of +from tests.fixtures import SMALL_MODEL, schema_of, varied -BASE = override( +BASE = varied( SMALL_MODEL, variables={'v': {'dims': ['g']}, 'w': {'dims': ['g']}}, objective={'sense': 'minimize', 'expression': 'sum(v, over=g)'}, diff --git a/tests/test_composition.py b/tests/test_composition.py new file mode 100644 index 00000000..f13d8f41 --- /dev/null +++ b/tests/test_composition.py @@ -0,0 +1,487 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""Two verbs, and what each one refuses. + +`merge` composes peers, so a name two fragments declare is a collision and the +order they are given in means nothing. `override` lays a base and its patches, +so a name the patch declares is the point, and what is pinned for it is the +opposite: every collision the caller did not ask for is an error naming both +sides. A patch that lands on nothing, two patches writing one field, a +dimension redeclared or removed under the expressions written over it, and a +whole section set to null are each a model that would otherwise load and mean +something nobody wrote. +""" + +from __future__ import annotations + +import copy + +import pytest + +from math_spec import LanguageError, merge, override, to_markdown, to_spec +from tests.fixtures import DISPATCH_MODEL + +#: The coupling surface a component library agrees on: one flow per port, and +#: one balance per bus. The two fragments below name `flow` and declare none of +#: it, which is what makes each of them a load error on its own. +SURFACE = { + 'dimensions': {'snapshot': {'dtype': 'int'}, 'port': {'dtype': 'str'}, 'bus': {'dtype': 'str'}}, + 'relations': {'port_bus': {'key': 'port', 'values': 'bus'}}, + 'variables': {'flow': {'dims': ['snapshot', 'port']}}, + 'constraints': { + 'balance': {'dims': ['snapshot', 'bus'], 'expression': 'sum(flow, by=port_bus, over=port, into=bus) == 0'} + }, +} + +SUPPLY = { + 'dimensions': {'snapshot': {'dtype': 'int'}, 'port': {'dtype': 'str'}, 'generator': {'dtype': 'str'}}, + 'relations': {'gen_port': {'key': 'generator', 'values': 'port'}}, + 'parameters': {'gen_cost': {'dims': ['generator']}, 'gen_p_max': {'dims': ['generator']}}, + 'variables': {'gen_p': {'dims': ['snapshot', 'generator'], 'bounds': {'lower': 0, 'upper': 'gen_p_max'}}}, + 'constraints': { + 'gen_injects': { + 'dims': ['snapshot', 'generator'], + 'expression': 'at(flow, by=gen_port, over=port, into=generator) == gen_p', + } + }, + 'objective': {'sense': 'minimize', 'expression': 'sum(gen_p * gen_cost)'}, +} + +DEMAND = { + 'dimensions': {'snapshot': {'dtype': 'int'}, 'port': {'dtype': 'str'}, 'demand': {'dtype': 'str'}}, + 'relations': {'dem_port': {'key': 'demand', 'values': 'port'}}, + 'parameters': {'dem_load': {'dims': ['snapshot', 'demand']}}, + 'constraints': { + 'dem_withdraws': { + 'dims': ['snapshot', 'demand'], + 'expression': 'at(flow, by=dem_port, over=port, into=demand) == -dem_load', + } + }, +} + +LIBRARY = {'surface': SURFACE, 'supply': SUPPLY, 'demand': DEMAND} + + +def test_a_fragment_names_what_a_sibling_declares(): + """The whole reason merging happens before validation: `supply` reads `flow` and declares none of it.""" + with pytest.raises(LanguageError, match=r'flow'): + to_spec(SUPPLY) + spec = to_spec(merge(LIBRARY)) + assert sorted(spec.variables) == ['flow', 'gen_p'], "both fragments' columns are in the one model" + assert to_markdown(spec), 'a composed library prints as math' + + +def test_the_balance_does_not_grow_when_a_component_type_is_added(): + """What the port convention buys: a component pins its own flow rather than adding a term.""" + three = to_spec(merge(LIBRARY)).constraints['balance'].expression + storage = { + 'dimensions': {'snapshot': {'dtype': 'int'}, 'port': {'dtype': 'str'}, 'store': {'dtype': 'str'}}, + 'relations': {'st_port': {'key': 'store', 'values': 'port'}}, + 'parameters': {'st_capacity': {'dims': ['store']}}, + 'variables': {'st_p': {'dims': ['snapshot', 'store'], 'bounds': {'lower': 0, 'upper': 'st_capacity'}}}, + 'constraints': { + 'st_injects': { + 'dims': ['snapshot', 'store'], + 'expression': 'at(flow, by=st_port, over=port, into=store) == st_p', + } + }, + } + four = to_spec(merge({**LIBRARY, 'storage': storage})).constraints['balance'].expression + assert three == four, 'the balance is written once, whatever is plugged into it' + + +@pytest.mark.parametrize( + 'fragments', + [ + pytest.param(LIBRARY, id='one-objective'), + pytest.param( + {**LIBRARY, 'demand': {**DEMAND, 'objective': {'sense': 'minimize', 'expression': 'sum(dem_load)'}}}, + id='an-objective-in-two-fragments', + ), + ], +) +def test_merging_is_order_independent(fragments): + assert merge(fragments) == merge(dict(reversed(list(fragments.items())))) + + +def test_the_fragments_are_never_mutated_and_share_nothing_with_the_result(): + before = copy.deepcopy(LIBRARY) + composed = merge(LIBRARY) + assert before == LIBRARY, 'a composed model is a new mapping, and the fragments are untouched' + assert composed['parameters']['gen_cost'] is not SUPPLY['parameters']['gen_cost'], ( + "a declaration carried over is a copy, not the fragment's own object" + ) + + +@pytest.mark.parametrize( + ('fragments', 'says'), + [ + pytest.param( + {'supply': SUPPLY, 'demand': {**DEMAND, 'parameters': {'gen_cost': {'dims': ['demand']}}}}, + 'two rows of a dimension', + id='one-name-declared-twice', + ), + pytest.param( + { + 'supply': SUPPLY, + 'demand': {**DEMAND, 'dimensions': {**DEMAND['dimensions'], 'snapshot': {'dtype': 'str'}}}, + }, + 'give one of them a name of its own', + id='one-dimension-described-two-ways', + ), + pytest.param( + {'supply': SUPPLY, 'demand': {**DEMAND, 'objective': {'sense': 'maximize', 'expression': 'sum(dem_load)'}}}, + 'negate the terms', + id='objectives-that-run-opposite-ways', + ), + pytest.param( + {'supply': {**SUPPLY, 'version': 0}, 'demand': {**DEMAND, 'version': 1}}, + 'One model has one version', + id='two-language-versions', + ), + ], +) +def test_a_disagreement_between_fragments_is_refused(fragments, says): + """No order of the fragments settles any of these, so each is a refusal rather than a rule.""" + with pytest.raises(LanguageError) as raised: + merge(fragments) + message = str(raised.value) + assert says in message, 'the refusal names the rewrite rather than only what is wrong' + assert all(f"'{name}'" in message for name in fragments), 'a disagreement names both fragments' + + +def test_two_descriptions_of_one_dimension_agree_and_the_first_is_carried(): + """Prose is not a claim, so two fragments describing one dimension in their own words agree about it.""" + first = {**SUPPLY, 'dimensions': {**SUPPLY['dimensions'], 'snapshot': {'dtype': 'int', 'description': 'an hour'}}} + second = {**DEMAND, 'dimensions': {**DEMAND['dimensions'], 'snapshot': {'dtype': 'int', 'description': 'a step'}}} + composed = merge({'supply': first, 'demand': second}) + assert composed['dimensions']['snapshot'] == {'dtype': 'int', 'description': 'an hour'}, ( + "the claim is carried whole, under the first fragment's wording of the prose" + ) + + +def test_the_objectives_are_summed_each_term_parenthesised(): + """`a + b * k` reassociates, so an unparenthesised join composes a different objective.""" + priced = {**DEMAND, 'objective': {'sense': 'minimize', 'expression': 'sum(dem_load) * 2'}} + composed = merge({'surface': SURFACE, 'supply': SUPPLY, 'demand': priced}) + assert composed['objective']['expression'] == '(sum(dem_load) * 2) + (sum(gen_p * gen_cost))', ( + "the terms are summed in the fragments' name order, which no argument order can change" + ) + + +def test_one_fragment_s_objective_is_carried_as_it_was_written(): + assert merge(LIBRARY)['objective']['expression'] == SUPPLY['objective']['expression'] + + +def test_a_version_no_fragment_pins_is_left_out(): + assert 'version' not in merge(LIBRARY), 'a composition claims a version only where a fragment wrote one' + assert merge({**LIBRARY, 'supply': {**SUPPLY, 'version': 0}})['version'] == 0 + + +def test_the_description_belongs_to_the_composition(): + described = merge({**LIBRARY, 'supply': {**SUPPLY, 'description': 'a fleet'}}, description='a fleet against a load') + assert described['description'] == 'a fleet against a load' + assert 'description' not in merge({**LIBRARY, 'supply': {**SUPPLY, 'description': 'a fleet'}}), ( + "no fragment's own description is carried" + ) + + +def test_merge_composes_the_model_and_override_configures_the_run(): + """The two verbs meet by taking and returning what the other does.""" + run = override(merge(LIBRARY), {'project': {'variables': {'gen_p': {'where': 'gen_p_max > 0'}}}}) + assert to_spec(run).variables['gen_p'].where == 'gen_p_max > 0' + + +def test_a_fragment_is_a_path_as_readily_as_a_mapping(tmp_path): + surface = tmp_path / 'surface.yaml' + surface.write_text(to_spec(SURFACE).to_yaml(), encoding='utf-8') + assert to_spec(merge({**LIBRARY, 'surface': str(surface)})) == to_spec(merge(LIBRARY)) + + +#: A patch that adds what it needs and a constraint that reads it, so the +#: composed model is one `to_spec` accepts rather than only one that lays. +CARBON = { + 'parameters': {'co2': {'dims': ['generator']}}, + 'constraints': {'co2_cap': {'dims': [], 'expression': 'sum(p * co2) <= 100'}}, +} + +#: A base that reads a solved model: one given column, one given constraint and +#: an expression over the constraint, so a patch to `given:` is checked by +#: `to_spec` rather than only laid. +GIVEN_BASE = { + 'dimensions': {'g': {'dtype': 'str'}}, + 'given': {'variables': {'p': {'dims': ['g']}}, 'constraints': {'cap': {'dims': ['g']}}}, + 'expressions': {'price': {'expression': 'dual(cap)'}}, +} + +#: `DISPATCH_MODEL` with no objective, for the patches that ask about one. +FEASIBILITY = {k: v for k, v in DISPATCH_MODEL.items() if k != 'objective'} + + +def test_a_patch_names_only_the_field_it_changes(): + laid = override(DISPATCH_MODEL, {'operate': {'variables': {'p': {'where': 'p_max > 0'}}}}) + assert laid['variables']['p'] == { + 'dims': ['snapshot', 'generator'], + 'bounds': {'lower': 0, 'upper': 'p_max'}, + 'where': 'p_max > 0', + }, 'the fields the patch does not name are the ones the base declared' + + +def test_the_base_and_the_patches_are_never_mutated(): + """The guarantee belongs to the function rather than to the caller's discipline.""" + patches = {'carbon': CARBON, 'operate': {'variables': {'p': {'where': 'p_max > 0'}}}} + before = copy.deepcopy((DISPATCH_MODEL, patches)) + override(DISPATCH_MODEL, patches) + assert before == (DISPATCH_MODEL, patches), 'a patched base is a new mapping, and both inputs are untouched' + + +def test_a_whole_declaration_is_created_and_the_model_loads(): + spec = to_spec(override(DISPATCH_MODEL, {'carbon': CARBON})) + assert 'co2_cap' in spec.constraints + assert to_markdown(spec), 'a composed model is one a reviewer can read as math' + + +@pytest.mark.parametrize( + ('patch', 'says'), + [ + pytest.param({'constraints': {'balnce': {'dims': ['snapshot']}}}, "Did you mean 'balance'?", id='a-near-miss'), + pytest.param( + {'constraints': {'co2_cap': {'dims': []}}}, 'a constraint needs `expression`', id='short-of-a-field' + ), + pytest.param({'parameters': {'co2': {'dtype': 'float'}}}, 'a parameter needs `dims`', id='short-of-its-frame'), + pytest.param( + {'expressions': {'spend': {'dims': ['snapshot']}}}, + 'one `expression:` or a set of `cases:`', + id='short-of-what-it-says', + ), + pytest.param( + {'given': {'variables': {'flow': {'domain': 'binary'}}}}, + 'a given variable needs `dims`', + id='a-given-column-short-of-its-frame', + ), + ], +) +def test_a_partial_entry_that_lands_on_nothing_is_refused(patch, says): + """The typo case: laying a partial entry on nothing would invent a declaration nothing refers to.""" + with pytest.raises(LanguageError, match=r'does not declare') as raised: + override(DISPATCH_MODEL, {'project': patch}) + assert says in str(raised.value), 'the refusal says what the entry is short of, or what it nearly named' + + +def test_a_null_removes_a_declaration_and_the_model_still_loads(): + laid = override(DISPATCH_MODEL, {'unconstrained': {'constraints': {'balance': None}}}) + assert laid['constraints'] == {}, 'the declaration is gone rather than emptied' + assert to_spec(laid).constraints == {} + + +def test_a_stale_removal_is_refused(): + with pytest.raises(LanguageError, match=r"'balnce'.*does not declare.*Did you mean 'balance'\?"): + override(DISPATCH_MODEL, {'stale': {'constraints': {'balnce': None}}}) + + +def test_a_null_inside_a_declaration_is_a_value_rather_than_a_removal(): + """`where: null` is the mask the schema already takes, so the marker is positional. + + The base carries a mask, so setting the field to `null` and deleting it are + two different declarations rather than the same one twice. + """ + masked = override(DISPATCH_MODEL, {'masked': {'variables': {'p': {'where': 'p_max > 0'}}}}) + laid = override(masked, {'unmasked': {'variables': {'p': {'where': None}}}}) + assert 'where' in laid['variables']['p'], 'the field is set to none, and is not deleted from the declaration' + assert laid['variables']['p']['where'] is None + assert to_spec(laid).variables['p'].where is None + + +def test_a_null_two_levels_down_is_a_value_too(): + """The removal marker reaches no deeper than the declaration, however deep the `null` sits.""" + laid = override(DISPATCH_MODEL, {'unbounded': {'variables': {'p': {'bounds': {'upper': None}}}}}) + assert laid['variables']['p']['bounds'] == {'lower': 0, 'upper': None}, ( + 'the bound is set to none beside the one the base keeps, and neither is deleted' + ) + + +def test_the_result_shares_no_declaration_with_the_base_or_the_patch(): + """Both sides are copied, so editing a composed model cannot reach back into either. + + A declaration no patch names is the case worth pinning: it is carried over + untouched, which is exactly where a reference would be passed on instead. + """ + laid = override(DISPATCH_MODEL, {'carbon': CARBON}) + assert laid['parameters']['load'] is not DISPATCH_MODEL['parameters']['load'], ( + "a declaration the patches leave alone is a copy, not the base's own object" + ) + assert laid['parameters']['co2'] is not CARBON['parameters']['co2'], ( + "a declaration a patch adds is a copy, not the patch mapping's own object" + ) + + +@pytest.mark.parametrize( + 'patches', + [ + pytest.param( + { + 'pathway': {'variables': {'p': {'bounds': {'upper': 'p_max'}}}}, + 'project': {'variables': {'p': {'bounds': {'upper': 'cost'}}}}, + }, + id='one-field-twice', + ), + pytest.param( + { + 'pathway': {'constraints': {'balance': None}}, + 'project': {'constraints': {'balance': {'dims': ['snapshot', 'generator']}}}, + }, + id='removed-here-edited-there', + ), + pytest.param( + {'pathway': {'objective': {'sense': 'maximize'}}, 'project': {'objective': {'sense': 'minimize'}}}, + id='the-objective-twice', + ), + pytest.param( + {'pathway': {'version': 0}, 'project': {'version': 1}}, + id='a-top-level-scalar-twice', + ), + ], +) +def test_two_patches_that_write_one_field_are_refused(patches): + with pytest.raises(LanguageError) as raised: + override(DISPATCH_MODEL, patches) + message = str(raised.value) + assert "'pathway'" in message and "'project'" in message, 'a collision names both patches, not just the second' + assert 'override(override(' in message, 'the message names the rewrite, which is to lay one on the other' + + +def test_disjoint_patches_compose_the_same_model_in_either_order(): + """What the disjointness rule buys: the argument's position never decides a model.""" + patches = {'carbon': CARBON, 'operate': {'variables': {'p': {'where': 'p_max > 0'}}}} + reversed_order = dict(reversed(list(patches.items()))) + assert override(DISPATCH_MODEL, patches) == override(DISPATCH_MODEL, reversed_order) + + +def test_layering_is_written_out_as_nesting(): + """The second call lays on the first's result, which is where an order is allowed to matter.""" + once = override(DISPATCH_MODEL, {'pathway': {'variables': {'p': {'where': 'p_max > 0'}}}}) + twice = override(once, {'project': {'variables': {'p': {'where': 'cost > 0'}}}}) + assert twice['variables']['p']['where'] == 'cost > 0' + + +def test_a_patch_adds_a_dimension_and_may_restate_one_it_shares(): + laid = override( + DISPATCH_MODEL, + {'periods': {'dimensions': {'snapshot': {'dtype': 'int'}, 'investment_period': {'dtype': 'int'}}}}, + ) + assert sorted(laid['dimensions']) == ['generator', 'investment_period', 'snapshot'], ( + 'the dimension the patch adds joins the two the base declares, and the restated one is not doubled' + ) + + +@pytest.mark.parametrize( + ('patch', 'says'), + [ + pytest.param( + {'dimensions': {'snapshot': {'dtype': 'str'}}}, + 'adjusts the math, not the coordinate space', + id='declared-as-something-else', + ), + pytest.param( + {'dimensions': {'snapshot': {}}}, + 'restate the declaration word for word', + id='restated-in-part', + ), + pytest.param( + {'dimensions': {'snapshot': None}}, + 'remove the declarations written over it one at a time', + id='removed', + ), + ], +) +def test_a_patch_that_rewrites_a_dimension_is_refused(patch, says): + """A dimension changed under the expressions already written over it is a different model, silently.""" + with pytest.raises(LanguageError) as raised: + override(DISPATCH_MODEL, {'relabelled': patch}) + assert says in str(raised.value), 'the refusal names the rewrite rather than only what is wrong' + + +@pytest.mark.parametrize( + 'patch', + [ + pytest.param({'constraints': None}, id='an-owned-section'), + pytest.param({'dimensions': None}, id='a-shared-section'), + pytest.param({'given': {'variables': None}}, id='one-kind-of-given'), + ], +) +def test_a_whole_section_set_to_null_is_refused(patch): + """Nulling a section reads as emptying it, and laying it silently changed nothing at all.""" + with pytest.raises(LanguageError, match=r'removes nothing') as raised: + override(DISPATCH_MODEL, {'blank': patch}) + assert 'one at a time' in str(raised.value), 'the refusal names the rewrite, which is one null per declaration' + + +def test_the_objective_is_laid_over_field_by_field(): + laid = override(DISPATCH_MODEL, {'maximised': {'objective': {'sense': 'maximize'}}}) + assert laid['objective'] == {'sense': 'maximize', 'expression': 'sum(p * cost)'}, ( + 'the sense the patch names changes, and the expression the base wrote stays' + ) + + +def test_the_objective_can_be_removed_and_the_model_is_a_feasibility_problem(): + laid = override(DISPATCH_MODEL, {'feasible': {'objective': None}}) + assert 'objective' not in laid + assert to_spec(laid).objective is None + + +def test_a_whole_objective_is_created_where_the_base_has_none(): + laid = override(FEASIBILITY, {'priced': {'objective': DISPATCH_MODEL['objective']}}) + assert to_spec(laid).objective is not None + + +@pytest.mark.parametrize( + ('patch', 'says'), + [ + pytest.param({'objective': None}, 'already the feasibility problem', id='removing-one-that-is-not-there'), + pytest.param( + {'objective': {'sense': 'maximize'}}, 'an objective needs `expression`', id='editing-one-that-is-not-there' + ), + ], +) +def test_an_objective_a_base_does_not_declare_is_refused(patch, says): + with pytest.raises(LanguageError, match=r'does not declare') as raised: + override(FEASIBILITY, {'project': patch}) + assert says in str(raised.value) + + +def test_a_patch_over_one_kind_of_given_leaves_the_other_alone(): + """`given:` is laid over a kind at a time, so patching the columns cannot drop the row families.""" + laid = override(GIVEN_BASE, {'wider': {'given': {'variables': {'p': {'domain': 'binary'}}}}}) + assert laid['given']['variables']['p'] == {'dims': ['g'], 'domain': 'binary'}, ( + 'the given column is edited field by field like any declaration' + ) + assert sorted(laid['given']['constraints']) == ['cap'], 'the kind the patch did not name is still there' + assert to_spec(laid).given.variables['p'].domain == 'binary' + + +@pytest.mark.parametrize( + ('base', 'reads'), + [ + pytest.param(GIVEN_BASE, ['p', 'q'], id='a-base-that-already-reads'), + pytest.param({'dimensions': {'g': {'dtype': 'str'}}}, ['q'], id='a-base-that-reads-nothing-yet'), + ], +) +def test_a_whole_given_entry_is_created_and_the_model_loads(base, reads): + """A patch adds a column to read, whether or not the base opened the block.""" + laid = override(base, {'solved': {'given': {'variables': {'q': {'dims': ['g']}}}}}) + assert sorted(to_spec(laid).given.variables) == reads, 'the created column joins whatever the base read' + + +def test_a_patch_is_a_path_as_readily_as_a_mapping(tmp_path): + """Whatever every other verb takes, so a patch travels as a file rather than as a script.""" + patch = tmp_path / 'carbon.yaml' + patch.write_text('parameters:\n co2: {dims: [generator]}\n', encoding='utf-8') + laid = override(DISPATCH_MODEL, {'carbon': str(patch)}) + assert 'co2' in laid['parameters'] + + +def test_a_loaded_spec_is_a_base_as_readily_as_a_mapping(): + laid = override(to_spec(DISPATCH_MODEL), {'carbon': CARBON}) + assert 'co2_cap' in to_spec(laid).constraints diff --git a/tests/test_dimensions.py b/tests/test_dimensions.py index 32ded946..4fea9cd1 100644 --- a/tests/test_dimensions.py +++ b/tests/test_dimensions.py @@ -14,7 +14,7 @@ from math_spec.program import Mask, RelationPairComparison from math_spec.resolution import Namespace from math_spec.validation import to_spec -from tests.fixtures import expression_of, override, schema_of, where_of +from tests.fixtures import expression_of, schema_of, varied, where_of if TYPE_CHECKING: from math_spec.model import Spec @@ -419,7 +419,7 @@ class TestTheEdgeRulesAreDecidedAtLoad: } def _refused(self, expression: str) -> str: - raw = override(self.BASE, **{'constraints.k.expression': expression}) + raw = varied(self.BASE, **{'constraints.k.expression': expression}) with pytest.raises(DimensionError) as caught: to_spec(raw) return str(caught.value) @@ -473,7 +473,7 @@ def test_a_zero_step_vacates_nothing_and_needs_no_edge(self): literal zero vacates none, so there is nothing for an `edge=` to answer for. A *named* offset may be zero in the data and is not known here. """ - to_spec(override(self.BASE, **{'constraints.k.expression': 'p <= shift(cap, along=g, offset=0)'})) + to_spec(varied(self.BASE, **{'constraints.k.expression': 'p <= shift(cap, along=g, offset=0)'})) # --------------------------------------------------------------------------- diff --git a/tests/test_expand.py b/tests/test_expand.py index 72fa085e..52852e39 100644 --- a/tests/test_expand.py +++ b/tests/test_expand.py @@ -18,7 +18,7 @@ from math_spec import piecewise from math_spec.lowering import to_program -from tests.fixtures import DISPATCH_MODEL, EXAMPLES, override, schema_of +from tests.fixtures import DISPATCH_MODEL, EXAMPLES, schema_of, varied from tests.test_sos import CURVE from tools.render_tex import models @@ -27,7 +27,7 @@ #: The curve masked by one of its own values parameters, the one block whose #: rows sit on more than the file's own names. -MASKED = override( +MASKED = varied( CURVE, **{ 'piecewise.cost_curve.method': 'lp', diff --git a/tests/test_given.py b/tests/test_given.py new file mode 100644 index 00000000..f80b8857 --- /dev/null +++ b/tests/test_given.py @@ -0,0 +1,319 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""What a file reads and does not build: a column, and a row family. + +A fragment reads a column the file beside it introduces, and `merge` folds the +two together, so the composed model carries no trace of the reading. A layer +reads a column, or the dual of a row family, that a model outside the language +holds, so there is nothing to fold into and the program carries the name for a +consumer to bind. What both need is that the file stands on its own: it loads, +it lowers, and it prints as math, without the thing that owns what it reads. +""" + +from __future__ import annotations + +import pytest + +from math_spec import FORMATS, LanguageError, advice, merge, to_markdown, to_program, to_spec, typeset + +#: One component file: it pins the flow at its own port, and the column it +#: pins belongs to another fragment. +SUPPLY = { + 'description': 'A fleet of generators, each on one port.', + 'dimensions': {'snapshot': {'dtype': 'int'}, 'port': {'dtype': 'str'}, 'generator': {'dtype': 'str'}}, + 'relations': {'gen_port': {'key': 'generator', 'values': 'port'}}, + 'given': {'variables': {'flow': {'dims': ['snapshot', 'port'], 'description': 'what a port puts into its bus'}}}, + 'parameters': {'gen_cost': {'dims': ['generator']}, 'gen_p_max': {'dims': ['generator']}}, + 'variables': {'gen_p': {'dims': ['snapshot', 'generator'], 'bounds': {'lower': 0, 'upper': 'gen_p_max'}}}, + 'constraints': { + 'gen_injects': { + 'dims': ['snapshot', 'generator'], + 'expression': 'at(flow, by=gen_port, over=port, into=generator) == gen_p', + } + }, + 'objective': {'sense': 'minimize', 'expression': 'sum(gen_p * gen_cost)'}, +} + +#: The fragment that introduces `flow`, with the bounds and the balance that go with it. +SURFACE = { + 'dimensions': {'snapshot': {'dtype': 'int'}, 'port': {'dtype': 'str'}, 'bus': {'dtype': 'str'}}, + 'relations': {'port_bus': {'key': 'port', 'values': 'bus'}}, + 'variables': {'flow': {'dims': ['snapshot', 'port'], 'bounds': {'lower': -1000, 'upper': 1000}}}, + 'constraints': { + 'balance': {'dims': ['snapshot', 'bus'], 'expression': 'sum(flow, by=port_bus, over=port, into=bus) == 0'} + }, +} + + +def test_given_holds_two_kinds_and_refuses_a_third(): + """The section is closed, so a kind nobody has admitted yet is the schema's own refusal.""" + with pytest.raises(LanguageError) as raised: + to_spec({**SUPPLY, 'given': {'parameters': {'gen_cost': {'dims': ['generator']}}}}) + assert 'Valid keys: constraints, variables' in str(raised.value), 'the refusal names what the block takes' + + +def test_a_fragment_that_says_what_it_reads_loads_on_its_own(): + spec = to_spec(SUPPLY) + assert sorted(spec.given.variables) == ['flow'], 'the column it reads is a declaration like any other' + assert sorted(spec.variables) == ['gen_p'], 'and it is not one of the columns this file introduces' + + +def test_a_given_name_is_held_to_the_name_rule(): + """`_names_are_names` walks the top-level mappings, and `given:` nests its two one level down.""" + with pytest.raises(LanguageError, match=r"given: variables: 'no-flow' is not a name"): + to_spec({**SUPPLY, 'given': {'variables': {'no-flow': {'dims': ['snapshot', 'port']}}}}) + + +def test_a_whole_model_writes_no_given_block(): + whole = to_spec({**SUPPLY, 'given': {}, 'constraints': {}}) + assert 'given' not in whole.to_dict(), 'an empty section is an absence, and is left out' + + +def test_a_fragment_round_trips_through_its_own_data(): + spec = to_spec(SUPPLY) + assert to_spec(spec.to_dict()) == spec + + +@pytest.mark.parametrize('fmt', sorted(FORMATS)) +def test_a_fragment_prints_as_math_in_every_format(fmt): + assert typeset(SUPPLY, fmt), f'{fmt} rendered nothing' + + +def test_the_given_column_prints_under_its_own_heading(): + printed = to_markdown(SUPPLY) + assert '#### Given' in printed, 'the legend says which symbols the file does not introduce' + assert '`flow`' in printed.split('#### Given')[1] + + +def test_a_program_carries_the_column_it_reads_apart_from_the_ones_it_builds(): + """The distinction a builder needs: create this column, or bind it to one the host already holds.""" + program = to_program(SUPPLY) + assert sorted(program.variables) == ['gen_p'], 'a build reads this group and creates a column for each' + assert sorted(program.given.variables) == ['flow'], 'and binds each of these to a column it is given' + assert program.given.variables['flow'].dims == ('snapshot', 'port'), 'the frame is what a binder checks' + + +def test_what_a_program_reads_is_sealed_like_what_it_builds(): + program = to_program(SUPPLY) + with pytest.raises(TypeError, match='does not support item assignment'): + program.given.variables['p'] = program.given.variables['flow'] + + +def test_the_advice_says_which_columns_a_consumer_has_to_bind(): + (note,) = [note for note in advice(SUPPLY) if note.kind == 'given'] + assert note.subject == 'flow' + + +def test_a_given_column_in_the_objective_is_not_advised_unbounded(): + """The unboundedness pass reads a variable's bounds, and a given column's bounds are the owner's.""" + priced = {**SUPPLY, 'constraints': {}, 'objective': {'sense': 'minimize', 'expression': 'sum(flow)'}} + assert not [note for note in advice(priced) if note.kind == 'unbounded'] + + +def test_a_name_both_introduced_and_given_in_one_file_is_refused(): + both = {**SUPPLY, 'variables': {**SUPPLY['variables'], 'flow': {'dims': ['snapshot', 'port']}}} + with pytest.raises(LanguageError, match=r"Given variable 'flow' collides with the variable"): + to_spec(both) + + +@pytest.mark.parametrize( + ('block', 'says'), + [ + pytest.param({'dims': ['snapshot', 'nowhere']}, 'nowhere', id='a-frame-over-an-undeclared-dimension'), + pytest.param({'dims': ['snapshot', 'snapshot']}, 'twice', id='a-frame-naming-one-dimension-twice'), + pytest.param({'dims': ['snapshot'], 'bounds': {'lower': 0}}, 'bounds', id='bounds-the-owner-holds'), + pytest.param({'dims': ['snapshot'], 'where': 'gen_cost > 0'}, 'where', id='a-mask-the-owner-holds'), + ], +) +def test_a_given_declaration_is_refused_where_it_oversteps(block, says): + with pytest.raises(LanguageError) as raised: + to_spec({**SUPPLY, 'given': {'variables': {'flow': block}}}) + assert says in str(raised.value) + + +def test_an_expression_reads_a_given_column_as_it_reads_any_other(): + """Resolution and the dim algebra see one namespace, so the walk lands on the generator frame.""" + spec = to_spec(SUPPLY) + assert spec.constraints['gen_injects'].dims == ['snapshot', 'generator'] + + +def test_merging_folds_the_given_declaration_into_the_one_that_introduces_it(): + composed = merge({'surface': SURFACE, 'supply': SUPPLY}) + assert 'given' not in composed, 'the expectation is spent once the column is in the composition' + spec = to_spec(composed) + assert sorted(spec.variables) == ['flow', 'gen_p'] + assert spec.variables['flow'].bounds.lower == -1000, "the introducer's declaration is the one that survives" + assert sorted(to_program(spec).variables) == ['flow', 'gen_p'], 'a composed library lowers like any model' + + +@pytest.mark.parametrize( + 'reads', + [ + pytest.param({'dims': ['snapshot', 'port']}, id='the-frame-alone'), + pytest.param({'dims': ['snapshot', 'port'], 'domain': 'continuous'}, id='the-domain-the-introducer-defaults'), + pytest.param({'dims': ['snapshot', 'port'], 'description': 'the flow, in my words'}, id='its-own-prose'), + ], +) +def test_a_given_declaration_may_say_less_than_the_introducer(reads): + """Bounds are the introducer's, so the reader states the frame and stops.""" + composed = merge({'surface': SURFACE, 'supply': {**SUPPLY, 'given': {'variables': {'flow': reads}}}}) + assert to_spec(composed).variables['flow'].bounds.upper == 1000 + + +@pytest.mark.parametrize( + 'reads', + [ + pytest.param({'dims': ['snapshot', 'generator']}, id='another-frame'), + pytest.param({'dims': ['snapshot', 'port'], 'domain': 'binary'}, id='another-domain'), + ], +) +def test_a_given_declaration_that_disagrees_with_the_introducer_is_refused(reads): + misread = {**SUPPLY, 'given': {'variables': {'flow': reads}}} + with pytest.raises(LanguageError, match=r'says the same as the declaration it is folded into, or less') as raised: + merge({'surface': SURFACE, 'supply': misread}) + message = str(raised.value) + assert "'supply'" in message and "'surface'" in message, 'both sides of a disagreement are named' + + +def test_two_fragments_must_read_one_column_the_same_way(): + other = { + 'dimensions': {'snapshot': {'dtype': 'int'}, 'port': {'dtype': 'str'}}, + 'given': {'variables': {'flow': {'dims': ['port']}}}, + } + with pytest.raises(LanguageError, match=r'say different things about the given variable'): + merge({'supply': SUPPLY, 'other': other}) + + +@pytest.mark.parametrize( + ('fragment', 'says'), + [ + pytest.param( + {**SUPPLY, 'given': {'variables': {'gen_p': {'dims': ['snapshot', 'generator']}}}}, + "the variable 'gen_p'", + id='a-column-it-builds', + ), + pytest.param( + {**SUPPLY, 'given': {'constraints': {'gen_injects': {'dims': ['snapshot', 'generator']}}}}, + "the constraint 'gen_injects'", + id='a-row-family-it-builds', + ), + ], +) +def test_a_fragment_that_reads_what_it_builds_is_refused(fragment, says): + """`to_spec` refuses such a file, and folding it silently would put it in a model that loads.""" + with pytest.raises(LanguageError) as raised: + merge({'surface': SURFACE, 'supply': fragment}) + message = str(raised.value) + assert says in message and "'supply'" in message, 'the refusal names the fragment and the name it reads twice' + assert 'drop the given' in message, 'the refusal names the rewrite' + + +def test_a_given_declaration_nothing_introduces_stays_for_a_consumer_to_bind(): + composed = merge({'supply': SUPPLY, 'other': {'dimensions': {'snapshot': {'dtype': 'int'}}}}) + assert composed['given'] == SUPPLY['given'], 'a name no fragment introduces is still read, and is carried' + assert sorted(to_program(composed).given.variables) == ['flow'] + + +#: A layer over a model this language never sees: it reads a column and the +#: dual of a row family, and adds one constraint of its own. +LAYER = { + 'description': 'A carbon cap laid over a model that already exists.', + 'dimensions': {'snapshot': {'dtype': 'int'}, 'bus': {'dtype': 'str'}}, + 'given': { + 'variables': {'p': {'dims': ['snapshot', 'bus']}}, + 'constraints': {'balance': {'dims': ['snapshot', 'bus'], 'description': 'the host clears each bus'}}, + }, + 'parameters': {'rate': {'dims': ['bus']}}, + 'constraints': {'cap': {'dims': [], 'expression': 'sum(p * rate) <= 100'}}, + 'expressions': {'price': {'expression': 'dual(balance)'}}, +} + + +def test_a_dual_may_name_a_row_family_this_file_does_not_build(): + spec = to_spec(LAYER) + assert sorted(spec.given.constraints) == ['balance'] + assert sorted(spec.constraints) == ['cap'], 'the row families it builds are its own, and that is not one' + + +def test_the_program_carries_the_row_family_a_consumer_binds(): + program = to_program(LAYER) + assert sorted(program.given.constraints) == ['balance'] + assert program.given.constraints['balance'].dims == ('snapshot', 'bus'), 'the frame is what a binder checks' + + +def test_the_dual_takes_its_frame_from_the_given_declaration(): + """Without the frame the reported expression has no dims, and nothing downstream could shape it.""" + assert to_markdown(LAYER).count(r'\lambda_{\mathrm{balance},t,b}') == 1 + + +def test_a_given_row_family_prints_under_the_given_heading(): + given = to_markdown(LAYER).split('#### Given')[1] + assert '`balance`' in given + assert 'reads the dual of' in given, 'the legend says what the file may do with it' + + +def test_a_row_family_both_built_and_given_is_refused(): + both = {**LAYER, 'constraints': {**LAYER['constraints'], 'balance': {'dims': [], 'expression': 'sum(p) >= 0'}}} + with pytest.raises(LanguageError, match=r"'balance'.*either built by this file or given to it"): + to_spec(both) + + +def test_a_dual_naming_nothing_says_where_to_declare_it(): + mistyped = {**LAYER, 'expressions': {'price': {'expression': 'dual(balnce)'}}} + with pytest.raises(LanguageError) as raised: + to_spec(mistyped) + message = str(raised.value) + assert "'constraints:'" in message and "'given: constraints:'" in message, ( + 'the message names both places the row family could be declared' + ) + + +@pytest.mark.parametrize( + ('block', 'says'), + [ + pytest.param({'dims': [], 'expression': 'sum(p) >= 0'}, 'expression', id='a-body-the-owner-holds'), + pytest.param({'dims': [], 'sense': '<='}, 'sense', id='a-sense-nothing-here-could-check'), + ], +) +def test_a_given_row_family_is_refused_where_it_oversteps(block, says): + with pytest.raises(LanguageError) as raised: + to_spec({**LAYER, 'given': {**LAYER['given'], 'constraints': {'balance': block}}}) + assert says in str(raised.value) + + +def test_merging_folds_a_row_family_into_the_file_that_builds_it(): + builder = { + 'dimensions': {'snapshot': {'dtype': 'int'}, 'bus': {'dtype': 'str'}}, + 'variables': {'p': {'dims': ['snapshot', 'bus'], 'bounds': {'lower': 0}}}, + 'constraints': {'balance': {'dims': ['snapshot', 'bus'], 'expression': 'p >= 0'}}, + } + composed = merge({'builder': builder, 'layer': LAYER}) + assert 'given' not in composed + program = to_program(composed) + assert sorted(program.constraints) == ['balance', 'cap'] + assert not program.given.constraints, 'nothing is left for a consumer to bind' + + +def test_the_advice_names_every_declaration_a_consumer_has_to_bind(): + subjects = {note.subject for note in advice(LAYER) if note.kind == 'given'} + assert subjects == {'p', 'balance'}, 'both the column and the row family are named' + + +#: `port` is named by nothing but the given column's frame, and `bus` by +#: nothing but the given row family's, so each is in use only through a +#: declaration this file does not build. +REACHED_ONLY_BY_A_GIVEN_FRAME = { + 'dimensions': {'g': {'dtype': 'str'}, 'port': {'dtype': 'str'}, 'bus': {'dtype': 'str'}}, + 'given': {'variables': {'flow': {'dims': ['port']}}, 'constraints': {'balance': {'dims': ['bus']}}}, + 'variables': {'p': {'dims': ['g'], 'bounds': {'lower': 0, 'upper': 1}}}, + 'constraints': {'tie': {'dims': ['g'], 'expression': 'p >= sum(flow, over=port)'}}, + 'expressions': {'price': {'expression': 'dual(balance)'}}, +} + + +def test_a_dimension_only_a_given_declaration_indexes_is_in_use(): + """The never-an-axis pass reads the frames a build emits, and these two are in neither.""" + unreached = {note.subject for note in advice(REACHED_ONLY_BY_A_GIVEN_FRAME) if note.kind == 'never-an-axis'} + assert not unreached, 'a dimension a given column or row family is indexed by is used' diff --git a/tests/test_library_example.py b/tests/test_library_example.py new file mode 100644 index 00000000..3af4d8df --- /dev/null +++ b/tests/test_library_example.py @@ -0,0 +1,108 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""The component library under `examples/library/`, held to what its pages claim. + +The gallery test holds each page to its generator, so the math on a page cannot +drift from the file above it. What is left for here is what no page states: that +every fragment stands alone, that the composition is one model, and that the +variant patch, the one file in the library that is not a model, applies to what +the fragments make. +""" + +from __future__ import annotations + +import pytest + +from math_spec import LanguageError, merge, override, to_markdown, to_spec +from math_spec.typesetting import FORMATS, typeset +from tests.fixtures import EXAMPLES +from tools import gallery + +LIBRARY = EXAMPLES / 'library' +FRAGMENTS = {name: LIBRARY / f'{name}.yaml' for name in ('surface', 'generator', 'load')} +PATCH = LIBRARY / 'variants' / 'commitment.yaml' +PATCHED = override(merge(FRAGMENTS), {'commitment': PATCH}) + + +@pytest.mark.parametrize('name', sorted(FRAGMENTS)) +def test_every_fragment_loads_and_prints_on_its_own(name): + """The unit a library ships is the unit somebody reviews, so each one is a model.""" + assert to_markdown(to_spec(FRAGMENTS[name])), f'{name} rendered nothing' + + +@pytest.mark.parametrize('name', ['generator', 'load']) +def test_a_component_file_reads_the_surface_and_introduces_no_flow(name): + spec = to_spec(FRAGMENTS[name]) + assert sorted(spec.given.variables) == ['Port_p'], 'the port flow is the one name a component file reads' + assert 'Port_p' not in spec.variables, 'the surface introduces the flow, and a component file only reads it' + + +def test_the_library_composes_into_one_model(): + spec = to_spec(merge(FRAGMENTS)) + assert sorted(spec.variables) == ['Generator_p', 'Port_p'], 'the composition declares each column once' + assert sorted(spec.constraints) == ['Bus_nodal_balance', 'Generator_injection', 'Load_withdrawal'], ( + 'the composition carries every row family of every fragment, and no other' + ) + assert not spec.given, 'each read is folded into the declaration that introduces it' + assert spec.objective is not None and spec.objective.expression == 'sum(Generator_p * Generator_marginal_cost)', ( + "the one fragment that priced anything carries the composed model's objective, as it wrote it" + ) + + +@pytest.mark.parametrize( + 'names', + [ + pytest.param(('surface', 'load'), id='one component file'), + pytest.param(('surface', 'generator', 'load'), id='the whole library'), + ], +) +def test_the_balance_is_written_once_however_many_fragments_are_merged(names): + merged = to_spec(merge({name: FRAGMENTS[name] for name in names})) + surface = to_spec(FRAGMENTS['surface']) + assert merged.constraints['Bus_nodal_balance'] == surface.constraints['Bus_nodal_balance'], ( + 'a component file pins the flow at its own port, so merging leaves the balance as the surface wrote it' + ) + + +def test_the_variant_is_a_patch_rather_than_a_model(): + """Which is why `render_tex` skips `variants/`: nothing there loads on its own.""" + with pytest.raises(LanguageError): + to_spec(PATCH) + + +def test_the_variant_patch_applies_to_the_composition(): + spec = to_spec(PATCHED) + assert spec.variables['Generator_status'].domain == 'binary' + assert spec.variables['Generator_p'].bounds.upper == float('inf'), 'the cap moves from the bound to a constraint' + assert sorted(spec.constraints) == [ + 'Bus_nodal_balance', + 'Generator_com_p_lower', + 'Generator_com_p_upper', + 'Generator_injection', + 'Load_withdrawal', + ], 'the patch adds its two rows and removes none' + + +@pytest.mark.parametrize('fmt', list(FORMATS), ids=list(FORMATS)) +def test_the_patched_model_prints_the_variant_math(fmt): + """A patch is read by the loader through the model it lands on, so what it declares prints like the rest.""" + printed = typeset(to_spec(PATCHED), fmt).replace(r'\_', '_') + declared = ('Generator_status', 'Generator_p_min_pu', 'Generator_com_p_upper', 'Generator_com_p_lower') + missing = [name for name in declared if name not in printed] + assert not missing, f'the patch declares {missing}, and the typeset document does not name them' + + +def test_the_variant_needs_the_fragment_it_patches(): + """Picking commitment without the generator is a patch that lands on nothing, and it is refused at load.""" + without_generator = merge({name: FRAGMENTS[name] for name in ('surface', 'load')}) + with pytest.raises(LanguageError, match="edits the variable 'Generator_p', which its base does not declare"): + to_spec(override(without_generator, {'commitment': PATCH})) + + +def test_every_variant_in_the_library_is_typeset_on_the_composed_page(): + """A patch prints only as the model it lands on, so one with no tab is a patch nothing prints.""" + page = (gallery.PAGES / 'library' / 'composed.md').read_text() + missing = [path.name for path in (LIBRARY / 'variants').glob('*.yaml') if f'=== "With {path.stem}"' not in page] + assert not missing, f'the composed page gives {missing} no tab, so what they print is on no page' diff --git a/tests/test_lowering.py b/tests/test_lowering.py index 8bbe6461..0aab7650 100644 --- a/tests/test_lowering.py +++ b/tests/test_lowering.py @@ -68,7 +68,7 @@ where_children, ) from math_spec.resolution import Namespace -from tests.fixtures import DISPATCH_MODEL, EXAMPLES, SMALL_MODEL, expression_of, override, schema_of, where_of +from tests.fixtures import DISPATCH_MODEL, EXAMPLES, SMALL_MODEL, expression_of, schema_of, varied, where_of if TYPE_CHECKING: from math_spec._expression_parser import ArithmeticNode @@ -80,7 +80,7 @@ #: One dimension, one parameter, one bounded variable and a scalar constraint: #: the smallest model that loads, for a claim about the plan's record rather -#: than about the math in it. A test adds what it judges with :func:`override`. +#: than about the math in it. A test adds what it judges with :func:`varied`. TINY = { 'dimensions': {'g': {}}, 'parameters': {'cost': {'dims': ['g']}}, @@ -98,7 +98,7 @@ #: offset. Which node a construct becomes is mostly a claim about the dim it #: consumes and the dim it lands on, and stating that needs a third dimension #: and two relations over one of them. -SHAPES_MODEL = override( +SHAPES_MODEL = varied( SMALL_MODEL, **{ 'dimensions.z': {'dtype': 'str'}, @@ -162,7 +162,7 @@ def test_lower_program_structure(dispatch_program): @pytest.mark.parametrize('sense', [pytest.param('minimize', id='minimize'), pytest.param('maximize', id='maximize')]) def test_the_objective_sense_crosses_untranslated(sense: str): """One spelling from the file to the program, in both directions — each sink translates at its own edge.""" - program = to_program(override(TINY, objective={'sense': sense, 'expression': 'sum(p * cost, over=g)'})) + program = to_program(varied(TINY, objective={'sense': sense, 'expression': 'sum(p * cost, over=g)'})) assert program.objective is not None assert program.objective.sense == sense, "the file's own word for the direction, unchanged" @@ -302,7 +302,7 @@ def test_a_lowered_where_is_a_mask_that_answers_from_its_root(dispatch_program): def test_a_lowered_mask_answers_its_dims_conjuncts_and_atoms(variable, where, dims, conjuncts, atoms): """`Mask.dims` is read off the leaves, which carry their declarations' dims; `atoms` crosses the `OR` that `conjuncts` stops at.""" - mask = to_program(override(SMALL_MODEL, **{f'variables.{variable}.where': where})).variables[variable].where + mask = to_program(varied(SMALL_MODEL, **{f'variables.{variable}.where': where})).variables[variable].where assert mask.dims == frozenset(dims) assert len(mask.conjuncts) == conjuncts, 'an OR is one conjunct, a leaf is one conjunct' @@ -398,7 +398,7 @@ def test_an_unwritten_where_lowers_to_none_not_an_empty_mask(): def test_a_constraint_where_is_a_mask_like_a_variable_s(): - lowered = to_program(override(DISPATCH_MODEL, **{'constraints.balance.where': 'load > 0'})) + lowered = to_program(varied(DISPATCH_MODEL, **{'constraints.balance.where': 'load > 0'})) (c,) = lowered.constraints.values() assert c.where == Mask(ParameterComparison('load', '>', 0.0, ('snapshot',))) @@ -407,7 +407,7 @@ def test_a_constraint_where_is_a_mask_like_a_variable_s(): 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( + varied( SHAPES_MODEL, **{ 'parameters.zc': {'dims': ['z']}, @@ -436,7 +436,7 @@ def test_a_comparison_of_expressions_lowers_to_program_expressions_on_both_sides def test_a_predicate_a_leaf_carries_is_lowered_like_any_other_mask(): """A comparison of expressions inside a count is rebuilt too, so a program mask is program vocabulary throughout.""" program = to_program( - override( + varied( SHAPES_MODEL, **{'constraints.w': {'dims': ['g'], 'where': 'count(c <= 0.5 * k, over=g) >= 2', 'expression': 'p <= c'}}, ) @@ -452,7 +452,7 @@ def test_a_predicate_a_leaf_carries_is_lowered_like_any_other_mask(): def test_a_translated_predicate_keeps_what_it_reads_in_reach(): """A walk that asks a mask what it names has to see through the translation, or the column is silently dropped.""" program = to_program( - override( + varied( SHAPES_MODEL, **{ 'constraints.w': { @@ -491,7 +491,7 @@ def test_assumptions_carry_the_file_s_entries_and_the_curves_behind_them(): def test_an_assumption_lowers_both_of_its_masks(): """The predicate and the ``where`` are rebuilt on program expressions, as every other mask is.""" - program = to_program(override(SHAPES_MODEL, assumptions={'sound': {'holds': 'c <= 0.5 * k', 'where': 'flag'}})) + program = to_program(varied(SHAPES_MODEL, assumptions={'sound': {'holds': 'c <= 0.5 * k', 'where': 'flag'}})) assumption = program.assumptions['sound'] assert assumption == Holds( @@ -511,7 +511,7 @@ def test_an_assumption_refuses_in_the_words_the_file_wrote(): the sentence quotes it where the file wrote one. """ reason = 'a shape with no room between its bounds cannot be cut' - program = to_program(override(SHAPES_MODEL, assumptions={'sound': {'holds': 'c <= k', 'description': reason}})) + program = to_program(varied(SHAPES_MODEL, assumptions={'sound': {'holds': 'c <= k', 'description': reason}})) assumption = program.assumptions['sound'] assert assumption.description == reason, 'the program carries it, so a consumer needs no second read of the file' @@ -524,7 +524,7 @@ def test_a_cased_side_reads_the_data_its_regions_are_decided_by(): """`names_read` promised every parameter and relation the sides read, and dropped the `when:` of a cased entry: the walk descends a `Cases` by its values alone.""" program = to_program( - override( + varied( SHAPES_MODEL, **{ 'expressions.e': { @@ -841,7 +841,7 @@ def test_a_node_answers_its_fan_in(node, expected): def test_a_relation_is_declared_as_the_file_declares_it(): """One group keyed by name, each entry its columns and its key, and nothing nested under a dimension.""" program = to_program( - override( + varied( TINY, dimensions={'g': {}, 'bus': {}, 'season': {}}, relations={ @@ -875,7 +875,7 @@ def test_a_program_seals_its_declaration_groups(dispatch_program, group): def test_roots_are_the_trees_a_row_is_built_from(): """`expressions` is the file's own section, which builds no row at all; the row-building trees are `roots`.""" program = to_program( - override( + varied( TINY, expressions={'spend': 'sum(cost, over=g)'}, objective={'sense': 'minimize', 'expression': 'sum(p * cost, over=g)'}, @@ -896,7 +896,7 @@ def test_roots_are_the_trees_a_row_is_built_from(): def _footprint_of(constraint: str, objective: str) -> Footprint: return to_program( - override( + varied( TINY, constraints={'k': {'dims': ['g'], 'expression': constraint}}, objective={'sense': 'minimize', 'expression': objective}, @@ -946,7 +946,7 @@ def test_the_footprint_is_walked_once_and_held(dispatch_program): def test_a_named_expression_is_not_in_the_footprint(): """It builds no row, so counting it would answer wrongly about what is solved.""" - program = to_program(override(TINY, expressions={'spend': 'sum(p * cost, over=g)'})) + program = to_program(varied(TINY, expressions={'spend': 'sum(p * cost, over=g)'})) assert Parameter not in program.footprint.kinds, "the named expression's parameter reaches no row" assert Parameter in {type(n) for n in walk(program.expressions['spend'].expression)}, ( @@ -960,7 +960,7 @@ def test_a_dimension_carries_the_dtype_its_labels_are_checked_against(): A dimension is read from whatever table carries it, so nothing downstream can infer what the column should have been. """ - program = to_program(override(TINY, **{'dimensions.t': {'dtype': 'int'}})) + program = to_program(varied(TINY, **{'dimensions.t': {'dtype': 'int'}})) assert program.dimensions['t'].dtype == 'int', 'a declared dtype reaches the plan' assert program.dimensions['g'].dtype == 'str', "and the schema's default does too, rather than nothing" @@ -1084,14 +1084,14 @@ def test_a_cased_expression_is_readable_by_the_name_the_file_wrote(): ) def test_an_entry_is_in_the_math_where_the_objective_or_a_constraint_inlines_it(patch, in_math): """`in_math` is usage, not shape: one affine body is in the math when a row inlines it, however indirectly, and a reported quantity when none does.""" - program = to_program(override(TINY, expressions={'spend': 'sum(p * cost, over=g)'}, **patch)) + program = to_program(varied(TINY, expressions={'spend': 'sum(p * cost, over=g)'}, **patch)) assert program.expressions['spend'].in_math is in_math def test_an_entry_reached_only_through_another_is_in_the_math_with_it(): """The whole chain is in the math, not only the entry a row names: the constraint inlines `twice`, and `twice` inlines `spend`.""" program = to_program( - override( + varied( TINY, expressions={'spend': 'sum(p * cost, over=g)', 'twice': 'spend * 2'}, **{'constraints.c.expression': 'twice >= 1'}, @@ -1106,7 +1106,7 @@ def test_an_entry_reached_only_through_another_is_in_the_math_with_it(): def test_a_macro_formal_named_like_an_entry_keeps_the_entry_out_of_the_math(): """A formal shadows the entry inside the template, so the row inlines the argument, not the same-named entry.""" program = to_program( - override( + varied( TINY, expressions={'spend': 'sum(p * cost, over=g)'}, macros={'scaled': {'args': ['spend'], 'template': 'spend * 2'}}, @@ -1120,7 +1120,7 @@ def test_a_macro_formal_named_like_an_entry_keeps_the_entry_out_of_the_math(): def test_an_entry_that_reads_a_dual_is_a_reported_quantity(): """A dual is read after the solve, so an entry calling one is never in the math: it lowers to a Dual leaf and stays reported.""" - program = to_program(override(TINY, expressions={'shadow_price': 'dual(c)'})) + program = to_program(varied(TINY, expressions={'shadow_price': 'dual(c)'})) declaration = program.expressions['shadow_price'] assert declaration.in_math is False, 'the entry reading a dual is reported, never in the math' assert isinstance(declaration.expression, Dual), 'and it lowers to a Dual leaf' diff --git a/tests/test_piecewise.py b/tests/test_piecewise.py index 4da273cb..e1b705b0 100644 --- a/tests/test_piecewise.py +++ b/tests/test_piecewise.py @@ -18,7 +18,7 @@ from math_spec.lowering import lower_program, to_program from math_spec.piecewise import expand_piecewise from math_spec.program import Holds, assumption_message -from tests.fixtures import DISPATCH_MODEL, override, raw_of, schema_of +from tests.fixtures import DISPATCH_MODEL, raw_of, schema_of, varied #: Larger than a minimal probe on purpose: a curve that exercises adjacency #: binaries and links is not something a smaller one can stand in for. @@ -56,12 +56,12 @@ sense: minimize expression: sum(op_cost, over=snapshot) """ -GATED = override( +GATED = varied( raw_of(NONCONVEX_YAML), **{'variables.u': {'dims': ['snapshot'], 'domain': 'binary'}, 'piecewise.cost_curve.activity': 'u'}, ) #: The convex curve stated as its segment lines, plus a binary the method cannot gate on. -LP = override( +LP = varied( raw_of(NONCONVEX_YAML), **{ 'piecewise.cost_curve.method': 'lp', @@ -70,9 +70,9 @@ }, ) #: The ``lp`` curve masked by one of its own values-parameters, so every check a block can carry is on it. -LP_MASKED = override(LP, **{'piecewise.cost_curve.points': 'bp_x'}) +LP_MASKED = varied(LP, **{'piecewise.cost_curve.points': 'bp_x'}) #: Two dims in the frame, so the emitted ``dims`` has an order to get wrong. -TWO_DIM = override( +TWO_DIM = varied( raw_of(NONCONVEX_YAML), **{ 'dimensions.generator': {'dtype': 'str'}, @@ -344,7 +344,7 @@ def test_a_gate_that_is_not_a_variable_is_refused(activity, match): #: ``lp`` bounded the other way: the same curve read as its lower envelope. -LP_CONCAVE = override( +LP_CONCAVE = varied( raw_of(NONCONVEX_YAML), **{ 'piecewise.cost_curve.method': 'lp', @@ -352,11 +352,11 @@ def test_a_gate_that_is_not_a_variable_is_refused(activity, match): }, ) #: Both links pinned, so nothing says which way the weights are pushed. -CONVEX = override(raw_of(NONCONVEX_YAML), **{'piecewise.cost_curve.method': 'convex'}) +CONVEX = varied(raw_of(NONCONVEX_YAML), **{'piecewise.cost_curve.method': 'convex'}) #: The hull bounded below, which is the same relaxation ``lp`` states as its segment lines. -CONVEX_BOUNDED = override(CONVEX, **{'piecewise.cost_curve.links': [['p', 'bp_x'], ['op_cost', 'bp_y', '>=']]}) +CONVEX_BOUNDED = varied(CONVEX, **{'piecewise.cost_curve.links': [['p', 'bp_x'], ['op_cost', 'bp_y', '>=']]}) #: The hull bounded above, so the binding side is the upper one. -CONVEX_BOUNDED_BELOW = override(CONVEX, **{'piecewise.cost_curve.links': [['p', 'bp_x'], ['op_cost', 'bp_y', '<=']]}) +CONVEX_BOUNDED_BELOW = varied(CONVEX, **{'piecewise.cost_curve.links': [['p', 'bp_x'], ['op_cost', 'bp_y', '<=']]}) #: Named so the completeness check below can read the answers back off them. @@ -412,7 +412,7 @@ def test_a_masked_lp_curve_sits_its_rows_on_predicates_rather_than_on_parameters def test_a_file_supplied_mask_is_what_the_contiguity_condition_reads(): """A ``points:`` naming a parameter the file declared is bound like any other, and the mask check names it.""" program = to_program( - override(LP, **{'parameters.reach': {'dims': ['bp'], 'dtype': 'bool'}, 'piecewise.cost_curve.points': 'reach'}) + varied(LP, **{'parameters.reach': {'dims': ['bp'], 'dtype': 'bool'}, 'piecewise.cost_curve.points': 'reach'}) ) contiguous = program.assumptions['cost_curve_contiguous'] @@ -450,7 +450,7 @@ def test_a_block_assumes_of_its_data_what_the_method_implies(): def test_a_curves_conditions_cannot_collide_with_a_written_assumption(): """A condition a method states is a name the block emits, and a file writing it is the collision every emitted name is.""" with pytest.raises(LanguageError, match="emitted assumption 'cost_curve_increasing' collides"): - to_program(override(LP, assumptions={'cost_curve_increasing': 'bp_x > 0'})) + to_program(varied(LP, assumptions={'cost_curve_increasing': 'bp_x > 0'})) @pytest.mark.parametrize('suffix', ['increasing', 'curvature', 'breakpoints', 'contiguous']) diff --git a/tests/test_public_surface.py b/tests/test_public_surface.py index a7dbc0e8..b53c580a 100644 --- a/tests/test_public_surface.py +++ b/tests/test_public_surface.py @@ -25,6 +25,8 @@ { # the two public states, and the conversion to each 'Spec', 'to_spec', 'program', 'to_program', + # the two file-level verbs: peers composed, and patches laid over a base + 'merge', 'override', # the error tree 'MathSpecError', 'LanguageError', 'SchemaError', 'DimensionError', 'PiecewiseExpansionError', 'did_you_mean', 'schema_error', diff --git a/tests/test_reading_page.py b/tests/test_reading_page.py index 7f932ecf..909569e0 100644 --- a/tests/test_reading_page.py +++ b/tests/test_reading_page.py @@ -55,6 +55,6 @@ def test_the_page_shows_the_declarations_the_expansion_emits(tmp_path, monkeypat exec(compile(code, str(PAGE), 'exec'), namespace) claims.extend(_claims(code)) - assert len(claims) == 19, 'every `expression # value` line on the page is checked; one without one is not' + assert len(claims) == 22, 'every `expression # value` line on the page is checked; one without one is not' for expression, claimed in claims: assert eval(expression, namespace) == claimed, f'reading.md says `{expression}` is {claimed}' diff --git a/tests/test_sos.py b/tests/test_sos.py index 9c54061d..8127211e 100644 --- a/tests/test_sos.py +++ b/tests/test_sos.py @@ -15,10 +15,10 @@ from math_spec.errors import SchemaError from math_spec.lowering import to_program -from tests.fixtures import SMALL_MODEL, override, schema_of +from tests.fixtures import SMALL_MODEL, schema_of, varied #: A set over a bounded member, which is the smallest model `expand('sos')` acts on. -PICKED = override( +PICKED = varied( SMALL_MODEL, **{ 'parameters.floor': {'dims': ['g']}, @@ -53,7 +53,7 @@ def test_a_set_of_order_one_admits_a_member_only_where_its_own_binary_is_one(): def test_a_set_of_order_two_admits_a_member_in_either_half_of_one_segment(): - schema = schema_of(override(PICKED, **{'sos.pick.type': 2})) + schema = schema_of(varied(PICKED, **{'sos.pick.type': 2})) expanded = schema.expand('sos') assert expanded.constraints['pick_adjacency'].expression == ( @@ -86,7 +86,7 @@ def test_an_unpicked_member_is_held_at_zero_from_the_sides_its_bounds_state(boun """A row multiplies by a bound rather than reading it, so a parameter needs no load-time knowledge of its value; and `x >= 0 * seg` is what the variable's own bound already says, so the second row is written only where it says more.""" - schema = schema_of(override(PICKED, **{'variables.p.bounds': bounds})) + schema = schema_of(varied(PICKED, **{'variables.p.bounds': bounds})) expanded = schema.expand('sos') written = {name: c.expression for name, c in expanded.constraints.items() if name.startswith('pick_nonzero')} @@ -99,19 +99,19 @@ def test_a_set_carries_no_coefficient_of_its_own(): a solver taking the set natively ignored it either way. So the coefficient is the member's own bound and nothing else, and the key is not in the language.""" with pytest.raises(SchemaError, match="unknown key 'bound' in a sos declaration"): - schema_of(override(PICKED, **{'sos.pick.bound': 500})) + schema_of(varied(PICKED, **{'sos.pick.bound': 500})) def test_a_coefficient_of_one_is_left_out_of_the_row_rather_than_printed(): """A binary carries no bounds block, and its upper bound is 1 all the same — which multiplies nothing.""" - schema = schema_of(override(PICKED, **{'variables.p': {'dims': ['g'], 'domain': 'binary'}})) + schema = schema_of(varied(PICKED, **{'variables.p': {'dims': ['g'], 'domain': 'binary'}})) assert schema.expand('sos').constraints['pick_nonzero'].expression == 'p <= (pick_seg)' def test_the_emitted_binary_carries_the_members_own_mask(): """A member that does not exist is not in the set, so its binary is not there either.""" - schema = schema_of(override(PICKED, **{'variables.p.where': 'flag'})) + schema = schema_of(varied(PICKED, **{'variables.p.where': 'flag'})) assert schema.expand('sos').variables['pick_seg'].where == 'flag' @@ -125,7 +125,7 @@ def test_a_set_emits_no_parameter_so_the_same_sources_bind_both(): def test_the_adjacency_method_is_the_sos2_curve_with_its_set_written_out(): """The one spelling of the binaries, so the two methods cannot drift apart.""" sos2 = to_program(schema_of(CURVE).expand()) - adjacency = to_program(schema_of(override(CURVE, **{'piecewise.cost_curve.method': 'adjacency'}))) + adjacency = to_program(schema_of(varied(CURVE, **{'piecewise.cost_curve.method': 'adjacency'}))) assert sos2.variables == adjacency.variables assert sos2.constraints == adjacency.constraints diff --git a/tests/test_validation.py b/tests/test_validation.py index ac78541a..97063ca8 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -19,20 +19,20 @@ from math_spec.resolution import Namespace from math_spec.typesetting import to_markdown from math_spec.validation import to_spec -from tests.fixtures import DISPATCH_MODEL, OPERATOR_PROBES, SMALL_MODEL, override, where_of +from tests.fixtures import DISPATCH_MODEL, OPERATOR_PROBES, SMALL_MODEL, varied, where_of if TYPE_CHECKING: from math_spec.model import Spec def _schema(**patch) -> Spec: - return to_spec(override(SMALL_MODEL, **patch)) + return to_spec(varied(SMALL_MODEL, **patch)) def _refusal(model: dict[str, Any] = SMALL_MODEL, **patch: Any) -> str: """The message `to_spec` refuses *model* patched with — and it has to refuse.""" with pytest.raises(LanguageError) as caught: - to_spec(override(model, **patch)) + to_spec(varied(model, **patch)) return str(caught.value) @@ -175,7 +175,7 @@ def test_an_unreferenced_nonlinear_entry_loads_and_is_reported(self): definition like any other — rather than degree-checking a declaration nothing consumes. """ - model = override(SMALL_MODEL, expressions={'lcoe': 'c / sum(p)'}) + model = varied(SMALL_MODEL, expressions={'lcoe': 'c / sum(p)'}) assert to_program(model).expressions['lcoe'].in_math is False, ( 'the unread nonlinear body loads rather than being refused, and nothing in the math reads it' ) @@ -206,7 +206,7 @@ def _kwarg_model(expression: str, dims: list[str] | None = None) -> dict[str, An class TestDual: """`dual(c)`: a primitive legal only in an entry the math never reads, its argument a constraint name resolved against constraints alone.""" - BASE = override(SMALL_MODEL, **{'constraints.lim': {'dims': ['g'], 'expression': 'p <= c'}}) + BASE = varied(SMALL_MODEL, **{'constraints.lim': {'dims': ['g'], 'expression': 'p <= c'}}) @pytest.mark.parametrize( ('patch', 'fragments'), @@ -266,13 +266,13 @@ class TestDual: ) def test_a_dual_out_of_place_is_refused(self, patch, fragments): with pytest.raises(LanguageError) as exc: - to_spec(override(self.BASE, **patch)) + to_spec(varied(self.BASE, **patch)) for fragment in fragments: assert fragment in str(exc.value) def test_a_dual_loads_in_an_expressions_entry(self): """The one place it is legal: an ``expressions:`` entry naming a declared constraint, which nothing in the math reads.""" - assert to_spec(override(self.BASE, expressions={'price': 'dual(lim)'})).expressions['price'] + assert to_spec(varied(self.BASE, expressions={'price': 'dual(lim)'})).expressions['price'] class TestDimensionKwargs: @@ -1892,15 +1892,13 @@ def test_an_expression_too_deep_to_walk_fails_as_a_language_error(patch, nests): nothing naming the file, the declaration, or what to write instead. """ with pytest.raises(LanguageError, match='past the 100 levels'): - to_spec(override(DISPATCH_MODEL, **patch)) + to_spec(varied(DISPATCH_MODEL, **patch)) def test_a_name_may_open_with_an_underscore(): """`expressions.md` said a name opens with a letter while the schema and the grammar both admitted `_`, so the page refused what the language accepts.""" schema = to_spec( - override( - DISPATCH_MODEL, **{'parameters._reserve': {'dims': ['generator']}, 'variables.p.where': '_reserve > 0'} - ) + varied(DISPATCH_MODEL, **{'parameters._reserve': {'dims': ['generator']}, 'variables.p.where': '_reserve > 0'}) ) assert '_reserve' in schema.parameters, 'a leading underscore is a name, as NAME and the schema both say' @@ -1931,7 +1929,7 @@ def record(*args, **kwargs): monkeypatch.setattr(module, door.__name__, recorded(door)) spec = to_spec( - override( + varied( DISPATCH_MODEL, **{ 'variables.p.where': 'p_max > 0', diff --git a/tests/typesetting/test_cases.py b/tests/typesetting/test_cases.py index 70da82ee..88039218 100644 --- a/tests/typesetting/test_cases.py +++ b/tests/typesetting/test_cases.py @@ -15,7 +15,7 @@ from math_spec.piecewise import expand_piecewise from math_spec.typesetting.symbols import chosen_expressions from tests.fixtures import DISPATCH_MODEL as DISPATCH -from tests.fixtures import override +from tests.fixtures import varied from tests.typesetting.fixtures import EVERY_FORMAT if TYPE_CHECKING: @@ -31,7 +31,7 @@ } #: The dispatch model, with a quantity defined by region and a constraint using it. -CASED = override( +CASED = varied( DISPATCH, **{ 'expressions.headroom': BY_REGION, @@ -41,7 +41,7 @@ #: One cased expression reached only through another's case. `opening_cost` has #: no variable of its own — its route to one runs through `headroom`. -_NESTED = override( +_NESTED = varied( CASED, **{ 'expressions.headroom.cases.opening.expression': 'p', @@ -83,7 +83,7 @@ def test_the_last_arm_prints_as_the_fallback_rather_than_a_condition(name: Forma @EVERY_FORMAT def test_a_declared_definition_prints_whether_or_not_a_row_names_it(name: FormatName, fmt: Format): """The rule a variable's domain follows: the file declared it, so it prints.""" - unused = override(CASED, **{'constraints.spare.expression': 'p <= p_max'}) + unused = varied(CASED, **{'constraints.spare.expression': 'p <= p_max'}) rendered = typeset(unused, name, legend=False) assert rendered.count(fmt.subscript(fmt.upright('headroom'), ['t', 'g'])) == 1, 'the definition, and no use' assert 'Definitions' in rendered @@ -111,7 +111,7 @@ def test_a_cased_expression_is_chosen_when_a_value_reaching_it_is( returns, and one case holding a variable is enough. The `otherwise:` is a value of the quantity like any case's, so a walk reading only the cases prints a solved quantity upright.""" - rendered = typeset(override(CASED, **patch), name, legend=False) + rendered = typeset(varied(CASED, **patch), name, legend=False) italic, upright = (fmt.subscript(face('headroom'), ['t', 'g']) for face in (fmt.italic, fmt.upright)) assert (italic in rendered) is chosen, 'the quantity is chosen exactly when a value reaching it holds a variable' assert (upright in rendered) is not chosen, 'and given otherwise, however its regions are chosen' @@ -145,7 +145,7 @@ def test_the_table_may_rename_a_named_expression_cased_or_plain(): tex = to_latex(CASED, symbols={'notation': 'latex', 'names': {'headroom': r'\bar h'}}, legend=False) assert r'\bar h_{t,g}' in tex - plain = override(DISPATCH, **{'expressions.supply': 'sum(p, over=generator)'}) + plain = varied(DISPATCH, **{'expressions.supply': 'sum(p, over=generator)'}) tex = to_latex(plain, symbols={'notation': 'latex', 'names': {'supply': 's'}}, legend=False) assert 's_{t} & =' in tex, 'the definition prints under the spelling the table gave' @@ -159,7 +159,7 @@ def test_the_definitions_print_in_declaration_order(): carrying two of them would churn on every regeneration. """ declared = ['alpha', 'bravo', 'charlie', 'delta', 'echo', 'foxtrot'] - tex = to_latex(override(CASED, **{f'expressions.{n}': BY_REGION for n in declared}), legend=False) + tex = to_latex(varied(CASED, **{f'expressions.{n}': BY_REGION for n in declared}), legend=False) section = tex[tex.index('Definitions') : tex.index('Variable domains')] labels = re.findall(r'^\\text\{(\w+)\} &&', section, flags=re.MULTILINE) assert labels == ['headroom', *declared], "declaration order, the file's own" diff --git a/tests/typesetting/test_declaration.py b/tests/typesetting/test_declaration.py index 2a96461d..924e78cc 100644 --- a/tests/typesetting/test_declaration.py +++ b/tests/typesetting/test_declaration.py @@ -6,13 +6,13 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import pytest from math_spec import LanguageError, SchemaError, typeset_declaration from tests.fixtures import DISPATCH_MODEL as DISPATCH -from tests.fixtures import override +from tests.fixtures import varied from tests.typesetting.fixtures import EVERY_FORMAT from tests.typesetting.test_cases import CASED @@ -22,7 +22,7 @@ #: A variable-carrying reduction, a scalar reduction, a data-only body, and a #: constraint reading the first — the shapes a line has to read. -PLAIN = override( +PLAIN = varied( DISPATCH, **{ 'expressions.spend': 'sum(p * cost, over=generator)', @@ -114,7 +114,7 @@ def test_a_symbol_table_renames_an_expression_either_way(): def test_a_body_naming_another_expression_inlines_it_on_its_own_and_names_it_in_the_document(): """On its own, `double_spend` is complete only with `spend` substituted; in the document both are defined, each once, so a use prints the symbol.""" - model = override(PLAIN, **{'expressions.double_spend': 'spend * 2'}) + model = varied(PLAIN, **{'expressions.double_spend': 'spend * 2'}) assert typeset_declaration(model, 'double_spend', 'latex') == ( r'\mathit{double\_spend}_{t} = \left( \sum_{g \in \mathcal{G}} p_{t,g} \cdot \mathrm{cost}_{g} \right) ' r'\cdot 2 \qquad \forall\, t \in \mathcal{T}' @@ -124,25 +124,42 @@ def test_a_body_naming_another_expression_inlines_it_on_its_own_and_names_it_in_ ) +#: A column and a row family this file reads, each named by something that prints. +GIVEN = varied( + PLAIN, + **{ + 'given.variables.flow': {'dims': ['snapshot']}, + 'given.constraints.clearing': {'dims': ['snapshot']}, + 'expressions.price': 'dual(clearing)', + 'expressions.drawn': 'flow * 2', + }, +) + + @pytest.mark.parametrize( - ('name', 'match'), + ('model', 'name', 'match'), [ pytest.param( + PLAIN, 'spent', r"'spent' is not a named expression, constraint, assumption, curve or variable.*spend", id='a-near-miss', ), - pytest.param('objective', r"'objective' is not a named expression", id='the-objective-has-no-name'), + pytest.param(PLAIN, 'objective', r"'objective' is not a named expression", id='the-objective-has-no-name'), + pytest.param(GIVEN, 'flow', r"'flow' is a given variable.*no line of its own.*legend", id='a-given-variable'), + pytest.param( + GIVEN, 'clearing', r"'clearing' is a given constraint.*no line of its own.*legend", id='a-given-constraint' + ), ], ) -def test_a_name_declared_as_none_of_the_four_is_refused(name: str, match: str): +def test_a_name_that_prints_no_line_of_its_own_is_refused(model: dict[str, Any], name: str, match: str): with pytest.raises(SchemaError, match=match): - typeset_declaration(PLAIN, name, 'latex') + typeset_declaration(model, name, 'latex') 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'}}) + model = varied(PLAIN, **{'constraints.p': {'dims': ['snapshot', 'generator'], 'expression': 'p <= 1'}}) with pytest.raises(SchemaError, match="'p' is declared twice, as constraint and as variable"): typeset_declaration(model, 'p', 'latex') @@ -153,6 +170,6 @@ def test_a_format_nobody_spells_is_refused(): def test_an_invalid_model_is_refused_before_anything_renders(): - broken = override(PLAIN, **{'expressions.spend': 'p * nonexistent'}) + broken = varied(PLAIN, **{'expressions.spend': 'p * nonexistent'}) with pytest.raises(LanguageError): typeset_declaration(broken, 'spend', 'latex') diff --git a/tests/typesetting/test_formats.py b/tests/typesetting/test_formats.py index 12498e5f..da438d3d 100644 --- a/tests/typesetting/test_formats.py +++ b/tests/typesetting/test_formats.py @@ -14,7 +14,7 @@ from math_spec import to_spec, typeset_declaration from math_spec.typesetting import FORMATS, to_latex, to_markdown, to_typst, typeset from math_spec.typesetting.format import OPERATOR_NAMES -from tests.fixtures import DISPATCH_MODEL, override +from tests.fixtures import DISPATCH_MODEL, varied from tests.typesetting import golden from tests.typesetting.fixtures import EVERY_FORMAT, TYPST_SYMBOLS @@ -116,7 +116,7 @@ def test_every_typst_operator_compiles(typst, tmp_path: Path): def test_the_model_description_opens_the_document(name: FormatName, fmt: Format, options: dict): """What the file says it is, printed before anything it declares — and printed with `legend=False` too, since it is not a symbol table.""" - described = override(DISPATCH_MODEL, description='least-cost dispatch of a generator fleet') + described = varied(DISPATCH_MODEL, description='least-cost dispatch of a generator fleet') out = typeset(described, name, **options) assert 'least-cost dispatch of a generator fleet' in out assert out.index('least-cost dispatch') < out.index(fmt.operators['minimize']), 'it opens the document' @@ -171,7 +171,7 @@ def test_a_description_sets_as_text_rather_than_as_markup(notation: str, positio one. """ where = 'description' if position == 'file' else 'parameters.load.description' - out = typeset(override(DISPATCH_MODEL, **{where: SPECIALS}), notation) + out = typeset(varied(DISPATCH_MODEL, **{where: SPECIALS}), notation) for expected in ESCAPED[notation]: assert expected in out, 'each special is escaped, and a character the notation reads as text is left alone' assert SPECIALS not in out, 'the raw prose reached the document unescaped' @@ -197,7 +197,7 @@ def test_a_backticked_name_in_a_description_sets_in_monospace(notation: str): span is part of the language's reading of prose rather than a Markdown habit that two formats printed as characters (#401). """ - out = typeset(override(DISPATCH_MODEL, **{'parameters.load.description': SPANNED}), notation) + out = typeset(varied(DISPATCH_MODEL, **{'parameters.load.description': SPANNED}), notation) assert SPANNED_AS[notation] in out, ( 'the span is monospace, its underscore escaped where the format needs it, and the lone backtick a character' ) @@ -207,7 +207,7 @@ def test_a_backticked_name_in_a_description_sets_in_monospace(notation: str): 'model', [ pytest.param(golden.MODEL, id='the-golden-model'), - pytest.param(override(DISPATCH_MODEL, description=SPECIALS), id='every-special'), + pytest.param(varied(DISPATCH_MODEL, description=SPECIALS), id='every-special'), ], ) def test_a_description_of_every_special_compiles(typst, tmp_path: Path, model): @@ -223,7 +223,7 @@ def test_a_description_of_every_special_compiles(typst, tmp_path: Path, model): def test_typst_prose_escapes_what_typst_reads_as_markup(typst, tmp_path: Path): - described = override(DISPATCH_MODEL, description='- a list? a // comment [a link] and = a heading') + described = varied(DISPATCH_MODEL, description='- a list? a // comment [a link] and = a heading') typ = to_typst(described, standalone=True) assert r'\- a list? a \/\/ comment \[a link\] and = a heading' in typ, ( 'a leading list marker, a comment and a link are escaped, and an inline `=` is no heading' @@ -234,7 +234,7 @@ def test_typst_prose_escapes_what_typst_reads_as_markup(typst, tmp_path: Path): def test_markdown_glossary_cells_survive_a_pipe_and_a_newline(): - described = override(DISPATCH_MODEL, **{'parameters.load.description': 'a | b\nc'}) + described = varied(DISPATCH_MODEL, **{'parameters.load.description': 'a | b\nc'}) md = to_markdown(described) assert r'| `load` over $`\mathcal{T}`$ — a \| b c |' in md, ( 'the pipe is escaped and the newline folded, so the cell stays one cell' diff --git a/tests/typesetting/test_symbols.py b/tests/typesetting/test_symbols.py index 872d3219..cbdcd431 100644 --- a/tests/typesetting/test_symbols.py +++ b/tests/typesetting/test_symbols.py @@ -13,7 +13,7 @@ from math_spec.errors import SchemaError from math_spec.typesetting import SymbolTable, to_latex, to_markdown, to_typst, typeset from math_spec.validation import to_spec -from tests.fixtures import DISPATCH_MODEL, override +from tests.fixtures import DISPATCH_MODEL, varied from tests.typesetting.fixtures import EVERY_FORMAT, TYPST_SYMBOLS if TYPE_CHECKING: @@ -21,7 +21,7 @@ from math_spec.typesetting.format import Format -WITH_MARGINAL_COST = override( +WITH_MARGINAL_COST = varied( DISPATCH_MODEL, **{'parameters.marginal_cost': {'dims': ['generator']}, 'objective.expression': 'sum(p * marginal_cost)'}, ) @@ -56,7 +56,7 @@ def test_the_table_prints_verbatim_and_the_rest_is_still_derived(render, symbols assert fragment in out -DESCRIBED = override( +DESCRIBED = varied( DISPATCH_MODEL, **{ 'dimensions.generator.description': 'dispatchable units', @@ -96,7 +96,7 @@ def test_a_named_expression_has_a_legend_row_exactly_while_its_symbol_prints(nam #: The dispatch model with a curve on it, so one model has two readings and one #: table has to spell both. -CURVED = override( +CURVED = varied( DISPATCH_MODEL, **{ 'dimensions.bp': {'dtype': 'int'}, diff --git a/tests/typesetting/test_walk.py b/tests/typesetting/test_walk.py index 86a93ace..ee1f93de 100644 --- a/tests/typesetting/test_walk.py +++ b/tests/typesetting/test_walk.py @@ -16,7 +16,7 @@ from math_spec.typesetting.format import OPERATOR_NAMES 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, EXAMPLES, OPERATOR_PROBES, override +from tests.fixtures import DISPATCH_MODEL, EXAMPLES, OPERATOR_PROBES, varied from tests.typesetting import golden from tests.typesetting.fixtures import EVERY_FORMAT, LATEX @@ -55,7 +55,7 @@ def test_a_dimension_index_never_steals_a_letter_a_variable_owns(name: FormatNam @EVERY_FORMAT def test_a_where_lands_on_the_quantifier_not_in_the_equation(name: FormatName, fmt: Format): """A mask is row absence, so it belongs to the ∀ that names the rows.""" - model = override(DISPATCH_MODEL, **{'variables.p.where': 'p_max > 0'}) + model = varied(DISPATCH_MODEL, **{'variables.p.where': 'p_max > 0'}) text = typeset(model, name, legend=False) forall, such_that = fmt.operators['forall'], fmt.operators['such_that'] masked = [line for line in text.splitlines() if such_that in line] @@ -231,7 +231,7 @@ def test_translations_that_disagree_at_the_edge_do_not_merge(name: FormatName, f @EVERY_FORMAT def test_a_negation_under_a_plus_is_the_subtraction_it_means(name: FormatName, fmt: Format): """`a + -b` is a spelling nobody uses, and the walk was printing it.""" - model = override(DISPATCH_MODEL, **{'objective.expression': 'sum(p) + -sum(p)'}) + model = varied(DISPATCH_MODEL, **{'objective.expression': 'sum(p) + -sum(p)'}) text = typeset(model, name) assert f'{fmt.operators["plus"]} {fmt.operators["minus"]}' not in text, 'a plus over a negation is a subtraction' assert fmt.operators['minus'] in text, 'the subtraction it folded into should still print' @@ -245,10 +245,10 @@ def test_a_mask_that_is_only_true_prints_no_condition(name: FormatName, fmt: For Nested it printed — `\\top \\wedge x` — while the program lowered the same mask to `x`: two readers of one file disagreeing about what it says. """ - always = override(DISPATCH_MODEL, **{'constraints.balance.where': 'True'}) + always = varied(DISPATCH_MODEL, **{'constraints.balance.where': 'True'}) assert typeset(always, name) == typeset(DISPATCH_MODEL, name), 'a mask every row passes is no mask at all' - nested = override(DISPATCH_MODEL, **{'constraints.balance.where': 'True AND load > 0'}) - plain = override(DISPATCH_MODEL, **{'constraints.balance.where': 'load > 0'}) + nested = varied(DISPATCH_MODEL, **{'constraints.balance.where': 'True AND load > 0'}) + plain = varied(DISPATCH_MODEL, **{'constraints.balance.where': 'load > 0'}) assert typeset(nested, name) == typeset(plain, name), 'a literal under a connective is folded before it prints' @@ -362,7 +362,7 @@ def test_a_description_is_joined_to_its_name_by_a_dash_the_format_renders(name: dash in two of the three outputs and as three hyphens in the one whose whole promise is that it renders where it lands. """ - described = override(DISPATCH_MODEL, **{'parameters.cost.description': 'marginal cost'}) + described = varied(DISPATCH_MODEL, **{'parameters.cost.description': 'marginal cost'}) text = typeset(described, name) assert f'{fmt.dash} marginal cost' in text if fmt is FORMATS['markdown']: @@ -374,7 +374,7 @@ def test_a_named_expression_prints_once_as_a_definition_and_by_symbol_where_used """The file names the quantity, so the page does: a use prints the symbol and the body prints once under Definitions. A macro is sugar with no identity of its own, so it is expanded away either way.""" - model = override( + model = varied( DISPATCH_MODEL, **{'expressions.supply': 'sum(p, over=generator)', 'constraints.balance.expression': 'supply == load'}, ) @@ -386,7 +386,7 @@ def test_a_named_expression_prints_once_as_a_definition_and_by_symbol_where_used @EVERY_FORMAT def test_inlining_substitutes_a_named_expression_where_it_is_used(name: FormatName, fmt: Format): """What prints then is the math a backend builds, not the name it was spelled with.""" - model = override( + model = varied( DISPATCH_MODEL, **{'expressions.supply': 'sum(p, over=generator)', 'constraints.balance.expression': 'supply == load'}, ) @@ -397,7 +397,7 @@ def test_inlining_substitutes_a_named_expression_where_it_is_used(name: FormatNa @EVERY_FORMAT def test_an_invalid_model_is_refused_before_anything_renders(name: FormatName, fmt: Format): - broken = override(DISPATCH_MODEL, **{'objective.expression': 'p * nonexistent'}) + broken = varied(DISPATCH_MODEL, **{'objective.expression': 'p * nonexistent'}) with pytest.raises(LanguageError): typeset(broken, name) @@ -407,7 +407,7 @@ def test_inlining_keeps_the_definition_of_an_entry_the_math_never_reads(name: Fo """Substitution has nowhere to put it: nothing in the objective or a constraint names it, so dropping its definition would drop the quantity from the page entirely.""" - model = override( + model = varied( DISPATCH_MODEL, **{ 'expressions.supply': 'sum(p, over=generator)', @@ -425,7 +425,7 @@ def test_a_dual_prints_the_constraint_symbol_not_a_same_named_variable(name: For """`dual(c)` subscripts λ from a map of its own, so a variable sharing the constraint's name — a legal collision, constraints sit outside the flat namespace (#74) — cannot lend the dual its italic letter.""" - model = override( + model = varied( DISPATCH_MODEL, **{ 'variables.balance': {'dims': ['snapshot'], 'bounds': {'lower': 0}}, @@ -446,7 +446,7 @@ def test_an_entry_reading_a_dual_prints_italic(name: FormatName, fmt: Format): """Upright is what the model is given, and a shadow price is not: no data hands it over, the solve settles it — the same reason a variable is italic, though a dual carries no variable for `carries_variable` to find.""" - model = override(DISPATCH_MODEL, **{'expressions.mp': 'dual(balance)'}) + model = varied(DISPATCH_MODEL, **{'expressions.mp': 'dual(balance)'}) assert fmt.subscript(fmt.italic('mp'), ['t']) in typeset(model, name, legend=False), ( 'the entry is read off the solution, so its own symbol is italic' ) @@ -488,14 +488,14 @@ def test_a_given_quantity_is_upright(name: str, expected: str): r"""Upright is what the data supplies, and it admits no exception — not for a single letter, and not for a Greek name, where an italic `\eta` that might be either is worse than an upright `\mathrm{eta}` that is one.""" - assert _derive_name_symbol(name, frozenset({'p', 'soc'}), LATEX, given=True) == expected + assert _derive_name_symbol(name, frozenset({'p', 'soc'}), LATEX, upright=True) == expected @EVERY_FORMAT def test_a_name_that_is_a_greek_letter_prints_as_the_letter(name: FormatName, fmt: Format): """A variable called `theta` set as the italic word *theta* is the one derived symbol no paper would accept.""" - model = override(DISPATCH_MODEL, **{'variables.theta': {'dims': ['snapshot']}}) + model = varied(DISPATCH_MODEL, **{'variables.theta': {'dims': ['snapshot']}}) assert fmt.greek('theta') in typeset(model, name) @@ -551,7 +551,7 @@ def test_a_dimension_is_not_a_head_a_qualifier_hangs_off(name: FormatName, fmt: whether some unrelated dimension happened to share its prefix: declare a dimension named `tech` and `tech_cap` silently re-rendered. """ - model = override( + model = varied( DISPATCH_MODEL, **{'dimensions.zone': {'dtype': 'str'}, 'parameters.zone_cap': {'dims': ['zone']}}, ) @@ -607,16 +607,14 @@ def test_the_objective_shows_the_summations_the_file_wrote(name: FormatName, fmt @EVERY_FORMAT def test_two_sums_of_the_same_dims_stay_two_summations(name: FormatName, fmt: Format): """The file's structure survives to the page, even where it repeats itself.""" - text = typeset(override(MIXED, **{'objective.expression': 'sum(p * cost) + sum(p * cost)'}), name, legend=False) + text = typeset(varied(MIXED, **{'objective.expression': 'sum(p * cost) + sum(p * cost)'}), name, legend=False) assert summations(text, fmt) == 2, 'two written sums are two summations' @EVERY_FORMAT def test_a_subtracted_summation_keeps_the_sign_outside_it(name: FormatName, fmt: Format): """The sign is applied to the whole reduction, and the bracket says so.""" - text = typeset( - override(MIXED, **{'objective.expression': 'sum(p * cost) - sum(p_nom * capex)'}), name, legend=False - ) + text = typeset(varied(MIXED, **{'objective.expression': 'sum(p * cost) - sum(p_nom * capex)'}), name, legend=False) opener = fmt.parenthesise('BODY').split('BODY')[0] + over_generators(fmt) assert f'{fmt.operators["minus"]} {opener}' in text @@ -662,7 +660,7 @@ def test_every_operator_probe_renders(path, name: FormatName, fmt: Format): def _grouped(dims: list[str], expression: str) -> str: """The constraint `c` over *dims*, as the one line of LaTeX it prints.""" - model = override(UNREAD, **{'constraints.c': {'dims': dims, 'expression': expression}}) + model = varied(UNREAD, **{'constraints.c': {'dims': dims, 'expression': expression}}) return next(line for line in to_latex(model, legend=False).splitlines() if line.startswith(r'\text{c}')) @@ -712,7 +710,7 @@ def test_a_value_column_the_call_consumes_is_a_condition_like_a_produced_one(): def _row(expression: str, where: str | None = None, **patch: object) -> str: - model = override( + model = varied( BUSES, **{'constraints.k': {'dims': ['snapshot', 'generator'], 'expression': expression, 'where': where}}, **patch, @@ -829,7 +827,7 @@ def test_a_string_value_in_a_where_prints_as_a_quoted_label(name: FormatName, fm @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'}) + model = varied(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']) @@ -839,7 +837,7 @@ def test_a_comparison_of_expressions_prints_as_the_arithmetic_it_is(name: Format @EVERY_FORMAT def test_a_count_prints_as_the_size_of_the_set_the_predicate_admits(name: FormatName, fmt: Format): """A count is a cardinality over a set by comprehension, which is how a paper writes one.""" - model = override(DISPATCH_MODEL, **{'constraints.balance.where': 'count(p_max > 0, over=generator) >= 2'}) + model = varied(DISPATCH_MODEL, **{'constraints.balance.where': 'count(p_max > 0, over=generator) >= 2'}) text = typeset(model, name, legend=False) p_max = fmt.subscript(fmt.superscript(fmt.upright('p'), fmt.upright('max')), ['g']) counted = fmt.set_of( @@ -852,7 +850,7 @@ def test_a_count_prints_as_the_size_of_the_set_the_predicate_admits(name: Format @EVERY_FORMAT def test_a_translated_predicate_prints_at_the_index_it_reads(name: FormatName, fmt: Format): """The translation shows at the leaf, as it does for arithmetic — it emits no operator of its own.""" - model = override( + model = varied( DISPATCH_MODEL, **{'constraints.balance.where': 'load AND NOT shift(load, along=snapshot, offset=1)'} ) text = typeset(model, name, legend=False) @@ -863,7 +861,7 @@ def test_a_translated_predicate_prints_at_the_index_it_reads(name: FormatName, f def test_a_count_along_a_dim_the_frame_carries_takes_a_primed_dummy(): """The set's index would otherwise shadow the frame's, and the two stand for different coordinates.""" - model = override( + model = varied( DISPATCH_MODEL, **{ 'constraints.balance': { @@ -880,7 +878,7 @@ def test_a_count_along_a_dim_the_frame_carries_takes_a_primed_dummy(): @EVERY_FORMAT def test_an_assumption_prints_under_its_own_heading(name: FormatName, fmt: Format): """What the data is held to prints with the math, because a reader checking it reads the same document.""" - model = override(DISPATCH_MODEL, assumptions={'costs_are_positive': 'cost > 0'}) + model = varied(DISPATCH_MODEL, assumptions={'costs_are_positive': 'cost > 0'}) text = typeset(model, name, legend=False) section = text[text.index('Assumptions') :] assert fmt.subscript(fmt.upright('cost'), ['g']) in section @@ -910,7 +908,7 @@ def test_a_curve_prints_what_its_method_assumes_of_the_breakpoints(name: FormatN def test_an_assumption_is_a_declaration_a_line_may_be_asked_for(): """`typeset_declaration` prints one line for a name; an assumption is now one of the names it takes.""" - model = override(DISPATCH_MODEL, assumptions={'costs_are_positive': 'cost > 0'}) + model = varied(DISPATCH_MODEL, assumptions={'costs_are_positive': 'cost > 0'}) assert typeset_declaration(model, 'costs_are_positive', 'latex') == ( r'\mathrm{cost}_{g} > 0 \qquad \forall\, g \in \mathcal{G}' ) @@ -986,12 +984,12 @@ def test_a_condition_a_method_states_is_a_line_that_may_be_asked_for_before_it_i ) def test_a_curve_prints_as_the_curve_it_states(patch: dict[str, Any], expected: str): """The block, not the rows it stands for: `typeset(spec.expand())` prints those.""" - assert expected in typeset_declaration(override(_CURVE, **patch), 'curve', 'latex') + assert expected in typeset_declaration(varied(_CURVE, **patch), 'curve', 'latex') def test_a_gate_that_does_not_exist_everywhere_prints_the_two_arms_the_expansion_writes_two_rows_for(): """The one place the walk decides what the weights sum to, which the expansion decides again.""" - spec = to_spec(override(_CURVE, **{'piecewise.curve.activity': 'warm'})) + spec = to_spec(varied(_CURVE, **{'piecewise.curve.activity': 'warm'})) rows = [name for name in spec.expand('piecewise').constraints if name.startswith('curve_convexity')] assert rows == ['curve_convexity', 'curve_convexity_ungated'], ( @@ -1005,7 +1003,7 @@ def test_a_gate_that_does_not_exist_everywhere_prints_the_two_arms_the_expansion def test_a_curve_prints_over_the_frame_its_expansion_builds_one_per_coordinate_of(): """Two homes for one union, so the line's quantifier is held to the rows the expansion emits.""" - model = override( + model = varied( _CURVE, **{ 'dimensions.generator': {'dtype': 'str'}, diff --git a/tools/_page.py b/tools/_page.py index 35a2b264..9048d7f8 100644 --- a/tools/_page.py +++ b/tools/_page.py @@ -9,6 +9,7 @@ import argparse import re import sys +import textwrap from pathlib import Path from typing import TYPE_CHECKING @@ -44,6 +45,11 @@ def inlined(markdown: str) -> str: return FENCE.sub(lambda m: f'$`{" ".join(m[1].splitlines())}`$', markdown) +def tab(title: str, body: str) -> str: + """One tab of a tabbed block: its title, and its body indented into it.""" + return f'=== "{title}"\n\n{textwrap.indent(body, " ")}' + + def without_header(path: Path) -> str: """The file from its first line that is neither blank nor a comment: the licence header is the repository's.""" lines = path.read_text().splitlines() diff --git a/tools/gallery.py b/tools/gallery.py index 74fba0f5..44d0badc 100644 --- a/tools/gallery.py +++ b/tools/gallery.py @@ -17,11 +17,13 @@ import re import textwrap from functools import partial -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any -from math_spec import to_spec +import yaml + +from math_spec import merge, override, to_spec from math_spec.typesetting import to_markdown -from tools._page import ROOT, sidecar_for, splice, without_header +from tools._page import ROOT, sidecar_for, splice, tab, without_header from tools._page import main as page_main from tools.notation import equations from tools.spec_math import OPERATORS, PROBES, _section, rendered_probe @@ -29,7 +31,14 @@ if TYPE_CHECKING: from pathlib import Path + from math_spec.model import Spec + PAGES = ROOT / 'docs' / 'examples' +LIBRARY = ROOT / 'examples' / 'library' +#: How `examples/library/` prints: one table for every fragment, the model +#: they compose and each variant laid over it. `symbols_for` cuts it to what +#: one model declares, because a table naming anything else is refused. +LIBRARY_SYMBOLS = ROOT / 'examples' / 'symbols' / 'library.yaml' BEGIN, END = '', '' #: Page -> the model it shows. One model per page, because a gallery of @@ -37,6 +46,20 @@ MODELS = { 'dispatch.md': ROOT / 'examples' / 'dispatch.yaml', 'commitment.md': ROOT / 'examples' / 'commitment.yaml', + 'library/surface.md': LIBRARY / 'surface.yaml', + 'library/generator.md': LIBRARY / 'generator.yaml', + 'library/load.md': LIBRARY / 'load.yaml', +} + +#: Page -> the fragments whose composition it shows, and the patches laid over +#: it. The model is what `merge` returns, which no file in the tree holds, so +#: the page carries it as YAML beside the math it prints. A patch has no math +#: of its own, so each one prints as the model it lands on, in a tab of its own. +COMPOSED = { + 'library/composed.md': ( + [LIBRARY / name for name in ('surface.yaml', 'generator.yaml', 'load.yaml')], + {path.stem: path for path in sorted((LIBRARY / 'variants').glob('*.yaml'))}, + ), } #: Page -> the model it shows one declaration at a time — its YAML, then the @@ -63,6 +86,48 @@ def model_block(path: Path) -> str: return f'```yaml\n{without_header(path)}\n```\n\n{to_markdown(path, numbered=False).strip()}' +def symbols_for(model: Spec) -> dict[str, Any]: + """The library's symbol table, cut to the dimensions and names *model* declares.""" + table = yaml.safe_load(LIBRARY_SYMBOLS.read_text()) + named = {*model.parameters, *model.variables, *model.given.variables, *model.expressions, *model.constraints} + return { + 'notation': table['notation'], + 'dimensions': {name: symbol for name, symbol in table['dimensions'].items() if name in model.dimensions}, + 'names': {name: symbol for name, symbol in table['names'].items() if name in named}, + } + + +def library_block(path: Path) -> str: + """One fragment of the library, then its document in the notation the whole library prints in.""" + model = to_spec(path) + printed = to_markdown(model, symbols=symbols_for(model), numbered=False) + return f'```yaml\n{without_header(path)}\n```\n\n{printed.strip()}' + + +def composed_block(fragments: list[Path], patches: dict[str, Path]) -> str: + """The mapping `merge` returns for *fragments* as YAML, then its document as composed and under each patch. + + The composed YAML is generated rather than committed, so the page cannot + show a composition the fragments beside it no longer make. A patch is + refused on its own, so its tab carries the patch file and then the whole + document of the model it is laid over. + """ + composed = merge({path.stem: path for path in fragments}) + model = to_spec(composed) + tabs = [tab('As composed', to_markdown(model, symbols=symbols_for(model), numbered=False).strip())] + for name, path in patches.items(): + patched = to_spec(override(composed, {name: path})) + tabs.append( + tab( + f'With {name}', + f'```yaml title="variants/{path.name}"\n{without_header(path)}\n```\n\n' + f'{to_markdown(patched, symbols=symbols_for(patched), numbered=False).strip()}', + ) + ) + dumped = yaml.safe_dump(composed, sort_keys=False, default_flow_style=None, allow_unicode=True, width=100).strip() + return f'```yaml\n{dumped}\n```\n\n' + '\n\n'.join(tabs) + + def probe_block() -> str: """Every operator probe: the model, then the one equation it renders.""" parts = [] @@ -188,6 +253,10 @@ def block(page: str) -> str: return probe_block() if page in DECLARED: return declared_block(DECLARED[page]) + if page in COMPOSED: + return composed_block(*COMPOSED[page]) + if MODELS[page].parent == LIBRARY: + return library_block(MODELS[page]) return model_block(MODELS[page]) @@ -199,7 +268,7 @@ def rendered(page: str, text: str) -> str: def pages() -> list[str]: - return [*MODELS, *DECLARED, 'operators.md'] + return [*MODELS, *COMPOSED, *DECLARED, 'operators.md'] def main(argv: list[str] | None = None) -> int: diff --git a/tools/home_math.py b/tools/home_math.py index a94d54ec..b9818928 100644 --- a/tools/home_math.py +++ b/tools/home_math.py @@ -16,11 +16,9 @@ from __future__ import annotations -import textwrap - from math_spec import to_spec from math_spec.typesetting import to_latex, to_markdown, to_typst -from tools._page import ROOT, inlined, sidecar_for, splice, without_header +from tools._page import ROOT, inlined, sidecar_for, splice, tab, without_header from tools._page import main as page_main PAGE = ROOT / 'docs' / 'index.md' @@ -76,11 +74,6 @@ loads.""" -def tab(title: str, body: str) -> str: - """One tab of the block: its title, and its body indented into it.""" - return f'=== "{title}"\n\n{textwrap.indent(body, " ")}' - - def block() -> str: """The three tabs, in the order a reader meets them.""" spec = to_spec(MODEL) diff --git a/tools/render_tex.py b/tools/render_tex.py index 8a53a9e2..b8a858c1 100644 --- a/tools/render_tex.py +++ b/tools/render_tex.py @@ -22,8 +22,10 @@ #: Every model the repository has; `examples/*.yaml` is not recursive, and a glob that narrows is a gate that stops testing. CORPUS = ('examples/**/*.yaml', 'tests/typesetting/golden/*.yaml') -#: Inside that glob and not models: the symbol tables `sidecar_for` looks up. -NOT_MODELS = ('examples/symbols',) +#: Inside that glob and not models: the symbol tables `sidecar_for` looks up, +#: and the patches a library's variants are written as, which `override` lays +#: over a model rather than anything loading them on their own. +NOT_MODELS = ('examples/symbols', 'examples/library/variants') def models() -> list[Path]: