Skip to content

Semi-analytic Jacobian for 1D flames#2139

Draft
speth wants to merge 28 commits into
Cantera:mainfrom
speth:oned-analytic-jacobian
Draft

Semi-analytic Jacobian for 1D flames#2139
speth wants to merge 28 commits into
Cantera:mainfrom
speth:oned-analytic-jacobian

Conversation

@speth

@speth speth commented Jun 16, 2026

Copy link
Copy Markdown
Member

Changes proposed in this pull request

The 1D flame solver currently computes a finite difference Jacobian for use with the Newton's method solver. The main efficiency this exploits is the block tridiagonal pattern of the Jacobian, where the state at each grid point only affects the residuals at that grid point and the immediate neighbors, so everything scales linearly with the number of grid points $N$. However, the number of function evaluations to compute each block row is $O(K)$ and the cost is also $O(K)$, making this Jacobian evaluation an $O(NK^2)$ cost. Using a banded matrix solver, where the bandwidth is proportional to $K$, the factorization cost is $O(NK^3)$ and the backsubstitution/solve cost is $O(NK^2)$. It turns out, though, that the constant factors on these different operations are quite different, and for a range of mechanism sizes, the Jacobian construction cost dominates.

This cost can be dramatically reduced by computing a semi-analytical Jacobian, where the derivatives with respect to species mass fractions are computed analytically and derivatives with respect to the remaining state variables are computed using the existing finite difference approach. Derivatives of the reaction rate expressions make use of the existing Kinetics derivatives, while derivatives of the transport-related terms are new additions to the Flow1D class.

Currently, the analytic Jacobian works for ideal gases using mixture-averaged transport, including the mixture-averaged approximation for the Soret diffusion term. It is enabled by default, but automatically falls back to the finite difference form for other flow domains (specifically, IonFlow), transport models, or equations of state. It can also be manually switched via the Domain1D.jacobian_mode property.

This PR also revises the performance statistics available from the 1D solver, introducing an AnyMap-based store accessible via Sim1D.solver_stats that provides break downs of run time and call count for function evaluations, Jacobian evaluations, Jacobian factorizations, and linear solves for each grid adaptation step.

If applicable, provide an example illustrating new features this pull request is introducing

The following is a performance comparison on a laminar flame speed calculation, using mechanisms of various sizes, including a couple from NUI Galway converted to YAML format.

import cantera as ct
import pandas as pd
ct.suppress_thermo_warnings()

def run(mech, reactants, jacobian_mode):
      p = ct.one_atm  # pressure [Pa]
      Tin = 300.0  # unburned gas temperature [K]
      width = 0.03  # m
      loglevel = 0  # amount of diagnostic output (0 to 8)

      gas = ct.Solution(mech)
      gas.TPX = Tin, p, reactants

      f = ct.FreeFlame(gas, width=width)
      f.flame.jacobian_mode = jacobian_mode
      f.set_refine_criteria(ratio=3, slope=0.06, curve=0.12)
      f.transport_model = 'mixture-averaged'
      f.flux_gradient_basis = "mass"
      f.solve(loglevel=loglevel, auto=True)
      print(f"mixture-averaged flamespeed = {f.velocity[0]:7f} m/s")

      f.soret_enabled = True
      f.solve(loglevel=loglevel) # don't use 'auto' on subsequent solves
      print("mixture-averaged flamespeed with Soret diffusion = {f.velocity[0]:7f} m/s")

      df = pd.DataFrame.from_dict(f.solver_stats)
      print(df)
      print(df[['residual_time', 'jacobian_time', 'factor_time', 'solve_time', 'total_time']].sum())


CASES = [
    ('h2o2.yaml', 'H2:1.1, O2:1, AR:5'),  # 10 species, 29 reactions
    ('gri30.yaml', 'CH4:0.45, O2:1, N2:3.76'),  # 53 species, 325 reactions
    ('methanol-galway2016.yaml', 'CH3OH:0.666, O2:1, N2:3.76'),  # 173 species, 1011 reactions
    ('ethanol-DME-galway2018.yaml', 'C2H5OH:0.3666, O2:1, N2:3.76')  # 433 species, 1004 reactions
]

for mech, reactants in CASES:
      for jacobian_mode in ['finite-difference', 'analytic']:
            print("-" * 80)
            print(f"{mech=}")
            run(mech, reactants, jacobian_mode)
            print(f"{jacobian_mode=}")

Collating the output shows the resulting performance benefits (times in seconds; using OpenBLAS with OMP_NUM_THREADS=1).

n_species mode f eval J eval J factor lin solve total speedup
10 FD 0.07 0.19 0.02 0.03 0.32 -
10 analytic 0.07 0.09 0.02 0.03 0.22 1.44
53 FD 1.21 9.62 0.55 0.34 11.76 -
53 analytic 1.17 1.89 0.54 0.32 3.95 2.97
173 FD 20.79 218.42 18.05 12.27 270.30 -
173 analytic 18.60 21.95 16.32 10.91 68.48 3.95
433 FD 92.42 632.96 200.89 62.40 992.94 -
433 analytic 96.15 69.33 196.78 65.21 431.70 2.30

The benefit for very small mechanisms is limited, but grows for medium-sized mechanisms. Eventually, for larger mechanisms, even though the Jacobian evaluation cost is reduced by a factor of ~9, the factorization step becomes the dominant contributor to the overall runtime and the relative speedup starts to decline.

AI Statement (required)

  • Extensive use of generative AI. Significant portions of code or documentation were generated with AI, including
    logic and implementation decisions. All generated code and documentation were reviewed and understood by the contributor. Implemented using Claude Code (Opus 4.8) and superpowers.

Checklist

  • The pull request includes a clear description of this code change
  • Commit messages have short titles and reference relevant issues
  • Build passes (scons build & scons test) and unit tests address code coverage
  • Style & formatting of contributed code follows contributing guidelines
  • AI Statement is included
  • The pull request is ready for review

speth and others added 28 commits June 16, 2026 09:57
…o Python

Add `Sim1D.eval_jacobian()` to trigger `evalSSJacobian()` from Python, and
`Domain1D.global_component_index(name, j)` to map a component/point pair to
its index in the global solution vector. Both are needed by the upcoming
analytic Jacobian comparison harness. Includes a test that verifies the
assembled Jacobian matches a manual finite-difference residual perturbation
to within 2% (the tolerance accounts for frozen transport properties in the
solver FD vs full recomputation in sim.eval()).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a per-domain `jacobian_mode` flag ("finite-difference" or
"analytic"), two virtual hooks (`hasAnalyticJacobian(j,n)` and
`evalJacobianAnalytic(x, jac)`), and the column-skipping logic in
`OneDim::evalJacobian` that calls `evalJacobianAnalytic` after the FD
loop. No domain claims any columns yet, so this is a behavioral no-op.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add an overload netProductionRates_ddCi(SparseMatrix&) that fills a
caller-owned sparse matrix and reuses its nonzero pattern across calls.
The pattern of d(wdot_k)/d(c_m) is fixed for a given mechanism and
derivative settings, so it is built once (delegating to the allocating
path) and subsequent calls overwrite the value array in place with no
allocation or symbolic SpGEMM.

The BulkKinetics override fuses the stoichiometry-matrix product directly
into the compressed column storage using an O(K) dense scatter column
(m_rbuf3) rather than a K x K dense buffer, mirroring
calculateCompositionDerivatives() with ddX=false. Result is numerically
identical to the allocating netProductionRates_ddCi() (verified to <1e-12
relative). No new Python API.

Benchmark (gri30, optimize=n library): ~1447 us/call allocating vs
~883 us/call reused = 1.64x on the primitive.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds the compare_modes harness and TestAnalyticVsFD, and fixes two bugs
found by the FD oracle:
- The capability probe ran inside evalJacobianAnalytic (after the FD
  column-skip loop), so claimed columns were never skipped and the
  analytic post-pass double-counted them. The probe now runs lazily from
  the const hasAnalyticJacobian()/usingAnalyticJacobian() queries.
- The 1/(rho cp) chain term in the energy row had the wrong sign
  (FD-verified: d(1/(rho cp))/dY_m = (1/(rho cp))(wbar/W_m - cpm/cp),
  giving -B*rcp*(...) for the residual rsd = -u dTdz - B/(rho cp)).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add TestAnalyticConfigMatrix with four tests covering: axisymmetric
counterflow diffusion flame, unstrained burner flame, Soret diffusion
enabled, and two-point control. All pass without new C++ changes,
confirming Tasks 1-7 cover these row/column variants correctly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add TestAnalyticSolve.test_full_solve_matches_fd which solves a GRI-3.0
stoichiometric CH4/air free flame twice — once with the default
finite-difference Jacobian and once with analytic mode — exercising
regridding, time stepping, and workspace reallocation. Asserts flame
speeds agree to within 1e-4 relative tolerance (actual: ~7.6e-7).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a 'Jacobian Evaluation Modes' subsection to the nonlinear-solver
reference page describing the new opt-in 'analytic' mode, its fallback
conditions, and the frozen-transport approximation. Update the
surrounding paragraph to match. Add a corresponding entry to the
Cantera 4.0 release notes under 'New features'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ecycle

Address final-review findings on the analytic 1D Jacobian:

- The banded MultiJac solver caches the steady-state diagonal in m_ssdiag,
  updated only when a diagonal entry is written and never cleared by reset().
  The analytic species fill skipped emitting entries that evaluated to exactly
  zero, so an exactly-zero claimed species-row diagonal would leave m_ssdiag
  stale and corrupt the transient diagonal. Always emit the (j == p, k == m)
  diagonal, mirroring the forced-diagonal write in OneDim::evalJacobian's FD
  loop. (In practice the species diagonal is never bit-exactly zero, so this is
  insurance rather than an observed failure.)

- Document that m_ddC's sparse pattern is assumed fixed for the lifetime of the
  grid: growing the kinetics derivativeSettings() pattern (e.g. disabling
  skip-third-bodies) without an intervening resize() is unsupported.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tateSystem

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…cate per-metric accessors

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tats test

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Route every updateTransient() call site in the 1D solver (steady solve,
time-step setup, steady-mode switch, and the Newton re-evaluation path)
through a single timed helper so factor_time/factorizations also account
for factorizations during time stepping, not just steady Newton solves.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Key changes:
- Extend analytic Jacobian to cover grid points 1 and N-2 (previously 2..N-3);
  boundary residuals have no dependence on neighboring species so those rows are
  safely skipped. Minimum grid size drops from 6 to 3.
- Refactor fluxJacobian to template<bool AtQ> computing only the needed endpoint
  derivative, eliminating the scratch buffer and halving the inner-loop work.
- Remove unused x parameter from computeWdotDerivatives.
- Rename fuseStoichProduct → accumulateStoichProduct; explain template necessity
  (StoichManagerN::derivatives returns Eigen::Map, not SparseMatrix).
- Move factorizeJacobian() body to SteadyStateSystem.cpp, localizing <chrono>.
- Inline the single-use accumulateGridTime lambda in SteadyStateSystem::solve.
- Reorganize Flow1D::hasAnalyticJacobian/evalJacobianAnalytic into the main
  public section rather than a mid-class public: block.
- Fix math-mode formula in radiationPolyFactor docstring.
- Fix Doxygen group comment in SteadyStateSystem.h staging accumulators.
- Remove unused Cython pxd getters (gridSizeStats etc.); add pyi stubs for
  new API (jacobian_mode, global_component_index, eval_jacobian, solver_stats).
- Add analytic-jacobian.md derivation page to Sphinx docs.
- Update release note with mechanism-size scaling explanation.
- Speed up test_radiation by using h2o2.yaml instead of GRI 3.0.
- Update TestAnalyticVsFD claimed-column range to match new j >= 1 condition.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
accumulateStoichProduct now scans for product entries that the
supplied pattern cannot store and throws instead of silently
dropping them (and leaking into the next column). Compiled out
under NDEBUG.

Aso, merge the three analytic-vs-FD comparison classes into one
TestAnalyticVsFD with compare_modes as a member; solve the comparison
configs with auto=False to avoid masking unexpected failures; fold the
deprecated-alias check into test_solver_stats; and replace GRI 3.0 with
h2o2 (loose refinement) in test_full_solve_matches_fd.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Overview: the Jacobian is reused across Newton steps (only re-evaluated
  on stalls), not rebuilt every iteration; the finite-difference build costs
  N_v *local* (3-point) residual evaluations, not N_v full ones, thanks to
  the block-tridiagonal structure; and N_v = (K+c)N including the non-species
  components, so the N_v - K(N-2) saving is c*N + 2K remaining FD columns.
- Adopt the discretization.md notation throughout: grid points as subscripts,
  Y_{k,j}/X_{k,j}, and the midpoint diffusive flux j_{k,j+1/2}. Resolves the
  overloaded F_k^{(j)} (previously both residual and flux) and the mixed
  Y_m(q)/X_n^{(q)}/D_{mq} indexing.
- Drop the "AtQ" implementation jargon in favor of left/right endpoint, and
  use p for the perturbed column point and j for the residual row point.
- Correct the species residual to the divided-by-rho_j form actually
  assembled in Flow1D::evalSpecies (consistent with the 1/rho_j Jacobian
  factors), and write out the density-chain and right-endpoint flux terms.
- Define the Kronecker delta in the Notation section ($\delta_{km}$ was used
  in the flux derivatives but never introduced; it is unrelated to the
  diffusion symbol).
- Rename the diffusion prefactor D -> \tilde{D} and define it explicitly as
  the composite flux prefactor rho*W_k/Wbar * D_{k,mix} (molar basis) actually
  stored in Flow1D::m_diff, distinguishing it from a bare mixture-averaged
  diffusion coefficient D_{k,mix}, and note its mass-basis form.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Introduces a three-valued jacobian_mode. 'auto' (the new default) uses the
analytic Jacobian where supported and silently falls back to finite
differences otherwise; the capability probe no longer warns on fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
In explicit jacobian_mode = "analytic", OneDim::evalJacobian now validates each
domain before the finite-difference column loop and raises if the kinetics lacks
composition derivatives or multicomponent transport is active. Internal/transient
conditions (adjoint force-full-update, fewer than 3 points) still fall back
silently.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…inistic

IonFlow overrides the species residual and diffusive-flux physics (ion drift,
electric-field coupling) without supplying matching analytic Jacobian
derivatives, so the inherited Flow1D analytic columns describe a different
residual than IonFlow solves. With the analytic Jacobian now the default, this
broke the stage-two ion-flame solve. Add an analyticJacobianSupported() hook
(false for IonFlow) so "auto" silently uses finite differences and explicit
"analytic" raises for ion flames.

Also pin finite differences in the high-pressure regrid test helper, whose
exact step-count assertions depend on the Jacobian method.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@codecov

codecov Bot commented Jun 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.56604% with 40 lines in your changes missing coverage. Please review.
✅ Project coverage is 78.01%. Comparing base (cfa382e) to head (1eb828d).

Files with missing lines Patch % Lines
interfaces/cython/cantera/_onedim.pyx 40.00% 15 Missing ⚠️
src/oneD/OneDim.cpp 58.33% 9 Missing and 1 partial ⚠️
src/oneD/Flow1D.cpp 97.13% 1 Missing and 6 partials ⚠️
src/kinetics/BulkKinetics.cpp 90.24% 0 Missing and 4 partials ⚠️
src/kinetics/Kinetics.cpp 0.00% 2 Missing ⚠️
include/cantera/numerics/SteadyStateSystem.h 90.00% 1 Missing ⚠️
src/numerics/SteadyStateSystem.cpp 98.24% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2139      +/-   ##
==========================================
+ Coverage   77.81%   78.01%   +0.19%     
==========================================
  Files         452      452              
  Lines       53700    54050     +350     
  Branches     8976     9056      +80     
==========================================
+ Hits        41789    42169     +380     
+ Misses       8886     8845      -41     
- Partials     3025     3036      +11     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant