[trails] Use half way waypoints
This commit is contained in:
@ -9,6 +9,7 @@ from django.core.files import File
|
||||
from locations.models import GeoLocation
|
||||
from scrobbles.importers.trail_gpx import (
|
||||
compute_trail_stats,
|
||||
find_route_waypoint,
|
||||
import_trail_gpx,
|
||||
parse_trackpoints,
|
||||
)
|
||||
@ -182,6 +183,24 @@ class TestTrailGPXImportModel:
|
||||
assert imp.processed_finished is not None
|
||||
|
||||
|
||||
class TestFindRouteWaypoint:
|
||||
def test_returns_halfway_point(self, sample_gpx_path):
|
||||
result = parse_trackpoints(sample_gpx_path)
|
||||
pt = find_route_waypoint(result["points"])
|
||||
assert pt is not None
|
||||
lat, lon = pt
|
||||
assert lat == pytest.approx(34.177853, abs=0.001)
|
||||
assert lon == pytest.approx(-118.829944, abs=0.001)
|
||||
|
||||
def test_returns_last_point_for_short_track(self):
|
||||
points = [(34.0, -118.0, None, None), (34.001, -118.001, None, None)]
|
||||
pt = find_route_waypoint(points)
|
||||
assert pt == (34.001, -118.001)
|
||||
|
||||
def test_returns_none_for_empty_points(self):
|
||||
assert find_route_waypoint([]) is None
|
||||
|
||||
|
||||
class TestFindByTrailhead:
|
||||
def test_exact_match(self, db):
|
||||
geo = GeoLocation.objects.create(lat=34.190598, lon=-118.844015)
|
||||
@ -206,6 +225,49 @@ class TestFindByTrailhead:
|
||||
found = Trail.find_by_trailhead(34.190598, -118.844015)
|
||||
assert found is None
|
||||
|
||||
def test_same_trailhead_same_route_matches(self, db):
|
||||
geo = GeoLocation.objects.create(lat=34.190598, lon=-118.844015)
|
||||
trail = Trail.objects.create(
|
||||
title="Same Route Trail",
|
||||
trailhead_location=geo,
|
||||
route_lat=34.192167,
|
||||
route_lon=-118.843143,
|
||||
)
|
||||
found = Trail.find_by_trailhead(
|
||||
34.190598, -118.844015,
|
||||
route_lat=34.192167, route_lon=-118.843143,
|
||||
tolerance_m=100,
|
||||
)
|
||||
assert found == trail
|
||||
|
||||
def test_same_trailhead_different_route_does_not_match(self, db):
|
||||
geo = GeoLocation.objects.create(lat=34.190598, lon=-118.844015)
|
||||
Trail.objects.create(
|
||||
title="Different Route Trail",
|
||||
trailhead_location=geo,
|
||||
route_lat=34.200000,
|
||||
route_lon=-118.850000,
|
||||
)
|
||||
found = Trail.find_by_trailhead(
|
||||
34.190598, -118.844015,
|
||||
route_lat=34.192167, route_lon=-118.843143,
|
||||
tolerance_m=100,
|
||||
)
|
||||
assert found is None
|
||||
|
||||
def test_legacy_trail_without_route_still_matches(self, db):
|
||||
geo = GeoLocation.objects.create(lat=34.190598, lon=-118.844015)
|
||||
trail = Trail.objects.create(
|
||||
title="Legacy Trail",
|
||||
trailhead_location=geo,
|
||||
)
|
||||
found = Trail.find_by_trailhead(
|
||||
34.190598, -118.844015,
|
||||
route_lat=34.192167, route_lon=-118.843143,
|
||||
tolerance_m=100,
|
||||
)
|
||||
assert found == trail
|
||||
|
||||
|
||||
class TestFindOrCreate:
|
||||
def test_find_existing(self, db):
|
||||
|
||||
@ -27,6 +27,27 @@ def haversine(lat1, lon1, lat2, lon2):
|
||||
return R * 2 * asin(sqrt(a))
|
||||
|
||||
|
||||
def find_route_waypoint(points):
|
||||
if not points:
|
||||
return None
|
||||
total = 0.0
|
||||
for i in range(1, len(points)):
|
||||
lat1, lon1 = points[i - 1][0], points[i - 1][1]
|
||||
lat2, lon2 = points[i][0], points[i][1]
|
||||
if lat1 is not None and lon1 is not None and lat2 is not None and lon2 is not None:
|
||||
total += haversine(lat1, lon1, lat2, lon2)
|
||||
halfway = total / 2.0
|
||||
cumulative = 0.0
|
||||
for i in range(1, len(points)):
|
||||
lat1, lon1 = points[i - 1][0], points[i - 1][1]
|
||||
lat2, lon2 = points[i][0], points[i][1]
|
||||
if lat1 is not None and lon1 is not None and lat2 is not None and lon2 is not None:
|
||||
cumulative += haversine(lat1, lon1, lat2, lon2)
|
||||
if cumulative >= halfway:
|
||||
return (lat2, lon2)
|
||||
return (points[-1][0], points[-1][1])
|
||||
|
||||
|
||||
def compute_trail_stats(points):
|
||||
distance_m = 0.0
|
||||
elevation_gain = 0.0
|
||||
@ -221,15 +242,27 @@ def import_trail_gpx(file_path, user_id, original_filename=None):
|
||||
logger.warning(f"No timestamps in {file_path}")
|
||||
return []
|
||||
|
||||
route_pt = find_route_waypoint(points)
|
||||
|
||||
geo, _ = GeoLocation.objects.get_or_create(
|
||||
lat=round(first_lat, 6),
|
||||
lon=round(first_lon, 6),
|
||||
defaults={"altitude": None},
|
||||
)
|
||||
|
||||
trail = Trail.find_by_trailhead(first_lat, first_lon, tolerance_m=100)
|
||||
trail = Trail.find_by_trailhead(
|
||||
first_lat, first_lon,
|
||||
route_lat=route_pt[0] if route_pt else None,
|
||||
route_lon=route_pt[1] if route_pt else None,
|
||||
tolerance_m=100,
|
||||
)
|
||||
if not trail:
|
||||
trail = Trail.objects.create(title=track_name, trailhead_location=geo)
|
||||
trail = Trail.objects.create(
|
||||
title=track_name,
|
||||
trailhead_location=geo,
|
||||
route_lat=route_pt[0] if route_pt else None,
|
||||
route_lon=route_pt[1] if route_pt else None,
|
||||
)
|
||||
|
||||
timestamp = first_time
|
||||
stop_timestamp = last_time
|
||||
|
||||
23
vrobbler/apps/trails/migrations/0009_trail_route_waypoint.py
Normal file
23
vrobbler/apps/trails/migrations/0009_trail_route_waypoint.py
Normal file
@ -0,0 +1,23 @@
|
||||
# Generated by Django 4.2.29 on 2026-05-21 19:58
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("trails", "0008_alter_trail_genre"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="trail",
|
||||
name="route_lat",
|
||||
field=models.FloatField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="trail",
|
||||
name="route_lon",
|
||||
field=models.FloatField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -61,6 +61,8 @@ class Trail(ScrobblableMixin):
|
||||
on_delete=models.DO_NOTHING,
|
||||
**BNULL,
|
||||
)
|
||||
route_lat = models.FloatField(**BNULL)
|
||||
route_lon = models.FloatField(**BNULL)
|
||||
strava_id = models.CharField(max_length=255, **BNULL)
|
||||
trailforks_id = models.CharField(max_length=255, **BNULL)
|
||||
alltrails_id = models.CharField(max_length=255, **BNULL)
|
||||
@ -91,7 +93,7 @@ class Trail(ScrobblableMixin):
|
||||
return trail
|
||||
|
||||
@classmethod
|
||||
def find_by_trailhead(cls, lat, lon, tolerance_m=100):
|
||||
def find_by_trailhead(cls, lat, lon, tolerance_m=100, route_lat=None, route_lon=None):
|
||||
candidates = cls.objects.filter(
|
||||
trailhead_location__isnull=False,
|
||||
).select_related("trailhead_location")
|
||||
@ -100,9 +102,14 @@ class Trail(ScrobblableMixin):
|
||||
for trail in candidates:
|
||||
loc = trail.trailhead_location
|
||||
d = haversine(lat, lon, loc.lat, loc.lon)
|
||||
if d < best_dist and d <= tolerance_m:
|
||||
best = trail
|
||||
best_dist = d
|
||||
if d >= best_dist or d > tolerance_m:
|
||||
continue
|
||||
if route_lat is not None and trail.route_lat is not None:
|
||||
route_dist = haversine(route_lat, route_lon, trail.route_lat, trail.route_lon)
|
||||
if route_dist > tolerance_m:
|
||||
continue
|
||||
best = trail
|
||||
best_dist = d
|
||||
return best
|
||||
|
||||
def scrobbles(self, user_id):
|
||||
|
||||
Reference in New Issue
Block a user