Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions adr/ADR-003-modular-numerical-strategies.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,18 +115,32 @@ interface change, no new ADR. `BoundaryFaceConfig.scalar_gradient` is
`scalar_value`'s exact mirror, the gap TASK-028's own drafting had
already named in advance for this task to resolve.

**Periodic Boundary followed the next day** (TASK-030, Stage 4,
2026-08-28, Stage 4's last task): not a seventh `BoundaryCondition`
implementation -- a periodic face is mesh geometry (`StructuredCartesianMesh.
wrapped_neighbour_cell`), not a prescribed value, so it bypasses this
ADR's own registry mechanism entirely rather than joining it. What *did*
land through this ADR's own pattern: `register_advection_scheme`/
`register_diffusion_scheme`'s factory types widened to also receive a
`periodic_pairs` mapping (mirrors `diffusion_coefficient`'s own
TASK-024 precedent -- no interface change, no new ADR), and
`FirstOrderUpwindAdvection`/`CentralDifferenceDiffusion` both gained the
logic to consult it. `mvp.md`'s "Periodic (where practical)" bullet is
now real.

**What no longer exists, and is worth stating plainly: every component
this ADR names now has a real concrete implementation.** All six --
Advection, Diffusion, Time Integration, Linear Solver, Pressure-Velocity
Coupling, Boundary Condition (both its Dirichlet and Neumann shapes) --
went real across TASK-023 through TASK-029, and `assembly.py`'s own
`_Null*` reference implementations, the whole Stage 3 Completion
Criterion 1 carve-out this section has been tracking task by task, are
now zero. Only `source.py` and periodic boundary faces remain unbuilt
among this project's numerical machinery, and neither is one of this
ADR's own six -- `source.py` per TASK-018's own P-016 reasoning (no
second implementation identified yet), periodic per `boundary_condition.
py`'s own deliberately-narrower scope (TASK-019).
now zero. Only `source.py` remains unbuilt among this project's
numerical machinery, and it is not one of this ADR's own six --
TASK-018's own P-016 reasoning (no second implementation identified
yet). Periodic boundary faces, the other item this paragraph used to
name here, are real as of TASK-030 (above) -- deliberately outside this
ADR's own six-component registry, not merely unbuilt.

---

Expand Down
30 changes: 18 additions & 12 deletions docs/architecture/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,18 +30,24 @@ Grounded directly in `bootstrap.py`, `engine/simulation.py`,
`engine/collocated_field.py` -- read those files, not this note, for
anything beyond orientation.

**Two of its four sections carry a `Planned` subsection for a mechanism
that doesn't exist yet** (driving `simulation.step()` from a live render
loop, and checkpointing simulation state) -- each anchored to the
specific roadmap task that will build it (TASK-030, TASK-034) rather than
left open-ended, per the maintainer's direction that an unbuilt piece
gets a placeholder and a backlog anchor, not silence or a fabricated
mechanism. Both of those tasks' own `docs/planning/roadmap.md` entries
carry a matching note asking for `sequences.md` to be updated in the same
change that lands them -- check both notes still agree with reality
whenever either task is touched, the same "a diagram makes claims too"
discipline this directory already applies to `overview.md`'s system
diagram (below).
**One of its four sections still carries a `Planned` subsection for a
mechanism that doesn't exist yet** (checkpointing simulation state,
Section 3) -- anchored to the specific roadmap task that will build it
(TASK-034) rather than left open-ended, per the maintainer's direction
that an unbuilt piece gets a placeholder and a backlog anchor, not
silence or a fabricated mechanism. That task's own `docs/planning/
roadmap.md` entry carries a matching note asking for `sequences.md` to
be updated in the same change that lands it -- check the note still
agrees with reality whenever that task is touched, the same "a diagram
makes claims too" discipline this directory already applies to
`overview.md`'s system diagram (below).

**Section 2's own `Planned` subsection (driving `simulation.step()` from
a live render loop) was replaced with the real, built sequence 2026-08-28
(TASK-030)** -- `bootstrap.py`'s `_add_passive_scalar_transport`, the
first config to wire a real timestepping loop into an actual `pyflow
run`. Do not assume it is still a placeholder from an older reading of
this note.

`icds.md` (KA-030, Interface Contract Definitions -- the
user/configuration-facing interfaces PyFlow's components expose, *not*
Expand Down
14 changes: 10 additions & 4 deletions docs/architecture/engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -305,15 +305,21 @@ condition type.
schemes, `src/pyflow/engine/numerics/boundary_condition.py`
(`BoundaryCondition`, TASK-019 Boundary Condition Interface, Stage 3;
`DirichletBoundaryCondition`, TASK-028; `NeumannBoundaryCondition`,
TASK-029; both Stage 4, 2026-08-28). Periodic -- TASK-030 Periodic
Boundary (Stage 4), not yet built. The interface covers only the
TASK-029; both Stage 4, 2026-08-28). The interface covers only the
Dirichlet/Neumann shapes; periodic fits neither and is deliberately not
modelled (see that module's own docstring).
modelled there (see that module's own docstring).
`src/pyflow/engine/numerics/assembly.py` registers
`DirichletBoundaryCondition`/`NeumannBoundaryCondition` under
`"dirichlet"`/`"neumann"` -- the last two of the six `adr/ADR-003`
components to go real, retiring the module's final `_Null*` reference
implementation.
implementation. **Periodic -- TASK-030 Periodic Boundary (Stage 4),
built 2026-08-28.** Not a `BoundaryCondition` implementation at all: a
periodic face is mesh geometry, not a prescribed value, so
`StructuredCartesianMesh.wrapped_neighbour_cell(face) -> int`
(`src/pyflow/engine/mesh.py`) is the real mechanism, and
`assemble_numerics` threads a second mapping (`periodic_pairs`) into the
advection/diffusion factories alongside `boundary_conditions` for a
concrete scheme to consult at a periodic face instead.

**Upgrade path:** basic edge boundaries → mixed conditions → internal
boundaries → arbitrary surfaces/geometries (`upgrade-paths.md` "Boundary
Expand Down
15 changes: 15 additions & 0 deletions docs/architecture/icds.md
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,21 @@ Completion Criterion 1's carve-out for good**: all six `adr/ADR-003`
components now have a real concrete scheme, and zero `_Null*` reference
implementations remain in `assembly.py`.

**Done, TASK-030, 2026-08-28, periodic's own half -- Stage 4's last
task.** Not a `BoundaryCondition` implementation, as this document's own
"Expected behaviour" already anticipated (a "wrapped-neighbour reference"
is not a value or a gradient): `StructuredCartesianMesh.
wrapped_neighbour_cell(face) -> int` (`src/pyflow/engine/mesh.py`) is
the real mechanism, additive and off the abstract `Mesh` interface, since
"the opposite edge of the domain" has no meaning for a mesh with no
`(i, j)` structure. `assemble_numerics` still resolves no `Boundary
Condition` instance for a periodic face -- it now also builds a second,
separate mapping (`periodic_pairs`, `{face_name: opposite_face_name}`)
threaded into the advection/diffusion factories alongside
`boundary_conditions`, which is what a concrete scheme consults at a
periodic face instead. `mvp.md`'s "Periodic (where practical)" bullet is
now real, not aspirational.

---

## Not Yet Addressed: Plugin / Component Discovery
Expand Down
72 changes: 52 additions & 20 deletions docs/architecture/sequences.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,26 +131,58 @@ strategies.md` components `assemble_numerics` resolved. See `engine.md`
for why that's true and what it buys; this document only shows that it's
true, in sequence.

### Planned: driving `step()` from a live run

**Not built yet.** Nothing in a real `pyflow run` calls `step()` today --
`bootstrap()` assembles the six numerics components and hands them to
`window.assembled_numerics` for inspection, but never advances a field
over time. `RenderWindow.run(on_frame=...)` (`rendering/window.py`) is
the existing seam a future run will use: it already calls a caller-supplied
callback once per rendered frame, and its own docstring names this
exact purpose ("exactly what a future real-time simulation loop will
need"). What is missing is a caller that closes `on_frame` over a mutable
`fields` mapping and calls `simulation.step(fields, velocity, numerics,
dt)` inside it, then feeds the result back into whatever the render loop
draws.

This is **TASK-030**'s obligation, not a gap left open-ended: Stage 4's
own Completion Criterion 1 states directly that "TASK-030's golden demo
cannot be assembled at all" without a real simulation-stepping mechanism
running (`docs/planning/roadmap.md`). Update this subsection with the
real sequence once TASK-030 lands -- a note on that task's own roadmap
entry asks for the same thing in the same change.
### Built today: driving `step()` from a live run

**Built 2026-08-28, TASK-030 -- Stage 4's own Passive Scalar Transport
golden demo.** `bootstrap.py`'s `_add_passive_scalar_transport` is the
caller Section 2's earlier "Planned" note above described in advance: it
builds the transported field and prescribed velocity from
`config.simulation`, renders the first frame, and returns a closure that
`RenderWindow.run(on_frame=...)` (`rendering/window.py`) calls once per
rendered frame thereafter -- the exact seam that subsection's own
docstring anticipated ("exactly what a future real-time simulation loop
will need").

```mermaid
sequenceDiagram
participant bootstrap as bootstrap()
participant Add as _add_passive_scalar_transport
participant Window as RenderWindow.run()
participant Advance as on_frame closure
participant Step as simulation.step()
participant Viz as field_visualization

bootstrap->>Add: _add_passive_scalar_transport(window, mesh, config)
Add->>Viz: build_scalar_field_mesh(scalar_field, colors)
Viz-->>Add: gfx.Mesh (frame 0)
Add->>Window: scene.add(rendered_object)
Add-->>bootstrap: on_frame closure
bootstrap->>Window: window.run(max_frames, on_frame)
loop each rendered frame
Window->>Advance: on_frame()
Advance->>Step: step(state, velocity, numerics, dt)
Step-->>Advance: new state
Advance->>Window: simulation_fields = new state
Advance->>Viz: scalar_field_colors(new tracer, low, high, range)
Viz-->>Advance: per-cell RGBA colors
Advance->>Window: scene.remove(old object); scene.add(new object)
end
```

**Each frame's rendered `gfx.Mesh` is rebuilt from scratch, not mutated
in place.** `build_scalar_field_mesh`/`scalar_field_colors` are already
proven correct (TASK-017); an in-place colour-buffer mutation
(`geometry.colors.data[:] = ...`) would be new, unverified pygfx-API
surface for a small win on a small demo mesh -- a deliberate, recorded
deferral (`docs/planning/roadmap.md` TASK-030's own Design decision), not
an oversight. `gfx.Scene.remove` was verified directly (add then remove
leaves `len(scene.children) == 0`) before being relied on.

`RenderWindow.simulation_fields` is what lets a caller -- the golden
demo's own regression test, most directly -- read back the real field
state a rendered frame came from, the same "`bootstrap()` populates it,
`RenderWindow` itself holds no simulation content" shape
`assembled_numerics` already established (Section 3, below).

---

Expand Down
59 changes: 58 additions & 1 deletion docs/implementation/golden-demos.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,64 @@ implementation (`engine/numerics/assembly.py`'s own docstring explains
why one exists under `src/` at all: a real CLI subprocess needs
*something* to assemble into). This demo proves the assembly mechanism
works, not that PyFlow computes anything yet; the first demo that
computes real physics is Scalar Transport (Stage 4).
computes real physics is Passive Scalar Transport, below (Stage 4).

## Passive Scalar Transport

TASK-030's own golden demo (`docs/planning/roadmap.md`, Stage 4
Completion Criterion 1) -- PyFlow's first demo that computes real
physics, and the first `pyflow run` that steps a real simulation
forward *live*, one timestep per rendered frame, rather than rendering
one static picture. Called "Scalar Transport" in
`docs/planning/implementation-plan.md`'s own Golden Demos table and
`planning/data/demos.yaml` (`demo-scalar-transport`) -- the same demo,
named there before it was built; `mvp.md`'s own validation bullet and
this task's own roadmap entry both say "Passive scalar transport",
which is the name used here and for the real artifact
(`passive_scalar_transport.yaml`). Not reconciled across those two
documents in this change -- noted here rather than left silent, per the
Blast Radius rule's "if something in the radius cannot be updated now,
say so explicitly."

"Working" means, concretely:

- the demo *is* `examples/golden-demos/passive_scalar_transport.yaml` --
a `simulation` section naming a `gaussian_blob` initial condition and
a `uniform` prescribed velocity (`SimulationConfig`,
`src/pyflow/configuration/schema.py`), a `numerics` section whose
east/west boundaries are `periodic` and north/south are `neumann`
(zero gradient -- the prescribed velocity is purely horizontal, so
nothing crosses them, but diffusion still needs some condition there
regardless of flow direction), run via
`uv run python -m pyflow run --config examples/golden-demos/passive_scalar_transport.yaml`;
- a real `simulation.step()` call advances the field once per rendered
frame (`src/pyflow/bootstrap.py`'s `_add_passive_scalar_transport`,
wired through `RenderWindow.run(on_frame=...)`) -- checked directly,
not only by pixel-diffing: the field's own mass-weighted centroid
moves downstream by approximately the prescribed velocity times the
elapsed real time, measured across two independent real runs at
different frame counts (`tests/golden/test_passive_scalar_transport.py`),
within a tolerance derived from an actual measured run (~4% agreement),
not guessed;
- the periodic wrap is exercised by this live run, not only proven in
isolation: rendered offscreen at increasing frame counts, the blob is
seen translating downstream and, once total elapsed travel reaches one
full domain width, reappearing spread across both the east and west
edges -- verified visually during this task's own build, not asserted
by the regression test above (which checks displacement over a
shorter interval, before any wrap occurs, per its own module
docstring); the wrap's own correctness claim belongs to
`periodic_boundary.feature`, checked in isolation as a convergence
property (see that task's own Design decisions,
`docs/planning/roadmap.md` TASK-030);
- it runs headlessly via `--backend offscreen`, same as every other
demo.

**The velocity field is prescribed, not solved.** Stage 5 is what
eventually solves Navier-Stokes for real (`PressureCoupling`, real
momentum equations); this demo's velocity is a fixed, uniform vector
from configuration, transporting the scalar the same way a wind field
transports smoke without itself being computed from the smoke.

## Initial Golden Demo

Expand Down
13 changes: 11 additions & 2 deletions docs/planning/backlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -1920,15 +1920,24 @@ here.):
- **Advection scheme** -- **done, TASK-023 (Stage 4, 2026-08-27)**:
total transported quantity is conserved on a closed domain (every
boundary cell's velocity exactly zero, interior cells nonzero --
no periodic boundary exists yet to test the alternative reading),
no periodic boundary existed yet to test the alternative reading),
summing the field over every cell before and after many timesteps
to floating-point tolerance -- `tests/features/
first_order_upwind_advection.feature`'s own "Conservation on a
closed domain" scenario. (Was Stage 4 Completion Criterion 4's
own Advection bullet in `docs/planning/roadmap.md`, renumbered
2026-08-26 when TASK-040/Simulation Orchestrator was added as
this Stage's own new Criterion 1; discharged when TASK-023
landed rather than staying a backlog note.)
landed rather than staying a backlog note.) **A periodic boundary
now exists (TASK-030, Stage 4, 2026-08-28) -- the "alternative
reading" this note flagged is still not itself checked**:
`periodic_boundary.feature`'s own round-trip scenario measures
convergence of a field's *shape* toward its starting distribution
under mesh refinement, not total transported quantity summed
before/after under a periodic wrap specifically. A genuinely
distinct, still-open claim, found while closing this note rather
than left implied-done by periodic boundaries existing at all --
not added to this item's own scope without a decision to do so.
- **Diffusion scheme** -- **done, TASK-024 (Stage 4, 2026-08-27)**:
same conservation check under zero-flux (Neumann) boundaries as
Advection's above -- an insulated domain's field total is
Expand Down
Loading
Loading