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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user