153 lines
3.9 KiB
Python
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"
|