Skip to content

math_spec.expression_parser

pyparsing-based expression parser for math expressions.

Parses strings like sum(p * cost, over=generator) == load into an AST that can be evaluated against a namespace of linopy variables and xarray parameters.

ArithmeticNode is the arithmetic-only union: every nested expression position (operands, args, kwargs) accepts it and nothing else, and ComparisonNode appears only at the top of a parsed expression. The node dataclasses reference the union in their annotations before it is defined, which works only because from __future__ import annotations makes annotations strings — removing that future-import requires reordering the definitions.

ArithmeticNode = NumberNode | NameNode | NameListNode | VariableNode | ParameterNode | DimensionNode | LookupNode | EdgeNode | KeywordNode | UnaryOperatorNode | BinaryOperatorNode | FunctionCallNode | CasesNode module-attribute #

ComparisonOperator = Literal['<=', '>=', '=='] module-attribute #

ExpressionNode = ArithmeticNode | ComparisonNode module-attribute #

BinaryOperatorNode(op, left, right) dataclass #

left instance-attribute #

op instance-attribute #

right instance-attribute #

CaseArm(label, when, value) dataclass #

One region of a :class:CasesNode: where it applies, and the value there.

label instance-attribute #

value instance-attribute #

when instance-attribute #

CasesNode(name, foreach, arms) dataclass #

A value defined by region — a named expression's cases:, inlined.

Built by :mod:math_spec.expansion where a reference to a cased expression stood; there is no grammar for it, because a file writes the cases on the declaration rather than at the use site.

The arms partition foreach — checked at load (:mod:math_spec.partition) — so exactly one applies at every coordinate and the value is a value rather than a choice. That is what lets this be an ordinary arithmetic node: a consumer selects per coordinate, the way a where already filters, and nothing about the shape of the plan depends on data.

arms instance-attribute #

foreach instance-attribute #

name instance-attribute #

ComparisonNode(op, left, right) dataclass #

left instance-attribute #

op instance-attribute #

right instance-attribute #

DimensionNode(name) dataclass #

A resolved reference to a declared dimension.

Only legal in operator kwarg values (sum(x, over=generator)), never as a value in arithmetic — a dimension is a coordinate space, not data.

name instance-attribute #

EdgeNode(policy) dataclass #

A resolved edge policy for shift(x, over=d, offset=n, edge='wrap').

Only legal as the value of shift's edge= kwarg. Like :class:DimensionNode and :class:LookupNode this names neither data nor a coordinate — it is a closed keyword, and the only one the language has. A number in the same position stays an ordinary :class:NumberNode, the value the vacated positions contribute, so one kwarg carries all three edge policies and no second kwarg can contradict it.

policy instance-attribute #

FunctionCallNode(name, args=list(), kwargs=dict()) dataclass #

args = field(default_factory=list) class-attribute instance-attribute #

kwargs = field(default_factory=dict) class-attribute instance-attribute #

name instance-attribute #

KeywordNode(value) dataclass #

A quoted closed keyword in a kwarg value — shift(..., edge='wrap').

Unresolved on purpose: which keywords a kwarg accepts is the operator's business, so this only records that the author wrote a literal rather than a name. resolution.py turns it into the typed node the kwarg wants, or reports it as not one of that kwarg's keywords.

value instance-attribute #

LookupNode(names, dimension, into) dataclass #

A resolved reference to one or more declared lookups.

Only legal in operator kwarg values (sum(x, by=to)). Like :class:DimensionNode this names structure, not data. The lookups carry their own dimensions: dimension is the one they are all over — what sum consumes and at produces — and into the ones their values are labels of, one per name and in the order written, all copied off the declarations once here so no backend has to re-derive them.

Plural because grouping through several lookups at once is one grouping, not a composition of two: sum(x, by=[gen_bus, gen_tech]) consumes generator once and produces both targets. The one-name case is the same node with one-element tuples, so no consumer branches on arity.

dimension instance-attribute #

into instance-attribute #

names instance-attribute #

shown property #

The kwarg value as the author wrote it, for an error message.

NameListNode(names) dataclass #

A bracketed list of names in a kwarg value — sum(x, by=[a, b]).

Unresolved on purpose, and unresolvable here: which kind of name a kwarg admits is the operator's business. Like :class:NameNode this never reaches a backend — resolution.py rewrites it into the one typed node its kwarg wants, so a pass that meets one ran before resolution.

names instance-attribute #

shown property #

The kwarg value as the author wrote it, for an error message.

NameNode(name) dataclass #

An unresolved token — a name whose kind is not yet known.

The parser cannot know whether p is a variable, a parameter or a dimension; only the schema knows. resolution.py rewrites every one of these into one of the typed nodes below, so a NameNode never reaches a backend. If you find one there, resolution was skipped.

name instance-attribute #

NumberNode(value) dataclass #

value instance-attribute #

ParameterNode(name) dataclass #

A resolved reference to a declared parameter.

name instance-attribute #

UnaryOperatorNode(op, operand) dataclass #

op instance-attribute #

operand instance-attribute #

VariableNode(name) dataclass #

A resolved reference to a declared decision variable.

name instance-attribute #

children(node) #

The sub-expressions of node — the structural half of any walk.

Every pass that recurses the whole tree and acts only at certain leaves goes through here, so a node added later reaches all of them. A pass whose answer differs per node type dispatches itself and keeps its assert_never; this is for the ones that only need to get everywhere.

An operator's kwargs are children too — a dimension or coordinate is an ordinary node in a kwarg value, which is what lets a macro bind a formal.

Source code in src/math_spec/expression_parser.py
def children(node: ExpressionNode) -> tuple[ArithmeticNode, ...]:
    """The sub-expressions of *node* — the structural half of any walk.

    Every pass that recurses the whole tree and acts only at certain leaves
    goes through here, so a node added later reaches all of them. A pass whose
    *answer* differs per node type dispatches itself and keeps its
    ``assert_never``; this is for the ones that only need to get everywhere.

    An operator's kwargs are children too — a dimension or coordinate is an
    ordinary node in a kwarg value, which is what lets a macro bind a formal.
    """
    if isinstance(node, UnaryOperatorNode):
        return (node.operand,)
    if isinstance(node, (BinaryOperatorNode, ComparisonNode)):
        return (node.left, node.right)
    if isinstance(node, FunctionCallNode):
        return (*node.args, *node.kwargs.values())
    if isinstance(node, CasesNode):
        return tuple(arm.value for arm in node.arms)
    return ()

parse_expression(text) #

Parse a math expression string into an AST.

With parse_all and a single top-level alternative, element 0 of the parse result is the root node.

RETURNS DESCRIPTION
ExpressionNode

One of NumberNode, NameNode, UnaryOperatorNode,

ExpressionNode

BinaryOperatorNode, ComparisonNode or FunctionCallNode.

Source code in src/math_spec/expression_parser.py
def parse_expression(text: str) -> ExpressionNode:
    """Parse a math expression string into an AST.

    With ``parse_all`` and a single top-level alternative, element 0 of the
    parse result is the root node.

    Returns:
        One of ``NumberNode``, ``NameNode``, ``UnaryOperatorNode``,
        ``BinaryOperatorNode``, ``ComparisonNode`` or ``FunctionCallNode``.
    """
    try:
        result = _GRAMMAR.parse_string(text, parse_all=True)
    except pp.ParseException as e:
        msg = f'Failed to parse expression: {text!r}\n{e}'
        raise SchemaError(msg) from e
    return cast('ExpressionNode', result[0])