diff --git a/tests/trails_tests/test_gpx_importer.py b/tests/trails_tests/test_gpx_importer.py index 7ede397..10c6428 100644 --- a/tests/trails_tests/test_gpx_importer.py +++ b/tests/trails_tests/test_gpx_importer.py @@ -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): diff --git a/vrobbler/apps/scrobbles/importers/trail_gpx.py b/vrobbler/apps/scrobbles/importers/trail_gpx.py index 835ab03..7b47a33 100644 --- a/vrobbler/apps/scrobbles/importers/trail_gpx.py +++ b/vrobbler/apps/scrobbles/importers/trail_gpx.py @@ -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 diff --git a/vrobbler/apps/trails/migrations/0009_trail_route_waypoint.py b/vrobbler/apps/trails/migrations/0009_trail_route_waypoint.py new file mode 100644 index 0000000..01b1a05 --- /dev/null +++ b/vrobbler/apps/trails/migrations/0009_trail_route_waypoint.py @@ -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), + ), + ] diff --git a/vrobbler/apps/trails/models.py b/vrobbler/apps/trails/models.py index 8827c78..33a5dc9 100644 --- a/vrobbler/apps/trails/models.py +++ b/vrobbler/apps/trails/models.py @@ -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):