Spaces:
Sleeping
Sleeping
| # backend/src/recommender.py | |
| """ | |
| recommender.py — slot scoring and top-N recommendation | |
| Formula (lower is better): | |
| score = (0.4 × norm_distance) - (0.6 × vacancy_prob) | |
| norm_distance : Euclidean distance from entry point to slot centroid, | |
| normalised to [0, 1] across all slots so that distance | |
| and vacancy_prob live on the same scale. | |
| vacancy_prob : Prophet forecast — probability the slot is free at the | |
| requested horizon (0 = certainly occupied, 1 = certainly free). | |
| Only slots whose latest observed status is 'empty' are considered. | |
| If Prophet models are unavailable the recommender falls back to distance-only | |
| ranking (vacancy_prob = 1.0 for all empty slots). | |
| Public interface used by api/main.py: | |
| from src.recommender import Recommender | |
| rec = Recommender() | |
| recs = rec.recommend(entry_x=0, entry_y=0, horizon_minutes=30, top_n=3) | |
| """ | |
| import json | |
| import math | |
| from pathlib import Path | |
| from src.database import get_connection, get_latest_predictions | |
| # --------------------------------------------------------------------------- | |
| # Paths | |
| # --------------------------------------------------------------------------- | |
| BASE_DIR = Path(__file__).resolve().parent.parent | |
| SLOT_MAP_PATH = BASE_DIR / "data" / "raw" / "slot_map.json" | |
| def _load_centroids() -> dict[str, tuple[float, float]]: | |
| """ | |
| Returns {slot_id: (cx, cy)} for every slot in slot_map.json. | |
| slot_id is zero-padded to match the format used by the detector | |
| (integer 1 → "slot_001"). | |
| """ | |
| with open(SLOT_MAP_PATH, "r") as f: | |
| raw = json.load(f) | |
| centroids = {} | |
| for s in raw["slots"]: | |
| slot_id = f"slot_{s['slot_id']:03d}" | |
| centroids[slot_id] = (float(s["cx"]), float(s["cy"])) | |
| return centroids | |
| def _get_current_empty_slots() -> set[str]: | |
| """ | |
| Return the set of slot_ids whose most recent logged status is 'empty'. | |
| """ | |
| conn = get_connection() | |
| rows = conn.execute(""" | |
| SELECT ol.slot_id | |
| FROM occupancy_log ol | |
| INNER JOIN ( | |
| SELECT slot_id, MAX(logged_at) AS max_ts | |
| FROM occupancy_log | |
| GROUP BY slot_id | |
| ) latest ON ol.slot_id = latest.slot_id | |
| AND ol.logged_at = latest.max_ts | |
| WHERE ol.status = 'empty' | |
| """).fetchall() | |
| conn.close() | |
| return {r["slot_id"] for r in rows} | |
| def _euclidean(x1: float, y1: float, x2: float, y2: float) -> float: | |
| return math.sqrt((x1 - x2) ** 2 + (y1 - y2) ** 2) | |
| def _normalise(values: list[float]) -> list[float]: | |
| """Min-max normalise a list to [0, 1]. Returns [0.5, ...] if all equal.""" | |
| lo, hi = min(values), max(values) | |
| if hi == lo: | |
| return [0.5] * len(values) | |
| return [(v - lo) / (hi - lo) for v in values] | |
| # --------------------------------------------------------------------------- | |
| # Recommender class | |
| # --------------------------------------------------------------------------- | |
| class Recommender: | |
| """ | |
| Loads slot centroids once at construction time. | |
| Call .recommend(...) for each API request. | |
| """ | |
| def __init__(self): | |
| self.centroids = _load_centroids() | |
| print(f"[Recommender] {len(self.centroids)} slot centroids loaded.") | |
| def recommend( | |
| self, | |
| entry_x: float = 0.0, | |
| entry_y: float = 0.0, | |
| horizon_minutes: int = 30, | |
| top_n: int = 3, | |
| ) -> list[dict]: | |
| """ | |
| Score every currently-empty slot and return the top_n lowest scorers. | |
| Parameters | |
| ---------- | |
| entry_x, entry_y : driver's entry point in image-pixel coordinates. | |
| Defaults to (0, 0) — top-left corner — if the | |
| frontend does not supply a position. | |
| horizon_minutes : forecast horizon passed to Prophet predictions. | |
| top_n : number of recommendations to return. | |
| Returns | |
| ------- | |
| list of dicts (sorted best-first): | |
| [ | |
| { | |
| "slot_id": "slot_042", | |
| "score": -0.381, | |
| "distance": 124.7, | |
| "vacancy_prob": 0.88, | |
| "cx": 312.0, | |
| "cy": 205.5, | |
| }, | |
| ... | |
| ] | |
| """ | |
| # 1. Candidate pool: only slots currently observed as empty | |
| empty_slots = _get_current_empty_slots() | |
| if not empty_slots: | |
| return [] | |
| candidates = [sid for sid in empty_slots if sid in self.centroids] | |
| if not candidates: | |
| return [] | |
| # 2. Raw distances from entry point to each candidate centroid | |
| raw_distances = [ | |
| _euclidean(entry_x, entry_y, self.centroids[sid][0], self.centroids[sid][1]) | |
| for sid in candidates | |
| ] | |
| # 3. Normalise distances to [0, 1] | |
| norm_distances = _normalise(raw_distances) | |
| # 4. Vacancy probabilities from Prophet predictions table. | |
| # Falls back to 1.0 (assume free) if no forecast exists for a slot. | |
| prediction_rows = get_latest_predictions(horizon_minutes) | |
| vacancy_map = {r["slot_id"]: r["vacancy_prob"] for r in prediction_rows} | |
| # 5. Score every candidate | |
| scored = [] | |
| for i, slot_id in enumerate(candidates): | |
| norm_dist = norm_distances[i] | |
| raw_dist = raw_distances[i] | |
| vacancy_prob = vacancy_map.get(slot_id, 1.0) | |
| score = (0.4 * norm_dist) - (0.6 * vacancy_prob) | |
| scored.append({ | |
| "slot_id": slot_id, | |
| "score": round(score, 4), | |
| "distance": round(raw_dist, 2), | |
| "vacancy_prob": round(vacancy_prob, 4), | |
| "cx": self.centroids[slot_id][0], | |
| "cy": self.centroids[slot_id][1], | |
| }) | |
| # 6. Sort ascending (lower score = better) and return top_n | |
| scored.sort(key=lambda x: x["score"]) | |
| return scored[:top_n] | |
| # --------------------------------------------------------------------------- | |
| # Quick smoke-test: python -m src.recommender | |
| # --------------------------------------------------------------------------- | |
| if __name__ == "__main__": | |
| import pprint | |
| rec = Recommender() | |
| print("\n--- Recommendations from entry point (0, 0), horizon 30 min ---") | |
| results = rec.recommend(entry_x=0, entry_y=0, horizon_minutes=30, top_n=3) | |
| pprint.pprint(results) | |
| print("\n--- Recommendations from centre of image (400, 300) ---") | |
| results = rec.recommend(entry_x=400, entry_y=300, horizon_minutes=30, top_n=3) | |
| pprint.pprint(results) |