Files
OpsLog/internal/cat/yaesu_split_test.go
T
rouggy e0cefb5c41 fix: the Yaesu power meter is not linear — calibrate it on three points
One point could not reveal the curve. Scaling 207 = 100 W straight down read
30 W where the radio showed 10, and 75 where it showed 50 — wrong everywhere
except at the single point it was fitted to.

Three readings taken against the rig's own display give the shape:

  raw  62 → 10 W
  raw 155 → 50 W
  raw 207 → 100 W

Interpolating between them reproduces the radio exactly at those points and stays
close in between. I did not fit a formula: three samples can be made to support
several curves, and the operator can check a table against their own meter.

Above the top the last segment's slope continues rather than clamping, so a rig
driving an amplifier does not sit pinned at 100 W. A test pins the measured pairs
and the monotonicity, so a later change that breaks them fails against the radio
rather than against taste.
2026-07-29 14:11:25 +02:00

164 lines
5.5 KiB
Go

package cat
import (
"testing"
"time"
)
// What SPLIT means when the operator presses it.
//
// Flipping the rig's split flag alone transmits wherever the OTHER VFO happens
// to sit — reported from a real FTDX10: listening on 14.244 with VFO B left on
// 18.115 from an earlier session, pressing SPLIT threw the transmitter onto
// another band. The other VFO is stale by nature, so the transmit frequency has
// to be derived from where the operator is listening now.
func TestYaesuDefaultSplitOffset(t *testing.T) {
cases := []struct {
raw string
want int64
}{
// CW and the data modes work 1 kHz up.
{"CW-U", 1000},
{"CW-L", 1000},
{"RTTY-U", 1000},
{"RTTY-L", 1000},
{"DATA-U", 1000},
{"DATA-L", 1000},
// Phone works 5 kHz up.
{"USB", 5000},
{"LSB", 5000},
{"AM", 5000},
{"FM", 5000},
// Unknown or not yet read: the phone offset is the safer default — too
// wide is audible and obvious, too narrow lands on top of the DX.
{"", 5000},
}
for _, c := range cases {
y := &Yaesu{}
y.panel.RawMode = c.raw
if got := y.defaultSplitOffset(); got != c.want {
t.Errorf("mode %q → split offset %d Hz, want %d", c.raw, got, c.want)
}
}
}
// The SWR scale, pinned to the two measurements it was derived from.
//
// Taken on an FTDX10 (2026-07-29) against an operator watching the rig's own
// meter: raw 0 at SWR 1.1, raw 52 at SWR 1.5. The second point is what proved
// the raw value is the reflection coefficient scaled to 255 — 52/255 = 0.204,
// rho for a 1.5 SWR — rather than a percentage of meter travel, which is how the
// bar came to read 81 on a perfect antenna.
func TestSWRFromReflection(t *testing.T) {
cases := []struct {
raw int
want float64
tol float64
}{
{0, 1.0, 0.01}, // no reflected power
{52, 1.5, 0.02}, // the measured mismatch
{85, 2.0, 0.05}, // rho = 1/3
{128, 3.0, 0.1}, // rho = 0.5
{-5, 1.0, 0.01}, // nonsense reading — never below 1.0, which is physical
{255, 9.9, 0.01}, // full scale is capped rather than infinite
}
for _, c := range cases {
got := swrFromReflection(c.raw)
if got < c.want-c.tol || got > c.want+c.tol {
t.Errorf("swrFromReflection(%d) = %.2f, want %.2f ±%.2f", c.raw, got, c.want, c.tol)
}
}
// It must rise with the reflected power, or a worsening match would read
// better on the panel than on the rig.
prev := 0.0
for raw := 0; raw <= 200; raw += 20 {
v := swrFromReflection(raw)
if v < prev {
t.Fatalf("SWR fell from %.2f to %.2f at raw=%d", prev, v, raw)
}
prev = v
}
}
// Needle inertia on the TX meters.
//
// The gaps are what this is for: milliseconds between CW elements, but most of a
// second between the words of a CQ — in CW as in SSB. A meter that decays
// immediately reads 0 in every one of those gaps, so the operator sees a bar
// flashing instead of the power they are running.
func TestMeterPeakHold(t *testing.T) {
var m meterPeak
t0 := time.Now()
if got := m.update(80, t0); got != 80 {
t.Fatalf("first sample = %d, want 80 — a meter must show a reading at once", got)
}
// A gap between two words: still inside the hold, so the reading stands.
if got := m.update(0, t0.Add(400*time.Millisecond)); got != 80 {
t.Errorf("during a word gap = %d, want 80 held", got)
}
if got := m.update(0, t0.Add(1400*time.Millisecond)); got != 80 {
t.Errorf("just before the hold expires = %d, want 80 held", got)
}
// Past the hold it falls — but gradually, not to zero in one step.
after := m.update(0, t0.Add(1600*time.Millisecond))
if after >= 80 || after <= 0 {
t.Errorf("after the hold = %d, want a value falling between 80 and 0", after)
}
// A HIGHER reading is taken immediately: a needle rises fast and falls slow.
if got := m.update(95, t0.Add(1700*time.Millisecond)); got != 95 {
t.Errorf("rising sample = %d, want 95 straight away", got)
}
// And it does reach the real value eventually, or a power drop would never show.
v := 0
for i := 0; i < 60; i++ {
v = m.update(10, t0.Add(time.Duration(2000+i*250)*time.Millisecond))
}
if v != 10 {
t.Errorf("settled at %d, want 10 — the meter must converge on the truth", v)
}
}
// The power curve, pinned to the readings taken against the rig's own display.
//
// It is NOT linear, and one calibration point could not show that: scaling
// 207 = 100 W straight down read 30 W where the radio showed 10, and 75 where it
// showed 50. These are the three measured pairs, so a change to the curve that
// breaks them is a regression against the radio, not against a preference.
func TestYaesuPowerCurve(t *testing.T) {
cases := []struct {
raw int
watts float64
tol float64
}{
{0, 0, 0.1},
{62, 10, 0.5}, // measured
{155, 50, 0.5}, // measured
{207, 100, 0.5}, // measured
// Between the measured points it interpolates, so it must land inside the
// bracket rather than shooting past it.
{100, 30, 10},
{180, 75, 10},
}
for _, c := range cases {
got := yaesuWatts(c.raw)
if got < c.watts-c.tol || got > c.watts+c.tol {
t.Errorf("yaesuWatts(%d) = %.1f W, want %.1f ±%.1f", c.raw, got, c.watts, c.tol)
}
}
// Monotonic: more meter must never mean less power.
prev := -1.0
for raw := 0; raw <= 255; raw++ {
v := yaesuWatts(raw)
if v < prev {
t.Fatalf("power fell from %.1f to %.1f at raw=%d", prev, v, raw)
}
prev = v
}
// Above the top of the curve it keeps rising rather than flattening at 100 W —
// a rig driving an amplifier can read past its own full scale.
if v := yaesuWatts(230); v <= 100 {
t.Errorf("yaesuWatts(230) = %.1f, want more than 100 — the curve should extend", v)
}
}