234 lines
9.6 KiB
Python
234 lines
9.6 KiB
Python
"""
|
|
Road-Sequencing Decision Agent
|
|
==============================
|
|
|
|
Autonomous controller for the `routing_use_road_distance` feature. Instead of a
|
|
human flipping a config flag, this agent periodically *measures* whether road-aware
|
|
sequencing (road travel-time matrix + OR-Tools open-TSP) actually beats the default
|
|
straight-line ordering on REAL recent batches, and turns the feature on or off by
|
|
itself — re-checking every cycle so it self-corrects if the gain ever disappears.
|
|
|
|
Decision (with hysteresis, so it doesn't flap):
|
|
mean travel-time gain >= routing_auto_enable_gain_pct -> enable
|
|
mean travel-time gain < routing_auto_disable_gain_pct -> disable
|
|
in between -> leave as-is
|
|
|
|
Every decision is stored (auditable) in DynamicConfig under `routing_road_eval`
|
|
and exposed via GET /api/v1/ml/road-eval. Cost is bounded: it evaluates only
|
|
`routing_eval_sample_batches` batches once per `routing_eval_interval_hours`.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import threading
|
|
import time
|
|
from datetime import datetime
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
import numpy as np
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _total(order_idx: List[int], matrix: List[List[float]]) -> float:
|
|
"""Total open-route cost: origin(0) -> drops in `order_idx` (1-based into matrix)."""
|
|
seq = [0] + [i + 1 for i in order_idx]
|
|
return sum(matrix[seq[k]][seq[k + 1]] for k in range(len(seq) - 1))
|
|
|
|
|
|
class RoadSequencingAgent:
|
|
"""Measures road-vs-aerial sequencing on real batches and auto-toggles the flag."""
|
|
|
|
def __init__(self):
|
|
self._opt = None # lazy RouteOptimizer
|
|
self._scheduler_started = False
|
|
self._lock = threading.Lock()
|
|
self.last_decision: Dict[str, Any] = {}
|
|
|
|
def _optimizer(self):
|
|
if self._opt is None:
|
|
from app.services.routing.route_optimizer import RouteOptimizer
|
|
self._opt = RouteOptimizer()
|
|
return self._opt
|
|
|
|
async def evaluate(self, sample_batches: int = 8, days: int = 14) -> Dict[str, Any]:
|
|
"""
|
|
Compare aerial-order vs road-order travel time on sampled real batches.
|
|
Returns mean gain % and per-batch detail. No flag changes here.
|
|
"""
|
|
from app.services.routing.delivery_history_service import get_delivery_history_service
|
|
from app.core.arrow_utils import calculate_haversine_matrix_vectorized
|
|
|
|
opt = self._optimizer()
|
|
if not opt.use_google_maps:
|
|
return {"evaluated": 0, "reason": "no_google_key", "mean_gain_pct": 0.0}
|
|
|
|
batches = get_delivery_history_service().sample_batches(
|
|
days=days, limit=sample_batches
|
|
)
|
|
if not batches:
|
|
return {"evaluated": 0, "reason": "no_local_batches", "mean_gain_pct": 0.0}
|
|
|
|
per_batch: List[Dict[str, Any]] = []
|
|
for b in batches:
|
|
origin, drops = b["origin"], b["drops"]
|
|
locs = [origin] + drops
|
|
matrix = await opt._road_duration_matrix(locs)
|
|
if matrix is None:
|
|
continue
|
|
# aerial order (current production behaviour)
|
|
lats = np.array([p[0] for p in locs])
|
|
lons = np.array([p[1] for p in locs])
|
|
aerial = calculate_haversine_matrix_vectorized(lats, lons)
|
|
aer = [i - 1 for i in opt._two_opt_improve(opt._solve_greedy(locs, aerial), aerial) if i != 0]
|
|
# road order (OR-Tools open-TSP on the road travel-time matrix)
|
|
road = [i - 1 for i in opt._solve_tsp_ortools(locs, matrix) if i != 0]
|
|
|
|
# Human's ACTUAL route: drops are already in delivered order
|
|
# (_load_raw_rows is ORDER BY deliverytime), so identity = what the
|
|
# rider really drove. This is the "do we beat the humans?" baseline.
|
|
human = list(range(len(drops)))
|
|
|
|
t_aer = _total(aer, matrix)
|
|
t_road = _total(road, matrix)
|
|
t_human = _total(human, matrix)
|
|
gain = (100.0 * (t_aer - t_road) / t_aer) if t_aer > 0 else 0.0
|
|
gain_vs_human = (100.0 * (t_human - t_road) / t_human) if t_human > 0 else 0.0
|
|
per_batch.append({
|
|
"rider": b["rider"], "day": b["day"], "drops": len(drops),
|
|
"aerial_min": round(t_aer, 1), "road_min": round(t_road, 1),
|
|
"human_min": round(t_human, 1),
|
|
"gain_pct": round(gain, 1),
|
|
"gain_vs_human_pct": round(gain_vs_human, 1),
|
|
"beat_human": t_road <= t_human + 1e-9,
|
|
})
|
|
|
|
if not per_batch:
|
|
return {"evaluated": 0, "reason": "matrix_unavailable", "mean_gain_pct": 0.0}
|
|
|
|
n = len(per_batch)
|
|
mean_gain = sum(x["gain_pct"] for x in per_batch) / n
|
|
mean_vs_human = sum(x["gain_vs_human_pct"] for x in per_batch) / n
|
|
beats = sum(1 for x in per_batch if x["beat_human"])
|
|
return {
|
|
"evaluated": n,
|
|
"mean_gain_pct": round(mean_gain, 2),
|
|
"median_gain_pct": round(sorted(x["gain_pct"] for x in per_batch)[n // 2], 2),
|
|
"human_beat_rate_pct": round(100.0 * beats / n, 1),
|
|
"human_beat_count": f"{beats}/{n}",
|
|
"mean_gain_vs_human_pct": round(mean_vs_human, 2),
|
|
"per_batch": per_batch,
|
|
}
|
|
|
|
def decide_and_apply(self) -> Dict[str, Any]:
|
|
"""Run an evaluation and autonomously enable/disable road sequencing."""
|
|
from app.config.dynamic_config import get_config
|
|
cfg = get_config()
|
|
|
|
sample = int(cfg.get("routing_eval_sample_batches", 8))
|
|
days = int(cfg.get("routing_eval_days", 14))
|
|
enable_thr = float(cfg.get("routing_auto_enable_gain_pct", 3.0))
|
|
disable_thr = float(cfg.get("routing_auto_disable_gain_pct", 1.0))
|
|
min_batches = int(cfg.get("routing_eval_min_batches", 3))
|
|
auto_manage = bool(cfg.get("routing_auto_manage", True))
|
|
|
|
try:
|
|
result = asyncio.run(self.evaluate(sample_batches=sample, days=days))
|
|
except RuntimeError:
|
|
# An event loop is already running in this thread — use a fresh one.
|
|
loop = asyncio.new_event_loop()
|
|
try:
|
|
result = loop.run_until_complete(self.evaluate(sample_batches=sample, days=days))
|
|
finally:
|
|
loop.close()
|
|
|
|
current = bool(cfg.get("routing_use_road_distance", False))
|
|
evaluated = result.get("evaluated", 0)
|
|
gain = result.get("mean_gain_pct", 0.0)
|
|
action = "kept"
|
|
new_state = current
|
|
|
|
if not auto_manage:
|
|
action = "auto_manage_off"
|
|
elif evaluated < min_batches:
|
|
action = "insufficient_data"
|
|
else:
|
|
if gain >= enable_thr and not current:
|
|
new_state = True
|
|
cfg.set("routing_use_road_distance", True, source="road_agent")
|
|
action = "enabled"
|
|
elif gain < disable_thr and current:
|
|
new_state = False
|
|
cfg.set("routing_use_road_distance", False, source="road_agent")
|
|
action = "disabled"
|
|
|
|
decision = {
|
|
"decided_at": datetime.utcnow().isoformat(),
|
|
"action": action,
|
|
"flag_before": current,
|
|
"flag_after": new_state,
|
|
"mean_gain_pct": gain,
|
|
"enable_threshold_pct": enable_thr,
|
|
"disable_threshold_pct": disable_thr,
|
|
"evaluation": result,
|
|
}
|
|
self.last_decision = decision
|
|
try:
|
|
# Persist a compact copy for audit (without the full per-batch list).
|
|
compact = {k: v for k, v in decision.items() if k != "evaluation"}
|
|
compact["evaluated"] = evaluated
|
|
compact["human_beat_rate_pct"] = result.get("human_beat_rate_pct")
|
|
compact["mean_gain_vs_human_pct"] = result.get("mean_gain_vs_human_pct")
|
|
cfg.set("routing_road_eval", compact, source="road_agent")
|
|
except Exception:
|
|
pass
|
|
|
|
logger.info(
|
|
f"[RoadAgent] action={action} gain_vs_aerial={gain}% evaluated={evaluated} "
|
|
f"flag {current}->{new_state}"
|
|
)
|
|
if evaluated:
|
|
logger.info(
|
|
f"[RoadAgent] beat humans on {result.get('human_beat_count', '?')} batches "
|
|
f"(avg {result.get('mean_gain_vs_human_pct', 0)}% faster than actual delivered order)"
|
|
)
|
|
return decision
|
|
|
|
def ensure_background_agent(self, interval_hours: int = 24,
|
|
warmup_seconds: int = 120) -> bool:
|
|
"""Start the autonomous decision loop once (daemon thread)."""
|
|
with self._lock:
|
|
if self._scheduler_started:
|
|
return False
|
|
self._scheduler_started = True
|
|
|
|
def _loop():
|
|
# Let the ETA sync agent populate the local mirror first.
|
|
time.sleep(max(0, warmup_seconds))
|
|
logger.info(f"[RoadAgent] autonomous road-sequencing decision loop started "
|
|
f"(interval={interval_hours}h)")
|
|
while True:
|
|
try:
|
|
self.decide_and_apply()
|
|
except Exception as e:
|
|
logger.warning(f"[RoadAgent] decision cycle failed (will retry): {e}")
|
|
from app.config.dynamic_config import get_config
|
|
hrs = int(get_config().get("routing_eval_interval_hours", interval_hours))
|
|
time.sleep(max(1, hrs) * 3600)
|
|
|
|
threading.Thread(target=_loop, daemon=True, name="road-seq-agent").start()
|
|
return True
|
|
|
|
|
|
_agent: Optional[RoadSequencingAgent] = None
|
|
_agent_lock = threading.Lock()
|
|
|
|
|
|
def get_road_agent() -> RoadSequencingAgent:
|
|
global _agent
|
|
with _agent_lock:
|
|
if _agent is None:
|
|
_agent = RoadSequencingAgent()
|
|
return _agent
|