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(typesetting): the math names a property after itself, and the reading page gives the tables of sets and properties ([#4](https://github.com/fluxopt/mathspec/pull/4))
- feat(composition): fragments join the properties of a subject, and a set two fragments declare differently is refused ([#3](https://github.com/fluxopt/mathspec/pull/3))
- 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))
Expand Down
10 changes: 8 additions & 2 deletions docs/reference/language/sets.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,12 @@ 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.

The typeset legend prints each set and each property beside the dimensions it
reads: `connection ⊆ G × B`, `bus: G → B`, and `zone: generator_period → Z` for
a property of a set. Beside them it prints
`` `line[bus1]` is `absent` where the data has no row `` for each one that
declares `missing: absent`, and prints nothing for `refused`.

## How a set or a property is read

A call names a subject and what it reads of it as `subject[name, …]`. Each name
Expand Down Expand Up @@ -181,9 +187,9 @@ constraints:
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}$$
$$p_{g,t} \ge 0 \qquad \forall\, g \in \mathcal{G},\ t \in \mathcal{T} \,:\, \mathrm{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}$$
$$\sum_{g \in \mathcal{G} \,:\, \mathrm{bus}(g) = b \wedge \mathrm{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
Expand Down
54 changes: 50 additions & 4 deletions docs/reference/notation.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,11 @@ parameters:
| Symbol | Meaning |
|---|---|
| $`\mathcal{T}`$ | index $`t`$ — `snapshot` (`int` coordinates) with $`\mathrm{season\_of}: \mathcal{T} \to \mathcal{S},\ \mathrm{gen\_zone}: \mathcal{G} \times \mathcal{T} \to \mathcal{Z},\ \mathrm{rep\_of}: \mathcal{T} \to \mathcal{T}`$ |
| $`\mathcal{G}`$ | index $`g`$ — `generator` with $`\mathrm{gen\_bus}: \mathcal{G} \to \mathcal{B},\ \mathrm{gen\_zone}: \mathcal{G} \times \mathcal{T} \to \mathcal{Z},\ \mathrm{connection} \subseteq \mathcal{G} \times \mathcal{B},\ \mathrm{gen\_bt}: \mathcal{G} \to \mathcal{B} \times \mathcal{E}`$, `connection` is `absent` where the data has no row |
| $`\mathcal{B}`$ | index $`b`$ — `bus` with $`\mathrm{gen\_bus}: \mathcal{G} \to \mathcal{B},\ \mathrm{zone\_of}: \mathcal{B} \to \mathcal{Z},\ \mathrm{area\_of}: \mathcal{B} \to \mathcal{Z},\ \mathrm{connection} \subseteq \mathcal{G} \times \mathcal{B},\ \mathrm{gen\_bt}: \mathcal{G} \to \mathcal{B} \times \mathcal{E}`$, `area_of` is `absent` where the data has no row, `connection` is `absent` where the data has no row |
| $`\mathcal{Z}`$ | index $`z`$ — `zone` with $`\mathrm{zone\_of}: \mathcal{B} \to \mathcal{Z},\ \mathrm{area\_of}: \mathcal{B} \to \mathcal{Z},\ \mathrm{gen\_zone}: \mathcal{G} \times \mathcal{T} \to \mathcal{Z}`$, `area_of` is `absent` where the data has no row |
| $`\mathcal{G}`$ | index $`g`$ — `generator` with $`\mathrm{gen\_bus}: \mathcal{G} \to \mathcal{B},\ \mathrm{gen\_zone}: \mathcal{G} \times \mathcal{T} \to \mathcal{Z},\ \mathrm{connection} \subseteq \mathcal{G} \times \mathcal{B},\ \mathrm{gen\_bt}: \mathcal{G} \to \mathcal{B} \times \mathcal{E},\ \mathrm{dispatch\_pair} \subseteq \mathcal{G} \times \mathcal{B},\ \mathrm{fuel}: \mathcal{G} \to \mathcal{E},\ \mathrm{area}: \mathrm{dispatch\_pair} \to \mathcal{Z}`$, `connection` is `absent` where the data has no row, `dispatch_pair` is `absent` where the data has no row, `generator[fuel]` is `absent` where the data has no row |
| $`\mathcal{B}`$ | index $`b`$ — `bus` with $`\mathrm{gen\_bus}: \mathcal{G} \to \mathcal{B},\ \mathrm{zone\_of}: \mathcal{B} \to \mathcal{Z},\ \mathrm{area\_of}: \mathcal{B} \to \mathcal{Z},\ \mathrm{connection} \subseteq \mathcal{G} \times \mathcal{B},\ \mathrm{gen\_bt}: \mathcal{G} \to \mathcal{B} \times \mathcal{E},\ \mathrm{dispatch\_pair} \subseteq \mathcal{G} \times \mathcal{B},\ \mathrm{area}: \mathrm{dispatch\_pair} \to \mathcal{Z}`$, `area_of` is `absent` where the data has no row, `connection` is `absent` where the data has no row, `dispatch_pair` is `absent` where the data has no row |
| $`\mathcal{Z}`$ | index $`z`$ — `zone` with $`\mathrm{zone\_of}: \mathcal{B} \to \mathcal{Z},\ \mathrm{area\_of}: \mathcal{B} \to \mathcal{Z},\ \mathrm{gen\_zone}: \mathcal{G} \times \mathcal{T} \to \mathcal{Z},\ \mathrm{area}: \mathrm{dispatch\_pair} \to \mathcal{Z}`$, `area_of` is `absent` where the data has no row |
| $`\mathcal{S}`$ | index $`s`$ — `season` with $`\mathrm{season\_of}: \mathcal{T} \to \mathcal{S}`$ |
| $`\mathcal{E}`$ | index $`e`$ — `technology` with $`\mathrm{gen\_bt}: \mathcal{G} \to \mathcal{B} \times \mathcal{E}`$ |
| $`\mathcal{E}`$ | index $`e`$ — `technology` with $`\mathrm{gen\_bt}: \mathcal{G} \to \mathcal{B} \times \mathcal{E},\ \mathrm{fuel}: \mathcal{G} \to \mathcal{E}`$, `generator[fuel]` is `absent` where the data has no row |
| $`\mathcal{A}`$ | index $`a`$ — `bp` |

#### Parameters
Expand Down Expand Up @@ -512,6 +512,52 @@ constraints:
\sum_{t' \in \mathcal{T} \,:\, \mathrm{rep\_of}(t') = t} \mathit{spill}_{t'} \le \mathit{spill}_{\mathrm{rep\_of}(t)} \qquad \forall\, t \in \mathcal{T}
```

#### Sum through a property of a dimension

a sum through a property of a dimension, read as the property's own name

```yaml
constraints:
by_fuel:
dims: [snapshot, technology]
expression: sum(p, over=generator, by=generator[fuel]) <= budget
```

```math
\sum_{g \in \mathcal{G} \,:\, \mathrm{fuel}(g) = e} p_{t,g} \le \mathrm{budget} \qquad \forall\, t \in \mathcal{T},\ e \in \mathcal{E}
```

#### Sum through a property of a set

a sum through a property of a set, over both of its members

```yaml
constraints:
by_area:
dims: [snapshot, zone]
expression: sum(p * link_eff, over=[generator, bus], by=dispatch_pair[area]) <= zone_cap
```

```math
\sum_{g \in \mathcal{G},\ b \in \mathcal{B} \,:\, \mathrm{area}(g,\ b) = z} p_{t,g} \cdot \mathrm{link\_eff}_{g,b} \le \mathrm{zone\_cap}_{z} \qquad \forall\, t \in \mathcal{T},\ z \in \mathcal{Z}
```

#### Property as a condition

a property alone as a where: the row has it

```yaml
constraints:
fuelled:
dims: [snapshot, generator]
where: "generator[fuel]"
expression: p <= budget
```

```math
p_{t,g} \le \mathrm{budget} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{fuel}(g) \text{ is defined}
```

#### Sum through a two-key map

a grouping through a two-key map, summing one key away: the condition reads the other, and the row keeps it
Expand Down
62 changes: 62 additions & 0 deletions docs/reference/reading.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,68 @@ there too. A `Not` of it is then true. So `connection != 'input'` keeps no
coordinate that `connection` leaves out, and `NOT connection == 'input'` keeps
every one.

## The tables of sets and properties

The data for a set or a property arrives as tables. The canonical form has one
table for each set and one for each property, and no table holds a null.

| Table | Name | Columns |
| ------------------------- | ------------------ | ----------------------------------------------------------- |
| a set | the set's name | one column per member, named after it |
| a property of a dimension | `subject.property` | the dimension, then one column named after the property |
| a property of a set | `subject.property` | the set's members, then one column named after the property |

- Every value is a label of the dimension its column is over.
- Every row of a property's table is a row of its subject: a label of the
dimension, or a row of the set's table. The engine refuses a row that is not.
- A set under `missing: 'refused'` has a row for every combination of its
dimensions. A property under `missing: 'refused'` has a row for every row of
its subject, which for a sparse set is every row the set lists.

`program.relations` holds a `Relation` for each of these tables. A set is a
relation with no value columns, under its own name. A property is a relation
under `subject.property`, keyed by the subject's members, with one value
column, and its `subject` names the dimension or set whose rows its key ranges
over. A relation from `relations:` has `subject` set to `None`.

```python
from mathspec import to_spec

network = to_spec(
{
'dimensions': {'generator': {}, 'bus': {}, 'carrier': {}, 'period': {'dtype': 'int'}, 'zone': {}},
'sets': {'generator_period': {'dims': ['generator', 'period'], 'missing': 'absent'}},
'properties': {
'generator': {'bus': 'bus', 'carrier': {'into': 'carrier', 'missing': 'absent'}},
'generator_period': {'zone': 'zone'},
},
'parameters': {'cap': {'dims': ['bus', 'carrier']}},
'expressions': {'cap_of': 'at(cap, by=generator[bus, carrier])'},
}
).program
sorted(network.relations) # ['generator.bus', 'generator.carrier', 'generator_period', 'generator_period.zone']
network.relations['generator_period.zone'].key # ('generator', 'period')
network.relations['generator_period.zone'].subject # 'generator_period'
network.relations['generator.carrier'].missing # 'absent'
join = network.expressions['cap_of'].expression.columns
join.name # 'generator'
join.tables # ('generator.bus', 'generator.carrier')
```

A call that reads several properties of one subject reads their inner join. Its
`JoinColumns` or `Partition` carries that join as its `relation`, with `name`
and `relation.subject` both set to the subject, and the program holds no table
of that name. The engine builds the join from the tables in `.tables`, on the
subject's members, and keeps a row only where every one of the tables has it.
Everywhere else `.tables` is the one table `name`.

An engine may also accept a **wide table** for each subject: the subject's key
columns, then one column per property. A null in a property column means the
row does not have that property. The canonical table of a property is then the
rows of the wide table where that column is not null, so the two forms hold the
same data. A wide table for a set is also the set's own table, so its rows are
the set's rows whatever its property columns hold.

## Nodes and masks

Import the node classes from `mathspec.program` to test a node with
Expand Down
18 changes: 16 additions & 2 deletions src/mathspec/_typesetting/legend.py
Original file line number Diff line number Diff line change
Expand Up @@ -264,11 +264,20 @@ def _missing(self, value: ParameterMissing, default: str = 'refused', where: str
return f', {shown} {where}'

def _signature(self, name: str, lk: Relation) -> str:
"""A relation in the legend: a function from its key sets to its value sets, or a relation inside the product."""
"""A relation in the legend: a function from its key sets to its value sets, or a relation inside the product.

A property is named after itself and maps from its subject: the set of
a dimension, or a set by its own name, since a property of a sparse set
is defined only on the rows the set lists.
"""

def product(roles: Iterable[str]) -> str:
return self.format.joined([self.symbols.set[lk.dim(r)] for r in roles], self._op('times'))

if lk.subject is not None:
(value,) = lk.values
domain = product(lk.key) if lk.subject in self.program.dimensions else self.format.upright(lk.subject)
return f'{self.format.upright(value)}: {domain} {self._op("maps_to")} {product(lk.values)}'
if lk.values:
return f'{self.format.upright(name)}: {product(lk.key)} {self._op("maps_to")} {product(lk.values)}'
return f'{self.format.upright(name)} {self._op("subset_of")} {product(lk.roles)}'
Expand All @@ -288,7 +297,7 @@ def _coords(self, dim: str, noticed: Noticed) -> str:
maps = self.format.joined([self._signature(c, lk) for c, lk in carried.items()], '')
clauses.append(f' with {self.format.math(maps)}')
clauses.extend(
f', {self.format.mono(c)} is {self.format.mono("absent")} {_NO_ROW}'
f', {self.format.mono(_spelled(c, lk))} is {self.format.mono("absent")} {_NO_ROW}'
for c, lk in carried.items()
if lk.missing == 'absent'
)
Expand Down Expand Up @@ -342,3 +351,8 @@ def _notation(self, noticed: Noticed) -> list[LegendItem]:
LegendItem(size, f'the size of {fmt.math(fmt.script("T"))}; {fmt.math(last)} is the last position')
)
return items


def _spelled(name: str, lk: Relation) -> str:
"""A table as the file reads it: ``line[bus1]`` for a property, and the name for a relation or a set."""
return f'{lk.subject}[{lk.values[0]}]' if lk.subject is not None else name
19 changes: 14 additions & 5 deletions src/mathspec/_typesetting/walk.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,11 +268,12 @@ def _relation_read(self, name: str, lk: Relation, at: Mapping[str, str], read: s

*lk* is the relation's shape, which a call carries, since an inner join
of several properties is no table of the program. *at* maps each key
role to the index it is read at. The function is named after the
relation alone where the key determines one column, and after the
column read otherwise.
role to the index it is read at. A property is named after itself, as
the file writes it in ``subject[property]``. A relation is named after
itself where the key determines one column, and after the column read
otherwise.
"""
function = name if len(lk.values) == 1 else f'{name}.{read}'
function = _function(name, lk, read)
return self.format.apply(self.format.upright(function), self.format.joined([at[k] for k in lk.key], ''))

def _relation_row(self, name: str, lk: Relation, at: Mapping[str, str]) -> str:
Expand All @@ -285,7 +286,8 @@ def _relation_row(self, name: str, lk: Relation, at: Mapping[str, str]) -> str:
"""
key = self.format.joined([at[k] for k in lk.key], '')
if lk.values:
return f'{self.format.apply(self.format.upright(name), key)} {self.format.prose(" is defined")}'
reads = [self.format.apply(self.format.upright(_function(name, lk, v)), key) for v in lk.values]
return f'{self.format.joined(reads, ", ")} {self.format.prose(" is defined")}'
return f'{self.format.parenthesise(key)} {self._op("in")} {self.format.upright(name)}'

def _frame_key(self, lk: Relation, ctx: _Context) -> dict[str, str]:
Expand Down Expand Up @@ -985,3 +987,10 @@ def _bound(self, ctx: _Context, value: Expression) -> str:
def _sorted(self, dims: frozenset[str]) -> list[str]:
order = list(self.program.dimensions)
return sorted(dims, key=order.index)


def _function(name: str, lk: Relation, read: str) -> str:
"""The function a read of column *read* of *name* prints as: the property, the relation, or ``relation.column``."""
if lk.subject is not None:
return read
return name if len(lk.values) == 1 else f'{name}.{read}'
57 changes: 43 additions & 14 deletions tests/test_property_calls.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import pytest

from mathspec import to_spec
from mathspec._typesetting import FORMATS, typeset
from mathspec._typesetting import typeset
from mathspec.errors import LanguageError
from mathspec.program import (
And,
Expand Down Expand Up @@ -304,17 +304,46 @@ def test_a_property_read_the_language_refuses(patch, fragment):
assert fragment in _refusal(**patch)


@pytest.mark.parametrize('fmt', FORMATS)
def test_the_typesetter_prints_a_read_of_several_properties(fmt):
spec = varied(
CALLS,
**{
'variables.x.where': 'generator[bus, carrier]',
'constraints.c': {
'dims': ['bus', 'carrier', 'snapshot'],
'expression': 'sum(x, over=generator, by=generator[bus, carrier]) <= cap',
},
PRINTED = varied(
CALLS,
**{
'variables.f.where': 'line[bus1]',
'variables.y': {'dims': ['generator', 'period']},
'constraints.c': {
'dims': ['bus', 'carrier', 'snapshot'],
'expression': 'sum(x, over=generator, by=generator[bus, carrier]) <= cap',
},
)
printed = typeset(spec, fmt)
assert 'carrier' in printed
'constraints.d': {'dims': ['generator', 'period'], 'expression': 'y >= at(price, by=generator_period[zone])'},
},
)


@pytest.mark.parametrize(
('fmt', 'fragment'),
[
pytest.param('latex', r'\mathrm{bus}(g) = b \wedge \mathrm{carrier}(g) = c', id='latex-two-properties'),
pytest.param('latex', r'\mathrm{bus1}(l) \text{ is defined}', id='latex-presence'),
pytest.param('latex', r'\mathrm{price}_{\mathrm{zone}(g,\ r),r}', id='latex-a-lookup'),
pytest.param('latex', r'\mathrm{bus}: \mathcal{G} \to \mathcal{B}', id='latex-legend-of-a-dimension'),
pytest.param('latex', r'\mathrm{zone}: \mathrm{generator\_period} \to \mathcal{Z}', id='latex-legend-of-a-set'),
pytest.param(
'latex', r'\texttt{line[bus1]} is \texttt{absent} where the data has no row', id='latex-legend-absent'
),
pytest.param('typst', 'upright("bus")(g) = b and upright("carrier")(g) = c', id='typst-two-properties'),
pytest.param('typst', 'upright("bus1")(l) upright(" is defined")', id='typst-presence'),
pytest.param(
'typst', 'upright("zone"): upright("generator_period") arrow.r cal(Z)', id='typst-legend-of-a-set'
),
pytest.param('typst', '`line[bus1]` is `absent` where the data has no row', id='typst-legend-absent'),
pytest.param('markdown', r'\mathrm{bus}(g) = b \wedge \mathrm{carrier}(g) = c', id='markdown-two-properties'),
pytest.param(
'markdown', r'\mathrm{zone}: \mathrm{generator\_period} \to \mathcal{Z}', id='markdown-legend-of-a-set'
),
pytest.param('markdown', '`line[bus1]` is `absent` where the data has no row', id='markdown-legend-absent'),
],
)
def test_the_typesetter_names_a_property_after_itself(fmt, fragment):
"""A read printed as the table's name, such as `generator.bus(g)`, is a name the file never writes."""
printed = typeset(PRINTED, fmt)
assert fragment in printed
assert 'generator.bus' not in printed, 'no table name the file cannot write reaches the page'
2 changes: 1 addition & 1 deletion tests/test_reading_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,6 @@ def test_the_page_shows_the_entries_the_expansion_emits(tmp_path, monkeypatch):
exec(compile(code, str(PAGE), 'exec'), namespace)
claims.extend(_claims(code))

assert len(claims) == 24, 'every `expression # value` line on the page is checked; one without one is not'
assert len(claims) == 30, 'every `expression # value` line on the page is checked; one without one is not'
for expression, claimed in claims:
assert eval(expression, namespace) == claimed, f'reading.md says `{expression}` is {claimed}'
Loading