Repository navigation
feat(sinks): gurobi, and the two families it revealed - #418
Conversation
📝 WalkthroughWalkthroughThe change adds an optional Gurobi solver sink, shared dense model projections, and ChangesSolver expansion
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant API as lpspec.api.solve
participant Executor as PolarsExecutor.solve
participant Registry as solver
participant Gurobi as solve_gurobi
participant Tables as ModelTables
API->>Executor: Forward solver_name and solver_options
Executor->>Registry: Look up selected solver
Registry-->>Executor: Return solve_gurobi
Executor->>Gurobi: Pass ModelTables and solver options
Gurobi->>Tables: Read dense solver projections
Gurobi-->>Executor: Return status, objective, primal, and dual results
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (5)
src/lpspec/relational/sinks/gurobi.py (2)
228-243: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick winValidate the concatenated dual length against
row_count.
_labelledassigns row labels fromnp.arange(len(values)), so the dual frame is correct only if the blocks tile0..row_count-1exactly._dualscannot seerow_count, so a tiling gap inrow_chunks_by_nonzeroswould shift every dual label after the gap and stay silent. The HiGHS sink does not carry this exposure, because it passesmodel.row_countto its own_labelled(src/lpspec/relational/sinks/highs.pyLine 173).Pass
model.row_countinto_dualsand assert the concatenated length.♻️ Proposed change
-def _duals(blocks: list[Any]) -> pl.DataFrame | None: +def _duals(blocks: list[Any], row_count: int) -> pl.DataFrame | None: @@ try: values = [block.Pi for block in blocks] except (AttributeError, gurobipy.GurobiError): return None - return _labelled('row', np.concatenate(values) if values else np.empty(0, dtype=np.float64)) + duals = np.concatenate(values) if values else np.empty(0, dtype=np.float64) + if len(duals) != row_count: + msg = f'gurobi returned {len(duals)} duals for {row_count} rows' + raise AssertionError(msg) + return _labelled('row', duals)Update the call site at Line 129 to
_duals(blocks, model.row_count).🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/lpspec/relational/sinks/gurobi.py` around lines 228 - 243, Update `_duals` to accept `row_count`, and update its call site to pass `model.row_count`. After concatenating the block dual values, validate that their length equals `row_count` before calling `_labelled`; preserve the existing `None` behavior when duals are unavailable.
122-129: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winDispose the Gurobi model and environment after solving.
_load()creates an explicitgurobipy.Envandgurobipy.Model(env=...), butsolve_gurobi()returns afteroptimize()without deterministic cleanup. With remote/token licenses, the license remains held until GC collects the model; the size-limited wheel license does not show this behavior. The dependency constraint isgurobipy>=11, where the Python context-manager protocol is supported. Keepbuild_gurobi()unchanged because the caller owns the returned model.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/lpspec/relational/sinks/gurobi.py` around lines 122 - 129, Update solve_gurobi() to deterministically dispose of the model and its explicitly created environment after optimize(), including the early return for unreadable status, using gurobipy>=11 context-manager support; leave build_gurobi() unchanged because its caller owns the returned model.src/lpspec/relational/sinks/tables.py (1)
68-68: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAnnotate the NumPy arrays instead of using
Any.numpy is a base runtime dependency, so
numpy.typingcan be imported underTYPE_CHECKINGand the four vectors can carry real types. The currentAnyreturn hides the element type from every caller, including the Gurobi sink, which passesintegraltonp.where.Note: the guideline "Keep pyrefly strict checking at zero errors; fix types rather than widening them" applies here. As per coding guidelines.♻️ Proposed typing change
if TYPE_CHECKING: + import numpy as np + import numpy.typing as npt- def dense_columns(self, infinity: float) -> tuple[Any, Any, Any, Any]: + def dense_columns( + self, infinity: float + ) -> tuple[npt.NDArray[np.float64], npt.NDArray[np.float64], npt.NDArray[np.float64], npt.NDArray[np.bool_]]:-def _scattered(count: int, at: Any, values: Any, absent: Any) -> Any: +def _scattered(count: int, at: npt.NDArray[Any], values: npt.NDArray[_T], absent: object) -> npt.NDArray[_T]:Also applies to: 109-109
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/lpspec/relational/sinks/tables.py` at line 68, Update the dense_columns return annotation and its related typing imports to use numpy.typing array types for all four vectors instead of Any, importing the NumPy typing module under TYPE_CHECKING as needed. Preserve the existing runtime behavior while exposing the vectors’ element types to callers such as the Gurobi sink and keeping strict type checking error-free.Source: Coding guidelines
tests/test_solve_status.py (1)
118-144: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winLet this helper serve the HiGHS test too.
_linopy_condition_maprepeats the AST walk thattest_the_highs_mapping_matches_linopyperforms inline at Lines 63-85. The only difference is the node attribute read from each key and value pair: the HiGHS map usesast.Attribute(.attr), and the Gurobi map usesast.Constant(.value). Both tests state that they are brittle on purpose, so the two copies also rot in two places.Parametrize the helper by the node type it expects, then call it from both tests.
♻️ Proposed shape
-def _linopy_condition_map(solver: str) -> dict[int, str]: +def _linopy_condition_map(solver: str, keyed_by: type[ast.expr], attribute: str) -> dict[Any, str]: @@ return { - key.value: value.value + getattr(key, attribute): getattr(value, attribute) for key, value in zip(literals[0].keys, literals[0].values, strict=True) - if isinstance(key, ast.Constant) and isinstance(value, ast.Constant) + if isinstance(key, keyed_by) and isinstance(value, keyed_by) }
test_the_highs_mapping_matches_linopythen reads_linopy_condition_map('Highs', ast.Attribute, 'attr'), and the Gurobi test reads_linopy_condition_map('Gurobi', ast.Constant, 'value').🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/test_solve_status.py` around lines 118 - 144, Generalize _linopy_condition_map to accept the expected AST key/value node type and attribute name, then move its existing literal extraction and mapping logic into that parameterized helper. Replace the inline AST walk in test_the_highs_mapping_matches_linopy with _linopy_condition_map('Highs', ast.Attribute, 'attr'), and call it for Gurobi with _linopy_condition_map('Gurobi', ast.Constant, 'value'), preserving each test’s existing assertions.tests/test_gurobi_sink.py (1)
199-205: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winThis test does not need gurobipy, and the module-level skip hides it.
Line 27 skips the whole module when gurobipy is absent.
test_the_set_of_solver_sinks_is_closedasserts registry behavior only: an unknownsolver_nameraises, andSOLVERSholds exactly two names. Neither assertion loads Gurobi. On a contributor machine or a CI job without the[gurobi]extra, the closed-set contract therefore goes unchecked.Move this test to the registry test module, so it runs in every configuration.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/test_gurobi_sink.py` around lines 199 - 205, Move test_the_set_of_solver_sinks_is_closed from tests/test_gurobi_sink.py into the registry test module, preserving its unknown-solver exception assertion and exact sinks.SOLVERS membership check. Ensure it is no longer covered by the module-level gurobipy skip and runs without the Gurobi extra.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/api.md`:
- Line 21: Update the `lps.solve` example on the Gurobi line to assign its
return value to the result variable, matching the preceding HiGHS invocation so
subsequent `result.*` expressions use the Gurobi result.
In `@src/lpspec/relational/sinks/__init__.py`:
- Around line 33-59: Update the solver registry near SOLVERS by storing the
entries in a private mutable dictionary, then expose SOLVERS as a read-only
MappingProxyType view of that registry. Ensure solver(name) continues resolving
through SOLVERS so the exported mapping cannot be modified while preserving the
existing solver entries and behavior.
In `@src/lpspec/relational/sinks/README.md`:
- Around line 49-52: Update the sink authoring guide’s solver return contract to
document the required four-tuple: status, objective, primal values, and dual
values. Keep the writer sink’s None behavior unchanged, and align the solver
wording with the SOLVERS registry and PolarsExecutor.solve() unpacking contract.
---
Nitpick comments:
In `@src/lpspec/relational/sinks/gurobi.py`:
- Around line 228-243: Update `_duals` to accept `row_count`, and update its
call site to pass `model.row_count`. After concatenating the block dual values,
validate that their length equals `row_count` before calling `_labelled`;
preserve the existing `None` behavior when duals are unavailable.
- Around line 122-129: Update solve_gurobi() to deterministically dispose of the
model and its explicitly created environment after optimize(), including the
early return for unreadable status, using gurobipy>=11 context-manager support;
leave build_gurobi() unchanged because its caller owns the returned model.
In `@src/lpspec/relational/sinks/tables.py`:
- Line 68: Update the dense_columns return annotation and its related typing
imports to use numpy.typing array types for all four vectors instead of Any,
importing the NumPy typing module under TYPE_CHECKING as needed. Preserve the
existing runtime behavior while exposing the vectors’ element types to callers
such as the Gurobi sink and keeping strict type checking error-free.
In `@tests/test_gurobi_sink.py`:
- Around line 199-205: Move test_the_set_of_solver_sinks_is_closed from
tests/test_gurobi_sink.py into the registry test module, preserving its
unknown-solver exception assertion and exact sinks.SOLVERS membership check.
Ensure it is no longer covered by the module-level gurobipy skip and runs
without the Gurobi extra.
In `@tests/test_solve_status.py`:
- Around line 118-144: Generalize _linopy_condition_map to accept the expected
AST key/value node type and attribute name, then move its existing literal
extraction and mapping logic into that parameterized helper. Replace the inline
AST walk in test_the_highs_mapping_matches_linopy with
_linopy_condition_map('Highs', ast.Attribute, 'attr'), and call it for Gurobi
with _linopy_condition_map('Gurobi', ast.Constant, 'value'), preserving each
test’s existing assertions.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 8f5753c9-f78d-4314-b0f8-cac28f1314c0
📒 Files selected for processing (17)
CONTRIBUTING.mdREADME.mddocs/ARCHITECTURE.mddocs/ROADMAP.mddocs/SPEC.mddocs/api.mddocs/index.mdpyproject.tomlsrc/lpspec/api.pysrc/lpspec/relational/engines/polars/executor.pysrc/lpspec/relational/sinks/README.mdsrc/lpspec/relational/sinks/__init__.pysrc/lpspec/relational/sinks/gurobi.pysrc/lpspec/relational/sinks/highs.pysrc/lpspec/relational/sinks/tables.pytests/test_gurobi_sink.pytests/test_solve_status.py
| schema = lps.load_schema('model.yaml') # MathSchema | ||
|
|
||
| result = lps.solve('model.yaml', sources, solver_options={'time_limit': 60}) | ||
| lps.solve('model.yaml', sources, solver_name='gurobi') # the other solver sink |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Bind the Gurobi result in the example.
Line 20 stores the HiGHS result. Line 21 discards the Gurobi result. The following result.* expressions therefore still refer to HiGHS.
-lps.solve('model.yaml', sources, solver_name='gurobi') # the other solver sink
+result = lps.solve('model.yaml', sources, solver_name='gurobi')📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| lps.solve('model.yaml', sources, solver_name='gurobi') # the other solver sink | |
| result = lps.solve('model.yaml', sources, solver_name='gurobi') |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/api.md` at line 21, Update the `lps.solve` example on the Gurobi line to
assign its return value to the result variable, matching the preceding HiGHS
invocation so subsequent `result.*` expressions use the Gurobi result.
A second solver sink reading the same ModelTables as HiGHS: dense column vectors and CSR row blocks, no float→text→parse round trip. `solver_name` at the call picks between them — a caller's choice, never a file's, since no YAML key names a solver and a model means the same thing either way. The column vectors move onto ModelTables so the two sinks cannot drift into loading different models; the hand-off code stays separate, because gurobipy takes a matrix object where HiGHS takes the three CSR arrays. That is what the scipy in the [gurobi] extra buys. The condition map is linopy's, minus three entries it gets wrong against Gurobi's own status codes — declared, and pinned in both directions. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A sink was a flat bag whose members happened to be two different shapes, and the naming still said `solver_direct`: a mechanism, when the thing that actually varies is which solver. So the taxonomy is now the tree. A solver runs the tables and returns an answer, is chosen by name, and lives in `solvers/<name>.py` — one module per solver, named for it, with `solve_<name>`, a `build_<name>` seam and a line in `SOLVERS`. A writer renders them to a file, is chosen by the output's suffix, and lives in `writers/`. Both registries are closed dict literals, and a test reads the shape off the path, so adding a solver is a module and a line with nothing above it to teach. `api.write` stops branching on suffix and asks the writer family, which is also where "planned" now lives — `.mps` answers for itself. Renamed rather than aliased, per CONTRIBUTING: `ex.write_lp(path)` is `ex.write(path)`, the suffix picking the writer the way `lps.write` already did, and `solve_direct` is `solve_highs` in `sinks.solvers.highs`.
An alias kept "so an existing except keeps catching what it used to" is a deprecation cycle, and CONTRIBUTING says this project does not run one while it is 0.0.1aN. The engine's failures are LanguageError and DataError, both under LpspecError; catch those.
The gurobi PR argued the same point in five places — the family split in two module docstrings, the README, ARCHITECTURE and api.md; the caller- chooses-the-solver rule in six. Reference pages carry rules, design notes carry arguments (CLAUDE.md), so each now lives in one layer and the rest point at it. No behaviour, no test coverage, ~120 lines of prose. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ec37479 to
fb16628
Compare
Five review findings, verified against the tree: - `solve_gurobi` disposes the model and its environment in a `finally`, so a licence token is held for the solve rather than until whatever frees the last reference. gurobipy has no `Model.getEnv()`, so `_load` hands the environment back. `build_gurobi` keeps its model — its caller owns it. - `_duals` takes `row_count` and refuses a vector that does not cover every row. The read-back join is positional, so a short one would drop rows in silence. - `dense_columns` says what it returns: three float vectors and a bool mask, not four `Any`. - `api.solve` refuses an unknown solver *before* the build, for the reason `write` checks the suffix first. - The closed-solver-set test moves to `test_api.py`, out of the module that skips without the extra — the set is the package's property, not gurobi's — and one reader now serves both linopy condition maps. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A licence parameter — WLSAccessID, ComputeServer, TokenServer — can only be set before an environment starts; `setParam` on the model refuses it with "unable to modify parameter after environment started". Applying options to the model therefore locked out every Compute-Server and WLS user, which is the whole population that does not solve on a local node licence. Nothing else moves: an environment's parameters are the defaults of the models built on it, an unknown name still raises at the same point, and OutputFlag leads so a caller can put the log back by passing their own. Checked against linopy, which reached the same place: it takes `env=` as a dict of parameters for exactly this, and disposes model-before-env while leaving a user-supplied environment alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`build_gurobi` hands the caller a model whose environment holds a licence, and gurobipy has no `Model.getEnv()` — so the caller had no way to release it and nothing was going to. A finalizer disposes the environment when the model is collected, which under refcounting is when the caller drops it: one thing to own rather than two, and no pair to return. That closes the licence story for both paths without a solver handle: one-shot solves dispose in a `finally`, a handed-over model disposes on drop. What still needs a handle is sharing one environment across many solves — a token-server round trip per solve today — and warm starts. Filed as a follow-up. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Licence/environment handling is now closed for both paths without a solver handle, after checking how linopy does it:
What genuinely needs a handle — one environment shared across many solves (a token-server round trip per solve today) and warm starts / in-place re-solve — is filed as #424, with linopy's |
#418 split `sinks/` into `solvers/` (highs, gurobi — chosen by name) and `writers/` (lp_file — chosen by suffix), dropped `write_lp_file`, `solve_direct` and `build_highs` from the package's exports, renamed `write_lp` to `write`, and gave `ModelTables` a `dense_columns` projection. The conflict is at the seam this branch exists to create. main added `write` and `solve(solver_name=...)` to the polars executor; here they live on `Engine`. So main's 47 lines of executor change become ~10 on the base class — and **the duckdb engine gets gurobi and writer-by-suffix without a line of its own**, which is the claim `engines/` makes, paying out on a feature written after it and unaware of it: LPSPEC_ENGINE=duckdb solvers available: ['gurobi', 'highs'] writers: ['.lp'] planned: ['.mps'] ex.write(path) dispatches on the suffix; .mps names itself as planned ex.solve(solver_name='highs') -> 3.0 `tables.py` took both sides: main's `dense_columns` and `DenseColumns`, and this branch's `COLS`/`OBJ`/`ROWS`/`MATRIX`/`DTYPES`/`VTYPE`. They answer different questions about the same contract and neither replaces the other. Also followed rather than resurrected: `RelationalBuildError`, which main deleted under #420's "breaking changes are free while we are 0.0.1aN", and which the merge would otherwise have brought back without its import. And `test_lp_text`'s second build — added by #404 on this branch — called `write_lp`, the verb main had just renamed. 765 passed on polars, 764 passed / 5 skipped on duckdb, ruff + pyrefly (0 errors) + mkdocs --strict clean.
…ug made (#441) * docs(bench): re-measure the published tables, and reverse a conclusion The committed results were taken at `ef8558c` — version `0.0.0a27`, before the package was even renamed. Eight perf changes have landed since (#408, #412, #413, #414, #415, #434, #421, #433), so every table on the page understated the engine, and one paragraph drew a conclusion the numbers no longer support. Re-run in one sitting on an idle machine: the ladder, the density sweep and the scaling run, so all three files describe the same tree. Parity gate passes on all six cases (`fleet` to 4.6e-16) before anything is timed. **What moved**, against the previously published numbers, `l` rung, our arm: wall -10% to -39% on nine of twelve rungs, peak -2% to -21% on nine. **What got worse, and is not buried.** `dispatch` build is 10-12% slower — that is `maintain_order='left'` from #433, which buys -21% peak on the same rung and is the trade that PR argued for. `fleet/highs` peak is up 8.2%, outside its 1.0% floor, and I have no explanation for it. **A conclusion is reversed.** `## Absurd sizes` read 1.07x peak at 120M variables and concluded that our memory headroom "is a small-and-sparse-model property, not a scaling one". Peak is now 0.73-0.83x across four orders of magnitude. Two things moved it and only one is us: #433 took ~20% off this case, and the earlier reading came from a run *that section itself flagged as noisy* — it recorded linopy at 38.5 s and 106.2 s for the same rung. The old number was a conclusion drawn across the noise floor. The section now says so rather than quietly presenting a better one. **`profiled` is no longer the case in the ladder we lose** (1.09x -> 0.74x through the highs sink), and `fleet/lp` has crossed on wall (1.03x -> 0.90x). Both paragraphs are rewritten to say what changed rather than to delete the sentences that admitted the losses. Two numbers still go against us and are still named: `profiled` 1.31x on wall through the LP sink, `fleet` 1.47x on peak. Not touched: `## Sink capabilities` still says Gurobi's row wants a spike before it is relied on. #418 shipped a real Gurobi sink since, so that caveat is probably obsolete — but exercising it is not this change, and asserting capabilities I have not run is how that table would come to lie. * docs(bench): re-measure at 98f382d, and correct a claim the harness bug made Every number on the page now comes from one run at `98f382d` on a clean tree, and the page says so — the fingerprint hash is what makes a published number traceable to the code that produced it, and the previous one recorded `782f76b-dirty`, which identifies nothing. **The `highs` comparison was not like for like, and it flattered us.** #438 found that linopy's `to_highspy()` was being timed *with* variable naming, which neither of our solver sinks does — 82% of its hand-off, and memory as well as time. Corrected: highs sink, l rung was now dispatch peak 0.87x 1.46x fleet peak 0.76x 1.10x profiled peak 0.82x 1.27x transport peak 0.75x 1.10x So the summary changes from "ahead on both, on all six" to **ahead on wall on five of six, and behind on peak on four of six through `highs`**. HiGHS's own dense model dominates both arms there and our COO triples are what is left. The `lp` sink barely moved, because that comparison was already like for like, and it is where the representation actually pays: ahead on peak on four of six. `profiled` is the case in the ladder we lose, on both sinks (1.27x, 1.07x). Said plainly rather than left to the tables. The scaling section's reversal stands and is re-measured: peak 0.68-0.78x from 10k to 120M variables, against the 1.07x this page used to conclude the advantage "runs out" from. That section now also says it is the LP sink, and therefore not evidence for the `highs` peak row.
… the head of energy-models/mathspec#421 (#1565) > **Prompt:** Lets check if lpspec needs to adjust anything after we merge the stack below energy-models/mathspec#421 — Do a Draft pr pinning the head of #421 > [!NOTE] > The following content was generated by AI. The pin is now the release tag `v0.0.0-alpha.82`, which carries the whole five-PR stack for math-spec's issue #401 (#415, #416, #418, #420, #421 — the last squash commit, a2dcee7, is an ancestor of the tag). It replaces the commit `37f8b5136f8433a56653c1954d7ffe770902dd40`, the head of [energy-models/mathspec#421](energy-models/mathspec#421), that this PR pinned while no release contained the stack. lpspec needs no code change. Nothing it imports from math-spec moved; the one consumer-visible change is that math-spec's Markdown typesetter now escapes a `description:` the way its LaTeX and Typst formats already did (`_`, `[`, `]`, `*` set as characters, a name in backticks as a code span). So this PR is: - the pin, in `pyproject.toml` and the three math-spec lines of `uv.lock` - the regenerated pages (`tools.gallery_math`, `tools.ladder`): every changed line is an escape such as `'bp\_x'` or `\[0, 1\]` - two descriptions written plain, in `examples/ports/pypsa_cvar.yaml` and `examples/ports/pypsa_losses.yaml`, because their `*and*` now sets as literal asterisks (and never set as emphasis in LaTeX or Typst); the same two lines inside the YAML block the gallery pages embed verbatim, which no tool writes Against the commit pin the full suite on `main` at 2928e08 was 3754 passed, 251 skipped, 1 xfailed, 2 failed, the two failures being the stale generated pages this PR regenerates. <details><summary>Verified at the tag</summary> - `uv lock` resolves math-spec to `v0.0.0a82` (commit 140ec94e); locked with uv 0.12.11 so the lock diff is the three math-spec lines only (older uv versions rewrite marker annotations across the file) - `uv sync --all-extras` - `uv run python -m tools.gallery_math`, `uv run python -m tools.ladder`, `uv run python -m tools.constructs`: no page changed against the commit pin, so the tag's tree is the pinned commit's tree for everything lpspec renders - `uv run pytest -q -n auto tests/test_models_gallery.py tests/test_pypsa_ladder_page.py tests/test_typeset_gallery.py tests/test_docs_site.py tests/test_ports.py`: 612 passed, 14 skipped (the typst binary, and the MILP ports that record no duals) - `ruff check .`, `ruff format --check .`, `pyrefly check` on both configs: clean - Left alone on purpose: the `*and*` in the comment of `examples/symbols/transport.yaml` and the bold sentence in the hand-written prose of `docs/examples/pypsa_losses.md`, neither being a description - Not run here: the full suite under pixi, and the typst-backed typeset tests </details> <details><summary>The next math-spec release needs a follow-up in lpspec</summary> math-spec's `main` already carries #427 (`refactor(program): a variable's domain is called domain in the program`), which renames `VariableDeclaration.variable_type` to `domain`. It is **not** in alpha.82, so this pin is unaffected. lpspec reads `variable_type` in `src/lpspec/linopy/builder.py` at three sites (two in the variable attach, one in the discrete-variable listing before the solve), and the next math-spec release will need a follow-up in lpspec that renames those reads. Not done here. </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_019d4fAY3RikKY317uerZ2Hc https://claude.ai/code/session_017tCiQ1MTt4jG8X3qNEGNvZ --------- Co-authored-by: Claude <noreply@anthropic.com>
A second solver, the reorganisation it argued for, and the licence handling it
turned out to need.
1. The sink
A second solver reading the same
ModelTablesas HiGHS: dense column vectorsin one
addMVar, the matrix in CSR blocks chunked by nonzeros. Nofloat→text→parse round trip.
scipy is in the
[gurobi]extra, and is not a transitive convenience.gurobipy's matrix API takes a matrix object where HiGHS takes the three CSR
arrays, so
scipy.sparse.csr_matrixover the arrays the labels already laidout is the only way to hand over a block of rows without one Python call per
row.
The condition map is linopy's, minus three entries it gets wrong. linopy
calls
SOLUTION_LIMIToptimal andWORK_LIMIT/MEM_LIMITinternal_solver_error; all three contradict Gurobi's documented status codes,
and the last also contradicts linopy's own HiGHS map, which sends
kMemoryLimittoresource_interrupt. Each is declared in_LINOPY_DIVERGENCESwith its reason, andtest_solve_status.pypins bothdirections — everything else still matches linopy, and every declared
divergence is still one, so if linopy fixes it the test fails and the exception
goes away.
2. Two families
The second solver made the flat
sinks/bag read wrong: its members are twodifferent shapes, and the naming still said
solver_direct— a mechanism, whenwhat varies is which solver. How many solvers there are will change; what a
solver has to answer will not. So the taxonomy is the tree:
A solver is four things that must agree — module named for the solver,
solve_<name>, abuild_<name>seam forbench/, and theSOLVERSkey — andtests/test_architecture.pyreads all four off the path, plus "no sink reachesa sibling", which is what keeps
gurobipyoff the import path of a HiGHScaller. Adding a solver is a module and a line: no method on the executor, no
branch in
api.py, no name on the Python surface.api.writeasks the writerfamily instead of branching on suffix, and planned lives there too, so
.mpsanswers for itself.
Renamed rather than aliased, per CONTRIBUTING's breaking changes are free:
ex.write_lp(path)→ex.write(path),solve_direct→solve_highs, and theRelationalBuildErrordeprecation alias is gone.3. Licence and environment
Checked against linopy, which has iterated on this:
solve_gurobidisposes model then environment in afinally— their order,and their comment says it is required for the license to be released even
while user code still holds a solver_model reference.
build_gurobihands the model over with aweakref.finalizedisposing theenvironment when the model is dropped. gurobipy has no
Model.getEnv(), so acaller given only the model could not release it at all; linopy's equivalent
case leaves it to gurobipy to free whenever.
Env(params=...), notsetParam.WLSAccessID/ComputeServer/TokenServercan only be set before an environment starts— applying options to the model locked out every Compute-Server and WLS user.
What needs a solver handle rather than a finalizer — one environment across
many solves, warm starts, in-place re-solve — is #424.
Evidence
tests/test_gurobi_sink.py, skipped without gurobipy: HiGHS is the oracle forobjective, primal and duals (including under
maximize, where a signconvention could differ and nothing else would notice), all 11 ported models
reach their published optima on gurobi, plus ragged block boundaries, the
MIP-has-no-duals path, options landing on the environment, the build seam, and
that nothing keeps a built model alive. Two architecture tests pin the
families; the closed-solver-set test lives in
test_api.pyso it runs withoutthe extra.
gurobipy's wheel carries a size-limited licence, so
lpspec[gurobi]is in thedevgroup and CI exercises the sink with nobody's licence; verified green withgurobipy hidden as well.
Groundwork for #106, and Track 3's uneven capability table is now concrete
rather than hypothetical.
🤖 Generated with Claude Code