diff --git a/CHANGELOG.md b/CHANGELOG.md index 724b144f..6165f23e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ it releases that version ([RELEASING.md](https://github.com/energy-models/mathsp ## Upcoming version +- feat(language): merge refuses a second objective, and a composed objective reads a sum that each file adds its part to ([#763](https://github.com/energy-models/mathspec/pull/763)) +- docs: the composition how-to shows a sum that one file defines and other files add terms to ([#800](https://github.com/energy-models/mathspec/pull/800)) +- feat(language): a term names the sum it adds to with `adds_to:`, and no file marks a sum as open ([#763](https://github.com/energy-models/mathspec/pull/763)) - fix(language): a cases block with one case may compare expressions in its when: ([#795](https://github.com/energy-models/mathspec/pull/795)) - feat(language): a parameter value that is null or NaN is refused when the data is attached ([#788](https://github.com/energy-models/mathspec/pull/788)) - fix(language): a where string names several columns in at's over= and into=, as an expression does ([#782](https://github.com/energy-models/mathspec/pull/782)) diff --git a/docs/examples/library/composed.md b/docs/examples/library/composed.md index 01817166..66c8eaae 100644 --- a/docs/examples/library/composed.md +++ b/docs/examples/library/composed.md @@ -20,9 +20,9 @@ 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 objective is the surface's, and it reads `total_cost`. The generator is +the one fragment that costs something, so `Generator_cost` is the one term of +that sum. A second priced fragment would add its own term to `total_cost`. The math under the file has a tab per formulation. **As composed** is the spec above. **With commitment** lays `variants/commitment.yaml` over it with @@ -90,7 +90,13 @@ constraints: expression: at(Port_p, by=Load_port, over=port, into=load) == -Load_p_set description: 'what a load takes is what its port withdraws. No PyPSA row stands for this: PyPSA writes the load into the balance instead' -objective: {sense: minimize, expression: sum(Generator_p * Generator_marginal_cost)} +objective: {sense: minimize, expression: total_cost} +expressions: + Generator_cost: sum(Generator_p * Generator_marginal_cost) + total_cost: + dims: [] + expression: Generator_cost + description: what running the system costs ``` === "As composed" @@ -120,10 +126,17 @@ objective: {sense: minimize, expression: sum(Generator_p * Generator_marginal_co | $`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 | + #### Definitions + + | Symbol | Meaning | + |---|---| + | $`\mathit{Generator\_cost}`$ | `Generator_cost` (scalar) | + | $`\mathit{total\_cost}`$ | `total_cost` (scalar) — what running the system costs | + #### Objective ```math - \min \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} \cdot \mathrm{c}_{g} + \min \mathit{total\_cost} ``` #### Subject to @@ -146,6 +159,20 @@ objective: {sense: minimize, expression: sum(Generator_p * Generator_marginal_co f_{t,\mathrm{Load\_port}(d)} = -\mathrm{load}_{t,d} \qquad \forall\, t \in \mathcal{T},\ d \in \mathcal{D} ``` + #### Definitions + + **`Generator_cost`** + + ```math + \mathit{Generator\_cost} = \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} \cdot \mathrm{c}_{g} + ``` + + **`total_cost`** + + ```math + \mathit{total\_cost} = \mathit{Generator\_cost} + ``` + #### Variable domains **`Port_p`** @@ -209,10 +236,17 @@ objective: {sense: minimize, expression: sum(Generator_p * Generator_marginal_co | $`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 | + #### Definitions + + | Symbol | Meaning | + |---|---| + | $`\mathit{Generator\_cost}`$ | `Generator_cost` (scalar) | + | $`\mathit{total\_cost}`$ | `total_cost` (scalar) — what running the system costs | + #### Objective ```math - \min \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} \cdot \mathrm{c}_{g} + \min \mathit{total\_cost} ``` #### Subject to @@ -247,6 +281,20 @@ objective: {sense: minimize, expression: sum(Generator_p * Generator_marginal_co 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} ``` + #### Definitions + + **`Generator_cost`** + + ```math + \mathit{Generator\_cost} = \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} \cdot \mathrm{c}_{g} + ``` + + **`total_cost`** + + ```math + \mathit{total\_cost} = \mathit{Generator\_cost} + ``` + #### Variable domains **`Port_p`** diff --git a/docs/examples/library/generator.md b/docs/examples/library/generator.md index 8d88fb78..354fc6b5 100644 --- a/docs/examples/library/generator.md +++ b/docs/examples/library/generator.md @@ -6,10 +6,12 @@ SPDX-License-Identifier: CC-BY-4.0 # 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`. +`port`, its parameters, its column and its cost. It reads `Port_p` and +`total_cost` from [the surface](surface.md) under +[`given`](../../reference/language/declarations.md#given), and adds its cost to +`total_cost` as the term `Generator_cost`. `Generator_port` stands where PyPSA +writes `Generator_bus`. The surface sets the objective, so this file on its +own sets none. The constraint is what makes the library composable. `at(Port_p, by=Generator_port, over=port, into=generator)` pins the flow at @@ -41,6 +43,8 @@ given: Port_p: dims: [snapshot, port] description: the surface introduces this flow, and this file pins it at its own ports + expressions: + total_cost: { dims: [] } parameters: Generator_p_nom: { dims: [generator], description: nominal power } Generator_marginal_cost: { dims: [generator], description: cost of one unit of output } @@ -56,9 +60,10 @@ constraints: 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) +expressions: + Generator_cost: + expression: sum(Generator_p * Generator_marginal_cost) + adds_to: total_cost ``` PyPSA's `Generator`, wired to a port rather than straight to a bus, and cut to what a dispatch spec needs: a fixed build, no availability profile, no ramp limits. @@ -89,12 +94,13 @@ PyPSA's `Generator`, wired to a port rather than straight to a bus, and cut to w | 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 | +| $`\mathit{total\_cost}`$ | `total_cost` (scalar), an expression this file adds `Generator_cost` to | -#### Objective +#### Definitions -```math -\min \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} \cdot \mathrm{c}_{g} -``` +| Symbol | Meaning | +|---|---| +| $`\mathit{Generator\_cost}`$ | `Generator_cost` (scalar) | #### Subject to @@ -104,6 +110,14 @@ PyPSA's `Generator`, wired to a port rather than straight to a bus, and cut to w f_{t,\mathrm{Generator\_port}(g)} = p_{t,g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} ``` +#### Definitions + +**`Generator_cost`** + +```math +\mathit{Generator\_cost} = \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} \cdot \mathrm{c}_{g} +``` + #### Variable domains **`Generator_p`** diff --git a/docs/examples/library/index.md b/docs/examples/library/index.md index 3c9ea85c..df1bd1cf 100644 --- a/docs/examples/library/index.md +++ b/docs/examples/library/index.md @@ -26,7 +26,7 @@ profile, no ramp limits. ```text examples/library/ - surface.yaml one flow per port, one balance per bus + surface.yaml one flow per port, one balance per bus, the objective generator.yaml PyPSA's Generator load.yaml PyPSA's Load variants/ @@ -35,8 +35,8 @@ examples/library/ | 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 | +| [The coupling surface](surface.md) | the surface, the sign convention and the objective | +| [Generators](generator.md) | a file that reads `Port_p` and adds its cost to `total_cost` | | [Loads](load.md) | a file with no variable of its own | | [The composed spec](composed.md) | what `merge` returns, and the math it prints with each variant | diff --git a/docs/examples/library/surface.md b/docs/examples/library/surface.md index dc40573f..b926db62 100644 --- a/docs/examples/library/surface.md +++ b/docs/examples/library/surface.md @@ -7,8 +7,11 @@ SPDX-License-Identifier: CC-BY-4.0 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. +port sits on. It sets the objective on `total_cost`, which it reads under +[`given`](../../reference/language/declarations.md#given): each component that +costs something adds its cost to that sum. 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, @@ -23,9 +26,10 @@ 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. + flow per port, one balance per bus, and one cost to minimise. 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. A component that + costs something adds its cost to `total_cost`. dimensions: snapshot: { dtype: datetime, description: dispatch periods } bus: { dtype: str, description: network nodes } @@ -41,9 +45,15 @@ constraints: 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 +given: + expressions: + total_cost: { dims: [], description: what running the system costs } +objective: + sense: minimize + expression: total_cost ``` -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. +The coupling surface every component in this library is written against: one flow per port, one balance per bus, and one cost to minimise. 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. A component that costs something adds its cost to `total_cost`. #### Sets @@ -59,6 +69,18 @@ The coupling surface every component in this library is written against: one flo |---|---| | $`f`$ | `Port_p` over $`\mathcal{T} \times \mathcal{J}`$ — what a port puts into its bus in a snapshot, negative for a withdrawal | +#### Given + +| Symbol | Meaning | +|---|---| +| $`\mathit{total\_cost}`$ | `total_cost` (scalar), an expression another file defines — what running the system costs | + +#### Objective + +```math +\min \mathit{total\_cost} +``` + #### Subject to **`Bus_nodal_balance`** diff --git a/docs/examples/pypsa.md b/docs/examples/pypsa.md index 0fd2d197..be933fe2 100644 --- a/docs/examples/pypsa.md +++ b/docs/examples/pypsa.md @@ -5289,6 +5289,15 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathit{StorageUnit\_opex}`$ | `StorageUnit_opex` over $`\Xi`$ | | $`\mathit{Store\_opex}`$ | `Store_opex` over $`\Xi`$ | | $`\mathit{scenario\_opex}`$ | `scenario_opex` over $`\Xi`$ — what a future costs to run — every operating term, weighted by the snapshot's hours and its period, before the scenario's own weight; a start and a stop cost what they cost, unweighted, as PyPSA adds them (`optimize.py:414-429`) | +| $`\mathit{Generator\_capex}`$ | `Generator_capex` (scalar) | +| $`\mathit{Link\_capex}`$ | `Link_capex` (scalar) | +| $`\mathit{StorageUnit\_capex}`$ | `StorageUnit_capex` (scalar) | +| $`\mathit{Store\_capex}`$ | `Store_capex` (scalar) | +| $`\mathit{Line\_capex}`$ | `Line_capex` (scalar) | +| $`\mathit{Process\_capex}`$ | `Process_capex` (scalar) | +| $`\mathit{Transformer\_capex}`$ | `Transformer_capex` (scalar) | +| $`\mathit{risk\_weighted\_opex}`$ | `risk_weighted_opex` (scalar) | +| $`\mathit{total\_cost}`$ | `total_cost` (scalar) — what the system costs — capacity once per active period at its expected cost over the scenarios, operation in expectation over the scenarios, and a share of it at the tail | | $`\mathit{Generator\_additions}`$ | `Generator_additions` over $`\mathcal{Y} \times \mathcal{I}`$ | | $`\mathit{Line\_additions}`$ | `Line_additions` over $`\mathcal{Y} \times \mathcal{I}`$ | | $`\mathit{Link\_additions}`$ | `Link_additions` over $`\mathcal{Y} \times \mathcal{I}`$ | @@ -5333,23 +5342,11 @@ $`\lvert \mathcal{T} \rvert`$ denotes the size of the set being counted along, a ```yaml objective: sense: minimize - description: >- - capacity once per active period at its expected cost over the scenarios, operation in - expectation over the scenarios, and a share of it at the tail - expression: >- - sum(scenario_weight * Generator_p_nom_ext * Generator_capital_cost * Generator_capital_weight) - + sum(scenario_weight * Link_p_nom_ext * Link_capital_cost * Link_capital_weight) - + sum(scenario_weight * StorageUnit_p_nom_ext * StorageUnit_capital_cost * StorageUnit_capital_weight) - + sum(scenario_weight * Store_e_nom_ext * Store_capital_cost * Store_capital_weight) - + sum(scenario_weight * Line_s_nom_ext * Line_capital_cost * Line_capital_weight) - + sum(scenario_weight * Process_p_nom_ext * Process_capital_cost * Process_capital_weight) - + sum(scenario_weight * Transformer_s_nom_ext * Transformer_capital_cost * Transformer_capital_weight) - + (1 - CVaR_omega) * sum(scenario_weight * scenario_opex, over=scenario) - + CVaR_omega * CVaR + expression: total_cost ``` ```math -\min \sum_{\xi \in \Xi,\ g \in \mathcal{G}} \pi_{\xi} \cdot P_{g} \cdot \mathrm{c}^{\mathrm{cap}}_{\xi,g} \cdot \mathrm{W}_{g} + \sum_{\xi \in \Xi,\ l \in \mathcal{L}} \pi_{\xi} \cdot F_{l} \cdot \mathrm{c}^{\mathrm{cap},f}_{\xi,l} \cdot \mathrm{W}^{f}_{l} + \sum_{\xi \in \Xi,\ s \in \mathcal{S}} \pi_{\xi} \cdot H_{s} \cdot \mathrm{c}^{\mathrm{cap},h}_{\xi,s} \cdot \mathrm{W}^{h}_{s} + \sum_{\xi \in \Xi,\ v \in \mathcal{V}} \pi_{\xi} \cdot E_{v} \cdot \mathrm{c}^{\mathrm{cap},e}_{\xi,v} \cdot \mathrm{W}^{e}_{v} + \sum_{\xi \in \Xi,\ k \in \mathcal{K}} \pi_{\xi} \cdot S_{k} \cdot \mathrm{c}^{\mathrm{cap},s}_{\xi,k} \cdot \mathrm{W}^{s}_{k} + \sum_{\xi \in \Xi,\ j \in \mathcal{J}} \pi_{\xi} \cdot Z_{j} \cdot \mathrm{c}^{\mathrm{cap},z}_{\xi,j} \cdot \mathrm{W}^{z}_{j} + \sum_{\xi \in \Xi,\ m \in \mathcal{M}} \pi_{\xi} \cdot \Sigma_{m} \cdot \mathrm{c}^{\mathrm{cap},\sigma}_{\xi,m} \cdot \mathrm{W}^{\sigma}_{m} + \left( 1 - \omega \right) \cdot \left( \sum_{\xi \in \Xi} \pi_{\xi} \cdot \mathit{scenario\_opex}_{\xi} \right) + \omega \cdot CVaR +\min \mathit{total\_cost} ``` ### `Generator-fix-p-lower` @@ -10526,6 +10523,118 @@ scenario_opex: \mathit{scenario\_opex}_{\xi} = \mathit{Generator\_opex}_{\xi} + \mathit{Generator\_commitment\_opex}_{\xi} + \mathit{Link\_opex}_{\xi} + \mathit{Link\_commitment\_opex}_{\xi} + \mathit{Process\_opex}_{\xi} + \mathit{Process\_commitment\_opex}_{\xi} + \mathit{StorageUnit\_opex}_{\xi} + \mathit{Store\_opex}_{\xi} \qquad \forall\, \xi \in \Xi ``` +### `Generator_capex` + +```yaml +Generator_capex: + expression: sum(scenario_weight * Generator_p_nom_ext * Generator_capital_cost * Generator_capital_weight) +``` + +```math +\mathit{Generator\_capex} = \sum_{\xi \in \Xi,\ g \in \mathcal{G}} \pi_{\xi} \cdot P_{g} \cdot \mathrm{c}^{\mathrm{cap}}_{\xi,g} \cdot \mathrm{W}_{g} +``` + +### `Link_capex` + +```yaml +Link_capex: + expression: sum(scenario_weight * Link_p_nom_ext * Link_capital_cost * Link_capital_weight) +``` + +```math +\mathit{Link\_capex} = \sum_{\xi \in \Xi,\ l \in \mathcal{L}} \pi_{\xi} \cdot F_{l} \cdot \mathrm{c}^{\mathrm{cap},f}_{\xi,l} \cdot \mathrm{W}^{f}_{l} +``` + +### `StorageUnit_capex` + +```yaml +StorageUnit_capex: + expression: sum(scenario_weight * StorageUnit_p_nom_ext * StorageUnit_capital_cost * StorageUnit_capital_weight) +``` + +```math +\mathit{StorageUnit\_capex} = \sum_{\xi \in \Xi,\ s \in \mathcal{S}} \pi_{\xi} \cdot H_{s} \cdot \mathrm{c}^{\mathrm{cap},h}_{\xi,s} \cdot \mathrm{W}^{h}_{s} +``` + +### `Store_capex` + +```yaml +Store_capex: + expression: sum(scenario_weight * Store_e_nom_ext * Store_capital_cost * Store_capital_weight) +``` + +```math +\mathit{Store\_capex} = \sum_{\xi \in \Xi,\ v \in \mathcal{V}} \pi_{\xi} \cdot E_{v} \cdot \mathrm{c}^{\mathrm{cap},e}_{\xi,v} \cdot \mathrm{W}^{e}_{v} +``` + +### `Line_capex` + +```yaml +Line_capex: + expression: sum(scenario_weight * Line_s_nom_ext * Line_capital_cost * Line_capital_weight) +``` + +```math +\mathit{Line\_capex} = \sum_{\xi \in \Xi,\ k \in \mathcal{K}} \pi_{\xi} \cdot S_{k} \cdot \mathrm{c}^{\mathrm{cap},s}_{\xi,k} \cdot \mathrm{W}^{s}_{k} +``` + +### `Process_capex` + +```yaml +Process_capex: + expression: sum(scenario_weight * Process_p_nom_ext * Process_capital_cost * Process_capital_weight) +``` + +```math +\mathit{Process\_capex} = \sum_{\xi \in \Xi,\ j \in \mathcal{J}} \pi_{\xi} \cdot Z_{j} \cdot \mathrm{c}^{\mathrm{cap},z}_{\xi,j} \cdot \mathrm{W}^{z}_{j} +``` + +### `Transformer_capex` + +```yaml +Transformer_capex: + expression: sum(scenario_weight * Transformer_s_nom_ext * Transformer_capital_cost * Transformer_capital_weight) +``` + +```math +\mathit{Transformer\_capex} = \sum_{\xi \in \Xi,\ m \in \mathcal{M}} \pi_{\xi} \cdot \Sigma_{m} \cdot \mathrm{c}^{\mathrm{cap},\sigma}_{\xi,m} \cdot \mathrm{W}^{\sigma}_{m} +``` + +### `risk_weighted_opex` + +```yaml +risk_weighted_opex: + expression: (1 - CVaR_omega) * sum(scenario_weight * scenario_opex, over=scenario) + CVaR_omega * CVaR +``` + +```math +\mathit{risk\_weighted\_opex} = \left( 1 - \omega \right) \cdot \left( \sum_{\xi \in \Xi} \pi_{\xi} \cdot \mathit{scenario\_opex}_{\xi} \right) + \omega \cdot CVaR +``` + +### `total_cost` + +```yaml +total_cost: + dims: [] + expression: >- + Generator_capex + + Link_capex + + StorageUnit_capex + + Store_capex + + Line_capex + + Process_capex + + Transformer_capex + + risk_weighted_opex + description: >- + what the system costs — capacity once per active period at its + expected cost over the scenarios, operation in expectation over the + scenarios, and a share of it at the tail +``` + +```math +\mathit{total\_cost} = \mathit{Generator\_capex} + \mathit{Link\_capex} + \mathit{StorageUnit\_capex} + \mathit{Store\_capex} + \mathit{Line\_capex} + \mathit{Process\_capex} + \mathit{Transformer\_capex} + \mathit{risk\_weighted\_opex} +``` + ### `Generator_additions` ```yaml diff --git a/docs/examples/pypsa/cost.md b/docs/examples/pypsa/cost.md index fa29b227..f4d1174c 100644 --- a/docs/examples/pypsa/cost.md +++ b/docs/examples/pypsa/cost.md @@ -5,7 +5,7 @@ SPDX-License-Identifier: CC-BY-4.0 # The cost -One of the [24 fragments](index.md) of `examples/pypsa.yaml`: the objective: the expected operating cost and its tail, weighted by scenario. It reads `CVaR_omega`, `scenario_opex`, `scenario_weight` under [`given`](../../reference/language/declarations.md#given). +One of the [24 fragments](index.md) of `examples/pypsa.yaml`: the expected operating cost and its tail, weighted by scenario. It adds a term to `total_cost`. It reads `CVaR_omega`, `scenario_opex`, `scenario_weight` under [`given`](../../reference/language/declarations.md#given). ```yaml @@ -37,6 +37,12 @@ given: CVaR_omega: { dims: [] } expressions: scenario_opex: { dims: [scenario] } + total_cost: { dims: [] } + +expressions: + risk_weighted_opex: + expression: (1 - CVaR_omega) * sum(scenario_weight * scenario_opex, over=scenario) + CVaR_omega * CVaR + adds_to: total_cost constraints: CVaR_excess: @@ -49,14 +55,6 @@ constraints: dims: [] where: CVaR_omega > 0 expression: CVaR_theta + 1 / (1 - CVaR_alpha) * sum(scenario_weight * CVaR_a, over=scenario) <= CVaR - -objective: - sense: minimize - description: >- - capacity once per active period at its expected cost over the scenarios, operation in expectation over the scenarios, and a share of it at the tail - expression: >- - (1 - CVaR_omega) * sum(scenario_weight * scenario_opex, over=scenario) - + CVaR_omega * CVaR ``` #### Sets @@ -86,12 +84,13 @@ objective: | $`\pi`$ | `scenario_weight` over $`\Xi`$, data another file declares | | $`\omega`$ | `CVaR_omega` (scalar), data another file declares | | $`\mathit{scenario\_opex}`$ | `scenario_opex` over $`\Xi`$, an expression another file defines | +| $`\mathit{total\_cost}`$ | `total_cost` (scalar), an expression this file adds `risk_weighted_opex` to | -#### Objective +#### Definitions -```math -\min \left( 1 - \omega \right) \cdot \left( \sum_{\xi \in \Xi} \pi_{\xi} \cdot \mathit{scenario\_opex}_{\xi} \right) + \omega \cdot CVaR -``` +| Symbol | Meaning | +|---|---| +| $`\mathit{risk\_weighted\_opex}`$ | `risk_weighted_opex` (scalar) | #### Subject to @@ -107,6 +106,14 @@ a_{\xi} - \mathit{scenario\_opex}_{\xi} + \theta \ge 0 \qquad \forall\, \xi \in \theta + \frac{1}{1 - \alpha} \cdot \left( \sum_{\xi \in \Xi} \pi_{\xi} \cdot a_{\xi} \right) \le CVaR \qquad \text{where } \omega > 0 ``` +#### Definitions + +**`risk_weighted_opex`** + +```math +\mathit{risk\_weighted\_opex} = \left( 1 - \omega \right) \cdot \left( \sum_{\xi \in \Xi} \pi_{\xi} \cdot \mathit{scenario\_opex}_{\xi} \right) + \omega \cdot CVaR +``` + #### Variable domains **`CVaR_a`** diff --git a/docs/examples/pypsa/generator.md b/docs/examples/pypsa/generator.md index 3461fcd0..4e4b82ab 100644 --- a/docs/examples/pypsa/generator.md +++ b/docs/examples/pypsa/generator.md @@ -5,7 +5,7 @@ SPDX-License-Identifier: CC-BY-4.0 # Generators -One of the [24 fragments](index.md) of `examples/pypsa.yaml`: PyPSA's `Generator`. It adds a term to `primary_energy`, `operational_limit`, `tech_capacity_expansion`, `scenario_opex`, `Carrier_additions`, `Bus_injection`. It reads `CVaR_omega`, `Generator_committable`, `Generator_maintenance`, `Generator_maintenance_capacity`, `Generator_maintenance_pu`, `GlobalConstraint_energy_weight` and 4 more under [`given`](../../reference/language/declarations.md#given). +One of the [24 fragments](index.md) of `examples/pypsa.yaml`: PyPSA's `Generator`. It adds a term to `primary_energy`, `operational_limit`, `tech_capacity_expansion`, `scenario_opex`, `total_cost`, `Carrier_additions`, `Bus_injection`. It reads `CVaR_omega`, `Generator_committable`, `Generator_maintenance`, `Generator_maintenance_capacity`, `Generator_maintenance_pu`, `GlobalConstraint_energy_weight` and 4 more under [`given`](../../reference/language/declarations.md#given). ```yaml @@ -167,12 +167,13 @@ given: Generator_maintenance_capacity: { dims: [scenario, snapshot, generator] } expressions: GlobalConstraint_energy_weight: { dims: [scenario, global_constraint, snapshot] } - primary_energy: { dims: [scenario, global_constraint], term: Generator_primary_energy } - operational_limit: { dims: [scenario, global_constraint], term: Generator_operational_limit } - tech_capacity_expansion: { dims: [global_constraint], term: Generator_tech_capacity_expansion } - scenario_opex: { dims: [scenario], term: Generator_opex } - Carrier_additions: { dims: [period, carrier], term: Generator_additions } - Bus_injection: { dims: [scenario, snapshot, bus], term: Generator_injection } + primary_energy: { dims: [scenario, global_constraint] } + operational_limit: { dims: [scenario, global_constraint] } + tech_capacity_expansion: { dims: [global_constraint] } + scenario_opex: { dims: [scenario] } + total_cost: { dims: [] } + Carrier_additions: { dims: [period, carrier] } + Bus_injection: { dims: [scenario, snapshot, bus] } expressions: Generator_p_nom_effective: @@ -192,20 +193,29 @@ expressions: Generator_primary_energy: expression: >- sum(sum((Generator_p * GlobalConstraint_energy_weight) * Generator_primary_energy_weight, over=snapshot), over=generator) + adds_to: primary_energy Generator_operational_limit: expression: >- sum(sum((Generator_p * GlobalConstraint_energy_weight) * Generator_operational_limit_weight, over=snapshot), over=generator) + adds_to: operational_limit Generator_tech_capacity_expansion: expression: sum(Generator_p_nom_ext * Generator_tech_capacity_weight, over=generator) + adds_to: tech_capacity_expansion Generator_opex: expression: >- sum(sum(((Generator_p * Generator_marginal_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) + sum(sum((((Generator_p * Generator_p) * Generator_marginal_cost_quadratic) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) + adds_to: scenario_opex + Generator_capex: + expression: sum(scenario_weight * Generator_p_nom_ext * Generator_capital_cost * Generator_capital_weight) + adds_to: total_cost Generator_additions: expression: >- sum(Generator_p_nom_ext * Generator_first_active, by=Generator_carrier, over=generator, into=carrier) + adds_to: Carrier_additions Generator_injection: expression: sum(Generator_sign * Generator_p, by=Generator_bus, over=generator, into=bus) + adds_to: Bus_injection constraints: Generator_fix_p_lower: @@ -273,11 +283,6 @@ assumptions: refuses quadratic costs under any risk preference (`optimize.py:467-474`). The spec cannot tell no risk preference from one with `omega = 0`, so it refuses only where `omega` is positive - -objective: - sense: minimize - expression: >- - sum(((scenario_weight * Generator_p_nom_ext) * Generator_capital_cost) * Generator_capital_weight) ``` #### Sets @@ -346,6 +351,7 @@ objective: | $`\mathit{operational\_limit}`$ | `operational_limit` over $`\Xi \times \mathcal{L}`$, an expression this file adds `Generator_operational_limit` to | | $`\mathit{tech\_capacity\_expansion}`$ | `tech_capacity_expansion` over $`\mathcal{L}`$, an expression this file adds `Generator_tech_capacity_expansion` to | | $`\mathit{scenario\_opex}`$ | `scenario_opex` over $`\Xi`$, an expression this file adds `Generator_opex` to | +| $`\mathit{total\_cost}`$ | `total_cost` (scalar), an expression this file adds `Generator_capex` to | | $`\mathit{Carrier\_additions}`$ | `Carrier_additions` over $`\mathcal{Y} \times \mathcal{I}`$, an expression this file adds `Generator_additions` to | | $`\mathit{Bus\_injection}`$ | `Bus_injection` over $`\Xi \times \mathcal{T} \times \mathcal{N}`$, an expression this file adds `Generator_injection` to | @@ -359,15 +365,10 @@ objective: | $`\mathit{Generator\_operational\_limit}`$ | `Generator_operational_limit` over $`\Xi \times \mathcal{L}`$ | | $`\mathit{Generator\_tech\_capacity\_expansion}`$ | `Generator_tech_capacity_expansion` over $`\mathcal{L}`$ | | $`\mathit{Generator\_opex}`$ | `Generator_opex` over $`\Xi`$ | +| $`\mathit{Generator\_capex}`$ | `Generator_capex` (scalar) | | $`\mathit{Generator\_additions}`$ | `Generator_additions` over $`\mathcal{Y} \times \mathcal{I}`$ | | $`\mathit{Generator\_injection}`$ | `Generator_injection` over $`\Xi \times \mathcal{T} \times \mathcal{N}`$ | -#### Objective - -```math -\min \sum_{\xi \in \Xi,\ g \in \mathcal{G}} \pi_{\xi} \cdot P_{g} \cdot \mathrm{c}^{\mathrm{cap}}_{\xi,g} \cdot \mathrm{W}_{g} -``` - #### Subject to **`Generator_fix_p_lower`** @@ -474,6 +475,12 @@ p_{\xi,t,g} = \mathrm{p}^{\mathrm{set}}_{\xi,t,g} \qquad \forall\, \xi \in \Xi,\ \mathit{Generator\_opex}_{\xi} = \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} p_{\xi,t,g} \cdot \mathrm{c}_{\xi,t,g} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} p_{\xi,t,g} \cdot p_{\xi,t,g} \cdot \mathrm{c}^{(2)}_{\xi,t,g} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} \qquad \forall\, \xi \in \Xi ``` +**`Generator_capex`** + +```math +\mathit{Generator\_capex} = \sum_{\xi \in \Xi,\ g \in \mathcal{G}} \pi_{\xi} \cdot P_{g} \cdot \mathrm{c}^{\mathrm{cap}}_{\xi,g} \cdot \mathrm{W}_{g} +``` + **`Generator_additions`** ```math diff --git a/docs/examples/pypsa/generator_commitment.md b/docs/examples/pypsa/generator_commitment.md index d793efc3..2384d6f3 100644 --- a/docs/examples/pypsa/generator_commitment.md +++ b/docs/examples/pypsa/generator_commitment.md @@ -117,7 +117,7 @@ given: Generator_maintenance_status: { dims: [scenario, snapshot, generator] } Generator_p_nom_ext: { dims: [generator] } expressions: - scenario_opex: { dims: [scenario], term: Generator_commitment_opex } + scenario_opex: { dims: [scenario] } expressions: Generator_previous_status: @@ -133,6 +133,7 @@ expressions: sum(sum(((Generator_status * Generator_stand_by_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) + sum(sum(Generator_start_up * Generator_start_up_cost, over=generator), over=snapshot) + sum(sum(Generator_shut_down * Generator_shut_down_cost, over=generator), over=snapshot) + adds_to: scenario_opex constraints: Generator_com_p_lower: diff --git a/docs/examples/pypsa/index.md b/docs/examples/pypsa/index.md index 150f0d71..0a2db179 100644 --- a/docs/examples/pypsa/index.md +++ b/docs/examples/pypsa/index.md @@ -14,11 +14,10 @@ Every fragment loads, prints and gets advice on its own. What it reads and does not declare, it states under [`given`](../../reference/language/declarations.md#given). A component that puts something into a sum every component adds to, such as the bus balance or -the operating cost, names its share as an expression of its own and adds it -with a [`term`](../../reference/language/declarations.md#a-term-a-file-adds). -One fragment declares each sum as an -[empty sum](../../reference/language/named.md#expressions), `empty: true`, so a -term always lands. A new component is one new file, and the network does +the operating cost, names its share as an expression of its own, a +[term](../../reference/language/declarations.md#terms) whose `adds_to:` names +the sum. One fragment reads each sum and adds nothing to it, so the terms +always have a reader. A new component is one new file, and the network does not change. The split is written by `tools/pypsa_split.py` from the one file and checked @@ -31,18 +30,21 @@ Three kinds of file. A **component** owns PyPSA's class of that name: its dimension, its data, its columns, its rows, and its share of each sum. The committable classes, `Generator`, `Link` and `Process`, are cut by feature into a file each for the class, its commitment, its ramping and its maintenance, -and the three sets read alike because PyPSA's rows do. An **owner** declares -a sum as `empty: true` over its frame, and the row that reads it: the network -declares `Bus_injection`, power flow declares `Cycle_angle_sum`. **Settings** -holds what every topic reads, the weightings and the flags, and declares the -totals whose own readers, the cost, the carriers and the global constraints, a -model may leave out. +and the three sets read alike because PyPSA's rows do. An **owner** reads a +sum with its description, and holds the row that reads it: the network reads +`Bus_injection`, power flow reads `Cycle_angle_sum`. **Settings** holds what +every topic reads, the weightings and the flags, and reads the totals whose +own readers, the cost, the carriers and the global constraints, a model may +leave out. It also sets the objective, which reads `total_cost`: each +component adds its capital cost to it, and the cost file adds the operating +cost at risk. ### The sums -| Sum | Over | Declared in | The terms, by the fragment that adds each | +| Sum | Over | Read in | The terms, by the fragment that adds each | | --- | --- | --- | --- | +| `total_cost` | scalar | [settings](settings.md) | [`risk_weighted_opex`](cost.md), [`Generator_capex`](generator.md), [`Line_capex`](line.md), [`Link_capex`](link.md), [`Process_capex`](process.md), [`StorageUnit_capex`](storage_unit.md), [`Store_capex`](store.md), [`Transformer_capex`](transformer.md) | | `scenario_opex` | `scenario` | [settings](settings.md) | [`Generator_opex`](generator.md), [`Generator_commitment_opex`](generator_commitment.md), [`Link_opex`](link.md), [`Link_commitment_opex`](link_commitment.md), [`Process_opex`](process.md), [`Process_commitment_opex`](process_commitment.md), [`StorageUnit_opex`](storage_unit.md), [`Store_opex`](store.md) | | `Bus_injection` | `scenario, snapshot, bus` | [network](network.md) | [`Generator_injection`](generator.md), [`Line_injection`](line.md), [`Link_injection`](link.md), [`Load_injection`](load.md), [`Process_injection`](process.md), [`StorageUnit_injection`](storage_unit.md), [`Store_injection`](store.md), [`Transformer_injection`](transformer.md) | | `tech_capacity_expansion` | `global_constraint` | [settings](settings.md) | [`Generator_tech_capacity_expansion`](generator.md), [`Line_tech_capacity_expansion`](line.md), [`Link_tech_capacity_expansion`](link.md), [`Process_tech_capacity_expansion`](process.md), [`StorageUnit_tech_capacity_expansion`](storage_unit.md), [`Store_tech_capacity_expansion`](store.md) | @@ -58,29 +60,29 @@ model may leave out. | Fragment | Parameters | Variables | Constraints | Reads | Adds to | | --- | --- | --- | --- | --- | --- | | [carrier](carrier.md) | 2 | 0 | 1 | 1 | | -| [cost](cost.md) | 1 | 3 | 2 | 3 | | -| [generator](generator.md) | 23 | 3 | 11 | 16 | `primary_energy`, `operational_limit`, `tech_capacity_expansion`, `scenario_opex`, `Carrier_additions`, `Bus_injection` | +| [cost](cost.md) | 1 | 3 | 2 | 4 | `total_cost` | +| [generator](generator.md) | 23 | 3 | 11 | 17 | `primary_energy`, `operational_limit`, `tech_capacity_expansion`, `scenario_opex`, `total_cost`, `Carrier_additions`, `Bus_injection` | | [generator_commitment](generator_commitment.md) | 10 | 3 | 20 | 17 | `scenario_opex` | | [generator_maintenance](generator_maintenance.md) | 5 | 4 | 13 | 9 | | | [generator_ramping](generator_ramping.md) | 5 | 0 | 6 | 14 | | | [global_constraints](global_constraints.md) | 3 | 0 | 15 | 6 | | -| [line](line.md) | 18 | 3 | 11 | 8 | `transmission_volume_expansion`, `transmission_expansion_cost`, `tech_capacity_expansion`, `Carrier_additions`, `Bus_injection`, `Cycle_angle_sum` | -| [link](link.md) | 23 | 3 | 9 | 14 | `transmission_volume_expansion`, `transmission_expansion_cost`, `tech_capacity_expansion`, `scenario_opex`, `Carrier_additions`, `Bus_injection` | +| [line](line.md) | 18 | 3 | 11 | 9 | `transmission_volume_expansion`, `transmission_expansion_cost`, `tech_capacity_expansion`, `total_cost`, `Carrier_additions`, `Bus_injection`, `Cycle_angle_sum` | +| [link](link.md) | 23 | 3 | 9 | 15 | `transmission_volume_expansion`, `transmission_expansion_cost`, `tech_capacity_expansion`, `scenario_opex`, `total_cost`, `Carrier_additions`, `Bus_injection` | | [link_commitment](link_commitment.md) | 10 | 3 | 20 | 17 | `scenario_opex` | | [link_maintenance](link_maintenance.md) | 5 | 4 | 13 | 9 | | | [link_ramping](link_ramping.md) | 5 | 0 | 6 | 14 | | | [load](load.md) | 3 | 0 | 0 | 1 | `Bus_injection` | -| [network](network.md) | 0 | 0 | 1 | 0 | | -| [power_flow](power_flow.md) | 0 | 0 | 1 | 0 | | -| [process](process.md) | 21 | 3 | 9 | 12 | `tech_capacity_expansion`, `scenario_opex`, `Carrier_additions`, `Bus_injection` | +| [network](network.md) | 0 | 0 | 1 | 1 | | +| [power_flow](power_flow.md) | 0 | 0 | 1 | 1 | | +| [process](process.md) | 21 | 3 | 9 | 13 | `tech_capacity_expansion`, `scenario_opex`, `total_cost`, `Carrier_additions`, `Bus_injection` | | [process_commitment](process_commitment.md) | 10 | 3 | 20 | 17 | `scenario_opex` | | [process_maintenance](process_maintenance.md) | 5 | 4 | 13 | 9 | | | [process_ramping](process_ramping.md) | 5 | 0 | 6 | 14 | | | [security](security.md) | 2 | 0 | 8 | 10 | | -| [settings](settings.md) | 9 | 0 | 0 | 0 | | -| [storage_unit](storage_unit.md) | 34 | 5 | 20 | 14 | `primary_energy`, `operational_limit`, `tech_capacity_expansion`, `scenario_opex`, `Carrier_additions`, `Bus_injection` | -| [store](store.md) | 27 | 3 | 10 | 14 | `primary_energy`, `operational_limit`, `tech_capacity_expansion`, `scenario_opex`, `Carrier_additions`, `Bus_injection` | -| [transformer](transformer.md) | 19 | 4 | 11 | 4 | `Bus_injection`, `Cycle_angle_sum` | +| [settings](settings.md) | 9 | 0 | 0 | 8 | | +| [storage_unit](storage_unit.md) | 34 | 5 | 20 | 15 | `primary_energy`, `operational_limit`, `tech_capacity_expansion`, `scenario_opex`, `total_cost`, `Carrier_additions`, `Bus_injection` | +| [store](store.md) | 27 | 3 | 10 | 15 | `primary_energy`, `operational_limit`, `tech_capacity_expansion`, `scenario_opex`, `total_cost`, `Carrier_additions`, `Bus_injection` | +| [transformer](transformer.md) | 19 | 4 | 11 | 5 | `total_cost`, `Bus_injection`, `Cycle_angle_sum` | ## Leaving a file out diff --git a/docs/examples/pypsa/line.md b/docs/examples/pypsa/line.md index 6d272bc7..7d980d8f 100644 --- a/docs/examples/pypsa/line.md +++ b/docs/examples/pypsa/line.md @@ -5,7 +5,7 @@ SPDX-License-Identifier: CC-BY-4.0 # Lines -One of the [24 fragments](index.md) of `examples/pypsa.yaml`: PyPSA's `Line`. It adds a term to `transmission_volume_expansion`, `transmission_expansion_cost`, `tech_capacity_expansion`, `Carrier_additions`, `Bus_injection`, `Cycle_angle_sum`. It reads `scenario_weight`, `transmission_losses` under [`given`](../../reference/language/declarations.md#given). +One of the [24 fragments](index.md) of `examples/pypsa.yaml`: PyPSA's `Line`. It adds a term to `transmission_volume_expansion`, `transmission_expansion_cost`, `tech_capacity_expansion`, `total_cost`, `Carrier_additions`, `Bus_injection`, `Cycle_angle_sum`. It reads `scenario_weight`, `transmission_losses` under [`given`](../../reference/language/declarations.md#given). ```yaml @@ -161,23 +161,31 @@ given: scenario_weight: { dims: [scenario] } transmission_losses: { dims: [], dtype: bool } expressions: - transmission_volume_expansion: { dims: [scenario, global_constraint], term: Line_transmission_volume_expansion } - transmission_expansion_cost: { dims: [scenario, global_constraint], term: Line_transmission_expansion_cost } - tech_capacity_expansion: { dims: [global_constraint], term: Line_tech_capacity_expansion } - Carrier_additions: { dims: [period, carrier], term: Line_additions } - Bus_injection: { dims: [scenario, snapshot, bus], term: Line_injection } - Cycle_angle_sum: { dims: [scenario, snapshot, cycle], term: Line_angle_sum } + transmission_volume_expansion: { dims: [scenario, global_constraint] } + transmission_expansion_cost: { dims: [scenario, global_constraint] } + tech_capacity_expansion: { dims: [global_constraint] } + total_cost: { dims: [] } + Carrier_additions: { dims: [period, carrier] } + Bus_injection: { dims: [scenario, snapshot, bus] } + Cycle_angle_sum: { dims: [scenario, snapshot, cycle] } expressions: Line_transmission_volume_expansion: expression: sum(Line_s_nom_ext * Line_volume_weight, over=line) + adds_to: transmission_volume_expansion Line_transmission_expansion_cost: expression: sum(Line_s_nom_ext * Line_expansion_cost_weight, over=line) + adds_to: transmission_expansion_cost Line_tech_capacity_expansion: expression: sum(Line_s_nom_ext * Line_tech_capacity_weight, over=line) + adds_to: tech_capacity_expansion + Line_capex: + expression: sum(scenario_weight * Line_s_nom_ext * Line_capital_cost * Line_capital_weight) + adds_to: total_cost Line_additions: expression: >- sum(Line_s_nom_ext * Line_first_active, by=Line_carrier, over=line, into=carrier) + adds_to: Carrier_additions Line_s_monitored: description: >- the flow a line's post-contingency rows read — its flow where it stands, @@ -193,7 +201,10 @@ expressions: + sum(Line_s, by=Line_bus1, over=line, into=bus) - (0.5 * sum(Line_loss, by=Line_bus0, over=line, into=bus)) - (0.5 * sum(Line_loss, by=Line_bus1, over=line, into=bus)) - Line_angle_sum: sum(Line_s * Line_cycle_weight, over=line) + adds_to: Bus_injection + Line_angle_sum: + expression: sum(Line_s * Line_cycle_weight, over=line) + adds_to: Cycle_angle_sum constraints: Line_fix_s_lower: @@ -257,11 +268,6 @@ constraints: dims: [scenario, snapshot, line, segment] where: transmission_losses AND Line_active expression: Line_loss - Line_loss_slope * Line_s >= Line_loss_offset - -objective: - sense: minimize - expression: >- - sum(((scenario_weight * Line_s_nom_ext) * Line_capital_cost) * Line_capital_weight) ``` #### Sets @@ -318,6 +324,7 @@ objective: | $`\mathit{transmission\_volume\_expansion}`$ | `transmission_volume_expansion` over $`\Xi \times \mathcal{G}`$, an expression this file adds `Line_transmission_volume_expansion` to | | $`\mathit{transmission\_expansion\_cost}`$ | `transmission_expansion_cost` over $`\Xi \times \mathcal{G}`$, an expression this file adds `Line_transmission_expansion_cost` to | | $`\mathit{tech\_capacity\_expansion}`$ | `tech_capacity_expansion` over $`\mathcal{G}`$, an expression this file adds `Line_tech_capacity_expansion` to | +| $`\mathit{total\_cost}`$ | `total_cost` (scalar), an expression this file adds `Line_capex` to | | $`\mathit{Carrier\_additions}`$ | `Carrier_additions` over $`\mathcal{Y} \times \mathcal{I}`$, an expression this file adds `Line_additions` to | | $`\mathit{Bus\_injection}`$ | `Bus_injection` over $`\Xi \times \mathcal{T} \times \mathcal{N}`$, an expression this file adds `Line_injection` to | | $`\mathit{Cycle\_angle\_sum}`$ | `Cycle_angle_sum` over $`\Xi \times \mathcal{T} \times \mathcal{C}`$, an expression this file adds `Line_angle_sum` to | @@ -329,17 +336,12 @@ objective: | $`\mathit{Line\_transmission\_volume\_expansion}`$ | `Line_transmission_volume_expansion` over $`\Xi \times \mathcal{G}`$ | | $`\mathit{Line\_transmission\_expansion\_cost}`$ | `Line_transmission_expansion_cost` over $`\Xi \times \mathcal{G}`$ | | $`\mathit{Line\_tech\_capacity\_expansion}`$ | `Line_tech_capacity_expansion` over $`\mathcal{G}`$ | +| $`\mathit{Line\_capex}`$ | `Line_capex` (scalar) | | $`\mathit{Line\_additions}`$ | `Line_additions` over $`\mathcal{Y} \times \mathcal{I}`$ | | $`\check{s}`$ | `Line_s_monitored` over $`\Xi \times \mathcal{T} \times \mathcal{K}`$ — the flow a line's post-contingency rows read — its flow where it stands, nothing where it does not, since PyPSA builds those rows for every branch of the sub-network in every snapshot | | $`\mathit{Line\_injection}`$ | `Line_injection` over $`\Xi \times \mathcal{T} \times \mathcal{N}`$ | | $`\mathit{Line\_angle\_sum}`$ | `Line_angle_sum` over $`\Xi \times \mathcal{T} \times \mathcal{C}`$ | -#### Objective - -```math -\min \sum_{\xi \in \Xi,\ k \in \mathcal{K}} \pi_{\xi} \cdot S_{k} \cdot \mathrm{c}^{\mathrm{cap},s}_{\xi,k} \cdot \mathrm{W}^{s}_{k} -``` - #### Subject to **`Line_fix_s_lower`** @@ -428,6 +430,12 @@ s_{\xi,t,k} = \mathrm{s}^{\mathrm{set}}_{\xi,t,k} \qquad \forall\, \xi \in \Xi,\ \mathit{Line\_tech\_capacity\_expansion}_{g} = \sum_{k \in \mathcal{K}} S_{k} \cdot \mathrm{m}^{l}_{g,k} \qquad \forall\, g \in \mathcal{G} ``` +**`Line_capex`** + +```math +\mathit{Line\_capex} = \sum_{\xi \in \Xi,\ k \in \mathcal{K}} \pi_{\xi} \cdot S_{k} \cdot \mathrm{c}^{\mathrm{cap},s}_{\xi,k} \cdot \mathrm{W}^{s}_{k} +``` + **`Line_additions`** ```math diff --git a/docs/examples/pypsa/link.md b/docs/examples/pypsa/link.md index f6898c49..77f20182 100644 --- a/docs/examples/pypsa/link.md +++ b/docs/examples/pypsa/link.md @@ -5,7 +5,7 @@ SPDX-License-Identifier: CC-BY-4.0 # Links -One of the [24 fragments](index.md) of `examples/pypsa.yaml`: PyPSA's `Link`. It adds a term to `transmission_volume_expansion`, `transmission_expansion_cost`, `tech_capacity_expansion`, `scenario_opex`, `Carrier_additions`, `Bus_injection`. It reads `CVaR_omega`, `Link_committable`, `Link_maintenance`, `Link_maintenance_capacity`, `Link_maintenance_pu`, `period_weight_objective` and 2 more under [`given`](../../reference/language/declarations.md#given). +One of the [24 fragments](index.md) of `examples/pypsa.yaml`: PyPSA's `Link`. It adds a term to `transmission_volume_expansion`, `transmission_expansion_cost`, `tech_capacity_expansion`, `scenario_opex`, `total_cost`, `Carrier_additions`, `Bus_injection`. It reads `CVaR_omega`, `Link_committable`, `Link_maintenance`, `Link_maintenance_capacity`, `Link_maintenance_pu`, `period_weight_objective` and 2 more under [`given`](../../reference/language/declarations.md#given). ```yaml @@ -205,12 +205,13 @@ given: Link_maintenance: { dims: [scenario, snapshot, link] } Link_maintenance_capacity: { dims: [scenario, snapshot, link] } expressions: - transmission_volume_expansion: { dims: [scenario, global_constraint], term: Link_transmission_volume_expansion } - transmission_expansion_cost: { dims: [scenario, global_constraint], term: Link_transmission_expansion_cost } - tech_capacity_expansion: { dims: [global_constraint], term: Link_tech_capacity_expansion } - scenario_opex: { dims: [scenario], term: Link_opex } - Carrier_additions: { dims: [period, carrier], term: Link_additions } - Bus_injection: { dims: [scenario, snapshot, bus], term: Link_injection } + transmission_volume_expansion: { dims: [scenario, global_constraint] } + transmission_expansion_cost: { dims: [scenario, global_constraint] } + tech_capacity_expansion: { dims: [global_constraint] } + scenario_opex: { dims: [scenario] } + total_cost: { dims: [] } + Carrier_additions: { dims: [period, carrier] } + Bus_injection: { dims: [scenario, snapshot, bus] } expressions: Link_p_nom_effective: @@ -244,21 +245,30 @@ expressions: otherwise: shift(at(Link_p, by=Link_output_link, over=link, into=link_output), along=snapshot, offset=Link_output_delay, edge=0, by=snapshot_period, within=period) * Link_efficiency Link_transmission_volume_expansion: expression: sum(Link_p_nom_ext * Link_volume_weight, over=link) + adds_to: transmission_volume_expansion Link_transmission_expansion_cost: expression: sum(Link_p_nom_ext * Link_expansion_cost_weight, over=link) + adds_to: transmission_expansion_cost Link_tech_capacity_expansion: expression: sum(Link_p_nom_ext * Link_tech_capacity_weight, over=link) + adds_to: tech_capacity_expansion Link_opex: expression: >- sum(sum(((Link_p * Link_marginal_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=link), over=snapshot) + sum(sum((((Link_p * Link_p) * Link_marginal_cost_quadratic) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=link), over=snapshot) + adds_to: scenario_opex + Link_capex: + expression: sum(scenario_weight * Link_p_nom_ext * Link_capital_cost * Link_capital_weight) + adds_to: total_cost Link_additions: expression: >- sum(Link_p_nom_ext * Link_first_active, by=Link_carrier, over=link, into=carrier) + adds_to: Carrier_additions Link_injection: expression: >- -sum(Link_p, by=Link_bus0, over=link, into=bus) + sum(Link_output_arrival, by=Link_output_bus, over=link_output, into=bus) + adds_to: Bus_injection constraints: Link_fix_p_lower: @@ -316,11 +326,6 @@ assumptions: refuses quadratic costs under any risk preference (`optimize.py:467-474`). The spec cannot tell no risk preference from one with `omega = 0`, so it refuses only where `omega` is positive - -objective: - sense: minimize - expression: >- - sum(((scenario_weight * Link_p_nom_ext) * Link_capital_cost) * Link_capital_weight) ``` #### Sets @@ -388,6 +393,7 @@ objective: | $`\mathit{transmission\_expansion\_cost}`$ | `transmission_expansion_cost` over $`\Xi \times \mathcal{G}`$, an expression this file adds `Link_transmission_expansion_cost` to | | $`\mathit{tech\_capacity\_expansion}`$ | `tech_capacity_expansion` over $`\mathcal{G}`$, an expression this file adds `Link_tech_capacity_expansion` to | | $`\mathit{scenario\_opex}`$ | `scenario_opex` over $`\Xi`$, an expression this file adds `Link_opex` to | +| $`\mathit{total\_cost}`$ | `total_cost` (scalar), an expression this file adds `Link_capex` to | | $`\mathit{Carrier\_additions}`$ | `Carrier_additions` over $`\mathcal{Y} \times \mathcal{I}`$, an expression this file adds `Link_additions` to | | $`\mathit{Bus\_injection}`$ | `Bus_injection` over $`\Xi \times \mathcal{T} \times \mathcal{N}`$, an expression this file adds `Link_injection` to | @@ -402,6 +408,7 @@ objective: | $`\mathit{Link\_transmission\_expansion\_cost}`$ | `Link_transmission_expansion_cost` over $`\Xi \times \mathcal{G}`$ | | $`\mathit{Link\_tech\_capacity\_expansion}`$ | `Link_tech_capacity_expansion` over $`\mathcal{G}`$ | | $`\mathit{Link\_opex}`$ | `Link_opex` over $`\Xi`$ | +| $`\mathit{Link\_capex}`$ | `Link_capex` (scalar) | | $`\mathit{Link\_additions}`$ | `Link_additions` over $`\mathcal{Y} \times \mathcal{I}`$ | | $`\mathit{Link\_injection}`$ | `Link_injection` over $`\Xi \times \mathcal{T} \times \mathcal{N}`$ | @@ -411,12 +418,6 @@ $`t \boxminus_{v} k`$ denotes translation with $`v`$ standing where index $`t-k` $`t \ominus^{\mathrm{relation}(t)} k`$ denotes a translation counted inside the group a relation puts $`t`$ in (`shift(by=relation)`), so a term never crosses out of its own group. The two modifiers take different slots — the group above, the fill below — so $`t \boxminus_{v}^{\mathrm{relation}(t)} k`$ is both at once. -#### Objective - -```math -\min \sum_{\xi \in \Xi,\ l \in \mathcal{L}} \pi_{\xi} \cdot F_{l} \cdot \mathrm{c}^{\mathrm{cap},f}_{\xi,l} \cdot \mathrm{W}^{f}_{l} -``` - #### Subject to **`Link_fix_p_lower`** @@ -517,6 +518,12 @@ f_{\xi,t,l} = \mathrm{f}^{\mathrm{set}}_{\xi,t,l} \qquad \forall\, \xi \in \Xi,\ \mathit{Link\_opex}_{\xi} = \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} f_{\xi,t,l} \cdot \mathrm{c}^{f}_{\xi,t,l} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{l \in \mathcal{L}} f_{\xi,t,l} \cdot f_{\xi,t,l} \cdot \mathrm{c}^{f,(2)}_{\xi,t,l} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} \qquad \forall\, \xi \in \Xi ``` +**`Link_capex`** + +```math +\mathit{Link\_capex} = \sum_{\xi \in \Xi,\ l \in \mathcal{L}} \pi_{\xi} \cdot F_{l} \cdot \mathrm{c}^{\mathrm{cap},f}_{\xi,l} \cdot \mathrm{W}^{f}_{l} +``` + **`Link_additions`** ```math diff --git a/docs/examples/pypsa/link_commitment.md b/docs/examples/pypsa/link_commitment.md index 894f7b60..0f76a073 100644 --- a/docs/examples/pypsa/link_commitment.md +++ b/docs/examples/pypsa/link_commitment.md @@ -117,7 +117,7 @@ given: Link_maintenance_status: { dims: [scenario, snapshot, link] } Link_p_nom_ext: { dims: [link] } expressions: - scenario_opex: { dims: [scenario], term: Link_commitment_opex } + scenario_opex: { dims: [scenario] } expressions: Link_previous_status: @@ -133,6 +133,7 @@ expressions: sum(sum(((Link_status * Link_stand_by_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=link), over=snapshot) + sum(sum(Link_start_up * Link_start_up_cost, over=link), over=snapshot) + sum(sum(Link_shut_down * Link_shut_down_cost, over=link), over=snapshot) + adds_to: scenario_opex constraints: Link_com_p_lower: diff --git a/docs/examples/pypsa/load.md b/docs/examples/pypsa/load.md index 7b61a4a7..1373b50f 100644 --- a/docs/examples/pypsa/load.md +++ b/docs/examples/pypsa/load.md @@ -46,7 +46,7 @@ parameters: given: expressions: - Bus_injection: { dims: [scenario, snapshot, bus], term: Load_injection } + Bus_injection: { dims: [scenario, snapshot, bus] } expressions: Load_demand: @@ -58,7 +58,9 @@ expressions: cases: active: { when: Load_active, expression: Load_sign * Load_p_set } otherwise: 0 - Load_injection: sum(Load_demand, by=Load_bus, over=load, into=bus) + Load_injection: + expression: sum(Load_demand, by=Load_bus, over=load, into=bus) + adds_to: Bus_injection ``` #### Sets diff --git a/docs/examples/pypsa/network.md b/docs/examples/pypsa/network.md index d0d3a9ed..d86b3344 100644 --- a/docs/examples/pypsa/network.md +++ b/docs/examples/pypsa/network.md @@ -5,7 +5,7 @@ SPDX-License-Identifier: CC-BY-4.0 # The network -One of the [24 fragments](index.md) of `examples/pypsa.yaml`: the buses and the balance at each of them. It declares `Bus_injection` as an empty sum, `empty: true`, which every component adds its injection to. +One of the [24 fragments](index.md) of `examples/pypsa.yaml`: the buses and the balance at each of them. It reads `Bus_injection` under `given:`, and every component adds its injection to it. ```yaml @@ -18,14 +18,14 @@ dimensions: bus: description: network nodes -expressions: - Bus_injection: - dims: [scenario, snapshot, bus] - empty: true - description: >- - what every component puts into a bus, less what it takes out of it; - PyPSA writes each term into the balance, and a load on its right-hand - side +given: + expressions: + Bus_injection: + dims: [scenario, snapshot, bus] + description: >- + what every component puts into a bus, less what it takes out of it; + PyPSA writes each term into the balance, and a load on its right-hand + side constraints: Bus_nodal_balance: @@ -52,11 +52,11 @@ constraints: | $`\mathcal{T}`$ | index $`t`$ — `snapshot` — dispatch periods | | $`\mathcal{N}`$ | index $`n`$ — `bus` — network nodes | -#### Definitions +#### Given | Symbol | Meaning | |---|---| -| $`\mathit{Bus\_injection}`$ | `Bus_injection` over $`\Xi \times \mathcal{T} \times \mathcal{N}`$ — what every component puts into a bus, less what it takes out of it; PyPSA writes each term into the balance, and a load on its right-hand side | +| $`\mathit{Bus\_injection}`$ | `Bus_injection` over $`\Xi \times \mathcal{T} \times \mathcal{N}`$, an expression another file defines — what every component puts into a bus, less what it takes out of it; PyPSA writes each term into the balance, and a load on its right-hand side | #### Subject to @@ -65,12 +65,4 @@ constraints: ```math \mathit{Bus\_injection}_{\xi,t,n} = 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ n \in \mathcal{N} ``` - -#### Definitions - -**`Bus_injection`** - -```math -\mathit{Bus\_injection}_{\xi,t,n} = \cdots \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ n \in \mathcal{N} -``` diff --git a/docs/examples/pypsa/power_flow.md b/docs/examples/pypsa/power_flow.md index 59bdf576..98bc6358 100644 --- a/docs/examples/pypsa/power_flow.md +++ b/docs/examples/pypsa/power_flow.md @@ -5,7 +5,7 @@ SPDX-License-Identifier: CC-BY-4.0 # Power flow -One of the [24 fragments](index.md) of `examples/pypsa.yaml`: Kirchhoff's voltage law around each cycle. It declares `Cycle_angle_sum` as an empty sum, `empty: true`, which the lines and transformers add to. +One of the [24 fragments](index.md) of `examples/pypsa.yaml`: Kirchhoff's voltage law around each cycle. It reads `Cycle_angle_sum` under `given:` and adds nothing to it, and the lines and transformers add their terms to it. ```yaml @@ -18,13 +18,13 @@ dimensions: cycle: description: independent cycles of the passive network graph — the cycle basis, data prep -expressions: - Cycle_angle_sum: - dims: [scenario, snapshot, cycle] - empty: true - description: >- - the voltage angle differences around a cycle: every branch flow times - its cycle weight, and every transformer phase shift +given: + expressions: + Cycle_angle_sum: + dims: [scenario, snapshot, cycle] + description: >- + the voltage angle differences around a cycle: every branch flow times + its cycle weight, and every transformer phase shift constraints: Kirchhoff_Voltage_Law: @@ -47,11 +47,11 @@ constraints: | $`\mathcal{T}`$ | index $`t`$ — `snapshot` — dispatch periods | | $`\mathcal{C}`$ | index $`c`$ — `cycle` — independent cycles of the passive network graph — the cycle basis, data prep | -#### Definitions +#### Given | Symbol | Meaning | |---|---| -| $`\mathit{Cycle\_angle\_sum}`$ | `Cycle_angle_sum` over $`\Xi \times \mathcal{T} \times \mathcal{C}`$ — the voltage angle differences around a cycle: every branch flow times its cycle weight, and every transformer phase shift | +| $`\mathit{Cycle\_angle\_sum}`$ | `Cycle_angle_sum` over $`\Xi \times \mathcal{T} \times \mathcal{C}`$, an expression another file defines — the voltage angle differences around a cycle: every branch flow times its cycle weight, and every transformer phase shift | #### Subject to @@ -60,12 +60,4 @@ constraints: ```math \mathit{Cycle\_angle\_sum}_{\xi,t,c} = 0 \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ c \in \mathcal{C} ``` - -#### Definitions - -**`Cycle_angle_sum`** - -```math -\mathit{Cycle\_angle\_sum}_{\xi,t,c} = \cdots \qquad \forall\, \xi \in \Xi,\ t \in \mathcal{T},\ c \in \mathcal{C} -``` diff --git a/docs/examples/pypsa/process.md b/docs/examples/pypsa/process.md index a509b570..00711b99 100644 --- a/docs/examples/pypsa/process.md +++ b/docs/examples/pypsa/process.md @@ -5,7 +5,7 @@ SPDX-License-Identifier: CC-BY-4.0 # Processes -One of the [24 fragments](index.md) of `examples/pypsa.yaml`: PyPSA's `Process`. It adds a term to `tech_capacity_expansion`, `scenario_opex`, `Carrier_additions`, `Bus_injection`. It reads `CVaR_omega`, `Process_committable`, `Process_maintenance`, `Process_maintenance_capacity`, `Process_maintenance_pu`, `period_weight_objective` and 2 more under [`given`](../../reference/language/declarations.md#given). +One of the [24 fragments](index.md) of `examples/pypsa.yaml`: PyPSA's `Process`. It adds a term to `tech_capacity_expansion`, `scenario_opex`, `total_cost`, `Carrier_additions`, `Bus_injection`. It reads `CVaR_omega`, `Process_committable`, `Process_maintenance`, `Process_maintenance_capacity`, `Process_maintenance_pu`, `period_weight_objective` and 2 more under [`given`](../../reference/language/declarations.md#given). ```yaml @@ -184,10 +184,11 @@ given: Process_maintenance: { dims: [scenario, snapshot, process] } Process_maintenance_capacity: { dims: [scenario, snapshot, process] } expressions: - tech_capacity_expansion: { dims: [global_constraint], term: Process_tech_capacity_expansion } - scenario_opex: { dims: [scenario], term: Process_opex } - Carrier_additions: { dims: [period, carrier], term: Process_additions } - Bus_injection: { dims: [scenario, snapshot, bus], term: Process_injection } + tech_capacity_expansion: { dims: [global_constraint] } + scenario_opex: { dims: [scenario] } + total_cost: { dims: [] } + Carrier_additions: { dims: [period, carrier] } + Bus_injection: { dims: [scenario, snapshot, bus] } expressions: Process_p_nom_effective: @@ -221,16 +222,23 @@ expressions: otherwise: shift(at(Process_p, by=Process_output_process, over=process, into=process_output), along=snapshot, offset=Process_output_delay, edge=0, by=snapshot_period, within=period) * Process_rate Process_tech_capacity_expansion: expression: sum(Process_p_nom_ext * Process_tech_capacity_weight, over=process) + adds_to: tech_capacity_expansion Process_opex: expression: >- sum(sum(((Process_p * Process_marginal_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=process), over=snapshot) + sum(sum((((Process_p * Process_p) * Process_marginal_cost_quadratic) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=process), over=snapshot) + adds_to: scenario_opex + Process_capex: + expression: sum(scenario_weight * Process_p_nom_ext * Process_capital_cost * Process_capital_weight) + adds_to: total_cost Process_additions: expression: >- sum(Process_p_nom_ext * Process_first_active, by=Process_carrier, over=process, into=carrier) + adds_to: Carrier_additions Process_injection: expression: >- sum(Process_output_arrival, by=Process_output_bus, over=process_output, into=bus) + adds_to: Bus_injection constraints: Process_fix_p_lower: @@ -288,11 +296,6 @@ assumptions: refuses quadratic costs under any risk preference (`optimize.py:467-474`). The spec cannot tell no risk preference from one with `omega = 0`, so it refuses only where `omega` is positive - -objective: - sense: minimize - expression: >- - sum(((scenario_weight * Process_p_nom_ext) * Process_capital_cost) * Process_capital_weight) ``` #### Sets @@ -356,6 +359,7 @@ objective: | $`\mu^{z,\mathrm{nom}}`$ | `Process_maintenance_capacity` over $`\Xi \times \mathcal{T} \times \mathcal{J}`$ | | $`\mathit{tech\_capacity\_expansion}`$ | `tech_capacity_expansion` over $`\mathcal{G}`$, an expression this file adds `Process_tech_capacity_expansion` to | | $`\mathit{scenario\_opex}`$ | `scenario_opex` over $`\Xi`$, an expression this file adds `Process_opex` to | +| $`\mathit{total\_cost}`$ | `total_cost` (scalar), an expression this file adds `Process_capex` to | | $`\mathit{Carrier\_additions}`$ | `Carrier_additions` over $`\mathcal{Y} \times \mathcal{I}`$, an expression this file adds `Process_additions` to | | $`\mathit{Bus\_injection}`$ | `Bus_injection` over $`\Xi \times \mathcal{T} \times \mathcal{N}`$, an expression this file adds `Process_injection` to | @@ -368,6 +372,7 @@ objective: | $`\overrightarrow{z}`$ | `Process_output_arrival` over $`\Xi \times \mathcal{T} \times \mathcal{R}`$ — what a process transfers at a port at a snapshot — its internal power delayed by the port's `delay` within its investment period, times the port's rate at the snapshot the transfer arrives; where the port is `cyclic_delay` the delayed transfer wraps from the period's end, and where it is not the energy still in transit at the period's first snapshots is lost. A port that does not delay (`delay` zero) transfers at once, cyclic or not | | $`\mathit{Process\_tech\_capacity\_expansion}`$ | `Process_tech_capacity_expansion` over $`\mathcal{G}`$ | | $`\mathit{Process\_opex}`$ | `Process_opex` over $`\Xi`$ | +| $`\mathit{Process\_capex}`$ | `Process_capex` (scalar) | | $`\mathit{Process\_additions}`$ | `Process_additions` over $`\mathcal{Y} \times \mathcal{I}`$ | | $`\mathit{Process\_injection}`$ | `Process_injection` over $`\Xi \times \mathcal{T} \times \mathcal{N}`$ | @@ -377,12 +382,6 @@ $`t \boxminus_{v} k`$ denotes translation with $`v`$ standing where index $`t-k` $`t \ominus^{\mathrm{relation}(t)} k`$ denotes a translation counted inside the group a relation puts $`t`$ in (`shift(by=relation)`), so a term never crosses out of its own group. The two modifiers take different slots — the group above, the fill below — so $`t \boxminus_{v}^{\mathrm{relation}(t)} k`$ is both at once. -#### Objective - -```math -\min \sum_{\xi \in \Xi,\ j \in \mathcal{J}} \pi_{\xi} \cdot Z_{j} \cdot \mathrm{c}^{\mathrm{cap},z}_{\xi,j} \cdot \mathrm{W}^{z}_{j} -``` - #### Subject to **`Process_fix_p_lower`** @@ -471,6 +470,12 @@ z_{\xi,t,j} = \mathrm{z}^{\mathrm{set}}_{\xi,t,j} \qquad \forall\, \xi \in \Xi,\ \mathit{Process\_opex}_{\xi} = \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} z_{\xi,t,j} \cdot \mathrm{c}^{z}_{\xi,t,j} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{j \in \mathcal{J}} z_{\xi,t,j} \cdot z_{\xi,t,j} \cdot \mathrm{c}^{z,(2)}_{\xi,t,j} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} \qquad \forall\, \xi \in \Xi ``` +**`Process_capex`** + +```math +\mathit{Process\_capex} = \sum_{\xi \in \Xi,\ j \in \mathcal{J}} \pi_{\xi} \cdot Z_{j} \cdot \mathrm{c}^{\mathrm{cap},z}_{\xi,j} \cdot \mathrm{W}^{z}_{j} +``` + **`Process_additions`** ```math diff --git a/docs/examples/pypsa/process_commitment.md b/docs/examples/pypsa/process_commitment.md index a64e95ea..f2087dc2 100644 --- a/docs/examples/pypsa/process_commitment.md +++ b/docs/examples/pypsa/process_commitment.md @@ -117,7 +117,7 @@ given: Process_maintenance_status: { dims: [scenario, snapshot, process] } Process_p_nom_ext: { dims: [process] } expressions: - scenario_opex: { dims: [scenario], term: Process_commitment_opex } + scenario_opex: { dims: [scenario] } expressions: Process_previous_status: @@ -133,6 +133,7 @@ expressions: sum(sum(((Process_status * Process_stand_by_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=process), over=snapshot) + sum(sum(Process_start_up * Process_start_up_cost, over=process), over=snapshot) + sum(sum(Process_shut_down * Process_shut_down_cost, over=process), over=snapshot) + adds_to: scenario_opex constraints: Process_com_p_lower: diff --git a/docs/examples/pypsa/settings.md b/docs/examples/pypsa/settings.md index 2ddbf7db..ab633b46 100644 --- a/docs/examples/pypsa/settings.md +++ b/docs/examples/pypsa/settings.md @@ -5,7 +5,7 @@ SPDX-License-Identifier: CC-BY-4.0 # Settings -One of the [24 fragments](index.md) of `examples/pypsa.yaml`: the weightings, the risk preference and the flags every topic reads. It declares the seven totals whose readers may be left out, `scenario_opex`, `Carrier_additions` and the five global-constraint sums, each as an empty sum, `empty: true`, for the components to add to. +One of the [24 fragments](index.md) of `examples/pypsa.yaml`: the weightings, the risk preference and the flags every topic reads. It reads the eight totals whose readers may be left out, `total_cost`, `scenario_opex`, `Carrier_additions` and the five global-constraint sums, under `given:`, so the terms the components add to them always have a reader. It sets the objective, which reads `total_cost`. ```yaml @@ -76,6 +76,58 @@ parameters: dims: [scenario, global_constraint, snapshot] dtype: bool +given: + expressions: + primary_energy: + dims: [scenario, global_constraint] + description: >- + what a `primary_energy` row totals — weighted generator energy over the + snapshots it counts, less the charge left in weighted storage at the + close; the initial charge it is compared against is folded into the + row's constant + operational_limit: + dims: [scenario, global_constraint] + description: >- + what an `operational_limit` row totals — the weighted energy its + generators deliver over the snapshots it counts, plus what its + non-cyclic storage draws down; the initial charge it draws from is + folded into the row's constant + transmission_volume_expansion: + dims: [scenario, global_constraint] + description: >- + what a `transmission_volume_expansion_limit` row totals — length times + the chosen build of the row's branches + transmission_expansion_cost: + dims: [scenario, global_constraint] + description: >- + what a `transmission_expansion_cost_limit` row totals — capital cost + times the chosen build of the row's branches + tech_capacity_expansion: + dims: [global_constraint] + description: >- + what a `tech_capacity_expansion_limit` row totals — the chosen build of + the row's carrier-and-bus set + scenario_opex: + dims: [scenario] + description: >- + what a future costs to run — every operating term, weighted by the + snapshot's hours and its period, before the scenario's own weight; a + start and a stop cost what they cost, unweighted, as PyPSA adds them + (`optimize.py:414-429`) + total_cost: + dims: [] + description: >- + what the system costs — capacity once per active period at its expected + cost over the scenarios, operation in expectation over the scenarios, + and a share of it at the tail + Carrier_additions: + dims: [period, carrier] + description: >- + what a carrier adds in a period — every extendable component of that + carrier, counting each build in the first period it stands in. Like + PyPSA, it sums only the components that carry a carrier attribute, so a + transformer, which has none, counts in no carrier + expressions: GlobalConstraint_energy_weight: description: >- @@ -96,56 +148,10 @@ expressions: when: GlobalConstraint_counts_snapshot AND NOT shift(GlobalConstraint_counts_snapshot, along=snapshot, offset=-1) expression: 1 otherwise: 0 - scenario_opex: - dims: [scenario] - empty: true - description: >- - what a future costs to run — every operating term, weighted by the - snapshot's hours and its period, before the scenario's own weight; a - start and a stop cost what they cost, unweighted, as PyPSA adds them - (`optimize.py:414-429`) - Carrier_additions: - dims: [period, carrier] - empty: true - description: >- - what a carrier adds in a period — every extendable component of that - carrier, counting each build in the first period it stands in. Like - PyPSA, it sums only the components that carry a carrier attribute, so a - transformer, which has none, counts in no carrier - primary_energy: - dims: [scenario, global_constraint] - empty: true - description: >- - what a `primary_energy` row totals — weighted generator energy over the - snapshots it counts, less the charge left in weighted storage at the - close; the initial charge it is compared against is folded into the - row's constant - operational_limit: - dims: [scenario, global_constraint] - empty: true - description: >- - what an `operational_limit` row totals — the weighted energy its - generators deliver over the snapshots it counts, plus what its - non-cyclic storage draws down; the initial charge it draws from is - folded into the row's constant - tech_capacity_expansion: - dims: [global_constraint] - empty: true - description: >- - what a `tech_capacity_expansion_limit` row totals — the chosen build of - the row's carrier-and-bus set - transmission_volume_expansion: - dims: [scenario, global_constraint] - empty: true - description: >- - what a `transmission_volume_expansion_limit` row totals — length times - the chosen build of the row's branches - transmission_expansion_cost: - dims: [scenario, global_constraint] - empty: true - description: >- - what a `transmission_expansion_cost_limit` row totals — capital cost - times the chosen build of the row's branches + +objective: + sense: minimize + expression: total_cost ``` #### Sets @@ -172,19 +178,31 @@ expressions: | $`\mathrm{lossy}`$ | `transmission_losses` (scalar) — whether the network dissipates transmission losses — PyPSA's `transmission_losses` read as a flag; its mode, tangents or secants, only decides how data prep fills the `segment` axis, the rows are the same; false with no segments is a lossless run. A security-constrained run over a network with passive branches builds no loss: PyPSA does not hand the keyword to `create_model` (`abstract.py:437-441`) but to the solver (`:491`), so data prep feeds false there | | $`\mathrm{in}`$ | `GlobalConstraint_counts_snapshot` over $`\Xi \times \mathcal{G} \times \mathcal{T}`$ — whether a row counts a snapshot in a scenario — PyPSA's `investment_period`: every snapshot where the row names none, and only that period's where it names one, data prep. A row that names a period the run does not model has no label here, as PyPSA skips it (`global_constraints.py:377`); PyPSA reads the column only under `multi_investment_periods`, and fails on a row that names a period without it (`global_constraints.py:375`) | +#### Given + +| Symbol | Meaning | +|---|---| +| $`\mathit{primary\_energy}`$ | `primary_energy` over $`\Xi \times \mathcal{G}`$, an expression another file defines — what a `primary_energy` row totals — weighted generator energy over the snapshots it counts, less the charge left in weighted storage at the close; the initial charge it is compared against is folded into the row's constant | +| $`\mathit{operational\_limit}`$ | `operational_limit` over $`\Xi \times \mathcal{G}`$, an expression another file defines — what an `operational_limit` row totals — the weighted energy its generators deliver over the snapshots it counts, plus what its non-cyclic storage draws down; the initial charge it draws from is folded into the row's constant | +| $`\mathit{transmission\_volume\_expansion}`$ | `transmission_volume_expansion` over $`\Xi \times \mathcal{G}`$, an expression another file defines — what a `transmission_volume_expansion_limit` row totals — length times the chosen build of the row's branches | +| $`\mathit{transmission\_expansion\_cost}`$ | `transmission_expansion_cost` over $`\Xi \times \mathcal{G}`$, an expression another file defines — what a `transmission_expansion_cost_limit` row totals — capital cost times the chosen build of the row's branches | +| $`\mathit{tech\_capacity\_expansion}`$ | `tech_capacity_expansion` over $`\mathcal{G}`$, an expression another file defines — what a `tech_capacity_expansion_limit` row totals — the chosen build of the row's carrier-and-bus set | +| $`\mathit{scenario\_opex}`$ | `scenario_opex` over $`\Xi`$, an expression another file defines — what a future costs to run — every operating term, weighted by the snapshot's hours and its period, before the scenario's own weight; a start and a stop cost what they cost, unweighted, as PyPSA adds them (`optimize.py:414-429`) | +| $`\mathit{total\_cost}`$ | `total_cost` (scalar), an expression another file defines — what the system costs — capacity once per active period at its expected cost over the scenarios, operation in expectation over the scenarios, and a share of it at the tail | +| $`\mathit{Carrier\_additions}`$ | `Carrier_additions` over $`\mathcal{Y} \times \mathcal{I}`$, an expression another file defines — what a carrier adds in a period — every extendable component of that carrier, counting each build in the first period it stands in. Like PyPSA, it sums only the components that carry a carrier attribute, so a transformer, which has none, counts in no carrier | + #### Definitions | Symbol | Meaning | |---|---| | $`\mathit{w}^{\mathrm{gc}}`$ | `GlobalConstraint_energy_weight` over $`\Xi \times \mathcal{G} \times \mathcal{T}`$ — what one unit of power at a snapshot counts for in a row — the generator weighting times the years of the snapshot's period, where the row counts the snapshot, and nothing where it does not | | $`\mathit{last}`$ | `GlobalConstraint_snapshot_closes` over $`\Xi \times \mathcal{G} \times \mathcal{T}`$ — one at the last snapshot a row counts, and zero elsewhere | -| $`\mathit{scenario\_opex}`$ | `scenario_opex` over $`\Xi`$ — what a future costs to run — every operating term, weighted by the snapshot's hours and its period, before the scenario's own weight; a start and a stop cost what they cost, unweighted, as PyPSA adds them (`optimize.py:414-429`) | -| $`\mathit{Carrier\_additions}`$ | `Carrier_additions` over $`\mathcal{Y} \times \mathcal{I}`$ — what a carrier adds in a period — every extendable component of that carrier, counting each build in the first period it stands in. Like PyPSA, it sums only the components that carry a carrier attribute, so a transformer, which has none, counts in no carrier | -| $`\mathit{primary\_energy}`$ | `primary_energy` over $`\Xi \times \mathcal{G}`$ — what a `primary_energy` row totals — weighted generator energy over the snapshots it counts, less the charge left in weighted storage at the close; the initial charge it is compared against is folded into the row's constant | -| $`\mathit{operational\_limit}`$ | `operational_limit` over $`\Xi \times \mathcal{G}`$ — what an `operational_limit` row totals — the weighted energy its generators deliver over the snapshots it counts, plus what its non-cyclic storage draws down; the initial charge it draws from is folded into the row's constant | -| $`\mathit{tech\_capacity\_expansion}`$ | `tech_capacity_expansion` over $`\mathcal{G}`$ — what a `tech_capacity_expansion_limit` row totals — the chosen build of the row's carrier-and-bus set | -| $`\mathit{transmission\_volume\_expansion}`$ | `transmission_volume_expansion` over $`\Xi \times \mathcal{G}`$ — what a `transmission_volume_expansion_limit` row totals — length times the chosen build of the row's branches | -| $`\mathit{transmission\_expansion\_cost}`$ | `transmission_expansion_cost` over $`\Xi \times \mathcal{G}`$ — what a `transmission_expansion_cost_limit` row totals — capital cost times the chosen build of the row's branches | + +#### Objective + +```math +\min \mathit{total\_cost} +``` #### Definitions @@ -199,46 +217,4 @@ expressions: ```math \mathit{last}_{\xi,g,t} = \begin{cases} 1 & \text{if } \mathrm{in}_{\xi,g,t} \wedge \neg \mathrm{in}_{\xi,g,t + 1} \\ 0 & \text{otherwise} \end{cases} \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G},\ t \in \mathcal{T} ``` - -**`scenario_opex`** - -```math -\mathit{scenario\_opex}_{\xi} = \cdots \qquad \forall\, \xi \in \Xi -``` - -**`Carrier_additions`** - -```math -\mathit{Carrier\_additions}_{y,i} = \cdots \qquad \forall\, y \in \mathcal{Y},\ i \in \mathcal{I} -``` - -**`primary_energy`** - -```math -\mathit{primary\_energy}_{\xi,g} = \cdots \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} -``` - -**`operational_limit`** - -```math -\mathit{operational\_limit}_{\xi,g} = \cdots \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} -``` - -**`tech_capacity_expansion`** - -```math -\mathit{tech\_capacity\_expansion}_{g} = \cdots \qquad \forall\, g \in \mathcal{G} -``` - -**`transmission_volume_expansion`** - -```math -\mathit{transmission\_volume\_expansion}_{\xi,g} = \cdots \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} -``` - -**`transmission_expansion_cost`** - -```math -\mathit{transmission\_expansion\_cost}_{\xi,g} = \cdots \qquad \forall\, \xi \in \Xi,\ g \in \mathcal{G} -``` diff --git a/docs/examples/pypsa/storage_unit.md b/docs/examples/pypsa/storage_unit.md index b4706672..f02ac6e1 100644 --- a/docs/examples/pypsa/storage_unit.md +++ b/docs/examples/pypsa/storage_unit.md @@ -5,7 +5,7 @@ SPDX-License-Identifier: CC-BY-4.0 # Storage units -One of the [24 fragments](index.md) of `examples/pypsa.yaml`: PyPSA's `StorageUnit`. It adds a term to `primary_energy`, `operational_limit`, `tech_capacity_expansion`, `scenario_opex`, `Carrier_additions`, `Bus_injection`. It reads `CVaR_omega`, `GlobalConstraint_counts_snapshot`, `GlobalConstraint_snapshot_closes`, `period_weight_objective`, `period_weight_years`, `scenario_weight` and 2 more under [`given`](../../reference/language/declarations.md#given). +One of the [24 fragments](index.md) of `examples/pypsa.yaml`: PyPSA's `StorageUnit`. It adds a term to `primary_energy`, `operational_limit`, `tech_capacity_expansion`, `scenario_opex`, `total_cost`, `Carrier_additions`, `Bus_injection`. It reads `CVaR_omega`, `GlobalConstraint_counts_snapshot`, `GlobalConstraint_snapshot_closes`, `period_weight_objective`, `period_weight_years`, `scenario_weight` and 2 more under [`given`](../../reference/language/declarations.md#given). ```yaml @@ -218,12 +218,13 @@ given: GlobalConstraint_counts_snapshot: { dims: [scenario, global_constraint, snapshot], dtype: bool } expressions: GlobalConstraint_snapshot_closes: { dims: [scenario, global_constraint, snapshot] } - primary_energy: { dims: [scenario, global_constraint], term: StorageUnit_primary_energy } - operational_limit: { dims: [scenario, global_constraint], term: StorageUnit_operational_limit } - tech_capacity_expansion: { dims: [global_constraint], term: StorageUnit_tech_capacity_expansion } - scenario_opex: { dims: [scenario], term: StorageUnit_opex } - Carrier_additions: { dims: [period, carrier], term: StorageUnit_additions } - Bus_injection: { dims: [scenario, snapshot, bus], term: StorageUnit_injection } + primary_energy: { dims: [scenario, global_constraint] } + operational_limit: { dims: [scenario, global_constraint] } + tech_capacity_expansion: { dims: [global_constraint] } + scenario_opex: { dims: [scenario] } + total_cost: { dims: [] } + Carrier_additions: { dims: [period, carrier] } + Bus_injection: { dims: [scenario, snapshot, bus] } expressions: StorageUnit_charge_carried_in: @@ -280,23 +281,32 @@ expressions: StorageUnit_primary_energy: expression: >- -sum(sum((StorageUnit_state_of_charge * StorageUnit_closing_weight) * StorageUnit_primary_energy_weight, over=snapshot), over=storage_unit) + adds_to: primary_energy StorageUnit_operational_limit: expression: >- -sum(sum((StorageUnit_state_of_charge * StorageUnit_closing_weight) * StorageUnit_operational_limit_weight, over=snapshot), over=storage_unit) + adds_to: operational_limit StorageUnit_tech_capacity_expansion: expression: sum(StorageUnit_p_nom_ext * StorageUnit_tech_capacity_weight, over=storage_unit) + adds_to: tech_capacity_expansion StorageUnit_opex: expression: >- sum(sum(((StorageUnit_p_dispatch * StorageUnit_marginal_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) + sum(sum((((StorageUnit_p_dispatch * StorageUnit_p_dispatch) * StorageUnit_marginal_cost_quadratic) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) + sum(sum(((StorageUnit_state_of_charge * StorageUnit_marginal_cost_storage) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) + sum(sum(((StorageUnit_spill * StorageUnit_spill_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) + adds_to: scenario_opex + StorageUnit_capex: + expression: sum(scenario_weight * StorageUnit_p_nom_ext * StorageUnit_capital_cost * StorageUnit_capital_weight) + adds_to: total_cost StorageUnit_additions: expression: >- sum(StorageUnit_p_nom_ext * StorageUnit_first_active, by=StorageUnit_carrier, over=storage_unit, into=carrier) + adds_to: Carrier_additions StorageUnit_injection: expression: >- sum(StorageUnit_sign * (StorageUnit_p_dispatch - StorageUnit_p_store), by=StorageUnit_bus, over=storage_unit, into=bus) + adds_to: Bus_injection constraints: StorageUnit_fix_p_dispatch_lower: @@ -462,11 +472,6 @@ assumptions: refuses quadratic costs under any risk preference (`optimize.py:467-474`). The spec cannot tell no risk preference from one with `omega = 0`, so it refuses only where `omega` is positive - -objective: - sense: minimize - expression: >- - sum(((scenario_weight * StorageUnit_p_nom_ext) * StorageUnit_capital_cost) * StorageUnit_capital_weight) ``` #### Sets @@ -546,6 +551,7 @@ objective: | $`\mathit{operational\_limit}`$ | `operational_limit` over $`\Xi \times \mathcal{G}`$, an expression this file adds `StorageUnit_operational_limit` to | | $`\mathit{tech\_capacity\_expansion}`$ | `tech_capacity_expansion` over $`\mathcal{G}`$, an expression this file adds `StorageUnit_tech_capacity_expansion` to | | $`\mathit{scenario\_opex}`$ | `scenario_opex` over $`\Xi`$, an expression this file adds `StorageUnit_opex` to | +| $`\mathit{total\_cost}`$ | `total_cost` (scalar), an expression this file adds `StorageUnit_capex` to | | $`\mathit{Carrier\_additions}`$ | `Carrier_additions` over $`\mathcal{Y} \times \mathcal{I}`$, an expression this file adds `StorageUnit_additions` to | | $`\mathit{Bus\_injection}`$ | `Bus_injection` over $`\Xi \times \mathcal{T} \times \mathcal{N}`$, an expression this file adds `StorageUnit_injection` to | @@ -559,6 +565,7 @@ objective: | $`\mathit{StorageUnit\_operational\_limit}`$ | `StorageUnit_operational_limit` over $`\Xi \times \mathcal{G}`$ | | $`\mathit{StorageUnit\_tech\_capacity\_expansion}`$ | `StorageUnit_tech_capacity_expansion` over $`\mathcal{G}`$ | | $`\mathit{StorageUnit\_opex}`$ | `StorageUnit_opex` over $`\Xi`$ | +| $`\mathit{StorageUnit\_capex}`$ | `StorageUnit_capex` (scalar) | | $`\mathit{StorageUnit\_additions}`$ | `StorageUnit_additions` over $`\mathcal{Y} \times \mathcal{I}`$ | | $`\mathit{StorageUnit\_injection}`$ | `StorageUnit_injection` over $`\Xi \times \mathcal{T} \times \mathcal{N}`$ | @@ -572,12 +579,6 @@ $`\mathrm{pos}_{\mathrm{relation}(t)}(t)`$ counts within the group a relation pu $`\lvert \mathcal{T} \rvert`$ denotes the size of the set being counted along, and a position counted from the end prints against it — $`\lvert \mathcal{T} \rvert - 1`$ is the last position, one less than the size because the first is $`0`$. -#### Objective - -```math -\min \sum_{\xi \in \Xi,\ s \in \mathcal{S}} \pi_{\xi} \cdot H_{s} \cdot \mathrm{c}^{\mathrm{cap},h}_{\xi,s} \cdot \mathrm{W}^{h}_{s} -``` - #### Subject to **`StorageUnit_fix_p_dispatch_lower`** @@ -738,6 +739,12 @@ h^{-}_{\xi,t,s} = \mathrm{h}^{-,\mathrm{set}}_{\xi,t,s} \qquad \forall\, \xi \in \mathit{StorageUnit\_opex}_{\xi} = \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} h^{+}_{\xi,t,s} \cdot \mathrm{c}^{h}_{\xi,t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} h^{+}_{\xi,t,s} \cdot h^{+}_{\xi,t,s} \cdot \mathrm{c}^{h,(2)}_{\xi,t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} \mathit{soc}_{\xi,t,s} \cdot \mathrm{c}^{\mathrm{soc}}_{\xi,t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{s \in \mathcal{S}} \mathit{spill}_{\xi,t,s} \cdot \mathrm{c}^{\mathrm{spill}}_{\xi,t,s} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} \qquad \forall\, \xi \in \Xi ``` +**`StorageUnit_capex`** + +```math +\mathit{StorageUnit\_capex} = \sum_{\xi \in \Xi,\ s \in \mathcal{S}} \pi_{\xi} \cdot H_{s} \cdot \mathrm{c}^{\mathrm{cap},h}_{\xi,s} \cdot \mathrm{W}^{h}_{s} +``` + **`StorageUnit_additions`** ```math diff --git a/docs/examples/pypsa/store.md b/docs/examples/pypsa/store.md index 7d05f8f3..7c350436 100644 --- a/docs/examples/pypsa/store.md +++ b/docs/examples/pypsa/store.md @@ -5,7 +5,7 @@ SPDX-License-Identifier: CC-BY-4.0 # Stores -One of the [24 fragments](index.md) of `examples/pypsa.yaml`: PyPSA's `Store`. It adds a term to `primary_energy`, `operational_limit`, `tech_capacity_expansion`, `scenario_opex`, `Carrier_additions`, `Bus_injection`. It reads `CVaR_omega`, `GlobalConstraint_counts_snapshot`, `GlobalConstraint_snapshot_closes`, `period_weight_objective`, `period_weight_years`, `scenario_weight` and 2 more under [`given`](../../reference/language/declarations.md#given). +One of the [24 fragments](index.md) of `examples/pypsa.yaml`: PyPSA's `Store`. It adds a term to `primary_energy`, `operational_limit`, `tech_capacity_expansion`, `scenario_opex`, `total_cost`, `Carrier_additions`, `Bus_injection`. It reads `CVaR_omega`, `GlobalConstraint_counts_snapshot`, `GlobalConstraint_snapshot_closes`, `period_weight_objective`, `period_weight_years`, `scenario_weight` and 2 more under [`given`](../../reference/language/declarations.md#given). ```yaml @@ -182,12 +182,13 @@ given: GlobalConstraint_counts_snapshot: { dims: [scenario, global_constraint, snapshot], dtype: bool } expressions: GlobalConstraint_snapshot_closes: { dims: [scenario, global_constraint, snapshot] } - primary_energy: { dims: [scenario, global_constraint], term: Store_primary_energy } - operational_limit: { dims: [scenario, global_constraint], term: Store_operational_limit } - tech_capacity_expansion: { dims: [global_constraint], term: Store_tech_capacity_expansion } - scenario_opex: { dims: [scenario], term: Store_opex } - Carrier_additions: { dims: [period, carrier], term: Store_additions } - Bus_injection: { dims: [scenario, snapshot, bus], term: Store_injection } + primary_energy: { dims: [scenario, global_constraint] } + operational_limit: { dims: [scenario, global_constraint] } + tech_capacity_expansion: { dims: [global_constraint] } + scenario_opex: { dims: [scenario] } + total_cost: { dims: [] } + Carrier_additions: { dims: [period, carrier] } + Bus_injection: { dims: [scenario, snapshot, bus] } expressions: Store_energy_carried_in: @@ -238,20 +239,30 @@ expressions: Store_primary_energy: expression: >- -sum(sum((Store_e * Store_closing_weight) * Store_primary_energy_weight, over=snapshot), over=store) + adds_to: primary_energy Store_operational_limit: expression: >- -sum(sum((Store_e * Store_closing_weight) * Store_operational_limit_weight, over=snapshot), over=store) + adds_to: operational_limit Store_tech_capacity_expansion: expression: sum(Store_e_nom_ext * Store_tech_capacity_weight, over=store) + adds_to: tech_capacity_expansion Store_opex: expression: >- sum(sum(((Store_p * Store_marginal_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=store), over=snapshot) + sum(sum((((Store_p * Store_p) * Store_marginal_cost_quadratic) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=store), over=snapshot) + sum(sum(((Store_e * Store_marginal_cost_storage) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=store), over=snapshot) + adds_to: scenario_opex + Store_capex: + expression: sum(scenario_weight * Store_e_nom_ext * Store_capital_cost * Store_capital_weight) + adds_to: total_cost Store_additions: expression: >- sum(Store_e_nom_ext * Store_first_active, by=Store_carrier, over=store, into=carrier) - Store_injection: sum(Store_sign * Store_p, by=Store_bus, over=store, into=bus) + adds_to: Carrier_additions + Store_injection: + expression: sum(Store_sign * Store_p, by=Store_bus, over=store, into=bus) + adds_to: Bus_injection constraints: Store_fix_e_lower: @@ -358,11 +369,6 @@ assumptions: refuses quadratic costs under any risk preference (`optimize.py:467-474`). The spec cannot tell no risk preference from one with `omega = 0`, so it refuses only where `omega` is positive - -objective: - sense: minimize - expression: >- - sum(((scenario_weight * Store_e_nom_ext) * Store_capital_cost) * Store_capital_weight) ``` #### Sets @@ -433,6 +439,7 @@ objective: | $`\mathit{operational\_limit}`$ | `operational_limit` over $`\Xi \times \mathcal{G}`$, an expression this file adds `Store_operational_limit` to | | $`\mathit{tech\_capacity\_expansion}`$ | `tech_capacity_expansion` over $`\mathcal{G}`$, an expression this file adds `Store_tech_capacity_expansion` to | | $`\mathit{scenario\_opex}`$ | `scenario_opex` over $`\Xi`$, an expression this file adds `Store_opex` to | +| $`\mathit{total\_cost}`$ | `total_cost` (scalar), an expression this file adds `Store_capex` to | | $`\mathit{Carrier\_additions}`$ | `Carrier_additions` over $`\mathcal{Y} \times \mathcal{I}`$, an expression this file adds `Store_additions` to | | $`\mathit{Bus\_injection}`$ | `Bus_injection` over $`\Xi \times \mathcal{T} \times \mathcal{N}`$, an expression this file adds `Store_injection` to | @@ -446,6 +453,7 @@ objective: | $`\mathit{Store\_operational\_limit}`$ | `Store_operational_limit` over $`\Xi \times \mathcal{G}`$ | | $`\mathit{Store\_tech\_capacity\_expansion}`$ | `Store_tech_capacity_expansion` over $`\mathcal{G}`$ | | $`\mathit{Store\_opex}`$ | `Store_opex` over $`\Xi`$ | +| $`\mathit{Store\_capex}`$ | `Store_capex` (scalar) | | $`\mathit{Store\_additions}`$ | `Store_additions` over $`\mathcal{Y} \times \mathcal{I}`$ | | $`\mathit{Store\_injection}`$ | `Store_injection` over $`\Xi \times \mathcal{T} \times \mathcal{N}`$ | @@ -459,12 +467,6 @@ $`\mathrm{pos}_{\mathrm{relation}(t)}(t)`$ counts within the group a relation pu $`\lvert \mathcal{T} \rvert`$ denotes the size of the set being counted along, and a position counted from the end prints against it — $`\lvert \mathcal{T} \rvert - 1`$ is the last position, one less than the size because the first is $`0`$. -#### Objective - -```math -\min \sum_{\xi \in \Xi,\ v \in \mathcal{V}} \pi_{\xi} \cdot E_{v} \cdot \mathrm{c}^{\mathrm{cap},e}_{\xi,v} \cdot \mathrm{W}^{e}_{v} -``` - #### Subject to **`Store_fix_e_lower`** @@ -565,6 +567,12 @@ q_{\xi,t,v} = \mathrm{q}^{\mathrm{set}}_{\xi,t,v} \qquad \forall\, \xi \in \Xi,\ \mathit{Store\_opex}_{\xi} = \sum_{t \in \mathcal{T}} \sum_{v \in \mathcal{V}} q_{\xi,t,v} \cdot \mathrm{c}^{q}_{\xi,t,v} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{v \in \mathcal{V}} q_{\xi,t,v} \cdot q_{\xi,t,v} \cdot \mathrm{c}^{q,(2)}_{\xi,t,v} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} + \sum_{t \in \mathcal{T}} \sum_{v \in \mathcal{V}} e_{\xi,t,v} \cdot \mathrm{c}^{e}_{\xi,t,v} \cdot \mathrm{w}_{t} \cdot \mathrm{w}^{y}_{\mathrm{snapshot\_period}(t)} \qquad \forall\, \xi \in \Xi ``` +**`Store_capex`** + +```math +\mathit{Store\_capex} = \sum_{\xi \in \Xi,\ v \in \mathcal{V}} \pi_{\xi} \cdot E_{v} \cdot \mathrm{c}^{\mathrm{cap},e}_{\xi,v} \cdot \mathrm{W}^{e}_{v} +``` + **`Store_additions`** ```math diff --git a/docs/examples/pypsa/transformer.md b/docs/examples/pypsa/transformer.md index b45100ae..c8c87e20 100644 --- a/docs/examples/pypsa/transformer.md +++ b/docs/examples/pypsa/transformer.md @@ -5,7 +5,7 @@ SPDX-License-Identifier: CC-BY-4.0 # Transformers -One of the [24 fragments](index.md) of `examples/pypsa.yaml`: PyPSA's `Transformer`. It adds a term to `Bus_injection`, `Cycle_angle_sum`. It reads `scenario_weight`, `transmission_losses` under [`given`](../../reference/language/declarations.md#given). +One of the [24 fragments](index.md) of `examples/pypsa.yaml`: PyPSA's `Transformer`. It adds a term to `total_cost`, `Bus_injection`, `Cycle_angle_sum`. It reads `scenario_weight`, `transmission_losses` under [`given`](../../reference/language/declarations.md#given). ```yaml @@ -172,10 +172,14 @@ given: scenario_weight: { dims: [scenario] } transmission_losses: { dims: [], dtype: bool } expressions: - Bus_injection: { dims: [scenario, snapshot, bus], term: Transformer_injection } - Cycle_angle_sum: { dims: [scenario, snapshot, cycle], term: Transformer_angle_sum } + total_cost: { dims: [] } + Bus_injection: { dims: [scenario, snapshot, bus] } + Cycle_angle_sum: { dims: [scenario, snapshot, cycle] } expressions: + Transformer_capex: + expression: sum(scenario_weight * Transformer_s_nom_ext * Transformer_capital_cost * Transformer_capital_weight) + adds_to: total_cost Transformer_s_monitored: description: the flow a transformer's post-contingency rows read, as a line's dims: [scenario, snapshot, transformer] @@ -188,11 +192,13 @@ expressions: + sum(Transformer_s, by=Transformer_bus1, over=transformer, into=bus) - (0.5 * sum(Transformer_loss, by=Transformer_bus0, over=transformer, into=bus)) - (0.5 * sum(Transformer_loss, by=Transformer_bus1, over=transformer, into=bus)) + adds_to: Bus_injection Transformer_angle_sum: expression: >- sum(Transformer_s * Transformer_cycle_weight, over=transformer) + sum(Transformer_phase_shift_weight, over=transformer) + sum(Transformer_phase_shift * Transformer_phase_shift_cycle_weight, over=transformer) + adds_to: Cycle_angle_sum constraints: Transformer_fix_s_lower: @@ -255,11 +261,6 @@ constraints: dims: [scenario, snapshot, transformer, segment] where: transmission_losses AND Transformer_active expression: Transformer_loss - Transformer_loss_slope * Transformer_s >= Transformer_loss_offset - -objective: - sense: minimize - expression: >- - sum(((scenario_weight * Transformer_s_nom_ext) * Transformer_capital_cost) * Transformer_capital_weight) ``` #### Sets @@ -312,6 +313,7 @@ objective: |---|---| | $`\pi`$ | `scenario_weight` over $`\Xi`$, data another file declares | | $`\mathrm{lossy}`$ | `transmission_losses` (scalar), data another file declares | +| $`\mathit{total\_cost}`$ | `total_cost` (scalar), an expression this file adds `Transformer_capex` to | | $`\mathit{Bus\_injection}`$ | `Bus_injection` over $`\Xi \times \mathcal{T} \times \mathcal{N}`$, an expression this file adds `Transformer_injection` to | | $`\mathit{Cycle\_angle\_sum}`$ | `Cycle_angle_sum` over $`\Xi \times \mathcal{T} \times \mathcal{C}`$, an expression this file adds `Transformer_angle_sum` to | @@ -319,18 +321,13 @@ objective: | Symbol | Meaning | |---|---| +| $`\mathit{Transformer\_capex}`$ | `Transformer_capex` (scalar) | | $`\check{\sigma}`$ | `Transformer_s_monitored` over $`\Xi \times \mathcal{T} \times \mathcal{M}`$ — the flow a transformer's post-contingency rows read, as a line's | | $`\mathit{Transformer\_injection}`$ | `Transformer_injection` over $`\Xi \times \mathcal{T} \times \mathcal{N}`$ | | $`\mathit{Transformer\_angle\_sum}`$ | `Transformer_angle_sum` over $`\Xi \times \mathcal{T} \times \mathcal{C}`$ | Upright is what the data supplies — a parameter such as $`\mathrm{Transformer\_phase\_shift\_varying}`$, a coordinate map, a label — and italic is what the solver chooses, such as $`\mathit{Transformer\_phase\_shift}`$. An index is italic too, being what a quantifier chooses, and a set is script. -#### Objective - -```math -\min \sum_{\xi \in \Xi,\ m \in \mathcal{M}} \pi_{\xi} \cdot \Sigma_{m} \cdot \mathrm{c}^{\mathrm{cap},\sigma}_{\xi,m} \cdot \mathrm{W}^{\sigma}_{m} -``` - #### Subject to **`Transformer_fix_s_lower`** @@ -401,6 +398,12 @@ Upright is what the data supplies — a parameter such as $`\mathrm{Transformer\ #### Definitions +**`Transformer_capex`** + +```math +\mathit{Transformer\_capex} = \sum_{\xi \in \Xi,\ m \in \mathcal{M}} \pi_{\xi} \cdot \Sigma_{m} \cdot \mathrm{c}^{\mathrm{cap},\sigma}_{\xi,m} \cdot \mathrm{W}^{\sigma}_{m} +``` + **`Transformer_s_monitored`** ```math diff --git a/docs/howto/compose.md b/docs/howto/compose.md index ca3652df..3950adbe 100644 --- a/docs/howto/compose.md +++ b/docs/howto/compose.md @@ -15,31 +15,40 @@ takes its files as a list, and the two compose as `override(merge([…]), […]) ## A library of components -1. **Write the network as a spec.** It declares the injection at a bus as an - [empty sum](../reference/language/declarations.md#a-term-a-file-adds), - `empty: true` over its frame, and balances it. Nothing in it names a - component class. +1. **Write the network as a spec.** It balances the injection at a bus, and + reads the injection under + [`given`](../reference/language/declarations.md#given): what the components + put in is theirs to say. It sets the objective on `total_cost`, which it + reads the same way: what each component costs is the component's to say. + Nothing in it names a component class. ```yaml title="network.yaml" dimensions: snapshot: { dtype: int } bus: { dtype: str } - expressions: - Bus_injection: - dims: [snapshot, bus] - empty: true - description: what the components put into a bus + given: + expressions: + Bus_injection: + dims: [snapshot, bus] + description: what the components put into a bus + total_cost: + dims: [] + description: what running the system costs constraints: Bus_balance: dims: [snapshot, bus] expression: Bus_injection == 0 + objective: + sense: minimize + expression: total_cost ``` 2. **Write each component file against the network.** It declares its own - dimension and its own math. It says what it puts into a bus as a named - expression, and names that expression as the `term:` of a - [`given`](../reference/language/declarations.md#given) entry for - `Bus_injection`. + dimension and its own math. It reads `Bus_injection` too, and says what it + puts into a bus as a named expression, a + [term](../reference/language/declarations.md#terms) whose `adds_to:` names + `Bus_injection`. A component that costs something adds its cost to + `total_cost` the same way. ```yaml title="generator.yaml" dimensions: @@ -53,14 +62,17 @@ takes its files as a list, and the two compose as `override(merge([…]), […]) Generator_marginal_cost: { dims: [generator] } variables: Generator_p: { dims: [snapshot, generator], bounds: { lower: 0, upper: Generator_p_nom } } - expressions: - Generator_injection: sum(Generator_p, by=Generator_bus, over=generator, into=bus) given: expressions: - Bus_injection: { dims: [snapshot, bus], term: Generator_injection } - objective: - sense: minimize - expression: sum(Generator_p * Generator_marginal_cost) + Bus_injection: { dims: [snapshot, bus] } + total_cost: { dims: [] } + expressions: + Generator_injection: + expression: sum(Generator_p, by=Generator_bus, over=generator, into=bus) + adds_to: Bus_injection + Generator_cost: + expression: sum(Generator_p * Generator_marginal_cost) + adds_to: total_cost ``` ```yaml title="load.yaml" @@ -72,14 +84,18 @@ takes its files as a list, and the two compose as `override(merge([…]), […]) Load_bus: { key: load, values: bus } parameters: Load_p_set: { dims: [snapshot, load] } - expressions: - Load_injection: -sum(Load_p_set, by=Load_bus, over=load, into=bus) given: expressions: - Bus_injection: { dims: [snapshot, bus], term: Load_injection } + Bus_injection: { dims: [snapshot, bus] } + expressions: + Load_injection: + expression: -sum(Load_p_set, by=Load_bus, over=load, into=bus) + adds_to: Bus_injection ``` - Each file loads on its own and prints as math on its own. + Each file loads on its own and prints as math on its own. Only the + network sets an objective, so `generator.yaml` on its own is a feasibility + problem. 3. **Merge the files you need.** Give them as a list. A refusal names a file by its path as the list gives it, and a mapping or a loaded spec by its @@ -91,11 +107,13 @@ takes its files as a list, and the two compose as `override(merge([…]), […]) spec = ms.merge(['network.yaml', 'generator.yaml', 'load.yaml']) ``` - `spec` writes `Bus_injection` as `Generator_injection + Load_injection`, and - keeps each term as a named expression. It carries no `given:`. The - objectives of the fragments are summed, each term in parentheses, in the - order of the list. The order changes no meaning: - [`canonical`](compare.md) writes the same text for every order. + `spec` defines `Bus_injection` as `Generator_injection + Load_injection`, + and keeps each term as a named expression. Nothing is left under `given:`, + so `spec` is fully defined. The objective is the network's, and + `total_cost` is `Generator_cost`. The order of the list changes no + meaning: [`canonical`](compare.md) writes the same text for every order. + A second file that sets an objective is refused: one fragment sets it, + and each other one adds its part to the sum it reads. 4. **Add a component without touching the network.** A new file adds its own term, and `network.yaml` stays as it is. @@ -116,15 +134,62 @@ takes its files as a list, and the two compose as `override(merge([…]), […]) Store_energy_balance: dims: [snapshot, store] expression: Store_e == shift(Store_e, along=snapshot, offset=1, edge='wrap') - Store_p + given: + expressions: + Bus_injection: { dims: [snapshot, bus] } expressions: - Store_injection: sum(Store_p, by=Store_bus, over=store, into=bus) + Store_injection: + expression: sum(Store_p, by=Store_bus, over=store, into=bus) + adds_to: Bus_injection + ``` + + Add the file to the list: + + ```python + spec = ms.merge(['network.yaml', 'generator.yaml', 'load.yaml', 'store.yaml']) + ``` + + `Bus_injection` is `Generator_injection + Load_injection + Store_injection`. + A merged spec takes a further term the same way, so + `ms.merge([ms.merge(['network.yaml', 'generator.yaml', 'load.yaml']), 'store.yaml'])` + gives the same sum. + +5. **Give the network a part of its own.** Define the sum in one file instead + of reading it there. The body that file writes comes first, and every term + follows it. The component files stay as they are. + + ```yaml title="network_slack.yaml" + dimensions: + snapshot: { dtype: int } + bus: { dtype: str } + variables: + Bus_slack: { dims: [snapshot, bus] } + expressions: + Bus_injection: + dims: [snapshot, bus] + expression: Bus_slack + description: what the components put into a bus given: expressions: - Bus_injection: { dims: [snapshot, bus], term: Store_injection } + total_cost: + dims: [] + description: what running the system costs + constraints: + Bus_balance: + dims: [snapshot, bus] + expression: Bus_injection == 0 + objective: + sense: minimize + expression: total_cost + ``` + + ```python + spec = ms.merge(['network_slack.yaml', 'generator.yaml', 'load.yaml']) ``` - With `'store.yaml'` added to the list, `Bus_injection` is - `Generator_injection + Load_injection + Store_injection`. + `Bus_injection` is `Bus_slack + Generator_injection + Load_injection`, over + the `dims:` and with the description of `network_slack.yaml`. One file at + most defines the sum. Each other file reads it under `given:`. A library can also couple its components through a flow variable per port, which each component pins at its own port. @@ -132,18 +197,18 @@ which each component pins at its own port. ## What a fragment may share -| The entry | What happens | -| ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| a dimension or a relation | every fragment may declare it, and the ones that do say the same thing about it | -| a `description` on a shared dimension or relation | it is prose rather than a claim, and the first wording in the list is carried | -| any other declaration | one fragment declares it, and a second is refused | -| an entry under `given:` | it is checked against the fragment that introduces the name, then folded into it. Its description fills the declaration where the introducer wrote none | -| a given expression | the definition's body carries no dimension the reader's `dims` do not name | -| a given entry no fragment introduces | it stays under `given:` until a host model provides it | -| a given expression with a `term` | the name is an `expressions:` block of one fragment, empty or bodied; its body becomes that body, if any, plus every term by its name, in the order of the list. Each term stays a named expression. A term that names no fragment's block is refused | -| `objective` | the terms are summed in the order of the list, each in parentheses, and the senses agree. The first description in the list is carried | -| `version` | every fragment is written against the same one | -| `description` at the top of a fragment | it is about the fragment and is not carried. Pass the composed spec's as `description=` | +| The entry | What happens | +| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| a dimension or a relation | every fragment may declare it, and the ones that do say the same thing about it | +| a `description` on a shared dimension or relation | it is prose rather than a claim, and the first wording in the list is carried | +| any other declaration | one fragment declares it, and a second is refused | +| an entry under `given:` | it is checked against the fragment that introduces the name, then folded into it. Its description fills the declaration where the introducer wrote none | +| a given expression | the definition's body carries no dimension the reader's `dims` do not name | +| a given entry no fragment introduces | it stays under `given:` until a host model provides it | +| an expression with `adds_to:` | the sum it names is the body one fragment defines, if any, followed by every term by its name, in the order of the list. [Terms](../reference/language/declarations.md#terms) gives what is refused | +| `objective` | one fragment sets it, and a second is refused. Several fragments contribute to it as terms of a sum the objective reads | +| `version` | every fragment is written against the same one | +| `description` at the top of a fragment | it is about the fragment and is not carried. Pass the composed spec's as `description=` | ## A name two fragments declare @@ -204,8 +269,11 @@ Given variable 'Generator_p' collides with the variable of the same name. Names 1. **Write the base as a spec**, 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. The base loads on its own, and `override` loads - it first. A patch is not a spec, so it is laid over as written, and the + stays as the base wrote it. A named expression the patch writes on one line + replaces only the body, `expression:` or `cases:`. The entry keeps its + `dims:`, its description and its `adds_to:`, so the new body carries no + dimension outside the kept `dims:`. The base loads on its own, and + `override` loads it first. A patch is not a spec, so it is laid over as written, and the patched spec is loaded after. ```yaml title="base.yaml" diff --git a/docs/reference/language/declarations.md b/docs/reference/language/declarations.md index d32c8b4d..2cef6649 100644 --- a/docs/reference/language/declarations.md +++ b/docs/reference/language/declarations.md @@ -222,17 +222,17 @@ constraints: expression: injection == 0 ``` -| Field | | | -| ------------- | ------------------------------------------------------------------------------- | -------------- | -| `dims` | required. The dimensions the expression runs over | | -| `term` | one expression: what this file adds to the name ([a term](#a-term-a-file-adds)) | default `null` | -| `description` | free text | default `null` | - -There is no body but the term this file adds, if any. This file reads the name -as it reads a given variable: a quantity over the frame, of degree one. A -`where` does not read it, because a mask is built before any variable exists. -A name declared under both `expressions:` and `given: expressions:` is -refused. The typeset legend lists a given expression under _Given_. +| Field | | | +| ------------- | ------------------------------------------------- | -------------- | +| `dims` | required. The dimensions the expression runs over | | +| `description` | free text | default `null` | + +There is no body. A [term](#terms) of this file may add to the name. This +file reads the name as it reads a given variable: a quantity over the frame, of +degree one. A `where` does not read it, because a mask is built before any +variable exists. A name declared under both `expressions:` and +`given: expressions:` is refused. The typeset legend lists a given expression +under _Given_. [`merge`](../../howto/compose.md#a-library-of-components) folds a given expression into the definition of another fragment. The `dims` are an upper @@ -243,18 +243,14 @@ term carries it. The composed spec holds the body to the rules of every place this file reads it: a square of a given expression that is quadratic is refused once folded. -#### A term a file adds +#### Terms -`term:` names the expression this file adds to the name. The term is an -ordinary named expression of this file, so it takes `cases:`, a description -and every other field a named expression takes. The file reads the name as -the whole sum, alone and composed, and the term is its part of it. A file that -only reads the name writes no term. - -The name is an `expressions:` block of one other file. Where that file has -nothing of its own to put in, it declares the sum `empty: true` over a -frame, an [empty sum](named.md#expressions), reads it as a column until the terms -arrive, and prints its definition as `injection = ⋯`: +`adds_to:` adds this expression as a **term** to the sum it names. The sum is +a given expression of the same file: the given entry is the read, and +`adds_to:` is the write. The term is an ordinary named expression, so it takes +`cases:`, a description and every other field a named expression takes. The +file reads the name as the whole sum, alone and composed, and the term is its +part of it. ```yaml # fleet.yaml adds a term @@ -266,57 +262,77 @@ relations: gen_bus: { key: generator, values: bus } variables: gen_p: { dims: [snapshot, generator], bounds: { lower: 0 } } +given: + expressions: + injection: { dims: [snapshot, bus] } expressions: generation: description: what the fleet puts into a bus expression: sum(gen_p, by=gen_bus, over=generator, into=bus) -given: - expressions: - injection: - dims: [snapshot, bus] - term: generation + adds_to: injection ``` ```yaml -# balance.yaml declares the sum, and adds nothing to it +# balance.yaml reads the sum, and adds nothing to it dimensions: snapshot: { dtype: int } bus: { dtype: str } -expressions: - injection: - dims: [snapshot, bus] - empty: true - description: what the components put into a bus +given: + expressions: + injection: + dims: [snapshot, bus] + description: what the components put into a bus constraints: balance: dims: [snapshot, bus] expression: injection == 0 ``` -Each file loads alone. The term names an expression the file declares, and -that expression does not read the name it adds to, directly or through -another name. It carries no dimension the entry does not state, and it is -held to degree two, as what reads the sum is. All of this is checked at load. -The typeset legend lists the entry under _Given_ and names the term, and the -math prints the term under _Definitions_ as its own line. - -[`merge`](../../howto/compose.md#a-library-of-components) writes the name's -body as the owner's body, if it has one, plus every term by its name, in -the order the files are given in, and keeps each term as a named expression of the -composed spec. An empty sum keeps its frame, so the composed spec holds the -terms to it. A term adds to whatever the owner wrote, as a fragment's -objective adds to the objective, and a later merge adds to the composed body -the same way. The owner does not opt in. A file with a body reads it alone, -and the body plus every term once composed; whoever composes the files -answers for that sum. A term has to land on an `expressions:` block another -file declares. Terms alone are refused, with the near miss named, since -`merge` fills or extends what a file declared and never invents a name. A -body written as `cases:` is refused, since it is summed as written: name the -cased body as its own expression, and define the name as that name. A cased -term is added like any other, by its name. The block keeps the owner's -description, or takes the first a reader wrote. Two files that both declare -the name under `expressions:` are refused as a collision, and the message -names `term:`. +Each file loads alone, and the loader checks each term: + +- **`adds_to:` names a `given: expressions:` entry of the same file.** A name + the file does not read there is refused, with the near miss. A name the file + defines is refused too: write the term into that body instead. +- **A term does not read the sum it adds to**, directly or through another + name. +- **A term carries no dimension the given entry does not state.** +- **A term is held to degree two**, as what reads the sum is. + +The typeset legend lists the entry under _Given_ and names the term. The math +prints the term under _Definitions_ as its own line. + +[`merge`](../../howto/compose.md#a-library-of-components) adds every term by +its name, in the order the files are given in. Each term stays a named +expression of the merged spec, without its `adds_to:`. A cased term is added +like any other. + +- **One file may define the sum with one `expression:`.** The merged body is + that body followed by the terms. A file that defines `injection` as `slack` + gives `slack + generation` once it is merged with `fleet.yaml`. The body + keeps its `dims:` and its description. A second file that defines the sum + collides with the first. +- **A merged spec takes further terms.** A merge defines every sum it has + terms for, and a later merge adds to that body. So the first merge that holds + the terms of a sum also holds a file that reads it. +- **Where no file defines the sum, the terms are its body.** The sum runs over + the frame its readers state, and every reader writes the dims in the same + order. The sum takes the first description a reader wrote. +- **Where no file defines the sum, some file reads it for more than adding to + it.** That file reads the sum and adds nothing, or uses it in its math. + Terms that only their own files read are refused, with the near miss, since + that is what a misspelt `given:` entry looks like. A sum that one file alone + reads is refused too, because that file can misspell the entry, the + `adds_to:` and its own use of the name alike. +- **A definition written as `cases:` takes no term.** Name the cased body as + its own expression, and define the sum as that name. +- **A name that a file declares as a variable, a parameter or a constraint + takes no term.** +- **A term does not read its own sum through another file.** The merge refuses + it and names both files: + + ```text + fragment '#2' adds 't' to 'injection', and 't' reads 'injection' back through 'x' of '#1', so the sum would define itself. A term may not read what reads its sum: write 't' from something else, or define 'x' without 'injection'. + ``` ## `constraints` @@ -383,3 +399,7 @@ state different objectives. There is one objective block. To pursue several goals, weight them into one expression. + +A spec composed from several files also has one objective, and one file sets +it. [`merge`](../../howto/compose.md) refuses a second one. Each other file adds its part +to a sum that the objective reads, with [`adds_to:`](#terms). diff --git a/docs/reference/language/named.md b/docs/reference/language/named.md index eb24cb2f..30bbc682 100644 --- a/docs/reference/language/named.md +++ b/docs/reference/language/named.md @@ -43,17 +43,10 @@ expressions: description: the nominal capacity, the same in every snapshot ``` -An entry written `empty: true` over a `dims:` is an **empty sum**: a -quantity this file declares and other files add terms to, through -[`merge`](../../howto/compose.md#a-library-of-components). It has no -`expression:`, `cases:` or `otherwise:`, and `dims:` is required. `empty` -defaults to `false`, and a spec writes it only where it is `true`. An entry -with a `dims:` and no body, and no `empty: true`, is refused. Alone, the file -reads it as a column over the frame, the way it reads a -[given expression](declarations.md#given-expressions), and its definition -prints as `injection = ⋯`: the name is declared here, and the body is what -the other files add. [A term a file adds](declarations.md#a-term-a-file-adds) -is the other half. +[`adds_to:`](declarations.md#terms) adds this expression as a term to the sum +it names. The sum is a [given expression](declarations.md#given-expressions) +of the same file, and [`merge`](../../howto/compose.md#a-library-of-components) +adds the entry to it by its own name. Where the objective or a constraint names it, the body is substituted there, and the [degree limit](expressions.md#where-a-product-of-two-variables-is-allowed) @@ -92,8 +85,7 @@ Each case prints as one row of the definition, and `otherwise:` as the last: $$\mathit{previous\_status}_{t,g} = \begin{cases} 1 & \text{if } \neg \mathrm{committable}_{g} \cr \mathrm{status}^{\mathrm{initial}}_{g} & \text{if } \mathrm{committable}_{g} \wedge \mathrm{pos}(t) = 0 \cr \mathit{status}_{t - 1,g} & \text{otherwise} \end{cases} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ -A named expression carries **exactly one** of `expression:` and `cases:`, or -is an [empty sum](#expressions), `empty: true`. +A named expression carries **exactly one** of `expression:` and `cases:`. | Key | | | ----------- | ---------------------------------------------------------------------------- | diff --git a/docs/reference/reading.md b/docs/reference/reading.md index c1043645..53acc422 100644 --- a/docs/reference/reading.md +++ b/docs/reference/reading.md @@ -170,12 +170,9 @@ reads and does not build ([given](language/declarations.md#given)). Every other group is a build instruction. These four are names to look up in the model this one is layered onto. An expression reads a given expression as a `Variable` of that name, over the frame under `program.given.expressions`. -A given expression with a `term` is one this file adds to: -`program.given.expressions[name].term` is the term: the `Named` node of the -entry of `program.expressions` it names. The name is still one the program -reads and does not build. An empty sum, an `expressions:` entry written -`empty: true`, sits here too, with `empty` set: the program declares the name, -and what fills it is the terms other files add. +An entry of `program.expressions` whose `adds_to` names a given expression is +a term this file adds to it. The name is still one the program reads and does +not build. ```python layer = to_spec( diff --git a/docs/reference/typeset.md b/docs/reference/typeset.md index b87d483f..90b85ece 100644 --- a/docs/reference/typeset.md +++ b/docs/reference/typeset.md @@ -55,9 +55,8 @@ a flag. The [Python API](api.md#typesetting) gives each signature. - A [named expression](language/named.md) prints its symbol where it is used and its body once, under a **Definitions** heading, in declaration order. A `cases:` block, a [reported entry](language/named.md#reported-expressions) - and a [term](language/declarations.md#a-term-a-file-adds) keep their - definition line under either `inline_expressions` setting. An - [empty sum](language/named.md#expressions) prints last, as `symbol = ⋯`. + and a [term](language/declarations.md#terms) keep their definition line + under either `inline_expressions` setting. - Wherever the math moves an index, which every `shift` does, the document prints a line saying what that notation means. - A file that does not load does not print. diff --git a/docs/several-files.md b/docs/several-files.md index 322597b6..92a64322 100644 --- a/docs/several-files.md +++ b/docs/several-files.md @@ -11,10 +11,12 @@ other files. Do [your first spec](first-spec.md) first. ## The network -Make a file `network.yaml`. It balances every bus, and it declares the -injection at a bus as an empty sum, `empty: true`: what the components put -in is theirs to say, in -[a term](reference/language/declarations.md#a-term-a-file-adds) each. +Make a file `network.yaml`. It balances every bus, and it reads the injection +at a bus under [`given:`](reference/language/declarations.md#given): what the +components put in is theirs to say, in +[a term](reference/language/declarations.md#terms) each. It also sets the +objective, and the objective reads the total cost the same way: what each +component costs is a term of its own. ```yaml title="network.yaml" description: Every bus is balanced in every snapshot. @@ -23,16 +25,23 @@ dimensions: snapshot: { dtype: int, description: dispatch periods } bus: { description: network nodes } -expressions: - injection: - dims: [snapshot, bus] - empty: true - description: what the components put into a bus, less what they take out +given: + expressions: + injection: + dims: [snapshot, bus] + description: what the components put into a bus, less what they take out + total_cost: + dims: [] + description: what running the system costs constraints: balance: dims: [snapshot, bus] expression: injection == 0 + +objective: + sense: minimize + expression: total_cost ``` Check the file: @@ -41,18 +50,20 @@ Check the file: python -m mathspec check network.yaml ``` -The check accepts it, and notes the sum with no body yet: +The check accepts it, and notes each name it reads and does not define: ```text -expression 'injection' is a sum this file declares and other files add terms to: merge() writes its body from their terms. Until then, the program reads it and does not build it. +expression 'injection' is read here and declared elsewhere: the model this one is layered onto provides it. A consumer checks that it does, on the same frame, and refuses the program where it does not. A fragment is composed instead: merge() folds this declaration into the one a sibling introduces, or writes it from the terms siblings add. +expression 'total_cost' is read here and declared elsewhere: the model this one is layered onto provides it. A consumer checks that it does, on the same frame, and refuses the program where it does not. A fragment is composed instead: merge() folds this declaration into the one a sibling introduces, or writes it from the terms siblings add. ``` ## The generators Make a file `generators.yaml`. It says what the fleet puts into a bus as a -named expression, `generation`. It reads the injection too, and a -[`term:`](reference/language/declarations.md#a-term-a-file-adds) on the entry -names `generation` as what this file adds to it. The two dimensions it shares +named expression, `generation`. It reads the injection too, and +[`adds_to:`](reference/language/declarations.md#terms) on `generation` names +the injection as what the expression adds to. What the fleet costs is a term +of the total cost in the same way, `generation_cost`. The two dimensions it shares with the network it restates as a dtype and nothing else: a description is not a claim, and `merge` carries the network's. @@ -77,20 +88,20 @@ variables: dims: [snapshot, generator] bounds: { lower: 0, upper: capacity } +given: + expressions: + injection: { dims: [snapshot, bus] } + total_cost: { dims: [] } + expressions: generation: description: what the fleet puts into a bus expression: sum(dispatch, by=gen_bus, over=generator, into=bus) - -given: - expressions: - injection: - dims: [snapshot, bus] - term: generation - -objective: - sense: minimize - expression: sum(dispatch * cost) + adds_to: injection + generation_cost: + description: what running the fleet costs + expression: sum(dispatch * cost) + adds_to: total_cost ``` Check the file: @@ -99,10 +110,11 @@ Check the file: python -m mathspec check generators.yaml ``` -The check accepts it, and notes the term: +The check accepts it, and notes each term: ```text -expression 'injection' is read here and declared elsewhere, and this file adds a term to it: merge() sums the term with what the other files declare under the name. Until then, the program reads it and does not build it. +expression 'injection' is read here, and this file adds a term to it: merge() sums the term with what the other files write under the name. Until then, the program reads it and does not build it. +expression 'total_cost' is read here, and this file adds a term to it: merge() sums the term with what the other files write under the name. Until then, the program reads it and does not build it. ``` Print the math of the file on its own: @@ -113,19 +125,14 @@ import mathspec as ms print(ms.to_markdown('generators.yaml', legend=False)) ``` -The term prints as its own definition, and the legend, left out here, says -what it adds to: +Each term prints as its own definition, and the legend, left out here, says +what it adds to. The file sets no objective, so on its own it is a +feasibility problem: !!! example "Rendered output" A generator fleet, each unit on one bus. - #### Objective - - ```math - \min \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} \mathit{dispatch}_{t,g} \cdot \mathrm{cost}_{g} - ``` - #### Definitions **`generation`** @@ -134,6 +141,12 @@ what it adds to: \mathit{generation}_{t,b} = \sum_{g \in \mathcal{G} \,:\, \mathrm{gen\_bus}(g) = b} \mathit{dispatch}_{t,g} \qquad \forall\, t \in \mathcal{T},\ b \in \mathcal{B} ``` + **`generation_cost`** + + ```math + \mathit{generation}^{\mathrm{cost}} = \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} \mathit{dispatch}_{t,g} \cdot \mathrm{cost}_{g} + ``` + #### Variable domains **`dispatch`** @@ -157,16 +170,15 @@ dimensions: parameters: demand: { dims: [snapshot, bus], description: demand to be met } +given: + expressions: + injection: { dims: [snapshot, bus] } + expressions: consumption: description: what the loads take out of a bus expression: -demand - -given: - expressions: - injection: - dims: [snapshot, bus] - term: consumption + adds_to: injection ``` Check the file: @@ -178,7 +190,7 @@ python -m mathspec check loads.yaml The check accepts it, with the same note: ```text -expression 'injection' is read here and declared elsewhere, and this file adds a term to it: merge() sums the term with what the other files declare under the name. Until then, the program reads it and does not build it. +expression 'injection' is read here, and this file adds a term to it: merge() sums the term with what the other files write under the name. Until then, the program reads it and does not build it. ``` ## Merge the files @@ -211,7 +223,7 @@ print(ms.to_markdown(spec, legend=False)) #### Objective ```math - \min \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} \mathit{dispatch}_{t,g} \cdot \mathrm{cost}_{g} + \min \mathit{total\_cost} ``` #### Subject to @@ -224,16 +236,16 @@ print(ms.to_markdown(spec, legend=False)) #### Definitions - **`injection`** + **`generation`** ```math - \mathit{injection}_{t,b} = \mathit{generation}_{t,b} + \mathrm{consumption}_{t,b} \qquad \forall\, t \in \mathcal{T},\ b \in \mathcal{B} + \mathit{generation}_{t,b} = \sum_{g \in \mathcal{G} \,:\, \mathrm{gen\_bus}(g) = b} \mathit{dispatch}_{t,g} \qquad \forall\, t \in \mathcal{T},\ b \in \mathcal{B} ``` - **`generation`** + **`generation_cost`** ```math - \mathit{generation}_{t,b} = \sum_{g \in \mathcal{G} \,:\, \mathrm{gen\_bus}(g) = b} \mathit{dispatch}_{t,g} \qquad \forall\, t \in \mathcal{T},\ b \in \mathcal{B} + \mathit{generation}^{\mathrm{cost}} = \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} \mathit{dispatch}_{t,g} \cdot \mathrm{cost}_{g} ``` **`consumption`** @@ -242,6 +254,18 @@ print(ms.to_markdown(spec, legend=False)) \mathrm{consumption}_{t,b} = -\mathrm{demand}_{t,b} \qquad \forall\, t \in \mathcal{T},\ b \in \mathcal{B} ``` + **`injection`** + + ```math + \mathit{injection}_{t,b} = \mathit{generation}_{t,b} + \mathrm{consumption}_{t,b} \qquad \forall\, t \in \mathcal{T},\ b \in \mathcal{B} + ``` + + **`total_cost`** + + ```math + \mathit{total\_cost} = \mathit{generation}^{\mathrm{cost}} + ``` + #### Variable domains **`dispatch`** @@ -253,7 +277,7 @@ print(ms.to_markdown(spec, legend=False)) ## Add a component Make a file `imports.yaml`. It adds a term, `purchase`, to the injection and a -term to the objective: +term, `import_cost`, to the total cost: ```yaml title="imports.yaml" description: Power bought from outside the network, at a price. @@ -272,20 +296,20 @@ variables: dims: [snapshot, bus] bounds: { lower: 0, upper: import_limit } +given: + expressions: + injection: { dims: [snapshot, bus] } + total_cost: { dims: [] } + expressions: purchase: description: what the imports put into a bus expression: imported - -given: - expressions: - injection: - dims: [snapshot, bus] - term: purchase - -objective: - sense: minimize - expression: sum(imported * import_price) + adds_to: injection + import_cost: + description: what the imports cost + expression: sum(imported * import_price) + adds_to: total_cost ``` Merge the four files: @@ -293,17 +317,21 @@ Merge the four files: ```python spec = ms.merge(['network.yaml', 'generators.yaml', 'loads.yaml', 'imports.yaml']) print(spec.expressions['injection'].expression) -print(spec.objective.expression) +print(spec.expressions['total_cost'].expression) ``` -The injection has a third term at the end, and the objective sums the two -objectives. `network.yaml` did not change: +The injection has a third term at the end, and the total cost a second one. +`network.yaml` did not change: ```text generation + consumption + purchase -(sum(dispatch * cost)) + (sum(imported * import_price)) +generation_cost + import_cost ``` +Only `network.yaml` sets the objective. A second file that sets one is +refused: `merge` does not join two objectives, and a part of the cost is a +term of `total_cost`. + ## Read what another file declares Make a file `emissions.yaml`. It caps what the fleet emits, and it reads @@ -366,11 +394,12 @@ Merge the generators and the loads without the network: ms.merge(['generators.yaml', 'loads.yaml']) ``` -`merge` refuses it. A term adds to a name another file declares, and without -the network no file declares `injection`: +`merge` refuses it. A term adds to a sum that the rest of the spec reads, and +without the network no file reads `injection` other than the two files that +add to it: ```text -fragments 'generators.yaml' and 'loads.yaml' add a term to 'injection', which no fragment declares. A term adds to a name another file declares under 'expressions:': declare it there, as `empty: true` over a `dims:` where the files add every term, or fix the spelling. +fragments 'generators.yaml' and 'loads.yaml' add a term to 'injection', and no other fragment reads it: none reads it without adding to it, or uses it in its math. A term writes into a sum the rest of the spec reads: add the fragment that reads it, or fix the spelling under 'given:'. ``` ## Where to next diff --git a/examples/library/generator.yaml b/examples/library/generator.yaml index fd37bf26..12e7fe93 100644 --- a/examples/library/generator.yaml +++ b/examples/library/generator.yaml @@ -17,6 +17,8 @@ given: Port_p: dims: [snapshot, port] description: the surface introduces this flow, and this file pins it at its own ports + expressions: + total_cost: { dims: [] } parameters: Generator_p_nom: { dims: [generator], description: nominal power } Generator_marginal_cost: { dims: [generator], description: cost of one unit of output } @@ -32,6 +34,7 @@ constraints: 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) +expressions: + Generator_cost: + expression: sum(Generator_p * Generator_marginal_cost) + adds_to: total_cost diff --git a/examples/library/surface.yaml b/examples/library/surface.yaml index cc568f2c..91a1586e 100644 --- a/examples/library/surface.yaml +++ b/examples/library/surface.yaml @@ -4,9 +4,10 @@ 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. + flow per port, one balance per bus, and one cost to minimise. 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. A component that + costs something adds its cost to `total_cost`. dimensions: snapshot: { dtype: datetime, description: dispatch periods } bus: { dtype: str, description: network nodes } @@ -22,3 +23,9 @@ constraints: 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 +given: + expressions: + total_cost: { dims: [], description: what running the system costs } +objective: + sense: minimize + expression: total_cost diff --git a/examples/pypsa.yaml b/examples/pypsa.yaml index d5204598..d6e7712e 100644 --- a/examples/pypsa.yaml +++ b/examples/pypsa.yaml @@ -2201,6 +2201,37 @@ expressions: snapshot's hours and its period, before the scenario's own weight; a start and a stop cost what they cost, unweighted, as PyPSA adds them (`optimize.py:414-429`) + Generator_capex: + expression: sum(scenario_weight * Generator_p_nom_ext * Generator_capital_cost * Generator_capital_weight) + Link_capex: + expression: sum(scenario_weight * Link_p_nom_ext * Link_capital_cost * Link_capital_weight) + StorageUnit_capex: + expression: sum(scenario_weight * StorageUnit_p_nom_ext * StorageUnit_capital_cost * StorageUnit_capital_weight) + Store_capex: + expression: sum(scenario_weight * Store_e_nom_ext * Store_capital_cost * Store_capital_weight) + Line_capex: + expression: sum(scenario_weight * Line_s_nom_ext * Line_capital_cost * Line_capital_weight) + Process_capex: + expression: sum(scenario_weight * Process_p_nom_ext * Process_capital_cost * Process_capital_weight) + Transformer_capex: + expression: sum(scenario_weight * Transformer_s_nom_ext * Transformer_capital_cost * Transformer_capital_weight) + risk_weighted_opex: + expression: (1 - CVaR_omega) * sum(scenario_weight * scenario_opex, over=scenario) + CVaR_omega * CVaR + total_cost: + dims: [] + expression: >- + Generator_capex + + Link_capex + + StorageUnit_capex + + Store_capex + + Line_capex + + Process_capex + + Transformer_capex + + risk_weighted_opex + description: >- + what the system costs — capacity once per active period at its + expected cost over the scenarios, operation in expectation over the + scenarios, and a share of it at the tail Generator_additions: expression: >- sum(Generator_p_nom_ext * Generator_first_active, by=Generator_carrier, over=generator, into=carrier) @@ -4148,16 +4179,4 @@ assumptions: objective: sense: minimize - description: >- - capacity once per active period at its expected cost over the scenarios, operation in - expectation over the scenarios, and a share of it at the tail - expression: >- - sum(scenario_weight * Generator_p_nom_ext * Generator_capital_cost * Generator_capital_weight) - + sum(scenario_weight * Link_p_nom_ext * Link_capital_cost * Link_capital_weight) - + sum(scenario_weight * StorageUnit_p_nom_ext * StorageUnit_capital_cost * StorageUnit_capital_weight) - + sum(scenario_weight * Store_e_nom_ext * Store_capital_cost * Store_capital_weight) - + sum(scenario_weight * Line_s_nom_ext * Line_capital_cost * Line_capital_weight) - + sum(scenario_weight * Process_p_nom_ext * Process_capital_cost * Process_capital_weight) - + sum(scenario_weight * Transformer_s_nom_ext * Transformer_capital_cost * Transformer_capital_weight) - + (1 - CVaR_omega) * sum(scenario_weight * scenario_opex, over=scenario) - + CVaR_omega * CVaR + expression: total_cost diff --git a/examples/pypsa/cost.yaml b/examples/pypsa/cost.yaml index d574f9aa..63703cdd 100644 --- a/examples/pypsa/cost.yaml +++ b/examples/pypsa/cost.yaml @@ -30,6 +30,12 @@ given: CVaR_omega: { dims: [] } expressions: scenario_opex: { dims: [scenario] } + total_cost: { dims: [] } + +expressions: + risk_weighted_opex: + expression: (1 - CVaR_omega) * sum(scenario_weight * scenario_opex, over=scenario) + CVaR_omega * CVaR + adds_to: total_cost constraints: CVaR_excess: @@ -42,11 +48,3 @@ constraints: dims: [] where: CVaR_omega > 0 expression: CVaR_theta + 1 / (1 - CVaR_alpha) * sum(scenario_weight * CVaR_a, over=scenario) <= CVaR - -objective: - sense: minimize - description: >- - capacity once per active period at its expected cost over the scenarios, operation in expectation over the scenarios, and a share of it at the tail - expression: >- - (1 - CVaR_omega) * sum(scenario_weight * scenario_opex, over=scenario) - + CVaR_omega * CVaR diff --git a/examples/pypsa/generator.yaml b/examples/pypsa/generator.yaml index 3d6308e3..36379bcb 100644 --- a/examples/pypsa/generator.yaml +++ b/examples/pypsa/generator.yaml @@ -160,12 +160,13 @@ given: Generator_maintenance_capacity: { dims: [scenario, snapshot, generator] } expressions: GlobalConstraint_energy_weight: { dims: [scenario, global_constraint, snapshot] } - primary_energy: { dims: [scenario, global_constraint], term: Generator_primary_energy } - operational_limit: { dims: [scenario, global_constraint], term: Generator_operational_limit } - tech_capacity_expansion: { dims: [global_constraint], term: Generator_tech_capacity_expansion } - scenario_opex: { dims: [scenario], term: Generator_opex } - Carrier_additions: { dims: [period, carrier], term: Generator_additions } - Bus_injection: { dims: [scenario, snapshot, bus], term: Generator_injection } + primary_energy: { dims: [scenario, global_constraint] } + operational_limit: { dims: [scenario, global_constraint] } + tech_capacity_expansion: { dims: [global_constraint] } + scenario_opex: { dims: [scenario] } + total_cost: { dims: [] } + Carrier_additions: { dims: [period, carrier] } + Bus_injection: { dims: [scenario, snapshot, bus] } expressions: Generator_p_nom_effective: @@ -185,20 +186,29 @@ expressions: Generator_primary_energy: expression: >- sum(sum((Generator_p * GlobalConstraint_energy_weight) * Generator_primary_energy_weight, over=snapshot), over=generator) + adds_to: primary_energy Generator_operational_limit: expression: >- sum(sum((Generator_p * GlobalConstraint_energy_weight) * Generator_operational_limit_weight, over=snapshot), over=generator) + adds_to: operational_limit Generator_tech_capacity_expansion: expression: sum(Generator_p_nom_ext * Generator_tech_capacity_weight, over=generator) + adds_to: tech_capacity_expansion Generator_opex: expression: >- sum(sum(((Generator_p * Generator_marginal_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) + sum(sum((((Generator_p * Generator_p) * Generator_marginal_cost_quadratic) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) + adds_to: scenario_opex + Generator_capex: + expression: sum(scenario_weight * Generator_p_nom_ext * Generator_capital_cost * Generator_capital_weight) + adds_to: total_cost Generator_additions: expression: >- sum(Generator_p_nom_ext * Generator_first_active, by=Generator_carrier, over=generator, into=carrier) + adds_to: Carrier_additions Generator_injection: expression: sum(Generator_sign * Generator_p, by=Generator_bus, over=generator, into=bus) + adds_to: Bus_injection constraints: Generator_fix_p_lower: @@ -266,8 +276,3 @@ assumptions: refuses quadratic costs under any risk preference (`optimize.py:467-474`). The spec cannot tell no risk preference from one with `omega = 0`, so it refuses only where `omega` is positive - -objective: - sense: minimize - expression: >- - sum(((scenario_weight * Generator_p_nom_ext) * Generator_capital_cost) * Generator_capital_weight) diff --git a/examples/pypsa/generator_commitment.yaml b/examples/pypsa/generator_commitment.yaml index cbd3c628..8551031e 100644 --- a/examples/pypsa/generator_commitment.yaml +++ b/examples/pypsa/generator_commitment.yaml @@ -110,7 +110,7 @@ given: Generator_maintenance_status: { dims: [scenario, snapshot, generator] } Generator_p_nom_ext: { dims: [generator] } expressions: - scenario_opex: { dims: [scenario], term: Generator_commitment_opex } + scenario_opex: { dims: [scenario] } expressions: Generator_previous_status: @@ -126,6 +126,7 @@ expressions: sum(sum(((Generator_status * Generator_stand_by_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=generator), over=snapshot) + sum(sum(Generator_start_up * Generator_start_up_cost, over=generator), over=snapshot) + sum(sum(Generator_shut_down * Generator_shut_down_cost, over=generator), over=snapshot) + adds_to: scenario_opex constraints: Generator_com_p_lower: diff --git a/examples/pypsa/line.yaml b/examples/pypsa/line.yaml index ce136665..dd325ecd 100644 --- a/examples/pypsa/line.yaml +++ b/examples/pypsa/line.yaml @@ -154,23 +154,31 @@ given: scenario_weight: { dims: [scenario] } transmission_losses: { dims: [], dtype: bool } expressions: - transmission_volume_expansion: { dims: [scenario, global_constraint], term: Line_transmission_volume_expansion } - transmission_expansion_cost: { dims: [scenario, global_constraint], term: Line_transmission_expansion_cost } - tech_capacity_expansion: { dims: [global_constraint], term: Line_tech_capacity_expansion } - Carrier_additions: { dims: [period, carrier], term: Line_additions } - Bus_injection: { dims: [scenario, snapshot, bus], term: Line_injection } - Cycle_angle_sum: { dims: [scenario, snapshot, cycle], term: Line_angle_sum } + transmission_volume_expansion: { dims: [scenario, global_constraint] } + transmission_expansion_cost: { dims: [scenario, global_constraint] } + tech_capacity_expansion: { dims: [global_constraint] } + total_cost: { dims: [] } + Carrier_additions: { dims: [period, carrier] } + Bus_injection: { dims: [scenario, snapshot, bus] } + Cycle_angle_sum: { dims: [scenario, snapshot, cycle] } expressions: Line_transmission_volume_expansion: expression: sum(Line_s_nom_ext * Line_volume_weight, over=line) + adds_to: transmission_volume_expansion Line_transmission_expansion_cost: expression: sum(Line_s_nom_ext * Line_expansion_cost_weight, over=line) + adds_to: transmission_expansion_cost Line_tech_capacity_expansion: expression: sum(Line_s_nom_ext * Line_tech_capacity_weight, over=line) + adds_to: tech_capacity_expansion + Line_capex: + expression: sum(scenario_weight * Line_s_nom_ext * Line_capital_cost * Line_capital_weight) + adds_to: total_cost Line_additions: expression: >- sum(Line_s_nom_ext * Line_first_active, by=Line_carrier, over=line, into=carrier) + adds_to: Carrier_additions Line_s_monitored: description: >- the flow a line's post-contingency rows read — its flow where it stands, @@ -186,7 +194,10 @@ expressions: + sum(Line_s, by=Line_bus1, over=line, into=bus) - (0.5 * sum(Line_loss, by=Line_bus0, over=line, into=bus)) - (0.5 * sum(Line_loss, by=Line_bus1, over=line, into=bus)) - Line_angle_sum: sum(Line_s * Line_cycle_weight, over=line) + adds_to: Bus_injection + Line_angle_sum: + expression: sum(Line_s * Line_cycle_weight, over=line) + adds_to: Cycle_angle_sum constraints: Line_fix_s_lower: @@ -250,8 +261,3 @@ constraints: dims: [scenario, snapshot, line, segment] where: transmission_losses AND Line_active expression: Line_loss - Line_loss_slope * Line_s >= Line_loss_offset - -objective: - sense: minimize - expression: >- - sum(((scenario_weight * Line_s_nom_ext) * Line_capital_cost) * Line_capital_weight) diff --git a/examples/pypsa/link.yaml b/examples/pypsa/link.yaml index 0116a0b6..cc46eafe 100644 --- a/examples/pypsa/link.yaml +++ b/examples/pypsa/link.yaml @@ -198,12 +198,13 @@ given: Link_maintenance: { dims: [scenario, snapshot, link] } Link_maintenance_capacity: { dims: [scenario, snapshot, link] } expressions: - transmission_volume_expansion: { dims: [scenario, global_constraint], term: Link_transmission_volume_expansion } - transmission_expansion_cost: { dims: [scenario, global_constraint], term: Link_transmission_expansion_cost } - tech_capacity_expansion: { dims: [global_constraint], term: Link_tech_capacity_expansion } - scenario_opex: { dims: [scenario], term: Link_opex } - Carrier_additions: { dims: [period, carrier], term: Link_additions } - Bus_injection: { dims: [scenario, snapshot, bus], term: Link_injection } + transmission_volume_expansion: { dims: [scenario, global_constraint] } + transmission_expansion_cost: { dims: [scenario, global_constraint] } + tech_capacity_expansion: { dims: [global_constraint] } + scenario_opex: { dims: [scenario] } + total_cost: { dims: [] } + Carrier_additions: { dims: [period, carrier] } + Bus_injection: { dims: [scenario, snapshot, bus] } expressions: Link_p_nom_effective: @@ -237,21 +238,30 @@ expressions: otherwise: shift(at(Link_p, by=Link_output_link, over=link, into=link_output), along=snapshot, offset=Link_output_delay, edge=0, by=snapshot_period, within=period) * Link_efficiency Link_transmission_volume_expansion: expression: sum(Link_p_nom_ext * Link_volume_weight, over=link) + adds_to: transmission_volume_expansion Link_transmission_expansion_cost: expression: sum(Link_p_nom_ext * Link_expansion_cost_weight, over=link) + adds_to: transmission_expansion_cost Link_tech_capacity_expansion: expression: sum(Link_p_nom_ext * Link_tech_capacity_weight, over=link) + adds_to: tech_capacity_expansion Link_opex: expression: >- sum(sum(((Link_p * Link_marginal_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=link), over=snapshot) + sum(sum((((Link_p * Link_p) * Link_marginal_cost_quadratic) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=link), over=snapshot) + adds_to: scenario_opex + Link_capex: + expression: sum(scenario_weight * Link_p_nom_ext * Link_capital_cost * Link_capital_weight) + adds_to: total_cost Link_additions: expression: >- sum(Link_p_nom_ext * Link_first_active, by=Link_carrier, over=link, into=carrier) + adds_to: Carrier_additions Link_injection: expression: >- -sum(Link_p, by=Link_bus0, over=link, into=bus) + sum(Link_output_arrival, by=Link_output_bus, over=link_output, into=bus) + adds_to: Bus_injection constraints: Link_fix_p_lower: @@ -309,8 +319,3 @@ assumptions: refuses quadratic costs under any risk preference (`optimize.py:467-474`). The spec cannot tell no risk preference from one with `omega = 0`, so it refuses only where `omega` is positive - -objective: - sense: minimize - expression: >- - sum(((scenario_weight * Link_p_nom_ext) * Link_capital_cost) * Link_capital_weight) diff --git a/examples/pypsa/link_commitment.yaml b/examples/pypsa/link_commitment.yaml index c90e0ec8..9c5cea68 100644 --- a/examples/pypsa/link_commitment.yaml +++ b/examples/pypsa/link_commitment.yaml @@ -110,7 +110,7 @@ given: Link_maintenance_status: { dims: [scenario, snapshot, link] } Link_p_nom_ext: { dims: [link] } expressions: - scenario_opex: { dims: [scenario], term: Link_commitment_opex } + scenario_opex: { dims: [scenario] } expressions: Link_previous_status: @@ -126,6 +126,7 @@ expressions: sum(sum(((Link_status * Link_stand_by_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=link), over=snapshot) + sum(sum(Link_start_up * Link_start_up_cost, over=link), over=snapshot) + sum(sum(Link_shut_down * Link_shut_down_cost, over=link), over=snapshot) + adds_to: scenario_opex constraints: Link_com_p_lower: diff --git a/examples/pypsa/load.yaml b/examples/pypsa/load.yaml index e04ef490..b84a0740 100644 --- a/examples/pypsa/load.yaml +++ b/examples/pypsa/load.yaml @@ -39,7 +39,7 @@ parameters: given: expressions: - Bus_injection: { dims: [scenario, snapshot, bus], term: Load_injection } + Bus_injection: { dims: [scenario, snapshot, bus] } expressions: Load_demand: @@ -51,4 +51,6 @@ expressions: cases: active: { when: Load_active, expression: Load_sign * Load_p_set } otherwise: 0 - Load_injection: sum(Load_demand, by=Load_bus, over=load, into=bus) + Load_injection: + expression: sum(Load_demand, by=Load_bus, over=load, into=bus) + adds_to: Bus_injection diff --git a/examples/pypsa/network.yaml b/examples/pypsa/network.yaml index f6b0de7f..87ea1317 100644 --- a/examples/pypsa/network.yaml +++ b/examples/pypsa/network.yaml @@ -11,14 +11,14 @@ dimensions: bus: description: network nodes -expressions: - Bus_injection: - dims: [scenario, snapshot, bus] - empty: true - description: >- - what every component puts into a bus, less what it takes out of it; - PyPSA writes each term into the balance, and a load on its right-hand - side +given: + expressions: + Bus_injection: + dims: [scenario, snapshot, bus] + description: >- + what every component puts into a bus, less what it takes out of it; + PyPSA writes each term into the balance, and a load on its right-hand + side constraints: Bus_nodal_balance: diff --git a/examples/pypsa/power_flow.yaml b/examples/pypsa/power_flow.yaml index 1ff17fd9..b7edbe54 100644 --- a/examples/pypsa/power_flow.yaml +++ b/examples/pypsa/power_flow.yaml @@ -11,13 +11,13 @@ dimensions: cycle: description: independent cycles of the passive network graph — the cycle basis, data prep -expressions: - Cycle_angle_sum: - dims: [scenario, snapshot, cycle] - empty: true - description: >- - the voltage angle differences around a cycle: every branch flow times - its cycle weight, and every transformer phase shift +given: + expressions: + Cycle_angle_sum: + dims: [scenario, snapshot, cycle] + description: >- + the voltage angle differences around a cycle: every branch flow times + its cycle weight, and every transformer phase shift constraints: Kirchhoff_Voltage_Law: diff --git a/examples/pypsa/process.yaml b/examples/pypsa/process.yaml index 48a4aa26..8c16c8a9 100644 --- a/examples/pypsa/process.yaml +++ b/examples/pypsa/process.yaml @@ -177,10 +177,11 @@ given: Process_maintenance: { dims: [scenario, snapshot, process] } Process_maintenance_capacity: { dims: [scenario, snapshot, process] } expressions: - tech_capacity_expansion: { dims: [global_constraint], term: Process_tech_capacity_expansion } - scenario_opex: { dims: [scenario], term: Process_opex } - Carrier_additions: { dims: [period, carrier], term: Process_additions } - Bus_injection: { dims: [scenario, snapshot, bus], term: Process_injection } + tech_capacity_expansion: { dims: [global_constraint] } + scenario_opex: { dims: [scenario] } + total_cost: { dims: [] } + Carrier_additions: { dims: [period, carrier] } + Bus_injection: { dims: [scenario, snapshot, bus] } expressions: Process_p_nom_effective: @@ -214,16 +215,23 @@ expressions: otherwise: shift(at(Process_p, by=Process_output_process, over=process, into=process_output), along=snapshot, offset=Process_output_delay, edge=0, by=snapshot_period, within=period) * Process_rate Process_tech_capacity_expansion: expression: sum(Process_p_nom_ext * Process_tech_capacity_weight, over=process) + adds_to: tech_capacity_expansion Process_opex: expression: >- sum(sum(((Process_p * Process_marginal_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=process), over=snapshot) + sum(sum((((Process_p * Process_p) * Process_marginal_cost_quadratic) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=process), over=snapshot) + adds_to: scenario_opex + Process_capex: + expression: sum(scenario_weight * Process_p_nom_ext * Process_capital_cost * Process_capital_weight) + adds_to: total_cost Process_additions: expression: >- sum(Process_p_nom_ext * Process_first_active, by=Process_carrier, over=process, into=carrier) + adds_to: Carrier_additions Process_injection: expression: >- sum(Process_output_arrival, by=Process_output_bus, over=process_output, into=bus) + adds_to: Bus_injection constraints: Process_fix_p_lower: @@ -281,8 +289,3 @@ assumptions: refuses quadratic costs under any risk preference (`optimize.py:467-474`). The spec cannot tell no risk preference from one with `omega = 0`, so it refuses only where `omega` is positive - -objective: - sense: minimize - expression: >- - sum(((scenario_weight * Process_p_nom_ext) * Process_capital_cost) * Process_capital_weight) diff --git a/examples/pypsa/process_commitment.yaml b/examples/pypsa/process_commitment.yaml index 35f34e50..79c1a12d 100644 --- a/examples/pypsa/process_commitment.yaml +++ b/examples/pypsa/process_commitment.yaml @@ -110,7 +110,7 @@ given: Process_maintenance_status: { dims: [scenario, snapshot, process] } Process_p_nom_ext: { dims: [process] } expressions: - scenario_opex: { dims: [scenario], term: Process_commitment_opex } + scenario_opex: { dims: [scenario] } expressions: Process_previous_status: @@ -126,6 +126,7 @@ expressions: sum(sum(((Process_status * Process_stand_by_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=process), over=snapshot) + sum(sum(Process_start_up * Process_start_up_cost, over=process), over=snapshot) + sum(sum(Process_shut_down * Process_shut_down_cost, over=process), over=snapshot) + adds_to: scenario_opex constraints: Process_com_p_lower: diff --git a/examples/pypsa/settings.yaml b/examples/pypsa/settings.yaml index 39d853da..5e62069a 100644 --- a/examples/pypsa/settings.yaml +++ b/examples/pypsa/settings.yaml @@ -69,6 +69,58 @@ parameters: dims: [scenario, global_constraint, snapshot] dtype: bool +given: + expressions: + primary_energy: + dims: [scenario, global_constraint] + description: >- + what a `primary_energy` row totals — weighted generator energy over the + snapshots it counts, less the charge left in weighted storage at the + close; the initial charge it is compared against is folded into the + row's constant + operational_limit: + dims: [scenario, global_constraint] + description: >- + what an `operational_limit` row totals — the weighted energy its + generators deliver over the snapshots it counts, plus what its + non-cyclic storage draws down; the initial charge it draws from is + folded into the row's constant + transmission_volume_expansion: + dims: [scenario, global_constraint] + description: >- + what a `transmission_volume_expansion_limit` row totals — length times + the chosen build of the row's branches + transmission_expansion_cost: + dims: [scenario, global_constraint] + description: >- + what a `transmission_expansion_cost_limit` row totals — capital cost + times the chosen build of the row's branches + tech_capacity_expansion: + dims: [global_constraint] + description: >- + what a `tech_capacity_expansion_limit` row totals — the chosen build of + the row's carrier-and-bus set + scenario_opex: + dims: [scenario] + description: >- + what a future costs to run — every operating term, weighted by the + snapshot's hours and its period, before the scenario's own weight; a + start and a stop cost what they cost, unweighted, as PyPSA adds them + (`optimize.py:414-429`) + total_cost: + dims: [] + description: >- + what the system costs — capacity once per active period at its expected + cost over the scenarios, operation in expectation over the scenarios, + and a share of it at the tail + Carrier_additions: + dims: [period, carrier] + description: >- + what a carrier adds in a period — every extendable component of that + carrier, counting each build in the first period it stands in. Like + PyPSA, it sums only the components that carry a carrier attribute, so a + transformer, which has none, counts in no carrier + expressions: GlobalConstraint_energy_weight: description: >- @@ -89,53 +141,7 @@ expressions: when: GlobalConstraint_counts_snapshot AND NOT shift(GlobalConstraint_counts_snapshot, along=snapshot, offset=-1) expression: 1 otherwise: 0 - scenario_opex: - dims: [scenario] - empty: true - description: >- - what a future costs to run — every operating term, weighted by the - snapshot's hours and its period, before the scenario's own weight; a - start and a stop cost what they cost, unweighted, as PyPSA adds them - (`optimize.py:414-429`) - Carrier_additions: - dims: [period, carrier] - empty: true - description: >- - what a carrier adds in a period — every extendable component of that - carrier, counting each build in the first period it stands in. Like - PyPSA, it sums only the components that carry a carrier attribute, so a - transformer, which has none, counts in no carrier - primary_energy: - dims: [scenario, global_constraint] - empty: true - description: >- - what a `primary_energy` row totals — weighted generator energy over the - snapshots it counts, less the charge left in weighted storage at the - close; the initial charge it is compared against is folded into the - row's constant - operational_limit: - dims: [scenario, global_constraint] - empty: true - description: >- - what an `operational_limit` row totals — the weighted energy its - generators deliver over the snapshots it counts, plus what its - non-cyclic storage draws down; the initial charge it draws from is - folded into the row's constant - tech_capacity_expansion: - dims: [global_constraint] - empty: true - description: >- - what a `tech_capacity_expansion_limit` row totals — the chosen build of - the row's carrier-and-bus set - transmission_volume_expansion: - dims: [scenario, global_constraint] - empty: true - description: >- - what a `transmission_volume_expansion_limit` row totals — length times - the chosen build of the row's branches - transmission_expansion_cost: - dims: [scenario, global_constraint] - empty: true - description: >- - what a `transmission_expansion_cost_limit` row totals — capital cost - times the chosen build of the row's branches + +objective: + sense: minimize + expression: total_cost diff --git a/examples/pypsa/storage_unit.yaml b/examples/pypsa/storage_unit.yaml index 9bb49146..0bcc83d9 100644 --- a/examples/pypsa/storage_unit.yaml +++ b/examples/pypsa/storage_unit.yaml @@ -211,12 +211,13 @@ given: GlobalConstraint_counts_snapshot: { dims: [scenario, global_constraint, snapshot], dtype: bool } expressions: GlobalConstraint_snapshot_closes: { dims: [scenario, global_constraint, snapshot] } - primary_energy: { dims: [scenario, global_constraint], term: StorageUnit_primary_energy } - operational_limit: { dims: [scenario, global_constraint], term: StorageUnit_operational_limit } - tech_capacity_expansion: { dims: [global_constraint], term: StorageUnit_tech_capacity_expansion } - scenario_opex: { dims: [scenario], term: StorageUnit_opex } - Carrier_additions: { dims: [period, carrier], term: StorageUnit_additions } - Bus_injection: { dims: [scenario, snapshot, bus], term: StorageUnit_injection } + primary_energy: { dims: [scenario, global_constraint] } + operational_limit: { dims: [scenario, global_constraint] } + tech_capacity_expansion: { dims: [global_constraint] } + scenario_opex: { dims: [scenario] } + total_cost: { dims: [] } + Carrier_additions: { dims: [period, carrier] } + Bus_injection: { dims: [scenario, snapshot, bus] } expressions: StorageUnit_charge_carried_in: @@ -273,23 +274,32 @@ expressions: StorageUnit_primary_energy: expression: >- -sum(sum((StorageUnit_state_of_charge * StorageUnit_closing_weight) * StorageUnit_primary_energy_weight, over=snapshot), over=storage_unit) + adds_to: primary_energy StorageUnit_operational_limit: expression: >- -sum(sum((StorageUnit_state_of_charge * StorageUnit_closing_weight) * StorageUnit_operational_limit_weight, over=snapshot), over=storage_unit) + adds_to: operational_limit StorageUnit_tech_capacity_expansion: expression: sum(StorageUnit_p_nom_ext * StorageUnit_tech_capacity_weight, over=storage_unit) + adds_to: tech_capacity_expansion StorageUnit_opex: expression: >- sum(sum(((StorageUnit_p_dispatch * StorageUnit_marginal_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) + sum(sum((((StorageUnit_p_dispatch * StorageUnit_p_dispatch) * StorageUnit_marginal_cost_quadratic) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) + sum(sum(((StorageUnit_state_of_charge * StorageUnit_marginal_cost_storage) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) + sum(sum(((StorageUnit_spill * StorageUnit_spill_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=storage_unit), over=snapshot) + adds_to: scenario_opex + StorageUnit_capex: + expression: sum(scenario_weight * StorageUnit_p_nom_ext * StorageUnit_capital_cost * StorageUnit_capital_weight) + adds_to: total_cost StorageUnit_additions: expression: >- sum(StorageUnit_p_nom_ext * StorageUnit_first_active, by=StorageUnit_carrier, over=storage_unit, into=carrier) + adds_to: Carrier_additions StorageUnit_injection: expression: >- sum(StorageUnit_sign * (StorageUnit_p_dispatch - StorageUnit_p_store), by=StorageUnit_bus, over=storage_unit, into=bus) + adds_to: Bus_injection constraints: StorageUnit_fix_p_dispatch_lower: @@ -455,8 +465,3 @@ assumptions: refuses quadratic costs under any risk preference (`optimize.py:467-474`). The spec cannot tell no risk preference from one with `omega = 0`, so it refuses only where `omega` is positive - -objective: - sense: minimize - expression: >- - sum(((scenario_weight * StorageUnit_p_nom_ext) * StorageUnit_capital_cost) * StorageUnit_capital_weight) diff --git a/examples/pypsa/store.yaml b/examples/pypsa/store.yaml index 9d0da30a..d10d8e2b 100644 --- a/examples/pypsa/store.yaml +++ b/examples/pypsa/store.yaml @@ -175,12 +175,13 @@ given: GlobalConstraint_counts_snapshot: { dims: [scenario, global_constraint, snapshot], dtype: bool } expressions: GlobalConstraint_snapshot_closes: { dims: [scenario, global_constraint, snapshot] } - primary_energy: { dims: [scenario, global_constraint], term: Store_primary_energy } - operational_limit: { dims: [scenario, global_constraint], term: Store_operational_limit } - tech_capacity_expansion: { dims: [global_constraint], term: Store_tech_capacity_expansion } - scenario_opex: { dims: [scenario], term: Store_opex } - Carrier_additions: { dims: [period, carrier], term: Store_additions } - Bus_injection: { dims: [scenario, snapshot, bus], term: Store_injection } + primary_energy: { dims: [scenario, global_constraint] } + operational_limit: { dims: [scenario, global_constraint] } + tech_capacity_expansion: { dims: [global_constraint] } + scenario_opex: { dims: [scenario] } + total_cost: { dims: [] } + Carrier_additions: { dims: [period, carrier] } + Bus_injection: { dims: [scenario, snapshot, bus] } expressions: Store_energy_carried_in: @@ -231,20 +232,30 @@ expressions: Store_primary_energy: expression: >- -sum(sum((Store_e * Store_closing_weight) * Store_primary_energy_weight, over=snapshot), over=store) + adds_to: primary_energy Store_operational_limit: expression: >- -sum(sum((Store_e * Store_closing_weight) * Store_operational_limit_weight, over=snapshot), over=store) + adds_to: operational_limit Store_tech_capacity_expansion: expression: sum(Store_e_nom_ext * Store_tech_capacity_weight, over=store) + adds_to: tech_capacity_expansion Store_opex: expression: >- sum(sum(((Store_p * Store_marginal_cost) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=store), over=snapshot) + sum(sum((((Store_p * Store_p) * Store_marginal_cost_quadratic) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=store), over=snapshot) + sum(sum(((Store_e * Store_marginal_cost_storage) * snapshot_weightings_objective) * at(period_weight_objective, by=snapshot_period, over=period, into=snapshot), over=store), over=snapshot) + adds_to: scenario_opex + Store_capex: + expression: sum(scenario_weight * Store_e_nom_ext * Store_capital_cost * Store_capital_weight) + adds_to: total_cost Store_additions: expression: >- sum(Store_e_nom_ext * Store_first_active, by=Store_carrier, over=store, into=carrier) - Store_injection: sum(Store_sign * Store_p, by=Store_bus, over=store, into=bus) + adds_to: Carrier_additions + Store_injection: + expression: sum(Store_sign * Store_p, by=Store_bus, over=store, into=bus) + adds_to: Bus_injection constraints: Store_fix_e_lower: @@ -351,8 +362,3 @@ assumptions: refuses quadratic costs under any risk preference (`optimize.py:467-474`). The spec cannot tell no risk preference from one with `omega = 0`, so it refuses only where `omega` is positive - -objective: - sense: minimize - expression: >- - sum(((scenario_weight * Store_e_nom_ext) * Store_capital_cost) * Store_capital_weight) diff --git a/examples/pypsa/transformer.yaml b/examples/pypsa/transformer.yaml index d04a2d1f..6f2f3061 100644 --- a/examples/pypsa/transformer.yaml +++ b/examples/pypsa/transformer.yaml @@ -165,10 +165,14 @@ given: scenario_weight: { dims: [scenario] } transmission_losses: { dims: [], dtype: bool } expressions: - Bus_injection: { dims: [scenario, snapshot, bus], term: Transformer_injection } - Cycle_angle_sum: { dims: [scenario, snapshot, cycle], term: Transformer_angle_sum } + total_cost: { dims: [] } + Bus_injection: { dims: [scenario, snapshot, bus] } + Cycle_angle_sum: { dims: [scenario, snapshot, cycle] } expressions: + Transformer_capex: + expression: sum(scenario_weight * Transformer_s_nom_ext * Transformer_capital_cost * Transformer_capital_weight) + adds_to: total_cost Transformer_s_monitored: description: the flow a transformer's post-contingency rows read, as a line's dims: [scenario, snapshot, transformer] @@ -181,11 +185,13 @@ expressions: + sum(Transformer_s, by=Transformer_bus1, over=transformer, into=bus) - (0.5 * sum(Transformer_loss, by=Transformer_bus0, over=transformer, into=bus)) - (0.5 * sum(Transformer_loss, by=Transformer_bus1, over=transformer, into=bus)) + adds_to: Bus_injection Transformer_angle_sum: expression: >- sum(Transformer_s * Transformer_cycle_weight, over=transformer) + sum(Transformer_phase_shift_weight, over=transformer) + sum(Transformer_phase_shift * Transformer_phase_shift_cycle_weight, over=transformer) + adds_to: Cycle_angle_sum constraints: Transformer_fix_s_lower: @@ -248,8 +254,3 @@ constraints: dims: [scenario, snapshot, transformer, segment] where: transmission_losses AND Transformer_active expression: Transformer_loss - Transformer_loss_slope * Transformer_s >= Transformer_loss_offset - -objective: - sense: minimize - expression: >- - sum(((scenario_weight * Transformer_s_nom_ext) * Transformer_capital_cost) * Transformer_capital_weight) diff --git a/schema/mathspec.schema.json b/schema/mathspec.schema.json index 720bbbad..317fc8ff 100644 --- a/schema/mathspec.schema.json +++ b/schema/mathspec.schema.json @@ -166,8 +166,20 @@ "anyOf": [ { "additionalProperties": false, - "description": "A named quantity: one arithmetic expression, referenced by the math or read back after a solve.\n\nWritten in YAML as a bare string, or as a mapping once it carries a\n``description:`` \u2014 and serialised back to whichever form it was written in,\nso a round trip through [`Spec.to_yaml`][] reproduces the file::\n\n expressions:\n total_generation: sum(p, over=generator)\n emissions:\n expression: sum(p * rate, over=generator)\n description: CO2 released, the quantity the cap bounds\n\n``dims:`` declares the frame the quantity is read over. A plain entry may\nleave it out, and its body then decides the frame; a body that carries a\ndimension the frame does not name is refused, and one that carries fewer\nis constant along the rest. A quantity whose value varies by region is\nwritten as ``cases:`` over a declared ``dims:``, with an ``otherwise:``\nfor the rest \u2014 see the language reference.\n\n``empty: true`` over a declared ``dims:``, with no body, is an **empty\nsum**: a quantity this file declares and other files add terms to,\nthrough [`merge`][mathspec.composition.merge]. Alone, the file reads it as\na column over the frame, as it reads a given expression.", + "description": "A named quantity: one arithmetic expression, referenced by the math or read back after a solve.\n\nWritten in YAML as a bare string, or as a mapping once it carries a\n``description:`` \u2014 and serialised back to whichever form it was written in,\nso a round trip through [`Spec.to_yaml`][] reproduces the file::\n\n expressions:\n total_generation: sum(p, over=generator)\n emissions:\n expression: sum(p * rate, over=generator)\n description: CO2 released, the quantity the cap bounds\n\n``dims:`` declares the frame the quantity is read over. A plain entry may\nleave it out, and its body then decides the frame; a body that carries a\ndimension the frame does not name is refused, and one that carries fewer\nis constant along the rest. A quantity whose value varies by region is\nwritten as ``cases:`` over a declared ``dims:``, with an ``otherwise:``\nfor the rest \u2014 see the language reference.\n\n``adds_to:`` adds this entry as a term to the sum it names, a\n``given: expressions:`` entry of this file: a write, where the given entry\nis the read.\n[`merge`][mathspec.composition.merge] adds every term to that name by\nname, after the body a file defines where one does. The term is read over at most the\nframe the given entry states, and does not read the name it adds to.", "properties": { + "adds_to": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Adds To" + }, "cases": { "additionalProperties": { "$ref": "#/$defs/ExpressionCase" @@ -204,11 +216,6 @@ "default": null, "title": "Dims" }, - "empty": { - "default": false, - "title": "Empty", - "type": "boolean" - }, "expression": { "anyOf": [ { @@ -347,7 +354,7 @@ }, "GivenExpressionBlock": { "additionalProperties": false, - "description": "A named expression this file reads and another file defines.\n\nThe frame is all this file states. This file reads the name as a quantity\nover that frame, affine in the columns, as it reads a given variable: the\nbody is the definer's, and the composed spec holds the body to the rules\nof every place this file reads it.\n\n``term:`` names the expression this file adds to the name, one this file\ndeclares under ``expressions:`` and reads over at most the frame.\n[`merge`][mathspec.composition.merge] adds it by name to the definition\nanother file writes and to the terms other files add, and keeps it as a\nnamed expression. The file itself reads the name as the whole sum, alone\nand composed.", + "description": "A named expression this file reads and another file defines.\n\nThe frame is all this file states. This file reads the name as a quantity\nover that frame, affine in the columns, as it reads a given variable: the\nbody is the definer's, and the composed spec holds the body to the rules\nof every place this file reads it.\n\nA named expression of this file may add to the name with ``adds_to:``.\n[`merge`][mathspec.composition.merge] then writes every term the files\nadd after the body a file defines, or as the whole body where no file\ndefines it. The file itself reads the name as the whole sum, alone and\ncomposed.", "properties": { "description": { "anyOf": [ @@ -367,18 +374,6 @@ }, "title": "Dims", "type": "array" - }, - "term": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Term" } }, "required": [ diff --git a/src/mathspec/advising.py b/src/mathspec/advising.py index 7ad9fef7..91def459 100644 --- a/src/mathspec/advising.py +++ b/src/mathspec/advising.py @@ -56,6 +56,7 @@ def _given(program: Program) -> list[Advice]: rather than by a host, so its note says that instead. """ given = program.given + written = {entry.adds_to for entry in program.expressions.values()} return [ *(Advice('given', name, _given_note('parameter', name)) for name in given.parameters), *(Advice('given', name, _given_note('variable', name)) for name in given.variables), @@ -63,13 +64,9 @@ def _given(program: Program) -> list[Advice]: Advice( 'given', name, - _sum_note(name) - if block.empty - else _term_note(name) - if block.term is not None - else _given_note('expression', name), + _term_note(name) if name in written else _given_note('expression', name), ) - for name, block in given.expressions.items() + for name in given.expressions ), *(Advice('given', name, _given_note('row family', name)) for name in given.constraints), ] @@ -80,22 +77,14 @@ def _given_note(kind: str, name: str) -> str: f"{kind} '{name}' is read here and declared elsewhere: the model this one is layered onto " f'provides it. A consumer checks that it does, on the same frame, and refuses the program where ' f'it does not. A fragment is composed instead: merge() folds this declaration into the one a ' - f'sibling introduces.' - ) - - -def _sum_note(name: str) -> str: - return ( - f"expression '{name}' is a sum this file declares and other files add terms to: merge() writes its " - f'body from their terms. Until then, the program reads it and does not build it.' + f'sibling introduces{", or writes it from the terms siblings add" if kind == "expression" else ""}.' ) def _term_note(name: str) -> str: return ( - f"expression '{name}' is read here and declared elsewhere, and this file adds a term to it: merge() " - f'sums the term with what the other files declare under the name. Until then, the program reads it ' - f'and does not build it.' + f"expression '{name}' is read here, and this file adds a term to it: merge() sums the term with what " + f'the other files write under the name. Until then, the program reads it and does not build it.' ) diff --git a/src/mathspec/boundedness.py b/src/mathspec/boundedness.py index 7ccf601e..320353b3 100644 --- a/src/mathspec/boundedness.py +++ b/src/mathspec/boundedness.py @@ -59,9 +59,9 @@ def unbounded_notes(program: Program) -> list[Advice]: Returns: One note per variable that is unbounded on the side its objective term - improves toward and named by no constraint. A column the program reads - and does not declare, a given one or an empty sum, has its bounds - where it is built, so it gets none. + improves toward and named by no constraint. A given column, which the + program reads and does not declare, has its bounds where it is built, + so it gets none. """ if program.objective is None: return [] diff --git a/src/mathspec/composition.py b/src/mathspec/composition.py index 26c341ca..b6f027c1 100644 --- a/src/mathspec/composition.py +++ b/src/mathspec/composition.py @@ -17,9 +17,9 @@ such as ``'#2'``. [`merge`][] writes nothing a fragment did not write, except a ``+``. It -joins two fragments' text in two places, the objective and a named expression -that terms add to; every other block is copied as written, refused where two -fragments own it, or held identical where it is a dimension or a relation. +joins fragments' text in one place, a named expression that terms add to; +every other block is copied as written, refused where two fragments own it, +or held identical where it is a dimension or a relation. What that means for each section: * **A dimension or a relation every fragment may declare**, and the ones that @@ -27,18 +27,22 @@ descriptions of one dimension agree, and the first one given 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 order the - fragments are given in, and the senses have to agree. -* **A term is added to the expression it names.** A ``given: expressions:`` - entry with a ``term:`` names the expression its fragment adds to the name. - The name is an ``expressions:`` block of one other fragment: a sum written - ``empty: true``, or a definition. The composed spec writes its body as - that body, if it has one, plus every term by its name, in the order the - fragments are given in, and keeps each term as the named expression its - fragment declares. A definition written as ``cases:`` is refused, since it is summed - as written. A later merge adds to the composed body the same way. A term - that names no ``expressions:`` block of any fragment is refused: merge - fills or extends what a file declared, and never invents a name. +* **One fragment sets the objective.** A second one is refused, both named. + Where several files contribute to it, the objective reads a sum, and each + file adds its part to that sum with ``adds_to:``. +* **Terms add to a named expression.** ``adds_to:`` adds a named + expression as a term to the sum it names, a ``given: expressions:`` entry + of its own fragment. The term keeps its name in the composed spec. Where a fragment + defines that name with one ``expression:``, the composed body is that + body followed by every term, in the order the fragments are given in, + so a composed spec takes more terms in a later merge. A definition + written as ``cases:`` takes no term. Where no fragment defines the + name, the composed spec defines it as its terms, over the frame the + readers state in one order. Some fragment then has to read the name for + more than adding to it: read it without adding to it, or use it in its + math. A name only its terms read, or only one fragment reads, is what a + misspelt ``given:`` entry looks like, so it is refused. A term that + reads its own sum through another fragment is refused, both named. * **A given declaration is folded** into the declaration that introduces the name, once the reader is checked to say the same as the introducer or less. A given expression's body may carry no dimension its reader does not state, @@ -91,14 +95,16 @@ from pydantic import BaseModel, ValidationError from mathspec._yaml import read_spec -from mathspec.dimensions import dims_of from mathspec.errors import LanguageError, did_you_mean, schema_error +from mathspec.program import variables_of from mathspec.spec import GivenBlock, Spec from mathspec.validation import to_spec if TYPE_CHECKING: from collections.abc import Callable, Iterable + from mathspec.program import Program + SHARED_SECTIONS = ('dimensions', 'relations') OWNED_SECTIONS = tuple( @@ -129,8 +135,8 @@ def merge(fragments: Sequence[Source], description: str | None = None) -> Spec: Args: fragments: Each fragment as a YAML path, YAML text, a mapping, or a - loaded [`Spec`][mathspec.spec.Spec]. A sum and the objective write - their terms in the order of the list. + loaded [`Spec`][mathspec.spec.Spec]. A sum writes its terms in + the order of the list. description: What the composed spec is. A fragment's own ``description`` is about the fragment, and is not carried. @@ -143,9 +149,14 @@ def merge(fragments: Sequence[Source], description: str | None = None) -> Spec: 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, as another kind - of thing, or over fewer dimensions than its body carries; two fragments are - written against different language versions; their objectives run - opposite ways; or the composed spec does not load. + of thing, or over fewer dimensions than its body carries; a fragment + adds a term to a variable, a parameter, a constraint or a + definition written as ``cases:``; a term reads its own sum through + another fragment; a name no fragment defines is read by nothing + but the fragments that add a term to it, or by one fragment alone; + the readers of such a name write its dims in different orders; two + fragments are written against different language versions; two + fragments set the objective; or the composed spec does not load. FileNotFoundError: A ``str`` with no newline that names no file. TypeError: *fragments* is one path rather than a list. """ @@ -157,7 +168,7 @@ def merge(fragments: Sequence[Source], description: str | None = None) -> Spec: for section in SHARED_SECTIONS: if agreed := _agreed(read, section, _singular(section), 'give one of them a name of its own'): merged[section] = agreed - asked = {name: _readings({'given': spec.given.model_dump(exclude_unset=True)}) for name, spec in loaded.items()} + asked = {name: spec.given.model_dump(exclude_unset=True) for name, spec in loaded.items()} readings = { kind: _agreed(asked, kind, label, 'read it over one frame', claims=_reading_claims) for kind, label in GIVEN_KINDS.items() @@ -165,11 +176,12 @@ def merge(fragments: Sequence[Source], description: str | None = None) -> Spec: for section in OWNED_SECTIONS: if claimed := _claimed(read, section): merged[section] = claimed - if summed := _summed(read, loaded, _mapping(merged.get('expressions')), readings['expressions']): - merged['expressions'] = {**_mapping(merged.get('expressions')), **summed} + summed = _summed(loaded, merged, readings['expressions'], read) + if expressions := {**_mapping(merged.get('expressions')), **summed}: + merged['expressions'] = {key: _without(block, 'adds_to') for key, block in expressions.items()} if given := _folded(read, merged, loaded, readings): merged['given'] = given - if (objective := _summed_objective(read)) is not None: + if (objective := _one_objective(read)) is not None: merged['objective'] = objective return to_spec(merged) @@ -245,18 +257,6 @@ def _reading_claims(block: object) -> object: return {**claims, 'dims': frozenset(cast('list[str]', claims.get('dims', [])))} -def _readings(sections: Mapping[str, object]) -> dict[str, object]: - """The ``given:`` block of one fragment, each entry without its term: what the fragment reads, apart from what it adds. - - ``merge`` hands it the fields the fragment wrote. A field left out says - less, and a default filled in would claim a value against the introducer. - """ - return { - kind: {key: {f: v for f, v in _mapping(entry).items() if f != 'term'} for key, entry in _mapping(group).items()} - for kind, group in _mapping(sections.get('given')).items() - } - - def _agreed( read: Mapping[str, dict[str, object]], section: str, @@ -303,8 +303,8 @@ def _claimed(read: Mapping[str, dict[str, object]], section: str) -> dict[str, o f"name each fragment's term apart, such as after its component." ) hint = ( - " If one fragment adds to the other's definition, write what it adds as `term:` under " - '`given: expressions:`.' + ' A sum several fragments add to is defined by one of them at most: each other reads it ' + "under 'given: expressions:' and adds its part with `adds_to:`." if section == 'expressions' else '' ) @@ -319,101 +319,185 @@ def _claimed(read: Mapping[str, dict[str, object]], section: str) -> dict[str, o def _adds(sections: Mapping[str, object], key: str) -> str | None: - """The name one fragment adds *key* to as a term, or ``None`` where it adds no term by that name.""" - given = _mapping(_mapping(sections.get('given')).get('expressions')) - return next((target for target, entry in given.items() if _mapping(entry).get('term') == key), None) + """The name one fragment's expression *key* adds to as a term, or ``None`` where it is no term.""" + return cast('str | None', _as_mapping(_mapping(sections.get('expressions')).get(key)).get('adds_to')) def _terms(read: Mapping[str, dict[str, object]], key: str) -> list[tuple[str, str]]: """Every term the fragments add to *key*, with the fragment that adds it, in the order they are given in.""" - found = [] - for name, sections in read.items(): - entry = _mapping(_mapping(_mapping(sections.get('given')).get('expressions')).get(key)) - if entry.get('term') is not None: - found.append((name, cast('str', entry['term']))) - return found + return [ + (name, term) + for name, sections in read.items() + for term in _mapping(sections.get('expressions')) + if _adds(sections, term) == key + ] def _summed( - read: Mapping[str, dict[str, object]], loaded: Mapping[str, Spec], - defined: Mapping[str, object], + merged: Mapping[str, object], readings: Mapping[str, object], + read: Mapping[str, dict[str, object]], ) -> dict[str, object]: - """Every name a fragment adds a term to, its body written as the owner's body plus the terms. - - The owner's body comes first where it has one, in parentheses where it is - more than a name, then every term by its name in the order the fragments - are given in; one body alone is carried as written. An empty sum keeps its frame, - so the composed load holds the terms to it. A definition written as - ``cases:`` is refused, since it is summed as written and a set of cases is - no one body. The block keeps the owner's description, or takes the first - a reader wrote. + """Every name a fragment adds a term to, its body the definer's followed by every term by name. + + The terms join with a plain ``+``, the loosest operator there is, so a + sum composed in two merges reads as the one composed in one. Where no + fragment defines the name, the terms are the body, over the frame the + readers state; the composed load holds the terms to it either way. """ summed: dict[str, object] = {} + defined = _mapping(merged.get('expressions')) for key, reading in readings.items(): - entry = _mapping(reading) terms = _terms(read, key) if not terms: continue - _landed(read, defined, key, [name for name, _ in terms]) - bodies = [term for _, term in terms] - base = _as_mapping(defined[key]) - if base.get('cases'): - raise LanguageError( - f"fragment '{_author_of(read, 'expressions', key)}' defines {key!r} as `cases:`, and fragment " - f"'{terms[0][0]}' adds a term to it. The definition is summed as written, and a set of cases is no " - f'one body: name the cased body as its own expression, and define {key!r} as that name.' - ) - block: dict[str, object] = {} - if base.get('dims'): - block['dims'] = base['dims'] - if base.get('expression') is not None: - bodies.insert(0, cast('str', base['expression'])) - if base.get('description'): - block['description'] = base['description'] - block['expression'] = bodies[0] if len(bodies) == 1 else ' + '.join(_summand(body) for body in bodies) - if 'description' not in block and entry.get('description'): - block['description'] = entry['description'] + contributors = [name for name, _ in terms] + _undeclared(read, merged, key, contributors[0]) + names = [term for _, term in terms] + if key in defined: + base = _as_mapping(defined[key]) + summed[key] = {**base, 'expression': ' + '.join([cast('str', base['expression']), *names])} + continue + _read_elsewhere(loaded, key, contributors) + block: dict[str, object] = {'dims': _frame(read, key), 'expression': ' + '.join(names)} + if said := _mapping(reading).get('description'): + block['description'] = said summed[key] = block + _acyclic(loaded, {key: _terms(read, key) for key in summed}) return summed -def _landed( - read: Mapping[str, dict[str, object]], - defined: Mapping[str, object], - key: str, - contributors: list[str], -) -> None: - """Refuse terms that name no ``expressions:`` block of any fragment. +def _undeclared(read: Mapping[str, dict[str, object]], merged: Mapping[str, object], key: str, adder: str) -> None: + """Refuse a term on a name a fragment declares as something a term cannot follow, naming what declares it.""" + section = next((section for section in OWNED_SECTIONS if key in _mapping(merged.get(section))), None) + if section is None: + return + author = _author_of(read, section, key) + if section == 'expressions': + if not _as_mapping(_mapping(merged['expressions'])[key]).get('cases'): + return + raise LanguageError( + f"fragment '{author}' defines {key!r} as `cases:`, and fragment '{adder}' adds a term to it. A term " + f'follows one body, and a set of cases is no one body: name the cased body as its own expression, ' + f'and define {key!r} as that name.' + ) + raise LanguageError( + f"fragment '{author}' declares {key!r} as a {_singular(section)}, and fragment '{adder}' adds a term to " + f'it. A term adds to a named expression: give the sum a name of its own, or read the ' + f"{_singular(section)} under 'given: {section}:' and add no term to it." + ) + + +def _acyclic(loaded: Mapping[str, Spec], terms: Mapping[str, list[tuple[str, str]]]) -> None: + """Refuse a term that reads its own sum through a name another fragment defines. - A term adds to a name another file declares: a sum written ``empty: - true``, or a definition. Terms alone would define a name nothing - declared, which is what a mistyped name looks like, so the refusal names - the near miss among the names a term could land on, which a term is not. + Each fragment refuses a term that reads its sum through its own names at + load. A loop across fragments shows only once they compose, where the + composed load would name the loop and no fragment, so it is refused here + with the fragments that close it. """ - if key in defined: + reads: dict[str, frozenset[str]] = {} + owner: dict[str, str] = {} + for name, spec in loaded.items(): + for key, expression in spec.program.expressions.items(): + reads[key] = variables_of(expression.expression) + owner.setdefault(key, name) + for key, added in terms.items(): + reads[key] = reads.get(key, frozenset()) | {term for _, term in added} + for key, added in terms.items(): + for adder, term in added: + if (path := _path(reads, term, key)) is None: + continue + through = ', '.join( + f"{step!r} of '{owner[step]}'" if step in owner else f'the sum {step!r}' for step in path[1:-1] + ) + raise LanguageError( + f"fragment '{adder}' adds {term!r} to {key!r}, and {term!r} reads {key!r} back through " + f'{through}, so the sum would define itself. A term may not read what reads its sum: write ' + f'{term!r} from something else, or define {path[-2]!r} without {key!r}.' + ) + + +def _path(reads: Mapping[str, frozenset[str]], start: str, goal: str) -> list[str] | None: + """The names from *start* to *goal* along what each name reads, both ends included, or ``None``.""" + trail = {start: [start]} + frontier = [start] + while frontier: + name = frontier.pop() + for step in sorted(reads.get(name, ())): + if step == goal: + return [*trail[name], goal] + if step not in trail: + trail[step] = [*trail[name], step] + frontier.append(step) + return None + + +def _frame(read: Mapping[str, dict[str, object]], key: str) -> list[str]: + """The dims every fragment reads the sum *key* over, in the order they all write. + + No fragment defines the sum, so no fragment's order is the one to take: + the first reader's would make the order of the list reach the canonical + text, which keeps a declaration's dims as written. + """ + frames = [ + (name, cast('list[str]', entry['dims'])) + for name, sections in read.items() + if (entry := _mapping(_mapping(_mapping(sections.get('given')).get('expressions')).get(key))) + ] + (first, dims), *rest = frames + for name, other in rest: + if other != dims: + raise LanguageError( + f"fragments '{first}' and '{name}' read the sum {key!r} over {dims} and {other}. No fragment " + f'defines the sum, so its frame is the order its readers write: write the dims in one order ' + f'in every file.' + ) + return dims + + +def _read_elsewhere(loaded: Mapping[str, Spec], key: str, contributors: list[str]) -> None: + """Refuse terms that write into a name nothing but the terms reads. + + A fragment reads the name for more than adding to it where it reads it + under ``given:`` and adds nothing, or uses it in its math. A name only + its terms read, or only one fragment reads, is what a misspelt ``given:`` + entry looks like, since the file that uses the sum reads it under the + right spelling, so the refusal names the near miss among the names the + fragments read. + """ + readers = {name: spec for name, spec in loaded.items() if key in spec.program.given.expressions} + if len(readers) > 1 and any(name not in contributors or _uses(spec.program, key) for name, spec in readers.items()): return - known = {name for sections in read.values() for name in _mapping(sections.get('expressions'))} - {key} - terms = { - _mapping(entry).get('term') - for sections in read.values() - for entry in _mapping(_mapping(sections.get('given')).get('expressions')).values() + known = { + n + for spec in loaded.values() + for n in (*spec.program.given.expressions, *(n for n, e in spec.program.expressions.items() if not e.adds_to)) } - known -= terms spelled = ', '.join(f"'{name}'" for name in contributors[:-1]) who = f"fragments {spelled} and '{contributors[-1]}' add" if spelled else f"fragment '{contributors[0]}' adds" - near = f' {hint}' if (hint := did_you_mean(key, known, listing=False)) else '' + near = f' {hint}' if (hint := did_you_mean(key, known - {key}, listing=False)) else '' raise LanguageError( - f'{who} a term to {key!r}, which no fragment declares. A term adds to a name another file declares ' - f"under 'expressions:': declare it there, as `empty: true` over a `dims:` where the files add " - f'every term, or fix the spelling.{near}' + f'{who} a term to {key!r}, and no other fragment reads it: none reads it without adding to it, or ' + f'uses it in its math. A term writes into a sum the rest of the spec reads: add the fragment that ' + f"reads it, or fix the spelling under 'given:'.{near}" ) -def _summand(body: str) -> str: - """*body* as one operand of a sum: a term is a name and needs no brackets, and a definition may.""" - return body if body.isidentifier() else f'({body})' +def _uses(program: Program, key: str) -> bool: + """Whether *program*'s math reads the given expression *key*, which it reads as a column. + + A reported expression builds no row, so a read there is not a read in the + math, and a fragment whose only use of the name is to report it is still + one that may have misspelt it. + """ + trees = [ + *program.roots, + *(e.expression for e in program.expressions.values() if e.in_math), + *(link.expression for curve in program.piecewise.values() for link in curve.links), + ] + return key in variables_of(*trees) def _as_mapping(block: object) -> dict[str, object]: @@ -421,6 +505,11 @@ def _as_mapping(block: object) -> dict[str, object]: return cast('dict[str, object]', block) if isinstance(block, dict) else {'expression': block} +def _without(block: object, field: str) -> object: + """*block* with *field* dropped, where it is a mapping that has it.""" + return {f: v for f, v in block.items() if f != field} if isinstance(block, dict) else block + + def _folded( read: Mapping[str, dict[str, object]], merged: Mapping[str, object], @@ -454,7 +543,7 @@ def _folded( def _reader_of(read: Mapping[str, dict[str, object]], kind: str, key: str) -> str: """The first fragment reading *key* under ``given: {kind}:``.""" - return next(name for name, sections in read.items() if key in _mapping(_readings(sections).get(kind))) + return next(name for name, sections in read.items() if key in _mapping(_mapping(sections.get('given')).get(kind))) def _fits( @@ -492,7 +581,7 @@ def _fits( def _definer_frame(loaded: Mapping[str, Spec], key: str) -> frozenset[str]: - """The frame of the composed body of *key*: the frame its owner declares, else its body's with every term's.""" + """The frame of the composed body of *key*: the frame its definer declares, else its body's with every term's.""" frame: set[str] = set() for spec in loaded.values(): declared = spec.expressions[key].dims if key in spec.expressions else None @@ -500,9 +589,7 @@ def _definer_frame(loaded: Mapping[str, Spec], key: str) -> frozenset[str]: return frozenset(declared) if key in spec.program.expressions: frame |= set(spec.program.expressions[key].dims) - given = spec.program.given.expressions.get(key) - if given is not None and given.term is not None: - frame |= dims_of(given.term, spec, f"Given expression '{key}'") + frame |= {d for e in spec.program.expressions.values() if e.adds_to == key for d in e.dims} return frozenset(frame) @@ -522,33 +609,22 @@ def _same_kind(read: Mapping[str, dict[str, object]], merged: Mapping[str, objec ) -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. +def _one_objective(read: Mapping[str, dict[str, object]]) -> object | None: + """The objective the one fragment that sets it wrote, or ``None`` where none sets one. - The terms are summed in the order the fragments are given in, and the - first description given is carried, as a shared dimension's is. The - senses have to agree: a sum has - one sense, and negating the odd one out would be this function deciding what - a spec means. + A composed spec has one objective, so a second is a collision like a name + two fragments declare. Several files add to it through a sum that the + fragment setting the objective reads. """ - declared = {name: _mapping(sections['objective']) for name, sections in read.items() if sections.get('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 senses.items()) + declared = [name for name, sections in read.items() if sections.get('objective')] + if len(declared) > 1: + first, second, *_ = declared raise LanguageError( - f'the fragments disagree about which way the objective runs: {spelled}. A composed spec 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.' + f"fragments '{first}' and '{second}' both set the objective. A composed spec has one objective, " + f"and one fragment sets it: read a sum under 'given: expressions:' in that fragment, and add " + f'each part to it with `adds_to:`.' ) - ordered = list(declared.values()) - terms = [objective['expression'] for objective in ordered] - joined = terms[0] if len(terms) == 1 else ' + '.join(f'({term})' for term in terms) - summed: dict[str, object] = {'sense': next(iter(senses.values())), 'expression': joined} - if description := next((o['description'] for o in ordered if o.get('description')), None): - summed['description'] = description - return summed + return read[declared[0]]['objective'] if declared else None def override(base: Source, patches: Sequence[Source]) -> Spec: @@ -664,6 +740,10 @@ def _lay_over(base: dict[str, object], patch: dict[str, object], name: str) -> d laid[key] = _shared(_mapping(laid.get(key)), _section(value, key, name), key, name) elif key in OWNED_SECTIONS: block = _section(value, key, name) + if key == 'expressions': + block = { + entry: _body(over) if entry in _mapping(laid.get(key)) else over for entry, over in block.items() + } laid[key] = _owned(_mapping(laid.get(key)), block, _singular(key), _entry_class(Spec, key), name) elif key == 'objective': laid = _objective(laid, value, name) @@ -674,6 +754,16 @@ def _lay_over(base: dict[str, object], patch: dict[str, object], name: str) -> d return laid +def _body(over: object) -> object: + """A patch's edit to a named expression, the one-line form read as a new body that keeps the entry's other fields. + + The one-line form writes a body and nothing else, so laid over an entry + it replaces the body, whether an ``expression:`` or ``cases:``, and keeps + what the entry says beside it, such as what a term ``adds_to:``. + """ + return {'expression': over, 'cases': None, 'otherwise': None} if isinstance(over, str) else over + + 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. diff --git a/src/mathspec/dimensions.py b/src/mathspec/dimensions.py index bf9f9a51..0a053793 100644 --- a/src/mathspec/dimensions.py +++ b/src/mathspec/dimensions.py @@ -51,7 +51,6 @@ WindowSum, children, ) -from mathspec.spec import empty_sums if TYPE_CHECKING: from mathspec.program import Program @@ -71,7 +70,7 @@ def dims_of(node: Expression, schema: Spec, context: str) -> frozenset[str]: return frozenset({**schema.parameters, **schema.given.parameters}[node.name].dims) if isinstance(node, Variable): - columns = {**schema.variables, **schema.given.variables, **schema.given.expressions, **empty_sums(schema)} + columns = {**schema.variables, **schema.given.variables, **schema.given.expressions} return frozenset(columns[node.name].dims or ()) if isinstance(node, Dual): @@ -297,14 +296,15 @@ def check_schema(schema: Spec, program: Program) -> None: _check_where_dims(region.when, frame, context) _check_value_dims(region.value, schema, frame, context) - for gname, given in program.given.expressions.items(): - if given.term is None: + for ename, entry in program.expressions.items(): + if entry.adds_to is None: continue - context = f"Given expression '{gname}'" - if extra := sorted(dims_of(given.term, schema, context) - set(given.dims)): + stated = program.given.expressions[entry.adds_to].dims + if extra := [d for d in entry.dims if d not in stated]: raise DimensionError( - f'{context}: its term carries {extra}, which its dims {list(given.dims)} do not. A term is read ' - f"over the frame the entry states: add {extra} to the entry's dims, or leave them out of the term." + f"Named expression '{ename}': it adds to {entry.adds_to!r} over {extra}, which the given entry's " + f'dims {list(stated)} do not name. A term is read over the frame the given entry states: add ' + f'{extra} to those dims, or leave them out of the term.' ) for cname, constraint in program.constraints.items(): diff --git a/src/mathspec/lowering.py b/src/mathspec/lowering.py index a826a297..cabeb1d8 100644 --- a/src/mathspec/lowering.py +++ b/src/mathspec/lowering.py @@ -52,11 +52,10 @@ resolve_expression_text, resolve_where_text, ) -from mathspec.spec import empty_sums from mathspec.validation import emitted_name_errors, reference_errors if TYPE_CHECKING: - from collections.abc import Mapping + from collections.abc import Iterable, Mapping from mathspec.program import Expression from mathspec.spec import AssumptionBlock, Spec @@ -121,42 +120,13 @@ def lower(schema: Spec) -> Program: resolve_expression(body_ast, ns, context, errors, formals=formals) entries: dict[str, Named] = {} - empty = empty_sums(schema) for ename in schema.expressions: - if ename in empty: - continue node, refusals = ns.named_entry(ename) errors.extend(refusals) if node is not None: entries[ename] = node - terms: dict[str, Named] = {} - for gname, gdef in schema.given.expressions.items(): - if gdef.term is None: - continue - context = f"Given expression '{gname}'" - if gdef.term not in schema.expressions: - errors.append( - f'{context}: its term {gdef.term!r} is no expression this file declares. A term is a named ' - f"expression: declare it under 'expressions:', and write its name here. " - f'{did_you_mean(gdef.term, schema.expressions)}' - ) - continue - term = resolve_expression_text(gdef.term, ns, context, errors, ceiling=2) - if term is None: - continue - assert isinstance(term, Named), 'a term is a name, and a name resolves to the entry it names' - terms[gname] = term - for gname, through in [(g, _loop(g, terms)) for g in terms]: - if through is None: - continue - via = f', through {" -> ".join(repr(name) for name in through)}' if through else '' - errors.append( - f"Given expression '{gname}': its term {schema.given.expressions[gname].term!r} reads '{gname}', the " - f'sum the term adds to{via}, so the sum would define itself. A term is what this file puts in: write ' - f'it in what this file declares.' - ) - del terms[gname] + terms = _terms(entries, schema, ns, errors) variables = {} for vname, vdef in schema.variables.items(): @@ -212,7 +182,7 @@ def lower(schema: Spec) -> Program: if objective is not None: roots.append(objective.expression) roots.extend(link for links in curves.values() for link in links) - roots.extend(terms.values()) + roots.extend(terms) in_math = frozenset(node.name for node in walk(*roots) if isinstance(node, Named)) piecewise = {} @@ -256,6 +226,7 @@ def lower(schema: Spec) -> Program: _frame_of(name, entry, schema), in_math=name in in_math, description=schema.expressions[name].description, + adds_to=schema.expressions[name].adds_to, ) for name, entry in entries.items() }, @@ -271,11 +242,7 @@ def lower(schema: Spec) -> Program: name: GivenDeclaration(tuple(g.dims), g.description) for name, g in schema.given.constraints.items() }, expressions={ - **{ - name: GivenDeclaration(tuple(g.dims), g.description, term=terms.get(name)) - for name, g in schema.given.expressions.items() - }, - **{name: GivenDeclaration(tuple(e.dims or ()), e.description, empty=True) for name, e in empty.items()}, + name: GivenDeclaration(tuple(g.dims), g.description) for name, g in schema.given.expressions.items() }, ), description=schema.description, @@ -286,23 +253,81 @@ def lower(schema: Spec) -> Program: return program -def _loop(target: str, terms: Mapping[str, Named]) -> list[str] | None: - """The sums *target*'s term reads *target* through, by this file's terms, or ``None`` where it does not read it. +def _terms(entries: Iterable[str], schema: Spec, ns: Namespace, errors: list[str]) -> list[Named]: + """Every entry with ``adds_to:`` that loads as a term, with a refusal in *errors* for each other. + + A term that reads its own sum, directly or through the sums the file's + other terms add to, defines that sum by itself whatever the other files + add, so the one file decides it. + """ + resolved = { + name: (target, entry) + for name in entries + if (target := schema.expressions[name].adds_to) is not None + and (entry := _term(name, target, schema, ns, errors)) is not None + } + sums: dict[str, list[Named]] = {} + for target, entry in resolved.values(): + sums.setdefault(target, []).append(entry) + terms = [] + for name, (target, entry) in resolved.items(): + through = _loop(target, entry, sums) + if through is None: + terms.append(entry) + continue + via = f', through {" -> ".join(repr(sum_) for sum_ in through)}' if through else '' + errors.append( + f"Named expression '{name}': it reads {target!r}, the sum it adds to{via}, so the sum would define " + f'itself. A term is what this file puts in: write it in what this file declares.' + ) + return terms + + +def _term(name: str, target: str, schema: Spec, ns: Namespace, errors: list[str]) -> Named | None: + """Named expression *name* as the term it writes into a given expression of its file, or ``None``. + + The given entry is what makes a misspelt target a refusal in the file that + wrote it: a term lands only on a name its own file reads. The term is + resolved as a use of its name, so it is held to the degree the math that + reads the sum admits. + """ + context = f"Named expression '{name}'" + if target in schema.expressions: + errors.append( + f'{context}: it adds to {target!r}, which this file defines. A file writes its own body in one ' + f"place, so a term fills only a name read under 'given: expressions:': write the term into the body of " + f'{target!r}, or read {target!r} there and add its body as a term of its own.' + ) + return None + if target not in schema.given.expressions: + errors.append( + f"{context}: it adds to {target!r}, which this file does not read under 'given: expressions:'. " + f'A term writes into a name this file reads: declare the name there over its frame, or fix ' + f'the spelling. {did_you_mean(target, schema.given.expressions)}' + ) + return None + entry = resolve_expression_text(name, ns, context, errors, ceiling=2) + if entry is None: + return None + assert isinstance(entry, Named), 'a term is a name, and a name resolves to the entry it names' + return entry + + +def _loop(target: str, entry: Named, sums: Mapping[str, list[Named]]) -> list[str] | None: + """The sums *entry* reads *target* through, by the terms in *sums*, or ``None`` where it does not read it. - Terms that read each other's sums in one file define each sum by itself - whatever the other files add, so the file decides it alone. ``[]`` is a - term that reads its own sum. + ``[]`` is a term that reads its own sum. """ seen = {target} - stack: list[tuple[str, list[str]]] = [(target, [])] + stack: list[tuple[list[str], frozenset[str]]] = [([], variables_of(entry))] while stack: - name, path = stack.pop() - for read in sorted(variables_of(terms[name])): + path, reads = stack.pop() + for read in sorted(reads): if read == target: return path - if read in terms and read not in seen: + if read in sums and read not in seen: seen.add(read) - stack.append((read, [*path, read])) + stack.append(([*path, read], variables_of(*sums[read]))) return None diff --git a/src/mathspec/program.py b/src/mathspec/program.py index f32a9955..fa0acc28 100644 --- a/src/mathspec/program.py +++ b/src/mathspec/program.py @@ -621,17 +621,11 @@ class GivenDeclaration: 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 a name the host does not provide. An empty sum is the one - entry this program declares itself: its body is what the other files add. + and refuses a name the host does not provide. """ dims: tuple[str, ...] description: str | None = None - #: The [`Named`][] node of the term this program adds to the name, or ``None`` where it only reads it. - term: Named | None = None - #: Whether this program declares the name itself, as an ``empty: true`` - #: expression, and leaves the body to the files that add terms. - empty: bool = False @dataclass(frozen=True) @@ -707,9 +701,9 @@ class ObjectiveDeclaration: class ExpressionDeclaration: """A named quantity — one the math reads, or one only read back after a solve. - ``in_math`` where the objective, a constraint or a term this program - adds to a given expression reads it, directly or through another entry or - a macro; its body then stands inside + ``in_math`` where the objective, a constraint or a term of a given + expression reads it, directly or through another entry or a macro, and + where it is such a term itself; its body then stands inside [`Program.roots`][] and is held to the degree rules where it is read. Otherwise nothing a solver sees contains it: it is a reported quantity, its body held to no degree, the one place a [`Dual`][] may @@ -722,6 +716,8 @@ class ExpressionDeclaration: dims: tuple[str, ...] in_math: bool description: str | None = None + #: The sum this entry adds to as a term, or ``None``. + adds_to: str | None = None @dataclass(frozen=True) diff --git a/src/mathspec/resolution.py b/src/mathspec/resolution.py index 6308650e..3f8fcf23 100644 --- a/src/mathspec/resolution.py +++ b/src/mathspec/resolution.py @@ -46,7 +46,6 @@ RelationDeclaration, carries_variable, ) -from mathspec.spec import empty_sums if TYPE_CHECKING: from collections.abc import Iterable @@ -88,11 +87,10 @@ 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 - empty = empty_sums(schema) - variables = {**schema.variables, **schema.given.variables, **schema.given.expressions, **empty} + variables = {**schema.variables, **schema.given.variables, **schema.given.expressions} parameters = {**schema.parameters, **schema.given.parameters} self.variables = frozenset(variables) - self.bodies = frozenset(name for name in schema.expressions if name not in empty) + self.bodies = frozenset(schema.expressions) self.parameters = frozenset(parameters) self.dimensions = frozenset(schema.dimensions) #: The declared constraint names, off the flat namespace: a bare name diff --git a/src/mathspec/spec.py b/src/mathspec/spec.py index ae1767f2..57fbd244 100644 --- a/src/mathspec/spec.py +++ b/src/mathspec/spec.py @@ -334,19 +334,16 @@ class GivenExpressionBlock(_StrictBlock): body is the definer's, and the composed spec holds the body to the rules of every place this file reads it. - ``term:`` names the expression this file adds to the name, one this file - declares under ``expressions:`` and reads over at most the frame. - [`merge`][mathspec.composition.merge] adds it by name to the definition - another file writes and to the terms other files add, and keeps it as a - named expression. The file itself reads the name as the whole sum, alone - and composed. + A named expression of this file may add to the name with ``adds_to:``. + [`merge`][mathspec.composition.merge] then writes every term the files + add after the body a file defines, or as the whole body where no file + defines it. The file itself reads the name as the whole sum, alone and + composed. """ _label: ClassVar[str] = 'a given expression declaration' dims: list[str] - #: The named expression this file adds to the name, or ``None`` where it only reads it. - term: str | None = None description: str | None = None @@ -465,10 +462,12 @@ class ExpressionBlock(_StrictBlock): written as ``cases:`` over a declared ``dims:``, with an ``otherwise:`` for the rest — see the language reference. - ``empty: true`` over a declared ``dims:``, with no body, is an **empty - sum**: a quantity this file declares and other files add terms to, - through [`merge`][mathspec.composition.merge]. Alone, the file reads it as - a column over the frame, as it reads a given expression. + ``adds_to:`` adds this entry as a term to the sum it names, a + ``given: expressions:`` entry of this file: a write, where the given entry + is the read. + [`merge`][mathspec.composition.merge] adds every term to that name by + name, after the body a file defines where one does. The term is read over at most the + frame the given entry states, and does not read the name it adds to. """ _label: ClassVar[str] = 'a named expression' @@ -481,8 +480,8 @@ class ExpressionBlock(_StrictBlock): cases: Annotated[dict[str, ExpressionCase], Field(min_length=1)] = {} #: The value wherever no case's ``when`` holds, printed as the last row. otherwise: Expression | None = None - #: Whether the entry is an empty sum: no body of its own, and the terms other files add are all of it. - empty: bool = False + #: The sum this entry adds to as a term, a ``given: expressions:`` entry of this file, or ``None``. + adds_to: str | None = None description: str | None = None @model_validator(mode='before') @@ -492,10 +491,7 @@ def _from_string(cls, data: object) -> object: @model_validator(mode='after') def _one_form_or_the_other(self) -> Self: - """One ``expression:``, ``cases:`` with the ``otherwise:`` and ``dims:`` they need, or ``empty: true`` over a ``dims:``.""" - if self.empty: - self._check_empty() - return self + """One ``expression:``, or ``cases:`` with the ``otherwise:`` and ``dims:`` they need.""" if self.cases and self.expression is not None: msg = ( 'a named expression is one `expression:` or a set of `cases:`, and this has both. ' @@ -506,7 +502,8 @@ def _one_form_or_the_other(self) -> Self: msg = ( 'a named expression is one `expression:` or a set of `cases:`, and this has neither. ' 'Cases are for a quantity whose value varies by region; one expression is everything else. ' - 'A sum other files add every term to is written `empty: true`, over a `dims:`.' + "A sum other files add every term to is read under 'given: expressions:', and each file " + 'names it with `adds_to:` on its term.' ) raise ValueError(msg) if self.cases and self.dims is None: @@ -530,22 +527,6 @@ def _one_form_or_the_other(self) -> Self: raise ValueError(msg) return self - def _check_empty(self) -> None: - """An empty sum has a frame and nothing else to be read over: no body, no cases, no fallback.""" - written = [f'`{key}:`' for key in ('expression', 'cases', 'otherwise') if getattr(self, key)] - if written: - msg = ( - f'`empty: true` is a sum with no body of its own, and this also has {" and ".join(written)}. ' - f'Drop `empty:` to keep the body, or drop the body: the terms other files add are all of it.' - ) - raise ValueError(msg) - if self.dims is None: - msg = ( - '`empty: true` needs a `dims:` — an empty sum has no body to give the frame it is read over, ' - 'and the terms that fill it are read over that frame.' - ) - raise ValueError(msg) - @classmethod @override def __get_pydantic_json_schema__(cls, core_schema: CoreSchema, handler: GetJsonSchemaHandler) -> dict[str, object]: @@ -560,16 +541,15 @@ def _as_written(self) -> str | dict[str, object]: written['description'] = self.description written['cases'] = {name: case.model_dump() for name, case in self.cases.items()} written['otherwise'] = self.otherwise - return written - if self.expression is not None and self.description is None and self.dims is None: + elif self.expression is not None and self.description is None and self.dims is None and self.adds_to is None: return self.expression - written = {'dims': list(self.dims)} if self.dims is not None else {} - if self.empty: - written['empty'] = True else: + written = {'dims': list(self.dims)} if self.dims is not None else {} written['expression'] = self.expression - if self.description is not None: - written['description'] = self.description + if self.description is not None: + written['description'] = self.description + if self.adds_to is not None: + written['adds_to'] = self.adds_to return written @@ -1032,11 +1012,6 @@ def _lower(self) -> Spec: return self -def empty_sums(schema: Spec) -> dict[str, ExpressionBlock]: - """The named expressions of *schema* written ``empty: true``: the sums other files add terms to.""" - return {name: e for name, e in schema.expressions.items() if e.empty} - - def _formulations(asked: tuple[str, ...]) -> tuple[Formulation, ...]: """What *asked* names, in [`FORMULATIONS`][] order — all of them where it names none. diff --git a/src/mathspec/typesetting/__init__.py b/src/mathspec/typesetting/__init__.py index 305ea096..6a85ebd8 100644 --- a/src/mathspec/typesetting/__init__.py +++ b/src/mathspec/typesetting/__init__.py @@ -204,15 +204,14 @@ def typeset_declaration( SchemaError: *name* is declared as none of the five, as two — a 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 spec. A sum this file declares ``empty: true`` - prints its line, ``symbol = ⋯``. + names nothing in the spec. """ walk = _walk(spec, fmt, symbols, inline_expressions=inline_expressions) given = walk.program.given givens = { 'parameter': given.parameters, 'variable': given.variables, - 'expression': {n: block for n, block in given.expressions.items() if not block.empty}, + 'expression': given.expressions, 'constraint': given.constraints, } given_kind = next((kind for kind, group in givens.items() if name in group), None) diff --git a/src/mathspec/typesetting/format.py b/src/mathspec/typesetting/format.py index ee7913b0..fe888114 100644 --- a/src/mathspec/typesetting/format.py +++ b/src/mathspec/typesetting/format.py @@ -161,8 +161,6 @@ class Format(Protocol): operators: ClassVar[Mapping[OperatorName, str]] #: The em dash in prose: TeX and Typst read ``---`` as one, Markdown does not. dash: ClassVar[str] - #: The body of a sum other files add terms to, which this file leaves open. - ellipsis: ClassVar[str] # -- atoms ------------------------------------------------------------- diff --git a/src/mathspec/typesetting/latex.py b/src/mathspec/typesetting/latex.py index ee83565b..3d6c9d80 100644 --- a/src/mathspec/typesetting/latex.py +++ b/src/mathspec/typesetting/latex.py @@ -47,7 +47,6 @@ class LatexFormat: notation: ClassVar[Notation] = 'latex' #: TeX's own em-dash ligature. dash: ClassVar[str] = '---' - ellipsis: ClassVar[str] = r'\cdots' operators: ClassVar[Mapping[OperatorName, str]] = {name: latex for name, (latex, _) in OPERATOR_SPELLINGS.items()} diff --git a/src/mathspec/typesetting/legend.py b/src/mathspec/typesetting/legend.py index 00cad840..62c014fb 100644 --- a/src/mathspec/typesetting/legend.py +++ b/src/mathspec/typesetting/legend.py @@ -173,14 +173,13 @@ def glossaries(self, noticed: Noticed, defined: Iterable[str]) -> list[tuple[str self.symbols.name[g], f'{fmt.mono(g)}{self._over(list(block.dims))}, ' + ( - f'an expression this file adds {fmt.mono(block.term.name)} to' - if block.term is not None + f'an expression this file adds {", ".join(fmt.mono(t) for t in terms)} to' + if (terms := [t for t, e in program.expressions.items() if e.adds_to == g]) else 'an expression another file defines' ), block.description, ) for g, block in program.given.expressions.items() - if not block.empty ), *( self._entry( @@ -192,10 +191,9 @@ def glossaries(self, noticed: Noticed, defined: Iterable[str]) -> list[tuple[str ), ] shown = set(defined) - empty = {e: block for e, block in program.given.expressions.items() if block.empty} definitions = [ self._entry(self.symbols.name[e], f'{fmt.mono(e)}{self._over(list(block.dims))}', block.description) - for e, block in {**program.expressions, **empty}.items() + for e, block in program.expressions.items() if e in shown ] groups = ( diff --git a/src/mathspec/typesetting/typst.py b/src/mathspec/typesetting/typst.py index 7d942aef..bdc52306 100644 --- a/src/mathspec/typesetting/typst.py +++ b/src/mathspec/typesetting/typst.py @@ -55,7 +55,6 @@ class TypstFormat: notation: ClassVar[Notation] = 'typst' #: Typst applies the same substitution TeX does. dash: ClassVar[str] = '---' - ellipsis: ClassVar[str] = 'dots.c' operators: ClassVar[Mapping[OperatorName, str]] = {name: typst for name, (_, typst) in OPERATOR_SPELLINGS.items()} diff --git a/src/mathspec/typesetting/walk.py b/src/mathspec/typesetting/walk.py index a34bc360..86c3fca8 100644 --- a/src/mathspec/typesetting/walk.py +++ b/src/mathspec/typesetting/walk.py @@ -241,10 +241,6 @@ def _frame_of(self, name: str) -> list[str]: entry = self.program.expressions.get(name) or self.program.given.expressions[name] return list(entry.dims) - def _empty(self) -> list[str]: - """The sums this file declares ``empty: true``, which other files add terms to.""" - return [name for name, block in self.program.given.expressions.items() if block.empty] - def _op(self, name: OperatorName) -> str: return self.format.operators[name] @@ -717,9 +713,7 @@ def _definitions(self) -> list[Line]: quantity it names. Every declared one prints, used or not. Inlining substitutes away the plain ones the math reads; a ``cases`` block has no single body to substitute, and an entry the math never reads has - nowhere to be substituted *into*, so both still print. A sum other - files add terms to prints last, as ``symbol = ⋯``: this file declares - the name and states no body for it. + nowhere to be substituted *into*, so both still print. """ return [self.definition(name) for name in self.defined()] @@ -728,29 +722,25 @@ def defined(self) -> list[str]: Inlining leaves a name standing only where substitution cannot reach it — a ``cases`` block, an entry the objective and constraints never - read, which is a quantity reported back rather than solved for, a term - this file adds to a sum, which the math reads only as that sum's - symbol, and a sum with no body yet. + read, which is a quantity reported back rather than solved for, and a + term this file adds to a sum, which the math reads only as that sum's + symbol. """ entries = self.program.expressions if not self.inline_expressions: - return [*entries, *self._empty()] - terms = {block.term.name for block in self.program.given.expressions.values() if block.term is not None} - standing = [ + return list(entries) + return [ name for name, entry in entries.items() - if isinstance(entry.expression, Cases) or not entry.in_math or name in terms + if isinstance(entry.expression, Cases) or not entry.in_math or entry.adds_to is not None ] - return [*standing, *self._empty()] def definition(self, name: str) -> Line: - """The line defining one named expression, ``symbol = body`` over its frame, or ``symbol = ⋯`` for a sum with no body yet.""" - entry = self.program.expressions.get(name) + """The line defining one named expression, ``symbol = body`` over its frame.""" + entry = self.program.expressions[name] frame = self._frame_of(name) ctx = self._context(frame) - if entry is None: - rendered = self.format.ellipsis - elif isinstance(entry.expression, Cases): + if isinstance(entry.expression, Cases): rendered = self.format.cases(self._arms(entry.expression, ctx)) else: rendered = self._expression(entry.expression, ctx) @@ -775,7 +765,7 @@ def line(self, name: str) -> Line: """ program = self.program kinds = { - 'named expression': ({*program.expressions, *self._empty()}, self.definition), + 'named expression': (program.expressions, self.definition), 'constraint': (program.constraints, self._constraint), 'assumption': (program.assumptions, self._assumption), 'curve': (program.piecewise, self._piecewise), diff --git a/tests/fixtures.py b/tests/fixtures.py index e71cd3ce..e4267ea6 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -70,14 +70,6 @@ 'constraints': {'balance': {'dims': BUS_FRAME, 'expression': 'injection == 0'}}, } -#: The network: it declares `injection` as an empty sum, for the component -#: fragments to add their terms to, and balances it. -NETWORK: dict[str, Any] = { - 'dimensions': BUS_DIMS, - 'expressions': {'injection': {'dims': BUS_FRAME, 'empty': True, 'description': INJECTION}}, - 'constraints': {'balance': {'dims': BUS_FRAME, 'expression': 'injection == 0'}}, -} - def varied(base: dict[str, Any], **patch: Any) -> dict[str, Any]: """A deep copy of ``base`` with dotted paths replaced, missing parents created. diff --git a/tests/test_boundedness.py b/tests/test_boundedness.py index 1d456cc9..ed064c51 100644 --- a/tests/test_boundedness.py +++ b/tests/test_boundedness.py @@ -123,10 +123,6 @@ def test_a_named_constant_coefficient_carries_its_sign(objective, side): {'given': {'expressions': {'e': {'dims': ['g']}}}, 'objective.expression': 'sum(e, over=g)'}, id='a-given-expression-is-bounded-by-whoever-defines-it', ), - pytest.param( - {'expressions.e': {'dims': ['g'], 'empty': True}, 'objective.expression': 'sum(e, over=g)'}, - id='an-empty-sum-is-bounded-by-its-terms', - ), ], ) def test_nothing_is_claimed_where_the_file_does_not_decide_it(patch): diff --git a/tests/test_composition.py b/tests/test_composition.py index ec2e96d1..41fe4d2c 100644 --- a/tests/test_composition.py +++ b/tests/test_composition.py @@ -72,6 +72,29 @@ LIBRARY = [SURFACE, SUPPLY, DEMAND] +#: The objective of a composed cost model, set by one file and read off a sum +#: the other files add a term each to (#801). +OBJECTIVE = { + 'given': {'expressions': {'total_cost': {'dims': [], 'description': 'what the system costs'}}}, + 'objective': {'sense': 'minimize', 'expression': 'total_cost'}, +} + + +def _priced(name: str) -> dict[str, object]: + """A fragment that builds *name* and adds what it costs to `total_cost`.""" + return { + 'dimensions': {name: {'dtype': 'str'}}, + 'parameters': {f'{name}_cost': {'dims': [name]}}, + 'variables': {f'{name}_p': {'dims': [name], 'bounds': {'lower': 0}}}, + 'given': {'expressions': {'total_cost': {'dims': []}}}, + 'expressions': { + f'{name}_spend': {'expression': f'sum({name}_p * {name}_cost)', 'adds_to': 'total_cost'}, + }, + } + + +GENERATOR, STORE = _priced('generator'), _priced('store') + def test_a_fragment_reads_what_a_sibling_declares(): """`supply` reads `flow` under `given:`, so it is a spec on its own and a piece of the composition.""" @@ -114,10 +137,7 @@ def test_the_balance_does_not_grow_when_a_component_type_is_added(): 'fragments', [ pytest.param(LIBRARY, id='one-objective'), - pytest.param( - [SURFACE, SUPPLY, {**DEMAND, 'objective': {'sense': 'minimize', 'expression': 'sum(dem_load)'}}], - id='an-objective-in-two-fragments', - ), + pytest.param([OBJECTIVE, GENERATOR, STORE], id='an-objective-on-a-sum'), ], ) def test_the_order_of_the_fragments_reaches_no_canonical_text(fragments): @@ -144,11 +164,6 @@ def test_the_fragments_are_never_mutated(): 'give one of them a name of its own', id='one-dimension-described-two-ways', ), - pytest.param( - [SUPPLY, {**DEMAND, 'objective': {'sense': 'maximize', 'expression': 'sum(dem_load)'}}], - 'negate the terms', - id='objectives-that-run-opposite-ways', - ), ], ) def test_a_disagreement_between_fragments_is_refused(fragments, says): @@ -206,23 +221,49 @@ def test_a_reader_s_description_fills_a_declaration_that_has_none(owner, carried assert composed.variables['flow'].description == carried -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, SUPPLY, priced]) - assert composed.objective is not None - assert composed.objective.expression == '(sum(gen_p * gen_cost)) + (sum(dem_load) * 2)', ( - 'the terms are summed in the order the fragments are given in' +@pytest.mark.parametrize( + 'composed', + [ + pytest.param(lambda: merge([OBJECTIVE, GENERATOR, STORE]), id='in-one-list'), + pytest.param(lambda: merge([merge([OBJECTIVE, GENERATOR]), STORE]), id='in-steps'), + ], +) +def test_a_composed_objective_reads_the_sum_its_fragments_add_to(composed): + spec = composed() + assert spec.objective is not None + assert (spec.objective.sense, spec.objective.expression) == ('minimize', 'total_cost'), ( + 'the objective is carried as the one file that sets it wrote it' + ) + assert spec.expressions['total_cost'].expression == 'generator_spend + store_spend', ( + 'each file adds its cost as a term of the sum the objective reads' ) -def test_a_composed_objective_keeps_the_first_description_a_fragment_gives_it(): - """Prose, as on a shared dimension: the first fragment's wording is carried, and none is lost.""" - said = {**SUPPLY, 'objective': {**SUPPLY['objective'], 'description': 'what running the fleet costs'}} - priced = {**DEMAND, 'objective': {'sense': 'minimize', 'expression': 'sum(dem_load) * 2'}} - composed = merge([SURFACE, said, priced]) - assert composed.objective is not None - assert composed.objective.description == 'what running the fleet costs' +@pytest.mark.parametrize( + 'fragments', + [ + pytest.param( + [SURFACE, SUPPLY, {**DEMAND, 'objective': {'sense': 'minimize', 'expression': 'sum(dem_load)'}}], + id='two-objectives', + ), + pytest.param( + [SUPPLY, {**DEMAND, 'objective': {'sense': 'maximize', 'expression': 'sum(dem_load)'}}], + id='two-objectives-that-run-opposite-ways', + ), + pytest.param( + [OBJECTIVE, GENERATOR, {**STORE, 'objective': {'sense': 'minimize', 'expression': 'total_cost'}}], + id='an-objective-on-a-sum-and-one-more', + ), + ], +) +def test_two_fragments_that_set_the_objective_are_refused(fragments): + """`merge` summed the objectives, by a rule that no other section has; a second objective now collides.""" + with pytest.raises(LanguageError, match=r'both set the objective') as raised: + merge(fragments) + message = str(raised.value) + assert 'given: expressions:' in message and '`adds_to:`' in message, 'the refusal names the rewrite' + first, second = [i for i, f in enumerate(fragments, 1) if 'objective' in f] + assert f"'#{first}'" in message and f"'#{second}'" in message, 'both fragments that set it are named' def test_one_fragment_s_objective_is_carried_as_it_was_written(): diff --git a/tests/test_library_example.py b/tests/test_library_example.py index f67203c5..6d613ae5 100644 --- a/tests/test_library_example.py +++ b/tests/test_library_example.py @@ -46,9 +46,10 @@ def test_the_library_composes_into_one_model(): '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 spec's objective, as it wrote it" + assert spec.objective is not None and spec.objective.expression == 'total_cost', ( + 'the surface sets the objective on the sum the components add their cost to' ) + assert spec.expressions['total_cost'].expression == 'Generator_cost', 'the one priced fragment is the one term' @pytest.mark.parametrize( diff --git a/tests/test_pypsa_collapses.py b/tests/test_pypsa_collapses.py index f683a516..9fd1912a 100644 --- a/tests/test_pypsa_collapses.py +++ b/tests/test_pypsa_collapses.py @@ -107,7 +107,9 @@ def test_the_extra_axes_and_rows_are_declared(): def test_omega_blends_expectation_and_tail_in_the_objective(): - expr = ALL.objective.expression + assert ALL.objective.expression == 'total_cost', 'the objective reads the system cost' + assert 'risk_weighted_opex' in ALL.expressions['total_cost'].expression, 'the system cost carries opex at risk' + expr = ALL.expressions['risk_weighted_opex'].expression assert all(term in expr for term in ('CVaR_omega', 'scenario_weight', 'scenario_opex', 'CVaR')), ( 'the objective prices expected opex by scenario weight and blends the tail by omega' ) diff --git a/tests/test_pypsa_split.py b/tests/test_pypsa_split.py index 3b67afd4..d61176e2 100644 --- a/tests/test_pypsa_split.py +++ b/tests/test_pypsa_split.py @@ -7,10 +7,10 @@ Each fragment reads what another topic declares under `given:`. A sum every component adds to (the bus balance, the operating cost, the global constraints) is one each component adds a term to: a named expression of its -own, such as `Generator_injection`, which the `term:` of its `given:` entry -names. One fragment declares each sum as an empty sum, `empty: true` over its -frame. So a component is a family of files, and leaving the family out leaves -a whole model. +own, such as `Generator_injection`, whose `adds_to:` names the sum its file +reads under `given:`. One fragment reads each sum without adding to it, with +its description. So a component is a family of files, and leaving the family +out leaves a whole model. """ from __future__ import annotations @@ -18,10 +18,14 @@ from pathlib import Path import pytest +import yaml from mathspec import FORMATS, LanguageError, merge, to_spec, typeset from mathspec.canonical import canonical_yaml -from tools.pypsa_split import SOURCE, SUM_HOME, Model, fragments +from tests.fixtures import BALANCE +from tests.test_terms import DEMAND, FLEET +from tools.gallery import split_index +from tools.pypsa_split import SOURCE, SUM_HOME, Model, _term_block, fragments FOLDER = Path(__file__).resolve().parent.parent / 'examples' / 'pypsa' PATHS = {path.stem: path for path in sorted(FOLDER.glob('*.yaml'))} @@ -60,8 +64,8 @@ def test_the_fragments_are_what_the_splitter_writes(model): #: What a model may leave out, as the fragment names or name prefixes it #: drops. A component comes as a family; security reads the branches. The -#: owner of a sum no model goes without is not listed: leaving it out leaves -#: its terms nowhere to land, which the test below holds. +#: reader of a sum no model goes without is not listed: leaving it out leaves +#: its terms with nothing else reading them, which the test below holds. OPTIONAL = [ 'carrier', 'cost', @@ -93,17 +97,19 @@ def test_leaving_a_topic_out_leaves_a_whole_model(dropped): assert not merge(list(kept.values())).program.given, f'nothing that stays reads what {dropped} declares' -def test_every_sum_is_declared_empty_with_its_description_in_one_fragment(model): - declared = { +def test_every_sum_is_read_with_its_description_in_one_fragment_that_adds_nothing(model): + described = { name: sorted( stem for stem, path in PATHS.items() - if (e := to_spec(path).expressions.get(name)) and e.empty and e.description + if (g := (spec := to_spec(path)).given.expressions.get(name)) + and g.description + and all(e.adds_to != name for e in spec.expressions.values()) ) for name in model.sums } - assert declared == {name: [SUM_HOME.get(name, 'settings')] for name in model.sums}, ( - 'the reader of a sum no model goes without declares it, and settings declares the rest' + assert described == {name: [SUM_HOME.get(name, 'settings')] for name in model.sums}, ( + 'the reader of a sum no model goes without carries its description, and settings carries the rest' ) @@ -114,10 +120,10 @@ def test_every_sum_is_declared_empty_with_its_description_in_one_fragment(model) pytest.param('power_flow', 'Cycle_angle_sum', id='kirchhoff-with-the-branches-kept'), ], ) -def test_leaving_out_the_owner_of_a_sum_no_model_goes_without_is_refused(dropped, sum_name): - """Only the owner declares the sum, so without it the terms land on no name rather than define one.""" +def test_leaving_out_the_reader_of_a_sum_no_model_goes_without_is_refused(dropped, sum_name): + """Only the terms read the sum without its reader, so they write into a name nothing else reads.""" kept = {name: path for name, path in PATHS.items() if name != dropped} - with pytest.raises(LanguageError, match=rf"add a term to '{sum_name}', which no fragment declares"): + with pytest.raises(LanguageError, match=rf"add a term to '{sum_name}', and no other fragment reads it"): merge(list(kept.values())) @@ -125,3 +131,42 @@ def test_leaving_out_the_owner_of_a_sum_no_model_goes_without_is_refused(dropped def test_every_fragment_and_the_composition_print(fmt): assert all(typeset(path, fmt) for path in PATHS.values()), f'a fragment rendered nothing in {fmt}' assert typeset(merge(list(PATHS.values())), fmt) + + +@pytest.mark.parametrize( + 'block', + [ + pytest.param(' Name: a + b', id='one-line'), + pytest.param(' Name: >-\n a\n + b', id='folded'), + pytest.param(' Name:\n expression: a + b', id='mapping'), + pytest.param(' Name: {expression: a + b}', id='flow-mapping'), + pytest.param(' Name: a\n + b', id='one-line-continued'), + ], +) +def test_a_term_block_carries_its_body_in_every_source_form(block): + """A body the head line does not hold whole was dropped or nested. + + A folded body follows a `>-` on the head line, and a plain body may run on + to the next line; the splitter kept only the head line. A flow mapping was + nested under `expression:`. + """ + assert yaml.safe_load(_term_block(block, 'hub')) == {'Name': {'expression': 'a + b', 'adds_to': 'hub'}} + + +def test_the_split_index_names_a_hub_once_per_fragment_and_needs_a_described_reader(): + """A fragment with two terms into one sum adds to it once, and a sum nobody reads with a description has no reader to name.""" + twice = { + **FLEET, + 'expressions': { + **FLEET['expressions'], + 'curtailment': {'expression': 'sum(gen_p, by=gen_bus, over=generator, into=bus)', 'adds_to': 'injection'}, + }, + } + specs = {'balance': to_spec(BALANCE), 'fleet': to_spec(twice), 'demand': to_spec(DEMAND)} + index = split_index(specs) + assert '| [fleet](fleet.md) | 0 | 1 | 0 | 1 | `injection` |' in index, 'two terms into one sum, listed once' + assert '[`demand_injection`](demand.md), [`curtailment`](fleet.md), [`generator_injection`](fleet.md)' in index, ( + 'every term of the fragment, by fragment then by name' + ) + with pytest.raises(ValueError, match=r"no fragment reads 'injection' with a description and adds nothing to it"): + split_index({'fleet': specs['fleet'], 'demand': specs['demand']}) diff --git a/tests/test_terms.py b/tests/test_terms.py index b5b7d56f..82311e43 100644 --- a/tests/test_terms.py +++ b/tests/test_terms.py @@ -5,11 +5,11 @@ """A named expression several files add terms to. A balance reads what every component puts into a bus, and a component file -says what it puts there: a named expression of its own, which the `term:` on -its `given: expressions:` entry names. The file reads the name as the whole -sum, alone and composed. `merge` defines the name as the definition one -fragment writes, if any, plus every term by name, and keeps each term, so -nothing has to declare that the name is a sum. +says what it puts there: a named expression of its own, whose `adds_to:` names +the `given: expressions:` entry of its file it writes into. The given entry is +the read, and `adds_to:` the write. `merge` writes every term by name after +the body a fragment defines, or as the whole body where none does, and keeps +each term, so no file has to declare that the name is a sum. """ from __future__ import annotations @@ -28,19 +28,19 @@ typeset_declaration, ) from mathspec.canonical import canonical_yaml -from mathspec.program import Named, Variable, walk -from tests.fixtures import BALANCE, BUS_DIMS, BUS_FRAME, INJECTION, NETWORK +from tests.fixtures import BALANCE, BUS_DIMS, BUS_FRAME, INJECTION #: A generator fleet: what it puts in is its term. FLEET = { 'dimensions': {**BUS_DIMS, 'generator': {'dtype': 'str'}}, 'relations': {'gen_bus': {'key': 'generator', 'values': 'bus'}}, 'variables': {'gen_p': {'dims': ['snapshot', 'generator'], 'bounds': {'lower': 0}}}, - 'given': {'expressions': {'injection': {'dims': BUS_FRAME, 'term': 'generator_injection'}}}, + 'given': {'expressions': {'injection': {'dims': BUS_FRAME}}}, 'expressions': { 'generator_injection': { 'expression': 'sum(gen_p, by=gen_bus, over=generator, into=bus)', 'description': 'what the generators put in', + 'adds_to': 'injection', } }, 'objective': {'sense': 'minimize', 'expression': 'sum(gen_p)'}, @@ -50,8 +50,8 @@ DEMAND = { 'dimensions': BUS_DIMS, 'parameters': {'load': {'dims': BUS_FRAME}}, - 'given': {'expressions': {'injection': {'dims': BUS_FRAME, 'term': 'demand_injection'}}}, - 'expressions': {'demand_injection': '-load'}, + 'given': {'expressions': {'injection': {'dims': BUS_FRAME}}}, + 'expressions': {'demand_injection': {'expression': '-load', 'adds_to': 'injection'}}, } #: A store, added in a second merge. @@ -59,8 +59,10 @@ 'dimensions': {**BUS_DIMS, 'store': {'dtype': 'str'}}, 'relations': {'store_bus': {'key': 'store', 'values': 'bus'}}, 'variables': {'store_p': {'dims': ['snapshot', 'store']}}, - 'given': {'expressions': {'injection': {'dims': BUS_FRAME, 'term': 'store_injection'}}}, - 'expressions': {'store_injection': 'sum(store_p, by=store_bus, over=store, into=bus)'}, + 'given': {'expressions': {'injection': {'dims': BUS_FRAME}}}, + 'expressions': { + 'store_injection': {'expression': 'sum(store_p, by=store_bus, over=store, into=bus)', 'adds_to': 'injection'} + }, } #: A network that defines the injection with a body of its own, its slack, and reads it. @@ -71,13 +73,26 @@ 'constraints': {'balance': {'dims': BUS_FRAME, 'expression': 'injection == 0'}}, } +#: The same network with its slack written as a term of the injection it reads. +SLACK_TERM = { + 'dimensions': BUS_DIMS, + 'variables': {'slack': {'dims': BUS_FRAME}}, + 'given': {'expressions': {'injection': {'dims': BUS_FRAME, 'description': INJECTION}}}, + 'expressions': {'slack_injection': {'expression': 'slack', 'adds_to': 'injection'}}, + 'constraints': {'balance': {'dims': BUS_FRAME, 'expression': 'injection == 0'}}, +} -def _demand(term: str = 'demand_injection', body: object = '-load', **fields: object) -> dict[str, object]: - """A demand whose `injection` entry names *term*, with *body* as `demand_injection` and *fields* on the entry.""" +#: A fleet that adds to the injection and caps it in its own math. +CAPPED = {**FLEET, 'constraints': {'capped': {'dims': BUS_FRAME, 'expression': 'injection <= 10'}}} + + +def _demand(target: str = 'injection', body: object = '-load', dims: list[str] = BUS_FRAME) -> dict[str, object]: + """A demand whose `demand_injection` is *body* and adds to *target*, reading `injection` over *dims*.""" + term = {**body, 'adds_to': target} if isinstance(body, dict) else {'expression': body, 'adds_to': target} return { **DEMAND, - 'given': {'expressions': {'injection': {'dims': BUS_FRAME, 'term': term, **fields}}}, - 'expressions': {'demand_injection': body}, + 'given': {'expressions': {'injection': {'dims': dims}}}, + 'expressions': {'demand_injection': term}, } @@ -86,97 +101,27 @@ def _demand(term: str = 'demand_injection', body: object = '-load', **fields: ob # --------------------------------------------------------------------------- -def test_an_empty_sum_loads_alone_and_reads_as_a_column(): - """The owner declares the name `empty: true`; alone, its math reads a column nothing defines yet.""" - program = to_spec(NETWORK).program - assert 'injection' not in program.expressions, 'no body, so no definition' - sum_ = program.given.expressions['injection'] - assert sum_.empty and sum_.dims == ('snapshot', 'bus') and sum_.description == INJECTION - assert program.constraints['balance'].dims == ('snapshot', 'bus'), 'the row reads it over the frame' - - -@pytest.mark.parametrize( - ('entry', 'message'), - [ - pytest.param({'empty': True}, r'`empty: true` needs a `dims:`', id='no-frame'), - pytest.param( - {'dims': BUS_FRAME}, - r'this has neither.*A sum other files add every term to is written `empty: true`, over a `dims:`', - id='a-frame-and-no-body', - ), - pytest.param( - {'dims': BUS_FRAME, 'empty': False}, - r'this has neither.*written `empty: true`', - id='empty-false-and-no-body', - ), - pytest.param( - {'dims': BUS_FRAME, 'empty': True, 'expression': '0'}, - r'`empty: true` is a sum with no body of its own, and this also has `expression:`', - id='beside-a-body', - ), - pytest.param( - {'dims': BUS_FRAME, 'empty': True, 'cases': {'a': {'when': 'load > 0', 'expression': '1'}}, 'otherwise': 0}, - r'this also has `cases:` and `otherwise:`', - id='beside-cases', - ), - ], -) -def test_an_empty_sum_is_written_as_empty_true_over_a_frame(entry, message): - """A frame with no body once loaded as an empty sum, so a forgotten body went unnoticed.""" - spec = {**NETWORK, 'parameters': {'load': {'dims': BUS_FRAME}}, 'expressions': {'injection': entry}} - with pytest.raises(LanguageError, match=message): - to_spec(spec) - - -def test_empty_false_is_the_default_and_is_not_written(): - entry = SLACKED['expressions']['injection'] - written = to_spec({**SLACKED, 'expressions': {'injection': {**entry, 'empty': False}}}) - assert written.to_dict()['expressions']['injection'] == entry, 'the default is not written back' - - -def test_an_empty_sum_round_trips(): - assert to_spec(NETWORK).to_dict()['expressions']['injection'] == { - 'dims': BUS_FRAME, - 'empty': True, - 'description': INJECTION, - }, 'the flag is written back, and nothing else is added' - assert to_spec(to_spec(NETWORK).to_yaml()) == to_spec(NETWORK) - - -def test_a_patch_empties_a_definition(): - """A body is dropped by a null and the sum marked by the flag, so neither spelling means two things.""" - slacked = override( - SLACKED, [{'expressions': {'injection': {'expression': None, 'empty': True, 'dims': BUS_FRAME}}}] - ) - assert slacked.expressions['injection'].empty - assert slacked.program.given.expressions['injection'].empty - - -def test_the_advice_says_other_files_fill_the_sum(): - (note,) = [note for note in advice(NETWORK) if note.kind == 'given'] - assert note.subject == 'injection' - assert 'a sum this file declares and other files add terms to' in note.text - assert 'merge()' in note.text +def test_a_contributor_loads_alone_and_its_term_is_its_named_expression(): + program = to_spec(DEMAND).program + assert program.expressions['demand_injection'].adds_to == 'injection' + assert program.given.expressions['injection'].dims == ('snapshot', 'bus'), 'the file reads what it adds to' -def test_a_contributor_loads_alone_and_its_term_is_its_named_expression(): - program = to_spec(FLEET).program - term = program.given.expressions['injection'].term - assert isinstance(term, Named) - assert term.name == 'generator_injection' - assert any(isinstance(node, Variable) and node.name == 'gen_p' for node in walk(term)), 'resolved in its own file' +def test_a_term_round_trips_with_what_it_adds_to(): + written = to_spec(DEMAND).to_dict()['expressions']['demand_injection'] + assert written == {'expression': '-load', 'adds_to': 'injection'}, 'a term is written as a mapping to carry it' + assert to_spec(to_spec(DEMAND).to_yaml()) == to_spec(DEMAND) def test_a_term_is_read_by_the_math(): - """The sum it lands in is read by a constraint, so it is held to what the math admits.""" + """A sum it lands in is read by a constraint somewhere, so it is held to what the math admits.""" assert to_spec(FLEET).program.expressions['generator_injection'].in_math def test_a_contributor_reads_the_name_as_the_whole_sum(): """Alone and composed the file reads one thing, so nothing has to refuse a file that adds and reads.""" - reads = {**FLEET, 'constraints': {'capped': {'dims': BUS_FRAME, 'expression': 'injection <= 10'}}} - assert to_spec(reads).program.constraints['capped'].dims == ('snapshot', 'bus') - composed = merge([NETWORK, reads, DEMAND]) + assert to_spec(CAPPED).program.constraints['capped'].dims == ('snapshot', 'bus') + composed = merge([BALANCE, CAPPED, DEMAND]) assert composed.constraints['capped'].expression == 'injection <= 10' assert composed.program.expressions['injection'].in_math, 'composed, the cap reads the sum of every term' @@ -186,48 +131,56 @@ def test_a_contributor_reads_the_name_as_the_whole_sum(): [ pytest.param( _demand(dims=['bus']), - r"Given expression 'injection': its term carries \['snapshot'\], which its dims \['bus'\] do not", + r"Named expression 'demand_injection': it adds to 'injection' over \['snapshot'\], which the given " + r"entry's dims \['bus'\] do not name", id='a-term-wider-than-the-entry', ), pytest.param( _demand(body='injection - load'), - r"Given expression 'injection': its term 'demand_injection' reads 'injection', the sum the term adds to", - id='a-term-reading-the-sum-through-its-name', + r"Named expression 'demand_injection': it reads 'injection', the sum it adds to", + id='a-term-reading-the-sum', + ), + pytest.param( + _demand(target='injecton'), + r"it adds to 'injecton', which this file does not read under 'given: expressions:'.*" + r"Did you mean 'injection'\?", + id='a-mistyped-target', ), pytest.param( { **DEMAND, - 'given': { - 'expressions': { - 'injection': {'dims': BUS_FRAME, 'term': 'demand_injection'}, - 'withdrawal': {'dims': BUS_FRAME, 'term': 'demand_withdrawal'}, - } + 'given': {'expressions': {'injection': {'dims': BUS_FRAME}, 'withdrawal': {'dims': BUS_FRAME}}}, + 'expressions': { + 'demand_injection': {'expression': 'withdrawal - load', 'adds_to': 'injection'}, + 'demand_withdrawal': {'expression': 'injection + load', 'adds_to': 'withdrawal'}, }, - 'expressions': {'demand_injection': 'withdrawal - load', 'demand_withdrawal': 'injection + load'}, }, - r"Given expression 'injection': its term 'demand_injection' reads 'injection', the sum the term adds " - r"to, through 'withdrawal'", + r"Named expression 'demand_injection': it reads 'injection', the sum it adds to, through 'withdrawal'", id='two-terms-reading-each-other-s-sum', ), pytest.param( - _demand(term='-load'), - r"Given expression 'injection': its term '-load' is no expression this file declares", - id='a-term-written-inline', + {**_demand(target='total'), 'expressions': {**_demand(target='total')['expressions'], 'total': '-load'}}, + r"it adds to 'total', which this file defines\. A file writes its own body in one place, so a term fills only " + r"a name read under 'given: expressions:': write the term into the body of 'total'", + id='a-target-this-file-defines', ), pytest.param( - _demand(term='demand_injecton'), - r"its term 'demand_injecton' is no expression.*Did you mean 'demand_injection'\?", - id='a-mistyped-term', - ), - pytest.param( - {**FLEET, 'expressions': {'generator_injection': 'sum(gen_p * gen_p * gen_p, over=generator)'}}, - r"Given expression 'injection'.*degree", + { + **FLEET, + 'expressions': { + 'generator_injection': { + 'expression': 'sum(gen_p * gen_p * gen_p, over=generator)', + 'adds_to': 'injection', + } + }, + }, + r"'generator_injection'.*degree", id='a-term-of-degree-three', ), pytest.param( {**DEMAND, 'expressions': {**DEMAND['expressions'], 'injection': '0'}}, r"Given expression 'injection' collides with the named expression", - id='a-term-beside-a-definition', + id='a-reading-beside-a-definition', ), ], ) @@ -236,25 +189,35 @@ def test_what_a_term_may_not_be_is_refused_at_load(spec, message): to_spec(spec) +def test_a_frame_with_no_body_names_where_a_sum_is_read(): + """A sum no file defines is read under `given:`, so a bodiless entry is refused with that rewrite.""" + spec = {**BALANCE, 'given': {}, 'expressions': {'injection': {'dims': BUS_FRAME}}} + with pytest.raises(LanguageError, match=r"this has neither.*read under 'given: expressions:'.*`adds_to:`"): + to_spec(spec) + + def test_a_term_may_read_another_sum_its_file_adds_to(): """A read of another sum makes no loop until that sum's term reads back.""" spec = { **DEMAND, - 'given': { - 'expressions': { - 'injection': {'dims': BUS_FRAME, 'term': 'demand_injection'}, - 'withdrawal': {'dims': BUS_FRAME, 'term': 'demand_withdrawal'}, - } + 'given': {'expressions': {'injection': {'dims': BUS_FRAME}, 'withdrawal': {'dims': BUS_FRAME}}}, + 'expressions': { + 'demand_injection': {'expression': 'withdrawal - load', 'adds_to': 'injection'}, + 'demand_withdrawal': {'expression': 'load', 'adds_to': 'withdrawal'}, }, - 'expressions': {'demand_injection': 'withdrawal - load', 'demand_withdrawal': 'load'}, } - assert to_spec(spec).program.given.expressions['injection'].term is not None + assert to_spec(spec).program.expressions['demand_injection'].adds_to == 'injection' def test_a_term_may_be_quadratic(): """A term is held to what an objective or a constraint admits, since one of them reads the sum.""" - square = {**FLEET, 'expressions': {'generator_injection': 'sum(gen_p * gen_p, over=generator)'}} - assert to_spec(square).program.given.expressions['injection'].term is not None + square = { + **FLEET, + 'expressions': { + 'generator_injection': {'expression': 'sum(gen_p * gen_p, over=generator)', 'adds_to': 'injection'} + }, + } + assert to_spec(square).program.expressions['generator_injection'].adds_to == 'injection' def test_the_advice_says_the_file_adds_a_term(): @@ -270,65 +233,183 @@ def test_the_advice_says_the_file_adds_a_term(): def test_merging_adds_the_terms_by_name_in_the_order_given_and_keeps_them(): - composed = merge([FLEET, DEMAND, NETWORK]) + composed = merge([FLEET, DEMAND, BALANCE]) assert composed.expressions['injection'].expression == 'generator_injection + demand_injection' assert composed.expressions['demand_injection'].expression == '-load', 'each term stays a named expression' + assert composed.expressions['demand_injection'].adds_to is None, 'the body now says what the term adds to' assert composed.program.expressions['generator_injection'].description == 'what the generators put in' - assert not composed.given, 'every reading is folded into the definition' + assert not composed.given, 'every reading is folded into the definition, so the spec is fully defined' def test_the_order_the_fragments_are_given_in_reaches_no_canonical_text(): """The order sorts the terms of the sum, and the canonical form sorts them again.""" - one = merge([FLEET, DEMAND, NETWORK]) - other = merge([NETWORK, DEMAND, FLEET]) + one = merge([FLEET, DEMAND, BALANCE]) + other = merge([BALANCE, DEMAND, FLEET]) assert canonical_yaml(one) == canonical_yaml(other) -def test_a_term_is_added_to_the_definition_one_fragment_writes(): - composed = merge([SLACKED, FLEET, DEMAND]) - assert composed.expressions['injection'].expression == 'slack + generator_injection + demand_injection' - assert composed.expressions['injection'].description == 'the slack, and what the components add', ( - 'the definition keeps its own description' +@pytest.mark.parametrize( + ('definer', 'body'), + [ + pytest.param(SLACKED, 'slack + store_injection', id='a-definition-a-file-writes'), + pytest.param( + merge([BALANCE, DEMAND, FLEET]), + 'demand_injection + generator_injection + store_injection', + id='a-sum-a-merge-wrote', + ), + ], +) +def test_a_term_follows_the_body_a_fragment_defines(definer, body): + """A term on a name a fragment defined was refused, so a merged spec took no further term.""" + composed = merge([definer, STORAGE]) + assert composed.expressions['injection'].expression == body + assert composed.expressions['store_injection'].adds_to is None + + +def test_the_definer_keeps_its_description(): + assert ( + merge([SLACKED, STORAGE]).expressions['injection'].description + == SLACKED['expressions']['injection']['description'] ) -def test_the_file_that_defines_the_name_reads_the_extended_sum_once_composed(): - """A contributor decides alone. The defining file does not opt in, and whoever composes answers for the sum.""" - assert to_spec(SLACKED).expressions['injection'].expression == 'slack' - composed = merge([SLACKED, DEMAND]) - assert composed.constraints['balance'].expression == 'injection == 0' - assert composed.expressions['injection'].expression == 'slack + demand_injection' +@pytest.mark.parametrize( + ('stepped', 'flat', 'body'), + [ + pytest.param( + merge([merge([BALANCE, DEMAND, FLEET]), STORAGE]), + merge([BALANCE, DEMAND, FLEET, STORAGE]), + 'demand_injection + generator_injection + store_injection', + id='the-last-file-later', + ), + pytest.param( + merge([merge([BALANCE]), DEMAND, FLEET, STORAGE]), + merge([BALANCE, DEMAND, FLEET, STORAGE]), + 'demand_injection + generator_injection + store_injection', + id='the-reader-alone-first', + ), + pytest.param( + merge([merge([merge([BALANCE, FLEET]), DEMAND]), STORAGE]), + merge([BALANCE, FLEET, DEMAND, STORAGE]), + 'generator_injection + demand_injection + store_injection', + id='three-steps', + ), + pytest.param( + merge([merge([SLACKED, FLEET]), STORAGE]), + merge([SLACKED, FLEET, STORAGE]), + 'slack + generator_injection + store_injection', + id='a-body-a-file-defines', + ), + ], +) +def test_a_sum_merged_in_steps_is_the_sum_merged_in_one(stepped, flat, body): + """The terms join with a plain `+`, so no bracket marks which merge wrote which term. + The canonical form reads `(a + b) + c` as `a + b + c`, so only the + written body tells the plain join from a bracketed one. + """ + assert stepped.expressions['injection'].expression == body + assert flat.expressions['injection'].expression == body + assert canonical_yaml(stepped) == canonical_yaml(flat) -def test_a_definition_that_is_more_than_a_name_is_bracketed(): - network = {**SLACKED, 'expressions': {'injection': 'slack - slack / 2'}} - composed = merge([network, DEMAND]) - assert composed.expressions['injection'].expression == '(slack - slack / 2) + demand_injection' +def test_a_step_where_no_other_file_reads_the_sum_is_refused(): + """A merge closes every sum it has terms for, so the step that first closes one needs a reader.""" + with pytest.raises(LanguageError, match=r"add a term to 'injection', and no other fragment reads it"): + merge([merge([FLEET, DEMAND]), BALANCE]) -def test_the_sum_takes_the_readers_description(): - composed = merge([FLEET, DEMAND, NETWORK]) - assert composed.program.expressions['injection'].description == INJECTION + +def test_a_term_on_a_cased_definition_is_refused(): + cased = { + **SLACKED, + 'parameters': {'on': {'dims': BUS_FRAME, 'dtype': 'bool'}}, + 'expressions': { + 'injection': {'dims': BUS_FRAME, 'cases': {'on': {'when': 'on', 'expression': 'slack'}}, 'otherwise': '0'} + }, + } + with pytest.raises( + LanguageError, + match=r"fragment '#1' defines 'injection' as `cases:`, and fragment '#2' adds a term to it\. " + r".*name the cased body as its own expression, and define 'injection' as that name\.", + ): + merge([cased, STORAGE]) -def test_two_readers_that_word_the_sum_apart_give_it_the_owner_s_wording(): - """The sum once took the wording of whichever reader was passed first.""" - capped = {**BALANCE, 'given': {'expressions': {'injection': {'dims': BUS_FRAME, 'description': 'a cap'}}}} - capped = {**capped, 'constraints': {'capped': {'dims': BUS_FRAME, 'expression': 'injection <= 10'}}} - for fragments in ( - [capped, NETWORK, FLEET], - [NETWORK, capped, FLEET], +def test_a_reader_that_states_less_than_the_definer_is_refused(): + narrow = { + **DEMAND, + 'parameters': {'load': {'dims': ['bus']}}, + 'given': {'expressions': {'injection': {'dims': ['bus']}}}, + } + with pytest.raises( + LanguageError, match=r"fragment '#2' reads the given expression 'injection'.*over \['bus', 'snapshot'\]" ): - assert merge(fragments).expressions['injection'].description == INJECTION, 'the wording of the network' + merge([SLACKED, narrow]) -def test_a_composed_spec_takes_more_terms_in_a_second_merge(): - """A composed definition is one a fragment wrote, so a later term adds to it like any other.""" - shipped = merge([NETWORK, DEMAND, FLEET]) - extended = merge([shipped, STORAGE]) - assert extended.expressions['injection'].expression == ( - '(demand_injection + generator_injection) + store_injection' - ) +#: A file that reads the injection and defines a name from it. +DOUBLED = {**BALANCE, 'expressions': {'doubled': {'expression': 'injection * 2'}}} + +#: A term that reads what the injection reads. +FEEDBACK = { + 'dimensions': BUS_DIMS, + 'given': {'expressions': {'injection': {'dims': BUS_FRAME}, 'doubled': {'dims': BUS_FRAME}}}, + 'expressions': {'feedback': {'expression': 'doubled', 'adds_to': 'injection'}}, +} + + +def _adding(term: str, body: str, target: str, reads: str) -> dict[str, object]: + """A file whose *term* is *body* and adds to *target*, reading *target* and *reads*.""" + return { + 'dimensions': BUS_DIMS, + 'given': {'expressions': {target: {'dims': BUS_FRAME}, reads: {'dims': BUS_FRAME}}}, + 'expressions': {term: {'expression': body, 'adds_to': target}}, + } + + +@pytest.mark.parametrize( + ('fragments', 'message'), + [ + pytest.param( + [DOUBLED, FEEDBACK], + r"fragment '#2' adds 'feedback' to 'injection', and 'feedback' reads 'injection' back through " + r"'doubled' of '#1', so the sum would define itself\. .*define 'doubled' without 'injection'\.", + id='through-a-definition', + ), + pytest.param( + [ + BALANCE, + _adding('outflow', 'withdrawal', 'injection', 'withdrawal'), + _adding('inflow', 'injection', 'withdrawal', 'injection'), + ], + r"fragment '#2' adds 'outflow' to 'injection', and 'outflow' reads 'injection' back through " + r"the sum 'withdrawal', 'inflow' of '#3'", + id='through-another-sum', + ), + ], +) +def test_a_term_that_reads_its_sum_through_another_fragment_is_refused(fragments, message): + """Each file loads alone, and the composed load named the loop but not the fragments that close it.""" + with pytest.raises(LanguageError, match=message): + merge(fragments) + + +def test_a_file_adds_its_own_part_as_a_term_of_what_it_reads(): + """What a definition once carried, the slack, is a term of the network's own reading.""" + composed = merge([SLACK_TERM, FLEET, DEMAND]) + assert composed.expressions['injection'].expression == 'slack_injection + generator_injection + demand_injection' + assert composed.expressions['injection'].description == INJECTION + + +def test_the_sum_takes_the_readers_description(): + composed = merge([FLEET, DEMAND, BALANCE]) + assert composed.program.expressions['injection'].description == INJECTION + + +def test_two_readers_that_word_the_sum_apart_give_it_the_first_wording(): + capped = {**CAPPED, 'given': {'expressions': {'injection': {'dims': BUS_FRAME, 'description': 'a cap'}}}} + assert merge([capped, BALANCE]).expressions['injection'].description == 'a cap' + assert merge([BALANCE, capped]).expressions['injection'].description == INJECTION def test_a_cased_term_is_added_like_any_other(): @@ -340,7 +421,7 @@ def test_a_cased_term_is_added_like_any_other(): 'otherwise': '0', } ) - composed = merge([NETWORK, cased, FLEET]) + composed = merge([BALANCE, cased, FLEET]) assert composed.expressions['injection'].expression == 'demand_injection + generator_injection' assert composed.program.expressions['demand_injection'].in_math @@ -350,47 +431,118 @@ def test_a_cased_term_is_added_like_any_other(): [ pytest.param( [FLEET, DEMAND], - r"fragments '#1' and '#2' add a term to 'injection', which no fragment declares\. " - r'.*or fix the spelling\.$', + r"fragments '#1' and '#2' add a term to 'injection', and no other fragment reads it: .*" + r"or fix the spelling under 'given:'\.$", id='terms-and-nothing-else-with-no-near-miss', ), pytest.param( - [NETWORK, {**FLEET, 'given': {'expressions': {'injecton': FLEET['given']['expressions']['injection']}}}], - r"fragment '#2' adds a term to 'injecton', which no fragment.*Did you mean 'injection'\?", - id='a-mistyped-name', + [ + BALANCE, + { + **FLEET, + 'given': {'expressions': {'injecton': {'dims': BUS_FRAME}}}, + 'expressions': { + 'generator_injection': {**FLEET['expressions']['generator_injection'], 'adds_to': 'injecton'} + }, + }, + ], + r"fragment '#2' adds a term to 'injecton', and no other fragment reads it.*Did you mean 'injection'\?", + id='a-misspelt-given-entry', + ), + pytest.param( + [ + BALANCE, + { + **FLEET, + 'given': {'expressions': {'injecton': {'dims': BUS_FRAME}}}, + 'expressions': { + 'generator_injection': {**FLEET['expressions']['generator_injection'], 'adds_to': 'injecton'}, + 'twice': 'injecton * 2', + }, + }, + ], + r"fragment '#2' adds a term to 'injecton', and no other fragment reads it.*Did you mean 'injection'\?", + id='a-misspelt-given-entry-the-same-file-reports', + ), + pytest.param( + [ + BALANCE, + { + **CAPPED, + 'given': {'expressions': {'injecton': {'dims': BUS_FRAME}}}, + 'expressions': { + 'generator_injection': {**FLEET['expressions']['generator_injection'], 'adds_to': 'injecton'} + }, + 'constraints': {'capped': {'dims': BUS_FRAME, 'expression': 'injecton <= 10'}}, + }, + ], + r"fragment '#2' adds a term to 'injecton', and no other fragment reads it.*Did you mean 'injection'\?", + id='a-misspelt-given-entry-the-same-file-caps', ), ], ) -def test_terms_that_land_on_no_name_are_refused(fragments, message): - """Merge fills or extends a block a file declared; it never invents a name, which is what a typo would ask for.""" +def test_terms_only_their_own_files_read_are_refused(fragments, message): + """A misspelt `given:` entry loads in its own file, and is refused where nothing else reads the name. + + A reported expression builds no row, so reading the name there is not + the use in the math the refusal asks for: `_uses` counted it, and a + contributor that reported its misspelt sum passed the check. A + contributor that capped its misspelt sum in its own math passed too, + since its use counted as a read though no other fragment read the name. + """ with pytest.raises(LanguageError, match=message): merge(fragments) -def test_a_reading_is_no_place_for_a_term_to_land(): - """A file that reads the name, or uses it in its own math, has not declared it; only an `expressions:` block has.""" - capped = {**FLEET, 'constraints': {'capped': {'dims': BUS_FRAME, 'expression': 'injection <= 10'}}} - for fragments in ([capped, DEMAND], [BALANCE, DEMAND]): - with pytest.raises(LanguageError, match=r'which no fragment declares'): - merge(fragments) +@pytest.mark.parametrize( + ('reader', 'contributor'), + [ + pytest.param(BALANCE, FLEET, id='a-fragment-that-reads-and-adds-nothing'), + pytest.param(SLACK_TERM, DEMAND, id='a-contributor-whose-math-reads-it'), + pytest.param(CAPPED, DEMAND, id='a-contributor-whose-own-constraint-reads-it'), + ], +) +def test_a_fragment_that_reads_the_sum_for_more_than_adding_lets_the_terms_land(reader, contributor): + assert merge([reader, contributor]).program.expressions['injection'].in_math -def test_the_composed_sum_keeps_the_owner_s_frame(): - """The frame the owner declared holds the terms to it when the composed spec loads.""" - composed = merge([NETWORK, FLEET, DEMAND]) - assert composed.expressions['injection'].dims == BUS_FRAME - narrow = {**NETWORK, 'expressions': {'injection': {'dims': ['bus'], 'empty': True, 'description': INJECTION}}} - narrow = {**narrow, 'constraints': {'balance': {'dims': ['bus'], 'expression': 'injection == 0'}}} - with pytest.raises(LanguageError, match=r"the body carries dims \['snapshot'\] outside the dims: \['bus'\]"): - merge([narrow, FLEET]) +def test_a_sum_a_sibling_declares_as_another_kind_names_that_kind(): + """A term on a name a sibling declares as a variable was refused as a misspelling nothing else reads.""" + network = { + 'dimensions': BUS_DIMS, + 'variables': {'injection': {'dims': BUS_FRAME}}, + 'constraints': {'balance': {'dims': BUS_FRAME, 'expression': 'injection == 0'}}, + } + with pytest.raises( + LanguageError, + match=r"fragment '#1' declares 'injection' as a variable, and fragment '#2' adds a term to it\. " + r'A term adds to a named expression', + ): + merge([network, DEMAND]) + + +def test_readers_that_order_the_frame_apart_are_refused(): + """The sum took its dims from the first reader, so the order of the list reached the canonical text.""" + reversed_ = {**BALANCE, 'given': {'expressions': {'injection': {'dims': ['bus', 'snapshot']}}}} + with pytest.raises( + LanguageError, + match=r"fragments '#1' and '#2' read the sum 'injection' over \['bus', 'snapshot'\] and " + r"\['snapshot', 'bus'\]", + ): + merge([reversed_, FLEET]) + + +def test_the_composed_sum_is_read_over_the_readers_frame(): + """The frame the readers state holds the terms to it when the composed spec loads.""" + assert merge([BALANCE, FLEET, DEMAND]).expressions['injection'].dims == BUS_FRAME def test_one_term_alone_is_its_name(): - composed = merge([NETWORK, STORAGE]) + composed = merge([BALANCE, STORAGE]) assert composed.expressions['injection'].expression == 'store_injection' -def test_two_definitions_collide_and_the_message_names_the_term(): +def test_two_definitions_collide_and_the_message_names_adds_to(): other = { 'dimensions': BUS_DIMS, 'variables': {'other_slack': {'dims': BUS_FRAME}}, @@ -401,31 +553,18 @@ def test_two_definitions_collide_and_the_message_names_the_term(): merge([SLACKED, other]) message = str(raised.value) assert "both declare the expression 'injection'" in message - assert '`term:` under `given: expressions:`' in message + assert "defined by one of them at most: each other reads it under 'given: expressions:'" in message def test_two_terms_of_one_name_collide(): """A term is an ordinary named expression, so two fragments name theirs apart.""" twin = {**FLEET, 'expressions': {'demand_injection': FLEET['expressions']['generator_injection']}} - twin = {**twin, 'given': {'expressions': {'injection': {'dims': BUS_FRAME, 'term': 'demand_injection'}}}} with pytest.raises( LanguageError, match=r"both declare the expression 'demand_injection', which a fragment adds to 'injection' as a term\. " r"A term shares one namespace.*name each fragment's term apart", ): - merge([NETWORK, DEMAND, twin]) - - -def test_a_cased_definition_a_term_adds_to_is_refused(): - cased = { - **SLACKED, - 'parameters': {'on': {'dims': BUS_FRAME, 'dtype': 'bool'}}, - 'expressions': { - 'injection': {'dims': BUS_FRAME, 'cases': {'on': {'when': 'on', 'expression': 'slack'}}, 'otherwise': '0'} - }, - } - with pytest.raises(LanguageError, match=r"'#1' defines 'injection' as `cases:`, and fragment '#2' adds"): - merge([cased, DEMAND]) + merge([BALANCE, DEMAND, twin]) def test_two_readers_that_disagree_about_the_frame_are_refused(): @@ -437,29 +576,33 @@ def test_two_readers_that_disagree_about_the_frame_are_refused(): def test_a_term_over_fewer_dimensions_merges_where_another_carries_the_rest(): flat = {**DEMAND, 'parameters': {'load': {'dims': ['bus']}}} - composed = merge([NETWORK, flat, FLEET]) + composed = merge([BALANCE, flat, FLEET]) assert composed.program.expressions['injection'].dims == ('snapshot', 'bus') -def test_a_definition_over_a_dimension_the_readers_do_not_state_is_refused(): - wide = { - **SLACKED, - 'dimensions': {**BUS_DIMS, 'carrier': {'dtype': 'str'}}, - 'variables': {'slack': {'dims': [*BUS_FRAME, 'carrier']}}, - } - wide = {**wide, 'constraints': {'balance': {'dims': [*BUS_FRAME, 'carrier'], 'expression': 'injection == 0'}}} - with pytest.raises( - LanguageError, - match=r"'#2' reads the given expression 'injection' as .*'#1' introduces it over \['bus', 'carrier', 'snapshot'\]", - ): - merge([wide, DEMAND]) +@pytest.mark.parametrize( + 'body', [pytest.param({'expression': '-2 * load'}, id='a-mapping'), pytest.param('-2 * load', id='one-line')] +) +def test_a_patch_changes_a_term_s_body_and_keeps_what_it_adds_to(body): + """A one-line patch replaced the whole block, so the term lost its `adds_to:` and left the sum unannounced.""" + doubled = override(DEMAND, [{'expressions': {'demand_injection': body}}]) + assert doubled.expressions['demand_injection'].expression == '-2 * load' + assert doubled.expressions['demand_injection'].adds_to == 'injection' -def test_a_patch_changes_a_term_by_its_name_and_null_drops_it(): - doubled = override(DEMAND, [{'expressions': {'demand_injection': '-2 * load'}}]) - assert doubled.expressions['demand_injection'].expression == '-2 * load' - reader = override(DEMAND, [{'given': {'expressions': {'injection': {'term': None}}}}]) - assert reader.given.expressions['injection'].term is None, 'the entry is a plain reading again' +def test_a_one_line_patch_replaces_a_cased_body(): + cased = _demand( + body={'dims': BUS_FRAME, 'cases': {'peak': {'when': 'load > 5', 'expression': '-load'}}, 'otherwise': '0'} + ) + laid = override(cased, [{'expressions': {'demand_injection': '-load'}}]) + assert laid.expressions['demand_injection'].cases == {}, 'the one-line body replaces the cases and the default' + assert laid.expressions['demand_injection'].otherwise is None + assert laid.expressions['demand_injection'].adds_to == 'injection' + + +def test_a_patch_drops_what_a_term_adds_to_with_null(): + reader = override(DEMAND, [{'expressions': {'demand_injection': {'adds_to': None}}}]) + assert reader.expressions['demand_injection'].adds_to is None, 'the entry is a plain named expression again' # --------------------------------------------------------------------------- @@ -483,16 +626,6 @@ def test_inlining_keeps_the_definition_of_a_term(): ) -def test_the_owner_s_sum_prints_as_a_definition_with_no_body(): - """The owner declares the name, so it prints under Definitions as ``symbol = ⋯``, and not under Given.""" - rendered = to_markdown(NETWORK) - assert '#### Given' not in rendered, 'the file declares the sum rather than reading one another file defines' - assert '`injection` over' in rendered.split('#### Definitions')[1] - assert typeset_declaration(NETWORK, 'injection', 'latex') == ( - r'\mathit{injection}_{t,b} = \cdots \qquad \forall\, t \in \mathcal{T},\ b \in \mathcal{B}' - ) - - @pytest.mark.parametrize('spec', [BALANCE, FLEET], ids=['a-reader', 'a-contributor']) def test_a_given_expression_prints_no_line_of_its_own(spec): """The term prints as the definition it is; the name it adds to prints in the legend.""" @@ -503,7 +636,7 @@ def test_a_given_expression_prints_no_line_of_its_own(spec): def test_the_composed_sum_prints_its_terms_by_name(): - composed = merge([FLEET, DEMAND, NETWORK]) + composed = merge([FLEET, DEMAND, BALANCE]) assert typeset_declaration(composed, 'injection', 'typst', inline_expressions=False) == ( 'italic("injection")_(t,b) = italic("generator_injection")_(t,b) + upright("demand_injection")_(t,b) ' 'quad forall t in cal(T), b in cal(B)' @@ -513,4 +646,4 @@ def test_the_composed_sum_prints_its_terms_by_name(): @pytest.mark.parametrize('fmt', sorted(FORMATS)) def test_a_contributor_and_a_composition_print_in_every_format(fmt): assert typeset(DEMAND, fmt), f'{fmt} rendered nothing for the contributor' - assert typeset(merge([FLEET, DEMAND, NETWORK]), fmt), f'{fmt}: the composition' + assert typeset(merge([FLEET, DEMAND, BALANCE]), fmt), f'{fmt}: the composition' diff --git a/tests/typesetting/test_golden.py b/tests/typesetting/test_golden.py index 9fd74179..06bdaac0 100644 --- a/tests/typesetting/test_golden.py +++ b/tests/typesetting/test_golden.py @@ -197,12 +197,9 @@ def test_the_golden_model_calls_every_operator_in_the_language(): #: `test_a_model_with_no_objective_prints_the_rest` covers it; that model #: declares no parameter, which is the legend's convention note with nothing #: to quote. A refusal of the name asked for renders nothing, and -#: `test_declaration.py` pins both. An empty sum draws advice until its terms -#: arrive, and `check` accepts the fixture in silence, so the fixture declares -#: none; `test_terms.py` prints one. +#: `test_declaration.py` pins both. UNREACHABLE = { walk: { - 'rendered = self.format.ellipsis', 'return self.format.cases(self._arms(node, ctx)), _ATOM', 'assert_never(node)', 'assert_never(check)', diff --git a/tools/gallery.py b/tools/gallery.py index 67d2df05..a6252f75 100644 --- a/tools/gallery.py +++ b/tools/gallery.py @@ -30,6 +30,7 @@ from tools.spec_math import OPERATORS, PROBES, _section, rendered_probe if TYPE_CHECKING: + from collections.abc import Mapping from pathlib import Path from mathspec.spec import Spec @@ -119,26 +120,45 @@ def fragment_block(path: Path, table_path: Path) -> str: def split_index_block() -> str: - """The PyPSA split's two tables: each sum with the fragment that declares it and the terms, and each fragment. + """The PyPSA split's two tables: each sum with the fragment that reads it and the terms, and each fragment. - Both are read off the fragments, so the index cannot name a term or an - owner the files no longer have. + Both are read off the fragments, so the index cannot name a term or a + reader the files no longer have. The reader is the fragment that reads + the sum with its description and adds nothing to it. """ - specs = {path.stem: to_spec(path) for path in sorted(PYPSA.glob('*.yaml'))} - owners: dict[str, tuple[str, tuple[str, ...]]] = {} - terms: dict[str, dict[str, str]] = {} + return split_index({path.stem: to_spec(path) for path in sorted(PYPSA.glob('*.yaml'))}) + + +def split_index(specs: Mapping[str, Spec]) -> str: + """The two tables for *specs*, each fragment under its name. + + Raises: + ValueError: A sum that no fragment reads with a description while + adding nothing to it, so the index has no reader to name. + """ + readers: dict[str, tuple[str, tuple[str, ...]]] = {} + terms: dict[str, list[tuple[str, str]]] = {} for name, spec in specs.items(): + adds = {block.adds_to for block in spec.expressions.values() if block.adds_to is not None} + for term, block in spec.expressions.items(): + if block.adds_to is not None: + terms.setdefault(block.adds_to, []).append((name, term)) for hub, entry in spec.given.expressions.items(): - if entry.term is not None: - terms.setdefault(hub, {})[name] = entry.term - for hub, block in spec.expressions.items(): - if block.expression is None and not block.cases: - owners[hub] = (name, tuple(block.dims or ())) - sums = ['| Sum | Over | Declared in | The terms, by the fragment that adds each |', '| --- | --- | --- | --- |'] + if entry.description and hub not in adds: + readers[hub] = (name, tuple(entry.dims)) + if unread := sorted(set(terms) - set(readers)): + spelled = ', '.join(f'{hub!r}' for hub in unread) + msg = ( + f'no fragment reads {spelled} with a description and adds nothing to it, so the index has no reader ' + f"to name: describe each under 'given: expressions:' in the fragment that reads it." + ) + raise ValueError(msg) + sums = ['| Sum | Over | Read in | The terms, by the fragment that adds each |', '| --- | --- | --- | --- |'] for hub, by_fragment in sorted(terms.items(), key=lambda item: -len(item[1])): - reader, dims = owners[hub] - cells = ', '.join(f'[`{term}`]({fragment}.md)' for fragment, term in sorted(by_fragment.items())) - sums.append(f'| `{hub}` | `{", ".join(dims)}` | [{reader}]({reader}.md) | {cells} |') + reader, dims = readers[hub] + cells = ', '.join(f'[`{term}`]({fragment}.md)' for fragment, term in sorted(by_fragment)) + over = f'`{", ".join(dims)}`' if dims else 'scalar' + sums.append(f'| `{hub}` | {over} | [{reader}]({reader}.md) | {cells} |') files = [ '| Fragment | Parameters | Variables | Constraints | Reads | Adds to |', '| --- | --- | --- | --- | --- | --- |', @@ -146,7 +166,8 @@ def split_index_block() -> str: for name, spec in specs.items(): given = spec.given reads = len(given.parameters) + len(given.variables) + len(given.expressions) + len(given.constraints) - adds = ', '.join(f'`{hub}`' for hub, entry in given.expressions.items() if entry.term is not None) + hubs = dict.fromkeys(block.adds_to for block in spec.expressions.values() if block.adds_to is not None) + adds = ', '.join(f'`{hub}`' for hub in hubs) files.append( f'| [{name}]({name}.md) | {len(spec.parameters)} | {len(spec.variables)} | {len(spec.constraints)} ' f'| {reads} | {adds} |' diff --git a/tools/pypsa_split.py b/tools/pypsa_split.py index bff21d84..17639c0f 100644 --- a/tools/pypsa_split.py +++ b/tools/pypsa_split.py @@ -12,11 +12,13 @@ The one file writes each hub, a row or a named expression that every component adds its share to, as the sum of named terms: `Bus_injection` is `Generator_injection + … + Transformer_injection`, and `Bus_nodal_balance` -reads `Bus_injection == 0`. A fragment owns the declarations of its topic, its -terms among them, and reads what another topic declares under `given:`; the -entry for a hub names the fragment's term. One fragment declares each hub as -an empty sum, `empty: true` over its frame, so a term always lands, and a new -component is one new fragment. +reads `Bus_injection == 0`. The objective reads the hub `total_cost`. A +fragment owns the declarations of its topic, its terms among them, and reads +what another topic declares under `given:`; each term names the hub it adds to +with `adds_to:`, and the fragment reads that hub under `given:`. One fragment +reads each hub without adding to it, with its description, so the terms always +have a reader, and a new component is one new fragment. The reader of +`total_cost` also sets the objective. `merge` then writes each hub as the file does, so `check` is one comparison: the merged fragments and the one file have one canonical form. @@ -37,14 +39,11 @@ import yaml from mathspec import merge, to_spec -from mathspec._expression_parser import BinaryOperatorNode, NumberNode, UnaryOperatorNode, operand, parse_expression from mathspec.canonical import canonical_yaml from mathspec.errors import LanguageError if TYPE_CHECKING: - from collections.abc import Iterator, Mapping - - from mathspec._expression_parser import ArithmeticNode + from collections.abc import Mapping SOURCE = Path('examples/pypsa.yaml') SECTIONS = ('dimensions', 'relations', 'parameters', 'variables', 'expressions', 'constraints', 'assumptions') @@ -99,11 +98,12 @@ 'tech_capacity_expansion', 'transmission_volume_expansion', 'transmission_expansion_cost', + 'total_cost', ) -#: The fragment that declares a hub as an empty sum: the reader where a model -#: without it is never wanted, so the terms land nowhere without it, and -#: `settings` where the reader may go. +#: The fragment that reads a hub without adding to it, and carries its +#: description: the reader where a model without it is never wanted, so the +#: terms have no reader without it, and `settings` where the reader may go. SUM_HOME = {'Bus_injection': 'network', 'Cycle_angle_sum': 'power_flow'} #: The component each topic that adds a term is named after. @@ -112,7 +112,6 @@ } Key = tuple[str, str] -Term = tuple[str, 'ArithmeticNode'] #: The components with unit commitment, whose declarations are cut by feature. @@ -177,18 +176,6 @@ def _names_in(value: object, known: Mapping[str, str]) -> set[str]: return set() -def _signed_terms(node: ArithmeticNode, sign: str = '+') -> Iterator[Term]: - """*node* as a flat sum, each term with the sign it is written under, a minus carried into a bracket.""" - flip = {'+': '-', '-': '+'} - if isinstance(node, BinaryOperatorNode) and node.op in ('+', '-'): - yield from _signed_terms(node.left, sign) - yield from _signed_terms(node.right, sign if node.op == '+' else flip[sign]) - elif isinstance(node, UnaryOperatorNode) and node.op == '-': - yield from _signed_terms(node.operand, flip[sign]) - elif not (isinstance(node, NumberNode) and node.value == 0): - yield sign, node - - def _entry(block: object) -> dict[str, Any]: """A named expression as a mapping, however the file wrote it.""" return dict(block) if isinstance(block, dict) else {'expression': block} @@ -213,11 +200,7 @@ def __init__(self, path: Path = SOURCE) -> None: self.terms = {term: hub for hub, by_topic in self.shares.items() for term in by_topic.values()} frames = self.frames() self.sums = { - hub: { - 'dims': list(frames[hub]), - 'empty': True, - 'description': _entry(self.data['expressions'][hub]).get('description'), - } + hub: {'dims': list(frames[hub]), 'description': _entry(self.data['expressions'][hub]).get('description')} for hub in HUBS } @@ -242,7 +225,7 @@ def frames(self) -> dict[str, tuple[str, ...]]: return {name: e.dims for name, e in to_spec(self.data).program.expressions.items()} def home(self, name: str) -> str: - """The fragment that declares the hub *name* as an empty sum.""" + """The fragment that reads the hub *name* without adding to it, and carries its description.""" return SUM_HOME.get(name, 'settings') @@ -261,46 +244,29 @@ def fragments(model: Model) -> dict[str, str]: for by_topic in model.shares.values(): for name, term in by_topic.items(): owned[name].add(('expressions', term)) - terms = _objective_terms(model) homes = {model.home(hub) for hub in HUBS} written = {} - for name in sorted({*owned, *terms, *homes}): + for name in sorted({*owned, *homes}): mine = owned[name] adds = {hub: by_topic[name] for hub, by_topic in model.shares.items() if name in by_topic} homes_here = {hub for hub in HUBS if model.home(hub) == name} - read = set().union( - *(model.names_in(model.data[s][n]) for s, n in mine), *map(model.names_in, terms[name]), homes_here, adds - ) - given = {model.key(n) for n in read - homes_here if model.key(n)[0] not in FRAME and model.key(n) not in mine} + read = set().union(*(model.names_in(model.data[s][n]) for s, n in mine), homes_here, adds) + given = {model.key(n) for n in read if model.key(n)[0] not in FRAME and model.key(n) not in mine} stated = { **{n: model.data[s][n]['dims'] for s, n in given if s in ('parameters', 'variables')}, **{n: list(frames[n]) for s, n in given if s == 'expressions'}, } - declared_frames = [*stated.values(), *(model.sums[hub]['dims'] for hub in homes_here)] - frame_reads = read | set().union(*(model.names_in(dims) for dims in declared_frames)) + frame_reads = read | set().union(*(model.names_in(dims) for dims in stated.values())) frame = {model.key(n) for n in frame_reads if model.key(n)[0] in FRAME} frame |= {model.key(n) for key in list(frame) for n in model.names_in(model.data[key[0]][key[1]])} - written[name] = _fragment(model, mine | frame, given, stated, terms[name], adds, homes_here) + written[name] = _fragment(model, mine | frame, given, stated, adds, homes_here) return written -def _objective_terms(model: Model) -> dict[str, list[str]]: - """The objective's terms by topic, the operating cost and its tail with the risk rows.""" - terms: dict[str, list[str]] = collections.defaultdict(list) - for sign, node in _signed_terms(parse_expression(model.data['objective']['expression'])): - names = model.names_in(str(node)) - if 'CVaR_omega' in names: - owner = 'cost' - else: - owner = topic(next(n for n in sorted(names) if model.kind[n] == 'variables')) - terms[owner].append(str(node) if sign == '+' else f'-{operand(node)}') - return terms - - -def _dumped(name: str, block: Mapping[str, object]) -> str: +def _dumped(name: str, block: Mapping[str, object], indent: str = ' ') -> str: """A generated declaration, its long text folded one term per line.""" - lines = [f' {name}:'] + lines = [f'{indent}{name}:'] for field, value in block.items(): if value is None: continue @@ -310,10 +276,10 @@ def _dumped(name: str, block: Mapping[str, object]) -> str: if field == 'expression' else textwrap.wrap(value, 72, break_on_hyphens=False, break_long_words=False) ) - lines += [f' {field}: >-', *(f' {line}' for line in body)] + lines += [f'{indent} {field}: >-', *(f'{indent} {line}' for line in body)] else: dumped = yaml.safe_dump(value, default_flow_style=True, width=1000).removesuffix('\n...\n') - lines.append(f' {field}: {dumped.strip()}') + lines.append(f'{indent} {field}: {dumped.strip()}') return '\n'.join(lines) @@ -322,29 +288,32 @@ def _fragment( included: set[Key], given: set[Key], stated: Mapping[str, list[str]], - terms: list[str], adds: Mapping[str, str], homes: set[str], ) -> str: """One fragment as YAML text, its sections and declarations in the order of the source file. - A hub the fragment is home to is written under ``expressions:`` as an - empty sum, ``empty: true`` over its frame: the sum the other fragments fill. + A term carries the hub it adds to as ``adds_to:``. A hub the fragment is + home to is read under ``given:`` with its description, and the home of the + hub the objective reads writes the objective. """ parts = [HEADER] + hubs = {term: hub for hub, term in adds.items()} for section in SECTIONS: - blocks = [model.blocks[key] for key in model.keys if key[0] == section and key in included] - if section == 'expressions': - blocks += [_dumped(hub, model.sums[hub]) for hub in HUBS if hub in homes] + blocks = [ + _term_block(model.blocks[key], hubs[key[1]]) + if section == 'expressions' and key[1] in hubs + else model.blocks[key] + for key in model.keys + if key[0] == section and key in included + ] if blocks: parts.append(f'{section}:\n' + '\n'.join(blocks) + '\n') if section == 'variables' and given: - parts.append('given:\n' + ''.join(_given(model, kind, given, stated, adds) for kind in GIVEN_KINDS)) - if terms: - objective = model.data['objective'] - said = f' description: >-\n {objective["description"]}\n' if 'CVaR_omega' in ''.join(terms) else '' - joined = '\n + '.join(terms) - parts.append(f'objective:\n sense: minimize\n{said} expression: >-\n {joined}\n') + parts.append('given:\n' + ''.join(_given(model, kind, given, stated, homes) for kind in GIVEN_KINDS)) + objective = model.data['objective'] + if objective['expression'] in homes: + parts.append(_dumped('objective', objective, indent='') + '\n') return '\n'.join(parts) @@ -353,16 +322,31 @@ def _fragment( GIVEN_KINDS = {'parameters': ('dtype',), 'variables': ('domain',), 'expressions': ()} -def _given(model: Model, kind: str, given: set[Key], stated: Mapping[str, list[str]], adds: Mapping[str, str]) -> str: - """One kind of a fragment's `given:` block, an entry per line in source order, a term it adds named on its entry.""" +def _term_block(block: str, hub: str) -> str: + """A term's source block with the hub it adds to, every form the head line does not open reread as a mapping.""" + head, _, rest = block.partition('\n') + name, _, inline = head.partition(':') + if not (scalar := inline.strip()): + return f'{head}\n{rest}\n adds_to: {hub}' + if scalar[0] in '>|': + return f'{name}:\n expression: {scalar}\n{rest}\n adds_to: {hub}' + entry = yaml.safe_load(block)[name.strip()] + fields = {**entry, 'adds_to': hub} if isinstance(entry, dict) else {'expression': entry, 'adds_to': hub} + dumped = yaml.safe_dump({name.strip(): fields}, default_flow_style=False, sort_keys=False, width=10**6) + return textwrap.indent(dumped, ' ' * (len(head) - len(head.lstrip()))).rstrip() + + +def _given(model: Model, kind: str, given: set[Key], stated: Mapping[str, list[str]], homes: set[str]) -> str: + """One kind of a fragment's `given:` block, an entry per line in source order, a hub it is home to described.""" names = [n for s, n in model.keys if s == kind and (s, n) in given] if not names: return '' lines = [f' {kind}:'] for n in names: + if kind == 'expressions' and n in homes: + lines.append(_dumped(n, model.sums[n], indent=' ')) + continue extra = {f: _entry(model.data[kind][n])[f] for f in GIVEN_KINDS[kind] if f in _entry(model.data[kind][n])} - if kind == 'expressions' and n in adds: - extra['term'] = adds[n] fields = {'dims': stated[n], **extra} spelled = ', '.join(f'{k}: [{", ".join(v)}]' if isinstance(v, list) else f'{k}: {v}' for k, v in fields.items()) lines.append(f' {n}: {{ {spelled} }}')