fix(bandopen): find the busiest sector, and name it correctly
Two faults, both found from one field log: 70 000 decodes, 10 and 12 m plainly open, nothing announced — and the two openings that DID fire named the wrong direction. evaluate() required EVERY distinct station in the window to fit inside one 90° arc. That is the shape of a sporadic-E cloud and of nothing else. With 10 and 12 m open on F2 the reports arrive from all round the compass, the arc is 360°, and the test can never pass — so the busier the band, the less likely an opening was announced. Exactly backwards. It now finds the DENSEST sector instead, which keeps the Es signature intact (a cloud still makes one direction dense) and lets a real F2 opening be seen through the handful of neighbours who are always there. Scattered-but-busy still reports nothing: the point was to stop demanding global agreement, not to call every open band an opening. Sector() averaged the two bearings arithmetically, so an arc crossing north was labelled by its opposite: 353–61° averaged to 207° and went out as SW when it was NE. Not a vague error — a reversed one, given to an operator who may turn a beam on it. The detector has handled the 0/360 wrap since it was written; only this label had not.
This commit is contained in:
+4
-2
@@ -10,7 +10,8 @@
|
||||
"Band-opening detection no longer ignores long paths. It capped them at 2400 km on the assumption that anything further was not a single hop; multi-hop sporadic E is ordinary on 6 m, so the openings most worth hearing about were the ones being discarded. Direction still decides — a second hop leaves the sector the first one entered.",
|
||||
"Band-opening detection now has the data it needs. Switching on \"Watch for band openings\" (Settings › DX Cluster) subscribes to the PSK Reporter feed — every station on the air reporting what it decodes, rather than the handful of VHF spots a cluster carries — and adds the two RBN nodes if they are missing. Pick the bands to watch: 12, 10, 6, 4 and 2 m. An MQTT chip in the status bar, beside the rig and amplifier, shows the feed is alive.",
|
||||
"Opening detection now covers 12 and 10 m, not just 6, 4 and 2 m. The feed was already subscribed to them while the detector still threw them away, so a busy 10 m could never be announced. An opening under way now shows as a blinking badge in the status bar, beside the clock, and stays there until the band goes quiet — the toast that announced it was gone in seconds, while an opening lasts hours.",
|
||||
"Test connection now actually tests. Club Log checked that the three fields were not empty and reported success without contacting anyone, so a wrong password looked exactly like a right one; it now signs in for real, through the read-only endpoint so a test can never add a record. LoTW reports its two credentials separately: TQSL signs uploads and never uses the website password, so a wrong one used to break nothing until the day confirmations were downloaded."
|
||||
"Test connection now actually tests. Club Log checked that the three fields were not empty and reported success without contacting anyone, so a wrong password looked exactly like a right one; it now signs in for real, through the read-only endpoint so a test can never add a record. LoTW reports its two credentials separately: TQSL signs uploads and never uses the website password, so a wrong one used to break nothing until the day confirmations were downloaded.",
|
||||
"Opening detection finds an opening through the everyday noise. It used to require every station heard to fit inside one 90° sector, which suits a sporadic-E cloud and nothing else: with 10 and 12 m open the reports come from all round the compass, so the busier the band, the less likely it was to be announced. It now looks for the busiest sector instead. The direction reported was also wrong whenever a sector crossed north — an opening to the NE was announced as SW, the exact opposite."
|
||||
],
|
||||
"fr": [
|
||||
"Décodes digitaux : le carré locator d une station pouvait être enregistré comme son indicatif quand le texte du message sortait de l ordinaire. Un grid à la place de l indicatif est maintenant refusé.",
|
||||
@@ -20,7 +21,8 @@
|
||||
"La détection d ouverture n ignore plus les longues distances. Elle plafonnait à 2400 km en supposant qu au-delà ce n était plus un saut simple ; l Es à sauts multiples est ordinaire sur 6 m, donc les ouvertures les plus intéressantes étaient précisément celles qu on jetait. C est toujours la direction qui tranche — un second saut repart dans le secteur où le premier est arrivé.",
|
||||
"La détection d ouverture dispose enfin des données qu il lui faut. Activer « Surveiller les ouvertures de bande » (Paramètres › Cluster DX) souscrit au flux PSK Reporter — toutes les stations en l air qui rapportent ce qu elles décodent, au lieu des quelques spots VHF que porte un cluster — et ajoute les deux nœuds RBN s ils manquent. Les bandes surveillées se choisissent : 12, 10, 6, 4 et 2 m. Une pastille MQTT dans la barre d état, à côté du rig et de l ampli, montre que le flux est vivant.",
|
||||
"La détection d ouverture couvre désormais le 12 et le 10 m, plus seulement le 6, 4 et 2 m. Le flux y était déjà abonné alors que le détecteur les jetait encore, donc un 10 m très actif ne pouvait jamais être annoncé. Une ouverture en cours s affiche désormais en pastille clignotante dans la barre d état, à côté de l heure, et y reste jusqu à ce que la bande se taise — le toast qui l annonçait disparaissait en quelques secondes, alors qu une ouverture dure des heures.",
|
||||
"Le bouton Tester la connexion teste vraiment. Club Log vérifiait que les trois champs n étaient pas vides et annonçait la réussite sans contacter personne : un mauvais mot de passe ressemblait exactement à un bon. Il s authentifie maintenant pour de vrai, via le point d accès en lecture seule pour qu un test ne puisse jamais ajouter un enregistrement. LoTW annonce ses deux identifiants séparément : TQSL signe les envois et n utilise jamais le mot de passe du site, donc un mauvais ne cassait rien jusqu au jour du téléchargement des confirmations."
|
||||
"Le bouton Tester la connexion teste vraiment. Club Log vérifiait que les trois champs n étaient pas vides et annonçait la réussite sans contacter personne : un mauvais mot de passe ressemblait exactement à un bon. Il s authentifie maintenant pour de vrai, via le point d accès en lecture seule pour qu un test ne puisse jamais ajouter un enregistrement. LoTW annonce ses deux identifiants séparément : TQSL signe les envois et n utilise jamais le mot de passe du site, donc un mauvais ne cassait rien jusqu au jour du téléchargement des confirmations.",
|
||||
"La détection d ouverture trouve désormais une ouverture au milieu du bruit ordinaire. Elle exigeait que toutes les stations entendues tiennent dans un même secteur de 90°, ce qui convient à un nuage d Es et à rien d autre : avec le 10 et le 12 m ouverts, les reports viennent de tout l horizon, donc plus la bande était ouverte moins elle pouvait être annoncée. Elle cherche maintenant le secteur le plus dense. La direction annoncée était par ailleurs fausse dès qu un secteur traversait le nord — une ouverture au NE était annoncée au SO, soit l opposé exact."
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -183,9 +183,20 @@ func evaluate(band string, spots []Spot, cfg Config) *Opening {
|
||||
dists = append(dists, s.DistKm)
|
||||
calls = append(calls, c)
|
||||
}
|
||||
lo, hi, spread := arc(bearings)
|
||||
if spread > cfg.BearingSpread {
|
||||
return nil // spots all round the compass — a busy band, not an opening
|
||||
// The DENSEST sector, not the sector covering everything.
|
||||
//
|
||||
// This used to demand that EVERY station fit inside one 90° arc, which is the
|
||||
// right shape for a sporadic-E cloud and hopeless for anything else. With 10
|
||||
// and 12 m open on F2 the reports come from all round the compass, the arc is
|
||||
// 360°, and the test can never pass — so the busier the band, the less likely
|
||||
// an opening was announced. Backwards.
|
||||
//
|
||||
// Finding the busiest 90° instead keeps the Es signature intact — a cloud
|
||||
// still makes one direction dense — and lets an F2 opening toward South
|
||||
// America be seen through the handful of Europeans that are always there.
|
||||
lo, hi, n := densestSector(bearings, cfg.BearingSpread)
|
||||
if n < cfg.MinCalls {
|
||||
return nil // nothing concentrated anywhere: a busy band, not an opening
|
||||
}
|
||||
sort.Ints(dists)
|
||||
sort.Strings(calls)
|
||||
@@ -200,6 +211,60 @@ func evaluate(band string, spots []Spot, cfg Config) *Opening {
|
||||
|
||||
// arc returns the smallest compass sector containing every bearing, coping with
|
||||
// the wrap at north: 350° and 10° are 20° apart, not 340°.
|
||||
// densestSector returns the width-degree arc containing the most bearings, as
|
||||
// its start, end and count.
|
||||
//
|
||||
// Brute force over each bearing as a starting edge. n is at most a few hundred
|
||||
// distinct callsigns in a twelve-minute window, so n² is nothing, and it runs
|
||||
// once per accepted spot rather than once per decode.
|
||||
//
|
||||
// The arc STARTS on a real bearing rather than sweeping every degree: the
|
||||
// densest window can always be slid until its leading edge sits on a station, so
|
||||
// nothing is missed and there are 360 fewer positions to try.
|
||||
func densestSector(b []int, width int) (lo, hi, count int) {
|
||||
if len(b) == 0 {
|
||||
return 0, 0, 0
|
||||
}
|
||||
s := append([]int(nil), b...)
|
||||
sort.Ints(s)
|
||||
best, bestAt := 0, 0
|
||||
for i, start := range s {
|
||||
n := 0
|
||||
for _, x := range s {
|
||||
// Distance clockwise from start to x, so the wrap through 0° needs no
|
||||
// special case — which is where the old arc() logic earned its keep and
|
||||
// this one has to match it.
|
||||
d := x - start
|
||||
if d < 0 {
|
||||
d += 360
|
||||
}
|
||||
if d <= width {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n > best {
|
||||
best, bestAt = n, i
|
||||
}
|
||||
}
|
||||
lo = s[bestAt]
|
||||
// The end is the furthest station actually inside the window, not lo+width:
|
||||
// reporting an empty 90° when every station sits in the first 20° would
|
||||
// overstate the opening's width by four times.
|
||||
hi = lo
|
||||
for _, x := range s {
|
||||
d := x - lo
|
||||
if d < 0 {
|
||||
d += 360
|
||||
}
|
||||
if d <= width {
|
||||
if e := (lo + d) % 360; d >= ((hi-lo)+360)%360 {
|
||||
hi = e
|
||||
}
|
||||
}
|
||||
}
|
||||
return lo, hi, best
|
||||
}
|
||||
|
||||
func arc(b []int) (lo, hi, spread int) {
|
||||
if len(b) == 0 {
|
||||
return 0, 0, 0
|
||||
@@ -253,10 +318,25 @@ func InSeason(band string, t time.Time, lat float64) bool {
|
||||
|
||||
// Sector renders the bearing range for a human, e.g. "NE (35–75°)".
|
||||
func (o *Opening) Sector() string {
|
||||
return compass(float64(o.BearingMin+o.BearingMax)/2) +
|
||||
return compass(midBearing(o.BearingMin, o.BearingMax)) +
|
||||
" (" + strconv.Itoa(o.BearingMin) + "–" + strconv.Itoa(o.BearingMax) + "°)"
|
||||
}
|
||||
|
||||
// midBearing is the middle of the arc running CLOCKWISE from min to max.
|
||||
//
|
||||
// Not the arithmetic mean, which is wrong for every sector crossing north and
|
||||
// wrong by the worst possible amount: an opening reported as 353–61° averaged to
|
||||
// 207° and was announced as SW when it was NE — the exact opposite direction, to
|
||||
// an operator who might turn a beam on it. The detector itself has handled the
|
||||
// 0/360 wrap since it was written; only this label did not.
|
||||
func midBearing(min, max int) float64 {
|
||||
span := max - min
|
||||
if span < 0 {
|
||||
span += 360
|
||||
}
|
||||
return math.Mod(float64(min)+float64(span)/2, 360)
|
||||
}
|
||||
|
||||
func compass(deg float64) string {
|
||||
names := []string{"N", "NE", "E", "SE", "S", "SW", "W", "NW"}
|
||||
i := int(math.Round(deg/45)) % 8
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package bandopen
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -225,3 +226,85 @@ func TestGroundWaveIsStillIgnored(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A sector crossing north must not be labelled by its opposite.
|
||||
//
|
||||
// Sector() averaged the two bearings arithmetically, so 353–61° — plainly north
|
||||
// — came out as (353+61)/2 = 207°, announced as SW. The worst possible error:
|
||||
// not vague, but exactly reversed, to an operator who may turn a beam on it.
|
||||
func TestSectorAcrossNorth(t *testing.T) {
|
||||
cases := []struct {
|
||||
min, max int
|
||||
want string
|
||||
}{
|
||||
{353, 61, "NE"}, // the one seen in the field
|
||||
{303, 11, "NW"}, // the other one
|
||||
{35, 75, "NE"}, // no wrap, unchanged
|
||||
{170, 190, "S"}, // due south, no wrap
|
||||
{340, 20, "N"}, // straddling north evenly
|
||||
{260, 300, "W"}, // due west
|
||||
}
|
||||
for _, c := range cases {
|
||||
o := Opening{BearingMin: c.min, BearingMax: c.max}
|
||||
got := o.Sector()
|
||||
if !strings.HasPrefix(got, c.want+" ") {
|
||||
t.Errorf("Sector(%d–%d°) = %q, want it to start with %q", c.min, c.max, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A band open in one direction must be found THROUGH the everyday noise.
|
||||
//
|
||||
// The detector used to demand that every station fit inside one 90° arc. On 10
|
||||
// and 12 m with F2 open, reports arrive from all round the compass, the arc is
|
||||
// 360°, and the test could never pass: the busier the band, the less likely an
|
||||
// opening was announced. Reported from the field — 70 000 decodes, 10 and 12 m
|
||||
// plainly open, nothing said.
|
||||
func TestOpeningFoundThroughAllRoundNoise(t *testing.T) {
|
||||
d := New(DefaultConfig())
|
||||
base := time.Date(2026, 8, 11, 14, 0, 0, 0, time.UTC)
|
||||
// Four stations concentrated to the south-west — the opening.
|
||||
south := []struct {
|
||||
call string
|
||||
deg int
|
||||
}{{"PY2AA", 220}, {"LU3BB", 228}, {"PY5CC", 235}, {"CX4DD", 240}}
|
||||
// And the usual Europeans scattered everywhere, which is what used to veto it.
|
||||
noise := []struct {
|
||||
call string
|
||||
deg int
|
||||
}{{"DL1XX", 40}, {"SM2YY", 20}, {"EA3ZZ", 190}, {"UA9QQ", 70}, {"G4WW", 320}}
|
||||
|
||||
var got *Opening
|
||||
for i, s := range noise {
|
||||
d.Add(Spot{Call: s.call, Band: "10m", DistKm: 1200, Bearing: s.deg,
|
||||
At: base.Add(time.Duration(i) * time.Second)}, 47.0)
|
||||
}
|
||||
for i, s := range south {
|
||||
if op := d.Add(Spot{Call: s.call, Band: "10m", DistKm: 9800, Bearing: s.deg,
|
||||
At: base.Add(time.Duration(10+i) * time.Second)}, 47.0); op != nil {
|
||||
got = op
|
||||
}
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("an opening concentrated to the SW was not found among stations all round the compass")
|
||||
}
|
||||
mid := midBearing(got.BearingMin, got.BearingMax)
|
||||
if mid < 200 || mid > 260 {
|
||||
t.Errorf("sector middle %.0f° — the reported sector is not the busy one (%d–%d°)",
|
||||
mid, got.BearingMin, got.BearingMax)
|
||||
}
|
||||
}
|
||||
|
||||
// Stations evenly all round the compass and nothing concentrated: still nothing.
|
||||
// The point of the change was to stop requiring global agreement, not to start
|
||||
// calling every busy band an opening.
|
||||
func TestScatteredBandIsNotAnOpening(t *testing.T) {
|
||||
d := New(DefaultConfig())
|
||||
base := time.Date(2026, 8, 11, 14, 0, 0, 0, time.UTC)
|
||||
for i, deg := range []int{0, 60, 120, 180, 240, 300} {
|
||||
if op := d.Add(Spot{Call: string(rune('A'+i)) + "1AAA", Band: "10m",
|
||||
DistKm: 3000, Bearing: deg, At: base.Add(time.Duration(i) * time.Second)}, 47.0); op != nil {
|
||||
t.Fatalf("evenly scattered stations were called an opening: %+v", op)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user