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.
164 lines
5.5 KiB
Go
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)
|
|
}
|
|
}
|