Skip to content

Instantly share code, notes, and snippets.

@thruflo
Created December 9, 2011 15:07
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save thruflo/1451887 to your computer and use it in GitHub Desktop.
Save thruflo/1451887 to your computer and use it in GitHub Desktop.
Example `LocationMixin` class that can be used to provide geolocation to any `SQLModel` class.
max_radius_of_earth = 6500 * 1000 # metres
max_sqrt_distance = math.sqrt(max_radius_of_earth * 0.95)
min_sqrt_distance = math.sqrt(100)
class LocationMixin(object):
"""Provides ``self.latitude`` and ``self.longitude`` attributes and a
``self.update_location()`` method which updates ``self.location``,
which is stored as a geography type in latlng projection.
You can keep self.location uptodate automatically by binding to
``before_insert`` and ``before_update`` events using, e.g.::
class MyGeoModel(SQLModel, LocationMixin):
pass
handler = lambda mapper, connection, target: target.update_location()
for event_name in 'before_insert', 'before_update':
event.listen(MyGeoModel, event_name, handler)
Also provides classmethods that return clauses to filter by ``within``,
``within_area`` and order by ``nearest``. So, for example, to filter
by within 10km of a latlng point, you can use::
class MyGeoModel(SQLModel, BaseMixin, LocationMixin):
pass
latitude = 51.51333
longitude = -0.0889469999
within = MyGeoModel.within(latitude, longitude, 10 * 1000)
query = MyGeoModel.query.filter(within)
And to order the results nearest to the location::
nearest = MyGeoModel.within(latitude, longitude)
query.order_by(nearest)
"""
latitude = Column(Float, nullable=False)
longitude = Column(Float, nullable=False)
@declared_attr
def location(self):
return geoalchemy.GeometryColumn(
Geography(),
comparator=PGComparator
)
def update_location(self):
"""Update ``self.location`` with a point value derived from
``self.latitude`` and ``self.longitude``. Note that the point will
be `autocast`_ to geography type on saving:
> Standard geometry type data will autocast to geography if it is of
SRID 4326.
_`autocast`: http://postgis.refractions.net/docs/ch04.html#Geography_Basics
"""
self.location = "POINT(%0.8f %0.8f)" % (self.longitude, self.latitude)
@classmethod
def within(cls, latitude, longitude, distance):
"""Return a within clause that explicitly casts the ``latitude`` and
``longitude`` provided to geography type. Note that `ST_DWithin`_
will use a spatial index to filter out rows that are not within the
boundary box before doing a sequential scan of the remaining rows.
> This function call will automatically include a bounding box comparison
that will make use of any indexes that are available on the geometries.
_`ST_DWithin`: http://postgis.refractions.net/docs/ST_DWithin.html
"""
attr = '%s.location' % cls.__tablename__
point = 'POINT(%0.8f %0.8f)' % (longitude, latitude)
location = "ST_GeographyFromText(E'SRID=4326;%s')" % point
return 'ST_DWithin(%s, %s, %d)' % (attr, location, distance)
@classmethod
def within_area(cls, area):
"""Return a within clause that explicitly casts the ``area`` polygon
provided to geography type.
"""
attr = '%s.location' % cls.__tablename__
location = "ST_GeographyFromText(E'SRID=4326;%s')" % area
return 'ST_DWithin(%s, %s, %d)' % (attr, location, 1)
@classmethod
def nearest(cls, latitude, longitude):
"""Return an order by `ST_Distance`_ clause to sort results by proximity.
"""
attr = '%s.location' % cls.__tablename__
point = 'POINT(%0.8f %0.8f)' % (longitude, latitude)
location = "ST_GeographyFromText(E'SRID=4326;%s')" % point
return 'ST_Distance(%s, %s)' % (attr, location)
@classmethod
def get_distance(cls, base_query, latitude, longitude, target_range=None,
too_few_results=45, too_many_results=75, too_close=6.0):
"""Recursive left-node-right algorithm for finding a distance that
yields an acceptable number of results.
Stops looking when it either finds an acceptable number of results
or when it gets to ``min_sqrt_distance`` or ``max_sqrt_distance``.
"""
if target_range is None:
target_range = (0, math.sqrt(max_radius_of_earth))
# Get the mid point in the range and expand into the actual distance
# in metres (the range is in sqrt because we're dealing with an area).
mid = (target_range[0] + target_range[1]) / 2.0
distance = mid * mid
# If the range is too narrow, let's stop wasting our own resources.
diff = target_range[1] - target_range[0]
if diff < too_close or diff/mid*100 < too_close:
return distance
# Count how many results are within that distance.
within = cls.within(latitude, longitude, distance)
distance_query = base_query.filter(within)
count = distance_query.count()
# If there are too many results, try again with the bottom half of the range.
# If too few, try again with the top half of the range.
new_range = None
if count > too_many_results and mid > min_sqrt_distance:
new_range = (target_range[0], mid)
elif count < too_few_results and mid < max_sqrt_distance:
new_range = (mid, target_range[1])
if new_range:
return cls.get_distance(
base_query,
latitude,
longitude,
target_range=new_range,
too_few_results=too_few_results,
too_many_results=too_many_results,
too_close=too_close
)
# Otherwise we've found an acceptable distance.
return distance
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment