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
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.
Constant(value)
dataclass
#
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).
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.
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.
LookupDeclaration
#
Bases: NamedTuple
One declared lookup and the dimension its values are labels of.
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.
Negate(operand)
dataclass
#
Bases: Expression
operand
instance-attribute
#
ObjectiveDeclaration(sense, expression)
dataclass
#
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.
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.
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
parameter(name)
#
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.
Sum(operand, over)
dataclass
#
Bases: Expression
Sum operand over the named dims, removing them from the result.
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.
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.
carries_variable(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
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
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
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
parameters_of(*expressions)
#
Every parameter named anywhere under expressions.
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
variables_of(*expressions)
#
Every variable named anywhere under expressions.
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.