如何在Django ORM层面计算大圆距离并解决相关报错
我想通过筛选条件获取用户,其中一个核心条件是计算用户间的大圆距离。当前实现抛出错误:TypeError: the float() argument should be a string or a real number, not "F"。我清楚报错原因是F()表达式和geopy的distance函数不兼容,但不知道怎么修复。循环计算所有用户距离的方案性能太差,所以想用annotate实现数据库层面的计算。考虑过写自定义数据库函数,但不确定可行性;也试过GeoDjango,但没找到合适的距离函数。现在用geopy.distance.distance计算距离,作为初学者很困惑,想知道正确的计算参与者间距离的方法。
模型代码
class Participant(models.Model): GENDER_CHOICES = [ ('M', ' Male'), ('F', 'Female'), ('O', 'Other'), ] user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="participant",) first_name = models.CharField(max_length=50) last_name = models.CharField(max_length=50) gender = models.CharField(max_length=1, choices=GENDER_CHOICES) avatar = models.ImageField(upload_to='avatars/') email = models.EmailField(unique=True) likes = models.ManyToManyField('self', related_name='liked_by', symmetrical=False, blank=True) latitude = models.DecimalField(max_digits=9, decimal_places=6) longitude = models.DecimalField(max_digits=9, decimal_places=6)
视图代码
class ParticipantListViewTest(generics.ListAPIView): queryset = Participant.objects.prefetch_related("likes") serializer_class = ParticipantSerializer filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] filterset_fields = ['gender', 'first_name', 'last_name'] search_fields = ['first_name', 'last_name', 'gender'] ordering_fields = ['first_name', 'last_name', 'gender'] def get_queryset(self): queryset = super().get_queryset() max_distance = self.request.query_params.get('distance', None) if max_distance: user = User.objects.get(id=5).participant queryset = queryset.annotate(fact_distance=distance( lonlat(user.longitude, user.latitude), lonlat(F("longitude"), F("latitude"))).km ).filter(fact_distance__lt=float(max_distance)) return queryset
核心问题解析
原代码报错的本质是:geopy的distance函数是在Python层面计算的,需要传入真实数值;但F("longitude")是Django的数据库层面表达式,不会立即求值,而是作为查询语句的一部分,所以geopy无法处理F()对象,导致类型错误。最优方案是让数据库直接计算距离,避免把全量数据拉到Python层处理。
方法1:用数据库原生函数计算(兼容多数数据库)
通用Haversine公式实现
如果不想依赖第三方扩展,可以基于Haversine公式自定义数据库函数,直接在SQL层面计算大圆距离:
from django.db.models import F, Func, Value, FloatField class Haversine(Func): function = 'ACOS' template = """ ACOS( COS(RADIANS(%(lat1)s)) * COS(RADIANS(%(lat2)s)) * COS(RADIANS(%(lon2)s) - RADIANS(%(lon1)s)) + SIN(RADIANS(%(lat1)s)) * SIN(RADIANS(%(lat2)s)) ) * 6371 """ output_field = FloatField() def __init__(self, lat1, lon1, lat2, lon2, **kwargs): super().__init__( lat1=Value(lat1), lon1=Value(lon1), lat2=F(lat2), lon2=F(lon2), **kwargs ) # 修改视图的get_queryset方法 def get_queryset(self): queryset = super().get_queryset() max_distance = self.request.query_params.get('distance', None) if max_distance: user = User.objects.get(id=5).participant queryset = queryset.annotate( fact_distance=Haversine( lat1=user.latitude, lon1=user.longitude, lat2='latitude', lon2='longitude' ) ).filter(fact_distance__lt=float(max_distance)) return queryset
注:6371是地球平均半径(单位:公里),如果需要米为单位,替换为6371000即可。
PostgreSQL+PostGIS优化实现
如果使用PostgreSQL数据库,可以安装PostGIS扩展,用官方提供的ST_DistanceSphere函数(精度更高):
from django.db.models import F, Func, Value, FloatField class Distance(Func): function = 'ST_DistanceSphere' output_field = FloatField() def get_queryset(self): queryset = super().get_queryset() max_distance = self.request.query_params.get('distance', None) if max_distance: user = User.objects.get(id=5).participant queryset = queryset.annotate( fact_distance=Distance( Func(Value(user.longitude), Value(user.latitude), function='ST_MakePoint'), Func(F('longitude'), F('latitude'), function='ST_MakePoint'), ) / 1000 # 转成公里 ).filter(fact_distance__lt=float(max_distance)) return queryset
方法2:GeoDjango标准实现
如果项目可以引入GeoDjango,这是最规范的方案:
第一步:修改模型
将原有的latitude和longitude字段替换为PointField(地理坐标系):
from django.contrib.gis.db import models class Participant(models.Model): # 保留其他字段不变 location = models.PointField(geography=True) # geography=True启用地理坐标系,自动计算大圆距离
运行迁移后,需要将原有经纬度数据批量导入到location字段中(可以写脚本处理)。
第二步:视图中计算距离
from django.contrib.gis.db.models.functions import Distance def get_queryset(self): queryset = super().get_queryset() max_distance = self.request.query_params.get('distance', None) if max_distance: user = User.objects.get(id=5).participant # Distance返回单位为米,转成公里需乘以1000 queryset = queryset.annotate( fact_distance=Distance('location', user.location) ).filter(fact_distance__lt=float(max_distance)*1000) return queryset
GeoDjango会自动调用数据库的地理函数,性能和精度都有保障。
内容的提问来源于stack exchange,提问作者Valentin Suyarov

