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.

curvature_required(pw) #

The curvature pw's method is only exact for, or None if any shape works.

convex relaxes the weights onto the hull, which cuts the corners of a mixed curve and nothing else, so it answers 'either'. lp states one side of the curve as its segment lines and the bounded link's sign says which side, so the opposite bend is silently wrong rather than merely loose.

The breakpoints decide whether a model meets the condition, and they arrive with the data rather than with the schema — so this names what to check, and the caller holding the numbers does the checking.

PARAMETER DESCRIPTION
pw

The block whose method is in question.

TYPE: PiecewiseBlock

RETURNS DESCRIPTION
Curvature | None

One of :data:~math_spec.model.CURVATURES for a method that constrains

Curvature | None

the shape, or None for one that does not.

Source code in src/math_spec/piecewise.py
def curvature_required(pw: PiecewiseBlock) -> Curvature | None:
    """The curvature *pw*'s method is only exact for, or ``None`` if any shape works.

    ``convex`` relaxes the weights onto the hull, which cuts the corners of a
    *mixed* curve and nothing else, so it answers ``'either'``. ``lp`` states
    one side of the curve as its segment lines and the bounded link's sign says
    which side, so the opposite bend is silently wrong rather than merely loose.

    The breakpoints decide whether a model meets the condition, and they arrive
    with the data rather than with the schema — so this names what to check,
    and the caller holding the numbers does the checking.

    Args:
        pw: The block whose method is in question.

    Returns:
        One of :data:`~math_spec.model.CURVATURES` for a method that constrains
        the shape, or ``None`` for one that does not.
    """
    if pw.method == 'convex':
        return 'either'
    if pw.method != 'lp':
        return None
    return 'convex' if pw.curve[1].sign == '>=' else 'concave'

expand_piecewise(schema) #

Return schema as a :class:Buildable — 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:Buildable 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: Model) -> Buildable:
    """Return *schema* as a :class:`Buildable` — 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:`Buildable` 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, Buildable):
        return schema
    if schema._expansion is not None:
        return schema._expansion
    if not schema.piecewise:
        schema._expansion = Buildable.model_construct(**dict(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 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 = 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 = 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" — in which case the mask is derived from that parameter when data binds, under the name this returns.

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" — in which case the
    mask is derived from that parameter when data binds, under the name this
    returns.
    """
    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