Skip to content

plan 0003: true RPM from spark mode and cylinder count - #133

Merged
TheAngryRaven merged 2 commits into
BETAfrom
claude/rpm-spark-cylinders
Aug 6, 2026
Merged

plan 0003: true RPM from spark mode and cylinder count#133
TheAngryRaven merged 2 commits into
BETAfrom
claude/rpm-spark-cylinders

Conversation

@TheAngryRaven

Copy link
Copy Markdown
Owner

Summary

The pickup counts ignition sparks; the tach reported them as revolutions. That's only true for a single cylinder firing every rev — a 2-stroke, or a 4-stroke with wasted spark — which is the common kart case and why it's been fine. Anything else is out by a fixed factor: a twin firing every rev reads double the real speed.

pulses_per_rev = cylinder_count × (wasted ? 1.0 : 0.5), and the reciprocal is applied at the period→RPM conversion in TACH_LOOP()before the Kalman filter, because the filter's tuning is in true-RPM units (Q = 800 RPM² models crank inertia), so correcting afterwards would filter each engine type differently. The correction point already existed and was already in the right place; it was just hardcoded to 1.0.

Defaults reproduce today's behaviour exactly (1 cylinder, wasted → 1.0), so a device you never configure reads identically. Anything other than an explicit "single" degrades to wasted, so a blank, garbled or future value reads as today rather than doubling every RPM.

The debounce had to follow — and the plan's floor was wrong

A fixed 3 ms gap caps ~20,000 pulses/min, which on a twin firing every rev is only ~10,000 real RPM — the debounce would have become the ceiling. minPulseGapUs() derives it as 3 ms ÷ pulses-per-rev.

Plan 0003 sketched a 1.5 ms floor. I used 750 µs, because 1.5 ms still left a triple at ~13,300 true RPM — under the old ceiling, which defeats the point. 750 µs holds the full ~20,000 through four cylinders; past that the floor binds (10,000 at eight), well clear of anything this logger targets.

The plan said "verify ISR headroom before lowering" — headroom was never the constraint (<1 µs body, ~1300 int/s worst case). Ringing was, and the margin holds from both ends: the input is RC-filtered (~100 µs), and your own TACHOMETER/README.md records circuit 1 emitting 5 ms-wide pulses, which is itself the ~9800 RPM limit on that hardware — i.e. the pulse width, not the debounce, is what binds there.

The audit came back clean

Plan 0003 asked me to check nothing else derives RPM from pulse periods. Nothing does — the only other 60e6 in the tree is the simulator's pulse generator, which is the inverse and matches the default.

Knock-on benefit: auto-race entry (>500) and the camera wake/record/stop thresholds (500/1500/300) now mean what they say on every engine instead of firing at half the real RPM on a twin.

Type of Change

  • New feature
  • Bug fix
  • Documentation

Checklist

  • Host unit tests pass (359 cases, 4510 assertions — +9 cases here)
  • Simulator builds and runs (golden fixtures + lap oracle)
  • Docs updated — CLAUDE.md (settings table, key constants, tachometer subsystem, pure-unit map), README.md, CHANGELOG.md, plan 0003 marked SHIPPED
  • Divisor math lives in the host-tested tach_filter pure unit, per the repo convention
  • No new Arduino String, no analogRead(), TIMER3 untouched

Notes for Reviewers

Verified end to end in the simulator, not just at the unit level. I built a probe against the sim core that feeds 6000 pulses/min through the real ISR and TACH_LOOP(): it reports 6000 RPM at the defaults and 3000 with revsPerPulse forced to a twin. So the wiring is proven, not only the math. Golden fixtures (16 pages) and the lap oracle are unchanged — that's the evidence existing devices are unaffected.

An earlier probe attempt was accidentally informative too: hardcoding cylinders = 2 before the getSetting call changed nothing, because cylinder_count=1 from SETTINGS.json correctly overrode it. The settings path works.

Two things worth your eye, since you know the hardware:

  • The 750 µs floor is the one judgement call I'd most like checked. My reasoning is above; if your circuit-2 ringing is worse than the RC filter suggests, raise it — the cost is only the ceiling on 3+ cylinder installs.
  • cylinder_count is "cylinders the pickup SEES", which I've documented in three places because getting it wrong halves or doubles every reading. A clamp on one plug wire of a twin sees one. Worth confirming that's how you'd phrase it to users.

Pairs with DovesDataViewer #389, which adds the enum control so spark_mode is a real dropdown rather than a free-text box. Neither PR depends on the other — the app builds its rows from what the device reports, so the new settings simply appear once this ships.


Generated by Claude Code

The pickup counts ignition sparks and the tach treated one spark as one
revolution. That is only true for a single cylinder firing every rev — a
2-stroke, or a 4-stroke with wasted spark — which is the common kart case and
why it has been fine. Anything else is out by a fixed factor: a twin firing
every rev reads DOUBLE the real speed.

Two settings fix it. pulses_per_rev = cylinder_count x (wasted ? 1.0 : 0.5),
and the reciprocal is applied at the period->RPM conversion in TACH_LOOP —
BEFORE the Kalman filter, because the filter's tuning is in true-RPM units
(Q = 800 RPM^2 models crank inertia), so correcting afterwards would filter
each engine type differently. The correction point already existed and was
already in the right place; it was just hardcoded to 1.0.

Defaults (1 cylinder, wasted) give exactly 1.0, so a device that has never
been configured reads identically to before. Anything other than an explicit
"single" degrades to wasted, so a blank, garbled or future value reads as
today rather than doubling every RPM.

THE DEBOUNCE HAD TO FOLLOW. A fixed 3 ms gap caps ~20,000 pulses/min, which on
a twin firing every rev is only ~10,000 real RPM — the debounce would have
become the ceiling. minPulseGapUs() derives it as 3 ms / pulses-per-rev.
The floor is 750 us, not the 1.5 ms the plan sketched: 1.5 ms still left a
triple at ~13,300 true RPM, under the old ceiling. 750 us holds the full
~20,000 through four cylinders. ISR headroom was never the constraint (<1 us
body, ~1300 int/s worst case) — ringing was, and the margin holds from both
ends: the input is RC-filtered ~100 us and the documented pickup circuits emit
pulses MILLISECONDS wide, TACHOMETER/README.md recording circuit 1's 5 ms
pulse as itself the ~9800 RPM limit on that hardware.

The audit plan 0003 asked for came back clean: nothing else derives RPM from
pulse periods. The only other 60e6 in the tree is the simulator's pulse
generator, which is the inverse and matches the default.

Knock-on: the RPM thresholds the device acts on — auto-race entry, camera
wake/record/stop — now mean what they say on every engine.

Verified end to end in the simulator, not just at the unit level: 6000
pulses/min reports 6000 RPM at the defaults and 3000 with revsPerPulse forced
to a twin. Golden fixtures and the lap oracle are unchanged, which is the
evidence existing devices are unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESkRRtF4vRrANPL6huSgmD
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown

Coverage — host-testable units

📂 Overall coverage

Metric Coverage
Lines 🟢 1178/1200 (98.2%)
Functions 🟢 126/126 (100.0%)
Branches 🟡 872/976 (89.3%)

📄 File coverage

File Lines Functions Branches
BirdsEye/camera_fsm.cpp 🟢 222/230 (96.5%) 🟢 20/20 (100.0%) 🟡 126/144 (87.5%)
BirdsEye/course_creator.cpp 🟢 209/217 (96.3%) 🟢 21/21 (100.0%) 🟡 114/131 (87.0%)
BirdsEye/crc32.cpp 🟢 30/30 (100.0%) 🟢 4/4 (100.0%) 🟢 24/24 (100.0%)
BirdsEye/crossing_pattern.cpp 🟢 15/15 (100.0%) 🟢 1/1 (100.0%) 🟢 12/12 (100.0%)
BirdsEye/dovex_header.cpp 🟢 106/107 (99.1%) 🟢 7/7 (100.0%) 🔴 62/88 (70.5%)
BirdsEye/filename_validator.cpp 🟢 14/14 (100.0%) 🟢 1/1 (100.0%) 🟢 30/30 (100.0%)
BirdsEye/gps_stats.cpp 🟢 25/25 (100.0%) 🟢 3/3 (100.0%) 🟢 8/8 (100.0%)
BirdsEye/gps_status_page.cpp 🟢 29/29 (100.0%) 🟢 4/4 (100.0%) 🟢 28/28 (100.0%)
BirdsEye/gps_time.cpp 🟢 45/45 (100.0%) 🟢 6/6 (100.0%) 🟢 30/32 (93.8%)
BirdsEye/gps_validation.cpp 🟢 24/24 (100.0%) 🟢 2/2 (100.0%) 🟢 66/66 (100.0%)
BirdsEye/haversine.cpp 🟢 8/8 (100.0%) 🟢 1/1 (100.0%) ⚫ 0/0 (0.0%)
BirdsEye/insta360_protocol.cpp 🟢 140/140 (100.0%) 🟢 16/16 (100.0%) 🟡 86/98 (87.8%)
BirdsEye/lap_format.cpp 🟢 18/18 (100.0%) 🟢 1/1 (100.0%) 🟢 9/9 (100.0%)
BirdsEye/sat_bars.cpp 🟢 33/33 (100.0%) 🟢 2/2 (100.0%) 🟢 51/54 (94.4%)
BirdsEye/sd_access_policy.cpp 🟢 9/9 (100.0%) 🟢 3/3 (100.0%) 🟢 18/18 (100.0%)
BirdsEye/sd_format_page.cpp 🟢 25/25 (100.0%) 🟢 3/3 (100.0%) 🟢 25/26 (96.2%)
BirdsEye/sensoregg_protocol.cpp 🟢 44/45 (97.8%) 🟢 7/7 (100.0%) 🟢 33/34 (97.1%)
BirdsEye/sprint_select.cpp 🟢 25/25 (100.0%) 🟢 4/4 (100.0%) 🟢 46/48 (95.8%)
BirdsEye/tach_filter.cpp 🟢 27/27 (100.0%) 🟢 6/6 (100.0%) 🟢 17/18 (94.4%)
BirdsEye/track_json.cpp 🟢 116/120 (96.7%) 🟢 12/12 (100.0%) 🟡 67/88 (76.1%)
BirdsEye/wake_cause.cpp 🟢 14/14 (100.0%) 🟢 2/2 (100.0%) 🟢 20/20 (100.0%)

clang-tidy caught bugprone-incorrect-roundings on (uint32_t)(gap + 0.5f) —
a real finding, not a false positive: that idiom rounds incorrectly for
negatives and is a known bug class.

lroundf would have silenced it, but the float was never needed. Pulses-per-rev
is either `cylinders` (wasted spark) or `cylinders / 2` (single-fire), so the
base gap divides exactly in both cases once the single-fire case is written as
a doubled numerator. Integer throughout keeps float rounding out of a value the
ISR compares against on every pulse, and drops a libm call from the firmware.

Same numbers as before, so the tests are unchanged and still pass: 3000 / 1500
/ 1000 / 750 for one through four cylinders wasted, 6000 for a single-fire
single, floored at 750.

Verified with the exact CI invocation locally — clang-tidy is clean across all
eleven analyzed units, not just this one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESkRRtF4vRrANPL6huSgmD
@TheAngryRaven
TheAngryRaven merged commit 1a1dbfd into BETA Aug 6, 2026
8 checks passed
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.

2 participants