diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ce6e8cb..1f95e783 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)) diff --git a/docs/reference/language/sets.md b/docs/reference/language/sets.md index 4511276c..16468f28 100644 --- a/docs/reference/language/sets.md +++ b/docs/reference/language/sets.md @@ -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 @@ -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 diff --git a/docs/reference/notation.md b/docs/reference/notation.md index c715929c..143328ef 100644 --- a/docs/reference/notation.md +++ b/docs/reference/notation.md @@ -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 @@ -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 diff --git a/docs/reference/reading.md b/docs/reference/reading.md index eeb49720..f3498d77 100644 --- a/docs/reference/reading.md +++ b/docs/reference/reading.md @@ -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 diff --git a/src/mathspec/_typesetting/legend.py b/src/mathspec/_typesetting/legend.py index ca17cc77..c00aa6e7 100644 --- a/src/mathspec/_typesetting/legend.py +++ b/src/mathspec/_typesetting/legend.py @@ -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)}' @@ -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' ) @@ -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 diff --git a/src/mathspec/_typesetting/walk.py b/src/mathspec/_typesetting/walk.py index b5e1585a..0bbe5257 100644 --- a/src/mathspec/_typesetting/walk.py +++ b/src/mathspec/_typesetting/walk.py @@ -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: @@ -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]: @@ -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}' diff --git a/tests/test_property_calls.py b/tests/test_property_calls.py index 81b53658..aa59f5b5 100644 --- a/tests/test_property_calls.py +++ b/tests/test_property_calls.py @@ -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, @@ -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' diff --git a/tests/test_reading_page.py b/tests/test_reading_page.py index f27595ee..ae1bf2cd 100644 --- a/tests/test_reading_page.py +++ b/tests/test_reading_page.py @@ -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}' diff --git a/tests/typesetting/golden/latex.out b/tests/typesetting/golden/latex.out index b51062aa..91eb2d34 100644 --- a/tests/typesetting/golden/latex.out +++ b/tests/typesetting/golden/latex.out @@ -10,11 +10,11 @@ \paragraph{Sets} \begin{description} \item[{$\mathcal{T}$}] index $t$ --- \texttt{snapshot} (\texttt{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}$ -\item[{$\mathcal{G}$}] index $g$ --- \texttt{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}$, \texttt{connection} is \texttt{absent} where the data has no row -\item[{$\mathcal{B}$}] index $b$ --- \texttt{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}$, \texttt{area\_of} is \texttt{absent} where the data has no row, \texttt{connection} is \texttt{absent} where the data has no row -\item[{$\mathcal{Z}$}] index $z$ --- \texttt{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}$, \texttt{area\_of} is \texttt{absent} where the data has no row +\item[{$\mathcal{G}$}] index $g$ --- \texttt{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}$, \texttt{connection} is \texttt{absent} where the data has no row, \texttt{dispatch\_pair} is \texttt{absent} where the data has no row, \texttt{generator[fuel]} is \texttt{absent} where the data has no row +\item[{$\mathcal{B}$}] index $b$ --- \texttt{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}$, \texttt{area\_of} is \texttt{absent} where the data has no row, \texttt{connection} is \texttt{absent} where the data has no row, \texttt{dispatch\_pair} is \texttt{absent} where the data has no row +\item[{$\mathcal{Z}$}] index $z$ --- \texttt{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}$, \texttt{area\_of} is \texttt{absent} where the data has no row \item[{$\mathcal{S}$}] index $s$ --- \texttt{season} with $\mathrm{season\_of}: \mathcal{T} \to \mathcal{S}$ -\item[{$\mathcal{E}$}] index $e$ --- \texttt{technology} with $\mathrm{gen\_bt}: \mathcal{G} \to \mathcal{B} \times \mathcal{E}$ +\item[{$\mathcal{E}$}] index $e$ --- \texttt{technology} with $\mathrm{gen\_bt}: \mathcal{G} \to \mathcal{B} \times \mathcal{E},\ \mathrm{fuel}: \mathcal{G} \to \mathcal{E}$, \texttt{generator[fuel]} is \texttt{absent} where the data has no row \item[{$\mathcal{A}$}] index $a$ --- \texttt{bp} \end{description} @@ -150,6 +150,9 @@ \text{relational} && \sum_{g \in \mathcal{G} \,:\, \left( g,\ b \right) \in \mathrm{connection}} p_{t,g} & \le \mathrm{load}_{t,b} && \forall\, t \in \mathcal{T},\ b \in \mathcal{B} \\ \text{connected} && p_{t,g} & \le \mathrm{load}_{t,b} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G},\ b \in \mathcal{B} \,:\, \left( g,\ b \right) \in \mathrm{connection} \\ \text{representative} && \sum_{t' \in \mathcal{T} \,:\, \mathrm{rep\_of}(t') = t} \mathit{spill}_{t'} & \le \mathit{spill}_{\mathrm{rep\_of}(t)} && \forall\, t \in \mathcal{T} \\ +\text{by\_fuel} && \sum_{g \in \mathcal{G} \,:\, \mathrm{fuel}(g) = e} p_{t,g} & \le \mathrm{budget} && \forall\, t \in \mathcal{T},\ e \in \mathcal{E} \\ +\text{by\_area} && \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} && \forall\, t \in \mathcal{T},\ z \in \mathcal{Z} \\ +\text{fuelled} && p_{t,g} & \le \mathrm{budget} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{fuel}(g) \text{ is defined} \\ \text{zonal} && \sum_{g \in \mathcal{G} \,:\, \mathrm{gen\_zone}(g,\ t) = z} p_{t,g} & \le \mathrm{zone\_cap}_{z} && \forall\, t \in \mathcal{T},\ z \in \mathcal{Z} \\ \text{zonal\_history} && \sum_{t \in \mathcal{T} \,:\, \mathrm{gen\_zone}(g,\ t) = z} p_{t,g} & \le \mathrm{zone\_cap}_{z} && \forall\, g \in \mathcal{G},\ z \in \mathcal{Z} \\ \text{zonal\_membership} && \sum_{g \in \mathcal{G} \,:\, \mathrm{gen\_zone}(g,\ t) \text{ is defined}} \mathit{units}_{g} & \le \mathrm{budget} && \forall\, t \in \mathcal{T} \\ diff --git a/tests/typesetting/golden/markdown.out b/tests/typesetting/golden/markdown.out index 84f7b7c5..235c0c72 100644 --- a/tests/typesetting/golden/markdown.out +++ b/tests/typesetting/golden/markdown.out @@ -7,11 +7,11 @@ every character a notation escapes, set as text: link\_to, 100% & \#1 costs \$5 | 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 @@ -381,6 +381,24 @@ p_{t,g} \le \mathrm{load}_{t,b} \qquad \forall\, t \in \mathcal{T},\ g \in \math \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} ``` +**`by_fuel`** + +```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} +``` + +**`by_area`** + +```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} +``` + +**`fuelled`** + +```math +p_{t,g} \le \mathrm{budget} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{fuel}(g) \text{ is defined} +``` + **`zonal`** ```math diff --git a/tests/typesetting/golden/model.yaml b/tests/typesetting/golden/model.yaml index d6e00d05..934ad2ed 100644 --- a/tests/typesetting/golden/model.yaml +++ b/tests/typesetting/golden/model.yaml @@ -31,6 +31,15 @@ relations: connection: { key: [generator, bus], missing: absent } # a bare relation, with no value columns: many-to-many, read only by sum with both ends named gen_bt: { key: generator, values: [bus, technology] } # one table with two value columns, read to both at once +sets: + dispatch_pair: { dims: [generator, bus], missing: absent } # a set: the pairs that exist, and the subject of a property below + +properties: + generator: + fuel: { into: technology, missing: absent } # a property of a dimension: the legend names it after itself, and says a generator may have none + dispatch_pair: + area: zone # a property of a set: the legend maps it from the set by name + parameters: p_max: { dims: [generator], missing: .inf } # a value: the legend says what a missing row reads as p_min: { dims: [generator] } @@ -244,6 +253,16 @@ constraints: representative: # a map into its own dimension, read both ways: the frame is unchanged and the index is primed dims: [snapshot] expression: sum(spill, over=snapshot, by=rep_of[rep]) <= at(spill, by=rep_of[rep]) + by_fuel: # a sum through a property of a dimension, read as the property's own name + dims: [snapshot, technology] + expression: sum(p, over=generator, by=generator[fuel]) <= budget + by_area: # a sum through a property of a set, over both of its members + dims: [snapshot, zone] + expression: sum(p * link_eff, over=[generator, bus], by=dispatch_pair[area]) <= zone_cap + fuelled: # a property alone as a where: the row has it + dims: [snapshot, generator] + where: "generator[fuel]" + expression: p <= budget zonal: # a grouping through a two-key map, summing one key away: the condition reads the other, and the row keeps it dims: [snapshot, zone] expression: sum(p, over=generator, by=gen_zone[zone]) <= zone_cap diff --git a/tests/typesetting/golden/typst.out b/tests/typesetting/golden/typst.out index 87d3e83e..1e1e5a01 100644 --- a/tests/typesetting/golden/typst.out +++ b/tests/typesetting/golden/typst.out @@ -5,11 +5,11 @@ every character a notation escapes, set as text: link\_to, 100% & \#1 costs \$5 == Sets / $cal(T)$: index $t$ --- `snapshot` (`int` coordinates) with $upright("season_of"): cal(T) arrow.r cal(S), upright("gen_zone"): cal(G) times cal(T) arrow.r cal(Z), upright("rep_of"): cal(T) arrow.r cal(T)$ -/ $cal(G)$: index $g$ --- `generator` with $upright("gen_bus"): cal(G) arrow.r cal(B), upright("gen_zone"): cal(G) times cal(T) arrow.r cal(Z), upright("connection") subset.eq cal(G) times cal(B), upright("gen_bt"): cal(G) arrow.r cal(B) times cal(E)$, `connection` is `absent` where the data has no row -/ $cal(B)$: index $b$ --- `bus` with $upright("gen_bus"): cal(G) arrow.r cal(B), upright("zone_of"): cal(B) arrow.r cal(Z), upright("area_of"): cal(B) arrow.r cal(Z), upright("connection") subset.eq cal(G) times cal(B), upright("gen_bt"): cal(G) arrow.r cal(B) times cal(E)$, `area_of` is `absent` where the data has no row, `connection` is `absent` where the data has no row -/ $cal(Z)$: index $z$ --- `zone` with $upright("zone_of"): cal(B) arrow.r cal(Z), upright("area_of"): cal(B) arrow.r cal(Z), upright("gen_zone"): cal(G) times cal(T) arrow.r cal(Z)$, `area_of` is `absent` where the data has no row +/ $cal(G)$: index $g$ --- `generator` with $upright("gen_bus"): cal(G) arrow.r cal(B), upright("gen_zone"): cal(G) times cal(T) arrow.r cal(Z), upright("connection") subset.eq cal(G) times cal(B), upright("gen_bt"): cal(G) arrow.r cal(B) times cal(E), upright("dispatch_pair") subset.eq cal(G) times cal(B), upright("fuel"): cal(G) arrow.r cal(E), upright("area"): upright("dispatch_pair") arrow.r cal(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 +/ $cal(B)$: index $b$ --- `bus` with $upright("gen_bus"): cal(G) arrow.r cal(B), upright("zone_of"): cal(B) arrow.r cal(Z), upright("area_of"): cal(B) arrow.r cal(Z), upright("connection") subset.eq cal(G) times cal(B), upright("gen_bt"): cal(G) arrow.r cal(B) times cal(E), upright("dispatch_pair") subset.eq cal(G) times cal(B), upright("area"): upright("dispatch_pair") arrow.r cal(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 +/ $cal(Z)$: index $z$ --- `zone` with $upright("zone_of"): cal(B) arrow.r cal(Z), upright("area_of"): cal(B) arrow.r cal(Z), upright("gen_zone"): cal(G) times cal(T) arrow.r cal(Z), upright("area"): upright("dispatch_pair") arrow.r cal(Z)$, `area_of` is `absent` where the data has no row / $cal(S)$: index $s$ --- `season` with $upright("season_of"): cal(T) arrow.r cal(S)$ -/ $cal(E)$: index $e$ --- `technology` with $upright("gen_bt"): cal(G) arrow.r cal(B) times cal(E)$ +/ $cal(E)$: index $e$ --- `technology` with $upright("gen_bt"): cal(G) arrow.r cal(B) times cal(E), upright("fuel"): cal(G) arrow.r cal(E)$, `generator[fuel]` is `absent` where the data has no row / $cal(A)$: index $a$ --- `bp` == Parameters @@ -130,6 +130,9 @@ $ upright("budgeted") & italic("spend")_(t) & <= upright("budget") & forall t in upright("relational") & sum_(g in cal(G) colon (g, b) in upright("connection")) p_(t,g) & <= upright("load")_(t,b) & forall t in cal(T), b in cal(B) \ upright("connected") & p_(t,g) & <= upright("load")_(t,b) & forall t in cal(T), g in cal(G), b in cal(B) colon (g, b) in upright("connection") \ upright("representative") & sum_(t' in cal(T) colon upright("rep_of")(t') = t) italic("spill")_(t') & <= italic("spill")_(upright("rep_of")(t)) & forall t in cal(T) \ + upright("by_fuel") & sum_(g in cal(G) colon upright("fuel")(g) = e) p_(t,g) & <= upright("budget") & forall t in cal(T), e in cal(E) \ + upright("by_area") & sum_(g in cal(G), b in cal(B) colon upright("area")(g, b) = z) p_(t,g) dot upright("link_eff")_(g,b) & <= upright("zone_cap")_(z) & forall t in cal(T), z in cal(Z) \ + upright("fuelled") & p_(t,g) & <= upright("budget") & forall t in cal(T), g in cal(G) colon upright("fuel")(g) upright(" is defined") \ upright("zonal") & sum_(g in cal(G) colon upright("gen_zone")(g, t) = z) p_(t,g) & <= upright("zone_cap")_(z) & forall t in cal(T), z in cal(Z) \ upright("zonal_history") & sum_(t in cal(T) colon upright("gen_zone")(g, t) = z) p_(t,g) & <= upright("zone_cap")_(z) & forall g in cal(G), z in cal(Z) \ upright("zonal_membership") & sum_(g in cal(G) colon upright("gen_zone")(g, t) upright(" is defined")) italic("units")_(g) & <= upright("budget") & forall t in cal(T) \ diff --git a/tools/notation.py b/tools/notation.py index 151e7d0f..7a604da1 100644 --- a/tools/notation.py +++ b/tools/notation.py @@ -90,6 +90,9 @@ 'relational': 'Sum through a bare relation', 'connected': 'Bare relation as a condition', 'representative': 'Map into its own dimension', + 'by_fuel': 'Sum through a property of a dimension', + 'by_area': 'Sum through a property of a set', + 'fuelled': 'Property as a condition', 'zonal': 'Sum through a two-key map', 'zonal_history': 'Sum over the other key of a two-key map', 'zonal_membership': 'Sum between the two keys of a map',