Skip to content

math_spec.piecewise

Expand piecewise: blocks into plain variables and constraints.

A piecewise: block becomes ordinary affine declarations before anything reads the model. The λ convex-combination method needs only the breakpoint parameters themselves, no derived data. 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 (:data:~math_spec.model.PIECEWISE_METHODS); lp emits no weights at all. A link expression is judged before expansion, so p * p is refused against the link the user wrote rather than curve_link0.

declaration_of(expanded) #

The facts of one expanded block, as a program carries them.

A curve has an x-axis only where two links tie it, so the increasing condition — and the shape it is checked with — exist only there; lp alone needs a segment to state a line for; a mask must be one run.

Source code in src/math_spec/piecewise.py
def declaration_of(expanded: ExpandedPiecewise) -> PiecewiseDeclaration:
    """The facts of one expanded block, as a program carries them.

    A curve has an x-axis only where two links tie it, so the increasing
    condition — and the shape it is checked with — exist only there; ``lp``
    alone needs a segment to state a line for; a mask must be one run.
    """
    pw = expanded.block
    checks: list[Check] = []
    curvature = _curvature_required(pw)
    if curvature is not None:
        x, y = pw.curve
        checks.append(Increasing(x.values, pw.over))
        checks.append(Curved(x.values, y.values, pw.over, curvature))
    if pw.method == 'lp':
        checks.append(AtLeastTwo(pw.over, expanded.points))
    if expanded.points is not None:
        checks.append(Contiguous(expanded.points, _nominated(expanded)))
    return PiecewiseDeclaration(
        over=pw.over,
        method=pw.method,
        breakpoints=tuple(link.values for link in pw.links),
        checks=tuple(checks),
    )

derivations_of(block, expanded) #

How each parameter block's expansion emitted is filled, by name.

Everything emitted hangs off the mask, so a block masking nothing emits nothing for the caller to be told about.

Source code in src/math_spec/piecewise.py
def derivations_of(block: str, expanded: ExpandedPiecewise) -> dict[str, Derivation]:
    """How each parameter *block*'s expansion emitted is filled, by name.

    Everything emitted hangs off the mask, so a block masking nothing emits
    nothing for the caller to be told about.
    """
    if (mask := expanded.points) is None:
        return {}
    derivations: dict[str, Derivation] = {}
    if (values := _nominated(expanded)) is not None:
        derivations[mask] = MaskOf(block, values)
    if expanded.starts is not None:
        derivations[expanded.starts] = FirstOf(block, mask)
    if expanded.ends is not None:
        derivations[expanded.ends] = LastOf(block, mask)
    return derivations

expand_piecewise(schema) #

Return schema as a :class:_ExpandedSpec — every piecewise: block expanded away.

The adjacency row shifts with edge=0: a bare shift would drop the first breakpoint's row and leave its weight unconstrained, a wrong MILP with no error (#289). points: masks the weights and the segment binaries and no constraint — every emitted row reduces over the breakpoint axis or carries a masked weight. The result is memoised on schema, and a :class:_ExpandedSpec comes straight back; a model with no piecewise: is retyped with model_construct, its validation already done on the way in.

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: Spec) -> _ExpandedSpec:
    """Return *schema* as a :class:`_ExpandedSpec` — every ``piecewise:`` block expanded away.

    The adjacency row shifts with ``edge=0``: a bare ``shift`` would drop the
    first breakpoint's row and leave its weight unconstrained, a wrong MILP
    with no error (#289). ``points:`` masks the weights and the segment
    binaries and no constraint — every emitted row reduces over the breakpoint
    axis or carries a masked weight. The result is memoised on *schema*, and a
    :class:`_ExpandedSpec` comes straight back; a model with no ``piecewise:`` is
    retyped with ``model_construct``, its validation already done on the way in.

    Raises:
        PiecewiseExpansionError: A block naming something that does not exist,
            or emitting a name the file already declares.
    """
    if isinstance(schema, _ExpandedSpec):
        return schema
    if schema._expansion is not None:
        return schema._expansion
    if not schema.piecewise:
        schema._expansion = _ExpandedSpec.model_construct(**dict(schema))
        return schema._expansion

    raw = schema.model_dump()
    raw.setdefault('variables', {})
    raw.setdefault('constraints', {})
    records: dict[str, dict[str, Any]] = {}
    raw['expanded_piecewise'] = records
    for name, pw in schema.piecewise.items():
        frame = _validate_block(schema, name, pw)
        mask, nominated = _mask_of(name, pw), pw.points
        record = records[name] = {'block': raw['piecewise'][name], 'points': mask}
        if mask is not None and nominated is not None and mask != nominated:
            _emit_parameter(
                raw,
                mask,
                list(schema.parameters[nominated].dims),
                f"where '{nominated}' has a row, and so where the curve runs",
            )
        if pw.method == 'lp':
            _expand_lp(raw, record, name, pw, frame, mask, schema.parameters[pw.points].dims if pw.points else ())
            continue
        lam = f'{name}_lam'

        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':
            seg = f'{name}_seg'
            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 = _ExpandedSpec.model_validate(raw)
    schema._expansion = expanded
    return expanded