geomodel 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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