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.

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

BinaryOperator = Literal['+', '-', '*', '/', '**'] module-attribute #

BranchNode = UnaryOperatorNode | BinaryOperatorNode | ComparisonNode | FunctionCallNode | CasesNode module-attribute #

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

ExpressionNode = ArithmeticNode | ComparisonNode module-attribute #

KwargNode = DimensionNode | LookupNode | EdgeNode module-attribute #

LeafNode = NumberNode | VariableNode | ParameterNode | KwargNode | UnresolvedNode module-attribute #

NAME = '[a-zA-Z_][a-zA-Z0-9_]*' module-attribute #

REAL = '\\d+\\.\\d*([eE][+-]?\\d+)?|\\d+[eE][+-]?\\d+' module-attribute #

UnaryOperator = Literal['+', '-'] module-attribute #

UnresolvedNode = NameNode | NameListNode | KeywordNode 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.

when is None on the last arm and only there — the block's otherwise:, which is what makes the quantity total without anything having to prove it. Every other arm's when is proved apart from every other arm's.

label instance-attribute #

value instance-attribute #

when instance-attribute #

CasesNode(name, 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, since a file writes the cases on the declaration rather than at the use site.

Exactly one arm applies at every coordinate: no two when masks can hold at once, which :mod:math_spec.exclusivity proves at load, and the last arm — the block's otherwise: — carries no when and so takes whatever the rest leave. So the arms may be read in any order; the file's is kept because it is the order they print in. The frame is not carried here: it is on the declaration, which every consumer needing it already holds.

arms 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, legal only as an edge= value.

A number in the same position stays a :class:NumberNode: the value the vacated positions contribute.

policy instance-attribute #

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

An operator or macro call — like every node, unrewritable once built.

kwargs is copied behind a read-only view at construction, so neither a holder of the mapping passed in nor a reader of the node can rewrite an argument under another pass; it is excluded from the hash because a mapping has none, which is lawful — equal nodes still hash equal on name and args.

args = () class-attribute instance-attribute #

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

name instance-attribute #

KeywordNode(value) dataclass #

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

Unresolved: which keywords the kwarg accepts is the operator's business.

value instance-attribute #

LookupNode(names, dimension, into) dataclass #

A resolved reference to one or more declared lookups, legal only in a kwarg value.

dimension is the one every lookup is over — what sum consumes and at produces — and into the targets, one per name in the order written; sum(x, by=[gen_bus, gen_tech]) is one grouping, not two.

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: which kind of name the kwarg admits is the operator's business.

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 #

case_context(name, label) #

Where an error inside one arm of a cased expression is reported: the declaration, not the use site.

A cased expression is expanded where its name stood, so the context in hand at that point is the constraint's — and naming it would report a case on a constraint that has none.

PARAMETER DESCRIPTION
name

The named expression the arm belongs to.

TYPE: str

label

The case's name, or None for the block's otherwise:, which is not a case and is not named as one.

TYPE: str | None

RETURNS DESCRIPTION
str

The context prefix an error message carries.

Source code in src/math_spec/expression_parser.py
def case_context(name: str, label: str | None) -> str:
    """Where an error inside one arm of a cased expression is reported: the declaration, not the use site.

    A cased expression is expanded where its name stood, so the context in hand
    at that point is the constraint's — and naming it would report a case on a
    constraint that has none.

    Args:
        name: The named expression the arm belongs to.
        label: The case's name, or ``None`` for the block's ``otherwise:``,
            which is not a case and is not named as one.

    Returns:
        The context prefix an error message carries.
    """
    where = 'otherwise' if label is None else f"case '{label}'"
    return f"Named expression '{name}', {where}"

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):
        # the values only: a `when` is a mask over the frame, not a value in it
        return tuple(arm.value for arm in node.arms)
    return ()

parse_expression(text) #

Parse a math expression string into an AST.

RAISES DESCRIPTION
SchemaError

If text is not an expression of the language. A predictable mistake — a strict or chained comparison, !=, a lone =, ^ for power — is named with its rewrite before the grammar's own complaint.

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

    Raises:
        SchemaError: If *text* is not an expression of the language. A
            predictable mistake — a strict or chained comparison, ``!=``, a
            lone ``=``, ``^`` for power — is named with its rewrite before the
            grammar's own complaint.
    """
    try:
        result = _GRAMMAR.parse_string(text, parse_all=True)
    except pp.ParseException as e:
        rewrite = _named_rewrite(text, e.loc)
        hint = f'{rewrite}\n' if rewrite is not None else ''
        msg = f'Failed to parse expression: {text!r}\n{hint}{e}'
        raise SchemaError(msg) from e
    return cast('ExpressionNode', result[0])

shown(names) #

Names as a kwarg value is written: bare when one, bracketed when several.

Source code in src/math_spec/expression_parser.py
def shown(names: tuple[str, ...]) -> str:
    """Names as a kwarg value is written: bare when one, bracketed when several."""
    return names[0] if len(names) == 1 else f'[{", ".join(names)}]'