Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
6 changes: 3 additions & 3 deletions docs/examples/pypsa.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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`
Expand Down
10 changes: 5 additions & 5 deletions docs/examples/pypsa/cost.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down
26 changes: 22 additions & 4 deletions docs/reference/language/expressions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 3 additions & 3 deletions examples/pypsa.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
6 changes: 3 additions & 3 deletions examples/pypsa/cost.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion examples/symbols/pypsa.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
28 changes: 1 addition & 27 deletions src/mathspec/degree.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

Expand All @@ -65,30 +50,19 @@ 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):
raise LanguageError(
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
Expand Down
10 changes: 4 additions & 6 deletions tests/test_degree.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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):
Expand Down
7 changes: 2 additions & 5 deletions tests/test_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down Expand Up @@ -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',),
Expand Down
14 changes: 14 additions & 0 deletions tests/typesetting/test_walk.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading