math_spec.piecewise
Expand piecewise: blocks into plain variables and constraints.
This is schema-level expansion (the piecewise rules): a piecewise: block becomes
ordinary affine declarations before anything is built, so both backends —
eager and relational — receive identical schemas and stay differential-
testable. Formulations never enter the plan as expression nodes.
The λ convex-combination method is used because it is expansion-pure: it needs only the breakpoint coordinate parameters themselves, no derived data (no slopes, intercepts, or segment lengths). For a block
piecewise:
curve:
over: bp
links:
- [power, power_bp]
- [fuel * eff, fuel_bp, "<="]
with F = the union of the links' dims, it emits:
variables:
curve_lam(F, bp) in [0, 1]
curve_seg(F, bp) binary (method: adjacency)
constraints:
curve_convexity(F): sum(curve_lam, over=bp) == 1
curve_pick(F): sum(curve_seg, over=bp) == 1 (method: adjacency)
curve_adjacency(F, bp): curve_lam <= curve_seg + shift(curve_seg, over=bp, offset=1, edge=0)
curve_link0(F): (power) == sum(curve_lam * power_bp, over=bp)
curve_link1(F): (fuel * eff) <= sum(curve_lam * fuel_bp, over=bp)
Only the restriction on λ varies, which is why it is one method: key
and not three formulations (:data:~math_spec.model.PIECEWISE_METHODS).
Every method emits the weights, the convexity row and the links. Then
adjacency adds the binaries above, so at most two neighbouring λ are
nonzero and the linked expressions lie on the curve exactly; sos2 states
that same restriction as a sos: block over the same weights, leaving a
sink that branches on a set to do so; and convex adds nothing, leaving λ
over the hull of the breakpoints — the correct relaxation for a convex or
concave curve under optimisation pressure.
sos2 is the one method emitting a declaration this module does not expand
away, and that is what lets the choice reach a solver at all: an expansion is
unconditional where a capability is per sink, so the formulation stays the
file's and the encoding stays the sink's (relational/sinks/sos.py).
A link expression is judged against the language before expansion — resolved,
degree-checked, dims from :mod:~math_spec.dimensions — which keeps
p * p named against the link the user wrote rather than curve_link0, a
declaration they never saw.
Two verdicts are deliberately elsewhere: what a plan node can represent is the
consuming lane's business, and curvature is a property of the breakpoint
values, so it needs data and lives in :mod:math_spec.sources.
expand_piecewise(schema)
#
Return schema as a :class:Buildable — every piecewise: block expanded away.
The adjacency constraint shifts with edge=0 rather than a bare
shift: at the first breakpoint the vacated term must contribute zero,
giving lam <= seg. Left absent it would propagate and drop that row,
leaving the first lambda unconstrained by segment selection — a wrong MILP
with no error, which is why #289 kept the escape hatch.
points: masks the declarations — the weights and the segment binaries
— and no constraint. Every emitted row either reduces over the breakpoint
axis, where absence does not spread, or carries a masked weight, which
takes the row with it: a second where: on the adjacency row builds the
same model down to the column.
Building the expanded model validates it, so the result is memoised on
schema — a validated schema already carries the expansion its own
validation built (:class:Model expands as a check on the way in), and
asking again returns it rather than validating a second copy. Idempotent:
a :class:Buildable is its own expansion and comes straight back, so a
consumer unsure whether it has expanded yet can simply ask.
| RAISES | DESCRIPTION |
|---|---|
PiecewiseExpansionError
|
A block naming something that does not exist, or emitting a name the file already declares. |
Source code in src/math_spec/piecewise.py
112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 | |
mask_of(block, pw)
#
The parameter a block masks its weights with, or None for a whole curve.
points: may name the mask itself, or one of the block's own values
parameters — "the curve runs as far as this does". The second is a mask
nobody wrote, derived from that parameter's rows when data binds
(:func:math_spec.sources.derive_curve_masks), so this is what says where it
lands. Both the expansion and the data guards ask here rather than reading
points: twice and disagreeing.