smart-parking-api / src /recommender.py
rohanv56's picture
Upload 380 files
2d30bad verified
Raw
History Blame Contribute Delete
6.87 kB
# 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)