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

- feat(language): a call reads a property of a dimension or a set, written subject[property] ([#2](https://github.com/fluxopt/mathspec/pull/2))
- feat(language): a file may declare sets of labels and the properties of a dimension or a set ([#1](https://github.com/fluxopt/mathspec/pull/1))
- feat(language): a parameter may declare the coordinates it has with `where:`, and outside them it is absent ([#945](https://github.com/energy-models/mathspec/pull/945))
- fix(language): a divisor that a where, a case or a masked variable guards loads under missing: 0, and so does a variable number outside its bounds ([#942](https://github.com/energy-models/mathspec/pull/942))
Expand Down
7 changes: 5 additions & 2 deletions docs/reference/language/expressions.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,8 @@ The place where a name stands decides which kinds of name are legal:
| -------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| expression (`p * cost`) | a variable, or a parameter whose values are numbers ([dtype](parameters-variables-constraints.md#parameters)) |
| dimension argument (`over=`, `along=`) | a dimension, or a list of them for `over=`. With `by=`, a name in `over=` is a column of the relation, if it has one |
| column argument (`by=`, `within=`) | columns of one relation, written `relation[column]` or `relation[column, …]` |
| condition | a parameter, variable, dimension, relation or named condition ([conditions](#conditions)) |
| column argument (`by=`, `within=`) | columns of one relation, or members and properties of one dimension or set, written `name[column, …]` |
| condition | a parameter, variable, dimension, relation, set or named condition ([conditions](#conditions)) |
| `bounds.lower` / `bounds.upper` | a parameter name, or a number |
| the `edge` key of `shift` | `'wrap'` in quotes, or a bare number |
| `dual` argument (`dual(c)`) | a constraint. It resolves against the constraints alone ([named expressions](named.md#reading-a-constraints-dual)) |
Expand Down Expand Up @@ -154,6 +154,9 @@ QUOTED ::= "'" chars "'" | '"' chars '"'
| `name` (bare) | parameter | The value is defined here. A `bool` is its own answer. A `str` is defined wherever the table has a row. A number has to have a row and be finite, and `0.0` is a row: write `inflow != 0` for non-zero. An `int` or `str` under `missing: refused` is a load error: it has a row at every coordinate |
| `name` (bare) | variable | The variable exists at this coordinate |
| `name` (bare) | relation | A row exists, read at the relation's key. A `missing: absent` relation is [partial](relations.md#the-data-contract), and this selects the labels that do map |
| `name` (bare) | set | A row exists, read at the set's members ([sets](sets.md#entries-over-a-set)) |
| `name[p, …]` | dimension or set | The row has every property named ([reading a property](sets.md#how-a-set-or-a-property-is-read)) |
| `name[p] OP value`, `name[a] OP name[b]` | property | As for a relation column, read from the property's own table |
| `name` (bare) | dimension | A load error. It would be true everywhere |
| `name` (bare) | named condition | Its condition ([conditions](named.md#conditions)). A given condition is true or false at each coordinate, as a `bool` parameter is |
| `name OP value` | parameter | Element-wise. A missing row with no `missing:` value compares false |
Expand Down
55 changes: 55 additions & 0 deletions docs/reference/language/sets.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,58 @@ The default `refused` refuses data that leaves a row out. For
every one of those rows has a `zone`. For `connection`, only the listed pairs
are rows, and a property of `connection` with `missing: refused` needs a value
at each listed pair, not at every pair of a generator and a bus.

## How a set or a property is read

A call names a subject and what it reads of it as `subject[name, …]`. Each name
is a member of the subject or a property of it. The call joins and groups by
the rules of a [relation](relations.md#how-a-relation-is-used), where a member
is a key column and a property is a value column.

| kind | written as |
| ----------------- | ------------------------------------------------------------- |
| join and group by | `sum(p, over=generator, by=generator[bus])` |
| join | `at(price, by=generator_period[zone])` |
| partition | `shift(x, along=snapshot, offset=1, within=snapshot[period])` |
| test a value | `generator[bus] == 'b1'`, or `line[bus0] != line[bus1]` |
| test a property | `line[bus1]`, true where the line has the property |

```yaml
dimensions:
generator: { dtype: str }
bus: { dtype: str }
carrier: { dtype: str }
snapshot: { dtype: int }
properties:
generator:
bus: bus
carrier: { into: carrier, missing: absent }
parameters:
limit: { dims: [bus, carrier] }
variables:
p:
dims: [generator, snapshot]
where: generator[carrier]
bounds: { lower: 0 }
constraints:
carrier_limit:
dims: [bus, carrier, snapshot]
expression: sum(p, over=generator, by=generator[bus, carrier]) <= limit
```

$$p_{g,t} \ge 0 \qquad \forall\, g \in \mathcal{G},\ t \in \mathcal{T} \,:\, \mathrm{generator.carrier}(g) \text{ is defined}$$

$$\sum_{g \in \mathcal{G} \,:\, \mathrm{generator.bus}(g) = b \wedge \mathrm{generator.carrier}(g) = c} p_{g,t} \le \mathrm{limit}_{b,c} \qquad \forall\, b \in \mathcal{B},\ c \in \mathcal{C},\ t \in \mathcal{T}$$

- A call that names several properties reads their **inner join**. A generator
with no `carrier` has no row in it, so `carrier_limit` adds up only the
generators that have a bus and a carrier.
- `subject[property]` alone in a condition is true where the row has the
property. `generator[bus, carrier]` is true where the row has both.
- A call reads one subject. To read through two, nest the calls:
`sum(sum(p, over=generator, by=generator[bus]), over=bus, by=bus[zone])`.
- A dimension is read by its properties, because it has no table of its own:

```text
Constraint 'carrier_limit': sum(by=generator[generator]) reads no property of 'generator', and a dimension has no table of its own. Name one of its properties, ['bus', 'carrier'].
```
29 changes: 25 additions & 4 deletions src/mathspec/_condition_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
COLUMNS,
NAME,
ArithmeticNode,
ColumnsNode,
KeywordNode,
NameNode,
children,
Expand Down Expand Up @@ -114,9 +115,18 @@ class UnresolvedComparisonNode:
right: ArithmeticNode | ColumnNode | KeywordNode


@dataclass(frozen=True)
class UnresolvedPresenceNode:
"""``subject[property, …]`` alone — true where the row of the subject has every property it names."""

columns: ColumnsNode


#: The condition nodes that resolution replaces: their leaves are names not yet
#: looked up in the spec.
UnresolvedConditionNode = NameNode | UnresolvedComparisonNode | UnresolvedConditionCallNode | UnresolvedCountNode
UnresolvedConditionNode = (
NameNode | UnresolvedComparisonNode | UnresolvedConditionCallNode | UnresolvedCountNode | UnresolvedPresenceNode
)

#: Every node a parsed condition is built of: the connectives and literals,
#: the unresolved leaves, and the arithmetic and the two side nodes under a
Expand All @@ -140,7 +150,8 @@ def _build_condition_grammar() -> pp.ParserElement:

Both quote characters are accepted because YAML already owns one of them.
``NOT`` binds tightest, then ``AND``, then ``OR``. A comparison is tried
before a bare name, since its left side begins with one.
before a bare name, since its left side begins with one, and so is
``name[...]`` alone, which tests that a row has the properties it names.
"""
condition = pp.Forward()

Expand Down Expand Up @@ -184,12 +195,15 @@ def _call(head: pp.ParserElement) -> pp.ParserElement:
).set_parse_action(lambda t: UnresolvedComparisonNode(t[0], t[1], t[2]))
# pyrefly: ignore[implicit-any-lambda]
existence = name.copy().set_parse_action(lambda t: NameNode(t[0]))
# pyrefly: ignore[implicit-any-lambda]
presence = COLUMNS.copy().add_parse_action(lambda t: UnresolvedPresenceNode(t[0]))

atom = (
true_lit
| false_lit
| count_comparison
| comparison
| presence
| condition_call
| existence
| (pp.Suppress('(') + condition + pp.Suppress(')'))
Expand Down Expand Up @@ -231,8 +245,9 @@ def _named_rewrite(text: str, loc: int) -> str | None:
"""The rewrite for a connective habit of pandas or C, or a comparison of several columns, or ``None``.

``!=``, ``<`` and ``>`` are legal here, so only the tokens no condition
admits are diagnosed. The grammar gives up at the bracket of
``relation[a, b]``, since a comparison reads one column.
admits are diagnosed. A comparison reads one column, so the grammar gives
up at the bracket of ``relation[a, b]`` inside a comparison, and at the
comparator after ``subject[a, b]``, which alone is a test of presence.
"""
rest = text[loc:].lstrip()
if rest.startswith('&'):
Expand All @@ -249,6 +264,12 @@ def _named_rewrite(text: str, loc: int) -> str | None:
relation, columns = before.group(1), [c.strip() for c in listed.group(1).split(',')]
each = ' AND '.join(f'{relation}[{column}] == …' for column in columns)
return f"a comparison reads one column of '{relation}'. Compare each column on its own: {each}."
if re.match(r'(==|!=|<=|>=|<|>)', rest) and (
read := re.search(rf'({NAME})\s*\[\s*({NAME}(?:\s*,\s*{NAME})+)\s*\]\s*$', text[:loc])
):
relation, columns = read.group(1), [c.strip() for c in read.group(2).split(',')]
each = ' AND '.join(f'{relation}[{column}] == …' for column in columns)
return f"a comparison reads one column of '{relation}'. Compare each column on its own: {each}."
return None


Expand Down
Loading