Skip to content

math_spec.model

The YAML surface's types — every block a file may contain, rooted at :class:Model.

A block per declaration kind, and one strict base: an unrecognised key is an error naming the near miss rather than a shrug, because a dropped bounds: leaves a variable unbounded and says nothing.

:class:Model is the first of the three stages the pipeline names — what a file declares, before plan.Program (what it lowers to) and an engine (what a build holds). Nothing here has seen data.

DIMENSION_DTYPES = frozenset(get_args(DimensionDtype)) module-attribute #

DimensionDtype = Literal['float', 'int', 'str', 'datetime'] module-attribute #

Expression = Annotated[str, BeforeValidator(_number_is_an_expression, json_schema_input_type=str | float)] module-attribute #

LinkSign = Literal['==', '<=', '>='] module-attribute #

ObjectiveSense = Literal['minimize', 'maximize'] module-attribute #

PARAMETER_DTYPES = frozenset(get_args(ParameterDtype)) module-attribute #

PIECEWISE_METHODS = {'adjacency': 'a binary per segment, and a row making the two nonzero weights neighbours', 'sos2': 'the same weights, restricted by a set the sink branches on (the sos rules)', 'convex': 'nothing — the weights range over the hull, which is a pure LP', 'lp': 'no weights at all — one row per segment line, plus the two rows holding the domain'} module-attribute #

ParameterDtype = Literal['float', 'int', 'bool', 'str'] module-attribute #

PiecewiseMethod = Literal['adjacency', 'sos2', 'convex', 'lp'] module-attribute #

SOS_TYPES = frozenset(get_args(SosType)) module-attribute #

SUPPORTED_VERSIONS = (0,) module-attribute #

SosType = Literal[1, 2] module-attribute #

VARIABLE_ABSENCE = frozenset(get_args(VariableAbsence)) module-attribute #

VARIABLE_DOMAINS = frozenset(get_args(VariableDomain)) module-attribute #

VariableAbsence = Literal['undefined', 'zero'] module-attribute #

VariableDomain = Literal['continuous', 'integer', 'binary'] module-attribute #

BoundsBlock #

Bases: _StrictBlock

Variable bounds — each side is a number or parameter name.

linopy's defaults (add_variables(lower=-inf, upper=inf)): omitting a bound leaves the variable unbounded on that side, not implicitly non-negative. Non-negativity is a real constraint, so the file says it.

lower = float('-inf') class-attribute instance-attribute #

upper = float('inf') class-attribute instance-attribute #

Buildable #

Bases: Model

A model with nothing left to expand — what rows are built from.

A :class:Model is the file as written, and a file may carry a piecewise: block whose variables and constraints do not exist until :func:~math_spec.piecewise.expand_piecewise emits them. This is the model after that pass, and it guarantees the one thing a builder needs: variables: and constraints: hold the whole model, so the rows built from it are the rows the file asked for.

Taking one is how a consumer says it builds rather than reads, and passing a :class:Model where one is wanted is a type error rather than a model quietly missing declarations. The subtyping runs the other way for the same reason: whatever reads the file as written — the curve masks :mod:math_spec.sources derives from piecewise: — takes a :class:Model and accepts either.

The guarantee is about declarations, and deliberately says nothing about the expression strings inside them: macros: and expressions: are substituted per read, because an expression is needed only when someone reads it where the set of declarations is needed before anything can be.

ConstraintBlock #

Bases: _StrictBlock

A declared constraint: one rule, over one frame.

description = None class-attribute instance-attribute #

expression instance-attribute #

foreach instance-attribute #

referenced_dims property #

where = None class-attribute instance-attribute #

DimensionBlock #

Bases: _StrictBlock

A declared dimension with optional dtype and values.

A dimension is an axis and nothing else. The maps its members carry — a generator's bus, a snapshot's period — are top-level lookups: (:class:LookupBlock), keyed by their own name.

description = None class-attribute instance-attribute #

dtype = 'str' class-attribute instance-attribute #

values = None class-attribute instance-attribute #

ExpressionBlock #

Bases: _StrictBlock

A named quantity: one arithmetic expression, readable after a solve.

Written in YAML as a bare string, or as a mapping once it carries a description: — and serialised back to whichever form it was written in, so a round trip through :meth:Model.to_yaml reproduces the file::

expressions:
  total_generation: sum(p, over=generator)
  emissions:
    expression: sum(p * rate, over=generator)
    description: CO2 released, the quantity the cap bounds

The description matters more here than anywhere else: a named expression is expanded away before the typeset walk, so its whole surface is result.expression(name) after a solve — a name arriving in a summary with nothing else to say what it counts.

cases = {} class-attribute instance-attribute #

description = None class-attribute instance-attribute #

expression = None class-attribute instance-attribute #

foreach = None class-attribute instance-attribute #

referenced_dims property #

foreach where there is one — only a cased expression declares it.

ExpressionCase #

Bases: _StrictBlock

One case of a named expression: the value, and where it is the value.

when rather than where: a case selects which value a coordinate takes, and creates no absence and deletes no row — which is what where means on every other block (:doc:absence </reference/language/absence>).

description = None class-attribute instance-attribute #

expression instance-attribute #

when instance-attribute #

LookupBlock #

Bases: _StrictBlock

A named single-valued map out of a dimension (the declaration rules).

Two kinds, told apart by which field is set:

  • into: names the dimension the values are labels of — the groupable kind, what sum(by=) lands terms on and at(by=) reads through, checked for containment once data is bound rather than joined blind::

    lookups: bus_of: {over: generator, into: bus} send: {over: line, into: bus}

  • dtype: declares an inline label space — the selection-only kind, owning its values and targeting nothing, so no axis exists for terms to land on. Grouping into one is refused with the promotion rewrite (:func:math_spec.resolution._ungroupable)::

    lookups: period: {over: snapshot, dtype: int}

values: gives the map in the file — {label of over: value} — for a relation small enough to read, the way a dimension's own values: does. A label it omits is unmapped, which is the partial case a lookup already allows. Without it the map is supplied at bind time under the lookup's own source key, as a (over, label space) relation of the rows it has (the data-binding rules). One of the two, and never neither.

description = None class-attribute instance-attribute #

dtype = None class-attribute instance-attribute #

into = None class-attribute instance-attribute #

over instance-attribute #

values = None class-attribute instance-attribute #

MacroBlock #

Bases: _StrictBlock

A parameterised expression template, defined in the YAML itself.

Language, not code: formals (args positional, kwargs keyword) shadow model names inside the template, and every call site expands into core AST before either backend sees the expression.

args = [] class-attribute instance-attribute #

description = None class-attribute instance-attribute #

kwargs = [] class-attribute instance-attribute #

template instance-attribute #

Model #

Bases: _StrictBlock

The declared math — one YAML file, or one dict, validated.

First of the three stages the pipeline names: Model is what a file says, plan.Program what it lowers to, an engine what a build holds. Nothing here has seen data.

The API is the ten declaration sections plus version and description, and two ways back out: :meth:to_dict for the model as data, :meth:to_yaml for the file a reviewer reads. In goes through lps.load_model, which raises :class:~math_spec.errors.LanguageError on a model the language refuses.

Everything else on this class is pydantic's, not a contract this package keeps — model_json_schema() describes the shape pydantic validates rather than the language (checked in for editors as schema/math_spec.schema.json), and model_construct() skips validation entirely, so a Model is valid when it was built the normal way.

constraints = {} class-attribute instance-attribute #

description = None class-attribute instance-attribute #

dimensions = {} class-attribute instance-attribute #

expressions = {} class-attribute instance-attribute #

lookups = {} class-attribute instance-attribute #

macros = {} class-attribute instance-attribute #

objective = None class-attribute instance-attribute #

parameters = {} class-attribute instance-attribute #

piecewise = {} class-attribute instance-attribute #

sos = {} class-attribute instance-attribute #

variables = {} class-attribute instance-attribute #

version = 0 class-attribute instance-attribute #

declared_maps(dimension) #

The lookups over dimension whose map the file declares, by name.

A map is not the dimension. It is a partial relation over one, free to omit labels and written in whatever key order someone typed, so it supplies values and never the label set or its order — those come from dimensions.<d>.values or from the caller, and a label no map mentions is a label with a null lookup, not a label that does not exist.

RETURNS DESCRIPTION
dict[str, dict[Any, Any]]

{lookup name: {label: value}}, empty where the file declares no

dict[str, dict[Any, Any]]

map over dimension.

Source code in src/math_spec/model.py
def declared_maps(self, dimension: str) -> dict[str, dict[Any, Any]]:
    """The lookups over *dimension* whose map the file declares, by name.

    **A map is not the dimension.** It is a partial relation over one, free
    to omit labels and written in whatever key order someone typed, so it
    supplies values and never the label set or its order — those come from
    ``dimensions.<d>.values`` or from the caller, and a label no map
    mentions is a label with a null lookup, not a label that does not exist.

    Returns:
        ``{lookup name: {label: value}}``, empty where the file declares no
        map over *dimension*.
    """
    return {n: lk.values or {} for n, lk in self.lookups.items() if lk.over == dimension and lk.values is not None}

label_space(lookup) #

What lookup's values are labels of, named as a supplied relation names it.

The groupable kind lands its values in the dimension it targets, so that dimension names them; the label-space kind owns its values and no dimension holds them, so the lookup does. One rule — the space the values live in — and the two kinds need no branch at the caller.

Source code in src/math_spec/model.py
def label_space(self, lookup: str) -> str:
    """What *lookup*'s values are labels of, named as a supplied relation names it.

    The groupable kind lands its values in the dimension it targets, so
    that dimension names them; the label-space kind owns its values and no
    dimension holds them, so the lookup does. One rule — *the space the
    values live in* — and the two kinds need no branch at the caller.
    """
    return self.lookups[lookup].into or lookup

labels_of(dimension) #

The label-space lookups over dimension — selection only, never an axis.

Source code in src/math_spec/model.py
def labels_of(self, dimension: str) -> dict[str, LookupBlock]:
    """The label-space lookups over *dimension* — selection only, never an axis."""
    return {n: lk for n, lk in self.lookups.items() if lk.over == dimension and lk.into is None}

model_validate(*args, **kwargs) classmethod #

Validate a mapping — see :func:_in_our_tree for what it raises.

Source code in src/math_spec/model.py
@classmethod
@override
def model_validate(cls, *args: Any, **kwargs: Any) -> Self:
    """Validate a mapping — see :func:`_in_our_tree` for what it raises."""
    return _in_our_tree(super().model_validate, *args, **kwargs)

model_validate_json(*args, **kwargs) classmethod #

The same door, for JSON.

Source code in src/math_spec/model.py
@classmethod
@override
def model_validate_json(cls, *args: Any, **kwargs: Any) -> Self:
    """The same door, for JSON."""
    return _in_our_tree(super().model_validate_json, *args, **kwargs)

targeted_of(dimension) #

The groupable lookups over dimension: name -> the dim they map into.

Source code in src/math_spec/model.py
def targeted_of(self, dimension: str) -> dict[str, str]:
    """The groupable lookups over *dimension*: name -> the dim they map into."""
    return {n: lk.into for n, lk in self.lookups.items() if lk.over == dimension and lk.into is not None}

to_dict() #

The model as plain data. load_model(m.to_dict()) reproduces it.

Source code in src/math_spec/model.py
def to_dict(self) -> dict[str, Any]:
    """The model as plain data. ``load_model(m.to_dict())`` reproduces it."""
    return self.model_dump()

to_yaml() #

The file a reviewer reads — including for a model that never had one.

Hard rule 5 is that the model is the file you review and diff; a model a framework emitted as a dict has no such file. Generated rather than authored, so length costs a reader nothing and being unambiguous saves them knowing this package's defaults at all.

Source code in src/math_spec/model.py
def to_yaml(self) -> str:
    """The file a reviewer reads — including for a model that never had one.

    Hard rule 5 is that the model is the file you review and diff; a model
    a framework emitted as a dict has no such file. Generated rather than
    authored, so length costs a reader nothing and being unambiguous saves
    them knowing this package's defaults at all.
    """
    import yaml

    return yaml.safe_dump(self.to_dict(), sort_keys=False, allow_unicode=True)

ObjectiveBlock #

Bases: _StrictBlock

A declared objective function.

description = None class-attribute instance-attribute #

expression instance-attribute #

sense = 'minimize' class-attribute instance-attribute #

ParameterBlock #

Bases: _StrictBlock

A declared parameter with dims and dtype.

description = None class-attribute instance-attribute #

dims instance-attribute #

dtype = 'float' class-attribute instance-attribute #

referenced_dims property #

The dimensions this block names — dims here, foreach on the rest.

PiecewiseBlock #

Bases: _StrictBlock

N expressions jointly pinned to a breakpoint-indexed piecewise curve.

Mirrors linopy.Model.add_piecewise_formulation. Each link is [expression, values_parameter] or [expression, values_parameter, sign]: expression is any affine expression string, values_parameter names a parameter carrying the over dim, and sign bounds the link by the curve instead of pinning it (at most one non-"==", and only with exactly two links).

over names the breakpoint dimension; method is which of :data:PIECEWISE_METHODS restricts the weights; activity names what the weights sum to — 1 where the block is unconditional, and a binary where a curve applies only when something runs, which pins the formulation to 0 when it is 0; points names a boolean parameter saying how far each curve runs, for a model whose curves are not all the same length. Expanded before building into plain variables and constraints — see math_spec.piecewise.

activity = None class-attribute instance-attribute #

convex property #

Whether this block relaxes to the hull, which needs no binaries.

The curvature guard and the expansion both ask this rather than comparing against the method name, since what they act on is the absence of a restriction and not which word was written.

curvature_required property #

The curvature this block's method is only exact for, if any.

'either' is the hull's condition — it cuts corners on a mixed curve and nothing else. lp states one side of the curve and its sign says which, so the opposite bend is silently wrong rather than merely loose.

curve property #

The two links as (x, y), the bounded one last.

Only a two-link block has a curve to speak of, and only a bounded link can be the wrong way round in links: — so this is what reads the pair anywhere the y side is the one being stated.

description = None class-attribute instance-attribute #

method = 'adjacency' class-attribute instance-attribute #

over instance-attribute #

points = None class-attribute instance-attribute #

Bases: _StrictBlock

One link of a piecewise block: an expression pinned to a values curve.

Written in YAML as [expression, values] or [expression, values, sign] and serialised back to exactly that form, so a round trip through :meth:Model.to_yaml reproduces the file.

expression instance-attribute #

sign = '==' class-attribute instance-attribute #

values instance-attribute #

SosBlock #

Bases: _StrictBlock

A special-ordered set over one dimension of one variable.

Mirrors linopy.Model.add_sos_constraints, whose decomposition this copies: a variable, the dimension the set runs along, the type, and the optional big-M a reformulating sink caps its linking rows with. One set per coordinate of the variable's foreach minus over; the members are the variable's existing coordinates along over, and their order is that dimension's declared one — what shift walks.

type: 1 admits at most one nonzero member, type: 2 at most two, and those two consecutive. Unlike every other block this one declares no math a sink can read off A: it is a set, carried to the sink that has the concept and reformulated for the sink that does not.

big_m = None class-attribute instance-attribute #

description = None class-attribute instance-attribute #

over instance-attribute #

type instance-attribute #

variable instance-attribute #

VariableBlock #

Bases: _StrictBlock

A declared decision variable.

absence = 'undefined' class-attribute instance-attribute #

bounds = BoundsBlock() class-attribute instance-attribute #

description = None class-attribute instance-attribute #

domain = 'continuous' class-attribute instance-attribute #

foreach instance-attribute #

referenced_dims property #

where = None class-attribute instance-attribute #