Skip to content

feat(ui): give the G radar a three-second trace, and measure what drawing it costs - #211

Merged
PurpleSentinel merged 1 commit into
mainfrom
feat/136-trail-sizing
Oct 16, 2026
Merged

PurpleSentinel merged 1 commit into
mainfrom
feat/136-trail-sizing

Conversation

@PurpleSentinel

Copy link
Copy Markdown
Contributor

Closes the two outstanding boxes on #136. The radar itself was built; these were the items left unticked.

The trace was 1.2 seconds, not "a few"

kImuTrailCapacity was a bare 24. The trail is appended once per display service, so at the 20 Hz display cadence that is 1.2 seconds. #136 predicted exactly this and asked for the capacity to be sized against the chosen update rate; the rate has since been chosen, so the number it was waiting on now exists.

The span is now what gets chosen and the capacity is derived from it:

inline constexpr std::int64_t kImuTrailSamplePeriodUs = 50'000;
inline constexpr std::int64_t kImuTrailSpanMs = 3'000;
inline constexpr std::size_t kImuTrailCapacity =
    static_cast<std::size_t>(kImuTrailSpanMs * 1'000 / kImuTrailSamplePeriodUs);

Three seconds is picked against what the trace is for: a corner in a Seven runs roughly two to four seconds from turn-in to unwinding, so the driver sees the corner they have just taken. A static_assert in screen_router.cpp ties kImuTrailSamplePeriodUs to the real kDisplayServicePeriodUs, so changing the display period fails the build rather than silently rescaling the trace. That missing coupling is how a capacity could be read as a duration in the first place.

Render cost, measured

The other unticked box, and the reason the span could be raised with any confidence. Two reports, because the two costs scale differently — timing only the cheap one would have been worse than not measuring:

  • radar: the object work, timed at the call site so the UI component stays free of esp_timer and buildable on the host
  • draw: lv_timer_handler in the panel task, which is where drawing actually happens. Repositioning an object only marks an area dirty; the pixels move later.

Measured on hardware at both capacities, G-ONLY on screen, device in motion throughout:

24 points 60 points
object work, mean 1,727 us 2,395 us
object work, worst 1,799 us 2,431 us
share of the 50 ms display period 3% 4%
worst repaint 23,597–23,813 us 24,255–24,287 us
repaints per 10 s — 144

Tripling the trace cost 2.3% more repaint time. Twenty-four dots already spanned the whole radar, so the invalidated area was already saturated and more dots inside it are nearly free. Per-point object cost works out at 18.6 us against a fixed 1.28 ms for the rings, labels, markers and text, so the dots were never the expensive part either.

The obvious reading — 150% more objects, so expect 150% more work — would have argued against a change that turns out to be almost free. #136 was right to ask for a measurement rather than a judgement.

The draw report is windowed by time, not call count: the handler runs at the minimum delay whether or not anything is dirty, so a count-based window fired every ~70 ms, produced 474 log lines in 45 seconds, and still said nothing about how often a repaint happens.

A finding this measurement surfaced, not addressed here

A repaint costs ~24 ms against a 25 ms service period — 97% — and it already did at 24 points, so it is not caused by this change. At 144 repaints per 10 s that is a ~35% duty cycle on the task that also dispatches the GNSS poll, and a poll arriving mid-repaint waits up to 24 ms. At 25 Hz a fix is due every 40 ms, so nothing is lost today, but most of the jitter margin is spent. This is pre-existing and worth its own issue rather than being folded in here; I have not raised one yet because I would want to confirm what area is actually being invalidated before claiming why.

It is also the reason not to grow the trace further on the strength of these numbers alone. 5 s (100 points) would cost ~6% of the display period in object work and probably nothing in repaint, but the repaint figure is close enough to its budget that the next change there should be a reduction, not an addition.

Verification

  • make check → CHECK_RC=0
  • make repo-check passes
  • Built and flashed to hardware twice, once per capacity, with the measurements above taken from cold-boot captures
  • The trail test is rewritten against kImuTrailCapacity rather than the number it currently produces, and two static_asserts now hold the span property: that it covers at least 2 s, and that capacity and span agree. The first would have failed before this change

🤖 Generated with Claude Code

…wing it costs

kImuTrailCapacity was a bare 24. The trail is appended once per display
service, so at the 20 Hz display cadence that was 1.2 seconds - a capacity on
its own says nothing about how much of a drive the trace shows, which is how 24
points came to be read as "a few seconds" when it was one and a fifth of one.

The span is now the figure that gets chosen and the capacity is derived from it.
Three seconds, picked against what the trace is for rather than as a round
number: a corner in a Seven runs roughly two to four seconds from turn-in to
unwinding, so the driver sees the corner they have just taken. A static_assert
in screen_router ties the assumed cadence to the real kDisplayServicePeriodUs,
so changing the display period fails the build instead of quietly rescaling the
trace.

Render cost is now measured rather than assumed, which is what let the span be
raised safely. Two periodic reports, because the two costs scale differently and
timing only the cheap one would have been worse than not measuring at all:

  radar:  the object work, timed at the call site in screen_router, so the UI
          component stays free of esp_timer and buildable on the host
  draw:   lv_timer_handler in the panel task, which is where drawing actually
          happens - repositioning an object only marks an area dirty

Measured on hardware at both capacities:

                                    24 points        60 points
  object work, mean                 1,727 us         2,395 us
  share of the 50 ms display period 3%               4%
  worst repaint                     23,597-23,813 us 24,255-24,287 us

Tripling the trace cost 2.3% more repaint time. Twenty-four dots already spanned
the whole radar, so the invalidated area was already saturated and more dots
inside it are nearly free. Per-point object cost is 18.6 us against a fixed
1.28 ms for the rings, labels and markers, so the dots were never the expensive
part either. The obvious reading - 150% more objects, so expect 150% more work -
would have argued against a change that turns out to be almost free, which is
why the issue was right to ask for a measurement rather than a judgement.

The draw report is windowed by time, not by call count: the handler runs at the
minimum delay whether or not anything is dirty, so a count-based window fires
every few tens of milliseconds, buries the log, and still says nothing about how
often a real repaint happens. It now reports every ten seconds and counts
repaints past a 5 ms threshold - 144 per ten seconds in steady state.

The trail test is rewritten against the capacity rather than against the number
it currently produces, since that number is now derived and a test pinned to 24
would have broken for a reason that says nothing about the ring buffer.

Refs #136

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@PurpleSentinel
PurpleSentinel merged commit 48222a9 into main Oct 16, 2026
3 checks passed
@PurpleSentinel
PurpleSentinel deleted the feat/136-trail-sizing branch October 16, 2026 20:51
PurpleSentinel added a commit that referenced this pull request Oct 16, 2026
… of the wrong code path (#212)

Reverts #211.

The render cost was measured while the device was being moved by hand. Motion
keeps GravityCalibration from completing, so resolve() stays invalid, the meter
state never reaches ready, and GRadarScreen::update() takes the branch that
hides every trail dot without positioning or restyling it. Every figure in #211
is the cost of drawing nothing.

Measured again with the device untouched:

                                    24 points        60 points
  object work, mean                 4,889-6,252 us   13,658-18,303 us
  share of the 50 ms display period 12-16%           36-41%
  worst repaint                     38,532-41,095 us 53,101-55,442 us
  repaints per 10 s                 147-152          166-170

Three times the object work and 14 ms more repaint, so the increase was a
regression rather than nearly free.

The revert is complete, instrumentation included, to put main back to a known
state. Re-landing the instrumentation on its own is worth proposing separately.

The pre-existing finding stands: at 24 points a repaint already costs ~40 ms
against a 25 ms service period, on the task that also dispatches the receiver
poll, with a fix due every 40 ms at 25 Hz.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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