diff --git a/adr/ADR-002-fvm-first.md b/adr/ADR-002-fvm-first.md
index d44dc62..6ba6893 100644
--- a/adr/ADR-002-fvm-first.md
+++ b/adr/ADR-002-fvm-first.md
@@ -37,7 +37,9 @@ total is unchanged after many timesteps, to floating-point tolerance.
the second.** `src/pyflow/engine/numerics/diffusion.py`'s
`CentralDifferenceDiffusion` computes a real per-face diffusive flux --
the face-normal gradient estimate central differencing gives, times the
-diffusion coefficient (`NumericsConfig.diffusion_coefficient`). FVM's
+diffusion coefficient (`NumericsConfig.diffusion_coefficient` at the
+time; migrated to `FluidConfig.diffusion_coefficient` by TASK-041,
+2026-08-28). FVM's
conservation property under diffusion is checked the same way advection's
was: `tests/features/central_difference_diffusion.feature`'s own
"Conservation under zero-flux boundaries" scenario confirms the
diff --git a/docs/architecture/icds.md b/docs/architecture/icds.md
index bce5686..a410e8a 100644
--- a/docs/architecture/icds.md
+++ b/docs/architecture/icds.md
@@ -56,8 +56,9 @@ shape every other configuration section does -- a dataclass field with a
`Literal[...]` type listing the valid choices by name, validated
immediately and explicitly in `validate()` rather than left to fail
wherever the value is first used (`rendering.backend`'s precedent).
-`engine/numerics/assembly.py`'s `assemble_numerics(NumericsConfig) ->
-AssembledNumerics` resolves each configured name to a live instance
+`engine/numerics/assembly.py`'s `assemble_numerics(NumericsConfig,
+diffusion_coefficient: float = 1.0) -> AssembledNumerics` resolves each
+configured name to a live instance
through a registry keyed by name, populated by `register_*` calls rather
than a chain `assemble_numerics` itself branches on -- adding a name
requires no edit to that function's body (Stage 3 Completion
diff --git a/docs/architecture/sequences.md b/docs/architecture/sequences.md
index e7ec9c2..816efd4 100644
--- a/docs/architecture/sequences.md
+++ b/docs/architecture/sequences.md
@@ -43,7 +43,7 @@ sequenceDiagram
bootstrap->>bootstrap: configure_logging(config.logging)
bootstrap->>Window: RenderWindow(config.rendering)
Window-->>bootstrap: window (canvas, renderer, scene, camera)
- bootstrap->>Assembly: assemble_numerics(config.numerics)
+ bootstrap->>Assembly: assemble_numerics(config.numerics, config.fluid.diffusion_coefficient)
Assembly-->>bootstrap: AssembledNumerics (6 live instances)
bootstrap->>Window: window.assembled_numerics = ...
alt show_mesh or a field_display pattern is configured
@@ -206,7 +206,7 @@ sequenceDiagram
Note over Mesh: cell/face geometry, no field values
bootstrap->>Field: ScalarField(mesh, name, initial_value=...)
Note over Field: CollocatedField allocates a
torch.float64 tensor, (num_cells, *component_shape)
- bootstrap->>Numerics: assemble_numerics(config.numerics)
+ bootstrap->>Numerics: assemble_numerics(config.numerics, config.fluid.diffusion_coefficient)
Note over Numerics: 6 live scheme instances,
constructed once, held for the run
bootstrap->>Window: window.scene.add(...), window.assembled_numerics = ...
Note over Window: process memory only --
nothing here is written to disk
diff --git a/docs/implementation/config-template.yaml b/docs/implementation/config-template.yaml
index aa4925a..c3051e4 100644
--- a/docs/implementation/config-template.yaml
+++ b/docs/implementation/config-template.yaml
@@ -120,6 +120,21 @@ simulation:
# Invalid: anything other than exactly two numbers.
velocity: [1.0, 0.0]
+# Physical properties of the simulated fluid -- separate from numerics
+# below, which selects numerical schemes and their solver tunables, not
+# properties of the fluid those schemes act on.
+fluid:
+ # Valid: a positive number -- momentum's own diffusion coefficient,
+ # distinct from diffusion_coefficient below (a transported scalar's own).
+ # Invalid: zero or negative.
+ viscosity: 1.0
+ # Valid: a positive number (a transported scalar's own diffusivity, Gamma)
+ # -- distinct from viscosity above. Invalid: zero or negative. Migrated
+ # here from numerics.diffusion_coefficient (TASK-041); a configuration
+ # still setting the old field is rejected with a named error, not silently
+ # defaulted.
+ diffusion_coefficient: 1.0
+
# Numerical scheme selection (adr/ADR-003-modular-numerical-strategies.md)
# plus the physical/solver parameters those schemes take.
numerics:
@@ -129,9 +144,6 @@ numerics:
# Valid: "central_difference" -- the only scheme PyFlow currently
# implements for this component. Invalid: any other string.
diffusion: central_difference
- # Valid: a positive number (the physical diffusivity, Gamma). Invalid:
- # zero or negative.
- diffusion_coefficient: 1.0
# Valid: "rk4" -- the only scheme PyFlow currently implements for this
# component. Invalid: any other string.
time_integration: rk4
diff --git a/docs/planning/roadmap.md b/docs/planning/roadmap.md
index 0c9bb7e..9c2ab01 100644
--- a/docs/planning/roadmap.md
+++ b/docs/planning/roadmap.md
@@ -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): **614 tests at 99% as of 2026-08-28**, having been 64 when
+(C1a/C1b): **622 tests at 99% as of 2026-08-28**, 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
@@ -395,8 +395,14 @@ suite (`tests/unit/test_generate_config_template.py`), eight tests, built
at a user's direct request for an annotated, always-current example
config -- again no Stage 4/5 task work involved, the same "count moves
for reasons having nothing to do with the fluid solver" pattern the
-paragraph above already records. **54 of those 614 are Gherkin scenarios
-rather than pytest functions**
+paragraph above already records. 622 after TASK-041 (Fluid Configuration
+Section, Stage 5's first task): four new Gherkin scenarios in
+`tests/integration/test_fluid_configuration.py`
+(`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**
(`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
@@ -440,7 +446,13 @@ downstream at approximately the prescribed velocity over real elapsed
time, not only that rendered pixels changed; and to 54 with the Stage 4
exit audit's own addition to `first_order_upwind_advection.feature`,
above -- the only scenario in this list added by an audit rather than by
-the task that owned the criterion).
+the task that owned the criterion; and to 58 with TASK-041's own
+`fluid_configuration.feature`, Stage 5's first task and its first four
+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).
**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`,
@@ -6724,6 +6736,24 @@ drafted against these on 2026-08-28, not in place of them.
Fluid Configuration Section
+**Status: Done, 2026-08-28, Stage 5's first task.** A real gap between
+this task's own Dependencies section (below, "no engine dependency at
+all") and the live repository was found while implementing, not
+predicted in advance: `src/pyflow/engine/numerics/assembly.py`'s
+`assemble_numerics` already read `NumericsConfig.diffusion_coefficient`
+directly (TASK-024, 2026-08-27), so migrating the field out from under
+it was a real, required engine-side change, not the zero-engine-impact
+migration the Dependencies section below claims. Recorded honestly here
+rather than silently fixed and left for a reader to rediscover
+(`docs/CLAUDE.md`'s Integrity section): `assemble_numerics` gained a
+second parameter, `diffusion_coefficient: float = 1.0` (the default
+preserves every existing caller that only cares about scheme selection),
+and `bootstrap.py`'s own call site now threads
+`config.fluid.diffusion_coefficient` through explicitly. See
+`src/pyflow/engine/numerics/assembly.py`'s own docstring and
+`src/pyflow/engine/CLAUDE.md`'s `assembly.py` entry for the mechanical
+detail.
+
**Added 2026-08-28, while auditing this stage's own readiness to start
-- not anticipated when Stage 5's criteria were drafted the same day.**
Design question four's answer (a new `fluid:` section, with
@@ -6755,9 +6785,17 @@ one property already misfiled there into it.
TASK-005 (the configuration framework), TASK-019 (`NumericsConfig` and
whole-configuration validation), TASK-039 (`pyflow generate-config`),
-and the `make config-template` generator added 2026-08-28. No engine
-dependency at all -- nothing in `src/pyflow/engine/` reads these fields
-until TASK-031b consumes viscosity.
+and the `make config-template` generator added 2026-08-28. **Wrong as
+drafted, corrected once implementation found the gap (this task's own
+Status note, above): `viscosity` alone has no engine dependency until
+TASK-031b, but `diffusion_coefficient` already had one the moment this
+task moved it out of `NumericsConfig` --
+`src/pyflow/engine/numerics/assembly.py`'s `assemble_numerics` (TASK-024)
+already read it off that section directly.** Closed in this task, not
+deferred: `assemble_numerics` gained a `diffusion_coefficient: float`
+parameter (defaulted to `1.0` so every scheme-selection-only caller is
+unaffected) and `bootstrap.py` threads
+`config.fluid.diffusion_coefficient` through it explicitly.
### Design decision, inherited rather than made here
@@ -6791,7 +6829,23 @@ pretends away.
hand-edited (`make config-template`).
- `examples/golden-demos/passive_scalar_transport.yaml` -- the one
committed config that sets the migrating field.
-- `tests/features/fluid_configuration.feature` and its binding module.
+- `tests/features/fluid_configuration.feature` and its binding module,
+ `tests/integration/test_fluid_configuration.py` -- `tests/integration/`,
+ not `tests/unit/`, because its own migration scenario needs a real CLI
+ subprocess run (`tests/CLAUDE.md`'s own split); not a golden demo
+ either, so it supplies its own local steps.
+- **Not predicted when this task was drafted, found while implementing
+ (this task's own Status note, above):**
+ `src/pyflow/engine/numerics/assembly.py` -- `assemble_numerics` gained
+ a `diffusion_coefficient: float = 1.0` parameter, since it could no
+ longer read the field off `NumericsConfig`; `src/pyflow/bootstrap.py`
+ -- its one call site now threads `config.fluid.diffusion_coefficient`
+ through explicitly. `tests/unit/numerics/test_assembly.py`,
+ `tests/unit/test_main.py`, `tests/unit/test_generator.py` and
+ `tests/integration/test_cli.py` -- each asserted the old
+ `NumericsConfig`/`PyFlowConfig` shape directly (a `diffusion_coefficient`
+ field, or a fixed top-level key order) and needed updating to the new
+ one.
**Why this task has a `.feature` file when it computes nothing**, stated
rather than assumed: Stage 5 Criterion 7 says *every* task's acceptance
diff --git a/docs/planning/status.md b/docs/planning/status.md
index 51b53af..16d88b8 100644
--- a/docs/planning/status.md
+++ b/docs/planning/status.md
@@ -15,14 +15,14 @@ demand, not part of this file.
## Progress
-**33/42 tasks complete (79%)** across 14 planned stages. For the full plan, including
+**34/42 tasks complete (81%)** 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" : 33
- "Not started" : 9
+ "Done" : 34
+ "Not started" : 8
```
### Milestones
@@ -35,13 +35,13 @@ pie showData
### Up next
-**Stage 5 -- First Fluid Solver** is next, starting with TASK-041 (Fluid Configuration Section), 4 more not yet started in this stage.
+**Stage 5 -- First Fluid Solver** is next, starting with TASK-031 (Velocity Field Support), 3 more not yet started in this stage.
## Live repository facts
- **45** `CLAUDE.md` files
-- **614** tests collected
-- **54** Gherkin scenarios (`tests/features/*.feature`)
+- **622** tests collected
+- **58** Gherkin scenarios (`tests/features/*.feature`)
## Stages
@@ -115,11 +115,11 @@ pie showData
### Stage 5 -- First Fluid Solver
-**no status recorded** -- `░░░░░░░░░░` 0/5 tasks; 13 criteria defined, no status line yet
+**no status recorded** -- `██░░░░░░░░` 1/5 tasks; 13 criteria defined, no status line yet
| Task | Status | Date | Artifact |
|------|--------|------|----------|
-| TASK-041 -- Fluid Configuration Section | Not started | | |
+| TASK-041 | Done | 2026-08-28 | `src/pyflow/engine/numerics/assembly.py` |
| TASK-031 -- Velocity Field Support | Not started | | |
| TASK-032 -- Pressure Field | Not started | | |
| TASK-033 -- Pressure Correction Loop | Not started | | |
diff --git a/docs/repository-inventory.md b/docs/repository-inventory.md
index 6b7e4de..cc4cfaf 100644
--- a/docs/repository-inventory.md
+++ b/docs/repository-inventory.md
@@ -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.
-**270 tracked files** across 45 directories;
+**272 tracked files** across 45 directories;
4 are empty.
## (root)
@@ -308,6 +308,7 @@ listing files.
- `empty_window.feature`
- `field_display.feature`
- `first_order_upwind_advection.feature`
+- `fluid_configuration.feature`
- `neumann_boundary.feature`
- `numerics_assembly.feature`
- `passive_scalar_transport.feature`
@@ -335,6 +336,7 @@ listing files.
- `test_bootstrap.py`
- `test_claude_hooks.py`
- `test_cli.py`
+- `test_fluid_configuration.py`
- `test_import_order.py`
- `test_interactive_window.py`
diff --git a/docs/repository-manifest.md b/docs/repository-manifest.md
index 04ab0e8..970debd 100644
--- a/docs/repository-manifest.md
+++ b/docs/repository-manifest.md
@@ -838,9 +838,15 @@ is load-bearing rather than tidiness.
`integration/` holds `test_cli.py` (C1a), `test_bootstrap.py` (D4) --
the real subprocess versions -- `test_import_order.py`, a permanent
regression test for D4's circular import, `test_interactive_window.py`
-(a real glfw window, skipped where no display exists), and
+(a real glfw window, skipped where no display exists),
`test_claude_hooks.py` (2026-08-21: the `.claude/` hooks actually run,
-and parse at an older interpreter floor). The repository-tooling tests
+and parse at an older interpreter floor), and `test_fluid_configuration.py`
+(TASK-041, 2026-08-28, Stage 5's first task: binds
+`tests/features/fluid_configuration.feature` here rather than in
+`tests/unit/`, since its own migration scenario needs a real CLI
+subprocess run against the Passive Scalar Transport golden demo's own
+committed config file -- not a golden demo itself, so no `conftest.py`
+vocabulary reused). The repository-tooling tests
live in `unit/` alongside them: `test_check_docs.py`,
`test_check_claims.py`, `test_check_graph.py` and
`test_generate_docs_index.py`/`test_generate_dependency_tree.py`/
@@ -856,8 +862,13 @@ same not-a-golden-demo shape for Stage 4's first real numerical scheme --
same shape again for Stage 4's second -- `rk4_time_integration.feature`,
TASK-025, same day, the same shape again for Stage 4's fourth --
`conjugate_gradient_solver.feature`, TASK-026, same day, the same shape
-again for Stage 4's fifth -- and `piso_pressure_coupling.feature`,
-TASK-027, same day, the same shape again for Stage 4's sixth --
+again for Stage 4's fifth -- `piso_pressure_coupling.feature`,
+TASK-027, same day, the same shape again for Stage 4's sixth -- and
+`fluid_configuration.feature`, TASK-041, 2026-08-28, Stage 5's first
+task and the first not tied to Stage 4's numerics -- the same
+not-a-golden-demo shape again, bound from `tests/integration/` instead
+of `tests/unit/` since one of its own scenarios needs a real CLI
+subprocess --
`adr/ADR-007-executable-acceptance-criteria.md`), which are criteria
rather than tests of criteria and are covered by a collective rule
above. `golden/` holds
diff --git a/examples/golden-demos/passive_scalar_transport.yaml b/examples/golden-demos/passive_scalar_transport.yaml
index 44f9dde..821ba3f 100644
--- a/examples/golden-demos/passive_scalar_transport.yaml
+++ b/examples/golden-demos/passive_scalar_transport.yaml
@@ -24,7 +24,6 @@ mesh:
numerics:
timestep: 0.02
- diffusion_coefficient: 0.02
boundary_conditions:
east:
type: periodic
@@ -42,6 +41,9 @@ simulation:
velocity_pattern: uniform
velocity: [1.0, 0.0]
+fluid:
+ diffusion_coefficient: 0.02
+
field_display:
low_color: "#0a0a2a"
high_color: "#ff8c00"
diff --git a/src/pyflow/bootstrap.py b/src/pyflow/bootstrap.py
index 3d8bd17..7db51dc 100644
--- a/src/pyflow/bootstrap.py
+++ b/src/pyflow/bootstrap.py
@@ -299,7 +299,13 @@ def bootstrap(
# does this, not only ones that need numerics for anything yet, since
# `NumericsConfig` always has a full section (defaulted or not) and
# assembly must not depend on whether a caller happens to care.
- window.assembled_numerics = assemble_numerics(config.numerics)
+ # `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.
+ window.assembled_numerics = assemble_numerics(
+ config.numerics, config.fluid.diffusion_coefficient
+ )
logger.info("numerics assembled: %s", window.assembled_numerics.names)
show_fields = (
diff --git a/src/pyflow/configuration/CLAUDE.md b/src/pyflow/configuration/CLAUDE.md
index 157a424..5e3a584 100644
--- a/src/pyflow/configuration/CLAUDE.md
+++ b/src/pyflow/configuration/CLAUDE.md
@@ -250,17 +250,40 @@ longer among them, since TASK-023/024), only a reference implementation
resolves it under `src/`. Completes the six-field `numerics` section
`adr/ADR-003-modular-numerical-strategies.md` names.
-**`diffusion_coefficient`** (TASK-024, added 2026-08-27): follows
-`timestep`'s own pattern -- a plain positive `float`, not a name from a
-closed set, because this is Gamma
-(`docs/handbook/numerical-methods/diffusion.md`), a physical property of
-what's being transported, not a choice between schemes. `1.0` is an
-arbitrary MVP default, same reasoning as `timestep`'s `0.01`; `validate()`
-rejects `<= 0`. Threaded into `CentralDifferenceDiffusion`'s constructor
-by `assembly.py`'s `register_diffusion_scheme`, the same "constructed
-with it, not handed it after the fact" mechanism `boundary_conditions`
-already established for advection/diffusion (TASK-040) -- see
-`src/pyflow/engine/CLAUDE.md`'s `assembly.py` entry.
+**`diffusion_coefficient`** (TASK-024, added 2026-08-27; **migrated to
+`FluidConfig.diffusion_coefficient` by TASK-041, 2026-08-28 -- no longer
+a `NumericsConfig` field**): originally followed `timestep`'s own
+pattern here -- a plain positive `float`, not a name from a closed set,
+because this is Gamma (`docs/handbook/numerical-methods/diffusion.md`),
+a physical property of what's being transported, not a choice between
+schemes -- which is exactly why it moved out of the section that selects
+schemes. `loader.py`'s `_numerics_config_from_raw` rejects a
+configuration still setting `numerics.diffusion_coefficient` with a
+named error pointing at its new home, rather than silently ignoring it
+or resolving it via `NumericsConfig`'s ordinary unknown-field path. See
+`FluidConfig`, below, for where it lives now.
+
+**`FluidConfig`** (`PyFlowConfig.fluid`, TASK-041, added 2026-08-28,
+Stage 5's first task): `viscosity` and `diffusion_coefficient`, physical
+properties of the simulated fluid, deliberately separated from
+`NumericsConfig` -- a numerical scheme is a discretisation choice, a
+fluid property is not, and the two do not belong in one section (Stage
+5's design question four, `docs/planning/roadmap.md`). `diffusion_
+coefficient` migrated here from `NumericsConfig` (previous entry, above);
+`viscosity` is new, and nothing reads it yet -- momentum's own diffusion
+coefficient, threaded into velocity's diffusive flux once TASK-031b
+lands. Both default to `1.0`, `validate()` rejects `<= 0` for either, the
+same plain-positive-number pattern `timestep`/`diffusion_coefficient`
+already established. **A real, unplanned engine-side consequence of the
+migration, not just a schema move**: `engine/numerics/assembly.py`'s
+`assemble_numerics` already read `NumericsConfig.diffusion_coefficient`
+directly (TASK-024) -- moving the field out from under it needed a real
+signature change (`assemble_numerics` gained its own `diffusion_
+coefficient: float = 1.0` parameter), not just a schema and a config
+file. See `src/pyflow/engine/CLAUDE.md`'s `assembly.py` entry for the
+mechanical detail, and `docs/planning/roadmap.md` TASK-041's own Status
+note for why this task's own Dependencies section ("no engine dependency
+at all") was wrong as drafted.
**`BoundaryFaceConfig`/`BoundaryConditionsConfig`** (TASK-019, added
2026-08-23): `NumericsConfig.boundary_conditions`, one
diff --git a/src/pyflow/configuration/loader.py b/src/pyflow/configuration/loader.py
index accd9a1..3f4b4f7 100644
--- a/src/pyflow/configuration/loader.py
+++ b/src/pyflow/configuration/loader.py
@@ -18,6 +18,7 @@
BoundaryConditionsConfig,
BoundaryFaceConfig,
FieldDisplayConfig,
+ FluidConfig,
LoggingConfig,
MeshConfig,
NumericsConfig,
@@ -48,6 +49,11 @@ def _boundary_conditions_config_from_raw(raw: object) -> BoundaryConditionsConfi
def _numerics_config_from_raw(raw: dict[str, Any]) -> NumericsConfig:
raw = dict(raw)
+ if "diffusion_coefficient" in raw:
+ raise ValueError(
+ "numerics.diffusion_coefficient has moved to fluid.diffusion_coefficient "
+ "(TASK-041) -- update your configuration file"
+ )
boundary_conditions_raw = raw.pop("boundary_conditions", {})
return NumericsConfig(
boundary_conditions=_boundary_conditions_config_from_raw(boundary_conditions_raw),
@@ -104,6 +110,7 @@ def load_config(path: str | Path | None = None) -> PyFlowConfig:
mesh=MeshConfig(**raw.get("mesh", {})),
field_display=FieldDisplayConfig(**raw.get("field_display", {})),
simulation=SimulationConfig(**raw.get("simulation", {})),
+ fluid=FluidConfig(**raw.get("fluid", {})),
numerics=_numerics_config_from_raw(raw.get("numerics", {})),
)
config.validate()
diff --git a/src/pyflow/configuration/schema.py b/src/pyflow/configuration/schema.py
index 3f091b6..182886a 100644
--- a/src/pyflow/configuration/schema.py
+++ b/src/pyflow/configuration/schema.py
@@ -389,6 +389,39 @@ def validate(self) -> None:
)
+@dataclass
+class FluidConfig:
+ """Physical properties of the simulated fluid (TASK-041, added
+ 2026-08-28) -- separate from `NumericsConfig` below, which selects
+ numerical *schemes* and their solver tunables, not properties of the
+ fluid those schemes act on (Stage 5's design question four,
+ `docs/planning/roadmap.md`).
+
+ `diffusion_coefficient` migrates here from
+ `NumericsConfig.diffusion_coefficient` (TASK-024's original field) --
+ Gamma, a transported scalar's own diffusivity. `viscosity` is new:
+ momentum's own diffusion coefficient, distinct from a scalar's
+ (TASK-031b threads it into the diffusive flux `assemble_numerics`
+ builds for velocity's own components). Both default to `1.0`, the
+ same arbitrary-MVP-scaffolding reasoning `NumericsConfig.timestep`'s
+ `0.01` already carries -- no golden demo or handbook page names a
+ specific value for either yet.
+ """
+
+ viscosity: float = 1.0
+ diffusion_coefficient: float = 1.0
+
+ def validate(self) -> None:
+ _require_number(self.viscosity, "fluid.viscosity")
+ if self.viscosity <= 0:
+ raise ValueError(f"fluid.viscosity must be > 0, got {self.viscosity!r}")
+ _require_number(self.diffusion_coefficient, "fluid.diffusion_coefficient")
+ if self.diffusion_coefficient <= 0:
+ raise ValueError(
+ f"fluid.diffusion_coefficient must be > 0, got {self.diffusion_coefficient!r}"
+ )
+
+
AdvectionSchemeName = Literal["first_order_upwind"]
DiffusionSchemeName = Literal["central_difference"]
TimeIntegrationSchemeName = Literal["rk4"]
@@ -581,20 +614,21 @@ class NumericsConfig:
specific ones yet; rejecting each at `<= 0` is the one acceptance
criterion each field carries on its own.
- `diffusion_coefficient` (TASK-024, added 2026-08-27) follows the same
- plain-positive-number pattern as `timestep`: this is Gamma
- (`docs/handbook/numerical-methods/diffusion.md`), a physical
- property of what's being transported, not a scheme choice, so it's
- a number, not a name from a closed set. Threaded into
- `CentralDifferenceDiffusion`'s constructor by `assembly.py`, the
- same mechanism TASK-040 used to thread `boundary_conditions` into
- the advection/diffusion factories. Its default of `1.0` is
- arbitrary MVP scaffolding, same reasoning as `timestep`'s `0.01`.
+ `diffusion_coefficient` (TASK-024, added 2026-08-27; **migrated to
+ `FluidConfig.diffusion_coefficient` by TASK-041, 2026-08-28**) used
+ to live here, following the same plain-positive-number pattern as
+ `timestep`: Gamma (`docs/handbook/numerical-methods/diffusion.md`),
+ a physical property of what's being transported, not a scheme
+ choice -- which is exactly why it moved: a fluid property has no
+ business in the section that selects numerical schemes (Stage 5's
+ design question four, `docs/planning/roadmap.md`). A config still
+ setting `numerics.diffusion_coefficient` is rejected with a named
+ error pointing at its new home (`loader.py`'s
+ `_numerics_config_from_raw`), not silently ignored.
"""
advection: AdvectionSchemeName = "first_order_upwind"
diffusion: DiffusionSchemeName = "central_difference"
- diffusion_coefficient: float = 1.0
time_integration: TimeIntegrationSchemeName = "rk4"
timestep: float = 0.01
linear_solver: LinearSolverName = "conjugate_gradient"
@@ -614,10 +648,6 @@ def validate(self) -> None:
f"numerics.diffusion must be one of {sorted(_VALID_DIFFUSION_SCHEMES)}, "
f"got {self.diffusion!r}"
)
- if self.diffusion_coefficient <= 0:
- raise ValueError(
- f"numerics.diffusion_coefficient must be > 0, got {self.diffusion_coefficient!r}"
- )
if self.time_integration not in _VALID_TIME_INTEGRATION_SCHEMES:
raise ValueError(
f"numerics.time_integration must be one of "
@@ -657,6 +687,7 @@ class PyFlowConfig:
mesh: MeshConfig = field(default_factory=MeshConfig)
field_display: FieldDisplayConfig = field(default_factory=FieldDisplayConfig)
simulation: SimulationConfig = field(default_factory=SimulationConfig)
+ fluid: FluidConfig = field(default_factory=FluidConfig)
numerics: NumericsConfig = field(default_factory=NumericsConfig)
def validate(self) -> None:
@@ -665,5 +696,6 @@ def validate(self) -> None:
self.mesh.validate()
self.field_display.validate()
self.simulation.validate()
+ self.fluid.validate()
self.numerics.validate()
_validate_boundary_conditions_jointly(self.mesh, self.numerics.boundary_conditions)
diff --git a/src/pyflow/engine/CLAUDE.md b/src/pyflow/engine/CLAUDE.md
index 0236105..f924a98 100644
--- a/src/pyflow/engine/CLAUDE.md
+++ b/src/pyflow/engine/CLAUDE.md
@@ -945,8 +945,9 @@ type annotation there and `from __future__ import annotations` already
makes that lazy -- costs nothing at runtime, and `accumulate_flux_to_cells`
itself did not need to move.
-**`assembly.py`** (TASK-021) is `assemble_numerics(NumericsConfig) ->
-AssembledNumerics` and the six independent registries
+**`assembly.py`** (TASK-021) is `assemble_numerics(NumericsConfig,
+diffusion_coefficient: float = 1.0) -> AssembledNumerics` and the six
+independent registries
(`register_advection_scheme` and five siblings) it resolves a configured
name through -- Stage 3 Completion Criterion 3's mechanism: adding a
name means calling a `register_*` function, never editing
@@ -1029,6 +1030,24 @@ helper this module used to route advection through,
`_resolve_with_argument`, is deleted in the same change as genuinely
dead code -- its only caller moved to the two-argument helper.
+**TASK-041 (2026-08-28, Stage 5's first task) widens `assemble_numerics`
+a third time, but for the opposite reason -- a field left, not arrived.**
+`FluidConfig.diffusion_coefficient` (`src/pyflow/configuration/
+schema.py`) replaces `NumericsConfig.diffusion_coefficient`, so
+`assemble_numerics` can no longer read Gamma off its own `config`
+parameter -- found while implementing TASK-041, not anticipated by that
+task's own drafted Dependencies section (`docs/planning/roadmap.md`
+TASK-041's own Status note records the gap). `assemble_numerics` gained
+a second parameter, `diffusion_coefficient: float = 1.0` -- the default
+matches `FluidConfig`'s own, so every existing caller that only cares
+about scheme selection (nearly all of `tests/unit/numerics/
+test_assembly.py`) keeps working unedited; `bootstrap.py`'s one real
+call site threads `config.fluid.diffusion_coefficient` through
+explicitly. `register_diffusion_scheme`'s own factory shape is
+unchanged -- a concrete diffusion scheme still receives
+`diffusion_coefficient` as its third constructor argument, now sourced
+from this new parameter instead of `config.diffusion_coefficient`.
+
**Registration refuses to overwrite a different factory**
(`DuplicateSchemeError`, added 2026-08-24). The registries are
module-level and filled by import side effect, so "last import wins"
diff --git a/src/pyflow/engine/numerics/assembly.py b/src/pyflow/engine/numerics/assembly.py
index 118f1f7..cb135df 100644
--- a/src/pyflow/engine/numerics/assembly.py
+++ b/src/pyflow/engine/numerics/assembly.py
@@ -195,11 +195,13 @@ def register_diffusion_scheme(
diffusion_coefficient)` in future `assemble_numerics` calls --
`boundary_conditions`/`periodic_pairs` the same as
`register_advection_scheme`'s own, `diffusion_coefficient` is
- `NumericsConfig.diffusion_coefficient` (TASK-024's own Design
- decision, `docs/planning/roadmap.md`): a concrete diffusion scheme is
- constructed with the physical coefficient (Gamma) it needs, the same
- "constructed with it, not handed it after the fact" reasoning
- `boundary_conditions` already established.
+ `assemble_numerics`'s own `diffusion_coefficient` parameter
+ (`FluidConfig.diffusion_coefficient` since TASK-041, 2026-08-28 --
+ `NumericsConfig.diffusion_coefficient` before it, TASK-024's original
+ field): a concrete diffusion scheme is constructed with the physical
+ coefficient (Gamma) it needs, the same "constructed with it, not
+ handed it after the fact" reasoning `boundary_conditions` already
+ established.
"""
_register(_diffusion_registry, name, factory, "diffusion")
@@ -297,9 +299,22 @@ def _resolve_with_three_arguments[T, A, B, C](
return factory(argument_a, argument_b, argument_c)
-def assemble_numerics(config: NumericsConfig) -> AssembledNumerics:
+def assemble_numerics(
+ config: NumericsConfig, diffusion_coefficient: float = 1.0
+) -> AssembledNumerics:
"""Resolve every name in `config` to a live instance.
+ `diffusion_coefficient` is `FluidConfig.diffusion_coefficient`
+ (`src/pyflow/configuration/schema.py`) -- passed in separately, not
+ read off `config`, because it moved out of `NumericsConfig` in
+ TASK-041 (2026-08-28): a fluid property, not a scheme choice, so it
+ no longer lives in the section this function's own `config`
+ parameter is typed against. Defaults to `1.0`, `FluidConfig`'s own
+ default, so every existing caller that only cares about scheme
+ selection keeps working unchanged; a real run threads
+ `config.fluid.diffusion_coefficient` through explicitly
+ (`bootstrap.py`).
+
Reads `config` once; the returned `AssembledNumerics` holds
instances, not a reference back to `config` -- mutating `config`
afterwards changes nothing about what was already assembled (Stage 3
@@ -343,7 +358,7 @@ def assemble_numerics(config: NumericsConfig) -> AssembledNumerics:
config.diffusion,
boundary_conditions,
periodic_pairs,
- config.diffusion_coefficient,
+ diffusion_coefficient,
"diffusion",
)
time_integration = _resolve(
diff --git a/src/pyflow/engine/numerics/diffusion.py b/src/pyflow/engine/numerics/diffusion.py
index c5abe83..5415c4f 100644
--- a/src/pyflow/engine/numerics/diffusion.py
+++ b/src/pyflow/engine/numerics/diffusion.py
@@ -66,8 +66,10 @@ class CentralDifferenceDiffusion(DiffusionScheme):
Boundary-aware by construction, the same pattern
`FirstOrderUpwindAdvection` establishes (TASK-040's own Design
decision): holds the boundary conditions and the diffusion
- coefficient (Gamma, `NumericsConfig.diffusion_coefficient`) it needs,
- rather than the orchestrator substituting either in afterward.
+ coefficient (Gamma, `FluidConfig.diffusion_coefficient` -- migrated
+ from `NumericsConfig.diffusion_coefficient` by TASK-041, 2026-08-28)
+ it needs, rather than the orchestrator substituting either in
+ afterward.
**Periodic-aware the same way `FirstOrderUpwindAdvection` is
(TASK-030).** At a face named in `periodic_pairs`, `flux` substitutes
diff --git a/tests/features/fluid_configuration.feature b/tests/features/fluid_configuration.feature
new file mode 100644
index 0000000..4e62295
--- /dev/null
+++ b/tests/features/fluid_configuration.feature
@@ -0,0 +1,42 @@
+# The acceptance criteria for Fluid Configuration Section (TASK-041,
+# Stage 5's first task). Not a golden demo of its own -- no new config
+# file under `examples/golden-demos/`, reusing the existing Passive
+# Scalar Transport demo for the migration scenario instead -- but its
+# own claims are genuinely user-observable (loading a configuration
+# file), so `tests/integration/test_fluid_configuration.py` binds these
+# scenarios, crossing the real CLI subprocess boundary the last scenario
+# needs (`tests/CLAUDE.md`'s own split between `unit/` and
+# `integration/`), rather than living in `tests/unit/`.
+#
+# `numerics.diffusion_coefficient` moves into a new `fluid:` section
+# alongside a new `fluid.viscosity`. The criterion that matters is that
+# the break is complete and visible, not partial and silent
+# (`docs/planning/roadmap.md` TASK-041's own Intent): a config still
+# setting the old field must fail loudly, never run with a silently
+# substituted default. Per-field type/range rejection (a non-numeric or
+# non-positive value) is not repeated here -- it stays in
+# `tests/unit/test_configuration.py`, where every other field's own
+# rejection tests already live; these scenarios carry the migration's
+# own claims only.
+
+Feature: Fluid Configuration Section
+
+ Scenario: A fluid configuration section loads both of its fields
+ Given a configuration file setting fluid.viscosity and fluid.diffusion_coefficient
+ When the configuration is loaded
+ Then both values arrive on the loaded configuration's fluid section
+
+ Scenario: Setting one fluid field leaves the other at its own default
+ Given a configuration file setting only fluid.viscosity
+ When the configuration is loaded
+ Then fluid.diffusion_coefficient is still its own default value
+
+ Scenario: A configuration still setting the retired numerics field is rejected, not silently defaulted
+ Given a configuration file setting numerics.diffusion_coefficient
+ When the configuration is loaded
+ Then loading is rejected with a named error saying the field moved to fluid.diffusion_coefficient
+
+ Scenario: The Passive Scalar Transport golden demo still runs through the real CLI after the migration
+ Given the Passive Scalar Transport golden demo's own committed configuration file
+ When the demo is run through the real CLI as a subprocess
+ Then the process exits successfully
diff --git a/tests/integration/test_cli.py b/tests/integration/test_cli.py
index ecc69b5..3175b51 100644
--- a/tests/integration/test_cli.py
+++ b/tests/integration/test_cli.py
@@ -65,6 +65,7 @@ def test_generate_config_prints_valid_yaml_to_stdout() -> None:
"mesh",
"field_display",
"simulation",
+ "fluid",
"numerics",
]
@@ -98,6 +99,7 @@ def test_generate_config_output_writes_file_and_round_trips_through_run(
"mesh",
"field_display",
"simulation",
+ "fluid",
"numerics",
]
diff --git a/tests/integration/test_fluid_configuration.py b/tests/integration/test_fluid_configuration.py
new file mode 100644
index 0000000..8f2b0c5
--- /dev/null
+++ b/tests/integration/test_fluid_configuration.py
@@ -0,0 +1,138 @@
+"""Binds `tests/features/fluid_configuration.feature` (TASK-041) --
+Stage 5's first task, moving `numerics.diffusion_coefficient` into a new
+`fluid:` section alongside a new `fluid.viscosity`. Lives here, not
+`tests/unit/`, because its own last scenario needs a real CLI subprocess
+run (`tests/CLAUDE.md`'s own unit/integration split); not a golden demo
+either -- no new config file under `examples/golden-demos/`, reusing the
+existing Passive Scalar Transport demo's own committed file for the
+migration scenario instead of a fixture copy, so a real drift between
+that file and the schema is exactly what this scenario would catch.
+Supplies its own local steps rather than drawing on
+`tests/golden/conftest.py`'s demo-running vocabulary, which a
+`conftest.py`'s own directory scoping could not reach from here anyway.
+"""
+
+from __future__ import annotations
+
+import subprocess
+import sys
+from dataclasses import dataclass
+from pathlib import Path
+
+from pytest_bdd import given, scenarios, then, when
+
+from pyflow.configuration import PyFlowConfig, load_config
+
+scenarios("fluid_configuration.feature")
+
+_REPO_ROOT = Path(__file__).resolve().parents[2]
+
+
+@dataclass
+class _Context:
+ config_path: Path
+ loaded: PyFlowConfig | None = None
+ error: ValueError | None = None
+ process: subprocess.CompletedProcess[str] | None = None
+
+
+# -- Given -------------------------------------------------------------
+
+
+@given(
+ "a configuration file setting fluid.viscosity and fluid.diffusion_coefficient",
+ target_fixture="ctx",
+)
+def _given_both_fluid_fields(tmp_path: Path) -> _Context:
+ config_path = tmp_path / "config.yaml"
+ config_path.write_text("fluid:\n viscosity: 2.5\n diffusion_coefficient: 3.5\n")
+ return _Context(config_path=config_path)
+
+
+@given("a configuration file setting only fluid.viscosity", target_fixture="ctx")
+def _given_only_viscosity(tmp_path: Path) -> _Context:
+ config_path = tmp_path / "config.yaml"
+ config_path.write_text("fluid:\n viscosity: 2.5\n")
+ return _Context(config_path=config_path)
+
+
+@given("a configuration file setting numerics.diffusion_coefficient", target_fixture="ctx")
+def _given_stale_numerics_field(tmp_path: Path) -> _Context:
+ config_path = tmp_path / "config.yaml"
+ config_path.write_text("numerics:\n diffusion_coefficient: 3.5\n")
+ return _Context(config_path=config_path)
+
+
+@given(
+ "the Passive Scalar Transport golden demo's own committed configuration file",
+ target_fixture="ctx",
+)
+def _given_the_real_demo_config() -> _Context:
+ config_path = _REPO_ROOT / "examples" / "golden-demos" / "passive_scalar_transport.yaml"
+ assert config_path.is_file(), f"expected the real demo config at {config_path}"
+ return _Context(config_path=config_path)
+
+
+# -- When ----------------------------------------------------------------
+
+
+@when("the configuration is loaded")
+def _when_loaded(ctx: _Context) -> None:
+ try:
+ ctx.loaded = load_config(ctx.config_path)
+ except ValueError as exc:
+ ctx.error = exc
+
+
+@when("the demo is run through the real CLI as a subprocess")
+def _when_run_through_the_cli(ctx: _Context) -> None:
+ ctx.process = subprocess.run(
+ [
+ sys.executable,
+ "-m",
+ "pyflow",
+ "run",
+ "--config",
+ str(ctx.config_path),
+ "--backend",
+ "offscreen",
+ "--max-frames",
+ "2",
+ ],
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+
+# -- Then ------------------------------------------------------------------
+
+
+@then("both values arrive on the loaded configuration's fluid section")
+def _then_both_values_arrive(ctx: _Context) -> None:
+ assert ctx.loaded is not None, ctx.error
+ assert ctx.loaded.fluid.viscosity == 2.5
+ assert ctx.loaded.fluid.diffusion_coefficient == 3.5
+
+
+@then("fluid.diffusion_coefficient is still its own default value")
+def _then_diffusion_coefficient_stays_default(ctx: _Context) -> None:
+ assert ctx.loaded is not None, ctx.error
+ assert ctx.loaded.fluid.viscosity == 2.5
+ assert ctx.loaded.fluid.diffusion_coefficient == PyFlowConfig().fluid.diffusion_coefficient
+
+
+@then(
+ "loading is rejected with a named error saying the field moved to fluid.diffusion_coefficient"
+)
+def _then_rejected_naming_the_new_home(ctx: _Context) -> None:
+ assert ctx.error is not None, "expected load_config to reject the retired field"
+ message = str(ctx.error)
+ assert "numerics.diffusion_coefficient" in message
+ assert "fluid.diffusion_coefficient" in message
+
+
+@then("the process exits successfully")
+def _then_the_process_exits_successfully(ctx: _Context) -> None:
+ assert ctx.process is not None
+ assert ctx.process.returncode == 0, ctx.process.stderr
diff --git a/tests/unit/numerics/test_assembly.py b/tests/unit/numerics/test_assembly.py
index 27c0266..7368c22 100644
--- a/tests/unit/numerics/test_assembly.py
+++ b/tests/unit/numerics/test_assembly.py
@@ -308,19 +308,20 @@ def test_diffusion_factory_receives_the_resolved_boundary_conditions_and_coeffic
# Every other test-only scheme in this module discards its
# constructor arguments -- this is the one that proves
# `assemble_numerics` actually threads the resolved boundary
- # conditions *and* `config.diffusion_coefficient` into the diffusion
- # factory, not stale or default ones.
+ # conditions *and* its own `diffusion_coefficient` parameter into the
+ # diffusion factory, not stale or default ones. `diffusion_coefficient`
+ # is `assemble_numerics`'s own second parameter, not a `NumericsConfig`
+ # field, since TASK-041 (2026-08-28) moved it to `FluidConfig`.
name = "test_only_capturing_diffusion_for_assembly_test"
register_diffusion_scheme(name, _CapturingDiffusion)
config = NumericsConfig(
diffusion=name, # type: ignore[arg-type]
- diffusion_coefficient=3.5,
boundary_conditions=BoundaryConditionsConfig(
north=BoundaryFaceConfig(type="dirichlet", velocity=2.5, pressure=None),
),
)
- assembled = assemble_numerics(config)
+ assembled = assemble_numerics(config, diffusion_coefficient=3.5)
assert isinstance(assembled.diffusion, _CapturingDiffusion)
assert assembled.diffusion.received_boundary_conditions == assembled.boundary_conditions
diff --git a/tests/unit/test_configuration.py b/tests/unit/test_configuration.py
index 129fa87..a145946 100644
--- a/tests/unit/test_configuration.py
+++ b/tests/unit/test_configuration.py
@@ -35,9 +35,10 @@ def test_defaults_are_valid() -> None:
assert config.simulation.scalar_pattern is None
assert config.simulation.velocity_pattern is None
assert config.simulation.velocity == (1.0, 0.0)
+ assert config.fluid.viscosity == 1.0
+ assert config.fluid.diffusion_coefficient == 1.0
assert config.numerics.advection == "first_order_upwind"
assert config.numerics.diffusion == "central_difference"
- assert config.numerics.diffusion_coefficient == 1.0
assert config.numerics.time_integration == "rk4"
assert config.numerics.timestep == 0.01
assert config.numerics.linear_solver == "conjugate_gradient"
@@ -549,26 +550,62 @@ def test_load_config_rejects_an_unknown_diffusion_scheme(tmp_path: Path) -> None
def test_load_config_reads_diffusion_coefficient(tmp_path: Path) -> None:
config_file = tmp_path / "config.yaml"
- config_file.write_text("numerics:\n diffusion_coefficient: 2.5\n")
+ config_file.write_text("fluid:\n diffusion_coefficient: 2.5\n")
config = load_config(config_file)
- assert config.numerics.diffusion_coefficient == 2.5
+ assert config.fluid.diffusion_coefficient == 2.5
def test_load_config_rejects_a_non_positive_diffusion_coefficient(tmp_path: Path) -> None:
config_file = tmp_path / "config.yaml"
- config_file.write_text("numerics:\n diffusion_coefficient: 0.0\n")
+ config_file.write_text("fluid:\n diffusion_coefficient: 0.0\n")
- with pytest.raises(ValueError, match="numerics.diffusion_coefficient"):
+ with pytest.raises(ValueError, match="fluid.diffusion_coefficient"):
load_config(config_file)
def test_load_config_rejects_a_negative_diffusion_coefficient(tmp_path: Path) -> None:
config_file = tmp_path / "config.yaml"
- config_file.write_text("numerics:\n diffusion_coefficient: -1.0\n")
+ config_file.write_text("fluid:\n diffusion_coefficient: -1.0\n")
- with pytest.raises(ValueError, match="numerics.diffusion_coefficient"):
+ with pytest.raises(ValueError, match="fluid.diffusion_coefficient"):
+ load_config(config_file)
+
+
+# -- FluidConfig (TASK-041) ------------------------------------------------
+
+
+def test_load_config_reads_viscosity(tmp_path: Path) -> None:
+ config_file = tmp_path / "config.yaml"
+ config_file.write_text("fluid:\n viscosity: 3.0\n")
+
+ config = load_config(config_file)
+
+ assert config.fluid.viscosity == 3.0
+
+
+def test_load_config_rejects_a_non_positive_viscosity(tmp_path: Path) -> None:
+ config_file = tmp_path / "config.yaml"
+ config_file.write_text("fluid:\n viscosity: 0.0\n")
+
+ with pytest.raises(ValueError, match="fluid.viscosity"):
+ load_config(config_file)
+
+
+def test_load_config_rejects_a_negative_viscosity(tmp_path: Path) -> None:
+ config_file = tmp_path / "config.yaml"
+ config_file.write_text("fluid:\n viscosity: -1.0\n")
+
+ with pytest.raises(ValueError, match="fluid.viscosity"):
+ load_config(config_file)
+
+
+def test_load_config_rejects_a_non_numeric_viscosity(tmp_path: Path) -> None:
+ config_file = tmp_path / "config.yaml"
+ config_file.write_text("fluid:\n viscosity: not-a-number\n")
+
+ with pytest.raises(ValueError, match="fluid.viscosity"):
load_config(config_file)
diff --git a/tests/unit/test_generator.py b/tests/unit/test_generator.py
index 6c16810..5c35c28 100644
--- a/tests/unit/test_generator.py
+++ b/tests/unit/test_generator.py
@@ -140,8 +140,8 @@ def test_top_level_key_order_matches_pyflowconfig_field_order() -> None:
schema's own declared field order -- it only proves the dict
iteration order was preserved. Check the parsed keys directly
against `PyFlowConfig`'s declared order (`logging`, `rendering`,
- `mesh`, `field_display`, `simulation`, `numerics`), not assumed from
- the dumper flag.
+ `mesh`, `field_display`, `simulation`, `fluid`, `numerics`), not
+ assumed from the dumper flag.
"""
text = generate_config_yaml(PyFlowConfig())
@@ -153,5 +153,6 @@ def test_top_level_key_order_matches_pyflowconfig_field_order() -> None:
"mesh",
"field_display",
"simulation",
+ "fluid",
"numerics",
]
diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py
index dd90b8f..23a5418 100644
--- a/tests/unit/test_main.py
+++ b/tests/unit/test_main.py
@@ -120,10 +120,13 @@ def test_generate_config_with_no_output_prints_to_stdout(
"velocity_pattern": None,
"velocity": [1.0, 0.0],
},
+ "fluid": {
+ "viscosity": 1.0,
+ "diffusion_coefficient": 1.0,
+ },
"numerics": {
"advection": "first_order_upwind",
"diffusion": "central_difference",
- "diffusion_coefficient": 1.0,
"time_integration": "rk4",
"timestep": 0.01,
"linear_solver": "conjugate_gradient",
@@ -180,6 +183,7 @@ def test_generate_config_with_output_writes_file_and_prints_nothing(
"mesh",
"field_display",
"simulation",
+ "fluid",
"numerics",
]
assert written["mesh"]["extent"] == list(PyFlowConfig().mesh.extent)
diff --git a/tools/generators/generate_config_template.py b/tools/generators/generate_config_template.py
index 8453ad2..17d9d80 100644
--- a/tools/generators/generate_config_template.py
+++ b/tools/generators/generate_config_template.py
@@ -75,6 +75,12 @@
"Live, repeatedly-stepped simulation seeding -- distinct from "
"field_display above, which renders one static frame."
),
+ "fluid": (
+ "Physical properties of the simulated fluid -- separate from "
+ "numerics below, which selects numerical schemes and their "
+ "solver tunables, not properties of the fluid those schemes "
+ "act on."
+ ),
"numerics": (
"Numerical scheme selection (adr/ADR-003-modular-numerical-"
"strategies.md) plus the physical/solver parameters those "
@@ -185,6 +191,19 @@
"solves for velocity). Invalid: anything other than exactly two "
"numbers."
),
+ "fluid.viscosity": (
+ "Valid: a positive number -- momentum's own diffusion "
+ "coefficient, distinct from diffusion_coefficient below (a "
+ "transported scalar's own). Invalid: zero or negative."
+ ),
+ "fluid.diffusion_coefficient": (
+ "Valid: a positive number (a transported scalar's own "
+ "diffusivity, Gamma) -- distinct from viscosity above. "
+ "Invalid: zero or negative. Migrated here from "
+ "numerics.diffusion_coefficient (TASK-041); a configuration "
+ "still setting the old field is rejected with a named error, "
+ "not silently defaulted."
+ ),
"numerics.advection": (
'Valid: "first_order_upwind" -- the only scheme PyFlow currently '
"implements for this component. Invalid: any other string."
@@ -193,9 +212,6 @@
'Valid: "central_difference" -- the only scheme PyFlow currently '
"implements for this component. Invalid: any other string."
),
- "numerics.diffusion_coefficient": (
- "Valid: a positive number (the physical diffusivity, Gamma). Invalid: zero or negative."
- ),
"numerics.time_integration": (
'Valid: "rk4" -- the only scheme PyFlow currently implements '
"for this component. Invalid: any other string."
diff --git a/tools/validators/check_references.py b/tools/validators/check_references.py
index 2e474b9..18e6060 100644
--- a/tools/validators/check_references.py
+++ b/tools/validators/check_references.py
@@ -110,7 +110,6 @@
# change is what that Stage's own design question one decides, and a
# guess here would be the speculation P-016 refuses.
PLANNED: dict[str, str] = {
- "tests/features/fluid_configuration.feature": "TASK-041",
"tests/features/velocity_field_support.feature": "TASK-031",
"tests/features/pressure_field.feature": "TASK-032",
"tests/features/pressure_correction_loop.feature": "TASK-033",