Skip to content

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
def expand_piecewise(schema: Model) -> Buildable:
    """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:
        PiecewiseExpansionError: A block naming something that does not exist,
            or emitting a name the file already declares.
    """
    if isinstance(schema, Buildable):
        return schema
    if schema._expansion is not None:
        return schema._expansion
    if not schema.piecewise:
        schema._expansion = _retyped(schema)
        return schema._expansion

    raw = schema.model_dump()
    raw.setdefault('variables', {})
    raw.setdefault('constraints', {})
    for name, pw in schema.piecewise.items():
        frame = _validate_block(schema, name, pw)
        mask, nominated = mask_of(name, pw), pw.points
        if mask is not None and nominated is not None and mask != nominated:
            raw.setdefault('parameters', {})[mask] = {
                'dims': list(schema.parameters[nominated].dims),
                'dtype': 'bool',
                'description': f"where '{nominated}' has a row, and so where the curve runs",
            }
        if pw.method == 'lp':
            _expand_lp(raw, name, pw, frame, mask, schema.parameters[pw.points].dims if pw.points else ())
            continue
        lam, seg = f'{name}_lam', f'{name}_seg'

        raw['variables'][lam] = {
            'foreach': [*frame, pw.over],
            **({'where': mask} if mask else {}),
            'bounds': {'lower': 0.0, 'upper': 1.0},
            'description': 'convex-combination weight on a breakpoint',
        }
        gated = _gate_rows(schema, pw)
        for suffix, where, rhs in gated:
            raw['constraints'][f'{name}_convexity{suffix}'] = {
                'foreach': list(frame),
                **({'where': where} if where else {}),
                'expression': f'sum({lam}, over={pw.over}) == {rhs}',
            }
        for i, link in enumerate(pw.links):
            raw['constraints'][f'{name}_link{i}'] = {
                'foreach': list(frame),
                'expression': (f'({link.expression}) {link.sign} sum({lam} * {link.values}, over={pw.over})'),
            }
        if pw.method == 'sos2':
            raw.setdefault('sos', {})[name] = {'variable': lam, 'over': pw.over, 'type': 2}
        elif pw.method == 'adjacency':
            raw['variables'][seg] = {
                'foreach': [*frame, pw.over],
                **({'where': mask} if mask else {}),
                'domain': 'binary',
                'bounds': {},
            }
            for suffix, where, rhs in gated:
                raw['constraints'][f'{name}_pick{suffix}'] = {
                    'foreach': list(frame),
                    **({'where': where} if where else {}),
                    'expression': f'sum({seg}, over={pw.over}) == {rhs}',
                }
            raw['constraints'][f'{name}_adjacency'] = {
                'foreach': [*frame, pw.over],
                'expression': f'{lam} <= {seg} + shift({seg}, over={pw.over}, offset=1, edge=0)',
            }

    raw['piecewise'].clear()
    expanded = Buildable.model_validate(raw)
    schema._expansion = expanded
    return expanded

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.

Source code in src/math_spec/piecewise.py
def mask_of(block: str, pw: PiecewiseBlock) -> str | None:
    """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.
    """
    if pw.points is None:
        return None
    return f'{block}_points' if pw.points in {link.values for link in pw.links} else pw.points