Skip to content

fix(copy): keep frozen constraints frozen in Model.copy - #982

Merged
FabianHofmann merged 5 commits into
masterfrom
fix/copy-frozen-constraints
Sep 25, 2026
Merged

FabianHofmann merged 5 commits into
masterfrom
fix/copy-frozen-constraints

Conversation

@FabianHofmann

Copy link
Copy Markdown
Collaborator

Closes #981.

Note

The following content was generated by AI.

Changes proposed in this Pull Request

Model.copy() (and copy.copy/copy.deepcopy) rebuilt every constraint as Constraint(con.mutable().data, ...), so a frozen CSRConstraint came back dense while the copy kept freeze_constraints=True.

  • linopy/io.py: a frozen constraint is copied through CSRConstraint._replace, rebound to the new model. deep=True copies its CSR and per-row arrays, deep=False shares them; without include_solution the dual is dropped, as for dense constraints. Dense constraints are copied as before.
  • The CSR-backed objective was kept sparse but shared its matrix and constant even with deep=True; it is now deep-copied too.
  • linopy/csr.py: Grid stores its auxiliary coordinate arrays read-only (copying writable input first, so the user's array is not flagged). The grid is therefore immutable and can be shared by copies. Before, writing to con.coords["aux"].values on any frozen constraint changed the grid in place, and after a deep copy also the original.

Tests

  • test_copy_frozen_matrices[deep]: solver-free, runs under legacy and v1. Frozen model with mask, row scaling, aux coord and an indicator constraint; the copy keeps CSRConstraint, A/b/sense/clabels and the indicator matrices equal the original's, buffers are shared exactly when deep=False, aux coords are read-only.
  • test_copy_keeps_frozen_constraints[deep, include_solution] (v1, HiGHS): softened frozen model with sparse objective; objective stays sparse, slack resolves in the copy, duals only with include_solution=True.
  • test_softening_frozen_copy_leaves_original[deep]: softening the copy leaves the original constraint and objective unchanged.
  • The copy tests fail on master and pass with the fix. A review pass also compared matrices and LP files of original and copy for softened, masked, scaled, aux-coord, mixed dense/frozen and indicator models under both semantics, checked duals and label counters after copy, and in-place operations on shallow copies.
Local results
  • Full suite without xpress (no license) and test/remote (oetc extras missing): 7113 passed, 0 failed.
  • ruff check, ruff format: clean. mypy: only the local-stub error at test/test_solvers.py:225, not present in CI.

Checklist

  • AI-generated content is marked (see AGENTS.md).
  • Code changes are sufficiently documented; i.e. new functions contain docstrings and further explanations may be given in doc.
  • Unit tests for new features were added (if applicable).
  • A note for the release notes doc/release_notes.rst of the upcoming release is included.
  • I consent to the release of this PR's code under the MIT license.

@FabianHofmann FabianHofmann added bug Something isn't working sparse Sparse / CSR-backed expressions and constraints labels Sep 25, 2026
@codspeed

codspeed Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Merging this PR will regress 1 benchmark

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 16 improved benchmarks
❌ 1 regressed benchmark
✅ 164 untouched benchmarks
⏩ 181 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
❌ test_to_lp[expression_arithmetic-n=250] 42.8 MB 50 MB -14.38%
⚡ test_to_lp[qp-n=1000] 2,646.5 KB 795.3 KB ×3.3
⚡ test_to_lp[masked-n=100] 2,793.7 KB 892.8 KB ×3.1
⚡ test_to_lp[milp-n=50] 2,760 KB 885.4 KB ×3.1
⚡ test_to_lp[sos-n=1000] 3 MB 1.4 MB ×2.2
⚡ test_to_lp[expression_arithmetic-n=10] 1,339.1 KB 691.1 KB +93.76%
⚡ test_to_lp[sparse_network-n=10] 1,337.7 KB 695.6 KB +92.3%
⚡ test_to_lp[storage-n=10] 2.9 MB 1.6 MB +78.9%
⚡ test_to_lp[piecewise-n=1000] 3 MB 1.9 MB +60.75%
⚡ test_to_lp[rolling-severity=0] 3.1 MB 2 MB +57.33%
⚡ test_to_lp[cumsum-severity=50] 62.3 MB 43.1 MB +44.74%
⚡ test_to_lp[knapsack-n=10000] 3 MB 2.1 MB +40.41%
⚡ test_to_lp[cumsum-severity=100] 208.9 MB 160.9 MB +29.83%
⚡ test_to_lp[rolling-severity=50] 429.9 MB 333.9 MB +28.76%
⚡ test_to_lp[rolling-severity=100] 274.6 MB 227 MB +20.97%
⚡ test_to_lp[kvl_cycles-severity=0] 280.9 MB 232.9 MB +20.61%
⚡ test_to_lp[kvl_cycles-severity=50] 141.6 MB 117.6 MB +20.41%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing fix/copy-frozen-constraints (2b8f02f) with master (14f601e)2

Open in CodSpeed

Footnotes

  1. 181 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

  2. No successful run was found on master (bee7d1f) during the generation of this report, so 14f601e was used instead as the comparison base. There might be some changes unrelated to this pull request in this report. ↩

@github-actions

github-actions Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Build cost — v1 vs legacy

v1 build peak & time relative to legacy, on this commit — not a comparison against master (that is CodSpeed).

peak — v1 / legacy time — v1 / legacy
peak v1/legacy time v1/legacy
Full table (time + peak, mean)
benchmarks/drivers/test_build.py::test_build[basic-n=10]
                  time (s)         peak (KiB) 
 name                 mean   │           mean 
──────────────────────────────────────────────
 (legacy)   0.09354 (1.13)   │   15.03 (1.00) 
 (v1)        0.08298 (1.0)   │    15.00 (1.0) 

benchmarks/drivers/test_build.py::test_build[basic-n=250]
                  time (s)         peak (MiB) 
 name                 mean   │           mean 
──────────────────────────────────────────────
 (legacy)   0.09539 (1.06)   │   12.04 (1.00) 
 (v1)        0.09019 (1.0)   │    12.04 (1.0) 

benchmarks/drivers/test_build.py::test_build[cumsum-severity=0]
                 time (s)        peak (KiB) 
 name                mean   │          mean 
────────────────────────────────────────────
 (legacy)   0.0387 (1.07)   │   15.20 (1.0) 
 (v1)       0.03616 (1.0)   │   15.20 (1.0) 

benchmarks/drivers/test_build.py::test_build[cumsum-severity=100]
                  time (s)        peak (MiB) 
 name                 mean   │          mean 
─────────────────────────────────────────────
 (legacy)   0.05547 (1.04)   │   44.93 (1.0) 
 (v1)        0.05333 (1.0)   │   44.93 (1.0) 

benchmarks/drivers/test_build.py::test_build[cumsum-severity=50]
                  time (s)        peak (MiB) 
 name                 mean   │          mean 
─────────────────────────────────────────────
 (legacy)   0.04211 (1.06)   │   11.51 (1.0) 
 (v1)        0.03974 (1.0)   │   11.51 (1.0) 

benchmarks/drivers/test_build.py::test_build[expression_arithmetic-n=10]
                  time (s)         peak (KiB) 
 name                 mean   │           mean 
──────────────────────────────────────────────
 (legacy)   0.09898 (1.02)   │   24.34 (1.06) 
 (v1)         0.0966 (1.0)   │    23.04 (1.0) 

benchmarks/drivers/test_build.py::test_build[expression_arithmetic-n=250]
                 time (s)         peak (MiB) 
 name                mean   │           mean 
─────────────────────────────────────────────
 (legacy)   0.1094 (1.04)   │   16.12 (1.00) 
 (v1)        0.1052 (1.0)   │    16.12 (1.0) 

benchmarks/drivers/test_build.py::test_build[knapsack-n=10000]
                  time (s)          peak (KiB) 
 name                 mean   │            mean 
───────────────────────────────────────────────
 (legacy)   0.02387 (1.04)   │   752.18 (1.10) 
 (v1)        0.02298 (1.0)   │    685.15 (1.0) 

benchmarks/drivers/test_build.py::test_build[knapsack-n=100]
                  time (s)        peak (KiB) 
 name                 mean   │          mean 
─────────────────────────────────────────────
 (legacy)   0.02335 (1.04)   │   3.12 (1.33) 
 (v1)        0.02248 (1.0)   │    2.34 (1.0) 

benchmarks/drivers/test_build.py::test_build[kvl_cycles-severity=0]
                  time (s)          peak (MiB) 
 name                 mean   │            mean 
───────────────────────────────────────────────
 (legacy)   0.06501 (1.21)   │   126.16 (1.44) 
 (v1)        0.05365 (1.0)   │     87.71 (1.0) 

benchmarks/drivers/test_build.py::test_build[kvl_cycles-severity=100]
                  time (s)          peak (MiB) 
 name                 mean   │            mean 
───────────────────────────────────────────────
 (legacy)   0.06413 (1.22)   │   126.16 (1.44) 
 (v1)        0.05253 (1.0)   │     87.71 (1.0) 

benchmarks/drivers/test_build.py::test_build[kvl_cycles-severity=50]
                  time (s)          peak (MiB) 
 name                 mean   │            mean 
───────────────────────────────────────────────
 (legacy)   0.06447 (1.22)   │   126.16 (1.44) 
 (v1)        0.05299 (1.0)   │     87.71 (1.0) 

benchmarks/drivers/test_build.py::test_build[masked-n=100]
                  time (s)          peak (KiB) 
 name                 mean   │            mean 
───────────────────────────────────────────────
 (legacy)   0.05556 (1.01)   │    715.12 (1.0) 
 (v1)        0.05509 (1.0)   │   787.73 (1.10) 

benchmarks/drivers/test_build.py::test_build[masked-n=10]
                  time (s)        peak (KiB) 
 name                 mean   │          mean 
─────────────────────────────────────────────
 (legacy)   0.05383 (1.09)   │   4.54 (1.27) 
 (v1)         0.0496 (1.0)   │    3.57 (1.0) 

benchmarks/drivers/test_build.py::test_build[merge_balance-severity=0]
                 time (s)          peak (KiB) 
 name                mean   │            mean 
──────────────────────────────────────────────
 (legacy)   0.3733 (1.02)   │   704.12 (1.09) 
 (v1)        0.3674 (1.0)   │    643.85 (1.0) 

benchmarks/drivers/test_build.py::test_build[merge_balance-severity=100]
                 time (s)        peak (MiB) 
 name                mean   │          mean 
────────────────────────────────────────────
 (legacy)   0.3935 (1.02)   │   18.34 (1.0) 
 (v1)        0.3851 (1.0)   │   18.34 (1.0) 

benchmarks/drivers/test_build.py::test_build[merge_balance-severity=50]
                 time (s)       peak (MiB) 
 name                mean   │         mean 
───────────────────────────────────────────
 (legacy)   0.3907 (1.03)   │   9.54 (1.0) 
 (v1)        0.3805 (1.0)   │   9.54 (1.0) 

benchmarks/drivers/test_build.py::test_build[milp-n=10]
                  time (s)        peak (KiB) 
 name                 mean   │          mean 
─────────────────────────────────────────────
 (legacy)   0.07613 (1.07)   │   3.77 (1.12) 
 (v1)        0.07143 (1.0)   │    3.37 (1.0) 

benchmarks/drivers/test_build.py::test_build[milp-n=50]
                  time (s)          peak (KiB) 
 name                 mean   │            mean 
───────────────────────────────────────────────
 (legacy)   0.07671 (1.07)   │   216.59 (1.10) 
 (v1)         0.0715 (1.0)   │    196.23 (1.0) 

benchmarks/drivers/test_build.py::test_build[nodal_balance-severity=0]
                  time (s)         peak (KiB) 
 name                 mean   │           mean 
──────────────────────────────────────────────
 (legacy)   0.03836 (1.08)   │   938.49 (1.0) 
 (v1)        0.03568 (1.0)   │   938.49 (1.0) 

benchmarks/drivers/test_build.py::test_build[nodal_balance-severity=100]
                  time (s)       peak (MiB) 
 name                 mean   │         mean 
────────────────────────────────────────────
 (legacy)   0.03992 (1.09)   │   9.66 (1.0) 
 (v1)        0.03649 (1.0)   │   9.66 (1.0) 

benchmarks/drivers/test_build.py::test_build[nodal_balance-severity=50]
                  time (s)       peak (MiB) 
 name                 mean   │         mean 
────────────────────────────────────────────
 (legacy)   0.03927 (1.09)   │   5.32 (1.0) 
 (v1)        0.03606 (1.0)   │   5.32 (1.0) 

benchmarks/drivers/test_build.py::test_build[nodal_balance_sparse-severity=0]
                  time (s)       peak (MiB) 
 name                 mean   │         mean 
────────────────────────────────────────────
 (legacy)   0.02139 (1.01)   │   1.28 (1.0) 
 (v1)        0.02109 (1.0)   │   1.28 (1.0) 

benchmarks/drivers/test_build.py::test_build[nodal_balance_sparse-severity=100]
                  time (s)       peak (MiB) 
 name                 mean   │         mean 
────────────────────────────────────────────
 (legacy)    0.02128 (1.0)   │   1.28 (1.0) 
 (v1)       0.02145 (1.01)   │   1.28 (1.0) 

benchmarks/drivers/test_build.py::test_build[nodal_balance_sparse-severity=50]
                 time (s)       peak (MiB) 
 name                mean   │         mean 
───────────────────────────────────────────
 (legacy)   0.0214 (1.00)   │   1.28 (1.0) 
 (v1)       0.02136 (1.0)   │   1.28 (1.0) 

benchmarks/drivers/test_build.py::test_build[piecewise-n=1000]
                time (s)          peak (KiB) 
 name               mean   │            mean 
─────────────────────────────────────────────
 (legacy)   0.189 (1.03)   │   946.85 (1.06) 
 (v1)        0.183 (1.0)   │    891.54 (1.0) 

benchmarks/drivers/test_build.py::test_build[piecewise-n=10]
                 time (s)         peak (KiB) 
 name                mean   │           mean 
─────────────────────────────────────────────
 (legacy)   0.1851 (1.03)   │   12.01 (1.00) 
 (v1)        0.1804 (1.0)   │    11.99 (1.0) 

benchmarks/drivers/test_build.py::test_build[qp-n=1000]
                  time (s)          peak (KiB) 
 name                 mean   │            mean 
───────────────────────────────────────────────
 (legacy)   0.04911 (1.08)   │   147.70 (1.06) 
 (v1)        0.04562 (1.0)   │    139.87 (1.0) 

benchmarks/drivers/test_build.py::test_build[qp-n=10]
                 time (s)        peak (KiB) 
 name                mean   │          mean 
────────────────────────────────────────────
 (legacy)   0.0478 (1.06)   │   2.60 (1.09) 
 (v1)       0.04517 (1.0)   │    2.38 (1.0) 

benchmarks/drivers/test_build.py::test_build[rolling-severity=0]
                  time (s)          peak (KiB) 
 name                 mean   │            mean 
───────────────────────────────────────────────
 (legacy)   0.03942 (1.08)   │   696.75 (1.03) 
 (v1)        0.03666 (1.0)   │    673.70 (1.0) 

benchmarks/drivers/test_build.py::test_build[rolling-severity=100]
                  time (s)         peak (MiB) 
 name                 mean   │           mean 
──────────────────────────────────────────────
 (legacy)   0.08762 (1.04)   │   137.97 (1.0) 
 (v1)        0.08406 (1.0)   │   137.97 (1.0) 

benchmarks/drivers/test_build.py::test_build[rolling-severity=50]
                  time (s)        peak (MiB) 
 name                 mean   │          mean 
─────────────────────────────────────────────
 (legacy)   0.06094 (1.07)   │   69.22 (1.0) 
 (v1)        0.05706 (1.0)   │   69.22 (1.0) 

benchmarks/drivers/test_build.py::test_build[sos-n=1000]
                  time (s)          peak (KiB) 
 name                 mean   │            mean 
───────────────────────────────────────────────
 (legacy)   0.04588 (1.10)   │   402.33 (1.00) 
 (v1)        0.04172 (1.0)   │    402.30 (1.0) 

benchmarks/drivers/test_build.py::test_build[sos-n=10]
                  time (s)        peak (KiB) 
 name                 mean   │          mean 
─────────────────────────────────────────────
 (legacy)   0.04563 (1.10)   │   3.19 (1.19) 
 (v1)        0.04144 (1.0)   │    2.69 (1.0) 

benchmarks/drivers/test_build.py::test_build[sparse_network-n=10]
                  time (s)         peak (KiB) 
 name                 mean   │           mean 
──────────────────────────────────────────────
 (legacy)   0.04966 (1.06)   │   29.00 (1.54) 
 (v1)        0.04699 (1.0)   │    18.84 (1.0) 

benchmarks/drivers/test_build.py::test_build[sparse_network-n=250]
                  time (s)         peak (MiB) 
 name                 mean   │           mean 
──────────────────────────────────────────────
 (legacy)   0.05996 (1.10)   │   37.95 (1.43) 
 (v1)        0.05441 (1.0)   │    26.51 (1.0) 

benchmarks/drivers/test_build.py::test_build[storage-n=10]
                  time (s)          peak (KiB) 
 name                 mean   │            mean 
───────────────────────────────────────────────
 (legacy)    0.09378 (1.0)   │    410.93 (1.0) 
 (v1)       0.09832 (1.05)   │   427.84 (1.04) 

benchmarks/drivers/test_build.py::test_build[storage-n=250]
                 time (s)         peak (MiB) 
 name                mean   │           mean 
─────────────────────────────────────────────
 (legacy)    0.1003 (1.0)   │     9.94 (1.0) 
 (v1)       0.1053 (1.05)   │   10.22 (1.03) 

📊 Interactive plots + CSV: download the semantics-report-v1-vs-legacy artifact from this run.

Report-only · not a gate · refreshed on every push · obsolete once legacy is dropped.

@FabianHofmann
FabianHofmann merged commit 0a33c37 into master Sep 25, 2026
21 of 22 checks passed
@FabianHofmann
FabianHofmann deleted the fix/copy-frozen-constraints branch September 25, 2026 06:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working sparse Sparse / CSR-backed expressions and constraints

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Model.copy() turns frozen CSRConstraints into dense Constraints

1 participant