From a5a26bc3e588e8746e8123e2744dd884e2b4866b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 20:13:37 +0000 Subject: [PATCH 1/2] feat(language): a divisor or a power over parameters may add, so a discount factor is written where it is used `x / (a + b)` and `(1 + rate) ** years` load. The degree check refused a sum anywhere under a divisor, a base or an exponent. The program already carries the tree, and the typesetter prints it in all three formats. The PyPSA stochastic example reads `alpha` and divides by `1 - alpha` in CVaR-def, instead of binding the inverted tail as data. expressions.md: n 92, avg 15.4, median 14, over25 11 (was n 91, same). Closes #277 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C7TEPiMeF7aowcvTX6CRRh --- CHANGELOG.md | 1 + docs/examples/pypsa_stochastic.md | 8 ++++---- docs/reference/language/expressions.md | 26 ++++++++++++++++++++---- examples/pypsa_stochastic.yaml | 6 +++--- examples/symbols/pypsa_stochastic.yaml | 2 +- src/mathspec/degree.py | 28 +------------------------- tests/test_degree.py | 10 ++++----- tests/test_validation.py | 7 ++----- tests/typesetting/test_walk.py | 14 +++++++++++++ 9 files changed, 52 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8026a9463..1c76641f2 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 ([#648](https://github.com/energy-models/mathspec/pull/648)) - docs: code examples on the site are readable in light and dark mode, and a diagram shows what mathspec leaves to engines and other tools ([#730](https://github.com/energy-models/mathspec/pull/730)) - docs: the site follows the reader's light or dark setting, and a page shows where it sits in the navigation ([#727](https://github.com/energy-models/mathspec/pull/727)) diff --git a/docs/examples/pypsa_stochastic.md b/docs/examples/pypsa_stochastic.md index 7119f0649..ecaf5507d 100644 --- a/docs/examples/pypsa_stochastic.md +++ b/docs/examples/pypsa_stochastic.md @@ -16,7 +16,7 @@ file of its own. Its network is the spine plus the script's own additions. | [`Generator-fix-p-*`, `-ext-p-*`, `Link-fix-p-*`, `Bus-nodal_balance`](#generator-fix-p-lower) | done | rungs 1 and 3, over `scenario` | | [`CVaR-a`, `CVaR-theta`, `CVaR`](#variable-domains) | done | | | [`CVaR-excess-{s}`](#cvar-excess-s) | split | PyPSA names a row per scenario; one block over the dimension | -| [`CVaR-def`](#cvar-def) | done | `1 / (1 - alpha)` is data prep | +| [`CVaR-def`](#cvar-def) | done | | | [objective](#objective) | done | capacity once; operation `(1 - omega)` in expectation, `omega` at the tail | @@ -81,7 +81,7 @@ The two-stage class of a plain `n.optimize()`: a network with scenarios, stated |---|---| | $`\pi`$ | `scenario_weight` over $`\mathcal{S}`$ — PyPSA's `scenario_weightings.weight` — the probability of a future | | $`\omega`$ | `CVaR_omega` (scalar) — PyPSA's `risk_preference['omega']` — the share of the operating cost priced at the tail rather than in expectation | -| $`\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}`$ | `snapshot_weightings_objective` over $`\mathcal{T}`$ — PyPSA's `snapshot_weightings.objective` — hours a snapshot stands for in the cost | | $`\mathrm{p}^{\mathrm{nom}}`$ | `Generator_p_nom` over $`\mathcal{G}`$ — nominal power | | $`\mathrm{ext}`$ | `Generator_p_nom_extendable` over $`\mathcal{G}`$ — whether the nominal power is a decision | @@ -302,11 +302,11 @@ a_{s} - \mathit{scenario\_opex}_{s} + \theta \ge 0 \qquad \forall\, s \in \mathc 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: [] - 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_{s \in \mathcal{S}} \pi_{s} \cdot a_{s} \right) \le CVaR +\theta + \frac{1}{1 - \alpha} \cdot \left( \sum_{s \in \mathcal{S}} \pi_{s} \cdot a_{s} \right) \le CVaR ``` ### `scenario_opex` 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_stochastic.yaml b/examples/pypsa_stochastic.yaml index f68b8461c..ae327a54d 100644 --- a/examples/pypsa_stochastic.yaml +++ b/examples/pypsa_stochastic.yaml @@ -39,8 +39,8 @@ parameters: CVaR_omega: description: PyPSA's `risk_preference['omega']` — the share of the operating cost priced at the tail rather than in expectation 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: [] snapshot_weightings_objective: description: PyPSA's `snapshot_weightings.objective` — hours a snapshot stands for in the cost @@ -209,7 +209,7 @@ constraints: 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: [] - 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_stochastic.yaml b/examples/symbols/pypsa_stochastic.yaml index dfd532f0b..550074e26 100644 --- a/examples/symbols/pypsa_stochastic.yaml +++ b/examples/symbols/pypsa_stochastic.yaml @@ -33,7 +33,7 @@ names: Generator_capital_cost: '\mathrm{c}^{\mathrm{cap}}' 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 0e8f46b8f..504f15763 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 26ce004d0..ced6d64c2 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 = override(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 From 94d652880535d5ca8b35cdd901995ef6db7213c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 14:46:29 +0000 Subject: [PATCH 2/2] docs(changelog): the entry links the PR that replaces #648 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C7TEPiMeF7aowcvTX6CRRh --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e601fb33d..bdd740a9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ it releases that version ([RELEASING.md](https://github.com/energy-models/mathsp ## Upcoming version -- feat(language): a divisor or a power over parameters may add, so a discount factor is written where it is used ([#648](https://github.com/energy-models/mathspec/pull/648)) +- 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)) - feat(language): a named expression may declare the frame it is read over ([#741](https://github.com/energy-models/mathspec/pull/741)) - docs: code examples on the site are readable in light and dark mode, and a diagram shows what mathspec leaves to engines and other tools ([#730](https://github.com/energy-models/mathspec/pull/730)) - docs: the site follows the reader's light or dark setting, and a page shows where it sits in the navigation ([#727](https://github.com/energy-models/mathspec/pull/727))