Skip to content
Closed
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
19 changes: 16 additions & 3 deletions docs/reference/language/expressions.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,8 @@ A `where:` is a boolean mask, and true means "this coordinate exists".
```text
where_expr ::= atom | "NOT" where_expr | where_expr ("AND"|"OR") where_expr
| "(" where_expr ")"
atom ::= NAME | NAME COMPARATOR value | POSITION COMPARATOR INTEGER
| "True" | "False"
atom ::= NAME | NAME COMPARATOR value | NAME "in" "[" [ value { "," value } ] "]"
| POSITION COMPARATOR INTEGER | "True" | "False"
COMPARATOR ::= "<=" | ">=" | "==" | "!=" | "<" | ">"
value ::= NUMBER | QUOTED | NAME_OR_STRING
POSITION ::= "position" "(" NAME [ "," "by" "=" NAME ] ")"
Expand All @@ -176,6 +176,7 @@ QUOTED ::= "'" chars "'" | '"' chars '"'
| `name` (bare) | dimension | load error: it is true everywhere, so it reads as a condition and is not one. Compare it instead |
| `name OP value` | parameter | element-wise; a null compares false. The right-hand side is a literal number, or a bare name read as a string coordinate |
| `name OP value` | dimension | a filter on the frame's own coordinate column |
| `name in [v, …]` | parameter, dimension, lookup | keeps the rows whose value is one of a **set of literals** — the same three left-hand sides a comparison takes, one `isin` filter instead of an `==` OR chain. Each element is dtype-checked like `==`, so a date is quoted per element |
| `name` (bare) | lookup | defined: the label maps somewhere. A lookup may be [partial](dimensions.md#lookups), and this is how a declaration asks for the labels that do map |
| `name OP value` | lookup | a filter on the lookup's column of its `over` dimension's index — which therefore has to be in the frame. A null value is **false**, whatever the comparator |
| `name OP name` | two lookups | the one comparison whose both sides are structure. Legal only where both map out of the **same** dimension _and_ into the **same** one — `from != to` excludes a self-loop |
Expand Down Expand Up @@ -221,7 +222,19 @@ dates: a `datetime` dimension compared to a number is compared against the
**epoch**, so `snapshot > 0` would silently mean "after 1970-01-01". That is a
load error naming the fix. A datetime boundary is a quoted ISO date —
`snapshot > '2030-01-01'`, or `'2030-01-01T06:00'` with a time. Calendar
arithmetic, resampling and timezone conversion stay data prep.
arithmetic, resampling and timezone conversion stay data prep. Membership
checks each element the same way, so a set of dates is a list of quoted ISO
strings and a `str` column takes quoted labels; a `float` column may be tested
against a set of floats, exact equality and all, exactly as `==` permits it.

**A membership list is literals only, and it is not empty.** `carrier in []`
matches nothing, which is `where: "False"` said obscurely — a load error names
that rewrite. A repeated element selects nothing extra and is a load error too.
A **declared** name among the elements is a near miss the same way a comparison's
right-hand side is: selecting by data on the right is
[data-driven membership](https://github.com/energy-models/math-spec/issues/258),
so the message points there, or to precomputing the test as a `bool` parameter.
Negation is the existing `NOT` — there is no `not in`.

**`position(dim)` converts a dimension to where the row sits along it**, so a
boundary clause survives the index being relabelled:
Expand Down
13 changes: 13 additions & 0 deletions docs/reference/notation.md
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,19 @@ northern:

$$\mathit{slack}_{t} \le \mathrm{load}_{t,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace b \in \mathcal{B} \thinspace:\thinspace \mathrm{zone\_of}(b) = \text{'}\mathrm{north}\text{'} \wedge \mathrm{zone\_of}(b) \neq \mathrm{area\_of}(b) \wedge \mathrm{zone\_of}(b) \text{ is defined}$$

#### `selected`

set membership per kind: a dimension's coordinates, a lookup's values, a parameter's numbers

```yaml
selected:
foreach: [snapshot, generator, bus]
where: "snapshot in [0, 3] OR zone_of in ['north', 'south'] OR min_up in [2, 3]"
expression: p <= load
```

$$p_{t,g} \le \mathrm{load}_{t,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G},\enspace b \in \mathcal{B} \thinspace:\thinspace t \in \{0,\enspace 3\} \vee \mathrm{zone\_of}(b) \in \{\text{'}\mathrm{north}\text{'},\enspace \text{'}\mathrm{south}\text{'}\} \vee \mathrm{min\_up}_{g} \in \{2,\enspace 3\}$$

#### `efficiency`

a Greek-named parameter, which is given — so the convention wins and it prints as the word
Expand Down
6 changes: 4 additions & 2 deletions src/math_spec/dimensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,11 @@
from math_spec.resolution import Namespace, expression_of, where_of
from math_spec.where_parser import (
DimensionComparisonNode,
DimensionMembershipNode,
DimensionPositionNode,
ParameterComparisonNode,
ParameterDefinedNode,
ParameterMembershipNode,
VariableDefinedNode,
WhereNode,
_atom_dims,
Expand Down Expand Up @@ -483,7 +485,7 @@ def _check_where_dims(
for atom in atoms(node):
if not (outside := sorted(_atom_dims(atom, name_dims) - frame)):
continue
if isinstance(atom, (ParameterDefinedNode, ParameterComparisonNode)):
if isinstance(atom, (ParameterDefinedNode, ParameterComparisonNode, ParameterMembershipNode)):
raise DimensionError(
f"{context}: where-parameter '{atom.name}' has dims "
f'{outside} outside the frame {sorted(frame)}. Reducing '
Expand All @@ -496,7 +498,7 @@ def _check_where_dims(
f'reducing over an unlisted dim would silently widen it — say which '
f'reduction you mean.'
)
if isinstance(atom, (DimensionComparisonNode, DimensionPositionNode)):
if isinstance(atom, (DimensionComparisonNode, DimensionMembershipNode, DimensionPositionNode)):
raise DimensionError(
f"{context}: where-comparison on dimension '{atom.name}', which is not in the frame {sorted(frame)}."
)
Expand Down
158 changes: 134 additions & 24 deletions src/math_spec/resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

import datetime
import re
from typing import TYPE_CHECKING, assert_never
from typing import TYPE_CHECKING, assert_never, cast

from math_spec.errors import LanguageError
from math_spec.expansion import parse_and_expand
Expand Down Expand Up @@ -49,16 +49,20 @@
AndNode,
BooleanLiteralNode,
DimensionComparisonNode,
DimensionMembershipNode,
DimensionPositionNode,
LookupComparisonNode,
LookupDefinedNode,
LookupMembershipNode,
LookupPairComparisonNode,
NotNode,
OrNode,
ParameterComparisonNode,
ParameterDefinedNode,
ParameterMembershipNode,
TypedPredicateNode,
UnresolvedComparisonNode,
UnresolvedMembershipNode,
UnresolvedNameNode,
UnresolvedPositionNode,
VariableDefinedNode,
Expand Down Expand Up @@ -616,28 +620,44 @@ def resolve_where(


def _typed_literal(
node: UnresolvedComparisonNode,
name: str,
value: float | str,
quoted: bool,
dtype: str,
context: str,
errors: list[str],
) -> float | str | datetime.date | None:
"""The comparison's literal, checked against the declared dtype.
"""One literal, checked against the declared dtype of the name it is tested against.

The one home for the dtype rule a where-comparison and a where-membership
both run: a comparison passes its single value, a membership each element
of its list. Getting it wrong is silent: polars reads a datetime column
against an integer as an epoch offset, so ``snapshot > 0`` drops every
coordinate before 1970 without a word (#460). Returns ``None`` once it has
recorded an error, so the caller leaves the node unresolved.

Args:
name: The declared name the literal is tested against.
value: The literal — a number, or a string label.
quoted: Whether it arrived in quotes, which shapes the rewrite the
message names.
dtype: The declared dtype of *name*.
context: Where a message locates itself.
errors: Collected problems, appended to on a mismatch.

Getting it wrong is silent: polars reads a datetime column against an
integer as an epoch offset, so ``snapshot > 0`` drops every coordinate
before 1970 without a word (#460). Returns ``None`` once it has recorded
an error, so the caller leaves the node unresolved.
Returns:
The literal in the dtype's own type — a :class:`datetime.date` for a
datetime dimension — or ``None`` on a mismatch.
"""
value = node.value
text = isinstance(value, str)

if dtype == 'datetime':
if not text:
errors.append(
f"{context}: '{node.name}' is a datetime dimension, so comparing it to "
f'{value!r} compares against the epoch — {node.name} > 0 means "after '
f'1970-01-01", not what it looks like. Quote an ISO date instead: '
f"{node.name} {node.op} '2030-01-01'."
f"{context}: '{name}' is a datetime dimension, so comparing it to "
f'{value!r} compares against the epoch — {name} > 0 means "after '
f'1970-01-01", not what it looks like. Quote an ISO date instead, '
f"e.g. '2030-01-01'."
)
return None
try:
Expand All @@ -648,22 +668,21 @@ def _typed_literal(
)
except ValueError:
errors.append(
f"{context}: '{node.name}' is a datetime dimension and {value!r} is not an "
f"{context}: '{name}' is a datetime dimension and {value!r} is not an "
f"ISO date. Write '2030-01-01' or '2030-01-01T06:00'."
)
return None

if dtype == 'str' and not text:
errors.append(
f"{context}: '{node.name}' has dtype 'str', so comparing it to the number "
f'{value!r} matches no label. Quote it if it is one: {node.name} {node.op} '
f"'{value:g}'."
f"{context}: '{name}' has dtype 'str', so comparing it to the number "
f'{value!r} matches no label. Quote it if it is one, e.g. {f"{value:g}"!r}.'
)
return None
if dtype in ('int', 'float', 'bool') and text:
fix = 'Drop the quotes if it is a number.' if quoted else 'Write it as a number if it is one.'
errors.append(
f"{context}: '{node.name}' has dtype '{dtype}', so comparing it to the string "
f'{value!r} matches nothing. Drop the quotes if it is a number.'
f"{context}: '{name}' has dtype '{dtype}', so comparing it to the string {value!r} matches nothing. {fix}"
)
return None
return value
Expand Down Expand Up @@ -729,6 +748,98 @@ def _lookup_pair_error(context: str, node: UnresolvedComparisonNode, other: str,
return None


def _variable_where_error(context: str, name: str) -> str:
"""Why a variable may not sit where a where reads a value — the one home for the sentence a comparison and a membership share."""
return (
f"{context}: where references variable '{name}'. A where mask is built before "
f'variables exist — it may test parameters and dimension coordinates only.'
)


def _declared_element_error(context: str, name: str, value: str, kind: str) -> str:
"""Why a membership list may not name a declaration among its literals."""
return (
f"{context}: '{name} in [...]' names {value!r}, a declared "
f'{kind}, but a membership list takes literal labels only. A declared name on the '
f'right is data-driven membership (#258); quote {value!r} to keep it a fixed label, '
f'or precompute the test as a bool parameter and mask on that.'
)


def _literal_repr(value: float | str | datetime.date) -> str:
"""A typed where-literal as a refusal shows it — numbers via ``:g``, dates as ISO, labels quoted."""
if isinstance(value, str):
return repr(value)
if isinstance(value, datetime.date):
return value.isoformat()
return f'{value:g}'


def _first_duplicate(
values: tuple[float | str | datetime.date, ...],
) -> float | str | datetime.date | None:
"""The first element that repeats an earlier one, or ``None`` — a repeat selects nothing extra."""
seen: set[float | str | datetime.date] = set()
for value in values:
if value in seen:
return value
seen.add(value)
return None


def _resolve_membership(node: UnresolvedMembershipNode, ns: Namespace, context: str, errors: list[str]) -> WhereNode:
"""Type ``name in [l1, l2, …]`` — the set form of a where-comparison.

The list carries literals only: empty selects no row and is refused for the
always-false mask it hides; a repeat says nothing; a declared name among the
elements is data-driven membership, which is #258. The left-hand side is the
same three kinds a comparison takes, each element checked against its dtype
by the one :func:`_typed_literal` a comparison runs.
"""
if not node.elements:
errors.append(
f"{context}: '{node.name} in []' matches nothing — an always-false mask is a "
f'declaration with no rows. Write where: "False" if that is what is meant.'
)
return node

for value, quoted in node.elements:
if not quoted and isinstance(value, str) and (element_kind := ns.kind(value)) is not None:
errors.append(_declared_element_error(context, node.name, value, element_kind))
return node

kind = ns.kind(node.name)
typed: list[float | str | datetime.date] = []
if kind in ('parameter', 'dimension', 'lookup'):
dtype = ns.dtypes[node.name]
for value, quoted in node.elements:
one = _typed_literal(node.name, value, quoted, dtype, context, errors)
if one is None:
return node
typed.append(one)
if (duplicate := _first_duplicate(tuple(typed))) is not None:
errors.append(
f"{context}: '{node.name} in [...]' lists {_literal_repr(duplicate)} more than "
f'once, which selects nothing extra. Drop the duplicate.'
)
return node

match kind:
case 'parameter':
assert not any(isinstance(value, datetime.date) for value in typed)
return ParameterMembershipNode(node.name, cast('tuple[float | str, ...]', tuple(typed)))
case 'dimension':
return DimensionMembershipNode(node.name, tuple(typed))
case 'lookup':
return LookupMembershipNode(node.name, ns.over_of(node.name), tuple(typed))
case 'variable':
errors.append(_variable_where_error(context, node.name))
return node
case _:
errors.append(ns._unknown(node.name, context, allow_dims=True))
return node


def _resolve_position(node: UnresolvedPositionNode, ns: Namespace, context: str, errors: list[str]) -> WhereNode:
"""Type ``position(dim[, by=lookup]) <op> i``.

Expand Down Expand Up @@ -818,7 +929,7 @@ def _resolve_where(

kind = ns.kind(node.name)
if kind in ('parameter', 'dimension', 'lookup'):
typed = _typed_literal(node, ns.dtypes[node.name], context, errors)
typed = _typed_literal(node.name, node.value, node.quoted, ns.dtypes[node.name], context, errors)
if typed is None:
return node
value = typed
Expand All @@ -832,16 +943,15 @@ def _resolve_where(
case 'lookup':
return LookupComparisonNode(node.name, ns.over_of(node.name), node.op, value)
case 'variable':
errors.append(
f"{context}: where references variable '{node.name}'. A where "
f'mask is built before variables exist — it may test parameters '
f'and dimension coordinates only.'
)
errors.append(_variable_where_error(context, node.name))
return node
case _:
errors.append(ns._unknown(node.name, context, allow_dims=True))
return node

if isinstance(node, UnresolvedMembershipNode):
return _resolve_membership(node, ns, context, errors)

if isinstance(node, NotNode):
return NotNode(_resolve_where(node.operand, ns, context, errors, self_variable))
if isinstance(node, AndNode):
Expand Down
4 changes: 4 additions & 0 deletions src/math_spec/typesetting/format.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,10 @@ def cardinality(self, inner: str) -> str:
"""How many members a set has: ``|T|``. A fence, so not an infix entry in :data:`OPERATOR_NAMES`."""
...

def set_braces(self, inner: str) -> str:
"""A set literal's braces around its elements: ``{a, b}``. A fence, like :meth:`cardinality`."""
...

def fraction(self, numerator: str, denominator: str) -> str: ...

def summation(self, domain: str, body: str) -> str: ...
Expand Down
3 changes: 3 additions & 0 deletions src/math_spec/typesetting/latex.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ def parenthesise(self, inner: str) -> str:
def cardinality(self, inner: str) -> str:
return rf'\lvert {inner} \rvert'

def set_braces(self, inner: str) -> str:
return rf'\{{{inner}\}}'

def fraction(self, numerator: str, denominator: str) -> str:
return rf'\frac{{{numerator}}}{{{denominator}}}'

Expand Down
3 changes: 3 additions & 0 deletions src/math_spec/typesetting/typst.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ def parenthesise(self, inner: str) -> str:
def cardinality(self, inner: str) -> str:
return f'abs({inner})'

def set_braces(self, inner: str) -> str:
return f'{{{inner}}}'

def fraction(self, numerator: str, denominator: str) -> str:
return f'frac({numerator}, {denominator})'

Expand Down
Loading
Loading