From 871981035a79518c5ece9cc278dad52b44109c72 Mon Sep 17 00:00:00 2001 From: Suriya Date: Mon, 6 Jul 2026 15:15:51 +0530 Subject: [PATCH] new changes in the api --- app/__pycache__/main.cpython-312.pyc | Bin 8769 -> 8232 bytes .../dynamic_config.cpython-312.pyc | Bin 17416 -> 11175 bytes app/config/dynamic_config.py | 199 +--------- .../__pycache__/arrow_utils.cpython-312.pyc | Bin 3658 -> 1794 bytes app/core/arrow_utils.py | 32 +- app/main.py | 13 - .../__pycache__/__init__.cpython-312.pyc | Bin 511 -> 554 bytes .../__pycache__/ml_admin.cpython-312.pyc | Bin 27267 -> 21921 bytes .../__pycache__/optimization.cpython-312.pyc | Bin 54237 -> 48731 bytes app/routes/__pycache__/riders.cpython-312.pyc | Bin 0 -> 6788 bytes app/routes/ml_admin.py | 157 +------- app/routes/optimization.py | 138 +------ .../assignment_service.cpython-312.pyc | Bin 43539 -> 33487 bytes app/services/core/assignment_service.py | 227 +----------- .../ml_data_collector.cpython-312.pyc | Bin 30158 -> 25809 bytes app/services/ml/behavior_analyzer.py | 55 --- app/services/ml/ml_data_collector.py | 114 +----- app/services/ml/strategy_bandit.py | 277 -------------- .../get_active_riders.cpython-312.pyc | Bin 0 -> 3916 bytes .../rider_history_service.cpython-312.pyc | Bin 0 -> 4421 bytes .../rider_state_manager.cpython-312.pyc | Bin 0 -> 6123 bytes .../substitution_service.cpython-312.pyc | Bin 0 -> 9181 bytes .../clustering_service.cpython-312.pyc | Bin 13764 -> 11312 bytes .../delivery_history_service.cpython-312.pyc | Bin 45210 -> 48069 bytes .../__pycache__/gps_smoother.cpython-312.pyc | Bin 0 -> 7355 bytes .../realistic_eta_calculator.cpython-312.pyc | Bin 5009 -> 4038 bytes .../route_optimizer.cpython-312.pyc | Bin 68523 -> 53436 bytes .../__pycache__/zone_service.cpython-312.pyc | Bin 6355 -> 6314 bytes .../routing/delivery_history_service.py | 67 +++- app/services/routing/gps_smoother.py | 150 ++++++++ app/services/routing/kalman_filter.py | 327 ----------------- app/services/routing/route_optimizer.py | 341 +----------------- .../delivery_history_store.cpython-312.pyc | Bin 35056 -> 38490 bytes app/services/vector/delivery_history_store.py | 140 +++++-- app/templates/ml_dashboard.html | 142 +------- data/rider_active_state.pkl | 1 + data/substitutions.db | Bin 0 -> 12288 bytes .../faiss_history/delivery_history.db.meta | 7 + ml_data/faiss_history/delivery_history.meta | 1 + .../delivery_history_records.db.pkl | Bin 0 -> 367714 bytes .../delivery_history_vectors.db.npy | Bin 0 -> 69696 bytes ml_data/ml_store.db | Bin 37826560 -> 38506496 bytes requirements.txt | 1 - 43 files changed, 414 insertions(+), 1975 deletions(-) create mode 100644 app/routes/__pycache__/riders.cpython-312.pyc delete mode 100644 app/services/ml/behavior_analyzer.py delete mode 100644 app/services/ml/strategy_bandit.py create mode 100644 app/services/rider/__pycache__/get_active_riders.cpython-312.pyc create mode 100644 app/services/rider/__pycache__/rider_history_service.cpython-312.pyc create mode 100644 app/services/rider/__pycache__/rider_state_manager.cpython-312.pyc create mode 100644 app/services/rider/__pycache__/substitution_service.cpython-312.pyc create mode 100644 app/services/routing/__pycache__/gps_smoother.cpython-312.pyc create mode 100644 app/services/routing/gps_smoother.py delete mode 100644 app/services/routing/kalman_filter.py create mode 100644 data/rider_active_state.pkl create mode 100644 data/substitutions.db create mode 100644 ml_data/faiss_history/delivery_history.db.meta create mode 100644 ml_data/faiss_history/delivery_history_records.db.pkl create mode 100644 ml_data/faiss_history/delivery_history_vectors.db.npy diff --git a/app/__pycache__/main.cpython-312.pyc b/app/__pycache__/main.cpython-312.pyc index 75c5542bb7f33e3ca8448a4b97c11b268668a8db..9f1d46b42ad7f71e2868b4693419995181f96e07 100644 GIT binary patch delta 1744 zcmZuxUrd`-6u;-YEv2-S(U$T-OIya@vO!=227;{3a4nUGWg)sX4#xW30kzP1Tfmq> zjnTx!mu+Vc>f)<^0zNe{KB=1o3Nuj`6R|J8`JxYOdst>n^xl>_7~JGLe}2Dn&OP@# z_xtV+f9|*3FE1}6V!eOi<=8^)O^XU+H(P#p&o)PzfoPyv;w`hSkyaoIVLk>zDzP#2 zf(KqjKh(hm90#YNUEmOV4c~^Uz_={8f2}~lk94xmSad%pJ(1nIvJ0;O>XiC)yH=@L zS?3h2+E#Q0D&GUduI>izQLOv_9eOnTFuBw~NTfI4(_zuQ#pqaKPmx`dwM!Nk6i zO)ZeODymq`ic+WnWS8A2viK*pi2Nn|hswH3=++gqU*`!5|B!Bv7SAH99U9TFKLS71 zd72K7Y~B}{#;mTV#?QL74n*I5t)hhg{%;$3yivTea(Y6}5&Dxht;w#vH1QU*$T>fV}?LF0@ zeTvI-U`r=1n|$EPOE*3+odge-m@mQyc-edj-V-u_|C(KpmKIdsDtE#_-mDX5wM`>S zbeS%~B6*WuB#ZE+uzlXlsO3InzCEG>9LMd-HE`f3)&Xe1->m1L5!-AnR{1`1<`nA#l~+SQZpUpF{$fvwr~=d{C8zDBf`_5J}Gz*f=8uS9mnkAd9Fop(bR#ij2G~1bHd-cN-8pzW_#*sF}h>PbzTxW*GvmP}5hW@g#MG@BHk37;114SdPD)*8wu%3xFC8sn3xnZ(>=oQDPC z)8!0F#usLyjE`cM%i)y2*QETPdU?LSjwcc+&IRxpUU9|Y4F2eH!6g3WIto3Rin^1a z@M=8mt_2k%ZWq+zqI=xgCE|0siTKQwTxpV}M%e<3b2%?wk2`KJOk<_z*r_22$kN;^ zM2_ek} zL&GB?^L$MyR2zM!6zRz4yC#+y^VILUE6sW+m|C&k27>`)nAR1VXS|(mcu+9JA^Jlx4V|FjfL0hI4^*f zpxr;XR^zKV6KCdYhu}5Ki~?n#e|N(S%*FLLa@L*Ta2WvoTeG#fb@I0sqGR!O)M_+F zmHC@+wwAMV4$jHBI5*#P%`>CS*J$ROV-ot7^3h~|LN#S)8(oIyQF2je5A9)EaMT1E zq5YVhKo|4#?fm>CJD4mlFBLx6j97*B z0#oMP!8dT#!y5q*-S~+qp{>6SpRg@;MK>|}o*-C)j_KX-Miy_#;#d}!6KwC`z!ZIo z9*5)LIDG(&!|NCa1F6sTX?R*Q#LEMN;RF|zM=SBQ9qh>B!)#xOm1Hq2^FtGCPG?0v z%uTSrzI&0)=p)I5%paErBFWK&d_v3S*HSgsEFOgqLdKLQTPE{v2WjK1W7P4kpQA8JW%Xe%#R@<0OgL=+Wp<4L6Yp$D>-p4&5?)_ZdYNM~kH-g(1%Q4CF=pzXD{ah$NO1028??%G}kExUejVq>M;jJVQs0 z4vBRbNk%-dEQ>6ba?Ngxn+f?1YPVe7qtP%s;Zs%XW0My5ao@`?{Yglr&-ad9avSjagsOtPl)%o`_ja5`zvj6mN*w7hUNDDu1+*J?m7#h1+ zaF=fEHiEm&JGxuJkIn5{dNzZ5RqeK3514~>ICQRF-Q}g{UUPIg=y|geGxH7xGxJ`W zU{>9&qvwN;?jm{t5_O@7!OVh=s0&7QcMHAXaCC2@7wVM5gxbasswFG8s(Uuki$#u} z3VP8;k{2r&!fYby;%0TPlV05B=(W;m15wjf1~X|VQPUoENJXbB93h%czfRQqG=rJ@ zDx&^nLpSs`G-wOM-KkmI98?~{M;G^?^NuZ$L$i(`452?9UM7L*%c$5nd*ID%03I^h zo7V=$5M#Hl;GI28hfaJG7%s9uIE+U7jQEhHTz|5FN~@Bwh~jW4s`e zHWFF%gNKC)^xRX`)IkWcujnK3v^bg&$1!?|zaUpzwLPBXMg_iAyn;DmO8OdgdV7@5 zz{Eo^fj;%7JY5jB!H&E`t^jm7*`T+ac0N@t374Uhs4RBCK+s<~tekXeY@KbCT;BIL@B)#ro zdqFbHMgT|I7~nYDC+J(0ZDoCI;4Euo``H0@@Ee}DXeOxp^pNhIR@xv-y@?$ff>j|0 z2|Ij)up>{B4?|JHx1CCmOoUD7;c0~(z2VM8*%5YZM#(gnmGp7V5iO!8tXIlOC{aN<&7B-ZMq>2E!-*?AZ|QYb$LYo&rEE(KfY*Tt=Hiq4CXRI~VZu)EbY@4BwcyC%uJa@{?zyyl*FPZG-`eiZBw zCqngp$4j zp>R&iT{KmYCGf`&qRAA%wWfVehT}5de(?1gh|LoK3*>>X;rG7yZ+-E5zK;9iSa_5k z5?>B~G&l*`nw#ZwPT-oSG{-c!Ej&+Jk*C1Invl1NBaIQot;i<&dGTIkJ3X}GiWU_* zD$JH-`~Ni_cB9k8E8l8qQs@z}5_@Lj3=4ZT&8Y)GBXf; zaiy#6*OaEjU*bu%KTY`=@k0C1uB+&fBbeybRnU#cr7WWqV>v~|(EnRS@xAsGeN)_P zKLI%PnW;lJGBdq)e>cIXrZT-?X-}7JQrzrK zC?67WyZ6)vd?#RFM$qG4+|%P}TJy|mMoBB6LhRVQyE2Sw?D1MKo7D^h;#v8kVP>;@ z991~1(DN4OZlpNZ9A}-siXht?hnl~Ra1;SksJY-B=5mw+*=(+$nPyHe6tYBownx>s8WZFrwFzEAvsACOKF8||<7 zMreofz}-=0wu)QZL)5OSX?rE{5w*QKX8R?nBWlyq2D@I82BL0Q-edEn@dc(l0gkfFxUpT4}G2+gl|Y zMAekNU6LI{ZCT>>PDzF^3B}$c$zGxcmwfiHBqJ1)y=0G4Ne=-{EH~SS9eM<4YFW1@ z9C{RJXZ4(Y%%Lvf{)|IU0!>2WzU1>a(F(Oeo|yn3^Lb%=~ms{H+c sGNI76M?~UTH}N%puCm~^Jqisi9a|-^*l^*|`|gmfy6TndUfFp61-fMdQ2+n{ delta 8058 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 diff --git a/app/config/dynamic_config.py b/app/config/dynamic_config.py index 08569aa..b73f18b 100644 --- a/app/config/dynamic_config.py +++ b/app/config/dynamic_config.py @@ -1,14 +1,14 @@ """ Dynamic Configuration - rider-api -Replaces all hardcoded hyperparameters with DB-backed values. -The ML hypertuner writes optimal values here; services read from here. +Replaces all hardcoded hyperparameters with DB-backed values. The autonomous +agents (ETA sync, road-sequencing, rider affinity) and the ml_admin API write +tuned values here; services read from here. Fallback: If DB is unavailable or no tuned values exist, defaults are used. This means zero risk - the system works day 1 with no data. """ -import contextvars import json import logging import os @@ -21,32 +21,9 @@ logger = logging.getLogger(__name__) # --- DB Path ------------------------------------------------------------------ _DB_PATH = os.getenv("ML_DB_PATH", "ml_data/ml_store.db") -# --------------------------------------------------------------------------- -# Per-request strategy override (async-safe via contextvars). -# Each FastAPI request/asyncio task gets its own copy — no cross-request leaks. -# Usage: -# set_request_strategy("fuel_saver") → override active for this request -# clear_request_strategy() → restore to DB value -# --------------------------------------------------------------------------- -_strategy_override: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar( - "strategy_override", default=None -) - - -def set_request_strategy(strategy: Optional[str]) -> None: - """Override ml_strategy for the current async task only (thread-safe).""" - _strategy_override.set(strategy) - - -def clear_request_strategy() -> None: - """Remove the per-request strategy override for the current async task.""" - _strategy_override.set(None) - # --- Hard Defaults (What the system used before ML) --------------------------- DEFAULTS: Dict[str, Any] = { - # System Strategy / Prompt - "ml_strategy": "balanced", # AssignmentService "max_pickup_distance_km": 10.0, "max_kitchen_distance_km": 3.0, @@ -65,9 +42,6 @@ DEFAULTS: Dict[str, Any] = { "road_factor": 1.3, # ClusteringService "cluster_radius_km": 3.0, - # KalmanFilter - "kalman_process_noise": 1e-4, - "kalman_measurement_noise": 0.01, # RealisticETACalculator "eta_pickup_time_min": 3.0, "eta_delivery_time_min": 4.0, @@ -101,6 +75,18 @@ DEFAULTS: Dict[str, Any] = { "rider_affinity_enabled": True, # False -> pure curated config "rider_affinity_min_deliveries": 10, # learned owner needs >= this many deliveries "rider_affinity_refresh_hours": 6, # recompute cadence (piggybacks the agent) + # Phase-0 kitchen+zone pattern store (delivery_history_store.py): which rider + # historically owns a kitchen->drop-zone pair, used to pre-assign orders. + # "csv" -> legacy: built from delivery_details.csv, only refreshed when a + # human re-exports it and calls POST /ml/reload-history. + # "db" -> built from the already-synced nearledb mirror (delivery_raw), + # rebuilt automatically every eta_sync_interval_hours — no manual + # step, no extra DB load (reuses rows the ETA sync already pulled). + "pattern_source": "csv", + "pattern_history_days": 30, # retention floor for delivery_raw kept for pattern- + # matching volume; independent of eta_history_days + # so the (already-validated) empirical ETA window + # is untouched. } @@ -137,18 +123,8 @@ class DynamicConfig: # -------------------------------------------------------------------------- def get(self, key: str, default: Any = None) -> Any: - """Get a config value. Returns ML-tuned value if available, else default. - - For 'ml_strategy' specifically, a per-request ContextVar override takes - precedence so that hypertuning_params requests don't mutate the shared - singleton (thread-safe for concurrent FastAPI requests). - """ + """Get a config value. Returns ML-tuned value if available, else default.""" self._maybe_reload() - # Per-request strategy override (async-safe, no cross-request leaks) - if key == "ml_strategy": - override = _strategy_override.get() - if override is not None: - return override val = self._cache.get(key) if val is not None: return val @@ -163,7 +139,7 @@ class DynamicConfig: return result def set(self, key: str, value: Any, source: str = "manual") -> None: - """Write a config value to DB (used by hypertuner).""" + """Write a config value to DB (used by the ml_admin API and agents).""" try: os.makedirs(os.path.dirname(_DB_PATH) or ".", exist_ok=True) conn = sqlite3.connect(_DB_PATH) @@ -185,8 +161,8 @@ class DynamicConfig: except Exception as e: logger.error(f"[DynamicConfig] Failed to set {key}: {e}") - def set_bulk(self, params: Dict[str, Any], source: str = "ml_hypertuner") -> None: - """Write multiple config values at once (called after each Optuna study).""" + def set_bulk(self, params: Dict[str, Any], source: str = "manual") -> None: + """Write multiple config values at once.""" for key, value in params.items(): self.set(key, value, source=source) logger.info(f"[DynamicConfig] Bulk update: {len(params)} params from {source}") @@ -219,16 +195,6 @@ class DynamicConfig: updated_at TEXT ) """) - conn.execute(""" - CREATE TABLE IF NOT EXISTS kitchen_encoding ( - kitchen_name TEXT PRIMARY KEY, - label_id INTEGER NOT NULL, - frequency REAL DEFAULT 0.0, - avg_profit REAL DEFAULT 0.0, - order_count INTEGER DEFAULT 0, - updated_at TEXT - ) - """) conn.commit() conn.close() except Exception as e: @@ -278,131 +244,4 @@ def get_config() -> DynamicConfig: __all__ = [ "DynamicConfig", "get_config", - "set_request_strategy", - "clear_request_strategy", - "get_kitchen_label_id", - "get_kitchen_frequency", - "update_kitchen_stats", - "get_kitchen_avg_profit_smoothed", ] - - -# --- Kitchen Encoding Persistence --------------------------------------------- -def get_kitchen_label_id(kitchen_name: str) -> int: - """Get or create persistent label ID for a kitchen.""" - try: - conn = sqlite3.connect(_DB_PATH) - row = conn.execute( - "SELECT label_id FROM kitchen_encoding WHERE kitchen_name = ?", - (kitchen_name,), - ).fetchone() - if row: - conn.close() - return row[0] - - max_id = conn.execute( - "SELECT COALESCE(MAX(label_id), -1) FROM kitchen_encoding" - ).fetchone()[0] - new_id = max_id + 1 - conn.close() - return new_id - except Exception as e: - logger.warning(f"[KitchenEncoding] Failed to get label_id: {e}") - return hash(kitchen_name.lower().strip()) % 10000 - - -def get_kitchen_frequency(kitchen_name: str) -> float: - """Get frequency ratio for a kitchen from DB.""" - try: - conn = sqlite3.connect(_DB_PATH) - row = conn.execute( - "SELECT frequency FROM kitchen_encoding WHERE kitchen_name = ?", - (kitchen_name,), - ).fetchone() - conn.close() - return row[0] if row else 0.0 - except Exception: - return 0.0 - - -def update_kitchen_stats(kitchen_name: str, profit: float): - """Update kitchen stats after order completion.""" - try: - conn = sqlite3.connect(_DB_PATH) - row = conn.execute( - "SELECT order_count, avg_profit FROM kitchen_encoding WHERE kitchen_name = ?", - (kitchen_name,), - ).fetchone() - - if row: - count, avg = row - new_count = count + 1 - new_avg = ((avg * count) + profit) / new_count - new_freq = new_count / ( - conn.execute( - "SELECT SUM(order_count) FROM kitchen_encoding" - ).fetchone()[0] - or 1 - ) - conn.execute( - "UPDATE kitchen_encoding SET order_count = ?, avg_profit = ?, frequency = ?, updated_at = ? WHERE kitchen_name = ?", - ( - new_count, - new_avg, - new_freq, - datetime.utcnow().isoformat(), - kitchen_name, - ), - ) - else: - conn.execute( - "INSERT INTO kitchen_encoding (kitchen_name, label_id, frequency, avg_profit, order_count, updated_at) VALUES (?, ?, ?, ?, ?, ?)", - ( - kitchen_name, - get_kitchen_label_id(kitchen_name), - 1.0, - profit, - 1, - datetime.utcnow().isoformat(), - ), - ) - conn.commit() - conn.close() - except Exception as e: - logger.warning(f"[KitchenEncoding] Failed to update stats: {e}") - - -def get_kitchen_avg_profit_smoothed( - kitchen_name: str, global_avg: float = 40.0, min_samples: int = 5 -) -> float: - """ - Get smoothed average profit for a kitchen using Bayesian smoothing. - Reduces noise for kitchens with few orders. - - Formula: smoothed = (kitchen_count * kitchen_avg + min_samples * global_avg) / (kitchen_count + min_samples) - - This means: - - Kitchen with many samples -> uses its own avg - - Kitchen with few samples -> pulls toward global avg - """ - try: - conn = sqlite3.connect(_DB_PATH) - row = conn.execute( - "SELECT order_count, avg_profit FROM kitchen_encoding WHERE kitchen_name = ?", - (kitchen_name,), - ).fetchone() - conn.close() - - if row and row[0] > 0: - count, avg = row - if count >= min_samples: - return avg - # Bayesian smoothing - smoothed = ((count * avg) + (min_samples * global_avg)) / ( - count + min_samples - ) - return smoothed - - return global_avg # Unknown kitchen defaults to global avg - except Exception: - return global_avg diff --git a/app/core/__pycache__/arrow_utils.cpython-312.pyc b/app/core/__pycache__/arrow_utils.cpython-312.pyc index 18e0c8241912cca62b568f97739a8775b04b7bc6..ed44d5cfd1b5bcbe900f0da805bf0162a706cfc4 100644 GIT binary patch delta 453 zcmZvYKTE?v7{>1|$t4Z6Dh^c<3^;YMxOA~NIQpkJ31$gL?i#Noxo}CqbSa3VTL-}p zAc%|5Z=v5Hj!te${QzF7rAy!8-rsZ2J$Kyux_#3az18bBP_=(~GPu?O_^8CZ@IOrN z6x;&}TtFd3A#~BKYp#X>(+1W&u;|QqhJVV%RAXq+Sa@@yIvXuFjOBqorubHOtpc^o z;gNm8{7gtbWYp>9(RtzI84r2JS?ctKbOI&JCvsxiGAkyB=qx;WL(7Qp8W~b zk3ny;iXJqqDU|y7#W=8=BuU!7kgPpk!5w2$Iriij@4!*3R+^qih3|Q#5$91-$ZCA0 z8Hym_aZuI*mYphQva~!eCJ|F7apkHVGp0mJQpoDYCm^?~CpW6R)r|zrLv|>4m8>S0 go~h+agAjfr1of!};QBiPjqzi>WB-6AT${yz0lt`G7ytkO literal 3658 zcmbVOTWs6b89o##i4x^YY^O;R$6;I>j*8h^-8%Jxw29la2?EzknhZ@8uLMmVSu`Y( zKBQtxkPD;Og9Qw8i(TTa9p+*K<{&TL(>!3q8f-v^?L~qzIAA@jK%esF&3V|mhwcBV zi)B|?fE|E5m;ZA9^IyJm{@l_MCeZ%$gWntf5()VnC$$!=4tBo65ptX8Bt~>j_a(WQ z&+GY^;Pw7gAQt2*G%+T^8lMcM!m)5lib<(RERt%8wQ$5os5sMIQ@-2Nw!5GoTdZ9d zsBfmXGHd+n{>Q8bny<$LdhpvqtV0)Rm`b#hM&iDwK_Py6fC@7w8?{%)aX}A(%)zEI zVcH3F3FuGhFa{l8(hKwm&<|yO(U#l~!xxR|#Gpl4++-;=tx-AS7)ir1s4Zt~BRwsT zS*n(x@)%?0oUEpGc_Nd#oRxuJo~GtBQ>}!d$-3&OL*dKJyk+PhdW&j~$&4J;WfPRE zj$x*4PewOv2luf(U7Be!-AJnrl`Uq%DsDLxeuzh%hzhPS4(zUQ-q0MEA4_Ll|Fw*j zq^^)u9UK0j)eSJ}WLR4BHTVj0gz)g~ybG(46WoH&sYZ{GnMTg4P@dGHyTCtzldE$I zd49&luoS>#B2VQN^)$AOBX6Hb;99X{))P)9l4?FfAs(~8WOTk$_ zShM7R^XYs(i1Dj&*JBEHEQonuUPOzolBhrTyP%Y~6 zV3f;iCM>B;gK{2r1$&k`(GWv0W|%`R_ogdil`0?(BdAnwBS=HvvP~Br`J(wI>-)235#GZ~#!5sMTADtpMvy8T+st4gBgGNs~8 zWfzFpG1#XSKDz@>xJb4mB-FMz_9#HwJC<{W+)7`$?fBxwt+oRVGhm8zZAwQ=($SSy zR=Kj&yLx@If27nuvaW7)l>5(=q%(`>A4r`~SaRM|3-e|1@XCpjc=W@m zpT>U_uPdF}H~~t_Jum&i_etPh;N$tt3vZP!yj8q@qj>XMXw8<4+42-CI+@Z`rqq)uiJ8T*2V&?uuPk3GT>3t{J@@|H%Ix=YKOg?ZiyytX z_T1X+ospltT#gKv#NoxU&!ouGY++<^deS5Q>T)&lDO9r zhAq=HF4%I8GILNft!(3sgZCAU#|_QE{MXE6CIy;h8!jhxx~e70yFN>Wn+5f_Qk{sx)jqC#%T3;b26QHYUaq`dcmSLWXP!Hc$hS!J^s>d5FN@5PY77fJ=p)U=w+ z+C~+(zG>5VCh74c&FN`;tYEw$De&z9pdi}@!X~DZ%&3)AvsKAlkBnC6^4`s>Tc4J5 zPs^{Xu)mHb*=TQ_G!YGY(T1Fvb)`9GI8^a+zzv`gl(~YDj+<`iwRw%gApz$66!3~g z1uvOcKlsh{S422!V-ykjD(TgYn<;hYD4uG`F}&PbXnilUlDIDpY>BPQQb8&{^}_n~^-IOBvwsoK{c}s|`Ul}a2#AqFqj&2P;=va-#gURY^6zaPh+o(TLH)a5KSO>K8XMs6`45lv^7nfEFm4VDEEP=}0W zu+Wghkax{M;x1^x=0U``mnq|>x+g?fnt&I3G6W-Dc>u~PyeaEof-q8zc9dsWn{Jp< zRj&ps1O;3za-SCxHV7;>UVF7zbR=Q>eGzyb=%rZxr5GIa!W8v-sKr zseSou;cQvzE(+aVwMP%TK}FF`O;KEbI+L=p4DTQ}Xk|V8U?*_aR-Fx1BMszg$UVb@ z)%11)PS%1nX9xleGlwB;FsxBGG)+2@|gKQ_}TU()}rE{Tqpv zN%XVOk)=bMp(CZxk(JSMsBbZ_Ega$wtowcy{$+TZ!0_YVPoBN^tT!xPxw?7fX6ee! z9VBcI`?-^=eXGvetLvR>7e9KZ)O%{1z}m*}??!(;x`V^*)-ZQ!t!qtRe}02sAH9<< fMPJ$`u=dB!9qhIb`?+JQohWu}AKN2b^N#mlMBZ%x diff --git a/app/core/arrow_utils.py b/app/core/arrow_utils.py index 738c081..f077316 100644 --- a/app/core/arrow_utils.py +++ b/app/core/arrow_utils.py @@ -1,13 +1,9 @@ """ -High-performance utilities using Apache Arrow and NumPy for geographic data. -Provides vectorized operations for distances and coordinate processing. +Vectorized NumPy utilities for geographic distance calculations. """ import numpy as np -import pyarrow as pa -import pyarrow.parquet as pq import logging -from typing import List, Dict, Any, Tuple logger = logging.getLogger(__name__) @@ -35,29 +31,3 @@ def calculate_haversine_matrix_vectorized(lats: np.ndarray, lons: np.ndarray) -> c = 2 * np.arctan2(np.sqrt(a), np.sqrt(1 - a)) return R * c - -def orders_to_arrow_table(orders: List[Dict[str, Any]]) -> pa.Table: - """ - Convert a list of order dictionaries to an Apache Arrow Table. - This enables zero-copy operations and efficient columnar storage. - """ - return pa.Table.from_pylist(orders) - -def save_optimized_route_parquet(orders: List[Dict[str, Any]], filename: str): - """ - Save optimized route data to a Parquet file for high-speed analysis. - Useful for logging and historical simulation replays. - """ - try: - table = orders_to_arrow_table(orders) - pq.write_table(table, filename) - logger.info(f" Saved route data to Parquet: {filename}") - except Exception as e: - logger.error(f" Failed to save Parquet: {e}") - -def load_route_parquet(filename: str) -> List[Dict[str, Any]]: - """ - Load route data from a Parquet file and return as a list of dicts. - """ - table = pq.read_table(filename) - return table.to_pylist() diff --git a/app/main.py b/app/main.py index fa7013a..dde5008 100644 --- a/app/main.py +++ b/app/main.py @@ -83,19 +83,6 @@ async def lifespan(app: FastAPI): except Exception as e: logger.warning(f"[Analytics] DB check failed (non-fatal): {e}") - # Warm up the Thompson Sampling bandit (bootstraps from historical DB) - try: - from app.services.ml.strategy_bandit import get_bandit - bandit = get_bandit() - stats = bandit.get_stats() - logger.info( - f"[Bandit] RL strategy bandit ready — " - f"{stats['context_count']} contexts, " - f"{stats['total_updates']} historical updates loaded." - ) - except Exception as e: - logger.warning(f"[Bandit] Warm-up failed (non-fatal): {e}") - # Start the autonomous empirical-ETA sync agent (daemon thread). # It mirrors completed deliveries locally and rebuilds learned ETAs on a # schedule, so the request path never touches Postgres. diff --git a/app/routes/__pycache__/__init__.cpython-312.pyc b/app/routes/__pycache__/__init__.cpython-312.pyc index d51954a1bc07a9c326c7d3c8923d17a9168e5021..0c5d8ff1d637109124a14da86dbd3577a2d77403 100644 GIT binary patch delta 145 zcmey*yo!bIG%qg~0}!;mH^~xVn#d=?7&B4bQzV5kg(-(Gmnn*$ks+NSg=G4 nBO~KY2GOSs=64yi?=ooKXK=gAU<+h_V&ULsYGf}G1*!r7^j{^< delta 84 zcmZ3*@}HUSG%qg~0}xDBlE}KqIFV0+F<_#)Cr3I%3dPVWGcrl>d}3keW@=| zSPHjfmuDm0G`DV6ylxrinR8$@_^@tS7cXT9o@3V69rwR57GL$hSkWdWoxbl zvuxALX`@~dlxb5=zDl0Y(M-3m<)b@J3s5Xo1$NSJty!}<#oU}(OUwURHs@M`5^dEh zXdAw}a4afqI-$6Vv3yVt7FgR6Y(vm=LF&AID3X;%)n8?H*8vQD}u$F?_TPB-!x z*FNgY@%Pc5?40h0@5r92hv|VFUvEzA<_WRxk>fAH9_1gCaTW`P4K2=e8Xh)ctX>O5 zsn#EgMdFEAwBhkUBt9CBj%W#k1q^*;LK{Lq28049bS*GCY97+{sK#_7j2X1QoP0rx z#-h3w3u;D4k7)V{ooRt!P#-qX9Y`d?BhiQ+HMAJhSnRlA<++D+%odKZ^n_}VbYp%@ zCJpHs>xxX;(l0og2@&Df>r*n7FUw$eZ-9O>8vGY zc-^@dNKwyvAF__&n@Ay?6qZ#B3>0mLzYSKv$J=U4U1&YEyITQJE$g6xhkYTZfhiF zel#U~S1y3m-T>nVtraO%6p~_6oh4|;=PC53@>2OJQ&>ouq29Z_&a~=d^~QtQO`51g zDDm!JSD*vsXPa@E~q?%OV(@oCvUJHED&fr?R2}0h^JTuL| zY)V<-O|PfiN=;r&n6y4Fc!VY)LB>QP3~+nBSjHgJkiHD&w04Mul==r;S~0V9;~2V*R=j?0G5qW(neI2+cX zx1(l*iPd7BjQty~v5vze1s`?nuuP(xo#gNgkZUpXoWG-e%Fe5WP5odg25_SabYY!{tIn9w# zKm4*YfAU4te#4oYa8XYRUloXox*{Mft0ju}j^m+4N5hnI$Du7cs;AUDP3<>&7Mu2d zn!k0*dNwjMaNAKhB`;ZQ(=BIOraR7bT&VlZvT?~#bT&Lw__MLk9W6JSmRw~&nm9Kx z}uQ5giSu*ZQ5rM=3N{1c1rUNdmN~|->K}&&s&i5P+qW@d?snZ+31s{4`jt> z0Ncdpnn#l;1U!YX)az8PpzcoJ>y>2p>pb|U9NyqijezC&ZeXu~^U%@at6b#0vnqs) z6pk7hX+$^R%%N@7--ym~vov)q9Hp@_%(3-I8QeQmpuC0AyVB;vTV%)2c{UB((pppg55BADNNK z&GUm!Q#O17jqqBpo7{w5gFBUvP#sSjgQXIA52laqS3ltKH(}{$6Ef__YSiDM;dzp7 zKQ=9?f8fqf;8vjC{s|Yko{{is&(3Z6kW>8(Ek7pdmZ3k2>JrLt6DZVQcKigzx|&R$ z-%n%1em{E-=Jn%~*@jBc#ddS>aR?w}6g-K;p^VHwM~q*c895%quMfXE8NcP&haC9k zXRREv)5BMu@x^6)wizEjTR delta 6869 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 diff --git a/app/routes/__pycache__/optimization.cpython-312.pyc b/app/routes/__pycache__/optimization.cpython-312.pyc index 2128f8e13f8318c868920bff2341194ce15ebd72..769eeee7b1e377093f525c99eb5432a0bc0cf5ee 100644 GIT binary patch delta 15882 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 literal 0 HcmV?d00001 diff --git a/app/routes/ml_admin.py b/app/routes/ml_admin.py index 28de651..be2de82 100644 --- a/app/routes/ml_admin.py +++ b/app/routes/ml_admin.py @@ -3,8 +3,8 @@ Analytics & ML Admin API ========================= Exposes historical assignment quality data, the empirical-ETA pipeline, and the autonomous road-sequencing / rider-affinity agents. (The XGBoost/Optuna hypertuner, -ID3 risk tree, and profit predictor have all been retired — they never affected -assignment.) +ID3 risk tree, profit predictor, and Thompson-Sampling strategy bandit have all +been retired — none of them ever affected assignment or routing behavior.) Endpoints: GET /api/v1/ml/status – quality trend, analytics DB + history stats @@ -12,7 +12,6 @@ Endpoints: GET /api/v1/ml/config – active config values PATCH /api/v1/ml/config – manual config override POST /api/v1/ml/reset – reset config to defaults - POST /api/v1/ml/strategy – change optimization strategy POST /api/v1/ml/refresh-eta – sync nearledb + rebuild empirical ETA stats GET /api/v1/ml/eta-accuracy – formula vs empirical ETA backtest GET/POST /api/v1/ml/road-eval – autonomous road-sequencing decision @@ -103,7 +102,6 @@ def ml_analytics(): "hourly_stats": collector.get_hourly_stats(), "zone_stats": collector.get_zone_stats(), "quality_histogram": collector.get_quality_histogram(), - "strategy_comparison": collector.get_strategy_comparison(), } except Exception as e: logger.error(f"[ML API] analytics: {e}", exc_info=True) @@ -163,142 +161,6 @@ def ml_reset(): raise HTTPException(status_code=500, detail=str(e)) -# --------------------------------------------------------------------------- -# POST /strategy -# --------------------------------------------------------------------------- - -@router.post("/strategy", summary="Change the optimization strategy") -def ml_strategy(strategy: str = Body(default="balanced", embed=True)): - """ - Choices: balanced | fuel_saver | aggressive_speed | zone_strict - Affects how the quality score is computed in analytics only. - """ - valid = ["balanced", "fuel_saver", "aggressive_speed", "zone_strict"] - if strategy not in valid: - raise HTTPException(400, f"Invalid strategy. Choose from {valid}") - from app.config.dynamic_config import get_config - try: - get_config().set("ml_strategy", strategy) - return {"status": "ok", "strategy": strategy} - except Exception as e: - logger.error(f"[ML API] strategy: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=str(e)) - - -# --------------------------------------------------------------------------- -# POST /auto-tune -# --------------------------------------------------------------------------- - -@router.post("/auto-tune", summary="Run SQL-based strategy auto-tuner") -def ml_auto_tune(): - """ - Analyses the assignment log and picks the best-performing ml_strategy - based on average quality score across all recorded calls. - - Rules: - - A strategy needs ≥ 10 calls to be considered. - - At least 2 strategies must have enough data to compare. - - If a better strategy is found it is written to DynamicConfig - immediately and takes effect on the next /riderassign call. - - Also returns per-hour breakdown so you can see peak-hour patterns. - - Safe to call any time. Runs in < 100ms. - """ - from app.services.ml.ml_data_collector import get_collector - from app.config.dynamic_config import get_config, DEFAULTS - try: - collector = get_collector() - cfg = get_config() - comparison = collector.get_strategy_comparison() - hourly = collector.get_hourly_stats() - total_records = collector.count_records() - - if total_records == 0: - return { - "status": "no_data", - "message": "No assignment events logged yet. " - "Call /riderassign a few times first.", - "total_records": 0, - } - - qualified = [s for s in comparison if s["call_count"] >= 10] - current_strategy = cfg.get("ml_strategy", "balanced") - action = "no_change" - recommendation = None - - if len(qualified) >= 2: - best = max(qualified, key=lambda x: x["avg_quality"]) - recommendation = best["strategy"] - if best["strategy"] != current_strategy: - cfg.set("ml_strategy", best["strategy"], source="auto_tuner") - action = "updated" - logger.info( - f"[AutoTune API] Strategy: '{current_strategy}' → " - f"'{best['strategy']}' (quality={best['avg_quality']:.1f})" - ) - elif len(comparison) > 0: - action = "insufficient_data" - recommendation = comparison[0]["strategy"] # best so far even if < 10 calls - - # Per-hour best strategy (informational — not auto-applied) - # Shows which strategy logged the highest quality at each hour - hour_best: list = [] - if hourly: - for h in hourly: - # Find which strategy performed best in this hour block - # (simple: use the dominant strategy for that hour from comparison) - hour_best.append({ - "hour": h["hour"], - "avg_quality": h["avg_quality"], - "call_count": h["call_count"], - "sla_breaches": h["sla_breaches"], - }) - - return { - "status": "ok", - "action": action, - "current_strategy": cfg.get("ml_strategy", "balanced"), - "recommendation": recommendation, - "total_records": total_records, - "strategy_comparison": comparison, - "hourly_quality": hour_best, - "message": ( - f"Strategy updated to '{recommendation}'." - if action == "updated" - else "Current strategy is already optimal." - if action == "no_change" - else "More data needed (≥ 10 calls per strategy to compare)." - ), - } - except Exception as e: - logger.error(f"[ML API] auto-tune: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=str(e)) - - -# --------------------------------------------------------------------------- -# GET /bandit -# --------------------------------------------------------------------------- - -@router.get("/bandit", summary="Thompson Sampling bandit — posterior stats per context") -def ml_bandit(): - """ - Shows the current state of the RL strategy bandit. - - Each context (time_band|load_band) has 4 arms (strategies). - For each arm: - mean_reward — expected quality / 100 based on posterior mean - observations — number of observed calls (excluding prior) - alpha / beta — Beta distribution parameters - - The bandit uses Thompson Sampling to select strategies automatically - on every /riderassign call, balancing exploration vs exploitation. - """ - try: - from app.services.ml.strategy_bandit import get_bandit - return {"status": "ok", **get_bandit().get_stats()} - except Exception as e: - logger.error(f"[ML API] bandit: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=str(e)) # --------------------------------------------------------------------------- @@ -346,21 +208,24 @@ def ml_history(patterns: bool = False): (all clear dominant-rider zones, sorted by pattern score). """ from app.services.vector.delivery_history_store import ( - get_delivery_history_store, _INDEX_PATH, _META_PATH, CSV_PATH + get_delivery_history_store, _paths_for, _pattern_source, CSV_PATH ) try: - store = get_delivery_history_store() - meta = {} - if os.path.isfile(_META_PATH): - with open(_META_PATH, "r", encoding="utf-8") as f: + store = get_delivery_history_store() + source = _pattern_source() + vectors_path, _records_path, meta_path = _paths_for(source) + meta = {} + if os.path.isfile(meta_path): + with open(meta_path, "r", encoding="utf-8") as f: meta = json.load(f) resp = { "status": "ok", + "pattern_source": source, "record_count": store.record_count(), "pattern_count": store.pattern_count(), "index_ready": store.record_count() > 0, - "disk_index": os.path.isfile(_INDEX_PATH), + "disk_index": os.path.isfile(vectors_path), "csv_path": CSV_PATH, "csv_exists": os.path.isfile(CSV_PATH), "saved_meta": meta, diff --git a/app/routes/optimization.py b/app/routes/optimization.py index eac8850..ed8ee2f 100644 --- a/app/routes/optimization.py +++ b/app/routes/optimization.py @@ -11,7 +11,6 @@ from fastapi import APIRouter, Body, Request, Depends, status, HTTPException, Qu from app.controllers.route_controller import RouteController from app.core.exceptions import APIException -from app.core.arrow_utils import save_optimized_route_parquet logger = logging.getLogger(__name__) @@ -85,68 +84,19 @@ def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float: return float("inf") -def _auto_tune_strategy(collector) -> None: - """ - SQL-based strategy auto-tuner. - - Reads quality_score statistics from the assignment log grouped by - ml_strategy. If one strategy clearly outperforms the rest (minimum - 10 calls, ≥ 2 strategies compared) it is written to DynamicConfig so - all future requests benefit automatically. - - Safe to call at any time — DynamicConfig write is idempotent. - """ - try: - comparison = collector.get_strategy_comparison() - if not comparison or len(comparison) < 2: - return # not enough variety to compare - - # Only consider strategies with at least 10 real observations - qualified = [s for s in comparison if s["call_count"] >= 10] - if not qualified: - return - - best = max(qualified, key=lambda x: x["avg_quality"]) - from app.config.dynamic_config import get_config as _gc - cfg = _gc() - current = cfg.get("ml_strategy", "balanced") - if best["strategy"] != current: - cfg.set("ml_strategy", best["strategy"], source="auto_tuner") - logger.info( - f"[AutoTune] Strategy updated: '{current}' → '{best['strategy']}' " - f"(avg_quality={best['avg_quality']:.1f}, n={best['call_count']})" - ) - else: - logger.debug( - f"[AutoTune] Strategy '{current}' already optimal " - f"(avg_quality={best['avg_quality']:.1f})" - ) - except Exception as _ate: - logger.warning(f"[AutoTune] Failed (non-fatal): {_ate}") - - -def _bg_log_and_maybe_retrain( +def _bg_log_assignment( num_orders: int, num_riders: int, hyperparams: dict, assignments: dict, unassigned_count: int, elapsed_ms: float, - bandit_context: "tuple | None" = None, # (time_band, load_band) ) -> None: - """ - Background worker: log event → update RL bandit → auto-retrain ID3 - → auto-tune strategy. Runs in _ml_executor (never blocks the response). - - Thresholds: - every call → bandit posterior update (Thompson Sampling RL) - every 100 evts → greedy SQL auto-tuner (backup check, rarely fires now) - """ + """Background worker: log the assignment event. Runs in _ml_executor so + it never blocks the API response.""" try: from app.services.ml.ml_data_collector import get_collector as _gc collector = _gc() - - # ── 1. Log event, get back quality_score ────────────────────────── quality_score = collector.log_assignment_event( num_orders=num_orders, num_riders=num_riders, @@ -155,29 +105,8 @@ def _bg_log_and_maybe_retrain( unassigned_count=unassigned_count, elapsed_ms=elapsed_ms, ) or 50.0 - count = collector.count_records() logger.debug(f"[ML BG] Logged event #{count}, quality={quality_score:.1f}") - - # ── 2. Thompson Sampling bandit update (RL feedback) ────────────── - if bandit_context: - try: - from app.services.ml.strategy_bandit import get_bandit as _gb - _t_band, _l_band = bandit_context - _strategy = hyperparams.get("ml_strategy", "balanced") - get_bandit_fn = _gb - get_bandit_fn().update(_t_band, _l_band, _strategy, quality_score) - logger.debug( - f"[Bandit] Updated ctx={_t_band}|{_l_band} " - f"arm={_strategy} reward={quality_score/100:.3f}" - ) - except Exception as _be: - logger.debug(f"[Bandit] Update failed (non-fatal): {_be}") - - # ── 3. Greedy SQL auto-tune every 100 events (backup) ──────────── - if count >= 20 and count % 100 == 0: - _auto_tune_strategy(collector) - except Exception as _e: logger.warning(f"[ML BG] Background task failed (non-fatal): {_e}") @@ -242,18 +171,6 @@ async def provider_optimize_forward( try: url = "https://jupiter.nearle.app/live/api/v1/deliveries/createdeliveries" result = await controller.optimize_and_forward_provider_payload(body, url) - - # Parquet snapshot — offloaded so it never blocks the response - def _snap(): - try: - os.makedirs("data/snapshots", exist_ok=True) - snapshot_path = f"data/snapshots/route_{int(time.time())}.parquet" - save_optimized_route_parquet(body, snapshot_path) - logger.info(f"Apache Arrow: Snapshot saved to {snapshot_path}") - except Exception as e: - logger.warning(f"Could not save Arrow snapshot: {e}") - _ml_executor.submit(_snap) - return result except APIException: raise @@ -554,7 +471,6 @@ async def assign_orders_to_riders( request: Request, body: Any = Body(default=None), reshuffle: bool = Query(False, alias="reshuffle"), - hypertuning_params: str = None, ): """ Smart assignment of orders to riders. @@ -653,11 +569,10 @@ async def assign_orders_to_riders( # 3. Log summary after all data is in hand mode_str = "reshuffle" if do_reshuffle else "normal" - tuning_str = hypertuning_params if hypertuning_params else "null" logger.info( f"\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n" f"[API HIT] POST /api/v1/optimization/riderassign\n" - f"[CONFIG] Mode: {mode_str.upper()} | Hypertuning: {tuning_str} | Active Riders: {len(riders)}\n" + f"[CONFIG] Mode: {mode_str.upper()} | Active Riders: {len(riders)}\n" f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" ) logger.info(f"[API] riderassign ▶ orders={len(orders)} riders={len(riders)} mode={mode_str}") @@ -811,44 +726,10 @@ async def assign_orders_to_riders( vrp_orders = [o for o in orders if id(o) not in history_pre_assigned_ids] # 3. Run Assignment (AssignmentService) - # -- Per-request strategy override (thread-safe via contextvars) -- - # Old approach mutated _cfg._cache directly — a race condition when two - # concurrent requests set different strategies simultaneously. - # New approach: each async task gets its OWN copy of the ContextVar, - # so overrides never leak between concurrent requests. - from app.config.dynamic_config import get_config, set_request_strategy - from app.services.ml.behavior_analyzer import time_band as _tb, load_band as _lb - from datetime import datetime as _DT + from app.config.dynamic_config import get_config _cfg = get_config() - # ── Bandit context (used both for selection + background update) ── - _bandit_t_band = _tb(_DT.now().isoformat()) - _bandit_l_band = _lb(len(vrp_orders) / max(1, len(riders))) - _bandit_context = (_bandit_t_band, _bandit_l_band) - - valid_strategies = ["balanced", "fuel_saver", "aggressive_speed", "zone_strict"] - if hypertuning_params and hypertuning_params in valid_strategies: - # Explicit caller override always wins - set_request_strategy(hypertuning_params) - logger.info(f"[HYPERTUNE] Per-request strategy override: {hypertuning_params}") - else: - # ── Thompson Sampling: let the bandit choose ────────────────── - # The bandit samples from Beta posteriors for this (time, load) - # context and picks the arm with highest sample. It naturally - # explores when uncertain and exploits known-good strategies as - # data accumulates. Replaces the one-size-fits-all greedy picker. - try: - from app.services.ml.strategy_bandit import get_bandit as _gb - _bandit_strategy = _gb().select(_bandit_t_band, _bandit_l_band) - set_request_strategy(_bandit_strategy) - logger.info( - f"[Bandit] Strategy='{_bandit_strategy}' " - f"ctx={_bandit_t_band}|{_bandit_l_band}" - ) - except Exception as _bse: - logger.debug(f"[Bandit] Selection failed (non-fatal): {_bse}") - optimizer = RouteOptimizer() # ── PHASE 3a: TRUE VRP (primary solver) ────────────────────────────── @@ -863,7 +744,7 @@ async def assign_orders_to_riders( if not do_reshuffle: # VRP not meaningful during reshuffle (intentional exploration) try: # Build minimal rider info for VRP - from app.services.routing.kalman_filter import smooth_rider_locations + from app.services.routing.gps_smoother import smooth_rider_locations _smooth_riders = smooth_rider_locations(list(riders)) from app.services.core.assignment_service import AssignmentService as _AS @@ -1272,21 +1153,19 @@ async def assign_orders_to_riders( risk_meta: dict = {"label": "n/a", "model_trained": False, "deprecated": True} # ── BACKGROUND ML LOGGING ──────────────────────────────────────────── - # Fire-and-forget: log this assignment event to SQLite, auto-retrain - # ID3 every 50 events, auto-tune strategy every 100 events. + # Fire-and-forget: log this assignment event to SQLite. # Uses _ml_executor so the API response is never delayed. try: _elapsed_ms = (time.time() - _t0) * 1000 _hyp_snapshot = get_config().get_all() # frozen copy for this call _ml_executor.submit( - _bg_log_and_maybe_retrain, + _bg_log_assignment, len(orders), len(riders), _hyp_snapshot, {rid: list(ords) for rid, ords in assignments.items()}, len(unassigned_orders), round(_elapsed_ms, 1), - _bandit_context, # (time_band, load_band) for RL update ) except Exception as _mle: logger.debug(f"[ML BG] Submit failed (non-fatal): {_mle}") @@ -1354,7 +1233,6 @@ async def assign_orders_to_riders( }, "reshuffle_mode": do_reshuffle, "solver_mode": "vrp_optimal" if used_vrp else "2phase_heuristic", - "hypertuning_params": hypertuning_params or "default", "faiss_coord_corrections": _faiss_corrected, "faiss_customer_records": _faiss_store.record_count() if _faiss_store else 0, "faiss_history_preassigned": _history_hits, diff --git a/app/services/core/__pycache__/assignment_service.cpython-312.pyc b/app/services/core/__pycache__/assignment_service.cpython-312.pyc index 12a190eb5cd990982158a71c3e710d3700880f95..be421f6f4cb8259a750f64c265b9aaba039ec3c6 100644 GIT binary patch delta 12617 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

oQ5+7S^agr)k0<{^dl=LiJ*4Dm!#mbIVt4{?5 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

KPjHZ{-nGn$#<&CxW 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 v59u|ZtK(bgr# float: - """Calculate great circle distance between two points.""" - try: - lon1, lat1, lon2, lat2 = map( - radians, [float(lon1), float(lat1), float(lon2), float(lat2)] - ) - dlon = lon2 - lon1 - dlat = lat2 - lat1 - a = sin(dlat / 2) ** 2 + cos(lat1) * cos(lat2) * sin(dlon / 2) ** 2 - c = 2 * asin(min(1.0, sqrt(a))) - return c * DataEncoder.EARTH_RADIUS_KM - except: - return 0.0 - - @staticmethod - def cyclic_encode_hour(hour: int) -> tuple[float, float]: - """ - Cyclic time encoding - captures traffic patterns better than discrete buckets. - hour_sin = sin(2π * hour / 24) - hour_cos = cos(2π * hour / 24) - """ - hour_sin = math.sin(2 * math.pi * hour / 24) - hour_cos = math.cos(2 * math.pi * hour / 24) - return hour_sin, hour_cos - - @staticmethod - def cyclic_encode_day(day_of_week: int) -> tuple[float, float]: - """ - Cyclic day encoding for weekly patterns. - """ - day_sin = math.sin(2 * math.pi * day_of_week / 7) - day_cos = math.cos(2 * math.pi * day_of_week / 7) - return day_sin, day_cos - - @staticmethod - def geohash_encode(lat: float, lon: float, precision: int = 7) -> str: - """ - Geohash encoding for spatial data. - Converts lat/lon to grid cell string for locality capture. - precision=7 gives ~153m x 153m cells (good for delivery zones) - """ - try: - return _simple_geohash(lat, lon, precision) - except: - return "unknown" - - -def _simple_geohash(lat: float, lon: float, precision: int = 7) -> str: - """Standard geohash encoding — 5 bits per character, BASE32 output.""" - if lat == 0 or lon == 0: - return "unknown" - BASE32 = "0123456789bcdefghjkmnpqrstuvwxyz" - lat_min, lat_max = -90.0, 90.0 - lon_min, lon_max = -180.0, 180.0 - hash_chars = [] - is_lon = True - for _ in range(precision): - char_bits = 0 - for _ in range(5): - if is_lon: - mid = (lon_min + lon_max) / 2 - if lon >= mid: - char_bits = (char_bits << 1) | 1 - lon_min = mid - else: - char_bits = char_bits << 1 - lon_max = mid - else: - mid = (lat_min + lat_max) / 2 - if lat >= mid: - char_bits = (char_bits << 1) | 1 - lat_min = mid - else: - char_bits = char_bits << 1 - lat_max = mid - is_lon = not is_lon - hash_chars.append(BASE32[char_bits]) - return "".join(hash_chars) - - def _zone_key(lat: float, lon: float) -> str: """O(1) ~5 km grid cell key for zone proximity matching.""" if lat == 0 or lon == 0: @@ -117,26 +22,6 @@ def _zone_key(lat: float, lon: float) -> str: return f"{int(lat / 0.044)},{int(lon / 0.044)}" -# Kitchen encoding now uses persistent DB storage from dynamic_config - - -def update_kitchen_encoding(kitchen_name: str, profit: float = None): - """Update kitchen stats in DB when orders are processed.""" - if kitchen_name and kitchen_name != "Unknown": - if profit is not None: - update_kitchen_stats(kitchen_name, profit) - - -def get_kitchen_label_id(kitchen_name: str) -> int: - """Get persistent label ID for a kitchen.""" - return _get_kitchen_label_id(kitchen_name) - - -def get_kitchen_frequency(kitchen_name: str) -> float: - """Get persistent frequency ratio for a kitchen.""" - return _get_kitchen_frequency(kitchen_name) - - class AssignmentService: def __init__(self): # Curated config drives HARD kitchen ownership. Copy so substitution @@ -196,35 +81,19 @@ class AssignmentService: self.earth_radius_km = 6371 self._cfg = get_config() - self._encoder = DataEncoder() # Cost parameters for composite cost function self._fuel_rate = 2.5 # Per km self._base_rider_cost = 0.0 self._merchant_margin_avg = 5.0 # Default average margin - # Profit encoding cache - self._kitchen_profit_cache: Dict[str, List[float]] = defaultdict(list) - self._profit_mean = 0.0 - self._profit_std = 1.0 - def calculate_order_profit_features( self, order: Dict[str, Any], distance_km: float ) -> Dict[str, float]: """ - Calculate engineered profit features for ML-ready data. - - Features: - - profit: order amount - rider cost - - profit_density: profit per kilometer (key signal!) - - cost_efficiency: rider cost per estimated time - - route_score: profit - composite cost (maximize this!) - - encoded_geohash: spatial encoding - - cyclic_time: hour_sin, hour_cos + Calculate profit and profit-density for one order, used as a scoring + signal (profit_bonus) when picking which rider gets a cluster. """ - features = {} - - # Extract order values try: order_amount = float( order.get("orderamount") or order.get("deliveryamount") or 0 @@ -232,38 +101,17 @@ class AssignmentService: except: order_amount = 0.0 - # Calculate rider cost: base + (distance * fuel_rate) + # Rider cost: base + (distance * fuel_rate) rider_cost = self._base_rider_cost + (distance_km * self._fuel_rate) - features["rider_cost"] = rider_cost # Profit = revenue - cost profit = order_amount - rider_cost - features["profit"] = profit - # Profit density = profit / distance (HIGH SIGNAL feature!) - # High density = profitable short deliveries - if distance_km > 0: - features["profit_density"] = profit / distance_km - else: - features["profit_density"] = 0.0 + # Profit density = profit / distance — high density means profitable + # short deliveries; used to prioritise clusters worth serving. + profit_density = profit / distance_km if distance_km > 0 else 0.0 - # Cost efficiency = cost / time estimate (assuming 15 min per order average) - estimated_time_min = max(15, distance_km * 4) # Rough estimate - features["cost_efficiency"] = ( - rider_cost / estimated_time_min if estimated_time_min > 0 else 0 - ) - - # Route score = profit - distance_cost (what we want to MAXIMIZE) - # This is the core optimization target - features["route_score"] = profit - ( - distance_km * 0.5 - ) # 0.5 = opportunity cost per km - - # Composite edge weight for optimizer (MINIMIZE this) - # weight = cost - profit_margin_bonus - features["composite_weight"] = rider_cost - (profit * 0.3) # 30% profit bonus - - return features + return {"profit": profit, "profit_density": profit_density} def _load_config(self): """Load ML-tuned hyperparams fresh on every assignment call.""" @@ -478,7 +326,6 @@ class AssignmentService: # Use caller-supplied pricing so dynamic API rates flow into scoring self._fuel_rate = fuel_charge self._base_rider_cost = base_pay - _call_start = time.time() # 0. Prep assignments: Dict[int, List[Dict[str, Any]]] = defaultdict(list) @@ -588,36 +435,14 @@ class AssignmentService: orders, max_cluster_radius_km=self.MAX_KITCHEN_DISTANCE_KM ) - # 2b. ENRICH CLUSTERS WITH PROFIT FEATURES (Data Encoding: Target + Frequency) + # 2b. Tag each cluster with the set of kitchen names it covers (used + # below for hard kitchen-ownership matching). for cluster in clusters: cluster["kitchen_names"] = set() for order in cluster["orders"]: - k_name = self.get_order_kitchen(order) - cluster["kitchen_names"].add(k_name) - update_kitchen_encoding(k_name) + cluster["kitchen_names"].add(self.get_order_kitchen(order)) - # Update kitchen profit cache for target encoding - for order in cluster["orders"]: - k_name = self.get_order_kitchen(order) - profit = ( - float(order.get("orderamount") or order.get("deliveryamount") or 50) - - 40 - ) - self._kitchen_profit_cache[k_name].append(profit) - - # Calculate profit statistics for normalization - all_profits = [ - p for profits in self._kitchen_profit_cache.values() for p in profits - ] - if all_profits: - self._profit_mean = sum(all_profits) / len(all_profits) - if len(all_profits) > 1: - variance = sum((p - self._profit_mean) ** 2 for p in all_profits) / len( - all_profits - ) - self._profit_std = variance**0.5 - - logger.info(f"Created {len(clusters)} order clusters with profit encoding") + logger.info(f"Created {len(clusters)} order clusters") # 2c. MINIMAL RIDER PRE-SELECTION # Calculate the theoretical minimum number of riders needed so we don't @@ -659,15 +484,6 @@ class AssignmentService: cluster_geohash = _zone_key(centroid_lat, centroid_lon) for order in cluster_orders: - k_name = self.get_order_kitchen(order) - # Target encoding: use average profit for this kitchen - kitchen_profits = self._kitchen_profit_cache.get(k_name, [0]) - avg_kitchen_profit = ( - sum(kitchen_profits) / len(kitchen_profits) - if kitchen_profits - else 0 - ) - o_lat = float(order.get("pickuplat", 0)) o_lon = float(order.get("pickuplon", 0)) dist = ( @@ -1004,19 +820,10 @@ class AssignmentService: # 6. Commit State and History self._post_process(assignments, rider_states, state_mgr) - # 7. -- ML DATA COLLECTION ----------------------------------------- - try: - elapsed_ms = (time.time() - _call_start) * 1000 - get_collector().log_assignment_event( - num_orders=len(orders), - num_riders=len(riders), - hyperparams=self._cfg.get_all(), - assignments=assignments, - unassigned_count=len(unassigned_orders), - elapsed_ms=elapsed_ms, - ) - except Exception as _ml_err: - logger.debug(f"ML logging skipped: {_ml_err}") + # ML event logging happens once, in the /riderassign endpoint after + # Phase-0 history merge + solo consolidation — not here — so every + # request produces exactly one assignment_ml_log row (see + # optimization.py::_bg_log_assignment). # Log final distribution (use r_orders to avoid shadowing the outer `orders` list) logger.info("=" * 50) diff --git a/app/services/ml/__pycache__/ml_data_collector.cpython-312.pyc b/app/services/ml/__pycache__/ml_data_collector.cpython-312.pyc index 63f726b3f18ed2006e26f41a4287b075756dfb73..ea46a5cafb6e3a6594913a94a50c50adee7b0682 100644 GIT binary patch delta 6941 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 delta 9723 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 Y=Yz9`vN@_IrBc$a^NBkYyyRK`e~kr?;Q#;t diff --git a/app/services/ml/behavior_analyzer.py b/app/services/ml/behavior_analyzer.py deleted file mode 100644 index 0ade27b..0000000 --- a/app/services/ml/behavior_analyzer.py +++ /dev/null @@ -1,55 +0,0 @@ -""" -Feature band encoders -====================== -Discrete bucketers for assignment-context features (distance, time-of-day, load, -order density). These are small pure helpers used by the Thompson-sampling -strategy bandit (`strategy_bandit.py`) and the /riderassign bandit context. - -NOTE: The ID3 SUCCESS/RISK decision tree that used to live here has been retired — -it only ever produced response metadata and never affected any assignment. Only -the generic feature-band encoders remain. -""" - -from datetime import datetime - - -def distance_band(km: float) -> str: - """Total route distance -> discrete band.""" - if km <= 5.0: return "SHORT" - if km <= 15.0: return "MID" - if km <= 30.0: return "LONG" - return "VERY_LONG" - - -def time_band(ts_str: str) -> str: - """ISO timestamp -> time-of-day band.""" - try: - hour = datetime.fromisoformat(ts_str).hour - if 6 <= hour < 10: return "MORNING_RUSH" - if 10 <= hour < 12: return "LATE_MORNING" - if 12 <= hour < 14: return "LUNCH_RUSH" - if 14 <= hour < 17: return "AFTERNOON" - if 17 <= hour < 20: return "EVENING_RUSH" - if 20 <= hour < 23: return "NIGHT" - return "LATE_NIGHT" - except Exception: - return "UNKNOWN" - - -def load_band(avg_load: float) -> str: - """Average orders-per-rider -> load band.""" - if avg_load <= 2.0: return "LIGHT" - if avg_load <= 5.0: return "MODERATE" - if avg_load <= 8.0: return "HEAVY" - return "OVERLOADED" - - -def order_density_band(num_orders: int, num_riders: int) -> str: - """Orders per available rider -> density band.""" - if num_riders == 0: - return "NO_RIDERS" - ratio = num_orders / num_riders - if ratio <= 2.0: return "SPARSE" - if ratio <= 5.0: return "NORMAL" - if ratio <= 9.0: return "DENSE" - return "OVERLOADED" diff --git a/app/services/ml/ml_data_collector.py b/app/services/ml/ml_data_collector.py index f83646d..79fd933 100644 --- a/app/services/ml/ml_data_collector.py +++ b/app/services/ml/ml_data_collector.py @@ -7,13 +7,12 @@ Key upgrades over the original -------------------------------- 1. FROZEN historical scores - quality_score is written ONCE at log time. get_training_data() returns scores as-is from the DB (no retroactive mutation). -2. Rich schema - zone_id, city_id, is_peak, weather_code, - sla_breached, avg_delivery_time_min for richer features. -3. SLA tracking - logs whether delivery SLA was breached. -4. Analytics API - get_hourly_stats(), get_strategy_comparison(), - get_quality_histogram(), get_zone_stats() for dashboard consumption. -5. Thread-safe writes - connection-per-write pattern for FastAPI workers. -6. Indexed columns - timestamp, ml_strategy, zone_id for fast queries. +2. Rich schema - zone_id, city_id, is_peak, weather_code for + richer features. +3. Analytics API - get_hourly_stats(), get_quality_histogram(), + get_zone_stats() for dashboard consumption. +4. Thread-safe writes - connection-per-write pattern for FastAPI workers. +5. Indexed columns - timestamp, zone_id for fast queries. """ import csv @@ -45,7 +44,7 @@ class MLDataCollector: Each log_assignment_event() call writes one row capturing: - Operating context (time, orders, riders, zone, city) - Active hyperparams (exact config snapshot for this call) - - Measured outcomes (quality score, SLA, latency, distances) + - Measured outcomes (quality score, latency, distances) quality_score is computed once and FROZEN - never retroactively changed. """ @@ -70,8 +69,6 @@ class MLDataCollector: zone_id: str = "default", city_id: str = "default", weather_code: str = "CLEAR", - sla_minutes: Optional[float] = None, - avg_delivery_time_min: Optional[float] = None, ) -> None: """ Log one assignment event. @@ -95,13 +92,8 @@ class MLDataCollector: o for orders in assignments.values() if orders for o in orders ] total_distance_km = sum(self._get_km(o) for o in all_orders) - ml_strategy = hyperparams.get("ml_strategy", "balanced") max_opr = hyperparams.get("max_orders_per_rider", 12) - sla_breached = 0 - if sla_minutes and avg_delivery_time_min: - sla_breached = int(avg_delivery_time_min > sla_minutes) - # Quality score - FROZEN at log time quality_score = self._compute_quality_score( num_orders=num_orders, @@ -111,7 +103,6 @@ class MLDataCollector: num_riders=num_riders, total_distance_km=total_distance_km, max_orders_per_rider=max_opr, - ml_strategy=ml_strategy, ) row = { @@ -146,7 +137,6 @@ class MLDataCollector: "search_time_limit_seconds", 5 ), "road_factor": hyperparams.get("road_factor", 1.3), - "ml_strategy": ml_strategy, "riders_used": riders_used, "total_assigned": total_assigned, "unassigned_count": unassigned_count, @@ -154,8 +144,6 @@ class MLDataCollector: "load_std": round(load_std, 3), "total_distance_km": round(total_distance_km, 2), "elapsed_ms": round(elapsed_ms, 1), - "sla_breached": sla_breached, - "avg_delivery_time_min": round(avg_delivery_time_min or 0.0, 2), "quality_score": round(quality_score, 2), } @@ -171,7 +159,7 @@ class MLDataCollector: except Exception as e: logger.warning(f"[MLCollector] Logging failed (non-fatal): {e}") - return 50.0 # neutral fallback so bandit update still fires + return 50.0 # neutral fallback # ------------------------------------------------------------------ # Data retrieval for training @@ -180,7 +168,6 @@ class MLDataCollector: def get_training_data( self, min_records: int = 30, - strategy_filter: Optional[str] = None, since_hours: Optional[int] = None, ) -> Optional[List[Dict[str, Any]]]: """ @@ -195,9 +182,6 @@ class MLDataCollector: params: list = [] clauses: list = [] - if strategy_filter: - clauses.append("ml_strategy = ?") - params.append(strategy_filter) if since_hours: cutoff = (datetime.utcnow() - timedelta(hours=since_hours)).isoformat() clauses.append("timestamp >= ?") @@ -253,7 +237,7 @@ class MLDataCollector: return {"avg_quality": 0.0, "sample_size": 0, "history": []} def get_hourly_stats(self, last_days: int = 7) -> List[Dict[str, Any]]: - """Quality, SLA, and call volume aggregated by hour-of-day.""" + """Quality and call volume aggregated by hour-of-day.""" try: conn = sqlite3.connect(self._db_path) cutoff = (datetime.utcnow() - timedelta(days=last_days)).isoformat() @@ -263,8 +247,7 @@ class MLDataCollector: COUNT(*) AS call_count, AVG(quality_score) AS avg_quality, AVG(unassigned_count) AS avg_unassigned, - AVG(elapsed_ms) AS avg_latency_ms, - SUM(CASE WHEN sla_breached=1 THEN 1 ELSE 0 END) AS sla_breaches + AVG(elapsed_ms) AS avg_latency_ms FROM assignment_ml_log WHERE timestamp >= ? GROUP BY hour ORDER BY hour """, @@ -278,7 +261,6 @@ class MLDataCollector: "avg_quality": round(r[2] or 0.0, 2), "avg_unassigned": round(r[3] or 0.0, 2), "avg_latency_ms": round(r[4] or 0.0, 1), - "sla_breaches": r[5], } for r in rows ] @@ -286,42 +268,6 @@ class MLDataCollector: logger.error(f"[MLCollector] get_hourly_stats: {e}") return [] - def get_strategy_comparison(self) -> List[Dict[str, Any]]: - """Compare quality metrics across ml_strategy values.""" - try: - conn = sqlite3.connect(self._db_path) - rows = conn.execute( - """ - SELECT ml_strategy, - COUNT(*) AS call_count, - AVG(quality_score) AS avg_quality, - MIN(quality_score) AS min_quality, - MAX(quality_score) AS max_quality, - AVG(unassigned_count) AS avg_unassigned, - AVG(total_distance_km) AS avg_distance_km, - AVG(elapsed_ms) AS avg_latency_ms - FROM assignment_ml_log - GROUP BY ml_strategy ORDER BY avg_quality DESC - """ - ).fetchall() - conn.close() - return [ - { - "strategy": r[0], - "call_count": r[1], - "avg_quality": round(r[2] or 0.0, 2), - "min_quality": round(r[3] or 0.0, 2), - "max_quality": round(r[4] or 0.0, 2), - "avg_unassigned": round(r[5] or 0.0, 2), - "avg_distance_km": round(r[6] or 0.0, 2), - "avg_latency_ms": round(r[7] or 0.0, 1), - } - for r in rows - ] - except Exception as e: - logger.error(f"[MLCollector] get_strategy_comparison: {e}") - return [] - def get_quality_histogram(self, bins: int = 10) -> List[Dict[str, Any]]: """Quality score distribution for histogram chart.""" try: @@ -348,14 +294,13 @@ class MLDataCollector: return [] def get_zone_stats(self) -> List[Dict[str, Any]]: - """Quality and SLA stats grouped by zone.""" + """Quality stats grouped by zone.""" try: conn = sqlite3.connect(self._db_path) rows = conn.execute( """ SELECT zone_id, COUNT(*) AS call_count, AVG(quality_score) AS avg_quality, - SUM(sla_breached) AS sla_breaches, AVG(total_distance_km) AS avg_distance_km FROM assignment_ml_log GROUP BY zone_id ORDER BY avg_quality DESC @@ -367,8 +312,7 @@ class MLDataCollector: "zone_id": r[0], "call_count": r[1], "avg_quality": round(r[2] or 0.0, 2), - "sla_breaches": r[3], - "avg_distance_km": round(r[4] or 0.0, 2), + "avg_distance_km": round(r[3] or 0.0, 2), } for r in rows ] @@ -385,17 +329,6 @@ class MLDataCollector: except Exception: return 0 - def count_by_strategy(self) -> Dict[str, int]: - try: - conn = sqlite3.connect(self._db_path) - rows = conn.execute( - "SELECT ml_strategy, COUNT(*) FROM assignment_ml_log GROUP BY ml_strategy" - ).fetchall() - conn.close() - return {r[0]: r[1] for r in rows} - except Exception: - return {} - def export_csv(self) -> str: """Export all records as CSV string.""" try: @@ -448,7 +381,6 @@ class MLDataCollector: num_riders: int, total_distance_km: float, max_orders_per_rider: int, - ml_strategy: str = "balanced", ) -> float: """ Multi-dimensional quality score (0–100, higher = better). @@ -461,11 +393,7 @@ class MLDataCollector: │ rider_efficiency │ reward using minimal riders for the batch size │ └──────────────────────┴────────────────────────────────────────────────┘ - Strategy weights (w_assign, w_dist, w_balance, w_efficiency): - - balanced : (45, 20, 20, 15) - - aggressive_speed: (70, 15, 0, 15) — care about assignment + efficiency - - fuel_saver : (25, 60, 0, 15) — heavily penalise long routes - - zone_strict : (35, 25, 25, 15) — balanced with zone awareness + One fixed weighting (45, 20, 20, 15) is used for every call. """ import math if num_orders == 0: @@ -488,14 +416,7 @@ class MLDataCollector: min_riders_needed = max(1, math.ceil(num_orders / max_orders_per_rider)) rider_efficiency = min(1.0, min_riders_needed / max(1, riders_used)) - weights = { - # assign dist balance efficiency - "aggressive_speed": (70.0, 15.0, 0.0, 15.0), - "fuel_saver": (25.0, 60.0, 0.0, 15.0), - "zone_strict": (35.0, 25.0, 25.0, 15.0), - "balanced": (45.0, 20.0, 20.0, 15.0), - } - w_comp, w_dist, w_bal, w_eff = weights.get(ml_strategy, (45.0, 20.0, 20.0, 15.0)) + w_comp, w_dist, w_bal, w_eff = (45.0, 20.0, 20.0, 15.0) return min( assigned_ratio * w_comp @@ -542,7 +463,6 @@ class MLDataCollector: cluster_radius_km REAL, search_time_limit_seconds INTEGER, road_factor REAL, - ml_strategy TEXT DEFAULT 'balanced', riders_used INTEGER, total_assigned INTEGER, unassigned_count INTEGER, @@ -550,8 +470,6 @@ class MLDataCollector: load_std REAL, total_distance_km REAL DEFAULT 0.0, elapsed_ms REAL, - sla_breached INTEGER DEFAULT 0, - avg_delivery_time_min REAL DEFAULT 0.0, quality_score REAL ) """) @@ -560,9 +478,6 @@ class MLDataCollector: "ALTER TABLE assignment_ml_log ADD COLUMN zone_id TEXT DEFAULT 'default'", "ALTER TABLE assignment_ml_log ADD COLUMN city_id TEXT DEFAULT 'default'", "ALTER TABLE assignment_ml_log ADD COLUMN weather_code TEXT DEFAULT 'CLEAR'", - "ALTER TABLE assignment_ml_log ADD COLUMN sla_breached INTEGER DEFAULT 0", - "ALTER TABLE assignment_ml_log ADD COLUMN avg_delivery_time_min REAL DEFAULT 0.0", - "ALTER TABLE assignment_ml_log ADD COLUMN ml_strategy TEXT DEFAULT 'balanced'", "ALTER TABLE assignment_ml_log ADD COLUMN total_distance_km REAL DEFAULT 0.0", ] for ddl in migrations: @@ -572,7 +487,6 @@ class MLDataCollector: pass for idx in [ "CREATE INDEX IF NOT EXISTS idx_timestamp ON assignment_ml_log(timestamp)", - "CREATE INDEX IF NOT EXISTS idx_strategy ON assignment_ml_log(ml_strategy)", "CREATE INDEX IF NOT EXISTS idx_zone ON assignment_ml_log(zone_id)", ]: conn.execute(idx) diff --git a/app/services/ml/strategy_bandit.py b/app/services/ml/strategy_bandit.py deleted file mode 100644 index 2c3cb51..0000000 --- a/app/services/ml/strategy_bandit.py +++ /dev/null @@ -1,277 +0,0 @@ -""" -Thompson Sampling Contextual Bandit — Strategy Selector -========================================================= -Replaces the greedy SQL auto-tuner with proper online RL. - -Problem -------- -The old greedy tuner picks the strategy with the highest *average* quality -across all history. It never explores alternatives once one strategy leads, -and it ignores context (the same strategy isn't best at all times of day -and all load levels). - -Solution --------- -A contextual multi-armed bandit with Thompson Sampling: - - Context = (time_band, load_band) — up to 7 × 4 = 28 states - Arms = 4 strategies — balanced / fuel_saver / aggressive_speed / zone_strict - Reward = quality_score / 100 — 0..1 float, already logged by MLDataCollector - -How Thompson Sampling works ---------------------------- -Each (context, arm) pair has a Beta(α, β) posterior where: - α = sum of rewards seen so far (high quality calls push α up) - β = sum of "anti-rewards" (low quality calls push β up) - -To SELECT a strategy: - 1. For each arm, sample θ ~ Beta(α, β) - 2. Pick arm with highest θ - → Naturally balances exploration (uncertain arms get sampled often) - with exploitation (well-known good arms dominate when confident) - -To UPDATE after an assignment: - reward = quality_score / 100 - α += reward - β += (1 - reward) - -Bootstrap ---------- -On first startup the entire historical SQLite DB is replayed to warm up -posteriors so the bandit starts informed, not blank. -If saved state already exists it is loaded from disk instead. - -Persistence ------------ -ml_data/strategy_bandit.json — saved every 10 updates. -""" - -import json -import logging -import os -import threading -from typing import Dict, List, Optional, Tuple - -import numpy as np - -logger = logging.getLogger(__name__) - -_SAVE_PATH = os.getenv("BANDIT_PATH", "ml_data/strategy_bandit.json") -_DB_PATH = os.getenv("ML_DB_PATH", "ml_data/ml_store.db") - -ARMS = ["balanced", "fuel_saver", "aggressive_speed", "zone_strict"] - - -class ContextualBandit: - """ - Thompson Sampling bandit for ml_strategy selection. - - Context key : "{time_band}|{load_band}" - Arms : ARMS list (4 strategies) - Prior : Beta(1, 1) — uniform, no initial preference - """ - - def __init__(self): - self._lock = threading.Lock() - # _posteriors[ctx_key][arm] = [alpha, beta] - self._posteriors: Dict[str, Dict[str, List[float]]] = {} - self._update_count = 0 - self._total_pulls = 0 - self._load() - self._bootstrap_from_db() - - # ── Public API ─────────────────────────────────────────────────────────── - - def select(self, time_band: str, load_band: str) -> str: - """ - Sample from Beta posteriors and return the strategy with the highest - sample. Explores uncertain arms naturally; exploits known-good arms - as confidence grows. - """ - ctx = self._ctx(time_band, load_band) - with self._lock: - samples = { - arm: float(np.random.beta(*self._ab(ctx, arm))) - for arm in ARMS - } - self._total_pulls += 1 - chosen = max(samples, key=samples.__getitem__) - logger.debug( - f"[Bandit] ctx={ctx} " - f"samples={{{', '.join(f'{k}:{v:.3f}' for k, v in samples.items())}}} " - f"→ {chosen}" - ) - return chosen - - def update(self, time_band: str, load_band: str, - strategy: str, quality_score: float) -> None: - """Update the Beta posterior for (context, arm) after observing quality.""" - if strategy not in ARMS: - return - ctx = self._ctx(time_band, load_band) - reward = min(1.0, max(0.0, float(quality_score) / 100.0)) - with self._lock: - ab = self._ab(ctx, strategy) - ab[0] += reward # alpha ← quality adds to success mass - ab[1] += (1.0 - reward) # beta ← (1-quality) adds to failure mass - self._update_count += 1 - should_save = self._update_count % 10 == 0 - if should_save: - self._save() - - def best_arm(self, time_band: str, load_band: str) -> Tuple[str, float]: - """ - Return the arm with the highest posterior mean (pure exploitation, no - sampling noise). Used for logging and dashboard, not for live selection. - """ - ctx = self._ctx(time_band, load_band) - with self._lock: - means = { - arm: self._ab(ctx, arm)[0] / sum(self._ab(ctx, arm)) - for arm in ARMS - } - best = max(means, key=means.__getitem__) - return best, round(means[best], 4) - - def get_stats(self) -> Dict: - """Return per-context arm statistics for the ML admin dashboard.""" - with self._lock: - stats: Dict[str, Dict] = {} - for ctx, arms in sorted(self._posteriors.items()): - stats[ctx] = {} - for arm in ARMS: - if arm not in arms: - stats[ctx][arm] = {"mean_reward": 0.5, "observations": 0, - "alpha": 1.0, "beta": 1.0} - continue - alpha, beta = arms[arm] - n = alpha + beta - 2.0 # subtract the Beta(1,1) prior mass - mean = alpha / (alpha + beta) - stats[ctx][arm] = { - "mean_reward": round(mean, 4), - "observations": round(max(0, n), 1), - "alpha": round(alpha, 2), - "beta": round(beta, 2), - } - return { - "total_pulls": self._total_pulls, - "total_updates": self._update_count, - "context_count": len(self._posteriors), - "arms": ARMS, - "contexts": stats, - } - - # ── Persistence ────────────────────────────────────────────────────────── - - def _save(self) -> None: - try: - with self._lock: - snapshot = { - "posteriors": { - ctx: {arm: list(ab) for arm, ab in arms.items()} - for ctx, arms in self._posteriors.items() - }, - "update_count": self._update_count, - "total_pulls": self._total_pulls, - } - os.makedirs(os.path.dirname(_SAVE_PATH) or ".", exist_ok=True) - with open(_SAVE_PATH, "w", encoding="utf-8") as f: - json.dump(snapshot, f, indent=2) - except Exception as e: - logger.warning(f"[Bandit] Save failed: {e}") - - def _load(self) -> None: - try: - if not os.path.exists(_SAVE_PATH): - logger.info("[Bandit] No saved state — will bootstrap from DB.") - return - with open(_SAVE_PATH, "r", encoding="utf-8") as f: - data = json.load(f) - self._posteriors = data.get("posteriors", {}) - self._update_count = data.get("update_count", 0) - self._total_pulls = data.get("total_pulls", 0) - logger.info( - f"[Bandit] Loaded from disk — " - f"{len(self._posteriors)} contexts, {self._update_count} updates" - ) - except Exception as e: - logger.warning(f"[Bandit] Load failed (starting fresh): {e}") - - # ── Bootstrap ──────────────────────────────────────────────────────────── - - def _bootstrap_from_db(self) -> None: - """ - Warm-up posteriors by replaying every historical assignment event. - Skipped if the saved JSON already reflects current DB data. - """ - if self._update_count > 0: - logger.info( - f"[Bandit] Already warmed ({self._update_count} updates) — " - "skipping DB bootstrap." - ) - return - try: - import sqlite3 - from app.services.ml.behavior_analyzer import ( - time_band as _tb, - load_band as _lb, - ) - conn = sqlite3.connect(_DB_PATH) - rows = conn.execute( - "SELECT timestamp, avg_load, ml_strategy, quality_score " - "FROM assignment_ml_log " - "WHERE ml_strategy IS NOT NULL AND quality_score IS NOT NULL " - "ORDER BY id ASC" - ).fetchall() - conn.close() - - if not rows: - logger.info("[Bandit] No historical data — starting with uniform priors.") - return - - for ts, avg_load, strategy, quality in rows: - t_band = _tb(str(ts)) if ts else "UNKNOWN" - l_band = _lb(float(avg_load or 0)) - self.update(t_band, l_band, strategy, float(quality or 50)) - - # Reset counter so we don't confuse bootstrap updates with live ones - with self._lock: - self._update_count = len(rows) - - logger.info( - f"[Bandit] Bootstrapped from {len(rows)} historical events — " - f"{len(self._posteriors)} contexts, {len(ARMS)} arms." - ) - self._save() - except Exception as e: - logger.warning(f"[Bandit] Bootstrap failed (non-fatal): {e}") - - # ── Helpers ────────────────────────────────────────────────────────────── - - @staticmethod - def _ctx(time_band: str, load_band: str) -> str: - return f"{time_band}|{load_band}" - - def _ab(self, ctx: str, arm: str) -> List[float]: - """Get or create Beta(1, 1) uniform prior for (ctx, arm). NOT thread-safe alone.""" - if ctx not in self._posteriors: - self._posteriors[ctx] = {} - if arm not in self._posteriors[ctx]: - self._posteriors[ctx][arm] = [1.0, 1.0] - return self._posteriors[ctx][arm] - - -# ── Singleton ──────────────────────────────────────────────────────────────── - -_bandit_instance: Optional[ContextualBandit] = None -_bandit_lock = threading.Lock() - - -def get_bandit() -> ContextualBandit: - """Return the process-level singleton ContextualBandit.""" - global _bandit_instance - if _bandit_instance is None: - with _bandit_lock: - if _bandit_instance is None: - _bandit_instance = ContextualBandit() - return _bandit_instance diff --git a/app/services/rider/__pycache__/get_active_riders.cpython-312.pyc b/app/services/rider/__pycache__/get_active_riders.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a7594321ce96d216389def720e3de3314df5c17c GIT binary patch literal 3916 zcmcguZ%iD=6`#5NbGvs72X_a@fe&v2m|RkGGNEybiyh*{aZ-m=u|Jd(61v_F`0#N@ zXBYFwP(>xGh}0^vA{9qT8>^oVnMzcu)P4kUNRj#l0u`ritES4ATI~l?EZIu>p>K9? z0TYv0mC{-5%$s@d&3p6a&+q-QqQZxu{PSNO!@u$)^eOAOMJN-y?*K86bfn`fN;)l0 zV(5Fao)J&dgV`9DTCV8JT6L5E_Qj!{ttSVAu`H0RbJm3z~tD%wV0<`DrwbbU_zC z5N13kvuBrK$))yN-MhFKkPbA9b@>K|&@Z@o?x%u)X1Hl?7&hi`na0zeMcwb5&x^nF)glNYRauu!|Z8}O~2)Qdr zdvuw7Zhsc~2jhUzWn@;l9uJ3HYQNPhr-k>?FFmeRjE2zT%z7IA?04|L4}S;z{w8z^ z+17d31v{Rnk$?<54#&I?&I(48@&wn1#&Hue!>;{iL{H3!2Rw|poc&f0OpDAzy=t-A zU8mz^AMD?Z_+#;MxgE#j$g%MoZXCyhQ#X7nlnca6UsY3SGjpA&G@}!$X7fXaI!N;) zs(F>DWWppgm&&T$-+x&h%u{bh#0qef>UF+4V!0aI-`lA>ACmKq&RWWlLKYBZC$-kumu zQ8P!Vkr*PT?I&|HahKh&yWfJ!LUF;W0Ih63onq!Px+RyDfRxyRn7fu}=Iz#djZBW` zsXpXH^Xv-?*z&R~pC2=OffhkIHDXwNIK!Ae<}80^JiCkD#rFKV6rud~C_ zPa#^w=(l&|%&?!{amoMr8O66*l%C z=)oIk$9GH!{v~{D{?g4$@AhtopVp_=;*j?BlQ_6McN znFl)@^dGE44-_5UFYYxWrRLwr11pv)-&WpI)+66uN-bSpuldfhHm7V!FP8$C&T{JVv33PrhtNUD23kiHg6 zrA66&|7|Sw9mJ5Y#8SWeA_JB>TK^9$Ra|fNim$}dN>}bkEUkP9OMNOv$SruRf zpi0f;)Ydd5fI51s%3f%q9*v-s9sa3vj|GRP5{`w0Nph*2$uO}zKw0``dCOL*0zJyo z@wh};@@7en1|XvE08ok!d{CCZQI-ied%h_oIt6HHt2tBk72CT4y9c9G+21@Tgqz;MM95(BDG&ak>{3vw3J@2>0G|TP~??Ip-2!=#HXof1)g+H zTnTWD4n{OTw-U#iAg(+ODB|Zo87*LtDSB;Mwrjtr#k|F8tc8_g5J1dZj55Q;W42bzrd7Eoz!l>SJw9%Y2Xx`q z1Oet;^8uD{YXJ`Ecm-`_2gA!UAINx|JW4btM_C7+3Pc4~iA+d_>vGA-kfecuTxx_2 z4A3UV;UF8ba}fJ1EudW(M9z_uzB`iF$Fk%sJqu$j?KJwJ+Tk$9pCaYYsPeBU_BZt8 z272-nxqdFPE!S_z^|zkil$+lWclal8@ODRmFI-+~D*Rxfa--?=E`qV03z%`VLb#A# UdbV(Np>d=6`Fo5*yI}VJ0#ak4Z~y=R literal 0 HcmV?d00001 diff --git a/app/services/rider/__pycache__/rider_history_service.cpython-312.pyc b/app/services/rider/__pycache__/rider_history_service.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e609b616e1bd48ba18200be62cfb4e129801b474 GIT binary patch literal 4421 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 delta 2964 zcmZuzeQ;FO6@T~bzPH(YLB4j`ge03}19^NVL_QQh$b=+-LQu3qN=cU77qXD-lDiv2 z!lI0PkKY_IVuBMRT7&?>*LFgywo2Q<@sEO3ldkPJRBM4cHk2v@?X*4TE=J1uGW&by zp7XoEd(M6LotNKSRbM)rtdzw!7>Gj&*Al;uq1>D#rs zbe;iIe1zZfnpBB)?%HlYLMh=@=jPL;s?AI^6U?MB zjvF;6$W>($k4?a_2NU$5em1w6sZA=+MmtAxW=IVOW|f0KiYD-hIAbE(Ix=&SnJbwy zc^)#T4r0fd`N+(psAQCKuj%3iW&$rX9cBXM%!?+VnI%h+MM$R)1&Se9>{&Vq1@cks z<`Pss85i|R$+EEpudT2o8N zAA#o{cEd7%dD5RL^clhD@T5N{|8Ho<9BciN#%41d(*hB*H5_9B@eNVFR^Ye(HSCvG z@!FjZ_5}OBym!nQ|KZvopdjK%lE3 zeFo_!wuBn}E%hSQ5f*vyN!Kntm!u46-h3NlaA5OlgVqFl@z@r76#AO8pdp-S zW5^lTL3WZy=Lu1JROilKTU4ocd@B`IpkYp?(Wr6<{T-o$j2BZ@C`C%d9v>-+56{OI zEut&%ep-&3t5K~-IiPaefc?7}V{2(T*CHwKtA&~AT>rl1e2wZVg*&!rk80nya6AHS zkLM&gHrZAw9n47Vx(F{l-UMs5P0aWDgWMZwHobn{8VGvLAm11Y1S5&w_yzm7&4Ax+ z%ZAeJw=I}T8=4gE3^vv``2#H`H*A6)l{kcz#T>$1+a74)%fw9d-3Z0(L>Kpv#|hOt z3M=N*1o;;1#aZH01j8YE$fias1_(|OEFk7#w7!*&glVUtX6Iraq?h%59Boy;NmWuk zqpBli%7{B>*j;kgU2=#I1y1ub?viuv70=p7$15pm&(!XzeR1LMlM6;%*~6~sXI;|| z@pG>7-8CcAN{4)>=bkNCu^YpABN^`DjN-Ey#aDA+&n~m(`c((Y@BZQS@Ey3?E>`KZ z?a8djR}aiX>)m{3rLMhOQju86wD)E)9K2U<$G|D3p?ylXk?qtf)2*j>f2l5#z2I5Y z5kDrFO9c*-wFDvN=^O^gG^GUId7_f-wA3g28GGJ(?Ws>~ z<0~mZGI8lDc>cK-b{N>+ewGCX_Zmryi6CE-`(R}68rE&i1x;m>;jVoZ%(5Qa*Q=$C z;`|5W?e{LEcjdzJ9(Mu{11p_|V4PRK$ZME>n;_Pv%htU-p!- zm5|r#)$ld+Z-UC+f~1wiln?qL2=$uk2dedPZ;;Jhihe^A`>29$0w7pU?EB*(c|;x> zn=a3ZA2z?dioIoh^m3U^mz}r-lKZRmOrl+fmHoc#Mlv~p>^~-1O$6)Uh5l)J0huLm zy8mAFGu6s^wUe<$@XSFk;^4s*%wgGITcWWvs6Cvg%d?&bX9Cl6Wmltbo55tq38Rd1 z_qQY<;AwX+z<0-%I}FthU6o|)wi@kY80Y`QmG(|Dlt^ChiuwV zLyxz$5HU6q+^E19@G~DgF%X$3dnk3&>X@UdF%%57cFJyV*hM4c9G#Axb@9=NgBrK^ zj{?6wx|Gd_^kZdgCM-NwmLy9nr(hmzJr>b#=wClpWptAFJf$N0uRO2mXS(I#4@=Wh1;v(WaKp+qMTC(V=iw=Tb_`6lWJdpO255e}+KTXZl zCiOLrDtH?zm$wnGZ`6jvk+Ru?jYH*w%_s7%(D+KJmHab9Wv|1Wp*h+cs8t7TLlx{G zSVIN6m$X+yY$(EBf!dQ3CRLCli}ImFW8>=U{lQ=;;*SJE!Ek-Oa8cY1Po11^yiVpJ t0#c3tBUVT=y)-l7&ejlbZ!zx{gVyDf-7KZu$r3Lsgrl1>VBarF{|EH<>WBaU diff --git a/app/services/routing/__pycache__/gps_smoother.cpython-312.pyc b/app/services/routing/__pycache__/gps_smoother.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..979b06202f80af423316da755112c02773d6f218 GIT binary patch literal 7355 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 literal 0 HcmV?d00001 diff --git a/app/services/routing/__pycache__/realistic_eta_calculator.cpython-312.pyc b/app/services/routing/__pycache__/realistic_eta_calculator.cpython-312.pyc index 29d666e821b864142eca79f5499dcbe46bd8dc88..c5af52c7404098097b8affa122285f4fc92278c4 100644 GIT binary patch delta 368 zcmbQJeoUV4G%qg~0}yyT$IBoS&DUTUn$Av|5vE@CJ5d z6By-gvE}6#YxN7@qyK*I7FSr>X5Yc$&nxn~GA=FXB*IL=u&NPzfKEtQ2eUjL9~R*PR_h z6K#|pQppuPknX`i4>jUYs&Jsng+I_gU(d6&4lu|G2;@X0!6}H`PHzO*DzUlyoIxG9!VLJf48Xcbg&qUAqW4lCiXj1s+r6XPz0Gy0 zyXoB!!;;*M6G%a7pSZ5LGLo0yV~VN^yeHz&hAX+MtDwMx;I`boVm&~)jm~fbeQj4n z+A&r`Ar=kr=$DGj44wyuzV=@T_QpxGRH&9ris9^q6Amoe1e)-QNefHRF2e4Mp;d+s z{=SOK1?<&>=`XwGEIea5^fe74t3Vxi-J(k{Qz7`8Wmlc$6(hf`oU`m$@J@c&K&!(A5g|1G5FYY_F{P zYS|_$X33BVWEAo)hcOQ0415XG4fBE@W_RTYHn|I{V935wEZHU{`#AA%hv-mZ1WAGy zKI8?Zb5~dNBVOgAe$aCAMT;=e^!uFeG&heosJS+|(7&5CzyBv} z^1&NqYA$o#Ee1b=-61fXtj#`4q#DW}+FkAbp-20gaJrc|Ry*B_#_CgBQ@1WP&NZW% z+Dt2c;P?3Lcw_pZ){JLs^KBKxM;pRzt#qfed{>)RM%rt_s4{$iq7B&c z@Z&wc>^#zM3M1rGE-R5aE-97m>%P2XSBW8#Ij48H^%+eC^AvBvlwU`yvsuK+CGIGg|g diff --git a/app/services/routing/__pycache__/route_optimizer.cpython-312.pyc b/app/services/routing/__pycache__/route_optimizer.cpython-312.pyc index e0a1a4a66570e0e9dc88c5a7460e6bd251639c4e..5353906b8d169f67a116958e8b491b8fd539d534 100644 GIT binary patch delta 14961 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 E0a(s!$p8QV delta 28574 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 diff --git a/app/services/routing/__pycache__/zone_service.cpython-312.pyc b/app/services/routing/__pycache__/zone_service.cpython-312.pyc index 6d6366753bc81a5620c215049e37c70c8f0c3dad..98ea58b872523ff1c0460b9199628d709bf71018 100644 GIT binary patch delta 1224 zcmZvb?`srC7{_OJcJ^oXk~>XJQc6;5JXd;RYGVCSS{q}FF*z{`t;?EzPs-A;;*k~`_hG1*V3+M2s{M(^s{2 z%v!)ceF{$AC$T%J^Ab7hr}0jp2YSGcC{=2`JN> zC@>i1CNtDya;s!8tEmSz>tqghxXUfxQPO%<33!G#s z4eB+1o{MiQMZS2|8Ia%LwNpOM-$p);^^jLfA$;_}f1iyVD@;`w7X?wDZ}6}nz{y>z z%<{tY+*~1S)lhMjuGGo}IjoKDj7fD`HK1Cr#fIRGc~L78`I9zzkwjXAX4<_|7i_IX z)%hjWfh8J|h|Z2F&j8Csvr4^WMtVDHfz^)MWwhi(hR8&Yyhpm_S(2irNVmK+{eNy4 zMb?()hSJz=&HL{;AzrYUJ!5XY$d;k1{7oKsu7KZ!NC7(K1AQv(Dsy0a30EGXFDVQ+ zFLTBuHslrKd%S;LH}_&~Ra-w`~m0Gc`3MTKDmlx7md_qR)8`zb7nPJT2+00||sApvT;Eo^3 zzMS@3_ep2+xVDkACXdjK<60h3pXAG`mRkA=@sZm6lzjHifo1p~h(16E;48pIz#xDC zbN~f}02=@k6EI8x(@y|KxTerV|7e6-n;j_AyE6WJx@TqlxsIu~X?#HU+#K9QYP-dg z?N?0X-CZkgQVt6EwmjN1FcsUXO%wHUnG4ZS;n=9QoaDAQ-ypU=xm;)SRsN}nloW;> ZeyFfXFvfqN)DyJ(A=)kP_4Mm-%`1Hw1MC0* delta 1167 zcmZvbNoW*76o#vMU#4eelDH)<(bzF21a}RhiQ6GF;tqoc!*tC!ai(Xeo)ID)<3U_c zv2KFkQO!lj!H5b9UPMG8w?^?G9=s`n;>~(J6OCX&fA#8ruU~b&SN&jce$aYtSq9?c z`lZhCxoK+&K0V#jHC2&wGDZR-XgmVme+N-#a8t=?0y>Bs@iKCx8KI@=6WEr&z#?l) zfizn3>VdScl&=TkUUwqU4%h0XsykNBH{+YMT8r;jxH0``#Z=r;i0Y{P(TGO$C0urN zq8BvBxGd)kC*hdHBoU$$s~~_kjEMY4vv`gufAq!C1;dH)Jkbf+P6lSK>XKuGo-gIe zg^R{YKG;EgAKz!+aH_3)tX3QFnqF^!RvYz_TO43pgyGI?AeP7Yxyn^HP-`VOUv)^J zP*QJDH!rc9LPnK*!AIi)dm*&%k{xkU@g-k5w-2F76)Y2-<8_^y#wyA+ykYi-vJOjrWJa9r)HaH^e|FSN3HTv9V|~Y) z*zxFo+{fA?h#P2^;hRwsHhogO|`9s}P!IUd-+~44&;AU*Im$ZGbFb6p&$0 zV!cU-u~oVmlrUgB`x!eh9A-WN;&H%9h|(8Dg)T__*H7Uc-kFj^s7HI*RJ^CX6C}A% zu0jWkgu|;-x}H6Y_oTx**^nYU!3MDU*su83^rGT{7=GIkb|}$8rs zB>7%`Se)@elsLwu}tCDv&C$YPkcyw=oUWNG4{T# z|HN``(iMOxAO}zYrvd8$B0vJjfCj(>Kj9_nPCoi-8NXdW96xE^FZf?8m$(Kgwpfe7bxZCBHZWSy#sBi kQgs?-vYTGyq0nZpkK?O|G5&<2A5q^t>SI?sy27jW8$W#fN&o-= diff --git a/app/services/routing/delivery_history_service.py b/app/services/routing/delivery_history_service.py index cc67927..5a7810e 100644 --- a/app/services/routing/delivery_history_service.py +++ b/app/services/routing/delivery_history_service.py @@ -444,6 +444,37 @@ class DeliveryHistoryService: cols = ["deliveryid", "userid", "pickupcustomer", "pickuptime", "deliverytime", "dlat", "dlon", "plat", "plon"] return [dict(zip(cols, r)) for r in rows] + def get_pattern_records(self, days: int) -> List[Dict[str, Any]]: + """ + Shape the local nearledb mirror into the record format the delivery- + history pattern store (Phase-0 kitchen+zone rider lookup) expects: + kitchen/pickuplat/pickuplon/deliverylat/deliverylong/userid/ridername. + + `ridername` is always "" — delivery_raw doesn't store it (it's + display-only in the /ml/history debug endpoint; every actual matching + decision is keyed on userid, so this doesn't affect assignment). + """ + records: List[Dict[str, Any]] = [] + for r in self._load_raw_rows(days): + try: + plat = float(r.get("plat") or 0) + plon = float(r.get("plon") or 0) + dlat = float(r.get("dlat") or 0) + dlon = float(r.get("dlon") or 0) + uid = int(float(r.get("userid") or 0)) + if not plat or not dlat or uid == 0: + continue + records.append({ + "kitchen": (r.get("pickupcustomer") or "").strip().lower(), + "pickuplat": plat, "pickuplon": plon, + "deliverylat": dlat, "deliverylong": dlon, + "userid": uid, + "ridername": "", + }) + except (TypeError, ValueError): + continue + return records + def sample_batches(self, days: int = 14, min_drops: int = 4, max_drops: int = 15, limit: int = 10) -> List[Dict[str, Any]]: """ @@ -623,12 +654,24 @@ class DeliveryHistoryService: full: bool = False) -> Dict[str, Any]: """ Full pipeline: incremental DB sync -> prune local store -> rebuild - aggregates locally. This is what the scheduler and the admin endpoint - call. The DB is touched only by the sync step (new rows only). + aggregates locally -> (if pattern_source=db) rebuild the Phase-0 + pattern store from the same synced rows. This is what the scheduler + and the admin endpoint call. The DB is touched only by the sync step + (new rows only). """ + from app.config.dynamic_config import get_config + cfg = get_config() + pattern_on = str(cfg.get("pattern_source", "csv")) == "db" + pattern_days = int(cfg.get("pattern_history_days", 30)) + # Local retention must cover whichever consumer needs more history + # (the ETA window vs. the pattern-store window) without changing the + # ETA computation's own `days` window below — that backtest result is + # already validated at 14 days and this must not perturb it. + retain_days = max(days, pattern_days) if pattern_on else days + with _WRITE_LOCK: try: - sync = self.sync_from_db(days=days, tenant_id=tenant_id, full=full) + sync = self.sync_from_db(days=retain_days, tenant_id=tenant_id, full=full) except Exception as e: logger.error(f"[DeliveryHistory] sync failed: {e}", exc_info=True) # Still try to serve whatever is already local. @@ -636,9 +679,23 @@ class DeliveryHistoryService: self._last_summary = {"status": "sync_failed", "error": str(e), "rebuild": rebuilt} return self._last_summary - self._prune_raw(days) + self._prune_raw(retain_days) rebuilt = self.rebuild_aggregates(days) - self._last_summary = {"status": "ok", "sync": sync, "rebuild": rebuilt} + + pattern_rebuild = None + if pattern_on: + try: + from app.services.vector.delivery_history_store import get_delivery_history_store + records = self.get_pattern_records(pattern_days) + n = get_delivery_history_store().rebuild_from_records(records) + pattern_rebuild = {"records": n, "days": pattern_days} + except Exception as e: + logger.warning(f"[DeliveryHistory] pattern-store rebuild skipped: {e}") + + self._last_summary = { + "status": "ok", "sync": sync, "rebuild": rebuilt, + "pattern_rebuild": pattern_rebuild, + } return self._last_summary # -- cache + lookup ----------------------------------------------------- diff --git a/app/services/routing/gps_smoother.py b/app/services/routing/gps_smoother.py new file mode 100644 index 0000000..e71eae4 --- /dev/null +++ b/app/services/routing/gps_smoother.py @@ -0,0 +1,150 @@ +""" +GPS location smoothing — rider-api + +Smooths noisy rider GPS pings (typical error +-5-15m, worse on poor signal, +occasional bad-fix "jumps") using a per-rider exponential moving average +(EMA): each new reading is blended with the running estimate so a single bad +ping can't yank the rider's position, while the estimate still tracks real +movement. + +This used to be implemented as a full Kalman filter (per-coordinate process/ +measurement covariance, gain computed every update). For a "constant +position" state model with no velocity term — which is what this is, since +we're smoothing noisy pings, not tracking motion — the Kalman update reduces +mathematically to an EMA once the gain reaches steady state, which happens +within the first couple of updates. The EMA below is the same behavior with +one constant instead of two, and no covariance bookkeeping to explain to the +next person reading this file. + +Only two things are actually used elsewhere in the app: + smooth_rider_locations(riders) — per-rider EMA, stateful across calls + smooth_order_coordinates(orders) — validates/normalises delivery coords + (NOT smoothed — see its docstring) +""" + +import logging +import time +from typing import Dict, Optional, Tuple + +logger = logging.getLogger(__name__) + +# Smoothing factor: how much weight a new GPS reading gets against the +# running estimate. Lower = smoother/slower to react, higher = trusts each +# new ping more. 0.1 matches the steady-state behavior of the Kalman filter +# this replaced (process_noise=1e-4, measurement_noise=0.01). +_ALPHA = 0.1 +_STALE_SECONDS = 1800.0 # reset a rider's running estimate after 30 min silence + + +def _is_valid_coord(lat: float, lon: float) -> bool: + try: + lat, lon = float(lat), float(lon) + return ( + -90.0 <= lat <= 90.0 + and -180.0 <= lon <= 180.0 + and not (lat == 0.0 and lon == 0.0) + ) + except (TypeError, ValueError): + return False + + +class _RiderEstimate: + """Running EMA estimate of one rider's position.""" + + def __init__(self): + self.lat: Optional[float] = None + self.lon: Optional[float] = None + self.last_updated: float = time.time() + + def update(self, lat: float, lon: float) -> Tuple[float, float]: + if not _is_valid_coord(lat, lon): + return (self.lat, self.lon) if self.lat is not None else (lat, lon) + + if time.time() - self.last_updated > _STALE_SECONDS: + self.lat = self.lon = None # stale — start fresh + + if self.lat is None: + self.lat, self.lon = lat, lon + else: + self.lat += _ALPHA * (lat - self.lat) + self.lon += _ALPHA * (lon - self.lon) + + self.last_updated = time.time() + return self.lat, self.lon + + +_rider_estimates: Dict[str, _RiderEstimate] = {} + + +def smooth_rider_locations(riders: list) -> list: + """ + Apply EMA smoothing to a list of rider dicts in-place, keyed by rider id + (history preserved across calls via a process-level registry). + + Reads/writes: latitude, longitude (and currentlat/currentlong if present). + Adds: _location_smoothed = True on each processed rider. + """ + for rider in riders: + try: + rider_id = str( + rider.get("userid") or rider.get("riderid") or + rider.get("id") or "unknown" + ) + raw_lat = float(rider.get("latitude") or rider.get("currentlat") or 0) + raw_lon = float(rider.get("longitude") or rider.get("currentlong") or 0) + if raw_lat == 0.0 and raw_lon == 0.0: + continue + + estimate = _rider_estimates.setdefault(rider_id, _RiderEstimate()) + smooth_lat, smooth_lon = estimate.update(raw_lat, raw_lon) + + # Cast back to string for Go compatibility + s_lat, s_lon = str(round(smooth_lat, 8)), str(round(smooth_lon, 8)) + rider["latitude"] = s_lat + rider["longitude"] = s_lon + if "currentlat" in rider: + rider["currentlat"] = s_lat + if "currentlong" in rider: + rider["currentlong"] = s_lon + rider["_location_smoothed"] = True + except Exception as e: + logger.debug(f"Rider location smoothing skipped: {e}") + return riders + + +def smooth_order_coordinates(orders: list) -> list: + """ + Validate and lightly normalise delivery coordinates in a list of order dicts. + + DESIGN NOTE — why these are NOT smoothed: + Smoothing blends successive measurements from the same source over time. + Delivery coordinates are a single static point (one measurement) — there + is nothing to blend. Per-customer GPS accuracy is handled upstream by the + FAISS coordinate store (verified historical rider-confirmed delivery + points). This function only normalises the coordinate fields to floats + so downstream code never sees raw strings or None values. + + Modifies orders in-place. Returns the same list. + """ + for order in orders: + try: + dlat_raw = order.get("deliverylat") or order.get("droplat") + dlon_raw = order.get("deliverylong") or order.get("droplon") + if dlat_raw is None or dlon_raw is None: + continue + dlat = float(dlat_raw) + dlon = float(dlon_raw) + if not _is_valid_coord(dlat, dlon): + continue + # Normalise to string (Go service expects string coordinates) + s_lat = str(round(dlat, 8)) + s_lon = str(round(dlon, 8)) + order["deliverylat"] = s_lat + order["deliverylong"] = s_lon + if "droplat" in order: + order["droplat"] = s_lat + if "droplon" in order: + order["droplon"] = s_lon + except Exception as e: + logger.debug(f"Coordinate normalisation skipped: {e}") + return orders diff --git a/app/services/routing/kalman_filter.py b/app/services/routing/kalman_filter.py deleted file mode 100644 index 608ac6f..0000000 --- a/app/services/routing/kalman_filter.py +++ /dev/null @@ -1,327 +0,0 @@ -""" -GPS Kalman Filter \u2014 rider-api - -A 1D Kalman filter applied independently to latitude and longitude -to smooth noisy GPS coordinates from riders and delivery points. - -Why Kalman for GPS? -- GPS readings contain measurement noise (\u00b15\u201315m typical, \u00b150m poor signal) -- Rider location pings can "jump" due to bad signal or device error -- Kalman filter gives an optimal estimate by balancing: - (1) Previous predicted position (process model) - (2) New GPS measurement (observation model) - -Design: -- Separate filter instance per rider (stateful \u2014 preserves history) -- `CoordinateKalmanFilter` \u2014 single lat/lon smoother -- `GPSKalmanFilter` \u2014 wraps two CoordinateKalmanFilters (lat + lon) -- `RiderKalmanRegistry` \u2014 manages per-rider filter instances -- `smooth_coordinates()` \u2014 stateless single-shot smoother for delivery coords - -Usage: - # Stateless (one-shot, no history \u2014 for delivery coords): - smooth_lat, smooth_lon = smooth_coordinates(raw_lat, raw_lon) - - # Stateful (per-rider, preserves motion history): - registry = RiderKalmanRegistry() - lat, lon = registry.update(rider_id=1116, lat=11.0067, lon=76.9558) -""" - -import logging -import time -from typing import Dict, Optional, Tuple - -logger = logging.getLogger(__name__) - - -# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -# CORE 1D KALMAN FILTER -# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 - -class CoordinateKalmanFilter: - """ - 1-dimensional Kalman filter for a single GPS coordinate (lat or lon). - - State model: position only (constant position with random walk). - - Equations: - Prediction: x\u0302\u2096\u207b = x\u0302\u2096\u208b\u2081 (no movement assumed between pings) - P\u0302\u2096\u207b = P\u2096\u208b\u2081 + Q (uncertainty grows over time) - - Update: K\u2096 = P\u0302\u2096\u207b / (P\u0302\u2096\u207b + R) (Kalman gain) - x\u0302\u2096 = x\u0302\u2096\u207b + K\u2096\u00b7(z\u2096 - x\u0302\u2096\u207b) (weighted fusion) - P\u2096 = (1 - K\u2096)\u00b7P\u0302\u2096\u207b (update uncertainty) - - Parameters: - process_noise (Q): How much position can change between measurements. - Higher = filter trusts new measurements more (less smoothing). - measurement_noise (R): GPS measurement uncertainty. - Higher = filter trusts history more (more smoothing). - """ - - def __init__( - self, - process_noise: float = 1e-4, - measurement_noise: float = 0.01, - initial_uncertainty: float = 1.0, - ): - self.Q = process_noise - self.R = measurement_noise - self._x: Optional[float] = None - self._P: float = initial_uncertainty - - @property - def initialized(self) -> bool: - return self._x is not None - - def update(self, measurement: float) -> float: - """Process one new measurement and return the filtered estimate.""" - if not self.initialized: - self._x = measurement - return self._x - - # Predict - x_prior = self._x - P_prior = self._P + self.Q - - # Update - K = P_prior / (P_prior + self.R) - self._x = x_prior + K * (measurement - x_prior) - self._P = (1.0 - K) * P_prior - - return self._x - - def reset(self): - self._x = None - self._P = 1.0 - - -# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -# 2D GPS KALMAN FILTER (lat + lon) -# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 - -class GPSKalmanFilter: - """ - Two-dimensional GPS smoother using independent 1D Kalman filters - for latitude and longitude. - """ - - def __init__( - self, - process_noise: float = 1e-4, - measurement_noise: float = 0.01, - ): - self.lat_filter = CoordinateKalmanFilter(process_noise, measurement_noise) - self.lon_filter = CoordinateKalmanFilter(process_noise, measurement_noise) - self.last_updated: float = time.time() - self.update_count: int = 0 - - def update(self, lat: float, lon: float) -> Tuple[float, float]: - """Feed a new GPS reading and get the smoothed (lat, lon).""" - if not self._is_valid_coord(lat, lon): - if self.lat_filter.initialized: - return self.lat_filter._x, self.lon_filter._x - return lat, lon - - smooth_lat = self.lat_filter.update(lat) - smooth_lon = self.lon_filter.update(lon) - self.last_updated = time.time() - self.update_count += 1 - - return smooth_lat, smooth_lon - - def get_estimate(self) -> Optional[Tuple[float, float]]: - if self.lat_filter.initialized: - return self.lat_filter._x, self.lon_filter._x - return None - - def reset(self): - self.lat_filter.reset() - self.lon_filter.reset() - self.update_count = 0 - - @staticmethod - def _is_valid_coord(lat: float, lon: float) -> bool: - try: - lat, lon = float(lat), float(lon) - return ( - -90.0 <= lat <= 90.0 - and -180.0 <= lon <= 180.0 - and not (lat == 0.0 and lon == 0.0) - ) - except (TypeError, ValueError): - return False - - -# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -# PER-RIDER FILTER REGISTRY -# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 - -class RiderKalmanRegistry: - """ - Maintains per-rider Kalman filter instances across calls. - Stale filters (> 30 min silence) are automatically reset. - """ - - def __init__( - self, - process_noise: float = 1e-4, - measurement_noise: float = 0.01, - stale_seconds: float = 1800.0, - ): - self._filters: Dict[str, GPSKalmanFilter] = {} - self._process_noise = process_noise - self._measurement_noise = measurement_noise - self._stale_seconds = stale_seconds - - def _get_or_create(self, rider_id) -> GPSKalmanFilter: - key = str(rider_id) - now = time.time() - if key in self._filters: - f = self._filters[key] - if now - f.last_updated > self._stale_seconds: - f.reset() - return f - self._filters[key] = GPSKalmanFilter( - process_noise=self._process_noise, - measurement_noise=self._measurement_noise, - ) - return self._filters[key] - - def update(self, rider_id, lat: float, lon: float) -> Tuple[float, float]: - return self._get_or_create(rider_id).update(lat, lon) - - def get_estimate(self, rider_id) -> Optional[Tuple[float, float]]: - key = str(rider_id) - if key in self._filters: - return self._filters[key].get_estimate() - return None - - def reset_rider(self, rider_id): - key = str(rider_id) - if key in self._filters: - self._filters[key].reset() - - def clear_all(self): - self._filters.clear() - - -# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -# GLOBAL REGISTRY (process-level singleton) -# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 - -_global_registry = RiderKalmanRegistry() - - -def get_registry() -> RiderKalmanRegistry: - """Get the process-level rider Kalman filter registry.""" - return _global_registry - - -# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -# STATELESS COORDINATE SMOOTHER -# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 - -def smooth_coordinates( - lat: float, - lon: float, - *, - prior_lat: Optional[float] = None, - prior_lon: Optional[float] = None, - process_noise: float = 1e-4, - measurement_noise: float = 0.01, -) -> Tuple[float, float]: - """ - Stateless single-shot GPS smoother. - If a prior is provided, blends the new reading towards it. - """ - f = GPSKalmanFilter(process_noise=process_noise, measurement_noise=measurement_noise) - if prior_lat is not None and prior_lon is not None: - try: - _flat = float(prior_lat) - _flon = float(prior_lon) - if GPSKalmanFilter._is_valid_coord(_flat, _flon): - f.update(_flat, _flon) - except (TypeError, ValueError): - pass - return f.update(lat, lon) - - -# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -# BATCH SMOOTHERS -# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 - -def smooth_rider_locations(riders: list) -> list: - """ - Apply Kalman smoothing to a list of rider dicts in-place using - the global per-rider registry (history preserved across calls). - - Reads/writes: latitude, longitude (and currentlat/currentlong if present). - Adds: _kalman_smoothed = True on each processed rider. - """ - registry = get_registry() - for rider in riders: - try: - rider_id = ( - rider.get("userid") or rider.get("riderid") or - rider.get("id") or "unknown" - ) - raw_lat = float(rider.get("latitude") or rider.get("currentlat") or 0) - raw_lon = float(rider.get("longitude") or rider.get("currentlong") or 0) - if raw_lat == 0.0 and raw_lon == 0.0: - continue - smooth_lat, smooth_lon = registry.update(rider_id, raw_lat, raw_lon) - # Cast back to string for Go compatibility - s_lat, s_lon = str(round(smooth_lat, 8)), str(round(smooth_lon, 8)) - rider["latitude"] = s_lat - rider["longitude"] = s_lon - if "currentlat" in rider: - rider["currentlat"] = s_lat - if "currentlong" in rider: - rider["currentlong"] = s_lon - rider["_kalman_smoothed"] = True - except Exception as e: - logger.debug(f"Kalman rider smoothing skipped: {e}") - return riders - - -def smooth_order_coordinates(orders: list) -> list: - """ - Validate and lightly normalise delivery coordinates in a list of order dicts. - - DESIGN NOTE — why we do NOT use Kalman filtering here: - ───────────────────────────────────────────────────── - Kalman filtering is a *temporal* smoother: it blends successive measurements - from the same sensor over time. Delivery coordinates are a single static - point (one measurement). Feeding the kitchen location as a "prior" would - pull the customer's address toward the kitchen — exactly wrong. - - Per-customer GPS accuracy is handled upstream by the FAISS coordinate store - (verified historical rider-confirmed delivery points). This function only - normalises the coordinate fields to floats so downstream code never sees - raw strings or None values. - - Modifies orders in-place. Returns the same list. - """ - for order in orders: - try: - dlat_raw = order.get("deliverylat") or order.get("droplat") - dlon_raw = order.get("deliverylong") or order.get("droplon") - if dlat_raw is None or dlon_raw is None: - continue - dlat = float(dlat_raw) - dlon = float(dlon_raw) - if not GPSKalmanFilter._is_valid_coord(dlat, dlon): - continue - # Normalise to string (Go service expects string coordinates) - s_lat = str(round(dlat, 8)) - s_lon = str(round(dlon, 8)) - order["deliverylat"] = s_lat - order["deliverylong"] = s_lon - if "droplat" in order: - order["droplat"] = s_lat - if "droplon" in order: - order["droplon"] = s_lon - except Exception as e: - logger.debug(f"Coordinate normalisation skipped: {e}") - return orders diff --git a/app/services/routing/route_optimizer.py b/app/services/routing/route_optimizer.py index fdc6580..1223b54 100644 --- a/app/services/routing/route_optimizer.py +++ b/app/services/routing/route_optimizer.py @@ -10,7 +10,6 @@ FEATURES: - Automatic outlier detection and coordinate correction - Hybrid distance calculation (Google Maps + Haversine fallback) - Robust error handling for invalid inputs -- Composite cost function (idea.txt: distance + profit - margin) """ import math @@ -21,7 +20,7 @@ import asyncio from typing import Dict, Any, List as _List, Optional, Tuple, Union from datetime import datetime, timedelta import httpx -from app.services.routing.kalman_filter import smooth_order_coordinates +from app.services.routing.gps_smoother import smooth_order_coordinates import numpy as np from app.core.arrow_utils import calculate_haversine_matrix_vectorized from app.config.dynamic_config import get_config @@ -38,191 +37,6 @@ except ImportError: logger = logging.getLogger(__name__) -class CompositeCostCalculator: - """ - Composite Cost Function for Profit-Aware Route Optimization. - - Based on idea.txt data encoding techniques: - - Total Cost = Distance Cost + Rider Cost - Merchant Profit - - Edge Cost = (distance * fuel_rate) + rider_cost - merchant_margin - - route_score = profit - composite_cost (what we want to MAXIMIZE) - - This transforms the problem from: - "Find shortest route" -> "Find most profitable route" - """ - - def __init__(self): - # Cost parameters (can be ML-tuned via DynamicConfig) - self.fuel_rate_per_km = 2.5 - self.base_rider_cost = 0.0 - self.opportunity_cost_per_km = 0.5 # Cost of rider's time per km - self.profit_weight = 0.3 # How much profit influences routing (0-1) - self.traffic_multiplier_peak = 1.5 # Peak hour traffic penalty - self.traffic_multiplier_normal = 1.2 # Normal traffic multiplier - - # Defaults for orders without profit data - self.default_order_amount = 80.0 - self.default_merchant_margin = 5.0 - - def calculate_composite_cost( - self, - distance_km: float, - order_amount: float = None, - merchant_margin: float = None, - traffic_factor: float = 1.0, - time_of_day: str = "NORMAL", - ) -> Dict[str, float]: - """ - Calculate composite cost for a route edge. - - Args: - distance_km: Distance for this leg - order_amount: Revenue from this order (if known) - merchant_margin: Merchant's margin for this order (if known) - traffic_factor: Traffic multiplier (1.0 = normal) - time_of_day: Traffic time category ("PEAK", "NORMAL", "OFF_PEAK") - - Returns: - Dict with: - - distance_cost: Raw distance cost - - rider_cost: Total rider cost for this leg - - gross_profit: Revenue - rider cost - - net_cost: Cost after profit adjustment (MINIMIZE THIS) - - route_score: Profitability score (MAXIMIZE THIS) - """ - # Distance cost = fuel + opportunity cost - distance_cost = distance_km * self.fuel_rate_per_km - - # Rider cost = base + distance cost - rider_cost = self.base_rider_cost + distance_cost - - # Apply traffic penalty - if time_of_day == "PEAK": - rider_cost *= self.traffic_multiplier_peak - elif time_of_day == "NORMAL": - rider_cost *= self.traffic_multiplier_normal - - # Apply custom traffic factor - rider_cost *= traffic_factor - - # Profit calculation (target encoding: use order data if available) - if order_amount is None: - order_amount = self.default_order_amount - if merchant_margin is None: - merchant_margin = self.default_merchant_margin - - gross_profit = order_amount - rider_cost - - # Net cost = rider cost - profit contribution - # This means high-profit orders have LOWER cost (more desirable) - profit_adjustment = gross_profit * self.profit_weight - net_cost = rider_cost - profit_adjustment - - # Route score = profit - opportunity cost (for route planning) - route_score = gross_profit - (distance_km * self.opportunity_cost_per_km) - - # Ensure net_cost is never negative (minimum cost for any delivery) - net_cost = max(net_cost, 5.0) # Minimum 5 km equivalent cost - - return { - "distance_cost": round(distance_cost, 2), - "rider_cost": round(rider_cost, 2), - "gross_profit": round(gross_profit, 2), - "net_cost": round(net_cost, 2), - "route_score": round(route_score, 2), - } - - def calculate_cost_matrix( - self, - dist_matrix: np.ndarray, - orders: _List[Dict[str, Any]] = None, - traffic_condition: str = "NORMAL", - ) -> Tuple[np.ndarray, Dict[str, Any]]: - """ - Calculate composite cost matrix for all node pairs. - - Args: - dist_matrix: Distance matrix (N x N) - orders: List of orders (for profit data) - traffic_condition: Traffic condition ("PEAK", "NORMAL", "OFF_PEAK") - - Returns: - Tuple of (cost_matrix, summary_stats) - """ - n = len(dist_matrix) - cost_matrix = np.zeros((n, n)) - - # Extract order data for profit encoding - order_amounts = [] - merchant_margins = [] - - if orders: - for o in orders: - try: - amount = float( - o.get("orderamount") - or o.get("deliveryamount") - or self.default_order_amount - ) - margin = float( - o.get("merchant_margin") or self.default_merchant_margin - ) - except: - amount = self.default_order_amount - margin = self.default_merchant_margin - order_amounts.append(amount) - merchant_margins.append(margin) - else: - order_amounts = [self.default_order_amount] * n - merchant_margins = [self.default_merchant_margin] * n - - # Calculate costs for each pair - total_cost = 0.0 - total_profit = 0.0 - high_cost_count = 0 - - for i in range(n): - for j in range(n): - if i == j: - cost_matrix[i][j] = 0 - continue - - dist = dist_matrix[i][j] - - # Use order j's profit data (destination) - order_amount = ( - order_amounts[j - 1] if j > 0 else self.default_order_amount - ) - merchant_margin = ( - merchant_margins[j - 1] if j > 0 else self.default_merchant_margin - ) - - cost_data = self.calculate_composite_cost( - distance_km=dist, - order_amount=order_amount, - merchant_margin=merchant_margin, - time_of_day=traffic_condition, - ) - - cost_matrix[i][j] = cost_data["net_cost"] - total_cost += cost_data["net_cost"] - total_profit += cost_data["gross_profit"] - - if cost_data["net_cost"] > 50: - high_cost_count += 1 - - summary = { - "total_cost": round(total_cost, 2), - "total_profit": round(total_profit, 2), - "avg_cost": round(total_cost / (n * n) if n > 0 else 0, 2), - "avg_profit": round(total_profit / (n * n) if n > 0 else 0, 2), - "high_cost_legs": high_cost_count, - "traffic_condition": traffic_condition, - } - - return cost_matrix, summary - - class RouteOptimizer: """Route optimization using Google OR-Tools (Async).""" @@ -253,8 +67,6 @@ class RouteOptimizer: # Solver time limit (ML-tuned) self.search_time_limit_seconds = int(_cfg.get("search_time_limit_seconds")) - self.cost_calculator = CompositeCostCalculator() - def haversine_distance( self, lat1: float, lon1: float, lat2: float, lon2: float ) -> float: @@ -274,153 +86,6 @@ class RouteOptimizer: except Exception: return 0.0 - async def _get_google_maps_distances_batch( - self, origin_lat: float, origin_lon: float, destinations: _List[tuple] - ) -> Dict[tuple, float]: - """Get road distances for multiple destinations from Google Maps API. (Async, Parallel)""" - if not self.use_google_maps or not destinations: - return {} - - results = {} - batch_size = 25 - chunks = [ - destinations[i : i + batch_size] - for i in range(0, len(destinations), batch_size) - ] - - async def process_batch(batch): - batch_result = {} - try: - dest_str = "|".join([f"{lat},{lon}" for lat, lon in batch]) - url = "https://maps.googleapis.com/maps/api/distancematrix/json" - params = { - "origins": f"{origin_lat},{origin_lon}", - "destinations": dest_str, - "key": self.google_maps_api_key, - "units": "metric", - } - async with httpx.AsyncClient(timeout=10.0) as client: - response = await client.get(url, params=params) - response.raise_for_status() - data = response.json() - - if data.get("status") == "OK": - rows = data.get("rows", []) - if rows: - elements = rows[0].get("elements", []) - for idx, element in enumerate(elements): - if idx < len(batch): - dest_coord = batch[idx] - if element.get("status") == "OK": - dist = element.get("distance", {}).get("value") - dur = element.get("duration", {}).get("value") - if dist is not None: - batch_result[dest_coord] = { - "distance": dist / 1000.0, - "duration": dur / 60.0 if dur else None, - } - except Exception as e: - logger.warning(f"Google Maps batch call failed: {e}") - return batch_result - - batch_results_list = await asyncio.gather( - *[process_batch(chunk) for chunk in chunks] - ) - for res in batch_results_list: - results.update(res) - return results - - # ------------------------------------------------------------------ - # GOOGLE DIRECTIONS - WAYPOINT OPTIMISATION - # ------------------------------------------------------------------ - - async def _optimize_waypoints_google( - self, - origin_lat: float, - origin_lon: float, - waypoints: _List[Tuple[float, float]], - ) -> Tuple[Optional[_List[int]], Optional[_List[float]]]: - """ - Ask Google Directions API to find the optimal visiting order for a set - of delivery points starting from a kitchen/pickup location. - - Uses `optimize:true` in the waypoints parameter - Google solves the TSP - internally using actual road geometry (turn restrictions, one-way - streets, real distances) rather than Haversine approximation. - - Returns - ------- - (waypoint_order, leg_km) - waypoint_order 0-based indices into `waypoints` in optimal order. - e.g. [2, 0, 1] means visit wp[2] -> wp[0] -> wp[1]. - leg_km Actual road distance (km) for each leg in order: - leg_km[0] = kitchen -> wp[order[0]], - leg_km[1] = wp[order[0]] -> wp[order[1]], etc. - Both are None on any failure - caller falls back to OR-Tools. - - Notes - ----- - - Supports up to 25 intermediate waypoints (Google's standard limit). - - destination = origin (closed-loop TSP); the return leg is discarded. - - One API call per rider per assignment - cheap at delivery scale. - """ - if not self.use_google_maps or not waypoints or len(waypoints) < 2: - return None, None - if len(waypoints) > 25: - return None, None # fall back to OR-Tools for unusually large routes - - try: - wp_str = "optimize:true|" + "|".join( - f"{lat},{lon}" for lat, lon in waypoints - ) - params = { - "origin": f"{origin_lat},{origin_lon}", - "destination": f"{origin_lat},{origin_lon}", # closed loop - "waypoints": wp_str, - "key": self.google_maps_api_key, - } - async with httpx.AsyncClient(timeout=8.0) as client: - resp = await client.get( - "https://maps.googleapis.com/maps/api/directions/json", - params=params, - ) - resp.raise_for_status() - data = resp.json() - - status_code = data.get("status") - if status_code != "OK": - logger.debug( - f"[GoogleWaypoints] status={status_code} " - f"error='{data.get('error_message', '')}'" - ) - return None, None - - routes = data.get("routes", []) - if not routes: - return None, None - - route = routes[0] - wp_order = route.get("waypoint_order") - legs = route.get("legs", []) - - if wp_order is None or len(wp_order) != len(waypoints): - return None, None - - # Extract leg distances (metres -> km), skip the return-to-origin leg - leg_km: _List[float] = [] - for leg in legs[: len(waypoints)]: # first N legs only - dist_m = leg.get("distance", {}).get("value") - leg_km.append(dist_m / 1000.0 if dist_m is not None else 0.0) - - logger.debug( - f"[GoogleWaypoints] Optimised {len(waypoints)} stops -> order={wp_order}" - ) - return wp_order, leg_km - - except Exception as _e: - logger.debug(f"[GoogleWaypoints] Failed (non-fatal): {_e}") - return None, None - # ------------------------------------------------------------------ # ROAD-AWARE VISITING ORDER (Phase 2 - opt-in, cached) # ------------------------------------------------------------------ @@ -698,8 +363,8 @@ class RouteOptimizer: routing_enums_pb2.LocalSearchMetaheuristic.GUIDED_LOCAL_SEARCH ) # TSP time limit hard-capped at 2 seconds per kitchen. - # The ML hypertuner may push search_time_limit_seconds up to 8-10s - # chasing marginally better routes, but at delivery scale (< 15 stops) + # search_time_limit_seconds can be tuned up to 8-10s via config, but at + # delivery scale (< 15 stops) # OR-Tools finds a near-optimal solution in < 200ms. Waiting 8-10s # per kitchen x 3 kitchens x 4 riders = 96s of unnecessary waiting. # The VRP already has its own 3s cap. This cap applies to per-rider diff --git a/app/services/vector/__pycache__/delivery_history_store.cpython-312.pyc b/app/services/vector/__pycache__/delivery_history_store.cpython-312.pyc index b184cb39e6d60b890c46d8d55017bd5ee573528d..91dba3e7d2a28213c9ce50371e7c658efce043ad 100644 GIT binary patch delta 11793 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 delta 8636 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 diff --git a/app/services/vector/delivery_history_store.py b/app/services/vector/delivery_history_store.py index 0a6519d..359819b 100644 --- a/app/services/vector/delivery_history_store.py +++ b/app/services/vector/delivery_history_store.py @@ -1,12 +1,21 @@ """ Delivery History Store ====================== -Pattern-first vector rider lookup using 30-day delivery history. +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 ------------ -At startup the CSV is parsed once and two structures are built: +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.). @@ -65,9 +74,21 @@ CORRECTIONS_PATH = os.getenv("DELIVERY_CORRECTIONS_CSV", "delivery_corrections.c _CORRECTION_WEIGHT = int(os.getenv("CORRECTION_WEIGHT", "1")) _STORE_DIR = os.getenv("FAISS_HISTORY_DIR", "ml_data/faiss_history") -_VECTORS_PATH = os.path.join(_STORE_DIR, "delivery_history_vectors.npy") -_RECORDS_PATH = os.path.join(_STORE_DIR, "delivery_history_records.pkl") -_META_PATH = os.path.join(_STORE_DIR, "delivery_history.meta") + + +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 @@ -78,6 +99,15 @@ _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 # --------------------------------------------------------------------------- @@ -152,6 +182,7 @@ class DeliveryHistoryStore: store.pattern_count() -> int store.get_pattern_stats() -> list store.reload_from_csv() -> int + store.rebuild_from_records(records) -> int """ def __init__(self): @@ -174,8 +205,21 @@ class DeliveryHistoryStore: 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(): + if self._load_from_disk("csv"): return logger.warning( "[DeliveryHistory] Saved files corrupt — rebuilding from CSV." @@ -184,14 +228,15 @@ class DeliveryHistoryStore: records = self._parse_csv() if not records: return - self._build_and_save(records) + self._build_and_save(records, source="csv") def _saved_files_are_current(self) -> bool: - for path in (_VECTORS_PATH, _RECORDS_PATH, _META_PATH): + 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: + with open(meta_path, "r", encoding="utf-8") as f: meta = json.load(f) if not os.path.isfile(CSV_PATH): return True @@ -199,10 +244,11 @@ class DeliveryHistoryStore: except Exception: return False - def _load_from_disk(self) -> bool: + def _load_from_disk(self, source: str) -> bool: try: - vectors = np.load(_VECTORS_PATH) # (N, 4) float32 - with open(_RECORDS_PATH, "rb") as f: + 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): @@ -342,7 +388,7 @@ class DeliveryHistoryStore: return patterns, zone_index - def _build_and_save(self, records: List[Dict]) -> None: + 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], @@ -373,19 +419,28 @@ class DeliveryHistoryStore: ) try: - np.save(_VECTORS_PATH, vectors) - with open(_RECORDS_PATH, "wb") as f: + 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 = os.path.getmtime(CSV_PATH) if os.path.isfile(CSV_PATH) else 0 - with open(_META_PATH, "w", encoding="utf-8") as f: + # 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}'. " + f"[DeliveryHistory] Saved to '{_STORE_DIR}' (source={source}). " "Next startup loads from disk." ) except Exception as e: @@ -665,7 +720,34 @@ class DeliveryHistoryStore: records = self._parse_csv() # already merges corrections internally if not records: return 0 - self._build_and_save(records) + 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, @@ -720,14 +802,22 @@ class DeliveryHistoryStore: self._patterns = patterns self._zone_index = zone_index - # Save to disk so next restart includes corrections + # Save to disk (under whichever source is currently active) so next + # restart includes corrections try: - np.save(_VECTORS_PATH, vectors) - with open(_RECORDS_PATH, "wb") as f: + 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 os.path.isfile(CSV_PATH) else 0 - with open(_META_PATH, "w", encoding="utf-8") as f: - json.dump({"csv_mtime": csv_mtime, "record_count": len(merged), + 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 — " diff --git a/app/templates/ml_dashboard.html b/app/templates/ml_dashboard.html index b30f3c9..31df1cd 100644 --- a/app/templates/ml_dashboard.html +++ b/app/templates/ml_dashboard.html @@ -452,43 +452,6 @@ transform: scaleY(1.1) } - /* ── STRATEGY SWITCHER ── */ - .strategy-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: .5rem - } - - .strategy-card { - padding: .75rem; - border: 1px solid var(--border); - border-radius: 2px; - cursor: pointer; - transition: all .15s; - } - - .strategy-card:hover { - border-color: var(--border2) - } - - .strategy-card.active { - border-color: var(--accent); - background: rgba(0, 212, 255, .06) - } - - .strategy-name { - font-size: .75rem; - font-weight: 700; - letter-spacing: .05em; - margin-bottom: .2rem - } - - .strategy-desc { - font-family: var(--mono); - font-size: .62rem; - color: var(--muted) - } - /* ── BADGE ── */ .badge { display: inline-block; @@ -718,11 +681,6 @@

-
-
SLA Breaches
-
-
Recent window
-
Avg Latency
@@ -758,17 +716,8 @@
-
- Strategy Mode - -
-
- -
-
-
Multi-Objective Pareto
-
-
+
Multi-Objective Pareto
+
@@ -794,28 +743,6 @@
Quality Distribution
-
-
Strategy Comparison
-
- - - - - - - - - - - - - - - -
StrategyCallsAvg QUnassignedAvg km
Loading... -
-
-
@@ -830,13 +757,12 @@ Zone Calls Avg Q - SLA Breaches Avg km - Loading... + Loading... @@ -900,14 +826,6 @@