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
69 changes: 58 additions & 11 deletions docs/planning/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ This paragraph previously said `make install` and `make test` were still
expected to fail, pending `uv.lock` and a test suite (B2/C1) -- stale
since 2026-08-16 and corrected 2026-08-19. Both now succeed: `uv.lock`
is committed (B2) and `make test` runs the suite with coverage
(C1a/C1b): **642 tests at 99% as of 2026-08-29**, having been 64 when
(C1a/C1b): **647 tests at 99% as of 2026-08-29**, having been 64 when
this paragraph was rewritten on 2026-08-19, 202 earlier the same day,
212 after TASK-014, 226 after TASK-015, 250 after TASK-016, 287 after
TASK-017, 297 after TASK-039, 315 after the Stage 2 exit audit and 337
Expand Down Expand Up @@ -413,8 +413,15 @@ fields across two sections rather than one -- and one new
decomposes/steps/reassembles velocity, not only `simulation.step()`
called directly (found necessary by its own coverage report: the new
`velocity_solved` branches in `_add_passive_scalar_transport` were
otherwise unexercised by anything in this run). **71 of those 642 are
Gherkin scenarios rather than pytest functions**
otherwise unexercised by anything in this run). 647 after TASK-032
(Pressure Field, Stage 5's third task): five new Gherkin scenarios in
`tests/unit/test_pressure_field.py` (`pressure_field.feature`), proving
properties `PISO` (TASK-027, Stage 4) already computed but Stage 4's own
criteria never had cause to check -- constant pressure for a
divergence-free provisional field, the null-space remedy actually
holding, `step` rejecting a `PressureField` -- against the real `PISO`
class throughout, no new pressure-solving mechanism. **76 of those 647
are Gherkin scenarios rather than pytest functions**
(`adr/ADR-007-executable-acceptance-criteria.md`; up from fourteen with
`field_display.feature` gaining scenarios and `numerics_assembly.feature`
joining, TASK-021; to 24 with TASK-040's own
Expand Down Expand Up @@ -477,7 +484,15 @@ unchanged by whether the velocity carrying it was solved or prescribed,
self-advection matching a hand-derived result, the existing
`IncompatibleVelocityFieldError` check surviving the new path, and the
orchestrator's own source still carrying no field-name-specific
branching for velocity).
branching for velocity); and to 76 with TASK-032's own
`pressure_field.feature`, five scenarios: a divergence-free provisional
field yielding pressure constant to solver tolerance, a divergent one
yielding non-constant pressure, adding a constant to the solved pressure
leaving the corrected velocity unchanged (the null-space remedy made
observable), `step` rejecting a `fields` mapping containing a
`PressureField` by name, and a boundary configuration violating the
zero-net-flux compatibility condition failing to load before any
pressure solve is attempted.
**All** `make ci`
targets pass, verified via the Makefile itself, not only via `uv tool
run` in isolation -- that is `lint`, `typecheck`, `test`, `check-docs`,
Expand Down Expand Up @@ -7196,6 +7211,32 @@ is TASK-041's**. Criterion 6 and Criterion 7, its own share.

Pressure Field

**Status: Done, 2026-08-29, Stage 5's third task.** `PISO` (TASK-027,
Stage 4) already performs the solve this task's own criteria describe;
this task adds no new pressure-solving mechanism, only the properties
Stage 4's own criteria never had cause to check and the one genuinely
missing API-level guard:

- **The local design question resolved: pressure gets a type of its
own, `PressureField(ScalarField)`** (`src/pyflow/engine/scalar_field.py`)
-- a marker subclass with no behaviour of its own, `PISO.correct` now
constructs one instead of a plain `ScalarField`. `simulation.step`
gains a real `isinstance` check, raising `PressureFieldTransportError`
(a new class, `simulation.py`) if `fields` contains one -- the "stated
at the API level" shape Criterion 2 asks for, checked directly against
the object handed in rather than by name.
- **`PressureField` lives in `scalar_field.py`, not
`engine/numerics/pressure_coupling.py`** (its one real producer) --
`simulation.py` needs to import it and cannot import from
`pressure_coupling.py` without a circular import (that module already
imports `accumulate_flux_to_cells` from `simulation.py`). The same
circular-import reasoning TASK-031a's own `IncompatibleVelocityFieldError`
move used.
- **`PressureCoupling.correct`'s own abstract signature is unchanged**
(`-> tuple[VectorField, ScalarField]`) -- no Stage 3 interface change;
a `PressureField` instance already satisfies it (valid covariant
return narrowing under `mypy --strict`).

**Intent:** pressure is *not* transported -- it is solved for, from the
incompressibility constraint. A criterion that treats it as another
advected scalar has misunderstood the task. See
Expand Down Expand Up @@ -7228,13 +7269,19 @@ empty finds an answer next to it.
### Artifacts Produced

- `tests/features/pressure_field.feature` -- this task's Acceptance
Criteria.
- Source artifacts named when this task is drafted. **TASK-031's own
design questions are no longer what this waits on** -- all three were
answered on 2026-08-28 -- but one local question genuinely remains
this task's to decide: whether pressure needs a type of its own, or is
a `ScalarField` the coupling owns. Nothing before it has cause to
choose, so it is not escalated as a stage-level design question.
Criteria. `tests/unit/test_pressure_field.py` binds them, per
`tests/unit/`'s own scope (isolated logic, no process boundary) --
every scenario is checked against the real `PISO`/`GreenGaussGradient`
machinery directly, none needs a CLI subprocess.
- `src/pyflow/engine/scalar_field.py` -- `PressureField(ScalarField)`,
resolving this task's one local design question (a type of its own,
not a plain `ScalarField` the coupling owns).
- `src/pyflow/engine/simulation.py` -- `step` gains an `isinstance`
guard against `PressureField`, raising a new
`PressureFieldTransportError`.
- `src/pyflow/engine/numerics/pressure_coupling.py` -- `PISO.correct`
constructs a `PressureField` instead of a plain `ScalarField`; no
signature change.

### Acceptance Criteria

Expand Down
16 changes: 8 additions & 8 deletions docs/planning/status.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,14 @@ demand, not part of this file.

## Progress

**35/42 tasks complete (83%)** across 14 planned stages. For the full plan, including
**36/42 tasks complete (86%)** across 14 planned stages. For the full plan, including
stages below not yet broken into tasks: [roadmap.md](roadmap.md).

```mermaid
pie showData
title "Tasks across the roadmap"
"Done" : 35
"Not started" : 7
"Done" : 36
"Not started" : 6
```

### Milestones
Expand All @@ -35,13 +35,13 @@ pie showData

### Up next

**Stage 5 -- First Fluid Solver** is next, starting with TASK-032 (Pressure Field), 2 more not yet started in this stage.
**Stage 5 -- First Fluid Solver** is next, starting with TASK-033 (Pressure Correction Loop), 1 more not yet started in this stage.

## Live repository facts

- **45** `CLAUDE.md` files
- **642** tests collected
- **71** Gherkin scenarios (`tests/features/*.feature`)
- **647** tests collected
- **76** Gherkin scenarios (`tests/features/*.feature`)

## Stages

Expand Down Expand Up @@ -115,13 +115,13 @@ pie showData

### Stage 5 -- First Fluid Solver

**no status recorded** -- `鈻堚枅鈻堚枅鈻戔枒鈻戔枒鈻戔枒` 2/5 tasks; 13 criteria defined, no status line yet
**no status recorded** -- `鈻堚枅鈻堚枅鈻堚枅鈻戔枒鈻戔枒` 3/5 tasks; 13 criteria defined, no status line yet

| Task | Status | Date | Artifact |
|------|--------|------|----------|
| TASK-041 | Done | 2026-08-28 | `src/pyflow/engine/numerics/assembly.py` |
| TASK-031 | Done | 2026-08-29 | `advection.py` |
| TASK-032 -- Pressure Field | Not started | | |
| TASK-032 | Done | 2026-08-29 | `src/pyflow/engine/scalar_field.py` |
| TASK-033 -- Pressure Correction Loop | Not started | | |
| TASK-034 -- Navier-Stokes Timestep | Not started | | |

Expand Down
4 changes: 3 additions & 1 deletion docs/repository-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ reading job and lives in the manifest. Test counts and coverage are
not here either -- those come from running the suite, not from
listing files.

**274 tracked files** across 45 directories;
**276 tracked files** across 45 directories;
4 are empty.

## (root)
Expand Down Expand Up @@ -314,6 +314,7 @@ listing files.
- `passive_scalar_transport.feature`
- `periodic_boundary.feature`
- `piso_pressure_coupling.feature`
- `pressure_field.feature`
- `rk4_time_integration.feature`
- `simulation_orchestrator.feature`
- `velocity_field_support.feature`
Expand Down Expand Up @@ -381,6 +382,7 @@ listing files.
- `test_neumann_boundary.py`
- `test_periodic_boundary.py`
- `test_piso_pressure_coupling.py`
- `test_pressure_field.py`
- `test_rendering.py`
- `test_rk4_time_integration.py`
- `test_scalar_field.py`
Expand Down
41 changes: 41 additions & 0 deletions src/pyflow/engine/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,22 @@ implementation without per-implementation casts.
claims (plain `float` return, exact formula, copy independence for its
own storage).

**`PressureField(ScalarField)` (TASK-032, added 2026-08-29) is a thin
marker subclass, no new behaviour at all.** Stage 5 Completion
Criterion 2's own "pressure is not among the fields `step` advances"
needed pressure to be identifiable at the API level -- a real
`isinstance` check, since there is no configuration surface that names
which fields are transported. `PISO.correct`
(`engine/numerics/pressure_coupling.py`) constructs one instead of a
plain `ScalarField`; `simulation.step` raises
`PressureFieldTransportError` if `fields` contains one. Lives here, not
in `pressure_coupling.py` (its one real producer), because
`simulation.py` needs to import it and cannot import from
`pressure_coupling.py` without a circular import -- the identical
reasoning `IncompatibleVelocityFieldError`'s own TASK-031a move to
`vector_field.py` used, applied to the same shape of problem a second
time.

**There are two contract suites here, one per interface, and the split
is load-bearing** (2026-08-22, Stage 2 exit audit -- `docs/planning/
roadmap.md`). `field.py` deliberately carries no storage so that a
Expand Down Expand Up @@ -962,6 +978,21 @@ boundedly reduces divergence; non-convergence is reported, not returned
as a plausible answer) is `tests/features/piso_pressure_coupling.feature`,
bound by `tests/unit/test_piso_pressure_coupling.py`.

**`correct` constructs a `PressureField`, not a plain `ScalarField`,
since TASK-032 (Stage 5, 2026-08-29).** No signature change --
`PressureCoupling.correct`'s own abstract return type stays
`tuple[VectorField, ScalarField]`, and a `PressureField` instance
already satisfies it (covariant return narrowing). This is what makes
`simulation.step`'s own new `PressureFieldTransportError` guard
meaningful: `PISO`'s real output is now identifiable as pressure at the
API level, not just by the string `"pressure"` its constructor happens
to name it. Its own null-space property (adding a constant to the
solved pressure leaves the corrected velocity unchanged) and the
divergence-free/divergent constant-vs-non-constant pressure pair are
`tests/features/pressure_field.feature`, bound by `tests/unit/
test_pressure_field.py` -- properties this class's own math already had,
proven directly against it rather than reimplemented.

**`gradient.py`/`divergence.py`** (TASK-018, Stage 3, interface-only
until TASK-027) hold `GradientScheme`/`DivergenceScheme` -- two of the
three operators (with `source.py`) that jointly compute the Flux layer
Expand Down Expand Up @@ -1292,6 +1323,16 @@ the buildable reading chosen instead, stated explicitly per root
`CLAUDE.md`'s Integrity section -- see TASK-040's own entry in
`docs/planning/roadmap.md` for the full reasoning.

**`PressureFieldTransportError` (TASK-032, added 2026-08-29) is
`step`'s second rejection, checked in the same per-field loop as
`MismatchedMeshError`, before either.** Raised if `fields` contains a
`PressureField` (`scalar_field.py`, above) -- Stage 5 Completion
Criterion 2's own claim that pressure is solved, not transported, made
real: a config-level check was not possible (`SimulationConfig` names no
surface for "which fields are transported" at all), so this is a real
`isinstance` check against the object itself, the same "stated at the
API level" shape the criterion asks for.

**`simulation_orchestrator.feature` is not a golden demo** -- no config
file under `examples/golden-demos/`, no CLI subprocess run, since this
is the mechanism a future demo (TASK-030) is built on top of, not a demo
Expand Down
4 changes: 2 additions & 2 deletions src/pyflow/engine/numerics/pressure_coupling.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
from pyflow.engine.numerics.divergence import GreenGaussDivergence
from pyflow.engine.numerics.gradient import GreenGaussGradient
from pyflow.engine.numerics.linear_solver import LinearSolver
from pyflow.engine.scalar_field import ScalarField
from pyflow.engine.scalar_field import PressureField, ScalarField
from pyflow.engine.simulation import accumulate_flux_to_cells
from pyflow.engine.vector_field import VectorField

Expand Down Expand Up @@ -172,7 +172,7 @@ def correct(
f"pressure correction did not converge in {result.iterations} iterations"
)

pressure = ScalarField(mesh, "pressure")
pressure = PressureField(mesh, "pressure")
pressure.values[:] = result.solution

corrected = provisional_velocity.copy()
Expand Down
35 changes: 35 additions & 0 deletions src/pyflow/engine/scalar_field.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
"""ScalarField (TASK-015): a single value per cell -- the collocated
scalar leaf of the `Field` hierarchy. See `collocated_field.py` for the
shared storage/initialisation/access logic this builds on.

**`PressureField` (TASK-032, added 2026-08-29)** is a thin marker
subclass -- see its own docstring below for why it exists and why it
lives here rather than in `engine/numerics/pressure_coupling.py`.
"""

from __future__ import annotations
Expand Down Expand Up @@ -33,3 +37,34 @@ def copy(self) -> ScalarField:
clone = ScalarField(self.mesh, self.name)
clone._values = self._values.clone()
return clone


class PressureField(ScalarField):
"""A `ScalarField` that is specifically pressure -- solved from the
incompressibility constraint, never transported (Stage 5 Completion
Criterion 2, `docs/planning/roadmap.md` TASK-032: "pressure is not
among the fields `step` advances, and handing `step` a `fields`
mapping that contains the pressure field raises a named error").

Carries no behaviour of its own beyond `ScalarField`'s -- purely a
type marker `simulation.step` can `isinstance`-check, the "stated at
the API level deliberately, not the configuration level" shape
Criterion 2 asks for: there is no configuration surface that names
which fields are transported, so the guard has to live where a real
`Field` object is actually in hand.

**Lives here, not in `engine/numerics/pressure_coupling.py` (this
class's one real producer, `PISO.correct`), because `simulation.py`
needs to import it and cannot import from `pressure_coupling.py`
without a circular import** -- that module already imports
`accumulate_flux_to_cells` from `simulation.py`. The same reasoning
`IncompatibleVelocityFieldError`'s own TASK-031a move to
`vector_field.py` used (`src/pyflow/engine/CLAUDE.md`'s
`vector_field.py` entry).

`PressureCoupling.correct`'s own abstract signature is unchanged
(`-> tuple[VectorField, ScalarField]`) -- no Stage 3 interface
change, since a `PressureField` instance already satisfies it
(covariant return narrowing, valid under `mypy --strict`). Only
`PISO`'s own concrete implementation constructs one.
"""
21 changes: 20 additions & 1 deletion src/pyflow/engine/simulation.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

from pyflow.engine.field import Field
from pyflow.engine.mesh import Mesh
from pyflow.engine.scalar_field import PressureField
from pyflow.engine.vector_field import VectorField

if TYPE_CHECKING:
Expand All @@ -45,6 +46,18 @@ class MismatchedMeshError(ValueError):
"""


class PressureFieldTransportError(ValueError):
"""Raised when `step`'s own `fields` mapping contains a
`PressureField` (TASK-032, Stage 5 Completion Criterion 2,
`docs/planning/roadmap.md`): pressure is solved from the
incompressibility constraint, not transported, so quietly advecting
it would silently discard exactly the property that makes it
meaningful. Stated at the API level -- a real `isinstance` check
against the field object itself, since there is no configuration
surface today that names which fields are transported.
"""


def accumulate_flux_to_cells(mesh: Mesh, face_values: torch.Tensor) -> torch.Tensor:
"""Reduce a face-valued array to a cell-valued one via the discrete
Gauss theorem: `sum(value * area * outward_normal_sign) / volume`,
Expand Down Expand Up @@ -122,10 +135,16 @@ def step(
decisions for the recorded version of this paragraph.

Raises `MismatchedMeshError` if any field in `fields` is not defined
over the same mesh as `velocity`.
over the same mesh as `velocity`, and `PressureFieldTransportError`
(TASK-032) if `fields` contains a `PressureField` -- pressure is
solved from the incompressibility constraint, never transported.
"""
mesh = velocity.mesh
for name, field in fields.items():
if isinstance(field, PressureField):
raise PressureFieldTransportError(
f"field {name!r} is a PressureField -- pressure is solved, not transported"
)
if field.mesh is not mesh:
raise MismatchedMeshError(
f"field {name!r} is defined over a different mesh than the velocity field"
Expand Down
48 changes: 48 additions & 0 deletions tests/features/pressure_field.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# The acceptance criteria for Pressure Field (TASK-032, Stage 5's third
# task in build order). Not a golden demo -- no config file under
# `examples/golden-demos/`, no CLI subprocess run, since every claim here
# is checked against the engine mechanism directly, the same
# `tests/unit/` shape every prior numerical-scheme feature file in this
# stage established. `tests/unit/test_pressure_field.py` binds these
# scenarios.
#
# Pressure is *not* transported -- it is solved for, from the
# incompressibility constraint. A criterion that treats it as another
# advected scalar has misunderstood the task
# (`docs/handbook/numerical-methods/pressure-velocity-coupling.md`).
# `PISO` (TASK-027, Stage 4) already performs the solve this task's own
# criteria describe -- these scenarios prove the properties Stage 4's
# own criteria never had cause to check (constant pressure for a
# divergence-free input, the null-space remedy actually holding), not a
# new pressure-solving mechanism.

Feature: Pressure Field

Background:
Given a small, non-square, non-trivially-origined mesh

Scenario: A divergence-free provisional velocity field yields a pressure field constant to solver tolerance
Given a uniform provisional velocity field with zero-gradient boundaries on every wall
When the field is corrected by one PISO pass
Then the solved pressure field is constant to solver tolerance

Scenario: A provisional velocity field with known nonzero divergence yields a pressure field that is not constant
Given a provisional velocity field with real interior divergence, not aligned with either mesh axis
When the field is corrected by one PISO pass
Then the solved pressure field is not constant

Scenario: Adding a constant to the pressure field leaves the corrected velocity unchanged
Given a provisional velocity field with real interior divergence, not aligned with either mesh axis
When the field is corrected by one PISO pass
And a nonzero constant is added to the solved pressure field everywhere
Then the velocity correction computed from the shifted pressure field matches the original exactly

Scenario: A fields mapping containing a pressure field is rejected by step, not silently transported
Given a pressure field produced by a real PISO correction
When the simulation is stepped with that pressure field included among the transported fields
Then a named error says pressure is not transported

Scenario: A boundary configuration whose prescribed velocities violate the zero-net-flux compatibility condition fails to load
Given a configuration prescribing a nonzero net velocity flux across all four boundaries
When the configuration is loaded
Then loading is rejected before any pressure solve could be attempted
Loading
Loading