Skip to content

Commit 1b781b0

Browse files
ten9876claudejensenpat
authored
feat(gui): make SmartMTR's meter ballistics project canon. Principle XI. (#5847)
## Why Follow-up to #5845. While fixing the TGXL peak marker, the TGXL gauge and the TX Controls applet — sitting side by side, reading the same transmission — visibly disagreed on how the needle and the peak behaved. They disagreed because they were running different code. SmartMTR (the VFO flag meter) is the one meter in the tree whose ballistics were actually designed rather than tuned by eye. Everything else had drifted: - a `setBallistics({0.030f, 0.800f})` override copy-pasted across **six** applets, silently replacing `MeterSmoother`'s defaults; - **five** separate hand-rolled hold-then-decay peak markers (Tuner, Amp, Acom, Spe, Vkamp/Tx), no two sharing a hold time or a decay rate. Jeremy's call: SmartMTR's ballistics are canon. ## What **One smoother.** `MeterSmoother`'s defaults are now SmartMTR's d'Arsonval ballistic — 18.2 ms attack / 269 ms release, derived from its k=0.60/0.06 at 60 Hz. All six `setBallistics()` overrides are **deleted** rather than re-tuned; the point is that there is one answer, not a better second one. **One peak engine.** `MeterExtremes` — SmartMTR's sliding-window envelope tracker — is generalised with `scaleMin`/`scaleMax` so it works in watts as well as its own UNIT span, and `HGauge` now drives its peak marker from it. The marker glides at constant velocity over a 3 s window and retires because the window rolls past the peak; there is no hold phase left to tune. Slew is scaled per gauge so a marker crosses any range in the same ~3.7 s it takes to cross SmartMTR's. **Device-reported peaks keep their own path.** The TGXL reports `peak` and the radio reports MICPEAK — both are real measurements taken closer to the signal than our polling can get. Those feed `setExternalPeak()`, SmartMTR's external-peak mode: the marker *is* the device's number, tracked at the fast peak slew, retiring when the device retires it. The five applet-side hold/decay implementations are gone. ## Two integration bugs found while wiring this up Both would have frozen the marker on screen, and both are worth knowing about before touching this again: 1. The engine clamps the marker to sit at or above the needle. Hand it the *raw* target and it drags the marker straight to the new reading — no glide at all. It has to be handed the **painted** needle position. 2. The window's clock only advances inside the animation tick. Stop the timer while a marker is still standing off the needle and the window can never expire, so the marker stays stranded at the old peak forever. `tick()`'s return value is now the sole keep-alive. ## Testing - Full suite: **523/524**. The one failure is `connection_panel_size_test`, which fails identically on `main` (pre-existing, ConnectionPanel 150%-scaling, #4515 territory). - `tgxl_docked_parity_test` and `amp_applet_test` peak tests rewritten. They assert the **shape** — the marker glides rather than jumping, stands off above the needle, and comes back down on its own — not the constants, so the window can be retuned without rewriting them. - The amp test discriminates: it failed against both integration bugs above during development. ## Note for review This changes how **every** meter in the app moves, not just the two that prompted it. That is the intent, but it is worth eyes on: if some meter was quietly depending on the slower 800 ms release to look right, this will change it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01AmTu3avSWtEovX1kuuZsWn --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: jensenpat <patjensen@gmail.com>
1 parent e6f3b07 commit 1b781b0

19 files changed

Lines changed: 304 additions & 296 deletions

src/gui/AcomApplet.cpp

Lines changed: 5 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ AcomApplet::AcomApplet(QWidget* parent)
141141
m_pwrLabel->setText("PWR");
142142
m_pwrGauge = new HGauge(0.0f, 700.0f, 600.0f, "", "",
143143
evenTicks(700.0f), this);
144-
m_pwrGauge->setBallistics({0.030f, 0.800f});
144+
m_pwrGauge->setWindowPeakEnabled(true);
145145
m_pwrGauge->setAccessibleName(tr("Forward power"));
146146
auto* pwrRow = new QHBoxLayout;
147147
pwrRow->setSpacing(4);
@@ -283,14 +283,6 @@ AcomApplet::AcomApplet(QWidget* parent)
283283
connect(&m_labelTimer, &QTimer::timeout, this, &AcomApplet::updateValueLabels);
284284
m_labelTimer.start();
285285

286-
m_peakTimer = new QTimer(this);
287-
m_peakTimer->setSingleShot(true);
288-
m_peakTimer->setInterval(2500);
289-
connect(m_peakTimer, &QTimer::timeout, this, [this]() {
290-
m_peakFwd = 0.0f;
291-
m_pwrGauge->clearPeak();
292-
});
293-
294286
setConnected(false);
295287
}
296288

@@ -308,11 +300,7 @@ void AcomApplet::setForwardPower(float watts)
308300
{
309301
m_fwdWatts = watts;
310302
m_pwrGauge->setValue(watts);
311-
if (watts > m_peakFwd) {
312-
m_peakFwd = watts;
313-
m_pwrGauge->setPeakValue(watts);
314-
m_peakTimer->start();
315-
}
303+
// Peak marker: HGauge's sliding window, fed by setValue (canon).
316304
}
317305

318306
void AcomApplet::setReflectedPower(float watts)
@@ -455,12 +443,9 @@ void AcomApplet::setConnected(bool connected)
455443
m_fwdWatts = 0.0f;
456444
m_reflectedWatts = 0.0f;
457445
m_swrVal = 1.0f;
458-
// Clear the forward-power peak hold too — otherwise a stale peak from
459-
// the prior session survives (m_peakFwd is not otherwise reset), and a
460-
// reconnect within the 2.5 s peak-hold window suppresses the new
461-
// session's peak marker until a reading exceeds the old peak.
462-
m_peakFwd = 0.0f;
463-
if (m_peakTimer) m_peakTimer->stop();
446+
// Clear the forward-power peak too — otherwise a stale marker from
447+
// the prior session survives a reconnect. clearPeak() drops the
448+
// gauge's sliding window as well, which is what retires it now.
464449
m_pwrGauge->setValueImmediate(0.0f);
465450
m_pwrGauge->clearPeak();
466451
m_refGauge->setValueImmediate(0.0f);

src/gui/AcomApplet.h

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,6 @@ class AcomApplet : public QWidget {
104104
QPushButton* m_offBtn{nullptr};
105105

106106
QTimer m_labelTimer;
107-
QTimer* m_peakTimer{nullptr};
108-
float m_peakFwd{0.0f};
109107

110108
float m_fwdWatts{0.0f};
111109
float m_reflectedWatts{0.0f};

src/gui/AmpApplet.cpp

Lines changed: 2 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -277,9 +277,7 @@ void AmpApplet::buildUI()
277277
m_fwdGauge = new HGauge(0.0f, 2000.0f, 1500.0f, "", "",
278278
{{0, "0"}, {500, "500"}, {1000, "1K"}, {1500, "1.5K"}, {2000, "2K"}},
279279
this, 1000.0f);
280-
// Slow release: bar rises quickly on RF bursts but decays over ~800 ms
281-
// so brief transmissions remain visible — matches S-meter peak-hold feel.
282-
m_fwdGauge->setBallistics({0.030f, 0.800f});
280+
m_fwdGauge->setWindowPeakEnabled(true);
283281
m_fwdGauge->setAccessibleName(tr("Forward power"));
284282
auto* pwrRow = new QHBoxLayout;
285283
// Zero margins, like every other nested layout here. A QLayout that is
@@ -311,7 +309,6 @@ void AmpApplet::buildUI()
311309
m_drvGauge = new HGauge(0.0f, 100.0f, 75.0f, "", "",
312310
{{0, "0"}, {25, "25"}, {50, "50"}, {75, "75"}, {100, "100"}},
313311
this, 50.0f);
314-
m_drvGauge->setBallistics({0.030f, 0.800f});
315312
m_drvGauge->setAccessibleName(tr("Drive power"));
316313
auto* drvRow = new QHBoxLayout;
317314
drvRow->setContentsMargins(0, 0, 0, 0);
@@ -540,32 +537,6 @@ void AmpApplet::buildUI()
540537
connect(&m_labelTimer, &QTimer::timeout, this, &AmpApplet::updateValueLabels);
541538
m_labelTimer.start();
542539

543-
// Peak-hold ballistics matching TxApplet and TunerApplet (#2561): hold,
544-
// then decay toward the live reading rather than snapping the marker
545-
// away. Three power meters on one screen should behave alike.
546-
m_peakTick = new QTimer(this);
547-
m_peakTick->setInterval(50);
548-
connect(m_peakTick, &QTimer::timeout, this, [this]() {
549-
if (!m_peakHoldRunning) { m_peakTick->stop(); return; }
550-
const qint64 elapsedMs = m_peakHoldTimer.elapsed();
551-
if (elapsedMs <= kPeakHoldMs) return;
552-
const float decaySecs = static_cast<float>(elapsedMs - kPeakHoldMs) / 1000.0f;
553-
const float decayed = m_peakDecayStart - kPeakDecayWattsPerSec * decaySecs;
554-
if (decayed <= m_fwdWatts) {
555-
m_peakFwd = m_fwdWatts;
556-
m_peakHoldRunning = false;
557-
m_peakTick->stop();
558-
// At the floor the marker goes away entirely, as it did before.
559-
if (m_fwdWatts <= 0.05f) {
560-
m_peakFwd = 0.0f;
561-
m_fwdGauge->clearPeak();
562-
return;
563-
}
564-
} else {
565-
m_peakFwd = decayed;
566-
}
567-
m_fwdGauge->setPeakValue(m_peakFwd);
568-
});
569540

570541
applyDensityAtScale(1.0);
571542
updatePortRows();
@@ -1157,14 +1128,7 @@ void AmpApplet::setFwdPower(float watts)
11571128
m_swrGauge->setValue(1.0f); // clear bar — SWR is unmeasurable at idle
11581129
else if (isPowered && !wasPowered)
11591130
m_swrGauge->setValue(m_swrVal); // power resumed — restore cached value
1160-
if (watts > m_peakFwd) {
1161-
m_peakFwd = watts;
1162-
m_peakDecayStart = watts;
1163-
m_peakHoldTimer.restart();
1164-
m_peakHoldRunning = true;
1165-
if (!m_peakTick->isActive()) m_peakTick->start();
1166-
m_fwdGauge->setPeakValue(watts);
1167-
}
1131+
// Peak marker: HGauge's sliding window, fed by setValue above (canon).
11681132
// Label text is updated by the 100 ms timer (updateValueLabels).
11691133
}
11701134

src/gui/AmpApplet.h

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -283,17 +283,6 @@ class AmpApplet : public QWidget {
283283

284284
// 100 ms timer — updates label text independently of gauge fill rate
285285
QTimer m_labelTimer;
286-
// Peak hold: white tick on fwd gauge, cleared 2.5 s after last new peak
287-
// Peak-hold ballistics, the same shape TxApplet and TunerApplet use
288-
// (#2561): hold, then decay at full-scale/2.5 s so the marker falls
289-
// rather than vanishing. This gauge is fixed at 2 kW full scale.
290-
QTimer* m_peakTick{nullptr};
291-
float m_peakFwd{0.0f};
292-
float m_peakDecayStart{0.0f};
293-
bool m_peakHoldRunning{false};
294-
QElapsedTimer m_peakHoldTimer;
295-
static constexpr qint64 kPeakHoldMs = 2000;
296-
static constexpr float kPeakDecayWattsPerSec = 2000.0f / 2.5f;
297286

298287
// When the radio relay last delivered a power/SWR sample. See
299288
// setDeviceMeters() for the rule it decides. Monotonic on purpose: an

src/gui/HGauge.h

Lines changed: 139 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#pragma once
22

33
#include "DragValuePopup.h"
4+
#include "MeterExtremes.h"
45
#include "MeterSmoother.h"
56

67
#include <QAccessible>
@@ -51,12 +52,47 @@ class HGauge : public QWidget {
5152
setFixedHeight(24);
5253
setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
5354

55+
applyExtremesScale();
5456
m_smooth.setTarget(fractionFor(m_value));
5557
m_smooth.snapToTarget();
5658
m_animTimer.setTimerType(Qt::PreciseTimer);
5759
m_animTimer.setInterval(kMeterSmootherIntervalMs);
5860
connect(&m_animTimer, &QTimer::timeout, this, [this]() {
59-
if (!m_smooth.tick(m_animElapsed.restart()))
61+
const qint64 dt = m_animElapsed.restart();
62+
const bool barMoving = m_smooth.tick(dt);
63+
// The extremes engine keeps ticking after the bar settles: a
64+
// window sample can expire and slide the marker with the needle
65+
// already at rest.
66+
bool markerMoving = false;
67+
if (m_peakSource != PeakSource::Disabled) {
68+
m_nowMs += dt;
69+
// The PAINTED needle, not the raw target: the engine clamps
70+
// the marker to sit at or above the needle, so handing it the
71+
// unsmoothed value would drag the marker straight to the new
72+
// reading and there would be no glide to see.
73+
const double needleUnits =
74+
double(m_min) + double(m_smooth.value())
75+
* (double(m_max) - double(m_min));
76+
markerMoving = m_extremes.tick(
77+
m_nowMs, dt, needleUnits,
78+
[this](double raw) {
79+
return double(qBound(m_min, float(raw), m_max));
80+
});
81+
// Publish the marker every tick, including after the window
82+
// has emptied: that is exactly when it is gliding back down to
83+
// the floor, and freezing the published value there would
84+
// strand the marker at the last peak forever.
85+
m_peakValue = static_cast<float>(m_extremes.maxPosUnits());
86+
m_peakEnabled =
87+
m_extremes.hasData()
88+
|| m_peakValue > float(needleUnits) + kMarkerCollapseEps;
89+
}
90+
// m_nowMs only advances in here, so the sliding window can only
91+
// expire while the timer runs. tick() reports true whenever a
92+
// marker is mid-slew or still standing off the needle — which is
93+
// exactly the state in which pruning still has work to do — so its
94+
// return value alone is a sufficient keep-alive.
95+
if (!barMoving && !markerMoving)
6096
m_animTimer.stop();
6197
// Republish so gaugeFraction tracks the bar through the sweep, not
6298
// just at the setValue/setRange call that started it — otherwise
@@ -109,6 +145,37 @@ class HGauge : public QWidget {
109145
// compression bar) the mapping is inverted at paint time — min means FULL —
110146
// so the painted width there is 1.0f - filledFraction(). Assert
111147
// accordingly; the fraction itself is always value-normalised.
148+
// Opt into a sliding-window marker driven by the values passed to
149+
// setValue(). Ordinary gauges remain marker-free; only readings for which
150+
// an extremum is meaningful (forward power today) enable this mode.
151+
void setWindowPeakEnabled(bool enabled) {
152+
selectPeakSource(enabled ? PeakSource::Window : PeakSource::Disabled,
153+
enabled);
154+
publishAutomationState();
155+
update();
156+
}
157+
158+
// Feed a separate raw sample into the sliding window without changing the
159+
// bar. TxApplet uses this because its bar receives a smoothed reading while
160+
// txPeakChanged carries the raw FWDPWR sample from which PEP is derived.
161+
void recordWindowPeakSample(float v) {
162+
selectPeakSource(PeakSource::Window, false);
163+
m_extremes.record(double(qBound(m_min, v, m_max)), m_nowMs);
164+
m_peakEnabled = true;
165+
armPeakTimer();
166+
}
167+
168+
// Drive the peak marker from a separately measured peak (TGXL `peak`)
169+
// instead of this gauge's own sliding window. The marker then tracks that
170+
// value at SmartMTR's fast peak slew. Switching source resets the old
171+
// window so record() and external-peak mode can never coexist.
172+
void setExternalPeak(float v) {
173+
selectPeakSource(PeakSource::External, false);
174+
m_extremes.setExternalPeak(double(qBound(m_min, v, m_max)));
175+
m_peakEnabled = true;
176+
armPeakTimer();
177+
}
178+
112179
float value() const { return m_value; }
113180
float filledFraction() const { return m_smooth.value(); }
114181
// The peak-hold marker. peakHeld() is separate from the value because
@@ -120,8 +187,11 @@ class HGauge : public QWidget {
120187
void setValue(float v) {
121188
if (qFuzzyCompare(m_value, v)) return;
122189
m_value = v;
190+
if (m_peakSource == PeakSource::Window && m_recordGaugeValuesForPeak) {
191+
m_extremes.record(double(v), m_nowMs);
192+
}
123193
m_smooth.setTarget(fractionFor(v));
124-
if (!m_smooth.needsAnimation()) {
194+
if (!m_smooth.needsAnimation() && !m_peakEnabled) {
125195
if (m_animTimer.isActive()) m_animTimer.stop();
126196
update();
127197
} else if (!m_animTimer.isActive()) {
@@ -143,6 +213,10 @@ class HGauge : public QWidget {
143213
}
144214

145215
void setPeakValue(float v) {
216+
// Legacy/manual callers own the marker value directly. In particular,
217+
// PhoneCwApplet supplies the radio's MICPEAK immediately after
218+
// setValue(); a window tick must not overwrite that measurement.
219+
selectPeakSource(PeakSource::Disabled, false);
146220
if (qFuzzyCompare(m_peakValue, v)) return;
147221
m_peakValue = v;
148222
m_peakEnabled = true;
@@ -151,6 +225,10 @@ class HGauge : public QWidget {
151225
}
152226

153227
void clearPeak() {
228+
// Park: drop the window too, or the engine keeps sliding a marker
229+
// for a gauge the caller has just said has nothing to show.
230+
m_extremes.reset();
231+
m_peakValue = static_cast<float>(m_extremes.floorPos());
154232
if (!m_peakEnabled) return;
155233
m_peakEnabled = false;
156234
publishAutomationState();
@@ -168,6 +246,10 @@ class HGauge : public QWidget {
168246
MeterSmoother::Ballistics ballistics = m_smooth.ballistics();
169247
std::swap(ballistics.attackSeconds, ballistics.releaseSeconds);
170248
m_smooth.setBallistics(ballistics);
249+
m_extremes.setReversed(rev);
250+
m_extremes.reset();
251+
m_peakValue = static_cast<float>(m_extremes.floorPos());
252+
m_peakEnabled = false;
171253
update();
172254
}
173255
// Anchor the fill bar to the right edge instead of the left. Unlike
@@ -211,6 +293,7 @@ class HGauge : public QWidget {
211293
void setRange(float min, float max, float redStart,
212294
const QVector<Tick>& ticks, float yellowStart = std::numeric_limits<float>::quiet_NaN()) {
213295
m_min = min; m_max = max; m_redStart = redStart;
296+
applyExtremesScale();
214297
m_yellowStart = std::isnan(yellowStart) ? redStart : yellowStart;
215298
m_ticks = ticks;
216299
// Re-map the CURRENT value onto the new axis. Without this the fill
@@ -432,6 +515,52 @@ class HGauge : public QWidget {
432515
}
433516

434517
private:
518+
519+
enum class PeakSource {
520+
Disabled,
521+
Window,
522+
External,
523+
};
524+
525+
void selectPeakSource(PeakSource source, bool recordGaugeValues) {
526+
const bool recordValues = source == PeakSource::Window && recordGaugeValues;
527+
if (m_peakSource == source
528+
&& m_recordGaugeValuesForPeak == recordValues) {
529+
return;
530+
}
531+
m_extremes.reset();
532+
m_peakSource = source;
533+
m_recordGaugeValuesForPeak = recordValues;
534+
m_peakValue = static_cast<float>(m_extremes.floorPos());
535+
m_peakEnabled = false;
536+
}
537+
538+
void armPeakTimer() {
539+
if (!m_animTimer.isActive()) {
540+
m_animElapsed.restart();
541+
m_animTimer.start();
542+
}
543+
}
544+
545+
// SmartMTR slews its markers at a constant 60 UNITS/s over a 220-UNIT bar
546+
// -- a marker crosses the full scale in ~3.7 s, deliberately lazy against
547+
// the bar's attack. Expressed as a fraction of span so every gauge range
548+
// takes the same ~3.7 s, which is what makes them feel alike.
549+
void applyExtremesScale() {
550+
MeterExtremes::Tuning t;
551+
t.windowSeconds = SmartMtrExtremes::kWindowMediumSec;
552+
t.scaleMin = m_min;
553+
t.scaleMax = m_max;
554+
const double span = double(m_max) - double(m_min);
555+
t.slewUnitsPerSec = span > 0.0 ? span / kMarkerCrossSeconds : 1.0;
556+
m_extremes.setTuning(t);
557+
}
558+
// Below this (in gauge units) the marker has effectively collapsed onto
559+
// the needle and stops being drawn as a separate peak.
560+
static constexpr float kMarkerCollapseEps = 0.001f;
561+
static constexpr double kMarkerCrossSeconds =
562+
(SmartMtrUnits::kScaleMax - SmartMtrUnits::kScaleMin)
563+
/ SmartMtrExtremes::kSlewUnitsPerSec;
435564
// Map a physical value onto the normalised [0,1] axis fraction the
436565
// smoother and paintEvent work in. Every site that moves the fill must
437566
// agree on this — the constructor, setValue, setValueImmediate and
@@ -612,6 +741,14 @@ class HGauge : public QWidget {
612741
float m_min, m_max, m_redStart, m_yellowStart;
613742
float m_value{0.0f};
614743
static constexpr int kPeakMarkerW = 2; // pixels
744+
// Peak marker, SmartMTR's engine (project canon): a sliding window over
745+
// recent samples with a constant-velocity glide, rather than a latched
746+
// peak on a hold-then-decay timer. The window expiring is what retires
747+
// the marker, so there is no hold phase to tune.
748+
MeterExtremes m_extremes;
749+
PeakSource m_peakSource{PeakSource::Disabled};
750+
bool m_recordGaugeValuesForPeak{false};
751+
qint64 m_nowMs{0}; // monotonic tick clock for the window
615752
float m_peakValue{0.0f};
616753
bool m_peakEnabled{false};
617754
bool m_reversed{false};

0 commit comments

Comments
 (0)