diff --git a/CHANGELOG.md b/CHANGELOG.md index 331c495af..9aac0cfac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ it releases that version ([RELEASING.md](https://github.com/energy-models/mathsp ## Upcoming version +- feat(language): a divisor or a power over parameters may add, so a discount factor is written where it is used ([#757](https://github.com/energy-models/mathspec/pull/757)) - docs(language): a reported quotient is absent where its divisor is zero, whether a solve or the data gave that zero ([#765](https://github.com/energy-models/mathspec/pull/765)) - fix(language): a given declaration that leaves out its domain or dtype folds into an introducer that sets one ([#768](https://github.com/energy-models/mathspec/pull/768)) - fix(advice): an objective that reads a given expression or an empty sum gets advice rather than a KeyError ([#767](https://github.com/energy-models/mathspec/pull/767)) diff --git a/docs/examples/pypsa.md b/docs/examples/pypsa.md index fc081bd50..0fd2d1976 100644 --- a/docs/examples/pypsa.md +++ b/docs/examples/pypsa.md @@ -5023,7 +5023,7 @@ A plain `n.optimize()`, and its multi-period and stochastic classes, in one file | $`\mathrm{on}^{\mathrm{load}}`$ | `Load_active` over $`\mathcal{D}`$ — whether a load stands in the model — PyPSA's `active`. A load has no build year and no lifetime, so the flag holds in every snapshot. PyPSA refuses one that differs by scenario (`consistency.py:1195`) | | $`\pi`$ | `scenario_weight` over $`\Xi`$ — PyPSA's `scenario_weightings.weight` — the probability of a future | | $`\omega`$ | `CVaR_omega` (scalar) — PyPSA's `risk_preference['omega']` — the share of operating cost priced at the tail rather than in expectation; zero recovers the risk-neutral model | -| $`\mathrm{v}`$ | `CVaR_inv_tail` (scalar) — PyPSA's `1 / (1 - alpha)` — the tail's own probability, inverted in data prep because a divisor is one factor | +| $`\alpha`$ | `CVaR_alpha` (scalar) — PyPSA's `risk_preference['alpha']` — the confidence level; the tail holds the other `1 - alpha` of the probability | | $`\mathrm{w}^{y}`$ | `period_weight_objective` over $`\mathcal{Y}`$ — PyPSA's `investment_period_weightings.objective` — what a period's cost weighs | | $`\mathrm{w}^{\mathrm{yr}}`$ | `period_weight_years` over $`\mathcal{Y}`$ — PyPSA's `investment_period_weightings.years` — what a period's energy weighs in a `primary_energy` or `operational_limit` row; PyPSA reads it only under `multi_investment_periods`, so data prep feeds one otherwise | | $`\mathrm{on}`$ | `Generator_active` over $`\mathcal{T} \times \mathcal{G}`$ — whether a generator stands in a snapshot's period — PyPSA's `active`, from build year and lifetime, data prep | @@ -9354,11 +9354,11 @@ CVaR_def: description: "`CVaR-def` — the tail's average is at least where it starts plus the expected excess over the tail's probability" dims: [] where: CVaR_omega > 0 - expression: CVaR_theta + CVaR_inv_tail * sum(scenario_weight * CVaR_a, over=scenario) <= CVaR + expression: CVaR_theta + 1 / (1 - CVaR_alpha) * sum(scenario_weight * CVaR_a, over=scenario) <= CVaR ``` ```math -\theta + \mathrm{v} \cdot \left( \sum_{\xi \in \Xi} \pi_{\xi} \cdot a_{\xi} \right) \le CVaR \qquad \text{where } \omega > 0 +\theta + \frac{1}{1 - \alpha} \cdot \left( \sum_{\xi \in \Xi} \pi_{\xi} \cdot a_{\xi} \right) \le CVaR \qquad \text{where } \omega > 0 ``` ### `Generator_previous_status` diff --git a/docs/examples/pypsa/cost.md b/docs/examples/pypsa/cost.md index e2e95f6bf..fa29b2276 100644 --- a/docs/examples/pypsa/cost.md +++ b/docs/examples/pypsa/cost.md @@ -14,8 +14,8 @@ dimensions: description: the futures dispatch is chosen in, each with a weight parameters: - CVaR_inv_tail: - description: PyPSA's `1 / (1 - alpha)` — the tail's own probability, inverted in data prep because a divisor is one factor + CVaR_alpha: + description: PyPSA's `risk_preference['alpha']` — the confidence level; the tail holds the other `1 - alpha` of the probability dims: [] variables: @@ -48,7 +48,7 @@ constraints: description: "`CVaR-def` — the tail's average is at least where it starts plus the expected excess over the tail's probability" dims: [] where: CVaR_omega > 0 - expression: CVaR_theta + CVaR_inv_tail * sum(scenario_weight * CVaR_a, over=scenario) <= CVaR + expression: CVaR_theta + 1 / (1 - CVaR_alpha) * sum(scenario_weight * CVaR_a, over=scenario) <= CVaR objective: sense: minimize @@ -69,7 +69,7 @@ objective: | Symbol | Meaning | |---|---| -| $`\mathrm{v}`$ | `CVaR_inv_tail` (scalar) — PyPSA's `1 / (1 - alpha)` — the tail's own probability, inverted in data prep because a divisor is one factor | +| $`\alpha`$ | `CVaR_alpha` (scalar) — PyPSA's `risk_preference['alpha']` — the confidence level; the tail holds the other `1 - alpha` of the probability | #### Variables @@ -104,7 +104,7 @@ a_{\xi} - \mathit{scenario\_opex}_{\xi} + \theta \ge 0 \qquad \forall\, \xi \in **`CVaR_def`** ```math -\theta + \mathrm{v} \cdot \left( \sum_{\xi \in \Xi} \pi_{\xi} \cdot a_{\xi} \right) \le CVaR \qquad \text{where } \omega > 0 +\theta + \frac{1}{1 - \alpha} \cdot \left( \sum_{\xi \in \Xi} \pi_{\xi} \cdot a_{\xi} \right) \le CVaR \qquad \text{where } \omega > 0 ``` #### Variable domains diff --git a/docs/reference/language/expressions.md b/docs/reference/language/expressions.md index df3d84ef4..8de216dcd 100644 --- a/docs/reference/language/expressions.md +++ b/docs/reference/language/expressions.md @@ -45,11 +45,29 @@ bound it: A [reported expression](named.md#reported-expressions) is not held to these. -`/` needs a divisor that carries no variable and is a single factor. +`/` needs a divisor that carries no variable. `**` needs a base and an exponent +that carry no variable. Any arithmetic over numbers and parameters is allowed in +those places, so a discount factor is written where it is used: -`**` needs a base and an exponent that both carry no variable and neither of -which adds. `growth ** period` is allowed, and `(1 + rate) ** period` is -refused: declare the factor itself as a parameter. Write `x * x` for a square. +```yaml +dimensions: + period: { dtype: int } +parameters: + rate: { dims: [] } + years: { dims: [period] } + price: { dims: [period] } +variables: + build: { dims: [period], bounds: { lower: 0 } } +constraints: + enough: { dims: [], expression: "sum(build, over=period) >= 1" } +objective: + sense: minimize + expression: sum(build * price / (1 + rate) ** years, over=period) +``` + +`years` is a parameter over `period`. The exponent cannot be `period` itself, +because an expression reads no [dimension](#name-resolution). Write `x * x` for +a square. ## Name resolution diff --git a/examples/pypsa.yaml b/examples/pypsa.yaml index 73e5afeaf..d52045985 100644 --- a/examples/pypsa.yaml +++ b/examples/pypsa.yaml @@ -542,8 +542,8 @@ parameters: CVaR_omega: description: PyPSA's `risk_preference['omega']` — the share of operating cost priced at the tail rather than in expectation; zero recovers the risk-neutral model dims: [] - CVaR_inv_tail: - description: PyPSA's `1 / (1 - alpha)` — the tail's own probability, inverted in data prep because a divisor is one factor + CVaR_alpha: + description: PyPSA's `risk_preference['alpha']` — the confidence level; the tail holds the other `1 - alpha` of the probability dims: [] period_weight_objective: description: PyPSA's `investment_period_weightings.objective` — what a period's cost weighs @@ -3852,7 +3852,7 @@ constraints: description: "`CVaR-def` — the tail's average is at least where it starts plus the expected excess over the tail's probability" dims: [] where: CVaR_omega > 0 - expression: CVaR_theta + CVaR_inv_tail * sum(scenario_weight * CVaR_a, over=scenario) <= CVaR + expression: CVaR_theta + 1 / (1 - CVaR_alpha) * sum(scenario_weight * CVaR_a, over=scenario) <= CVaR assumptions: Generator_maintenance_events_positive: diff --git a/examples/pypsa/cost.yaml b/examples/pypsa/cost.yaml index 4c6e433a5..d574f9aac 100644 --- a/examples/pypsa/cost.yaml +++ b/examples/pypsa/cost.yaml @@ -7,8 +7,8 @@ dimensions: description: the futures dispatch is chosen in, each with a weight parameters: - CVaR_inv_tail: - description: PyPSA's `1 / (1 - alpha)` — the tail's own probability, inverted in data prep because a divisor is one factor + CVaR_alpha: + description: PyPSA's `risk_preference['alpha']` — the confidence level; the tail holds the other `1 - alpha` of the probability dims: [] variables: @@ -41,7 +41,7 @@ constraints: description: "`CVaR-def` — the tail's average is at least where it starts plus the expected excess over the tail's probability" dims: [] where: CVaR_omega > 0 - expression: CVaR_theta + CVaR_inv_tail * sum(scenario_weight * CVaR_a, over=scenario) <= CVaR + expression: CVaR_theta + 1 / (1 - CVaR_alpha) * sum(scenario_weight * CVaR_a, over=scenario) <= CVaR objective: sense: minimize diff --git a/examples/symbols/pypsa.yaml b/examples/symbols/pypsa.yaml index 7ff238949..c5331425f 100644 --- a/examples/symbols/pypsa.yaml +++ b/examples/symbols/pypsa.yaml @@ -299,7 +299,7 @@ names: Store_p_set: '\mathrm{q}^{\mathrm{set}}' scenario_weight: '\pi' CVaR_omega: '\omega' - CVaR_inv_tail: '\mathrm{v}' + CVaR_alpha: '\alpha' CVaR_a: a CVaR_theta: '\theta' CVaR: "CVaR" diff --git a/src/mathspec/degree.py b/src/mathspec/degree.py index 6a5c61f33..cd7088761 100644 --- a/src/mathspec/degree.py +++ b/src/mathspec/degree.py @@ -15,10 +15,6 @@ terms**. ``sum(x, over=i) * sum(y, over=j)`` is a cross join whose size the file states nowhere. Factors carrying *different dims* are not that: ``x[i] * y[j]`` broadcasts. - -A divisor's **shape** is decided here too: a quotient is multiplication by one -reciprocal factor, so a divisor that adds is refused at load, where the message -can name the rewrite. """ from __future__ import annotations @@ -41,17 +37,6 @@ ) -def _adds(node: Expression) -> bool: - """Whether *node* adds anywhere inside it. - - Anywhere, not only at its head: every operator over a variable-free - expression maps over its parts rather than folding them, so an addition - under a ``sum`` or a product reaches the quotient as two factors just as - one at the top does. - """ - return any(isinstance(found, Add) for found in walk(node)) - - def check_binary(node: Multiply | Divide | Power, context: str, *, ceiling: int) -> None: """Check that *node* stays inside the degree its position allows. @@ -65,18 +50,12 @@ def check_binary(node: Multiply | Divide | Power, context: str, *, ceiling: int) LanguageError: A product of two variable-carrying factors where the position allows only degree 1 or where both factors are sums of terms, a power over anything carrying a variable, a divisor carrying - a variable or adding. + a variable. """ where = f'{context}: ' if context else '' if isinstance(node, Power): if carries_variable(node): raise LanguageError(_a_variable_under_a_power_message(where)) - if _adds(node.base) or _adds(node.exponent): - raise LanguageError( - f'{where}a base and an exponent must each be a single Constant/Parameter factor, ' - f'not a sum — addition does not distribute over `**`, so `(1 + rate) ** period` is ' - f'refused where `growth ** period` is not. Declare the factor itself as a parameter.' - ) return if isinstance(node, Divide): if carries_variable(node.divisor): @@ -84,11 +63,6 @@ def check_binary(node: Multiply | Divide | Power, context: str, *, ceiling: int) f'{where}the divisor contains variables, which is not affine. ' f'Divide by a parameter, or precompute the reciprocal as one.' ) - if _adds(node.divisor): - raise LanguageError( - f'{where}a divisor must be a single Constant/Parameter factor, ' - f'not a sum — rewrite as multiplication by a precomputed parameter' - ) return if not (carries_variable(node.left) and carries_variable(node.right)): return diff --git a/tests/test_degree.py b/tests/test_degree.py index d4d4e21ef..0aa3a1f04 100644 --- a/tests/test_degree.py +++ b/tests/test_degree.py @@ -35,6 +35,10 @@ def _ast(text: str): pytest.param('k ** c', id='a-parameter-exponent'), pytest.param('sum(p * c, over=g)', id='a-reduction-of-affine-terms'), pytest.param('p + q', id='a-sum-of-variables'), + pytest.param('p * (c + 1) ** 2', id='a-sum-as-a-base'), + pytest.param('p * k ** (c + 1)', id='a-sum-as-an-exponent'), + pytest.param('p / (c + 1)', id='a-divisor-that-adds'), + pytest.param('p / sum(c + k, over=g)', id='an-addition-under-a-reduction-divisor'), ], ) def test_an_affine_expression_passes_everywhere(text): @@ -48,13 +52,7 @@ def test_an_affine_expression_passes_everywhere(text): pytest.param('p * (c * q)', 'which is degree 2', id='a-variable-under-each-factor'), pytest.param('p ** 2', '`**` is not in the language over variables', id='a-variable-base'), pytest.param('k ** p', '`**` is not in the language over variables', id='a-variable-exponent'), - pytest.param('(c + 1) ** 2', 'not a sum', id='a-sum-as-a-base'), - pytest.param('k ** (c + 1)', 'not a sum', id='a-sum-as-an-exponent'), pytest.param('p / q', 'the divisor contains variables', id='a-variable-divisor'), - pytest.param('p / (c + 1)', 'a divisor must be a single Constant/Parameter factor', id='a-divisor-that-adds'), - pytest.param( - 'p / sum(c + k, over=g)', 'a divisor must be a single', id='an-addition-under-a-reduction-divisor' - ), ], ) def test_the_affine_ceiling_refuses_and_names_the_rewrite(text, fragment): diff --git a/tests/test_validation.py b/tests/test_validation.py index 7fdf2068c..ac51fea9e 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -645,6 +645,8 @@ def test_a_signed_literal_and_inf_are_numbers_on_a_side(self): ('patch', 'where'), [ pytest.param({}, 'c <= 0.5 * k', id='arithmetic-on-a-side'), + pytest.param({}, 'c / (k + 1) > 0', id='a-divisor-that-adds'), + pytest.param({}, '(1 + k) ** c > 1', id='a-base-that-adds'), pytest.param({}, 'c > k', id='two-parameters'), pytest.param({}, '2 < c', id='a-literal-on-the-left'), pytest.param({'macros.half': {'args': ['x'], 'template': 'x / 2'}}, 'c <= half(k)', id='a-macro'), @@ -693,11 +695,6 @@ def test_a_reduction_on_a_side_leaves_the_frame_it_reduced(self): ("'flag' is declared dtype: bool, and an expression is arithmetic",), id='a-flag-against-a-parameter', ), - pytest.param( - {'variables.p.where': 'c / (k + 1) > 0'}, - ('a divisor must be a single Constant/Parameter factor',), - id='a-divisor-that-adds', - ), pytest.param( {'variables.p.where': 'shift(c, along=g, offset=1) <= k'}, ('shift() over a variable-free expression leaves vacated positions with no value',), diff --git a/tests/typesetting/test_walk.py b/tests/typesetting/test_walk.py index c9d98895a..93d7761c4 100644 --- a/tests/typesetting/test_walk.py +++ b/tests/typesetting/test_walk.py @@ -258,6 +258,20 @@ def test_a_negation_under_a_plus_is_the_subtraction_it_means(name: FormatName, f assert fmt.operators['minus'] in text, 'the subtraction it folded into should still print' +@EVERY_FORMAT +def test_a_base_that_adds_is_bracketed_and_a_divisor_that_adds_is_not(name: FormatName, fmt: Format): + """A superscript binds tighter than a plus, and a fraction's bar already groups its denominator.""" + model = varied(DISPATCH_MODEL, **{'objective.expression': 'sum(p * (1 + cost) ** 2 + p / (cost + p_max))'}) + text = typeset(model, name, legend=False) + opener = fmt.parenthesise(f'1 {fmt.operators["plus"]} BASE').split('BASE')[0] + closer = fmt.superscript(fmt.parenthesise('BASE'), '2').split('BASE')[1] + assert opener in text, 'the base opens its bracket before the sum' + assert closer in text, 'the bracket closes before the exponent' + bar = fmt.fraction('TOP', 'BOTTOM').split('TOP')[1].split('BOTTOM')[0] + assert bar in text, 'the quotient prints as a fraction' + assert bar + fmt.parenthesise('BOTTOM').split('BOTTOM')[0] not in text, 'the denominator needs no bracket' + + @EVERY_FORMAT def test_a_mask_that_is_only_true_prints_no_condition(name: FormatName, fmt: Format): """The language says `True` is the same as no `where`, so a `\\top` on the