[locations] Add distance and speed calculations
All checks were successful
build & deploy / test (push) Successful in 1m57s
build & deploy / deploy (push) Successful in 27s

This commit is contained in:
2026-05-01 11:50:24 -04:00
parent acf0c342bf
commit de733d5893
6 changed files with 340 additions and 2 deletions

View File

@ -0,0 +1,113 @@
import logging
from django.core.management.base import BaseCommand
from scrobbles.models import Scrobble
from locations.utils import detect_movement
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = "Run movement detection on all location scrobbles retroactively"
def add_arguments(self, parser):
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would be updated without making changes",
)
parser.add_argument(
"--batch-size",
type=int,
default=100,
help="Number of scrobbles to process at once (default: 100)",
)
parser.add_argument(
"--user-id",
type=int,
help="Only process scrobbles for a specific user ID",
)
def handle(self, *args, **options):
dry_run = options["dry_run"]
batch_size = options["batch_size"]
user_id = options.get("user_id")
# Get all location scrobbles ordered by timestamp
queryset = Scrobble.objects.filter(
media_type=Scrobble.MediaType.GEO_LOCATION,
).order_by("timestamp")
if user_id:
queryset = queryset.filter(user_id=user_id)
total_count = queryset.count()
self.stdout.write(f"Processing {total_count} location scrobbles...")
processed = 0
updated = 0
errors = 0
# Track previous scrobble per user for calculated speed
previous_scrobbles = {}
for scrobble in queryset.iterator(chunk_size=batch_size):
try:
if not scrobble.geo_location:
processed += 1
continue
# Get previous scrobble for this user
prev = previous_scrobbles.get(scrobble.user_id)
# Run movement detection
movement_data = detect_movement(scrobble, prev)
# Update previous scrobble tracker
previous_scrobbles[scrobble.user_id] = scrobble
# Check if we need to update
log = scrobble.log or {}
existing_detection = log.get("movement_detection", {})
if existing_detection != movement_data:
if not dry_run:
# Store movement data in log
scrobble.log["movement_detection"] = movement_data
scrobble.save(update_fields=["log"])
updated += 1
self.stdout.write(
f"[{processed}/{total_count}] "
f"User {scrobble.user_id}: "
f"{movement_data['movement_type']} "
f"({movement_data['confidence']} confidence, "
f"{movement_data['detection_method']})"
)
else:
self.stdout.write(f"[{processed}/{total_count}] No change needed")
processed += 1
if processed % 100 == 0:
self.stdout.write(
f"Progress: {processed}/{total_count} "
f"({processed * 100 // total_count}%)"
)
except Exception as e:
errors += 1
logger.error(
f"Error processing scrobble {scrobble.id}: {e}",
exc_info=True,
)
self.stderr.write(f"Error processing scrobble {scrobble.id}: {e}")
self.stdout.write(
self.style.SUCCESS(
f"Done! Processed: {processed}, "
f"Updated: {updated}, Errors: {errors}"
)
)
if dry_run:
self.stdout.write(self.style.WARNING("Dry run - no changes were made"))

View File

@ -13,14 +13,20 @@ from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
logger = logging.getLogger(__name__)
BNULL = {"blank": True, "null": True}
User = get_user_model()
GEOLOC_ACCURACY = int(getattr(settings, "GEOLOC_ACCURACY", 4))
GEOLOC_PROXIMITY = Decimal(getattr(settings, "GEOLOC_PROXIMITY", "0.0001"))
@dataclass
class GeoLocationLogData(BaseLogData, WithPeopleLogData):
pass
is_moving: bool = False
movement_type: str = "unknown"
confidence: str = "low"
speed_mps: float = 0.0
speed_accuracy: float = 0.0
detection_method: str = "unknown"
activity: str = ""
detected_at: str = ""
class GeoLocation(ScrobblableMixin):

View File

@ -0,0 +1,198 @@
import logging
from typing import Optional
from django.utils import timezone
from scrobbles.models import Scrobble
logger = logging.getLogger(__name__)
# Speed thresholds in meters per second
SPEED_THRESHOLDS = {
"at_rest": 0.5,
"walking": 1.5,
"running": 3.5,
"bicycling": 9.0,
"driving": float("inf"),
}
# Activity mapping from Android activity recognition
ACTIVITY_MAPPING = {
"still": "at_rest",
"on_foot": "walking",
"walking": "walking",
"running": "running",
"on_bicycle": "bicycling",
"in_vehicle": "driving",
"unknown": "unknown",
}
def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Calculate the great-circle distance between two points in meters."""
from math import radians, cos, sin, asin, sqrt
lat1, lon1, lat2, lon2 = map(radians, [lat1, lon1, lat2, lon2])
dlat = lat2 - lat1
dlon = lon2 - lon1
a = sin(dlat / 2) ** 2 + cos(lat1) * cos(lat2) * sin(dlon / 2) ** 2
c = 2 * asin(sqrt(a))
r = 6371000 # Radius of earth in meters
return c * r
def detect_movement(
current_scrobble: Scrobble,
previous_scrobble: Optional[Scrobble] = None,
) -> dict:
"""
Analyze location scrobble to determine if moving and mode of transport.
Returns a dict shaped like GeoLocationLogData that can be stored in scrobble.log:
{
"is_moving": bool,
"movement_type": str,
"confidence": str,
"speed_mps": float,
"speed_accuracy": float,
"detection_method": str,
"activity": str,
"detected_at": str,
}
"""
result = {
"is_moving": False,
"movement_type": "unknown",
"confidence": "low",
"speed_mps": 0.0,
"speed_accuracy": 0.0,
"detection_method": "unknown",
"activity": "",
"detected_at": timezone.now().isoformat(),
}
if current_scrobble.media_type != Scrobble.MediaType.GEO_LOCATION:
return result
# Get GPS data from log
log_data = current_scrobble.log or {}
gps_data = log_data.get("gps_data", {})
# Method 1: Check Android activity recognition (highest confidence)
activity = gps_data.get("activity", "")
if activity and activity in ACTIVITY_MAPPING:
movement_type = ACTIVITY_MAPPING.get(activity, "unknown")
result.update(
{
"is_moving": movement_type != "at_rest",
"movement_type": movement_type,
"confidence": "high",
"detection_method": "activity_recognition",
"activity": activity,
}
)
return result
# Method 2: Use speed from GPS
speed = gps_data.get("speed")
accuracy = gps_data.get("accuracy", 100.0)
if speed is not None:
try:
speed = float(speed)
result["speed_mps"] = speed
result["speed_accuracy"] = float(accuracy)
# Determine movement type from speed
movement_type = "at_rest"
is_moving = False
if speed >= SPEED_THRESHOLDS["walking"]:
movement_type = "walking"
is_moving = True
if speed >= SPEED_THRESHOLDS["running"]:
movement_type = "running"
is_moving = True
if speed >= SPEED_THRESHOLDS["bicycling"]:
movement_type = "bicycling"
is_moving = True
if speed >= SPEED_THRESHOLDS["driving"]:
movement_type = "driving"
is_moving = True
# Confidence based on GPS accuracy
confidence = "low"
if accuracy <= 10:
confidence = "high"
elif accuracy <= 30:
confidence = "medium"
result.update(
{
"is_moving": is_moving,
"movement_type": movement_type,
"confidence": confidence,
"detection_method": "gps_speed",
}
)
return result
except (ValueError, TypeError):
pass
# Method 3: Calculate speed from distance/time between scrobbles
if not previous_scrobble:
# Try to get previous scrobble from the same user
previous_scrobble = (
Scrobble.objects.filter(
user=current_scrobble.user,
media_type=Scrobble.MediaType.GEO_LOCATION,
timestamp__lt=current_scrobble.timestamp,
)
.order_by("-timestamp")
.first()
)
if previous_scrobble and previous_scrobble.geo_location:
time_diff = (
current_scrobble.timestamp - previous_scrobble.timestamp
).total_seconds()
if time_diff > 0:
distance = haversine_distance(
previous_scrobble.geo_location.lat,
previous_scrobble.geo_location.lon,
current_scrobble.geo_location.lat,
current_scrobble.geo_location.lon,
)
speed = distance / time_diff
result["speed_mps"] = round(speed, 2)
# Determine movement type from calculated speed
movement_type = "at_rest"
is_moving = False
if speed >= SPEED_THRESHOLDS["walking"]:
movement_type = "walking"
is_moving = True
if speed >= SPEED_THRESHOLDS["running"]:
movement_type = "running"
is_moving = True
if speed >= SPEED_THRESHOLDS["bicycling"]:
movement_type = "bicycling"
is_moving = True
if speed >= SPEED_THRESHOLDS["driving"]:
movement_type = "driving"
is_moving = True
# Lower confidence for calculated speed
result.update(
{
"is_moving": is_moving,
"movement_type": movement_type,
"confidence": "low",
"detection_method": "calculated_speed",
}
)
return result
return result

View File

@ -1034,6 +1034,8 @@ def manual_scrobble_twitch_channel(
def gpslogger_scrobble_location(data_dict: dict, user_id: int) -> Scrobble:
from locations.utils import detect_movement
location = GeoLocation.find_or_create(data_dict)
timestamp = pendulum.parse(data_dict.get("time", timezone.now()))
@ -1052,6 +1054,24 @@ def gpslogger_scrobble_location(data_dict: dict, user_id: int) -> Scrobble:
provider = LOCATION_PROVIDERS[data_dict.get("prov")]
# Store GPS data in log
if "gps_data" not in scrobble.log.keys():
scrobble.log["gps_data"] = {}
gps_data = {
"speed": data_dict.get("speed"),
"accuracy": data_dict.get("accuracy"),
"direction": data_dict.get("direction"),
"activity": data_dict.get("activity"),
"provider": provider,
"battery": data_dict.get("battery"),
}
scrobble.log["gps_data"] = gps_data
# Run movement detection using utils function
movement_data = detect_movement(scrobble)
scrobble.log["movement_detection"] = movement_data
if "gps_updates" not in scrobble.log.keys():
scrobble.log["gps_updates"] = []
@ -1076,6 +1096,7 @@ def gpslogger_scrobble_location(data_dict: dict, user_id: int) -> Scrobble:
"timestamp": extra_data.get("timestamp"),
"raw_timestamp": data_dict.get("time"),
"media_type": Scrobble.MediaType.GEO_LOCATION,
"movement_data": movement_data,
},
)