initial commit
This commit is contained in:
commit
1253955070
75 changed files with 13972 additions and 0 deletions
92
api/internal/geo/geo.go
Normal file
92
api/internal/geo/geo.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
// Package geo provides the geographic distance calculations used to decide
|
||||
// whether two pictures were taken at the same place.
|
||||
package geo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
|
||||
"go-places-api/internal/domain"
|
||||
)
|
||||
|
||||
// EarthRadiusMeters is the mean radius of the WGS-84 ellipsoid.
|
||||
const EarthRadiusMeters = 6371008.8
|
||||
|
||||
var ErrInvalidCoordinate = errors.New("invalid coordinate")
|
||||
|
||||
// DistanceMeters returns the great-circle distance between two points using the
|
||||
// haversine formula. It is accurate to roughly 0.5% which is far finer than the
|
||||
// radii this application compares against.
|
||||
func DistanceMeters(a, b domain.Coordinate) float64 {
|
||||
lat1 := degreesToRadians(a.Latitude)
|
||||
lat2 := degreesToRadians(b.Latitude)
|
||||
dLat := lat2 - lat1
|
||||
dLon := degreesToRadians(b.Longitude - a.Longitude)
|
||||
|
||||
sinLat := math.Sin(dLat / 2)
|
||||
sinLon := math.Sin(dLon / 2)
|
||||
|
||||
h := sinLat*sinLat + math.Cos(lat1)*math.Cos(lat2)*sinLon*sinLon
|
||||
return 2 * EarthRadiusMeters * math.Asin(math.Sqrt(math.Min(1, h)))
|
||||
}
|
||||
|
||||
// WithinRadius reports whether two points are no further apart than radiusMeters.
|
||||
func WithinRadius(a, b domain.Coordinate, radiusMeters float64) bool {
|
||||
return DistanceMeters(a, b) <= radiusMeters
|
||||
}
|
||||
|
||||
// BoundingBox is a coarse lat/lon window that fully contains a circle of the
|
||||
// given radius around a centre. It exists to let the store pre-filter rows in
|
||||
// SQL before the exact haversine test is applied.
|
||||
type BoundingBox struct {
|
||||
MinLat float64
|
||||
MaxLat float64
|
||||
MinLon float64
|
||||
MaxLon float64
|
||||
}
|
||||
|
||||
// Around returns the bounding box of a circle of radiusMeters around center.
|
||||
func Around(center domain.Coordinate, radiusMeters float64) BoundingBox {
|
||||
// Latitude degrees are a constant distance apart, so the latitude delta is
|
||||
// a simple division. Longitude degrees shrink towards the poles, so the
|
||||
// delta is scaled by 1/cos(latitude) and clamped near the poles.
|
||||
latDelta := radiusMeters / EarthRadiusMeters * 180 / math.Pi
|
||||
|
||||
cos := math.Cos(degreesToRadians(center.Latitude))
|
||||
var lonDelta float64
|
||||
if math.Abs(cos) > 1e-9 {
|
||||
lonDelta = latDelta / math.Abs(cos)
|
||||
} else {
|
||||
// At the poles every longitude is the same meridian; use everything.
|
||||
lonDelta = 180
|
||||
}
|
||||
// Clamp so the box stays within valid longitude range.
|
||||
if lonDelta > 180 {
|
||||
lonDelta = 180
|
||||
}
|
||||
|
||||
return BoundingBox{
|
||||
MinLat: clamp(center.Latitude-latDelta, -90, 90),
|
||||
MaxLat: clamp(center.Latitude+latDelta, -90, 90),
|
||||
MinLon: clamp(center.Longitude-lonDelta, -180, 180),
|
||||
MaxLon: clamp(center.Longitude+lonDelta, -180, 180),
|
||||
}
|
||||
}
|
||||
|
||||
// Contains reports whether the point falls inside the box.
|
||||
func (b BoundingBox) Contains(c domain.Coordinate) bool {
|
||||
return c.Latitude >= b.MinLat && c.Latitude <= b.MaxLat &&
|
||||
c.Longitude >= b.MinLon && c.Longitude <= b.MaxLon
|
||||
}
|
||||
|
||||
// Round rounds a distance to the nearest metre so that JSON output and equality
|
||||
// assertions are stable.
|
||||
func Round(meters float64) float64 {
|
||||
return math.Round(meters)
|
||||
}
|
||||
|
||||
func degreesToRadians(deg float64) float64 { return deg * math.Pi / 180 }
|
||||
|
||||
func clamp(v, lo, hi float64) float64 {
|
||||
return math.Max(lo, math.Min(hi, v))
|
||||
}
|
||||
219
api/internal/geo/geo_test.go
Normal file
219
api/internal/geo/geo_test.go
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
package geo
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"go-places-api/internal/domain"
|
||||
)
|
||||
|
||||
// TestDistanceKnownSeparations checks the haversine result against separations
|
||||
// whose expected value is well established.
|
||||
func TestDistanceKnownSeparations(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
a, b domain.Coordinate
|
||||
wantMetres float64
|
||||
tolerance float64
|
||||
}{
|
||||
{
|
||||
// One degree of latitude is about 111.2 km anywhere.
|
||||
name: "one degree of latitude",
|
||||
a: domain.Coordinate{Latitude: 0, Longitude: 0},
|
||||
b: domain.Coordinate{Latitude: 1, Longitude: 0},
|
||||
wantMetres: 111195,
|
||||
tolerance: 100,
|
||||
},
|
||||
{
|
||||
// A degree of longitude is the same at the equator.
|
||||
name: "one degree of longitude at the equator",
|
||||
a: domain.Coordinate{Latitude: 0, Longitude: 0},
|
||||
b: domain.Coordinate{Latitude: 0, Longitude: 1},
|
||||
wantMetres: 111195,
|
||||
tolerance: 100,
|
||||
},
|
||||
{
|
||||
// Paris to London, roughly 344 km.
|
||||
name: "Paris to London",
|
||||
a: domain.Coordinate{Latitude: 48.8566, Longitude: 2.3522},
|
||||
b: domain.Coordinate{Latitude: 51.5074, Longitude: -0.1278},
|
||||
wantMetres: 343500,
|
||||
tolerance: 3000,
|
||||
},
|
||||
{
|
||||
// A short walk: the same spot is zero distance away.
|
||||
name: "identical points",
|
||||
a: domain.Coordinate{Latitude: 45.0, Longitude: 9.0},
|
||||
b: domain.Coordinate{Latitude: 45.0, Longitude: 9.0},
|
||||
wantMetres: 0,
|
||||
tolerance: 0.001,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := DistanceMeters(tc.a, tc.b)
|
||||
if math.Abs(got-tc.wantMetres) > tc.tolerance {
|
||||
t.Errorf("DistanceMeters = %.1f m, want %.1f m (±%.1f)",
|
||||
got, tc.wantMetres, tc.tolerance)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDistanceIsSymmetric guards against an asymmetry bug in the formula.
|
||||
func TestDistanceIsSymmetric(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
a := domain.Coordinate{Latitude: 52.5200, Longitude: 13.4050}
|
||||
b := domain.Coordinate{Latitude: 48.8566, Longitude: 2.3522}
|
||||
|
||||
forward := DistanceMeters(a, b)
|
||||
backward := DistanceMeters(b, a)
|
||||
if math.Abs(forward-backward) > 1e-6 {
|
||||
t.Errorf("distance is not symmetric: %.6f vs %.6f", forward, backward)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLongitudeDegreeShrinksTowardsPoles confirms the latitude dependency that
|
||||
// the bounding box calculation relies on.
|
||||
func TestLongitudeDegreeShrinksTowardsPoles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
atEquator := DistanceMeters(
|
||||
domain.Coordinate{Latitude: 0, Longitude: 0},
|
||||
domain.Coordinate{Latitude: 0, Longitude: 1},
|
||||
)
|
||||
atSixty := DistanceMeters(
|
||||
domain.Coordinate{Latitude: 60, Longitude: 0},
|
||||
domain.Coordinate{Latitude: 60, Longitude: 1},
|
||||
)
|
||||
|
||||
// A degree of longitude at 60 degrees is half the equatorial distance.
|
||||
if ratio := atSixty / atEquator; math.Abs(ratio-0.5) > 0.01 {
|
||||
t.Errorf("longitude degree at 60 lat = %.3f of equatorial, want 0.500", ratio)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithinRadius(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Two points about 22 m apart.
|
||||
origin := domain.Coordinate{Latitude: 48.8584, Longitude: 2.2945}
|
||||
// 0.0002 degrees of latitude is roughly 22 m.
|
||||
nearby := domain.Coordinate{Latitude: 48.8586, Longitude: 2.2945}
|
||||
far := domain.Coordinate{Latitude: 48.8684, Longitude: 2.2945}
|
||||
|
||||
if !WithinRadius(origin, nearby, 50) {
|
||||
t.Errorf("expected points ~22 m apart to be within 50 m, got %.1f m",
|
||||
DistanceMeters(origin, nearby))
|
||||
}
|
||||
if WithinRadius(origin, far, 50) {
|
||||
t.Errorf("expected points ~1112 m apart to be outside 50 m")
|
||||
}
|
||||
// The boundary itself is inclusive.
|
||||
if !WithinRadius(origin, nearby, DistanceMeters(origin, nearby)) {
|
||||
t.Errorf("expected the radius boundary to be inclusive")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBoundingBoxContainsCircle is the property the SQL pre-filter relies on:
|
||||
// every point within the radius must fall inside the box, so the box is only
|
||||
// ever a superset and never loses a candidate.
|
||||
func TestBoundingBoxContainsCircle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
centres := []domain.Coordinate{
|
||||
{Latitude: 0, Longitude: 0},
|
||||
{Latitude: 48.8566, Longitude: 2.3522},
|
||||
{Latitude: -33.8688, Longitude: 151.2093},
|
||||
{Latitude: 64.1466, Longitude: -21.9426},
|
||||
}
|
||||
radii := []float64{50, 150, 5000}
|
||||
|
||||
for _, c := range centres {
|
||||
for _, radius := range radii {
|
||||
box := Around(c, radius)
|
||||
for _, bearing := range []float64{0, 45, 90, 135, 180, 225, 270, 315} {
|
||||
p := offset(c, bearing, radius)
|
||||
if !box.Contains(p) {
|
||||
t.Errorf("box around %+v r=%v excludes point at bearing %v (%+v): %+v",
|
||||
c, radius, bearing, p, box)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBoundingBoxHandlesPoles ensures a circle at the pole does not divide by
|
||||
// the vanishing cosine of the latitude.
|
||||
func TestBoundingBoxHandlesPoles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
box := Around(domain.Coordinate{Latitude: 90, Longitude: 0}, 1000)
|
||||
if box.MinLon != -180 || box.MaxLon != 180 {
|
||||
t.Errorf("at the pole the box should span all longitudes, got lon %v..%v",
|
||||
box.MinLon, box.MaxLon)
|
||||
}
|
||||
if box.MaxLat > 90 || box.MinLat < -90 {
|
||||
t.Errorf("latitude bounds must stay in range, got %v..%v", box.MinLat, box.MaxLat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
in, want float64
|
||||
}{
|
||||
{0, 0},
|
||||
{0.4, 0},
|
||||
{0.5, 1},
|
||||
{1.4, 1},
|
||||
{1.5, 2},
|
||||
{123.456, 123},
|
||||
{-0.6, -1},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := Round(tc.in); got != tc.want {
|
||||
t.Errorf("Round(%v) = %v, want %v", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoordinateValid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
coord domain.Coordinate
|
||||
want bool
|
||||
}{
|
||||
{domain.Coordinate{Latitude: 0, Longitude: 0}, true},
|
||||
{domain.Coordinate{Latitude: 48.85, Longitude: 2.35}, true},
|
||||
{domain.Coordinate{Latitude: 90, Longitude: 180}, true},
|
||||
{domain.Coordinate{Latitude: -90, Longitude: -180}, true},
|
||||
{domain.Coordinate{Latitude: 90.1, Longitude: 0}, false},
|
||||
{domain.Coordinate{Latitude: -90.1, Longitude: 0}, false},
|
||||
{domain.Coordinate{Latitude: 0, Longitude: 180.1}, false},
|
||||
{domain.Coordinate{Latitude: 0, Longitude: -180.1}, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := tc.coord.Valid(); got != tc.want {
|
||||
t.Errorf("%+v.Valid() = %v, want %v", tc.coord, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// offset moves a point by bearing degrees and distance metres using a simple
|
||||
// equirectangular approximation, which is accurate enough over the small
|
||||
// distances these tests use.
|
||||
func offset(origin domain.Coordinate, bearingDeg, metres float64) domain.Coordinate {
|
||||
const degToRad = math.Pi / 180
|
||||
lat := origin.Latitude + (metres*math.Cos(bearingDeg*degToRad))/EarthRadiusMeters*180/math.Pi
|
||||
lon := origin.Longitude + (metres*math.Sin(bearingDeg*degToRad))/
|
||||
(EarthRadiusMeters*math.Cos(origin.Latitude*degToRad))*180/math.Pi
|
||||
return domain.Coordinate{Latitude: lat, Longitude: lon}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue