From 84d1ab140148998d57951692669d0d7093270178 Mon Sep 17 00:00:00 2001 From: Ryan McGuire Date: Fri, 26 Jun 2026 22:08:28 -0400 Subject: [PATCH] add position filter --- pkg/types/readsb/filter.go | 23 +++++++++++++++++++++++ pkg/types/readsb/readsb_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/pkg/types/readsb/filter.go b/pkg/types/readsb/filter.go index 0d676da..23d3d47 100644 --- a/pkg/types/readsb/filter.go +++ b/pkg/types/readsb/filter.go @@ -2,6 +2,7 @@ package readsb import ( "iter" + "math" "slices" "strings" "time" @@ -141,6 +142,28 @@ func WithinNM(nm float64) AircraftFilter { return func(a *Aircraft) bool { return a.RDst > 0 && a.RDst <= nm } } +// WithinNMOf keeps aircraft whose current position is within nm nautical miles +// of the given point (decimal degrees). Unlike WithinNM, which measures from the +// receiver, this measures from an arbitrary point; aircraft with no position are +// dropped. +func WithinNMOf(lat, lon, nm float64) AircraftFilter { + return func(a *Aircraft) bool { + return a.HasPosition() && haversineNM(lat, lon, a.Lat, a.Lon) <= nm + } +} + +// haversineNM returns the great-circle distance in nautical miles between two +// points given in decimal degrees. +func haversineNM(lat1, lon1, lat2, lon2 float64) float64 { + const earthRadiusNM = 3440.065 // mean Earth radius in nautical miles + dLat, dLon := rad(lat2-lat1), rad(lon2-lon1) + h := math.Sin(dLat/2)*math.Sin(dLat/2) + + math.Cos(rad(lat1))*math.Cos(rad(lat2))*math.Sin(dLon/2)*math.Sin(dLon/2) + return earthRadiusNM * 2 * math.Asin(math.Sqrt(h)) +} + +func rad(deg float64) float64 { return deg * math.Pi / 180 } + // SeenWithin keeps aircraft heard from within the given duration. func SeenWithin(d time.Duration) AircraftFilter { return func(a *Aircraft) bool { return a.SeenFor() <= d } diff --git a/pkg/types/readsb/readsb_test.go b/pkg/types/readsb/readsb_test.go index 7ba0bf3..9909526 100644 --- a/pkg/types/readsb/readsb_test.go +++ b/pkg/types/readsb/readsb_test.go @@ -88,6 +88,31 @@ func TestAircraftFilters(t *testing.T) { } } +func TestWithinNMOf(t *testing.T) { + // One degree of latitude is ~60 nm; sanity-check the haversine helper. + if d := haversineNM(0, 0, 1, 0); d < 59 || d > 61 { + t.Errorf("haversineNM(1 deg lat) = %.2f nm, want ~60", d) + } + r := decodeFile[AircraftReport](t, "aircraft.json") + var lat, lon float64 + for _, a := range r.Aircraft { + if a.HasPosition() { + lat, lon = a.Lat, a.Lon + break + } + } + near := r.Filter(WithinNMOf(lat, lon, 100)) + for _, a := range near { + if !a.HasPosition() || haversineNM(lat, lon, a.Lat, a.Lon) > 100 { + t.Errorf("WithinNMOf kept %s at %.1f nm", a.Hex, haversineNM(lat, lon, a.Lat, a.Lon)) + } + } + // The point came from a real aircraft, so at least that one must match. + if len(near) == 0 { + t.Error("WithinNMOf returned no aircraft for an in-data point") + } +} + func TestAltBaroGround(t *testing.T) { var a AltBaro if err := json.Unmarshal([]byte(`"ground"`), &a); err != nil {