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
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(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))
Expand Down
7 changes: 4 additions & 3 deletions docs/about/relations-as-linear-maps.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
7 changes: 4 additions & 3 deletions src/mathspec/_expression_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
)
from mathspec.program import (
Add,
Axis,
Constant,
Divide,
Dual,
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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.
Expand Down
30 changes: 19 additions & 11 deletions src/mathspec/dimensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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.
Expand Down
66 changes: 50 additions & 16 deletions src/mathspec/program.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,10 @@
'Add',
'And',
'Assumption',
'Axis',
'BooleanLiteral',
'Cases',
'Column',
'Connective',
'Constant',
'ConstraintDeclaration',
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
15 changes: 7 additions & 8 deletions src/mathspec/separability.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion src/mathspec/typesetting/walk.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, '')
Expand Down
15 changes: 6 additions & 9 deletions tests/test_dimensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'


Expand Down
4 changes: 2 additions & 2 deletions tests/test_expansion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading