File size: 11,820 Bytes
4e22ad8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
"""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