Files
vrobbler/vrobbler/apps/locations/utils.py
Colin Powell f1c777d4ef
Some checks failed
build / test (push) Has been cancelled
[discgolf] Add trail maps and addresses
2026-06-21 01:02:43 -04:00

319 lines
9.6 KiB
Python

import logging
import xml.etree.ElementTree as ET
from typing import Optional
from urllib.parse import urlencode
import requests
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,
"running": 2.5,
"bicycling": 5,
"driving": 10,
}
# 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
NOMINATIM_URL = "https://nominatim.openstreetmap.org/reverse"
USER_AGENT = "Vrobbler/1.0 (https://github.com/secstate/vrobbler)"
def reverse_geocode(lat: float, lon: float) -> Optional[dict]:
"""Reverse geocode lat/lon to an address using Nominatim.
Returns a dict with address fields, or None on failure.
Nominatim usage policy: max 1 request per second.
"""
params = {
"lat": lat,
"lon": lon,
"format": "json",
}
headers = {"User-Agent": USER_AGENT}
try:
resp = requests.get(NOMINATIM_URL, params=params, headers=headers, timeout=10)
resp.raise_for_status()
except requests.RequestException as e:
logger.warning("Failed to reverse geocode %s,%s: %s", lat, lon, e)
return None
data = resp.json()
if "error" in data:
logger.warning("Nominatim error for %s,%s: %s", lat, lon, data["error"])
return None
address = data.get("address", {})
return {
"street": address.get("road")
or address.get("pedestrian")
or address.get("footway"),
"city": address.get("city")
or address.get("town")
or address.get("village")
or address.get("hamlet"),
"state_province": address.get("state"),
"postal_code": address.get("postcode"),
"country": address.get("country"),
}
NWS_URL = "https://forecast.weather.gov/MapClick.php"
def fetch_current_weather(lat: float, lon: float) -> Optional[dict]:
"""Fetch current weather from NWS DWML feed for the given lat/lon.
Returns dict with 'temp' and 'description' keys, or None on failure.
"""
params = {
"lat": lat,
"lon": lon,
"unit": 0,
"lg": "english",
"FcstType": "dwml",
}
url = f"{NWS_URL}?{urlencode(params)}"
try:
resp = requests.get(url, timeout=10)
resp.raise_for_status()
except requests.RequestException as e:
logger.warning("Failed to fetch NWS weather data: %s", e)
return None
try:
root = ET.fromstring(resp.content)
except ET.ParseError as e:
logger.warning("Failed to parse NWS XML: %s", e)
return None
# Find current observations data block
data_blocks = root.findall("data")
obs_block = None
for block in data_blocks:
if block.get("type") == "current observations":
obs_block = block
break
if obs_block is None:
logger.warning("No current observations block in NWS response")
return None
params_el = obs_block.find("parameters")
if params_el is None:
return None
# Temperature (apparent / feels-like)
temp = None
for temp_el in params_el.findall("temperature"):
if temp_el.get("type") == "apparent":
val_el = temp_el.find("value")
if val_el is not None and val_el.text:
try:
temp = int(round(float(val_el.text)))
except (ValueError, TypeError):
pass
break
# Weather description
description = None
weather_el = params_el.find("weather")
if weather_el is not None:
cond_el = weather_el.find("weather-conditions")
if cond_el is not None:
description = cond_el.get("weather-summary")
if temp is None and description is None:
logger.warning("No weather data found in NWS response")
return None
return {"temp": temp, "description": description}