Skip to content

math_spec.program

The program: what a file declares, with names resolved and shapes fixed.

A :class:Program is a complete declarative description of a linear program over named tidy tables — every declaration a file makes, and no data in it at all. Data is bound against these declarations by whatever builds the model; :func:~math_spec.lowering.to_program is what produces one from a spec.

It is the second public state, and the one a consumer reads. A :class:~math_spec.model.Spec is what the file says; a program is what it means, with macros expanded, names typed, operators resolved to nodes and every dim rule already checked. Consumers dispatch on these nodes and read them; nothing here is built by hand, so what ships beside the nodes is the walk (:func:children), not builders.

A mask is the language's own resolved where node (:mod:math_spec.where_parser) rather than a second set spelling the same predicates — one home, so the two cannot come to disagree about what a comparison is.

Frozen dataclasses only — no execution logic, and nothing imported from a consumer.

Expressions support operator sugar so programs read naturally in Python:

balance = GroupSum(Variable("p"), over="generator", coordinate=("bus",), into=("bus",)) - Parameter("load")

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

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

DimensionDtype = Literal['float', 'int', 'str', 'datetime'] module-attribute #

ExpressionNode = Constant | Parameter | Variable | Negate | Add | Multiply | Power | Divide | Sum | GroupSum | At | Translate | Window module-attribute #

FanIn = Literal['one-to-one', 'many-to-one', 'one-to-many'] module-attribute #

ObjectiveSense = Literal['minimize', 'maximize'] module-attribute #

ParameterDtype = Literal['float', 'int', 'bool', 'str'] module-attribute #

VariableAbsence = Literal['undefined', 'zero'] module-attribute #

VariableType = Literal['continuous', 'binary', 'integer'] module-attribute #

Add(left, right) dataclass #

Bases: Expression

left instance-attribute #

right instance-attribute #

At(operand, over, coordinate, into) dataclass #

Bases: Expression

Read operand through a lookup — the adjoint of :class:GroupSum.

Same mapping table, walked the other way: GroupSum consumes over and produces into, this consumes into and produces over. The fields are named for the table rather than the direction, so the pair reads as one relation; the surface says which end you stand on (sum(by=) consumes it, at(by=) produces it, the lookup names the map).

The join fans out, many over labels sharing one into tuple — the fan-out GroupSum pays in reverse, so the locality class is unchanged.

coordinate instance-attribute #

fan_in = 'one-to-one' class-attribute #

into instance-attribute #

operand instance-attribute #

over instance-attribute #

Constant(value) dataclass #

Bases: Expression

A scalar constant.

value instance-attribute #

ConstraintDeclaration(name, dims, lhs, sense, rhs, where=None) dataclass #

lhs sense rhs for each coord combination of dims.

Either side may carry variables and constants alike; which side a consumer gathers them onto is its own arrangement and not stated here. where masks out coord combinations (row absence, like variables).

dims instance-attribute #

lhs instance-attribute #

name instance-attribute #

rhs instance-attribute #

sense instance-attribute #

where = None class-attribute instance-attribute #

DimensionDeclaration(name, lookups=(), label_spaces=(), dtype='str') dataclass #

A dimension and the lookups its labels carry.

lookups names each lookup and the dimension its values are labels of, checked for containment once the dim tables exist — which keeps a mistyped label from silently dropping its terms in the join that places them.

label_spaces are the inline kind: maps the dimension owns outright, with no target and so nothing to check. They are read for selection and rendering, and resolution refuses to group into one, so no expression node reaches them.

dtype = 'str' class-attribute instance-attribute #

label_spaces = () class-attribute instance-attribute #

lookups = () class-attribute instance-attribute #

maps property #

Every map over the dimension, targeted and label-space alike.

What binding needs a relation for: both kinds are read by a where and both arrive the same way, and only the targeted ones have a label set to be checked against.

name instance-attribute #

targets property #

Each targeted map over the dimension, to the dimension its values are labels of.

The question every consumer of a by= asks, and asked here so it has one answer: an operator grouping through a lookup names the target as the dim it lands on, and a partition array is named for it so an amount declared over the group's own dim can be read through it.

Divide(numerator, divisor) dataclass #

Bases: Expression

Quotient numerator / divisor. The divisor must be variable-free.

divisor instance-attribute #

numerator instance-attribute #

Expression() dataclass #

Base class for expressions over variables and parameters.

Affine everywhere but the objective, where a :class:Multiply of two variable-carrying operands is degree 2; which position allows what is math_spec.degree's to say and no node here records.

The four operators exist for the tests that compose plans by hand; constructing Programs in Python is not supported API, so there is no scalar coercion and no reflected form.

GroupSum(operand, over, coordinate, into) dataclass #

Bases: Expression

Sum operand through coordinates declared on dim over.

coordinate names coordinates carried by dim over whose values are labels of the matching dim in into; the result replaces over with all of them.

into restates each coordinate's declared target, because a node is read on its own — a consumer places terms from one without consulting the program — and lowering is the only thing that writes it.

Several coordinates are one grouping into a product of targets, not a composition of groupings — they are consumed in a single join, so the pair of tuples is always the same length and their order pairs them up.

coordinate instance-attribute #

fan_in = 'many-to-one' class-attribute #

into instance-attribute #

operand instance-attribute #

over instance-attribute #

LookupDeclaration #

Bases: NamedTuple

One declared lookup and the dimension its values are labels of.

name instance-attribute #

target instance-attribute #

Multiply(left, right) dataclass #

Bases: Expression

Product of two operands.

Affine where at least one factor is variable-free. Degree 2 where neither is, which the language allows in the objective alone (math_spec.degree) — so a consumer that cannot represent a quadratic term is told which position it is compiling rather than assuming it.

left instance-attribute #

right instance-attribute #

Negate(operand) dataclass #

Bases: Expression

operand instance-attribute #

ObjectiveDeclaration(sense, expression) dataclass #

Objective — scalar, every reduction in it one the file wrote.

expression instance-attribute #

sense instance-attribute #

Parameter(name) dataclass #

Bases: Expression

A parameter reference — contributes to the constant part.

name instance-attribute #

ParameterDeclaration(name, dims, dtype='float') dataclass #

Shape declaration; data is bound at execution time by name.

dtype is what the declaration claims the values are, and a consumer binding data refuses a column that is not it — so the declaration is what is read, rather than whatever the column happens to hold.

dims instance-attribute #

dtype = 'float' class-attribute instance-attribute #

name instance-attribute #

Power(base, exponent) dataclass #

Bases: Expression

base ** exponent, both variable-free.

Degree 0 in variables wherever it appears, so no consumer has to ask what position it stands in: the language refuses a variable anywhere under it (math_spec.degree), which is what lets this fold to one number per coordinate like any other parameter arithmetic.

base instance-attribute #

exponent instance-attribute #

Program(parameters, variables, constraints, objective, dimensions=(), sos=(), expressions=dict()) dataclass #

A complete linear program over named tidy tables.

constraints instance-attribute #

dimensions = () class-attribute instance-attribute #

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

lookups property #

Every targeted map in the program, with the dimension it is over.

One walk for the several shapes consumers want it in — name to target, target to origin, the set of targets — because the nested comprehension that produces any of them is the same walk written again.

objective instance-attribute #

parameters instance-attribute #

sos = () class-attribute instance-attribute #

variables instance-attribute #

dimension(name) #

The dimension called name.

Undeclared is not an error here: a dimension with no lookups has nothing to declare.

Source code in src/math_spec/program.py
def dimension(self, name: str) -> DimensionDeclaration:
    """The dimension called *name*.

    Undeclared is not an error here: a dimension with no lookups has
    nothing to declare.
    """
    for d in self.dimensions:
        if d.name == name:
            return d
    return DimensionDeclaration(name)

parameter(name) #

Source code in src/math_spec/program.py
def parameter(self, name: str) -> ParameterDeclaration:
    return _declared(self.parameters, name, 'parameter')

variable(name) #

Source code in src/math_spec/program.py
def variable(self, name: str) -> VariableDeclaration:
    return _declared(self.variables, name, 'variable')

SosDeclaration(name, variable, over, sos_type, big_m=None) dataclass #

One special-ordered set per coordinate of the variable's foreach minus over.

The only declaration that adds neither a column nor a row: it names columns a consumer already has and says what may be nonzero among them. Which dims those are is the variable's own foreach and is read from it: a copy here would be a second home for a fact (:meth:Program.variable).

big_m caps the linking coefficient a consumer without the concept reformulates with, and is None where the variable's own upper bound is the only cap.

big_m = None class-attribute instance-attribute #

name instance-attribute #

over instance-attribute #

sos_type instance-attribute #

variable instance-attribute #

Sum(operand, over) dataclass #

Bases: Expression

Sum operand over the named dims, removing them from the result.

fan_in = 'many-to-one' class-attribute #

operand instance-attribute #

over instance-attribute #

Translate(operand, dimension, offset, wrap, fill=None, partition=None) dataclass #

Bases: Expression

Re-index along one dimension: the result at t is operand at t - by.

One node for the whole of shift, whose edge= decides wrap: edge='wrap' is periodic, absent or numeric is not.

wrap carries no default, on this node or on :class:Window. Whether an axis closes onto itself is the difference between a battery that must end as it started and one that need not, and there is no reading of a translation that leaves it unsaid — a node that guessed would be answering for the file.

fill decides what an acyclic shift leaves behind. None, what bare shift lowers to, leaves the vacated positions absent: they carry no value, the absence rules propagate that, and the row drops. A number makes them present and contribute it, which is the only way a file can say "before the axis starts, read zero" without inventing coordinates. Always None under wrap, a cyclic map vacating nothing.

offset is how far back to reach: an integer, or the name of an integer parameter when it differs per entity — a construction lead time, a transit time, a minimum up time. A named offset may not depend on the dimension being translated, and carries its sign in the values.

partition names a lookup over dimension, and then the translation happens inside each group it makes: the neighbour of a coordinate is the one before it in its own group, the edge is that group's edge, and a wrap closes each group onto itself. A coordinate the lookup sends nowhere is in no group and reaches nothing.

dimension instance-attribute #

fan_in = 'one-to-one' class-attribute #

fill = None class-attribute instance-attribute #

offset instance-attribute #

operand instance-attribute #

partition = None class-attribute instance-attribute #

wrap instance-attribute #

Variable(name) dataclass #

Bases: Expression

A variable reference — one term per existing variable row.

name instance-attribute #

VariableDeclaration(name, dims, where=None, lower=(lambda: Constant(float('-inf')))(), upper=(lambda: Constant(float('inf')))(), variable_type='continuous', absence='undefined') dataclass #

absence = 'undefined' class-attribute instance-attribute #

dims instance-attribute #

lower = field(default_factory=lambda: Constant(float('-inf'))) class-attribute instance-attribute #

name instance-attribute #

upper = field(default_factory=lambda: Constant(float('inf'))) class-attribute instance-attribute #

variable_type = 'continuous' class-attribute instance-attribute #

where = None class-attribute instance-attribute #

Window(operand, dimension, width, wrap, partition=None) dataclass #

Bases: Expression

Sum operand over a trailing window along one dimension.

The result at t is the sum of the operand at every position from t - width + 1 through t, so a width of 1 is the operand itself. The dimension survives: this replicates terms onto the positions that can see them rather than reducing anything away.

width is a whole number, or the name of an integer parameter when the window differs per entity — a minimum up time, a rolling budget, a delivery horizon. A named width may not depend on the dimension being summed over.

wrap says whether the window reaches around the start of the axis instead of stopping short at it, and is stated at every construction for the reason :class:Translate gives.

partition names a lookup over that dimension, and the window then stops at each group's edge: a representative day, a season, a scenario's own run of hours. Positions are counted inside the group rather than along the axis, so a coordinate the lookup places nowhere reaches nothing at all — not even itself.

One node rather than a sum of Translates, because the number of terms would then be read from data and the program's shape is fixed before any data is bound. What data supplies is the mask's cardinality, exactly as it supplies how many snapshots there are.

dimension instance-attribute #

fan_in = 'one-to-many' class-attribute #

operand instance-attribute #

partition = None class-attribute instance-attribute #

width instance-attribute #

wrap instance-attribute #

carries_variable(expression) #

Whether a variable appears anywhere under expression.

Source code in src/math_spec/program.py
def carries_variable(expression: ExpressionNode) -> bool:
    """Whether a variable appears anywhere under *expression*."""
    return any(isinstance(node, Variable) for node in walk(expression))

children(expression) #

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

Every walk over a program's expressions recurses through here and differs only in what it does at the leaves. Enumerating the children once is how a node added later reaches all of them rather than one.

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

    Every walk over a program's expressions recurses through here and differs only in
    what it does at the leaves. Enumerating the children once is how a node
    added later reaches all of them rather than one.
    """
    if isinstance(expression, Negate):
        return (expression.operand,)
    if isinstance(expression, (Add, Multiply)):
        return (expression.left, expression.right)
    if isinstance(expression, Divide):
        return (expression.numerator, expression.divisor)
    if isinstance(expression, (Sum, GroupSum, At, Translate, Window)):
        return (expression.operand,)
    return ()

declares_quadratic(c) #

Whether constraint c's expression multiplies two variable-carrying operands.

One home, because unrelated readers act on it — what a solver must support, which declarations to build last — and a third side added to a constraint has to be found by every one of them.

Source code in src/math_spec/program.py
def declares_quadratic(c: ConstraintDeclaration) -> bool:
    """Whether constraint *c*'s expression multiplies two variable-carrying operands.

    One home, because unrelated readers act on it — what a solver must
    support, which declarations to build last — and a third side added to a
    constraint has to be found by every one of them.
    """
    return is_quadratic(c.lhs) or is_quadratic(c.rhs)

divisor_parameters(*expressions) #

Parameters appearing anywhere in a divisor position.

Static, like :func:parameters_of: which names can reach a divisor is the program's to answer, and where they must have values is decided by the rows a declaration builds.

Source code in src/math_spec/program.py
def divisor_parameters(*expressions: ExpressionNode) -> frozenset[str]:
    """Parameters appearing anywhere in a divisor position.

    Static, like :func:`parameters_of`: which names *can* reach a divisor is
    the program's to answer, and *where* they must have values is decided by the
    rows a declaration builds.
    """
    return frozenset().union(*(parameters_of(q.divisor) for q in quotients(*expressions)))

is_quadratic(expression) #

Whether expression contains a product of two variable-carrying operands.

A structural question over the program, and unrelated consumers ask it — what a solver must support, which declarations to build last, whether this form can be represented at all — so it is answered once here beside the other walks rather than once per consumer in its own terms.

Whether a degree may be written is the language's verdict, and this is not a second opinion on it: by the time a program exists the question is which shape the expression has, and the program is what is in hand to answer it.

Source code in src/math_spec/program.py
def is_quadratic(expression: ExpressionNode) -> bool:
    """Whether *expression* contains a product of two variable-carrying operands.

    A structural question over the program, and unrelated consumers ask it —
    what a solver must support, which declarations to build last, whether this
    form can be represented at all — so it is answered once here beside the
    other walks rather than once per consumer in its own terms.

    Whether a degree *may be written* is the language's verdict, and this is
    not a second opinion on it: by the time a program exists the question is
    which shape the expression has, and the program is what is in hand to
    answer it.
    """
    return any(
        isinstance(node, Multiply) and all(carries_variable(side) for side in (node.left, node.right))
        for node in walk(expression)
    )

parameters_of(*expressions) #

Every parameter named anywhere under expressions.

Source code in src/math_spec/program.py
def parameters_of(*expressions: ExpressionNode) -> frozenset[str]:
    """Every parameter named anywhere under *expressions*."""
    return frozenset(node.name for node in walk(*expressions) if isinstance(node, Parameter))

quotients(*expressions) #

Every division under expressions, each kept whole.

The divisor and the numerator answer different questions and one consumer needs them paired: a divisor is judged against the rows the declaration builds narrowed by the variables in its own numerator, which the flat :func:divisor_parameters cannot say.

Source code in src/math_spec/program.py
def quotients(*expressions: ExpressionNode) -> tuple[Divide, ...]:
    """Every division under *expressions*, each kept whole.

    The divisor and the numerator answer different questions and one consumer
    needs them paired: a divisor is judged against the rows the declaration
    builds *narrowed by the variables in its own numerator*, which the flat
    :func:`divisor_parameters` cannot say.
    """
    return tuple(node for node in walk(*expressions) if isinstance(node, Divide))

variables_of(*expressions) #

Every variable named anywhere under expressions.

Source code in src/math_spec/program.py
def variables_of(*expressions: ExpressionNode) -> frozenset[str]:
    """Every variable named anywhere under *expressions*."""
    return frozenset(node.name for node in walk(*expressions) if isinstance(node, Variable))

walk(*expressions) #

Every node under expressions, each expression itself included, parents first.

The traversal every question about a program is a filter of — which names it mentions, whether a variable stands under it, which divisions it contains. One generator rather than that five-line recursion once per question: how a program is traversed is one fact, so a node kind :func:children learns to descend into reaches every caller at once rather than the callers that remembered.

Source code in src/math_spec/program.py
def walk(*expressions: ExpressionNode) -> Iterator[ExpressionNode]:
    """Every node under *expressions*, each expression itself included, parents first.

    The traversal every *question* about a program is a filter of — which names
    it mentions, whether a variable stands under it, which divisions it
    contains. One generator rather than that five-line recursion once per
    question: how a program is traversed is one fact, so a node kind
    :func:`children` learns to descend into reaches every caller at once
    rather than the callers that remembered.
    """
    for expression in expressions:
        yield expression
        yield from walk(*children(expression))