Solver¶
Configure and run the Navier–Stokes solver.
ember integrates the compressible Navier–Stokes equations to a steady state
using an explicit pseudo-time march, accelerated by multigrid and residual
smoothing. The finite-volume discretisation is second-order accurate in space
on structured multi-block grids; cell-centred residuals are distributed equally
back to nodal conserved variables. This page describes the solver loop driven
by Solver.run() and the configuration parameters in Solver that
control each stage.
Overview of one time step¶
Solver.run() performs the following operations each step:
Cache flush and boundary conditions:
update_cached_conserved()refreshes the pressure and temperature caches left stale by the previous step’s in-place march, thenupdate_bconds()updates boundary patch targets (mass-flow throttle, radial equilibrium, mixing-plane exchange) andapply_bconds()imposes those boundary conditions by modifyingconserved_nd.NaN check:
check_nan()aborts the run early if the conserved state has gone non-finite. Sets a flag on the returnedConvergenceHistoryand leaves the invalid field in place for inspection.Source terms:
update_sources()updates viscous forces and the polar coordinates source term needed to balance the radial momentum equation. With the Runge–Kutta integrator,n_stage> 0, this runs once every full step with the source terms held constant for all stages. When using the scree integrator,n_stage== 0, the source terms are recomputed every few steps to save cost.Update time step:
update_timestep()computes the time step and stores it pre-divided by cell volume indt_vol_nd.Filter: when
gain_filtis nonzero,update_filter()advances the selective-frequency-damping low-pass filter one step using that time step, every step regardless of the source cadence above. The SFD body force of stage 3 reads what this leaves behind on the following step. Skipped entirely at the default zero gain.Residual:
update_residual()calculates the unintegrated net-flow residual, with optional implicit residual smoothing ,sf_resid.Convergence logging: every
n_step_logsteps,record_convergence()andformat_message()record and print aConvergenceHistoryrow using the current residual.March: advance the solution with the selected integrator – Denton’s scree march,
scree_step(), or Jameson multi-stage Runge–Kutta,rk_step()– optionally accelerated by multigrid.Smoothing:
smooth()applies a constant-coefficient blended second- and fourth-order filter to the post-marchember.block.Block.conserved_ndfield, to provide artificial dissipation and suppress odd-even decoupling.Pseudotime averaging: over the final
n_step_avgsteps,accumulate_avg()accumulates the conserved state into a running average, whichfinalise_average()uses to replace the instantaneous state once the run completes. Withn_step_avgof 0 or 1 the whole stage is skipped and the final state stands as the solution.
Time integrators¶
n_stage selects one of two integrators, applied to every
block each step:
scree_step() – n_stage == 0
Implements Denton’s basic scree scheme (two steps forward, one step back)
The residual from the previous step, \((d\mathcal{U}/dt)_{n-1}\), is kept in
store between steps. This scheme is only first-order accurate in pseudotime, unlike Adams–Bashforth, but experience shows it requires less artificial dissipation and is more robust.
rk_step() – n_stage >= 1
– Jameson’s classic Runge–Kutta scheme. Every full step
snapshots the starting conserved state
\(\mathcal{U}_n\), saving it to store.
Then, n_stage substages call
advance_rk_stage_mg(), each stage \(k\) marching relative to the starting snapshot but with the residual freshly evaluated on the most recent stage:
for \(m =\) n_stage stages with coefficients
\(\alpha_k = 1 / (m - k + 1)\), so the final stage takes the full step
\(\alpha_m = 1\).
Both integrators share the Multigrid acceleration and the constant-coefficient
Smoothing step; they differ only in the march formula and in how many
residual evaluations occur per step. At the end of the step, conserved_nd contains the advanced solution.
CFL number¶
The integrators of the previous section scale each cell’s residual by
cfl and the local volumetric timestep
\(\Delta t_\mathrm{vol}\) set by update_timestep(),
so the \(\Delta t\) appearing in the march formulae is
\(\mathrm{cfl}\,\Delta t_\mathrm{vol}\) per cell. The timestep is the
reciprocal of the larger of a convective and a turbulent-diffusion spectral
radius (a max-of-directional-radii variant of the JST/Blazek definition):
For each direction \(d \in \{i, j, k\}\), with \(\mathbf{S}_d\) the mean of the two opposing face-area vectors, \(\mathbf{V}_\mathrm{rel}\) the relative-frame velocity, \(a\) the speed of sound, \(\mu_t\) the turbulent viscosity, \(\rho\) the density and \(\mathcal{V}\) the cell volume, the directional convective radius and the two combined radii are
Taking the max over directions (rather than Blazek’s sum) makes
cfl the true 1D Courant limit, so it stays
aspect-ratio-independent for both limits and a single value scales them
consistently; fac_visc tightens only the diffusion radius
so the viscous march tolerates the same CFL as the inviscid one. Setting
\(\mu_t = 0\) drops \(\lambda_\mathrm{diff}\) and recovers the bare
convective limit. The 4-stage Runge–Kutta march is stable up to
\(\mathrm{cfl} \approx 2\sqrt{2}\); the scree scheme is stable up to \(\mathrm{cfl} \approx 0.6\) on a uniform mesh.
cfl is a single constant applied uniformly to every cell
for the entire run – there is no per-cell adaptive CFL field and no
tolerance-driven backoff inside the solver. A larger CFL converges faster but
risks divergence; implicit residual smoothing (sf_resid)
damps high-frequency residual content and so tolerates a substantially higher
CFL for a given scheme.
Smoothing¶
A constant-coefficient blend of second- and fourth-difference operators, ember.grid.Grid.smooth(), is applied to ember.block.Block.conserved_nd after
each step to suppress odd–even decoupling and high-frequency content
introduced by the march and multigrid corrections.
sf4 and sf2 are coefficients on the
fourth- and second-difference terms, each scaled by the run’s cfl to make the effective dissipation independent of the time step.
Multigrid¶
In-step Denton block-sum multigrid. Coarse-grid corrections are computed in place within a single march call over one grid – coarse block-sum corrections are folded directly into the fine-grid increment before it is scattered onto the conserved state – rather than as a classical restrict/prolong V-cycle across separate coarse grids. Both integrators honor two knobs:
n_levels– number of coarse levels; 0 disables multigrid. Each block’s cell counts must be an exact multiple of the coarsest block size2**n_levelsin every direction, orSolver.run()raises before marching.fac_mgrid– scaling on the coarse correction; 0 also disables multigrid.expon_mgrid– base of the per-level geometric decay applied on top offac_mgrid, so successively coarser levels are damped further (coef_l ~ expon_mgrid**-(l-1)).
sf_resid additionally drives implicit residual smoothing
(Jameson IRS) on the fine-grid residual via
update_residual(), independent of whether multigrid is
enabled.
Full multigrid startup. Solver.run_fmg() runs the same solver
coarse-to-fine as a startup schedule, rather than within a single step. It
builds n_levels progressively-halved grids
(resample()), solves the coarsest first, then
prolongs each converged solution onto the next finer grid as its initial
guess (interp_from_grid()) and calls Solver.run()
again with the in-step multigrid depth set to that level’s index – so the
coarsest level runs with no in-step multigrid and the finest runs at the full
requested n_levels, identical to calling Solver.run()
directly on the finest grid. With n_levels <= 0 it reduces to a single call
to Solver.run().
Body forces and viscous model¶
The cell-centred body-force buffer (block.F_body_nd) accumulates all
source terms before they are added to the residual, rebuilt by
ember.grid.Grid.update_sources():
Viscous shear stresses and heat flux, computed unless
Solver.inviscidis set. The viscous pass is phased across the whole grid (tau/q on every block, then a periodic-seam halo exchange, then face-flux accumulation) so block-to-block periodic interfaces stay consistent.A polar (axisymmetric) source term to balance the cylindrical coordinate metric.
An optional selective-frequency-damping (SFD) force when
Solver.gain_filtis nonzero.
The mixing-length turbulent viscosity uses a fixed turbulent Prandtl number
of 1.0 and is evaluated from the absolute-frame vorticity magnitude.
Solver.fac_visc multiplies the turbulent-diffusion timestep radius
independently of this, tightening the viscous stability limit to recover the
inviscid stable CFL where needed.
Boundary patches and inter-block coupling¶
Inlet, outlet, and mixing-plane patches each relax their own state towards a target every step, with their own relaxation factor rather than a single solver-wide setting:
InletPatchandOutletPatchtake one under-relaxed step of the characteristic condition per timestep, scaled bysigma; seerf_inletandrf_outlet.MixingPatchtakes the same under-relaxed characteristic step as the inlet and outlet, scaled by its ownsigma; seerf_mix.MixingCommunicatorrelaxes the mixing-plane target exchanged between adjacent blocks with the patches’rf_exchange, separately from either side’s own step.mix_reflectivereplaces that whole exchange, and both sides’ characteristic steps, with a direct mixed-out state.OutletPatchrelaxes its spanwise radial-equilibrium profile separately, viaset_adjustment(rf=...), and damps its mass-flow throttle separately again, via the dimensionless gains ofset_throttle(mdot_target, Kp=..., Ki=...).
ember.grid.Grid.update_bconds() advances the slowly-varying boundary
targets once per step (mixing-plane exchange, characteristic mean state,
outlet throttle and spanwise target); ember.grid.Grid.apply_bconds() then
imposes the full set of physical boundary conditions and closes periodic
seams every time it is called, including between Runge–Kutta substages.
Logging, averaging, and convergence history¶
Convergence diagnostics are recorded into a
ConvergenceHistory every
Solver.n_step_log steps: mean residual, mass flow / stagnation
enthalpy / entropy at row interfaces, and outlet throttle state
(ember.grid.Grid.get_convergence()).
Pseudotime averaging of the conserved variables accumulates over the final
Solver.n_step_avg steps of the run
(ember.grid.Grid.accumulate_avg()). On completion, the time-averaged
state replaces the instantaneous state
(ember.grid.Grid.finalise_average()) – skipped if the run diverged, so
the invalid field is preserved for inspection rather than overwritten by a
partially-accumulated average, and skipped when Solver.n_step_avg is 0 or
1, where the mean of one sample is the sample and no accumulator is
allocated.
- class solver.BaseSolver[source]¶
Common interface for in-place flow solvers.
A solver is constructed from its configuration and run with
solver.run(grid), which advancesgridin place and returns the convergence history. Both the built-inSolverand external-solver plugins (e.g.ember.plugins.ts.TS3Solver) implement this contract so they are drop-in interchangeable.
- class solver.Solver(n_step, n_step_log=10, n_step_avg=1, cfl=5.0, sf4=0.008, sf2=0.002, inviscid=False, fac_visc=1.0, sf_resid=1.0, gain_filt=0.0, delta_filt=1.0, n_stage=0, n_levels=3, fac_mgrid=0.2, expon_mgrid=1.414, dampin=0.0, rf_inlet=0.05, rf_outlet=0.05, rf_mix=0.01, rf_exchange=0.01, mix_reflective=False)[source]¶
Configuration for the explicit time-marching solver.
Also the entry point: build one with the parameters below and call
run()(orrun_fmg()) to march a grid in place.Frozen, because a solver is a set of parameters rather than a thing with state: nothing here is written after construction, the march keeps its working state on the grid, and
run_fmg()already derives its per-level configurations withdataclasses.replace(). Settings that would otherwise be adjusted in place are made by building another one.- n_step_avg: int = 1¶
Number of steps at the end of the march to average the solution over.
0and1both mean no averaging: a one-sample mean is the sample, so the march skips the accumulator entirely and leaves the final state as the solution. Must not exceedn_step— see__post_init__().
- fac_visc: float = 1.0¶
Multiplier on the turbulent-diffusion timestep radius; >1 tightens the viscous limit to recover the inviscid stable CFL.
- sf_resid: float = 1.0¶
Implicit residual smoothing factor. Applied to the fine residual by
update_residual()(sf) and, on both integrators, to the coarse block-restricted residual of the multigrid correction (advance_rk_stage_mg()’ssf_irsfor RK,scree_step()’ssf_irsfor scree). The coarse smoothing needsn_levels > 0to have any effect.
- n_levels: int = 3¶
Number of coarse multigrid levels; 0 disables multigrid. Honored by both integrators (
scree_step()andrk_step()).
- fac_mgrid: float = 0.2¶
Scaling factor on multigrid corrections. Honored by both integrators (
scree_step()andrk_step()).
- expon_mgrid: float = 1.414¶
Base of the per-level multigrid decay,
coef_l ~ expon_mgrid**-(l-1). Honored by both integrators (scree_step()andrk_step()).
- dampin: float = 0.0¶
Negative-feedback change limiter (multall’s
DAMP); 0 disables it.Applied to the ASSEMBLED increment – fine term plus injected coarse multigrid correction – immediately before it reaches the nodes, which is where multall applies it (
tblock-p-2_3_1.f:7736, after the block-sum corrections are summed in at 7710-7713). Each cell’s increment is soft- clipped todU / (1 + |dU| / (dampin * mean|dU|)), per conserved variable, so cells far above their block mean are pulled back towardsdampintimes it.This is NOT the limiter removed in ember
7b4fd71. That one sat inset_residual, on the fine residual upstream of the multigrid restriction, and destroyed the extensivity the box sum relies on – which is why it and multigrid diverged together while either alone converged. Here the restriction has already happened.The block mean is lagged one call (see
ember.block.Block.damp_rfac). Honored by both integrators; note RK applies it once per stage, where multall’s scree-equivalent single update applies it once per step, so the scree path (n_stage=0) is the faithful analogue.
- rf_inlet: float | None = 0.05¶
Characteristic under-relaxation (
sigma) on everyInletPatch. Imposed on every such patch at the start of the run, so the default overrides a value the patches carried in; pass None to leave whatever they already hold.
- rf_outlet: float | None = 0.05¶
As
rf_inlet, for everyOutletPatch. This is the characteristic relaxation only; the spanwise radial-equilibrium profile has its own, set viaset_adjustment(rf=...).
- rf_mix: float | None = 0.01¶
As
rf_inlet, for everyMixingPatch. This is each side’s own characteristic relaxation;rf_exchangeis the separate factor on the cross-plane exchange between them.
- rf_exchange: float | None = 0.01¶
Relaxation of the cross-plane mismatch on every
MixingPatch. Read from the patches byMixingCommunicatorat each exchange. Asrf_inlet, the default is imposed and None leaves each plane’s own value alone.
- mix_reflective: bool | None = False¶
Run every mixing plane as a reflective one, imposing the mixed-out state directly instead of the characteristic exchange.
The default plane is the whole of Saxer and Giles [2]: the cross-plane mismatch is split by direction of propagation, relaxed onto a target with
rf_exchange, and the target drives only the mean mode of a boundary condition that stays non-reflecting to the harmonics. This replaces all of that with the simplest thing that couples two rows – at every span station the conserved variables of both faces are set to the average of the two sides’ circumferential means, and the whole face is reset to it on every application – and it is worth being clear about what is given up and what is not.What is given up is accuracy at the plane. Every pitchwise harmonic reaching either face is annihilated at the boundary node rather than absorbed, so the plane reflects; the state imposed is the area average of the conserved variables, which preserves each row’s mass flow exactly (the face mass flux is linear in the conserved vector, and
weight_pitchis the same trapezoidal quadrature the face quads use) but not the momentum or energy flux, so it is not the flux-conserving mixed-out state a loss audit would want; and there is no under-relaxation anywhere, so the two rows are yanked onto their common mean every stage.rf_mixandrf_exchangeboth address machinery this switches off, and so do nothing while it is set.What is not given up is conservation. The inviscid face flow is built from the four boundary nodes of each face quad and the face area vector alone, so two faces carrying the same pitch-uniform state pass identical mass, meridional momentum, angular momentum and energy per unit annulus – whatever their blade counts and pitchwise resolutions, and whatever their rotational speeds, since the frame terms enter only through the circumferential component of the face area, which vanishes on a surface of revolution.
As
rf_inlet, this is imposed on every mixing patch of every level at the start of the run, so the default overrides whatever a grid was pickled with, and both sides of every plane necessarily agree; pass None to leave each plane as it is. A grid with no mixing plane is unaffected whatever this is set to.
- run(grid)[source]¶
Drive
gridthroughn_stepsteps in place; return the history.The public
BaseSolverentry point for the stage-by-stage march.
- run_fmg(grid)[source]¶
Full-multigrid startup on
gridin place.Returns a list of per-level
ConvergenceHistory, coarsest first. Not part of theBaseSolvercontract (plugins have no FMG analogue).
- solver.scree_step(grid, cfl, fac_mgrid=0.0, expon_mgrid=2.0, n_levels=0, sf_irs=0.0, dampin=0.0)[source]¶
Advance every block one Denton scree step in place.
- solver.mg_coarse_shapes(ni, nj, nk, n_levels)[source]¶
Shapes of the multigrid kernels’ seven scratch buffers, in MG_COARSE_NAMES order.
Separated from the carve so a CALLER can fold these into the single
util.carve_viewthat also carves its own buffers. That matters because the multigrid scratch and the caller’s increment buffer are live at the same time and come from the same arena (Block.scratch): carving them together is what makes them provably disjoint rather than disjoint by convention. It is also what the arena sizing is computed from, so the sizing and the carve cannot disagree.
- solver.advance_rk_stage_mg(grid, alpha, cfl, fac_mgrid, n_levels, expon_mgrid=2.0, sf_irs=0.0, dampin=0.0)[source]¶
One Jameson RK stage, optionally with Denton block-sum multigrid.
The single RK stage integrator. Each stage marches every block off its step-start conserved snapshot (
block.store, seeded by the caller) using the residual evaluated on the previous stage’s state. In one pass it assembles a cell-centred increment – the fine RK term plus the injected coarse block corrections ofn_levelscoarse levels – and scatters it onto the snapshot (multall’s DO 1500 combine, thencell_to_node):dU_cell = alpha*cfl*dt_vol*residual (fine) + sum_l inject_l( coef_l * dt_coarse_l * restrict_l(residual) ) cons = snapshot + cell_to_node(dU_cell)
n_levelscounts the coarse levels only.n_levels == 0(the default) is the trivial subcase: the coarse loop is empty, so the stage reduces to a plain Jameson RK stepcons = snapshot + alpha*cfl*dt_vol*residual. Forl = 1..n_levelsthe coarse block hasb = 2**landcoef_l = alpha*cfl*fac_mgrid/b**2 * expon_mgrid**-(l-1). Theexpon_mgrid**-(l-1)term damps successively coarser levels: level 1 (finest coarse,b=2) carries the fullfac_mgrid, level 2fac_mgrid/expon_mgrid, level 3fac_mgrid/expon_mgrid**2, and so on (the defaultexpon_mgrid=2.0reproduces the original fixed factor-2 decay).dt_coarse_lis the volume-weighted HARMONIC mean ofdt_volover the coarse block,sum(vol)/sum(vol/dt_vol), which is why the kernels takeblock.vol_nd. Harmonic because the block needs the reciprocal of the block’s spectral radius,1/<Lambda>, anddt_volis1/Lambdaper cell; by Jensen the arithmetic meansum(dt_vol*vol)/sum(vol)that this used to take is the larger of the two wheneverLambdavaries over the block, so it overstated the coarse timestep on a stretched mesh and over-drove the block’s smallest cells. This mirrors multall’sSTEP1 = CFL*FBLK*PERPMIN/VSOUND/VOLB: ourdt_vol*volis the per-cellperp/(a+V)that multall sums intoPERPMIN, and the1/b**2stays incoef_l. Samplingdt_volat the block’s centre cell instead – what this used to do – is wrong by the local clustering ratio on a stretched mesh. On a uniform mesh the two agree identically. Scaling the block push by the samealphaas the fine term keeps the stage consistent; the final stage (alpha=1) therefore lands the full-weight coarse correction, matching Denton, while earlier stages damp it like the fine residual.Prolongation is injection: every fine cell under a coarse block takes that block’s correction unaltered, so
inject_labove is a lookup.mg_collapse_levelssums the levels in place insidecorr_all– coarsest first, each slot gaining the injected total of the one above it – andmg_fine_scatterreads the finest slot once per fine cell. The correction is then a cell quantity like the fine term, so both are added into the increment and ride the onecell_to_nodescatter. That costs nothing: within a coarse block the correction is constant and the scatter is a partition of unity, so it comes through exactly; the two differ only at block faces, where the node takes the mean of the two adjoining blocks’ corrections. That is a one-cell smoothing of the staircase applied where the staircase is.Injection is exactly the transpose of the block-sum restriction, on any mesh and with no normalisation, weights or geometry. This replaced a cascade of factor-2 trilinear interpolations whose final hop targeted the fine nodes through geometry-derived weights; that scheme, its per-block weight cache and the ill-conditioning that made
MG_W_LO/MG_W_HInecessary are all gone. Seedocs/dev/plan_piecewise_constant_mgrid.md.The whole per-block body – fine term, all coarse levels, and the final scatter – runs in one fused Fortran kernel (
rk_mg_irs/rk_mg_noirs, thin wrappers over the shared scheme-agnostic enginemg_restrict_levels), with no per-level Python crossings or numpy temporaries. Withn_levels == 0(orfac_mgrid == 0) the coarse machinery is skipped entirely by therk_plainkernel (fine term + scatter, no coarse scratch). Restriction is hierarchical: only level 1 reads the fine grid, coarser levels reduce the running accumulators (rawbuffor the residual,sdt/svfor the volume-weighted dt), cutting restriction reads fromn_levels x Nto ~``1.14 x N``. Prolongation is injection, collapsed IN PLACE insidecorr_all: its per-level slots are compact and disjoint, so no accumulator is needed and nothing but the final read touches the fine grid.The coarse timestep (
dtblk), the restriction accumulators,corr_all, the coarse-IRS coefficients (triw) and the rolling increment are all carved fromblock.scratchat non-overlapping offsets, in ONEcarve_view– they reach the same kernel call, so carving them together is what makes them disjoint. The scatter reads the snapshot fromblock.storeand writesconserved_nddirectly (frozen pressure, bypasses the P/T cache).dtblkis rebuilt inside the kernel on every call, so for RK it is recomputed once per stage even thoughdt_volonly changes once per step. That redundancy is deliberate: confiningdtblk’s live range to a single kernel call is what makes it safe to borrow the arena, whichupdate_residual()clobbers between stages. The pre-pass costs under 1.15 fine-cell passes of two multiply-adds per level, against a full residual evaluation already paid every stage.No boundary masking is applied here:
grid.apply_bcondsre-imposes the inlet/outlet/mixing/cusp targets between stages and at the next step top, so the coarse push cannot leave a BC-controlled node inconsistent – exactly as for the fine RK term, which is likewise unmasked.sf_irs(0 disables it, the default) applies implicit residual smoothing (Jameson IRS) to the coarse block-restricted residual at every level, exactly like the fine-grid smoothingGrid.update_residualalready applies via itssfargument – both are driven by the sameSolver.sf_residvalue (seerk_step()).sf_irs > 0dispatchesrk_mg_irs;sf_irs == 0(the default) dispatchesrk_mg_noirs, which enters no smoothing code at all. The two sharemg_restrict_levelsand differ only in the coarse-residual smoother passed to it, so the choice is a Python-side branch with nosf_irstest inside the engine (the fine term is never smoothed here – it already carries the fine residual the caller smoothed). The per-level scratch it needs (triw) is carved fromblock.scratch– caller-owned, no per-call allocation.Assumes
block.dt_vol_ndandblock.residual_ndare populated and the caller refreshes P/T, boundary conditions and the residual between stages.fac_mgrid == 0scales every coarse correction to identically zero, so it collapses to the plain-RK fast path (n_levelspassed as 0, empty coarse loop) rather than running restrict/prolong for a guaranteed-zero push – and makessf_irsinert, exactly as inscree_step().