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
28 changes: 25 additions & 3 deletions docs/implementation/config-template.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -115,10 +115,16 @@ simulation:
# Valid: null or "uniform", the only built-in pattern this field currently
# accepts. Invalid: any other string.
velocity_pattern: null
# Valid: a pair of finite numbers [vx, vy] -- a *prescribed*, not solved,
# constant velocity (Stage 5 is what eventually solves for velocity).
# Invalid: anything other than exactly two numbers.
# Valid: a pair of finite numbers [vx, vy] -- the initial velocity
# condition either way; whether it stays fixed or is transported afterward
# is velocity_solved below, not this field. Invalid: anything other than
# exactly two numbers.
velocity: [1.0, 0.0]
# Valid: true or false. false (default): velocity is prescribed -- held at
# its initial value every frame, Stage 4's own shape. true: velocity is
# solved -- transported by step alongside any scalar, self-advected by its
# own value.
velocity_solved: false

# Physical properties of the simulated fluid -- separate from numerics
# below, which selects numerical schemes and their solver tunables, not
Expand Down Expand Up @@ -193,21 +199,37 @@ numerics:
# scalar field is given at this face. Only read when type is
# "neumann"; harmless but unused otherwise.
scalar_gradient: 0.0
# Valid: a mapping of field name to a finite number -- a per-field
# override of scalar_value above, e.g. {u: 1.0, v: 0.0} for a moving
# lid's two velocity components. A field name absent from this mapping
# falls back to scalar_value. Only read when type is "dirichlet".
# Invalid: a non-finite value.
field_values: {}
# Valid: a mapping of field name to a finite number -- field_values'
# own Neumann counterpart, overriding scalar_gradient per field name.
# Only read when type is "neumann". Invalid: a non-finite value.
field_gradients: {}
south:
type: dirichlet
velocity: 0.0
pressure: null
scalar_value: 0.0
scalar_gradient: 0.0
field_values: {}
field_gradients: {}
east:
type: dirichlet
velocity: 0.0
pressure: null
scalar_value: 0.0
scalar_gradient: 0.0
field_values: {}
field_gradients: {}
west:
type: dirichlet
velocity: 0.0
pressure: null
scalar_value: 0.0
scalar_gradient: 0.0
field_values: {}
field_gradients: {}
118 changes: 100 additions & 18 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): **622 tests at 99% as of 2026-08-28**, having been 64 when
(C1a/C1b): **642 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 @@ -401,8 +401,20 @@ Section, Stage 5's first task): four new Gherkin scenarios in
(`fluid_configuration.feature`) and four new `FluidConfig.viscosity`
load/reject tests in `test_configuration.py`, the same shape every prior
config-section addition in this run used -- this is Stage 5's own first
climb, not another Stage 4 audit finding. **58 of those 622 are Gherkin
scenarios rather than pytest functions**
climb, not another Stage 4 audit finding. 642 after TASK-031 (Velocity
Field Support, Stage 5's second task, all four subtasks in one branch):
thirteen new Gherkin scenarios in `tests/unit/test_velocity_field_support.py`
(`velocity_field_support.feature`) covering the four subtasks together,
six new `SimulationConfig.velocity_solved`/`BoundaryFaceConfig.
field_values`/`field_gradients` load/reject tests in `test_configuration.py`
-- the same config-section-addition shape again, this time three small
fields across two sections rather than one -- and one new
`test_bootstrap.py` test proving `bootstrap()`'s own live loop actually
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**
(`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 @@ -452,7 +464,20 @@ scenarios: a fluid section loading both its fields, one field's default
surviving the other being set, the retired `numerics.diffusion_
coefficient` field rejected by name rather than silently defaulted, and
the Passive Scalar Transport golden demo still running through the real
CLI after its own config migrated to the new section).
CLI after its own config migrated to the new section); and to 71 with
TASK-031's own `velocity_field_support.feature`, thirteen scenarios
across its four subtasks: a `VectorField` decompose/reassemble round
trip plus its two rejection paths; viscosity and a scalar's own
diffusion coefficient each moving one field's flux and leaving the
other's alone, both directions; two ordinary scalars (not a velocity
pair) each seeing their own prescribed value at one shared wall,
independently of the other's; and velocity's own components advanced
by the same `step` call as a scalar, a transported scalar's result
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).
**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 @@ -6897,6 +6922,41 @@ Criterion 6 and Criterion 7, its own share.

Velocity Field Support

**Status: Done, 2026-08-29, Stage 5's second task -- all four subtasks
in one branch, per the roadmap's own "meant to be done in one session"
instruction.** Three design choices made while implementing, not
anticipated when this task was drafted, recorded here rather than left
implicit:

- **`IncompatibleVelocityFieldError` moved from `advection.py` to
`vector_field.py`.** Subtask (a)'s own rejection (a component count
disagreeing with the mesh's dimensionality) needed the same class
`AdvectionScheme._check_velocity` already raises, per subtask (d)'s
own criterion ("the existing named error") -- but `vector_field.py`
cannot import it back from `advection.py`, which already imports
`VectorField` from there. Co-located with `VectorField` instead
(`advection.py` now imports it from there); every other importer is
unaffected, since the name still resolves the same way through
`engine/numerics/__init__.py`'s own re-export.
- **`SimulationConfig.velocity_solved: bool`, not a widened
`velocity_pattern`.** A pattern says what shape the initial condition
has; solved-vs-prescribed says what happens to it afterward -- the
same switch-vs-configured-thing distinction this project already
learned once (`RenderingConfig.show_mesh`/`grid_color`,
`src/pyflow/configuration/CLAUDE.md`) and did not want to relearn here.
- **`bootstrap.py`'s own live-loop wiring (`_add_passive_scalar_transport`)
supports `velocity_solved` only alongside a configured `scalar_pattern`**
-- a velocity-only live run has no rendering path yet (no per-frame
vector-arrow display exists), so `velocity_solved` set without a
scalar is validated but has no visible effect through `bootstrap.py`
today. The mechanism itself (`step` transporting velocity's own
components) is proven directly against `simulation.step()`
(`tests/features/velocity_field_support.feature`), independent of this
gap; TASK-034's own Lid-Driven Cavity is the likely first real
consumer of velocity-only live rendering. Recorded here rather than
silently narrowed, the same honesty TASK-041's own Status note applied
to its own found gap.

**Intent:** velocity is the first field the engine *transports* rather
than merely stores. The distinction worth a criterion is that nothing
here may special-case velocity -- Stage 6 adds four more transported
Expand Down Expand Up @@ -7066,21 +7126,43 @@ whose.
its scenarios grouped by subtask, not four files: the subtasks are one
session's work and one task's claim, and `make check-scenarios` cares
that every scenario runs, not how many files they live in.
- `src/pyflow/engine/simulation.py` -- `step` advancing velocity's
components alongside any scalar (subtask d).
- `src/pyflow/configuration/schema.py` -- the solved-vs-prescribed
control on `SimulationConfig`, and whatever subtask (c)'s per-field
boundary values require. **Not the `fluid:` section itself, which is
TASK-041's.**
`tests/unit/test_velocity_field_support.py` binds them, per
`tests/unit/`'s own scope (isolated logic, no process boundary) --
every scenario is checked against the engine mechanism directly, none
needs a CLI subprocess.
- `src/pyflow/engine/vector_field.py` -- `VectorField.decompose`/
`.assemble`/`.component_name` (subtask a), plus
`IncompatibleVelocityFieldError`/`ComponentCountMismatchError`/
`ComponentMeshMismatchError` (moved and new, above). **This is where
the component-to-`VectorField` assembly helper landed** -- the
previously-open question resolved in favour of `vector_field.py` over
`simulation.py`, since `decompose`/`assemble` are properties of a
`VectorField`'s own shape, not of the orchestration loop.
- `src/pyflow/engine/numerics/boundary_condition.py` -- `Dirichlet
BoundaryCondition`/`NeumannBoundaryCondition` gain an `overrides:
Mapping[str, float]` constructor parameter, dispatched by `field.name`
at `evaluate()` time (subtask c). Every existing call site passing
only a value is unaffected.
- `src/pyflow/engine/numerics/diffusion.py` -- `CentralDifferenceDiffusion`
gains a `coefficient_overrides: Mapping[str, float]` constructor
parameter, the same per-field-name-dispatch shape (subtask b).
- `src/pyflow/engine/numerics/assembly.py` -- `assemble_numerics` gains
a `coefficient_overrides` parameter, threaded to the diffusion factory
via a new `_resolve_with_four_arguments`; `register_diffusion_scheme`'s
factory type widens to match. Stays field-name-agnostic itself --
which names get an override is decided by whoever calls it.
- `src/pyflow/bootstrap.py` -- `_add_passive_scalar_transport` decomposes/
reassembles velocity around each `step` call when `velocity_solved` is
set (subtask d, see this task's own Status note above for the one
scope limit), and threads the viscosity override into
`assemble_numerics`.
- `src/pyflow/configuration/schema.py` -- `SimulationConfig.
velocity_solved: bool` (the solved-vs-prescribed control), and
`BoundaryFaceConfig.field_values`/`field_gradients: dict[str, float]`
(subtask c's per-field boundary overrides). **Not the `fluid:` section
itself, which is TASK-041's.**
- `tools/generators/generate_config_template.py` -- comment entries for
any field the two bullets above add, which
`make check-config-template` gates.
- **Still genuinely open, and small: where the component-to-`VectorField`
assembly helper lives.** `vector_field.py` and `simulation.py` are both
defensible homes and no new module is obviously needed; that is a
choice to make while implementing rather than a design question to
escalate, and it is left unnamed here because a wrong path in prose is
a `make check-references` failure rather than a harmless guess.
the three fields above, which `make check-config-template` gates.

### 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

**34/42 tasks complete (81%)** across 14 planned stages. For the full plan, including
**35/42 tasks complete (83%)** 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" : 34
"Not started" : 8
"Done" : 35
"Not started" : 7
```

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

### Up next

**Stage 5 -- First Fluid Solver** is next, starting with TASK-031 (Velocity Field Support), 3 more not yet started in this stage.
**Stage 5 -- First Fluid Solver** is next, starting with TASK-032 (Pressure Field), 2 more not yet started in this stage.

## Live repository facts

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

## Stages

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

### Stage 5 -- First Fluid Solver

**no status recorded** -- `██░░░░░░░░` 1/5 tasks; 13 criteria defined, no status line yet
**no status recorded** -- `████░░░░░░` 2/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 -- Velocity Field Support | Not started | | |
| TASK-031 | Done | 2026-08-29 | `advection.py` |
| TASK-032 -- Pressure Field | Not started | | |
| 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.

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

## (root)
Expand Down Expand Up @@ -316,6 +316,7 @@ listing files.
- `piso_pressure_coupling.feature`
- `rk4_time_integration.feature`
- `simulation_orchestrator.feature`
- `velocity_field_support.feature`

## tests/golden

Expand Down Expand Up @@ -387,6 +388,7 @@ listing files.
- `test_structured_cartesian_mesh.py`
- `test_uniform_vertex_coordinate_system.py`
- `test_vector_field.py`
- `test_velocity_field_support.py`

## tests/unit/numerics

Expand Down
48 changes: 45 additions & 3 deletions src/pyflow/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,25 @@ def _add_passive_scalar_transport(
correct (TASK-017); an in-place buffer mutation would be new,
unverified pygfx-API surface for a small win on a small demo mesh
(TASK-030's own Design decision).

**`config.simulation.velocity_solved` (TASK-031, added 2026-08-29)**:
when true, velocity's own two components join `state` (decomposed
via `VectorField.decompose`) and are advanced by the same `step`
call as the scalar -- self-advected by the transporting `velocity`
itself, reassembled (`VectorField.assemble`) from the just-advanced
components after every frame so the *next* frame transports against
the current velocity, not the initial one. `simulation.py` itself
needs no change for this (Stage 5 Completion Criterion 1's own
structural clause): decompose-before/reassemble-after lives entirely
here, and `step` just sees more entries in `fields`. **Still requires
a scalar (`scalar_pattern`)** -- a velocity-only live run has nothing
this function knows how to render yet (no vector-arrow-per-frame
path exists), so `velocity_solved` set without `scalar_pattern` is
validated but has no visible effect through `bootstrap.py` today; the
mechanism itself is proven directly against `simulation.step()`
(`tests/features/velocity_field_support.feature`), not only through
this live path. Revisit when a demo genuinely needs velocity-only
live rendering (TASK-034's own Lid-Driven Cavity is the likely first).
"""
assert window.assembled_numerics is not None
numerics = window.assembled_numerics
Expand All @@ -170,7 +189,11 @@ def _add_passive_scalar_transport(
mesh, "velocity", num_components=2, initial_value=velocity_initializer
)

solved = config.simulation.velocity_solved
state: dict[str, Field] = {"tracer": scalar_field}
if solved:
for component in velocity_field.decompose():
state[component.name] = component
window.simulation_fields = state

colors = scalar_field_colors(
Expand All @@ -183,9 +206,16 @@ def _add_passive_scalar_transport(
window.scene.add(rendered_object)

def _advance() -> None:
nonlocal state, rendered_object
nonlocal state, rendered_object, velocity_field
state = simulation_step(state, velocity_field, numerics, config.numerics.timestep)
window.simulation_fields = state
if solved:
u_name = VectorField.component_name("velocity", 0)
v_name = VectorField.component_name("velocity", 1)
u, v = state[u_name], state[v_name]
assert isinstance(u, ScalarField)
assert isinstance(v, ScalarField)
velocity_field = VectorField.assemble([u, v], "velocity")
tracer = state["tracer"]
assert isinstance(tracer, ScalarField)
colors = scalar_field_colors(
Expand Down Expand Up @@ -302,9 +332,21 @@ def bootstrap(
# `config.fluid.diffusion_coefficient` (TASK-041, 2026-08-28) is
# threaded in explicitly -- it moved out of `NumericsConfig` into its
# own `fluid:` section, so `assemble_numerics` can no longer read it
# off `config.numerics` alone.
# off `config.numerics` alone. `coefficient_overrides` (TASK-031b,
# 2026-08-29): when velocity is solved, its own two components
# (`VectorField.component_name`) are diffused with `fluid.viscosity`
# instead of the scalar default -- this is the one place in the
# engine that legitimately knows a run's velocity field is
# conventionally named "velocity", so it is where that mapping is
# built, not inside `assemble_numerics`/`CentralDifferenceDiffusion`
# themselves (both stay field-name-agnostic).
coefficient_overrides = None
if config.simulation.velocity_solved:
coefficient_overrides = {
VectorField.component_name("velocity", i): config.fluid.viscosity for i in range(2)
}
window.assembled_numerics = assemble_numerics(
config.numerics, config.fluid.diffusion_coefficient
config.numerics, config.fluid.diffusion_coefficient, coefficient_overrides
)
logger.info("numerics assembled: %s", window.assembled_numerics.names)

Expand Down
Loading
Loading