You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Identical RF pulses played at different absolute times produce
different signal once cumulative simulated time crosses ~128 s. The
drift is silent — no warning, no NaN. Reproduced on master
@ d0d5c5fdd.
Root cause: the fixed constant MIN_RISE_TIME = 1e-14 in KomaMRIBase/src/timing/TimeStepCalculation.jl is used to place a
rise-edge marker at t1 + MIN_RISE_TIME. Past the point where eps(t)/2 > 1e-14 (i.e. t ≳ 128 s for Float64), that addition
rounds back onto t1 itself. sort!(unique!(t)) removes the
bit-equal duplicate, linear_interpolation returns the leading 0 at t1, and the integrator loses exactly one Δt_rf of B1 area at
the leading edge of every affected pulse (5 % of pulse area with the
default Δt_rf = 5e-5, T_pulse = 1 ms).
For a single 90°-ish pulse, that 5 % area loss → 7.7° flip angle drop
→ sin(145.61°) / sin(153.27°) = 1.256 → +25.6 % |Mxy| jump. For
the 16-shot IR sequence below the compound cos(α_180)·sin(α_90)
ratio lands at ~1.20 — the observed 22 % shift.
The pair (t1, t1 + ϵ) encodes the rise-edge step. At small t both
entries are distinct Float64s and linear_interpolation evaluates to B1 = 0 at t1, B1 = full at t1 + ϵ. Past ~128 s, t1 + ϵ == t1 bit-exactly, sort!(unique!(t)) collapses the pair,
and the integrator integrates B1 = 0 over a full Δt_rf interval
instead of the intended ULP-sized one.
This is not the same as the cumsum/sum 1-ULP path disagreement at
block boundaries; that one is benign (Interpolations.deduplicate_knots!
handles it). The load-bearing failure is the bit-equal collapse of
the rise-edge marker.
Affected scenarios
Any multi-shot acquisition where total simulated time > ~128 s. There is currently no way to detect this from the simulator output. I am using KomaMRI for RL agent training, so I was testing longer sequences (250s).
Why existing tests didn't catch it
Every existing KomaMRICore test runs sequences of milliseconds total
duration (no Delay past tens of ms anywhere in the test suite). The
bug only manifests past 128 s of cumulative simulated time, so the
suite was structurally blind to time-scaling regressions in get_variable_times.
Fix and PR
PR #780 implements the fix in get_variable_times by:
Wrapping the RF key-time list with Interpolations.deduplicate_knots!(_; move_knots=true) so any
bit-equal duplicates introduced by t ± ϵ rounding are perturbed
by 1 ULP via nextfloat. This reuses the same library function
already used in 5 places elsewhere on event timelines
(Sequence.jl:770/793, KeyValuesCalculation.jl:29/30/66).
Replacing the sequence-bookend t[1] - ϵ / t[end] + ϵ with max(MIN_RISE_TIME, 2·eps(t)) so the pad is distinct at any t
while preserving a MIN_RISE_TIME floor for tiny t.
The fix is a no-op at normal time scales (markers stay at t1 + ε / t2 - ε inside the pulse window) and only activates in
the regime where the original design was silently broken.
Regression tests added
Three layers in the PR — all fail on master, all pass after the fix:
KomaMRIBase (unit): get_variable_times produces sub-Δt_rf
marker pairs on both sides of every RF pulse, at any absolute t,
with strictly increasing t.
KomaMRIBase (semantic): the discrete ∫ B1·dt over the RF
window equals A · T_pulse at any absolute t (rtol = 1e-6).
KomaMRICore (integration): four identical 50 s-spaced shots
produce bit-identical |Mxy|. Crosses the 128 s threshold between
shots 2 and 3.
What happened?
Summary
Identical RF pulses played at different absolute times produce
different signal once cumulative simulated time crosses ~128 s. The
drift is silent — no warning, no NaN. Reproduced on
master@
d0d5c5fdd.Root cause: the fixed constant
MIN_RISE_TIME = 1e-14inKomaMRIBase/src/timing/TimeStepCalculation.jlis used to place arise-edge marker at
t1 + MIN_RISE_TIME. Past the point whereeps(t)/2 > 1e-14(i.e.t ≳ 128 sfor Float64), that additionrounds back onto
t1itself.sort!(unique!(t))removes thebit-equal duplicate,
linear_interpolationreturns the leading 0 att1, and the integrator loses exactly oneΔt_rfof B1 area atthe leading edge of every affected pulse (5 % of pulse area with the
default
Δt_rf = 5e-5, T_pulse = 1 ms).For a single 90°-ish pulse, that 5 % area loss → 7.7° flip angle drop
→
sin(145.61°) / sin(153.27°) = 1.256→ +25.6 % |Mxy| jump. Forthe 16-shot IR sequence below the compound
cos(α_180)·sin(α_90)ratio lands at ~1.20 — the observed 22 % shift.
Minimal reproducer
Output (master)
The threshold is time-driven, not shot-count-driven: it sits at the
power-of-2 boundary above which
eps(t) > 2·MIN_RISE_TIME(~128 s).Mechanism in one walk
KomaMRIBase/src/timing/TimeStepCalculation.jl:106:The pair
(t1, t1 + ϵ)encodes the rise-edge step. At small t bothentries are distinct Float64s and
linear_interpolationevaluates toB1 = 0att1,B1 = fullatt1 + ϵ. Past ~128 s,t1 + ϵ == t1bit-exactly,sort!(unique!(t))collapses the pair,and the integrator integrates
B1 = 0over a fullΔt_rfintervalinstead of the intended ULP-sized one.
This is not the same as the cumsum/sum 1-ULP path disagreement at
block boundaries; that one is benign (
Interpolations.deduplicate_knots!handles it). The load-bearing failure is the bit-equal collapse of
the rise-edge marker.
Affected scenarios
Any multi-shot acquisition where total simulated time > ~128 s. There is currently no way to detect this from the simulator output. I am using KomaMRI for RL agent training, so I was testing longer sequences (250s).
Why existing tests didn't catch it
Every existing
KomaMRICoretest runs sequences of milliseconds totalduration (no
Delaypast tens of ms anywhere in the test suite). Thebug only manifests past 128 s of cumulative simulated time, so the
suite was structurally blind to time-scaling regressions in
get_variable_times.Fix and PR
PR #780 implements the fix in
get_variable_timesby:Wrapping the RF key-time list with
Interpolations.deduplicate_knots!(_; move_knots=true)so anybit-equal duplicates introduced by
t ± ϵrounding are perturbedby 1 ULP via
nextfloat. This reuses the same library functionalready used in 5 places elsewhere on event timelines
(
Sequence.jl:770/793,KeyValuesCalculation.jl:29/30/66).Replacing the sequence-bookend
t[1] - ϵ/t[end] + ϵwithmax(MIN_RISE_TIME, 2·eps(t))so the pad is distinct at any twhile preserving a
MIN_RISE_TIMEfloor for tiny t.The fix is a no-op at normal time scales (markers stay at
t1 + ε/t2 - εinside the pulse window) and only activates inthe regime where the original design was silently broken.
Regression tests added
Three layers in the PR — all fail on
master, all pass after the fix:KomaMRIBase(unit):get_variable_timesproduces sub-Δt_rfmarker pairs on both sides of every RF pulse, at any absolute t,
with strictly increasing
t.KomaMRIBase(semantic): the discrete∫ B1·dtover the RFwindow equals
A · T_pulseat any absolute t (rtol = 1e-6).KomaMRICore(integration): four identical 50 s-spaced shotsproduce bit-identical
|Mxy|. Crosses the 128 s threshold betweenshots 2 and 3.
Versions reproduced on
master@d0d5c5fdd(2026-05-08, "Fix CLI world age dispatch Fix CLI world age dispatch #776")precision = "f64"andprecision = "f32"as time is always Float64.#Δt_rf(loss scales linearly: 5e-5 → 25.6 %, 1e-5 → 5.3 %, 2e-6 → 1.1 %)Environment