Skip to content
Draft
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

- fix(language): a divisor that a shift fills with 0 at the edge is refused at load ([#889](https://github.com/energy-models/mathspec/pull/889))
- docs(pypsa): an extendable unit has a capacity to choose exactly where its active flag is set, as in PyPSA ([#886](https://github.com/energy-models/mathspec/pull/886))
- refactor(api)!: a module outside spec, program and errors has a name that starts with an underscore ([#878](https://github.com/energy-models/mathspec/pull/878))
- feat(cli)!: `python -m mathspec diff` compares two specs in the canonical form, and `canonical` no longer rewrites or checks a file ([#874](https://github.com/energy-models/mathspec/pull/874))
Expand Down
8 changes: 8 additions & 0 deletions docs/reference/language/expressions.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,14 @@ objective:
because an expression reads no [dimension](#name-resolution). Write `x * x` for
a square.

A divisor that `edge=0` can make 0 is refused at load, because the file itself
puts that 0 in. `p / shift(c, along=t, offset=1, edge=0)` divides by 0 at the
first `t`, whatever the data holds. The same holds where the shift is a factor,
a numerator, a base, the operand of a sum, another shift or a lookup, a named
expression or a region of a case, and in a sum only where every term can be 0:
`shift(c, along=t, offset=1, edge=0) + 1` loads. Write `edge='wrap'`, or an
`edge=` value other than 0.

## Name resolution

One flat namespace covers dimensions, relations, parameters, variables, named
Expand Down
38 changes: 38 additions & 0 deletions src/mathspec/_expression_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,12 @@
from mathspec.program import (
Add,
Axis,
Cases,
Constant,
Divide,
Dual,
Expression,
ExpressionReference,
Join,
JoinColumns,
Multiply,
Expand Down Expand Up @@ -83,6 +85,37 @@
MAX_RESOLVED_DEPTH = 3 * MAX_DEPTH


def _zero_in(e: Expression) -> tuple[str, str] | None:
"""Where the file itself makes *e* 0, with the rewrite that removes it, or ``None`` where it never does.

``edge=0`` on a shift puts a 0 where the shift vacates, whatever the
data says, so a divisor that holds one is refused at load. A factor, the
numerator of a quotient, the base of a power, and the operand of a
negation, a sum, a window, a shift, a lookup or a named expression make *e*
0 where they are 0; a sum of terms only where every term is, so
``shift(p, edge=0) + 1`` never is; a case where any region is.
"""
match e:
case Translate(fill=fill) if fill == 0:
return f'where shift(along={e.along}, edge=0) vacates', "Write edge='wrap', or an edge= value other than 0."
case Negate(operand=operand) | Sum(operand=operand) | WindowSum(operand=operand) | Translate(operand=operand):
return _zero_in(operand)
case Join(operand=operand) | ExpressionReference(body=operand):
return _zero_in(operand)
case Multiply(left=left, right=right):
return _zero_in(left) or _zero_in(right)
case Divide(numerator=numerator):
return _zero_in(numerator)
case Power(base=base):
return _zero_in(base)
case Add(left=left, right=right):
zero = _zero_in(left)
return zero if zero is not None and _zero_in(right) is not None else None
case Cases(regions=regions):
return next((zero for r in regions if (zero := _zero_in(r.value)) is not None), None)
return None


@dataclass(frozen=True)
class ExpressionResolver:
"""One resolution walk over an expression, and the three things every step of it reads.
Expand Down Expand Up @@ -172,6 +205,11 @@ def _binary(self, node: BinaryOperatorNode) -> Expression | None:
case '*':
return Multiply(left, right)
case '/':
if (zero := _zero_in(right)) is not None:
self.errors.append(
f'{self.context}: the divisor is 0 {zero[0]}, which no number divides by. {zero[1]}'
)
return None
return Divide(left, right)
case '**':
return Power(left, right)
Expand Down
58 changes: 58 additions & 0 deletions tests/test_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -2675,3 +2675,61 @@ def test_a_dump_that_leaves_ordered_out_still_writes_the_dimension(dump):
def test_a_row_a_set_a_curve_and_an_assumption_sit_outside_the_flat_namespace(patch):
"""What the language reference states under name resolution: none of these four is read by a bare name."""
spec_of(EXAMPLES / 'sos.yaml', **patch)


#: The shift a divisor below is 0 at the edge of, wherever it stands in the divisor.
EDGE_ZERO = 'shift(c, along=g, offset=1, edge=0)'


@pytest.mark.parametrize(
('divisor', 'patch'),
[
pytest.param(EDGE_ZERO, {}, id='the-shift'),
pytest.param('shift(c, along=g, offset=-1, edge=0)', {}, id='a-shift-ahead'),
pytest.param(f'2 * {EDGE_ZERO}', {}, id='a-factor'),
pytest.param(f'-{EDGE_ZERO}', {}, id='negated'),
pytest.param(f'{EDGE_ZERO} / 2', {}, id='a-numerator'),
pytest.param(f'{EDGE_ZERO} ** 2', {}, id='a-base'),
pytest.param(f'sum({EDGE_ZERO}, over=g)', {}, id='summed'),
pytest.param(f'shift({EDGE_ZERO}, along=g, offset=1, edge=1)', {}, id='shifted-again'),
pytest.param(f'{EDGE_ZERO} + {EDGE_ZERO}', {}, id='every-addend'),
pytest.param('e', {'expressions.e': EDGE_ZERO}, id='a-named-expression'),
pytest.param(
'e',
{
'expressions.e': {
'dims': ['g'],
'cases': {'on': {'when': 'flag', 'expression': EDGE_ZERO}},
'otherwise': '1',
}
},
id='a-case',
),
pytest.param(
'at(shift(d, along=h, offset=1, edge=0), by=lk[h])', {'parameters.d': {'dims': ['h']}}, id='a-lookup'
),
],
)
def test_a_divisor_a_shift_fills_with_zero_is_refused(divisor, patch):
"""`shift(c, edge=0)` loaded as a divisor, so the row at the edge divided by the 0 that `edge=0` stands for."""
message = _refusal(**patch, **{'constraints.cap': {'dims': ['g'], 'expression': f'p / ({divisor}) <= 1'}})
vacates = (
'the divisor is 0 where shift(along=g, edge=0) vacates',
'the divisor is 0 where shift(along=h, edge=0) vacates',
)
assert any(v in message for v in vacates), message
assert 'which no number divides by' in message
assert "Write edge='wrap', or an edge= value other than 0" in message, 'the refusal names the rewrite'


@pytest.mark.parametrize(
'divisor',
[
pytest.param('shift(c, along=g, offset=1, edge=1)', id='another-edge'),
pytest.param("shift(c, along=g, offset=1, edge='wrap')", id='a-wrap'),
pytest.param('shift(c, along=g, offset=1, edge=0) + 1', id='an-addend-the-edge-does-not-zero'),
],
)
def test_a_divisor_a_shift_fills_with_another_value_loads(divisor):
spec = to_spec(varied(SMALL_MODEL, **{'constraints.cap': {'dims': ['g'], 'expression': f'p / ({divisor}) <= 1'}}))
assert 'cap' in spec.program.constraints
Loading