// Package bandopen spots a band OPENING in the cluster stream — sporadic-E on // 6 m, 4 m and 2 m above all. // // This is observation, not prediction. Every spot OpsLog receives is already // enriched with the great-circle distance and bearing from the operator's own // grid, so the signature of a single-hop Es opening is directly measurable: // several distinct stations appearing on a VHF band, all at single-hop range, // all in the same bearing sector, within a few minutes. That combination does // not happen by chance — scattered spots at random distances and bearings are // just a busy band. // // The season is REPORTED, never used to suppress. Es peaks in late spring and // summer, so an opening in November is unusual — and an unusual opening is // precisely the one an operator must not be told about last. InSeason only // labels the announcement. package bandopen import ( "math" "sort" "strconv" "strings" "time" ) // Spot is the little a detection needs, taken from an enriched cluster spot. type Spot struct { Call string Band string DistKm int Bearing int // degrees from the operator, short path At time.Time } // Config tunes the detector. The defaults describe single-hop sporadic E. type Config struct { Window time.Duration // how far back a burst may span MinCalls int // distinct DX calls before it counts as an opening MinKm, MaxKm int // single-hop Es range BearingSpread int // widest arc (degrees) the spots may cover Requiet time.Duration // silence after announcing a band, so it is announced once } // DefaultConfig is the single-hop Es envelope. // // 500–2400 km: below ~500 km a 6 m contact is ordinary tropo or ground wave and // says nothing about the ionosphere; beyond ~2400 km it is no longer one hop, so // the bearing test stops meaning anything. 90° of spread because a genuine Es // cloud illuminates a sector, not the whole horizon — the constraint that // separates an opening from a merely busy evening. func DefaultConfig() Config { return Config{ Window: 12 * time.Minute, MinCalls: 4, MinKm: 500, MaxKm: 2400, BearingSpread: 90, Requiet: 45 * time.Minute, } } // Bands watched. HF is deliberately absent: an "opening" on 20 m is the normal // state of the band and announcing it would be noise. var watched = map[string]bool{"6m": true, "4m": true, "2m": true} // Watched reports whether a band is one the detector looks at. func Watched(band string) bool { return watched[strings.ToLower(strings.TrimSpace(band))] } // Opening is a detected opening, ready to be announced. type Opening struct { Band string `json:"band"` Calls int `json:"calls"` // distinct DX stations seen MedianKm int `json:"median_km"` // typical hop length BearingMin int `json:"bearing_min"` // sector, degrees BearingMax int `json:"bearing_max"` InSeason bool `json:"in_season"` // false = unusual for the time of year At time.Time `json:"at"` Examples []string `json:"examples"` // a few callsigns, for the announcement } // Detector keeps the rolling window and the per-band quiet period. type Detector struct { cfg Config recent []Spot lastFire map[string]time.Time } func New(cfg Config) *Detector { if cfg.Window <= 0 { cfg = DefaultConfig() } return &Detector{cfg: cfg, lastFire: map[string]time.Time{}} } // Add records a spot and returns an Opening when this spot completes one. // // Returns nil far more often than not; that is the point. lat is the operator's // latitude, for the hemisphere the season depends on. func (d *Detector) Add(s Spot, lat float64) *Opening { if !Watched(s.Band) { return nil } band := strings.ToLower(strings.TrimSpace(s.Band)) s.Band = band if s.At.IsZero() { s.At = time.Now() } // Out-of-range spots are dropped rather than stored: they can never be part // of a single-hop detection, and keeping them only grows the window. if s.DistKm < d.cfg.MinKm || s.DistKm > d.cfg.MaxKm { return nil } d.recent = append(d.recent, s) d.prune(s.At) if last, ok := d.lastFire[band]; ok && s.At.Sub(last) < d.cfg.Requiet { return nil // already announced this band recently } inBand := make([]Spot, 0, len(d.recent)) for _, r := range d.recent { if r.Band == band { inBand = append(inBand, r) } } op := evaluate(band, inBand, d.cfg) if op == nil { return nil } op.At = s.At op.InSeason = InSeason(band, s.At, lat) d.lastFire[band] = s.At return op } func (d *Detector) prune(now time.Time) { cut := now.Add(-d.cfg.Window) keep := d.recent[:0] for _, r := range d.recent { if r.At.After(cut) { keep = append(keep, r) } } d.recent = keep } // evaluate decides whether a band's recent spots look like one opening. func evaluate(band string, spots []Spot, cfg Config) *Opening { // Distinct callsigns, not spot count: one station spotted by six skimmers is // six spots and one station, and it is not an opening. seen := map[string]Spot{} for _, s := range spots { c := strings.ToUpper(strings.TrimSpace(s.Call)) if c == "" { continue } if _, dup := seen[c]; !dup { seen[c] = s } } if len(seen) < cfg.MinCalls { return nil } bearings := make([]int, 0, len(seen)) dists := make([]int, 0, len(seen)) calls := make([]string, 0, len(seen)) for c, s := range seen { bearings = append(bearings, ((s.Bearing%360)+360)%360) 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 } sort.Ints(dists) sort.Strings(calls) if len(calls) > 5 { calls = calls[:5] } return &Opening{ Band: band, Calls: len(seen), MedianKm: dists[len(dists)/2], BearingMin: lo, BearingMax: hi, Examples: calls, } } // arc returns the smallest compass sector containing every bearing, coping with // the wrap at north: 350° and 10° are 20° apart, not 340°. func arc(b []int) (lo, hi, spread int) { if len(b) == 0 { return 0, 0, 0 } s := append([]int(nil), b...) sort.Ints(s) // The widest GAP between consecutive bearings (round the circle) is the part // NOT covered; the sector is everything else. worst, at := -1, 0 for i := range s { next := s[(i+1)%len(s)] gap := next - s[i] if i == len(s)-1 { gap = next + 360 - s[i] } if gap > worst { worst, at = gap, i } } lo = s[(at+1)%len(s)] hi = s[at] spread = 360 - worst return lo, hi, spread } // InSeason reports whether the time of year is one where sporadic E is common // at the operator's latitude. // // Each hemisphere has a strong summer peak AND a smaller winter one, and both // count as expected: a December opening in Europe surprises nobody. What the // label marks is the genuinely odd month — an equinox opening. // // This LABELS a detection, it never gates one. Out-of-season Es exists, and it // is precisely the opening an operator must not be told about last. func InSeason(band string, t time.Time, lat float64) bool { m := t.UTC().Month() var months map[time.Month]bool if lat >= 0 { months = map[time.Month]bool{ time.May: true, time.June: true, time.July: true, time.August: true, // main time.December: true, time.January: true, // lesser winter peak } } else { months = map[time.Month]bool{ time.November: true, time.December: true, time.January: true, time.February: true, time.June: true, time.July: true, } } return months[m] } // 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) + " (" + strconv.Itoa(o.BearingMin) + "–" + strconv.Itoa(o.BearingMax) + "°)" } func compass(deg float64) string { names := []string{"N", "NE", "E", "SE", "S", "SW", "W", "NW"} i := int(math.Round(deg/45)) % 8 if i < 0 { i += 8 } return names[i] }