interhack26 / firestore_writer.py
danvancea's picture
Upload 34 files
4e22ad8 verified
Raw
History Blame Contribute Delete
11.8 kB
"""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