184 lines
5.5 KiB
Go
184 lines
5.5 KiB
Go
// Package geo is the one place that turns Maidenhead locators into positions,
|
|
// and positions into distances and bearings.
|
|
//
|
|
// It exists because there were about to be three copies. These functions lived
|
|
// in package main, which the internal packages cannot import, so the PSK
|
|
// Reporter watcher had its geometry injected from main and the web publisher
|
|
// was about to grow its own. A bearing that disagrees with itself between two
|
|
// panels is the kind of fault nobody reports, because each screen looks
|
|
// plausible on its own.
|
|
package geo
|
|
|
|
import (
|
|
"math"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// GridToLatLon parses a Maidenhead locator (4 or 6 characters) and returns the
|
|
// centre of that square in degrees. ok=false on malformed input.
|
|
func GridToLatLon(grid string) (lat, lon float64, ok bool) {
|
|
g := strings.ToUpper(strings.TrimSpace(grid))
|
|
if len(g) < 4 {
|
|
return 0, 0, false
|
|
}
|
|
A := g[0] - 'A'
|
|
B := g[1] - 'A'
|
|
C := g[2] - '0'
|
|
D := g[3] - '0'
|
|
if A > 17 || B > 17 || C > 9 || D > 9 {
|
|
return 0, 0, false
|
|
}
|
|
lon = -180 + float64(A)*20 + float64(C)*2
|
|
lat = -90 + float64(B)*10 + float64(D)*1
|
|
if len(g) >= 6 {
|
|
E := g[4] - 'A'
|
|
F := g[5] - 'A'
|
|
if E <= 23 && F <= 23 {
|
|
lon += float64(E)*(5.0/60.0) + 2.5/60.0
|
|
lat += float64(F)*(2.5/60.0) + 1.25/60.0
|
|
return lat, lon, true
|
|
}
|
|
}
|
|
// 4-character locator: aim at the centre of the square.
|
|
lon += 1
|
|
lat += 0.5
|
|
return lat, lon, true
|
|
}
|
|
|
|
// HaversineKm returns the great-circle distance between two positions in
|
|
// kilometres. Mean Earth radius 6371 km.
|
|
func HaversineKm(lat1, lon1, lat2, lon2 float64) float64 {
|
|
const R = 6371.0
|
|
rad := math.Pi / 180.0
|
|
dLat := (lat2 - lat1) * rad
|
|
dLon := (lon2 - lon1) * rad
|
|
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
|
|
math.Cos(lat1*rad)*math.Cos(lat2*rad)*math.Sin(dLon/2)*math.Sin(dLon/2)
|
|
return R * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
|
|
}
|
|
|
|
// DistanceBetweenGrids is the distance in kilometres between two locators,
|
|
// ok=false when either cannot be parsed.
|
|
func DistanceBetweenGrids(a, b string) (km float64, ok bool) {
|
|
lat1, lon1, ok1 := GridToLatLon(a)
|
|
lat2, lon2, ok2 := GridToLatLon(b)
|
|
if !ok1 || !ok2 {
|
|
return 0, false
|
|
}
|
|
return HaversineKm(lat1, lon1, lat2, lon2), true
|
|
}
|
|
|
|
// LatLonToGrid returns the 4-character Maidenhead square for a position.
|
|
func LatLonToGrid(lat, lon float64) string {
|
|
lon = math.Mod(lon+180, 360)
|
|
if lon < 0 {
|
|
lon += 360
|
|
}
|
|
lat = lat + 90
|
|
if lat < 0 {
|
|
lat = 0
|
|
} else if lat > 180 {
|
|
lat = 180
|
|
}
|
|
return string([]byte{
|
|
byte('A' + int(lon/20)),
|
|
byte('A' + int(lat/10)),
|
|
byte('0' + int(math.Mod(lon, 20)/2)),
|
|
byte('0' + int(math.Mod(lat, 10)/1)),
|
|
})
|
|
}
|
|
|
|
// NeighbourGrids returns the square holding (lat, lon) and the ring of squares
|
|
// around it — 9 squares for ring 1, 25 for ring 2.
|
|
//
|
|
// Used to filter the PSK Reporter feed at the BROKER rather than in OpsLog. A
|
|
// square is about 111 km tall and 150 km wide at mid latitudes, so one ring is
|
|
// roughly the 300 km "around here" the feed already meant, and the traffic that
|
|
// used to be received and discarded is never sent.
|
|
func NeighbourGrids(lat, lon float64, ring int) []string {
|
|
if ring < 0 {
|
|
ring = 0
|
|
}
|
|
seen := map[string]bool{}
|
|
out := []string{}
|
|
for dLat := -ring; dLat <= ring; dLat++ {
|
|
for dLon := -ring; dLon <= ring; dLon++ {
|
|
// One square step: 1° of latitude, 2° of longitude.
|
|
la := lat + float64(dLat)
|
|
lo := lon + float64(dLon)*2
|
|
if la > 90 || la < -90 {
|
|
continue // past a pole there is no square, not a wrapped one
|
|
}
|
|
g := LatLonToGrid(la, lo)
|
|
if !seen[g] {
|
|
seen[g] = true
|
|
out = append(out, g)
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// GridsWithin returns every Maidenhead square whose centre lies within km of
|
|
// (lat, lon), nearest first, at most max of them.
|
|
//
|
|
// NeighbourGrids answers "the ring around here", which is the right shape for a
|
|
// few hundred kilometres and the wrong one past that: a ring is a square, so
|
|
// asking for 2000 km through it means 1369 squares, most of them further away
|
|
// than the ones it left out. This measures instead, and the count then follows
|
|
// the AREA asked for rather than the corner of a box.
|
|
//
|
|
// Nearest first because the caller has to be able to trim: these become one
|
|
// broker subscription each, and when there are more than can be afforded, the
|
|
// squares to keep are the close ones.
|
|
func GridsWithin(lat, lon, km float64, max int) []string {
|
|
if km <= 0 || max <= 0 {
|
|
return nil
|
|
}
|
|
// One square is 1° of latitude and 2° of longitude. Sweep a box big enough
|
|
// to hold the circle — a degree of latitude is ~111 km everywhere, and a
|
|
// degree of longitude never MORE than that, so this cannot cut the circle.
|
|
steps := int(km/111.0) + 1
|
|
type cand struct {
|
|
grid string
|
|
d float64
|
|
}
|
|
seen := map[string]bool{}
|
|
out := []cand{}
|
|
for dLat := -steps; dLat <= steps; dLat++ {
|
|
for dLon := -2 * steps; dLon <= 2*steps; dLon++ {
|
|
la := lat + float64(dLat)
|
|
lo := lon + float64(dLon)*2
|
|
if la > 90 || la < -90 {
|
|
continue
|
|
}
|
|
g := LatLonToGrid(la, lo)
|
|
if seen[g] {
|
|
continue
|
|
}
|
|
// Measured to the square's own centre, not to the sample point, so
|
|
// two samples landing in one square agree about how far it is.
|
|
cLat, cLon, ok := GridToLatLon(g)
|
|
if !ok {
|
|
continue
|
|
}
|
|
d := HaversineKm(lat, lon, cLat, cLon)
|
|
if d > km {
|
|
continue
|
|
}
|
|
seen[g] = true
|
|
out = append(out, cand{g, d})
|
|
}
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].d < out[j].d })
|
|
if len(out) > max {
|
|
out = out[:max]
|
|
}
|
|
grids := make([]string, len(out))
|
|
for i, c := range out {
|
|
grids[i] = c.grid
|
|
}
|
|
return grids
|
|
}
|