Repository navigation
Text-based (YAML) math definition interface #561
Description
It would be useful to be able to define all the mathematics of a linopy model in human-readable text format. This would help with transparency and shareability (if that's a word) of math, as well as making it easier to define new math without needing to be so familiar with Python.
I have developed something that I think would be suitable in the Calliope project. It is a vectorised syntax that is documented. The main ideas are:
- it works with xarray dataarrays, using a
foreachdirective to define the array dimensions and awheredirective to mask the array so that variables/exprs/constraints can be defined only for a subset. - Math expressions do not reference dimensions unless slicing specific elements in the math. That is, you could define
p <= p_nomand it would be applied automatically to all entries in the dimensions defined inforeach, or you could definep[snapshot=2012-01-01] <= p_nom * 0.8and it would slicepto a given snapshot. - helper functions exist to deal with more complex cases. An obvious use-case is to roll the timeseries dimension forward/backward on one element so that you can get the equivalent of
state_of_charge[t] = state_of_charge[t-1] + p_store[t] - p_discharge[t]by callingstate_of_charge = roll(state_of_charge, snapshot=1) + p_store - p_discharge. These are designed as plugins so that power users can add and use new ones if the need arises. - It can automatically be rendered as LaTeX math for use in documentation. This means you only document your math once, when defining it in YAML, and it can then be kept up-to-date in the docs automatically.
It may not be fit for all linopy purposes but the fact that it also relies on multi-dim xarrays I think is already a good starting point. I have been exploring how it could also be used for tidy dataframes (if e.g. updating to using pyoframe) and it seems relatively straightforward; I just need to better understand how to apply the where string masking on polars dfs.
To mitigate bad actors injecting code into the expressions, all strings are parsed with pyparsing (ref. where and expression strings).
Examples of use for user-defined math can be seen here.
Activity
coroa commentedon Jan 30, 2026
That would be a wonderful addition especially for making the custom constraints interface of PyPSA declarative (and thus persistable within a saved network) rather than providing only a callback function.
Do you have any thoughts on how to bring in external data (f.ex. an external csv file containing some co2 limits)?
brynpickering commentedon Mar 6, 2026
By the data having been loaded in as a parameter that can be called upon or (less ideal) by hardcoding the numbers directly in the YAML.
FBumann commentedon Mar 10, 2026
I really like this.
I think we should probably wait unit we merge #591 and #582, but i sketched sth up here: https://github.com/FBumann/linopy-yaml
Im open for feedback and would like to hear your thoughts
brynpickering commentedon Jul 7, 2026
I've opened a dev branch in linopy for this (based on my existing implementation in Calliope): https://github.com/PyPSA/linopy/tree/feature/declarative-yaml-interface
Main things:
A working input dataset (based on PyPSA model.energy example) and some YAML math to go along with it: Archive.zip
Things we might want to add, based on offline discussion:
namedimension)FBumann commentedon Jul 7, 2026
@brynpickering I argue that having this as a standalone package, that plugs into linopy might be better architecturally.
It allows having breaking changes in the yaml layer until it matures.
Are you interested in discussing your approach compared to mine? (linopy-yaml) https://github.com/FBumann/linopy-yaml
FBumann commentedon Jul 7, 2026
I promped claude Fable 5 to review for architectual blockers of making this a standalone. Thought i should share the findings.
Note
The content below was generated by AI (checked against linopy
master).Does a standalone package block anything? Checked against linopy's actual extension surface — no hard blocks, two clean-integration gaps.
Everything hard about the YAML layer runs on the public API and needs nothing from a standalone package: building variables/constraints/objectives/expressions (
add_*), LaTeX rendering (pure), the parser (slicing, sub-expressions), and the helper plugin registry.Two integration points can't be done cleanly today — neither requires the parser to live in-tree; both are small, generic core hooks that help any extension:
No accessor/plugin registration. linopy has no
register_*_accessor/entry_pointsgroup, so amodel.yamlnamespace has to monkeypatch today. Brittle (breaks on refactors, collides across packages). Fix: a tiny public registration point (or anentry_pointsgroup).In-file persistence of the math (the "persistable within a saved network" motivation). linopy persists model-level state via a hardcoded known-key convention in
ds.attrs, andread_netcdfdrops attrs it doesn't recognize. A standalone package can round-trip by wrappingto_netcdf/read_netcdf— so it's not blocked — but it'll never be part of the format guarantee. Fix: reserve a namespaced attr and haveread_netcdfpreserve unknown namespaced attrs.Proposal: standalone package + a small "extensibility" PR to core exposing those two hooks. This yields everything people want from in-tree — a clean
model.yamland guaranteed persistence — while keeping breaking-change freedom for the pre-1.0 YAML schema and keeping ~3–6k lines of parser out of core. Both hooks are defensible on their own merits, independent of this feature.What this buys core linopy (independent of the YAML layer): mostly it's what core doesn't have to carry.
pyparsinggrammar, LaTeX rendering and helper functions live and are tested outside core: no new core dependency, smaller install, less CI, smaller security-review surface, and no support load from monkeypatched installs.read_netcdfsilently dropping third-party metadata, and stops core accreting a bespoke known-key per feature (_relaxed_registry,_piecewise_formulations, …) — one generic mechanism instead of N special cases to keep maintaining.Net: the hooks are a one-time, generic addition; everything volatile stays outside. Core gets an extension ecosystem (PyPSA custom constraints included) without taking on the maintenance or versioning burden of any of it.
Code paths this is based on (linopy master)
register_*_accessor,entry_points, or plugin mechanism inlinopy/*.pyorpyproject.toml.to_netcdf(linopy/io.py) writes extra model state as hardcodedds.attrskeys (_relaxed_registry,_piecewise_formulations,NETCDF_VERSION_ATTR).read_netcdfrebuilds a freshModeland only restores recognized keys (for k in m.scalar_attrs: if k in ds.attrs, plus explicit_relaxed_registry/_piecewise_formulationshandling) — unknown attrs are dropped. So an external_yaml_mathattr survives to disk but isn't reattached unless the package also wraps read.brynpickering commentedon Jul 8, 2026
Claude has some valid points but I don't agree with the general conclusion. I think the text math functionality would be safer living within the core repository for several reasons:
Your separate repo is a lightweight version of what I started implementing in Calliope some years ago and it misses some key functionality and design decisions that would be necessary to make all features I mention above possible. Pyparsing is much more powerful than AST for the
maskandexpressionstring parsing, giving us much more control of the syntax we create (we will likely need some funky special character uses like in languages like GAMS to make this whole thing work). And helper functions need to be more strictly managed to work in all cases that they might be evaluated from in the expression or mask strings. I opted for a plugin approach using an ABC as it ensures I have a stable API on all registered helpers that I can then leverage in various other places.I plan to pass Claude over my branch to tidy it up since it has several rough edges following my porting across from Calliope. I can then open a PR but would also like to continue discussion here with @coroa and @FabianHofmann first, to make a final decision on the best approach for where the implementation should live.
FBumann commentedon Jul 8, 2026
@brynpickering Just to make this clear: Im perfectly fine discarding my repo. I agree that your approach with years of experience and hardening is for sure superior. Im merely interested in the architectural discussion about this being a standalone package.
For me, having a separate Issue tracker AND separate docs actually seems like an advantage. It keeps each page focused on the layer. And the yaml layer is pretty much its own language. So a separate page seems justified.
Having this in the core linopy docs would probably double the surface, and make for a much harder read.
Having it installable via
pip install "linopy[yaml]"should of course be possible.FBumann commentedon Jul 8, 2026
I have one case where I think a decision in my approach may be worth adopting @brynpickering. I decided to declare the expected dims of parameters in the yaml. This is then evaluated upfront, and documents the dimensionality of the model in the yaml file. Maybe you have good reasons to not do this, but I think we should consider doing this.
brynpickering commentedon Jul 9, 2026
Thanks @FBumann. I expected we would need to iterate over these design decisions so it's good to discuss them here. We'll need @coroa and @FabianHofmann to weigh in on where it lives as we're unlikely to reach consensus just between the two of us!
Not defining the parameter dimensions in advance was a conscious design decision. Frequently, they are dynamically dimensioned in energy system models. For example, load could be static (not indexed over snapshot), efficiency could be static, per-snapshot, per-node, or per-node + per-snapshot. Since the xarray/linopy broadcasting handles it automatically (both
dispatch_{n, t} * efficiency_{n}anddispatch_{n, t} * efficiency_{n, t}are fine), it isn't necessary or indeed helpful to specify it in advance.Saying that, perhaps listing the allowed (or excluded) dimensions could be an option, to at least guide someone reading the math as to what to expect to be working with.
FBumann commentedon Jul 9, 2026
@brynpickering Yes, of course we'll fait for them.
I think having validation would make the yaml much more robust. But it should be optional.
The validation should have in mind that the parameters are meant for linopy, so subsetting dims is always ok (clean broadcast).
brynpickering commentedon Jul 9, 2026
Yeah, having them optional seems reasonable, so that a user can give more context with which linopy can validate. It would be the max dims that a parameter can be indexed over, so that inputs can be a subset of that and linopy is OK with this. If not defined, the full set of
dimensionsis assumed?Where I'd like to not be overly constrained (as a modeller) is when I add a new dimension to a problem (e.g. "scenario") and I don't want to have the overhead of adding that dimension to all my parameter
dimsdefs since it's likely there will be a lot of parameters.FBumann commentedon Jul 9, 2026
Yes, id give the max dims. Subsetting a dim is always possible in linopy. I would also say the parameters should be broadcast to those dims, so all further operations (reductions, selections, ...) can rely on that contract.
Im not sure how we should handle your "scenario" dim though. If it applies to ALL variables, i think a global param outside of the yaml would work as a modifier. If its only for some, we would need to switch of validation or some other workaround.
This comes down to what the yaml files should be for: recipies for certain components/patterns (no dims), or as full model specs (with dims).
brynpickering commentedon Jul 9, 2026
I wouldn't broadcast parameters to those dims at the start as that would bloat in-memory footprint. Instead, I would broadcast them just before applying arithmetic so that only those few params that need broadcast exist in that bloated state at any given time. It would mean that we lose that reliable contract, of course, but it will likely be necessary so long as we have
xarrayas our data layer.What I mean about my
scenariodim is that I might have a base recipe that I want to extend with a partial YAML that extends theforeachof some variables and constraints to include ascenariodimension (e.g. for 2-stage stochastic in energy modelling, it might be only added to the timeseries variables). The number of lists that need extending to include this extra dimension is relatively small, but if I have to also addscenarioto all parameters as well it then becomes quite a verbose addition that is more prone to human error. For example, there's 86 params+lookups vs 41 constraints+variables in the Calliope base math.The way I had imagined it is that you might have a library of recipes that you can apply patches to for a given use-case, e.g.:
base recipe
scenario patch
It's like with snakemake YAML configs, that you can have a file with only the changes relative to default, to avoid duplications. adding the equivalent of
bar: {dims: [name, time, scenario]}across all relevant params then might be verbose. But that might well be fine since at least it's explicit!FBumann commentedon Jul 9, 2026
@brynpickering about broadcasting: The memory footprint of expanding parameters to their dims will be payed anyway, as soon as they are used in arithmetics or as variable bounds. Its only delayed.
I dont really have an opinion on the recipe/patch stuff yet.
brynpickering commentedon Jul 9, 2026
It won't necessarily be paid to the full extent. An example would be that if we have 80 parameters which are all configured to be indexed over a
timedim, then 40 vars+constrs also all indexed over atimedim. If you supply all 80 parameters not indexed overtimeand broadcast them straight away, you will have 120 arrays in memory broadcast over that dimension by the end (params+vars+constrs). If you only broadcast them at the point of applying arithmetic then you might have 2-3 params broadcast overtimeat any given moment, but not all 80. You'll then end up with only 40 arrays in memory broadcast over that dimension at the end (vars+constrs).FBumann commentedon Jul 9, 2026
Thats correct, but Im confident that the amount of extra memory is tiny compared to the resulting linopy Objects. Ill get back with a small benchmark of this!
EDIT: Im wrong. See below
FBumann commentedon Jul 9, 2026
Here's the broadcast-memory benchmark I promised.
Note
The content below was generated by AI (run locally against linopy
master).Same model built both ways; the only difference is whether the resident input params are broadcast to their full
dimsup front (EAGER) or kept minimal and broadcast by linopy just before arithmetic (LAZY, @brynpickering's position). Peak measured withmemrayviapytest-benchmem, single run pivoted on thestrategydim:P= number of params,K= terms/constraint; all configs have 40 vars+constraints over(name=200, time=T).The extra peak is exactly the resident broadcast param set (
P·N·T·8bytes): constant acrossKat fixedP(+11.5 MiB for dense-K1/calliope-K2/manyterm-K6), doubling withP(paramheavy → +22.9 MiB), and scaling withT(bigtime, 8760h → +1.07 GiB). The relative cost only shrinks withKbecause the vars/constraints in the denominator grow. So it's "tiny" only in the few-params/high-term corner (+10%); in the hourly-resolution regime it's a gigabyte.Benchmark script (
dev-scripts/test_param_broadcast_mem.py)Run:
pytest dev-scripts/test_param_broadcast_mem.py --benchmark-only --benchmark-memory --benchmark-json=run.jsonthenbenchmem compare run.json --pivot param:strategy --columns peak --stat min.coroa commentedon Jul 9, 2026
I'll not be able to review this this week.
As we discussed, a few small points, we'd want to to be able to leverage this math eventually to register streaming constraints. ie. you pass data as a lazy polars long-form frame and the math for a constraint:
the math is parsed into an ast, dimensions and shapes are determined (and maybe sparseness if cheap enough), but no data is evaluated yet. only when the CSR matrix is needed, it is constructed on the fly. and dropped again (unless there was a good reason not to, like persistent solver).
the promise is that we don't need to hold any memory for the constraint anymore.
we also should evaluate where the point is to convert then from the lazy frame to xarray. potentially never. potentially based on specific triggers in the ast.
i plan to read it on Monday.
coroa commentedon Jul 9, 2026
This lazy construction might become essential when we get further into stochastic space. the mentioned scenario dimension can easily become too large for everything to fit into memory and we'd need to break it down into sub problems and instantiate only parts of anywhere
brynpickering commentedon Jul 10, 2026
@FBumann thanks for the benchmarking work. I hadn't considered the fact that each
Kconsists of multiple arrays in a wrapped dataset so I would have probably come round to your position if not for the benchmarking!@coroa I hadn't thought about streaming constraints so that they never exist as linopy objects for longer than necessary, with the YAML being the source of truth that one returns to if they want to re-construct the constraint at any point. Makes sense when you don't have/want a persistent solver. What you would need in that case, for reproducibility, is the ability to keep the YAML math close to the input data when dumping a model representation to file.
FBumann commentedon Jul 10, 2026
This would say if this is the long term goal for the yaml layer, of course needs to be part of the core package