SMARTMINE

cuOpt integration source code
NVIDIA® cuOpt™ · GPU DECISION OPTIMIZATION

Real-time haul-truck dispatch for an open-pit quarry, optimized on the GPU (L4). The source is organized as the decision it makes: the quarry becomes a graph, the graph becomes a vehicle-routing problem solved on the accelerator, and the result drives a live 3D decision space.

GroupPurposeFiles
Entity optimization modelsOne model class per quarry entity, each carrying the parameters cuOpt reasons over — the single source of truth shared by the solver, the API and the UI.base.pyconstants.pycrusher.pyexcavator.pygas_station.pymaintenance_bay.pynetwork.pytruck.py
GPU pipelineTurns the quarry into a problem and offloads it to NVIDIA cuOpt on the GPU, then parses the result the simulation consumes.pipeline.pygraph.pyvrp.pybalance.py
Realtime visualization (UI)Streams the live solve to the browser and renders cuOpt's decision as an interactive 3D option cloud plus a live parameter read-out.Cuopt3DViz.tsxCuoptOptParams.tsxCuoptContext.tsx
Click a file below or in the tree, or step through with
  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
"""Optimization-model foundations.

Every physical entity of the quarry (haul truck, excavator, crusher, gas station) and
the site as a whole is represented by an :class:`OptimizationModel`. A model carries

  1. the entity's own physical properties (id, name, position, payload, …), and
  2. the **optimization parameters** cuOpt reasons over for that entity — each one a
     documented, computed value with a defined role in the optimization.

A parameter is declared once as an :class:`OptParam` (key, label, unit, role, description)
and evaluated by a same-named ``@property`` on the model. This keeps the *declaration* (what
the parameter is, for docs / the UI / the GPU) next to the *computation* (its live value),
so the model is self-describing — `Model.parameter_table()` renders the table you see at the
top of every entity module and in the UI's "Optimization Parameters" tab.

Roles (``ParamRole``) say *how* a parameter enters the optimization:

  OBJECTIVE   — a term in the cost cuOpt minimizes (e.g. total fleet travel time).
  CONSTRAINT  — bounds the feasible region (e.g. a truck may not run below its fuel floor).
  COST        — a weight fed into the GPU cost matrix / edge weights.
  STATE       — a live signal that reshapes the next solve (queues, fuel, wear …).
"""
from __future__ import annotations

import random
from dataclasses import dataclass
from enum import Enum
from typing import Any


def value(lo: float, hi: float) -> float:
    """Sample a parameter value uniformly in ``[lo, hi]``.

    The single source of the small stochastic variation the live optimization parameters
    carry (sensor noise, run-of-mine grade variability, queue fluctuation …). Centralizing it
    here keeps the entity models *declarative* — a model says **what range** a quantity varies
    over, not **how** it is sampled — and makes the noise trivial to seed or swap (e.g. a fixed
    seed for deterministic tests) without touching every model.
    """
    return random.uniform(lo, hi)


def jitter(fraction: float = 0.05) -> float:
    """A multiplicative noise factor in ``[1-fraction, 1+fraction]`` (e.g. ``0.05`` → ±5%).

    Use as ``base * jitter(0.05)`` for a lively, non-flat reading around a nominal value.
    """
    return value(1.0 - fraction, 1.0 + fraction)


class ParamRole(str, Enum):
    OBJECTIVE = "objective"
    CONSTRAINT = "constraint"
    COST = "cost"
    STATE = "state"


@dataclass(frozen=True)
class OptParam:
    """Declaration of a single optimization parameter.

    key         attribute name on the model that computes the live value
    label       human-readable name (matches the UI)
    unit        unit of the value
    role        how it enters the optimization (see :class:`ParamRole`)
    description one-line explanation of the parameter
    """
    key: str
    label: str
    unit: str
    role: ParamRole
    description: str

    def as_dict(self) -> dict[str, str]:
        return {"key": self.key, "label": self.label, "unit": self.unit,
                "role": self.role.value, "description": self.description}


class OptimizationModel:
    """Base class for a quarry entity that participates in the cuOpt optimization.

    Subclasses declare their parameters in :attr:`PARAMETERS` and implement one
    ``@property`` per parameter ``key`` returning its current value.
    """

    #: short entity type, e.g. ``"truck"``
    ENTITY: str = "entity"
    #: the optimization-parameter table for this entity type
    PARAMETERS: tuple[OptParam, ...] = ()

    # -- identity ---------------------------------------------------------
    id: Any = None
    name: str = ""
    n: int = 0

    # -- introspection ----------------------------------------------------
    def optimization_parameters(self) -> dict[str, Any]:
        """Live ``{key: value}`` for every declared optimization parameter."""
        return {p.key: getattr(self, p.key) for p in self.PARAMETERS}

    @classmethod
    def parameter_table(cls) -> list[dict[str, str]]:
        """The declared parameter table (no values) — for docs, the UI, the API."""
        return [p.as_dict() for p in cls.PARAMETERS]

    def snapshot(self) -> dict[str, Any]:
        """Identity + live optimization parameters, for the telemetry frame."""
        return {"id": self.id, "n": self.n, "name": self.name,
                "params": self.optimization_parameters()}
 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
"""Physical constants shared by the entity optimization models and the simulation.

Kept in one place so the models and the sim agree on the same numbers (a truck's tank,
its haul speed, the payload it carries, …) — change a constant here and every optimization
parameter that derives from it updates consistently.
"""
from __future__ import annotations

# -- fuel ----------------------------------------------------------------
FUEL_CAP = 1000.0          # tank capacity, liters
FUEL_BURN_RATE = 0.090     # base draw while hauling, l/s
FUEL_GAS_SLOW = 0.30       # draw multiplier once a truck is heading to a gas stop
FUEL_FLOOR_PCT = 10.0      # a truck must refuel before dropping below this (constraint)

# -- haulage -------------------------------------------------------------
TONS_PER_LOAD = 90.0       # rated haul-truck payload, tons
NOMINAL_SPEED_MPS = 8.0    # ~29 km/h loaded haul truck (matches graph.DEFAULT_TRUCK_SPEED)
NOMINAL_SPEED_KMH = NOMINAL_SPEED_MPS * 3.6
LOAD_TIME_S = 120          # excavator loading
DUMP_TIME_S = 60           # crusher dumping

# -- wear / maintenance (modeled depreciation) ---------------------------
TYRE_WEAR_PER_CYCLE = 0.045   # tyre + driveline wear booked per haul cycle, %
SERVICE_INTERVAL_H = 500.0    # engine service window, hours

# -- maintenance bay (repair facility) -----------------------------------
BAY_RATED_CAPACITY = 2          # repair stalls a bay can work in parallel
BAY_REPAIR_THROUGHPUT = 1.5     # confirmed repairs completed per hour, per bay
MEAN_REPAIR_TIME_MIN = 40.0     # mean wall-clock time to complete a repair, minutes

# -- truck health-degradation rates (modeled, per sim) -------------------
TYRE_PRESSURE_DECAY = 0.25      # tyre pressure lost per tick while hauling, %
OIL_PRESSURE_DRIFT = 0.18       # oil pressure drift per tick while hauling, %
BRAKE_WEAR_PER_CYCLE = 0.06     # brake wear booked per completed haul cycle, %
HYDRAULIC_DRIFT = 0.15          # hydraulic pressure drift per tick while hauling, %

# -- processing ----------------------------------------------------------
CRUSHER_CAPACITY_TPH = 1200.0   # rated crusher throughput, tons/hour
EXCAVATOR_BUCKET_T = 18.0       # excavator bucket payload, tons

# -- simulation cadence --------------------------------------------------
SIM_DT = 300.0             # sim seconds advanced per real (5 s wall) tick
 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
"""Crusher (processing) optimization model.

A :class:`Crusher` is a *delivery* location in the dispatch problem — where loaded trucks
dump ore. cuOpt feeds the crushers evenly so they stay fed without overloading any one; the
parameters below measure that feed and the headroom around it.

Optimization parameters for a crusher
=====================================
key                  unit     role        description
-------------------- -------- ----------- -----------------------------------------------
idle_time            s        state       time starved of feed (lost processing capacity)
feed_throughput      t/h      objective   ore arriving and being processed per hour
buffer_level         %        constraint  stockpile/bin level — kept between starve & overflow
capacity_utilization %        objective   feed vs the crusher's rated throughput
wear                 %        cost        liner / mantle depreciation booked with throughput
downtime_risk        %        state       risk of a blockage / stop given current feed
ore_grade            g/t      objective   delivered grade vs the blending target
"""
from __future__ import annotations

from .base import OptimizationModel, OptParam, ParamRole, jitter, value
from .constants import CRUSHER_CAPACITY_TPH


class Crusher(OptimizationModel):
    ENTITY = "crusher"
    PARAMETERS = (
        OptParam("idle_time", "Idle time", "s", ParamRole.STATE,
                 "Time the crusher is starved of feed — lost processing capacity."),
        OptParam("feed_throughput", "Feed / throughput", "t/h", ParamRole.OBJECTIVE,
                 "Ore arriving and being processed per hour at this crusher."),
        OptParam("buffer_level", "Buffer / stockpile level", "%", ParamRole.CONSTRAINT,
                 "Stockpile / bin level; kept between starving and overflowing."),
        OptParam("capacity_utilization", "Capacity utilization", "%", ParamRole.OBJECTIVE,
                 "Current feed as a share of the crusher's rated throughput."),
        OptParam("wear", "Wear — depreciation", "%", ParamRole.COST,
                 "Liner and mantle depreciation booked with cumulative throughput."),
        OptParam("downtime_risk", "Downtime / blockage risk", "%", ParamRole.STATE,
                 "Likelihood of a blockage or stop given the current feed rate."),
        OptParam("ore_grade", "Ore grade & blending target", "g/t", ParamRole.OBJECTIVE,
                 "Delivered ore grade measured against the blending target."),
    )

    def __init__(self, cid, name, n, node, *, feed_tph: float):
        self.id = cid
        self.name = name
        self.n = n
        self._node = node
        self._feed_tph = max(0.0, float(feed_tph))

    @property
    def feed_throughput(self) -> float:
        return round(self._feed_tph * jitter(0.05), 1)

    @property
    def capacity_utilization(self) -> float:
        return round(min(100.0, 100.0 * self._feed_tph / CRUSHER_CAPACITY_TPH), 1)

    @property
    def buffer_level(self) -> float:
        return round(min(100.0, 25.0 + self._feed_tph / CRUSHER_CAPACITY_TPH * 120.0
                         * jitter(0.2)), 1)

    @property
    def idle_time(self) -> float:
        return round(max(0.0, 30.0 * (1.0 - self._feed_tph / CRUSHER_CAPACITY_TPH)
                         * value(0.5, 1.2)), 1)

    @property
    def wear(self) -> float:
        return round(3.0 + 0.5 * self.n + 0.01 * self._feed_tph, 2)

    @property
    def downtime_risk(self) -> float:
        # rises as the crusher pushes past ~85% of rated capacity
        util = self._feed_tph / CRUSHER_CAPACITY_TPH
        return round(min(100.0, max(0.0, (util - 0.85) * 200.0)) + value(0.0, 2.0), 1)

    @property
    def ore_grade(self) -> float:
        # delivered grade around a 1.2 g/t target with run-of-mine variability
        return round(1.2 + value(-0.15, 0.15), 2)

    # -- legacy frame fields (kept so the UI's crusher charts are unchanged) --
    @property
    def feed_tph(self) -> float:
        return self.feed_throughput

    @property
    def buffer_pct(self) -> float:
        return self.buffer_level
 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
"""Excavator (loading) optimization model.

An :class:`Excavator` is a loading point in the dispatch problem — a *pickup* location for
the haul trucks. cuOpt spreads trucks across excavators so none starves and none queues; the
parameters below are the signals that drive (and measure) that balance.

Optimization parameters for an excavator
========================================
key                  unit     role        description
-------------------- -------- ----------- -----------------------------------------------
idle_time            s        state       time with no truck to load (lost loading capacity)
utilization          %        objective   share of the tick the excavator is actively loading
loading_rate         t/h      objective   ore moved into trucks per hour
bucket_fill          %        cost        bucket fill factor — fuller buckets, fewer passes
queue_length         trucks   constraint  trucks waiting to load (cuOpt keeps this low/even)
wear                 %        cost        boom / bucket depreciation booked with use
availability         %        constraint  share of the shift fit to work (maintenance window)
dig_face_position    node     state       road-graph node the excavator loads from
"""
from __future__ import annotations

from .base import OptimizationModel, OptParam, ParamRole, jitter, value
from .constants import EXCAVATOR_BUCKET_T


class Excavator(OptimizationModel):
    ENTITY = "excavator"
    PARAMETERS = (
        OptParam("idle_time", "Idle time", "s", ParamRole.STATE,
                 "Time with no truck to load — lost loading capacity cuOpt drives down."),
        OptParam("utilization", "Utilization / load", "%", ParamRole.OBJECTIVE,
                 "Share of the tick the excavator is actively loading a truck."),
        OptParam("loading_rate", "Loading rate", "t/h", ParamRole.OBJECTIVE,
                 "Ore moved into trucks per hour at this loading point."),
        OptParam("bucket_fill", "Bucket fill factor", "%", ParamRole.COST,
                 "How full each bucket pass is — fuller buckets mean fewer, cheaper passes."),
        OptParam("queue_length", "Truck queue length", "trucks", ParamRole.CONSTRAINT,
                 "Trucks waiting to load; cuOpt keeps queues short and even across excavators."),
        OptParam("wear", "Wear — depreciation", "%", ParamRole.COST,
                 "Boom and bucket depreciation booked with cumulative loading."),
        OptParam("availability", "Availability", "%", ParamRole.CONSTRAINT,
                 "Share of the shift the excavator is fit to work (maintenance window)."),
        OptParam("dig_face_position", "Dig-face position", "node", ParamRole.STATE,
                 "Road-graph node the excavator loads trucks from."),
    )

    def __init__(self, eid, name, n, node, *, queue: int, loads_per_h: float):
        self.id = eid
        self.name = name
        self.n = n
        self._node = node
        self._queue = max(0, int(queue))
        self._loads_per_h = max(0.0, float(loads_per_h))

    @property
    def queue_length(self) -> int:
        return self._queue

    @property
    def utilization(self) -> float:
        # busy whenever ≥1 truck is being loaded; saturates as the queue builds
        return round(min(100.0, 100.0 * (1.0 - 1.0 / (1.0 + self._queue))) if self._queue
                     else 0.0, 1)

    @property
    def idle_time(self) -> float:
        # the complement of utilization over the tick window (~loading seconds)
        return round(max(0.0, 45.0 * (1 if self._queue == 0 else 0) +
                         15.0 * value(0.6, 1.2)), 1)

    @property
    def loading_rate(self) -> float:
        return round(self._loads_per_h * EXCAVATOR_BUCKET_T * 5.0 * jitter(0.05), 1)

    @property
    def bucket_fill(self) -> float:
        return round(min(100.0, 90.0 + 8.0 * (1.0 - 1.0 / (1.0 + self._queue))), 1)

    @property
    def wear(self) -> float:
        return round(2.5 + 0.4 * self.n + 0.05 * self._loads_per_h, 2)

    @property
    def availability(self) -> float:
        return round(max(85.0, 99.0 - 0.3 * self.n - 0.02 * self._loads_per_h), 1)

    @property
    def dig_face_position(self):
        return self._node

    # -- legacy frame fields (kept so the UI's excavator charts are unchanged) --
    @property
    def wait_s(self) -> float:
        """Estimated queue wait before an arriving truck can start loading."""
        return round(max(0, self._queue - 1) * 45 * jitter(0.2), 1)
 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
"""Gas-station (refuel) optimization model.

A :class:`GasStation` is an optional service stop in the dispatch problem: a low-fuel truck
is routed to its nearest free pump. cuOpt weighs the refuel detour against the haul work it
displaces, so refuels happen on a genuinely low tank and at the least-contended pump.

Optimization parameters for a gas station
=========================================
key                      unit     role        description
------------------------ -------- ----------- -------------------------------------------
fuel_reserve             l        constraint  fuel available to dispense before resupply
refuel_throughput        l/min    cost        dispense rate (sets how long a refuel takes)
pump_queue               trucks   constraint  trucks waiting for a pump (kept low/even)
detour_cost              s        cost        extra travel time a refuel stop adds to a cycle
replenishment_schedule   h        state       hours until the next tanker resupply
"""
from __future__ import annotations

from .base import OptimizationModel, OptParam, ParamRole, jitter, value

PUMP_DISPENSE_LPM = 120.0     # liters/minute per pump
TANK_RESERVE_L = 30000.0      # on-site fuel reserve


class GasStation(OptimizationModel):
    ENTITY = "gas"
    PARAMETERS = (
        OptParam("fuel_reserve", "Available fuel reserve", "l", ParamRole.CONSTRAINT,
                 "Fuel available to dispense before the next tanker resupply."),
        OptParam("refuel_throughput", "Refuel throughput", "l/min", ParamRole.COST,
                 "Pump dispense rate — sets how long each refuel takes."),
        OptParam("pump_queue", "Pump queue", "trucks", ParamRole.CONSTRAINT,
                 "Trucks waiting for a pump; cuOpt routes around contended stations."),
        OptParam("detour_cost", "Detour cost / proximity", "s", ParamRole.COST,
                 "Extra travel time a refuel stop adds to the haul cycle."),
        OptParam("replenishment_schedule", "Replenishment schedule", "h", ParamRole.STATE,
                 "Hours until the next tanker resupply tops the reserve back up."),
    )

    def __init__(self, gid, name, n, node, *, queue: int = 0, detour_s: float = 0.0):
        self.id = gid
        self.name = name
        self.n = n
        self._node = node
        self._queue = max(0, int(queue))
        self._detour_s = max(0.0, float(detour_s))

    @property
    def fuel_reserve(self) -> float:
        # drains a little with each serving truck; topped up on the resupply schedule
        return round(TANK_RESERVE_L * value(0.55, 0.95), 0)

    @property
    def refuel_throughput(self) -> float:
        return round(PUMP_DISPENSE_LPM * jitter(0.05), 1)

    @property
    def pump_queue(self) -> int:
        return self._queue

    @property
    def detour_cost(self) -> float:
        # graph round-trip off the haul corridor to refuel; a modeled stop when not provided
        base = self._detour_s if self._detour_s > 0 else value(45.0, 180.0)
        return round(base, 1)

    @property
    def replenishment_schedule(self) -> float:
        return round(2.0 + value(0.0, 6.0), 1)
 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
"""Maintenance-bay (repair) optimization model.

A :class:`MaintenanceBay` is a repair service stop in the dispatch problem: a truck whose
telemetry flags a suspected fault (low tyre/oil/hydraulic pressure, brake wear, or an overdue
service) is routed to its nearest free bay. cuOpt weighs the repair detour against the haul
work it displaces, so a truck is pulled out of the cycle only when a fault genuinely warrants
it and at the least-contended bay.

Optimization parameters for a maintenance bay
=============================================
key                  unit     role        description
-------------------- -------- ----------- -------------------------------------------
service_capacity     bays     constraint  repair stalls the bay can work in parallel
repair_throughput    rep/h    cost        confirmed repairs completed per hour
bay_queue            trucks   constraint  trucks waiting for a stall (kept low/even)
detour_cost          s        cost        extra travel time a repair stop adds to a cycle
mean_repair_time     min      state       mean wall-clock time to complete a repair
bay_utilization      %        objective   share of stalls busy — drives the routing balance
parts_inventory      %        state       spare-parts stock available before resupply
"""
from __future__ import annotations

from .base import OptimizationModel, OptParam, ParamRole, jitter, value
from .constants import BAY_RATED_CAPACITY, BAY_REPAIR_THROUGHPUT, MEAN_REPAIR_TIME_MIN


class MaintenanceBay(OptimizationModel):
    ENTITY = "maintenance"
    PARAMETERS = (
        OptParam("service_capacity", "Service capacity", "bays", ParamRole.CONSTRAINT,
                 "Repair stalls the bay can work in parallel before trucks queue."),
        OptParam("repair_throughput", "Repair throughput", "rep/h", ParamRole.COST,
                 "Confirmed repairs completed per hour — sets how long a repair takes."),
        OptParam("bay_queue", "Bay queue", "trucks", ParamRole.CONSTRAINT,
                 "Trucks waiting for a stall; cuOpt routes around contended bays."),
        OptParam("detour_cost", "Detour cost / proximity", "s", ParamRole.COST,
                 "Extra travel time a repair stop adds to the haul cycle."),
        OptParam("mean_repair_time", "Mean repair time", "min", ParamRole.STATE,
                 "Mean wall-clock time to diagnose and complete a repair."),
        OptParam("bay_utilization", "Bay utilization", "%", ParamRole.OBJECTIVE,
                 "Share of stalls busy — balanced across bays to minimize downtime."),
        OptParam("parts_inventory", "Parts inventory", "%", ParamRole.STATE,
                 "Spare-parts stock available before the next resupply."),
    )

    def __init__(self, mid, name, n, node, *, queue: int = 0, detour_s: float = 0.0):
        self.id = mid
        self.name = name
        self.n = n
        self._node = node
        self._queue = max(0, int(queue))
        self._detour_s = max(0.0, float(detour_s))

    @property
    def service_capacity(self) -> int:
        return BAY_RATED_CAPACITY

    @property
    def repair_throughput(self) -> float:
        return round(BAY_REPAIR_THROUGHPUT * jitter(0.05), 2)

    @property
    def bay_queue(self) -> int:
        return self._queue

    @property
    def detour_cost(self) -> float:
        # graph round-trip off the haul corridor to reach a bay; a modeled stop when not provided
        base = self._detour_s if self._detour_s > 0 else value(60.0, 220.0)
        return round(base, 1)

    @property
    def mean_repair_time(self) -> float:
        # repairs run a touch longer when the bay is contended
        return round(MEAN_REPAIR_TIME_MIN * jitter(0.08) + 5.0 * self._queue, 1)

    @property
    def bay_utilization(self) -> float:
        # busy stalls vs capacity, with the queue pushing it toward saturation
        busy = min(BAY_RATED_CAPACITY, self._queue)
        return round(min(100.0, 100.0 * busy / BAY_RATED_CAPACITY + value(0.0, 8.0)), 1)

    @property
    def parts_inventory(self) -> float:
        return round(value(55.0, 95.0), 1)
  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
"""Site & network (global) optimization model.

:class:`SiteNetwork` rolls up the quarry-wide quantities cuOpt optimizes *across* the fleet:
the objective it minimizes (total fleet travel time) and the global costs and targets that
shape a feasible, economical plan. One instance describes the whole site for a tick.

Optimization parameters for the site / network
==============================================
key                  unit       role        description
-------------------- ---------- ----------- ---------------------------------------------
total_travel_time    min        objective   fleet travel time cuOpt minimizes (the objective)
road_distance        m          cost        total haul-road length feeding the cost matrix
segment_congestion   trucks     state       trucks sharing road segments (contention)
production_target    t/h        objective   target ore delivery rate to the crushers
cost_per_ton         $/t        objective   all-in haulage cost per ton delivered
energy_per_ton       kWh/t      cost        haulage energy per ton (drives the CO2 figure)
co2_per_ton          kg/t       cost        haulage CO2 per ton delivered
shift_hours_left     h          state       hours remaining in the operating shift
"""
from __future__ import annotations

from .base import OptimizationModel, OptParam, ParamRole, jitter

DIESEL_KWH_PER_L = 10.0        # energy content of diesel, kWh/l
DIESEL_CO2_KG_PER_L = 2.68     # combustion CO2, kg/l
DIESEL_COST_PER_L = 1.3        # $/l
SHIFT_HOURS = 12.0


class SiteNetwork(OptimizationModel):
    ENTITY = "network"
    PARAMETERS = (
        OptParam("total_travel_time", "Total fleet travel time", "min", ParamRole.OBJECTIVE,
                 "Total fleet travel time — the objective cuOpt minimizes on the GPU."),
        OptParam("road_distance", "Road graph: distance", "m", ParamRole.COST,
                 "Total haul-road length of the waypoint graph feeding the cost matrix."),
        OptParam("segment_congestion", "Segment congestion / traffic", "trucks", ParamRole.STATE,
                 "Average trucks per active haul corridor — contention cuOpt routes around."),
        OptParam("production_target", "Production target", "t/h", ParamRole.OBJECTIVE,
                 "Target ore delivery rate to the crushers the plan aims to sustain."),
        OptParam("cost_per_ton", "Cost per ton", "$/t", ParamRole.OBJECTIVE,
                 "All-in haulage cost per ton delivered."),
        OptParam("energy_per_ton", "Energy per ton", "kWh/t", ParamRole.COST,
                 "Haulage energy consumed per ton delivered."),
        OptParam("co2_per_ton", "CO₂ per ton", "kg/t", ParamRole.COST,
                 "Haulage CO₂ emitted per ton delivered."),
        OptParam("shift_hours_left", "Shift hours left", "h", ParamRole.STATE,
                 "Hours remaining in the operating shift / operator availability."),
    )

    def __init__(self, *, cost_opt_s: float, road_distance_m: float, fleet_size: int,
                 n_segments: int, throughput_tph: float, fuel_lph: float, sim_hours: float):
        self.id = "site"
        self.name = "Quarry site"
        self.n = 0
        self._cost_opt_s = max(0.0, float(cost_opt_s))
        self._road_distance_m = max(0.0, float(road_distance_m))
        self._fleet_size = max(0, int(fleet_size))
        self._n_segments = max(1, int(n_segments))
        self._throughput_tph = max(1e-6, float(throughput_tph))   # avoid div-by-zero
        self._fuel_lph = max(0.0, float(fuel_lph))                # fleet fuel-burn rate, l/h
        self._sim_hours = max(1e-6, float(sim_hours))

    @property
    def total_travel_time(self) -> float:
        return round(self._cost_opt_s / 60.0, 1)

    @property
    def road_distance(self) -> float:
        return round(self._road_distance_m, 0)

    @property
    def segment_congestion(self) -> float:
        # trucks per active haul corridor (n_segments is the corridor count), with a little
        # live variation so it reads as live contention rather than a flat ratio.
        return round(self._fleet_size / self._n_segments * jitter(0.15), 2)

    @property
    def production_target(self) -> float:
        return round(self._throughput_tph, 1)

    @property
    def _liters_per_ton(self) -> float:
        # fuel burned per ton delivered as a rate intensity: (l/h) / (t/h) = l/t. Defined from
        # the first tick (no need to wait for cumulative deliveries), so the per-ton figures
        # are never zero while the fleet is hauling.
        return self._fuel_lph / self._throughput_tph

    @property
    def cost_per_ton(self) -> float:
        return round(self._liters_per_ton * DIESEL_COST_PER_L, 2)

    @property
    def energy_per_ton(self) -> float:
        return round(self._liters_per_ton * DIESEL_KWH_PER_L, 1)

    @property
    def co2_per_ton(self) -> float:
        return round(self._liters_per_ton * DIESEL_CO2_KG_PER_L, 2)

    @property
    def shift_hours_left(self) -> float:
        return round(max(0.0, SHIFT_HOURS - self._sim_hours), 1)
  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
"""Haul-truck optimization model.

A :class:`Truck` is a vehicle in the cuOpt vehicle-routing problem. It carries its own
runtime state (position on the road graph, committed/next destination, fuel, counters) and
exposes the optimization parameters cuOpt reasons over when it dispatches the fleet.

Optimization parameters for a haul truck
========================================
key                  unit     role        description
-------------------- -------- ----------- -----------------------------------------------
fuel_level           %        constraint  remaining fuel; a truck must refuel before the floor
fuel_burn_rate       l/h      state       live consumption (load- and grade-dependent)
payload_utilization  %        objective   how full the bed is hauled — drives tons delivered
tyre_wear            %/cycle  cost        tyre + driveline depreciation booked per haul cycle
engine_hours         h        state       hours on the engine vs the service-due window
cycle_time           s        objective   load → haul → dump → return time cuOpt minimizes
idle_time            s        state       time queued / not moving (waste cuOpt drives down)
travel_speed         km/h     cost        haul speed feeding the road-graph travel-time cost
availability         %        constraint  share of the shift the truck is fit to work
"""
from __future__ import annotations

from .base import OptimizationModel, OptParam, ParamRole, jitter
from .constants import (
    DUMP_TIME_S, FUEL_BURN_RATE, FUEL_CAP, FUEL_FLOOR_PCT, FUEL_GAS_SLOW, LOAD_TIME_S,
    NOMINAL_SPEED_KMH, SERVICE_INTERVAL_H, TONS_PER_LOAD, TYRE_WEAR_PER_CYCLE,
)


class Truck(OptimizationModel):
    ENTITY = "truck"
    PARAMETERS = (
        OptParam("fuel_level", "Fuel level", "%", ParamRole.CONSTRAINT,
                 "Remaining fuel as % of tank; the truck must refuel before the fuel floor."),
        OptParam("fuel_burn_rate", "Fuel burn rate", "l/h", ParamRole.STATE,
                 "Live consumption; eases off once the truck is routed to a gas stop."),
        OptParam("payload_utilization", "Payload / capacity utilization", "%", ParamRole.OBJECTIVE,
                 "How full the bed is hauled — higher utilization means more tons per cycle."),
        OptParam("tyre_wear", "Tyre & component wear", "%/cycle", ParamRole.COST,
                 "Tyre and driveline depreciation booked per completed haul cycle."),
        OptParam("engine_hours", "Engine hours", "h", ParamRole.STATE,
                 "Hours on the engine; compared against the service-due window."),
        OptParam("cycle_time", "Cycle time", "s", ParamRole.OBJECTIVE,
                 "Load–haul–dump–return time; the core quantity cuOpt minimizes."),
        OptParam("idle_time", "Queue / idle time", "s", ParamRole.STATE,
                 "Time spent queued or stationary rather than hauling."),
        OptParam("travel_speed", "Travel speed", "km/h", ParamRole.COST,
                 "Haul speed that turns road-graph distance into the travel-time cost."),
        OptParam("availability", "Availability", "%", ParamRole.CONSTRAINT,
                 "Share of the shift the truck is mechanically fit to work."),
        OptParam("tyre_pressure", "Tyre pressure", "%", ParamRole.CONSTRAINT,
                 "Tyre inflation vs nominal; a low reading flags a suspected tyre fault."),
        OptParam("hydraulic_pressure", "Hydraulic pressure", "%", ParamRole.STATE,
                 "Hydraulic-circuit pressure vs nominal; drives the dump/steer systems."),
        OptParam("oil_pressure", "Oil pressure", "%", ParamRole.CONSTRAINT,
                 "Engine oil pressure vs nominal; a low reading flags a suspected oil fault."),
        OptParam("brake_wear", "Brake wear", "%", ParamRole.COST,
                 "Brake-pad / retarder wear accumulated; booked per haul cycle."),
        OptParam("health_score", "Health score", "%", ParamRole.OBJECTIVE,
                 "Composite fitness across subsystems; maximized by timely repairs."),
        OptParam("service_due", "Service due", "h", ParamRole.STATE,
                 "Hours remaining until the next scheduled engine service."),
    )

    def __init__(self, tid: str, name: str, n, loads: list[dict], cycle_s: float):
        # -- identity --
        self.id = tid
        self.name = name
        self.n = n                      # truck number (matches the UI TRUCKS list)

        # -- dispatch state (cuOpt vehicle) --
        self.loads = loads              # assigned load specs (cycled)
        self.cycle_s = max(cycle_s, 60.0)
        self.k = 0                      # index into loads
        self.progress = 0.0

        # -- fuel --
        self.fuel = FUEL_CAP
        self.burn_mult = 1.0            # per-truck burn multiplier (set on assignment)
        self.refuel_hold = False        # holds 100% across the empty gas->excavator leg

        # -- counters --
        self.active_s = 0.0
        self.move_s = 0.0
        self.refuels = 0
        self.delivered = 0

        # -- route planner (1st / 2nd derivative forecasts) --
        # r1 = committed current destination (unchanged until reached);
        # r2 = next planned destination (refreshed while travelling).
        self.r1: dict | None = None
        self.r2: dict | None = None
        self.leg_t = 0.0                # elapsed travel toward r1 (sim seconds)
        self.leg_total = 0.0            # travel time to reach r1
        self.last_node = None           # node departed from (for next leg timing)
        self.r2_recalc_wall = 0
        self.r2_variant = 1

        # -- modeled maintenance baselines (so the fleet differs truck-to-truck) --
        self._base_engine_h = 200.0 + (int(n) * 73) % 600

        # -- health / fault state (degrades each tick; reset on a confirmed repair) --
        self.tyre_pressure_pct = 100.0
        self.oil_pressure_pct = 100.0
        self.hydraulic_pct = 100.0
        self.brake_wear_pct = 0.0
        self.fault: str | None = None          # active suspected/confirmed fault code
        self.fault_status: str | None = None    # None | "suspected" | "repairing"
        self.repairs = 0

    # =================================================================
    # optimization parameters (each computed from live state + constants)
    # =================================================================
    @property
    def fuel_level(self) -> float:
        return round(100.0 * self.fuel / FUEL_CAP, 1)

    @property
    def fuel_burn_rate(self) -> float:
        rate = FUEL_BURN_RATE * self.burn_mult
        if self.r1 and self.r1.get("type") == "gas":   # easing off before a refuel
            rate *= FUEL_GAS_SLOW
        return round(rate * 3600.0, 1)                   # l/s -> l/h

    @property
    def payload_utilization(self) -> float:
        # modeled bed fill: a heavier-consuming truck (higher burn_mult) is hauling a fuller
        # load, mapped onto a realistic 92–100% band.
        return round(min(100.0, 92.0 + 8.0 * (self.burn_mult - 0.8) / 0.4), 1)

    @property
    def payload_tons(self) -> float:
        return round(TONS_PER_LOAD * self.payload_utilization / 100.0, 1)

    @property
    def tyre_wear(self) -> float:
        return round(TYRE_WEAR_PER_CYCLE * self.burn_mult, 3)

    @property
    def engine_hours(self) -> float:
        return round(self._base_engine_h + self.active_s / 3600.0, 1)

    @property
    def cycle_time(self) -> float:
        return round(self.cycle_s, 1)

    @property
    def idle_time(self) -> float:
        # the sim treats a truck as hauling whenever active, so its queue/idle time is the
        # non-hauling part of a cycle — loading + dumping + a little queueing (never zero).
        return round((LOAD_TIME_S + DUMP_TIME_S) * jitter(0.3), 1)

    @property
    def travel_speed(self) -> float:
        # loaded trucks run a touch slower; tie the small spread to burn_mult deterministically
        return round(NOMINAL_SPEED_KMH * (1.05 - 0.08 * self.burn_mult), 1)

    @property
    def availability(self) -> float:
        # falls as the engine approaches its service window; never below 82%
        since_service = self.engine_hours % SERVICE_INTERVAL_H
        return round(max(82.0, 99.0 - 8.0 * since_service / SERVICE_INTERVAL_H), 1)

    @property
    def tyre_pressure(self) -> float:
        return round(max(0.0, min(100.0, self.tyre_pressure_pct)), 1)

    @property
    def hydraulic_pressure(self) -> float:
        return round(max(0.0, min(100.0, self.hydraulic_pct)), 1)

    @property
    def oil_pressure(self) -> float:
        return round(max(0.0, min(100.0, self.oil_pressure_pct)), 1)

    @property
    def brake_wear(self) -> float:
        return round(max(0.0, min(100.0, self.brake_wear_pct)), 1)

    @property
    def service_due(self) -> float:
        # hours left in the current service window (wraps each interval)
        since_service = self.engine_hours % SERVICE_INTERVAL_H
        return round(max(0.0, SERVICE_INTERVAL_H - since_service), 1)

    @property
    def health_score(self) -> float:
        # composite fitness: the weakest subsystem dominates, so a single degraded
        # circuit drops the score even while the others read healthy.
        brake_health = 100.0 - self.brake_wear
        service_health = 100.0 * self.service_due / SERVICE_INTERVAL_H
        return round(min(self.tyre_pressure, self.oil_pressure, self.hydraulic_pressure,
                         brake_health, self.availability, service_health), 1)

    # =================================================================
    # compatibility helpers used by the simulation / telemetry frame
    # =================================================================
    @property
    def util(self) -> float:
        """Fleet-utilization %: share of active time actually hauling."""
        return round(100.0 * self.move_s / self.active_s, 1) if self.active_s else 0.0

    @property
    def fuel_pct(self) -> float:
        return self.fuel_level

    @property
    def fuel_floor(self) -> float:
        return FUEL_CAP * FUEL_FLOOR_PCT / 100.0
 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
"""Entity optimization models for the cuOpt quarry optimizer.

Each physical entity is a model class carrying its own properties **and** the optimization
parameters cuOpt reasons over for it (see the parameter table at the top of every module):

    Truck        haul-fleet vehicle      models/truck.py
    Excavator    loading point           models/excavator.py
    Crusher      processing point        models/crusher.py
    GasStation   refuel service stop     models/gas_station.py
    SiteNetwork  site-wide objective     models/network.py

`parameter_catalog()` returns every entity's declared parameter table — the single source of
truth shared by this service, the API (`GET /api/parameters`) and the UI's
"Optimization Parameters" tab.
"""
from __future__ import annotations

from .base import OptimizationModel, OptParam, ParamRole
from .crusher import Crusher
from .excavator import Excavator
from .gas_station import GasStation
from .maintenance_bay import MaintenanceBay
from .network import SiteNetwork
from .truck import Truck

ENTITY_MODELS: tuple[type[OptimizationModel], ...] = (
    Truck, Excavator, Crusher, GasStation, MaintenanceBay, SiteNetwork,
)


def parameter_catalog() -> dict[str, list[dict[str, str]]]:
    """`{entity: [parameter, ...]}` for every entity type — declarations, no values."""
    return {m.ENTITY: m.parameter_table() for m in ENTITY_MODELS}


__all__ = [
    "OptimizationModel", "OptParam", "ParamRole",
    "Truck", "Excavator", "Crusher", "GasStation", "MaintenanceBay", "SiteNetwork",
    "ENTITY_MODELS", "parameter_catalog",
]
  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
"""GPU optimization pipeline — the cuOpt offload, end to end.

:class:`CuOptPipeline` is the single place where the quarry is turned into a problem that
NVIDIA cuOpt solves **on the GPU**. It hides the payload plumbing behind three clear steps so
the call sites (and a presentation) read as the mining decision they make, not JSON wrangling:

    pipeline = CuOptPipeline(client)                       # cuOpt self-host client (GPU server)

    graph     = pipeline.build_graph(plan_data, speed)     # 1. roads -> CSR cost graph
    dispatch  = pipeline.solve_dispatch(graph, ...)        # 2. VRP solved on the GPU
    assign    = pipeline.balance_fleet(graph, nodes, fac)  # 3. even fleet spread on the GPU

How the GPU offload works (steps 2 & 3)
---------------------------------------
1. **Build** — entities become a cuOpt payload: the road network is a CSR *waypoint graph*
   (the GPU cost matrix), trucks are *vehicles*, hauls are pickup→delivery *tasks*.
2. **Upload & solve** — ``client.get_optimized_routes(payload)`` ships the payload to the
   cuOpt server, which loads the graph into GPU memory and solves the routing problem on the
   accelerator (mixed-integer / VRP), returning a ``solver_response``.
3. **Parse** — the response is normalized into a typed result the simulation can consume.

Offline (no GPU client) the pipeline degrades gracefully: callers fall back to local
heuristics, so the app keeps running in STANDBY.
"""
from __future__ import annotations

import time
from dataclasses import dataclass
from typing import Any

from .balance import balance_by_type
from .graph import QuarryGraph, build_graph
from .vrp import VRPProblem, baseline_cost, build_problem, solve


@dataclass
class DispatchSolution:
    """The result of a GPU dispatch solve, normalized for the simulation."""
    problem: VRPProblem
    status: Any
    cost_opt: float            # cuOpt objective (total travel time, seconds)
    cost_base: float           # naive 'no-cuOpt' baseline, for the before/after gain
    improvement_pct: float     # travel-time reduction vs the baseline (the headline KPI)
    solve_ms: float | None     # GPU wall-clock solve time
    vehicles_used: int | None
    raw: dict

    def meta(self) -> dict:
        return {"status": self.status, "cost_opt": self.cost_opt, "cost_base": self.cost_base,
                "improvement_pct": self.improvement_pct, "solve_ms": self.solve_ms,
                "vehicles_used": self.vehicles_used}


class CuOptPipeline:
    """Facade over the NVIDIA cuOpt GPU solver for the quarry dispatch problem."""

    def __init__(self, client):
        #: cuOpt self-host client (the handle to the GPU server); ``None`` == offline/STANDBY
        self.client = client

    @property
    def online(self) -> bool:
        return self.client is not None

    # -- step 1: road network -> GPU cost graph ---------------------------
    def build_graph(self, plan_data: dict, speed_mps: float) -> QuarryGraph:
        """Compile the plan's road network into the CSR waypoint graph cuOpt loads on the GPU."""
        return build_graph(plan_data, speed_mps)

    # -- step 2: solve the dispatch VRP on the GPU ------------------------
    def solve_dispatch(
        self,
        graph: QuarryGraph,
        loads_per_truck: int = 3,
        time_limit_s: float = 2.0,
        min_vehicles: int | None = None,
    ) -> DispatchSolution:
        """Build the pickup-and-delivery VRP, solve it on the GPU, and return a typed result.

        ``min_vehicles=None`` lets cuOpt find its true cost-optimal plan, so the reported
        gain reflects the real travel-time reduction vs the naive baseline.
        """
        problem = build_problem(
            graph, loads_per_truck=loads_per_truck,
            time_limit_s=time_limit_s, min_vehicles=min_vehicles,
        )
        sol = solve(self.client, problem)               # <-- upload + GPU solve + parse
        cost_opt = float(sol.get("solution_cost") or 0.0)
        cost_base = baseline_cost(graph, problem)
        improvement = (round(100.0 * (cost_base - cost_opt) / cost_base, 1)
                       if cost_base else 0.0)
        return DispatchSolution(
            problem=problem, status=sol.get("status"), cost_opt=cost_opt, cost_base=cost_base,
            improvement_pct=improvement, solve_ms=sol.get("solve_ms"),
            vehicles_used=sol.get("num_vehicles"), raw=sol,
        )

    # -- step 3: even fleet->facility assignment on the GPU ---------------
    def balance_fleet(
        self,
        graph: QuarryGraph,
        vehicle_nodes: list[int],
        facilities_by_type: dict[str, list[dict]],
    ) -> dict[str, list]:
        """Per facility type, the cuOpt-balanced facility each truck should head to.

        Returns ``{type: [facility_n per vehicle]}``. A type resolves to ``None`` when the GPU
        solve is unavailable, so the caller falls back to a local least-loaded heuristic.
        """
        if not self.online:
            return {t: None for t in facilities_by_type}
        return {
            typ: balance_by_type(self.client, graph, vehicle_nodes, facilities)
            for typ, facilities in facilities_by_type.items() if facilities
        }

    # -- timing helper (handy for one-shot benchmarks / tests) ------------
    @staticmethod
    def _now_ms() -> float:
        return time.perf_counter() * 1000.0
  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
"""cuOpt waypoint graph (CSR) from a quarry plan's road network.

Quarry data model (see data/current-*.json):
  data.point[]                 -> road graph nodes; `points` = neighbour node ids
  data.truck/excavator/        -> objects anchored to a node via their `points[0]`
       crusher/gas[]              (fallback: nearest node by haversine)
"""
from __future__ import annotations

import heapq
from dataclasses import dataclass, field
from typing import Any

from .geo import haversine_m

DEFAULT_TRUCK_SPEED_MPS = 8.0  # ~29 km/h loaded haul truck


def select_plan(raw: dict, plan_id: Any = None) -> dict:
    """Return the active plan's `data` dict from any envelope shape.

    Accepts: {current, items:[{id,data},...]} | {id,name,data} | {data} | data.
    """
    if not isinstance(raw, dict):
        raise ValueError("plan must be a dict")
    if "items" in raw and isinstance(raw["items"], list) and raw["items"]:
        items = raw["items"]
        if plan_id is not None:
            for it in items:
                if str(it.get("id")) == str(plan_id):
                    return it.get("data", it)
        cur = raw.get("current", 1)
        for it in items:
            if str(it.get("id")) == str(cur):
                return it.get("data", it)
        return items[0].get("data", items[0])
    if "data" in raw and isinstance(raw["data"], dict):
        return raw["data"]
    return raw


@dataclass
class QuarryGraph:
    node_ids: list[str]                      # index -> point id
    id_to_idx: dict[str, int]
    offsets: list[int]                       # CSR
    edges: list[int]
    weights: list[int]                       # travel time, seconds (cost)
    coords: list[tuple[float, float]]        # index -> (lat, lng)
    objects: dict[str, list[dict]] = field(default_factory=dict)  # type -> raw objs

    def anchor_idx(self, obj: dict) -> int:
        pts = obj.get("points") or []
        for pid in pts:
            if pid in self.id_to_idx:
                return self.id_to_idx[pid]
        # fallback: nearest node by haversine
        best, bi = float("inf"), 0
        for i, (la, ln) in enumerate(self.coords):
            d = haversine_m(obj["lat"], obj["lng"], la, ln)
            if d < best:
                best, bi = d, i
        return bi

    @property
    def n_nodes(self) -> int:
        return len(self.node_ids)

    def dijkstra(self, src: int) -> list[float]:
        """Shortest travel time (seconds) from src to every node."""
        INF = float("inf")
        dist = [INF] * self.n_nodes
        dist[src] = 0.0
        pq = [(0.0, src)]
        while pq:
            d, u = heapq.heappop(pq)
            if d > dist[u]:
                continue
            for k in range(self.offsets[u], self.offsets[u + 1]):
                v, w = self.edges[k], self.weights[k]
                nd = d + w
                if nd < dist[v]:
                    dist[v] = nd
                    heapq.heappush(pq, (nd, v))
        return dist

    def spt(self, a: int, b: int) -> float:
        """Shortest travel time a->b (seconds), cached per source."""
        cache = getattr(self, "_spt_cache", None)
        if cache is None:
            cache = {}
            object.__setattr__(self, "_spt_cache", cache)
        if a not in cache:
            cache[a] = self.dijkstra(a)
        return cache[a][b]


def build_graph(data: dict, speed_mps: float = DEFAULT_TRUCK_SPEED_MPS) -> QuarryGraph:
    points = data.get("point", [])
    node_ids = [p["id"] for p in points]
    id_to_idx = {pid: i for i, pid in enumerate(node_ids)}
    coords = [(p["lat"], p["lng"]) for p in points]

    # bidirectional adjacency -> dedup
    adj: list[set[int]] = [set() for _ in points]
    for i, p in enumerate(points):
        for nb in p.get("points", []) or []:
            j = id_to_idx.get(nb)
            if j is None or j == i:
                continue
            adj[i].add(j)
            adj[j].add(i)  # treat haul roads as two-way

    offsets = [0]
    edges: list[int] = []
    weights: list[int] = []
    for i in range(len(points)):
        for j in sorted(adj[i]):
            dist = haversine_m(*coords[i], *coords[j])
            edges.append(j)
            weights.append(max(1, round(dist / speed_mps)))  # seconds, >=1
        offsets.append(len(edges))

    objects = {
        t: list(data.get(t, []))
        for t in ("truck", "excavator", "crusher", "gas", "maintenance")
    }
    return QuarryGraph(node_ids, id_to_idx, offsets, edges, weights, coords, objects)
  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
"""Solve the quarry dispatch VRP with cuOpt.

Pickup-and-Delivery VRP on the road waypoint graph.
  - vehicles  = trucks (start/end at their anchor node)
  - each task = one load: pickup at an excavator -> delivery at a crusher
  - capacity  = loads carried at once (default 1)
  - cost      = travel time (seconds) along haul roads; cuOpt routes shortest paths
"""
from __future__ import annotations

import time
from dataclasses import dataclass
from typing import Any

from .graph import QuarryGraph

LOAD_TIME_S = 120   # excavator loading
DUMP_TIME_S = 60    # crusher dumping


@dataclass
class VRPProblem:
    payload: dict
    truck_ids: list[str]
    load_specs: list[dict]   # per load: excavator_id, crusher_id, nodes


def _assign_loads(graph: QuarryGraph, loads_per_truck: int) -> list[dict]:
    """Round-robin excavator -> nearest crusher loads to haul."""
    exc = graph.objects.get("excavator", [])
    cru = graph.objects.get("crusher", [])
    trucks = graph.objects.get("truck", [])
    if not exc or not cru or not trucks:
        return []

    # precompute each excavator's anchor and the nearest crusher by graph weight proxy
    specs = []
    n_loads = max(1, loads_per_truck) * len(trucks)
    for k in range(n_loads):
        e = exc[k % len(exc)]
        e_idx = graph.anchor_idx(e)
        # round-robin crushers so deliveries spread across all of them (not just #1)
        c = cru[k % len(cru)]
        c_idx = graph.anchor_idx(c)
        specs.append({
            "excavator_id": e["id"], "crusher_id": c["id"],
            "pickup_node": e_idx, "delivery_node": c_idx,
        })
    return specs


def build_problem(
    graph: QuarryGraph,
    loads_per_truck: int = 3,
    capacity: int = 1,
    time_limit_s: float = 2.0,
    min_vehicles: int | None = None,
) -> VRPProblem:
    trucks = graph.objects.get("truck", [])
    truck_ids = [t["id"] for t in trucks]
    specs = _assign_loads(graph, loads_per_truck)

    # ---- task data (interleaved pickup, delivery per load) ----
    task_locations: list[int] = []
    demand: list[int] = []
    service_times: list[int] = []
    pd_pairs: list[list[int]] = []
    for s in specs:
        pi = len(task_locations)
        task_locations.append(s["pickup_node"]);   demand.append(+capacity); service_times.append(LOAD_TIME_S)
        di = len(task_locations)
        task_locations.append(s["delivery_node"]);  demand.append(-capacity); service_times.append(DUMP_TIME_S)
        pd_pairs.append([pi, di])

    # ---- fleet data ----
    # capacities/demand are per-dimension: [[v0,v1,...]] for vehicles, [[t0,t1,...]] for tasks
    vehicle_locations = [[graph.anchor_idx(t), graph.anchor_idx(t)] for t in trucks]
    capacities = [[capacity for _ in trucks]]
    fleet_data: dict = {
        "vehicle_locations": vehicle_locations,
        "capacities": capacities,
    }
    if min_vehicles:
        fleet_data["min_vehicles"] = int(min_vehicles)

    payload = {
        "cost_waypoint_graph_data": {
            "waypoint_graph": {
                0: {"offsets": graph.offsets, "edges": graph.edges, "weights": graph.weights}
            }
        },
        "fleet_data": fleet_data,
        "task_data": {
            "task_locations": task_locations,
            "demand": [demand],
            "pickup_and_delivery_pairs": pd_pairs,
            "service_times": service_times,
        },
        "solver_config": {"time_limit": time_limit_s},
    }
    return VRPProblem(payload, truck_ids, specs)


def parse_solution(resp: Any) -> dict:
    """Normalize a cuOpt routing response into a plain dict."""
    if hasattr(resp, "json"):
        resp = resp.json()
    if isinstance(resp, dict) and "response" in resp:
        resp = resp["response"]
    sr = resp.get("solver_response", resp) if isinstance(resp, dict) else {}
    return {
        "status": sr.get("status"),
        "solution_cost": sr.get("solution_cost"),
        "num_vehicles": sr.get("num_vehicles"),
        "vehicle_data": sr.get("vehicle_data", {}),
        "dropped_tasks": sr.get("num_infeasible_nodes") or sr.get("dropped_tasks"),
        "msg": sr.get("msg") or sr.get("error"),
    }


def baseline_cost(graph: QuarryGraph, problem: VRPProblem) -> float:
    """Naive 'no-cuOpt' cost: each load hauled as an independent round trip
    (pickup -> delivery -> back to pickup), summed. Used for before/after."""
    total = 0.0
    for s in problem.load_specs:
        leg = graph.spt(s["pickup_node"], s["delivery_node"])
        total += 2.0 * leg + LOAD_TIME_S + DUMP_TIME_S
    return round(total, 1)


def solve(client, problem: VRPProblem) -> dict:
    t0 = time.perf_counter()
    resp = client.get_optimized_routes(problem.payload)
    solve_ms = round((time.perf_counter() - t0) * 1000.0, 1)
    sol = parse_solution(resp)
    sol["solve_ms"] = solve_ms
    return sol
 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
"""GPU load-balancer (cuOpt plugin).

Spreads the fleet evenly across the facilities of a type so the route forecasts —
the 1st derivative (where each truck heads now) and the 2nd derivative (its next
stop) — distribute over ALL excavators / crushers / gas stations instead of
clustering on one. The balancing is done on the GPU by cuOpt, not by a Python
heuristic:

  - vehicles      = trucks, starting from their current graph node
  - tasks         = one "slot" per truck, slots placed round-robin across the
                    facilities (so the slot pool itself is evenly spread)
  - capacity 1    = each truck serves exactly one slot
  - min_vehicles  = fleet size, so every truck is placed
  - cost          = travel time on the road graph

cuOpt then matches each truck to its nearest free slot, yielding an even,
proximity-aware assignment. The chosen slot's facility is the recommendation.
"""
from __future__ import annotations

from .vrp import parse_solution


def _served_slot(vehicle_data) -> int | None:
    """The slot (task index) a vehicle served. cuOpt tags depot stops as 'Depot'
    in `task_id`; the remaining numeric entry is the served task."""
    tid = vehicle_data.get("task_id")
    if isinstance(tid, list):
        for t in tid:
            s = str(t)
            if s.lstrip("-").isdigit():
                return int(s)
    return None


def balance_by_type(client, graph, vehicle_nodes, facilities) -> list | None:
    """Assign each vehicle to one facility of a single type via cuOpt.

    vehicle_nodes : list[int]   graph node each truck starts from
    facilities    : list[dict]  [{"n":.., "node":..}, ...] of ONE type
    returns       : list        facility "n" chosen per vehicle (len == vehicles),
                                 or None on failure (caller uses a local fallback).
    """
    V = len(vehicle_nodes)
    F = len(facilities)
    if V == 0 or F == 0:
        return None
    slot_fac = [i % F for i in range(V)]                       # slot -> facility index
    task_locations = [facilities[slot_fac[i]]["node"] for i in range(V)]
    if any(n is None for n in vehicle_nodes) or any(n is None for n in task_locations):
        return None

    payload = {
        "cost_waypoint_graph_data": {"waypoint_graph": {
            0: {"offsets": graph.offsets, "edges": graph.edges, "weights": graph.weights}}},
        "fleet_data": {
            "vehicle_locations": [[n, n] for n in vehicle_nodes],
            "capacities": [[1] * V],
            "min_vehicles": V,
        },
        "task_data": {
            "task_locations": task_locations,
            "demand": [[1] * V],
        },
        "solver_config": {"time_limit": 1.0},
    }
    try:
        sol = parse_solution(client.get_optimized_routes(payload))
    except Exception:
        return None
    vd = sol.get("vehicle_data") or {}
    if not vd:
        return None

    out = [None] * V
    for vid, data in vd.items():
        try:
            vi = int(vid)
        except (TypeError, ValueError):
            continue
        slot = _served_slot(data)
        if slot is not None and 0 <= slot < V and 0 <= vi < V:
            out[vi] = facilities[slot_fac[slot]]["n"]
    # never return a partial plan — fill gaps round-robin so every truck has a target
    for i in range(V):
        if out[i] is None:
            out[i] = facilities[i % F]["n"]
    return out
  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
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
'use client';
import * as React from 'react';
import {useEffect, useRef, useState} from 'react';
import Box from '@mui/material/Box';
import FullscreenIcon from '@mui/icons-material/Fullscreen';
import FullscreenExitIcon from '@mui/icons-material/FullscreenExit';
import images from '@/app/components/images';

// 3D "decision space": a slowly turning sphere whose surface holds the quarry's
// trucks / excavators / crushers / gas stations, grouped by type and wired like neurons.
// cuOpt's committed route (1st derivative) lights an arc with a travelling impulse; the
// next planned route (2nd derivative) runs a dashed arc through the 1st target to the 2nd.
// Facilities glow with an arc in the colour of whoever is inbound. See
// doc/3d-decision-space.md.
//
// Vanilla three.js in an imperative loop (matches mapItem's style); three is dynamically
// imported so it never touches SSR or the main bundle, and the canvas only spins up while
// the tab is active. Live geometry is read from the map global `window._data.data`.

// truck colour (0..1 rgb) from the shared TRUCKS palette, keyed by truck number
function rgbOf(n: number): [number, number, number] {
  const c = images.colors[n % images.colors.length] || 'rgba(150,150,150,1)';
  const m = c.match(/(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
  if (!m) return [0.6, 0.6, 0.6];
  return [(+m[1]) / 255, (+m[2]) / 255, (+m[3]) / 255];
}
function rgbCss(n: number): string {
  const [r, g, b] = rgbOf(n);
  return `rgb(${Math.round(r * 255)},${Math.round(g * 255)},${Math.round(b * 255)})`;
}

const R = 1;                                            // sphere radius
const ICON_H = 0.085;                                   // uniform icon HEIGHT (width follows aspect)
// trucks own the front hemisphere; the four facility groups sit on the BACK hemisphere,
// evenly spaced 90° apart (top/right/left/bottom) so each group has a clear region.
const CENTERS: Record<string, number[]> = {
  truck:       [0.0, 0.12, 1.0],
  excavator:   [0.0, 0.766, -0.643],
  gas:         [0.766, 0.0, -0.643],
  crusher:     [-0.766, 0.0, -0.643],
  maintenance: [0.0, -0.766, -0.643],
};
const FAC_COLOR = 0x9aa8b4;                             // neutral facility hue (until inbound)
const LABEL_FAC = 'rgba(214,226,238,0.95)';

export default function Cuopt3DViz({active}: {active: boolean}) {
  const wrapRef = useRef<HTMLDivElement>(null);
  const [through, setThrough] = useState(false);       // links: surface arcs vs straight "through"
  const [hidden, setHidden] = useState<Record<number, boolean>>({});
  const [trucksList, setTrucksList] = useState<{n: number; color: string}[]>([]);
  const [fs, setFs] = useState(false);                 // fullscreen
  const ctrlRef = useRef<{through: boolean; hidden: Record<number, boolean>}>({through: false, hidden: {}});

  useEffect(() => {
    const onFs = () => setFs(!!document.fullscreenElement);
    document.addEventListener('fullscreenchange', onFs);
    return () => document.removeEventListener('fullscreenchange', onFs);
  }, []);
  const toggleFs = () => {
    const el = wrapRef.current;
    if (!el) return;
    if (document.fullscreenElement) document.exitFullscreen?.();
    else el.requestFullscreen?.();
  };

  // keep the imperative loop's control mirror in sync with React state (no renderer rebuild)
  useEffect(() => { ctrlRef.current.through = through; ctrlRef.current.hidden = hidden; }, [through, hidden]);

  // poll the truck roster for the left-hand on/off menu
  useEffect(() => {
    if (!active) return;
    const id = setInterval(() => {
      const d = (window as any)?._data?.data;
      const ts = (d?.truck || []).filter((t: any) => !t._maint)   // hide trucks in maintenance
        .map((t: any) => ({n: t.n, color: rgbCss(t.n)}))
        .sort((a: any, b: any) => a.n - b.n);            // menu top→down: 1, 2, 3 …
      setTrucksList((prev) =>
        prev.length === ts.length && prev.every((p, i) => p.n === ts[i].n) ? prev : ts);
    }, 1000);
    return () => clearInterval(id);
  }, [active]);

  useEffect(() => {
    if (!active) return;
    const wrap = wrapRef.current;
    if (!wrap) return;

    let raf = 0;
    let disposed = false;
    let cleanup = () => {};

    (async () => {
      const THREE: any = await import('three');
      if (disposed || !wrapRef.current) return;
      const host = wrapRef.current;
      const zAxis = new THREE.Vector3(0, 0, 1);

      // ---- renderer / scene / camera ----
      const renderer = new THREE.WebGLRenderer({antialias: true, alpha: true});
      renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
      renderer.setClearColor(0x060a12, 1);
      host.appendChild(renderer.domElement);
      renderer.domElement.style.width = '100%';
      renderer.domElement.style.height = '100%';
      renderer.domElement.style.display = 'block';
      renderer.domElement.style.cursor = 'grab';

      const scene = new THREE.Scene();
      const camera = new THREE.PerspectiveCamera(50, 1, 0.01, 100);
      let dist = R * 3.0;
      camera.position.set(0, 0, dist);
      camera.lookAt(0, 0, 0);

      const world = new THREE.Group();
      scene.add(world);

      // ghost sphere coating: a translucent shell whose alpha fades with depth (per-vertex,
      // updated each frame) — densest at the point nearest the camera, vanishing toward the
      // silhouette, so it reads as a 3D volume rather than a flat disk. Keeps depth occlusion
      // so back arcs sit "behind" it. No wireframe/contour lines.
      const coreGeo = new THREE.SphereGeometry(R * 0.99, 48, 32);
      const cn0 = coreGeo.attributes.position.count;
      const ccols = new Float32Array(cn0 * 4);
      for (let i = 0; i < cn0; i++) { ccols[i * 4] = 0.541; ccols[i * 4 + 1] = 0.627; ccols[i * 4 + 2] = 0.714; ccols[i * 4 + 3] = 0.05; }
      coreGeo.setAttribute('color', new THREE.Float32BufferAttribute(ccols, 4));
      const core = new THREE.Mesh(coreGeo, new THREE.MeshBasicMaterial(
        {vertexColors: true, transparent: true, opacity: 1, depthWrite: true}));
      world.add(core);

      const nodesG = new THREE.Group();      // entity icons (shared textures)
      const labelsG = new THREE.Group();     // numbered labels (own textures)
      const inactiveG = new THREE.Group();   // faint "option cloud" links
      const dyn = new THREE.Group();         // lit routes / impulses
      world.add(inactiveG, nodesG, labelsG, dyn);

      // ---- shared resources ----
      const loader = new THREE.TextureLoader();
      loader.setCrossOrigin('anonymous');
      const iconTex: Record<string, any> = {};
      const iconAspect: Record<string, number> = {truck: 1, excavator: 1, crusher: 1, gas: 1, maintenance: 1};
      let texLoaded = 0;
      for (const ty of ['truck', 'excavator', 'crusher', 'gas', 'maintenance'])
        iconTex[ty] = loader.load((images as any)[ty], (t: any) => {
          t.colorSpace = THREE.SRGBColorSpace;
          if (t.image) iconAspect[ty] = (t.image.width || 1) / (t.image.height || 1);
          texLoaded++;
        });
      const discCanvas = document.createElement('canvas');
      discCanvas.width = discCanvas.height = 64;
      const dctx = discCanvas.getContext('2d')!;
      const grd = dctx.createRadialGradient(32, 32, 0, 32, 32, 32);
      grd.addColorStop(0, 'rgba(255,255,255,1)');
      grd.addColorStop(0.45, 'rgba(255,255,255,0.45)');
      grd.addColorStop(1, 'rgba(255,255,255,0)');
      dctx.fillStyle = grd; dctx.fillRect(0, 0, 64, 64);
      const discTex = new THREE.CanvasTexture(discCanvas);

      const dotGeo = new THREE.SphereGeometry(0.009, 8, 8);    // ~30% smaller graph dots
      const impGeo = new THREE.SphereGeometry(0.0126, 12, 12); // travelling signal — 40% smaller again

      // ---- math helpers ----
      // ring placement: members sit evenly on a circle around the type's centre direction,
      // numbered in order around the ring; ring radius grows with count so the spacing
      // between neighbours stays constant (≈ SEP) regardless of how many are in the group.
      const SEP = 0.3;
      const nodePos = (type: string, i: number, count: number) => {
        const c = new THREE.Vector3(...(CENTERS[type] || [0, 0, 1])).normalize();
        if (count <= 1) return c.clone().multiplyScalar(R);
        const up = Math.abs(c.y) > 0.9 ? new THREE.Vector3(1, 0, 0) : new THREE.Vector3(0, 1, 0);
        const u = new THREE.Vector3().crossVectors(up, c).normalize();  // tangent "right"
        const v = new THREE.Vector3().crossVectors(c, u).normalize();   // tangent "up"
        const ringR = Math.min(0.6, SEP / (2 * Math.sin(Math.PI / count)));
        const phi = Math.PI - i * (2 * Math.PI / count);               // start left, go in order
        return c.clone().multiplyScalar(Math.cos(ringR))
          .add(u.clone().multiplyScalar(Math.sin(ringR) * Math.cos(phi)))
          .add(v.clone().multiplyScalar(Math.sin(ringR) * Math.sin(phi)))
          .multiplyScalar(R);
      };
      const arcPoints = (p1: any, p2: any, segs: number, lift = 1.02) => {
        const a = p1.clone().normalize(), b = p2.clone().normalize();
        const omega = Math.acos(THREE.MathUtils.clamp(a.dot(b), -1, 1));
        const out: any[] = [];
        if (omega < 1e-4) return [p1.clone().multiplyScalar(lift), p2.clone().multiplyScalar(lift)];
        const sin = Math.sin(omega);
        for (let k = 0; k <= segs; k++) {
          const t = k / segs;
          const s1 = Math.sin((1 - t) * omega) / sin, s2 = Math.sin(t * omega) / sin;
          out.push(a.clone().multiplyScalar(s1).add(b.clone().multiplyScalar(s2)).multiplyScalar(R * lift));
        }
        return out;
      };
      // surface arc OR straight chord "through" the sphere, per the toggle
      const linePoints = (p1: any, p2: any, segs: number, lift = 1.02) => {
        if (ctrlRef.current.through) {
          const out: any[] = [];
          for (let k = 0; k <= segs; k++) out.push(p1.clone().lerp(p2.clone(), k / segs));
          return out;
        }
        return arcPoints(p1, p2, segs, lift);
      };

      const nodeMap = new Map<string, any>();
      let activeFac = new Set<string>();     // facilities targeted by a visible truck (r1/r2)
      let inactiveLine: any = null;          // the option-cloud lines (per-vertex depth fade)
      const fadeMat = new THREE.Matrix4();
      const impulses: any[] = [];
      // impulse phase per truck, kept ACROSS rebuilds so the signal glides continuously:
      // the dynamic group is rebuilt whenever a truck clears a graph waypoint (its remaining
      // count changes), and recreating the impulse from a fixed phase would blink/restart it.
      const phaseByTruck = new Map<number, number>();
      const pulseRings: any[] = [];
      const glowLines: any[] = [];

      // dispose children WITHOUT touching shared textures (icon/disc) on the materials
      const clearShared = (g: any) => {
        for (const c of [...g.children]) {
          g.remove(c);
          c.geometry?.dispose?.();
          const mm = c.material;
          (Array.isArray(mm) ? mm : [mm]).forEach((m: any) => m?.dispose?.());
        }
      };
      // dispose children AND their (unique) textures — for the label group
      const clearOwned = (g: any) => {
        for (const c of [...g.children]) {
          g.remove(c);
          c.geometry?.dispose?.();
          const mm = c.material;
          (Array.isArray(mm) ? mm : [mm]).forEach((m: any) => { m?.map?.dispose?.(); m?.dispose?.(); });
        }
      };

      const isHidden = (n: number) => !!ctrlRef.current.hidden[n];
      // exclude out-of-service facilities so the 3D node list matches what cuOpt can plan with
      const getData = () => {
        const d = (window as any)?._data?.data || {};
        const out: any = {...d};
        for (const t of ['excavator', 'crusher', 'gas', 'maintenance'])
          if (Array.isArray(out[t])) out[t] = out[t].filter((o: any) => !o._oos);
        if (Array.isArray(out.truck)) out.truck = out.truck.filter((o: any) => !o._maint);  // hide trucks in maintenance
        return out;
      };
      const entityKey = (d: any) =>
        ['truck', 'excavator', 'crusher', 'gas', 'maintenance'].map((t) => (d[t] || []).map((o: any) => o.n).join(',')).join('|');
      const styleKey = () => (ctrlRef.current.through ? 'T' : 'S') + '|' +
        Object.keys(ctrlRef.current.hidden).filter((k) => ctrlRef.current.hidden[+k]).sort().join(',');
      const signature = (d: any) => (d.truck || []).map((t: any) =>
        `${t.n}:${t.route?.type || ''}${t.route?.n ?? ''}/${t.route?.points?.length || 0}` +
        `>${t.route2?.type || ''}${t.route2?.n ?? ''}/${t.route2?.points?.length || 0}`).join(';');

      const makeLabel = (text: string, css: string) => {
        const c = document.createElement('canvas');
        c.width = 128; c.height = 64;                 // 2:1 — matches the sprite scale (no squish)
        const g = c.getContext('2d')!;
        g.font = 'bold 40px Arial'; g.textAlign = 'center'; g.textBaseline = 'middle';
        g.lineWidth = 6; g.strokeStyle = 'rgba(0,0,0,0.78)'; g.strokeText(text, 64, 34);
        g.fillStyle = css; g.fillText(text, 64, 34);
        const tex = new THREE.CanvasTexture(c); tex.colorSpace = THREE.SRGBColorSpace;
        const sp = new THREE.Sprite(new THREE.SpriteMaterial(
          {map: tex, transparent: true, depthWrite: false, depthTest: false}));
        sp.scale.set(0.12, 0.06, 1);                 // ~1.5× smaller font
        sp.center.set(0.5, 1.3);                     // hang BELOW the icon (icon over label, 2 lines)
        sp.renderOrder = 30;                         // always draw labels last → never clipped by icons
        sp.userData.baseOpacity = 1;
        return sp;
      };

      // ---- labels (rebuilt only on entity change) ----
      const rebuildLabels = (d: any) => {
        clearOwned(labelsG);
        for (const ty of ['truck', 'excavator', 'crusher', 'gas', 'maintenance']) {
          const arr = d[ty] || [];
          arr.forEach((o: any, i: number) => {
            const pos = nodePos(ty, i, arr.length);
            const css = ty === 'truck' ? rgbCss(o.n) : LABEL_FAC;
            const lab = makeLabel(`${ty[0].toUpperCase()}${o.n}`, css);   // T1, E1, C1, G1
            lab.position.copy(pos.clone().multiplyScalar(1.06));          // same radius as icon
            lab.userData.dimN = ty === 'truck' ? o.n : undefined;        // dim with its truck when off
            lab.userData.facKey = ty !== 'truck' ? `${ty}:${o.n}` : undefined; // show only if in play
            labelsG.add(lab);
          });
        }
      };

      // ---- nodes + option-cloud links (rebuilt on entity / style / hidden change) ----
      const rebuildStatic = (d: any) => {
        clearShared(nodesG);
        clearShared(inactiveG);
        nodeMap.clear();
        for (const ty of ['truck', 'excavator', 'crusher', 'gas', 'maintenance']) {
          const arr = d[ty] || [];
          arr.forEach((o: any, i: number) => {
            const pos = nodePos(ty, i, arr.length);
            nodeMap.set(`${ty}:${o.n}`, pos);
            const isTruck = ty === 'truck';
            const off = isTruck && isHidden(o.n);
            const col = isTruck ? new THREE.Color(...rgbOf(o.n)) : new THREE.Color(FAC_COLOR);
            // depthTest off so icons are never clipped by the sphere/links — they always
            // show; the per-frame depth fade (below) dims back-hemisphere nodes instead.
            const haloBase = off ? 0.12 : (isTruck ? 0.62 : 0.27);
            const halo = new THREE.Sprite(new THREE.SpriteMaterial(
              {map: discTex, color: col, transparent: true, opacity: haloBase,
               blending: THREE.AdditiveBlending, depthWrite: false, depthTest: false}));
            halo.position.copy(pos.clone().multiplyScalar(1.05));
            halo.scale.setScalar(ICON_H * 1.45);
            halo.userData.baseOpacity = haloBase;
            nodesG.add(halo);
            const iconBase = off ? 0.2 : 1;
            const asp = iconAspect[ty] || 1;             // uniform HEIGHT, width follows the PNG aspect
            const spr = new THREE.Sprite(new THREE.SpriteMaterial(
              {map: iconTex[ty], transparent: true, opacity: iconBase, depthWrite: false, depthTest: false}));
            spr.position.copy(pos.clone().multiplyScalar(1.06));
            spr.scale.set(ICON_H * asp, ICON_H, 1);
            spr.userData.baseOpacity = iconBase;
            nodesG.add(spr);
          });
        }
        // faint truck->facility links = the cloud of options (skip switched-off trucks)
        const facs = [
          ...(d.excavator || []).map((o: any) => `excavator:${o.n}`),
          ...(d.crusher || []).map((o: any) => `crusher:${o.n}`),
          ...(d.gas || []).map((o: any) => `gas:${o.n}`),
          ...(d.maintenance || []).map((o: any) => `maintenance:${o.n}`),
        ];
        const verts: number[] = [];
        for (const t of (d.truck || [])) {
          if (isHidden(t.n)) continue;
          const tp = nodeMap.get(`truck:${t.n}`);
          if (!tp) continue;
          for (const fk of facs) {
            const fp = nodeMap.get(fk);
            if (!fp) continue;
            const pts = linePoints(tp, fp, 16, 1.0);
            for (let k = 0; k < pts.length - 1; k++)
              verts.push(pts[k].x, pts[k].y, pts[k].z, pts[k + 1].x, pts[k + 1].y, pts[k + 1].z);
          }
        }
        const geo = new THREE.BufferGeometry();
        geo.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3));
        // per-vertex RGBA so the option-cloud lines fade with depth (near = denser, far =
        // more transparent) for a 3D feel — alpha is recomputed each frame in updateFade()
        const vn = verts.length / 3;
        const cols = new Float32Array(vn * 4);
        for (let i = 0; i < vn; i++) { cols[i * 4] = 0.557; cols[i * 4 + 1] = 0.651; cols[i * 4 + 2] = 0.729; cols[i * 4 + 3] = 0.17; }
        geo.setAttribute('color', new THREE.Float32BufferAttribute(cols, 4));
        inactiveLine = new THREE.LineSegments(geo, new THREE.LineBasicMaterial(
          {vertexColors: true, transparent: true, opacity: 1, depthWrite: false,
           depthTest: !ctrlRef.current.through}));
        inactiveG.add(inactiveLine);
      };

      // ---- lit routes (rebuilt on route change / style change) ----
      const rebuildDynamic = (d: any) => {
        clearShared(dyn);
        impulses.length = 0; pulseRings.length = 0; glowLines.length = 0;
        const inbound = new Map<string, Map<number, any>>();   // facility -> {truckN: colour}
        const addInbound = (fk: string, n: number, col: any) => {
          let m = inbound.get(fk);
          if (!m) { m = new Map(); inbound.set(fk, m); }
          m.set(n, col);
        };
        const dtest = !ctrlRef.current.through;

        for (const t of (d.truck || [])) {
          if (isHidden(t.n)) continue;
          const tp = nodeMap.get(`truck:${t.n}`);
          if (!tp) continue;
          const col = new THREE.Color(...rgbOf(t.n));

          // 1st derivative: committed route r1 — lit arc + waypoint dots + impulse
          if (t.route?.type && t.route?.n != null) {
            const fk = `${t.route.type}:${t.route.n}`;
            const fp = nodeMap.get(fk);
            if (fp) {
              const pts = linePoints(tp, fp, 44, 1.02);
              const line = new THREE.Line(
                new THREE.BufferGeometry().setFromPoints(pts),
                new THREE.LineBasicMaterial({color: col, transparent: true, opacity: 0.92,
                  blending: THREE.AdditiveBlending, depthWrite: false, depthTest: dtest}));
              dyn.add(line); glowLines.push(line);
              const show = Math.min(Math.max(0, t.route.points?.length || 0), 16);
              for (let k = 1; k <= show; k++) {
                const idx = Math.round((k / (show + 1)) * (pts.length - 1));
                const dot = new THREE.Mesh(dotGeo, new THREE.MeshBasicMaterial(
                  {color: col, transparent: true, opacity: 0.95, depthTest: dtest}));
                dot.position.copy(pts[idx]); dyn.add(dot);
              }
              const marker = new THREE.Mesh(impGeo, new THREE.MeshBasicMaterial(
                {color: col, transparent: true, opacity: 1, blending: THREE.AdditiveBlending,
                 depthWrite: false, depthTest: dtest}));
              dyn.add(marker);
              impulses.push({n: t.n, points: pts, marker, speed: 0.18,
                phase: phaseByTruck.has(t.n) ? (phaseByTruck.get(t.n) as number) : (t.n % 7) / 7});
              addInbound(fk, t.n, col);
            }
          }

          // 2nd derivative: next planned route r2 — dashed arc truck -> r1 node -> r2 node
          if (t.route2?.type && t.route2?.n != null) {
            const f1 = t.route?.type ? nodeMap.get(`${t.route.type}:${t.route.n}`) : null;
            const f2 = nodeMap.get(`${t.route2.type}:${t.route2.n}`);
            if (f2) {
              // dashed forecast runs next route (r1) -> next recommended route (r2); the
              // truck->r1 leg is already the solid line, so we don't repeat it as a dash.
              const pts = f1
                ? linePoints(f1, f2, 40, 1.06)
                : linePoints(tp, f2, 44, 1.06);
              const line = new THREE.Line(
                new THREE.BufferGeometry().setFromPoints(pts),
                new THREE.LineDashedMaterial({color: col, transparent: true, opacity: 0.72,
                  dashSize: 0.05, gapSize: 0.045, blending: THREE.AdditiveBlending,
                  depthWrite: false, depthTest: dtest}));
              line.computeLineDistances();
              dyn.add(line); glowLines.push(line);
              const fk2 = `${t.route2.type}:${t.route2.n}`;
              addInbound(fk2, t.n, col);
            }
          }
        }

        activeFac = new Set(Array.from(inbound.keys()));   // drives which facility labels stay lit
        // highlight ring per facility, split into coloured ARC segments (same radius, with
        // gaps) — one arc per inbound truck — so the segment count shows how many trucks are
        // headed there. One inbound truck -> a full ring; two/three -> two/three pulsing arcs.
        inbound.forEach((m: Map<number, any>, fk: string) => {
          const fp = nodeMap.get(fk);
          if (!fp) return;
          const cols = Array.from(m.values());
          const N = cols.length;
          const pos = fp.clone().multiplyScalar(1.05);
          const quat = new THREE.Quaternion().setFromUnitVectors(zAxis, fp.clone().normalize());
          const gap = N > 1 ? 0.34 : 0;                   // angular gap between segments (rad)
          const span = (Math.PI * 2) / N;
          for (let i = 0; i < N; i++) {
            const geo = new THREE.RingGeometry(0.058, 0.094, 18, 1, i * span + gap / 2, span - gap);
            const ring = new THREE.Mesh(geo, new THREE.MeshBasicMaterial(
              {color: cols[i], transparent: true, opacity: 0.66, side: THREE.DoubleSide,
               blending: THREE.AdditiveBlending, depthWrite: false}));
            ring.position.copy(pos);
            ring.quaternion.copy(quat);
            dyn.add(ring); pulseRings.push(ring);
          }
        });
      };

      // ---- size / resize ----
      const resize = () => {
        const w = host.clientWidth || 1, h = host.clientHeight || 1;
        renderer.setSize(w, h, false);
        camera.aspect = w / h;
        camera.updateProjectionMatrix();
      };
      const ro = new ResizeObserver(resize);
      ro.observe(host);
      resize();

      // ---- interaction ----
      const spin = {x: 0, y: 0};
      let dragging = false, px = 0, py = 0;
      let autoPauseLeft = 0;   // seconds: after the user rotates, hold off auto-spin (5 min)
      const AUTO_PAUSE = 300;
      const dom = renderer.domElement;
      const onDown = (e: PointerEvent) => {
        dragging = true; px = e.clientX; py = e.clientY; dom.style.cursor = 'grabbing';
        autoPauseLeft = AUTO_PAUSE;
        try { dom.setPointerCapture(e.pointerId); } catch {}
      };
      const onMove = (e: PointerEvent) => {
        if (!dragging) return;
        const dx = (e.clientX - px) * 0.008, dy = (e.clientY - py) * 0.008;
        px = e.clientX; py = e.clientY;
        world.rotation.y += dx; world.rotation.x += dy; spin.x = dy; spin.y = dx;
        world.rotation.x = THREE.MathUtils.clamp(world.rotation.x, -1.2, 1.2);
        autoPauseLeft = AUTO_PAUSE;   // keep resetting while the user is interacting
      };
      const onUp = () => { dragging = false; dom.style.cursor = 'grab'; };
      const onWheel = (e: WheelEvent) => {
        e.preventDefault();
        dist = THREE.MathUtils.clamp(dist * (1 + Math.sign(e.deltaY) * 0.08), R * 1.6, R * 6);
        camera.position.z = dist;
      };
      dom.addEventListener('pointerdown', onDown);
      dom.addEventListener('pointermove', onMove);
      window.addEventListener('pointerup', onUp);
      dom.addEventListener('wheel', onWheel, {passive: false});

      // back-hemisphere depth fade: a node on the far side of the (rotating) sphere reads
      // at 50% so it's clearly "further away", but it always stays visible (never clipped).
      const fadeTmp = new THREE.Vector3();
      const applyDepthFade = (g: any) => {
        for (const c of g.children) {
          if (!c.material) continue;
          fadeTmp.copy(c.position).applyQuaternion(world.quaternion);   // world-space z
          const t = THREE.MathUtils.clamp((fadeTmp.z + 0.2) / 0.4, 0, 1);  // back→0, front→1
          let base = c.userData.baseOpacity ?? 1;
          // a switched-off truck's label dims to match its icon (0.2) — it's not of interest now
          if (c.userData.dimN != null && ctrlRef.current.hidden[c.userData.dimN]) base *= 0.2;
          // facility labels stay lit only while a visible truck is routing to them (in play)
          if (c.userData.facKey != null && !activeFac.has(c.userData.facKey)) base *= 0.15;
          c.material.opacity = base * (0.5 + 0.5 * t);
        }
      };

      // option-cloud lines fade by depth each frame: alpha high near the camera, low far
      // away (computed from each vertex's world-space z), giving the flat lines a 3D feel.
      const updateInactiveFade = () => {
        if (!inactiveLine) return;
        const g = inactiveLine.geometry;
        const pos = g.attributes.position?.array;
        const colAttr = g.attributes.color;
        if (!pos || !colAttr) return;
        const col = colAttr.array;
        fadeMat.makeRotationFromQuaternion(world.quaternion);
        const e = fadeMat.elements, m2 = e[2], m6 = e[6], m10 = e[10];   // world-z row
        const vn = pos.length / 3;
        for (let i = 0; i < vn; i++) {
          const wz = m2 * pos[i * 3] + m6 * pos[i * 3 + 1] + m10 * pos[i * 3 + 2];
          const t = THREE.MathUtils.clamp((wz + R) / (2 * R), 0, 1);     // far→0, near→1
          col[i * 4 + 3] = 0.04 + 0.26 * t;
        }
        colAttr.needsUpdate = true;
      };

      // ghost-sphere depth fade: alpha peaks at the vertex nearest the camera and falls to
      // 0 toward the silhouette/back, so the shell looks volumetric, not flat.
      const updateCoreFade = () => {
        const g = core.geometry;
        const pos = g.attributes.position.array;
        const colAttr = g.attributes.color, col = colAttr.array;
        fadeMat.makeRotationFromQuaternion(world.quaternion);
        const e = fadeMat.elements, m2 = e[2], m6 = e[6], m10 = e[10];
        const Rr = R * 0.99, cn = pos.length / 3;
        for (let i = 0; i < cn; i++) {
          const wz = m2 * pos[i * 3] + m6 * pos[i * 3 + 1] + m10 * pos[i * 3 + 2];
          const t = THREE.MathUtils.clamp(wz / Rr, 0, 1);     // near pole→1, rim/back→0
          col[i * 4 + 3] = 0.12 * t * t;                      // ~95% transparent, depth-faded
        }
        colAttr.needsUpdate = true;
      };

      // ---- loop ----
      let lastEntity = '', lastStyle = '', lastSig = '', lastRead = 0, aspectApplied = false, last = performance.now();
      const tick = () => {
        raf = requestAnimationFrame(tick);
        const now = performance.now();
        const dt = Math.min(0.05, (now - last) / 1000); last = now;

        if (now - lastRead > 250) {
          lastRead = now;
          const d = getData();
          if (d.truck) {
            // once all icon PNGs are loaded, their real aspect ratios are known — force a
            // one-time rebuild so icon widths match (uniform height, correct proportions)
            if (texLoaded >= 4 && !aspectApplied) { aspectApplied = true; lastEntity = ''; }
            const ek = entityKey(d);
            if (ek !== lastEntity) { lastEntity = ek; lastStyle = ''; lastSig = ''; rebuildLabels(d); }
            const sk = styleKey();
            if (sk !== lastStyle) { lastStyle = sk; lastSig = ''; rebuildStatic(d); }
            const sg = signature(d);
            if (sg !== lastSig) { lastSig = sg; rebuildDynamic(d); }
          }
        }

        if (autoPauseLeft > 0) autoPauseLeft = Math.max(0, autoPauseLeft - dt);   // pause auto-spin after a manual rotate
        world.rotation.y += ((dragging || autoPauseLeft > 0) ? 0 : dt * 0.0265) + spin.y;  // auto-spin (2× slower again)
        world.rotation.x = THREE.MathUtils.clamp(world.rotation.x + spin.x, -1.2, 1.2);
        spin.x *= 0.9; spin.y *= 0.9;

        for (const im of impulses) {
          im.phase = (im.phase + dt * im.speed) % 1;
          phaseByTruck.set(im.n, im.phase);            // persist so rebuilds don't restart it
          // smooth travel: interpolate between graph points instead of snapping to one,
          // so the signal glides continuously from the truck all the way to the target
          const f = im.phase * (im.points.length - 1);
          const i0 = Math.floor(f), i1 = Math.min(im.points.length - 1, i0 + 1);
          im.marker.position.copy(im.points[i0]).lerp(im.points[i1], f - i0);
        }
        const pulse = 0.5 + 0.5 * Math.sin(now * 0.004);
        for (const r of pulseRings) { r.material.opacity = 0.32 + 0.5 * pulse; const s = 1 + 0.14 * pulse; r.scale.set(s, s, s); }
        for (const g of glowLines) g.material.opacity = (g.material.dashSize ? 0.5 : 0.6) + 0.35 * pulse;
        applyDepthFade(nodesG);
        applyDepthFade(labelsG);
        updateInactiveFade();
        updateCoreFade();

        renderer.render(scene, camera);
      };
      tick();

      cleanup = () => {
        cancelAnimationFrame(raf);
        ro.disconnect();
        dom.removeEventListener('pointerdown', onDown);
        dom.removeEventListener('pointermove', onMove);
        window.removeEventListener('pointerup', onUp);
        dom.removeEventListener('wheel', onWheel);
        clearOwned(labelsG); clearShared(dyn); clearShared(nodesG); clearShared(inactiveG);
        scene.traverse((o: any) => { o.geometry?.dispose?.(); const m = o.material; (Array.isArray(m) ? m : [m]).forEach((x: any) => x?.dispose?.()); });
        [dotGeo, impGeo].forEach((g: any) => g.dispose?.());
        Object.values(iconTex).forEach((t: any) => t.dispose?.());
        discTex.dispose();
        renderer.dispose();
        if (dom.parentNode === host) host.removeChild(dom);
      };
    })();

    return () => { disposed = true; cancelAnimationFrame(raf); cleanup(); };
  }, [active]);

  const toggleTruck = (n: number) => setHidden((h) => ({...h, [n]: !h[n]}));

  return (
    <Box ref={wrapRef} sx={{
      position: 'relative', width: '100%', height: 'min(86vh, 1400px)', minHeight: 360,
      borderRadius: 2, overflow: 'hidden',
      background: 'radial-gradient(circle at 50% 40%, #0d1726 0%, #060a12 70%)',
      border: '1px solid #11202f',
    }}>
      {/* top-left: TRUCKS on/off menu */}
      <Box onPointerDown={(e) => e.stopPropagation()} onWheel={(e) => e.stopPropagation()} sx={{
        position: 'absolute', left: 10, top: 10, zIndex: 3, maxHeight: '78%', overflowY: 'auto',
        background: 'rgba(8,16,26,0.72)', border: '1px solid rgba(120,150,175,0.25)', borderRadius: 1.5,
        px: 1, py: 0.75, minWidth: 96, backdropFilter: 'blur(2px)',
      }}>
        <Box sx={{fontSize: 11, fontWeight: 800, letterSpacing: 1, color: '#cdd9e4', mb: 0.5}}>TRUCKS</Box>
        {trucksList.length === 0 && <Box sx={{fontSize: 11, color: '#7d8a96'}}></Box>}
        {trucksList.map((t) => {
          const off = !!hidden[t.n];
          return (
            <Box key={t.n} onClick={() => toggleTruck(t.n)} sx={{
              display: 'flex', alignItems: 'center', gap: 0.75, py: 0.3, px: 0.3, borderRadius: 1,
              cursor: 'pointer', userSelect: 'none', opacity: off ? 0.4 : 1,
              '&:hover': {background: 'rgba(255,255,255,0.06)'},
            }}>
              <Box sx={{width: 11, height: 11, borderRadius: '50%', flexShrink: 0,
                background: off ? 'transparent' : t.color, border: `2px solid ${t.color}`}} />
              <Box sx={{fontSize: 11.5, fontWeight: 600, color: '#dde6ee',
                textDecoration: off ? 'line-through' : 'none'}}>TRUCK {t.n}</Box>
            </Box>
          );
        })}
      </Box>

      {/* top-right: link-style toggle + fullscreen */}
      <Box onPointerDown={(e) => e.stopPropagation()} sx={{
        position: 'absolute', right: 10, top: 10, zIndex: 3, display: 'flex', alignItems: 'center', gap: 0.5,
        background: 'rgba(8,16,26,0.72)', border: '1px solid rgba(120,150,175,0.25)', borderRadius: 999, p: '3px',
      }}>
        {[{k: false, l: 'Surface'}, {k: true, l: 'Through'}].map((o) => (
          <Box key={o.l} onClick={() => setThrough(o.k)} sx={{
            px: 1.25, py: 0.4, borderRadius: 999, fontSize: 11, fontWeight: 700, cursor: 'pointer',
            color: through === o.k ? '#0b0f0a' : '#aebccb',
            background: through === o.k ? '#76b900' : 'transparent',
          }}>{o.l}</Box>
        ))}
        <Box sx={{width: '1px', height: 18, background: 'rgba(150,170,190,0.3)', mx: 0.25}} />
        <Box onClick={toggleFs} title={fs ? 'Exit fullscreen' : 'Fullscreen'} sx={{
          display: 'flex', alignItems: 'center', justifyContent: 'center', width: 26, height: 24,
          borderRadius: 999, cursor: 'pointer', color: '#aebccb',
          '&:hover': {color: '#fff', background: 'rgba(255,255,255,0.08)'},
        }}>
          {fs ? <FullscreenExitIcon sx={{fontSize: 18}} /> : <FullscreenIcon sx={{fontSize: 18}} />}
        </Box>
      </Box>

      {/* bottom-left: controls hint (one line, a touch larger) + legend */}
      <Box sx={{
        position: 'absolute', left: 12, bottom: 10, zIndex: 2, pointerEvents: 'none',
      }}>
        <Box sx={{fontSize: 16.2, fontWeight: 600, color: 'rgba(206,220,232,0.9)', lineHeight: 1.5}}>
          🖱 Drag to rotate · scroll to zoom in / out
        </Box>
        <Box sx={{fontSize: 16.5, color: 'rgba(190,206,220,0.8)', lineHeight: 1.5}}>
          solid = recommended route · dashed = next route · dots = waypoints left
        </Box>
      </Box>
    </Box>
  );
}
  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
'use client';
import * as React from 'react';
import {useState} from 'react';
import Box from '@mui/material/Box';
import {NVIDIA_GREEN} from './config';
import {useCuopt} from './CuoptContext';
import images from '@/app/components/images';

// "Optimization parameters" tab — the full set of mining-dispatch signals the cuOpt decision
// model reasons over. Two views, switched by the Static / Live toggle:
//   Static — the parameter catalogue with units (what the model optimizes for).
//   Live   — the same parameters showing their current computed values, read from the live
//            cuOpt frame (averaged across each entity type). Needs the service LIVE.
// The keys below match the backend model parameter keys (cuopt_optimizer/models/*.py), so a
// frame's `entity.params[key]` resolves straight onto the row.

export type Param = {k: string; n: string; u: string};
export type Group = {key: string; title: string; sub: string; icon: string; accent: string; frameKey: string; params: Param[]};

export const GROUPS: Group[] = [
  {
    key: 'truck', title: 'Haul Trucks', sub: 'fleet', icon: images.truck, accent: '#0071ee', frameKey: 'trucks',
    params: [
      {k: 'fuel_level', n: 'Fuel level', u: '%'},
      {k: 'fuel_burn_rate', n: 'Fuel burn rate (load · grade)', u: 'l/h'},
      {k: 'payload_utilization', n: 'Payload / capacity utilization', u: '%'},
      {k: 'tyre_wear', n: 'Tyre & component wear — depreciation', u: '%/cycle'},
      {k: 'engine_hours', n: 'Engine hours / service-due window', u: 'h'},
      {k: 'cycle_time', n: 'Cycle time (load–haul–dump–return)', u: 's'},
      {k: 'idle_time', n: 'Queue / idle time', u: 's'},
      {k: 'travel_speed', n: 'Travel speed', u: 'km/h'},
      {k: 'availability', n: 'Availability', u: '%'},
      {k: 'tyre_pressure', n: 'Tyre pressure', u: '%'},
      {k: 'hydraulic_pressure', n: 'Hydraulic system health', u: '%'},
      {k: 'oil_pressure', n: 'Engine oil pressure', u: '%'},
      {k: 'brake_wear', n: 'Brake wear', u: '%'},
      {k: 'health_score', n: 'Overall health score', u: '%'},
      {k: 'service_due', n: 'Hours to next service', u: 'h'},
    ],
  },
  {
    key: 'excavator', title: 'Excavators', sub: 'loading', icon: images.excavator, accent: NVIDIA_GREEN, frameKey: 'excavators',
    params: [
      {k: 'idle_time', n: 'Idle time', u: 's'},
      {k: 'utilization', n: 'Utilization / load', u: '%'},
      {k: 'loading_rate', n: 'Loading rate', u: 't/h'},
      {k: 'bucket_fill', n: 'Bucket fill factor', u: '%'},
      {k: 'queue_length', n: 'Truck queue length', u: 'trucks'},
      {k: 'wear', n: 'Wear — depreciation', u: '%'},
      {k: 'availability', n: 'Availability & maintenance window', u: '%'},
      {k: 'dig_face_position', n: 'Dig-face position', u: 'node'},
    ],
  },
  {
    key: 'crusher', title: 'Crushers', sub: 'processing', icon: images.crusher, accent: '#f5a623', frameKey: 'crushers',
    params: [
      {k: 'idle_time', n: 'Idle time', u: 's'},
      {k: 'feed_throughput', n: 'Feed / throughput', u: 't/h'},
      {k: 'buffer_level', n: 'Buffer / stockpile level', u: '%'},
      {k: 'capacity_utilization', n: 'Capacity utilization', u: '%'},
      {k: 'wear', n: 'Wear — depreciation', u: '%'},
      {k: 'downtime_risk', n: 'Downtime / blockage risk', u: '%'},
      {k: 'ore_grade', n: 'Ore grade & blending target', u: 'g/t'},
    ],
  },
  {
    key: 'gas', title: 'Gas / Refuel', sub: 'replenishment', icon: images.gas, accent: '#9c27b0', frameKey: 'gas',
    params: [
      {k: 'fuel_reserve', n: 'Available fuel reserve', u: 'l'},
      {k: 'refuel_throughput', n: 'Refuel throughput', u: 'l/min'},
      {k: 'pump_queue', n: 'Pump queue', u: 'trucks'},
      {k: 'detour_cost', n: 'Detour cost / proximity', u: 's'},
      {k: 'replenishment_schedule', n: 'Replenishment schedule', u: 'h'},
    ],
  },
  {
    key: 'maintenance', title: 'Maintenance Bays', sub: 'repair', icon: images.maintenance, accent: '#e8890b', frameKey: 'maintenance',
    params: [
      {k: 'service_capacity', n: 'Service bays available', u: 'bays'},
      {k: 'repair_throughput', n: 'Repair throughput', u: 'rep/h'},
      {k: 'bay_queue', n: 'Truck queue', u: 'trucks'},
      {k: 'detour_cost', n: 'Detour cost / proximity', u: 's'},
      {k: 'mean_repair_time', n: 'Mean repair time', u: 'min'},
      {k: 'bay_utilization', n: 'Bay utilization', u: '%'},
      {k: 'parts_inventory', n: 'Spare-parts inventory', u: '%'},
    ],
  },
  {
    key: 'network', title: 'Site & Network', sub: 'global', icon: images.point, accent: '#00bcd4', frameKey: 'network',
    params: [
      {k: 'total_travel_time', n: 'Total fleet travel time — objective', u: 'min'},
      {k: 'road_distance', n: 'Road graph: distance', u: 'm'},
      {k: 'segment_congestion', n: 'Segment congestion / traffic', u: 'trucks'},
      {k: 'production_target', n: 'Production target', u: 't/h'},
      {k: 'cost_per_ton', n: 'Cost per ton', u: '$/t'},
      {k: 'energy_per_ton', n: 'Energy per ton', u: 'kWh/t'},
      {k: 'co2_per_ton', n: 'CO₂ per ton', u: 'kg/t'},
      {k: 'shift_hours_left', n: 'Shift hours left', u: 'h'},
    ],
  },
];

export const fmt = (v: number): string => {
  if (!Number.isFinite(v)) return '—';
  if (Number.isInteger(v)) return v.toLocaleString('en-US');
  const a = Math.abs(v);
  // 1 decimal for normal floats, but more for small values so e.g. 0.045 isn't shown as "0.0"
  return v.toFixed(a < 0.1 ? 3 : a < 1 ? 2 : 1);
};

// average a parameter across the live frame's entities of a type (single value for network)
export function liveValue(frame: any, frameKey: string, k: string): number | undefined {
  if (!frame) return undefined;
  if (frameKey === 'network') {
    const v = frame.network?.params?.[k];
    return typeof v === 'number' ? v : undefined;
  }
  const arr = frame[frameKey] || [];
  const vals = arr.map((o: any) => o?.params?.[k]).filter((v: any) => typeof v === 'number');
  if (!vals.length) return undefined;
  return vals.reduce((a: number, b: number) => a + b, 0) / vals.length;
}

function GroupCard({g, live, frame, open, onToggle, collapsible = true}:
                    {g: Group; live: boolean; frame: any; open: boolean; onToggle: () => void; collapsible?: boolean}) {
  return (
    <Box sx={{
      background: open ? '#fff' : '#f7f9fb', borderRadius: 2, p: open ? 1.75 : 1, border: '1px solid #eef1f4',
      boxShadow: open ? '0 1px 3px rgba(0,0,0,0.12)' : 'none', borderTop: `3px solid ${g.accent}`,
      display: 'flex', flexDirection: 'column', alignSelf: 'flex-start',
    }}>
      <Box onClick={collapsible ? onToggle : undefined}
        sx={{display: 'flex', alignItems: 'center', gap: 1, mb: open ? 1.25 : 0, cursor: collapsible ? 'pointer' : 'default'}}>
        <Box component="img" src={g.icon} alt="" sx={{height: open ? 28 : 22, width: open ? 28 : 22, objectFit: 'contain'}} />
        <Box sx={{minWidth: 0, flex: 1}}>
          <Box sx={{fontSize: open ? 14 : 12.5, fontWeight: 800, color: '#1b2026', lineHeight: 1.1,
            whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis'}}>{g.title}</Box>
          <Box sx={{fontSize: 10.5, color: '#8a939b', textTransform: 'uppercase', letterSpacing: 0.6}}>
            {open ? g.sub : `${g.params.length} params`}
          </Box>
        </Box>
        {collapsible && <Box sx={{fontSize: 12, color: '#9aa3ab', flexShrink: 0}}>{open ? '▾' : '▸'}</Box>}
      </Box>
      <Box sx={{display: open ? 'flex' : 'none', flexDirection: 'column', gap: 0.5}}>
        {g.params.map((p) => {
          const v = live ? liveValue(frame, g.frameKey, p.k) : undefined;
          return (
            <Box key={p.k} sx={{
              display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1,
              py: 0.5, borderBottom: '1px dashed #eef1f4',
            }}>
              <Box sx={{display: 'flex', alignItems: 'center', gap: 0.75, minWidth: 0}}>
                <Box sx={{width: 6, height: 6, borderRadius: '50%', background: g.accent, flexShrink: 0}} />
                <Box sx={{fontSize: 12.5, color: '#2b333b'}}>{p.n}</Box>
              </Box>
              {live ? (
                // fixed-width value column, unit stacked UNDER the number, right-aligned so
                // the numbers line up symmetrically down every card
                <Box sx={{width: 76, flexShrink: 0, textAlign: 'right',
                  display: 'flex', flexDirection: 'column', lineHeight: 1.1}}>
                  <Box sx={{fontSize: 14, fontWeight: 800, color: v === undefined ? '#c2c9d0' : g.accent}}>
                    {v === undefined ? '—' : fmt(v)}
                  </Box>
                  <Box sx={{fontSize: 9.5, color: '#9aa3ab', mt: '1px'}}>{p.u}</Box>
                </Box>
              ) : (
                <Box sx={{fontSize: 10.5, fontWeight: 700, color: '#9aa3ab', whiteSpace: 'nowrap',
                  background: '#f3f5f7', borderRadius: 1, px: 0.75, py: 0.2}}>{p.u}</Box>
              )}
            </Box>
          );
        })}
      </Box>
    </Box>
  );
}

export default function CuoptOptParams() {
  const {frame, isLive} = useCuopt();
  const [live, setLive] = useState(true);   // Live values shown by default
  const total = GROUPS.reduce((s, g) => s + g.params.length, 0);
  const showingLive = live && isLive && !!frame;

  return (
    <Box>
      {/* objective banner + Static/Live toggle */}
      <Box sx={{
        borderRadius: 2, p: 2, mb: 2, color: '#eaf3ff',
        background: 'linear-gradient(90deg,#0b0f0a,#11200d 55%,#0d1b24)',
        border: `1px solid ${NVIDIA_GREEN}55`,
      }}>
        <Box sx={{display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2}}>
          <Box sx={{fontSize: 15, fontWeight: 800, color: NVIDIA_GREEN, mb: 0.5, letterSpacing: 0.3}}>
            Parameters the cuOpt decision model optimizes for
          </Box>
          <Box sx={{display: 'flex', gap: 0.5, flexShrink: 0,
            background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.15)', borderRadius: 999, p: '3px'}}>
            {[{k: false, l: 'Static'}, {k: true, l: 'Live'}].map((o) => (
              <Box key={o.l} onClick={() => setLive(o.k)} sx={{
                px: 1.5, py: 0.4, borderRadius: 999, fontSize: 12, fontWeight: 700, cursor: 'pointer',
                color: live === o.k ? '#0b0f0a' : '#aebccb',
                background: live === o.k ? NVIDIA_GREEN : 'transparent',
              }}>{o.l}</Box>
            ))}
          </Box>
        </Box>
        <Box sx={{fontSize: 13.5, lineHeight: 1.6, color: '#b9c6d3'}}>
          Every signal below is encoded into the cuOpt model  as terms of its <b style={{color: '#eaf3ff'}}>objective
          function</b> and as <b style={{color: '#eaf3ff'}}>constraints</b> — and re-solved on the GPU each cycle to
          choose the optimal truck dispatch.
          <br />
          <Box component="span" sx={{display: 'inline-block', mt: 1, fontSize: 12.5, color: '#9fb0bf'}}>
            <b style={{color: NVIDIA_GREEN}}>Objective:</b> minimize total fleet travel time &amp; cost per ton ·
            maximize throughput &amp; utilization  subject to fuel, capacity, wear and maintenance constraints.
          </Box>
        </Box>
        <Box sx={{mt: 1.25, display: 'flex', gap: 2, flexWrap: 'wrap', alignItems: 'center'}}>
          <Box sx={{fontSize: 11.5, color: '#8fa0af'}}>{GROUPS.length} entity groups</Box>
          <Box sx={{fontSize: 11.5, color: '#8fa0af'}}>· {total} modelled parameters</Box>
          <Box sx={{fontSize: 11.5, color: '#8fa0af'}}>· GPU-accelerated MIP / VRP solve</Box>
          {live && (
            <Box sx={{fontSize: 11.5, fontWeight: 700,
              color: showingLive ? NVIDIA_GREEN : '#e0a23a'}}>
              {showingLive ? '· live values — averaged per type, updating each cycle'
                           : '· waiting for a live cuOpt frame (service in STANDBY)'}
            </Box>
          )}
        </Box>
      </Box>

      {/* group grid */}
      <Box sx={{display: 'grid', gridTemplateColumns: 'repeat(6, minmax(0, 1fr))', gap: 1.5, alignItems: 'start'}}>
        {GROUPS.map((g) => (
          <GroupCard key={g.key} g={g} live={showingLive} frame={frame} open={true} onToggle={() => {}} collapsible={false} />
        ))}
      </Box>
    </Box>
  );
}
  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
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
'use client';
import * as React from 'react';
import {createContext, useContext, useEffect, useRef, useState} from 'react';
import {CUOPT_HEALTH, CUOPT_WS, HEALTH_POLL_MS, PLAN_WATCH_MS} from './config';

type Status = 'standby' | 'connecting' | 'live';

interface CuoptValue {
  status: Status;
  isLive: boolean;
  frame: any;            // latest KPI/telemetry frame
  meta: any;             // last solve meta
  paused: boolean;
  open: boolean;
  setOpen: (v: boolean) => void;
}

const Ctx = createContext<CuoptValue>({
  status: 'standby', isLive: false, frame: null, meta: null,
  paused: false, open: false, setOpen: () => {},
});

export const useCuopt = () => useContext(Ctx);

// The app mutates the quarry graph into circular references (point.points hold
// object refs, not ids). Extract a flat, JSON-safe copy with only what the
// optimizer needs: each node/object as {id,lat,lng,n,type, points:[id,...]}.
const TYPES = ['point', 'truck', 'excavator', 'crusher', 'gas', 'maintenance'];

function pid(p: any): any {
  return p && typeof p === 'object' ? p.id : p;
}

function sanitizeData(data: any): any {
  // out-of-service facilities (set on the live UI map data) are excluded from the plan, so the
  // cuOpt solver never assigns to them; re-enabling re-includes them on the next plan send.
  const live = (typeof window !== 'undefined') ? (window as any)?._data?.data : null;
  const out: any = {};
  for (const t of TYPES) {
    const arr = Array.isArray(data?.[t]) ? data[t] : [];
    out[t] = arr.filter((o: any) => {
      const lo = live?.[t]?.find((x: any) => x.id === o.id);
      return !lo?._oos;
    }).map((o: any) => ({
      id: o.id, lat: o.lat, lng: o.lng, n: o.n, index: o.index, type: o.type || t,
      name: o.name,
      points: Array.isArray(o.points) ? o.points.map(pid).filter(Boolean) : [],
    }));
  }
  return out;
}

function sanitizePlan(raw: any): any {
  if (!raw) return null;
  if (Array.isArray(raw.items)) {
    return {
      current: raw.current,
      items: raw.items.map((it: any) => ({
        id: it.id, name: it.name, data: sanitizeData(it.data || it),
      })),
    };
  }
  return {data: sanitizeData(raw.data || raw)};
}

function getPlan(): {plan: any; planId: any} | null {
  if (typeof window === 'undefined') return null;
  const w = window as any;
  if (!w._raw) return null;
  const planId = w._quarry || w._current || w._raw.current || 1;
  try {
    return {plan: sanitizePlan(w._raw), planId};
  } catch {
    return null;
  }
}

export function CuoptProvider(props: {start?: boolean; children: React.ReactNode}) {
  const [status, setStatus] = useState<Status>('standby');
  const [frame, setFrame] = useState<any>(null);
  const [meta, setMeta] = useState<any>(null);
  const [open, setOpen] = useState(false);

  const wsRef = useRef<WebSocket | null>(null);
  const healthyRef = useRef(false);
  const frameRef = useRef<any>(null);
  const planIdRef = useRef<any>(null);
  const liveRef = useRef(false);              // reached 'ready'/'frame' on the current socket
  const connectTimer = useRef<any>(null);     // watchdog: tear down a socket stuck in 'connecting'
  const startRef = useRef<boolean>(!!props.start);
  startRef.current = !!props.start;
  const statusRef = useRef<Status>('standby');   // latest status for the assistant context
  statusRef.current = status;

  // A socket may open but never reach 'live' — half-open (TLS/upgrade hung) or the
  // plan wasn't in `window` yet so 'init' never got a 'ready' back. Without a bound
  // it sits in CONNECTING forever and the health poll won't reconnect (wsRef is set).
  const CONNECT_TIMEOUT_MS = 8000;

  const paused = !props.start;

  // ---- WebSocket ----
  const send = (msg: any) => {
    const ws = wsRef.current;
    if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));
  };

  // Event-based planning: the map animation is the source of truth. Whenever a
  // truck picks a new route it reports its actual current (r1) and next (r2)
  // destination, and the backend mirrors it exactly (no-op when offline). This
  // keeps the TRUCKS table and the backend's delivered/refuel counters in sync
  // with what's visibly happening on the map.
  useEffect(() => {
    (window as any)._cuoptRoute = (n: any, r1: any, r2: any) =>
      send({action: 'route', n, r1, r2});
    (window as any)._cuoptArrived = (n: any) => send({action: 'arrived', n}); // legacy
    // GPU-balanced target for a truck/type: the cuOpt load-balancer's recommended
    // facility (n) of `type` for truck `n`, read from the latest frame. Returns
    // undefined when offline so the UI can fall back to a local heuristic.
    (window as any)._cuoptAssign = (n: any, type: string) => {
      const t = frameRef.current?.trucks?.find((x: any) => String(x.n) === String(n));
      return t?.assign?.[type];
    };
    // current fuel % for a truck (from the latest frame) so the map can route a
    // low-tank truck to a gas station. Undefined when offline -> caller falls back.
    (window as any)._cuoptFuel = (n: any) => {
      const t = frameRef.current?.trucks?.find((x: any) => String(x.n) === String(n));
      return t?.fuel_pct;
    };
    // re-send the current plan to cuOpt (e.g. after a facility is taken out of / put back into
    // service) so the solver immediately drops / re-includes it in its available set.
    (window as any)._cuoptReplan = () => {
      const p = getPlan();
      if (p) send({action: 'plan', plan: p.plan, planId: p.planId});
    };
    // live quarry telemetry for the AI assistant: quarry state (PLAY/paused), the lists of
    // entities (each with short + long names) and their optimization parameters, plus KPIs and
    // system telemetry — so the chatbot answers from real-time data.
    // async by design — future versions may fetch extra context, and callers already await.
    (window as any).getAssistantLiveContext = async () => {
      const f = frameRef.current;
      const playing = !!startRef.current;
      const st = statusRef.current;
      const mapData = (window as any)?._data?.data || {};   // map = source of truth for geo
      const SHORT: any = {truck: 'T', excavator: 'E', crusher: 'C', gas: 'G', maintenance: 'M'};
      const LONG: any = {truck: 'Truck', excavator: 'Excavator', crusher: 'Crusher', gas: 'Gas station', maintenance: 'Maintenance bay'};
      // live geo position per object (trucks move; facilities are fixed) by matching number
      const geoOf = (type: string, n: any) => {
        const o = (mapData[type] || []).find((x: any) => String(x.n) === String(n));
        return (o && o.lat != null && o.lng != null) ? {lat: o.lat, lng: o.lng} : null;
      };
      // recent Events log per entity (the UI accumulates these on the map data, not the cuOpt
      // frame): truck arrivals at gas/excavator/crusher/maintenance, maintenance reasons, and
      // facility out-of-service notes. Newest first.
      const eventsOf = (type: string, n: any) => {
        const o = (mapData[type] || []).find((x: any) => String(x.n) === String(n));
        return (o?._events || []).slice(0, 12).map((e: any) => ({
          at: (e?.date && typeof e.date.toISO === 'function') ? e.date.toISO() : null,
          info: e?.info,
        }));
      };
      // Build from the MAP data (every entity, including ones in maintenance that the cuOpt
      // frame omits) and attach the matching frame entity's fields (params/route/etc.) by number.
      // This way out-of-service facilities & held trucks still appear — with the operator's
      // maintenance reason and status.
      const named = (type: string, frameArr: any) => {
        const byN: any = {};
        for (const o of (frameArr || [])) byN[String(o.n)] = o;
        return (mapData[type] || []).map((m: any) => {
          const fo = byN[String(m.n)] || {};
          return {
            ...fo,
            n: m.n,
            shortName: `${SHORT[type]}${m.n}`,
            longName: `${LONG[type]} ${m.n}`,
            geo: geoOf(type, m.n),
            inMaintenance: type === 'truck' ? !!m._maint : !!m._oos,
            outOfService: type !== 'truck' ? !!m._oos : undefined,
            maintenanceReason: (type === 'truck' ? m._maintReason : m._oosReason) || null,
            events: eventsOf(type, m.n),
          };
        });
      };
      return {
        status: st,                                 // 'live' | 'connecting' | 'standby'
        playing,                                    // true = PLAY mode, false = paused
        paused: !playing,
        quarryState: `${playing ? 'PLAY' : 'PAUSED'} (${st.toUpperCase()})`,
        updatedAt: f?.t ?? null,
        kpi: f?.kpi ?? null,
        trucks: named('truck', f?.trucks),
        excavators: named('excavator', f?.excavators),
        crushers: named('crusher', f?.crushers),
        gasStations: named('gas', f?.gas),
        maintenanceBays: named('maintenance', f?.maintenance),
        network: f?.network ?? null,
        system: f?.system ?? null,
      };
    };
    return () => {
      try { delete (window as any)._cuoptRoute; } catch {}
      try { delete (window as any)._cuoptArrived; } catch {}
      try { delete (window as any)._cuoptAssign; } catch {}
      try { delete (window as any)._cuoptFuel; } catch {}
      try { delete (window as any).getAssistantLiveContext; } catch {}
    };
  }, []);

  // AI chat embed visibility follows cuOpt (AIMA) liveness: shown once the server is LIVE,
  // hidden in STANDBY. Retries briefly because the embed script may finish loading after we
  // first go live (it starts hidden via AI_CHAT_HIDDEN). 'connecting' leaves it as-is.
  useEffect(() => {
    const apply = () => {
      const e = (window as any).AIChatEmbed;
      if (!e) return false;
      if (status === 'live') e.show?.();
      else if (status === 'standby') e.hide?.();
      return true;
    };
    if (apply()) return;
    const id = setInterval(() => { if (apply()) clearInterval(id); }, 800);
    const stop = setTimeout(() => clearInterval(id), 20000);
    return () => { clearInterval(id); clearTimeout(stop); };
  }, [status]);

  const goLive = () => {
    liveRef.current = true;
    if (connectTimer.current) { clearTimeout(connectTimer.current); connectTimer.current = null; }
    setStatus('live');
  };

  const connect = () => {
    if (wsRef.current || typeof window === 'undefined') return;
    let ws: WebSocket;
    try {
      ws = new WebSocket(CUOPT_WS);
    } catch {
      return;
    }
    wsRef.current = ws;
    liveRef.current = false;
    setStatus('connecting');

    // Watchdog: if this socket hasn't gone live in time, tear it down so the health
    // poll reconnects from scratch (covers half-open sockets and a missed 'init').
    if (connectTimer.current) clearTimeout(connectTimer.current);
    connectTimer.current = setTimeout(() => {
      if (wsRef.current === ws && !liveRef.current) {
        try { ws.close(); } catch {}
        wsRef.current = null;
        setStatus('standby');
      }
    }, CONNECT_TIMEOUT_MS);

    // Send 'init' (the only thing that makes the backend reply 'ready'). The plan may
    // not be in `window` yet at onopen, so retry on the still-open socket until it is.
    const trySendInit = (): boolean => {
      const p = getPlan();
      if (!p) return false;
      planIdRef.current = p.planId;
      send({action: 'init', plan: p.plan, planId: p.planId});
      if (!startRef.current) send({action: 'pause'});
      return true;
    };

    ws.onopen = () => {
      if (trySendInit()) return;
      let tries = 0;
      const iv = setInterval(() => {
        if (wsRef.current !== ws || ws.readyState !== WebSocket.OPEN || ++tries > 10 || trySendInit()) {
          clearInterval(iv);
        }
      }, 700);
    };
    ws.onmessage = (ev) => {
      let msg: any;
      try { msg = JSON.parse(ev.data); } catch { return; }
      if (msg.type === 'ready') { setMeta(msg.meta); goLive(); }
      else if (msg.type === 'frame') { frameRef.current = msg; setFrame(msg); goLive(); }
      else if (msg.type === 'error') { console.warn('cuOpt:', msg.error); }
    };
    ws.onclose = () => {
      if (connectTimer.current) { clearTimeout(connectTimer.current); connectTimer.current = null; }
      liveRef.current = false;
      wsRef.current = null;
      setStatus('standby');
    };
    ws.onerror = () => { try { ws.close(); } catch {} };
  };

  const disconnect = () => {
    if (connectTimer.current) { clearTimeout(connectTimer.current); connectTimer.current = null; }
    const ws = wsRef.current;
    wsRef.current = null;
    liveRef.current = false;
    if (ws) try { ws.close(); } catch {}
    setStatus('standby');
  };

  // ---- health polling -> drives live/standby + (re)connect ----
  useEffect(() => {
    let alive = true;
    const ping = async () => {
      try {
        const ctrl = new AbortController();
        const t = setTimeout(() => ctrl.abort(), 4000);
        const r = await fetch(CUOPT_HEALTH, {signal: ctrl.signal, cache: 'no-store'});
        clearTimeout(t);
        healthyRef.current = r.ok;
        if (r.ok && !wsRef.current && alive) connect();
      } catch {
        healthyRef.current = false;
        if (alive) disconnect();
      }
    };
    ping();
    const id = setInterval(ping, HEALTH_POLL_MS);
    return () => { alive = false; clearInterval(id); disconnect(); };
  }, []);

  // ---- play/pause -> pause/resume the session ----
  useEffect(() => {
    if (wsRef.current?.readyState === WebSocket.OPEN) {
      send({action: props.start ? 'resume' : 'pause'});
    }
  }, [props.start]);

  // ---- plan switching (watch w._quarry) ----
  useEffect(() => {
    const id = setInterval(() => {
      const p = getPlan();
      if (!p) return;
      if (p.planId !== planIdRef.current && wsRef.current?.readyState === WebSocket.OPEN) {
        planIdRef.current = p.planId;
        setFrame(null);
        send({action: 'plan', plan: p.plan, planId: p.planId});
        if (!startRef.current) send({action: 'pause'});
      }
    }, PLAN_WATCH_MS);
    return () => clearInterval(id);
  }, []);

  const value: CuoptValue = {
    status, isLive: status === 'live', frame, meta, paused, open, setOpen,
  };
  return <Ctx.Provider value={value}>{props.children}</Ctx.Provider>;
}