Skip to content

feat(api): the feasible region of a model on two of its quantities is traced as a polygon - #1527

Draft
FBumann wants to merge 2 commits into
mainfrom
claude/feasible-region-analysis-26xcox
Draft

FBumann wants to merge 2 commits into
mainfrom
claude/feasible-region-analysis-26xcox

Conversation

@FBumann

@FBumann FBumann commented Sep 4, 2026 •

Copy link
Copy Markdown
Collaborator

Prompt: Is it possible to analyze the feasible region of an optimization problem? […] I want to analyze if I modeled sth correctly. Like in a complex multi component power plant. […] What would we need to visualize the feasible region or sth like it? […] Draft pr this […] Add the plotting […] How are binaries etc handled? We should handle all combinations, individual regions […] I want a way of having everything as a long form dff maybe? […] We need a nice extensive notebook showing this feature in the docs […] I think this feature could be a bit more self explanatory. Especially the data frames! Rethink the feature […] I think the plots could be nicer. Plotly? […] I can't see the plots in the docs

Note

The following content was generated by AI.

sps.project(spec, sources, x='heat', y='power', at={'t': 5}) traces the region two named quantities can reach. It returns a Region of tidy frames and a plotly plot(). binaries='each' traces every binary combination as its own piece. docs/region.md walks it on a CHP plant.

frame columns
vertices (piece, vertex, x, y) every vertex of every piece, counter-clockwise; a free trace is piece 0
pieces (piece, variable, dims…, value) what each piece pinned, with the coordinate as typed columns; no rows when nothing was pinned
edges (piece, edge, kind, name, dims…, side) the variable bounds and constraint rows tight at both ends of an edge, at the coordinates at names
optimum (piece, x, y) where the model as written lands, and in which piece
hull (vertex, x, y) the region as one polygon; the hull of the pieces when they were traced apart

Every frame keeps its schema whether the binaries were free or traced apart. label(piece) spells a piece for a legend, such as running[chp]=1, running[boiler]=1, running[peaker]=0.

How it works

The probe. project adds ordinary declarations to the caller's spec: three scalar weights, two named expressions, and a one-hot selection parameter per axis where at fixes a coordinate. The objective becomes x_direction * x_axis + y_direction * y_axis + objective_weight * (the file's own), maximised. Four compass directions enclose the region. Then each hull edge is probed along its outward normal: a solve past the edge is a new vertex, a solve on it settles the edge. The trace ends when every edge is settled. All solves run on the fast path with keep='progress': one load, however many vertices. The optimum is one more solve with the directions at zero and the file's objective weighted ±1. A name the spec already declares is refused.

Edges. PolarsEngine.binding(result, tolerance) reads which bounds and rows a solve sits on, off the built model's handoff.cols and handoff.rows. An edge is bound by what is tight at both its ends. Rows at coordinates other than those at names are left out, and so are binaries.

Binaries. A binary makes the region a union of polygons, and a directional solve finds only the hull. binaries='each' adds two rows per binary, b >= b_at_least and b <= b_at_most, with both sides as data. A combination is then a right-hand side pushed onto the loaded solver, so loads stays at one. The pinned columns come from the first free solve's primal, so a column a where removed is not pinned. An infeasible combination is left out. Above 10 columns (1024 traces) it refuses and names at as the way to ask about fewer. integer variables are never pinned.

The picture. plot() needs the new [plot] extra (plotly). Each piece is a filled polygon in its own colour under its label. Hovering a vertex reads its coordinates. Hovering the middle of an edge reads the rows of edges, such as capacity[0, chp] at its upper. The optimum is a diamond naming its piece. plot(figure, name='hour 1') draws onto an existing figure with the next colours.

The page. docs/region.md runs at build time like the other tutorial pages. It traces a gas well feeding a CHP, a boiler and a peaker, each with a minimum load, over four hours. It reads every edge off edges, stacks every hour on one figure, traces the five feasible commitment states of eight, and breaks the model twice on purpose: no cap is named at the first direction, and a boiler that cannot idle names minimum_load. A small show(figure) helper prints figure.to_html(include_plotlyjs='cdn') into html="true" blocks. tests/test_tutorials.py runs the page and holds each claim.

Rebase onto main 68234d2, and gates

The branch was 88 commits behind, across the lpspec → specsolve rename. Its own changes are squashed into one commit and ported by hand:

  • projection.py moved to src/specsolve/. lps is now sps, math_spec is now mathspec, and the name-clash check reads the spec's relations section.
  • binding and expression_dims follow main's internals: model.handoff, program.expressions, PolarsCompiler(Scope(...)), SpecsolveError.
  • src/ admits no Any (refactor: only the linopy lane and the solver sinks say Any #1699), so 19 annotations in projection.py use main's Buildable, Source, Label and Mapping[str, object]. A _section() helper narrows the spec dict. _shape imports plotly itself.
  • One test writes the multi-link balance as sum(gen, by=gen_bus, over=generator, into=bus), because mathspec refuses the short form.
  • Main replaced notebooks with pages that run at build time (docs: the site is built by zensical, and the two tutorials run as pages rather than notebooks #1693). docs/region.ipynb is now docs/region.md. Its tests moved from test_notebook.py into tests/test_tutorials.py with their assertions unchanged.
  • The API page is built from docstrings, so the prose section is now a "Look at a model" subsection with ::: specsolve.project and ::: specsolve.Region. The list of names the probe adds moved into project's docstring.
  • uv.lock was regenerated with uv lock. It adds plotly and narwhals only. The dev and docs pixi features take the plot extra.
  • The changelog line is added.

Gates, in a uv venv with .[plot] and the dev and docs groups (pixi is unavailable in the sandbox):

  • ruff check . and ruff format --check .: clean. pyrefly check: 0 errors.
  • pytest -n 8: 3929 passed, 334 skipped, 1 xfailed, 10 failed. The 10 are gurobi and xpress cases that fail with ModuleNotFoundError in the sandbox; they fail the same way on main.
  • test_projection.py: 44 passed. test_tutorials.py: 15 passed. zensical build --strict: clean; the region page holds 4 plotly figures.
  • CI on d99c36a: every check green.

Not checked: the figures in a browser, whether their inline scripts run under Zensical's navigation.instant, and that plotly.js loads from the CDN once per figure (four times on the page).

Departures and what was not done
  • projection.py reads model._program and model._engine: to learn a named expression's dims after the build, and what binds at a solve. The language exports no dims_of, and a walker over lowered nodes would be a second home for the dim algebra.
  • Not done: per-axis coordinates (at is shared by both axes), rays for an unbounded region (it raises and names the direction), pinning integer variables, a matplotlib figure, committed figures.
  • The mutation table was taken at 8b5ebdc against base 227ccdd, before the rethink and the rebase, and was not re-taken. Every mutation was caught: a lowered Program, the same quantity on both axes, an unknown quantity, at naming no declared dimension, a probe name the spec declares, at on a scalar quantity, at naming a dim the quantity does not carry, an infeasible model, an unbounded direction, the solve cap.

🤖 Generated with Claude Code

https://claude.ai/code/session_011BopadYhLNiGHaa37K5pC1

@codspeed

codspeed Bot commented Sep 4, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 24 untouched benchmarks
⏩ 82 skipped benchmarks1


Comparing claude/feasible-region-analysis-26xcox (d99c36a) with main (8c783ca)2

Open in CodSpeed

Footnotes

  1. 82 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 main (68234d2) during the generation of this report, so 8c783ca was used instead as the comparison base. There might be some changes unrelated to this pull request in this report. ↩

@read-the-docs-community

read-the-docs-community Bot commented Sep 4, 2026 •

Copy link
Copy Markdown

@FBumann FBumann mentioned this pull request Sep 25, 2026
33 tasks
FBumann added a commit that referenced this pull request Sep 25, 2026
> **Prompt:** And prepare a todo list for me the same way. Also start
with 0.1.0rc1

> [!NOTE]
> The following content was generated by AI.

This is the release PR for `0.1.0rc1`, the release candidate for the
first PyPI upload. It is stacked on #1751. It stays a draft until PyPI
can take the package. One direct reference is left, the `linopy` extra,
and #1755 removes it (step 1).

## The first PyPI release, step by step

### 1. What blocks the upload

PyPI refuses a distribution that names a git URL in its metadata.

- [x] mathspec is on PyPI, and `pyproject.toml` depends on
`mathspec>=0.1.0` (#1754). The parity job reads the corpus tag from that
floor.
- [x] Merge #1755. It takes linopy out of the package and keeps it as
the test oracle: the `linopy @ git+…@master` reference moves to the
`dev` group, which PyPI never sees. It also deletes
`allow-direct-references`, so hatchling refuses a direct reference at
build time, before PyPI would.

### 2. One-time setup

**PyPI**
- [x] On pypi.org, go to Account → Publishing → "Add a new pending
publisher" → GitHub, and fill in:
  - PyPI project name: `specsolve`
  - Owner: `fluxopt`
  - Repository name: `specsolve`
  - Workflow name: `release.yaml`
  - Environment name: `pypi`

**GitHub**, under fluxopt/specsolve Settings
- [x] Environments: create or edit `pypi`. Under "Deployment branches
and tags", choose "Selected branches and tags" and add `main`. Add the
people who may approve a release as required reviewers.
- [x] After #1751 merges: Rules → the `main` ruleset. Require `ci`,
`Conventional commit subject` and `Changelog line`. A required check
must run on `main` once before you can require it.
- [x] Issues → Labels: create `no changelog`.

**Clean-up that the release does not need**
- [ ] Delete the repository variables `AUTO_RELEASE` and
`PUBLISH_TO_PYPI` if they exist. Turn off "Allow auto-merge" unless you
want it for other PRs.
- [x] Remove `specsolve` from the release app's (`fluxopt-release-bot`)
repository access. If no other repository uses the app, uninstall it and
delete the secrets `APP_CLIENT_ID` and `APP_PRIVATE_KEY`.
- [x] Delete the branches
`release-please--branches--main--components--farkas` and
`release-please--branches--main--components--linopy-yaml`.
- [x] Close #1675 and #1676. #1751 replaces both.
- [ ] Open PRs owe a changelog line after #1751 merges: #1737, #1527,
#1516 and #1541. #1541 (drop the linopy lane for linopy's own
`from_spec`) conflicts with #1755; close it or rebase it.

### 3. Before this PR merges: what the PyPI page shows

- [x] `[project.urls]` has only `repository`. Add `Documentation`,
`Issues` and `Changelog`, as energy-models/mathspec#707 did.
- [x] The README has two relative links, `CONTRIBUTING.md` and
`docs/about/prior-art.md`. On PyPI they do not resolve. Make them
absolute.
- [x] `src/specsolve/relational/parquet.py` has two error messages that
say "while the package is on 0.0.1aN". `tests/test_archive.py` asserts
the same. Reword them to "before 1.0".
- [x] RELEASING.md, "Relaxed while in early development": it says to
tighten CI before 0.1.0, starting with a Python matrix behind the gate
job. Do that, or change the sentence.

### 4. This PR

- [x] Merge #1751, then #1755. Then this PR's base must be `main`.
GitHub retargets it when #1751's branch is deleted; if the branch stays,
retarget by hand.
- [x] Mark the PR ready.
- [x] Set the date in `## 0.1.0rc1 (2026-09-25)` to the day you merge,
if that is not the 25th.
- [x] Edit the notes under the heading. Add the lines of the PRs merged
since. The text becomes the GitHub release notes, as written.
- [x] Check that `ci` is green. Its step "Check the changelog headings"
prints `releases 0.1.0rc1 on merge`.
- [x] Review, then squash-merge.

### 5. After the merge

- [ ] Actions → **Release**, the run for the merge commit:
- [ ] `Tag the version the changelog names` created the tag `v0.1.0rc1`
and the GitHub release `0.1.0rc1`, marked as a pre-release.
  - [ ] `Build` passed, and its check printed `specsolve-0.1.0rc1`.
- [ ] `Publish to PyPI` waits for approval: "Review deployments" →
`pypi` → Approve.
- [ ] https://pypi.org/project/specsolve/0.1.0rc1/ exists. The README
renders, and the project links work.
- [ ] Smoke test in a fresh environment:
  ```bash
  python -m venv /tmp/ss && /tmp/ss/bin/pip install specsolve==0.1.0rc1
/tmp/ss/bin/python -c "import specsolve, mathspec;
print(specsolve.__version__, mathspec.__version__)" # 0.1.0rc1 0.1.0
/tmp/ss/bin/pip install specsolve # finds no stable version until 0.1.0
exists
  ```
- [ ] If a job failed, fix the cause, then use "Re-run failed jobs" on
that run. If `0.1.0rc1` reached PyPI broken, release `0.1.0rc2`.

### 6. Then 0.1.0

- [ ] Open the next release PR, titled `chore: release 0.1.0`. It
renames the next `## Upcoming version` to `## 0.1.0 (date)`, with notes
that say this is the first release on PyPI. Merge it and approve the
upload as above.
- [ ] Smoke test `pip install specsolve` without a version. It should
install `0.1.0`.

### 7. Follow-ups

- [ ] 83 links in the tree point at `math-spec.readthedocs.io`. The
redirect keeps them working. Point them at `mathspec.readthedocs.io`.
- [ ] Optional: a conda-forge recipe.
- [ ] Patch releases to an older version line. No path exists yet;
mathspec tracks the same gap in energy-models/mathspec#710.

<details><summary>What this PR changes, what merging does,
gates</summary>

**The changes:**
- `CHANGELOG.md`: an empty `## Upcoming version`, then `## 0.1.0rc1
(2026-09-25)`, with a paragraph and #1748's line.
- `AGENTS.md`: "The project is `0.0.1aN` and holds no compatibility
promise" becomes "The project holds no compatibility promise before
1.0". One sentence is added: a release that breaks a model file or an
import raises the minor version, and its notes name the break.
- `CONTRIBUTING.md`: the same rule, in the same words.

**Why a release candidate:** it goes through the same tag, GitHub
release, build, approval and trusted-publishing upload as `0.1.0`. The
only addition is `--prerelease` on the GitHub release. pip skips it
unless someone asks for it, so a broken first upload reaches no user.
The version sorts after `0.0.1a358` and before `0.1.0`.

**What merging does:** `release.yaml` finds `0.1.0rc1` with no tag. It
creates `v0.1.0rc1` and a GitHub pre-release, builds from the tag,
checks that the wheel and the sdist are `0.1.0rc1`, and waits for
approval on `pypi` before the upload.

**Gates:** `python -m tools.changelog check` prints "releases 0.1.0rc1
on merge", and `notes 0.1.0rc1` prints the section above. The suite on
#1751's commit is in that PR. This commit changes only prose and the
changelog.

**Not verified:** the name `specsolve` on PyPI. The PyPI API cannot be
reached from this container, so check that the name is free when you add
the pending publisher.

The type is `chore`, which owes no changelog line.

</details>

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_016Pv7LzSgzt7Yn2K3ioyJXw

Co-authored-by: Claude <noreply@anthropic.com>
… traced as a polygon

`sps.project(spec, sources, x=, y=, at=, binaries=)` traces the region two
named quantities can reach and returns a `Region`: vertices, hull, pieces,
edges and optimum as tidy frames, and `plot()` as a plotly figure (the
`[plot]` extra). `PolarsEngine.binding` and `PolarsEngine.expression_dims`
read what a solve sits on and a named expression's dims off the built model.
`docs/region.md` walks it on a three-unit CHP plant.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KVqZodsEAc7AMYGDyu73b5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011BopadYhLNiGHaa37K5pC1
@FBumann
FBumann force-pushed the claude/feasible-region-analysis-26xcox branch from 5e07749 to cda2fd8 Compare September 25, 2026 17:22
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011BopadYhLNiGHaa37K5pC1
@read-the-docs-community

read-the-docs-community Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Documentation build overview

📚 specsolve | 🛠️ Build #34763738 | 📁 Comparing d99c36a against latest (68234d2)

  🔍 Preview build  

4 files changed
+ region/index.html
± about/architecture/index.html
± about/changelog/index.html
± reference/api/index.html

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants