Skip to content

Language Reference

What a YAML file may contain and what it means. Why it is shaped this way: docs/ARCHITECTURE.md. What is planned or refused: docs/ROADMAP.md. A worked example: README.

0. The laws

Ten rules the whole language reduces to. Every section below elaborates one, and each law names the section that does — so a rule stated here is not restated there.

Nothing is guessed. Where a file does not determine the answer, loading fails and the message names the rewrite. Every law is that one principle, applied in a different position.

# Law §
1 Eight top-level keys, and the schema is closed at every level — an unknown key is an error naming the near miss. Booleans are YAML 1.2, so no / on / off stay labels. §1
2 Everything decidable without data is decided without data. §9
3 One flat namespace, no shadowing — a collision is a load error naming both declarations. §5.1
4 Position decides which kinds of name are legal, and a name's kind is fixed at load time. A dimension is never legal in a value position: it is a coordinate space, not data. §5.1
5 Dim sets compose by union. A constraint must equal its foreach; a where or a bound must not exceed its frame. §5.2
6 Absence is a property of variables. Four constructs create it; nothing else does. §6
7 Through arithmetic absence spreads, taking the row with it. Out of a reduction it does not — so a reduction does not distribute over +, and sum(x + y) and sum(x) + sum(y) are different questions. §6
8 Identity of the position. A missing value reads as whatever makes it contribute nothing — zero as a coefficient, the identity of a sum; false in a where, where the coordinate then does not exist. Where no such reading exists it is refused: a divisor, a bound. shift(…, fill=) is the one place a value may be asked for, and it takes the identity of its position too. §6, §7
9 Degree 1, always: * needs a variable-free factor, / a variable-free divisor, ** is refused. Bounds are narrower still — a name or a number, never arithmetic. §5, §2
10 The operator set is closed. Compositions go in macros:. §7

1. File shape

Eight top-level keys: dimensions, parameters, variables, constraints, objectives (§2), expressions, macros (§3), piecewise (§4). The schema accepts any subset, but check, solve and write require an objective — there is nothing for the streaming lane to optimise without one.

The schema is closed at every level. An unrecognised key — top-level or inside any declaration — is a load error naming the near miss (unknown key 'boundz' … Did you mean 'bounds'?). Ignoring it would let a typo change the model: a dropped bounds: leaves a variable unbounded, a dropped where: leaves it unmasked.

Reading rules. Booleans are YAML 1.2 (true/false only), everything else 1.1 — under 1.1 on/off/yes/no/y/n become booleans and silently destroy dimension labels that are country codes, so values: [no, se, on] is three labels here. The implicit timestamp (2024-01-01) and sexagesimal ints (12:30750) deliberately survive; the dtype guard below catches them wherever they were not meant. A duplicate key is a load error naming both lines. <<: merge keys are honoured, and a key the mapping declares itself overrides the merged value. The document must be a mapping.

2. Declarations

An empty dim list is the empty coordinate, everywhere it appears — one value for a parameter's dims: [], one column for a variable's foreach: [], one row for a constraint's. Not a special case but the ordinary reading of a product over nothing, whose unit is a single coordinate rather than none. So a dummy dimension of size 1 is never how a scalar is written, and an objective — scalar by definition — needs no foreach at all. One gap: a scalar variable may not carry a where (#340). Put the condition on the constraints that use it.

dimensions — the master coordinate index. Every dimension named anywhere must be declared. dtype ∈ {float, int, str, datetime}, default str. values is a list or null; if null, coordinates must arrive via sources= (coords= on the linopy lane), else loading fails. Every declared value must be of the declared dtypevalues: [2024-01-01] under the default dtype: str is a load error, because YAML resolved it to a date and a date does not join '2024-01-01' in the data.

coords declares non-index coordinates the dimension's labels carry — a generator's bus, a line's endpoints, a snapshot's month — mapping each coordinate name to the dimension its values are labels of. Written as a list when the two names coincide, or as a mapping when they do not:

dimensions:
  bus: {dtype: str}
  generator:
    coords: [bus]  # same as {bus: bus}
  line:
    coords: {from: bus, to: bus}  # two coordinates onto one dimension

The target must be a declared dimension, must not be the dimension carrying the coordinate, and a coordinate must not be named after a different dimension. A coordinate is single-valued per label, and its non-null values are checked to be coordinates of the target once data is bound (§8) — that check is what makes group_sum safe.

A coordinate may be partial: a null value says the label belongs to no group, and group_sum places its terms nowhere. That is the same row-absence idiom the language uses everywhere else for "not present" — a generator on no bus, a line with one open end — and it is distinct from a wrong label, which is still an error. Null means "no group"; an unknown non-null value is a typo. A dimension declaring coords needs an index source carrying those columns; they are never inferred from the parameters that use the dimension, because inferring them would let a mistyped label extend the label space instead of being rejected.

parameters — declared shape only; data binds by name at run time (§8). dims required ([] is a scalar); dtype ∈ {float, int, bool, str}, default float.

variables

Field Type Default
foreach list[str] required — dim signature, one variable per coordinate
where str or null null — §6; variables exist only where true
bounds.lower / .upper number or parameter name -inf / inf
binary, integer bool false; not both

Omitting a bound means unbounded on that side, as in linopy.Model.add_variables — non-negativity is written, not assumed. Bounds are a narrower language than expressions (a name or a number, never arithmetic) and the error says so rather than reporting a parse failure; expressions here are #31. A bound parameter's dims must not exceed foreach.

Equal bounds pin a variable, which is how one declaration covers a quantity that is a decision in one model and data in another: declare it as a variable always, and bind lower and upper to the same value where it is fixed. rate - relmax * size <= 0 is then one equation whether size is chosen or given, instead of a block per regime with pre-multiplied coefficients whose names encode the regime rather than the quantity. Presolve fixes and substitutes the pinned column, so the solver receives the LP the pre-multiplied form would have produced; the cost is the columns before presolve. Two limits: a pinned variable is still a variable, so size * on remains variable × variable and is refused (§5), and it cannot appear in another variable's bounds, which take a parameter or a number.

constraintsone rule per block: foreach (required), an optional where, and one expression carrying exactly one of <=, >=, ==. The block's name is the constraint's name, which is what a row is read back by. The LHS must involve at least one decision variable.

foreach: [] is one scalar row — a single system-wide budget, where the expression reduces every dim away. Nothing special: law 5 requires dims(lhs) ∪ dims(rhs) to equal foreach, and sum(x, over=f) <= 120 has no free dims, so [] is the signature that satisfies it.

Two regimes of one rule are two blocks, and each gets a name a reader chose rather than a position in a list:

storage_balance:
  foreach: [snapshot, storage]
  expression: soc == shift(soc, snapshot=1) * (1 - loss) + charge - discharge

storage_balance_initial:
  foreach: [snapshot, storage]
  where: "snapshot == 0"
  expression: soc == soc_initial

shift vacates the first snapshot and a vacated position is absent (§7), so that row drops without a where saying so. Spelling the carry-over roll and gating it with where: "snapshot > 0" builds the same rows here and a different model on a horizon that does not start at 0 — the gate hardcodes the origin, where the operator does not.

objectivessense ∈ {minimize, maximize}, default minimize. An objective is a scalar by definition, so every dim the expression carries is summed; writing the sums out says nothing extra. Each term is summed over the dims that term carries, and is not repeated because another term carries a dim it does not: in x * a + y * b with x, a on i and y, b on j, the objective has |i| + |j| summands, never |i| · |j|.

One expression, like a constraint — an objective was always one expression, and now it says so in its shape. Declaring more than one objective is a load error.

3. expressions and macros

Pure AST substitution before dispatch — neither backend ever sees one, so they cost nothing and cannot make the lanes diverge. A named expression is a macro with no formals.

expressions:
  total_generation: sum(p, over=generator)
macros:
  weighted_sum:
    args: [array, weights]  # positional formals, default []
    kwargs: [over]  # keyword formals, default []
    template: sum(array * weights, over=over)

Both hold arithmetic (no comparison). Arguments expand before substitution (call-by-value), so they may themselves use macros and named expressions. Formals shadow model names inside a template but may not collide with a declared dimension. Arity is checked per call site; cycles are reported with the reference chain. Templates are schema-local, so every one is parsed and name-checked at load time even if never called.

4. piecewise

N expressions jointly pinned to a breakpoint-indexed piecewise-linear curve, mirroring linopy.Model.add_piecewise_formulation.

piecewise:
  chp:
    over: bp  # breakpoint dimension
    links:
      - [power, power_bp]  # [expression, values-parameter]
      - [fuel, fuel_bp]
      - [heat, heat_bp]
    convex: false  # true: pure-LP convex hull, no binaries
    active: null  # optional gating expression: formulation pinned to 0

  # a two-link block may bound one side instead of pinning it
  fuel_cap:
    over: bp
    links:
      - [power, power_bp]
      - [fuel, fuel_bp, "<="]

expression is any affine expression (a bare variable name being the simplest); values names a parameter carrying the over dim, so curves may vary along other dims (per-generator, say); sign (<=/>=, at most one, only with exactly two links) bounds the link instead of pinning it. Blocks expand before building into plain variables and constraints via λ convex-combination — weights in [0,1] with a convexity row, one link row per tuple, and unless convex: true segment binaries with an adjacency row lam <= seg + shift(seg, bp=1). Both lanes receive the identical expansion.

5. Expressions

expression  ::= arithmetic | arithmetic COMPARATOR arithmetic
arithmetic  ::= atom | unary_op arithmetic | arithmetic binary_op arithmetic
             |  function_call | "(" arithmetic ")"
atom        ::= NUMBER | NAME
unary_op    ::= "+" | "-"       binary_op ::= "+" | "-" | "*" | "/" | "**"
COMPARATOR  ::= "<=" | ">=" | "=="
function_call ::= NAME "(" [pos_arg ("," pos_arg)*] ["," kwarg ("," kwarg)*] ")"
kwarg       ::= NAME "=" (arithmetic | NAME)
NAME        ::= [a-zA-Z][a-zA-Z0-9_]*
NUMBER      ::= integer | float | "inf" | ".inf"

Precedence, highest first: **, then * /, then binary + -, then unary + -; parentheses override. Affinity is enforced — * needs at least one variable-free factor, / a variable-free divisor that is a single factor rather than a sum. ** parses but is not in the language: both lanes reject it at load time, so the refusal can name the operator and its rewrite. A variable base breaks degree 1; over parameters alone it is data prep.

5.1 Name resolution

A load-time pass (resolution.py), not an evaluation-time lookup: parsers emit NameNode tokens, the pass rewrites each into VariableNode, ParameterNode or DimensionNode, so no unresolved name crosses into a backend and no backend can hold its own opinion about what a name means.

One flat namespace covers dimensions, parameters, variables, named expressions, macros and built-in operators; a collision is a load error naming both declarations. Ordered resolution with shadowing is wrong for a fail-loud language: under it, declaring a parameter named snapshot would silently change what an existing where: "snapshot > 0" means.

Position Legal kinds
expression (p * cost) variable, parameter
dimension argument (over=, into=, roll(x, snapshot=1)) dimension
where string parameter, dimension
bounds.lower / .upper parameter name, or a number
shift(x, d=n, fill=0) — the fill key a number, never a dimension

fill is the one keyword whose key is fixed rather than naming a dimension, so a dimension called fill does not change what it means; the position takes a number and nothing else.

A dimension in a value position is an error — it is a coordinate space, not data. To use its coordinates as data, declare a parameter over it.

5.2 Dim algebra

Parameter dims and variable foreach are declared and dimension arguments are name-checked, so every node's dim set is computable before any data is bound. dimensions.py computes it at load time on the resolved AST, which is what makes both lanes agree by construction.

Node Dim set Error
number {}
parameter / variable its dims / its foreach
-x, +x dims(x)
a + b, a * b, a / b dims(a) ∪ dims(b)
sum(x, over=d) dims(x) − {d} if d ∉ dims(x)
group_sum(x, over=d, by=c) (dims(x) − {d}) ∪ {target(c)} unless d ∈ dims(x), or d declares no coordinate c
roll(x, d=n), shift(x, d=n) dims(x) if d ∉ dims(x)

Binary operators union: an outer product is legitimate when the frame declares the result. What must not be silent is the declaration disagreeing — so a constraint requires dims(lhs) ∪ dims(rhs) to equal foreach (a stray dim multiplies rows and an unused foreach dim repeats one row across them, either way building a different model than the file reads as), while a where predicate's dims and a bound parameter's dims must not exceed the frame.

6. Absence

A coordinate where a variable does not exist — not a value and not a zero, but a state the language tracks (law 6).

construct what is absent
where: on a variable the variable, at the masked coordinates
where: on a constraint the row
shift(x, d=n) with no fill= the vacated edge coordinate (§7)
a null value in a dimension's coords: that label's group membership (§2)

A sparse parameter table is not one of them. Missing rows are compressed encoding, and law 8 says what one reads as: the reading under which the missing thing contributes nothing — or a refusal, where no such reading exists.

position a missing parameter row why that reading
coefficient — w * x zero: the term does not participate, the row survives 0 is the identity of a sum, so the term contributes nothing
where operand false a coordinate whose data is missing is not one the model can claim exists
divisor — x / d refused at bind where the model divides by it nothing contributes nothing: 0 divides by zero, 1 rescales, dropping rewrites the constraint
bounds: an error nothing contributes nothing: unbounded is not bounded-at-zero

How absence travels

Through arithmetic it spreads (law 7), taking the row with it: x + y >= 10 is no constraint where y is masked, not x >= 10. Its asymmetry with the table above is the whole hazard, in one example: x - rel_max * size <= 0 loses the row where the variable size is masked, and keeps it as x <= 0 where the parameter rel_max has no row — feasible, plausible, no error. A missing correction term tightens in the safe direction and is a legitimate idiom; a missing coefficient that is the bound rewrites what the constraint says.

Out of a reduction it does notsum(x, over=d) is defined when only some of d exists, or one masked component would delete a system-wide accounting row. So the two spellings below are different questions:

spelling sums over with y absent at f=b
sum(x + y, over=f) where the summand exists x[a] + y[a]x[b] goes with the absent y[b]
sum(x, over=f) + sum(y, over=f) each operand over its own domain x[a] + x[b] + y[a]

The total of the net where the net is defined, against the total in minus the total out. Rewriting the first into the second would read the absent y[b] as a zero — the honest consequence of + being addition on a partial domain.

Asking for the other reading

Each rule has a spelling for the opposite intent:

you want you write
the row kept, the missing term read as zero two constraints under complementary where clauses
a vacated shift position to contribute shift(x, d=n, fill=0) — the identity of its position (§7)
to test whether a variable exists here its bare name in a where
a sparse coefficient to remove the row rather than zero the term mask on it — where: "rel_max"
to divide by a parameter you only have some of mask the row or the variable — where: "d". The divisor is required where the division survives, not everywhere it is indexed
a bound only where the data has one supply the missing value (inf is a value), or mask the variable — the two build different models, so neither is inferred

Only one of those is a fill (law 8): the coordinate shift vacates is created by the operator, so there is no row a caller could have supplied. Everywhere else the value is expressible in the data, and §11 keeps it there — .fillna(inf) for a bound is one line in the caller, over a table that is one row per foreach coordinate anyway.

This is linopy's v1 arithmetic convention, which both lanes are built against; lpspec.linopy.semantics is where the eager lane answers it.

6.1 Where strings

A boolean mask; true means "this coordinate exists". Semantics are row absence, not zero-fill: a masked-out variable is not created, a masked-out constraint row is not built.

where_expr ::= atom | "NOT" where_expr | where_expr ("AND"|"OR") where_expr
            |  "(" where_expr ")"
atom       ::= NAME | NAME COMPARATOR value | "True" | "False"
COMPARATOR ::= "<=" | ">=" | "==" | "!=" | "<" | ">"
value      ::= NUMBER | NAME_OR_STRING
Surface Names a… Meaning
name (bare) parameter defined: non-null and finite
name (bare) variable defined: the variable exists at this coordinate. The counterpart of the parameter row, and the way to say which coordinates the row-dropping rule above applies to
name (bare) dimension load error — true everywhere, so it reads as a condition and is not one; compare it instead
name OP value parameter element-wise, NaN → False. RHS is a literal number, or a bare name read as a string coordinate — a name that is declared is a load error instead (below)
name OP value dimension filter on the frame's own coordinate column
AND OR NOT case-insensitive; NOT > AND > OR
True / False literals; True ≡ no where

Comparing two parameters is not in the language — precompute a boolean parameter in data prep — and neither is comparing two dimensions. The string reading of an RHS name is for names the model does not declare, which is how a string coordinate is compared; a declared name on the RHS (parameter, variable or dimension) is a load error naming the near miss, because reading it as text would compare a coordinate column against another declaration's name and mask everything out. An undeclared bare name is a load error on both lanes, and a mask dim outside foreach is one too (§5.2).

7. Operators

The built-in set is closed — no Python registry — which is what makes both lanes accept the same language and the differential tests an oracle rather than a comparison of dialects. Dimension arguments are name-checked at load time: sum(p, over=snapshto) is an error, not a no-op.

Operator Result Notes
sum(array, over=dim) dim collapses array must carry dim
group_sum(array, over=dim, by=coord) over → the dimension coord targets coord is declared on over (§2); its values are the group labels, checked against the target dimension at bind time. The membership sum that makes topology data rather than structure; groups with no members contribute nothing
roll(array, dim=n) value at t−n, cyclic coordinates fixed, values wrap
shift(array, dim=n) value at t−n, acyclic vacated positions are absent: they propagate and drop the row (§6)
shift(array, dim=n, fill=v) as above vacated positions contribute v instead, and the row survives (0 for a sum, 1 for a product)

array is any node of the right dim set, so roll and shift re-index a parameter as readily as a variable: shift(dt, t=1, fill=0) is the previous snapshot's duration, and saves shipping a pre-shifted copy of a table the model already has.

The position a shift leaves at the edge is absent in exactly §6's sense, so an acyclic recurrence has no row at its first coordinate rather than a row asserting the quantity starts at zero. An initial condition is then something the model states, under a complementary where, rather than something the language supplies unasked.

fill= asks for a value back instead, and it takes a number rather than a flag because the identity is positional (law 8): lam <= seg + shift(seg, bp=1, fill=0) bounds the first breakpoint by the first segment, where dropping the row would leave it unbounded; x * shift(eff, t=1, fill=1) leaves the first coordinate governed by its own bound, where fill=0 would pin it. The library cannot see which position it is in and the model can — which is v1's own reason for refusing to fill on a caller's behalf.

The same law bounds what fill may be. Over an expression carrying a variable the only representable value is 0, since a vacated slot there contributes no term at all and a nonzero one would be a constant standing where a term was. roll refuses fill outright: it vacates nothing, so there is no position to take an identity of.

A bare shift over a variable-free expression is a load error. Absence is a property of variables (§6); a parameter's missing row is a zero coefficient, so there is no absence for the vacated slot to carry and inventing a value there is what silently turned x <= shift(dt, t=1) into x <= 0. The error names the three things it could have meant: fill=0, a where that masks the coordinate out, or roll if the horizon is genuinely cyclic.

Anything composable out of these belongs in macros:. Math that is not sayable at all goes to a declared escape: island (#38): named in the file, bounded by the preceding where mask, terminal (it yields a constraint, never a sub-expression), and billed against a label budget before any Python runs.

8. Data binding

Master coordinates are resolved per dimension before any parameter loads, highest precedence first:

  1. a key in sources — a DataFrame carrying a column of that name, or a parquet path; first occurrence of each value is its position
  2. coords= — anything pd.Index() accepts, or a DataFrame carrying the label column plus one column per declared coordinate (§2)
  3. values: in the YAML
  4. streaming lane only — derived from the parameter tables that carry the dim, as sorted distinct values

Step 4 is unavailable to a dimension declaring coords — it reads index columns only, so it cannot supply a coordinate. Otherwise step 4 exists because a dim some parameter already spans needs no second declaration, but it costs the declared order, which roll/shift read positionally — so pass an explicit index whenever order matters. The linopy lane has no step 4: a dimension with neither coords= nor values: raises there. A dim that no source names and no parameter carries raises on both.

Accepted per parameter (declared dims: [d1, d2]), streaming lane: a parquet path; any table exposing the Arrow PyCapsule protocol with columns d1, d2, value; int/float for a 0-D parameter. pd.Series and xr.DataArray keep their dims in an index rather than in columns, so they are unwrapped first — but only if that library is already imported, never by importing it.

Compat lane (data=): int/float as a scalar that broadcasts freely; dict and pd.Series for 1-D (keys / index values become coordinates); pd.DataFrame for 2-D (index name → d1, column name → d2); xr.DataArray directly, with dim names a subset of the declared dims. np.ndarray and list have no named axes, so only 0-D or 1-D matching one declared dim is accepted — anything else is refused with a message asking for a named object.

Index names are optional but binding: an unnamed index binds positionally to the declared dims, a named one binds by name in any order, and a name outside the declared dims raises rather than being overwritten.

Coordinate values in the data must be a subset of the master coordinate; values outside it raise rather than being dropped silently. Every declared parameter must be provided, and every provided key must be declared — the YAML is the source of truth. Validation order: dimension coords → parameter presence → dim names → coordinate values → unknown keys.

The loader deliberately does not check that values are sensible, that a parameter is used, or that coordinates cover the master index. Missing coordinates produce no rows — sparse data gives sparse variables.

9. Errors

Fail at load time, not at evaluation time. Anything detectable before building is detected before building; the worst error is an opaque xarray or solver exception with no pointer back to a YAML declaration. Every message names what went wrong, what to do about it, and where it helps, the valid options:

Constraint 'balance', equation 0: 'p_charge' not found.
  Variables: ['p', 'soc']
  Parameters: ['p_max', 'load', 'efficiency']
Check for typos, or ensure 'p_charge' is declared.

A construct outside the language names the construct and its rewrite — never a silent fallback, never a redirection to the other lane.

10. Python API

Five verbs — check, load_schema, build, solve, write — and the exception tree rooted at LinopyYamlError: LanguageError (with SchemaError, DimensionError, PiecewiseExpansionError) for the model, DataError for what was bound to it.

import lpspec as lps

lps.check('model.yaml')  # parse → validate → lower, no data bound
schema = lps.load_schema('model.yaml')  # MathSchema

result = lps.solve('model.yaml', sources, solver_options={'time_limit': 60})
result.status, result.termination_condition, result.objective
result.is_ok  # linopy's rollup: not an error, abort or refusal
result.has_primal  # narrower: are there values to read
result.primal('p')  # tidy frame (dims…, value) — the native shape
result.dual('power_balance')  # shadow prices, the same shape and the same join
result.to_pandas('p')  # the same, as a DataFrame
result.to_dataarray('p')  # the same, labelled: .sel / resample / plot
result.to_dataset()  # every variable by default; names for a subset
result.to_parquet(directory)  # streamed to disk, never through this process

lps.write('model.yaml', sources, 'model.lp')  # sink chosen by the suffix

Nothing has to be released. The built model is frames this process owns, so primal and the to_* readers stay valid for as long as the Result does. close() and the context-manager protocol exist to hand a large model back early, not because forgetting them breaks anything. lps.build returns the executor when one build should feed more than one sink:

ex = lps.build('model.yaml', sources)
ex.write_lp('model.lp')
result = ex.solve()

sources maps parameter and dimension names to parquet paths, scalars, or any table exposing the Arrow PyCapsule protocol — polars, pyarrow and pandas all qualify, and the recogniser imports none of them. Nothing on this path imports linopy. primal returns a polars.DataFrame, which is Arrow-backed and exports the same protocol, so no dataframe library is a dependency of this package; to_pandas and to_dataarray are the bridges out and need pandas / xarray, which ship with the [linopy] extra.

The only build knob is coords, shared by all three entry points. solver_options is separate and is not a build knob — it is forwarded verbatim to the solver, the shape linopy takes ({"time_limit": 60, "mip_rel_gap": 0.01}); build knobs govern construction and never reach it.

is_ok is not has_primal. is_ok is linopy's rollup of the termination condition; has_primal adds the solver's own verdict on whether an incumbent exists, and it is what every reader gates on. They differ exactly when a run stops early: a MIP that hits time_limit before finding any feasible point is ok with nothing to read. Reading anyway raises NoSolutionError, and objective is nan. to_dataset costs what it says — each variable arrives dense over its own dims, so anything but a small model should name a subset or use to_parquet. dual is the same label join against the constraint's row frame, and raises rather than returning zeros in either of the two ways it can come up empty: no values at all is NoSolutionError, the gate primal passes through too, while a solve that did leave values but no duals — any integer or binary variable makes them undefined — raises LinopyYamlError, because the primals are still readable and only this quantity is missing. Duals exist only on the solver_direct path — a model written to LP and solved elsewhere never passes back through here. Reduced costs and slacks ride the same join and are not exposed yet (#78). .lp is the only sink write supports today; .mps raises NotImplementedError.

Linopy shim (lpspec.linopy, [linopy] extra) — two pure producers, YAML in, model out, nothing retained:

from lpspec import linopy as lpspec_linopy

m = lpspec_linopy.build('model.yaml', data={...}, coords={...})  # -> linopy.Model
lpspec_linopy.extend(m, 'ramp.yaml', data={...})  # mutates m in place

build returns a plain linopy.Model — no accessor, no attached schema, no patched attributes — so nothing is lost across pickle, deepcopy or to_netcdf; to inspect the math, re-read the file with lps.load_schema. extend may reference variables already on the model (they come from the model argument, not from Python-side history), while the YAML must still declare every parameter and dimension it uses — the declaration is required, the values: are not, since they can come from the model. Coords precedence for extend: the coords= kwarg, then coords inferred from the model's variables, then values: in the YAML, then error — a values: contradicting the model's existing coordinate is an error, not a silent override. There is no register() decorator and no helper registry.

11. Out of scope

Not here Instead
time-series processing (resample, cluster, interpolate, align), file IO, units data prep; pass a parameter
solver breadth HiGHS via solver_direct, Gurobi planned on the same path, LP files for everything else (#28)
SOS and indicator constraints piecewise: (§4) covers SOS2's usual purpose; the streaming lane's default solver has no SOS or indicator concept at all, so this is a sink capability question rather than a language one — #23, ROADMAP Track 4
multi-objective one objective — declaring a second is a load error (§2); weight them into one expression
schema migrations
arbitrary array ops (merge, reindex, apply_ufunc) data prep, or a declared escape: island — the closed AST is what makes streaming possible
filling a missing value (.fillna) data prep, or a where if you meant the coordinate not to exist. In the language only where the data cannot reach — shift(..., fill=), §6

Calliope's math language is a corpus we score coverage against, not a specification we match; file portability is not a goal, and neither is operation parity with xarray/pandas. Whether .yaml should ever be a complete representation of a model built partly in Python is open — the math side is feasible, but expression and where strings become anonymous arrays, giving a functional round-trip and not a readable one (#3).