Skip to content

Commit 6f46eea

Browse files
on8stclaude
andauthored
fix(hl2): linearize the SWR detector and re-derive its noise gate — Principle VIII. (#5521)
## Summary `Refs #4578`. The SWR published from the HL2's directional coupler is computed from raw ADC counts that were never taken back through the detector's curve, and it is admitted from a floor that was a guess. This linearizes the ratio and re-derives the floor — the second from a hardware measurement, not from arithmetic. Four commits: a red one that adds the assertions against the unchanged arithmetic, a green one that linearizes, a third that corrects the gate against bench data, and a fourth that brings the prose in line with the third (`docs/HERMES.md` §17.5 still described both halves as underived from hardware after one of them had been measured). ### Half A — it reads optimistically low, always `swrFromRaw()` computed `(fwd + rev) / (fwd - rev)` on raw counts. The standing defence — repeated in `Hl2Backend::publishTelemetry`, in `Hl2Backend::healthSnapshot` and in `docs/HERMES.md` §17.5 — was *"a ratio of two readings from the same converter, so the unknown scale cancels."* The scale cancels. **The curve does not.** Writing a count as `c = k(c)·V`, rho_shown / rho_true = k(c_rev) / k(c_fwd) and `k = counts / sqrt(watts)` from the shipped table rises monotonically across the whole region of interest (512 at 26 counts to 1516 at 4953). Since `c_rev < c_fwd` always, the shown reflection coefficient is **unconditionally** low and the shown SWR **unconditionally** optimistic — never conservative, on a meter whose entire job is to warn about a mismatch. At 265 forward counts a true 2.0:1 displays **1.44**. `swrFromRaw()` now computes `rho = detectorVolts(rev) / detectorVolts(fwd)`. Everything else is untouched: the `forwardRaw <= 0 → nullopt` case, the `rev >= fwd` clamp, and the voltage form `(1 + rho) / (1 - rho)` with no square root. The existing comment survives verbatim — its voltage-proportionality reasoning is correct and is the part people usually get wrong; the nonlinearity caveat is appended below it, not substituted for it. `detectorVolts()` is new: `sqrt(directionalWatts(raw))`, the inverse curve in arbitrary voltage units since only the ratio is ever used. Deliberately *not* a second table — taking `sqrt()` of the existing function makes the interpolation scheme the same one by construction, so the two cannot drift apart when a per-unit calibration replaces the points. ### Half B — the noise gate, and why 320 is a measurement Linearization does **not** fix the low-end saturation and must not be read as fixing it. Two counts one LSB apart are two nearly-equal numbers on either side of the curve, and the knee's slope amplifies the reverse channel relative to the forward one: `20/19` counts goes from a raw ratio of 39.0 to a linearized **78.0**. It runs away *harder* after the fix. The gate needed its own raise on its own grounds. The criterion is unchanged from the original derivation: **one count of quantisation on either channel must not move the reported SWR by more than 0.25** — half the finest distinction anything downstream makes (1.5 against 2.0 against 2.5, and the 3.0 at which a TX Cal sweep aborts) — for every true SWR from 1.0 to 3.0. An earlier revision of this branch set the gate to 96 on that criterion swept against the reference curve, and flagged in its own comment that it assumed a **one-count** channel disagreement that nobody had put an instrument on. **Bench run D89 put an instrument on it, and 96 was still too low.** The gate is 320 because it was measured, not because it was derived: - **Offset, which is not noise at all.** Fitting the reverse channel against the forward one across 16 legs spanning 1.3 to 822 forward counts gives **`rev = 3.41 + 0.00097·fwd`**, residual sd 0.21 counts. With **no reflected power** the reverse channel still reads ~3.4 counts. Averaging does not remove a bias and neither does a gate — a gate only shrinks its weight against a growing forward reading. The intercept is stable to 0.05 counts over forty minutes and is identical keyed and unkeyed, so it is the converter and not the PA. - **Noise.** With RF in the load the reverse channel carries **2.73 counts of standard deviation** and a 0..12 count range (2311 settled samples, 15 drive levels). It does not shrink at low drive because it does not come from the signal: keyed with the drive register at zero the same channel reads 0.67, and unkeyed 0.68 over 6418 samples. - **The two channels are independent** (Pearson −0.135 to +0.308 across 15 RF legs; sd(fwd−rev) measures 1.052 against 1.002 predicted for independent channels), **so the noise does not partly cancel in the ratio** the way common-mode noise would. The one-count model had no term for any of this. Re-running the *same* criterion against the measured distributions instead of the assumed one count: forward counts 16 32 64 96 160 256 320 p95 error, measured 6.35 1.95 0.91 0.65 0.38 0.26 0.20 p95 error, 1-count 1.57 0.50 0.30 0.20 — — — The two rows agree exactly where they overlap, which is what makes this a correction rather than a second opinion. **320 is the smallest gridded count whose 95th-percentile error stays within 0.25 everywhere above it** (256 misses by 0.008). At 96 the linearized form publishes 1.40 on a load the same instrument reads at 1.03–1.06 where it is trustworthy. Cost: SWR absent below ~74 mW instead of ~12 mW, 18 dB below the rated 5 W. The conclusion does not rest on the noise figure alone. If the reverse noise at 96 counts were the quiet no-RF value of 0.67, the **offset alone** still requires 164 counts. Every route lands above 160; none near 96. `kMeasuredReverseFloorCounts = 3.41` is added beside the gate so the test can **run** the offset criterion rather than restate it, as it already runs the quantisation one. ### What did not reproduce, and it is the issue's headline **nigelfenton's 256.00 saturation case did not reproduce on this radio.** Of 3726 keyed samples, none with forward ≥ 16 produced a linearized SWR above 10. The mechanism checks out arithmetically against the measured noise (67% of resampled readings exceed SWR 2.0 at 16 forward counts), but this radio **cannot hold a forward reading that low**: its gateware decodes only the top nibble of the drive register, so the smallest steady non-zero output is already ~165 counts. **That is a bound on the reproduction, not a confirmation of it**, and it should not be read as one. Relatedly, the issue's own suggested fix — raising the gate to ~1200 — does not work, and gating harder is not a substitute for linearizing. At 1197 forward counts a true 2.0 still displays 1.73 on the raw ratio. Gating on the **forward** channel cannot lift the **reverse** channel out of the knee: at a true 1.5 the reverse sits a factor of five below forward in voltage, so putting it above 1200 counts needs about 16 W forward — past the top of the table and past what an HL2 makes. ### Where the calibration table lives — a decision the triage left open Triage flagged *"whether the shared calibration table lives in `MetisProtocol` or stays in `Hl2Backend` with a seam"* as needing a maintainer. It had to be decided to write the code. **Chosen: the table moves down.** `Hl2Backend::directionalWatts()` becomes `AetherSDR::hl2::directionalWatts()` in `MetisProtocol`, with `detectorVolts()` and the two constants beside it. `Hl2Backend.h` already includes `MetisProtocol.h` and every call site is inside `namespace AetherSDR::hl2`, so the existing calls resolve unqualified and are **textually unchanged** — the only line the diff removes that names the function is its old definition. No new dependency edge, no duplicated table, no seam. **The cost, stated because a maintainer may weigh it differently:** `MetisProtocol` now carries a calibration concern as well as a wire-format one. That is a real widening of its remit. If it is refused, the fallback that preserves the most is an injected-curve seam — it keeps the layering and keeps the test able to see the arithmetic. **This is cheap to overrule; say the word and it moves.** ### A small licensing note, offered rather than argued Noticed while moving the code, and entirely for a maintainer to judge. The calibration table this PR relocates is Quisk's `power_meter_std_calibrations['HL2FilterE3']`, reproduced verbatim with attribution in a source comment. **Quisk does not appear in `THIRD_PARTY_LICENSES`**, while piHPSDR, openHPSDR, OpenWebRX and WDSP all do — including entries the file itself marks as *"consulted as a behavioral reference only"*. This PR does not introduce the dependency: `MetisProtocol.h` already reproduces Quisk's `Hermes_BandDict` verbatim for the N2ADR filter-board masks, so the question predates it and is broader than this change. It may well be a non-issue — a table of measured count/watt pairs is plausibly fact rather than expression, and facts are not copyrightable. Flagging it only because it was in front of me and the file's existing entries suggest the project likes to record consulted sources even when nothing is incorporated. Happy to add an entry, or to leave it alone, on a maintainer's call. ## Tests `tests/hl2_metis_protocol_test.cpp`, the SWR block. **Rewritten.** `swrFromRaw(3000, 1000) == 2.0` asserted a **raw-count ratio** of 1/3 and called it SWR 2.0 — it asserted that the detector is linear, which it is not, so it locked in the bug and any correct implementation had to fail it. It is replaced with counts that are a known true SWR **through the curve** (1000 forward → 0.5046 W → V 0.7103; a true 2.0 → rho 1/3 → 277.9 reverse counts), so `swrFromRaw(1000, 278)` must return 2.0. What the original assertion was really protecting — the voltage form against the power form, which would give 2.37 from the same rho — is still protected, and the comment says so. **Added:** the knee (`swrFromRaw(265, 48)`, a true 2.0:1 through the same curve — raw gives 1.442, linearized 1.992, asserted both within 0.05 of 2.0 *and* explicitly not optimistic); convergence above the knee (`swrFromRaw(4953, 1623)` within 0.01 of 2.0 and within 0.05 of the raw ratio's own 1.975, so the change is provably a low-end correction and not a rescaling of every reading in anyone's log); half B (`swrFromRaw(20, 19)` still exceeds 30 after linearization, and 20 is below the gate); and both criteria **run** rather than restated — the quantisation sweep and the measured-offset check, each asserting the shipped constant satisfies it. **Kept:** the `nullopt`, flat-match and clamp assertions, all passing. **Red before green, observed.** The red commit moves the table and adds every assertion with the arithmetic and the gate unchanged: 8 assertions fail, including `the publish gate is at or above the count the quantisation criterion requires — gate is 16, criterion needs 1201`. The green commit passes every check in that file. The gate commit shows the same shape at the old value: `gate is 96; a matched load with the measured reverse floor 3.41 counts reads 1.371, off by 0.371`, then passes at 320. Two of the new assertions — convergence, and "linearization does not subsume the gate" — **cannot** fail against the unfixed tree, because what they guard is the fixed implementation. Rather than leave them unexamined, each was given observed failure evidence by perturbing the *fixed* code: a wrong exponent in `detectorVolts()` fires the convergence assertion, and a cap inside `swrFromRaw()` fires the half-B one. ## What a reviewer should push back on - **The 0.25 tolerance is chosen, not measured.** Nothing in the code depends on it being right; it is the number the criterion is stated with, so that it can be disagreed with. Reading the same measured data at the median rather than the 95th percentile gives 160 instead of 320. Both are tabulated in the record so either can be taken without re-running the bench. - **Measured on ONE radio.** The 3.41-count offset is a per-unit property of a diode detector. What generalises is that it is **not zero**, not its value. A per-unit calibration would replace this constant along with the curve. - **Half A has no hardware behind it and cannot get any here.** It needs a real mismatch at a known true SWR; this station is on a dummy load. That half rests on the two reporters' evidence and on the algebra. - **The publish gate is not tested where it is applied.** `publishTelemetry` is private and reachable only through a fake radio on a socket. Nothing here asserts that the meter and the Radio Health snapshot still agree — which was true by reading once before, while they in fact disagreed. Worth having; not in this PR. - **The table's new home** — flagged above, not defended to the death. ## Constitution principle honored **Principle VIII — Evidence Over Assertion.** The bias claim is carried by a red-before-green pair with the observed failure output, and the two assertions that could not fail against the unfixed tree were given failure evidence by deliberate perturbation instead of being left to look verified. The gate value replaces an assumption with a measurement and says so, including where the measurement *failed* to reproduce the issue's headline symptom. Principle XI is deliberately **not** cited. Its demonstration is CI on the squash-merge commit, maintainer reproduction, or reporter confirmation, and it explicitly excludes agent self-grading. None of those has happened, and the reporter's own case did not reproduce here. ## Test plan - [x] Local build passes (`cmake --build build`) — **0 `FAILED:` edges** over the full unpiped log. - [x] Behavior verified on a real radio if applicable — **partially, and only the gate.** Bench run D89 read `fwd_pwr`/`rev_pwr` out of a Hermes-Lite 2's response registers over UDP with AetherSDR not in the measurement path: 3726 keyed samples across 18 legs of ≤10 s into a **dummy load**, plus 8978 unkeyed. That is what 320 rests on. **Half A was not verified on hardware and cannot be at this station**, and the reporter's saturation case did not reproduce. Ticked because real hardware genuinely carried part of this; the limits are stated rather than hidden behind the tick. - [x] Existing tests pass (CI) — full `ctest`, not a filtered `-R` subset: **370 tests registered, 370 passed, 0 failed, 5 skipped**, ctest exit 0, 229 s, on the rebased tree. `hl2_metis_protocol_test` passes (`all checks passed`). The five skips are the standing environmental set on this machine (`crdv_quarantined_test`, `app_settings_safety_explicit-profile-path-isolation`, `weather_radar_texture_gl_test`, `range_slider_a11y_test`, `relay_bar_a11y_test`). `vkamp_connection_test`, the known under-load flake, **passed** in 10.4 s and needed no isolated re-run — note this supersedes the third commit's own message, which recorded 360/361 with that test failing; on the rebased tree at this base the suite is fully green, which is the stronger and the current result. CI has not run yet; that is the maintainer's gate. - [x] Reproduction steps documented if user-reported bug — the arithmetic for half A is reproducible from the shipped table with no radio; the bench procedure for the gate is written up in full. Half B's reproduction is documented as a **bound**, not a success. ## Checklist - [x] Commits are signed — all four SSH-signed; verified with `git cat-file commit <sha> | grep '^gpgsig'`. - [x] No new flat-key `AppSettings` calls — no settings touched. - [x] Code is clean-room — nothing decompiled or reverse-engineered from a proprietary binary. The calibration table is reproduced from Quisk, an open-source client, with attribution in the source comment; see the licensing note above. - [x] All meter UI uses `MeterSmoother` — no meter UI changed; this is the value feeding one. - [x] Documentation updated if user-visible behavior changed — `docs/HERMES.md` §17.5 updated twice: once because the standing "the scale cancels" justification was wrong, and again because it went on describing the gate as underived from hardware after D89 had measured it. `CHANGELOG.md` deliberately not touched. - [x] Security-sensitive changes reference a GHSA if applicable — not security-sensitive. It is *safety*-adjacent (an SWR meter that under-reads), which is why the correction is in the conservative direction. **On the template's self-assignment step:** `on8st` has pull-only access, so `gh issue edit 4578 --add-assignee on8st` fails with *"on8st does not have the correct permissions to execute `ReplaceActorsForAssignable`"*. Recorded rather than left silently unticked. **Why `Refs` and not `Fixes`:** this corrects the arithmetic and the floor, but the issue's headline saturation case did not reproduce here, half A has no hardware confirmation, and a per-unit calibration is the real end state for both constants. A maintainer or the reporters are better placed than I am to say whether #4578 is finished. **Base:** rebased onto `main` at `8a358c5f`; four commits, clean rebase, no conflicts. **On the `maintainer-review` label:** the originating issue carries it ("Requires maintainer review before any action is taken"). This PR is offered as a proposal for that review, not as a way around it — nothing here has been merged or acted on upstream, and if the label means the issue should not have been worked at all, say so and I will close this without argument. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 623a97a commit 6f46eea

6 files changed

Lines changed: 525 additions & 99 deletions

File tree

docs/HERMES.md

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2184,7 +2184,35 @@ replaces the table and nothing else.
21842184

21852185
SWR remains the one directional quantity that is meaningful without
21862186
calibration — it is a ratio from the same converter, so the unknown scale
2187-
cancels.
2187+
cancels. **The unknown scale cancels; the detector's CURVE does not.** A ratio
2188+
of raw counts is scale-invariant, not curve-invariant, and the detector is a
2189+
diode with a knee: `k = counts / sqrt(watts)` from the table above runs 512 at
2190+
26 counts to a flat ~1516 above ~1200. The reverse channel always sits further
2191+
down that knee than the forward one, so a raw-count ratio always reads
2192+
*optimistically low* — at 265 forward counts a true 2.0:1 displayed 1.44
2193+
(#4578). `swrFromRaw()` therefore maps both counts through `detectorVolts()`,
2194+
the inverse of this same curve, before taking the ratio. Above the knee that
2195+
converges to what the raw ratio already gave, so it is a low-end correction.
2196+
2197+
Linearizing does **not** rescue the bottom. Two counts one LSB apart are two
2198+
nearly-equal numbers on either side of the curve and the ratio still runs away —
2199+
harder, if anything, because the knee's slope amplifies the reverse channel down
2200+
there. That is what `kMinForwardCountsForSwr` is for, and it was re-derived at
2201+
the same time: 16 counts admitted a live reading of SWR 256.00 on an antenna a
2202+
RigExpert AA-170 measured at 1.50. The constant's own comment carries the
2203+
criterion and the sweep.
2204+
2205+
The two halves rest on different evidence, and the difference matters. The
2206+
**linearization** is derived from the reference curve and has **not** been
2207+
measured on any radio — it inherits every caveat the curve carries. The
2208+
**gate** was measured: bench run D89 read `fwd_pwr` and `rev_pwr` out of a
2209+
Hermes-Lite 2's response registers into a dummy load and found the reverse
2210+
channel carries a fixed ~3.41-count offset with no reflected power plus 2.73
2211+
counts of sd with RF, against the one count the original derivation assumed.
2212+
The channels are independent, so that noise does not cancel in the ratio.
2213+
Re-running the same criterion against the measured distributions gives **320**,
2214+
not 96. That is one radio, and the offset is a per-unit diode property — what
2215+
generalises is that it is not zero, not its value.
21882216

21892217
### 17.6 Meter pacing
21902218

src/core/backends/hl2/Hl2Backend.cpp

Lines changed: 12 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -96,22 +96,6 @@ SampleRate sampleRateEnum(int hz) noexcept
9696
// drift apart, which is exactly the failure being fixed here.
9797
constexpr int kIqSampleRatesHz[] = {48000, 96000, 192000, 384000};
9898

99-
// Minimum forward-power reading, in raw converter counts, below which an SWR
100-
// ratio is noise rather than a measurement.
101-
//
102-
// With no carrier, forward and reverse are both near zero and dominated by
103-
// noise; reverse frequently exceeds forward and the ratio saturates. An
104-
// operator glancing at that sees a catastrophic mismatch on an antenna that is
105-
// fine. Raw counts because that is what we have — this is a noise floor, not a
106-
// calibrated power level.
107-
//
108-
// File-scope so EVERY consumer shares one threshold. It was previously local to
109-
// the meter path, so the Radio Health snapshot computed an unguarded ratio and
110-
// bounced at its 500 ms refresh while the meter beside it stayed silent — two
111-
// surfaces disagreeing about the same radio because only one of them had the
112-
// guard.
113-
constexpr int kMinForwardCountsForSwr = 16;
114-
11599
// The radio's rated output, in watts, as the gauges' full-scale reference.
116100
//
117101
// The HL2 wiki's own FAQ: "The Hermes-Lite 2.0 is a QRP transceiver and
@@ -4482,9 +4466,10 @@ IRadioBackend::HealthSnapshot Hl2Backend::healthSnapshot() const
44824466
put("reversePowerW", QStringLiteral("Reverse (W, approx)"),
44834467
m_telemetry.reversePowerRaw
44844468
? QVariant(directionalWatts(*m_telemetry.reversePowerRaw)) : QVariant());
4485-
// Meaningful without calibration — it is a ratio of two readings from the
4486-
// same converter, so the unknown scale cancels. Absent below the noise
4487-
// floor, where a ratio of two noise samples is not a mismatch reading.
4469+
// Meaningful without calibration — it is a ratio, so the unknown SCALE
4470+
// cancels. The detector's CURVE does not cancel, which is why swrFromRaw()
4471+
// linearizes both counts first (#4578). Absent below the noise floor, where
4472+
// a ratio of two noise samples is not a mismatch reading.
44884473
//
44894474
// That last sentence described the intent but not the code: this site had no
44904475
// floor, so with no carrier it recomputed a noise ratio at the dialog's
@@ -5246,8 +5231,11 @@ void Hl2Backend::publishTelemetry(const Hl2Telemetry& t)
52465231
// build one, and that raw counts must not be presented as watts), only the
52475232
// quantities that are actually meaningful get published.
52485233
//
5249-
// SWR is meaningful WITHOUT calibration because it is a ratio of two
5250-
// readings from the same converter, so the unknown scale cancels.
5234+
// SWR is meaningful WITHOUT calibration because it is a RATIO — but of two
5235+
// linearized readings, not of two raw counts. The unknown SCALE cancels in
5236+
// a raw ratio; the detector's CURVE does not, and taking the raw ratio read
5237+
// optimistically low at low drive (#4578). swrFromRaw() maps both counts
5238+
// through detectorVolts() first; see its comment for the whole argument.
52515239
// SWR only means something with real forward power behind it.
52525240
//
52535241
// Measured on the live radio: with no carrier the forward and reverse counts
@@ -5257,8 +5245,9 @@ void Hl2Backend::publishTelemetry(const Hl2Telemetry& t)
52575245
// catastrophic mismatch on an antenna that is fine.
52585246
//
52595247
// The threshold is in raw counts because that is what we have; it is a
5260-
// noise floor, not a calibrated power level. It lives at file scope so the
5261-
// Radio Health snapshot applies the SAME floor — see kMinForwardCountsForSwr.
5248+
// noise floor, not a calibrated power level. It lives in MetisProtocol.h,
5249+
// beside the calibration curve its value is derived from, so the Radio
5250+
// Health snapshot applies the SAME floor — see kMinForwardCountsForSwr.
52625251
if (t.forwardPowerRaw && t.reversePowerRaw
52635252
&& *t.forwardPowerRaw >= kMinForwardCountsForSwr) {
52645253
if (const auto swr = swrFromRaw(*t.forwardPowerRaw, *t.reversePowerRaw))
@@ -5379,66 +5368,6 @@ void Hl2Backend::publishTelemetry(const Hl2Telemetry& t)
53795368
}
53805369
}
53815370

5382-
double Hl2Backend::directionalWatts(int raw)
5383-
{
5384-
// Quisk's `power_meter_std_calibrations['HL2FilterE3']` verbatim
5385-
// (quisk_conf_defaults.py): measured [ADC count, watts] pairs for a
5386-
// Hermes-Lite 2 with an N2ADR companion filter board, rev E3. Quisk is the
5387-
// reference client and tier 3 on the source-precedence ladder, and this is
5388-
// the only published curve for this coupler.
5389-
//
5390-
// WHAT THIS IS NOT: a calibration of THIS radio. The oracle (§6) is explicit
5391-
// that these counts need a per-unit calibration against a dummy load to mean
5392-
// watts, because the coupler, the toroid winding and the detector diode all
5393-
// vary between boards. A reading from this curve is the right ORDER OF
5394-
// MAGNITUDE and roughly the right shape; it is not a measurement.
5395-
//
5396-
// It is still much better than the alternative, which was publishing
5397-
// nothing: an operator had no way to tell 100 mW from 5 W, and on a radio
5398-
// where a mis-set drive level is silent that is the difference between
5399-
// "working" and "not transmitting". The meters are labelled uncalibrated
5400-
// (defineMeters) so nobody reads them as a power measurement.
5401-
//
5402-
// A future per-unit calibration replaces this table and nothing else.
5403-
struct Point { double counts; double watts; };
5404-
static constexpr Point kCurve[] = {
5405-
{ 0.000000, 0.000000 }, { 25.865385, 0.002550 },
5406-
{ 101.024540, 0.012752 }, { 265.290123, 0.050601 },
5407-
{ 647.915584, 0.216458 }, { 1196.593548, 0.665480 },
5408-
{ 1603.703226, 1.155723 }, { 2012.327160, 1.811892 },
5409-
{ 2616.772727, 3.008585 }, { 3173.818182, 4.392743 },
5410-
{ 3382.792208, 4.979133 }, { 3721.071429, 6.024751 },
5411-
{ 4093.178571, 7.289948 }, { 4502.496429, 8.820838 },
5412-
{ 4952.746071, 10.673214 },
5413-
};
5414-
constexpr std::size_t kN = std::size(kCurve);
5415-
5416-
const double counts = static_cast<double>(raw);
5417-
if (counts <= kCurve[0].counts)
5418-
return 0.0;
5419-
// Above the top of the table, extrapolate along the last segment rather
5420-
// than clamping. Clamping would pin the meter at 10.7 W and hide the one
5421-
// reading an operator most needs to see — that they are past where the
5422-
// curve was ever measured.
5423-
if (counts >= kCurve[kN - 1].counts) {
5424-
const Point& a = kCurve[kN - 2];
5425-
const Point& b = kCurve[kN - 1];
5426-
const double slope = (b.watts - a.watts) / (b.counts - a.counts);
5427-
return b.watts + (counts - b.counts) * slope;
5428-
}
5429-
for (std::size_t i = 1; i < kN; ++i) {
5430-
if (counts <= kCurve[i].counts) {
5431-
const Point& a = kCurve[i - 1];
5432-
const Point& b = kCurve[i];
5433-
const double span = b.counts - a.counts;
5434-
if (span <= 0.0)
5435-
return b.watts;
5436-
return a.watts + (counts - a.counts) * (b.watts - a.watts) / span;
5437-
}
5438-
}
5439-
return kCurve[kN - 1].watts;
5440-
}
5441-
54425371
double Hl2Backend::wattsToDbm(double watts)
54435372
{
54445373
// The meter seam carries dBm (MeterDef unit), and MeterModel converts back

src/core/backends/hl2/Hl2Backend.h

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -261,9 +261,10 @@ class Hl2Backend : public IRadioBackend {
261261
// Shared by setTxPower() and setTune() so the mapping exists exactly once.
262262
void applyDrive(int percent);
263263
static double temperatureCelsius(int raw);
264-
// Uncalibrated directional-coupler counts -> watts. See the table in the
265-
// .cpp for what this curve is and, more importantly, what it is not.
266-
static double directionalWatts(int raw);
264+
// Uncalibrated directional-coupler counts -> watts now lives beside the
265+
// curve itself, as AetherSDR::hl2::directionalWatts() in MetisProtocol —
266+
// swrFromRaw() needs the same table and this layer already depends on that
267+
// one. Call sites here resolve it unqualified from the enclosing namespace.
267268
// Watts -> dBm for the meter seam, floored so 0 W does not become -inf.
268269
static double wattsToDbm(double watts);
269270

src/core/backends/hl2/MetisProtocol.cpp

Lines changed: 126 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,85 @@ void Hl2Telemetry::apply(const Ep6Response& r) noexcept
305305
}
306306
}
307307

308+
double directionalWatts(int raw) noexcept
309+
{
310+
// MOVED here from Hl2Backend with #4578: swrFromRaw() below needs this same
311+
// curve to linearize the detector, and MetisProtocol is the layer Hl2Backend
312+
// already includes. One copy, at the lower layer, no new dependency edge.
313+
//
314+
// Quisk's `power_meter_std_calibrations['HL2FilterE3']` verbatim
315+
// (quisk_conf_defaults.py): measured [ADC count, watts] pairs for a
316+
// Hermes-Lite 2 with an N2ADR companion filter board, rev E3. Quisk is the
317+
// reference client and tier 3 on the source-precedence ladder, and this is
318+
// the only published curve for this coupler.
319+
//
320+
// WHAT THIS IS NOT: a calibration of THIS radio. The oracle (§6) is explicit
321+
// that these counts need a per-unit calibration against a dummy load to mean
322+
// watts, because the coupler, the toroid winding and the detector diode all
323+
// vary between boards. A reading from this curve is the right ORDER OF
324+
// MAGNITUDE and roughly the right shape; it is not a measurement.
325+
//
326+
// It is still much better than the alternative, which was publishing
327+
// nothing: an operator had no way to tell 100 mW from 5 W, and on a radio
328+
// where a mis-set drive level is silent that is the difference between
329+
// "working" and "not transmitting". The meters are labelled uncalibrated
330+
// (defineMeters) so nobody reads them as a power measurement.
331+
//
332+
// A future per-unit calibration replaces this table and nothing else.
333+
struct Point { double counts; double watts; };
334+
static constexpr Point kCurve[] = {
335+
{ 0.000000, 0.000000 }, { 25.865385, 0.002550 },
336+
{ 101.024540, 0.012752 }, { 265.290123, 0.050601 },
337+
{ 647.915584, 0.216458 }, { 1196.593548, 0.665480 },
338+
{ 1603.703226, 1.155723 }, { 2012.327160, 1.811892 },
339+
{ 2616.772727, 3.008585 }, { 3173.818182, 4.392743 },
340+
{ 3382.792208, 4.979133 }, { 3721.071429, 6.024751 },
341+
{ 4093.178571, 7.289948 }, { 4502.496429, 8.820838 },
342+
{ 4952.746071, 10.673214 },
343+
};
344+
constexpr std::size_t kN = std::size(kCurve);
345+
346+
const double counts = static_cast<double>(raw);
347+
if (counts <= kCurve[0].counts)
348+
return 0.0;
349+
// Above the top of the table, extrapolate along the last segment rather
350+
// than clamping. Clamping would pin the meter at 10.7 W and hide the one
351+
// reading an operator most needs to see — that they are past where the
352+
// curve was ever measured.
353+
if (counts >= kCurve[kN - 1].counts) {
354+
const Point& a = kCurve[kN - 2];
355+
const Point& b = kCurve[kN - 1];
356+
const double slope = (b.watts - a.watts) / (b.counts - a.counts);
357+
return b.watts + (counts - b.counts) * slope;
358+
}
359+
for (std::size_t i = 1; i < kN; ++i) {
360+
if (counts <= kCurve[i].counts) {
361+
const Point& a = kCurve[i - 1];
362+
const Point& b = kCurve[i];
363+
const double span = b.counts - a.counts;
364+
if (span <= 0.0)
365+
return b.watts;
366+
return a.watts + (counts - a.counts) * (b.watts - a.watts) / span;
367+
}
368+
}
369+
return kCurve[kN - 1].watts;
370+
}
371+
372+
373+
374+
double detectorVolts(int raw) noexcept
375+
{
376+
// The inverse of the count->power curve, taken back to VOLTAGE, in
377+
// arbitrary units — only ratios of two of these are ever used.
378+
//
379+
// sqrt() of directionalWatts() rather than a second table, deliberately:
380+
// the interpolation scheme is then the SAME scheme by construction, and the
381+
// two functions cannot drift apart when a per-unit calibration replaces the
382+
// points. A separate voltage table would be a second thing to keep in step.
383+
const double w = directionalWatts(raw);
384+
return w > 0.0 ? std::sqrt(w) : 0.0;
385+
}
386+
308387
std::optional<double> swrFromRaw(int forwardRaw, int reverseRaw) noexcept
309388
{
310389
// No carrier, no SWR. Returning 1.0 here would render as a perfect match
@@ -322,13 +401,57 @@ std::optional<double> swrFromRaw(int forwardRaw, int reverseRaw) noexcept
322401
// (one branch uses the voltage form (Vf+Vr)/(Vf-Vr), another a sqrt form
323402
// whose arguments are the wrong way round and would return a NEGATIVE SWR),
324403
// so it is not usable as the tie-breaker.
325-
const double fwd = static_cast<double>(forwardRaw);
326-
double rev = static_cast<double>(reverseRaw < 0 ? 0 : reverseRaw);
404+
//
405+
// ---- and the caveat that reasoning does not cover (#4578) ----
406+
//
407+
// All of the above is about the FORM of the expression and it is correct.
408+
// What it does not establish is that the counts may be used RAW. This
409+
// function used to compute (fwd + rev) / (fwd - rev) directly on counts,
410+
// defended by "a ratio of two readings from the same converter, so the
411+
// unknown scale cancels". A ratio of raw counts is scale-invariant; it is
412+
// not CURVE-invariant, and a diode detector's curve is not a straight line.
413+
//
414+
// Write a count as c = k(c)·V. Then
415+
//
416+
// rho_shown / rho_true = k(c_rev) / k(c_fwd)
417+
//
418+
// and k, from directionalWatts()'s own table (k = counts / sqrt(watts)),
419+
// rises from 512 at 26 counts to a flat ~1516 above ~1200:
420+
//
421+
// counts 26 101 265 648 1197 2012 4953
422+
// k 512 895 1179 1393 1467 1495 1516
423+
//
424+
// c_rev is below c_fwd always, so k(c_rev) <= k(c_fwd) always, so the shown
425+
// reflection coefficient is always LOW and the shown SWR always optimistic
426+
// — never conservative. That is the unsafe direction on a meter whose whole
427+
// job is to warn about a mismatch. Reported by ten9876 (#4578): at 265
428+
// forward counts a true 2.0:1 displayed 1.44.
429+
//
430+
// The repair is to undo the curve before taking the ratio: detectorVolts()
431+
// is sqrt(directionalWatts()), the inverse curve in arbitrary voltage units,
432+
// and rho is the ratio of two of those. Everything else here is unchanged —
433+
// the nullopt on no carrier, the clamp, and the voltage form with no square
434+
// root. Above the knee this converges to what the raw ratio already gave
435+
// (at 4953 counts a true 2.0 read 1.975 before and 2.000 after), so it is a
436+
// low-end correction and not a rescaling of every reading in the log.
437+
//
438+
// What this does NOT fix, and must not be read as fixing: two counts one LSB
439+
// apart are two nearly-equal numbers on either side of the curve, so the
440+
// ratio still runs away down at the noise floor — harder, if anything, since
441+
// the knee's slope amplifies the reverse channel relative to the forward one
442+
// there. At 20/19 counts the raw ratio gave 39.0 and this gives 78.0. That
443+
// case is refused by kMinForwardCountsForSwr, which is why that constant had
444+
// to be re-derived at the same time; it is not repaired here.
445+
const double fwd = detectorVolts(forwardRaw);
446+
if (!(fwd > 0.0))
447+
return std::nullopt; // below the bottom of the curve entirely
448+
double rev = detectorVolts(reverseRaw < 0 ? 0 : reverseRaw);
327449
// Reverse above forward is physically impossible; it means noise on a tiny
328450
// reading. Clamp rather than emit a negative or infinite SWR.
329451
if (rev >= fwd)
330452
rev = fwd * 0.999;
331-
return (fwd + rev) / (fwd - rev);
453+
const double rho = rev / fwd;
454+
return (1.0 + rho) / (1.0 - rho);
332455
}
333456

334457
std::array<std::uint8_t, 64> metisCommand(std::uint8_t cmd) noexcept

0 commit comments

Comments
 (0)