geomodel 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,28 @@
1
+ require 'geocoder'
2
+
3
+ module Geomodel::Math
4
+
5
+ RADIUS = 6378135
6
+
7
+ # Calculates the great circle distance between two points (law of cosines).
8
+ #
9
+ # Args:
10
+ # start_point: A geotypes.Point or db.GeoPt indicating the first point.
11
+ # end_point_: A geotypes.Point or db.GeoPt indicating the second point.
12
+ #
13
+ # Returns:
14
+ # The 2D great-circle distance between the two given points, in meters.
15
+ #
16
+ def self.distance(start_point, end_point)
17
+ start_point_lat = Geocoder::Calculations.to_radians(start_point.latitude)
18
+ start_point_lon = Geocoder::Calculations.to_radians(start_point.longitude)
19
+ end_point_lat = Geocoder::Calculations.to_radians(end_point.latitude)
20
+ end_point_lon = Geocoder::Calculations.to_radians(end_point.longitude)
21
+ # work out the internal value for the spherical law of cosines and clamp
22
+ # it between -1.0 and 1.0 to avoid rounding errors
23
+ sloc = (Math.sin(start_point_lat) * Math.sin(end_point_lat) +
24
+ Math.cos(start_point_lat) * Math.cos(end_point_lat) * Math.cos(end_point_lon - start_point_lon))
25
+ sloc = [[sloc, 1.0].min, -1.0].max
26
+ RADIUS * Math.acos(sloc)
27
+ end
28
+ end
@@ -0,0 +1,109 @@
1
+ module Geomodel::Types
2
+
3
+ # A two-dimensional point in the [-90,90] x [-180,180] lat/lon space.
4
+ #
5
+ # Attributes:
6
+ # lat: A float in the range [-90,90] indicating the point's latitude.
7
+ # lon: A float in the range [-180,180] indicating the point's longitude.
8
+ #
9
+ class Point
10
+
11
+ attr_reader :latitude, :longitude
12
+
13
+ alias_method :lat, :latitude
14
+ alias_method :lon, :longitude
15
+
16
+ def initialize(latitude, longitude)
17
+ if -90 > latitude || latitude > 90
18
+ raise ArgumentError.new("Latitude must be in [-90, 90]")
19
+ else
20
+ @latitude = latitude
21
+ end
22
+
23
+ if -180 > longitude || longitude > 180
24
+ raise ArgumentError.new("Longitude must be in [-180, 180]")
25
+ else
26
+ @longitude = longitude
27
+ end
28
+ end
29
+
30
+ def ==(point)
31
+ (@latitude === point.latitude) && (@longitude === point.longitude)
32
+ end
33
+
34
+ def to_s
35
+ "(#{@latitude}, #{@longitude})"
36
+ end
37
+
38
+ end
39
+
40
+ # A two-dimensional rectangular region defined by NE and SW points.
41
+ #
42
+ # Attributes:
43
+ # north_east: A read-only geotypes.Point indicating the box's Northeast
44
+ # coordinate.
45
+ # south_west: A read-only geotypes.Point indicating the box's Southwest
46
+ # coordinate.
47
+ # north: A float indicating the box's North latitude.
48
+ # east: A float indicating the box's East longitude.
49
+ # south: A float indicating the box's South latitude.
50
+ # west: A float indicating the box's West longitude.
51
+ #
52
+ class Box
53
+ attr_reader :north_east, :south_west
54
+
55
+ def initialize(north, east, south, west)
56
+ south, north = north, south if south > north
57
+
58
+ # Don't swap east and west to allow disambiguation of
59
+ # antimeridian crossing.
60
+ @north_east = Point.new(north, east)
61
+ @south_west = Point.new(south, west)
62
+ end
63
+
64
+ def north=(north)
65
+ raise ArgumentError.new("Latitude must be north of box's south latitude") if north < @south_west.latitude
66
+ @north_east.latitude = north
67
+ end
68
+
69
+ def east=(east)
70
+ @north_east.longitude = east
71
+ end
72
+
73
+ def south=(south)
74
+ raise ArgumentError.new("Latitude must be south of box's north latitude") if south > @south_west.latitude
75
+ @south_west.latitude = south
76
+ end
77
+
78
+ def west=(west)
79
+ @south_west.longitude = west
80
+ end
81
+
82
+ def north
83
+ @north_east.latitude
84
+ end
85
+
86
+ def east
87
+ @north_east.longitude
88
+ end
89
+
90
+ def south
91
+ @south_west.latitude
92
+ end
93
+
94
+ def west
95
+ @south_west.longitude
96
+ end
97
+
98
+ def ==(box)
99
+ (@north_east === box.north_east) && (@south_west === box.south_west)
100
+ end
101
+
102
+ def to_s
103
+ "(#{@north_east.latitude}, #{@north_east.longitude}, #{@south_west.latitude}, #{@south_west.longitude})"
104
+ end
105
+
106
+ end
107
+ end
108
+
109
+
@@ -0,0 +1,51 @@
1
+ module Geomodel::Util
2
+
3
+ def self.merge_in_place(target, arrays, dup_func = nil, comp_func = nil)
4
+ arrays.each do |array|
5
+ array.each do |element|
6
+ target.push(element)
7
+ end
8
+ end
9
+
10
+ comp_func.nil? ? target.sort! : target.sort!(&comp_func)
11
+ dup_func.nil? ? target.uniq! : target.uniq!(&dup_func)
12
+ end
13
+
14
+ # Returns the edges of the rectangular region containing all of the
15
+ # given geocells, sorted by distance from the given point, along with
16
+ # the actual distances from the point to these edges.
17
+ #
18
+ # Args:
19
+ # cells: The cells (should be adjacent) defining the rectangular region
20
+ # whose edge distances are requested.
21
+ # point: The point that should determine the edge sort order.
22
+ #
23
+ # Returns:
24
+ # A list of (direction, distance) tuples, where direction is the edge
25
+ # and distance is the distance from the point to that edge. A direction
26
+ # value of (0,-1), for example, corresponds to the South edge of the
27
+ # rectangular region containing all of the given geocells.
28
+ #
29
+ def self.distance_sorted_edges(cells, point)
30
+
31
+ # TODO(romannurik): Assert that lat,lon are actually inside the geocell.
32
+ boxes = cells.map { |cell| Geomodel::GeoCell.compute_box(cell) }
33
+
34
+ max_box = Geomodel::Types::Box.new(
35
+ boxes.map(&:north).max,
36
+ boxes.map(&:east).max,
37
+ boxes.map(&:south).max,
38
+ boxes.map(&:west).max
39
+ )
40
+
41
+ dist_south = Geomodel::Math.distance(Geomodel::Types::Point.new(max_box.south, point.longitude), point)
42
+ dist_north = Geomodel::Math.distance(Geomodel::Types::Point.new(max_box.north, point.longitude), point)
43
+ dist_west = Geomodel::Math.distance(Geomodel::Types::Point.new(point.latitude, max_box.west), point)
44
+ dist_east = Geomodel::Math.distance(Geomodel::Types::Point.new(point.latitude, max_box.east), point)
45
+
46
+ [
47
+ [Geomodel::GeoCell::SOUTH, dist_south], [Geomodel::GeoCell::NORTH, dist_north], [Geomodel::GeoCell::WEST, dist_west], [Geomodel::GeoCell::EAST, dist_east]
48
+ ].sort { |x, y| x[1] <=> y[1] }.transpose
49
+ end
50
+
51
+ end
@@ -0,0 +1,3 @@
1
+ module Geomodel
2
+ VERSION = "0.0.1"
3
+ end
@@ -0,0 +1,154 @@
1
+ require 'spec_helper'
2
+
3
+ describe 'Geomodel::GeoCell' do
4
+
5
+ it "can compute a valid geocell" do
6
+ cell = Geomodel::GeoCell.compute(Geomodel::Types::Point.new(37, -122), 14)
7
+
8
+ expect(cell.size).to eq(14)
9
+ expect(Geomodel::GeoCell.is_valid(cell)).to be_true
10
+ expect(Geomodel::GeoCell.contains_point(cell, Geomodel::Types::Point.new(37, -122)))
11
+ end
12
+
13
+ it "can determined if a geocell is invalid" do
14
+ cell = Geomodel::GeoCell.compute(Geomodel::Types::Point.new(0, 0), 0)
15
+
16
+ expect(cell.size).to eq(0)
17
+ expect(Geomodel::GeoCell.is_valid(cell)).to be_false
18
+ end
19
+
20
+ it "contains a lower resolution cell containing the same point as a prefix" do
21
+ cell = Geomodel::GeoCell.compute(Geomodel::Types::Point.new(37, -122), 14)
22
+ lowres_cell = Geomodel::GeoCell.compute(Geomodel::Types::Point.new(37, -122), 8)
23
+
24
+ expect(cell.start_with?(lowres_cell)).to be_true
25
+ expect(Geomodel::GeoCell.contains_point(lowres_cell, Geomodel::Types::Point.new(37, -122)))
26
+ end
27
+
28
+ it "can compute a box" do
29
+ cell = Geomodel::GeoCell.compute(Geomodel::Types::Point.new(37, -122), 14)
30
+ box = Geomodel::GeoCell.compute_box(cell)
31
+
32
+ expect(box.south).to be <= 37
33
+ expect(box.north).to be >= 37
34
+ expect(box.west).to be <= -122
35
+ expect(box.east).to be >= -122
36
+ end
37
+
38
+ it "can determine adjacency using bounding boxes" do
39
+ cell = Geomodel::GeoCell.compute(Geomodel::Types::Point.new(37, -122), 14)
40
+ box = Geomodel::GeoCell.compute_box(cell)
41
+
42
+ adjacent_south = Geomodel::GeoCell.adjacent(cell, [0, 1])
43
+ adjacent_north = Geomodel::GeoCell.adjacent(cell, [0, -1])
44
+ adjacent_west = Geomodel::GeoCell.adjacent(cell, [1, 0])
45
+ adjacent_east = Geomodel::GeoCell.adjacent(cell, [-1, 0])
46
+
47
+ adjacent_south_box = Geomodel::GeoCell.compute_box(adjacent_south)
48
+ adjacent_north_box = Geomodel::GeoCell.compute_box(adjacent_north)
49
+ adjacent_west_box = Geomodel::GeoCell.compute_box(adjacent_west)
50
+ adjacent_east_box = Geomodel::GeoCell.compute_box(adjacent_east)
51
+
52
+ all_adjacents = Geomodel::GeoCell.all_adjacents(cell)
53
+
54
+ expect(adjacent_south_box.north).to be_within(0.00001).of(box.north)
55
+ expect(adjacent_north_box.south).to be_within(0.00001).of(box.south)
56
+ expect(adjacent_west_box.east).to be_within(0.00001).of(box.east)
57
+ expect(adjacent_east_box.west).to be_within(0.00001).of(box.west)
58
+ expect(all_adjacents.size).to eq(8)
59
+ end
60
+
61
+ it "can determine collinearity" do
62
+ cell = Geomodel::GeoCell.compute(Geomodel::Types::Point.new(37, -122), 14)
63
+
64
+ adjacent_south = Geomodel::GeoCell.adjacent(cell, [0, 1])
65
+ adjacent_west = Geomodel::GeoCell.adjacent(cell, [1, 0])
66
+
67
+ expect(Geomodel::GeoCell.collinear(cell, adjacent_south, true)).to be_true
68
+ expect(Geomodel::GeoCell.collinear(cell, adjacent_south, false)).to be_false
69
+ expect(Geomodel::GeoCell.collinear(cell, adjacent_west, false)).to be_true
70
+ expect(Geomodel::GeoCell.collinear(cell, adjacent_west, true)).to be_false
71
+ end
72
+
73
+ it "can be interpolated" do
74
+ cell = Geomodel::GeoCell.compute(Geomodel::Types::Point.new(37, -122), 14)
75
+
76
+ sw_adjacent = Geomodel::GeoCell.adjacent(cell, [-1, -1])
77
+ sw_adjacent2 = Geomodel::GeoCell.adjacent(sw_adjacent, [-1, -1])
78
+
79
+ # interpolate between a cell and south-west adjacent, should return
80
+ # 4 total cells
81
+ expect(Geomodel::GeoCell.interpolate(cell, sw_adjacent).size).to eq(4)
82
+ expect(Geomodel::GeoCell.interpolation_count(cell, sw_adjacent)).to eq(4)
83
+
84
+ # interpolate between a cell and the cell SW-adjacent twice over,
85
+ # should return 9 total cells
86
+ expect(Geomodel::GeoCell.interpolate(cell, sw_adjacent2).size).to eq(9)
87
+ expect(Geomodel::GeoCell.interpolation_count(cell, sw_adjacent2)).to eq(9)
88
+ end
89
+
90
+ it "can create the best bounding box across a major cell boundary" do
91
+ bbox = Geomodel::Types::Box.new(43.195111, -89.998193, 43.19302, -90.002356)
92
+ geocells = Geomodel::GeoCell.best_bbox_search_cells(bbox, Geomodel::DEFAULT_COST_FUNCTION)
93
+
94
+ expect(geocells.size).to be(16)
95
+ expect(geocells).to include(
96
+ "8ff77dfd4", "8ff77dfd5", "8ff77dfd6", "8ff77dfd7", "8ff77dfdc", "8ff77dfdd",
97
+ "8ff77dfde", "8ff77dfdf", "9aa228a80", "9aa228a81", "9aa228a82", "9aa228a83",
98
+ "9aa228a88", "9aa228a89", "9aa228a8a", "9aa228a8b"
99
+ )
100
+ end
101
+
102
+ it "can create the best bounding box at the maximum resolution" do
103
+ bbox = Geomodel::Types::Box.new(43.195110, -89.998193, 43.195110, -89.998193)
104
+ geocells = Geomodel::GeoCell.best_bbox_search_cells(bbox, lambda { |num_cells, resolution|
105
+ resolution <= Geomodel::GeoCell::MAX_GEOCELL_RESOLUTION ? 0 : Math.exp(10000)
106
+ })
107
+
108
+ expect(geocells.size).to be(1)
109
+ expect(geocells).to include("9aa228a8b3b00")
110
+ end
111
+
112
+ # TODO implement these tests!
113
+
114
+ # @Test
115
+ # public void testBestBoxSearchOnAntimeridian() {
116
+ # float east = 64.576263f;
117
+ # float west = 87.076263f;
118
+ # float north = 76.043611f;
119
+ # float south = -54.505934f;
120
+ # Set<String> antimeridianSearch = new HashSet<String>(GeocellManager.bestBboxSearchCells(new BoundingBox(north,east,south,west), null));
121
+ #
122
+ # List<String> equivalentSearchPart1 = GeocellManager.bestBboxSearchCells(new BoundingBox(north,east,south,-180.0f), null);
123
+ # List<String> equivalentSearchPart2 = GeocellManager.bestBboxSearchCells(new BoundingBox(north,180.0f,south,west), null);
124
+ # Set<String> equivalentSearch = new HashSet<String>();
125
+ # equivalentSearch.addAll(equivalentSearchPart1);
126
+ # equivalentSearch.addAll(equivalentSearchPart2);
127
+ #
128
+ # assertEquals(equivalentSearch, antimeridianSearch);
129
+ # }
130
+
131
+ # @Test
132
+ # public void testBestBoxWithCustomCostFunction() {
133
+ # final int numCellsMax = 30;
134
+ # BoundingBox bb = new BoundingBox(38.912056, -118.40747, 35.263195, -123.88965);
135
+ #
136
+ # List<String> cells = GeocellManager.bestBboxSearchCells(bb, new CostFunction() {
137
+ #
138
+ # @Override
139
+ #
140
+ # public double defaultCostFunction(int numCells, int resolution)
141
+ #
142
+ # {
143
+ # // Here we ensure that we do not try to query more than 30 cells, the limit of a gae IN filter
144
+ # return numCells > numCellsMax ? Double.MAX_VALUE : 0;
145
+ # }
146
+ #
147
+ # });
148
+ #
149
+ # assertTrue(cells != null);
150
+ # assertTrue(cells.size() > 0);
151
+ # assertTrue(cells.size() <= numCellsMax);
152
+ # }
153
+
154
+ end
@@ -0,0 +1,55 @@
1
+ require 'spec_helper'
2
+ require 'hashie'
3
+
4
+ describe 'Geomodel::Math' do
5
+
6
+ it 'can compute the distance between two points' do
7
+ [ # lat a, lon a, lat b, lon b, distance
8
+ [ 37, -122, 42, -75, 4024365 ],
9
+ [ 36.12, -86.67, 33.94, -118.40, 2889677.0 ],
10
+ ].each do |lat_a, lon_a, lat_b, lon_b, expected_dist|
11
+ # known distances using GLatLng from the Maps API
12
+ point_a = Hashie::Mash.new
13
+ point_a.latitude = lat_a
14
+ point_a.longitude = lon_a
15
+
16
+ point_b = Hashie::Mash.new
17
+ point_b.latitude = lat_b
18
+ point_b.longitude = lon_b
19
+
20
+ half_of_a_percent = expected_dist / 200
21
+
22
+ calc_dist = Geomodel::Math.distance(point_a, point_b)
23
+
24
+ expect(calc_dist).to be_within(half_of_a_percent).of(expected_dist)
25
+ end
26
+ end
27
+
28
+ # Test location that can cause math domain error (due to rounding) unless
29
+ # the distance function clamps the spherical law of cosines value between
30
+ # -1.0 and 1.0.
31
+ it 'can compute the distance correctly for in spite of rounding errors' do
32
+ point_a = Hashie::Mash.new
33
+ point_a.latitude = 47.291288
34
+ point_a.longitude = 8.56613
35
+
36
+ point_b = Hashie::Mash.new
37
+ point_b.latitude = 47.291288
38
+ point_b.longitude = 8.56613
39
+
40
+ calc_dist = Geomodel::Math.distance(point_a, point_b)
41
+ expected_dist = 0.0
42
+
43
+ expect(calc_dist).to eq(expected_dist)
44
+ end
45
+
46
+ # TODO: implement this test
47
+
48
+ #
49
+ # @Test
50
+ # public void testInterpolationForEdgeCase() {
51
+ #
52
+ # assertTrue(GeocellUtils.interpolationCount("8e6f727a6b0dd", "8e1d5c3ce9aff") > 0);
53
+ # }
54
+
55
+ end
@@ -0,0 +1,89 @@
1
+ require 'spec_helper'
2
+ require 'hashie'
3
+
4
+ describe 'Geomodel' do
5
+
6
+ before(:all) do
7
+ class Entity
8
+ attr_accessor :id, :location, :geocells
9
+
10
+ def to_s
11
+ self.id
12
+ end
13
+ end
14
+
15
+ @flatiron = Entity.new
16
+ @flatiron.id = 'Flatiron'
17
+ @flatiron.location = Geomodel::Types::Point.new(40.7407092, -73.9894039)
18
+
19
+ @outback = Entity.new
20
+ @outback.id = 'Outback Steakhouse'
21
+ @outback.location = Geomodel::Types::Point.new(40.7425610, -73.9922670)
22
+
23
+ @museum_of_sex = Entity.new
24
+ @museum_of_sex.id = 'Museum of Sex'
25
+ @museum_of_sex.location = Geomodel::Types::Point.new(40.7440290, -73.9873500)
26
+
27
+ @wolfgang = Entity.new
28
+ @wolfgang.id = 'Wolfgang Steakhouse'
29
+ @wolfgang.location = Geomodel::Types::Point.new(40.7466230, -73.9820620)
30
+
31
+ @morgan = Entity.new
32
+ @morgan.id ='Morgan Library'
33
+ @morgan.location = Geomodel::Types::Point.new(40.7493672, -73.9817685)
34
+
35
+ @places = [@flatiron, @outback, @museum_of_sex, @wolfgang, @morgan]
36
+
37
+ @places.each do |place|
38
+ place.geocells = Geomodel::GeoCell.generate_geocells(place.location)
39
+ end
40
+
41
+ @query_runner = lambda do |geocells|
42
+ result = @places.reject do |o|
43
+ (o.geocells & geocells).length < 0
44
+ end
45
+
46
+ result
47
+ end
48
+ end
49
+
50
+ it "can calculate the geocells for a bounding box using the default cost function" do
51
+ cell = Geomodel::GeoCell.compute(Geomodel::Types::Point.new(37, -122), 14)
52
+ bounding_box = Geomodel::GeoCell.compute_box(cell)
53
+ geocells = Geomodel.geocells_for_bounding_box(bounding_box)
54
+
55
+ expect(geocells.size).to be(2)
56
+ expect(geocells).to include("8e6187fe6187f", "8e6187fe618d5")
57
+ end
58
+
59
+ it "can find nearby locations given a location (lat & lon) and a radius in meters" do
60
+ results = Geomodel.proximity_fetch(@flatiron.location, @query_runner, 5, 500)
61
+ places = results.map(&:first)
62
+ distances = results.map(&:last)
63
+
64
+ expect(results.size).to be(3)
65
+ expect(places).to include(@flatiron, @outback, @museum_of_sex)
66
+ expect(distances.max).to be <= 500
67
+ end
68
+
69
+ it "respects the max results parameters in a search by proximity" do
70
+ results = Geomodel.proximity_fetch(@flatiron.location, @query_runner, 2, 500)
71
+ places = results.map(&:first)
72
+ distances = results.map(&:last)
73
+
74
+ expect(results.size).to be(2)
75
+ expect(places).to include(@flatiron, @outback)
76
+ expect(distances.max).to be <= 500
77
+ end
78
+
79
+ it "respects the max results parameters in a search by proximity" do
80
+ results = Geomodel.proximity_fetch(@flatiron.location, @query_runner, 5, 1000)
81
+ places = results.map(&:first)
82
+ distances = results.map(&:last)
83
+
84
+ expect(results.size).to be(4)
85
+ expect(places).to include(@flatiron, @outback, @museum_of_sex, @wolfgang)
86
+ expect(distances.max).to be <= 1000
87
+ end
88
+
89
+ end