diff --git a/CHANGELOG.md b/CHANGELOG.md index 076db77b..ad5c7285 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(program): the axis a join opens names the relation column it stands for ([#665](https://github.com/energy-models/mathspec/pull/665)) - feat(language): a call names the columns of a relation as relation[column], and a sum through a relation is a sum over the axes its join opens ([#664](https://github.com/energy-models/mathspec/pull/664)) - feat(language): a parameter value that is null or NaN is refused when the data is attached ([#788](https://github.com/energy-models/mathspec/pull/788)) - fix(language): a where string names several columns in at's over= and into=, as an expression does ([#782](https://github.com/energy-models/mathspec/pull/782)) diff --git a/docs/about/relations-as-linear-maps.md b/docs/about/relations-as-linear-maps.md index 1ac29638..e9530465 100644 --- a/docs/about/relations-as-linear-maps.md +++ b/docs/about/relations-as-linear-maps.md @@ -133,9 +133,10 @@ $`x`$ over generators and $`y`$ over buses, which is why the program lowers `at` to a `Join` node and `sum(by=)` to the same `Join` under a `Sum`. The join keeps the column it sums away as an axis -named for the relation's column, `gen_zone.generator`, and the `Sum` stands -over that axis. So a map into its own dimension, which drops and adds one -dimension, still has two axes between the join and the sum. A bare relation has +of its own, `Axis('generator', Column('gen_zone', 'generator'))`: it runs over +`generator` and stands for the relation's column. The `Sum` stands over that +axis. So a map into its own dimension, which drops and adds one dimension, +still has two axes between the join and the sum. A bare relation has the same matrix without the functional claim. A column of $`M`$ may hold several ones, so the sum fans out and no group is one row. That is why `at` through a bare relation is refused. diff --git a/docs/contributing.md b/docs/contributing.md index cc36caeb..fc9dcbe7 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -111,7 +111,7 @@ to two nodes, so the file's spelling cannot decide the name. | File verb | Node | What the node names | | ------------------ | ------------------- | ---------------------------------------------------- | -| `sum(over=)` | `Sum` | dims removed from the result | +| `sum(over=)` | `Sum` | axes removed from the result | | `sum(by=)` | `Sum` over a `Join` | a join, and the sum over the axes it opens | | `at(by=)` | `Join` | a join whose groups are one row, with no sum over it | | `shift(along=)` | `Translate` | a re-index along one dimension | diff --git a/src/mathspec/_expression_resolver.py b/src/mathspec/_expression_resolver.py index ddd8d25c..560776d8 100644 --- a/src/mathspec/_expression_resolver.py +++ b/src/mathspec/_expression_resolver.py @@ -43,6 +43,7 @@ ) from mathspec.program import ( Add, + Axis, Constant, Divide, Dual, @@ -295,14 +296,14 @@ def _sum( if over is None: return self._bare_sum(operand) assert not isinstance(over, ColumnsNode), 'over=relation[column] is read only beside by=' - return Sum(operand, over) + return Sum(operand, tuple(Axis(d) for d in over)) assert over is not None, 'the call shape requires over= beside by=' grouping = self._grouping(columns, over) if grouping is None: return None join, plain = grouping grouped = Sum(Join(operand, join), join.axes) - return Sum(grouped, plain) if plain else grouped + return Sum(grouped, tuple(Axis(d) for d in plain)) if plain else grouped def _at(self, operand: Expression, columns: ColumnsNode) -> Expression | None: """``at(x, by=relation[column])``: *operand* read at the value of *columns* each row of the relation holds.""" @@ -356,7 +357,7 @@ def _bare_sum(self, operand: Expression) -> Expression | None: f'scalar. Drop the sum.' ) return None - return Sum(operand, tuple(sorted(inner))) + return Sum(operand, tuple(Axis(d) for d in sorted(inner))) def _edge_fits(self, operand: Expression, offset: int | str, *, wrap: bool, fill: float | None) -> bool: """What a ``shift``'s ``edge=`` may say, and where saying nothing is an answer. diff --git a/src/mathspec/dimensions.py b/src/mathspec/dimensions.py index 825356f8..64b176f3 100644 --- a/src/mathspec/dimensions.py +++ b/src/mathspec/dimensions.py @@ -85,10 +85,11 @@ def dims_of(node: Expression, schema: Spec, context: str) -> frozenset[str]: if isinstance(node, Negate | Add | Multiply | Power | Divide): return frozenset().union(*(dims_of(child, schema, context) for child in children(node))) - inner = dims_of(node.operand, schema, context) if isinstance(node, Sum): - return _sum_dims(node, inner, context) + return _sum_dims(node, schema, context) + inner = dims_of(node.operand, schema, context) if isinstance(node, Join): + assert not node.columns.axes, 'a join that opens axes is read only by the sum that closes them' return join_dims(node.columns, inner, context, 'the expression') if isinstance(node, Translate | WindowSum): return _translation_dims(node, inner, schema, context) @@ -112,21 +113,28 @@ def _not_carried(context: str, call: str, inner: frozenset[str], rewrite: str) - ) -def _sum_dims(node: Sum, inner: frozenset[str], context: str) -> frozenset[str]: - """``sum`` reduces each named dim away, so the operand carries every one.""" +def _sum_dims(node: Sum, schema: Spec, context: str) -> frozenset[str]: + """``sum`` reduces each dim in ``over`` away, so the operand carries every one. + + The axes a join opens never reach a frame: the sum over the join closes + every one, so its frame is the join's frame less them. + """ + if isinstance(node.operand, Join) and node.operand.columns.axes: + join = node.operand + assert node.over == join.columns.axes, 'a sum through a relation closes exactly the axes its join opens' + return join_dims(join.columns, dims_of(join.operand, schema, context), context, 'the expression') + inner = dims_of(node.operand, schema, context) for summed in node.over: - if summed not in inner: + if summed.dimension not in inner: raise DimensionError(_not_carried(context, f'sum(over={summed})', inner, 'drop the sum, or fix the dim')) - return inner - frozenset(node.over) + return inner - {axis.dimension for axis in node.over} def join_dims(columns: JoinColumns, inner: frozenset[str], context: str, operand: str) -> frozenset[str]: """The dims *inner* has once *columns* joins it, an expression's or a predicate's alike. - The dims joined on go, the dims grouped by arrive, and each column joined - on and not grouped by opens its own axis ([`JoinColumns.axes`][mathspec.program.JoinColumns.axes]), which - the [`Sum`][mathspec.program.Sum] over the join takes away. A column - both joined on and grouped by keeps its dim. The call a refusal quotes is + The dims joined on go and the dims grouped by arrive. A column both + joined on and grouped by keeps its dim. The call a refusal quotes is ``at`` where each group is one row, and ``sum`` otherwise. Raises: @@ -155,7 +163,7 @@ def join_dims(columns: JoinColumns, inner: frozenset[str], context: str, operand f'or group by a column over another dimension.' ) _check_joined(call, columns, inner, context) - return (inner - set(columns.joined_dims)) | set(columns.grouped_dims) | set(columns.axes) + return (inner - set(columns.joined_dims)) | set(columns.grouped_dims) #: The verb a file writes each translation with, which its refusals quote. diff --git a/src/mathspec/program.py b/src/mathspec/program.py index 441d56c8..d518a8d9 100644 --- a/src/mathspec/program.py +++ b/src/mathspec/program.py @@ -39,8 +39,10 @@ 'Add', 'And', 'Assumption', + 'Axis', 'BooleanLiteral', 'Cases', + 'Column', 'Connective', 'Constant', 'ConstraintDeclaration', @@ -245,19 +247,50 @@ class Divide: divisor: Expression +@dataclass(frozen=True) +class Column: + """One column of a relation: the relation's name, and the column's name in its declaration.""" + + relation: str + name: str + + def __str__(self) -> str: + """The column as a file writes it, ``relation[column]``.""" + return f'{self.relation}[{self.name}]' + + +@dataclass(frozen=True) +class Axis: + """A position in a frame: the dimension whose labels it runs over, and the relation column it stands for. + + A declared dimension is the axis ``Axis(dimension)``. A [`Join`][] + opens ``Axis(dimension, column)`` for each column it drops, so a column + dropped and a column added over one dimension are two axes of one frame. + Two axes are equal where both fields are, so a frame holds a dimension's + own axis at most once however many join axes run over the same labels. + """ + + dimension: str + column: Column | None = None + + def __str__(self) -> str: + """The dimension's name, or the column's ``relation[column]`` for a join's axis.""" + return self.dimension if self.column is None else str(self.column) + + @dataclass(frozen=True) class Sum: - """Sum ``operand`` over the named dims, removing them from the result. + """Sum ``operand`` over the axes in ``over``, removing them from the result. - A name in ``over`` is a dimension, or one of the axes a [`Join`][] - under it opens ([`JoinColumns.axes`][]): ``sum(x, over=a, - by=relation[b])`` lowers to a ``Sum`` over a ``Join``, over the axis of - each column the join does not group by. The join is the join, and this node is the - group-by that follows it. + An axis in ``over`` is a dimension's own, or one a [`Join`][] under it + opens ([`JoinColumns.axes`][]): ``sum(x, over=a, by=relation[b])`` + lowers to a ``Sum`` over a ``Join``, over the axis of each column the join + does not group by. The join is the join, and this node is the group-by + that follows it. """ operand: Expression - over: tuple[str, ...] + over: tuple[Axis, ...] @dataclass(frozen=True) @@ -266,9 +299,9 @@ class Join: The operand carries every dim joined on. The result has the operand's dims, less the dims joined on, plus the dims grouped by, plus one axis - per column joined on and not grouped by ([`JoinColumns.axes`][]), named - for the relation's column and not for its dimension. So a column dropped - and a column added over one dimension stay two axes. A [`Sum`][] over + per column joined on and not grouped by ([`JoinColumns.axes`][]), which + stands for the relation's column and runs over its dimension. So a column + dropped and a column added over one dimension stay two axes. A [`Sum`][] over those axes is the group-by that follows the join, which is how ``sum(x, over=a, by=relation[b])`` lowers. Where the grouped columns hold the relation's whole key, they determine every other column, the @@ -540,16 +573,17 @@ def one_row_per_group(self) -> bool: return set(self.relation.key) <= set(self.grouped) @property - def axes(self) -> tuple[str, ...]: - """The axis the join opens for each column it drops, ``relation.column``, empty where each group is one row. + def axes(self) -> tuple[Axis, ...]: + """The axis the join opens for each column it drops, empty where each group is one row. - A dimension's name holds no dot, so an axis never meets one. A column - the grouped columns determine opens none: in a lookup they hold the - key, and the key determines every column. + Each runs over the column's dimension and stands for the column, so it + never equals that dimension's own axis. A column the grouped columns + determine opens none: in a lookup they hold the key, and the key + determines every column. """ if self.one_row_per_group: return () - return tuple(f'{self.name}.{role}' for role in self.dropped) + return tuple(Axis(self.dim(role), Column(self.name, role)) for role in self.dropped) @dataclass(frozen=True) diff --git a/src/mathspec/separability.py b/src/mathspec/separability.py index a5fda511..0619b65c 100644 --- a/src/mathspec/separability.py +++ b/src/mathspec/separability.py @@ -98,22 +98,21 @@ def waits_on(dimension: str, label: str, name: str, kind: Literal['offset', 'par if isinstance(node, Cases): masks.extend(region.when for region in node.regions) elif isinstance(node, Sum): - join = node.operand.columns if isinstance(node.operand, Join) and node.operand.columns.axes else None - grouped = dict(zip(join.axes, join.dropped_dims, strict=True)) if join is not None else {} - for name in node.over: - if join is not None and name in grouped: + for axis in node.over: + if axis.column is not None: + assert isinstance(node.operand, Join), 'a join axis is opened by the join the sum stands over' report( 'coupled', - grouped[name], + axis.dimension, label, - f'groups {grouped[name]} into {", ".join(join.added_dims)} — window that dimension instead, or cut only at the group edges', + f'groups {axis.dimension} into {", ".join(node.operand.columns.added_dims)} — window that dimension instead, or cut only at the group edges', ) elif reductions_couple: report( 'coupled', - name, + axis.dimension, label, - f'sums over {name} — a rolling sum_back(window=n) windows, a total over the horizon does not', + f'sums over {axis.dimension} — a rolling sum_back(window=n) windows, a total over the horizon does not', ) elif isinstance(node, Join) and not node.columns.axes: for dimension in node.columns.dropped_dims: diff --git a/src/mathspec/typesetting/walk.py b/src/mathspec/typesetting/walk.py index 4a2d4a8c..29408da9 100644 --- a/src/mathspec/typesetting/walk.py +++ b/src/mathspec/typesetting/walk.py @@ -422,7 +422,7 @@ def _sum(self, node: Sum, ctx: _Context) -> tuple[str, int]: return self._grouped_sum(node.operand, ctx) memberships = [] inner = ctx - for d in self._sorted(frozenset(node.over)): + for d in self._sorted(frozenset(axis.dimension for axis in node.over)): dummy, inner = inner.reducing(d) memberships.append(self._membership(d, dummy)) domain = self.format.joined(memberships, '') diff --git a/tests/test_dimensions.py b/tests/test_dimensions.py index 7607eef8..96c8ab21 100644 --- a/tests/test_dimensions.py +++ b/tests/test_dimensions.py @@ -12,7 +12,7 @@ from mathspec.dimensions import DimensionError, _check_where_dims, dims_of from mathspec.errors import SchemaError -from mathspec.program import Join, Mask, RelationPairComparison, Sum +from mathspec.program import Axis, Column, Join, Mask, RelationPairComparison, Sum from mathspec.resolution import Namespace from mathspec.validation import to_spec from tests.fixtures import DISPATCH_MODEL, expression_of, schema_of, varied, where_of @@ -235,19 +235,16 @@ def test_a_lookup_carries_the_whole_key_and_what_the_operand_brings_beside_it(): def test_a_join_opens_an_axis_for_the_column_it_drops_and_the_sum_over_it_closes_it(): - """A map into its own dimension drops and adds one dimension, so the join names the dropped column for the relation. + """A map into its own dimension drops and adds one dimension, so the axis the join opens stands for the column. - Named for its dimension, the column the join drops and the column it - groups by are one name, and the sum over the join takes away the dim the - row keeps. + The dropped column's axis runs over `snapshot` and stands for + `rep_of[snapshot]`, so it is not the dimension's own axis. The sum over + the join closes it, and the frame keeps the `snapshot` the row groups by. """ s = _schema() node = expression_of('sum(p, over=snapshot, by=rep_of[rep])', Namespace(s), 't') assert isinstance(node, Sum) and isinstance(node.operand, Join) - assert node.over == ('rep_of.snapshot',), 'the sum stands over the axis the join opens' - assert dims_of(node.operand, s, 't') == {'generator', 'snapshot', 'rep_of.snapshot'}, ( - 'the join keeps the dropped column beside the dimension it groups by' - ) + assert node.over == (Axis('snapshot', Column('rep_of', 'snapshot')),), 'the sum stands over the axis the join opens' assert dims_of(node, s, 't') == {'generator', 'snapshot'}, 'and the sum over it leaves the frame the row keeps' diff --git a/tests/test_expansion.py b/tests/test_expansion.py index c3f96a68..0f8178f4 100644 --- a/tests/test_expansion.py +++ b/tests/test_expansion.py @@ -14,7 +14,7 @@ from mathspec import to_spec from mathspec.errors import LanguageError from mathspec.expansion import parse_and_expand -from mathspec.program import Multiply, Named, Parameter, Sum, Translate, Variable +from mathspec.program import Axis, Multiply, Named, Parameter, Sum, Translate, Variable from mathspec.resolution import Namespace from tests.fixtures import DISPATCH_MODEL, SMALL_MODEL, comparison_of, expression_of, schema_of @@ -141,7 +141,7 @@ def test_a_call_expands_to_core_ast(expressions, macros, call, want): def test_a_named_expression_arrives_under_the_node_carrying_its_name(): ns = Namespace(schema(expressions={'gen_cost': 'p * cost'})) resolved = expression_of('sum(gen_cost, over=generator)', ns, 'e') - assert resolved == Sum(Named('gen_cost', Multiply(Variable('p'), Parameter('cost'))), ('generator',)), ( + assert resolved == Sum(Named('gen_cost', Multiply(Variable('p'), Parameter('cost'))), (Axis('generator'),)), ( 'the body is inlined resolved and the name kept, for the typesetter to define it once' ) assert isinstance(resolved, Sum) diff --git a/tests/test_lowering.py b/tests/test_lowering.py index b44811bb..33fd7837 100644 --- a/tests/test_lowering.py +++ b/tests/test_lowering.py @@ -22,8 +22,10 @@ Add, And, Assumption, + Axis, BooleanLiteral, Cases, + Column, Constant, CountComparison, DimensionComparison, @@ -138,13 +140,13 @@ def test_program_structure(dispatch_program): ((cname, c),) = dispatch_program.constraints.items() assert cname == 'power_balance' assert c.dims == ('snapshot',), 'the frame is the dims, in the order the file wrote it' - assert c.lhs == Sum(Variable('dispatch'), ('generator',)) + assert c.lhs == Sum(Variable('dispatch'), (Axis('generator'),)) assert c.sense == '==', "the comparison crosses as the file's own operator, untranslated" assert c.rhs == Parameter('load') assert dispatch_program.objective.sense == 'minimize', "the program carries the language's spelling, untranslated" assert dispatch_program.objective.expression == Sum( - Multiply(Variable('dispatch'), Parameter('cost')), ('generator', 'snapshot') + Multiply(Variable('dispatch'), Parameter('cost')), (Axis('generator'), Axis('snapshot')) ), 'the objective carries the sum the file wrote, over the dims it named none of' @@ -566,11 +568,15 @@ def test_a_power_resolves_to_a_node_of_its_own(dispatch_schema): @pytest.mark.parametrize( ('expression', 'expected'), [ - pytest.param('sum(q)', Sum(Variable('q'), ('g', 'h')), id='a-bare-sum-sums-away-every-dim-the-operand-carries'), - pytest.param('sum(q, over=h)', Sum(Variable('q'), ('h',)), id='an-over-sums-away-the-dim-it-names'), + pytest.param( + 'sum(q)', + Sum(Variable('q'), (Axis('g'), Axis('h'))), + id='a-bare-sum-sums-away-every-dim-the-operand-carries', + ), + pytest.param('sum(q, over=h)', Sum(Variable('q'), (Axis('h'),)), id='an-over-sums-away-the-dim-it-names'), pytest.param( 'sum(p, over=g, by=lk[h])', - Sum(Join(Variable('p'), LK_JOIN), ('lk.g',)), + Sum(Join(Variable('p'), LK_JOIN), (Axis('g', Column('lk', 'g')),)), id='a-grouped-sum-is-a-sum-over-the-axis-its-join-opens', ), pytest.param( @@ -709,7 +715,7 @@ def test_a_relation_lowers_with_the_join_each_call_names(): assert program.relations == {'zone_of': declared}, 'the relation sits once in the program, under its name' zonal = program.constraints['zonal'].lhs columns = JoinColumns('zone_of', declared, ('generator', 'snapshot'), ('zone', 'snapshot')) - assert zonal == Sum(Join(Variable('p'), columns), ('zone_of.generator',)), ( + assert zonal == Sum(Join(Variable('p'), columns), (Axis('generator', Column('zone_of', 'generator')),)), ( 'a grouped sum is a sum over a join: the join names the column over the over= dim and the unnamed key ' 'column as joined on, the by= column and that key column as grouped by, and the sum stands over the axis ' 'the join opens for the column it drops' @@ -725,7 +731,7 @@ def test_a_relation_lowers_with_the_join_each_call_names(): ) assert program.constraints['history'].lhs == Sum( Join(Variable('p'), JoinColumns('zone_of', declared, ('snapshot', 'generator'), ('zone', 'generator'))), - ('zone_of.snapshot',), + (Axis('snapshot', Column('zone_of', 'snapshot')),), ), 'the same table joined on its other key column' priced = program.constraints['priced'].rhs assert priced == Join( @@ -762,7 +768,7 @@ def test_a_divisor_under_a_join_is_still_named(): looked_up = Join(quotient, JoinColumns('component_of', component_of, ('component',), ('flow',))) assert parameters_of(looked_up) == frozenset({'rate'}), 'the walk descends through `Join`' - assert parameters_of(Sum(looked_up, ('flow',))) == frozenset({'rate'}), 'and through a `Sum` over it' + assert parameters_of(Sum(looked_up, (Axis('flow'),))) == frozenset({'rate'}), 'and through a `Sum` over it' def test_a_divisor_under_a_power_is_still_named(): diff --git a/tests/typesetting/test_golden.py b/tests/typesetting/test_golden.py index da588018..b716caa3 100644 --- a/tests/typesetting/test_golden.py +++ b/tests/typesetting/test_golden.py @@ -150,11 +150,11 @@ def _rendered_trees() -> Iterator[object]: #: A dataclass the walk steps *through* rather than renders: a region has no #: branch of its own — its ``when`` and ``value`` do — the columns a join -#: names and the relation it reads are the facts a node carries rather than -#: nodes, and a ``Mask`` is the wrapper a leaf carries a predicate in. None is a +#: names, the axes a sum is over and the relation it reads are the facts a +#: node carries rather than nodes, and a ``Mask`` is the wrapper a leaf carries a predicate in. None is a #: member of any node union, so they are subtracted from what the tree walk #: finds rather than added to what the vocabulary declares. -CARRIERS = {'Region', 'JoinColumns', 'Mask', 'Partition', 'RelationDeclaration'} +CARRIERS = {'Axis', 'Column', 'Region', 'JoinColumns', 'Mask', 'Partition', 'RelationDeclaration'} def test_the_golden_model_carries_every_node_kind_the_walk_renders():