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.
| Group | Purpose | Files |
|---|---|---|
| Entity optimization models | One 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 pipeline | Turns 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 & cost per ton · maximize throughput & 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>; } |