Skip to content

fix(imspy-simulation): never write a non-finite precursor intensity (#488) - #489

Merged
theGreatHerrLebert merged 3 commits into
mainfrom
fix/488-charge-norm-inf
Oct 15, 2026
Merged

theGreatHerrLebert merged 3 commits into
mainfrom
fix/488-charge-norm-inf

Conversation

@theGreatHerrLebert

Copy link
Copy Markdown
Owner

Fixes the simulator side of #488: some simulated DDA .d files carried Precursors.Intensity = inf, which timsrust >= 0.6 rejects (it reads TDF rows through JSON, which has no infinity).

Root cause

BinomialChargeStateDistributionModel renormalises the charge states that pass min_charge_contrib. A peptide with many basic sites (the affected HLA peptide AVGKNKRLTKGGKKGAKKKVVD has nine) puts most of its binomial mass above max_charge, which is truncated away, so every remaining state falls below the threshold and the kept mass is zero. Before January 2026 the loop divided by it: every state became inf, inf passed the threshold, and relative_abundance * events * frame_abundance reached the precursor table as inf. The current guard avoids the division, but the peptide is then silently dropped.

Changes

  • imspy_predictors.ionization.predictors: the selection moves into kept_charge_states(). When the kept mass is zero under normalisation, the peptide is kept at its most probable finite charge with abundance 1.0. Every other path is the original arithmetic unchanged (rescale all states, then threshold; same np.sum, same dtype), checked against a verbatim copy of the old loop on 5000 random distributions and on binomial outputs.
  • imspy_simulation.tdf: write_precursor_table calls a new validate_precursor_values(), which refuses inf/NaN in the m/z, scan and intensity columns (a missing MonoisotopicMz is still allowed) and names the offending Ids. Any future upstream source of a non-finite value then fails the run instead of producing a .d that some readers reject.

Verification

  • New tests: zero-mass fallback, the real binomial model on the affected peptide (now one finite z=4 ion), parity with the old loop, threshold-0/all-zero/NaN/dtype edge cases, and the writer guard including pandas nullable Float64.
  • imspy-predictors/tests/test_predictors.py + new tests + all of imspy-simulation/tests: 249 passed, 15 skipped (hardware/SDK-gated), 0 failed. Same failing set as main in a reduced environment, i.e. no regressions.
  • Reproduced the reader failure with timsrust 0.4.2 vs 0.6.7 on the affected fixture; after patching the precursor values, 0.6.7 reads it.

Code review

Two Codex review rounds. Round 1 caught a real regression in the first version of the helper (it thresholded before rescaling, which changed the ions of ordinary peptides) plus three edge cases (zero threshold, normalize=False, nullable dtype); all are fixed in a153ae7. Round 2 confirmed those fixes and flagged a dtype-parity nit, fixed in 183f2d3.

Out of scope, noted by the review: other TDF metadata writers (PasefFrameMsMsInfo, DIA windows) have no finite-value guard yet; no current path is known to feed them non-finite values.

The affected published fixtures (SAGEBench HLA bundles on Zenodo) are corrected separately as a new record version; no re-simulation is needed.

…488)

A peptide with many basic sites puts most of its binomial charge mass above
max_charge, which is truncated away, so every remaining state can fall below
min_charge_contrib. The charge-state normalisation then divided by a zero kept
mass: every state became inf, inf passed the threshold, and the DDA precursor
intensity (relative_abundance * events * frame_abundance) reached
Precursors.Intensity as inf. timsrust >= 0.6 reads TDF rows through JSON, which
has no infinity, and rejects the whole table.

- predictors: factor the selection into kept_charge_states(). When no state
  passes the threshold, keep the most probable charge at abundance 1.0 instead
  of dividing by zero (old) or silently dropping the peptide (current guard).
- tdf: write_precursor_table refuses inf/NaN in m/z, scan and intensity columns
  (MonoisotopicMz may be NULL, never inf), naming the offending Ids, so any
  future upstream source fails the run instead of emitting an unreadable .d.

Tests cover the zero-mass fallback, the binomial model on the peptide from the
affected HLA fixture, and the writer guard.
…-degenerate path

Review follow-up. kept_charge_states() applied the threshold before rescaling,
while the original loop divides all charged states by the kept mass and then
thresholds the rescaled values, so peptides whose low-probability states cross
the threshold after rescaling lost ions. Restore the original arithmetic
verbatim (np.sum, rescale-then-threshold) and confine the change to the
zero-kept-mass case under normalisation:

- the fallback no longer fires with normalize=False, where nothing is divided
- an all-zero charged vector behaves as before (finite zeros at threshold 0)
- the fallback picks the largest finite state, never a NaN position
- validate_precursor_values accepts pandas nullable Float64 with missing m/z

Tests now compare the helper against a verbatim copy of the old loop on 5000
random distributions and on binomial outputs.
@theGreatHerrLebert
theGreatHerrLebert merged commit 6bb300f into main Oct 15, 2026
5 checks passed
@theGreatHerrLebert
theGreatHerrLebert deleted the fix/488-charge-norm-inf branch October 15, 2026 19:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant