Skip to content

feat(api): a built model can be handed to pyomo as a ConcreteModel indexed by its labels - #1793

Draft
FBumann wants to merge 9 commits into
claude/festive-galileo-9481hqfrom
feat/pyomo-model
Draft

FBumann wants to merge 9 commits into
claude/festive-galileo-9481hqfrom
feat/pyomo-model

Conversation

@FBumann

@FBumann FBumann commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

Prompt: I meant create a pyomo.Model from mathspec, like linopy does it with a linopy.Model

Note

The following content was generated by AI.

Model.to_pyomo(rename=None) returns a pyomo.environ.ConcreteModel. Each variable becomes a Var, each constraint a Constraint and each sos: set an SOSConstraint. Each keeps its declared name and is indexed by the labels the build produced (m.p[0, 'wind']). Model.check('pyomo', rename=...) refuses what the export refuses, without building it. It needs the new [pyomo] extra, which requires pyomo 6.8 or later.

Naming was set by a second prompt: "Maybe we should expose a rename dict and raise otherwise? It should be predictable and deterministic!" The export renames nothing by itself. A clash is refused, and the error suggests a rename. The check was set by a third: "Maybe rename= should be added to check() instead??? We dont want to change specs for pyomo i think. But we also dont want much complexity...?"

Stacked on #1795

The base is claude/festive-galileo-9481hq, the named LP/MPS files in #1795, which lands first. This PR adds Declared beside Handoff. Declared records which declaration owns each column, row and set, and at which coordinate. A sink cannot read the engine's BuiltModel, so the export reads Declared instead. The branch takes #1795 by merge commits, with no rebase.

Names: exact, or renamed by the caller

A pyomo model has one namespace. Before it adds any component, the export decides every name: the declared name, or the name rename gives. It refuses the export if a name:

  • is taken by an earlier declaration. mathspec lets a variable and a constraint share a name, and three ports do: start_up and shut_down in pypsa_min_up_down, pypsa_unit_commitment and pypsa_linearized_uc. The later declaration, in the order variables → constraints → sos, is the one to rename.
  • is rc or slack. pyomo's solver interfaces load results into components with these names. mathspec already reserves dual.
  • is an attribute that every ConcreteModel has, such as write, name or component.
  • is objective, when the model has an objective.

One SpecsolveError lists every clash. It also suggests a rename: the caller's own rename, plus <name>_<kind> for each clash, or <name>_<kind>_2 and up when that name is in use too. Paste it back to export:

a pyomo model has one namespace, and 2 component name(s) cannot be used: constraint 'start_up' takes the name of variable 'start_up'; constraint 'shut_down' takes the name of variable 'shut_down'. Pass rename={'constraints': {'start_up': 'start_up_constraint', 'shut_down': 'shut_down_constraint'}} to name them apart.

The export also validates rename:

  • A section other than variables, constraints or sos is refused with a "did you mean".
  • A name that is not a declaration of that section is refused with a "did you mean".
  • A new name that still clashes is refused.
The check
  • One function refuses names: pyomo.component_names(declared, rename, objective=...). The engine's check('pyomo', rename=...) and to_pyomo both call it, so the messages are the same; a test asserts they are equal.
  • pyomo is a sink in the table: EXPORTS = {'pyomo': PYOMO_CAPABILITIES} beside SOLVERS and WRITERS, every capability native. Another sink's refusal now lists pyomo among the sinks that take the model, and an unknown sink name lists pyomo among the names there are.
  • rename for any other sink is refused: rename= is read by pyomo, not by '.lp'.
  • pyomo must be installed for the check: its reserved names are the attributes of its own ConcreteModel, so check('pyomo') without the extra refuses with the extra's message, as to_pyomo does. A solver's check needs no package.
  • to_pyomo does not call check itself: pyomo's table refuses no construct, so the only refusal is component_names, which the export calls anyway. A call to check there would be a guard the suite cannot reach.
What the model holds
  • Index: the coordinates the build made. A coordinate that a where removed has no entry, and a row the build dropped is not in the component. One dim indexes by the bare label, more dims by a tuple, and a dimensionless declaration is a scalar component. A label keeps its type: int, str and datetime stay as they are.
  • Empty scalars: two cases give an empty component: a dimensionless constraint whose only row the build dropped, and a set ordered along the only dim of a fully masked variable.
  • Rows: the build's numbers as a flat LinearExpression, plus quadratic terms. This is the same structure that a linopy.Model holds. LinearExpression keeps a row whose coefficients are all zero as a relation. A plain sum would let pyomo reduce 0 >= 10 to a Python bool.
  • SOS: one SOSConstraint per declaration, indexed by the members' coordinate without the ordering dim. If the ordering dim is the variable's only dim, the declaration is one scalar set.
  • Objective: m.objective. A spec without an objective gets none.
  • Structure: sinks/pyomo.py reads the handoff and Declared. pyomo is imported only when to_pyomo() or check('pyomo') is called, so a bare install stays pyomo-free.
  • Floor: pyomo>=6.8, in the extra and in the dev group. Pyomo 6.7 reads np.float_, which NumPy 2 removed: 6.7.0, 6.7.2 and 6.7.3 fail at import, and 6.7.1 fails 54 of the new tests.
Verification
  • 94a6179: pixi run check (4756 passed, 522 skipped, 1 xfailed). pixi run docs-build ran on f50d74c, before the rebase onto main and the check, and was not run again.
  • tests/test_pyomo_model.py, all passing with Gurobi present:
    • Every linear fixture reaches the engine's optimum through appsi_highs.
    • Both quadratic positions reach the Gurobi sink's optimum through gurobi_direct.
    • Every port reaches its published objective to 1e-6. The three unit-commitment ports pass the suggested rename.
    • Every row holds the terms, bound and sense that Model.row reads at that coordinate, and the component has no extra rows.
    • Naming tests: one error lists every clash; the suggestion, passed back, exports and solves; the suggestion keeps the caller's rename and skips a name that is in use (x_constraint → x_constraint_2); each reserved kind of name is refused; objective is free when the model has no objective; an invalid rename is refused.
    • Check tests: check('pyomo') refuses a clash in the export's words; it takes the suggested rename; a model with SOS checks clean for pyomo while check('highs') names pyomo as a taker; rename for .lp is refused; without pyomo, both the export and its check name the extra.
  • Mutation table (full suite each time; the tree came back clean after every row). The last two rows were taken on the check commit; the rest earlier, on guards this change moved but did not alter:
mutation result
an unknown rename section caught
an unknown declaration in a rename caught
a clash is recorded caught
clashes are refused caught
a name two declarations take caught
a result-suffix name caught
a ConcreteModel attribute name caught
the suggestion skips a name in use caught
objective always reserved (by hand) caught
objective never reserved (by hand) caught
the missing-extra message (by hand) caught
a plain sum in place of LinearExpression (by hand) caught: the all-zero row
an objective added for a feasibility problem (by hand) caught
a declaration's sets not narrowed to its variable's columns caught by the purpose-built probe
a missing constraint row read as None, not Skip caught
a missing set read as None, not Skip caught
a scalar keyed by (), not None caught
the rename refusal for a sink other than pyomo (tools.mutate, engine.py:150-151) caught
the pyomo name check in check (tools.mutate, engine.py:154-155) caught
Not done
  • No mutable Params: new data means a new export. Model.update rebuilds specsolve's side, and nothing updates an exported pyomo model in place.
  • No Expression components: expressions: entries do not become pyomo Expressions, and the export adds no dimension Sets. Rows are flat, as in linopy.
  • No timing: the export has not been timed.
  • Solvers compared: each fixture is compared against one solver, HiGHS (or Gurobi for quadratic models), not against every specsolve sink.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Rf94H9iybapukHJU5KaB7r
https://claude.ai/code/session_01CM4oeTkzkk5G5zE6zgikSE
https://claude.ai/code/session_01V3XyQpbHm6dhHbSkGcvxTb
https://claude.ai/code/session_017HhMcpuGcaXgttYMf9Jg2q

@codspeed

codspeed Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

Merging this PR will regress 1 benchmark

2 warnings

⚠️ 15 benchmarks with a significant change ran on different hardware

Their base and head results were measured on machines with a different CPU, core count or memory, so the changes might not come from the code. Run base and head on the same runner type.

Learn more about Macro Runners

See affected benchmarks

⚠️ 15 benchmarks with a significant change ran in a different software environment

Their base and head results were measured with a different OS or toolchain, such as compiler, runtime or library versions, so the changes might come from this difference rather than from the code. Open an affected benchmark to see what changed.

See affected benchmarks

⚡ 14 improved benchmarks
❌ 1 regressed benchmark
✅ 78 untouched benchmarks
⏩ 307 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
❌ test_read[fleet-s-specsolve-parquet] 955 KB 1,121.7 KB -14.86%
⚡ test_read[commitment-s-specsolve-parquet] 688.5 KB 220.7 KB ×3.1
⚡ test_read[sector-s-specsolve-parquet] 935.6 KB 545.9 KB +71.37%
⚡ test_read[storage-s-specsolve-parquet] 2 MB 1.4 MB +42.76%
⚡ test_emit[fleet-s-specsolve-lp] 31.6 MB 25.6 MB +23.31%
⚡ test_emit[fleet-s-specsolve-in-memory-lp] 31.6 MB 25.6 MB +23.31%
⚡ test_emit[commitment-s-specsolve-lp] 3.2 MB 2.7 MB +21.87%
⚡ test_emit[commitment-s-specsolve-in-memory-lp] 3.2 MB 2.7 MB +21.86%
⚡ test_emit[milp-s-specsolve-in-memory-lp] 47.8 MB 39.8 MB +19.95%
⚡ test_emit[milp-s-specsolve-lp] 47.8 MB 39.9 MB +19.67%
⚡ test_emit[nodal-s-specsolve-lp] 6.8 MB 5.7 MB +19.03%
⚡ test_emit[nodal-s-specsolve-in-memory-lp] 6.9 MB 5.8 MB +18.96%
⚡ test_emit[storage-s-specsolve-lp] 24.5 MB 20.8 MB +17.83%
⚡ test_emit[storage-s-specsolve-in-memory-lp] 24.5 MB 20.8 MB +17.83%
⚡ test_read[nodal-s-specsolve-parquet] 1.6 MB 1.5 MB +11.1%

Tip

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


Comparing feat/pyomo-model (9671da2) with claude/festive-galileo-9481hq (7d83e70)

Open in CodSpeed

Footnotes

  1. 307 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. ↩

FBumann pushed a commit that referenced this pull request Sep 30, 2026
The two branches both knew which declaration owns which column and row
at which coordinate. Merged, `file_names` reads `readback.declared`, the
one home #1793 puts beside the handoff, and keeps its label cleaning as
polars expressions over each run's coordinate frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CM4oeTkzkk5G5zE6zgikSE
@FBumann
FBumann added this pull request to stack #1794 September 30, 2026 10:57
@FBumann
FBumann removed this pull request from stack #1794 September 30, 2026 14:13
@FBumann
FBumann changed the base branch from main to claude/festive-galileo-9481hq September 30, 2026 14:22
@FBumann
FBumann added this pull request to stack #1796 September 30, 2026 14:32
…dexed by its labels

Rebased onto the rebased #1795 as one commit: the branch's net diff
over its old base, with the changelog and Model's methods kept from
both sides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017HhMcpuGcaXgttYMf9Jg2q
FBumann pushed a commit that referenced this pull request Oct 2, 2026
…mes and dimensions

Rebased onto the rebased #1793 as one commit: the branch's net diff
over its old base, without the sinks import that only the retired
check(sink=) used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017HhMcpuGcaXgttYMf9Jg2q
claude added 6 commits October 2, 2026 09:52
…and pyomo is named among the sinks that take a model

to_pyomo asks the check first, so the clash refusal and its suggested
rename have one path. The pyomo capability table sits in EXPORTS beside
SOLVERS and WRITERS, so another sink's refusal names pyomo as a taker.
check refuses rename= for any sink but pyomo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017HhMcpuGcaXgttYMf9Jg2q
# Conflicts:
#	src/specsolve/api.py
#	src/specsolve/relational/engines/polars/engine.py
@FBumann FBumann added the feature New capability label Nov 11, 2026 — with Claude

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New capability

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants