[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__)
|
logger = logging.getLogger(__name__)
|
||||||
BNULL = {"blank": True, "null": True}
|
BNULL = {"blank": True, "null": True}
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
GEOLOC_ACCURACY = int(getattr(settings, "GEOLOC_ACCURACY", 4))
|
GEOLOC_ACCURACY = int(getattr(settings, "GEOLOC_ACCURACY", 4))
|
||||||
GEOLOC_PROXIMITY = Decimal(getattr(settings, "GEOLOC_PROXIMITY", "0.0001"))
|
GEOLOC_PROXIMITY = Decimal(getattr(settings, "GEOLOC_PROXIMITY", "0.0001"))
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class GeoLocationLogData(BaseLogData, WithPeopleLogData):
|
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):
|
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:
|
def gpslogger_scrobble_location(data_dict: dict, user_id: int) -> Scrobble:
|
||||||
|
from locations.utils import detect_movement
|
||||||
|
|
||||||
location = GeoLocation.find_or_create(data_dict)
|
location = GeoLocation.find_or_create(data_dict)
|
||||||
|
|
||||||
timestamp = pendulum.parse(data_dict.get("time", timezone.now()))
|
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")]
|
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():
|
if "gps_updates" not in scrobble.log.keys():
|
||||||
scrobble.log["gps_updates"] = []
|
scrobble.log["gps_updates"] = []
|
||||||
|
|
||||||
@ -1076,6 +1096,7 @@ def gpslogger_scrobble_location(data_dict: dict, user_id: int) -> Scrobble:
|
|||||||
"timestamp": extra_data.get("timestamp"),
|
"timestamp": extra_data.get("timestamp"),
|
||||||
"raw_timestamp": data_dict.get("time"),
|
"raw_timestamp": data_dict.get("time"),
|
||||||
"media_type": Scrobble.MediaType.GEO_LOCATION,
|
"media_type": Scrobble.MediaType.GEO_LOCATION,
|
||||||
|
"movement_data": movement_data,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user