From 55b97c1dd243d86a42f77b9628be60a71767002f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 21 Jul 2026 13:44:16 +0200 Subject: [PATCH] docs(v1): move arithmetic-convention specs into doc/ as published rst MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relocates the load-bearing v1 specs out of the untracked-by-Sphinx arithmetics-design/ folder into the rendered docs site: - Convert convention.md / goals.md / legacy-removal.md → reStructuredText under doc/design/ and wire them into a "Design & Internals" toctree in doc/index.rst. rst (not markdown) so no myst-parser dependency is needed. - Update every path reference: linopy/semantics.py and the five `LEGACY: remove at 1.0` comments in linopy/expressions.py, the test_legacy_violations.py header, and the arithmetics-design/ scaffolding files (open-items.md, docs-plan.md) that link to them. - release_notes.rst and migrating-to-v1.rst now cross-reference the rendered convention page via :doc: instead of a GitHub blob URL. The three process/scaffolding files (docs-plan.md, open-items.md, multiindex-feasibility.md) stay in arithmetics-design/ as internal notes. Docs build clean (sphinx-build exit 0, all three pages render, no new warnings); ruff + the convention test suite pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- arithmetics-design/convention.md | 293 ----------------- arithmetics-design/docs-plan.md | 2 +- arithmetics-design/legacy-removal.md | 163 ---------- arithmetics-design/open-items.md | 6 +- doc/design/convention.rst | 298 ++++++++++++++++++ .../goals.md => doc/design/goals.rst | 22 +- doc/design/legacy-removal.rst | 178 +++++++++++ doc/index.rst | 9 + doc/migrating-to-v1.rst | 2 +- doc/release_notes.rst | 2 +- linopy/expressions.py | 10 +- linopy/semantics.py | 4 +- test/test_legacy_violations.py | 2 +- 13 files changed, 510 insertions(+), 481 deletions(-) delete mode 100644 arithmetics-design/convention.md delete mode 100644 arithmetics-design/legacy-removal.md create mode 100644 doc/design/convention.rst rename arithmetics-design/goals.md => doc/design/goals.rst (77%) create mode 100644 doc/design/legacy-removal.rst diff --git a/arithmetics-design/convention.md b/arithmetics-design/convention.md deleted file mode 100644 index e757fe96..00000000 --- a/arithmetics-design/convention.md +++ /dev/null @@ -1,293 +0,0 @@ -# The v1 convention - -The strict ("v1") convention for linopy. Goals and rollout plan: -[`goals.md`](goals.md). The bugs it fixes are catalogued in [#714]. - -An object-scope statement, then thirteen sections in three groups: absence -(§1–§7), coordinate alignment (§8–§11), then constraints and reductions -(§12–§13). - -## Object scope - -The convention governs every operation a linopy object takes part in, -whatever the other operand is — a DataArray, a pandas Series or DataFrame, a -numpy array, a list, or a scalar. A non-linopy operand is converted to a -labelled array (`as_dataarray` in `linopy.common`) and from there behaves -exactly like the constant-only expression holding the same values and -coordinates: `x + arr` builds what `x + arr_expr` builds, for every operator -and in either operand position. No rule below depends on what type an -operand arrived as. How an operand *gets* its labels — its own coordinates, -or pairing by size when it has none — is the alignment group's first rule. - -(The lone exception is type-decided: an expression is never a valid divisor, -so `x / arr` works where `x / arr_expr` raises `TypeError`.) - -## Absence - -Absence — a labelled slot the model does not cover — is the richer half of the -convention. The sections below say what it is (§1–§3), how it arises (§4–§5), -and how it flows through arithmetic and is resolved (§6–§7). - -### §1. Absence is a first-class state - -A *slot* — one labelled position — is either present or *absent*. An absent -slot is one the model does not cover. Absence is a state in its own right, -never a stand-in for a number: an absent variable is not a variable fixed to -zero ([#712]). - -### §2. Encoding absence - -The *marker* is how an absent slot is stored: `NaN` in floating-point fields -(`coeffs`, `const`, numeric constants), and `-1` in integer label fields (a -variable's `labels`, an expression's `vars`, which cannot hold a NaN). The two -encodings are one concept — an absent slot, whatever the dtype. - -Within a single slot, the markers move together: `const.isnull()` at a slot -implies *every* term at that slot has `coeffs = NaN` and `vars = -1`. Operators -that introduce absence at a slot also absorb any live terms there, so the -storage never carries a half-absent row. A term at a *present* slot may still -carry `vars = -1` after `fillna(value)` revives the slot — that's a *dead -term*, inert at the solver layer, and only meaningful as storage book-keeping. - -### §3. Testing absence - -`isnull()` is the one predicate for absence. It reads the marker — `NaN` or -`-1`, whichever the field uses — and reports absence slot by slot. Every rule -that speaks of an "absent slot" means exactly what `isnull()` reports; the -caller never inspects the raw marker. - -### §4. Creating absence - -Absence enters a model only through named operations: `mask=` at construction -marks slots absent up front; `.where(cond)` masks slots in place, keeping -shape; `.reindex()`, `.reindex_like()`, `.shift()`, and `.unstack()` -restructure a coordinate and leave the new positions absent. Operations that -merely move or select existing data — `.roll()`, `.sel()`, `.isel()` — never -introduce it. - -### §5. User-supplied NaN raises - -A NaN in a user-supplied constant raises `ValueError`. linopy trusts NaN only -from its own structural operations (§4), which genuinely mark absence. A NaN in -user data is ambiguous — a deliberate "absent", or a data error — so linopy -refuses to guess and asks the caller to resolve it with `fillna()`. This -replaces today's silent per-operator fills, which guessed a different value for -every operator ([#713]). To mark slots absent, use the mechanisms of §4 — a -bare NaN in a constant is not one of them. - -The alternative — reading user NaN as "absent" instead of raising — was -discussed in [#627] and closed: ambiguous overload of a numeric value -defeats goal #1, since a data-error NaN is silently re-labelled as -intentional absence. - -### §6. Absence propagates through every operator - -Every operator carries absence through unchanged: a slot absent in any operand -is absent in the result. `shifted * 3` is absent; `shifted + 5` is absent; -`x + shifted` is absent wherever `shifted` is — even though `x` itself is fine -there. - -linopy never fills an absent slot on the user's behalf, because the right fill -depends on intent it cannot see: 0 for a sum, 1 for a product, or "leave this -out" entirely. Because every operator propagates the same way, the algebraic -laws of §10 carry over to absent slots untouched — absence absorbs, so every -grouping of an expression agrees. And `shifted * 3` staying absent, rather than -collapsing to `0`, is what preserves the absent-vs-zero distinction of §1. - -### §7. Resolving absence - -Because §6 never fills, turning an absent slot into a value is the caller's -explicit act, never linopy's. `fillna(value)` fills an expression's absent -slots; `.fillna(...)` fills a constant before it enters the arithmetic; -`fill_value=` on a named method fills as part of the call. Filling at the call -site documents the intent: `x + y.shift(time=1).fillna(0)` says "treat the -missing earlier step as zero" exactly where it matters. - -## Coordinate alignment - -linopy's operands are xarray objects, so the convention starts from xarray's -alignment model (goal 4): coordinates align by *label*, never by position; -non-shared dimensions broadcast; a mismatch on a shared dimension is resolved -by an explicit *join*. - -Operands that carry coordinates — a DataArray, a pandas Series or DataFrame — -align by them, under the rules below. *Unlabeled* operands — numpy arrays, -lists, polars Series — carry no labels to align by, so they pair with the -linopy operand's dimensions by size: each axis adopts the dimension (and the -coordinates) whose length matches, and the rules below apply from there. The -pairing must be determined by the sizes alone. A length-4 array meeting a -variable with dims `(a: 4, time: 5)` pairs with `a`; meeting a variable with -dims `(a: 4, b: 4)` it could pair with either, so the operation raises — as -it does when no dimension matches. The same goes for a 4×4 array against -`(a: 4, b: 4)`: sizes cannot tell `(a, b)` from `(b, a)`. To name the -dimensions, wrap the array in a DataArray. - -A scalar broadcasts over every dimension and so needs no pairing. A 0-d -array is treated as a scalar; a Python `list` is read as a numpy array -(it carries values, not labels). Implemented in `linopy.alignment` -([#736]). - -### §8. Shared dimensions must carry identical labels - -If two operands share a dimension, their coordinate labels must be *identical* — -the same labels in the same order — or the operator raises `ValueError`. This is -xarray's `arithmetic_join="exact"`. Both a difference in the label *set* and a -pure *reorder* (the same labels in a different order) raise; linopy never -silently reindexes one operand to the other. Either is resolved explicitly (§10). - -The rule is identical for every operator, so the operator-alignment split -([#708]) — `*` aligning by label while `+`, `-`, `/` go by position — -disappears. - -§8 governs *arithmetic between operands*. Conforming a single input to a target -the caller has already fixed — a variable's bounds or `mask=` against its -declared `coords`, `.reindex()` against an index — is not an operator and still -reindexes a reordered-but-matching input by label. - -### §9. Non-shared dimensions broadcast freely - -A dimension present in only one operand broadcasts over the other, with no -restriction — for both expressions and constants. Only *shared* dimensions are -subject to §8. - -### §10. Mismatches resolve via an explicit join - -When coordinates genuinely differ, §8 raises — and the caller says how to -resolve it. Several primitives bring operands into agreement: - -- `.sel()` / `.isel()` cut operands down to a shared subset — often the - clearest fix. -- The named methods — `.add` `.sub` `.mul` `.div` `.le` `.ge` `.eq` — take a - `join=` argument: `exact`, `inner`, `outer`, `left`, `right`, or `override`. - The default (calling the operator, or `join=None`) follows the global - `options["semantics"]` setting — under v1 that is `exact`, so passing - `join="exact"` is the explicit spelling of the v1 default, not a stricter - mode. `override` is the old positional behavior — still available, but now - opt-in and named rather than triggered by a size coincidence. It still - requires the shared dimensions to match in size: a genuine size mismatch raises rather - than relabelling mismatched data, so reach for a label join (`inner` / - `outer` / `left` / `right`) when the sizes really differ. -- `.reindex()` / `.reindex_like()` conform an operand to a target index - (extending past the original creates absent positions — §4). -- `.assign_coords()` relabels an operand outright. Unlike `join="override"`, - which checks the shared dims match in size, this is an *unguarded* relabel: - it renames positions whether or not they correspond, so the "made explicit" - here is the caller's responsibility, not a safety check. -- `linopy.align()` pre-aligns several operands at once. - -A pure reorder (§8) is resolved the same way: a reindexing join -(`.add(other, join="outer")` / `inner` / `left` / `right`) realigns both sides by -label, or bring one side into the other's order first — `other.sel(dim=…)`, -`other.reindex_like(self)`, or `other.sortby(dim)` on a plain-array operand. - -Because no operator silently drops coordinates, the associativity break -([#711]) cannot occur: the operation that used to drop coordinates now raises. -Every standard algebraic law — commutativity, associativity, distributivity, -the identities — holds for same-coordinate operands. - -### §11. Auxiliary-coordinate conflicts raise - -Auxiliary (non-dimension) coordinates are user-attached metadata: a coord -defined on some dimension but not itself a dimension, like a `B(A)` group -label on dimension `A`. linopy *validates* them (the conflict-raise rule -below) and *propagates* them through arithmetic unchanged, but never -*computes* with them — they describe the data, they don't enter the math. - -When two operands carry an aux coord with the same name and values agree, -the coord propagates to the result. When only one operand carries the -coord, it propagates from that operand unchanged — asymmetric presence is -not a conflict. When the values *do* disagree (same name on both sides, -different values), the operator raises — `xarray` silently drops the -conflict, which is the [#295] bug. The caller resolves it explicitly with -`.drop_vars(name)` (remove the coord) or `.assign_coords(name=...)` -(relabel one side). - -A common source is a *scalar* coordinate left behind by positional indexing: -`x.isel(time=0)` drops `time` as a dimension but keeps it as a scalar coord -(the first label), so a cyclic/boundary constraint like -`x.isel(time=0) == x.isel(time=-1)` conflicts on `time` (first vs last). Drop -it at the source with `.isel(..., drop=True)` / `.sel(..., drop=True)`. - -**Stacked MultiIndex dimensions.** A stacked MultiIndex dim (e.g. PyPSA's -`(period, timestep)` `snapshot`) stores its *levels* as auxiliary -coordinates — `period` and `timestep` are non-dimension coords on -`snapshot` — and its elements are *level combinations* (one tuple per -position). An operand whose *dimension* names one of those levels — a -per-`period` weighting meeting a `snapshot`-indexed expression — is a -same-name conflict between a dimension and an auxiliary coordinate, and it -raises like any other conflict of this section. There is no implicit -projection. `snapshot` is a flat dimension carrying `period` and `timestep` -as auxiliary coordinates (v1 rejects a first-class `pd.MultiIndex` dimension, -so `expr.indexes["snapshot"]` is a flat index, not a `MultiIndex`). Map the -per-`period` input onto `snapshot` through that coordinate — a plain array -of one weight per position — then use it in the operation: - - # weights: pd.Series indexed by `period`; expr: indexed by `snapshot` - w = xr.DataArray( - weights.loc[expr.coords["period"].values].to_numpy(), dims="snapshot" - ) - weighted = expr * w # one constraint/term per snapshot, weighted by period - -An input that reconstructs the *entire* MultiIndex (all levels, every -combination) is not a conflict — it is the same coordinate spelled -differently, and aligns by tuple under §8: the tuples must match in the same -order, a reorder raises (§10). - -(Legacy projects implicitly and warns — scenario B of the [#732]/[#737] -discussion; the implicit projection is removed at 1.0.) - -## Constraints and reductions - -Two kinds of operation build on the rules above without being binary operators: -the comparisons that form constraints, and the reductions that collapse a -dimension. - -### §12. Constraints follow the same rules - -A constraint is built by comparing two sides with `<=`, `>=`, or `==` — and a -comparison is an operator like any other. It aligns its sides by §8 and carries -absence by §6, exactly as `+`, `-`, `*`, and `/` do. So algebraically equal -forms build the same constraint: `x - a <= 0` and `x <= a` agree, where today -they do not ([#707]). - -Each slot becomes one constraint row. An absent slot yields no row — absence -propagated into a comparison drops the constraint there, the same outcome as -masking it. - -### §13. Reductions skip absent slots - -Reductions collapse a dimension rather than combining two operands, so the -NaN propagation of §6 does not apply: they *skip* absent slots instead. `sum` -(including `groupby.sum`) adds only the present terms, and the sum of none is -the zero expression. The objective totals its terms the way `sum` does. - -Further reductions (`mean`, `resample`, `coarsen`) are not in linopy yet; they -are added under v1 only ([#703]) — as new operations with no legacy behaviour — -and follow this same skip-absent rule. - -**Groupers.** A `groupby` grouper — a Series, DataArray, DataFrame, or the name -of an attached coordinate — labels each position of the grouped dimension. It -aligns to that dimension by §8: the grouper's index must carry the grouped -dimension's labels, and a differing label *set* or the same labels in a -different *order* raises. The grouper is never matched by position and never -silently reindexed ([#827]). Under legacy the fast path matches positionally; v1 -validates and raises, whichever grouper type is used. - -A multi-key grouper (a list of coordinate names, or a DataFrame) groups by the -tuple of keys. The result is a flat `group` dimension carrying the keys as -auxiliary coordinates; the stacked `group` MultiIndex is legacy-only (§11). - - -[#827]: https://github.com/PyPSA/linopy/issues/827 -[#732]: https://github.com/PyPSA/linopy/pull/732 -[#737]: https://github.com/PyPSA/linopy/pull/737 -[#736]: https://github.com/PyPSA/linopy/issues/736 -[#714]: https://github.com/PyPSA/linopy/issues/714 -[#703]: https://github.com/PyPSA/linopy/issues/703 -[#713]: https://github.com/PyPSA/linopy/issues/713 -[#712]: https://github.com/PyPSA/linopy/issues/712 -[#711]: https://github.com/PyPSA/linopy/issues/711 -[#708]: https://github.com/PyPSA/linopy/issues/708 -[#707]: https://github.com/PyPSA/linopy/issues/707 -[#627]: https://github.com/PyPSA/linopy/issues/627 -[#295]: https://github.com/PyPSA/linopy/issues/295 diff --git a/arithmetics-design/docs-plan.md b/arithmetics-design/docs-plan.md index 7c8c711a..2458367a 100644 --- a/arithmetics-design/docs-plan.md +++ b/arithmetics-design/docs-plan.md @@ -32,4 +32,4 @@ the three pieces it needs to cover, written when someone picks it up. codebase: opt in on a branch, run tests, fix the raises. The legacy warning text already names the rule and the fix per site, so the guide is mostly the high-level recipe plus a pointer to - the spec (`arithmetics-design/convention.md`) for the rule list. + the spec (`doc/design/convention.rst`) for the rule list. diff --git a/arithmetics-design/legacy-removal.md b/arithmetics-design/legacy-removal.md deleted file mode 100644 index 460163fd..00000000 --- a/arithmetics-design/legacy-removal.md +++ /dev/null @@ -1,163 +0,0 @@ -# Legacy removal checklist (for linopy 1.0) - -The v1 convention ships alongside legacy from 0.x onward and replaces it -entirely at 1.0. This file enumerates everything to delete when that -release happens, in dependency order. Most edits are mechanical; -`grep "LEGACY: remove at 1.0"` finds every inline marker comment in the -source tree. - -## Implementation - -### `linopy/config.py` - -- Drop `LEGACY_SEMANTICS`, `V1_SEMANTICS`, `VALID_SEMANTICS` constants. -- Drop the `LinopySemanticsWarning` class. -- Remove the `semantics` key from `options`. The option no longer exists; - callers don't need to opt in. -- Remove the v1/legacy validation branch in `set_value`. - -### `linopy/semantics.py` - -- Delete `is_v1()` (always true). Inline `True` at the four import sites - in `expressions.py`/`variables.py` or, better, delete the import and - the now-dead `else` branches alongside it. -- Drop the legacy-warn branch from `check_user_nan_scalar` / - `check_user_nan_array` — both become a single `raise ValueError(...)`. -- Delete `dim_coords_differ`: only the legacy `_align_constant` default - path uses it. -- `merge_shared_user_coords_differ`, `conflicting_aux_coord`, - `absorb_absence` stay — they're v1 enforcement helpers. - -### `linopy/expressions.py` - -- `_add_constant`: delete `_add_constant_legacy`; inline - `_add_constant_v1` into the now-trivial dispatcher (or rename it to - `_add_constant`). -- `_apply_constant_op`: same treatment with `_apply_constant_op_legacy`. -- `_align_constant`: delete the `else` branch under `if join is None:` - that handles the legacy size-aware default (`other.sizes == self.const.sizes` - positional + `reindex_like` left-join paths). The explicit-join code - below stays. -- `to_constraint`: drop the `if is_v1(): ... return ...` wrapper and - keep its body; delete the legacy auto-mask fallthrough that follows - (the `rhs_nan_mask` plumbing plus the `rhs.reindex_like(..., - fill_value=np.nan)` pad). -- `LinearExpression.isnull`: drop the legacy `(self.vars == -1).all(...) - & self.const.isnull()` branch — `self.const.isnull()` is the v1 answer. -- `merge`: - - Drop the `if differ: warn(...)` line and the `if aux_conflict: - warn(...)` line — these are the §8 / §11 legacy warns. The raises - above stay. - - The `skipna = not is_v1()` simplifies to `skipna = False` (v1's - propagation rule). - - The trailing `if is_v1(): ds = absorb_absence(ds)` becomes - unconditional. -- Drop the legacy `warn_legacy` message imports from `expressions.py`. - -### `linopy/variables.py` - -- `Variable.to_linexpr`: drop the `else` branch (legacy - `reindex_like(fill_value=0).fillna(0)`); make the v1 `reindex_like( - fill_value=NaN) → .where(~absent)` path unconditional. The - `const = NaN`/`0` assign also becomes unconditional. -- Drop the `from linopy.semantics import is_v1` import. - -### `linopy/alignment.py` - -- `_enforce_implicit_projections`: drop the legacy `warn_legacy(...)` - branch — the v1 raise for partial-level / coverage-gap projections - becomes unconditional. The projection machinery itself - (`_project_onto_multiindex_levels`, `_LevelProjection`) stays: - full-coverage full-level projections remain legal under v1 (they are - the same coordinate spelled differently, §8). -- `_dims_for_unlabeled_operand`: drop the legacy positional-pairing - fallback (the `warn_legacy(...)` branches plus the `return - list(candidates)`); the v1 size-pairing — the `is_v1()` block that - raises on ambiguity / no-match — becomes the whole function. The - `as_constant` / `_pair_axes_by_size` helpers stay (v1-clean). - -### `linopy/piecewise.py` / `linopy/sos_reformulation.py` - -Nothing to remove; these are v1-clean (the `drop=True` / `assign_coords` -fixes from Slice P are correct for both semantics). - -## Tests - -### `test/conftest.py` - -- Drop the `LEGACY_SEMANTICS`/`V1_SEMANTICS`/`VALID_SEMANTICS` imports - and the `LinopySemanticsWarning` import. -- Drop the `legacy` / `v1` marker registration in `pytest_configure`. -- Delete the autouse `semantics` fixture entirely (no more parameterization, - no more warning suppression). - -### `test/test_legacy_violations.py` - -- Delete the file. Everything in it either documents legacy behaviour - (gone) or tests v1 raises (covered by the per-module test files we - add tests to alongside the implementation). - - Before deleting, move any v1 tests that don't have a per-§ home into - the appropriate module: - - `TestExactAlignmentConstant`, `TestExactAlignmentMerge`, - `TestBroadcastNonSharedDim` → `test_linear_expression.py`. - - `TestConstraintRHS` → `test_constraints.py`. - - The rest are small enough to fold in alongside related tests. - -### `test/test_convention.py` - -- Delete the file (it tests the `options["semantics"]` framework, which - is gone). - -### Marker stripping - -`grep -rn "@pytest.mark.legacy\|@pytest.mark.v1\|pytestmark = pytest.mark.legacy" -test/` finds every marker: - -- `@pytest.mark.legacy` decorators — delete the decorator (the test - body is documenting old behaviour; deleting the whole test is - usually right). Spot-check before each delete; a few "legacy" - marks turned out to gate on legacy auto-mask semantics and the - test itself stays valid under v1. -- `@pytest.mark.v1` decorators — strip the decorator (the test stays). -- `pytestmark = pytest.mark.legacy` at module level — was only used - while `piecewise.py` was non-v1-aware; removed in Slice P. Verify - none remain. - -## Documentation - -### `arithmetics-design/goals.md` - -- Drop the entire "Transitioning goals" section (the three transitioning - goals + the schedule are about the legacy bridge, which is gone). -- Goal #4's mention of `LinopySemanticsWarning` (if any) goes. - -### `arithmetics-design/convention.md` - -- Drop the `## Legacy` framing if it exists. Each § already describes - v1 directly; any "where today this does X" asides referencing legacy - behaviour can go. -- Update the intro: "The strict ('v1') convention" → "The arithmetic - convention" (drops the v1 framing now that there's only one). - -### This file (`legacy-removal.md`) - -- Delete after the 1.0 release ships. - -## Order of operations - -A safe sequence (each step compiles and tests pass): - -1. Delete legacy test infrastructure (`test/conftest.py` fixture, - `test/test_convention.py`, `test/test_legacy_violations.py` after - moving v1 tests out). -2. Strip `@pytest.mark.legacy` decorators (the tests fail under v1 - anyway once the legacy paths are gone — delete or update each). -3. Delete legacy implementation branches in `expressions.py` / - `variables.py`. -4. Delete `semantics.py` legacy bits (`is_v1`, the warn branches in - `check_user_nan_*`, `dim_coords_differ`). -5. Delete `config.py` symbols (`LEGACY_SEMANTICS`, the warning class, - the option key). -6. Update `arithmetics-design/goals.md` and `convention.md`. -7. Delete this file. diff --git a/arithmetics-design/open-items.md b/arithmetics-design/open-items.md index 82b2dd1c..29bdbec0 100644 --- a/arithmetics-design/open-items.md +++ b/arithmetics-design/open-items.md @@ -97,7 +97,7 @@ here ([`goals.md`] step 1: *warn on legacy, raise on v1*). [#714]: https://github.com/PyPSA/linopy/issues/714 [#717]: https://github.com/PyPSA/linopy/pull/717 [#803]: https://github.com/PyPSA/linopy/pull/803 -[`goals.md`]: goals.md +[`goals.md`]: ../doc/design/goals.rst [`docs-plan.md`]: docs-plan.md -[`legacy-removal.md`]: legacy-removal.md -[`convention.md`]: convention.md +[`legacy-removal.md`]: ../doc/design/legacy-removal.rst +[`convention.md`]: ../doc/design/convention.rst diff --git a/doc/design/convention.rst b/doc/design/convention.rst new file mode 100644 index 00000000..94e1f585 --- /dev/null +++ b/doc/design/convention.rst @@ -0,0 +1,298 @@ +The v1 convention +================= + +The strict ("v1") convention for linopy. Goals and rollout plan: +:doc:`goals`. The bugs it fixes are catalogued in `#714 `__. + +An object-scope statement, then thirteen sections in three groups: absence +(§1–§7), coordinate alignment (§8–§11), then constraints and reductions +(§12–§13). + +Object scope +------------ + +The convention governs every operation a linopy object takes part in, +whatever the other operand is — a DataArray, a pandas Series or DataFrame, a +numpy array, a list, or a scalar. A non-linopy operand is converted to a +labelled array (``as_dataarray`` in ``linopy.common``) and from there behaves +exactly like the constant-only expression holding the same values and +coordinates: ``x + arr`` builds what ``x + arr_expr`` builds, for every operator +and in either operand position. No rule below depends on what type an +operand arrived as. How an operand *gets* its labels — its own coordinates, +or pairing by size when it has none — is the alignment group's first rule. + +(The lone exception is type-decided: an expression is never a valid divisor, +so ``x / arr`` works where ``x / arr_expr`` raises ``TypeError``.) + +Absence +------- + +Absence — a labelled slot the model does not cover — is the richer half of the +convention. The sections below say what it is (§1–§3), how it arises (§4–§5), +and how it flows through arithmetic and is resolved (§6–§7). + +§1. Absence is a first-class state +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A *slot* — one labelled position — is either present or *absent*. An absent +slot is one the model does not cover. Absence is a state in its own right, +never a stand-in for a number: an absent variable is not a variable fixed to +zero (`#712 `__). + +§2. Encoding absence +~~~~~~~~~~~~~~~~~~~~ + +The *marker* is how an absent slot is stored: ``NaN`` in floating-point fields +(``coeffs``, ``const``, numeric constants), and ``-1`` in integer label fields (a +variable's ``labels``, an expression's ``vars``, which cannot hold a NaN). The two +encodings are one concept — an absent slot, whatever the dtype. + +Within a single slot, the markers move together: ``const.isnull()`` at a slot +implies *every* term at that slot has ``coeffs = NaN`` and ``vars = -1``. Operators +that introduce absence at a slot also absorb any live terms there, so the +storage never carries a half-absent row. A term at a *present* slot may still +carry ``vars = -1`` after ``fillna(value)`` revives the slot — that's a *dead +term*, inert at the solver layer, and only meaningful as storage book-keeping. + +§3. Testing absence +~~~~~~~~~~~~~~~~~~~ + +``isnull()`` is the one predicate for absence. It reads the marker — ``NaN`` or +``-1``, whichever the field uses — and reports absence slot by slot. Every rule +that speaks of an "absent slot" means exactly what ``isnull()`` reports; the +caller never inspects the raw marker. + +§4. Creating absence +~~~~~~~~~~~~~~~~~~~~ + +Absence enters a model only through named operations: ``mask=`` at construction +marks slots absent up front; ``.where(cond)`` masks slots in place, keeping +shape; ``.reindex()``, ``.reindex_like()``, ``.shift()``, and ``.unstack()`` +restructure a coordinate and leave the new positions absent. Operations that +merely move or select existing data — ``.roll()``, ``.sel()``, ``.isel()`` — never +introduce it. + +§5. User-supplied NaN raises +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A NaN in a user-supplied constant raises ``ValueError``. linopy trusts NaN only +from its own structural operations (§4), which genuinely mark absence. A NaN in +user data is ambiguous — a deliberate "absent", or a data error — so linopy +refuses to guess and asks the caller to resolve it with ``fillna()``. This +replaces today's silent per-operator fills, which guessed a different value for +every operator (`#713 `__). To mark slots absent, use the mechanisms of §4 — a +bare NaN in a constant is not one of them. + +The alternative — reading user NaN as "absent" instead of raising — was +discussed in `#627 `__ and closed: ambiguous overload of a numeric value +defeats goal #1, since a data-error NaN is silently re-labelled as +intentional absence. + +§6. Absence propagates through every operator +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Every operator carries absence through unchanged: a slot absent in any operand +is absent in the result. ``shifted * 3`` is absent; ``shifted + 5`` is absent; +``x + shifted`` is absent wherever ``shifted`` is — even though ``x`` itself is fine +there. + +linopy never fills an absent slot on the user's behalf, because the right fill +depends on intent it cannot see: 0 for a sum, 1 for a product, or "leave this +out" entirely. Because every operator propagates the same way, the algebraic +laws of §10 carry over to absent slots untouched — absence absorbs, so every +grouping of an expression agrees. And ``shifted * 3`` staying absent, rather than +collapsing to ``0``, is what preserves the absent-vs-zero distinction of §1. + +§7. Resolving absence +~~~~~~~~~~~~~~~~~~~~~ + +Because §6 never fills, turning an absent slot into a value is the caller's +explicit act, never linopy's. ``fillna(value)`` fills an expression's absent +slots; ``.fillna(...)`` fills a constant before it enters the arithmetic; +``fill_value=`` on a named method fills as part of the call. Filling at the call +site documents the intent: ``x + y.shift(time=1).fillna(0)`` says "treat the +missing earlier step as zero" exactly where it matters. + +Coordinate alignment +-------------------- + +linopy's operands are xarray objects, so the convention starts from xarray's +alignment model (goal 4): coordinates align by *label*, never by position; +non-shared dimensions broadcast; a mismatch on a shared dimension is resolved +by an explicit *join*. + +Operands that carry coordinates — a DataArray, a pandas Series or DataFrame — +align by them, under the rules below. *Unlabeled* operands — numpy arrays, +lists, polars Series — carry no labels to align by, so they pair with the +linopy operand's dimensions by size: each axis adopts the dimension (and the +coordinates) whose length matches, and the rules below apply from there. The +pairing must be determined by the sizes alone. A length-4 array meeting a +variable with dims ``(a: 4, time: 5)`` pairs with ``a``; meeting a variable with +dims ``(a: 4, b: 4)`` it could pair with either, so the operation raises — as +it does when no dimension matches. The same goes for a 4×4 array against +``(a: 4, b: 4)``: sizes cannot tell ``(a, b)`` from ``(b, a)``. To name the +dimensions, wrap the array in a DataArray. + +A scalar broadcasts over every dimension and so needs no pairing. A 0-d +array is treated as a scalar; a Python ``list`` is read as a numpy array +(it carries values, not labels). Implemented in ``linopy.alignment`` +(`#736 `__). + +§8. Shared dimensions must carry identical labels +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If two operands share a dimension, their coordinate labels must be *identical* — +the same labels in the same order — or the operator raises ``ValueError``. This is +xarray's ``arithmetic_join="exact"``. Both a difference in the label *set* and a +pure *reorder* (the same labels in a different order) raise; linopy never +silently reindexes one operand to the other. Either is resolved explicitly (§10). + +The rule is identical for every operator, so the operator-alignment split +(`#708 `__) — ``*`` aligning by label while ``+``, ``-``, ``/`` go by position — +disappears. + +§8 governs *arithmetic between operands*. Conforming a single input to a target +the caller has already fixed — a variable's bounds or ``mask=`` against its +declared ``coords``, ``.reindex()`` against an index — is not an operator and still +reindexes a reordered-but-matching input by label. + +§9. Non-shared dimensions broadcast freely +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A dimension present in only one operand broadcasts over the other, with no +restriction — for both expressions and constants. Only *shared* dimensions are +subject to §8. + +§10. Mismatches resolve via an explicit join +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +When coordinates genuinely differ, §8 raises — and the caller says how to +resolve it. Several primitives bring operands into agreement: + +- ``.sel()`` / ``.isel()`` cut operands down to a shared subset — often the + clearest fix. +- The named methods — ``.add`` ``.sub`` ``.mul`` ``.div`` ``.le`` ``.ge`` ``.eq`` — take a + ``join=`` argument: ``exact``, ``inner``, ``outer``, ``left``, ``right``, or ``override``. + The default (calling the operator, or ``join=None``) follows the global + ``options["semantics"]`` setting — under v1 that is ``exact``, so passing + ``join="exact"`` is the explicit spelling of the v1 default, not a stricter + mode. ``override`` is the old positional behavior — still available, but now + opt-in and named rather than triggered by a size coincidence. It still + requires the shared dimensions to match in size: a genuine size mismatch raises rather + than relabelling mismatched data, so reach for a label join (``inner`` / + ``outer`` / ``left`` / ``right``) when the sizes really differ. +- ``.reindex()`` / ``.reindex_like()`` conform an operand to a target index + (extending past the original creates absent positions — §4). +- ``.assign_coords()`` relabels an operand outright. Unlike ``join="override"``, + which checks the shared dims match in size, this is an *unguarded* relabel: + it renames positions whether or not they correspond, so the "made explicit" + here is the caller's responsibility, not a safety check. +- ``linopy.align()`` pre-aligns several operands at once. + +A pure reorder (§8) is resolved the same way: a reindexing join +(``.add(other, join="outer")`` / ``inner`` / ``left`` / ``right``) realigns both sides by +label, or bring one side into the other's order first — ``other.sel(dim=…)``, +``other.reindex_like(self)``, or ``other.sortby(dim)`` on a plain-array operand. + +Because no operator silently drops coordinates, the associativity break +(`#711 `__) cannot occur: the operation that used to drop coordinates now raises. +Every standard algebraic law — commutativity, associativity, distributivity, +the identities — holds for same-coordinate operands. + +§11. Auxiliary-coordinate conflicts raise +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Auxiliary (non-dimension) coordinates are user-attached metadata: a coord +defined on some dimension but not itself a dimension, like a ``B(A)`` group +label on dimension ``A``. linopy *validates* them (the conflict-raise rule +below) and *propagates* them through arithmetic unchanged, but never +*computes* with them — they describe the data, they don't enter the math. + +When two operands carry an aux coord with the same name and values agree, +the coord propagates to the result. When only one operand carries the +coord, it propagates from that operand unchanged — asymmetric presence is +not a conflict. When the values *do* disagree (same name on both sides, +different values), the operator raises — ``xarray`` silently drops the +conflict, which is the `#295 `__ bug. The caller resolves it explicitly with +``.drop_vars(name)`` (remove the coord) or ``.assign_coords(name=...)`` +(relabel one side). + +A common source is a *scalar* coordinate left behind by positional indexing: +``x.isel(time=0)`` drops ``time`` as a dimension but keeps it as a scalar coord +(the first label), so a cyclic/boundary constraint like +``x.isel(time=0) == x.isel(time=-1)`` conflicts on ``time`` (first vs last). Drop +it at the source with ``.isel(..., drop=True)`` / ``.sel(..., drop=True)``. + +**Stacked MultiIndex dimensions.** A stacked MultiIndex dim (e.g. PyPSA's +``(period, timestep)`` ``snapshot``) stores its *levels* as auxiliary +coordinates — ``period`` and ``timestep`` are non-dimension coords on +``snapshot`` — and its elements are *level combinations* (one tuple per +position). An operand whose *dimension* names one of those levels — a +per-``period`` weighting meeting a ``snapshot``-indexed expression — is a +same-name conflict between a dimension and an auxiliary coordinate, and it +raises like any other conflict of this section. There is no implicit +projection. ``snapshot`` is a flat dimension carrying ``period`` and ``timestep`` +as auxiliary coordinates (v1 rejects a first-class ``pd.MultiIndex`` dimension, +so ``expr.indexes["snapshot"]`` is a flat index, not a ``MultiIndex``). Map the +per-``period`` input onto ``snapshot`` through that coordinate — a plain array +of one weight per position — then use it in the operation: + +:: + + # weights: pd.Series indexed by `period`; expr: indexed by `snapshot` + w = xr.DataArray( + weights.loc[expr.coords["period"].values].to_numpy(), dims="snapshot" + ) + weighted = expr * w # one constraint/term per snapshot, weighted by period + +An input that reconstructs the *entire* MultiIndex (all levels, every +combination) is not a conflict — it is the same coordinate spelled +differently, and aligns by tuple under §8: the tuples must match in the same +order, a reorder raises (§10). + +(Legacy projects implicitly and warns — scenario B of the `#732 `__/`#737 `__ +discussion; the implicit projection is removed at 1.0.) + +Constraints and reductions +-------------------------- + +Two kinds of operation build on the rules above without being binary operators: +the comparisons that form constraints, and the reductions that collapse a +dimension. + +§12. Constraints follow the same rules +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A constraint is built by comparing two sides with ``<=``, ``>=``, or ``==`` — and a +comparison is an operator like any other. It aligns its sides by §8 and carries +absence by §6, exactly as ``+``, ``-``, ``*``, and ``/`` do. So algebraically equal +forms build the same constraint: ``x - a <= 0`` and ``x <= a`` agree, where today +they do not (`#707 `__). + +Each slot becomes one constraint row. An absent slot yields no row — absence +propagated into a comparison drops the constraint there, the same outcome as +masking it. + +§13. Reductions skip absent slots +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Reductions collapse a dimension rather than combining two operands, so the +NaN propagation of §6 does not apply: they *skip* absent slots instead. ``sum`` +(including ``groupby.sum``) adds only the present terms, and the sum of none is +the zero expression. The objective totals its terms the way ``sum`` does. + +Further reductions (``mean``, ``resample``, ``coarsen``) are not in linopy yet; they +are added under v1 only (`#703 `__) — as new operations with no legacy behaviour — +and follow this same skip-absent rule. + +**Groupers.** A ``groupby`` grouper — a Series, DataArray, DataFrame, or the name +of an attached coordinate — labels each position of the grouped dimension. It +aligns to that dimension by §8: the grouper's index must carry the grouped +dimension's labels, and a differing label *set* or the same labels in a +different *order* raises. The grouper is never matched by position and never +silently reindexed (`#827 `__). Under legacy the fast path matches positionally; v1 +validates and raises, whichever grouper type is used. + +A multi-key grouper (a list of coordinate names, or a DataFrame) groups by the +tuple of keys. The result is a flat ``group`` dimension carrying the keys as +auxiliary coordinates; the stacked ``group`` MultiIndex is legacy-only (§11). diff --git a/arithmetics-design/goals.md b/doc/design/goals.rst similarity index 77% rename from arithmetics-design/goals.md rename to doc/design/goals.rst index ba434836..f321b556 100644 --- a/arithmetics-design/goals.md +++ b/doc/design/goals.rst @@ -1,18 +1,20 @@ -# The v1 convention — design & transitioning goals +The v1 convention — design & transitioning goals +================================================ Goals for linopy's strict ("v1") convention. The bugs that motivate -it are catalogued in [#714]; the convention itself is in -[`convention.md`](convention.md). +it are catalogued in `#714 `__; the convention itself is in +:doc:`convention`. -## Design goals +Design goals +------------ The convention serves four goals, in priority order: -1. **No silent wrong answers.** Every bug in the catalogue ([#714]) returns a +1. **No silent wrong answers.** Every bug in the catalogue (`#714 `__) returns a plausible result with no error. The overriding goal: a mismatch linopy cannot resolve unambiguously must raise, not get guessed. Where the library - cannot decide, the caller does — with an explicit join, `.sel()`, or - `fill_value=`. + cannot decide, the caller does — with an explicit join, ``.sel()``, or + ``fill_value=``. 2. **Preserve the algebraic laws.** Commutativity, associativity, distributivity, the identities. Optimization code builds expressions by rearranging terms, and the convention must keep that safe. @@ -27,7 +29,8 @@ The convention serves four goals, in priority order: attached are the user's; linopy validates and carries them through, never silently dropped or rewritten. -## Transitioning goals +Transitioning goals +------------------- 1. **Non-breaking.** Existing code keeps working — legacy stays available and unchanged until it is removed at linopy 1.0. @@ -42,6 +45,3 @@ The convention serves four goals, in priority order: opted into v1. 2. Make v1 the default, allow opt-out. 3. linopy 1.0 — drop the legacy convention entirely. - - -[#714]: https://github.com/PyPSA/linopy/issues/714 diff --git a/doc/design/legacy-removal.rst b/doc/design/legacy-removal.rst new file mode 100644 index 00000000..b480bf60 --- /dev/null +++ b/doc/design/legacy-removal.rst @@ -0,0 +1,178 @@ +Legacy removal checklist (for linopy 1.0) +========================================= + +The v1 convention ships alongside legacy from 0.x onward and replaces it +entirely at 1.0. This file enumerates everything to delete when that +release happens, in dependency order. Most edits are mechanical; +``grep "LEGACY: remove at 1.0"`` finds every inline marker comment in the +source tree. + +Implementation +-------------- + +``linopy/config.py`` +~~~~~~~~~~~~~~~~~~~~ + +- Drop ``LEGACY_SEMANTICS``, ``V1_SEMANTICS``, ``VALID_SEMANTICS`` constants. +- Drop the ``LinopySemanticsWarning`` class. +- Remove the ``semantics`` key from ``options``. The option no longer exists; + callers don't need to opt in. +- Remove the v1/legacy validation branch in ``set_value``. + +``linopy/semantics.py`` +~~~~~~~~~~~~~~~~~~~~~~~ + +- Delete ``is_v1()`` (always true). Inline ``True`` at the four import sites + in ``expressions.py``/``variables.py`` or, better, delete the import and + the now-dead ``else`` branches alongside it. +- Drop the legacy-warn branch from ``check_user_nan_scalar`` / + ``check_user_nan_array`` — both become a single ``raise ValueError(...)``. +- Delete ``dim_coords_differ``: only the legacy ``_align_constant`` default + path uses it. +- ``merge_shared_user_coords_differ``, ``conflicting_aux_coord``, + ``absorb_absence`` stay — they're v1 enforcement helpers. + +``linopy/expressions.py`` +~~~~~~~~~~~~~~~~~~~~~~~~~ + +- ``_add_constant``: delete ``_add_constant_legacy``; inline + ``_add_constant_v1`` into the now-trivial dispatcher (or rename it to + ``_add_constant``). +- ``_apply_constant_op``: same treatment with ``_apply_constant_op_legacy``. +- ``_align_constant``: delete the ``else`` branch under ``if join is None:`` + that handles the legacy size-aware default (``other.sizes == self.const.sizes`` + positional + ``reindex_like`` left-join paths). The explicit-join code + below stays. +- ``to_constraint``: drop the ``if is_v1(): ... return ...`` wrapper and + keep its body; delete the legacy auto-mask fallthrough that follows + (the ``rhs_nan_mask`` plumbing plus the ``rhs.reindex_like(..., fill_value=np.nan)`` pad). +- ``LinearExpression.isnull``: drop the legacy ``(self.vars == -1).all(...) & self.const.isnull()`` branch — ``self.const.isnull()`` is the v1 answer. +- ``merge``: + + - Drop the ``if differ: warn(...)`` line and the ``if aux_conflict: warn(...)`` line — these are the §8 / §11 legacy warns. The raises + above stay. + - The ``skipna = not is_v1()`` simplifies to ``skipna = False`` (v1's + propagation rule). + - The trailing ``if is_v1(): ds = absorb_absence(ds)`` becomes + unconditional. + +- Drop the legacy ``warn_legacy`` message imports from ``expressions.py``. + +``linopy/variables.py`` +~~~~~~~~~~~~~~~~~~~~~~~ + +- ``Variable.to_linexpr``: drop the ``else`` branch (legacy + ``reindex_like(fill_value=0).fillna(0)``); make the v1 ``reindex_like( fill_value=NaN) → .where(~absent)`` path unconditional. The + ``const = NaN``/``0`` assign also becomes unconditional. +- Drop the ``from linopy.semantics import is_v1`` import. + +``linopy/alignment.py`` +~~~~~~~~~~~~~~~~~~~~~~~ + +- ``_enforce_implicit_projections``: drop the legacy ``warn_legacy(...)`` + branch — the v1 raise for partial-level / coverage-gap projections + becomes unconditional. The projection machinery itself + (``_project_onto_multiindex_levels``, ``_LevelProjection``) stays: + full-coverage full-level projections remain legal under v1 (they are + the same coordinate spelled differently, §8). +- ``_dims_for_unlabeled_operand``: drop the legacy positional-pairing + fallback (the ``warn_legacy(...)`` branches plus the ``return list(candidates)``); the v1 size-pairing — the ``is_v1()`` block that + raises on ambiguity / no-match — becomes the whole function. The + ``as_constant`` / ``_pair_axes_by_size`` helpers stay (v1-clean). + +``linopy/piecewise.py`` / ``linopy/sos_reformulation.py`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Nothing to remove; these are v1-clean (the ``drop=True`` / ``assign_coords`` +fixes from Slice P are correct for both semantics). + +Tests +----- + +``test/conftest.py`` +~~~~~~~~~~~~~~~~~~~~ + +- Drop the ``LEGACY_SEMANTICS``/``V1_SEMANTICS``/``VALID_SEMANTICS`` imports + and the ``LinopySemanticsWarning`` import. +- Drop the ``legacy`` / ``v1`` marker registration in ``pytest_configure``. +- Delete the autouse ``semantics`` fixture entirely (no more parameterization, + no more warning suppression). + +``test/test_legacy_violations.py`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +- Delete the file. Everything in it either documents legacy behaviour + (gone) or tests v1 raises (covered by the per-module test files we + add tests to alongside the implementation). + + Before deleting, move any v1 tests that don't have a per-§ home into + the appropriate module: + + - ``TestExactAlignmentConstant``, ``TestExactAlignmentMerge``, + ``TestBroadcastNonSharedDim`` → ``test_linear_expression.py``. + - ``TestConstraintRHS`` → ``test_constraints.py``. + - The rest are small enough to fold in alongside related tests. + +``test/test_convention.py`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +- Delete the file (it tests the ``options["semantics"]`` framework, which + is gone). + +Marker stripping +~~~~~~~~~~~~~~~~ + +``grep -rn "@pytest.mark.legacy\|@pytest.mark.v1\|pytestmark = pytest.mark.legacy" test/`` finds every marker: + +- ``@pytest.mark.legacy`` decorators — delete the decorator (the test + body is documenting old behaviour; deleting the whole test is + usually right). Spot-check before each delete; a few "legacy" + marks turned out to gate on legacy auto-mask semantics and the + test itself stays valid under v1. +- ``@pytest.mark.v1`` decorators — strip the decorator (the test stays). +- ``pytestmark = pytest.mark.legacy`` at module level — was only used + while ``piecewise.py`` was non-v1-aware; removed in Slice P. Verify + none remain. + +Documentation +------------- + +``doc/design/goals.rst`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +- Drop the entire "Transitioning goals" section (the three transitioning + goals + the schedule are about the legacy bridge, which is gone). +- Goal #4's mention of ``LinopySemanticsWarning`` (if any) goes. + +``doc/design/convention.rst`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +- Drop the ``## Legacy`` framing if it exists. Each § already describes + v1 directly; any "where today this does X" asides referencing legacy + behaviour can go. +- Update the intro: "The strict ('v1') convention" → "The arithmetic + convention" (drops the v1 framing now that there's only one). + +This file (``doc/design/legacy-removal.rst``) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +- Delete after the 1.0 release ships. + +Order of operations +------------------- + +A safe sequence (each step compiles and tests pass): + +1. Delete legacy test infrastructure (``test/conftest.py`` fixture, + ``test/test_convention.py``, ``test/test_legacy_violations.py`` after + moving v1 tests out). +2. Strip ``@pytest.mark.legacy`` decorators (the tests fail under v1 + anyway once the legacy paths are gone — delete or update each). +3. Delete legacy implementation branches in ``expressions.py`` / + ``variables.py``. +4. Delete ``semantics.py`` legacy bits (``is_v1``, the warn branches in + ``check_user_nan_*``, ``dim_coords_differ``). +5. Delete ``config.py`` symbols (``LEGACY_SEMANTICS``, the warning class, + the option key). +6. Update ``doc/design/goals.rst`` and ``doc/design/convention.rst``. +7. Delete this file. diff --git a/doc/index.rst b/doc/index.rst index 8e6f19ee..3fc56c59 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -167,3 +167,12 @@ This package is published under MIT license. api release_notes contributing + +.. toctree:: + :hidden: + :maxdepth: 2 + :caption: Design & Internals + + design/convention + design/goals + design/legacy-removal diff --git a/doc/migrating-to-v1.rst b/doc/migrating-to-v1.rst index fd82d8e0..ad8b4543 100644 --- a/doc/migrating-to-v1.rst +++ b/doc/migrating-to-v1.rst @@ -137,7 +137,7 @@ Every row is a legacy guess that becomes an explicit rule under v1. The - ``.sortby`` / ``.reindex`` the grouper to the dimension's labels. The full, normative rule list lives in the -`arithmetic convention `_. +:doc:`arithmetic convention `. .. _silencing-v1: diff --git a/doc/release_notes.rst b/doc/release_notes.rst index f4030da0..18a63570 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -19,7 +19,7 @@ Upcoming Version * a first-class ``pd.MultiIndex`` dimension is rejected in favour of a flat dimension with the levels as auxiliary coordinates. * a ``groupby`` grouper aligns to the grouped dimension by label — a grouper whose labels differ in set or order from the dimension raises instead of being matched positionally, and a multi-key grouper yields a flat ``group`` dimension (keys as aux coords) rather than a stacked ``group`` MultiIndex. (https://github.com/PyPSA/linopy/issues/827) -* Every operation whose result changes under v1 emits a ``LinopySemanticsWarning`` under legacy, naming the fix — so a model can be migrated incrementally before opting in. The full rules are specified in `the arithmetic convention `_. +* Every operation whose result changes under v1 emits a ``LinopySemanticsWarning`` under legacy, naming the fix — so a model can be migrated incrementally before opting in. The full rules are specified in :doc:`the arithmetic convention `. *In-place solver updates (persistent re-solve)* diff --git a/linopy/expressions.py b/linopy/expressions.py index 7ef2d527..e0828826 100644 --- a/linopy/expressions.py +++ b/linopy/expressions.py @@ -858,7 +858,7 @@ def _align_constant( if is_v1(): join = "exact" else: - # LEGACY: remove at 1.0 — see arithmetics-design/legacy-removal.md. + # LEGACY: remove at 1.0 — see doc/design/legacy-removal.rst. mismatch = first_mismatched_dim(self.const, other) if mismatch is not None: warn_legacy( @@ -957,7 +957,7 @@ def _add_constant_v1(self, other: ConstantLike, join: JoinOptions | None) -> Sel ) return self.assign(const=self_const + da) - # LEGACY: remove at 1.0 — see arithmetics-design/legacy-removal.md. + # LEGACY: remove at 1.0 — see doc/design/legacy-removal.rst. def _add_constant_legacy( self, other: ConstantLike, join: JoinOptions | None ) -> Self: @@ -1032,7 +1032,7 @@ def _apply_constant_op_v1( ) return self.assign(coeffs=op(self.coeffs, factor), const=op(self_const, factor)) - # LEGACY: remove at 1.0 — see arithmetics-design/legacy-removal.md. + # LEGACY: remove at 1.0 — see doc/design/legacy-removal.rst. def _apply_constant_op_legacy( self, other: ConstantLike, @@ -1587,7 +1587,7 @@ def to_constraint( ) return constraints.Constraint(data, model=self.model) - # LEGACY: remove at 1.0 — see arithmetics-design/legacy-removal.md. + # LEGACY: remove at 1.0 — see doc/design/legacy-removal.rst. # Legacy auto-mask path: NaN RHS is silently preserved as "no # constraint at this row" (the legacy reindex_like-pad fills # subset coords with NaN, then `sub` would fill them with 0 as @@ -1654,7 +1654,7 @@ def isnull(self) -> DataArray: """ if is_v1(): return self.const.isnull() - # LEGACY: remove at 1.0 — see arithmetics-design/legacy-removal.md. + # LEGACY: remove at 1.0 — see doc/design/legacy-removal.rst. helper_dims = set(self.vars.dims).intersection(HELPER_DIMS) return (self.vars == -1).all(helper_dims) & self.const.isnull() diff --git a/linopy/semantics.py b/linopy/semantics.py index f3e8fa64..4f312057 100644 --- a/linopy/semantics.py +++ b/linopy/semantics.py @@ -6,8 +6,8 @@ here keeps ``expressions.py`` focused on the operator dispatch and lets a future legacy removal be a single-file delete. -See ``arithmetics-design/convention.md`` for the rules these helpers -implement and ``arithmetics-design/goals.md`` for the design intent. +See ``doc/design/convention.rst`` for the rules these helpers +implement and ``doc/design/goals.rst`` for the design intent. """ from __future__ import annotations diff --git a/test/test_legacy_violations.py b/test/test_legacy_violations.py index 831d45b8..89ad2d5f 100644 --- a/test/test_legacy_violations.py +++ b/test/test_legacy_violations.py @@ -3,7 +3,7 @@ Pairs ``@pytest.mark.legacy`` tests that document the surprising legacy behaviour against ``@pytest.mark.v1`` tests that pin the v1 fix. Each -class corresponds to a section of ``arithmetics-design/convention.md`` +class corresponds to a section of ``doc/design/convention.rst`` and to one or more linopy bug reports. Slice A — constant operand path (§5, §8, §9):