Initial import

This commit is contained in:
2026-02-14 15:59:31 +01:00
parent 05dcea3c2b
commit f1ecbfaf8d
19 changed files with 2675 additions and 0 deletions

10
util/maidenhead/doc.go Normal file
View File

@@ -0,0 +1,10 @@
// Package maidenhead implements the maidenhead locator system.
//
// The Maidenhead Locator System (a.k.a. QTH Locator and IARU Locator) is a geocode system used
// by amateur radio operators to succinctly describe their geographic coordinates, which replaced
// the deprecated QRA locator, which was limited to European contacts.
//
// Maidenhead locators are also commonly referred to as QTH locators, grid locators or grid
// squares, although the "squares" are distorted on any non-equirectangular cartographic
// projection.
package maidenhead

View File

@@ -0,0 +1,148 @@
// Package maidenhead implements the Maidenhead Locator System, a geographic
// coordinate system used by amataur radio (HAM) operators.
package maidenhead
import (
"errors"
"fmt"
"math"
"strings"
)
// Precision of the computed locator.
const (
FieldPrecision = iota + 1
SquarePrecision
SubSquarePrecision
ExtendedSquarePrecision
)
var (
upper = "ABCDEFGHIJKLMNOPQRSTUVWX"
lower = "abcdefghijklmnopqrstuvwx"
digit = "0123456789"
)
// locator computes the Maidenhead Locator for a given position.
func locator(p Point, precision int) (string, error) {
if math.IsNaN(p.Latitude) {
return "", errors.New("maidenhead: latitude is not a digit")
}
if math.IsInf(p.Latitude, 0) {
return "", errors.New("maidenhead: latitude is infinite")
}
if math.IsNaN(p.Longitude) {
return "", errors.New("maidenhead: longitude is not a digit")
}
if math.IsInf(p.Longitude, 0) {
return "", errors.New("maidenhead: longitude is infinite")
}
if math.Abs(p.Latitude) == 90.0 {
return "", errors.New("maidenhead: grid square invalid at poles")
} else if math.Abs(p.Latitude) > 90.0 {
return "", fmt.Errorf("maidenhead: invalid latitude %.04f", p.Latitude)
} else if math.Abs(p.Longitude) > 180.0 {
return "", fmt.Errorf("maidenhead: invalid longitude %.05f", p.Longitude)
}
var (
lat = p.Latitude + 90.0
lng = p.Longitude + 180.0
loc string
)
lat = lat/10.0 + 0.0000001
lng = lng/20.0 + 0.0000001
loc = loc + string(upper[int(lng)]) + string(upper[int(lat)])
if precision == 1 {
return loc, nil
}
lat = 10 * (lat - math.Floor(lat))
lng = 10 * (lng - math.Floor(lng))
loc = loc + fmt.Sprintf("%d%d", int(lng)%10, int(lat)%10)
if precision == 2 {
return loc, nil
}
lat = 24 * (lat - math.Floor(lat))
lng = 24 * (lng - math.Floor(lng))
loc = loc + string(upper[int(lng)]) + string(upper[int(lat)])
if precision == 3 {
return loc, nil
}
lat = 10 * (lat - math.Floor(lat))
lng = 10 * (lng - math.Floor(lng))
loc = loc + fmt.Sprintf("%d%d", int(lng)%10, int(lat)%10)
if precision == 4 {
return loc, nil
}
lat = 24 * (lat - math.Floor(lat))
lng = 24 * (lng - math.Floor(lng))
loc = loc + string(lower[int(lng)]) + string(lower[int(lat)])
return loc, nil
}
var parseLocatorMult = []struct {
s, p string
mult float64
}{
{upper[:18], lower[:18], 20.0},
{upper[:18], lower[:18], 10.0},
{digit[:10], digit[:10], 20.0 / 10.0},
{digit[:10], digit[:10], 10.0 / 10.0},
{upper[:24], lower[:24], 20.0 / (10.0 * 24.0)},
{upper[:24], lower[:24], 10.0 / (10.0 * 24.0)},
{digit[:10], digit[:10], 20.0 / (10.0 * 24.0 * 10.0)},
{digit[:10], digit[:10], 10.0 / (10.0 * 24.0 * 10.0)},
{lower[:24], lower[:24], 20.0 / (10.0 * 24.0 * 10.0 * 24.0)},
{lower[:24], lower[:24], 10.0 / (10.0 * 24.0 * 10.0 * 24.0)},
}
var maxLocatorLength = len(parseLocatorMult)
func parseLocator(locator string, strict bool, centered bool) (point Point, err error) {
var (
lnglat = [2]float64{
-180.0,
-90.0,
}
i, j int
char rune
)
if len(locator) > maxLocatorLength {
err = fmt.Errorf("maidenhead: locator is too long (%d characters, maximum %d characters allowed)",
len(locator), maxLocatorLength)
return
}
if len(locator)%2 != 0 {
err = fmt.Errorf("maidenhead: locator has odd number of characters")
return
}
if strict {
for i, char = range locator {
if j = strings.Index(parseLocatorMult[i].s, string(char)); j < 0 {
err = fmt.Errorf("maidenhead: invalid character at offset %d", i)
return
}
lnglat[i%2] += float64(j) * parseLocatorMult[i].mult
}
} else {
for i, char = range strings.ToLower(locator) {
if j = strings.Index(parseLocatorMult[i].p, string(char)); j < 0 {
err = fmt.Errorf("maidenhead: invalid character at offset %d", i)
return
}
lnglat[i%2] += float64(j) * parseLocatorMult[i].mult
}
}
if centered {
lnglat[0] += parseLocatorMult[i-1].mult / 2.0
lnglat[1] += parseLocatorMult[i].mult / 2.0
}
point = NewPoint(lnglat[1], lnglat[0])
return
}

View File

@@ -0,0 +1,142 @@
package maidenhead
import (
"math"
"strings"
"testing"
)
var tests = []struct {
point Point
loc string
loc4 string
}{
{Point{48.14666, 11.60833}, "JN58TD", "JN58TD25"},
{Point{-34.91, -56.21166}, "GF15VC", "GF15VC41"},
{Point{38.92, -77.065}, "FM18LW", "FM18LW20"},
{Point{-41.28333, 174.745}, "RE78IR", "RE78IR92"},
{Point{41.714775, -72.727260}, "FN31PR", "FN31PR21"},
{Point{37.413708, -122.1073236}, "CM87WJ", "CM87WJ79"},
{Point{35.0542, -85.1142}, "EM75KB", "EM75KB63"},
}
func TestGridSquare(t *testing.T) {
for _, test := range tests {
t.Run(test.loc, func(t *testing.T) {
enc, err := test.point.GridSquare()
if err != nil {
t.Fatal(err)
}
if enc != test.loc {
t.Fatalf("%s want %q, got %q\n", test.point, test.loc, enc)
}
//t.Logf("%s encoded to %q\n", test.point, enc)
})
}
}
func TestExtendedSquarePrecision(t *testing.T) {
for _, test := range tests {
t.Run(test.loc4, func(t *testing.T) {
got, err := test.point.Locator(ExtendedSquarePrecision)
if err != nil {
t.Fatal(err)
}
if got != test.loc4 {
t.Fatalf("%s want %q, got %q\n", test.point, test.loc4, got)
}
//t.Logf("%s encoded to %q\n", test.point, got)
})
}
}
// parsed locator must be translated to the same locator
// using GridSquare()
func TestParseLocator(t *testing.T) {
var locTests = map[string]Point{
"JN88RT": Point{48.791667, 17.416667},
"JN89HF": Point{49.208333, 16.583333},
"JN58TD": Point{48.125000, 11.583333},
"GF15VC": Point{-34.916667, -56.250000},
"FM18LW": Point{38.916667, -77.083333},
"RE78IR": Point{-41.291667, 174.666667},
"PM45lm": Point{35.5, 128.916667},
}
for loc, p := range locTests {
t.Run(loc, func(t *testing.T) {
point, err := ParseLocator(loc)
if err != nil {
t.Fatalf("%s parsing error: %s", loc, err)
}
l, err := point.GridSquare()
if err != nil {
t.Fatalf("%s: %v to GridSquare(): %s", loc, point, err)
}
if !strings.EqualFold(l, loc) {
t.Errorf("%s: parsed to %v produces %s\n", loc, point, l)
}
if !(almostEqual(p.Latitude, point.Latitude) && almostEqual(p.Longitude, point.Longitude)) {
t.Errorf("%s: at %s, expeted %s", loc, point, p)
}
})
}
}
func almostEqual(a, b float64) bool {
return math.Abs(a-b) < 1e-06
}
// invalid Maiden Head locators must return error
func TestParseInvalidLocatorStrict(t *testing.T) {
locs := []string{
"JN58td",
"JN58TDAA",
"JNH",
"QN58jh",
"JN77ya",
" ",
"JN55J",
"JN89HA11aa2",
"JN89HA11aa22",
}
for _, l := range locs {
t.Run(l, func(t *testing.T) {
_, err := ParseLocatorStrict(l)
if err == nil {
t.Errorf("Parsing invalid locator '%s' with ParseLocatorStrict() doesn't return any error", l)
}
})
}
}
// Distance between corner point and center of the locator square
func TestParseLocatorCentered(t *testing.T) {
tests := []struct {
loc string
distExpected float64
}{
{"JN89", 91.42870273454076},
{"JN89HF", 3.8111046375990782},
{"JN89HF23", 0.38109528459829756},
{"JN89HF23ag", 0.015878904160500258},
}
for _, test := range tests {
t.Run(test.loc, func(t *testing.T) {
p, _ := ParseLocator(test.loc)
pc, _ := ParseLocatorCentered(test.loc)
dist := pc.Distance(p)
if !almostEqual(dist, test.distExpected) {
t.Errorf("Distance between the center and corner of square locator '%s' is %g, expected %g",
test.loc, dist, test.distExpected)
}
})
}
}

162
util/maidenhead/point.go Normal file
View File

@@ -0,0 +1,162 @@
package maidenhead
import (
"fmt"
"math"
)
const (
// Earth radius
r = 6371
)
var compassBearing = []struct {
label string
start, ended float64
}{
{"N", 000.00, 011.25}, {"NNE", 011.25, 033.75}, {"NE", 033.75, 056.25}, {"ENE", 056.25, 078.75},
{"E", 078.75, 101.25}, {"ESE", 101.25, 123.75}, {"SE", 123.75, 146.25}, {"SSE", 146.25, 168.75},
{"S", 168.75, 191.25}, {"SSW", 191.25, 213.75}, {"SW", 213.75, 236.25}, {"WSW", 236.25, 258.75},
{"W", 258.75, 281.25}, {"WNW", 281.25, 303.75}, {"NW", 303.75, 326.25}, {"NNW", 326.25, 348.75},
{"N", 348.75, 360.00},
}
// Point is a geographical point on the map.
type Point struct {
Latitude float64
Longitude float64
}
// NewPoint returns a new Point structure with given latitude and longitude.
func NewPoint(latitude, longitude float64) Point {
return Point{latitude, longitude}
}
// ParseLocator parses a Maidenhead Locator with permissive rule matching.
func ParseLocator(locator string) (Point, error) {
return parseLocator(locator, false, false)
}
// ParseLocatorStrict parses a Maidenhead Locator with strict rule matching.
func ParseLocatorStrict(locator string) (Point, error) {
return parseLocator(locator, true, false)
}
// ParseLocatorCentered parses a Maidenhead Locator with permissive rule matching.
// Returns Points structure with coordinates of the square center
func ParseLocatorCentered(locator string) (Point, error) {
return parseLocator(locator, false, true)
}
// ParseLocatorStrictCentered parses a Maidenhead Locator with strict rule matching.
// Returns Points structure with coordinates of the square center
func ParseLocatorStrictCentered(locator string) (Point, error) {
return parseLocator(locator, true, true)
}
// EqualTo returns true if the coordinates point to the same geographical location.
func (p Point) EqualTo(other Point) bool {
var (
dlat = p.Latitude - other.Latitude
dlng = p.Longitude - other.Longitude
)
for dlat < -180.0 {
dlat += 360.0
}
for dlat > 180.0 {
dlat -= 360.0
}
for dlng < -90.0 {
dlng += 90.0
}
for dlng > 90.0 {
dlng -= 90.0
}
return dlat == 0.0 && dlng == 0.0
}
// Bearing calculates the (approximate) bearing to another heading.
func (p Point) Bearing(heading Point) float64 {
var (
hn = p.Latitude / 180 * math.Pi
he = p.Longitude / 180 * math.Pi
n = heading.Latitude / 180 * math.Pi
e = heading.Longitude / 180 * math.Pi
co = math.Cos(he-e)*math.Cos(hn)*math.Cos(n) + math.Sin(hn)*math.Sin(n)
ca = math.Atan(math.Abs(math.Sqrt(1-co*co) / co))
)
if co < 0.0 {
ca = math.Pi - ca
}
var si = math.Sin(e-he) * math.Cos(n) * math.Cos(hn)
co = math.Sin(n) - math.Sin(hn)*math.Cos(ca)
var az = math.Atan(math.Abs(si / co))
if co < 0.0 {
az = math.Pi - az
}
if si < 0.0 {
az = -az
}
if az < 0.0 {
az = az + 2.0*math.Pi
}
return az * 180 / math.Pi
}
// CompassBearing returns the compass bearing to a heading.
func (p Point) CompassBearing(heading Point) string {
bearing := p.Bearing(heading)
for bearing < 0.0 {
bearing += 360.0
}
for bearing > 360.0 {
bearing -= 360.0
}
for _, compass := range compassBearing {
if bearing >= compass.start && bearing <= compass.ended {
return compass.label
}
}
// Should never reach
return ""
}
// Distance calculates the (approximate) distance to another point in km.
func (p Point) Distance(other Point) float64 {
var (
hn = p.Latitude / 180 * math.Pi
he = p.Longitude / 180 * math.Pi
n = other.Latitude / 180 * math.Pi
e = other.Longitude / 180 * math.Pi
co = math.Cos(he-e)*math.Cos(hn)*math.Cos(n) + math.Sin(hn)*math.Sin(n)
ca = math.Atan(math.Abs(math.Sqrt(1-co*co) / co))
)
if co < 0.0 {
ca = math.Pi - ca
}
return r * ca
}
// GridSquare returns a Maidenhead Locator for the point coordinates.
func (p Point) GridSquare() (string, error) {
return locator(p, SubSquarePrecision)
}
// Locator returns a Maidenhead Locator for the point coordinates with
// specified precision
func (p Point) Locator(precision int) (string, error) {
return locator(p, precision)
}
// String returns a stringified Point structure.
func (p Point) String() string {
return fmt.Sprintf("Point(%f, %f)", p.Latitude, p.Longitude)
}

View File

@@ -0,0 +1,37 @@
package maidenhead
import (
"math"
"testing"
)
var pointTests = []struct {
point Point
bearing float64
compass string
}{
{Point{48.14666, 11.60833}, 195, "SSW"},
{Point{-34.91, -56.21166}, 69, "ENE"},
{Point{38.92, -77.065}, 98, "E"},
{Point{-41.28333, 174.745}, 187, "S"},
{Point{41.714775, -72.727260}, 101, "ESE"},
{Point{37.413708, -122.1073236}, 69, "ENE"},
{Point{35.0542, -85.1142}, 92, "E"},
}
func TestPointBearing(t *testing.T) {
var center = NewPoint(0, 0)
for _, test := range pointTests {
t.Run("", func(t *testing.T) {
bearing := math.Floor(test.point.Bearing(center))
if bearing != test.bearing {
t.Fatalf("%s -> %s, expected %0.f, got %0.f\n", test.point, center, test.bearing, bearing)
}
compass := test.point.CompassBearing(center)
if compass != test.compass {
t.Logf("%s -> %s, expected %q, got %q\n", test.point, center, test.compass, compass)
}
//t.Logf("%s -> %s, bearing %.0f %s\n", test.point, center, bearing, compass)
})
}
}