Skip to content

Adaptive (error-controlled) timestep for the MetSim erosion model (on by default) - #86

Open
dvida wants to merge 17 commits into
masterfrom
metsim-adaptive-dt
Open

Adaptive (error-controlled) timestep for the MetSim erosion model (on by default)#86
dvida wants to merge 17 commits into
masterfrom
metsim-adaptive-dt

Conversation

@dvida

@dvida dvida commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Adaptive (error-controlled) timestep for the MetSim erosion model — on by default

Why

The fixed RK4 step const.dt = 0.005 s has no convergence basis and is badly under-resolved:

  • On a fast Orionid single body, the dt=0.005 velocity error vs a converged run is ~6 km/s, converging only ~1st order (dt=1e-4 still ~0.56 km/s).
  • The light-curve shape depends on the step size — historically you had to crank dt way down (and eat the runtime) to resolve the erosion tail.
  • Small grains near erosion_mass_min overshoot their true velocity by tens of percent in a single step.

This PR makes accuracy a tolerance rather than a magic constant, turns it on by default, and keeps the fixed path bit-for-bit for anyone who wants the old behaviour.

What

  • Adaptive steppers (MetSimErosionCyTools.pyx), per fragment, within a fixed output cadence:
    • adaptiveDP45Step() — the default: an embedded Dormand-Prince RK45 pair on the coupled [mass, velocity] system. One step yields the 5th-order solution and a 4th-order error estimate in ~7 RHS evals; the higher order takes larger steps (~3–4× faster than step-doubling).
    • adaptiveSingleBodyStep() — step-doubling on the original operator-split RK4, kept as a fallback (adaptive_high_order=False).
    • Both: PI step-size controller, atmosphere refreshed each sub-step, drag mass floored at m_kill so near-exhaustion grains stay stable, and erosion grains shed per sub-step so the light-curve shape no longer depends on dt.
  • On by default. Constants.adaptive_dt=True, adaptive_rtol=1e-5 (targets numerical error below measurement noise ~0.1 mag / ~0.1 km/s at ~25 FPS, not machine precision). ablateAll() dispatches on const.adaptive_dt; False → the original fixed path, untouched. The macro step stays the fixed output cadence (one results row per dt), so no downstream consumer (GUI residuals, Dynesty likelihood, wake) changes.
  • Grain-release cadence is a tunable physical resolution, decoupled from dt:
    • erosion_release_length (default 50 m) — an eroding fragment's sub-step is capped so grains are shed ~every this many metres of flight → grain births independent of dt/rtol. Now editable in the GUI.
    • erosion_release_vref (default 30000 m/s) — above this speed the cadence is frozen in time instead of distance, so the sub-step count stays bounded for fast meteors (a distance cadence makes cost grow with v). Slower fragments keep the exact distance cadence; vref ≤ 0 disables the cap. JSON-level knob.
  • GUI: an "adaptive dt" checkbox (checked by default) that disables the fixed-dt box when on; rtol and grain-release-length boxes alongside, enabled with the checkbox (tooltips explain tuning). Fixed-dt box is used only when adaptive is off.
  • Constants gain adaptive_dt / adaptive_high_order / adaptive_rtol / adaptive_atol_m / adaptive_atol_v / adaptive_dt_min / adaptive_dt_max / adaptive_max_substeps / erosion_release_length / erosion_release_vref; runSimulation/loadConstants back-fill them for older JSONs (which now pick up adaptive-on).
  • Tests: wmpl/MetSim/Tests is an importable package; added adaptive-mode regression tests (no-NaN / mass-non-negative / grain stability, radiated-energy conservation vs fixed). Suite passes 8/8.

Performance (measured on the 13 fitted 2019 Orionids)

  • Adaptive on by default is the win. Against a fixed dt fine enough to reach the same accuracy, adaptive rtol=1e-4 matches fixed dt≈0.001 at ~3.4× less time, and ~11× less than the fixed dt that beats it. So you get resolved erosion without hand-tuning dt down — the old workaround.
  • Velocity-scaled cadence (erosion_release_vref=30000) gives a further mean 1.81× over a pure-distance cadence on these fast (~67 km/s) events, with a max peak-magnitude shift of 0.020 mag (≪ measurement noise; the change is confined to the faint erosion tail).
  • Against the coarse legacy dt=0.005, adaptive costs more wall time (it actually resolves the physics), but that step was never accurate — see Why.
  • rtol=1e-4 vs 1e-5 is a wash on eroding meteors (the 50 m grain cap, not rtol, sets the sub-step count), so the default stays at the tighter, safe 1e-5.

Validation

  • Backward compat: adaptive_dt=False reproduces master bit-for-bit (np.array_equal, equal_nan=True, all output columns) on the synthetic scenario + 13 Orionids. Fixed mode is also bit-for-bit invariant to the new erosion_release_vref.
  • Convergence (single body): adaptive cuts the velocity error from ~6 km/s (fixed dt=0.005) to well below measurement precision; DP45 agrees with step-doubling to ~0.06 mag.
  • Erosion: light curves converge to the fine-fixed reference; radiated energy conserved (luminosity ratio ~0.997–1.005).

Tuning map

rtol = dynamics accuracy · erosion_release_length = grain-birth resolution · erosion_release_vref = fast-meteor cost cap · dt = output sampling cadence.

🤖 Generated with Claude Code

dvida and others added 10 commits July 22, 2026 01:05
… model

The fixed RK4 step (const.dt = 0.005 s) has no convergence basis and is badly
under-resolved for fast bodies and small grains: on a fast Orionid single body the
dt=0.005 velocity error vs a converged run is ~6 km/s, and the light-curve shape
depends on the step size. This adds an opt-in adaptive integrator so accuracy is set
by a tolerance rather than a magic constant, while leaving the default behaviour
(and every stored dt=0.005 fit) bit-for-bit unchanged.

- New Cython adaptiveSingleBodyStep() in MetSimErosionCyTools.pyx advances one fragment
  across a macro step with per-fragment error-controlled adaptive sub-steps (step-doubling
  on the existing RK4, PI controller, atmosphere refreshed each sub-step). The m^(-1/3)
  drag term is floored at m_kill so near-exhaustion grains stay numerically stable.
- ablateAll() is gated on const.adaptive_dt: True -> the adaptive stepper; False -> the
  original fixed-step code path, unchanged. The macro step stays the fixed output cadence
  (one results row per dt), so the output grid stays uniform and no downstream consumer
  (GUI residuals, Dynesty likelihood, wake) needs changes.
- Erosion grains are shed per sub-step at each sub-step's resolved height/velocity/time, so
  the erosion light-curve shape stops depending on the step size (grain births match a
  finely-stepped fixed run; radiated energy conserved to <0.5%).
- Constants gets adaptive_dt / adaptive_rtol / adaptive_atol_m / adaptive_atol_v /
  adaptive_dt_min / adaptive_dt_max / adaptive_max_substeps (default rtol 1e-5, targeting
  numerical error below measurement noise, not machine precision). runSimulation and
  loadConstants back-fill these for older JSONs so they default to fixed-step.
- GUI: an "adaptive dt" checkbox next to the time-step box; when checked, adaptive_dt is on
  and the fixed-dt box is disabled (dt is then only the output cadence). Tolerances are set
  via the sim-fit JSON.
- Tests: MetSim/Tests is now an importable package (__init__.py; still excluded from
  wmpl's import-time walk). Added adaptive-mode regression tests (stability/no-NaN/mass
  and radiated-energy conservation vs fixed).

Validation: adaptive_dt=False reproduces master bit-for-bit (np.array_equal) on the
synthetic scenario + 13 fitted Orionids. Convergence study: adaptive at rtol=1e-6 cuts the
single-body velocity error from 6.2 to 0.05 km/s (130x); erosion light curves converge with
mass/energy conserved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "adaptive" checkbox previously overlapped the m_kill "kg" unit label and the
len_kill field in the packed first row. Move the adaptive controls into the free
right-hand column: "adaptive" checkbox on row 2, an "rtol" input box on row 3 (enabled
only when adaptive is on), so tolerance is tunable from the GUI instead of JSON-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Designer layout tweaks to the simulation settings group.
- Normalize Qt6-style fully-scoped enum names (Qt::AlignmentFlag::*, Qt::Orientation::*)
  back to the short form so PyQt5's loadUi can parse the .ui.
- Expand the rtol tooltip with concrete tuning guidance (accuracy vs cost, target below
  measurement noise).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The adaptive stepper fed the net macro-step speed change (v_start - v)/dt into the
luminosity deceleration term. For a massive, high-altitude fragment (drag ~ 0) that net
change is dominated by the gravity/curvature velocity reallocation and can flip sign,
making the m*v*decel term (and thus lum) large and negative. The fragment was then killed
on tick 1 by the lum<0 check, yielding an empty simulation and a downstream interp1d crash
in the GUI when adaptive stepping was enabled.

Accumulate the drag-only speed change (sum of decelerationRK4 * substep) instead, matching
the fixed path's use of drag deceleration. Verified on a 2100 kg fragmentation fireball:
adaptive now matches the fixed light curve to <0.05 mag (was: 1 row, lum<0, crash).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A simulation that dies immediately (0-1 rows, or all-NaN magnitudes) previously crashed the
whole app with an interp1d "reshape array of size 0" error. Add simulationUsable() and skip
the interpolation/plotting chain with a clear warning when the current (or previous) run has
too few valid points or no positive luminosity. Also fall back to the full arrays inside
updateInterpolations() when too little of the trajectory reaches past the observed begin,
so the normalized interpolators still build instead of crashing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…r diagnostic

From the PR #86 review:
- F2: carry the controller's natural (pre-boundary-clamp) sub-step as the next macro step's
  warm-start instead of the tiny clamped leftover. Cuts sub-steps ~2-5x (Orionid 3436->740/row,
  fireball ~1673->527/row) with no effect on the visible light curve - only the faint,
  sub-detection tail shifts slightly.
- F1: on 'going up' (vv>0) recompute h_new from the along-track length instead of forcing 0. The
  fixed path's frag.h=0 in that branch is immediately overwritten by the same recompute, so
  forcing 0 was killing upward-turning end-of-life fragments a tick earlier than fixed mode.
- F3: count sub-steps accepted at the dt_min floor while still over tolerance
  (const.adaptive_floor_accepts) and warn once in runSimulation, so under-resolution is visible.
- Record the erosion-begin (vel, mass, dyn_press) triple once at the end-of-step state so it is
  mutually consistent in adaptive mode (was mixing a rewound sub-step velocity with end-of-step
  mass/dyn_press).

Backward compat unchanged: adaptive_dt=False is bit-for-bit vs master (13 Orionids + synthetic);
regression suite passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replaces step-doubling (which needed ~3 RK4 solves per sub-step for its error estimate) with an
embedded Dormand-Prince RK45 pair on the COUPLED [mass, vv, vh, length] system (adaptiveDP45Step +
_rhsDP in MetSimErosionCyTools.pyx). One embedded step gives the 5th-order solution and a 4th-order
error estimate in ~7 RHS evals, and the higher order takes larger sub-steps - ~3.8x faster than
step-doubling at the same tolerance. Atmosphere density is refreshed at every stage (from the stage
height), removing the operator-split/frozen-rho error; gravity drop is added once per accepted
sub-step, matching the fixed model.

Selected by const.adaptive_high_order (default True); the step-doubling stepper is kept and used
when False. Grains are still shed per accepted sub-step.

Validation vs the step-doubling stepper (within measurement noise, ~0.1-0.3 mag):
- Fireball (no erosion, pure integrator): visible-LC max |dmag| = 0.065, 3.8x faster.
- Orionid (erosion): visible-LC max |dmag| = 0.18 (grain-birth cadence), converged (rtol 1e-5 ~ 1e-6),
  3.8x faster.
Backward compat unchanged: adaptive_dt=False is bit-for-bit vs master (13 Orionids); regression
suite passes with DP45 as the default adaptive method.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Full Arguments:/Return: docstrings for the new Cython functions: clampMassC, heightCurvatureC,
  atmDensityPolyC, advanceVelPosC, adaptiveSingleBodyStep, _rhsDP, adaptiveDP45Step (the last two
  reference adaptiveSingleBodyStep for the shared I/O).
- Corrected the adaptiveSingleBodyStep return description to the actual 17-tuple.
- Dropped the unused const_out binding in the erosion grain-spawn helper.

No behaviour change; regression suite passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
In adaptive mode grains are shed once per accepted sub-step, so the sub-step cadence set the
grain-birth resolution - which made the erosion light-curve shape depend on dt (dt caps the
sub-step via adaptive_dt_max). Add const.erosion_release_length (m, default 50): an eroding
fragment's sub-step is capped at erosion_release_length/v, so grains are released ~every that many
metres of flight, a physical resolution independent of dt and the error tolerance. Applied in both
the DP45 and step-doubling steppers; only for eroding fragments (grains do not erode further).

Effect (Orionid 084502): peak-magnitude difference between dt=0.005 and dt=0.02 drops from ~2.0 mag
to ~0.01 mag, and the total grain count becomes dt-independent (+-6%). The knob is a set-and-forget
JSON default (not a GUI box); tune it for finer/coarser grain sampling. Backward compat unchanged:
adaptive_dt=False is bit-for-bit vs master; regression suite passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Turn the error-controlled adaptive integrator on by default so erosion light
curves are well resolved without hand-tuning dt down (the old workaround). The
fixed-step path is unchanged and still bit-for-bit vs master when adaptive_dt is
set False in the JSON/GUI.

- Constants: adaptive_dt True, adaptive_rtol 1e-4 (was False / 1e-5).
- runSimulation back-fill and GUI getattr fallbacks track the new defaults;
  older JSONs without the fields now pick up adaptive-on.
- GUI: adaptive checkbox checked by default; rtol tooltip updated.

rtol=1e-4 vs 1e-5 is indistinguishable on the 2019 Orionids (<0.0005 mag);
1e-4 is the slightly looser, safe default. Set adaptive_dt False for legacy runs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0179geUg2TGRWWqpNPx2k234
@dvida

dvida commented Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

Adaptive timestep now on by default (adaptive_dt=True, rtol=1e-4)

Latest commit flips the defaults so the error-controlled adaptive integrator (DP45) is used out of the box, with adaptive_rtol=1e-4. The fixed-step path is untouched and remains bit-for-bit identical to master whenever adaptive_dt=False is set in the JSON/GUI, so legacy runs are fully reproducible.

Benchmarked on the 13 fitted 2019 Orionids (all strongly eroding, JSON dt=0.005). Two separate questions, two separate answers — worth stating plainly because they're easy to conflate.

1. Turning adaptive on is the real win (vs an equally-accurate fixed dt)

Getting well-resolved erosion out of the fixed scheme means cranking dt down by hand — the old workaround. Adaptive gives that resolution automatically and far cheaper. Cost vs light-curve accuracy for one representative event (20191023_091310), LC RMS measured against a fine dt=5e-5 fixed reference:

config wall time LC RMS vs fine ref
fixed dt=0.005 (old default) 0.05 s 0.554 mag
fixed dt=0.001 0.90 s 0.516 mag
fixed dt=0.0005 3.04 s 0.496 mag
fixed dt=0.0002 15.5 s 0.433 mag
adaptive rtol=1e-4 0.26 s 0.539 mag

Adaptive lands at roughly the accuracy of fixed dt=0.001 but at ~3.4× less time, and ~11× less than the fixed dt (0.0005) that actually beats it. That's the case for making it the default: good resolution without per-event dt tuning.

2. rtol=1e-4 vs 1e-5 is a wash on eroding meteors — but safe

Across all 13 Orionids, loosening rtol from 1e-5 to 1e-4 gave mean 1.0× speedup (1e-4 uses ~97% of the sub-steps) and a light curve identical to <0.0005 mag (max 0.0002 mag, max 0.03 km/s). The reason: for an eroding meteor the sub-step count is set by the erosion_release_length=50 m grain-release cap, not by the error tolerance — so rtol barely moves the cost. Same holds for single-body cases (313 → 302 sub-steps over 1e-5 → 1e-3), where the dt_max=0.005 ceiling binds instead.

So rtol=1e-4 is a slightly-looser, zero-downside default (identical output), but the speedup does not come from loosening rtol on these events. If a faster-with-1e-4 result shows up elsewhere, it'd be an event class where the tolerance actually binds (few grains, short flight) rather than the grain cap — none of the fitted Orionids are in that regime.

Backward compatibility & tests

  • adaptive_dt=False → fixed path, bit-for-bit vs master (checked with np.array_equal, equal_nan=True, on the 13 Orionids + synthetic single-body).
  • Older JSONs without the adaptive fields now back-fill to adaptive-on; set adaptive_dt=False for legacy fixed-step behaviour.
  • Regression suite wmpl/MetSim/Tests/test_MetSimErosion.py: 8/8 pass.

Figures saved under 2019orionids/pr86_adaptive_benchmark/ (pr86_defaults_benchmark.png, default_rtol_1e4_vs_1e5.png).

Benchmarking the 2019 Orionids showed rtol=1e-4 gives no speedup over 1e-5:
for eroding meteors the sub-step count is set by erosion_release_length (the
50 m grain-release cap), not by the error tolerance, and single-body cases are
bounded by dt_max. Since 1e-5 is tighter (more accuracy headroom where rtol does
bind) at negligible extra cost, keep it as the default. adaptive_dt stays True.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0179geUg2TGRWWqpNPx2k234
@dvida

dvida commented Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up — reverted the rtol default to 1e-5, kept adaptive_dt=True.

The benchmark above showed rtol=1e-4 buys no speedup over 1e-5 on these events (sub-step count is set by the 50 m erosion_release_length grain cap, not the tolerance; single-body cases are bounded by dt_max). Since 1e-5 is tighter — more accuracy headroom in the regime where rtol actually binds — at negligible extra cost, it stays the default. So the net change is just adaptive on by default (adaptive_dt=True), which is the real win (§1: ~3.4–11× faster than an equally-accurate fixed dt). Commit 267cf47.

…eor cost)

The adaptive steppers cap an eroding fragment's sub-step at
erosion_release_length/v so grains are shed every ~50 m of flight (a
dt-independent grain-birth resolution). Because that cadence is fixed in
distance, a fast meteor takes tiny sub-steps and the cost grows with velocity -
e.g. a 67 km/s Orionid spends ~2x the sub-steps of a slower one for no visible
gain. (A slow, massive fireball is unaffected: the error controller already
takes small steps, so the cap never binds.)

- Add erosion_release_vref (default 30000 m/s): above this speed the cadence is
  frozen in TIME (cap = erosion_release_length/erosion_release_vref) instead of
  distance, so the sub-step count stays bounded. Fragments slower than vref keep
  the exact distance cadence, so their output is unchanged. vref <= 0 disables
  the cap (original pure-distance behaviour).
- Expose erosion_release_length in the GUI (box next to rtol, enabled with the
  adaptive checkbox) so it can be coarsened for expensive fits; vref stays a
  JSON-level knob.
- Thread the new parameter through both steppers and the runSimulation back-fill.

Validated on the 13 fitted 2019 Orionids (all ~67 km/s): mean 1.81x faster with
the vref=30000 default, max peak-magnitude shift 0.020 mag (well under the ~0.1
mag measurement noise; the change is confined to the faint erosion tail). Fixed
mode is bit-for-bit invariant to vref, and the regression suite passes (8/8).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0179geUg2TGRWWqpNPx2k234
@dvida dvida changed the title Opt-in adaptive (error-controlled) timestep for MetSim erosion model Adaptive (error-controlled) timestep for the MetSim erosion model (on by default) Jul 22, 2026
dvida and others added 5 commits July 23, 2026 09:20
Qt Designer re-saves enum values in the Qt6 scoped form
(Qt::AlignmentFlag::AlignCenter, Qt::Orientation::Horizontal), which PyQt5's
uic.loadUi cannot parse. Strip the scope prefixes back to the Qt5 short form
(Qt::AlignCenter, Qt::Horizontal). No layout change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0179geUg2TGRWWqpNPx2k234
Benchmarking showed 200 m is substantially faster on eroding fits while the
light-curve peak is barely affected (the difference is confined to the faint
erosion tail, well under measurement noise). Update the Constants default, the
runSimulation back-fill, the GUI getattr fallbacks, and the tooltips. Finer
sampling is still available by lowering the value in the GUI/JSON.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0179geUg2TGRWWqpNPx2k234
…lts)

The GUI display/readback and the ablateAll dispatch used
getattr(const, 'name', <literal>) with the default value hardcoded a second
time, so a default change had to be edited in two places. Every const starts
from Constants() and runSimulation back-fills older JSONs, so the attributes are
always present - read them directly (const.adaptive_rtol, const.adaptive_dt,
const.adaptive_high_order, const.erosion_release_length, const.erosion_release_vref),
matching how h_init/P_0m/dt are already handled. Single source of truth is now
Constants.__init__ (plus the runSimulation back-fill table for migration).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0179geUg2TGRWWqpNPx2k234
Formatting-only cleanup from a review of the new code against the rest of the
file/repo (no behaviour change; regression suite still 8/8, fixed mode
bit-for-bit):

- Split semicolon-packed multi-assignments onto one statement per line (the
  file has zero semicolon statements elsewhere), in both steppers and _rhsDP.
- Add the blank line after the closing docstring """ in clampMassC,
  heightCurvatureC, atmDensityPolyC, advanceVelPosC and _rhsDP.
- Replace fixed-path line-number references in comments with behaviour
  descriptions (line numbers drift).
- Wrap the one >110-col erosion-cap line to match its DP45 twin; capitalize two
  lowercase-first comments.
- MetSimErosion.py: drop the stale "opt-in / Default False" wording now that
  adaptive is the default; stop misusing the "### ... ###" section marker for a
  paragraph; document the tolerance fields with preceding comment lines instead
  of column-aligned inline comments (matches the rest of Constants); rename
  underscore-prefixed locals (_stepper, _saved_state, _adaptive_defaults, etc.)
  to plain names.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0179geUg2TGRWWqpNPx2k234
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