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.
This commit is contained in:
2026-07-29 14:11:25 +02:00
parent 113faede14
commit e0cefb5c41
3 changed files with 87 additions and 13 deletions
+2 -2
View File
@@ -3,7 +3,7 @@
"version": "0.21.9", "version": "0.21.9",
"date": "2026-07-28", "date": "2026-07-28",
"en": [ "en": [
"Yaesu console meters are right: power now reads the meter that follows the RF (in watts, measured, not the power setting) and SWR shows the ratio itself. Both were reading the wrong index — the SWR bar sat near 80 on a perfect match. The bars also hold their peak for a moment before falling back, so they stay readable through the gaps between words in a CQ, in CW as in SSB.", "Yaesu console meters are right: power now reads the meter that follows the RF (in watts, on a curve measured against the rig own display, not the power setting) and SWR shows the ratio itself. Both were reading the wrong index — the SWR bar sat near 80 on a perfect match. The bars also hold their peak for a moment before falling back, so they stay readable through the gaps between words in a CQ, in CW as in SSB.",
"CW speed is now one setting wherever you change it: the Yaesu console slider and the CW panel drive the keyer that is actually sending, and the rig own keyer follows so its front panel agrees.", "CW speed is now one setting wherever you change it: the Yaesu console slider and the CW panel drive the keyer that is actually sending, and the rig own keyer follows so its front panel agrees.",
"Antenna Genius: port A no longer jumps onto port B antenna a few seconds after a change. Only one port can hold the transmit antenna, so the switch reports the same one on both — the display now follows each port own receive antenna.", "Antenna Genius: port A no longer jumps onto port B antenna a few seconds after a change. Only one port can hold the transmit antenna, so the switch reports the same one on both — the display now follows each port own receive antenna.",
"CW through the Yaesu keyer: a fifth CW engine sends your macros with the radio own keyer (CAT \"KY\"), for the FTDX101 / FT-991A / FT-710 family. An FTDX10 does not accept it — DAKY included — and OpsLog says so, pointing to the serial DTR keyer on the rig second COM port, which works.", "CW through the Yaesu keyer: a fifth CW engine sends your macros with the radio own keyer (CAT \"KY\"), for the FTDX101 / FT-991A / FT-710 family. An FTDX10 does not accept it — DAKY included — and OpsLog says so, pointing to the serial DTR keyer on the rig second COM port, which works.",
@@ -21,7 +21,7 @@
"CQ and ITU zones you correct by hand in your profile stay corrected. They are derived from cty.dat, which gives the zones of the whole entity — in a country spanning several, the automatic value is wrong and it came back at every restart. They now only fill in when empty, and recompute when the callsign or grid changes." "CQ and ITU zones you correct by hand in your profile stay corrected. They are derived from cty.dat, which gives the zones of the whole entity — in a country spanning several, the automatic value is wrong and it came back at every restart. They now only fill in when empty, and recompute when the callsign or grid changes."
], ],
"fr": [ "fr": [
"Les mesures de la console Yaesu sont justes : la puissance lit l'instrument qui suit la HF (en watts mesurés, et non le réglage de puissance) et le ROS affiche le rapport lui-même. Les deux lisaient le mauvais index — la barre de ROS restait vers 80 sur une antenne parfaite. Les barres retiennent aussi leur crête un instant avant de redescendre : elles restent lisibles pendant les silences entre les mots dun CQ, en CW comme en phonie.", "Les mesures de la console Yaesu sont justes : la puissance lit l'instrument qui suit la HF (en watts, sur une courbe relevée face à l'affichage de la radio, et non le réglage de puissance) et le ROS affiche le rapport lui-même. Les deux lisaient le mauvais index — la barre de ROS restait vers 80 sur une antenne parfaite. Les barres retiennent aussi leur crête un instant avant de redescendre : elles restent lisibles pendant les silences entre les mots dun CQ, en CW comme en phonie.",
"La vitesse CW est désormais un réglage unique où que vous la changiez : le curseur de la console Yaesu et le panneau CW pilotent le manipulateur qui émet réellement, et le keyer interne de la radio suit pour que sa façade affiche la même valeur.", "La vitesse CW est désormais un réglage unique où que vous la changiez : le curseur de la console Yaesu et le panneau CW pilotent le manipulateur qui émet réellement, et le keyer interne de la radio suit pour que sa façade affiche la même valeur.",
"Antenna Genius : le port A ne bascule plus sur l'antenne du port B quelques secondes après un changement. Un seul port peut porter l'antenne d'émission, le switch renvoie donc la même sur les deux — l'affichage suit désormais l'antenne de réception propre à chaque port.", "Antenna Genius : le port A ne bascule plus sur l'antenne du port B quelques secondes après un changement. Un seul port peut porter l'antenne d'émission, le switch renvoie donc la même sur les deux — l'affichage suit désormais l'antenne de réception propre à chaque port.",
"CW par le keyer Yaesu : un cinquième moteur CW envoie vos macros avec le keyer de la radio (CAT « KY »), pour la famille FTDX101 / FT-991A / FT-710. Un FTDX10 ne l'accepte pas — DAKY compris — et OpsLog le dit en renvoyant vers le keyer série DTR sur son second port COM, qui fonctionne.", "CW par le keyer Yaesu : un cinquième moteur CW envoie vos macros avec le keyer de la radio (CAT « KY »), pour la famille FTDX101 / FT-991A / FT-710. Un FTDX10 ne l'accepte pas — DAKY compris — et OpsLog le dit en renvoyant vers le keyer série DTR sur son second port COM, qui fonctionne.",
+42 -11
View File
@@ -713,20 +713,52 @@ func swrFromReflection(raw int) float64 {
// yaesuWatts converts the power-meter reading to watts. // yaesuWatts converts the power-meter reading to watts.
// //
// Single-point calibration, measured rather than assumed: an FTDX10 keying CW at // The meter is NOT linear in power, which one calibration point could never
// 100 W reads 207. The PO meter is close enough to linear in power for one point // reveal: scaling 207 = 100 W straight down read 30 W where the rig showed 10,
// to carry the scale, and a figure taken from the METER beats the power SETTING, // and 75 where it showed 50. Three points measured against the radio's own
// which says what was asked for and not what left the radio. // display settle the curve:
// //
// A second point at low power would refine it; until then the number is right at // raw 62 → 10 W
// 100 W and approximate below. // raw 155 → 50 W
const yaesuPowerFullScale = 207.0 // raw reading measured at 100 W // raw 207 → 100 W
//
// Interpolating between them reproduces the rig exactly at those points and
// stays close in between — better than fitting a formula to three samples and
// pretending it holds everywhere. Above the last point it keeps the final
// segment's slope, so an amplifier-driving rig does not flatten at 100 W.
//
// Measured on an FTDX10 (2026-07-29). Another model may well need its own row.
var yaesuPowerCurve = []struct {
raw int
watts float64
}{
{0, 0},
{62, 10},
{155, 50},
{207, 100},
}
func yaesuWatts(raw int) float64 { func yaesuWatts(raw int) float64 {
if raw <= 0 { if raw <= 0 {
return 0 return 0
} }
return float64(raw) * 100.0 / yaesuPowerFullScale for i := 1; i < len(yaesuPowerCurve); i++ {
hi := yaesuPowerCurve[i]
if raw <= hi.raw {
lo := yaesuPowerCurve[i-1]
span := float64(hi.raw - lo.raw)
if span <= 0 {
return hi.watts
}
f := float64(raw-lo.raw) / span
return lo.watts + f*(hi.watts-lo.watts)
}
}
// Past the top of the curve: extend the last segment rather than clamp.
n := len(yaesuPowerCurve)
lo, hi := yaesuPowerCurve[n-2], yaesuPowerCurve[n-1]
slope := (hi.watts - lo.watts) / float64(hi.raw-lo.raw)
return hi.watts + float64(raw-hi.raw)*slope
} }
// meterPeak gives a meter the inertia a needle has: it jumps to a higher reading // meterPeak gives a meter the inertia a needle has: it jumps to a higher reading
@@ -735,8 +767,7 @@ func yaesuWatts(raw int) float64 {
// The hold is the part that matters on the air. Between CW elements the gap is // The hold is the part that matters on the air. Between CW elements the gap is
// milliseconds, but between the words of a CQ — in CW as in SSB — it is most of // milliseconds, but between the words of a CQ — in CW as in SSB — it is most of
// a second, and a meter that decays straight away reads 0 in every one of those // a second, and a meter that decays straight away reads 0 in every one of those
// gaps. The operator sees a bar flashing rather than the power and SWR they are // gaps: the operator sees a bar flashing rather than the power they are running.
// actually running.
type meterPeak struct { type meterPeak struct {
val int val int
at time.Time at time.Time
@@ -757,7 +788,7 @@ func (m *meterPeak) update(sample int, now time.Time) int {
return m.val // still inside the hold — the needle has not started to fall return m.val // still inside the hold — the needle has not started to fall
} }
// At least one step down, always. A proportional decay on integers stalls // At least one step down, always. A proportional decay on integers stalls
// near the end with the needle at 13 and the truth at 10, a quarter of the // near the end: with the needle at 13 and the truth at 10, a quarter of the
// gap rounds to zero and the meter sits three units high for ever. // gap rounds to zero and the meter sits three units high for ever.
step := (m.val - sample) / meterDecay step := (m.val - sample) / meterDecay
if step < 1 { if step < 1 {
+43
View File
@@ -118,3 +118,46 @@ func TestMeterPeakHold(t *testing.T) {
t.Errorf("settled at %d, want 10 — the meter must converge on the truth", v) 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)
}
}