feat(diagnostics): adaptive throttle visibility — heartbeat color + Network Diagnostics graphs#3203
Conversation
…etwork Diagnostics graphs
Adds end-to-end visibility for when the adaptive frame-rate throttle is
active, across the title-bar heartbeat indicator and the Network
Diagnostics dialog.
## TitleBar
- `setThrottleFlashColor(hex)` — callers pass a hex color that replaces
the default #20c060 green while the throttle is active; empty string
restores green when the throttle lifts.
- `onHeartbeat()` flashes the throttle color instead of hardcoded green.
- `setBlinkEnabled(false)` freeze path respects the throttle color.
- Disconnected-alarm path is never overridden.
## MainWindow
- `adaptiveThrottleChanged` lambda calls `setThrottleFlashColor()` using
the same quality-palette colors as the footer network label:
≤4 fps → #cc3333 (Poor), ≤8 fps → #cc9900 (Fair),
else → #00b4d8 (Good); inactive → "" (restore green).
## NetworkDiagnosticsHistory
- `NetworkDiagnosticsSample` gains `adaptiveFpsCap` and
`adaptivePendingLift` fields, populated from `RadioModel` each second.
- `ThrottleEvent` struct records each activate/deactivate transition with
timestamp and fps cap.
- `throttleSessionCount()` — cumulative count of throttle activations
for the current session.
- `adaptiveThrottleChanged` wired in the constructor via UniqueConnection.
## TimeSeriesGraphWidget
- `setThrottleSpans(spans)` — accepts a vector of (startRatio, endRatio)
pairs; renders a subtle amber (#cc9900, α 28) band over the matching
x-range on every graph that receives spans.
- `Series::stepFunction` flag — draws horizontal-then-vertical steps
instead of a smoothed line; used for the fps-cap graph.
## NetworkDiagnosticsDialog
- **Overview Status card**: inline amber badge ("Adaptive throttle: N fps
cap") shown while throttle is active; hidden otherwise.
- **Details tab**: new "Adaptive Frame-Rate Throttle" subsection with
current state, pending-lift indicator, and session count; hidden until
the first throttle event fires.
- **Rates tab**: 120–160 px fps-cap step-function graph below the stream
rates graph; shows the cap level over time as a step trace.
- `updateCharts()`: builds throttle spans from the event log and passes
them to all nine graphs (4 overview + 5 detail tabs).
- `refresh()`: updates badge text, section visibility, and label values
each second.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
@AetherClaude Your thoughts on this PR? Anything that will affect ongoing work? |
|
Reviewed against the current heartbeat and diagnostics code. No conflicts with in-flight work — this is a clean visibility layer on top of infrastructure that already exists. A few observations worth pinning down before/while implementing: 1. Alarm path precedence in // TitleBar.cpp
QString TitleBar::currentBeatColor() const {
return m_throttleColor.isEmpty() ? QStringLiteral("#20c060") : m_throttleColor;
}
void TitleBar::onHeartbeat() {
m_discovering = false;
m_missedBeats = 0;
m_heartbeatAlarmTimer->stop();
m_alarmRed = false;
m_heartbeat->setStyleSheet(
QStringLiteral("QLabel { background: %1; border-radius: 5px; }").arg(currentBeatColor()));
if (m_blinkEnabled) m_heartbeatOffTimer->start();
}
void TitleBar::setThrottleFlashColor(const QString& color) {
if (m_throttleColor == color) return;
m_throttleColor = color;
// Don't fight the alarm — alarm timer / static-red path owns the indicator.
if (m_missedBeats >= 3 || m_heartbeatAlarmTimer->isActive()) return;
// Blink-disabled freeze path: repaint immediately to the new static color.
if (!m_blinkEnabled && !m_heartbeatOffTimer->isActive()) {
m_heartbeat->setStyleSheet(
QStringLiteral("QLabel { background: %1; border-radius: 5px; }").arg(currentBeatColor()));
}
}2. 3. Quality-palette single source of truth. The footer network label already owns the Poor/Fair/Good color mapping. The MainWindow lambda at MainWindow.cpp:4652-4655 already has 4. 5. Step-function fps-cap graph. Worth confirming 6. Sample-vector growth. Nothing here blocks the design — it's a useful diagnostic surface and the infrastructure ( A maintainer can authorize the orchestrator to land this fix by adding the 73, Jeremy KK7GWY & Claude (AI dev partner) 🤖 aethersdr-agent · cost: $2.9387 · model: claude-opus-4-7 |
…lity Addresses all six points from the AetherClaude review of PR aethersdr#3203. 1. Alarm-path precedence (TitleBar): add currentBeatColor() private helper that resolves m_throttleFlashColor or falls back to #20c060. setThrottleFlashColor() is now a no-op when m_missedBeats >= 3 or m_heartbeatAlarmTimer->isActive(); adds early-exit guard and idempotency check (no-op when color unchanged). 2. setBlinkEnabled() freeze path: the "freeze to solid connected color" branch now calls currentBeatColor() instead of hardcoding #20c060, so toggling blink off during a throttle event preserves the throttle color cue. 3. Quality-palette single source of truth (MainWindow): extract qualityColor(quality) and fpsCapColor(fpsCap) lambdas before both connects so the footer label and heartbeat indicator share identical hex values — no drift possible. 4. pendingThrottleLift() read live from RadioModel in refresh() instead of the sample-derived flag — more accurate for state that can change between 1-second sample polls. 5. Step-function rendering: already present (Series::stepFunction + paintEvent branch) — confirmed correct, no change needed. 6. ThrottleEvent separate from sample vector: already correct (QVector<ThrottleEvent> lives in NetworkDiagnosticsHistory, samples only carry a snapshot int+bool) — no change needed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
@AetherClaude Addressed all six review points in 7018add:
|
|
@AetherClaude All six points from your initial review have been addressed in commit 7018add. Requesting a second pass to confirm the changes are correct, particularly:
Points 5 and 6 were already correct in the original commit — step-function rendering via |
|
Second pass complete — all six points are correctly addressed in 7018add. Walking through each against the diff: 1. 2. 3. 4. 5. Step-function rendering ✅ — confirmed at NetworkDiagnosticsDialog.cpp:541-557. Horizontal-then-vertical traversal is correct for zero-order hold, and the "extend last step to right edge" line is the right touch — without it the trailing segment would terminate at the most recent sample x instead of "now", which looks wrong on a live graph. 6. Two small follow-ups worth noting (not blockers for this PR):
LGTM as-is — the state-machine interactions in TitleBar are correct and the alarm path is properly protected. The follow-ups above can land in a separate cleanup if at all. A maintainer can authorize the orchestrator to land any of the follow-ups by adding the 73, Jeremy KK7GWY & Claude (AI dev partner) 🤖 aethersdr-agent · cost: $3.4523 · model: claude-opus-4-7 |
…pass Three follow-ups from the second AetherClaude review of PR aethersdr#3203: 1. Drop dead field: `NetworkDiagnosticsSample::adaptivePendingLift` was written in sampleNow() but never read after refresh() was changed to call m_model->pendingThrottleLift() directly. Removed from the struct and from sampleNow(). 2. Prune throttle event vector: m_throttleEvents now gets pruned in pruneSamples() on the same kMaxHistoryMs window as the sample vector, preventing unbounded growth during long-running / throttle-flapping sessions. 3. Single amber constant: introduce kThrottleAmber = "#cc9900" at file scope (matching qualityColor("Fair") in MainWindow.cpp). All four usages — band fill, badge stylesheet, state label stylesheet, fps-cap series color — now reference this one constant. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
@AetherClaude All three follow-ups from your second pass addressed in 889c323:
|
|
Third pass complete — all three follow-ups in 1. Dead field removed ✅ — 2. Throttle-event pruning ✅ — 3. Single amber constant ✅ —
The comment is the right call — without it, a future palette refactor in MainWindow would silently desync from this file. Cross-file constants always need a breadcrumb. One theoretical edge case worth noting (not a blocker): The pruning erases events strictly by timestamp, so it's possible for a "lone close" event to survive — i.e., a throttle session that started >7 days ago but ended within the last 7 days would leave an orphaned This requires a throttle session lasting 7+ days, which doesn't happen in practice — and even if it did, the cosmetic loss is partial-band shading on graphs that don't extend back that far anyway. Mentioning it for the record, not asking for a change. LGTM as-is. The PR is in good shape to land. 73, Jeremy KK7GWY & Claude (AI dev partner) 🤖 aethersdr-agent · cost: $3.2669 · model: claude-opus-4-7 |
There was a problem hiding this comment.
Thanks @chibondking — nice piece of work. The single-source-of-truth color lambdas in MainWindow.cpp, the symmetry between kThrottleAmber and qualityColor("Fair"), and the currentBeatColor() indirection in TitleBar all make the throttle visibility feel like a first-class concept rather than a bolted-on indicator. The amber spans + step-function fps-cap trace will make adaptive-throttle behaviour much easier to reason about in the field.
A few observations:
1. NetworkDiagnosticsHistory::m_currentFpsCap not seeded at construction (functional)
src/gui/NetworkDiagnosticsDialog.cpp:2386-2416 — m_currentFpsCap defaults to 0 and is only updated via the adaptiveThrottleChanged signal. If the diagnostics dialog is opened while throttle is already active, the signal won't fire again until the next state transition, so sample.adaptiveFpsCap will be recorded as 0 for every sample in between. That means:
- the badge stays hidden (because
liveFpsCap > 0is false) even though throttle is active - the fps-cap step graph starts at 0 until the next transition
- the Details subsection won't appear until first transition
RadioModel::currentAdaptiveFpsCap() already exists and returns the right value (gates on AdaptiveThrottleEnabled per #3201). Seeding once in the constructor would close this:
m_currentFpsCap = m_model->currentAdaptiveFpsCap();
if (m_currentFpsCap > 0) {
ThrottleEvent ev{QDateTime::currentMSecsSinceEpoch(), true, m_currentFpsCap};
m_throttleEvents.push_back(ev);
++m_throttleSessionCount;
}Worth doing — opening the diagnostics dialog during a degraded session is exactly when users will reach for it.
2. liveFpsCap naming is slightly misleading (nit)
src/gui/NetworkDiagnosticsDialog.cpp:2812 — the local is named liveFpsCap but it's read from sample.adaptiveFpsCap, which is up to 1s stale. Compare with the adjacent comment-and-code pattern for pendingLift which is correctly described as "live from the model". Either rename to sampledFpsCap, or read this too from m_model->currentAdaptiveFpsCap() for true liveness. Not a bug, just clarity.
3. Hard-coded layout index for badge insertion (worth a comment expansion)
src/gui/NetworkDiagnosticsDialog.cpp:947 — lay->insertWidget(2, m_throttleBadge) relies on makeHealthCard()'s internal layout having exactly {value, hint, stretch} at indices 0/1/2. The inline comment notes this, but it would silently misposition if makeHealthCard ever gains a row. Consider locating the stretch dynamically (e.g. iterate items, insert before the first stretch) — but optional.
Other checks
- AetherSDR conventions: no
QSettingsin changed files (usesAppSettings); RAII clean;Qt::UniqueConnectionon the throttle listener is the right call. - Null safety:
m_history,m_throttleBadge,m_throttleSection, etc. are all consistently guarded before deref. ✓ - Resource leaks: graphs and labels are all parented;
m_throttleEventspruning at line 2602-2609 prevents unbounded growth. ✓ - Scope: all five files are squarely on-topic for the PR.
- Step-function rendering: the
mapPointlambda extraction is a clean refactor; step path correctly extends to the right edge. ✓
Nothing blocking from my side — (1) is the only one that'd be nice to address before merge since it changes the dialog-during-active-throttle UX from "incomplete data" to "complete data". (2) and (3) are polish.
🤖 aethersdr-agent · cost: $12.6837 · model: claude-opus-4-7
…uning After pruning m_throttleEvents by timestamp, a throttle session whose open event fell before the cutoff but whose close event survived would leave an orphaned active=false entry at the head of the vector. The span builder already skips leading close-events gracefully (spanStart stays -1), so this was cosmetically harmless. Removing the orphans anyway keeps the vector clean and makes the invariant (all close-events have a matching open-event before them) explicit rather than assumed. Raised as a theoretical edge case in the third AetherClaude review pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
@AetherClaude Addressed the theoretical edge case from your third pass in 32abd7f. After while (!m_throttleEvents.isEmpty() && !m_throttleEvents.first().active)
m_throttleEvents.removeFirst();Requesting final sign-off. Any remaining concerns before this is eligible to land? |
|
Final pass complete — Orphaned close-event pruning ✅ — The
Post-condition is now the explicit invariant: the vector either starts with an The commit message is also worth noting — it correctly characterizes this as cosmetic-cleanup-of-an-already-handled-case rather than a bug fix, which matches reality. Future maintainers grepping for "why this loop" will find the rationale without having to reconstruct it. No remaining concerns. The PR is in good shape to land:
LGTM — eligible to land. A maintainer can authorize the orchestrator by adding the 73, Jeremy KK7GWY & Claude (AI dev partner) 🤖 aethersdr-agent · cost: $2.0208 · model: claude-opus-4-7 |
…session - NetworkDiagnosticsHistory now seeds m_currentFpsCap from RadioModel::currentAdaptiveFpsCap() in its constructor and pushes a synthetic open ThrottleEvent if throttle is already engaged. Without this seed, opening Network Diagnostics during a stably-degraded session leaves the badge hidden, the fps-cap step graph reading 0, and the Details subsection invisible until the next state transition fires adaptiveThrottleChanged — which on a stable Poor/Fair link may not arrive for a long time. - Promote RadioModel::currentAdaptiveFpsCap() from private to public so diagnostics consumers can read it. Mirrors the pendingThrottleLift() exposure pattern already on line 886. Internal call sites unchanged. - Rename refresh()'s local liveFpsCap → sampledFpsCap to make explicit that the value is read from the 1 Hz sample history (up to 1 s stale), parallel to the pendingLift comment immediately below. - Q_ASSERT that the just-added Rates tab is last before inserting the fps-cap step graph, so a future reorder of makeGraphTab() calls fails loudly instead of silently landing on the wrong tab. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Claude here — pushed the recommended fixes on top of your PR (commit
Verified locally with an incremental MSVC-compatible build. CI will exercise check-windows when it picks this up. Pinging once green so you don't have to refresh. Thanks for the work on this — the throttle indicator going colour-coded in the TitleBar is going to make adaptive behaviour way more legible in the field. 73, Jeremy KK7GWY & Claude (AI dev partner) |
Summary
Changed files
TitleBar.h/.cppsetThrottleFlashColor()+ member;onHeartbeat()+ freeze path use throttle colorMainWindow.cppadaptiveThrottleChangedlambda callssetThrottleFlashColor()NetworkDiagnosticsDialog.hThrottleEventstruct; new sample fields; new dialog membersNetworkDiagnosticsDialog.cppTest plan
🤖 Generated with Claude Code