857 lines
33 KiB
Python
857 lines
33 KiB
Python
"""
|
||
Delivery History Store
|
||
======================
|
||
Pattern-first vector rider lookup using recent delivery history.
|
||
Works on any platform — no faiss-cpu dependency.
|
||
|
||
Data source (config `pattern_source`)
|
||
--------------------------------------
|
||
"csv" (legacy) — built from delivery_details.csv, only refreshed when a
|
||
human re-exports it and calls POST /ml/reload-history.
|
||
"db" (default going forward) — built from the nearledb mirror the ETA-sync
|
||
agent already maintains (delivery_raw), rebuilt
|
||
automatically every sync cycle via rebuild_from_records().
|
||
No manual step, no extra database load.
|
||
|
||
How it works
|
||
------------
|
||
Records (however sourced) are turned into two structures:
|
||
|
||
1. PATTERN TABLE (primary, O(1) lookup)
|
||
City divided into ~1.1 km grid cells (round coords to 2 d.p.).
|
||
For every (kitchen, zone_cell) pair we count how many times each
|
||
rider delivered there. A "clear pattern" = one rider owns ≥ 60 %
|
||
of all deliveries in that cell AND ≥ 3 total deliveries.
|
||
|
||
From the 3 500-row CSV:
|
||
• 177 unique (kitchen, zone) cells
|
||
• 121 clear dominant-rider patterns (68 %)
|
||
• 42 cells where 1 rider owns 100 %
|
||
|
||
2. VECTOR INDEX (fallback, ~0.5 ms)
|
||
Pure-NumPy brute-force L2 search over 4D vectors:
|
||
[pickuplat, pickuplon, deliverylat, deliverylong]
|
||
Used ONLY when the pattern table has no clear answer.
|
||
If faiss-cpu is installed it is used instead of NumPy (faster
|
||
for very large indexes), but NumPy is the default.
|
||
|
||
Disk persistence
|
||
----------------
|
||
ml_data/faiss_history/
|
||
delivery_history_vectors.npy – float32 (N, 4) array
|
||
delivery_history_records.pkl – list of record dicts
|
||
delivery_history.meta – JSON: csv_mtime, counts
|
||
|
||
Priority chain in the assignment endpoint:
|
||
1. Pattern table (dominant rider for this kitchen + delivery zone)
|
||
2. Vector K-NN (nearest-neighbour vote, fallback for new areas)
|
||
3. rider_preferences.py hard lock
|
||
4. VRP proximity solver
|
||
"""
|
||
|
||
import csv
|
||
import json
|
||
import logging
|
||
import os
|
||
import pickle
|
||
from collections import Counter, defaultdict
|
||
from threading import Lock
|
||
from typing import Any, Dict, List, Optional, Tuple
|
||
|
||
import numpy as np
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Paths
|
||
# ---------------------------------------------------------------------------
|
||
CSV_PATH = os.getenv("DELIVERY_HISTORY_CSV", "delivery_details.csv")
|
||
CORRECTIONS_PATH = os.getenv("DELIVERY_CORRECTIONS_CSV", "delivery_corrections.csv")
|
||
|
||
# Each correction record is already written N times in the corrections CSV
|
||
# (currently 5× per unique delivery) so they naturally outweigh noisy history.
|
||
# Setting CORRECTION_WEIGHT > 1 here adds a further runtime multiplier on top.
|
||
_CORRECTION_WEIGHT = int(os.getenv("CORRECTION_WEIGHT", "1"))
|
||
|
||
_STORE_DIR = os.getenv("FAISS_HISTORY_DIR", "ml_data/faiss_history")
|
||
|
||
|
||
def _paths_for(source: str) -> Tuple[str, str, str]:
|
||
"""
|
||
Disk cache paths, namespaced by source ("csv" or "db"). Namespaced so that
|
||
flipping `pattern_source` at runtime (e.g. to debug, or to roll back)
|
||
never clobbers the other mode's cached snapshot — each mode keeps its own
|
||
independent copy on disk.
|
||
"""
|
||
suffix = "" if source == "csv" else f".{source}"
|
||
return (
|
||
os.path.join(_STORE_DIR, f"delivery_history_vectors{suffix}.npy"),
|
||
os.path.join(_STORE_DIR, f"delivery_history_records{suffix}.pkl"),
|
||
os.path.join(_STORE_DIR, f"delivery_history{suffix}.meta"),
|
||
)
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Thresholds
|
||
# ---------------------------------------------------------------------------
|
||
_K_NEIGHBORS = 15
|
||
_MIN_KNN_CONFIDENCE = 0.50 # vector fallback: top rider wins ≥ 50 % of votes
|
||
_MIN_PATTERN_DOMINANCE = 0.60 # pattern table: rider owns ≥ 60 % of zone
|
||
_MIN_PATTERN_VOLUME = 3 # pattern table: at least 3 deliveries in zone
|
||
|
||
|
||
def _pattern_source() -> str:
|
||
"""'csv' (legacy, manual refresh) or 'db' (auto-refreshed from nearledb mirror)."""
|
||
try:
|
||
from app.config.dynamic_config import get_config
|
||
return str(get_config().get("pattern_source", "csv"))
|
||
except Exception:
|
||
return "csv"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Pure-NumPy L2 index — drop-in replacement for faiss.IndexFlatL2
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class _NumpyL2Index:
|
||
"""
|
||
Brute-force L2 nearest-neighbour search using NumPy.
|
||
Same interface as faiss.IndexFlatL2 so both paths share one code.
|
||
For 3 000–5 000 vectors each query takes < 1 ms — fast enough.
|
||
"""
|
||
|
||
def __init__(self, d: int):
|
||
self.d = d
|
||
self.ntotal = 0
|
||
self._vecs: Optional[np.ndarray] = None # shape (N, d) float32
|
||
|
||
def add(self, vectors: np.ndarray) -> None:
|
||
self._vecs = vectors.astype(np.float32)
|
||
self.ntotal = len(self._vecs)
|
||
|
||
def search(self, query: np.ndarray, k: int) -> Tuple[np.ndarray, np.ndarray]:
|
||
"""Return (distances, indices) shaped (1, k), same as faiss."""
|
||
if self._vecs is None or self.ntotal == 0:
|
||
return (np.array([[]], dtype=np.float32),
|
||
np.array([[-1]], dtype=np.int64))
|
||
|
||
q = query.astype(np.float32) # (1, d)
|
||
diff = self._vecs - q # (N, d) broadcasting
|
||
sq_dists = np.einsum("ij,ij->i", diff, diff) # (N,) squared L2
|
||
|
||
actual_k = min(k, self.ntotal)
|
||
if actual_k < self.ntotal:
|
||
part = np.argpartition(sq_dists, actual_k)[:actual_k]
|
||
top_idxs = part[np.argsort(sq_dists[part])]
|
||
else:
|
||
top_idxs = np.argsort(sq_dists)
|
||
|
||
return (sq_dists[top_idxs].reshape(1, -1),
|
||
top_idxs.reshape(1, -1).astype(np.int64))
|
||
|
||
|
||
def _build_index(vectors: np.ndarray) -> Any:
|
||
"""
|
||
Build the best available index for the given float32 vector array.
|
||
Tries faiss first; falls back to NumPy silently.
|
||
"""
|
||
try:
|
||
import faiss as _faiss
|
||
idx = _faiss.IndexFlatL2(vectors.shape[1])
|
||
idx.add(vectors)
|
||
logger.info("[DeliveryHistory] Using faiss-cpu index.")
|
||
return idx
|
||
except Exception:
|
||
idx = _NumpyL2Index(vectors.shape[1])
|
||
idx.add(vectors)
|
||
logger.info("[DeliveryHistory] Using NumPy L2 index (faiss-cpu not available).")
|
||
return idx
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Main store
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class DeliveryHistoryStore:
|
||
"""
|
||
Pattern-first, vector-fallback rider lookup.
|
||
|
||
Public API
|
||
----------
|
||
store.find_rider(kitchen, plat, plon, dlat, dlon) -> dict | None
|
||
store.record_count() -> int
|
||
store.pattern_count() -> int
|
||
store.get_pattern_stats() -> list
|
||
store.reload_from_csv() -> int
|
||
store.rebuild_from_records(records) -> int
|
||
"""
|
||
|
||
def __init__(self):
|
||
self._lock = Lock()
|
||
self._records: List[Dict] = []
|
||
self._index = None
|
||
|
||
# Pattern table: (kitchen_lower, zone_lat, zone_lon) → pattern dict
|
||
self._patterns: Dict[Tuple, Dict] = {}
|
||
|
||
# Zone index: (zone_lat, zone_lon) → [pattern keys at that cell]
|
||
self._zone_index: Dict[Tuple, List[Tuple]] = defaultdict(list)
|
||
|
||
self._load()
|
||
|
||
# ------------------------------------------------------------------
|
||
# Startup
|
||
# ------------------------------------------------------------------
|
||
|
||
def _load(self) -> None:
|
||
os.makedirs(_STORE_DIR, exist_ok=True)
|
||
|
||
if _pattern_source() == "db":
|
||
# DB-driven: the ETA-sync agent keeps this fresh on its own cadence
|
||
# by calling rebuild_from_records() after every sync cycle. At
|
||
# startup we just load whatever was last persisted — no CSV
|
||
# freshness check applies in this mode.
|
||
if self._load_from_disk("db"):
|
||
return
|
||
logger.info(
|
||
"[DeliveryHistory] pattern_source=db, no persisted snapshot yet "
|
||
"— will populate on the next ETA-sync cycle."
|
||
)
|
||
return
|
||
|
||
if self._saved_files_are_current():
|
||
if self._load_from_disk("csv"):
|
||
return
|
||
logger.warning(
|
||
"[DeliveryHistory] Saved files corrupt — rebuilding from CSV."
|
||
)
|
||
|
||
records = self._parse_csv()
|
||
if not records:
|
||
return
|
||
self._build_and_save(records, source="csv")
|
||
|
||
def _saved_files_are_current(self) -> bool:
|
||
vectors_path, records_path, meta_path = _paths_for("csv")
|
||
for path in (vectors_path, records_path, meta_path):
|
||
if not os.path.isfile(path):
|
||
return False
|
||
try:
|
||
with open(meta_path, "r", encoding="utf-8") as f:
|
||
meta = json.load(f)
|
||
if not os.path.isfile(CSV_PATH):
|
||
return True
|
||
return abs(os.path.getmtime(CSV_PATH) - meta.get("csv_mtime", 0)) < 1.0
|
||
except Exception:
|
||
return False
|
||
|
||
def _load_from_disk(self, source: str) -> bool:
|
||
try:
|
||
vectors_path, records_path, _ = _paths_for(source)
|
||
vectors = np.load(vectors_path) # (N, 4) float32
|
||
with open(records_path, "rb") as f:
|
||
records = pickle.load(f)
|
||
|
||
if not records or len(vectors) != len(records):
|
||
return False
|
||
|
||
index = _build_index(vectors)
|
||
patterns, zone_index = self._compute_patterns(records)
|
||
|
||
with self._lock:
|
||
self._records = records
|
||
self._index = index
|
||
self._patterns = patterns
|
||
self._zone_index = zone_index
|
||
|
||
clear = sum(
|
||
1 for p in patterns.values()
|
||
if p["dominance"] >= _MIN_PATTERN_DOMINANCE
|
||
and p["total_deliveries"] >= _MIN_PATTERN_VOLUME
|
||
)
|
||
logger.info(
|
||
f"[DeliveryHistory] Loaded {len(records)} records from disk. "
|
||
f"Pattern table: {len(patterns)} zones, {clear} with clear dominant rider."
|
||
)
|
||
return True
|
||
except Exception as e:
|
||
logger.warning(f"[DeliveryHistory] Disk load failed: {e}")
|
||
return False
|
||
|
||
def _parse_csv_file(self, path: str, label: str = "CSV") -> List[Dict]:
|
||
"""Parse any delivery CSV (main history or corrections) into records list."""
|
||
records: List[Dict] = []
|
||
for encoding in ("utf-8", "latin-1", "cp1252"):
|
||
try:
|
||
with open(path, newline="", encoding=encoding) as fh:
|
||
for row in csv.DictReader(fh):
|
||
try:
|
||
plat = float(row.get("pickuplat") or 0)
|
||
plon = float(row.get("pickuplon") or 0)
|
||
dlat = float(row.get("deliverylat") or 0)
|
||
dlon = float(row.get("deliverylong") or 0)
|
||
uid = int(float(row.get("userid") or 0))
|
||
if not plat or not dlat or uid == 0:
|
||
continue
|
||
# Skip rows where the delivery destination IS a kitchen
|
||
dcust = (row.get("deliverycustomer") or "").strip().lower()
|
||
if "kitchen" in dcust or "selvarani" in dcust:
|
||
continue
|
||
records.append({
|
||
"kitchen": (row.get("pickupcustomer") or "").strip().lower(),
|
||
"pickuplat": plat, "pickuplon": plon,
|
||
"deliverylat": dlat, "deliverylong": dlon,
|
||
"userid": uid,
|
||
"ridername": (row.get("ridername") or "").strip(),
|
||
})
|
||
except Exception:
|
||
continue
|
||
break
|
||
except (UnicodeDecodeError, FileNotFoundError) as e:
|
||
if isinstance(e, FileNotFoundError):
|
||
logger.warning(f"[DeliveryHistory] {label} not found at '{path}'.")
|
||
return []
|
||
records = []
|
||
|
||
if records:
|
||
logger.info(f"[DeliveryHistory] Parsed {len(records)} rows from {label} '{path}'.")
|
||
else:
|
||
logger.warning(f"[DeliveryHistory] {label} at '{path}' contained no valid rows.")
|
||
return records
|
||
|
||
def _parse_csv(self) -> List[Dict]:
|
||
"""Parse main history CSV, then merge manually-corrected records on top."""
|
||
main_records = self._parse_csv_file(CSV_PATH, label="Main CSV")
|
||
if not main_records:
|
||
return []
|
||
|
||
# Load manually-corrected assignments and merge with runtime weight multiplier
|
||
if os.path.isfile(CORRECTIONS_PATH):
|
||
corr = self._parse_csv_file(CORRECTIONS_PATH, label="Corrections CSV")
|
||
if corr:
|
||
weighted = corr * _CORRECTION_WEIGHT # extra amplification if configured
|
||
main_records = main_records + weighted
|
||
logger.info(
|
||
f"[DeliveryHistory] Merged {len(corr)} correction rows "
|
||
f"(×{_CORRECTION_WEIGHT} weight) into {len(main_records)} total records."
|
||
)
|
||
else:
|
||
logger.debug(
|
||
f"[DeliveryHistory] No corrections file at '{CORRECTIONS_PATH}' — "
|
||
"using main CSV only."
|
||
)
|
||
|
||
return main_records
|
||
|
||
def _compute_patterns(
|
||
self, records: List[Dict]
|
||
) -> Tuple[Dict, Dict]:
|
||
"""
|
||
Build pattern table from records.
|
||
|
||
Pattern dict keys
|
||
-----------------
|
||
userid, ridername, dominance, total_deliveries,
|
||
top_deliveries, pattern_score, all_riders
|
||
"""
|
||
zone_counts: Dict[Tuple, Dict[int, int]] = defaultdict(lambda: defaultdict(int))
|
||
zone_names: Dict[Tuple, Dict[int, str]] = defaultdict(lambda: defaultdict(str))
|
||
|
||
for rec in records:
|
||
key = (rec["kitchen"],
|
||
round(rec["deliverylat"], 2),
|
||
round(rec["deliverylong"], 2))
|
||
zone_counts[key][rec["userid"]] += 1
|
||
zone_names[key][rec["userid"]] = rec["ridername"]
|
||
|
||
patterns: Dict[Tuple, Dict] = {}
|
||
zone_index: Dict[Tuple, List[Tuple]] = defaultdict(list)
|
||
|
||
for key, rider_counts in zone_counts.items():
|
||
kitchen, zone_lat, zone_lon = key
|
||
total = sum(rider_counts.values())
|
||
top_rid = max(rider_counts, key=rider_counts.get)
|
||
top_count = rider_counts[top_rid]
|
||
dominance = top_count / total
|
||
# Penalise thin data: full score only at ≥ 5 deliveries
|
||
pattern_score = dominance * min(1.0, total / 5.0)
|
||
|
||
patterns[key] = {
|
||
"userid": top_rid,
|
||
"ridername": zone_names[key].get(top_rid, ""),
|
||
"dominance": round(dominance, 4),
|
||
"total_deliveries": total,
|
||
"top_deliveries": top_count,
|
||
"pattern_score": round(pattern_score, 4),
|
||
"all_riders": dict(rider_counts),
|
||
}
|
||
zone_index[(zone_lat, zone_lon)].append(key)
|
||
|
||
return patterns, zone_index
|
||
|
||
def _build_and_save(self, records: List[Dict], source: str = "csv") -> None:
|
||
vectors = np.array(
|
||
[[r["pickuplat"], r["pickuplon"], r["deliverylat"], r["deliverylong"]]
|
||
for r in records],
|
||
dtype=np.float32,
|
||
)
|
||
index = _build_index(vectors)
|
||
patterns, zone_index = self._compute_patterns(records)
|
||
|
||
with self._lock:
|
||
self._records = records
|
||
self._index = index
|
||
self._patterns = patterns
|
||
self._zone_index = zone_index
|
||
|
||
clear = sum(
|
||
1 for p in patterns.values()
|
||
if p["dominance"] >= _MIN_PATTERN_DOMINANCE
|
||
and p["total_deliveries"] >= _MIN_PATTERN_VOLUME
|
||
)
|
||
sole = sum(
|
||
1 for p in patterns.values()
|
||
if p["dominance"] == 1.0
|
||
and p["total_deliveries"] >= _MIN_PATTERN_VOLUME
|
||
)
|
||
logger.info(
|
||
f"[DeliveryHistory] Pattern table: {len(patterns)} zones, "
|
||
f"{clear} clear patterns (≥60% dominance), {sole} sole-owner zones."
|
||
)
|
||
|
||
try:
|
||
vectors_path, records_path, meta_path = _paths_for(source)
|
||
np.save(vectors_path, vectors)
|
||
with open(records_path, "wb") as f:
|
||
pickle.dump(records, f, protocol=pickle.HIGHEST_PROTOCOL)
|
||
# csv_mtime only means something for the CSV path's freshness check
|
||
# (_saved_files_are_current). DB-built snapshots are refreshed by the
|
||
# ETA-sync agent's own schedule, not a file-mtime comparison.
|
||
csv_mtime = (
|
||
os.path.getmtime(CSV_PATH)
|
||
if source == "csv" and os.path.isfile(CSV_PATH)
|
||
else 0
|
||
)
|
||
with open(meta_path, "w", encoding="utf-8") as f:
|
||
json.dump({
|
||
"source": source,
|
||
"csv_mtime": csv_mtime,
|
||
"record_count": len(records),
|
||
"pattern_zones": len(patterns),
|
||
"clear_patterns": clear,
|
||
}, f, indent=2)
|
||
logger.info(
|
||
f"[DeliveryHistory] Saved to '{_STORE_DIR}' (source={source}). "
|
||
"Next startup loads from disk."
|
||
)
|
||
except Exception as e:
|
||
logger.warning(
|
||
f"[DeliveryHistory] Could not save to disk (non-fatal): {e}"
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Public API
|
||
# ------------------------------------------------------------------
|
||
|
||
def find_rider(
|
||
self,
|
||
kitchen_name: str,
|
||
pickup_lat: float,
|
||
pickup_lon: float,
|
||
delivery_lat: float,
|
||
delivery_lon: float,
|
||
k: int = _K_NEIGHBORS,
|
||
min_confidence: float = _MIN_KNN_CONFIDENCE,
|
||
) -> Optional[Dict[str, Any]]:
|
||
"""
|
||
Return the best historical rider, or None.
|
||
|
||
Step 1 — pattern table (O(1)):
|
||
Clear dominant rider for this kitchen + 1.1 km delivery zone.
|
||
|
||
Step 2 — vector K-NN (fallback):
|
||
Brute-force 4D search, kitchen-filtered vote.
|
||
|
||
Return keys: userid, ridername, confidence, match_count, top_votes, source
|
||
"""
|
||
if not delivery_lat:
|
||
return None
|
||
|
||
kitchen_lower = (kitchen_name or "").strip().lower()
|
||
|
||
result = self._pattern_lookup(kitchen_lower, delivery_lat, delivery_lon)
|
||
if result:
|
||
return result
|
||
|
||
if self._index is None or not self._records:
|
||
return None
|
||
if not pickup_lat:
|
||
return None
|
||
|
||
return self._vector_knn(
|
||
kitchen_lower, pickup_lat, pickup_lon,
|
||
delivery_lat, delivery_lon, k, min_confidence
|
||
)
|
||
|
||
def _pattern_lookup(
|
||
self,
|
||
kitchen_lower: str,
|
||
delivery_lat: float,
|
||
delivery_lon: float,
|
||
) -> Optional[Dict[str, Any]]:
|
||
zone_pos = (round(delivery_lat, 2), round(delivery_lon, 2))
|
||
|
||
with self._lock:
|
||
candidate_keys = self._zone_index.get(zone_pos, [])
|
||
|
||
best_pattern = None
|
||
best_score = -1.0
|
||
|
||
for key in candidate_keys:
|
||
key_kitchen = key[0]
|
||
if kitchen_lower and key_kitchen:
|
||
if (kitchen_lower not in key_kitchen
|
||
and key_kitchen not in kitchen_lower):
|
||
continue
|
||
|
||
with self._lock:
|
||
pat = self._patterns.get(key)
|
||
if not pat:
|
||
continue
|
||
|
||
if (pat["dominance"] >= _MIN_PATTERN_DOMINANCE
|
||
and pat["total_deliveries"] >= _MIN_PATTERN_VOLUME
|
||
and pat["pattern_score"] > best_score):
|
||
best_pattern = pat
|
||
best_score = pat["pattern_score"]
|
||
|
||
if not best_pattern:
|
||
return None
|
||
|
||
return {
|
||
"userid": best_pattern["userid"],
|
||
"ridername": best_pattern["ridername"],
|
||
"confidence": best_pattern["dominance"],
|
||
"match_count": best_pattern["total_deliveries"],
|
||
"top_votes": best_pattern["top_deliveries"],
|
||
"source": "pattern",
|
||
}
|
||
|
||
def _vector_knn(
|
||
self,
|
||
kitchen_lower: str,
|
||
pickup_lat: float,
|
||
pickup_lon: float,
|
||
delivery_lat: float,
|
||
delivery_lon: float,
|
||
k: int,
|
||
min_confidence: float,
|
||
) -> Optional[Dict[str, Any]]:
|
||
with self._lock:
|
||
query = np.array(
|
||
[[pickup_lat, pickup_lon, delivery_lat, delivery_lon]],
|
||
dtype=np.float32,
|
||
)
|
||
actual_k = min(k, len(self._records))
|
||
_, indices = self._index.search(query, actual_k)
|
||
|
||
matched: List[Dict] = []
|
||
for idx in indices[0]:
|
||
idx = int(idx)
|
||
if idx < 0 or idx >= len(self._records):
|
||
continue
|
||
rec = self._records[idx]
|
||
if kitchen_lower and rec["kitchen"]:
|
||
if (kitchen_lower not in rec["kitchen"]
|
||
and rec["kitchen"] not in kitchen_lower):
|
||
continue
|
||
matched.append(rec)
|
||
|
||
if not matched:
|
||
return None
|
||
|
||
votes = Counter(rec["userid"] for rec in matched)
|
||
top_uid, top_count = votes.most_common(1)[0]
|
||
confidence = top_count / len(matched)
|
||
|
||
if confidence < min_confidence:
|
||
return None
|
||
|
||
ridername = next(
|
||
(r["ridername"] for r in matched if r["userid"] == top_uid), ""
|
||
)
|
||
return {
|
||
"userid": top_uid,
|
||
"ridername": ridername,
|
||
"confidence": round(confidence, 3),
|
||
"match_count": len(matched),
|
||
"top_votes": top_count,
|
||
"source": "vector_knn",
|
||
}
|
||
|
||
# ------------------------------------------------------------------
|
||
# Analytics
|
||
# ------------------------------------------------------------------
|
||
|
||
def record_count(self) -> int:
|
||
return len(self._records)
|
||
|
||
def pattern_count(self) -> int:
|
||
return sum(
|
||
1 for p in self._patterns.values()
|
||
if p["dominance"] >= _MIN_PATTERN_DOMINANCE
|
||
and p["total_deliveries"] >= _MIN_PATTERN_VOLUME
|
||
)
|
||
|
||
def get_pattern_stats(self) -> List[Dict]:
|
||
stats = []
|
||
with self._lock:
|
||
for (kitchen, zlat, zlon), pat in self._patterns.items():
|
||
if (pat["dominance"] < _MIN_PATTERN_DOMINANCE
|
||
or pat["total_deliveries"] < _MIN_PATTERN_VOLUME):
|
||
continue
|
||
stats.append({
|
||
"kitchen": kitchen,
|
||
"zone_lat": zlat,
|
||
"zone_lon": zlon,
|
||
"userid": pat["userid"],
|
||
"ridername": pat["ridername"],
|
||
"dominance": pat["dominance"],
|
||
"total_deliveries": pat["total_deliveries"],
|
||
"top_deliveries": pat["top_deliveries"],
|
||
"pattern_score": pat["pattern_score"],
|
||
"all_riders": pat["all_riders"],
|
||
})
|
||
return sorted(stats, key=lambda x: x["pattern_score"], reverse=True)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Rider Efficiency Scores
|
||
# ------------------------------------------------------------------
|
||
|
||
def get_rider_efficiency_scores(self) -> Dict[int, Dict]:
|
||
"""
|
||
Compute a per-rider efficiency score from the 30-day CSV history.
|
||
|
||
Metrics per rider
|
||
-----------------
|
||
delivery_count — total deliveries in CSV
|
||
avg_km — average Haversine distance (pickup → delivery)
|
||
unique_zones — number of distinct 1.1 km delivery cells covered
|
||
efficiency_score — normalised 0..1 composite score:
|
||
more deliveries × lower avg km × more zones = higher score
|
||
|
||
A rider who completes many deliveries efficiently across many zones
|
||
scores highest. Used as a tiebreaker during solo consolidation
|
||
and Phase-0 host selection.
|
||
"""
|
||
from math import radians, cos, sin, asin, sqrt as _sqrt
|
||
|
||
def _hav(la1, lo1, la2, lo2):
|
||
try:
|
||
la1, lo1, la2, lo2 = map(radians, [la1, lo1, la2, lo2])
|
||
a = sin((la2-la1)/2)**2 + cos(la1)*cos(la2)*sin((lo2-lo1)/2)**2
|
||
return 2 * asin(min(1.0, _sqrt(a))) * 6371.0
|
||
except Exception:
|
||
return 0.0
|
||
|
||
with self._lock:
|
||
records = list(self._records) # snapshot under lock
|
||
|
||
if not records:
|
||
return {}
|
||
|
||
stats: Dict[int, Dict] = {}
|
||
for rec in records:
|
||
uid = rec["userid"]
|
||
km = _hav(rec["pickuplat"], rec["pickuplon"],
|
||
rec["deliverylat"], rec["deliverylong"])
|
||
zone = (round(rec["deliverylat"], 2), round(rec["deliverylong"], 2))
|
||
|
||
if uid not in stats:
|
||
stats[uid] = {
|
||
"ridername": rec["ridername"],
|
||
"delivery_count": 0,
|
||
"total_km": 0.0,
|
||
"zones": set(),
|
||
}
|
||
s = stats[uid]
|
||
s["delivery_count"] += 1
|
||
s["total_km"] += km
|
||
s["zones"].add(zone)
|
||
|
||
# Build scores
|
||
result: Dict[int, Dict] = {}
|
||
raw_scores: Dict[int, float] = {}
|
||
|
||
for uid, s in stats.items():
|
||
n = s["delivery_count"]
|
||
avg_km = s["total_km"] / n if n else 0.0
|
||
zone_count = len(s["zones"])
|
||
# Raw: many deliveries, low km per delivery, wide zone coverage
|
||
raw = n / (1.0 + avg_km) * (1.0 + zone_count ** 0.5)
|
||
raw_scores[uid] = raw
|
||
result[uid] = {
|
||
"ridername": s["ridername"],
|
||
"delivery_count": n,
|
||
"avg_km": round(avg_km, 2),
|
||
"unique_zones": zone_count,
|
||
}
|
||
|
||
# Normalise 0..1
|
||
max_raw = max(raw_scores.values()) if raw_scores else 1.0
|
||
for uid in result:
|
||
result[uid]["efficiency_score"] = round(
|
||
raw_scores[uid] / max_raw, 4
|
||
)
|
||
|
||
return result
|
||
|
||
def get_rider_score(self, rider_id: int) -> float:
|
||
"""Convenience: return a single rider's efficiency_score (0..1), or 0.5 if unknown."""
|
||
scores = self.get_rider_efficiency_scores()
|
||
return scores.get(rider_id, {}).get("efficiency_score", 0.5)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Reload
|
||
# ------------------------------------------------------------------
|
||
|
||
def reload_from_csv(self) -> int:
|
||
"""Rebuild index from main CSV + corrections (if present)."""
|
||
logger.info("[DeliveryHistory] Force-reload from CSV requested.")
|
||
records = self._parse_csv() # already merges corrections internally
|
||
if not records:
|
||
return 0
|
||
self._build_and_save(records, source="csv")
|
||
return len(records)
|
||
|
||
def rebuild_from_records(self, records: List[Dict]) -> int:
|
||
"""
|
||
Rebuild the pattern table + vector index directly from pre-shaped
|
||
records (kitchen/pickuplat/pickuplon/deliverylat/deliverylong/userid/
|
||
ridername), bypassing CSV parsing entirely.
|
||
|
||
Called by the ETA-sync agent (delivery_history_service.py) after each
|
||
sync cycle when pattern_source=db, so this store stays as fresh as the
|
||
nearledb mirror — no manual CSV re-export/reload needed.
|
||
"""
|
||
if not records:
|
||
logger.warning(
|
||
"[DeliveryHistory] rebuild_from_records got 0 records — "
|
||
"keeping the existing store as-is."
|
||
)
|
||
return 0
|
||
|
||
# Merge manually-verified corrections on top, same as the CSV path,
|
||
# so the human-override mechanism still works in DB mode.
|
||
if os.path.isfile(CORRECTIONS_PATH):
|
||
corr = self._parse_csv_file(CORRECTIONS_PATH, label="Corrections CSV")
|
||
if corr:
|
||
records = records + (corr * _CORRECTION_WEIGHT)
|
||
|
||
self._build_and_save(records, source="db")
|
||
return len(records)
|
||
|
||
def inject_corrections(self, corrections_path: str = CORRECTIONS_PATH,
|
||
weight: int = 5) -> Dict:
|
||
"""
|
||
Hot-inject a correction CSV into the live store without a full CSV reload.
|
||
|
||
Each row in the corrections file is counted `weight` times so manually
|
||
verified assignments quickly dominate the pattern table for those zones.
|
||
|
||
Returns a summary dict with old/new record counts and pattern changes.
|
||
"""
|
||
if not os.path.isfile(corrections_path):
|
||
raise FileNotFoundError(f"Corrections file not found: {corrections_path}")
|
||
|
||
corr = self._parse_csv_file(corrections_path, label="Corrections")
|
||
if not corr:
|
||
return {"status": "error", "message": "No valid rows in corrections file."}
|
||
|
||
weighted = corr * weight
|
||
|
||
with self._lock:
|
||
old_count = len(self._records)
|
||
# Remove any existing correction rows for the same zones to avoid
|
||
# double-injection (identify by source: corrections have no
|
||
# "source" field — we use a tag approach).
|
||
# Simplest safe approach: just append (first inject from scratch).
|
||
merged = list(self._records) + weighted
|
||
|
||
patterns, zone_index = self._compute_patterns(merged)
|
||
old_clear = sum(
|
||
1 for p in self._patterns.values()
|
||
if p["dominance"] >= _MIN_PATTERN_DOMINANCE
|
||
and p["total_deliveries"] >= _MIN_PATTERN_VOLUME
|
||
)
|
||
new_clear = sum(
|
||
1 for p in patterns.values()
|
||
if p["dominance"] >= _MIN_PATTERN_DOMINANCE
|
||
and p["total_deliveries"] >= _MIN_PATTERN_VOLUME
|
||
)
|
||
|
||
vectors = np.array(
|
||
[[r["pickuplat"], r["pickuplon"], r["deliverylat"], r["deliverylong"]]
|
||
for r in merged],
|
||
dtype=np.float32,
|
||
)
|
||
index = _build_index(vectors)
|
||
|
||
with self._lock:
|
||
self._records = merged
|
||
self._index = index
|
||
self._patterns = patterns
|
||
self._zone_index = zone_index
|
||
|
||
# Save to disk (under whichever source is currently active) so next
|
||
# restart includes corrections
|
||
try:
|
||
active_source = _pattern_source()
|
||
vectors_path, records_path, meta_path = _paths_for(active_source)
|
||
np.save(vectors_path, vectors)
|
||
with open(records_path, "wb") as f:
|
||
pickle.dump(merged, f)
|
||
csv_mtime = (
|
||
os.path.getmtime(CSV_PATH)
|
||
if active_source == "csv" and os.path.isfile(CSV_PATH)
|
||
else 0
|
||
)
|
||
with open(meta_path, "w", encoding="utf-8") as f:
|
||
json.dump({"source": active_source, "csv_mtime": csv_mtime,
|
||
"record_count": len(merged),
|
||
"pattern_count": len(patterns)}, f)
|
||
logger.info(
|
||
f"[DeliveryHistory] Corrections injected and saved — "
|
||
f"records: {old_count} → {len(merged)}, "
|
||
f"clear patterns: {old_clear} → {new_clear}"
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f"[DeliveryHistory] Save after inject failed: {e}")
|
||
|
||
return {
|
||
"status": "ok",
|
||
"unique_corrections": len(corr),
|
||
"weight": weight,
|
||
"records_before": old_count,
|
||
"records_after": len(merged),
|
||
"clear_patterns_before": old_clear,
|
||
"clear_patterns_after": new_clear,
|
||
"zones_total": len(patterns),
|
||
}
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# Module-level singleton
|
||
# ------------------------------------------------------------------
|
||
|
||
_store: Optional[DeliveryHistoryStore] = None
|
||
_store_lock: Lock = Lock()
|
||
|
||
|
||
def get_delivery_history_store() -> DeliveryHistoryStore:
|
||
global _store
|
||
if _store is None:
|
||
with _store_lock:
|
||
if _store is None:
|
||
_store = DeliveryHistoryStore()
|
||
return _store
|