Skip to content

math_spec.piecewise

Expand piecewise: blocks into plain variables and constraints.

A block becomes ordinary affine declarations when a caller asks :meth:~math_spec.model.Spec.expand for them, under names prefixed with the block's own; what each method emits is tabled in docs/reference/language/piecewise.md. Every rule a block is held to is decided at load, before this runs: the names it references in :class:~math_spec.model.Spec, its links where every expression is typed, and its frame in :func:curve_frame.

Emitted(name, lam, convexity, set, chord, domain_lo, domain_hi, links, assumptions) dataclass #

Every name one block's expansion may write, spelled once for the emitter and the collision check.

The set a block states writes names of its own, and they are reserved whichever method the block declares: which of the two write them is the method's business, and a collision is the file's either way.

assumptions instance-attribute #

by_kind property #

Each name by the kind of declaration it would collide with.

chord instance-attribute #

convexity instance-attribute #

domain_hi instance-attribute #

domain_lo instance-attribute #

lam instance-attribute #

name instance-attribute #

set instance-attribute #

of(name, pw) classmethod #

The names block name writes.

Source code in src/math_spec/piecewise.py
@classmethod
def of(cls, name: str, pw: PiecewiseDeclaration) -> Emitted:
    """The names block *name* writes."""
    return cls(
        name,
        f'{name}_lam',
        f'{name}_convexity',
        sos.Emitted.of(name, 2),
        f'{name}_chord',
        f'{name}_domain_lo',
        f'{name}_domain_hi',
        tuple(f'{name}_link{i}' for i in range(len(pw.links))),
        tuple(assumptions_of(name, pw)),
    )

written(method, *, ungated) #

The variables and constraints :meth:~math_spec.model.Spec.expand declares for a block of method.

ungated is :func:leaves_ungated of the block's gate. The set a sos2 or adjacency block states is written out too, since expand() writes every set.

Source code in src/math_spec/piecewise.py
def written(self, method: PiecewiseMethod, *, ungated: bool) -> tuple[str, ...]:
    """The variables and constraints :meth:`~math_spec.model.Spec.expand` declares for a block of *method*.

    *ungated* is :func:`leaves_ungated` of the block's gate. The set a
    ``sos2`` or ``adjacency`` block states is written out too, since
    ``expand()`` writes every set.
    """
    if method == 'lp':
        return (self.chord, self.domain_lo, self.domain_hi)
    convexity = (self.convexity, self.convexity + _UNGATED) if ungated else (self.convexity,)
    restriction = (self.set.seg, self.set.pick, self.set.link) if method in ('sos2', 'adjacency') else ()
    return (self.lam, *convexity, *self.links, *restriction)

assumptions_of(block, pw) #

What block assumes of its numbers, by the name the document prints and a refusal quotes.

Every curve assumes its breakpoints are there: a missing parameter row is not absence, it is a zero, so an undeclared breakpoint sits the curve on the origin rather than shortening it. 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.

Read off the block rather than off an expansion, so a model states what it assumes whether or not its curves have been written out. Each condition is an assumptions: entry over the parameters the file declared, its description naming the method and the rewrite that takes a curve of any shape: the expansion writes them into the model, and a model that still declares the block resolves the same entries at load.

Source code in src/math_spec/piecewise.py
def assumptions_of(block: str, pw: PiecewiseDeclaration) -> dict[str, AssumptionBlock]:
    """What *block* assumes of its numbers, by the name the document prints and a refusal quotes.

    Every curve assumes its breakpoints are there: a missing parameter row is
    not absence, it is a zero, so an undeclared breakpoint sits the curve on
    the origin rather than shortening it. 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.

    Read off the block rather than off an expansion, so a model states what it
    assumes whether or not its curves have been written out. Each condition is
    an ``assumptions:`` entry over the parameters the file declared, its
    ``description`` naming the method and the rewrite that takes a curve of any
    shape: the expansion writes them into the model, and a model that still
    declares the block resolves the same entries at load.
    """
    d, mask = pw.over, pw.points
    assumed: dict[str, AssumptionBlock] = {}
    assumed[f'{block}_complete'] = AssumptionBlock(
        holds=' AND '.join(dict.fromkeys(link.values for link in pw.links)),
        where=mask,
        description=f"piecewise '{block}': every breakpoint the curve runs through needs a row in "
        f'{_quoted(link.values for link in pw.links)} — a missing row is read as a zero rather than as a '
        f'shorter curve, so it sits the curve on the origin. '
        + (
            f"Bind the rows, or narrow points: '{mask}' to where the curve runs."
            if mask is not None
            else 'Bind the rows, or declare points: to say how far the curve runs.'
        ),
    )
    curvature = _curvature_required(pw)
    if curvature is not None:
        x, y = (link.values for link in pw.curve)
        assumed[f'{block}_increasing'] = AssumptionBlock(
            holds=f'{_back(x, d, 1)} < {x}',
            where=_neighbours(d, mask),
            description=f"piecewise '{block}': method: {pw.method} requires strictly increasing breakpoints in '{x}' along '{d}'",
        )
        assumed[f'{block}_curvature'] = _bends(block, pw, x, y, curvature)
    if pw.method == 'lp':
        assumed[f'{block}_breakpoints'] = AssumptionBlock(
            holds=f'count({mask or pw.curve[0].values}, over={d}) >= 2',
            description=f"piecewise '{block}': method: lp needs at least two breakpoints per curve — the method *is* its "
            f'segment lines, so a curve with no segment states nothing and leaves the bounded link on its own '
            f'bound. Use method: adjacency, sos2 or convex, which pin it to the points it does have.',
        )
    if mask is not None:
        assumed[f'{block}_contiguous'] = AssumptionBlock(
            holds=f'count({_edge(d, mask, "first")}, over={d}) == 1',
            description=f"piecewise '{block}': points: '{mask}' must mark a consecutive run of at least one breakpoint per "
            f'curve — {_GAP[pw.method]}.',
        )
    return assumed

curve_frame(schema, name, pw, links) #

The dimensions block name builds one curve per coordinate of: every one its links and its gate carry.

In declaration order, because iterating a set would vary the emitted dims — and every column index behind it — per process. links are the block's link expressions typed, as :func:resolve_links answers.

RAISES DESCRIPTION
DimensionError

A link or the gate carries the breakpoint dimension, or a values or points: parameter varies along a dimension no link expression carries.

Source code in src/math_spec/piecewise.py
def curve_frame(schema: Spec, name: str, pw: PiecewiseBlock, links: Iterable[Expression]) -> tuple[str, ...]:
    """The dimensions block *name* builds one curve per coordinate of: every one its links and its gate carry.

    In declaration order, because iterating a set would vary the emitted
    ``dims`` — and every column index behind it — per process. *links* are the
    block's link expressions typed, as :func:`resolve_links` answers.

    Raises:
        DimensionError: A link or the gate carries the breakpoint dimension, or
            a values or ``points:`` parameter varies along a dimension no link
            expression carries.
    """
    context = f"piecewise '{name}'"
    carried = [(f'link {i} expression', dims_of(node, schema, f'{context} link {i}')) for i, node in enumerate(links)]
    if pw.activity is not None:
        carried.append(('activity', frozenset(schema.variables[pw.activity].dims)))
    frame: list[str] = []
    for what, found in carried:
        for d in (d for d in schema.dimensions if d in found):
            if d == pw.over:
                raise DimensionError(f"{context}: {what} already carries the breakpoint dim '{pw.over}'")
            if d not in frame:
                frame.append(d)
    for i, link in enumerate(pw.links):
        if stray := [d for d in schema.parameters[link.values].dims if d != pw.over and d not in frame]:
            raise DimensionError(
                f"{context}: link {i} values parameter '{link.values}' carries {stray}, which no link "
                f'expression does — the block builds one curve per coordinate of {frame}, so a curve '
                f'varying along {stray} has nothing to vary against. Declare a link expression over '
                f"it, or drop it from '{link.values}'."
            )
    if pw.points is not None and pw.nominated is None:
        mask = schema.parameters[pw.points].dims
        if stray := [d for d in mask if d != pw.over and d not in frame]:
            raise DimensionError(
                f"{context}: points parameter '{pw.points}' carries {stray}, which the links do not — "
                f"a mask says which of the block's own coordinates exist, and cannot add coordinates"
            )
    return tuple(frame)

expand_piecewise(schema) #

schema with every piecewise: block written out — schema itself where it declares none.

A method: adjacency block states its restriction as the set method: sos2 states, and then that set is written out here too: the binaries are what the method is, so the model that comes back carries no set of its own (:func:math_spec.sos.emit is where they are spelled). Each block's frame and names are read off the program schema lowered to.

Source code in src/math_spec/piecewise.py
def expand_piecewise(schema: Spec) -> Spec:
    """*schema* with every ``piecewise:`` block written out — *schema* itself where it declares none.

    A ``method: adjacency`` block states its restriction as the set
    ``method: sos2`` states, and then that set is written out here too: the
    binaries are what the method *is*, so the model that comes back carries no
    set of its own (:func:`math_spec.sos.emit` is where they are spelled).
    Each block's frame and names are read off the program *schema* lowered to.
    """
    if not schema.piecewise:
        return schema
    program = schema.program
    raw = schema.model_dump()
    raw.setdefault('variables', {})
    raw.setdefault('constraints', {})
    for name, pw in schema.piecewise.items():
        _Block(schema, raw, name, pw, program.piecewise[name]).expand()
    raw['piecewise'].clear()
    for name, pw in schema.piecewise.items():
        if pw.method == 'adjacency':
            sos.emit(raw, name)
    return Spec.model_validate(raw)

leaves_ungated(gate) #

Whether a curve gated by gate runs ungated where the gate does not exist, which takes a second convexity row.

A masked gate is absent off its mask, and there the curve sums to 1; absence: zero reads the gate as 0 there instead, which one row states.

Source code in src/math_spec/piecewise.py
def leaves_ungated(gate: VariableBlock | VariableDeclaration | None) -> bool:
    """Whether a curve gated by *gate* runs ungated where the gate does not exist, which takes a second convexity row.

    A masked gate is absent off its mask, and there the curve sums to 1;
    ``absence: zero`` reads the gate as 0 there instead, which one row states.
    """
    return gate is not None and gate.where is not None and gate.absence != 'zero'

lp_domain_refusal(name, pw, links) #

The refusal for a method: lp curve whose x-link carries no variable, or None.

The method bounds the curve's domain with two rows comparing the x-link against the first and the last breakpoint, and a row with no variable decides nothing. Decided on the link the file wrote, rather than on the row the expansion would write under a name the file never declared.

Source code in src/math_spec/piecewise.py
def lp_domain_refusal(name: str, pw: PiecewiseBlock, links: tuple[Expression, ...]) -> str | None:
    """The refusal for a ``method: lp`` curve whose x-link carries no variable, or ``None``.

    The method bounds the curve's domain with two rows comparing the x-link
    against the first and the last breakpoint, and a row with no variable
    decides nothing. Decided on the link the file wrote, rather than on the
    row the expansion would write under a name the file never declared.
    """
    x = pw.curve[0]
    i = next(i for i, link in enumerate(pw.links) if link is x)
    if carries_variable(links[i]):
        return None
    return (
        f"piecewise '{name}' link {i}: method: lp bounds the curve's domain by rows comparing this link's expression "
        f'against its first and last breakpoint, and {x.expression!r} carries no variable, so those rows decide '
        f'nothing. Name a variable in the link, or use method: convex, sos2 or adjacency, whose weights pin the '
        f'domain themselves.'
    )

Block name's link expressions typed, in link order, or None once one failed, its refusal appended.

A link is read affinely, so it is held to degree 1 where it is read.

Source code in src/math_spec/piecewise.py
def resolve_links(name: str, pw: PiecewiseBlock, ns: Namespace, errors: list[str]) -> tuple[Expression, ...] | None:
    """Block *name*'s link expressions typed, in link order, or ``None`` once one failed, its refusal appended.

    A link is read affinely, so it is held to degree 1 where it is read.
    """
    links = [
        resolve_expression_text(link.expression, ns, f"piecewise '{name}' link {i}", errors, ceiling=1)
        for i, link in enumerate(pw.links)
    ]
    if any(link is None for link in links):
        return None
    return tuple(link for link in links if link is not None)