Skip to content

High: [Crystallizers] Concentration Jacobian diagonal has wrong sign for crystallization sink #26

Description

@bernalde

Maintainer triage (June 29, 2026)

  • Status: Verified bug against the current master source in this fork.
  • Severity: High.
  • Area labels: area:crystallizers.
  • Tackle batch: P4 - high-impact numerical/model correctness.

The source review confirmed that the cited code path and failure mode are still present. The original audit details are preserved below for reproduction notes and suggested fixes.


Original audit details

Part of the model-correctness audit — see #17.

Severity: high | Category: sign-convention | Location: PharmaPy/Crystallizers.py lines 1401-1410

What's wrong

In jac_states, the concentration RHS is dcomp_i/dt = -transf/vol * (kron_jtg_i - conc_i/rho_l) (material_balances line 1513, with transf == tr from line 1401). Differentiating w.r.t. conc_j gives two terms: dfconc_dconc_ij = -(1/vol)[ (d tr/d conc_j)(kron_i - conc_i/rho_l) - trdelta_ij/rho_l ]. The second (diagonal) term must enter the bracket with a MINUS sign because d(kron_i - conc_i/rho_l)/d conc_j = -delta_ij/rho_l. The code instead builds second_conc = tr/rho_lnp.eye(...) and adds it: dfconc_dconc = -1/vol_liq*(dtr_dconc_tgfirst_conc + second_conc). This makes the diagonal contribution -tr/(volrho_l) instead of the correct +tr/(vol*rho_l), i.e. the sign of the entire self-derivative term is inverted for every species (including the target).

Why it's incorrect

Hand differentiation of dcomp_i = -tr/vol*(kron_i - conc_i/rho_l): the chain-rule on the bracket yields -tr/vol * (-delta_ij/rho_l) = +tr/(vol*rho_l)delta_ij. The code produces -tr/(volrho_l)*delta_ij. The off-diagonal/outer-product (first_conc) term is correct and the analogous dfconc_dvol (line 1421) and dfconc_dmu2 (line 1378) are correct, which confirms the leading -1/vol sign convention; only second_conc has the wrong relative sign. This directly corrupts the species-concentration block of the sensitivity RHS (rhs_sensitivity line 697 uses jac_states_vals) and any Newton/Jacobian-based integration that consumes the analytical jac_states.

Suggested regression test

Finite-difference cross-check: set method='moments' on a BatchCryst, call jac_states(time, states, params, return_only=False) and compare against numerical_jac_central(lambda st: unit_model(time, st, params), states). The num_distr:-1 x num_distr:-1 sub-block diagonal entries will have opposite sign to the FD reference; fixing requires second_conc = -tr/rho_l*np.eye(len(w_conc)) (or subtracting it in the bracket).


Verification: 2/2 adversarial reviewers confirmed (refuted: 0).

Metadata

Metadata

Assignees

Labels

area:crystallizersCrystallizer models and slurry crystallization behaviorbugSomething isn't workingcorrectnessModel/numerical correctness defectcrystallizersCrystallization modelsseverity:highConfirmed bug with major correctness impact or broken documented workflowstatus:verifiedSource-reviewed against the current codebase

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions