[locations] Add distance and speed calculations
This commit is contained in:
0
vrobbler/apps/locations/management/__init__.py
Normal file
0
vrobbler/apps/locations/management/__init__.py
Normal 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"))
|
||||
@ -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):
|
||||
|
||||
198
vrobbler/apps/locations/utils.py
Normal file
198
vrobbler/apps/locations/utils.py
Normal 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
|
||||
@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user