Troubleshooting¶
A field guide to the most common failure modes you'll hit running an aeolus1d case, with the diagnostic flags + root causes for each. Organised by what you SEE first.
"My run diverged / blew up"¶
Symptoms. Solver prints NaNs, exits with RuntimeError:
non-finite state, or a --info-every-n line shows
p_max = inf. The HDF5 (if written) has NaN values in the
final-state pipe arrays.
Most likely cause. CFL violation or unphysical initial conditions.
Fix in order:
- Check CFL. The default is 0.5 for explicit RK; if you've lowered it, raise back to 0.5. If you've raised it past 0.7, drop it.
- Drop t_end first. If t_end is 1.0 s, try 0.001 s. A 1-second simulation that diverges at t=10⁻⁵ s reveals the issue much faster.
- Check the initial state.
[pipe.init].T = 0orp = 0produces1/Tand1/pdenormals on the first step. UseT ≥ 200 K,p ≥ 100 Pa. - Use
--info-every-n 1to see EVERY step'sdt. A shrinkingdt(e.g.1e-5 → 1e-7 → 1e-9) signals adaptive-Δt is fighting an unstable closure. - Switch to implicit. If explicit RK can't handle your
stiffness (typical with porous-Forchheimer or chemistry),
set
rk = "implicit_euler"or"bdf2"in[case]. The implicit drivers don't have a CFL constraint. One exception: a[[cylinder]]using the default orifice valve coupling (cylinder_orifice_valve_flow, ADR-0008) is rejected at build under an implicitrk— the valve sources are explicit-driver-only — so either keep an explicitrkor setcylinder_orifice_valve_flow = falsefirst.
"Newton didn't converge"¶
Symptoms. Console shows Newton failed at t = X.X (n_iters
= 12, F_norm / U_norm = ...) and the run terminates.
Most likely cause. The Newton residual norm isn't reaching
config.tol within config.max_iters, OR the line search
exhausts its backtracks.
Fix in order:
- Loosen the tolerance. Set
[case] newton_tol = 1e-6(default is 1e-8). The default is conservative; 1e-6 is fine for most physics studies. - Allow more iterations.
[case] newton_max_iters = 50(default 8). For stiff catalyst kinetics, 50 is often needed. - Reduce the Δt. The Newton matrix conditioning degrades
with larger Δt × residual; smaller steps stabilise. Three
real knobs, in order of preference: lower
[case] cfl(implicit Δt = cfl · acoustic dt, so halving cfl halves the step); turn on[case] adaptive_dt = trueand loweradaptive_cfl_maxso the Newton-difficulty controller caps the step; or pin the step exactly with[case] dt_fixed = 1e-6(overrides the CFL calculation entirely). Runaeolus1d analyze-cfl case.tomlfirst to see which pipe/cell sets the acoustic dt. - Loosen Picard. Network cases use a Picard outer loop;
[case] picard_max_iter = 5(default 3) gives the outer loop more chances before declaring failure. - Activate adaptive Picard.
[case] adaptive_picard = trueautomatically growspicard_max_iterwhen the convergence rate signals saturation. The runner-dock Picard badge surfaces saturation in real time. - Check the closure pack. A
friction_kindwith wrong units (D_h in mm instead of m) produces huge source values that the Newton can't track. Cross-reference closures reference for the expected units.
"QS Picard non-convergence"¶
Symptoms. solver = "quasi_steady" raises
QS junction-pressure Picard failed to converge after
qs_picard_max_iter outer iterations. Often the iterate is
visibly near the answer — |Δjp| keeps oscillating between
two values, or the relative net mass imbalance plateaus at
~0.5–2 % and refuses to drop below qs_picard_tol = 1e-6.
Typical on TWC AT systems with a stepwise inlet ṁ transient,
or on cases that init at the outlet pressure (zero pressure
gradient → blind FD probe at startup).
Most likely cause. A stale-secant 2-cycle in the junction-
pressure Newton-damping update
jp_new = jp − qs_picard_relax · net / slope. The damped
secant can settle into a non-zero fixed point that is
converged in the |Δjp| sense but not the net-imbalance
sense.
Algorithmic fix (default since MR !131, 2026-05-31). The
QS driver now wraps the secant update in a sign-bracket
bisection guard: once net flips sign between two iterates,
the next step bisects the bracket instead of trusting the
stale slope. This kills the 2-cycle by construction and is
the right fix for the failure mode the qs_picard_relax
knob was originally introduced to paper over. No flag — it
is always on.
Diagnostic scripts. Two helpers ship under scripts/ for
when a stuck case still falls through the guard:
scripts/diagnose_qs_picard_2cycle.py— runs the case with the Picard iterate trace enabled and flags any junction that visits the same(jp, net)pair twice withinqs_picard_max_iter. Useful as a first triage: a 2-cycle signal means the bracket guard never engaged (likely a zero-gradient bootstrap blind spot), a monotone-but-slow trace means the relaxation is too aggressive for the transient.scripts/analyze_qs_picard_trace.py— post-processes the trace HDF5 / JSON and produces a per-junction convergence rate, the final|net|, and the iterate at which the bracket first formed. Plot this when triaging a case that fails late (well after the inlet transient settled).
qs_picard_relax tuning ladder. When the bracket guard
gets you converged but slowly, or for legacy cases that need
the old behaviour preserved, the relaxation knob is still
exposed and follows a simple ladder
(src/aeolus1d/io/schema.py:247, default 0.7):
qs_picard_relax = 0.7— default. Works on near-steady or smoothly-evolving cases; matches the historicalQSNetworkConfigdesign and is bit-equivalent for every validated v1 case.qs_picard_relax = 0.3— sharp inlet-condition transients (e.g. a stepwise ṁ drop/rise over ~1 s on a TWC AT system). Cuts the secant amplitude so the bracket guard sees the sign flip earlier and lands on the true zero-imbalance solution at a modest convergence cost.qs_picard_relax = 0.1— very stiff regimes (cold-start light-off ramp, large porous-α + transient ṁ at the same time). Treat as the last-resort knob; convergence cost scales roughly inversely with the relaxation.
In all three cases the sign-bracket guard is doing the algorithmic heavy lifting — the knob just trims the secant's overshoot amplitude when the bracket forms late.
"My case used to load and now it is rejected"¶
Symptoms. CaseValidationError naming an unknown key, with a
did you mean …? suggestion and the file:line — on a case that ran
before.
Most likely cause. It was never doing what you thought. Unknown keys
in [[bc]], [[junction]], [[turbo]] and the nested sub-blocks used to
be a warning: the parser dropped the key and left the field at its
default. [case] and [[pipe]] always raised; the split was the
dangerous half, because every BC field is read with a default, so a
mistyped pO silently loaded p0 = 0.0 and ran a near-vacuum boundary.
Fix. Take the suggestion — it now normalises confusable glyphs, so
pO → p0 and TO → T0 are matched even though a two-character key
scores below a plain fuzzy-match cutoff. If a key is genuinely obsolete,
delete it; the error lists the valid keys for that block.
Related refusal: p0 must be positive / T0 must be positive on a BC.
That is the consequence the unknown-key error prevents, caught directly
— a stagnation inlet computes a0 = sqrt(γ·R·T0) and divides by it, so a
zero used to kill the run at step 0 with a bare ZeroDivisionError
naming nothing.
"Everything is at near-vacuum / density ~1e-5"¶
Symptoms. The run completes, no warning, and the final state has
rho around 1e-5 kg/m³ with pressures that look four or five orders of
magnitude too small.
Most likely cause. Pressures written in bar. p = 1.0 means one
pascal. This is the single most common unit slip for anyone arriving from
GT-POWER or AVL, and nothing in the schema catches it — 1.0 is a
perfectly valid float for a pressure.
Fix. Multiply by 1e5. aeolus1d is strict SI throughout: Pa, K, m, s,
kg. A load-time warning now fires for this ("p = 1 Pa with T = 300 K
— pressures are in PASCAL"), but only when a temperature in the Kelvin
band shows the case is in physical units at all: several bundled examples
(01_single_pipe_sod among them) are the classic non-dimensional
Riemann problems with p = 1.0 and no temperature, and warning on those
would train you to ignore the message.
"negative density (or e_internal ≤ 0)"¶
Symptoms. _line_search_trial_jit rejects every trial,
exits with Newton step produced non-physical state on cell
N. Or HDF5 has rho < 0 in a final cell.
Most likely cause. Either an extreme expansion wave reached near-vacuum conditions, OR a momentum-conservation BC mismatch is forcing mass out of a closed pipe.
Fix in order:
- Check far BCs. Both ends of every pipe need something filling ghost cells. A wall on a pipe that should have a pressure-outlet creates a closed reservoir; small numerical error grows over many steps.
- Refine cells near shocks. A first-order scheme on a
sharp shock can produce
rho ≈ 1e-3and trip the positivity guard. Either usescheme = "muscl"or doublen_cells. - Check the slope limiter. The limiter is a case-level
field (
[case] limiter, applied to every MUSCL pipe) and already defaults to"minmod"— the safest choice. If your case setslimiter = "van_leer"(sharper but less robust near genuine vacuum) or"none", set it back to"minmod". - Reduce the step near the expansion. There is no density
floor knob — clamping ρ would hide conservation bugs. Lower
[case] cfl(or, on the implicit drivers, enableadaptive_dt = trueso Newton difficulty shrinks Δt automatically) so the expansion is resolved in time;aeolus1d analyze-cfl case.toml --cellsshows which cells are closest to the positivity limit.
"CFL exploded"¶
Symptoms. --info-every-n shows dt < 1e-15. The
adaptive-Δt path is stuck shrinking forever.
Most likely cause. A high-frequency oscillation has set in, typically from a discontinuity-amplifying scheme on near-discontinuous initial data.
Fix in order:
- Run
aeolus1d analyze-cfl case.toml. It reports the acoustic-CFL dt per pipe before you run anything, and--cellspinpoints the constraining cells — answering "which pipe is driving dt to zero" without a solver run. - Switch to MP5 or MUSCL. First-order is dissipative but stable; MP5 + a limiter handles sharp gradients cleanly.
- Check the initial state for steps. A Riemann problem
needs
[pipe.init] kind = "riemann"with explicit left/right states, not a hand-built step in a uniform init. - Add a small artificial viscosity. Set
[pipe.friction] kind = "darcy_const" f = 0.01 D_h = (pipe D)— tiny friction smooths high-frequency artifacts at the cost of some physical damping.
"Solver hangs"¶
Symptoms. No output for minutes; the runner dock's progress bar stops advancing.
Most likely cause. The Numba JIT is compiling, OR a Python callback is blocking.
Fix in order:
- First run is slow. Numba compiles every kernel on first
call — a one-time ~19 s (measured 2026-08-08) for the full quasi-steady +
chemistry kernel set on a cold cache (~8 s warm). Run
aeolus1d warmuponce to do the compile out loud and prime the on-disk cache;aeolus1d doctorreports whether the cache is warm or cold. Subsequent runs reuse the cache. - Check
py_friction/py_heat/py_porous. A user callable that calls back into matplotlib, GUI, or a slow external library can block. Test the callable in isolation first. - GIL contention. With
parallel_pipes = trueand a Python override on any pipe, the threads collide on the GIL. Use native closure kinds where possible.
"GUI dependencies missing (No module named '_lzma')"¶
That message was wrong, and is fixed as of 2026-08-17. If you are on an
older build and see it, do not run the pip install ... [gui] it suggests:
it cannot help.
_lzma is part of the Python standard library, not the [gui] extra. It
is a CPython extension module compiled only when liblzma headers are present at
interpreter build time, so it is absent from an interpreter built without
them — most often a pyenv / python-build compile on a machine that did not
have liblzma-dev installed. Nothing you pip install can supply it.
Confirm the diagnosis:
python -c "import lzma" # ModuleNotFoundError: No module named '_lzma'
aeolus1d doctor # reports INCOMPLETE PYTHON BUILD and what to do
Fix it by giving the build the headers and rebuilding:
sudo apt install liblzma-dev # Debian / Ubuntu
sudo dnf install xz-devel # Fedora / RHEL
brew install xz # macOS / Homebrew
pyenv install --force 3.11.9 # then rebuild the interpreter
A distribution Python (apt install python3) or a python.org installer already
has it, so switching interpreters also works. The same applies to _bz2,
_ssl, _sqlite3, _ctypes, _tkinter, readline and zlib — aeolus1d
doctor lists every one it finds missing, with the package that supplies it.
Note that aeolus1d's solver does not need _lzma: a case loads and runs on
an interpreter without it. It surfaces through a GUI dependency's import chain,
which is why it looked like a GUI problem.
"GUI runs the case but I see no plot"¶
Symptoms. The runner shows "Run completed"; the network view loads colours, but the Plot dock shows "select a component".
Most likely cause. The case has no [[tracer]] or
--trace-dt wasn't set, so only the final state was written.
The Plot dock falls back to "click a component" prompt rather
than auto-selecting.
Fix. In the GUI runner dock, tick "Trace every Δt:" and
set the cadence (defaults to 1e-4 s, capped at 1e6 s), then
re-run. On the command line pass --trace-dt 1e-4 to aeolus1d
run. There is no [case] TOML key for this — tracing is a
run-time option (CLI flag / GUI control), not a case field, and
adding trace_dt under [case] is rejected as an unknown key.
Default is no trace, so only the final state is written.
"Cylinder pressure looks wrong"¶
Symptoms. The cylinder p(θ) trace shows compression but combustion adds way too much pressure (or too little).
Most likely cause. Wiebe heat-release parameters off, or
the combustion m_fuel is misset.
Fix in order:
- Check
m_fuelin[cylinder.combustion]. Units are kg per cycle per cylinder. A 500 cc cylinder at λ = 1 gets ~5e-5 kg/cycle of fuel; an order-of-magnitude error is obvious in p_max. - Two-zone vs single-zone. Bulk T (single-zone) is
inflated near peak combustion because constant γ doesn't
capture the burnt-mixture composition. Set
[cylinder.combustion] two_zone = trueand readlast_T_burnt(the GUI plot dock surfaces it asT_burntwhen traced). - Burn duration. Diesel: 40–60° CA. Naturally aspirated SI: 30–50° CA. Turbocharged SI: 25–35° CA. If you're off-window, the Wiebe shape produces wrong p_max.
- Spark / injection timing.
theta_start_degin[cylinder.combustion]is the crank angle (degrees) at which the heat release ramp starts. TDC is360 degby convention; advance is< 360, retard is> 360.
"Everything conserves, but the answer is still wrong"¶
Symptoms. Mass and energy balance to machine precision, the golden snapshots pass, nothing warns — and a temperature, a residual mass or an emissions number is plainly unphysical.
Most likely cause. An attribution error rather than a
conservation one. A transfer can move the wrong amount and still
balance perfectly, because the same quantity is credited to one
side and debited from the other. Two shipped examples, both fixed:
the exhaust valve shed mass at cp(gamma_advection) while the
cylinder held cp_eff, retaining 26 % of the heat release; and
volume.x_burnt carried the burn progress, so gamma_eff
reinterpreted an unchanged (m, E, V) and pressure jumped 29.6 %
at every crank wrap.
No conservation check can see either. Tightening one will not help — it is the wrong instrument.
Fix in order:
-
Turn on the attribution checks and re-run:
AEOLUS_PHYSICS_ASSERTS=1 aeolus1d run case.tomlThis asserts two things a conservation check cannot: that every unit of mass a cylinder sheds carries the cylinder's own specific enthalpy, and that no derived quantity (
p,T) jumps while its arguments(m, E, V)stand still. Both are off by default and cost nothing when off.Use
AEOLUS_PHYSICS_ASSERTS=warnto print to stderr and keep going, which is the useful mode when bringing up a case that trips the check immediately. -
Read the ratio it reports. A shed-enthalpy ratio near
cp(gamma_advection)/cp_eff(≈ 0.74 for 1.4 against 1.27) means something is rebuilding enthalpy from the advection gamma. A pressure jump at fixed(m, E, V)means something changed the mapping rather than the state —gamma_effand its burnt fraction, the EOS branch, or a resume path applying fields in the wrong order. -
Suspect any
cp = gamma*R/(gamma-1)near a component boundary. Crossing between a 0-D volume and a 1-D pipe must be written in a form that presumes neither side's gamma —h = (E + pV)/mnames no thermodynamic coefficient at all and is exact undergamma_eff, undercaloric_thermo, and under a real-gas EOS alike. See the "Two γ's, and the seam between them" section of cylinder-reference.md.
"Chemistry too slow on AVL-style runs"¶
Symptoms. A QS driver case with a Brinkmeier TWC
(AVL Cruise-M MFR_transition_SC630, MFR EAT bench
variants, anything that bolts the oxygen-storage / NOx
mechanism onto a catalyst pipe) walls at ≫ 1× realtime
even after JIT compile has settled. Profiler shows
_qs_surface_chem_step dominating, with the per-substep
LSODA RHS closure showing up as a hot Python frame.
Most likely cause. Either the fused @njit Brinkmeier RHS isn't engaging (so LSODA is paying the per-substep Python closure tax), or the chemistry sub-step is firing every macro step through a regime where it could safely super-cycle.
Fix in order:
-
Verify
qs_chem_fused_rhs = True. This is the default since MR !134 (2026-05-31,src/aeolus1d/io/schema.py:359). It swaps the per-substep Python RHS closure in_qs_surface_chem_step(dict comprehension + reshape + extras-reorder +ascontiguousarraycasts) for a single @njit call into_qs_brinkmeier_rhs_fused. Measured 2.08× speedup on AVL Cruise-MMFR_transition_SC630(97.7 s → 47.0 s on a 12 s sim, 8.15× → 3.92× realtime), bit-equivalent to the Python RHS — LSODA takes the identical step sequence. Anchored bytests/integration/test_qs_chem_fused_rhs.py. Non-Brinkmeier closures (generic builder, NH3,py_reactionkinds) lack_jit_paramsand silently fall back to the Python RHS — no per-case opt-out needed. If you have manually set this toFalsein a case TOML, undo that unless you are diagnosing a Brinkmeier-specific regression.scripts/profile_post_fused.pyis the reference profile to confirm the fused path is engaged. -
Enable the chemistry super-cycle with a settle gate.
chem_every_n = N(default1) fires the chemistry sub-step once every N macro steps over the accumulatedΣ Δt(transport + steady-energy still run every step). The same knob drives the Strang and implicit-network Lie super-cycle, not just QS. It is anN×lever on chemistry-dominated walls, but it is catastrophic during light-off — at the transient achem_every_n = 4run took 2.25 M steps. The fix is to combine it with a settle gate so the super-cycle self-suspends during transients (the two settle gates below are consulted only whenchem_every_n > 1): -
qs_chem_settle_tol(default0.0= disabled): fire chemistry on EVERY macro step while the lagged HRR fractional change exceeds the tol; only honour the every-N skip once HRR settles below it. A small positive value like1e-2(1 %/step) is a safe start. qs_chem_coverage_settle_tol(default0.0): companion gate, coverage-aware. The HRR gate is energy-based and blind to a still-relaxing oxygen-storage θ on a hot catalyst — set this to a small absolute per-fireΔθ(e.g.1e-3) to keep NOx / θ fidelity while still super-cycling once both gates settle.chem_interval(default0.0, seconds): simulated-time companion tochem_every_n, shared by the Strang, implicit-network, AND quasi-steady drivers. Forces a chemistry fire whenever the accumulatedΣ Δtsince the last chem call exceeds the interval, no matter how cheap the macro step is. Typical1e-3–1e-1s for transient AT — track the chemistry timescale, not the acoustic / advective one. Fires on EITHER the step counter or the interval.
The gates are independent and fire on EITHER — set the
ones that match the regime you're super-cycling through,
leave the rest at 0.0.
- Pick
chem_solverfor the mechanism. The defaultchem_solver = "auto"(src/aeolus1d/io/schema.py:375) routes to"bs_jit"only when the pipe matches the JIT kernel's Arrhenius-only, single-species contract (py_reaction_paramsset, no multi / surface / extras); everything else (including Brinkmeier surface chem) falls back to LSODA. For an Arrhenius-only mechanism thatautois not routing tobs_jit(e.g. you've added extras for diagnostics that block the contract), setchem_solver = "bs_jit"explicitly to force the @njit Bader-Deuflhard semi-implicit Bulirsch-Stoer kernel. Do not set it on stiff oxygen-storage TWC (Brinkmeier) — LSODA's variable-order BDF is the right integrator there, and the fused-RHS path already delivers the 2.08× win without changing the integrator choice.chem_solver = "fixed_step"is the backward-Euler path and is opt-in for non-stiff mechanisms only (overshoots the Brinkmeier Ce-OSC transient).
Got an issue that isn't here?¶
- Check the validator output. Phase B10 emits a list of every spec problem before the build path runs — fixing those usually fixes the symptom.
- Run
aeolus1d analyze-cfl case.tomlfor a pre-run picture of the acoustic time-step budget (per pipe;--cellsfor the constraining cells). - Run with
--info-every-n 100to see the time-stepping cadence. A near-zerodtor growing Newton iteration count is the strongest signal of a physics or closure problem. - File an issue with the case TOML + run command + the last
few lines of solver output, and paste the
aeolus1d doctorblock (Python / package versions, env vars, numba-cache state) — it is the environment dump SUPPORT.md asks for. The bundled examples are the reference for "what's expected to work."