""" app.py — Nyvra Safety API v4.1 ================================== Dart contract (read from source): GET /predict_area?latitude=&longitude= → { safety_score:int, danger_level:str, danger_label:int, factors:[str] } GET /analyze_route?origin_lat=&origin_lng=&dest_lat=&dest_lng=&time= → { overall_score:int, routes:[ { name, duration, distance, safety_score:int, factors:[str], is_recommended:bool } ] } GET /heatmap_data?latitude=&longitude=&radius_km=&step_km= → { points:[ { lat, lng, risk:"Low"|"Medium"|"High", score:int } ], center_lat, center_lng } POST /ai { message:str } → { reply:str } GET /health Scoring v4.1: base = safe_p^0.55 * 100 * (1 - high_p^0.50) — weighted expected value thresholds: >=85 Low(GREEN) 45-84 Medium(YELLOW) <45 High(RED) time penalty (deterministic, no safe_p dependency): 8PM-4AM -30 4AM -25 5-7AM -15 6-8PM -10 else 0 Both GET and POST endpoints exist for every action endpoint because safety_api_service.dart uses POST while heatmap_service.dart uses GET. """ import datetime import logging import math import os import pickle import warnings from concurrent.futures import ThreadPoolExecutor, as_completed from typing import List, Optional import numpy as np import pandas as pd from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from geopy.distance import geodesic from pydantic import BaseModel warnings.filterwarnings("ignore") logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") log = logging.getLogger("nyvra") # ── Optional deps ────────────────────────────────────────────────────────── _groq_key = os.environ.get("GROQ_API_KEY") try: from groq import Groq groq_client = Groq(api_key=_groq_key) log.info("Groq client ready") except Exception as _ge: groq_client = None log.warning("Groq unavailable: %s", _ge) try: import httpx as _httpx _HTTPX_OK = True except ImportError: _HTTPX_OK = False # ── App ──────────────────────────────────────────────────────────────────── app = FastAPI(title="Nyvra Safety API", version="4.1") app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], ) @app.get("/") def root(): return { "app": "Nyvra Safety API", "version": "4.1", "status": "running", "endpoints": [ "GET /health", "GET /predict_area?latitude=&longitude=", "POST /predict_area", "GET /analyze_route?origin_lat=&origin_lng=&dest_lat=&dest_lng=", "POST /analyze_route", "GET /heatmap_data?latitude=&longitude=&radius_km=&step_km=", "POST /ai" ] } MODELS_DIR = os.path.join(os.path.dirname(__file__), "models") R_0P5 = 0.5 / 6371.0 R_1KM = 1.0 / 6371.0 R_2KM = 2.0 / 6371.0 # ══════════════════════════════════════════════════════════════════════════ # MODEL LOADING # ══════════════════════════════════════════════════════════════════════════ def _load(name: str): path = os.path.join(MODELS_DIR, f"{name}.pkl") if not os.path.exists(path): raise FileNotFoundError(f"'{path}' not found") with open(path, "rb") as f: return pickle.load(f) def _try_load(name: str): try: return _load(name), True except Exception as e: log.warning("Could not load %s: %s", name, e) return None, False print("=" * 55) print("Loading Nyvra models ...") scaler = _load("scaler") kmeans = _load("kmeans") center_lat = float(_load("center_lat")) center_lon = float(_load("center_lon")) ball_tree = _load("ball_tree") X_columns, _ok = _try_load("X_columns") if not _ok: X_columns, _ok2 = _try_load("feature_columns") if not _ok2: raise RuntimeError("Neither X_columns.pkl nor feature_columns.pkl found.") N_FEATURES = scaler.n_features_in_ log.info("Scaler expects %d features, X_columns has %d", N_FEATURES, len(X_columns)) lgb_model, lgb_ok = _try_load("lgb_model") xgb_model, xgb_ok = _try_load("xgb_model") rf_model, rf_ok = _try_load("rf_model") et_model, et_ok = _try_load("et_model") if not any([lgb_ok, xgb_ok, rf_ok]): raise RuntimeError("No usable model found (lgb/xgb/rf all missing).") _model_pool: List[tuple] = [] if lgb_ok: _model_pool.append((lgb_model, True, "LightGBM")) if xgb_ok: _model_pool.append((xgb_model, False, "XGBoost")) if rf_ok: _model_pool.append((rf_model, False, "RandomForest")) if et_ok: _model_pool.append((et_model, False, "ExtraTrees")) ENSEMBLE_OK = len(_model_pool) >= 2 print(f"Models : {[m[2] for m in _model_pool]}") print(f"Ensemble : {ENSEMBLE_OK}") print(f"Features : {N_FEATURES}") print(f"Center : ({center_lat:.4f}, {center_lon:.4f})") print("=" * 55) # ══════════════════════════════════════════════════════════════════════════ # SCHEMAS — field names must match Dart JSON keys exactly # ══════════════════════════════════════════════════════════════════════════ class AreaRequest(BaseModel): latitude: float longitude: float hour: Optional[int] = None month: Optional[int] = None is_weekend: Optional[int] = None class RouteRequest(BaseModel): origin_lat: float origin_lng: float dest_lat: float dest_lng: float time: Optional[str] = "Now" class AreaResponse(BaseModel): safety_score: int # main.dart: _areaData['safety_score'] danger_level: str # main.dart: _areaData['danger_level'] "Low"|"Medium"|"High" danger_label: int # main.dart: _areaData['danger_label'] 0|1|2 factors: List[str] # main.dart: _areaData['factors'] class RouteResult(BaseModel): name: str # trip_planning: RouteOption.name duration: str # trip_planning: RouteOption.duration distance: str # trip_planning: RouteOption.distance safety_score: int # trip_planning: RouteOption.safetyScore factors: List[str] is_recommended: bool # trip_planning: RouteOption.isRecommended class RouteResponse(BaseModel): overall_score: int # trip_planning parses this routes: List[RouteResult] class HeatmapPoint(BaseModel): lat: float # heatmap_service: HeatmapPoint.lat lng: float # heatmap_service: HeatmapPoint.lng risk: str # heatmap_screen: _riskFill() switches on "Low"|"Medium"|"High" score: int # heatmap_screen: p.score for circle radius class HeatmapResponse(BaseModel): points: List[HeatmapPoint] center_lat: float center_lng: float class AiRequest(BaseModel): message: str # chatbot_ui: {"message": msg} class AiResponse(BaseModel): reply: str # chatbot_ui: data["reply"] # ══════════════════════════════════════════════════════════════════════════ # SCORING # ══════════════════════════════════════════════════════════════════════════ def _time_penalty(hour: int) -> int: """ Deterministic penalty by hour — no safe_p dependency. 8PM-4AM: -30 4AM: -25 5-7AM: -15 6-8PM: -10 else: 0 """ if hour >= 20 or hour < 4: return 30 if hour == 4: return 25 if 5 <= hour < 7: return 15 if 18 <= hour < 20: return 10 return 0 def _score_to_label(score: int) -> tuple: """ Consistent thresholds used across ALL endpoints. heatmap_screen.dart has its own route-segment thresholds (>=70 green, >=45 orange, <45 red) inside Flutter — those are independent of this. """ if score >= 85: return 0, "Low" elif score >= 45: return 1, "Medium" else: return 2, "High" def _score_to_factors(score: int, density: int, hour: int = 12) -> List[str]: if score >= 85: factors = ["Well-lit streets", "High foot traffic"] elif score >= 70: factors = ["Mostly safe area", "Some foot traffic"] elif score >= 55: factors = ["Moderate foot traffic", "Some isolated roads"] elif score >= 45: factors = ["Limited lighting", "Below-average safety"] else: factors = ["High crime density", "Avoid if possible"] if density > 20: factors.append(f"High incident density (~{density} nearby)") elif density > 5: factors.append(f"Moderate incidents (~{density} nearby)") else: factors.append("Low historical incidents") if hour >= 20 or hour < 4: if score < 45: factors.append("Moon High risk — avoid travel at this hour") elif score < 65: factors.append("Moon Use caution — late night travel risky") else: factors.append("Moon Relatively safer but stay alert at night") elif hour == 4: factors.append("Moon Pre-dawn hours — stay alert") elif 5 <= hour < 7: factors.append("Sun Early morning — limited activity in area") elif 18 <= hour < 20: factors.append("Evening caution advised" if score < 65 else "Moderate evening activity — stay aware") return factors # ══════════════════════════════════════════════════════════════════════════ # FEATURE ENGINEERING — matches ML_Model.ipynb Cell 5 # ══════════════════════════════════════════════════════════════════════════ def _build_feature_row( lat: float, lon: float, hour: int = 12, month: int = 6, is_weekend: int = 0, ) -> np.ndarray: dist_km = geodesic((lat, lon), (center_lat, center_lon)).km dist_sq = dist_km ** 2 lat_lon_prod = lat * lon coord_rad = np.radians([[lat, lon]]) d_0p5 = int(ball_tree.query_radius(coord_rad, r=R_0P5, count_only=True)[0]) d_1km = int(ball_tree.query_radius(coord_rad, r=R_1KM, count_only=True)[0]) d_2km = int(ball_tree.query_radius(coord_rad, r=R_2KM, count_only=True)[0]) density_ratio = d_1km / (d_0p5 + 1) density_dist = d_1km * dist_km cluster_id = int(kmeans.predict([[lat, lon]])[0]) quarter = (month - 1) // 3 + 1 is_night = int(hour >= 20 or hour <= 5) row = { "Latitude": lat, "Longitude": lon, "dist_to_center_km": dist_km, "dist_sq": dist_sq, "density_0p5km": float(d_0p5), "density_1km": float(d_1km), "density_2km": float(d_2km), "density_ratio": density_ratio, "density_dist_interaction": density_dist, "lat_lon_product": lat_lon_prod, "cluster_id": float(cluster_id), "FIR_MONTH": float(month), "FIR_QUARTER": float(quarter), "FIR_IS_WEEKEND": float(is_weekend), "is_night": float(is_night), } df = pd.DataFrame([row]) for col in X_columns: if col not in df.columns: df[col] = 0.0 df = df[list(X_columns)].fillna(0.0) arr = df.to_numpy(dtype=np.float64) if arr.shape[1] < N_FEATURES: arr = np.hstack([arr, np.zeros((1, N_FEATURES - arr.shape[1]))]) elif arr.shape[1] > N_FEATURES: arr = arr[:, :N_FEATURES] return arr # ══════════════════════════════════════════════════════════════════════════ # INFERENCE # ══════════════════════════════════════════════════════════════════════════ def _safe_proba(model, X_scaled: np.ndarray, is_lgb: bool) -> Optional[np.ndarray]: try: name = type(model).__name__ if hasattr(model, "predict") and "Booster" in name: p = model.predict(X_scaled) return p[0] if p.ndim == 2 else p if is_lgb or "XGB" in name or "Forest" in name or "Trees" in name: return model.predict_proba(X_scaled)[0] if "LogisticRegression" in name: scores = model.decision_function(X_scaled)[0] e = np.exp(scores - scores.max()) return e / e.sum() return model.predict_proba(X_scaled)[0] except Exception as e: log.warning("Model %s failed: %s", type(model).__name__, e) return None def _predict_danger( lat: float, lon: float, hour: int = 12, month: int = 6, is_weekend: int = 0, ) -> dict: X_raw = _build_feature_row(lat, lon, hour, month, is_weekend) X_scaled = scaler.transform(X_raw) probas = [] for model, is_lgb, _ in _model_pool: p = _safe_proba(model, X_scaled, is_lgb) if p is not None and len(p) == 3: probas.append(p) if not probas: log.error("All models failed for (%.4f, %.4f)", lat, lon) proba = np.array([0.33, 0.34, 0.33]) elif len(probas) == 1: proba = probas[0] else: proba = np.mean(probas, axis=0) safe_p = float(proba[0]) high_p = float(proba[2]) # Weighted expected-value formula (v4.1) — avoids clustering at 50-70 med_p = max(0.0, 1.0 - safe_p - high_p) expected = safe_p * 100.0 + med_p * 50.0 # high_p contributes 0 raw = 100.0 * ((expected / 100.0) ** 0.55) base = int(max(0.0, min(100.0, raw))) penalty = _time_penalty(hour) score = max(5, min(100, base - penalty)) label, _ = _score_to_label(score) try: density_idx = list(X_columns).index("density_1km") density = int(X_raw[0, density_idx]) except (ValueError, IndexError): density = 0 return {"label": label, "score": score, "density": density, "hour": hour} # ══════════════════════════════════════════════════════════════════════════ # UTILITIES # ══════════════════════════════════════════════════════════════════════════ def _now_params(): n = datetime.datetime.now() return n.hour, n.month, int(n.weekday() >= 5) def _parse_time(t: str) -> int: t = (t or "Now").lower().strip() if t in ("now", ""): return datetime.datetime.now().hour if "night" in t: return 22 if "morning" in t: return 8 if "afternoon" in t: return 14 if "evening" in t: return 18 if "1 hour" in t: return (datetime.datetime.now().hour + 1) % 24 return datetime.datetime.now().hour def _osrm_leg(o_lat: float, o_lng: float, d_lat: float, d_lng: float) -> dict: if _HTTPX_OK: try: r = _httpx.get( f"http://router.project-osrm.org/route/v1/driving/" f"{o_lng},{o_lat};{d_lng},{d_lat}?overview=false", timeout=3.0, ) if r.status_code == 200: leg = r.json()["routes"][0]["legs"][0] return { "dist_km": round(leg["distance"] / 1000, 1), "mins": max(1, round(leg["duration"] / 60)), } except Exception: pass straight = geodesic((o_lat, o_lng), (d_lat, d_lng)).km return { "dist_km": round(straight * 1.3, 1), "mins": max(5, round(straight * 1.3 / 28 * 60)), } def _build_routes( o_lat: float, o_lng: float, d_lat: float, d_lng: float, hour: int, month: int, is_weekend: int, ) -> RouteResponse: m1_lat = (o_lat + d_lat) / 2 + 0.010 m1_lng = (o_lng + d_lng) / 2 + 0.010 m2_lat = (o_lat + d_lat) / 2 - 0.008 m2_lng = (o_lng + d_lng) / 2 - 0.008 with ThreadPoolExecutor(max_workers=10) as ex: f_main = ex.submit(_osrm_leg, o_lat, o_lng, d_lat, d_lng) f_a1 = ex.submit(_osrm_leg, o_lat, o_lng, m1_lat, m1_lng) f_b1 = ex.submit(_osrm_leg, m1_lat, m1_lng, d_lat, d_lng) f_a2 = ex.submit(_osrm_leg, o_lat, o_lng, m2_lat, m2_lng) f_b2 = ex.submit(_osrm_leg, m2_lat, m2_lng, d_lat, d_lng) f_orig = ex.submit(_predict_danger, o_lat, o_lng, hour, month, is_weekend) f_dest = ex.submit(_predict_danger, d_lat, d_lng, hour, month, is_weekend) f_mid = ex.submit(_predict_danger, (o_lat+d_lat)/2, (o_lng+d_lng)/2, hour, month, is_weekend) f_mid1 = ex.submit(_predict_danger, m1_lat, m1_lng, hour, month, is_weekend) f_mid2 = ex.submit(_predict_danger, m2_lat, m2_lng, hour, month, is_weekend) main = f_main.result() a1, b1 = f_a1.result(), f_b1.result() a2, b2 = f_a2.result(), f_b2.result() orig_r = f_orig.result() dest_r = f_dest.result() mid_r = f_mid.result() mid1_r = f_mid1.result() mid2_r = f_mid2.result() def ws(mid: dict) -> int: return int(orig_r["score"] * 0.2 + mid["score"] * 0.5 + dest_r["score"] * 0.3) routes_raw = [ { "name": "Main Route", "duration": f"{main['mins']} min", "distance": f"{main['dist_km']} km", "safety_score": ws(mid_r), "factors": _score_to_factors(ws(mid_r), mid_r["density"], hour), "is_recommended": False, }, { "name": "Alternate Route 1", "duration": f"{a1['mins'] + b1['mins']} min", "distance": f"{round(a1['dist_km'] + b1['dist_km'], 1)} km", "safety_score": ws(mid1_r), "factors": _score_to_factors(ws(mid1_r), mid1_r["density"], hour), "is_recommended": False, }, { "name": "Alternate Route 2", "duration": f"{a2['mins'] + b2['mins']} min", "distance": f"{round(a2['dist_km'] + b2['dist_km'], 1)} km", "safety_score": ws(mid2_r), "factors": _score_to_factors(ws(mid2_r), mid2_r["density"], hour), "is_recommended": False, }, ] routes_raw.sort(key=lambda r: r["safety_score"], reverse=True) routes_raw[0]["is_recommended"] = True overall = int(sum(r["safety_score"] for r in routes_raw) / len(routes_raw)) return RouteResponse( overall_score=overall, routes=[RouteResult(**r) for r in routes_raw], ) # ══════════════════════════════════════════════════════════════════════════ # ENDPOINTS # ══════════════════════════════════════════════════════════════════════════ @app.get("/health") def health(): return { "status": "ok", "version": "4.1", "models": [m[2] for m in _model_pool], "ensemble": ENSEMBLE_OK, "n_features": N_FEATURES, "n_clusters": kmeans.n_clusters, "feature_cols": list(X_columns), "scoring": "weighted_expected_v4.1", } # GET — used by heatmap_service.dart SafetyApiService.predictArea() @app.get("/predict_area", response_model=AreaResponse) def predict_area_get(latitude: float, longitude: float): try: hour, month, iw = _now_params() r = _predict_danger(latitude, longitude, hour, month, iw) label, level = _score_to_label(r["score"]) return AreaResponse( safety_score=r["score"], danger_level=level, danger_label=label, factors=_score_to_factors(r["score"], r["density"], hour), ) except Exception as e: log.exception("predict_area_get failed") raise HTTPException(status_code=500, detail=str(e)) # POST — used by safety_api_service.dart SafetyApiService.predictArea() @app.post("/predict_area", response_model=AreaResponse) def predict_area_post(req: AreaRequest): try: h, mo, iw = _now_params() hour = req.hour if req.hour is not None else h month = req.month if req.month is not None else mo is_weekend = req.is_weekend if req.is_weekend is not None else iw r = _predict_danger(req.latitude, req.longitude, hour, month, is_weekend) label, level = _score_to_label(r["score"]) return AreaResponse( safety_score=r["score"], danger_level=level, danger_label=label, factors=_score_to_factors(r["score"], r["density"], hour), ) except Exception as e: log.exception("predict_area_post failed") raise HTTPException(status_code=500, detail=str(e)) # Alias used by some older callers @app.post("/predict_area_full", response_model=AreaResponse) def predict_area_full(req: AreaRequest): return predict_area_post(req) # GET — used by heatmap_service.dart SafetyApiService.analyzeRoute() @app.get("/analyze_route", response_model=RouteResponse) def analyze_route_get( origin_lat: float, origin_lng: float, dest_lat: float, dest_lng: float, time: str = "Now", ): try: hour = _parse_time(time) _, month, iw = _now_params() return _build_routes(origin_lat, origin_lng, dest_lat, dest_lng, hour, month, iw) except Exception as e: log.exception("analyze_route_get failed") raise HTTPException(status_code=500, detail=str(e)) # POST — used by safety_api_service.dart SafetyApiService.analyzeRoute() @app.post("/analyze_route", response_model=RouteResponse) def analyze_route_post(req: RouteRequest): try: hour = _parse_time(req.time) _, month, iw = _now_params() return _build_routes(req.origin_lat, req.origin_lng, req.dest_lat, req.dest_lng, hour, month, iw) except Exception as e: log.exception("analyze_route_post failed") raise HTTPException(status_code=500, detail=str(e)) # GET — used by heatmap_service.dart HeatmapService.fetchHeatmap() @app.get("/heatmap_data", response_model=HeatmapResponse) def heatmap_data( latitude: float = 12.9716, longitude: float = 77.5946, radius_km: float = 2.0, step_km: float = 0.3, ): """ risk field MUST be "Low", "Medium", or "High" — heatmap_screen.dart _riskFill() switches on these exact strings. score is used for circle radius: (100-score)/100 * 60 + 80. """ try: hour, month, iw = _now_params() lat_step = step_km / 111.0 lng_step = step_km / (111.0 * max(abs(math.cos(math.radians(latitude))), 0.01)) lat_range = np.arange( latitude - radius_km / 111.0, latitude + radius_km / 111.0, lat_step, ) lng_range = np.arange( longitude - radius_km / 111.0, longitude + radius_km / 111.0, lng_step, ) MAX_PTS = 200 if len(lat_range) * len(lng_range) > MAX_PTS: s = max(1, int(math.ceil(math.sqrt( len(lat_range) * len(lng_range) / MAX_PTS)))) lat_range = lat_range[::s] lng_range = lng_range[::s] grid = [(float(la), float(lo)) for la in lat_range for lo in lng_range] log.info("Heatmap grid: %d points", len(grid)) results_map: dict = {} with ThreadPoolExecutor(max_workers=24) as ex: futures = { ex.submit(_predict_danger, la, lo, hour, month, iw): (la, lo) for la, lo in grid } for fut in as_completed(futures): coord = futures[fut] try: results_map[coord] = fut.result() except Exception as exc: log.warning("Heatmap point %s skipped: %s", coord, exc) points = [] for la, lo in grid: res = results_map.get((la, lo)) if res is None: continue _lbl, level = _score_to_label(res["score"]) points.append(HeatmapPoint( lat=round(la, 5), lng=round(lo, 5), risk=level, score=res["score"], )) if not points: raise HTTPException(status_code=500, detail="No valid predictions returned.") return HeatmapResponse( points=points, center_lat=latitude, center_lng=longitude, ) except HTTPException: raise except Exception as e: log.exception("heatmap_data failed") raise HTTPException(status_code=500, detail=str(e)) # POST — chatbot_ui.dart getBotResponse() POSTs to /ai @app.post("/ai", response_model=AiResponse) async def ai_chat(body: AiRequest): try: msg = body.message.strip() lower = msg.lower() if not msg: return AiResponse(reply="Please enter a message.") FAST = [ ({"danger", "emergency", "attack", "unsafe", "threat", "help"}, "Stay calm. Call 112 immediately and move to a safe, crowded place."), ({"police"}, "Call 112 for police. Use the SOS button in the app for instant alerts."), ({"sos", "panic", "alert"}, "Press the SOS button to instantly alert your emergency contacts."), ({"hospital", "ambulance", "injured", "hurt", "accident", "medical"}, "Call 108 for an ambulance. Tap Nearby Help for the nearest hospital."), ({"route", "safe route", "path", "navigate"}, "Tap Plan Safe Route on the home screen for ML-rated safe routes."), ({"heatmap", "crime map", "danger zone"}, "Check the Safety Heatmap on the home screen for real-time crime density."), ({"safe", "area", "location", "nearby"}, "Use the heatmap on the home screen for a live area safety overview."), ] for kws, reply in FAST: if any(k in lower for k in kws): return AiResponse(reply=reply) if groq_client is None: return AiResponse( reply="I am your Nyvra safety assistant. For emergencies, call 112. " "Use the SOS button to alert your contacts instantly." ) import asyncio loop = asyncio.get_running_loop() resp = await loop.run_in_executor( None, lambda: groq_client.chat.completions.create( model="llama-3.1-8b-instant", max_tokens=120, temperature=0.4, messages=[ {"role": "system", "content": ( "You are Nyvra, a concise women's safety assistant for India. " "Reply in 2-3 short sentences. Be calm, practical, empathetic. " "For emergencies always say: call 112." )}, {"role": "user", "content": msg}, ], ) ) return AiResponse(reply=resp.choices[0].message.content.strip()) except Exception as e: log.exception("ai_chat failed") return AiResponse( reply="I am here to help with your safety. For emergencies, call 112 immediately." ) if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8000, reload=False)