You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Discussion issue deferred from #972 (part G) and #969 (part 3). Before PR 4 adds more switches, decide what the configuration surface of the sparse/CSR path should be.
Problem
Important
This issue describes master at b9a698b (after #973 and #974); all code links point there. Since then #979 (closes #975) added native soften for frozen constraints; the softening parts below are updated for it.
A model only stays sparse end to end with three independent opt-ins, and any subset of them silently gives dense results:
linopy.options["semantics"] = "v1": the sparse path is v1-only (is_v1() gate at linopy/expressions.py:613-618 for groupby, :2836 for @).
linopy.options["sparse_groupby"] = True, or per call groupby(...).sum(sparse=True): turns on sparse groupby (expressions.py:613) and, as a side effect, decides whether @ returns a CSR-backed result (expressions.py:2893).
Model(freeze_constraints=True), or per call add_constraints(..., freeze=True): makes the constraint a CSRConstraint (model.py:1310-1311, :1335).
Under legacy semantics sparse_groupby=True is ignored without a message (only an explicit sparse=True raises, expressions.py:614). Without sparse_groupby, groupby().sum() and @ on a dense input return dense expressions. Without freezing, a sparse lhs becomes a dense Constraint. warn_on_densify would report the last case, but it is off by default (config.py:88).
Only the last row is sparse end to end. In the first two rows the CSRConstraint is built from a dense lhs (Constraint.freeze() → CSRConstraint.from_dense), so the build itself was dense and nothing says so.
sparse_groupby (default False), read at expressions.py:613 (default of groupby sparse=) and expressions.py:2893 (whether @ on a dense input returns CSR). Also mentioned in the linopy/csr.py:4-5 module docstring. merge has no switch: _try_csr_merge (expressions.py:3668, gate at :3686) stays sparse only if an operand is already CSR-backed.
LinearExpressionGroupby.sum(sparse: bool | None = None) (expressions.py:559): None → is_v1() and (sparse_groupby or source is CSR-backed); True under legacy raises; True with an unsupported grouper raises (expressions.py:650-655). This is the only per-call sparse= today; dot says so (expressions.py:1589).
LinearExpression.is_sparse (expressions.py:989, :2424) is read-only diagnostics, not configuration.
Proposal
As little exposed configuration as possible. The rule: the user picks the representation of expressions once per model, and never per operation. Mutability of a single constraint stays a per-constraint choice.
One read-only model key, Model(sparse: bool = False). For that model it turns on sparse groupby().sum(), @/dot and merge results, and frozen constraints by default. No setter: freeze_constraints has one today (model.py:535-537), and flipping it mid-build makes earlier and later objects differ. Model(dtypes=...) is the precedent for a constructor-only setting that expression operations read. A bool, not a mode (backend=...): there is no third state.
v1 only, checked where it matters.Model(sparse=True) raises under legacy semantics. Because semantics is a global option that can change after creation, the sparse gates (expressions.py:613, :2836, :3686) also raise when the model is sparse and semantics is legacy, instead of falling back to dense without a message. The model does not pin its semantics.
No per-operation arguments.groupby(...).sum(sparse=...) and options["sparse_groupby"] go. @ no longer reads sparse_groupby (expressions.py:2893).
Per-constraint override stays.add_constraints(freeze=...) overrides the model default, like mask= overrides auto_mask. Constraint.freeze() / CSRConstraint.mutable() stay. Immutability is a property of one registered object (loc, update, from_rule need it), not of how an intermediate expression is stored.
Softening. Frozen constraints soften natively since feat(csr): native soften for frozen constraints #979, so penalty= needs no special case in a sparse model. Do not add a softened constraint unfrozen automatically: a dense constraint inside a sparse model is the divergence this issue removes.
Model.chunk.Model(sparse=True, chunk=...) and setting chunk on a sparse model raise, instead of dropping freeze (model.py:1312-1313).
warn_on_densify stays global. It configures reporting, not results, and applies across models.
Not the v1 default.CSRConstraint has fewer methods than Constraint (constraints.py:937-956); a default that removes methods is more than a storage change. Revisit after the update/from_rule gaps close (soften is done in feat(csr): native soften for frozen constraints #979), independently of the v1 switch.
Persistence. The key is written to netcdf and read back, and Model.copy keeps it. Today Model.copy keeps freeze_constraints but rebuilds a CSRConstraint as a dense Constraint; a copy of a sparse model stays sparse. Old files that store freeze_constraints map onto the key (see Deprecation).
Deprecation.sparse_groupby, sum(sparse=...) and Model(freeze_constraints=...) map onto the new key with a FutureWarning and are removed with legacy semantics; all are released API since 0.8.0. Netcdf files written by 0.8.x store freeze_constraints (io.py:1349); reading that attribute stays supported indefinitely.
Definition of done. For the reproduction above, the four configurations collapse to Model(sparse=True), which is sparse end to end (groupby, @, constraint). Each partial setup either raises (legacy + sparse) or is dense by the user's explicit choice (freeze=False), never silently.
Long-term target: a Constraint with an optional CSR backing, like LinearExpression has today. Then "frozen" disappears as a concept and sparse only describes storage. Until then the key bundles storage and immutability, because a plain Constraint cannot hold a CSR backing.
Combining expressions of two models is out of scope: it is accepted today and silently wrong (the second model's labels alias the first's), which is a separate bug.
Open questions
warn_on_densify default. With the key, a CSR backing can only come from a model that opted in, so warning by default no longer affects existing users. If the default flips to True, a deliberate freeze=False must not warn (model.py:1350-1357 does today); only unrequested densifying should.
PyPSA migration. Under the key every constraint in n.model.constraints is a CSRConstraint. Which PyPSA code paths use loc, update or soften on constraints and need freeze=False? (soften works on frozen constraints since feat(csr): native soften for frozen constraints #979.)
Constraints.add(freeze=...) (constraints.py:2462). Keep it as the low-level twin of add_constraints(freeze=...), or drop it if nothing outside linopy uses it.
Note
The following content was generated by AI.
Describe the feature you'd like to see
Discussion issue deferred from #972 (part G) and #969 (part 3). Before PR 4 adds more switches, decide what the configuration surface of the sparse/CSR path should be.
Problem
Important
This issue describes master at b9a698b (after #973 and #974); all code links point there. Since then #979 (closes #975) added native soften for frozen constraints; the softening parts below are updated for it.
A model only stays sparse end to end with three independent opt-ins, and any subset of them silently gives dense results:
linopy.options["semantics"] = "v1": the sparse path is v1-only (is_v1()gate atlinopy/expressions.py:613-618for groupby,:2836for@).linopy.options["sparse_groupby"] = True, or per callgroupby(...).sum(sparse=True): turns on sparse groupby (expressions.py:613) and, as a side effect, decides whether@returns a CSR-backed result (expressions.py:2893).Model(freeze_constraints=True), or per calladd_constraints(..., freeze=True): makes the constraint aCSRConstraint(model.py:1310-1311,:1335).Under legacy semantics
sparse_groupby=Trueis ignored without a message (only an explicitsparse=Trueraises,expressions.py:614). Withoutsparse_groupby,groupby().sum()and@on a dense input return dense expressions. Without freezing, a sparse lhs becomes a denseConstraint.warn_on_densifywould report the last case, but it is off by default (config.py:88).Output on master (b9a698b):
Only the last row is sparse end to end. In the first two rows the
CSRConstraintis built from a dense lhs (Constraint.freeze()→CSRConstraint.from_dense), so the build itself was dense and nothing says so.Current configuration surface (file:line)
Global options (
linopy/config.py:84-89):semantics(default"legacy", validated atconfig.py:48), read throughis_v1()(linopy/semantics.py:350-352). Sparse gates:expressions.py:613-618(groupby),expressions.py:2836(@, dense path otherwise).sparse_groupby(defaultFalse), read atexpressions.py:613(default of groupbysparse=) andexpressions.py:2893(whether@on a dense input returns CSR). Also mentioned in thelinopy/csr.py:4-5module docstring.mergehas no switch:_try_csr_merge(expressions.py:3668, gate at:3686) stays sparse only if an operand is already CSR-backed.warn_on_densify(defaultFalse), read only in_densify_notice(csr.py:741-750), called fromexpressions.py:2419/:3626-3634,constraints.py:1325(CSRConstraint.to_dense/mutable) andmodel.py:1350-1357(sparse lhs added unfrozen).Per-call arguments:
LinearExpressionGroupby.sum(sparse: bool | None = None)(expressions.py:559):None→is_v1() and (sparse_groupby or source is CSR-backed);Trueunder legacy raises;Truewith an unsupported grouper raises (expressions.py:650-655). This is the only per-callsparse=today;dotsays so (expressions.py:1589).Model.add_constraints(freeze: bool | None = None)(model.py:1247, default from the model at:1310-1311).freezeis dropped whenModel.chunkis set (model.py:1312-1313) (at b9a698b it also raised withpenalty=; feat(csr): native soften for frozen constraints #979 removed that).Constraints.add(constraint, freeze=False)(constraints.py:2462).Model-level:
Model(freeze_constraints: bool = False)(model.py:236, stored at:302), public property with setter (model.py:531-537), round-tripped through netcdf (io.py:1349,model.py:612), also used byadd_indicator_constraints(model.py:1507-1508).Object-level API:
ConstraintBase.freeze()/.mutable()(constraints.py:279-284),Constraint.freeze()→CSRConstraint.from_dense(constraints.py:2073-2075),CSRConstraint.freeze()returns self,.mutable()/.to_dense()gives a detached dense copy (constraints.py:1319-1330).from_rule→ "call .freeze()" (constraints.py:951-956);Constraint.softendocstring (constraints.py:2175-2178).LinearExpression.is_sparse(expressions.py:989,:2424) is read-only diagnostics, not configuration.Proposal
As little exposed configuration as possible. The rule: the user picks the representation of expressions once per model, and never per operation. Mutability of a single constraint stays a per-constraint choice.
Model(sparse: bool = False). For that model it turns on sparsegroupby().sum(),@/dotandmergeresults, and frozen constraints by default. No setter:freeze_constraintshas one today (model.py:535-537), and flipping it mid-build makes earlier and later objects differ.Model(dtypes=...)is the precedent for a constructor-only setting that expression operations read. A bool, not a mode (backend=...): there is no third state.Model(sparse=True)raises under legacy semantics. Becausesemanticsis a global option that can change after creation, the sparse gates (expressions.py:613,:2836,:3686) also raise when the model is sparse and semantics is legacy, instead of falling back to dense without a message. The model does not pin its semantics.groupby(...).sum(sparse=...)andoptions["sparse_groupby"]go.@no longer readssparse_groupby(expressions.py:2893).add_constraints(freeze=...)overrides the model default, likemask=overridesauto_mask.Constraint.freeze()/CSRConstraint.mutable()stay. Immutability is a property of one registered object (loc,update,from_ruleneed it), not of how an intermediate expression is stored.penalty=needs no special case in a sparse model. Do not add a softened constraint unfrozen automatically: a dense constraint inside a sparse model is the divergence this issue removes.Model.chunk.Model(sparse=True, chunk=...)and settingchunkon a sparse model raise, instead of droppingfreeze(model.py:1312-1313).warn_on_densifystays global. It configures reporting, not results, and applies across models.CSRConstrainthas fewer methods thanConstraint(constraints.py:937-956); a default that removes methods is more than a storage change. Revisit after theupdate/from_rulegaps close (softenis done in feat(csr): native soften for frozen constraints #979), independently of the v1 switch.Model.copykeeps it. TodayModel.copykeepsfreeze_constraintsbut rebuilds aCSRConstraintas a denseConstraint; a copy of a sparse model stays sparse. Old files that storefreeze_constraintsmap onto the key (see Deprecation).sparse_groupby,sum(sparse=...)andModel(freeze_constraints=...)map onto the new key with aFutureWarningand are removed with legacy semantics; all are released API since 0.8.0. Netcdf files written by 0.8.x storefreeze_constraints(io.py:1349); reading that attribute stays supported indefinitely.Model(sparse=True), which is sparse end to end (groupby,@, constraint). Each partial setup either raises (legacy + sparse) or is dense by the user's explicit choice (freeze=False), never silently.Long-term target: a
Constraintwith an optional CSR backing, likeLinearExpressionhas today. Then "frozen" disappears as a concept andsparseonly describes storage. Until then the key bundles storage and immutability, because a plainConstraintcannot hold a CSR backing.Combining expressions of two models is out of scope: it is accepted today and silently wrong (the second model's labels alias the first's), which is a separate bug.
Open questions
warn_on_densifydefault. With the key, a CSR backing can only come from a model that opted in, so warning by default no longer affects existing users. If the default flips toTrue, a deliberatefreeze=Falsemust not warn (model.py:1350-1357does today); only unrequested densifying should.add_indicator_constraintsalready followsfreeze_constraints(model.py:1507-1508). Do the objective (sparse since Sparse path for the objective #967) andadd_expressionsfollow the key too?n.model.constraintsis aCSRConstraint. Which PyPSA code paths useloc,updateorsoftenon constraints and needfreeze=False? (softenworks on frozen constraints since feat(csr): native soften for frozen constraints #979.)Constraints.add(freeze=...)(constraints.py:2462). Keep it as the low-level twin ofadd_constraints(freeze=...), or drop it if nothing outside linopy uses it.Consequence for the roadmap
sparse=on@/dot/mergeto wiring the single read-only model key into groupby,@andmerge, turning the silent v1 gates into errors, removing the@coupling tooptions["sparse_groupby"]and deprecating the per-call arguments.freeze=override, andis_sparseandwarn_on_densifyas diagnostics.