Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat/precision #34

Merged
merged 2 commits into from
Jun 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 73 additions & 26 deletions db/rtree.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ import (
)

type Geo2TzRTreeIndex struct {
land rtree.RTreeG[string]
sea rtree.RTreeG[string]
land rtree.RTreeG[timezoneGeo]
sea rtree.RTreeG[timezoneGeo]
size int
}

Expand All @@ -22,13 +22,13 @@ func IsOcean(label string) bool {
}

// Insert adds a new timezone bounding box to the index
func (g *Geo2TzRTreeIndex) Insert(min, max [2]float64, label string) {
func (g *Geo2TzRTreeIndex) Insert(min, max [2]float64, element timezoneGeo) {
g.size++
if IsOcean(label) {
g.sea.Insert(min, max, label)
if IsOcean(element.Name) {
g.sea.Insert(min, max, element)
return
}
g.land.Insert(min, max, label)
g.land.Insert(min, max, element)
}

func NewGeo2TzRTreeIndexFromGeoJSON(geoJSONPath string) (*Geo2TzRTreeIndex, error) {
Expand All @@ -45,22 +45,7 @@ func NewGeo2TzRTreeIndexFromGeoJSON(geoJSONPath string) (*Geo2TzRTreeIndex, erro
// this function will add the timezone polygons to the shape index
iter := func(tz timezoneGeo) error {
for _, p := range tz.Polygons {
minLat, minLng, maxLat, maxLng := p.Vertices[0].lat, p.Vertices[0].lng, p.Vertices[0].lat, p.Vertices[0].lng
for _, v := range p.Vertices {
if v.lng < minLng {
minLng = v.lng
}
if v.lng > maxLng {
maxLng = v.lng
}
if v.lat < minLat {
minLat = v.lat
}
if v.lat > maxLat {
maxLat = v.lat
}
}
gri.Insert([2]float64{minLat, minLng}, [2]float64{maxLat, maxLng}, tz.Name)
gri.Insert([2]float64{p.MinLat, p.MinLng}, [2]float64{p.MaxLat, p.MaxLng}, tz)
}
return nil
}
Expand All @@ -81,21 +66,43 @@ func NewGeo2TzRTreeIndexFromGeoJSON(geoJSONPath string) (*Geo2TzRTreeIndex, erro
// It first searches in the land index, if not found, it searches in the sea index
func (g *Geo2TzRTreeIndex) Lookup(lat, lng float64) (tzID string, err error) {

chances := 5
// search the land index
g.land.Search(
[2]float64{lat, lng},
[2]float64{lat, lng},
func(min, max [2]float64, label string) bool {
tzID = label
func(min, max [2]float64, data timezoneGeo) bool {
chances--
if chances == 0 {
return false
}
for _, p := range data.Polygons {
if isPointInPolygonPIP(vertex{lat, lng}, p) {
tzID = data.Name
return false
}
}
return true
},
)

if tzID == "" {
// if not found, search the sea index
chances = 5
g.sea.Search(
[2]float64{lat, lng},
[2]float64{lat, lng},
func(min, max [2]float64, label string) bool {
tzID = label
func(min, max [2]float64, data timezoneGeo) bool {
chances--
if chances == 0 {
return false
}
for _, p := range data.Polygons {
if isPointInPolygonPIP(vertex{lat, lng}, p) {
tzID = data.Name
return false
}
}
return true
},
)
Expand All @@ -111,6 +118,23 @@ func (g *Geo2TzRTreeIndex) Size() int {
return g.size
}

func isPointInPolygonPIP(point vertex, polygon polygon) bool {
oddNodes := false
n := len(polygon.Vertices)
for i := 0; i < n; i++ {
j := (i + 1) % n
vi := polygon.Vertices[i]
vj := polygon.Vertices[j]
// Check if the point lies on an edge of the polygon (including horizontal)
if (vi.lng == vj.lng && vi.lng == point.lng && point.lat >= min(vi.lat, vj.lat) && point.lat <= max(vi.lat, vj.lat)) ||
((vi.lat < point.lat && point.lat <= vj.lat) || (vj.lat < point.lat && point.lat <= vi.lat)) &&
(point.lng < (vj.lng-vi.lng)*(point.lat-vi.lat)/(vj.lat-vi.lat)+vi.lng) {
oddNodes = !oddNodes
}
}
return oddNodes
}

/*
GeoJSON processing
*/
Expand All @@ -119,6 +143,10 @@ GeoJSON processing
// with a list of vertices [lat, lng]
type polygon struct {
Vertices []vertex
MaxLat float64
MinLat float64
MaxLng float64
MinLng float64
}

type vertex struct {
Expand All @@ -137,6 +165,25 @@ type GeoJSONFeature struct {
}

func (p *polygon) AddVertex(lat, lng float64) {
if len(p.Vertices) == 0 {
p.MaxLat = lat
p.MinLat = lat
p.MaxLng = lng
p.MinLng = lng
} else {
if lat > p.MaxLat {
p.MaxLat = lat
}
if lat < p.MinLat {
p.MinLat = lat
}
if lng > p.MaxLng {
p.MaxLng = lng
}
if lng < p.MinLng {
p.MinLng = lng
}
}
p.Vertices = append(p.Vertices, vertex{lat, lng})
}

Expand Down
69 changes: 33 additions & 36 deletions db/rtree_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,29 +14,14 @@ func TestGeo2TzTreeIndex_LookupZone(t *testing.T) {
Tz string `json:"tz"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
HasError bool `json:"err,omitempty"`
NotFound bool `json:"not_found,omitempty"`
}

// load the database
gsi, err := NewGeo2TzRTreeIndexFromGeoJSON("../tzdata/timezones.zip")
assert.NoError(t, err)
assert.NotEmpty(t, gsi.Size())

// load the timezone references
var tzZones map[string]struct {
Zone string `json:"zone"`
UtcOffset float32 `json:"utc_offset_h"`
Dst struct {
Start string `json:"start"`
End string `json:"end"`
Zone string `json:"zone"`
UtcOffset float32 `json:"utc_offset_h"`
} `json:"dst,omitempty"`
}
err = helpers.LoadJSON("testdata/zones.json", &tzZones)
assert.NoError(t, err)
assert.NotEmpty(t, tzZones)

// load the coordinates
err = helpers.LoadJSON("testdata/coordinates.json", &tests)
assert.NoError(t, err)
Expand All @@ -45,31 +30,43 @@ func TestGeo2TzTreeIndex_LookupZone(t *testing.T) {
for _, tt := range tests {
t.Run(tt.Tz, func(t *testing.T) {
got, err := gsi.Lookup(tt.Lat, tt.Lon)
assert.NoError(t, err)

if tt.HasError {
t.Skip("skipping test as it is expected to fail (know error)")
}

// for oceans do exact match
if IsOcean(got) {
assert.Equal(t, tt.Tz, got, "expected %s to be %s for https://www.google.com/maps/@%v,%v,12z", tt.Tz, got, tt.Lat, tt.Lon)
if tt.NotFound {
assert.ErrorIs(t, err, ErrNotFound)
return
}
assert.NoError(t, err)
assert.Equal(t, got, tt.Tz, "expected %s to be %s for https://www.google.com/maps/@%v,%v,12z", tt.Tz, got, tt.Lat, tt.Lon)
})
}
}

// get the zone for the expected timezone
zoneExpected, ok := tzZones[tt.Tz]
assert.True(t, ok, "timezone %s not found in zones.json", tt.Tz)
// benchmark the lookup function
func BenchmarkGeo2TzTreeIndex_LookupZone(b *testing.B) {
// load the database
gsi, err := NewGeo2TzRTreeIndexFromGeoJSON("../tzdata/timezones.zip")
assert.NoError(b, err)
assert.NotEmpty(b, gsi.Size())

// get the reference timezone for the expected timezone
zoneGot, ok := tzZones[got]
assert.True(t, ok, "timezone %s not found in zones.json", got)
// load the coordinates
var tests []struct {
Tz string `json:"tz"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
NotFound bool `json:"not_found,omitempty"`
}
err = helpers.LoadJSON("testdata/coordinates.json", &tests)
assert.NoError(b, err)
assert.NotEmpty(b, tests)

if !ok {
assert.Equal(t, zoneExpected.Zone, got, "expected %s (%s) to be %s (%s) for https://www.google.com/maps/@%v,%v,12z", tt.Tz, zoneExpected.Zone, got, zoneGot.Zone, tt.Lat, tt.Lon)
} else {
assert.Equal(t, zoneExpected.Zone, zoneGot.Zone, "expected %s (%s) to be %s (%s) for https://www.google.com/maps/@%v,%v,12z", tt.Tz, zoneExpected.Zone, got, zoneGot.Zone, tt.Lat, tt.Lon)
b.ResetTimer()
for i := 0; i < b.N; i++ {
for _, tt := range tests {
_, err := gsi.Lookup(tt.Lat, tt.Lon)
if tt.NotFound {
assert.ErrorIs(b, err, ErrNotFound)
return
}
})
assert.NoError(b, err)
}
}
}
Loading
Loading