"""Push solver output into Firestore so the frontend reads it live. Document shape: routes/{driverId} driver_id, truck_id, truck_layout {rows, cols} pallet floor grid item_grid {L, W, H} lattice cells (cube_size_m per van) items [{position {x,y,z}, shape {w_x, w_y, w_z}, stop_index, product_id, is_returnable}] points [{lat, lng, address?}] ordered route (depot at index 0) pallets [{row, col, products[]}] legacy floor-only view deliveries [{pallet_positions[]}] aligned with points windows [{start, end}] service_times [number] minutes per stop delivery_status, status The Firebase Admin app is initialised lazily so the FastAPI process starts fine without credentials (e.g. local solver-only dev). When the `seed/service-account.json` is missing we log a warning and noop on writes. """ from __future__ import annotations import json import os from pathlib import Path from typing import Optional _BASE = Path(__file__).resolve().parent _REPO = _BASE.parent _DEFAULT_CRED = _REPO / "seed" / "service-account.json" _db = None _init_attempted = False def _try_init() -> None: """Lazy init. Credential precedence: 1. FIREBASE_SERVICE_ACCOUNT_JSON — full JSON inline (best for serverless secrets) 2. FIREBASE_SERVICE_ACCOUNT — path to a service-account.json on disk 3. seed/service-account.json — legacy default for local dev """ global _db, _init_attempted if _init_attempted: return _init_attempted = True try: import firebase_admin from firebase_admin import credentials, firestore except ImportError as exc: print(f"[firestore_writer] firebase_admin missing: {exc}") return cred = None inline = os.environ.get("FIREBASE_SERVICE_ACCOUNT_JSON") if inline: try: cred = credentials.Certificate(json.loads(inline)) print("[firestore_writer] loaded credentials from FIREBASE_SERVICE_ACCOUNT_JSON") except Exception as exc: # noqa: BLE001 print(f"[firestore_writer] FIREBASE_SERVICE_ACCOUNT_JSON parse failed: {exc}") if cred is None: cred_path = Path(os.environ.get("FIREBASE_SERVICE_ACCOUNT", _DEFAULT_CRED)) if not cred_path.exists(): print(f"[firestore_writer] no service account (env or {cred_path}) — writes disabled") return try: cred = credentials.Certificate(str(cred_path)) print(f"[firestore_writer] loaded credentials from {cred_path}") except Exception as exc: # noqa: BLE001 print(f"[firestore_writer] cert load failed: {exc}") return try: if not firebase_admin._apps: firebase_admin.initialize_app(cred) _db = firestore.client() print("[firestore_writer] firestore client initialised") except Exception as exc: # noqa: BLE001 print(f"[firestore_writer] init failed: {exc}") def is_enabled() -> bool: _try_init() return _db is not None def _hm(s: int) -> str: h, m = divmod(int(s) // 60, 60) return f"{h:02d}:{m:02d}" def _van_layout(van_type: str) -> dict: """Pallet floor layout (rows × cols) for the visual grid.""" return {"6_pallets": {"rows": 2, "cols": 3}, "8_pallets": {"rows": 2, "cols": 4}}.get( van_type, {"rows": 2, "cols": 3} ) def _van_lattice(van_type: str) -> tuple[int, int, int]: """Lattice dimensions in cells: each axis is van_dim_m / cube_size_m. cube_size_m and physical interior dimensions both live in vans.json — keep the source of truth there. Returns (L, W, H) where L is along the truck length, W its depth, H its stacking height. """ data = json.loads((_BASE / "vans.json").read_text(encoding="utf-8")) spec = next((v for v in data["van_types"] if v["type"] == van_type), None) if spec is None: raise KeyError(f"unknown van_type: {van_type}") cube = spec["cube_size_m"] return ( int(round(spec["length_m"] / cube)), int(round(spec["width_m"] / cube)), int(round(spec["height_m"] / cube)), ) def _compute_item_layout( L: int, W: int, H: int, deliveries_per_stop: list[tuple[int, list[str]]], products: dict, ) -> tuple[list[dict], dict]: """Run SmartTruckOptimizer3D with real product shapes. Returns one entry per *item* (not per cell): each carries an anchor `position` + box `shape` so the frontend can render multi-cell products as single boxes. `deliveries_per_stop` is an ordered list of (stop_index, [product_id per unit]); the optimizer respects that route order so earlier stops are extractable first. Every unit of an `is_returnable` product is created as a return-type instance so the optimizer's EMPTY_KEG ablation models the space empties leave behind. """ counts_per_stop: dict[int, dict[str, int]] = {} for stop_idx, units in deliveries_per_stop: if not units: continue bucket: dict[str, int] = {} for pid in units: bucket[pid] = bucket.get(pid, 0) + 1 counts_per_stop[stop_idx] = bucket if not counts_per_stop: return [], {"L": L, "W": W, "H": H} needed_pids = {pid for counts in counts_per_stop.values() for pid in counts} item_shapes = { pid: ( int(products[pid]["length_cells"]), int(products[pid]["width_cells"]), int(products[pid]["height_cells"]), ) for pid in needed_pids } capacity = L * W * H total_cells = sum( item_shapes[pid][0] * item_shapes[pid][1] * item_shapes[pid][2] * cnt for counts in counts_per_stop.values() for pid, cnt in counts.items() ) if total_cells > capacity: # OR-tools VRP capacity already enforces this; defensive scaling so the # lattice optimizer doesn't deadlock if a request slips through. scale = capacity / total_cells counts_per_stop = { stop_idx: {pid: max(1, int(cnt * scale)) for pid, cnt in counts.items()} for stop_idx, counts in counts_per_stop.items() } returns_per_stop = { stop_idx: {pid: cnt for pid, cnt in counts.items() if products[pid]["is_returnable"]} for stop_idx, counts in counts_per_stop.items() } route_ids = [stop_idx for stop_idx, _ in deliveries_per_stop if stop_idx in counts_per_stop] import numpy as np # noqa: F401 (used by SmartTruckOptimizer3D) from optimize_box import SmartTruckOptimizer3D opt = SmartTruckOptimizer3D(L, W, H, route_ids, item_shapes=item_shapes) initial = opt.generate_initial_state(counts_per_stop, returns_per_stop) final, _, _ = opt.optimize(initial, steps=2000) opt._sync_instances_to_state(final) items: list[dict] = [] for inst in opt._instances.values(): x, y, z = inst.anchor lx, ly, lz = inst.shape items.append({ "position": {"x": int(x), "y": int(y), "z": int(z)}, "shape": {"w_x": int(lx), "w_y": int(ly), "w_z": int(lz)}, "stop_index": int(inst.client), "product_id": inst.item_type, "is_returnable": bool(products[inst.item_type]["is_returnable"]), }) return items, {"L": L, "W": W, "H": H} def _pallets_for_stop(stop: dict, products: dict, capacity_cells: int = 9) -> list[list[dict]]: """Greedy packer: group a stop's deliveries into pallets capped at `capacity_cells` cube-units each. One pallet's `products` list is in the same shape the frontend already consumes.""" items: list[tuple[str, int]] = [] for line in stop.get("deliveries", []): pid = line["product_id"] cells = ( products[pid]["length_cells"] * products[pid]["width_cells"] * products[pid]["height_cells"] ) items.extend([(pid, cells)] * line["qty"]) pallets: list[list[dict]] = [] current: dict[str, int] = {} used = 0 for pid, cells in items: if used + cells > capacity_cells and current: pallets.append([{"product_id": p, "quantity": q} for p, q in current.items()]) current, used = {}, 0 current[pid] = current.get(pid, 0) + 1 used += cells if current: pallets.append([{"product_id": p, "quantity": q} for p, q in current.items()]) return pallets def build_route_doc( *, driver_id: str, truck_id: str, van_type: str, depot: dict, request_stops: list[dict], van_plan, service_times_s: dict[str, float], ) -> dict: """Translate one VanPlan into the Firestore route document shape.""" layout = _van_layout(van_type) rows, cols = layout["rows"], layout["cols"] L, W, H = _van_lattice(van_type) products = {p["id"]: p for p in json.loads((_BASE / "products.json").read_text())["products"]} by_id = {s["id"]: s for s in request_stops} points: list[dict] = [ {"lat": depot["coords"]["lat"], "lng": depot["coords"]["lng"], "address": depot.get("id", "Depot")} ] windows: list[dict] = [{"start": depot["open"], "end": depot["close"]}] service_times_min: list[float] = [0] pallets: list[dict] = [] deliveries: list[dict] = [{"pallet_positions": []}] next_slot = 0 # row-major fill of the truck floor deliveries_per_stop: list[tuple[int, list[str]]] = [] for stop_idx, sp in enumerate(van_plan.stops, start=1): s = by_id[sp.id] points.append({ "lat": s["coords"]["lat"], "lng": s["coords"]["lng"], "address": s.get("address", s["id"]), }) windows.append({"start": s["time_window"]["open"], "end": s["time_window"]["close"]}) service_times_min.append(round(service_times_s.get(sp.id, 0) / 60, 1)) stop_pallets = _pallets_for_stop(s, products) positions: list[dict] = [] for pdata in stop_pallets: if next_slot >= rows * cols: break r, c = divmod(next_slot, cols) pallets.append({"row": r, "col": c, "products": pdata}) positions.append({"row": r, "col": c}) next_slot += 1 deliveries.append({"pallet_positions": positions}) units: list[str] = [] for line in s.get("deliveries", []): units.extend([line["product_id"]] * line["qty"]) deliveries_per_stop.append((stop_idx, units)) items, item_grid = _compute_item_layout(L, W, H, deliveries_per_stop, products) return { "driver_id": driver_id, "truck_id": truck_id, "truck_layout": layout, "item_grid": item_grid, "items": items, "points": points, "pallets": pallets, "deliveries": deliveries, "windows": windows, "service_times": service_times_min, "delivery_status": ["pending"] * len(points), "status": "pending", } def write_routes(docs: list[dict]) -> int: """Write a batch of route docs to `routes/{driver_id}`. Returns count written; 0 when Firestore is disabled.""" if not is_enabled(): return 0 written = 0 for doc in docs: _db.collection("routes").document(doc["driver_id"]).set(doc) written += 1 return written