Skip to content
Open
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
23 changes: 23 additions & 0 deletions docs/implementation/golden-demos.md
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,16 @@ reproducible, visible run rather than re-validated.
implementation;
- it runs headlessly via `--backend offscreen`, same as every other demo.

**`pyflow run` also draws the solved velocity as arrows alongside the
temperature panel now (added 2026-09-09)** -- until then
`_add_declared_field_transport` colour-mapped the declared field but
never rendered the velocity carrying it, a gap `playback.py`'s own
combined `play()` path (TASK-051, above) closed for `pyflow play` first
and explicitly flagged as separately open for `pyflow run`
(`src/pyflow/CLAUDE.md`'s `playback.py` entry). Closed the same day; see
`tests/unit/test_bootstrap.py::
test_bootstrap_with_velocity_solved_and_a_declared_field_also_draws_arrows`.

## Multi-Field Plume

**Stage 6's own claim, made runnable, and the one demo it was missing.**
Expand Down Expand Up @@ -525,6 +535,11 @@ none of them shows what the stage claims".
every other scenario;
- it runs headlessly via `--backend offscreen`, same as every other demo.

**`pyflow run` also draws the solved velocity carrying all four fields
as arrows now (added 2026-09-09)**, the same fix applied to Thermal
Buoyancy and Smoke Transport below -- see those sections' own entries
for the full history.

## Smoke Transport

TASK-038's own golden demo -- `docs/planning/implementation-plan.md`
Expand Down Expand Up @@ -556,6 +571,14 @@ now also carrying a declared `smoke` field.
that proof;
- it runs headlessly via `--backend offscreen`, same as every other demo.

**`pyflow run` itself also draws the solved velocity as arrows alongside
the smoke panels now (added 2026-09-09)** -- the live counterpart to the
`pyflow play` combined rendering below, closing the gap that fix's own
commit explicitly flagged rather than closed
(`src/pyflow/CLAUDE.md`'s `playback.py` entry); see
`tests/unit/test_bootstrap.py::
test_bootstrap_with_velocity_solved_and_a_declared_field_also_draws_arrows`.

**Also this stage's own combined-rendering demo for `pyflow play`
(TASK-051, Stage 8 reopening, added 2026-09-09).** Solved velocity plus
a declared field is exactly the config shape playback used to reject
Expand Down
8 changes: 7 additions & 1 deletion docs/planning/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,13 @@ 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): **1199 tests as of 2026-09-09**, up from 1196 the same day
(C1a/C1b): **1200 tests as of 2026-09-09**, up from 1199 the same day
(`_add_declared_field_transport` also drawing the solved velocity as
arrows when `config.simulation.velocity_solved` is set, alongside its
declared fields' own panels -- the live `pyflow run` counterpart to
TASK-051's `pyflow play` fix below, closing the gap that fix's own
commit explicitly flagged rather than closed: 1 in
`tests/unit/test_bootstrap.py`), 1199 itself up from 1196 the same day
(TASK-051, Combined Solved-Velocity + Declared-Field Playback: 1 in
`tests/unit/test_playback.py` (a materialized frame's own panel colours
matching an independently live-stepped run's, `rtol=0, atol=0`), 2 in
Expand Down
2 changes: 1 addition & 1 deletion docs/planning/status.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ pie showData
## Live repository facts

- **49** `CLAUDE.md` files
- **1199** tests collected
- **1200** tests collected
- **144** Gherkin scenarios (`tests/features/*.feature`)

## Stages
Expand Down
7 changes: 7 additions & 0 deletions examples/golden-demos/multi_field_plume.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,13 @@ field_display:
# The other three are transported all the same -- which is why this
# demo's own report, not its rendered frame, is what demonstrates it
# (`tests/features/multi_field_plume.feature`).
#
# `_add_declared_field_transport` also draws the solved velocity
# carrying all four fields as arrows now (added 2026-09-09, closing the
# gap `playback.py`'s own combined `play()` path, TASK-051, flagged) --
# P-019 requires the same name-the-quantity treatment
# `lid_driven_cavity.yaml`'s own arrows already get.
vector_label: Velocity (model units)
panels:
- field: temperature
value_range: [0.0, 1.0]
Expand Down
6 changes: 6 additions & 0 deletions examples/golden-demos/smoke_transport.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ fluid:
field_display:
low_color: "#0a0a2a"
high_color: "#e8e8ff"
# `_add_declared_field_transport` also draws the solved velocity
# carrying `smoke` as arrows now (added 2026-09-09, closing the gap
# `playback.py`'s own combined `play()` path, TASK-051, flagged) --
# P-019 requires the same name-the-quantity treatment
# `lid_driven_cavity.yaml`'s own arrows already get.
vector_label: Velocity (model units)
# Two modular panels (`field_display.panels`), both showing the same
# `smoke` field -- added at a user's direct request after watching
# this exact demo: smoke decays toward zero over the run, and a fixed
Expand Down
6 changes: 6 additions & 0 deletions examples/golden-demos/thermal_buoyancy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ fluid:
field_display:
low_color: "#0a0a2a"
high_color: "#ff4400"
# `_add_declared_field_transport` also draws the solved velocity
# carrying `temperature` as arrows now (added 2026-09-09, closing the
# gap `playback.py`'s own combined `play()` path, TASK-051, flagged) --
# P-019 requires the same name-the-quantity treatment
# `lid_driven_cavity.yaml`'s own arrows already get.
vector_label: Velocity (model units)
panels:
- field: temperature
value_range: [0.0, 1.0]
Expand Down
31 changes: 21 additions & 10 deletions src/pyflow/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -379,16 +379,27 @@ build new" way `_rebuild_arrows`/`_rebuild_thumb` already are.
-- no solved velocity at all (Heat Diffusion's own shape) -- not "any
declared field", which TASK-046/047's own first cut used to reject too.

**This is a real, deliberate widening beyond what `bootstrap.py`'s own
live `_add_declared_field_transport` currently does for the identical
config shape** -- that path draws only the declared fields' panels,
never arrows for a solved velocity alongside them (see this file's own
entry for it, above: "smoke_transport.yaml/thermal_buoyancy.yaml...
don't [set vector_label], because that path has never drawn velocity
as arrows at all"). `playback.py` is not obligated to reproduce a gap
in `pyflow run` just because the two share history; flagged separately
as a possible follow-up rather than fixed here (out of this task's own
scope) or silently matched (worse for a viewer, for no real reason).
**This was a real, deliberate widening beyond what `bootstrap.py`'s own
live `_add_declared_field_transport` did for the identical config shape
at the time this task landed** -- that path drew only the declared
fields' panels, never arrows for a solved velocity alongside them.
`playback.py` was not obligated to reproduce a gap in `pyflow run` just
because the two share history, so this task flagged it separately as a
possible follow-up rather than fixing it as part of this task's own
scope, or silently matching it (worse for a viewer, for no real reason).

**Closed the same day, in a separate change: `_add_declared_field_transport`
now also draws the solved velocity as arrows** (added 2026-09-09, own
regression test `tests/unit/test_bootstrap.py::
test_bootstrap_with_velocity_solved_and_a_declared_field_also_draws_arrows`),
the live `pyflow run` counterpart to this task's own `pyflow play` fix,
reusing the same `field_visualization.build_vector_field_arrows` this
module's `_add_solved_velocity_rendering` already calls. `smoke_transport.yaml`,
`thermal_buoyancy.yaml` and `multi_field_plume.yaml` all gained a
`field_display.vector_label` in the same change -- P-019
(`tests/unit/test_golden_demo_annotations.py`) requires one on every
demo that draws arrows, and none of the three needed one while this
path drew none.

**`panel_colors`/`panel_caption`/`build_panel_legend`/`PanelRenderState`
were extracted from `bootstrap.py`'s own private `_panel_colors`/
Expand Down
103 changes: 89 additions & 14 deletions src/pyflow/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ def _add_legend(

def _add_declared_field_transport(
window: RenderWindow, mesh: Mesh, config: PyFlowConfig
) -> tuple[Callable[[], None], _Bounds]:
) -> tuple[Callable[[], None], _Bounds, Callable[[], bool]]:
"""Wires a real `simulation.step()` into a live `pyflow run`
(Stage 4 Completion Criterion 1, TASK-030) -- the mechanism the
Passive Scalar Transport golden demo needs and no demo before it
Expand Down Expand Up @@ -313,6 +313,25 @@ def _add_declared_field_transport(
happens to it after frame zero, not what it starts as
(`src/pyflow/configuration/CLAUDE.md`).

**Also draws the solved velocity as arrows, rebuilt every frame the
same "remove old, build new" way as `_add_solved_velocity_rendering`
(added 2026-09-09).** Until now this function colour-mapped the
declared fields' own panels but never rendered the velocity carrying
them at all -- reachable from two shipped demos, Smoke Transport and
Thermal Buoyancy, whose own `pyflow run` showed no indication of
flow direction or magnitude despite a real, pressure-corrected
velocity driving the transport. `playback.py`'s combined `play()`
path (TASK-051, Stage 8 reopening) built the identical combined
rendering for `pyflow play` first and explicitly flagged this path as
a separate, pre-existing gap rather than fixing it as part of that
task's own scope (`src/pyflow/CLAUDE.md`'s `playback.py` entry) --
this closes it. The third return value, `arrows_drawn`, is this
path's own per-frame query, mirroring
`_add_solved_velocity_rendering`'s identically-named closure: `False`
always when `config.simulation.velocity_solved` is unset, and a real
per-frame answer otherwise, since a velocity starting from rest draws
nothing on frame zero the same way it does on that other path.

**State construction and per-frame advance moved to `simulation_run.
build_simulation_state`/`advance_simulation_state` (TASK-045, Stage 8,
2026-09-07)** -- `recording.py`'s own headless path needs the
Expand Down Expand Up @@ -381,6 +400,28 @@ def _add_declared_field_transport(
state: SimulationState = built_state
window.simulation_fields = state.fields

# **Arrows for the solved velocity carrying these fields (added
# 2026-09-09), mirroring `_add_solved_velocity_rendering`'s own
# "remove old, build new" shape.** Until now this function drew only
# the declared fields' own panels, never the velocity itself, even
# when `config.simulation.velocity_solved` is true -- a real,
# previously-flagged gap (this function's own comment used to read
# "a velocity-only live run has nothing this function knows how to
# render yet", and `playback.py`'s combined `play()` path (TASK-051)
# closed the identical gap for `pyflow play` while explicitly leaving
# this one open as "a pre-existing, separately flagged gap"). No
# separate bounds contribution: arrows are drawn over the same mesh
# extent the panels' own `bounds` already covers.
rendered_arrows_object: gfx.Line | None = None
if config.simulation.velocity_solved:
velocity_field = velocity_field_from_state(state)
rendered_arrows_object = build_vector_field_arrows(
velocity_field, config.field_display.arrow_color, config.field_display.arrow_scale
)
if rendered_arrows_object is not None:
rendered_arrows_object.local.position = (0.0, 0.0, _ARROWS_Z)
window.scene.add(rendered_arrows_object)

mesh_width = bounds[2] - bounds[0]
mesh_height = bounds[3] - bounds[1]
panel_states = [
Expand Down Expand Up @@ -457,9 +498,19 @@ def _add_declared_field_transport(
)

def _advance() -> None:
nonlocal state
nonlocal state, rendered_arrows_object
state = advance_simulation_state(state, numerics, config.numerics.timestep)
window.simulation_fields = state.fields
if config.simulation.velocity_solved:
velocity_field = velocity_field_from_state(state)
if rendered_arrows_object is not None:
window.scene.remove(rendered_arrows_object)
rendered_arrows_object = build_vector_field_arrows(
velocity_field, config.field_display.arrow_color, config.field_display.arrow_scale
)
if rendered_arrows_object is not None:
rendered_arrows_object.local.position = (0.0, 0.0, _ARROWS_Z)
window.scene.add(rendered_arrows_object)
# Note for anyone inspecting `window.scene.children` order (found
# while fixing `tests/unit/test_field_declaration_configuration.
# py` for Stage 7's own legend addition): after each panel's own
Expand Down Expand Up @@ -489,7 +540,19 @@ def _advance() -> None:
float(rendered_field.values.min()), float(rendered_field.values.max())
)

return _advance, overall_bounds
def _arrows_drawn() -> bool:
"""Queried per frame, not captured once -- the same reason
`_add_solved_velocity_rendering`'s own identically-named closure
is: a solved velocity starting from rest draws no arrows on
frame zero, and this function's own `config.simulation.
velocity_solved` fixtures (Smoke Transport, Thermal Buoyancy) can
start at rest too. Always `False` when `velocity_solved` is
unset -- `rendered_arrows_object` never leaves `None` in that
case.
"""
return rendered_arrows_object is not None

return _advance, overall_bounds, _arrows_drawn


def _add_solved_velocity_rendering(
Expand Down Expand Up @@ -722,9 +785,10 @@ def _arrows_drawn_constantly(drawn: bool) -> Callable[[], bool]:
"""`_add_hud`'s `show_vector_scale` for a path whose answer cannot
change during the run: a static `vector_pattern` either drew arrows
when the scene was built or it never will, and a run drawing no
arrows at all stays that way. Only the live velocity path
(`_add_solved_velocity_rendering`) needs a genuine per-frame query,
and it supplies its own.
arrows at all stays that way. Only the two live paths that render a
solved velocity (`_add_solved_velocity_rendering`,
`_add_declared_field_transport` when `velocity_solved` is set) need a
genuine per-frame query, and each supplies its own.
"""
return lambda: drawn

Expand Down Expand Up @@ -984,10 +1048,13 @@ def bootstrap(
# unaffected by this addition.
run_velocity_only_simulation = config.simulation.velocity_solved and not config.fields
run_simulation = run_scalar_simulation or run_velocity_only_simulation
# Vectors are drawn as arrows by two different paths (a static
# `vector_pattern`, or a live, velocity-only solved run) -- neither
# implies the other, so both report separately below, and `False`
# here is the answer for a run that takes neither path.
# Vectors are drawn as arrows by up to three independent paths (a
# static `vector_pattern`, a live velocity-only solved run, or --
# since 2026-09-09 -- `_add_declared_field_transport`'s own solved
# velocity alongside its declared fields) -- none implies another, so
# each reports separately below and every one that fires joins
# `show_vector_scale` via `_either_path_drew_arrows`. `False` here is
# the answer for a run that takes none of them.
#
# **Answered by the drawing paths themselves since the Stage 7 exit
# audit (2026-09-03), not computed from configuration here.** This
Expand Down Expand Up @@ -1046,10 +1113,18 @@ def bootstrap(
# step()` into this run's own render loop, one timestep per
# rendered frame -- every capability before it only ever
# rendered one static frame.
# Draws a colour map, never arrows, so it leaves
# `show_vector_scale` alone (`_add_field_display`'s static
# `vector_pattern` above may still have drawn some).
on_frame, declared_field_bounds = _add_declared_field_transport(window, mesh, config)
# Also draws the solved velocity as arrows when
# `config.simulation.velocity_solved` is set (added
# 2026-09-09) -- joined into `show_vector_scale` below the
# same way the velocity-only path already joins its own,
# since `_add_field_display`'s static `vector_pattern` above
# may have drawn some too and neither implies the other.
on_frame, declared_field_bounds, declared_field_arrows_drawn = (
_add_declared_field_transport(window, mesh, config)
)
show_vector_scale = _either_path_drew_arrows(
show_vector_scale, declared_field_arrows_drawn
)
# `legend_bounds` is left exactly as `show_fields`'s own
# static overlay above set it (or `None`, if it didn't run):
# every live panel captions itself directly now
Expand Down
43 changes: 43 additions & 0 deletions tests/unit/test_bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -1015,6 +1015,49 @@ def test_bootstrap_with_velocity_solved_and_a_scalar_pressure_corrects_the_veloc
)


def test_bootstrap_with_velocity_solved_and_a_declared_field_also_draws_arrows(
tmp_path: Path,
) -> None:
"""Smoke Transport's own shape -- `simulation.velocity_solved: true`
alongside a declared field -- goes through `_add_declared_field_transport`,
which colour-maps the declared field's own panel but, until now, never
drew the solved velocity carrying it as arrows at all: a real,
previously undocumented-as-deliberate gap (`src/pyflow/CLAUDE.md`'s
own `playback.py` entry names it explicitly, "a pre-existing,
separately flagged gap" from TASK-051, which built the identical
combined rendering for `pyflow play`). The moving north wall here is
`lid_driven_cavity.yaml`'s own boundary condition, the same fixture
`test_bootstrap_vector_label_scale_line_returns_once_the_flow_develops`
uses to get real (non-zero) arrows by frame 1.
"""
config_file = tmp_path / "config.yaml"
config_file.write_text(
"rendering:\n backend: offscreen\n"
"mesh:\n extent: [4, 4]\n spacing: [0.25, 0.25]\n"
"numerics:\n timestep: 0.01\n"
" boundary_conditions:\n"
" north:\n type: dirichlet\n field_values:\n"
" velocity.0: 1.0\n velocity.1: 0.0\n"
" south:\n type: dirichlet\n"
" east:\n type: dirichlet\n"
" west:\n type: dirichlet\n"
"fields:\n - name: smoke\n initial_condition: gaussian_blob\n"
"simulation:\n velocity_solved: true\n"
"field_display:\n vector_label: Velocity\n arrow_scale: 0.05\n"
" panels:\n - field: smoke\n"
)

window = bootstrap(config_file, max_frames=1)

assert any(isinstance(child, gfx.Line) for child in window.scene.children), (
"a solved velocity alongside a declared field must draw arrows too, the same way "
"_add_solved_velocity_rendering already does for a solved velocity with no field"
)
scale_line = next(t for t in _text_children(window.scene) if "length =" in _text_content(t))
assert "Velocity" in _text_content(scale_line)
assert "0.05" in _text_content(scale_line)


def test_bootstrap_backend_override(tmp_path: Path) -> None:
config_file = tmp_path / "config.yaml"
config_file.write_text(
Expand Down
Loading
Loading