Skip to content

feat(language): a sum other files add terms to is declared as an expression with empty: true - #742

Merged
FBumann merged 27 commits into
mainfrom
feat/empty-sum
Sep 28, 2026
Merged

FBumann merged 27 commits into
mainfrom
feat/empty-sum

Conversation

@FBumann

@FBumann FBumann commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Prompt: "So every given: block finds its counterpart somewhere? I think thats important to keep the feature understandable!" — then "Why not simply allow expression: 0?", "Is that rule to narrow?", "Go", and on the printing: "I think the ellipsis might be the most clear one…", "Fold it into 742". Later: "I think an empty expression should be marked explicitly", and "I think we should have an explicit flag".

Note

The following content was generated by AI.

An expressions: entry written empty: true over a dims: is an empty sum: the file declares the name, and other files add the terms. A term lands only on a declared expressions: block, empty or bodied. Alone, the sum prints as injection = ⋯.

Based on main, which carries #741, #731 and #732. The empty sum is #741's rule 1 with no body. #743 is merged into this branch. #758 wrote the same feature with expression: null. It is closed, and its explicit spelling is here as a flag.

The rule

A given: entry always assumes one upstream, and the upstream is always a regular block. For a parameter, a column or a row family, that was already so. For an expression it now is too. The owner declares the name under expressions:. It writes a body if it has one of its own, and empty: true if the components supply all of it. A reading with no term still folds into that block. A term lands on that block and nowhere else, so a mistyped target is refused as "no fragment declares it".

# network.yaml owns the sum, and adds nothing to it
expressions:
  injection:
    dims: [snapshot, bus]
    empty: true
    description: what the components put into a bus

# generators.yaml, unchanged
expressions:
  generation: sum(dispatch, by=gen_bus, over=generator, into=bus)
given:
  expressions:
    injection: { dims: [snapshot, bus], term: generation }
The owner writes Alone, the name is Composed, the body is
injection: { dims: [snapshot, bus], empty: true } a column over the frame the terms
injection: shed - load its body (shed - load) + terms
injection: { dims: [snapshot, bus] } refused: neither expression: nor cases:, and the message names empty: true —

empty defaults to false, and a spec writes it only where it is true. It is refused beside expression:, cases: or otherwise:, and it needs a dims:. The composed block has a body and keeps the owner's frame, so a later merge adds to it like any bodied expression. The composed load holds every term to the frame by #741's rule 2.

What this changes

  • Schema. ExpressionBlock gains empty: bool = False. An entry with empty: true and a dims: has no body. An entry with no body and no empty: true is refused as having neither form, and the refusal names the flag. The flag is serialised only where it is true.
  • Load. An empty sum resolves as a column over its frame, through the same path a given expression takes. Namespace.bodies is the set of names that resolve to a body, and the expression and where resolvers read it instead of schema.expressions. lowering puts the empty sum under program.given.expressions with empty set, since a host or merge must provide it. It does not go under program.expressions, which holds bodies. ExpressionDeclaration is untouched.
  • Advice. One note says the file declares a sum other files fill.
  • Typesetting. The owner declares the name, so the sum prints under Definitions and not under Given. Its line is symbol = ⋯ over its frame, last among the definitions, under either inline_expressions setting, and its legend row sits with the definitions. typeset_declaration prints that line. Where the name is read, it prints as a column, as before. Each format spells the ellipsis (\cdots, dots.c) the way it spells the dash: as a format spelling, not an operator. So the operator census of the golden model is untouched. A contributor's page is as refactor(language): a term prints as the definition it is, and one rule folds every reading #738 left it: the term prints as its own definition, and the Given line names what it adds to.
  • Merge. _landed accepts a declared name and nothing else. The "reads or uses" anchors are gone, and so are the Variable and walk imports. _summed writes the terms alone onto an empty block, and body + terms onto a bodied one. It keeps the owner's dims, and it does not carry empty. _definer_frame is the owner's declared frame where there is one.
  • Override. A patch empties a bodied definition with {expression: null, empty: true, dims: [...]}. The null drops the body and the flag marks the sum, so neither spelling means two things.
  • Docs. The tutorial's network.yaml declares the sum empty: true. Its check prints the new note, and "Leave the network out" prints the new refusal. The reference section "A term a file adds" shows the owner as an empty sum, and states the landing rule and how the sum prints. The named-expressions page defines the empty sum and its flag beside feat(language): a named expression may declare the frame it is read over #741's frame. The typeset page lists it among the definitions. reading.md names empty, and the how-to table's row follows. From docs: a file restates a shared dimension as its dtype alone, and merge carries the one description written for it #743, the tutorial's files after the first restate a shared dimension as a dtype alone.

Why

The sum was the one name whose regular block existed only in the composed spec, written by merge from a reading. That was the confusing part of the design. expression: 0 cannot be that block: alone, it is a row with no variable, and its frame is not one the body carries. Both are refused for good reasons.

A frame with no body was the first spelling here. It made a forgotten body load silently as an empty sum. expression: null (#758) was explicit, but a null means absent everywhere else: output drops it, and an override patch uses it to remove a field. empty: true is explicit, and it leaves the null with one meaning. The program flag has the same name as the file's key.

A Given row said another file provides the name. That is true for a consumer and false for the file, which declares the name. A definition with an open body says what the file states and no more, and leaves Given to names another file defines.

Method, guards, the retarget, gates, what was not done

Guards, each with the test that fails without it (tests/test_terms.py).

  • At load: test_an_empty_sum_loads_alone_and_reads_as_a_column, test_an_empty_sum_round_trips and test_empty_false_is_the_default_and_is_not_written. test_an_empty_sum_is_written_as_empty_true_over_a_frame has five cases: no frame, a frame and no body, empty: false and no body, beside a body, and beside cases.
  • At merge: test_a_reading_is_no_place_for_a_term_to_land, where neither a reader nor a contributor's own use anchors a term. test_terms_that_land_on_no_name_are_refused checks the new message. test_the_composed_sum_keeps_the_owner_s_frame checks that the composed load refuses a term wider than the owner's frame. test_a_patch_empties_a_definition checks the override.
  • Printing: test_the_owner_s_sum_prints_as_a_definition_with_no_body pins the ellipsis line, the Definitions legend row and the absence of a Given section. The advice note and the no-line refusals for a reader and a contributor are pinned too.

Mutation table for the flag commit 98ad950: each guard was removed by hand, then restored with git checkout --, __pycache__ was dropped, and the tree was clean after.

Guard removed Result
empty: true refused beside a body caught: [beside-a-body], [beside-cases]
empty: true needs dims: caught: [no-frame]
a frame with no body is refused caught: 3 failed, including test_composition.py::…[short-of-what-it-says]
empty: true written back caught: test_an_empty_sum_round_trips

The golden model declares no empty sum. check accepts the fixture without output, and an empty sum draws advice until its terms arrive. So the one walk line that renders the ellipsis is listed in UNREACHABLE with that reason, and test_terms.py reaches it. The golden output is unchanged.

Fixtures. tests/fixtures.py gains NETWORK, the owner of the empty sum. BALANCE, the reader, stays for the tests of readings. In test_terms.py, every merge that anchored on the reader now anchors on the owner. The file that defines injection as its slack is SLACKED.

test_composition.py is back as it is on main. A frame with no body is partial again, so {'expressions': {'spend': {'dims': ['snapshot']}}} is refused as short of what it says.

The retarget. #732 landed on main as the squash 74c36d6. Its tree is identical to feat/composition at fc464a7 (git diff is empty). This took three merge commits and no force-push:

  1. 5190229 merges fc464a7, the one feat(language): a spec is composed from files that each state part of it, and patched with files that each change part of it #732 commit this branch lacked. Its one conflict was in Namespace.__init__.
  2. 88e8ccc merges main at 74c36d6 with -s ours. All 19 conflicts from the squash are resolved to this branch's side, since step 1 already brought in main's content.
  3. 63e4b8c merges main at 8e4765a, which brings docs(notation): the notation page shows a named expression whose declared frame is wider than its body #756. The one conflict was the changelog, which keeps both lines.

Then 98ad950 changes the spelling to the flag, as a fast-forward.

Gates. Pixi cannot be installed in this container. I ran each gate in a Python 3.12 uv virtualenv with pytest, pytest-xdist, coverage, typst, pyrefly==1.2.0 and ruff==0.16.1, on 98ad950:

pytest -n auto                              2114 passed
ruff check . / ruff format --check .        clean
pyrefly check                               0 errors
typos                                       clean
prettier --check docs CHANGELOG.md examples mkdocs.yml   clean
tools.schema / tests.typesetting.golden / tools.notation / tools.gallery   regenerated; only the schema changed
zensical build --strict                     one warning: the docs.python.org inventory, which the proxy blocks (main has the same warning)

Not run: compile-tex, reuse lint, zizmor, taplo.

Not done.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CQxVP5uX2V4rpvJNPhbyR2

claude and others added 19 commits September 25, 2026 16:22
…d canonical --check fails a file that is not in it

Spec.to_yaml(canonical=True) writes the normal form: sections in one
order, declarations sorted by name, every expression printed from its
parsed tree with the terms of a sum and the factors of a product sorted,
one term per line. python -m mathspec canonical writes it; --check exits
1 for a file not in the form and --write rewrites it.

Squashes #530 and #718 onto main after #721, in its words: a file states
a spec. Also normalises a named expression written on one line, which the
form passed through as written.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sr4SCw5A7GUF5ARhdCaKR1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sr4SCw5A7GUF5ARhdCaKR1
… it, and patched with files that each change part of it

merge composes fragments as peers: each loads on its own, owns what it
declares, and reads what a sibling declares under given:, which now takes
parameters, variables, named expressions and row families. A given
expression's dims bound what it reads. A given: expressions: entry marked
additive: true says the name is a sum other files add terms to; each
contributor declares an ordinary named expression, merge sums them and
keeps the marked entry. override lays patches over a base, field by
field, with null as the removal. Both return a loaded Spec.

Squashes #571, #690, #728 and #729 onto #731, in #721's words. Adds the
tutorial 'A spec in several files', with a test that runs every step, and
sorts the names under given: in the canonical form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sr4SCw5A7GUF5ARhdCaKR1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sr4SCw5A7GUF5ARhdCaKR1
…and only `merge` holds it to the rules of the sum

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1F2gprjeiGPnvDaWjZwBB
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1F2gprjeiGPnvDaWjZwBB
…nes, under `given:`

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1F2gprjeiGPnvDaWjZwBB
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1F2gprjeiGPnvDaWjZwBB
`term:` on a `given: expressions:` entry now names a named expression the
same file declares, and no longer takes an expression written inline. The
term is then free: it takes `cases:`, a description and every rule of a
named expression, and it counts as read by the math.

`merge` adds the terms by name, without brackets around a name, and keeps
each term as a named expression of the composed spec. A refusal for terms
that land on no name names only a near miss.

The typeset term line is `name = ⋯ + term`. The tutorial, the reference,
the how-to, the golden model and the notation page follow. The #734 line
leaves the changelog; #732 carries the design.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sr4SCw5A7GUF5ARhdCaKR1
… land on

Two fragments that declare one term name now hear that terms share one
namespace, not the dimension-rows advice. Terms that land on no name are
refused with a near miss among the definitions and readings only, since a
term name is no place for a term to land; `did_you_mean` takes
`listing=False` for the case where only a near miss helps. The how-to says
to name each term after its component.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sr4SCw5A7GUF5ARhdCaKR1
…r the fragments are passed in, and a reader's fills one its owner left out (#739)

* Choose a merged description by fragment name, and let a reader's fill a gap

A shared dimension or relation, a reading several fragments share, and a
sum built from terms took the description of whichever fragment was passed
first. Each now takes the first description in the fragments' name order, as
the objective already did, so the order of the arguments reaches no field.
The order of the declarations still follows the order passed in, which is
presentation.

A folded given declaration dropped its reader's description even where the
declaration it folds into had none; it now fills that gap, and yields to the
owner's own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sr4SCw5A7GUF5ARhdCaKR1

* Add the changelog line for #739

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sr4SCw5A7GUF5ARhdCaKR1

---------

Co-authored-by: Claude <noreply@anthropic.com>
…le folds every reading (#738)

* refactor(language): a term prints as the definition it is, and merge takes the fragments in name order once

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1F2gprjeiGPnvDaWjZwBB

* Add the changelog link for #738

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1F2gprjeiGPnvDaWjZwBB

* Retitle the changelog line: the name-order rule is #739's

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1F2gprjeiGPnvDaWjZwBB

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1F2gprjeiGPnvDaWjZwBB
…tack

# Conflicts:
#	tests/test_dimensions.py
…ession with a frame and no body

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1F2gprjeiGPnvDaWjZwBB
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1F2gprjeiGPnvDaWjZwBB
…e carries the one description written for it (#743)
@FBumann
FBumann added this pull request to stack #744 September 26, 2026 09:42
@FBumann
FBumann removed this pull request from stack #744 September 26, 2026 09:43
FBumann pushed a commit to fluxopt/fluxopt that referenced this pull request Sep 26, 2026
FabianHofmann and others added 3 commits September 28, 2026 10:03
The one commit of #732 this branch lacked. `Namespace.variables` and
`Namespace.bodies` carry no `#:` comment, as fc464a7 asks of internal names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1VBZvT7UNJJexTrMX7mRi
#732 landed on main as the squash 74c36d6, whose tree is feat/composition
at fc464a7, merged in the commit before. Every conflict of the squash is
this branch's side, so the merge keeps this branch's tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1VBZvT7UNJJexTrMX7mRi
@FBumann
FBumann changed the base branch from feat/composition to main September 28, 2026 14:51
Brings #756. The changelog keeps both lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1VBZvT7UNJJexTrMX7mRi
…used

A `dims:` with no body loaded as an empty sum, so a forgotten body went
unnoticed. The flag is explicit: `empty: true` over a `dims:`, refused beside
`expression:`, `cases:` or `otherwise:`. `empty` defaults to false and is
written only where true. The program flag `owned` is renamed `empty`, to
match the file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CQxVP5uX2V4rpvJNPhbyR2
@FBumann FBumann changed the title feat(language): a sum other files add terms to is declared as an expression with a frame and no body feat(language): a sum other files add terms to is declared as an expression with empty: true Sep 28, 2026
@FBumann
FBumann marked this pull request as ready for review September 28, 2026 15:42
@FBumann
FBumann merged commit 8418623 into main Sep 28, 2026
7 checks passed
FBumann pushed a commit that referenced this pull request Sep 28, 2026
Brings #742, #743, #756 and #759. Only CHANGELOG.md conflicted, and it keeps
both sides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CQxVP5uX2V4rpvJNPhbyR2
FBumann pushed a commit that referenced this pull request Sep 28, 2026
#740 gives each of the nine sums one owning fragment, names the terms in
`pypsa.yaml`, simplifies the splitter and adds a page per fragment. The
language, typesetter and their tests keep main's side, which holds #742 and
#759 as merged. The PyPSA files take #740's side, the splitter writes
`empty: true` on each owner block, and the fragments and pages are
regenerated from `pypsa.yaml`, which keeps #717's per-snapshot coefficients.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CQxVP5uX2V4rpvJNPhbyR2
FBumann pushed a commit that referenced this pull request Sep 28, 2026
…n earlier #742

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CQxVP5uX2V4rpvJNPhbyR2
FBumann added a commit that referenced this pull request Sep 28, 2026
… it, each component adding its share of a sum by name (#736)

* docs(pypsa): a single file covers the standard, stochastic and multi-period classes

Fold pypsa_stochastic and pypsa_multi_period into examples/pypsa.yaml; retire
the sibling pages, symbols and example files. Add the rung 14 and rung 15
sections to the gallery page, and guard the plain-run collapse to the standard
model with a frozen shape fixture.

* docs(pypsa): the spec covers the process and transformer components

Add the Process component (a generalized multi-port converter, superset of
Link) and the full-depth Transformer (a passive branch like Line, with tap
ratio and a fixed phase shift in the KVL cycle) to examples/pypsa.yaml, with
symbols, the regenerated gallery, rung 17 and rung 18 reference networks and
oracles, and the collapse guard extended to the new standard names.

* docs(pypsa): the spec covers quadratic marginal cost

* docs(pypsa): the spec covers transmission losses

* docs(pypsa): the spec covers the secant loss mode

* docs(pypsa): the secant loss mode carries a solved reference

rung_19 records the secant-mode triangle solved through the pinned pypsa,
objective 10840.93, the same 150 rows as the tangent rung. The two loss-cut
blocks now stand for both their tangent and secant PyPSA names, matched by a
gallery helper that reads every backticked name before the dash.

* docs(pypsa): the spec covers the optimised transformer phase shift

A phase-shifting transformer's angle becomes a per-snapshot decision where
phase_shift_min < phase_shift_max, bounded by them and entering the KVL cycle
sum in place of the fixed constant; a plain run keeps the shift fixed and
collapses byte-for-byte. rung_20 records the phase-shifter triangle solved
through the pinned pypsa, objective 16455.0.

* docs(pypsa): the carrier growth limit binds every extendable component

* docs(pypsa): the spec covers transformer transmission losses

A transformer carries its own loss under transmission_losses, as a line does:
the loss counted against its rating, its cap, its tangent or secant cuts and half
of it at either bus. A plain run collapses byte-for-byte. rung_22 and rung_23
record the transformer triangle in both modes, objectives 10643.48 and 10822.00.

* docs(pypsa): a committable unit serving its brought-in down time stays off

Generator_com_status_must_stay_down mirrors the must-stay-up block over a
data-prep mask, status fixed to zero; PyPSA names the row
Generator-com-status-min_down_time_must_stay_up. rung_24 records a cheap unit
held off for two snapshots, objective 9007.5.

* docs(pypsa): a committable link carries the generator's unit commitment

Link gains the status, start-up and shut-down variables, the committable,
big-M and modular p bounds, transitions, up and down times, both must-stay
rules, the committed ramp rows, n_mod and its costs, mirroring Generator.
rung_25 records five committable links, objective 14013.0.

* docs(pypsa): a committable process carries the generator's unit commitment

Process gains the same unit commitment blocks as Link, over its internal
power. rung_26 restates rung 25's links as processes drawing a quarter more
than they deliver, objective 15956.125.

* docs(pypsa): a ramp row follows pypsa for a modular committed build and a start-up ramp alone

A committable extendable modular unit takes the ordinary ramp rows against
one module, not the big-M rows. A start-up or shut-down ramp alone builds
the row, and a missing limit reads as the full build. rung_27 (45469.5) and
rung_28 (83283.0) record both for Generator, Link and Process.

* docs(pypsa): storage cycles or reopens per investment period, and a ramp restarts at a period start

* docs(pypsa): a security-constrained run limits every branch flow after any one listed outage

* docs(pypsa): an mga flag caps the system cost at a budget and minimises weighted builds

* docs(pypsa): mga leaves the standard spec until it can land as a patch file

Reverts 5109686. The commit is kept on docs/pypsa-mga and waits for #571.

* docs(pypsa): a storage built in a later period opens at its first active snapshot

* docs(pypsa): a unit may be scheduled off for maintenance

* docs(pypsa): the spec states what maintenance and late-opening storage assume of the data

* docs(pypsa): a growth limit counts no transformer, as pypsa does

* docs(pypsa): a global constraint may count one investment period, weighted by its years

* docs(pypsa): a process, a storage unit and a store may carry a quadratic marginal cost

* docs(pypsa): a store's power and a storage unit's dispatch and charging may each be pinned to a schedule

* docs(pypsa): a link's and a process's delay applies within each investment period

* docs(pypsa): a negative relative growth adds nothing to a carrier's growth limit, as pypsa clips it at zero

* docs(pypsa): a tech capacity expansion limit on a network with several scenarios is refused, as pypsa does

* docs(pypsa): the carrier growth rung says a transformer counts in no carrier

* docs(pypsa): a global constraint takes its own constant and sense in each scenario

* docs(pypsa): the stochastic rung says which component data spans a scenario

* docs(pypsa): component data spans a scenario wherever pypsa reads it per scenario (#688)

* docs(pypsa): component data spans a scenario wherever pypsa reads it per scenario

* docs(pypsa): two rungs show operating and first-stage data that differ by scenario

* docs(pypsa): a component's sign turns its term in the bus balance around

* docs(pypsa): the linearized commitment file keeps a unit down, ramps at the full build where a limit is missing, and schedules maintenance

* docs(pypsa): a ramp limit may change over time and lift at a snapshot

* docs(pypsa): a unit that came in running ramps from its p_init into the first snapshot

* docs(pypsa): the relaxed commitment file ramps every generator per snapshot and from its p_init, tightens at the full build and signs its balance

* docs(pypsa): a start and a stop cost what they cost, with no snapshot or period weight

* docs(pypsa): a growth limit binds only under multi_investment_periods

* docs(pypsa): a load that is not active draws nothing from its bus

* docs(pypsa): a security-constrained run dissipates no transmission loss

* docs: the changelog lists the pypsa spec that covers every model class and component

* docs(pypsa): an efficiency, a rate or a phase shift may change from snapshot to snapshot

* docs: the changelog lists efficiencies per snapshot

* docs(pypsa): a growth limit counts an asset only in the first period it stands in

A rung may now record a PyPSA bug: its intended objective from an oracle,
and what PyPSA 1.3.0 gives instead. Rung 51 records PyPSA/PyPSA#1938.

* docs(pypsa): a transmission cost or volume limit holds in every scenario

Rungs 52 and 53 record PyPSA/PyPSA#1939, where PyPSA 1.3.0 drops both rows.

* docs(pypsa): each scenario delays a link's and a process's flow by its own delay

Rung 54 records PyPSA/PyPSA#1941, where PyPSA 1.3.0 delivers the flow twice.

* docs(pypsa): transformer cycles, security-constrained runs, fixed builds and committable units hold in every scenario

Rungs 55 to 58 record PyPSA/PyPSA#1942 and PyPSA/PyPSA#1913, where PyPSA 1.3.0 raises.

* docs(pypsa): the first-snapshot ramp refusals link the open PyPSA question

* feat(language): two files that state the same spec write one text, and canonical --check fails a file that is not in it

Spec.to_yaml(canonical=True) writes the normal form: sections in one
order, declarations sorted by name, every expression printed from its
parsed tree with the terms of a sum and the factors of a product sorted,
one term per line. python -m mathspec canonical writes it; --check exits
1 for a file not in the form and --write rewrites it.

Squashes #530 and #718 onto main after #721, in its words: a file states
a spec. Also normalises a named expression written on one line, which the
form passed through as written.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sr4SCw5A7GUF5ARhdCaKR1

* Add the changelog link for #731

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sr4SCw5A7GUF5ARhdCaKR1

* feat(language): a spec is composed from files that each state part of it, and patched with files that each change part of it

merge composes fragments as peers: each loads on its own, owns what it
declares, and reads what a sibling declares under given:, which now takes
parameters, variables, named expressions and row families. A given
expression's dims bound what it reads. A given: expressions: entry marked
additive: true says the name is a sum other files add terms to; each
contributor declares an ordinary named expression, merge sums them and
keeps the marked entry. override lays patches over a base, field by
field, with null as the removal. Both return a loaded Spec.

Squashes #571, #690, #728 and #729 onto #731, in #721's words. Adds the
tutorial 'A spec in several files', with a test that runs every step, and
sorts the names under given: in the canonical form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sr4SCw5A7GUF5ARhdCaKR1

* Add the changelog link for #732

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sr4SCw5A7GUF5ARhdCaKR1

* refactor(language): a term of a sum is an ordinary named expression, and only `merge` holds it to the rules of the sum

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1F2gprjeiGPnvDaWjZwBB

* Add the changelog link for #734

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1F2gprjeiGPnvDaWjZwBB

* The reader's description wins, and the term checks stay at load

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1F2gprjeiGPnvDaWjZwBB

* feat(language): a file adds a term to an expression another file defines, under `given:`

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1F2gprjeiGPnvDaWjZwBB

* Walk the loaded fragments by value

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1F2gprjeiGPnvDaWjZwBB

* A term names an expression of its file, and merge adds it by name

`term:` on a `given: expressions:` entry now names a named expression the
same file declares, and no longer takes an expression written inline. The
term is then free: it takes `cases:`, a description and every rule of a
named expression, and it counts as read by the math.

`merge` adds the terms by name, without brackets around a name, and keeps
each term as a named expression of the composed spec. A refusal for terms
that land on no name names only a near miss.

The typeset term line is `name = ⋯ + term`. The tutorial, the reference,
the how-to, the golden model and the notation page follow. The #734 line
leaves the changelog; #732 carries the design.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sr4SCw5A7GUF5ARhdCaKR1

* Name the term in a term collision, and suggest only a name a term can land on

Two fragments that declare one term name now hear that terms share one
namespace, not the dimension-rows advice. Terms that land on no name are
refused with a near miss among the definitions and readings only, since a
term name is no place for a term to land; `did_you_mean` takes
`listing=False` for the case where only a near miss helps. The how-to says
to name each term after its component.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sr4SCw5A7GUF5ARhdCaKR1

* Cut pypsa.yaml into topic fragments whose components add named terms

`examples/pypsa/` holds the 24 fragments `tools/pypsa_split.py` writes from
#620's `examples/pypsa.yaml`. Each component's share of a sum (the bus
balance, the cycle sum, the operating cost, the carrier additions and the
five global-constraint totals) is a named expression of its own, such as
`Generator_injection`, and the `term:` of its `given:` entry names it. The
reader of each sum describes it: `network` and `power_flow` for the two
rows, `core` for the rest.

`check` merges the fragments to the canonical form of the one file with its
sums written as named terms, and compares that file with `pypsa.yaml` row by
row, every term substituted back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sr4SCw5A7GUF5ARhdCaKR1

* Add the changelog line for #736

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sr4SCw5A7GUF5ARhdCaKR1

* fix(language): a merged spec's descriptions do not depend on the order the fragments are passed in, and a reader's fills one its owner left out (#739)

* Choose a merged description by fragment name, and let a reader's fill a gap

A shared dimension or relation, a reading several fragments share, and a
sum built from terms took the description of whichever fragment was passed
first. Each now takes the first description in the fragments' name order, as
the objective already did, so the order of the arguments reaches no field.
The order of the declarations still follows the order passed in, which is
presentation.

A folded given declaration dropped its reader's description even where the
declaration it folds into had none; it now fills that gap, and yields to the
owner's own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sr4SCw5A7GUF5ARhdCaKR1

* Add the changelog line for #739

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sr4SCw5A7GUF5ARhdCaKR1

---------

Co-authored-by: Claude <noreply@anthropic.com>

* refactor(language): a term prints as the definition it is, and one rule folds every reading (#738)

* refactor(language): a term prints as the definition it is, and merge takes the fragments in name order once

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1F2gprjeiGPnvDaWjZwBB

* Add the changelog link for #738

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1F2gprjeiGPnvDaWjZwBB

* Retitle the changelog line: the name-order rule is #739's

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1F2gprjeiGPnvDaWjZwBB

---------

Co-authored-by: Claude <noreply@anthropic.com>

* fix(typeset): a substituted term prints its leading minus as a subtraction, and the PyPSA split has a page per fragment

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1F2gprjeiGPnvDaWjZwBB

* Expect the substituted sum over both dimensions, and link the changelog line to #740

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1F2gprjeiGPnvDaWjZwBB

* feat(language): a named expression may declare the frame it is read over

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1F2gprjeiGPnvDaWjZwBB

* Add the changelog link for #741

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1F2gprjeiGPnvDaWjZwBB

* feat(language): a sum other files add terms to is declared as an expression with a frame and no body

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1F2gprjeiGPnvDaWjZwBB

* Add the changelog link for #742

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1F2gprjeiGPnvDaWjZwBB

* The owner of each PyPSA sum declares it with a frame and no body

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1F2gprjeiGPnvDaWjZwBB

* The golden model substitutes a signed sum into a plus

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1F2gprjeiGPnvDaWjZwBB

* docs: a file restates a shared dimension as its dtype alone, and merge carries the one description written for it (#743)

* An empty sum prints as a definition with an ellipsis for its body

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1F2gprjeiGPnvDaWjZwBB

* The owner pages of the PyPSA split print their sums with an ellipsis

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1F2gprjeiGPnvDaWjZwBB

* Keep main's patch case in test_composition, which #740 carried from an earlier #742

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CQxVP5uX2V4rpvJNPhbyR2

---------

Co-authored-by: Fabian <fab.hof@gmx.de>
Co-authored-by: Claude <noreply@anthropic.com>
FBumann pushed a commit that referenced this pull request Oct 1, 2026
The Upcoming section becomes 0.3.0, grouped into composition, language,
typesetting and advice, and documentation. The notes name the two breaks
against 0.2.0 (#788, #751). The #742 line is left out: #763 replaced
`empty: true` before any release carried it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBmqeqBrqpQC6MVoSrguHK
@FBumann FBumann mentioned this pull request Oct 1, 2026
FBumann added a commit that referenced this pull request Oct 1, 2026
* fix(language): a where that reads a given expression names it as a given expression, not as a variable

The namespace files a given expression with the variables, because it is
read as a column. A where that read one was refused as if it read a
variable. The three refusals (the left name, the right name, a side of a
comparison of expressions) now name the given expression and say why a
mask may not read it: it may hold a variable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBmqeqBrqpQC6MVoSrguHK

* docs: the changelog line links #809

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBmqeqBrqpQC6MVoSrguHK

* chore: release 0.3.0

The Upcoming section becomes 0.3.0, grouped into composition, language,
typesetting and advice, and documentation. The notes name the two breaks
against 0.2.0 (#788, #751). The #742 line is left out: #763 replaced
`empty: true` before any release carried it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBmqeqBrqpQC6MVoSrguHK

* docs: the 0.3.0 notes say that nothing a 0.2.0 file or call does stops working

#788 and #751 change only documentation and a docstring. mathspec neither
attaches data nor computes duals, so neither breaks a file or an import.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBmqeqBrqpQC6MVoSrguHK

* chore: the release is 0.2.1, since nothing in it breaks a file or an import

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBmqeqBrqpQC6MVoSrguHK

* chore: a release with no break raises the patch version, features included

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBmqeqBrqpQC6MVoSrguHK

* docs: the 0.2.1 notes say that every fragment typesets and gets advice on its own

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBmqeqBrqpQC6MVoSrguHK

---------

Co-authored-by: Claude <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.

3 participants