StaySense/backend/score_engine.py

153 lines
3.9 KiB
Python

import datetime as dt
import math
import uuid
from dataclasses import dataclass
NRW_HOLIDAYS_2026 = {
dt.date(2026, 1, 1),
dt.date(2026, 4, 3),
dt.date(2026, 4, 6),
dt.date(2026, 5, 1),
dt.date(2026, 5, 14),
dt.date(2026, 5, 25),
dt.date(2026, 6, 4),
dt.date(2026, 10, 3),
dt.date(2026, 11, 1),
dt.date(2026, 12, 25),
dt.date(2026, 12, 26),
}
POLICE_POINTS = [
(51.2507, 6.9751), # Mettmann
(51.2965, 6.8494), # Ratingen
(51.3398, 7.0438), # Velbert
]
FIRE_POINTS = [
(51.2518, 6.9800),
(51.2937, 6.8568),
(51.3314, 7.0540),
]
HOSPITAL_POINTS = [
(51.2556, 6.9723),
(51.2891, 6.8457),
(51.3321, 7.0403),
]
INDUSTRIAL_ZONES = [
(51.2348, 6.9902),
(51.3022, 6.8340),
]
COMMERCIAL_ZONES = [
(51.2512, 6.9878),
(51.2934, 6.8541),
]
NATURE_ZONES = [
(51.2715, 6.9440),
(51.3188, 7.0274),
]
PARKING_ZONES = [
(51.2499, 6.9831),
]
MAIN_ROAD_POINTS = [
(51.2524, 6.9921),
(51.2951, 6.8615),
]
@dataclass
class Factor:
key: str
label: str
points: float
def haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
r = 6371000
p1 = math.radians(lat1)
p2 = math.radians(lat2)
dp = math.radians(lat2 - lat1)
dl = math.radians(lon2 - lon1)
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * r * math.asin(math.sqrt(a))
def nearest_distance_m(lat: float, lon: float, points: list[tuple[float, float]]) -> int:
return int(min(haversine_m(lat, lon, p_lat, p_lon) for p_lat, p_lon in points))
def classify_area(lat: float, lon: float) -> str:
candidates = [
("industrial", nearest_distance_m(lat, lon, INDUSTRIAL_ZONES)),
("commercial", nearest_distance_m(lat, lon, COMMERCIAL_ZONES)),
("nature", nearest_distance_m(lat, lon, NATURE_ZONES)),
("parking", nearest_distance_m(lat, lon, PARKING_ZONES)),
]
best_type, best_dist = min(candidates, key=lambda x: x[1])
if best_dist <= 400:
return best_type
return "residential"
def classify_road(lat: float, lon: float) -> str:
if nearest_distance_m(lat, lon, MAIN_ROAD_POINTS) < 250:
return "primary"
return "residential"
def spot_id_for(lat: float, lon: float) -> str:
key = f"{lat:.4f}:{lon:.4f}"
return str(uuid.uuid5(uuid.NAMESPACE_DNS, f"staysense:{key}"))
def score_area_modifier(area_type: str) -> Factor:
mapping = {
"residential": Factor("area", "Wohngebiet", -10),
"industrial": Factor("area", "Industriegebiet", 10),
"commercial": Factor("area", "Gewerbegebiet", 6),
"parking": Factor("area", "Parkplatzumfeld", -5),
"nature": Factor("area", "Naturnah", 8),
}
return mapping.get(area_type, Factor("area", "Umgebung", 0))
def night_window_for(reference: dt.datetime) -> tuple[dt.datetime, dt.datetime]:
ref = reference.replace(second=0, microsecond=0)
if ref.hour >= 22:
start = ref.replace(hour=22, minute=0)
elif ref.hour < 6:
start = (ref - dt.timedelta(days=1)).replace(hour=22, minute=0)
else:
start = ref.replace(hour=22, minute=0)
end = start + dt.timedelta(hours=8)
return start, end
def weekend_or_holiday(night_start: dt.datetime) -> bool:
weekday = night_start.weekday() # 0=Mon, 4=Fri
if weekday in (4, 5):
return True
if night_start.date() in NRW_HOLIDAYS_2026:
return True
if (night_start + dt.timedelta(days=1)).date() in NRW_HOLIDAYS_2026:
return True
return False
def decay(age_days: float, half_life_days: float) -> float:
if age_days <= 0:
return 1.0
return 0.5 ** (age_days / half_life_days)
def clamp_score(value: float) -> int:
return max(0, min(100, int(round(value))))
def ampel(score: int) -> str:
if score >= 70:
return "green"
if score >= 45:
return "yellow"
return "red"