Skip to content

Backend router, vtype, shift(), and piecewise: blocks - #19

Merged
FBumann merged 7 commits into
mainfrom
relational/vtype-shift-router
Jul 24, 2026
Merged

Backend router, vtype, shift(), and piecewise: blocks#19
FBumann merged 7 commits into
mainfrom
relational/vtype-shift-router

Conversation

@FBumann

@FBumann FBumann commented Jul 24, 2026

Copy link
Copy Markdown
Owner

Follow-up to #18: the scope decision and the full route back to piecewise. Five commits, in order:

  1. Backend router (SPEC §12.8)linopy_yaml.router: a schema is relational-eligible iff it lowers; select_backend() falls back to the eager builder with the verbatim rejection reason. Eligibility is decided by attempting the lowering, so it can never drift. SPEC §12 records the scope decision: relational = streaming compiler for large pure-affine/basic-MILP models; eager = feature-complete default; the IR stays affine-by-design.
  2. vtype — binary/integer on the relational backend (cols.vtype, LP binary/general sections, HiGHS integrality per batch). Basic MILP is relational-eligible; unit-commitment differential verifies objective to 1e-9 with true 0/1 integrality on both sinks.
  3. shift(expr, dim=n) — non-cyclic roll (vacated positions contribute zero) on both backends; eager = linopy .shift(), relational = the roll ord-join without the modulo. Used by acyclic storage recurrences and the piecewise adjacency row.
  4. piecewise: blocks — mirroring linopy.Model.add_piecewise_formulation: N symmetric tuple links [expression, values-parameter, sign?] jointly pinned to a breakpoint-indexed curve (2 links = y=f(x); 3+ = CHP joint operating curves). Links accept inline affine expressions; breakpoints are parameters, so curves may vary along other dims. Expanded at the schema level (λ convex-combination + adjacency binaries) before either backend runs, so eager and relational receive identical affine declarations and stay differential-testable. convex: true drops the binaries (pure-LP hull). Tests prove the nonconvex objective lands exactly ON the curve vs numpy interpolation while convex: true provably yields the chord.
  5. Gating + curvature guardactive: (linopy parity: formulation pinned to 0 when the gate is 0; in λ form it is just sum(lam) == active), and a two-level guard for convex: true: exactly two links at schema time, strictly-monotone breakpoints and no mixed curvature at data time — closing the silent-hull-relaxation wrong-answer gap.

Remaining piecewise method options tracked as issues: #22 (incremental) and #23 (sos2 / lp).

110 tests green; mypy/ruff clean on all touched code.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@FBumann, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 1 minute

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 59f3c748-b345-418a-9779-4e33d5e47c28

📥 Commits

Reviewing files that changed from the base of the PR and between 69be75d and 858df4c.

⛔ Files ignored due to path filters (1)
  • docs/bench.svg is excluded by !**/*.svg
📒 Files selected for processing (17)
  • ARCHITECTURE.md
  • SPEC.md
  • examples/piecewise.yaml
  • linopy_yaml/_patch.py
  • linopy_yaml/helpers.py
  • linopy_yaml/lowering.py
  • linopy_yaml/piecewise.py
  • linopy_yaml/relational/executor.py
  • linopy_yaml/relational/ir.py
  • linopy_yaml/router.py
  • linopy_yaml/schema.py
  • tests/test_lowering.py
  • tests/test_milp.py
  • tests/test_piecewise_block.py
  • tests/test_piecewise_convex.py
  • tests/test_roll.py
  • tests/test_router.py
📝 Walkthrough

Walkthrough

The relational backend gains shift support, binary and integer variable handling, backend eligibility routing with eager fallback, and new piecewise-linear and MILP examples with differential tests across Linopy, DuckDB, LP export, and HiGHS.

Changes

Relational backend expansion

Layer / File(s) Summary
Backend contracts and eligibility
SPEC.md, linopy_yaml/router.py, tests/test_router.py
The specification defines relational scope, affine IR and sink boundaries, while routing selects the relational backend for successfully lowered schemas and otherwise returns an eager fallback reason.
Shift helper and affine lowering
SPEC.md, linopy_yaml/helpers.py, linopy_yaml/relational/ir.py, linopy_yaml/lowering.py, tests/test_roll.py
Adds the zero-filled shift helper and represents it as Shift(..., wrap=False), while preserving wrapped roll behavior and validating both call forms.
Mixed-integer lowering and sinks
linopy_yaml/lowering.py, linopy_yaml/relational/ir.py, linopy_yaml/relational/executor.py, tests/test_lowering.py, tests/test_milp.py
Carries variable types through relational compilation, LP output, and direct HiGHS solving, with differential MILP coverage.
Piecewise model validation
examples/piecewise_convex.yaml, tests/test_piecewise_convex.py
Adds a convex piecewise-linear generation model and verifies matching eager, relational, and exported-LP objectives plus epigraph cost correctness.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant MathSchema
  participant select_backend
  participant lower_program
  participant DuckdbExecutor
  participant HiGHS
  MathSchema->>select_backend: provide schema
  select_backend->>lower_program: check relational eligibility
  lower_program-->>select_backend: eligibility or fallback reason
  select_backend->>DuckdbExecutor: lower eligible schema
  DuckdbExecutor->>HiGHS: solve typed columns and constraints
  HiGHS-->>DuckdbExecutor: objective and variable values
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Title check ❓ Inconclusive The title is related, but it reads like a loose feature list and does not clearly state the primary change. Use a concise sentence that names the main change, e.g. backend routing with shift/vtype support and piecewise examples.
✅ Passed checks (4 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch relational/vtype-shift-router

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 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 `@linopy_yaml/helpers.py`:
- Around line 131-132: Update the shift-amount handling around dim and n to
validate that n is integral before converting it with int(). Reject fractional
values such as 1.5 consistently with relational lowering, while preserving valid
integer shift behavior and preventing eager fallback from truncating
expressions.

In `@linopy_yaml/relational/executor.py`:
- Around line 538-546: Update the dimension-table construction used by the
relational executor so each dim_* table assigns ordinals in declared/coords
order rather than ORDER BY val, preserving xarray positional shift semantics for
non-monotonic and string coordinates. Keep the shift join logic in the affected
executor path unchanged, and add a differential test comparing eager and
relational results for unsorted coordinates.

In `@SPEC.md`:
- Line 1041: Synchronize the relational capability documentation in SPEC.md: add
shift to the supported affine subset near lines 1041-1041, update the
vtype/integrality description near lines 1108-1115 to reflect current support
beyond continuous-only columns, and remove binary/integer variables from the
eager-fallback list near lines 1138-1141.

In `@tests/test_piecewise_convex.py`:
- Line 78: Update the piecewise cost oracle’s max computation to use strict
zipping for slopes and intercepts, ensuring mismatched segment lengths raise an
error instead of being silently truncated. Preserve the existing cost
calculation and max behavior.

In `@tests/test_roll.py`:
- Around line 127-131: Update the YAML conversion setup around STORAGE_YAML to
verify that the original roll(soc, snapshot=1) token exists before replacing it,
and assert the resulting yaml_text differs or contains the expected shift(soc,
snapshot=1) expression so the test cannot silently continue using roll().
🪄 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: 11545865-d2e4-47b4-a710-23f36d0c75ca

📥 Commits

Reviewing files that changed from the base of the PR and between a1d83aa and 69be75d.

📒 Files selected for processing (12)
  • SPEC.md
  • examples/piecewise_convex.yaml
  • linopy_yaml/helpers.py
  • linopy_yaml/lowering.py
  • linopy_yaml/relational/executor.py
  • linopy_yaml/relational/ir.py
  • linopy_yaml/router.py
  • tests/test_lowering.py
  • tests/test_milp.py
  • tests/test_piecewise_convex.py
  • tests/test_roll.py
  • tests/test_router.py

Comment thread linopy_yaml/helpers.py
Comment thread linopy_yaml/relational/executor.py
Comment thread SPEC.md Outdated
Comment thread tests/test_piecewise_convex.py Outdated
Comment thread tests/test_roll.py Outdated
@FBumann

FBumann commented Jul 24, 2026

Copy link
Copy Markdown
Owner Author

Added the piecewise: block itself (f26f5a2) — linopy-shaped tuple links ([expression, values, sign?]), inline affine expressions allowed, λ expansion at the schema level so both backends receive identical declarations. Nonconvex verified ON-curve vs numpy interpolation; convex: true provably yields the hull; CHP 3-link case covered. See SPEC §3.6.

@FBumann FBumann changed the title vtype + shift() + backend router: MILP eligibility and the piecewise route Backend router, vtype, shift(), and piecewise: blocks Jul 24, 2026
FBumann and others added 5 commits July 24, 2026 16:59
…st lane)

Record the scoping decision prompted by linopy 0.9's trajectory
(piecewise/SOS/indicator/dualize/updates are model transformations the
eager builder inherits for free and a flat matrix streamer cannot):

- §12.1: the relational backend is a streaming compiler for large
  pure-affine models — an optimization lane with automatic fallback,
  not a general replacement for the eager builder.
- §12.4: the IR is affine-by-design, decided. Formulations are
  eager-only; if ever streamed, they enter as an expansion stage that
  emits declarations, never as expression nodes. Reimplementing
  linopy's reformulation passes is explicitly rejected.
  Semi-continuous (a vtype, not a formulation) is the planned extension.
- §12.6: the solver_direct sink cap is stated (three streams today;
  five-stream upgrade path documented).
- §12.8 (new): backend eligibility and automatic fallback.

linopy_yaml/router.py implements §12.8: relational_eligibility(schema)
decides by attempting the lowering — so eligibility can never drift
from what the backend actually supports — and select_backend() returns
an explicit choice with the verbatim rejection reason on fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The route back to piecewise, per discussion — at the right layers:

- vtype: binary/integer variables on the relational backend. cols gains
  a vtype column; the LP sink writes binary/general sections; the
  solver_direct sink sets HiGHS integrality per column batch. Lowering
  maps binary to vtype='binary' with fixed 0/1 bounds (linopy parity).
  Basic MILP is now relational-eligible — verified by a unit-commitment
  differential (objective, 0/1 integrality, both sinks).

- shift(expr, dim=n): non-cyclic counterpart of roll — vacated
  positions contribute zero. Eager: linopy/xarray .shift(); relational:
  ir.Shift(wrap=False), the same ord-join without the modulo. This is
  the ordering primitive the nonconvex piecewise expansion needs, and
  useful directly for acyclic storage recurrences. The differential
  test also caught that a start-empty battery makes the original test
  data infeasible — on which both backends agreed exactly.

- Convex piecewise needs no machinery: examples/piecewise_convex.yaml
  shows the epigraph formulation in ordinary affine YAML — pure LP,
  relational-eligible today, verified pointwise against a numpy
  evaluation of the piecewise cost.

SPEC: §7.3 shift docs, §12.4 updated (vtype landed; nonconvex piecewise
planned as schema-level expansion so both backends receive identical
affine declarations; formulations never enter as IR expression nodes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ends

Add the piecewise: schema section, mirroring
linopy.Model.add_piecewise_formulation: N symmetric links
[expression, values-parameter, sign?] jointly pinned to a
breakpoint-indexed curve (2 links = y=f(x); 3+ = CHP-style joint
operating curves). Links accept any affine expression string — a bare
variable name is just the simplest case — and one link may carry
<=/>= to be bounded by the curve instead of pinned (linopy's
restriction mirrored: only with exactly two links). Breakpoints are
parameters, not literals, so curves can vary along other dims.

Expansion (linopy_yaml/piecewise.py) runs before either backend and
emits only existing language constructs — λ weights with a convexity
row, one link row per tuple, and segment binaries with adjacency
lam <= seg + shift(seg, bp=1) — so eager and relational receive
identical affine declarations and stay differential-testable.
convex: true drops the binaries (pure-LP convex hull), keeping the
model relational-eligible as an LP. The λ method is expansion-pure:
no derived data (slopes/intercepts/segment lengths) needed.

Wired into both entry points: Model.from_yaml expands after schema
validation; lower_program expands before lowering, so the router's
eligibility answer accounts for expansion automatically.

Tests: nonconvex objective lands exactly ON the curve vs a numpy
interpolation (adjacency binaries load-bearing) while convex: true
provably drops to the chord; CHP 3-link joint curve matches
interpolation on all links; inline-expression links; reference and
sign validation. SPEC §3.6 + §12.4 updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
active gating (linopy's active= parameter): the λ method makes this a
one-line formulation change — sum(lam, over=bp) == active (and same for
the segment pick row), which reproduces linopy's semantics exactly:
when the gate is 0 all λ vanish and every ==-link pins its expression
to zero. active accepts an expression (bare binary variable being the
common case); a bare non-binary variable is rejected at expansion.
Not supported with convex: true (mirroring method='lp').

Curvature guard for convex: true, closing the silent-wrong-answer gap:
- schema-time: exactly two links (the hull relaxation is only
  well-defined for a single y=f(x) curve — mirrors linopy's lp method)
- data-time (validate_piecewise_data, wired into Model.from_yaml and
  tidy_sources): x-breakpoints strictly increasing, and mixed-curvature
  y-curves rejected with a pointer to the exact MILP form. Consistent
  curvature passes — the hull semantics remain documented behavior.
  Checked per curve slice when breakpoints vary along other dims;
  parquet-path sources bypass the guard (data never enters Python).

Tests: gated UC-style model differential on both backends (cost ON the
curve when committed, pinned to zero when off), binary-check, mixed-
curvature and monotonicity rejections, convex two-link restriction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ample

examples/piecewise.yaml showcases the block with per-generator curves
(breakpoint parameters carrying [generator, bp] — inexpressible with
flat breakpoint lists), convex: true, relational-eligible LP; new
differential test checks each generator's cost sits on its own curve.
The epigraph pattern stays as an inlined tested pattern in
test_piecewise_convex.py (automating it is issue #23's method: lp).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@FBumann
FBumann force-pushed the relational/vtype-shift-router branch from b6c12eb to c127f00 Compare July 24, 2026 15:01
FBumann and others added 2 commits July 24, 2026 17:01
Per the update-in-same-PR rule: the schema-level piecewise expansion
and the backend router are structural additions from this PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…, doc sync

- relational executor: explicit dimension indexes now assign ordinals in
  declared/coords order (pandas first-occurrence; parquet via
  file_row_number) instead of ORDER BY val, so Shift's positional
  semantics match xarray/linopy for non-monotonic and string
  coordinates. Derived dims keep the sorted deterministic fallback,
  documented. New differential test with lexicographically-scrambling
  string labels (t0..t47) fails on the old code and passes now.
- helpers: shift()/roll() reject fractional amounts (TypeError) instead
  of silently truncating via int(), consistent with relational lowering.
- SPEC: v0 subset gains shift; sink-cap paragraph reflects vtype
  integrality; binary/integer removed from the §12.8 fallback list; the
  stale "piecewise ... eager-only" sentence in §12.4 now points at the
  schema-level expansion.
- tests: strict zip in the piecewise cost oracle; the roll→shift YAML
  replacement in the acyclic test is now asserted so it cannot silently
  keep testing roll().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@FBumann
FBumann merged commit 864895a into main Jul 24, 2026
1 check passed
FBumann added a commit that referenced this pull request Jul 24, 2026
* docs: architecture pipeline as mermaid; linopy-free rule; #19 content folded in

Ports the doc improvements stranded on the feat/expression-macros branch
after #20's squash-merge: the pipeline as one validated mermaid flowchart
(renders on GitHub, stays diffable text), the promoted hard rule that the
relational lane is linopy-free (duckdb -> highspy, linopy's semantics as
a spec to match, not code to share), and the completed module map. Folds
in #19's additions: piecewise expansion in the front end, piecewise.py
and router.py rows, and roll/shift as the dim-as-key counterexample in
the macro-friendliness rule.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: separate the two lanes visually; add language-tiers diagram

Pipeline redesign after actually rendering it: each lane gets its own
tinted subgraph (green relational, blue eager) with its own data entry,
which removes every crossing edge — the router's two labeled edges are
now the only lines entering the lanes. New small mermaid for the
two-tier language economy (free composition -> taxed primitives -> both
backends; @register as a dashed escape hatch that reaches the eager
backend only). Both diagrams validated with mermaid-cli.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: README catches up with the two-backend era

The README predated the relational backend entirely. Adds: the
two-backend framing in the intro, a 'models that don't fit in memory'
use case with the automatic-routing story and the 107M-var/0.6GB
headline, the three new schema sections (expressions, macros,
piecewise), updated helpers (group_sum, shift) and features list,
the [relational] install extra, honest scoping of the pure-consumer
goal to the eager path, fixed introspection API names, and links to
ARCHITECTURE.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: shrink README; frame linopy as compat layer + validation oracle

README drops from ~220 to ~140 lines keeping headlines and use cases:
callback comparison condensed to one paragraph plus example, open-
questions section folded into Status, features/schema merged into one
compact language section. Framing updated per the scope decision:
models build on the relational/streaming engine; linopy is kept for
exactly two roles — the compatibility layer (Python-built models,
.yaml.extend, out-of-subset fallback with stated reason) and the
validation oracle. ARCHITECTURE.md lane title and hard rule 3 aligned.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: miniature two-lane diagram in the README

A 7-node, module-name-free version of the pipeline — different altitude
than ARCHITECTURE.md's full diagram, so it only goes stale on true
topology changes. Validated with mermaid-cli.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@FBumann
FBumann deleted the relational/vtype-shift-router branch July 31, 2026 10:52
FBumann added a commit that referenced this pull request Jul 31, 2026
The benchmarks page was 110 table rows in twelve tables, all weighted the
same, with no shape and no headline — and the one chart page we had was a
separate HTML file linked from a mid-paragraph, absent from the nav. So
the numbers were published and the *finding* was not.

Four figures now lead the Results section, written by `bench.plot` from
the same jsonl the tables come from:

  wall    time to a loaded solver, faceted by sink, log-log
  peak    the same builds' resident memory, its own chart, never a
          second axis on the first
  cases   all six models as small multiples
  phases  where the time goes at `l` — the chart the tables cannot be
          read for at all, since they publish one total per row

`bench/svg.py` draws them: no plotting library, because these pages are
read on GitHub as often as on the site and a chart has to be a committed
file rather than something a renderer produces at view time. Two files
per figure, light and dark, because mkdocs-material's toggle stamps the
host page and an `<img>`-referenced SVG cannot see it; the `#only-light`
/ `#only-dark` suffixes choose, and GitHub takes the first of the pair.

The palette is validated rather than chosen — slots 1 and 2 of a
reference categorical palette, run through the six checks against both
surfaces (lightness band, chroma floor, CVD separation, normal-vision
floor, contrast). Every series is direct-labelled as well as coloured, so
identity survives a reader who sees neither hue.

The twelve tables stay, one `<details>` per case, and their headings
became bold text: a heading inside a fold still lands in the table of
contents, which is twelve rail entries for the appendix.

`docs/bench.svg` deleted — drawn for #19, hardcoded light-mode colours,
referenced from nowhere since. `tests/test_docs_site.py` now checks both
directions, so the next one fails instead of rotting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FBumann added a commit that referenced this pull request Aug 1, 2026
The benchmarks page was 110 table rows in twelve tables, all weighted the
same, with no shape and no headline — and the one chart page we had was a
separate HTML file linked from a mid-paragraph, absent from the nav. So
the numbers were published and the *finding* was not.

Four figures now lead the Results section, written by `bench.plot` from
the same jsonl the tables come from:

  wall    time to a loaded solver, faceted by sink, log-log
  peak    the same builds' resident memory, its own chart, never a
          second axis on the first
  cases   all six models as small multiples
  phases  where the time goes at `l` — the chart the tables cannot be
          read for at all, since they publish one total per row

`bench/svg.py` draws them: no plotting library, because these pages are
read on GitHub as often as on the site and a chart has to be a committed
file rather than something a renderer produces at view time. Two files
per figure, light and dark, because mkdocs-material's toggle stamps the
host page and an `<img>`-referenced SVG cannot see it; the `#only-light`
/ `#only-dark` suffixes choose, and GitHub takes the first of the pair.

The palette is validated rather than chosen — slots 1 and 2 of a
reference categorical palette, run through the six checks against both
surfaces (lightness band, chroma floor, CVD separation, normal-vision
floor, contrast). Every series is direct-labelled as well as coloured, so
identity survives a reader who sees neither hue.

The twelve tables stay, one `<details>` per case, and their headings
became bold text: a heading inside a fold still lands in the table of
contents, which is twelve rail entries for the appendix.

`docs/bench.svg` deleted — drawn for #19, hardcoded light-mode colours,
referenced from nowhere since. `tests/test_docs_site.py` now checks both
directions, so the next one fails instead of rotting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

1 participant