diff --git a/CHANGELOG.md b/CHANGELOG.md index ac9e23c7..f4214b45 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(language): a call reads a property of a dimension or a set, written subject[property] ([#2](https://github.com/fluxopt/mathspec/pull/2)) - feat(language): a file may declare sets of labels and the properties of a dimension or a set ([#1](https://github.com/fluxopt/mathspec/pull/1)) - feat(language): a parameter may declare the coordinates it has with `where:`, and outside them it is absent ([#945](https://github.com/energy-models/mathspec/pull/945)) - fix(language): a divisor that a where, a case or a masked variable guards loads under missing: 0, and so does a variable number outside its bounds ([#942](https://github.com/energy-models/mathspec/pull/942)) diff --git a/docs/reference/language/expressions.md b/docs/reference/language/expressions.md index 4d4b26ac..bde469c8 100644 --- a/docs/reference/language/expressions.md +++ b/docs/reference/language/expressions.md @@ -82,8 +82,8 @@ The place where a name stands decides which kinds of name are legal: | -------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | | expression (`p * cost`) | a variable, or a parameter whose values are numbers ([dtype](parameters-variables-constraints.md#parameters)) | | dimension argument (`over=`, `along=`) | a dimension, or a list of them for `over=`. With `by=`, a name in `over=` is a column of the relation, if it has one | -| column argument (`by=`, `within=`) | columns of one relation, written `relation[column]` or `relation[column, …]` | -| condition | a parameter, variable, dimension, relation or named condition ([conditions](#conditions)) | +| column argument (`by=`, `within=`) | columns of one relation, or members and properties of one dimension or set, written `name[column, …]` | +| condition | a parameter, variable, dimension, relation, set or named condition ([conditions](#conditions)) | | `bounds.lower` / `bounds.upper` | a parameter name, or a number | | the `edge` key of `shift` | `'wrap'` in quotes, or a bare number | | `dual` argument (`dual(c)`) | a constraint. It resolves against the constraints alone ([named expressions](named.md#reading-a-constraints-dual)) | @@ -154,6 +154,9 @@ QUOTED ::= "'" chars "'" | '"' chars '"' | `name` (bare) | parameter | The value is defined here. A `bool` is its own answer. A `str` is defined wherever the table has a row. A number has to have a row and be finite, and `0.0` is a row: write `inflow != 0` for non-zero. An `int` or `str` under `missing: refused` is a load error: it has a row at every coordinate | | `name` (bare) | variable | The variable exists at this coordinate | | `name` (bare) | relation | A row exists, read at the relation's key. A `missing: absent` relation is [partial](relations.md#the-data-contract), and this selects the labels that do map | +| `name` (bare) | set | A row exists, read at the set's members ([sets](sets.md#entries-over-a-set)) | +| `name[p, …]` | dimension or set | The row has every property named ([reading a property](sets.md#how-a-set-or-a-property-is-read)) | +| `name[p] OP value`, `name[a] OP name[b]` | property | As for a relation column, read from the property's own table | | `name` (bare) | dimension | A load error. It would be true everywhere | | `name` (bare) | named condition | Its condition ([conditions](named.md#conditions)). A given condition is true or false at each coordinate, as a `bool` parameter is | | `name OP value` | parameter | Element-wise. A missing row with no `missing:` value compares false | diff --git a/docs/reference/language/sets.md b/docs/reference/language/sets.md index 678b60ae..4511276c 100644 --- a/docs/reference/language/sets.md +++ b/docs/reference/language/sets.md @@ -142,3 +142,58 @@ The default `refused` refuses data that leaves a row out. For every one of those rows has a `zone`. For `connection`, only the listed pairs are rows, and a property of `connection` with `missing: refused` needs a value at each listed pair, not at every pair of a generator and a bus. + +## How a set or a property is read + +A call names a subject and what it reads of it as `subject[name, …]`. Each name +is a member of the subject or a property of it. The call joins and groups by +the rules of a [relation](relations.md#how-a-relation-is-used), where a member +is a key column and a property is a value column. + +| kind | written as | +| ----------------- | ------------------------------------------------------------- | +| join and group by | `sum(p, over=generator, by=generator[bus])` | +| join | `at(price, by=generator_period[zone])` | +| partition | `shift(x, along=snapshot, offset=1, within=snapshot[period])` | +| test a value | `generator[bus] == 'b1'`, or `line[bus0] != line[bus1]` | +| test a property | `line[bus1]`, true where the line has the property | + +```yaml +dimensions: + generator: { dtype: str } + bus: { dtype: str } + carrier: { dtype: str } + snapshot: { dtype: int } +properties: + generator: + bus: bus + carrier: { into: carrier, missing: absent } +parameters: + limit: { dims: [bus, carrier] } +variables: + p: + dims: [generator, snapshot] + where: generator[carrier] + bounds: { lower: 0 } +constraints: + carrier_limit: + dims: [bus, carrier, snapshot] + expression: sum(p, over=generator, by=generator[bus, carrier]) <= limit +``` + +$$p_{g,t} \ge 0 \qquad \forall\, g \in \mathcal{G},\ t \in \mathcal{T} \,:\, \mathrm{generator.carrier}(g) \text{ is defined}$$ + +$$\sum_{g \in \mathcal{G} \,:\, \mathrm{generator.bus}(g) = b \wedge \mathrm{generator.carrier}(g) = c} p_{g,t} \le \mathrm{limit}_{b,c} \qquad \forall\, b \in \mathcal{B},\ c \in \mathcal{C},\ t \in \mathcal{T}$$ + +- A call that names several properties reads their **inner join**. A generator + with no `carrier` has no row in it, so `carrier_limit` adds up only the + generators that have a bus and a carrier. +- `subject[property]` alone in a condition is true where the row has the + property. `generator[bus, carrier]` is true where the row has both. +- A call reads one subject. To read through two, nest the calls: + `sum(sum(p, over=generator, by=generator[bus]), over=bus, by=bus[zone])`. +- A dimension is read by its properties, because it has no table of its own: + + ```text + Constraint 'carrier_limit': sum(by=generator[generator]) reads no property of 'generator', and a dimension has no table of its own. Name one of its properties, ['bus', 'carrier']. + ``` diff --git a/src/mathspec/_condition_parser.py b/src/mathspec/_condition_parser.py index b5abf083..2fe02694 100644 --- a/src/mathspec/_condition_parser.py +++ b/src/mathspec/_condition_parser.py @@ -26,6 +26,7 @@ COLUMNS, NAME, ArithmeticNode, + ColumnsNode, KeywordNode, NameNode, children, @@ -114,9 +115,18 @@ class UnresolvedComparisonNode: right: ArithmeticNode | ColumnNode | KeywordNode +@dataclass(frozen=True) +class UnresolvedPresenceNode: + """``subject[property, …]`` alone — true where the row of the subject has every property it names.""" + + columns: ColumnsNode + + #: The condition nodes that resolution replaces: their leaves are names not yet #: looked up in the spec. -UnresolvedConditionNode = NameNode | UnresolvedComparisonNode | UnresolvedConditionCallNode | UnresolvedCountNode +UnresolvedConditionNode = ( + NameNode | UnresolvedComparisonNode | UnresolvedConditionCallNode | UnresolvedCountNode | UnresolvedPresenceNode +) #: Every node a parsed condition is built of: the connectives and literals, #: the unresolved leaves, and the arithmetic and the two side nodes under a @@ -140,7 +150,8 @@ def _build_condition_grammar() -> pp.ParserElement: Both quote characters are accepted because YAML already owns one of them. ``NOT`` binds tightest, then ``AND``, then ``OR``. A comparison is tried - before a bare name, since its left side begins with one. + before a bare name, since its left side begins with one, and so is + ``name[...]`` alone, which tests that a row has the properties it names. """ condition = pp.Forward() @@ -184,12 +195,15 @@ def _call(head: pp.ParserElement) -> pp.ParserElement: ).set_parse_action(lambda t: UnresolvedComparisonNode(t[0], t[1], t[2])) # pyrefly: ignore[implicit-any-lambda] existence = name.copy().set_parse_action(lambda t: NameNode(t[0])) + # pyrefly: ignore[implicit-any-lambda] + presence = COLUMNS.copy().add_parse_action(lambda t: UnresolvedPresenceNode(t[0])) atom = ( true_lit | false_lit | count_comparison | comparison + | presence | condition_call | existence | (pp.Suppress('(') + condition + pp.Suppress(')')) @@ -231,8 +245,9 @@ def _named_rewrite(text: str, loc: int) -> str | None: """The rewrite for a connective habit of pandas or C, or a comparison of several columns, or ``None``. ``!=``, ``<`` and ``>`` are legal here, so only the tokens no condition - admits are diagnosed. The grammar gives up at the bracket of - ``relation[a, b]``, since a comparison reads one column. + admits are diagnosed. A comparison reads one column, so the grammar gives + up at the bracket of ``relation[a, b]`` inside a comparison, and at the + comparator after ``subject[a, b]``, which alone is a test of presence. """ rest = text[loc:].lstrip() if rest.startswith('&'): @@ -249,6 +264,12 @@ def _named_rewrite(text: str, loc: int) -> str | None: relation, columns = before.group(1), [c.strip() for c in listed.group(1).split(',')] each = ' AND '.join(f'{relation}[{column}] == …' for column in columns) return f"a comparison reads one column of '{relation}'. Compare each column on its own: {each}." + if re.match(r'(==|!=|<=|>=|<|>)', rest) and ( + read := re.search(rf'({NAME})\s*\[\s*({NAME}(?:\s*,\s*{NAME})+)\s*\]\s*$', text[:loc]) + ): + relation, columns = read.group(1), [c.strip() for c in read.group(2).split(',')] + each = ' AND '.join(f'{relation}[{column}] == …' for column in columns) + return f"a comparison reads one column of '{relation}'. Compare each column on its own: {each}." return None diff --git a/src/mathspec/_condition_resolver.py b/src/mathspec/_condition_resolver.py index fa3471db..61c3dd82 100644 --- a/src/mathspec/_condition_resolver.py +++ b/src/mathspec/_condition_resolver.py @@ -24,6 +24,7 @@ UnresolvedConditionCallNode, UnresolvedConditionNode, UnresolvedCountNode, + UnresolvedPresenceNode, ) from mathspec._dimensions import join_dims from mathspec._divisors import zero_divisor_refusals @@ -120,6 +121,8 @@ def build(self, node: Condition | UnresolvedConditionNode) -> Condition | Unreso return self._condition_call(node) if isinstance(node, UnresolvedCountNode): return self._count(node) + if isinstance(node, UnresolvedPresenceNode): + return self._presence(node) if isinstance(node, Not): return Not(self._child(node.operand)) if isinstance(node, And): @@ -319,6 +322,44 @@ def _count(self, node: UnresolvedCountNode) -> Condition | UnresolvedConditionNo dims = tuple(sorted(mask.dims - {over.name})) return CountComparison(mask, over.name, node.op, value.value, dims) + def _presence(self, node: UnresolvedPresenceNode) -> Condition | UnresolvedConditionNode: + """``subject[property, …]`` alone: true where the row of the subject has every property it names. + + Each property is a table of its own, and a row has the property where + the table has a row at its key, so several properties are the + conjunction of one test each. A relation, and a set read by its + members, are tested by their bare name. + """ + ns, context = self.ns, self.context + subject, named = node.columns.relation, node.columns.columns + properties = ns.properties.get(subject, {}) + if subject in ns.relations and not set(named) & set(properties): + self.errors.append( + f"{context}: '{node.columns}' alone tests no property of '{subject}'. Test whether '{subject}' has " + f"a row with its bare name, '{subject}'." + ) + return node + if subject not in ns.subjects: + self.errors.append( + ns.unknown(subject, context, allow_dims=True) + if ns.kind(subject) is None + else f"{context}: '{node.columns}' reads a column of '{subject}', which is " + f'{_declared_as(ns, subject)}. Only a relation, a set or a dimension has columns.' + ) + return node + if unknown := [c for c in named if c not in properties]: + self.errors.append( + f"{context}: '{node.columns}' names {unknown}, which is no property of '{subject}'. " + f'{did_you_mean(unknown[0], properties, label="Properties")}' + ) + return node + dims = tuple(d for _, d in ns.subjects[subject]) + tests: list[Condition] = [RelationDefined(f'{subject}.{p}', dims) for p in dict.fromkeys(named)] + held = tests[0] + for test in tests[1:]: + held = And(held, test) + return held + def _comparison(self, node: UnresolvedComparisonNode) -> Condition | UnresolvedConditionNode: """``side side``, read for what each side is. @@ -476,16 +517,17 @@ def _plain_comparison(self, node: UnresolvedComparisonNode, plain: _Plain) -> Co if not plain.quoted and isinstance(value, str): right_name, right_column = _split_column(value) if (rhs_kind := ns.kind(right_name)) is not None: - if rhs_kind == 'relation' and ns.kind(left_name) == 'relation': - left = self._relation_column(left_name, left_column or None, plain.name, plain.op) - right = self._relation_column(right_name, right_column or None, value, plain.op) + if _reads(ns, right_name, right_column) and _reads(ns, left_name, left_column): + left = self._read(left_name, left_column or None, plain.name, plain.op) + right = self._read(right_name, right_column or None, value, plain.op) if left is None or right is None: return node if (refusal := _relation_pair_error(context, plain, value, ns, left, right)) is not None: self.errors.append(refusal) return node - dims = tuple(ns.relations[left_name].dim(k) for k in ns.relations[left_name].key) - return RelationPairComparison(left_name, left, right_name, right, plain.op, dims) + shape = ns.tables[left[0]] + dims = tuple(shape.dim(k) for k in shape.key) + return RelationPairComparison(left[0], left[1], right[0], right[1], plain.op, dims) given = right_name in ns.spec.given.expressions self.errors.append(_declared_rhs_error(context, plain, value, rhs_kind, given=given)) return node @@ -494,19 +536,19 @@ def _plain_comparison(self, node: UnresolvedComparisonNode, plain: _Plain) -> Co if kind is None: self.errors.append(ns.unknown(left_name, context, allow_dims=True)) return node - if left_column and kind != 'relation': + if left_column and ns.columns(left_name) is None: self.errors.append( f"{context}: '{plain.name}' reads a column of '{left_name}', which is {_declared_as(ns, left_name)}. " - f'Only a relation has columns.' + f'Only a relation, a set or a dimension has columns.' ) return node - column = None + read: tuple[str, str] | None = None dtype: DeclaredDtype | None = None - if kind == 'relation': - column = self._relation_column(left_name, left_column or None, plain.name, plain.op) - if column is None: + if _reads(ns, left_name, left_column): + read = self._read(left_name, left_column or None, plain.name, plain.op) + if read is None: return node - dtype = ns.dtypes[ns.relations[left_name].dim(column)] + dtype = ns.dtypes[ns.tables[read[0]].dim(read[1])] elif kind in ('parameter', 'dimension'): dtype = ns.dtypes[left_name] if dtype is not None: @@ -515,6 +557,9 @@ def _plain_comparison(self, node: UnresolvedComparisonNode, plain: _Plain) -> Co return node value = typed + if read is not None: + shape = ns.tables[read[0]] + return RelationComparison(read[0], read[1], plain.op, value, tuple(shape.dim(k) for k in shape.key)) match kind: case 'parameter': assert not isinstance(value, datetime.date) @@ -522,9 +567,7 @@ def _plain_comparison(self, node: UnresolvedComparisonNode, plain: _Plain) -> Co case 'dimension': return DimensionComparison(left_name, plain.op, value) case 'relation': - assert column is not None - shape = ns.relations[left_name] - return RelationComparison(left_name, column, plain.op, value, tuple(shape.dim(k) for k in shape.key)) + raise AssertionError('a relation is read as a table above, with or without a column') case 'variable' if left_name in ns.spec.given.expressions: self.errors.append( f"{context}: where references the given expression '{left_name}'. {GIVEN_IN_A_CONDITION}" @@ -536,6 +579,25 @@ def _plain_comparison(self, node: UnresolvedComparisonNode, plain: _Plain) -> Co ) return node + def _read(self, name: str, column: str | None, spelling: str, op: ComparisonOperator) -> tuple[str, str] | None: + """The table and the column a side of a where-comparison reads, or ``None`` with the refusal appended. + + A property is read from its own table, ``subject.property``, and a + relation's or a set's own column from the relation or the set. + """ + ns, context = self.ns, self.context + properties = ns.properties.get(name, {}) + if column is not None and column in properties: + return f'{name}.{column}', column + if name in ns.relations: + read = self._relation_column(name, column, spelling, op) + return None if read is None else (name, read) + self.errors.append( + f"{context}: '{spelling}' reads '{column}', which is no property of '{name}'. " + f'{did_you_mean(column or "", properties, label="Properties")}' + ) + return None + def _relation_column(self, name: str, column: str | None, spelling: str, op: ComparisonOperator) -> str | None: """The value column a where-comparison on relation *name* reads, or the refusal. @@ -682,6 +744,11 @@ def _side_name(side: ArithmeticNode | ColumnNode) -> str | None: return None +def _reads(ns: Namespace, name: str, column: str) -> bool: + """Whether a side spelled *name* or ``name[column]`` reads a table: a relation or a set by its bare name, or any subject with a column.""" + return ns.kind(name) == 'relation' or (bool(column) and ns.columns(name) is not None) + + def _split_column(name: str) -> tuple[str, str]: """``relation[column]`` as its two names, and a bare name with an empty column: the one reader of a side's spelling.""" relation, _, column = name.partition('[') @@ -779,7 +846,9 @@ def _declared_rhs_error(context: str, node: _Plain, value: str, kind: str, *, gi ) -def _relation_pair_error(context: str, node: _Plain, other: str, ns: Namespace, left: str, right: str) -> str | None: +def _relation_pair_error( + context: str, node: _Plain, other: str, ns: Namespace, left_read: tuple[str, str], right_read: tuple[str, str] +) -> str | None: """Why two relation columns may not be compared, or ``None`` where they may. Both relations are read at their keys, so the keys must be over the same @@ -791,7 +860,8 @@ def _relation_pair_error(context: str, node: _Plain, other: str, ns: Namespace, """ comparison = f"'{node.name} {node.op} {other}'" left_name, right_name = _split_column(node.name)[0], _split_column(other)[0] - ls, rs = ns.relations[left_name], ns.relations[right_name] + (left_table, left), (right_table, right) = left_read, right_read + ls, rs = ns.tables[left_table], ns.tables[right_table] left_keys, right_keys = {ls.dim(k) for k in ls.key}, {rs.dim(k) for k in rs.key} if left_keys != right_keys: return ( diff --git a/src/mathspec/_dimensions.py b/src/mathspec/_dimensions.py index ab69f24d..efd85e4f 100644 --- a/src/mathspec/_dimensions.py +++ b/src/mathspec/_dimensions.py @@ -100,7 +100,7 @@ def join_dims(columns: JoinColumns, inner: frozenset[str], context: str, operand """ lookup = columns.one_row_per_group named = columns.dropped if lookup else columns.added - call = f'{"at" if lookup else "sum"}(by={columns.name}[{", ".join(named)}])' + call = f'{"at" if lookup else "sum"}(by={columns.written}[{", ".join(named)}])' if missing := sorted(set(columns.dropped_dims) - inner): if lookup: raise DimensionError( @@ -151,7 +151,7 @@ def _check_translation(node: Translate | WindowSum, inner: frozenset[str], spec: ) _check_named_amount(node, verb, inner, spec, context) if node.partition is not None: - within = f'{node.partition.name}[{", ".join(node.partition.grouped)}]' + within = f'{node.partition.written}[{", ".join(node.partition.grouped)}]' _check_joined(f'{verb}(along={node.along}, within={within})', node.partition, inner, context) @@ -357,7 +357,7 @@ def _check_condition_dims( case TranslatedCondition(): leaf = f"a condition translated along '{atom.along}'" case JoinedCondition(): - leaf = f"a condition read through '{atom.columns.name}'" + leaf = f"a condition read through '{atom.columns.written}'" case _: assert_never(atom) raise DimensionError( diff --git a/src/mathspec/_exclusivity.py b/src/mathspec/_exclusivity.py index 77e05c53..51bb4e3e 100644 --- a/src/mathspec/_exclusivity.py +++ b/src/mathspec/_exclusivity.py @@ -270,7 +270,7 @@ def _observe(node: TypedCondition, subject: Subject, values: set[_Literal]) -> N raise Undecidable(msg) if isinstance(node, JoinedCondition): msg = ( - f"it reads a condition through '{node.columns.name}', which only the data decides. " + f"it reads a condition through '{node.columns.written}', which only the data decides. " 'Test this row, or precompute the read as a boolean parameter and test that' ) raise Undecidable(msg) diff --git a/src/mathspec/_expression_resolver.py b/src/mathspec/_expression_resolver.py index 6d2edb27..07e5b094 100644 --- a/src/mathspec/_expression_resolver.py +++ b/src/mathspec/_expression_resolver.py @@ -545,11 +545,13 @@ def columns_ref(self, value: ArithmeticNode, operator: str, key: str) -> Columns if (problem := self.not_a_relation(value.relation, operator, key)) is not None: self.errors.append(problem) return None - shape = self.ns.relations[value.relation] - if unknown := [c for c in value.columns if c not in shape.roles and c not in self.formals]: + admitted = self.ns.columns(value.relation) + assert admitted is not None, 'not_a_relation refuses a name that takes no brackets' + if unknown := [c for c in value.columns if c not in admitted and c not in self.formals]: self.errors.append( - f'{self.context}: {operator}({key}={value}) names {unknown}, which is no column of ' - f"'{value.relation}'. Name one of its columns, {list(shape.roles)}." + f'{self.context}: {operator}({key}={value}) names {unknown}, which is no ' + f"{_columns_noun(self.ns, value.relation)[0]} of '{value.relation}'. Name one of its " + f'{_columns_noun(self.ns, value.relation)[1]}, {_listed(self.ns, value.relation)}.' ) return None if any(c in self.formals for c in value.columns): @@ -557,6 +559,13 @@ def columns_ref(self, value: ArithmeticNode, operator: str, key: str) -> Columns if len(set(value.columns)) < len(value.columns): self.errors.append(f'{self.context}: {operator}({key}={value}) names a column twice. Name each once.') return None + if value.relation in self.ns.dimensions and not set(value.columns) & set(self.ns.properties[value.relation]): + self.errors.append( + f"{self.context}: {operator}({key}={value}) reads no property of '{value.relation}', and a dimension " + f'has no table of its own. Name one of its properties, ' + f'{list(self.ns.properties[value.relation])}.' + ) + return None return value def _not_columns(self, value: ArithmeticNode, operator: str, key: str) -> str: @@ -566,12 +575,10 @@ def _not_columns(self, value: ArithmeticNode, operator: str, key: str) -> str: return ( f'{context}: {operator}({key}={value.name}) names the relation and none of its columns. Write ' f"{key}={value.name}[], with a column of '{value.name}': " - f'{list(ns.relations[value.name].roles)}.' + f'{list(ns.columns(value.name) or ())}.' ) if isinstance(value, NameNode) and value.name in ns.dimensions: - over_here = [ - f'{n}[{r}]' for n, shape in ns.relations.items() for r, dim in shape.columns if dim == value.name - ] + over_here = ns.reads_over(value.name) hint = ( f'Columns over {value.name!r}: {over_here}' if over_here @@ -596,25 +603,28 @@ def _grouping(self, columns: ColumnsNode, over: tuple[str, ...]) -> tuple[JoinCo so a relation may gain a column without changing what the call means. """ name, into_roles = columns.relation, columns.columns - ns, shape = self.ns, self.ns.relations[name] + ns = self.ns + admitted = ns.columns(name) or () call = f'sum(by={columns})' - from_roles = tuple(n for n in over if n in shape.roles) - plain = tuple(n for n in over if n not in shape.roles) + from_roles = tuple(n for n in over if n in admitted) + plain = tuple(n for n in over if n not in admitted) + noun, nouns = _columns_noun(ns, name) if unknown := [n for n in plain if n not in ns.dimensions]: self.errors.append( - f"{self.context}: {call}: over={unknown[0]} names no column of '{name}', whose columns are " - f'{list(shape.roles)}, and no dimension{_or_a_formal(self.formals)}. ' - f'{did_you_mean(unknown[0], [*shape.roles, *ns.dimensions], label="Names")}' + f"{self.context}: {call}: over={unknown[0]} names no {noun} of '{name}', whose {nouns} are " + f'{list(admitted)}, and no dimension{_or_a_formal(self.formals)}. ' + f'{did_you_mean(unknown[0], [*admitted, *ns.dimensions], label="Names")}' ) return None if not from_roles: self.errors.append( - f"{self.context}: {call}: over={shown(over)} names no column of '{name}', so the sum reads nothing " + f"{self.context}: {call}: over={shown(over)} names no {noun} of '{name}', so the sum reads nothing " f"through '{name}'. Name in over= the column that the operand is joined on: " - f'{_columns_over(shape, plain)}.' + f'{_columns_over(ns.table(name, admitted)[1], plain)}.' ) return None - join = self._checked_join(name, call, from_roles, into_roles) + table, shape = ns.table(name, (*from_roles, *into_roles)) + join = self._checked_join(table, shape, call, from_roles, into_roles) if join is None: return None if join.one_row_per_group: @@ -635,7 +645,7 @@ def lookup(self, columns: ColumnsNode, inner: frozenset[str]) -> JoinColumns | N joins on the value column alone, and the key column arrives. """ name, call = columns.relation, f'at(by={columns})' - shape = self.ns.relations[name] + table, shape = self.ns.table(name, columns.columns) if not shape.values: self.errors.append( f"{self.context}: {call}: '{name}' has every column in its key, so it has no value column for at() " @@ -650,14 +660,13 @@ def lookup(self, columns: ColumnsNode, inner: frozenset[str]) -> JoinColumns | N return None matched = inner - {shape.dim(r) for r in columns.columns} into = tuple(r for r in shape.key if shape.dim(r) not in matched) - return self._checked_join(name, call, columns.columns, into) + return self._checked_join(table, shape, call, columns.columns, into) def _checked_join( - self, name: str, call: str, from_roles: tuple[str, ...], into_roles: tuple[str, ...] + self, table: str, shape: Relation, call: str, from_roles: tuple[str, ...], into_roles: tuple[str, ...] ) -> JoinColumns | None: - """The join of relation *name* between the columns that leave the frame and the columns that arrive.""" + """The join of *table*, shaped *shape*, between the columns that leave the frame and the columns that arrive.""" context = self.context - shape = self.ns.relations[name] if both := sorted(set(from_roles) & set(into_roles)): self.errors.append(f'{context}: {call}: over= and by= both name {both}. Name different columns in each.') return None @@ -670,7 +679,7 @@ def _checked_join( ) return None kept = tuple(r for r in shape.key if r not in from_roles and r not in into_roles) - return JoinColumns(name, shape, (*from_roles, *kept), (*into_roles, *kept)) + return JoinColumns(table, shape, (*from_roles, *kept), (*into_roles, *kept)) def partition(self, columns: ColumnsNode, operator: str, along_dim: str) -> Partition | None: """How a partition (``shift``, ``sum_back``, ``position``) steps along the relation *columns* names. @@ -682,7 +691,7 @@ def partition(self, columns: ColumnsNode, operator: str, along_dim: str) -> Part """ context = self.context name, within_roles = columns.relation, columns.columns - shape = self.ns.relations[name] + table, shape = self.ns.table(name, within_roles) call = f'{operator}(within={columns})' if not shape.values: self.errors.append( @@ -704,17 +713,18 @@ def partition(self, columns: ColumnsNode, operator: str, along_dim: str) -> Part return None (along,) = over_keys joined = tuple(r for r in shape.key if r != along) - return Partition(name, shape, along, within_roles, joined) + return Partition(table, shape, along, within_roles, joined) def not_a_relation(self, name: str, operator: str, key: str) -> str | None: """Why *name*, written before the columns of a selection, is not a relation; ``None`` where it is one.""" ns, context = self.ns, self.context - if name in ns.relations: + if name in ns.relations or name in ns.properties: return None if name in ns.dimensions: return ( - f"{context}: {operator}({key}={name}[...]): '{name}' is a dimension, not a relation. Write the " - f'relation first and its columns after it: {key}=[].' + f"{context}: {operator}({key}={name}[...]): '{name}' is a dimension with no properties. Declare " + f'one under properties: {name}: {{: }}, or write a relation first and its ' + f'columns after it: {key}=[].' ) return ( f'{context}: {operator}({key}={name}[...]) does not name a relation{_or_a_formal(self.formals)}. ' @@ -764,6 +774,22 @@ def _columns_over(shape: Relation, dims: tuple[str, ...]) -> str: return f'its columns are {list(shape.roles)}, over {sorted(set(shape.dims))}' +def _columns_noun(ns: Namespace, name: str) -> tuple[str, str]: + """What one name, and several, inside ``name[...]`` are called: a relation's columns, a dimension's properties, a set's members and properties.""" + if name in ns.spec.relations: + return 'column', 'columns' + if name in ns.dimensions: + return 'property', 'properties' + return 'member or property', 'members and properties' + + +def _listed(ns: Namespace, name: str) -> list[str]: + """What a refusal offers inside ``name[...]``: a dimension's properties, since its own name reads nothing there.""" + if name in ns.dimensions: + return list(ns.properties.get(name, {})) + return list(ns.columns(name) or ()) + + def _or_a_formal(formals: frozenset[str]) -> str: """The words a refusal inside a template adds, since a formal would have stood there too.""" return ' or a formal argument of this macro' if formals else '' diff --git a/src/mathspec/_resolution.py b/src/mathspec/_resolution.py index 313bfe91..6cb58355 100644 --- a/src/mathspec/_resolution.py +++ b/src/mathspec/_resolution.py @@ -86,8 +86,10 @@ class Namespace: 'given_conditions', 'leaf_dims', 'parameters', + 'properties', 'relations', 'spec', + 'subjects', 'tables', 'variables', ) @@ -132,6 +134,16 @@ def __init__(self, spec: Spec) -> None: #: relation or set name -> its columns and key: the tables a file #: writes by name, which a property's dotted name is not. self.relations: dict[str, Relation] = {n: lk for n, lk in self.tables.items() if lk.subject is None} + #: dimension or set -> its key as ``(member, dimension)`` pairs: a + #: dimension is its own one member, and a set has its own. + self.subjects: dict[str, tuple[tuple[str, str], ...]] = { + **{d: ((d, d),) for d in spec.dimensions}, + **{s: entry.pairs for s, entry in spec.sets.items()}, + } + #: subject -> property name -> the dimension the property maps into. + self.properties: dict[str, dict[str, str]] = { + s: {p: prop.into for p, prop in group.items()} for s, group in spec.properties.items() + } #: parameter or variable name -> the ``dims`` it is read through. Stamped onto #: each leaf a condition names, the way a relation leaf carries ``over``. self.leaf_dims: dict[str, tuple[str, ...]] = { @@ -287,6 +299,49 @@ def unordered(self, name: str) -> bool: """Whether the declared dimension *name* is one whose order the file does not declare part of the model.""" return not self.spec.dimensions[name].ordered + def columns(self, name: str) -> tuple[str, ...] | None: + """What ``name[...]`` may name, or ``None`` where *name* takes no brackets. + + A relation's columns; a set's members and properties; a dimension's + own name, which is its one member, and its properties. + """ + if name in self.spec.relations: + return self.relations[name].roles + if name in self.subjects: + return (*(m for m, _ in self.subjects[name]), *self.properties.get(name, {})) + return None + + def table(self, name: str, columns: Iterable[str]) -> tuple[str, Relation]: + """The table ``name[columns]`` reads: the name a program node carries, and its shape. + + A relation, and a set where *columns* names none of its properties, + read their own table. One property is read from its own table, + ``subject.property``, which carries the subject's members too. Several + are read from their inner join, a relation named after the subject, + keyed by its members, with one value column per property in the order + *columns* names them, and with ``subject`` set. The program holds no + such table: a consumer builds it from the tables of its properties. + *columns* holds names [`columns`][] admits, and a dimension's hold a + property. + """ + properties = self.properties.get(name, {}) + read = [c for c in dict.fromkeys(columns) if c in properties] + if not read: + return name, self.relations[name] + if len(read) == 1: + return f'{name}.{read[0]}', self.tables[f'{name}.{read[0]}'] + key = self.subjects[name] + missing = 'absent' if any(self.tables[f'{name}.{p}'].missing == 'absent' for p in read) else 'refused' + columns_read = (*key, *((p, properties[p]) for p in read)) + return name, Relation(columns_read, tuple(m for m, _ in key), missing, None, name) + + def reads_over(self, dimension: str) -> list[str]: + """Every ``name[column]`` a file may write whose column is over *dimension*, for the writer who named the dimension.""" + return [ + *(f'{n}[{r}]' for n, shape in self.relations.items() for r, dim in shape.columns if dim == dimension), + *(f'{s}[{p}]' for s, group in self.properties.items() for p, into in group.items() if into == dimension), + ] + def kind(self, name: str) -> NameKind | None: """What *name* was declared as, or ``None`` where the file declares it nowhere.""" if name in self.variables: diff --git a/src/mathspec/_typesetting/walk.py b/src/mathspec/_typesetting/walk.py index 5ae8f3dd..b5e1585a 100644 --- a/src/mathspec/_typesetting/walk.py +++ b/src/mathspec/_typesetting/walk.py @@ -66,7 +66,7 @@ from mathspec._expression_parser import BinaryOperator from mathspec._typesetting.format import Format from mathspec._typesetting.symbols import Symbols - from mathspec.program import Piecewise, Program + from mathspec.program import Piecewise, Program, Relation #: Operator precedence, for deciding brackets. A reduction sits at the bottom #: with ``+``: an unbracketed sum reads as capturing whatever follows it, so as @@ -263,18 +263,19 @@ def _translation(self, step: _Step) -> str: return operator return self.format.superscript(operator, step.within) - def _relation_read(self, name: str, at: Mapping[str, str], read: str) -> str: + def _relation_read(self, name: str, lk: Relation, at: Mapping[str, str], read: str) -> str: """Relation *name*'s column *read* as a function at the columns *at* fixes: ``bus(g)``, ``zone_of(g, p)`` or ``ends.bus0(l)``. - *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. + *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. """ - lk = self.program.relations[name] function = name if len(lk.values) == 1 else f'{name}.{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, at: Mapping[str, str]) -> str: + def _relation_row(self, name: str, lk: Relation, at: Mapping[str, str]) -> str: """That relation *name* has a row at the key *at* fixes. A keyed table is a function, so the claim is that it is defined there: @@ -282,20 +283,18 @@ def _relation_row(self, name: str, at: Mapping[str, str]) -> str: column of it is a key column, so the row is written out: ``(g, b) ∈ connection``. """ - lk = self.program.relations[name] 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")}' return f'{self.format.parenthesise(key)} {self._op("in")} {self.format.upright(name)}' - def _frame_key(self, name: str, ctx: _Context) -> dict[str, str]: - """Relation *name*'s key roles at the frame's own indices of their dimensions.""" - lk = self.program.relations[name] + def _frame_key(self, lk: Relation, ctx: _Context) -> dict[str, str]: + """The key roles of *lk* at the frame's own indices of their dimensions.""" return {k: ctx.subscript(lk.dim(k)) for k in lk.key} - def _value_read(self, name: str, column: str, ctx: _Context) -> str: + def _value_read(self, name: str, lk: Relation, column: str, ctx: _Context) -> str: """A keyed relation's value *column* read at the frame's own indices of its key: ``period_of(t)``.""" - return self._relation_read(name, self._frame_key(name, ctx), column) + return self._relation_read(name, lk, self._frame_key(lk, ctx), column) def _tuple(self, reads: list[str]) -> str: """Several reads as one group label: the read alone where there is one, a bracketed tuple otherwise.""" @@ -468,7 +467,7 @@ def _looked_up(self, columns: JoinColumns, ctx: _Context) -> _Context: """*ctx* with each dimension *columns* drops read at the relation, as ``at`` re-indexes a leaf.""" at = {r: ctx.subscript(columns.dim(r)) for r in columns.grouped} for read in columns.dropped: - ctx = ctx.looked_up(columns.dim(read), self._relation_read(columns.name, at, read)) + ctx = ctx.looked_up(columns.dim(read), self._relation_read(columns.name, columns.relation, at, read)) return ctx def _grouping(self, join: JoinColumns, dummies: Mapping[str, str], ctx: _Context) -> list[str]: @@ -484,10 +483,10 @@ def _grouping(self, join: JoinColumns, dummies: Mapping[str, str], ctx: _Context **{r: dummies[join.dim(r)] for r in join.dropped}, **{r: ctx.subscript(join.dim(r)) for r in join.grouped}, } - fixed = [r for r in self.program.relations[join.name].values if r in at] + fixed = [r for r in join.relation.values if r in at] if not fixed: - return [self._relation_row(join.name, at)] - return [f'{self._relation_read(join.name, at, r)} {self._op("equal")} {at[r]}' for r in fixed] + return [self._relation_row(join.name, join.relation, at)] + return [f'{self._relation_read(join.name, join.relation, at, r)} {self._op("equal")} {at[r]}' for r in fixed] def _group(self, partition: Partition | None) -> str: """A ``within=`` as the superscript its translation operator carries. @@ -499,7 +498,7 @@ def _group(self, partition: Partition | None) -> str: if partition is None: return '' at = {r: self.symbols.index[partition.dim(r)] for r in (partition.along, *partition.joined)} - return self._tuple([self._relation_read(partition.name, at, r) for r in partition.grouped]) + return self._tuple([self._relation_read(partition.name, partition.relation, at, r) for r in partition.grouped]) def _width(self, width: int | str) -> str: """``sum_back``'s ``window=``: a number, or a parameter's own symbol. @@ -565,7 +564,8 @@ def _boolean(self, node: Condition, ctx: _Context) -> tuple[str, int]: return self._boolean(node.operand.root, self._looked_up(node.columns, ctx)) if isinstance(node, RelationDefined): - return self._relation_row(node.name, self._frame_key(node.name, ctx)), comparison + lk = self.program.relations[node.name] + return self._relation_row(node.name, lk, self._frame_key(lk, ctx)), comparison if isinstance(node, Not): return ( @@ -601,15 +601,21 @@ def sides(self, node: AlignedComparison, ctx: _Context) -> tuple[str, str]: grouping = ( None if node.partition is None - else self._tuple([self._value_read(node.partition.name, c, ctx) for c in node.partition.grouped]) + else self._tuple( + [ + self._value_read(node.partition.name, node.partition.relation, c, ctx) + for c in node.partition.grouped + ] + ) ) left = self._position(ctx.subscript(node.name), grouping) right = self._ordinal(node.name, node.position, grouping) elif isinstance(node, RelationComparison): - left, right = self._value_read(node.name, node.column, ctx), self._literal(node.value) + lk = self.program.relations[node.name] + left, right = self._value_read(node.name, lk, node.column, ctx), self._literal(node.value) elif isinstance(node, RelationPairComparison): - left = self._value_read(node.name, node.column, ctx) - right = self._value_read(node.other, node.other_column, ctx) + left = self._value_read(node.name, self.program.relations[node.name], node.column, ctx) + right = self._value_read(node.other, self.program.relations[node.other], node.other_column, ctx) elif isinstance(node, CountComparison): index, inner = ctx.reducing(node.over) counted = self.format.set_of( diff --git a/src/mathspec/program.py b/src/mathspec/program.py index 288ca0d5..a019b93d 100644 --- a/src/mathspec/program.py +++ b/src/mathspec/program.py @@ -549,12 +549,28 @@ def dim(self, role: str) -> str: return self._dim_of[role] +def _tables_read(name: str, relation: Relation) -> tuple[str, ...]: + """The tables a call through *relation*, named *name*, reads. + + One table, *name*, unless the call reads several properties of one + subject. Then *name* is the subject, and each property is a table of its + own, ``subject.property``, whose inner join on the subject's members is + *relation*. + """ + if relation.subject is not None and name == relation.subject: + return tuple(f'{name}.{value}' for value in relation.values) + return (name,) + + @dataclass(frozen=True) class JoinColumns: """One relation as one call joins it: the columns joined on, and the columns grouped by. The entry fixes no direction; the call does, and this is the one it - named. ``name`` is the relation's, as [`Program.relations`][] keys it. + named. ``name`` is the relation's, as [`Program.relations`][] keys it, + except for a call that reads several properties of one subject: there + ``name`` is the subject, ``relation`` is the inner join of their tables, + and [`tables`][] names them. ``joined`` and ``grouped`` are *roles*, which are column names of ``relation``. ``relation`` maps every role to its dimension and names the key. ``joined`` is @@ -579,6 +595,16 @@ def dim(self, role: str) -> str: """The dimension *role* ranges over.""" return self.relation.dim(role) + @property + def tables(self) -> tuple[str, ...]: + """The tables of [`Program.relations`][] the call reads, which the data supplies.""" + return _tables_read(self.name, self.relation) + + @property + def written(self) -> str: + """The name the file writes before the brackets: the subject of a property, else the table.""" + return self.relation.subject or self.name + @property def dropped(self) -> tuple[str, ...]: """The roles joined on and not grouped by: what the call sums away or looks up.""" @@ -633,7 +659,9 @@ def axes(self) -> tuple[Axis, ...]: class Partition: """One relation as a partition steps along it: the key column it steps along, the columns it partitions by, and the key columns it joins on. - ``name`` is the relation's, as [`Program.relations`][] keys it. + ``name`` is the relation's, as [`Program.relations`][] keys it, or the + subject where ``within=`` names several of its properties, as in + [`JoinColumns`][]. ``along``, ``grouped`` and ``joined`` are *roles*, which are column names of ``relation``. ``relation`` maps every role to its dimension and names the key. @@ -654,6 +682,16 @@ def dim(self, role: str) -> str: """The dimension *role* ranges over.""" return self.relation.dim(role) + @property + def tables(self) -> tuple[str, ...]: + """The tables of [`Program.relations`][] the partition reads, which the data supplies.""" + return _tables_read(self.name, self.relation) + + @property + def written(self) -> str: + """The name the file writes before the brackets: the subject of a property, else the table.""" + return self.relation.subject or self.name + @property def along_dim(self) -> str: return self.dim(self.along) @@ -1589,7 +1627,8 @@ def _atom_names(atom: TypedCondition) -> frozenset[str]: """One leaf's entries, its dimension apart — the rule [`Mask.names_read`][] is the union of. A comparison on a dimension names no entry — a coordinate is not - data to feed — a relation pair names both maps it compares, and a + data to feed — unless a position counts within groups, which names the + tables the groups are read from. A relation pair names both maps it compares, and a comparison of expressions names every parameter and relation its sides read, answered on the program's form of it since only a program mask is asked. ``assert_never``-closed for the reason [`_atom_dims`][] is: a condition @@ -1608,8 +1647,10 @@ def _atom_names(atom: TypedCondition) -> frozenset[str]: case TranslatedCondition(): return atom.operand.names_read case JoinedCondition(): - return atom.operand.names_read | {atom.columns.name} - case DimensionComparison() | DimensionPosition(): + return atom.operand.names_read | set(atom.columns.tables) + case DimensionPosition(): + return frozenset(atom.partition.tables if atom.partition is not None else ()) + case DimensionComparison(): return frozenset() case _: assert_never(atom) @@ -1629,10 +1670,10 @@ def _names_under(*expressions: Expression) -> frozenset[str]: if isinstance(node, Cases): names.update(*(region.when.names_read for region in node.regions)) elif isinstance(node, Join): - names.add(node.columns.name) + names.update(node.columns.tables) elif isinstance(node, (Translate, WindowSum)): if node.partition is not None: - names.add(node.partition.name) + names.update(node.partition.tables) amount = node.offset if isinstance(node, Translate) else node.width if isinstance(amount, str): names.add(amount) diff --git a/tests/test_property_calls.py b/tests/test_property_calls.py new file mode 100644 index 00000000..81b53658 --- /dev/null +++ b/tests/test_property_calls.py @@ -0,0 +1,320 @@ +# SPDX-FileCopyrightText: mathspec Contributors +# +# SPDX-License-Identifier: MIT + +"""`subject[property]` in a call and in a condition: the table it reads, and every refusal decided at load.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from mathspec import to_spec +from mathspec._typesetting import FORMATS, typeset +from mathspec.errors import LanguageError +from mathspec.program import ( + And, + Join, + Not, + Relation, + RelationComparison, + RelationDefined, + RelationPairComparison, + Sum, + Translate, + walk, +) +from tests.fixtures import varied +from tests.test_sets import MODEL + +CALLS: dict[str, Any] = varied( + MODEL, + **{ + 'dimensions.carrier': {}, + 'dimensions.season': {}, + 'properties.generator.carrier': {'into': 'carrier', 'missing': 'absent'}, + 'properties.snapshot.season': 'season', + 'parameters.cap': {'dims': ['bus', 'carrier']}, + 'parameters.price': {'dims': ['zone', 'period']}, + 'parameters.load': {'dims': ['bus', 'snapshot']}, + 'variables.x': {'dims': ['generator', 'snapshot']}, + 'variables.f': {'dims': ['line', 'snapshot']}, + }, +) + + +def _expression(text: str) -> Any: + return to_spec(varied(CALLS, **{'expressions.e': text})).program.expressions['e'].expression + + +def _where(variable: str, text: str) -> Any: + where = to_spec(varied(CALLS, **{f'variables.{variable}.where': text})).program.variables[variable].where + assert where is not None + return where + + +def _refusal(**patch: Any) -> str: + with pytest.raises(LanguageError) as caught: + to_spec(varied(CALLS, **patch)) + return str(caught.value) + + +@pytest.mark.parametrize( + ('text', 'dims', 'name', 'tables'), + [ + pytest.param( + 'sum(x, over=generator, by=generator[bus])', + ('bus', 'snapshot'), + 'generator.bus', + ('generator.bus',), + id='a-sum-grouped-by-a-property', + ), + pytest.param( + 'sum(x, over=generator, by=generator[bus, carrier])', + ('bus', 'carrier', 'snapshot'), + 'generator', + ('generator.bus', 'generator.carrier'), + id='a-sum-grouped-by-two-properties', + ), + pytest.param( + 'at(cap, by=generator[bus, carrier])', + ('generator',), + 'generator', + ('generator.bus', 'generator.carrier'), + id='a-lookup-at-two-properties', + ), + pytest.param( + 'at(price, by=generator_period[zone])', + ('generator', 'period'), + 'generator_period.zone', + ('generator_period.zone',), + id='a-lookup-at-a-property-of-a-set', + ), + pytest.param( + 'sum(eff * inj, over=[entity, port], by=connection[bus])', + ('bus', 'snapshot'), + 'connection.bus', + ('connection.bus',), + id='a-sum-over-the-members-of-a-set-grouped-by-its-property', + ), + pytest.param( + 'sum(x, over=start, by=cover[covered])', + ('generator', 'snapshot'), + 'cover', + ('cover',), + id='a-sum-through-the-members-of-a-set', + ), + ], +) +def test_a_call_reads_the_table_of_what_it_names(text, dims, name, tables): + expression = _expression(text) + (join,) = [node for node in walk(expression) if isinstance(node, Join)] + assert expression.dims == dims + assert join.columns.name == name + assert join.columns.tables == tables + + +@pytest.mark.parametrize( + ('text', 'tables'), + [ + pytest.param('shift(x, along=snapshot, offset=1, within=snapshot[period])', ('snapshot.period',), id='one'), + pytest.param( + 'shift(x, along=snapshot, offset=1, within=snapshot[period, season])', + ('snapshot.period', 'snapshot.season'), + id='two', + ), + ], +) +def test_a_partition_reads_the_tables_of_the_properties_it_names(text, tables): + expression = _expression(text) + assert isinstance(expression, Translate) + assert expression.partition is not None + assert expression.partition.tables == tables + + +def test_several_properties_are_read_as_their_inner_join(): + """A row of the join is a generator with every property named; one it lacks is no row, so its missing is absent.""" + (join,) = [node for node in walk(_expression('at(cap, by=generator[bus, carrier])')) if isinstance(node, Join)] + assert join.columns.relation == Relation( + (('generator', 'generator'), ('bus', 'bus'), ('carrier', 'carrier')), + ('generator',), + 'absent', + subject='generator', + ) + + +def test_a_sum_through_two_properties_sums_each_group_once(): + sum_ = _expression('sum(x, over=generator, by=generator[bus, carrier])') + assert isinstance(sum_, Sum) + assert [type(node).__name__ for node in walk(sum_)][:2] == ['Sum', 'Join'], 'one join, then one sum' + + +@pytest.mark.parametrize( + ('variable', 'text', 'root'), + [ + pytest.param('f', 'line[bus1]', RelationDefined('line.bus1', ('line',)), id='has-a-property'), + pytest.param( + 'x', + 'generator[bus, carrier]', + And(RelationDefined('generator.bus', ('generator',)), RelationDefined('generator.carrier', ('generator',))), + id='has-two-properties', + ), + pytest.param('f', 'not line[bus1]', Not(RelationDefined('line.bus1', ('line',))), id='lacks-a-property'), + pytest.param( + 'x', + "generator[bus] == 'b1'", + RelationComparison('generator.bus', 'bus', '==', 'b1', ('generator',)), + id='compares-a-property', + ), + pytest.param( + 'inj', + "connection[bus] == 'b1'", + RelationComparison('connection.bus', 'bus', '==', 'b1', ('entity', 'port')), + id='compares-a-property-of-a-set', + ), + pytest.param( + 'f', + 'line[bus0] != line[bus1]', + RelationPairComparison('line.bus0', 'bus0', 'line.bus1', 'bus1', '!=', ('line',)), + id='compares-two-properties', + ), + ], +) +def test_a_condition_reads_a_property(variable, text, root): + assert _where(variable, text).root == root + + +def test_a_mask_through_two_properties_reads_both_tables(): + where = _where('x', 'at(cap > 0, by=generator[bus, carrier])') + assert where.names_read == {'cap', 'generator.bus', 'generator.carrier'}, ( + 'the data supplies each property as a table, and no table named after the subject' + ) + + +def test_a_position_within_a_property_reads_its_table(): + where = _where('x', 'position(snapshot, within=snapshot[period]) == 0') + assert where.root.partition is not None + assert where.root.partition.name == 'snapshot.period' + + +@pytest.mark.parametrize( + ('patch', 'text', 'tables'), + [ + pytest.param( + {'relations.period_of': {'key': 'snapshot', 'values': 'period'}}, + 'position(snapshot, within=period_of[period]) == 0', + {'period_of'}, + id='a-relation', + ), + pytest.param({}, 'position(snapshot, within=snapshot[period]) == 0', {'snapshot.period'}, id='a-property'), + pytest.param( + {}, + 'position(snapshot, within=snapshot[period, season]) == 0', + {'snapshot.period', 'snapshot.season'}, + id='two-properties', + ), + ], +) +def test_a_position_within_a_partition_reads_its_tables(patch, text, tables): + """A position counted within groups named no table, so a consumer attaching what a mask reads left the groups out.""" + where = to_spec(varied(CALLS, **patch, **{'variables.x.where': text})).program.variables['x'].where + assert where is not None + assert where.names_read == tables, 'the data supplies the tables the groups are read from' + + +@pytest.mark.parametrize( + ('patch', 'fragment'), + [ + pytest.param( + {'expressions.e': 'sum(x, over=generator, by=generator[generator])'}, + "sum(by=generator[generator]) reads no property of 'generator', and a dimension has no table of its " + "own. Name one of its properties, ['bus', 'carrier'].", + id='a-dimension-read-by-its-own-name', + ), + pytest.param( + {'expressions.e': 'sum(x, over=generator, by=generator[zone])'}, + "sum(by=generator[zone]) names ['zone'], which is no property of 'generator'. Name one of its " + "properties, ['bus', 'carrier'].", + id='a-property-the-dimension-lacks', + ), + pytest.param( + {'expressions.e': 'sum(eff, over=entity, by=connection[zone])'}, + "sum(by=connection[zone]) names ['zone'], which is no member or property of 'connection'. Name one " + "of its members and properties, ['entity', 'port', 'bus'].", + id='a-property-the-set-lacks', + ), + pytest.param( + {'expressions.e': 'sum(load, over=bus, by=bus[zone])'}, + "sum(by=bus[...]): 'bus' is a dimension with no properties. Declare one under properties: " + 'bus: {: }', + id='a-dimension-with-no-properties', + ), + pytest.param( + {'expressions.e': 'at(x, by=generator[bus])'}, + "at(by=generator[bus]) joins on ['bus'], which the expression does not carry", + id='a-lookup-at-a-dimension-the-operand-lacks', + ), + pytest.param( + {'variables.x.where': 'generator[zone]'}, + "'generator[zone]' names ['zone'], which is no property of 'generator'. Properties: bus, carrier.", + id='a-presence-test-of-a-property-the-dimension-lacks', + ), + pytest.param( + {'variables.x.where': 'nowhere[bus]'}, + "Variable 'x': 'nowhere' not found.", + id='a-presence-test-of-an-undeclared-name', + ), + pytest.param( + {'variables.x.where': 'cap[bus]'}, + "'cap[bus]' reads a column of 'cap', which is a parameter. Only a relation, a set or a dimension has " + 'columns.', + id='a-presence-test-of-a-parameter', + ), + pytest.param( + {'variables.inj.where': 'connection[port]'}, + "'connection[port]' alone tests no property of 'connection'. Test whether 'connection' has a row with " + "its bare name, 'connection'.", + id='a-presence-test-of-a-member', + ), + pytest.param( + {'variables.x.where': "generator[zone] == 'z'"}, + "'generator[zone]' reads 'zone', which is no property of 'generator'. Properties: bus, carrier.", + id='a-comparison-of-a-property-the-dimension-lacks', + ), + pytest.param( + {'variables.f.where': 'line[bus0] != line[bus9]'}, + "'line[bus9]' reads 'bus9', which is no property of 'line'. Did you mean 'bus1'?", + id='a-pair-with-a-property-the-dimension-lacks', + ), + pytest.param( + {'variables.x.where': "generator[bus, carrier] == 'b1'"}, + "a comparison reads one column of 'generator'. Compare each column on its own: generator[bus] == … " + 'AND generator[carrier] == ….', + id='a-comparison-of-two-properties', + ), + pytest.param( + {'variables.x.where': "x[bus] == 'b1'"}, + "'x[bus]' reads a column of 'x', which is a variable. Only a relation, a set or a dimension has columns.", + id='a-column-of-a-variable', + ), + ], +) +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 = typeset(spec, fmt) + assert 'carrier' in printed diff --git a/tests/test_validation.py b/tests/test_validation.py index 608cc838..a527ee49 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -1765,7 +1765,10 @@ class TestRulesDecidedWithoutData: ), pytest.param( {'variables.p.where': "c[h] == 'x'"}, - ("'c[h]' reads a column of 'c', which is a parameter", 'Only a relation has columns'), + ( + "'c[h]' reads a column of 'c', which is a parameter", + 'Only a relation, a set or a dimension has columns', + ), id='where-a-column-of-a-parameter', ), pytest.param(