Config direction: a validated, serializable element layer¶
Status: Draft / decision record.
Audience: ourselves; informs the PyPSA-core Effects push (pypsa-core-integration.md).
Decision: adopt pydantic for the element layer ("standalone DX first"),
with structural YAML/JSON round-trip and time-series kept as references. A
declarative YAML-math layer (linopy/Calliope) is a separate, later,
complementary concern — not a blocker.
1. Question¶
Should fluxopt's user-facing model definition become more config-/YAML-like — using pydantic or similar — and can it reuse the emerging YAML-math tooling from linopy?
Two sub-questions that turn out to live at different layers:
- Data/config: what components exist and their parameters → the element
dataclasses (
elements.py,components.py). - Math: the equations themselves → the model builder (
model.py,constraints/).
They compose; adopting one does not require the other.
2. Where fluxopt and PyPSA stand¶
fluxopt's three layers — Elements (dataclasses) → ModelData (xr.Datasets)
→ Model (linopy) — already make the element layer declarative value objects.
Construction is a single optimize() call over lists of dataclasses, not an
imperative mutation loop. Validation is hand-rolled in __post_init__;
serialization is netCDF/DataTree at the ModelData layer, not the element layer.
PyPSA (v1.0), for contrast:
| fluxopt | PyPSA v1.0 | PyPSA direction | |
|---|---|---|---|
| Storage | dataclasses → xr.Datasets → linopy |
pandas frames per component + _t dicts |
Components class is an access wrapper over the same pandas frames; default in v2.0 |
| Construction | declarative lists of dataclasses | imperative n.add(...) |
— |
| Validation | __post_init__ guards |
CSV attribute registry (component_attrs/*.csv) coerced at n.add |
vague "stricter validation" — built on the registry, not pydantic |
| Multi-quantity | Effect system (N quantities, chaining, any objective) | cost objective + CO₂ GlobalConstraint |
"categorised components: separate physical components from shared properties" |
| Piecewise | PiecewiseConversion (shared weights, auto LP/SOS2) |
none | #1473 → PR #1603 (open, ~v1.3): segments MultiIndex + SOS2 |
Key finding: core PyPSA has no pydantic and no typed-schema plans. Its "schema" is the CSV registry; its declarative ambitions (below) target math and topology, not typed validated objects.
3. What PyPSA is actually doing on "declarative" (July 2026)¶
Three open, maintainer-engaged proposals — none merged, none pydantic:
- #1789 (@FabianHofmann) —
reusable component baskets (
battery = bus+link+storeas one unit,n.basket.<name>), registered from files/dicts (likely YAML). This is the concrete design behind the roadmap's "categorised components." - #1796 (@brynpickering) —
replace GlobalConstraints with YAML text mathematics (Calliope-style
parameters/expressions/constraints). Notably flags our Effects issue #1788 as "potentially superseded." - linopy #561 — the text-based (YAML) math interface the above depend on. Proposal only, no PR; #1796 says it is "not yet capable" yet. The working reference is Calliope 0.7+, which already stores all its optimization math in YAML.
Corrected read: PyPSA is moving toward YAML — but at the constraint-math and
component-composition layers, not as a typed data schema. The config YAML most
people see (config.default.yaml) is a pypsa-eur Snakemake workflow config,
not the core data model.
4. Decision: pydantic element layer ("standalone DX first")¶
Optimize fluxopt's own ergonomics. Convert the element layer to pydantic:
- Declarative validation replaces the
__post_init__guards (carrier-match, status-requires-size, breakpoint-length, …) with validators and better errors. - JSON Schema for free — the real prize: a future config front-end, GUI, and LLM-assisted authoring all validate against one schema.
- Structural round-trip to dict/YAML/JSON, with time-series kept as references.
4.1 Time-series stay references, not inline¶
Variate fields are float | series | array. Real profiles (8760+ points,
multi-period) do not belong inline in YAML — that is exactly why PyPSA splits
static frames from _t time-varying data. Model it as:
Variate := float | list[float] | ProfileRef
ProfileRef := reference into a dataset variable (dataset + variable)
Structural YAML carries the graph + scalars + refs; profiles live in data files. Inlining profiles produces a toy; referencing them is real config.
4.2 Known friction¶
- pydantic +
xr.DataArray/Variateneedsarbitrary_types_allowedor a custom validator (bounded work). - pydantic v2 becomes a hard runtime dep alongside the scientific stack.
4.3 Portability guardrail (for the Effects push)¶
PyPSA core is registry-based, not pydantic — so keep the pydantic layer separable
from the modeling semantics. In particular, keep the Effect declaration (unit,
coefficients) cleanly split from the study verbs (bound / minimize / price),
per pypsa-core-integration.md. The schema then stays portable even as PyPSA moves
the constraint side to YAML-math.
5. Can fluxopt reuse linopy/Calliope YAML-math?¶
Not today (linopy #561 is unimplemented), but naturally later — fluxopt's backend already is linopy, so a YAML-math layer would be reusable at the same point PyPSA would use it.
Where it fits — the linear-algebraic subset: Effect accumulation, Effect
bounds, cross-effect chains (contribution_from), and user custom constraints
(today the customize callback). This is exactly the sum(co2, over=snapshots) <=
limit shape Calliope/#561 handle, and exactly what #1796 targets.
Where it does not — the procedural parts of the builder: piecewise formulation
selection (LP tangent vs SOS2 vs incremental), status/uptime big-M, storage level
dynamics, and the materialization-closure algorithm. YAML-math expresses an
equation; it does not decide which formulation to emit. So YAML-math is a good
front-end for the effect/constraint layer, not a replacement for constraints/.
5.1 Illustrative sketch (Calliope-style, not a commitment)¶
An Effect total plus a total cap, expressed declaratively:
expressions:
effect_total:
foreach: [effect]
equation: >
sum(flow_rate * effects_per_flow_hour * dt, over=[flow, time])
+ sum(size * effects_per_size, over=flow)
constraints:
effect_total_cap:
foreach: [effect]
where: "total_max" # only effects that declare a cap
equation: "effect_total <= total_max"
The point: Effects declare the quantity; YAML-math expresses the bound. They compose — which is the counter-argument to #1796's "superseded" framing: demonstrating Effects in YAML-math idiom shows the two are complementary, not rival. That demo is a strong card in the #1788 ↔ #1796 discussion.
6. Plan¶
- Don't block on YAML-math — it is unimplemented in linopy.
constraints/stays the source of truth. - pydantic element layer, structural-only round-trip,
ProfileReffor time-series; port__post_init__guards to validators; emit JSON Schema. - Design the YAML-math seam now, cheaply — keep Effect bounds +
contribution_from+customizeexpressible as vectorized linopy expressions so a text front-end can slot in later. - Feed the sketch (§5.1) into the PyPSA discussion as evidence Effects and YAML-math compose.
6a. Implementation status¶
Phase 1 landed in this PR — element layer on pydantic, validation + schema:
elements.py/components.pyelement classes are nowpydantic.dataclasses.dataclasswitharbitrary_types_allowed(soVariatearrays /IdListpass through byisinstance).__post_init__guards,field(init=False), nested-instance identity, and in-place qualification all survive unchanged (pydantic keeps instances —revalidate_instances='never').- Construction now validates types with pydantic errors.
ruff'sflake8-type-checkingis toldpydantic.dataclasses.dataclassevaluates annotations at runtime, so annotation imports stay at runtime. - JSON Schema works for every element type —
fluxopt.element_schema(Flow)andall_element_schemas(). Better than §4.2 feared: arbitraryVariatefields degrade to permissive{}rather than erroring.
Phase 2 landed (stacked PR) — ProfileRef + full structural round-trip:
ProfileRef(float | list | ProfileRefat last, intypes.py): a serializable reference to a time-series in a data file, with.resolve(profiles)→DataArray. Added to theVariateunion so config round-trips without inlining profiles;as_dataarrayrejects an unresolved ref loudly.to_dict/from_dict(fluxopt.to_dict,fluxopt.from_dict): JSON-safe round-trip for every element type, components included.IdListgot a pydantic core schema (validate-from-list / serialize-to-list). The feared "non-idempotent qualification" was a non-issue:Flow.__post_init__resetsid = short_id, so the parent re-qualifies cleanly on rebuild (b(gas), notb(b(gas))). Inline raw-arrayVariatestill doesn't serialize — that's theProfileRefpath.
Phase 3 landed (stacked PR) — the YAML front door + layer naming.
Philosophy B (chosen): layer 1 is an inert, validated declaration, consumed by a builder — not a mutable domain object. This mirrors the codebase's existing "declaration vs use" principle. The three layers, named to match:
| layer | name | role | serialization |
|---|---|---|---|
| 1 | FlowSystem |
what the user authors (elements + config) | dict / YAML |
| 2 | ModelData |
materialized xarray | netCDF |
| 3 | FlowSystemModel |
the linopy solver; owns .m (escape hatch) |
→ Result |
FlowSystem(flow_system.py): a pydantic aggregate mirroringoptimize();from_dict/from_yaml/to_dict/to_yaml;.optimize(profiles=...)delegates to the existing pipeline (ModelData.build → FlowSystemModel). Python construction stays first-class — the same object is built in code or loaded from YAML.ProfileRefauto-resolution:.optimize(profiles=...)runs a recursive pre-pass (_resolve_refs) that swaps everyProfileRefforresolve(profiles)on a deep copy, so theFlowSystemstays reusable across different data. Sources are passed in code ({id: Dataset}), not via file paths in YAML — structure is declarative, data supply is explicit.- Naming decision: the flagship name
FlowSystemgoes to the user-authored layer; today's solver was renamedFlowSystem→FlowSystemModel(it owns a linopyModelas.m, so...Modelfits and there's no clash).*Specwas rejected — it decorates the primary user object; the qualifier belongs on the internal solver. Narrow rename, done pre-1.0.
Phase 4 landed (stacked PR) — fail-fast aggregate validation on FlowSystem.
A @model_validator(mode='after') runs at construction (and inside from_dict/
from_yaml) and rejects, with clear messages: undeclared effect references
(effects_* / contribution_from / objective), undeclared carrier
references, and duplicate effect / carrier / component ids. This moves the common
authoring mistakes from build-time (.optimize()) to load-time — the point of a
declarative workflow. Runs once after all fields are assembled, so element order
is irrelevant.
Split / save deliberately dropped. An earlier plan added split()/save()/
load() to externalize inline arrays into a data bundle. Cut as unneeded:
authoring with ProfileRef already keeps data external, and spec-→-YAML export is
not a priority. The genuinely wanted future feature is arithmetic / expressions
in the YAML (e.g. 2 * base_cost, shared parameters) — the Calliope/linopy
YAML-math direction (§5), deferred.
Phase 5 (partially landed) — id qualification moved from element
construction to the build step: Flow lost its mutable id field, parents no
longer rewrite child flows, and qualified ids are derived on demand by internal
_qualified_flows() methods (records carrying the qualified id and the
carrier-balance sign of the placement). Nothing new is exported — the public
mental model is just short_id + the component(short_id) pattern in data and
results. Declaration-vs-use tightening as planned; flows are now reusable
across components, and duplicate short_ids within a component fail at
construction.
Still deferred:
- Arithmetic / expressions in YAML (the real future want above).
- Porting the remaining
__post_init__guards to pydantic validators. Incremental cleanup, not required for the win.
7. Non-goals¶
- Inlining time-series profiles in YAML (they belong in data files, referenced
by
ProfileRef). Declaring system structure in YAML is exactly the goal. - A mutable domain object with
.add()/.remove()(philosophy A) —FlowSystemis an inert declaration; assemble the lists, then hand them to the builder. - Replacing
constraints/with declarative math. - Blocking any of the above on an unmerged linopy feature.