Simulation MVP and Waste management
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Ekaropolus 2026-01-03 03:10:53 -06:00
parent c8034ee5d2
commit a271b43318
11 changed files with 1745 additions and 1 deletions

View File

@ -64,6 +64,7 @@ INSTALLED_APPS = [
'pxy_sami',
'pxy_routing',
'pxy_sites',
"pxy_simulations",
"rest_framework",
"pxy_api",

View File

@ -53,6 +53,7 @@ urlpatterns = [
path("", include("pxy_contracts.urls")),
path('', include('pxy_agents_coral.urls')),
path("", include("pxy_simulations.urls")),
@ -63,4 +64,4 @@ urlpatterns = [
if settings.DEBUG:
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

View File

6
pxy_simulations/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class PxySimulationsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "pxy_simulations"

View File

View File

@ -0,0 +1,508 @@
from __future__ import annotations
from dataclasses import dataclass, field
import math
import random
import threading
import time
import uuid
from typing import Dict, List, Optional, Tuple
MAX_RUNS = 25
MAX_ROAD_SEGMENTS = 4000
DEFAULTS = {
"width": 120.0,
"height": 80.0,
"residents": 60,
"services": 4,
"speed": 1.25,
"service_radius": 3.5,
"seed": None,
"lat": 19.4326,
"lon": -99.1332,
"dist": 700.0,
}
@dataclass
class Resident:
id: int
x: float
y: float
node_id: Optional[int] = None
path_nodes: List[int] = field(default_factory=list)
path_index: int = 0
edge_progress: float = 0.0
target_node_id: Optional[int] = None
@dataclass
class Service:
id: int
x: float
y: float
node_id: Optional[int] = None
class SimulationRun:
def __init__(
self,
run_id: str,
width: float,
height: float,
residents: int,
services: int,
speed: float,
service_radius: float,
seed: Optional[int],
use_roads: bool = False,
road_network: Optional[Dict[str, object]] = None,
road_meta: Optional[Dict[str, float]] = None,
road_error: Optional[str] = None,
) -> None:
road_network = road_network or {}
road_nodes = road_network.get("nodes") or {}
road_edges = road_network.get("edges") or []
road_width = road_network.get("width")
road_height = road_network.get("height")
road_bounds = road_network.get("bounds")
if use_roads and road_nodes and road_width and road_height:
width = float(road_width)
height = float(road_height)
self.run_id = run_id
self.width = width
self.height = height
self.speed = speed
self.service_radius = service_radius
self.seed = seed
self.use_roads = bool(use_roads and road_nodes)
self.road_graph = road_network.get("graph")
self.road_nodes = road_nodes
self.road_edges = road_edges
self.road_meta = road_meta or {}
self.road_error = road_error
self.road_bounds = road_bounds
self.created_at = time.time()
self.step_count = 0
self._lock = threading.Lock()
rng = random.Random(seed)
if self.use_roads:
node_ids = list(self.road_nodes.keys())
if not node_ids:
self.use_roads = False
else:
self.residents = [
_resident_on_node(i, rng.choice(node_ids), self.road_nodes)
for i in range(residents)
]
self.services = [
_service_on_node(i, rng.choice(node_ids), self.road_nodes)
for i in range(services)
]
if not self.use_roads:
self.residents = [
Resident(i, rng.uniform(0, width), rng.uniform(0, height))
for i in range(residents)
]
self.services = [
Service(i, rng.uniform(0, width), rng.uniform(0, height))
for i in range(services)
]
def step(self, steps: int = 1) -> None:
steps = max(1, steps)
with self._lock:
for _ in range(steps):
if self.use_roads:
self._step_once_roads()
else:
self._step_once()
self.step_count += 1
def _step_once(self) -> None:
for resident in self.residents:
target, distance = self._nearest_service(resident)
if target is None or distance <= self.service_radius:
continue
if distance <= 0:
continue
dx = target.x - resident.x
dy = target.y - resident.y
step_size = min(self.speed, distance)
resident.x += (dx / distance) * step_size
resident.y += (dy / distance) * step_size
resident.x = _clamp(resident.x, 0.0, self.width)
resident.y = _clamp(resident.y, 0.0, self.height)
def _step_once_roads(self) -> None:
if not self.road_graph or not self.road_nodes:
return
for resident in self.residents:
if resident.node_id is None:
continue
if not resident.path_nodes or resident.path_index >= len(resident.path_nodes) - 1:
self._assign_path(resident)
if not resident.path_nodes or resident.path_index >= len(resident.path_nodes) - 1:
continue
self._advance_along_path(resident, self.speed)
def _nearest_service(self, resident: Resident) -> Tuple[Optional[Service], float]:
if not self.services:
return None, float("inf")
nearest = None
min_dist = float("inf")
for service in self.services:
dist = math.hypot(service.x - resident.x, service.y - resident.y)
if dist < min_dist:
min_dist = dist
nearest = service
return nearest, min_dist
def _assign_path(self, resident: Resident) -> None:
if not self.road_graph or resident.node_id is None:
return
import networkx as nx
best_service = None
best_length = None
for service in self.services:
if service.node_id is None:
continue
try:
length = nx.shortest_path_length(
self.road_graph, resident.node_id, service.node_id, weight="length"
)
except (nx.NetworkXNoPath, nx.NodeNotFound):
continue
if best_length is None or length < best_length:
best_length = length
best_service = service.node_id
if best_service is None:
resident.path_nodes = []
return
try:
path_nodes = nx.shortest_path(
self.road_graph, resident.node_id, best_service, weight="length"
)
except (nx.NetworkXNoPath, nx.NodeNotFound):
resident.path_nodes = []
return
resident.path_nodes = path_nodes
resident.path_index = 0
resident.edge_progress = 0.0
resident.target_node_id = best_service
def _advance_along_path(self, resident: Resident, distance: float) -> None:
remaining = max(0.0, distance)
while remaining > 0 and resident.path_index < len(resident.path_nodes) - 1:
current_node = resident.path_nodes[resident.path_index]
next_node = resident.path_nodes[resident.path_index + 1]
p0 = self.road_nodes.get(current_node)
p1 = self.road_nodes.get(next_node)
if not p0 or not p1:
resident.path_index += 1
resident.edge_progress = 0.0
resident.node_id = next_node
continue
edge_length = math.hypot(p1[0] - p0[0], p1[1] - p0[1])
if edge_length <= 0:
resident.path_index += 1
resident.edge_progress = 0.0
resident.node_id = next_node
resident.x, resident.y = p1
continue
edge_remaining = edge_length - resident.edge_progress
if remaining < edge_remaining:
resident.edge_progress += remaining
ratio = resident.edge_progress / edge_length
resident.x = p0[0] + (p1[0] - p0[0]) * ratio
resident.y = p0[1] + (p1[1] - p0[1]) * ratio
remaining = 0.0
else:
remaining -= edge_remaining
resident.path_index += 1
resident.edge_progress = 0.0
resident.node_id = next_node
resident.x, resident.y = p1
def metrics(self) -> Dict[str, float | int]:
if not self.services:
return {
"served": 0,
"avg_distance": 0.0,
"max_distance": 0.0,
}
total_distance = 0.0
max_distance = 0.0
served = 0
for resident in self.residents:
_, dist = self._nearest_service(resident)
total_distance += dist
max_distance = max(max_distance, dist)
if dist <= self.service_radius:
served += 1
avg_distance = total_distance / max(1, len(self.residents))
return {
"served": served,
"avg_distance": avg_distance,
"max_distance": max_distance,
}
def state(self, include_roads: bool = True) -> Dict[str, object]:
with self._lock:
metrics = self.metrics()
payload = {
"run_id": self.run_id,
"step": self.step_count,
"world": {
"width": self.width,
"height": self.height,
"mode": "roads" if self.use_roads else "grid",
},
"counts": {
"residents": len(self.residents),
"services": len(self.services),
},
"config": {
"speed": self.speed,
"service_radius": self.service_radius,
"seed": self.seed,
"use_roads": self.use_roads,
},
"metrics": metrics,
"residents": [
{"id": agent.id, "x": agent.x, "y": agent.y}
for agent in self.residents
],
"services": [
{"id": svc.id, "x": svc.x, "y": svc.y}
for svc in self.services
],
}
if self.road_meta:
payload["world"]["center"] = {
"lat": self.road_meta.get("lat"),
"lon": self.road_meta.get("lon"),
"dist": self.road_meta.get("dist"),
}
if self.road_bounds:
payload["world"]["bbox"] = self.road_bounds
if self.road_error:
payload["world"]["road_error"] = self.road_error
if include_roads and self.road_edges:
payload["roads"] = self.road_edges
return payload
_RUNS: Dict[str, SimulationRun] = {}
_RUN_ORDER: List[str] = []
RUNS_LOCK = threading.Lock()
def _resident_on_node(resident_id: int, node_id: int, node_positions: Dict[int, Tuple[float, float]]) -> Resident:
x, y = node_positions[node_id]
return Resident(resident_id, x, y, node_id=node_id)
def _service_on_node(service_id: int, node_id: int, node_positions: Dict[int, Tuple[float, float]]) -> Service:
x, y = node_positions[node_id]
return Service(service_id, x, y, node_id=node_id)
def _clamp(value: float, low: float, high: float) -> float:
return max(low, min(high, value))
def _to_int(value: object, default: int) -> int:
if value is None:
return default
try:
return int(value)
except (TypeError, ValueError):
return default
def _to_float(value: object, default: float) -> float:
if value is None:
return default
try:
return float(value)
except (TypeError, ValueError):
return default
def _to_bool(value: object) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return value != 0
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "on"}
return False
def _normalize_config(payload: Dict[str, object]) -> Dict[str, object]:
width = _clamp(_to_float(payload.get("width"), DEFAULTS["width"]), 40.0, 500.0)
height = _clamp(_to_float(payload.get("height"), DEFAULTS["height"]), 40.0, 500.0)
residents = _clamp(_to_int(payload.get("residents"), DEFAULTS["residents"]), 5, 500)
services = _clamp(_to_int(payload.get("services"), DEFAULTS["services"]), 1, 50)
speed = _clamp(_to_float(payload.get("speed"), DEFAULTS["speed"]), 0.1, 8.0)
service_radius = _clamp(
_to_float(payload.get("service_radius"), DEFAULTS["service_radius"]), 0.5, 15.0
)
seed_value = payload.get("seed")
seed = _to_int(seed_value, None) if seed_value is not None else None
return {
"width": width,
"height": height,
"residents": int(residents),
"services": int(services),
"speed": float(speed),
"service_radius": float(service_radius),
"seed": seed,
}
def _normalize_roads(payload: Dict[str, object]) -> Dict[str, float]:
lat = _clamp(_to_float(payload.get("lat"), DEFAULTS["lat"]), -85.0, 85.0)
lon = _clamp(_to_float(payload.get("lon"), DEFAULTS["lon"]), -180.0, 180.0)
dist = _clamp(_to_float(payload.get("dist"), DEFAULTS["dist"]), 200.0, 2500.0)
return {
"lat": lat,
"lon": lon,
"dist": dist,
}
def _build_road_network(lat: float, lon: float, dist: float) -> Dict[str, object]:
import osmnx as ox
graph = ox.graph_from_point((lat, lon), dist=dist, network_type="drive", simplify=True)
if graph is None or len(graph.nodes) == 0:
raise ValueError("OSM graph is empty")
graph = ox.project_graph(graph, to_crs="EPSG:3857").to_undirected()
node_positions_raw: Dict[int, Tuple[float, float]] = {}
for node_id, data in graph.nodes(data=True):
if "x" in data and "y" in data:
node_positions_raw[node_id] = (float(data["x"]), float(data["y"]))
if not node_positions_raw:
raise ValueError("OSM graph has no coordinates")
xs = [pos[0] for pos in node_positions_raw.values()]
ys = [pos[1] for pos in node_positions_raw.values()]
min_x, max_x = min(xs), max(xs)
min_y, max_y = min(ys), max(ys)
width = max(max_x - min_x, 1.0)
height = max(max_y - min_y, 1.0)
node_positions = {
node_id: (x - min_x, y - min_y) for node_id, (x, y) in node_positions_raw.items()
}
edges: List[Dict[str, float]] = []
for u, v, data in graph.edges(data=True):
geom = data.get("geometry")
if geom is not None:
coords = list(geom.coords)
for i in range(len(coords) - 1):
x1, y1 = coords[i]
x2, y2 = coords[i + 1]
edges.append({
"x1": x1 - min_x,
"y1": y1 - min_y,
"x2": x2 - min_x,
"y2": y2 - min_y,
})
else:
if u in node_positions and v in node_positions:
x1, y1 = node_positions[u]
x2, y2 = node_positions[v]
edges.append({
"x1": x1,
"y1": y1,
"x2": x2,
"y2": y2,
})
if len(edges) > MAX_ROAD_SEGMENTS:
step = max(1, int(len(edges) / MAX_ROAD_SEGMENTS))
edges = edges[::step]
return {
"graph": graph,
"nodes": node_positions,
"edges": edges,
"width": width,
"height": height,
"bounds": {
"min_x": min_x,
"min_y": min_y,
"max_x": max_x,
"max_y": max_y,
},
}
def create_run(payload: Optional[Dict[str, object]] = None) -> SimulationRun:
payload = payload or {}
config = _normalize_config(payload)
use_roads = _to_bool(payload.get("use_roads")) or payload.get("mode") == "roads"
road_meta = _normalize_roads(payload) if use_roads else {}
road_network = None
road_error = None
if use_roads:
try:
road_network = _build_road_network(
road_meta["lat"], road_meta["lon"], road_meta["dist"]
)
except Exception as exc:
road_error = f"{type(exc).__name__}: {exc}"
road_network = None
use_roads = False
run_id = uuid.uuid4().hex
run = SimulationRun(
run_id=run_id,
width=config["width"],
height=config["height"],
residents=config["residents"],
services=config["services"],
speed=config["speed"],
service_radius=config["service_radius"],
seed=config["seed"],
use_roads=use_roads,
road_network=road_network,
road_meta=road_meta,
road_error=road_error,
)
with RUNS_LOCK:
_RUNS[run_id] = run
_RUN_ORDER.append(run_id)
while len(_RUN_ORDER) > MAX_RUNS:
oldest = _RUN_ORDER.pop(0)
_RUNS.pop(oldest, None)
return run
def get_run(run_id: str) -> Optional[SimulationRun]:
if not run_id:
return None
with RUNS_LOCK:
return _RUNS.get(run_id)

View File

@ -0,0 +1,400 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import math
import random
from typing import Dict, List, Optional, Tuple
from pxy_city_digital_twins.services.waste_routes import get_dispatch_data_for
WASTE_TYPES = ("organic", "recyclable", "landfill")
BIN_TYPE_CAPACITY = {
"organic": 8.0,
"recyclable": 6.0,
"landfill": 10.0,
}
TRUCK_CAPACITY = {
"organic": 180.0,
"recyclable": 140.0,
"landfill": 220.0,
}
DEFAULTS = {
"truck_speed_kmh": 25.0,
"bins_per_stop": 3,
"bin_radius_m": 60.0,
"walk_speed_mps": 1.2,
"window_min_s": 900.0,
"window_max_s": 2400.0,
"seed": None,
"route_point_limit": 800,
}
STATUS_COLORS = {
"served": [80, 200, 120, 220],
"missed_time": [255, 90, 90, 220],
"missed_capacity": [255, 170, 60, 220],
}
def _to_int(value: object, default: int) -> int:
if value is None:
return default
try:
return int(value)
except (TypeError, ValueError):
return default
def _to_float(value: object, default: float) -> float:
if value is None:
return default
try:
return float(value)
except (TypeError, ValueError):
return default
def _clamp(value: float, low: float, high: float) -> float:
return max(low, min(high, value))
def _normalize_params(params: Dict[str, object]) -> Dict[str, object]:
truck_speed_kmh = _clamp(_to_float(params.get("truck_speed_kmh"), DEFAULTS["truck_speed_kmh"]), 5.0, 60.0)
bins_per_stop = _clamp(_to_int(params.get("bins_per_stop"), DEFAULTS["bins_per_stop"]), 1, 10)
bin_radius_m = _clamp(_to_float(params.get("bin_radius_m"), DEFAULTS["bin_radius_m"]), 10.0, 250.0)
walk_speed_mps = _clamp(_to_float(params.get("walk_speed_mps"), DEFAULTS["walk_speed_mps"]), 0.5, 3.0)
window_min_s = _clamp(_to_float(params.get("window_min_s"), DEFAULTS["window_min_s"]), 300.0, 3600.0)
window_max_s = _clamp(_to_float(params.get("window_max_s"), DEFAULTS["window_max_s"]), window_min_s, 7200.0)
seed_value = params.get("seed")
seed = _to_int(seed_value, None) if seed_value is not None else None
route_point_limit = _clamp(_to_int(params.get("route_point_limit"), DEFAULTS["route_point_limit"]), 200, 3000)
return {
"truck_speed_kmh": truck_speed_kmh,
"bins_per_stop": int(bins_per_stop),
"bin_radius_m": float(bin_radius_m),
"walk_speed_mps": float(walk_speed_mps),
"window_min_s": float(window_min_s),
"window_max_s": float(window_max_s),
"seed": seed,
"route_point_limit": int(route_point_limit),
}
def _haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
r = 6371000.0
dlat = math.radians(lat2 - lat1)
dlon = math.radians(lon2 - lon1)
a = math.sin(dlat / 2) ** 2 + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(dlon / 2) ** 2
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
return r * c
def _lonlat_to_mercator(lon: float, lat: float) -> Tuple[float, float]:
r = 6378137.0
x = r * math.radians(lon)
y = r * math.log(math.tan(math.pi / 4 + math.radians(lat) / 2))
return x, y
def _mercator_to_lonlat(x: float, y: float) -> Tuple[float, float]:
r = 6378137.0
lon = math.degrees(x / r)
lat = math.degrees(2 * math.atan(math.exp(y / r)) - math.pi / 2)
return lon, lat
def _offset_lonlat(lon: float, lat: float, dx: float, dy: float) -> Tuple[float, float]:
x, y = _lonlat_to_mercator(lon, lat)
x += dx
y += dy
return _mercator_to_lonlat(x, y)
def _nearest_index(lon: float, lat: float, coords: List[Tuple[float, float]]) -> int:
best_idx = 0
best_dist = None
for idx, (clon, clat) in enumerate(coords):
dist = _haversine_m(lat, lon, clat, clon)
if best_dist is None or dist < best_dist:
best_dist = dist
best_idx = idx
return best_idx
def _downsample_coords(coords: List[Tuple[float, float]], limit: int) -> List[Tuple[float, float]]:
if len(coords) <= limit:
return coords
step = max(1, int(len(coords) / limit))
sampled = coords[::step]
if sampled[-1] != coords[-1]:
sampled.append(coords[-1])
return sampled
def build_waste_cesium_payload(subdivision: str, vehicle: str, params: Optional[Dict[str, object]] = None) -> Dict[str, object]:
params = params or {}
config = _normalize_params(params)
rng = random.Random(config["seed"])
dispatch_data = get_dispatch_data_for(subdivision)
if not dispatch_data:
raise ValueError("No dispatch data found for subdivision")
route = next((r for r in dispatch_data if str(r.get("route_id")) == str(vehicle)), None)
if not route:
raise ValueError("Route not found for vehicle")
coords_raw = route.get("coords") or []
steps = route.get("steps") or []
if not coords_raw and steps:
coords_raw = [step.get("position") for step in steps if step.get("position")]
if not coords_raw or len(coords_raw) < 2:
raise ValueError("Route geometry is missing")
coords_full = [(float(lon), float(lat)) for lon, lat in coords_raw]
cumulative = [0.0]
for i in range(len(coords_full) - 1):
lon1, lat1 = coords_full[i]
lon2, lat2 = coords_full[i + 1]
cumulative.append(cumulative[-1] + _haversine_m(lat1, lon1, lat2, lon2))
total_distance = cumulative[-1]
truck_speed_mps = config["truck_speed_kmh"] / 3.6
if truck_speed_mps <= 0 or total_distance <= 0:
raise ValueError("Route distance or speed is invalid")
total_time_s = total_distance / truck_speed_mps
start_time = datetime.now(timezone.utc)
end_time = start_time + timedelta(seconds=total_time_s)
coords_czml = _downsample_coords(coords_full, config["route_point_limit"])
indices_lookup = {coords_full[idx]: idx for idx in range(len(coords_full))}
positions = []
for lon, lat in coords_czml:
idx = indices_lookup.get((lon, lat))
if idx is None:
idx = _nearest_index(lon, lat, coords_full)
t = cumulative[idx] / truck_speed_mps
positions.extend([t, lon, lat, 0])
stop_positions = []
if steps:
for step in steps:
pos = step.get("position")
if not pos:
continue
stop_positions.append((float(pos[0]), float(pos[1]), step.get("step_type") or "job", step.get("popup") or ""))
else:
stride = max(1, int(len(coords_full) / 12))
for idx in range(0, len(coords_full), stride):
lon, lat = coords_full[idx]
stop_positions.append((lon, lat, "job", ""))
stop_info = []
for stop_idx, (lon, lat, step_type, popup) in enumerate(stop_positions):
coord_idx = _nearest_index(lon, lat, coords_full)
arrival_s = cumulative[coord_idx] / truck_speed_mps
stop_info.append({
"id": f"stop-{stop_idx}",
"lon": lon,
"lat": lat,
"arrival_s": arrival_s,
"step_type": step_type,
"popup": popup,
})
bins = []
for stop in stop_info:
for _ in range(config["bins_per_stop"]):
angle = rng.uniform(0, math.pi * 2)
radius = rng.uniform(5.0, config["bin_radius_m"])
dx = math.cos(angle) * radius
dy = math.sin(angle) * radius
lon, lat = _offset_lonlat(stop["lon"], stop["lat"], dx, dy)
ready_time = rng.uniform(0.0, total_time_s * 0.8)
window_s = rng.uniform(config["window_min_s"], config["window_max_s"])
walk_time = radius / config["walk_speed_mps"]
waste = {}
for wtype in WASTE_TYPES:
fullness = rng.uniform(0.2, 1.0)
waste[wtype] = round(fullness * BIN_TYPE_CAPACITY[wtype], 2)
arrival_s = stop["arrival_s"]
on_time = arrival_s >= (ready_time + walk_time) and arrival_s <= (ready_time + window_s)
status = "served" if on_time else "missed_time"
bins.append({
"id": f"bin-{len(bins)}",
"lon": lon,
"lat": lat,
"arrival_s": arrival_s,
"ready_s": ready_time,
"window_s": window_s,
"walk_s": walk_time,
"distance_m": radius,
"status": status,
"waste": waste,
})
remaining = TRUCK_CAPACITY.copy()
bins_sorted = sorted(bins, key=lambda b: b["arrival_s"])
for b in bins_sorted:
if b["status"] != "served":
continue
fits = True
for wtype, amount in b["waste"].items():
if amount > remaining[wtype]:
fits = False
break
if fits:
for wtype, amount in b["waste"].items():
remaining[wtype] -= amount
else:
b["status"] = "missed_capacity"
total_bins = len(bins)
served = sum(1 for b in bins if b["status"] == "served")
missed_time = sum(1 for b in bins if b["status"] == "missed_time")
missed_capacity = sum(1 for b in bins if b["status"] == "missed_capacity")
waste_total = {wtype: 0.0 for wtype in WASTE_TYPES}
waste_collected = {wtype: 0.0 for wtype in WASTE_TYPES}
for b in bins:
for wtype, amount in b["waste"].items():
waste_total[wtype] += amount
if b["status"] == "served":
waste_collected[wtype] += amount
metrics = {
"bins_total": total_bins,
"bins_served": served,
"bins_missed_time": missed_time,
"bins_missed_capacity": missed_capacity,
"waste_total": waste_total,
"waste_collected": waste_collected,
"route_km": round(total_distance / 1000.0, 2),
"route_minutes": round(total_time_s / 60.0, 1),
}
interval = f"{start_time.isoformat()}/{end_time.isoformat()}"
czml = [
{
"id": "document",
"name": "Waste Simulation",
"version": "1.0",
"clock": {
"interval": interval,
"currentTime": start_time.isoformat(),
"multiplier": 10,
"range": "LOOP_STOP",
"step": "SYSTEM_CLOCK_MULTIPLIER",
},
},
{
"id": "truck",
"availability": interval,
"position": {
"epoch": start_time.isoformat(),
"cartographicDegrees": positions,
},
"point": {
"pixelSize": 10,
"color": {"rgba": [255, 80, 80, 255]},
"outlineColor": {"rgba": [20, 20, 20, 255]},
"outlineWidth": 1,
},
"path": {
"material": {
"polylineOutline": {
"color": {"rgba": [255, 80, 80, 160]},
"outlineColor": {"rgba": [0, 0, 0, 40]},
"outlineWidth": 1,
}
},
"width": 3,
"leadTime": 0,
"trailTime": total_time_s,
"resolution": 5,
},
},
]
for stop in stop_info:
czml.append({
"id": stop["id"],
"position": {"cartographicDegrees": [stop["lon"], stop["lat"], 0]},
"point": {
"pixelSize": 6,
"color": {"rgba": [60, 160, 255, 200]},
},
"properties": {
"arrival_s": stop["arrival_s"],
"step_type": stop["step_type"],
},
})
for b in bins:
color = STATUS_COLORS.get(b["status"], [200, 200, 200, 200])
desc = (
f"<table class='cesium-infoBox-defaultTable'>"
f"<tr><th>Status</th><td>{b['status']}</td></tr>"
f"<tr><th>Arrival</th><td>{b['arrival_s'] / 60.0:.1f} min</td></tr>"
f"<tr><th>Ready</th><td>{b['ready_s'] / 60.0:.1f} min</td></tr>"
f"<tr><th>Walk</th><td>{b['walk_s'] / 60.0:.1f} min</td></tr>"
f"<tr><th>Organic</th><td>{b['waste']['organic']}</td></tr>"
f"<tr><th>Recyclable</th><td>{b['waste']['recyclable']}</td></tr>"
f"<tr><th>Landfill</th><td>{b['waste']['landfill']}</td></tr>"
f"</table>"
)
czml.append({
"id": b["id"],
"position": {"cartographicDegrees": [b["lon"], b["lat"], 0]},
"point": {
"pixelSize": 8,
"color": {"rgba": color},
"outlineColor": {"rgba": [10, 10, 10, 160]},
"outlineWidth": 1,
},
"description": desc,
"properties": {
"status": b["status"],
"arrival_s": b["arrival_s"],
"ready_s": b["ready_s"],
"walk_s": b["walk_s"],
"waste": b["waste"],
},
})
center_lon = sum(lon for lon, _ in coords_full) / len(coords_full)
center_lat = sum(lat for _, lat in coords_full) / len(coords_full)
return {
"czml": czml,
"metrics": metrics,
"config": {
"truck_speed_kmh": config["truck_speed_kmh"],
"bins_per_stop": config["bins_per_stop"],
"bin_radius_m": config["bin_radius_m"],
"walk_speed_mps": config["walk_speed_mps"],
"window_min_s": config["window_min_s"],
"window_max_s": config["window_max_s"],
"capacity": TRUCK_CAPACITY,
},
"center": {
"lat": center_lat,
"lon": center_lon,
},
"route": {
"vehicle": vehicle,
"subdivision": subdivision,
},
}

View File

@ -0,0 +1,456 @@
{% extends "pxy_dashboard/partials/base.html" %}
{% load static %}
{% block title %}Simulation MVP (Cesium){% endblock %}
{% block extra_css %}
<link
rel="stylesheet"
href="https://cesium.com/downloads/cesiumjs/releases/1.114/Build/Cesium/Widgets/widgets.css"
/>
<style>
.sim-cesium {
width: 100%;
height: 520px;
border: 1px solid #2b3348;
border-radius: 8px;
overflow: hidden;
}
.sim-metric {
font-weight: 600;
}
.sim-status {
font-size: 0.9rem;
color: #8a94a6;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row mb-4">
<div class="col-12">
<h4 class="mb-1">Simulation MVP (Cesium)</h4>
<div class="text-muted">Residents move toward their nearest service node, now rendered in Cesium.</div>
</div>
</div>
<div class="row">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Live State</h5>
</div>
<div class="card-body">
<div id="cesiumContainer" class="sim-cesium"></div>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Controls</h5>
</div>
<div class="card-body">
<div class="mb-2">
<label class="form-label">Residents</label>
<input id="simResidents" type="number" class="form-control" value="60" min="5" max="500" />
</div>
<div class="mb-2">
<label class="form-label">Services</label>
<input id="simServices" type="number" class="form-control" value="4" min="1" max="50" />
</div>
<div class="mb-2">
<label class="form-label">Speed</label>
<input id="simSpeed" type="number" class="form-control" value="1.25" step="0.1" min="0.1" max="8" />
</div>
<div class="mb-3">
<label class="form-label">Service Radius</label>
<input id="simRadius" type="number" class="form-control" value="3.5" step="0.1" min="0.5" max="15" />
</div>
<div class="mb-2">
<label class="form-label">Basemap</label>
<select id="basemap" class="form-select">
<option value="osm">OpenStreetMap</option>
<option value="esri">ESRI World Imagery</option>
<option value="carto_light">CARTO Light</option>
<option value="carto_dark">CARTO Dark</option>
</select>
</div>
<div class="form-check form-switch mb-2">
<input class="form-check-input" type="checkbox" id="simRoads" checked />
<label class="form-check-label" for="simRoads">Use Real Roads (OSM)</label>
</div>
<div class="row g-2 mb-2">
<div class="col-6">
<label class="form-label">Lat</label>
<input id="simLat" type="number" class="form-control" value="19.4326" step="0.0001" />
</div>
<div class="col-6">
<label class="form-label">Lon</label>
<input id="simLon" type="number" class="form-control" value="-99.1332" step="0.0001" />
</div>
</div>
<div class="mb-3">
<label class="form-label">OSM Radius (m)</label>
<input id="simDist" type="number" class="form-control" value="700" step="50" min="200" max="2500" />
</div>
<div class="d-flex gap-2 flex-wrap">
<button id="simNew" class="btn btn-primary">New Run</button>
<button id="simStep" class="btn btn-outline-primary">Step</button>
<button id="simAuto" class="btn btn-outline-secondary">Auto Run</button>
</div>
<div id="simStatus" class="sim-status mt-3">Ready.</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h5 class="mb-0">Metrics</h5>
</div>
<div class="card-body">
<div class="d-flex justify-content-between mb-2"><span>Step</span><span id="metricStep" class="sim-metric">-</span></div>
<div class="d-flex justify-content-between mb-2"><span>Residents</span><span id="metricResidents" class="sim-metric">-</span></div>
<div class="d-flex justify-content-between mb-2"><span>Services</span><span id="metricServices" class="sim-metric">-</span></div>
<div class="d-flex justify-content-between mb-2"><span>Served</span><span id="metricServed" class="sim-metric">-</span></div>
<div class="d-flex justify-content-between mb-2"><span>Avg Distance</span><span id="metricAvg" class="sim-metric">-</span></div>
<div class="d-flex justify-content-between"><span>Max Distance</span><span id="metricMax" class="sim-metric">-</span></div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="https://cesium.com/downloads/cesiumjs/releases/1.114/Build/Cesium/Cesium.js"></script>
<script>
Cesium.Ion.defaultAccessToken = "{{ ion_token }}";
const viewer = new Cesium.Viewer("cesiumContainer", {
animation: false,
timeline: false,
baseLayerPicker: false,
geocoder: false,
homeButton: false,
navigationHelpButton: false,
sceneModePicker: false,
infoBox: false,
selectionIndicator: false,
});
const basemapSelect = document.getElementById("basemap");
const basemapLayers = {};
function getBasemapProvider(key) {
if (key === "osm") {
return new Cesium.OpenStreetMapImageryProvider({
url: "https://tile.openstreetmap.org/",
});
}
if (key === "esri") {
return new Cesium.UrlTemplateImageryProvider({
url: "https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
credit: "Esri",
});
}
if (key === "carto_light") {
return new Cesium.UrlTemplateImageryProvider({
url: "https://cartodb-basemaps-a.global.ssl.fastly.net/light_all/{z}/{x}/{y}.png",
credit: "CARTO",
});
}
if (key === "carto_dark") {
return new Cesium.UrlTemplateImageryProvider({
url: "https://cartodb-basemaps-a.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png",
credit: "CARTO",
});
}
return null;
}
function setBaseLayer(key) {
let layer = basemapLayers[key];
if (!layer) {
const provider = getBasemapProvider(key);
if (!provider) {
return;
}
layer = viewer.imageryLayers.addImageryProvider(provider);
basemapLayers[key] = layer;
}
Object.keys(basemapLayers).forEach((k) => {
basemapLayers[k].show = k === key;
});
viewer.imageryLayers.raiseToTop(layer);
}
basemapSelect.addEventListener("change", () => {
setBaseLayer(basemapSelect.value);
});
setBaseLayer(basemapSelect.value);
const metricStep = document.getElementById("metricStep");
const metricResidents = document.getElementById("metricResidents");
const metricServices = document.getElementById("metricServices");
const metricServed = document.getElementById("metricServed");
const metricAvg = document.getElementById("metricAvg");
const metricMax = document.getElementById("metricMax");
const statusEl = document.getElementById("simStatus");
const btnNew = document.getElementById("simNew");
const btnStep = document.getElementById("simStep");
const btnAuto = document.getElementById("simAuto");
const inputResidents = document.getElementById("simResidents");
const inputServices = document.getElementById("simServices");
const inputSpeed = document.getElementById("simSpeed");
const inputRadius = document.getElementById("simRadius");
const inputRoads = document.getElementById("simRoads");
const inputLat = document.getElementById("simLat");
const inputLon = document.getElementById("simLon");
const inputDist = document.getElementById("simDist");
const residentEntities = new Map();
const serviceEntities = new Map();
let runId = null;
let autoTimer = null;
let busy = false;
let lastState = null;
let gridCenter = { lat: 19.4326, lon: -99.1332 };
const R = 6378137;
const GRID_METERS_PER_UNIT = 10.0;
function setStatus(text) {
statusEl.textContent = text;
}
function mercatorToLon(x) {
return (x / R) * (180 / Math.PI);
}
function mercatorToLat(y) {
return (2 * Math.atan(Math.exp(y / R)) - Math.PI / 2) * (180 / Math.PI);
}
function lonLatOffset(lon, lat, dxMeters, dyMeters) {
const x = R * (lon * Math.PI / 180);
const y = R * Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI / 180) / 2));
const nx = x + dxMeters;
const ny = y + dyMeters;
return {
lon: mercatorToLon(nx),
lat: mercatorToLat(ny),
};
}
function worldToLonLat(x, y, state) {
const bbox = state?.world?.bbox;
if (bbox) {
const mercX = bbox.min_x + x;
const mercY = bbox.min_y + y;
return { lon: mercatorToLon(mercX), lat: mercatorToLat(mercY) };
}
const world = state?.world || { width: 1, height: 1 };
const dx = (x - world.width / 2) * GRID_METERS_PER_UNIT;
const dy = (y - world.height / 2) * GRID_METERS_PER_UNIT;
return lonLatOffset(gridCenter.lon, gridCenter.lat, dx, dy);
}
function syncEntities(map, items, color, pixelSize, state, prefix) {
const seen = new Set();
items.forEach((item) => {
const id = `${prefix}-${item.id}`;
seen.add(id);
const pos = worldToLonLat(item.x, item.y, state);
let entity = map.get(id);
if (!entity) {
entity = viewer.entities.add({
id,
position: Cesium.Cartesian3.fromDegrees(pos.lon, pos.lat, 2),
point: {
pixelSize,
color,
outlineColor: Cesium.Color.BLACK.withAlpha(0.6),
outlineWidth: 1,
},
});
map.set(id, entity);
} else {
entity.position = Cesium.Cartesian3.fromDegrees(pos.lon, pos.lat, 2);
}
});
map.forEach((entity, id) => {
if (!seen.has(id)) {
viewer.entities.remove(entity);
map.delete(id);
}
});
}
function updateMetrics(state) {
if (!state) return;
metricStep.textContent = state.step ?? "-";
metricResidents.textContent = state.counts?.residents ?? "-";
metricServices.textContent = state.counts?.services ?? "-";
metricServed.textContent = state.metrics?.served ?? "-";
metricAvg.textContent = state.metrics?.avg_distance?.toFixed(2) ?? "-";
metricMax.textContent = state.metrics?.max_distance?.toFixed(2) ?? "-";
}
function updateEntities(state) {
if (!state) return;
syncEntities(
serviceEntities,
state.services || [],
Cesium.Color.fromCssColorString("#ffd166"),
10,
state,
"service"
);
syncEntities(
residentEntities,
state.residents || [],
Cesium.Color.fromCssColorString("#64f3ff"),
6,
state,
"resident"
);
}
function flyToState(state) {
if (!state) return;
const bbox = state?.world?.bbox;
if (bbox) {
const west = mercatorToLon(bbox.min_x);
const east = mercatorToLon(bbox.max_x);
const south = mercatorToLat(bbox.min_y);
const north = mercatorToLat(bbox.max_y);
const rect = Cesium.Rectangle.fromDegrees(west, south, east, north);
viewer.camera.flyTo({ destination: rect });
return;
}
viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(gridCenter.lon, gridCenter.lat, 1200),
orientation: {
heading: Cesium.Math.toRadians(0),
pitch: Cesium.Math.toRadians(-45),
roll: 0,
},
});
}
async function apiPost(url, payload) {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload || {}),
});
const data = await response.json();
if (!response.ok || data.ok === false) {
const message = data.message || `Request failed: ${response.status}`;
throw new Error(message);
}
return data;
}
async function createRun() {
if (busy) return;
busy = true;
setStatus("Creating run...");
try {
gridCenter = {
lat: Number(inputLat.value) || 19.4326,
lon: Number(inputLon.value) || -99.1332,
};
const payload = {
residents: inputResidents.value,
services: inputServices.value,
speed: inputSpeed.value,
service_radius: inputRadius.value,
};
if (inputRoads.checked) {
payload.use_roads = true;
payload.lat = inputLat.value;
payload.lon = inputLon.value;
payload.dist = inputDist.value;
}
const data = await apiPost("/api/simulations/new", payload);
runId = data.run_id;
lastState = data.state;
updateMetrics(data.state);
updateEntities(data.state);
flyToState(data.state);
const mode = data.state?.world?.mode || "grid";
const roadError = data.state?.world?.road_error;
if (roadError) {
setStatus(`Roads fallback: ${roadError}`);
} else {
setStatus(`Run ${runId.slice(0, 8)} ready (${mode}).`);
}
} catch (err) {
setStatus(err.message || "Failed to create run.");
} finally {
busy = false;
}
}
async function stepRun() {
if (busy || !runId) return;
busy = true;
try {
const data = await apiPost("/api/simulations/step", { run_id: runId, steps: 1 });
lastState = data.state;
updateMetrics(data.state);
updateEntities(data.state);
setStatus(`Stepped to ${data.state.step}.`);
} catch (err) {
setStatus(err.message || "Failed to step.");
} finally {
busy = false;
}
}
function toggleAuto() {
if (autoTimer) {
clearInterval(autoTimer);
autoTimer = null;
btnAuto.textContent = "Auto Run";
setStatus("Auto run stopped.");
return;
}
if (!runId) {
setStatus("Create a run first.");
return;
}
btnAuto.textContent = "Stop Auto";
autoTimer = setInterval(() => {
stepRun();
}, 300);
setStatus("Auto run started.");
}
btnNew.addEventListener("click", () => {
if (autoTimer) {
clearInterval(autoTimer);
autoTimer = null;
btnAuto.textContent = "Auto Run";
}
createRun();
});
btnStep.addEventListener("click", () => {
stepRun();
});
btnAuto.addEventListener("click", () => {
toggleAuto();
});
createRun();
</script>
{% endblock %}

View File

@ -0,0 +1,256 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Waste Simulation (Cesium)</title>
<link
rel="stylesheet"
href="https://cesium.com/downloads/cesiumjs/releases/1.114/Build/Cesium/Widgets/widgets.css"
/>
<script src="https://cesium.com/downloads/cesiumjs/releases/1.114/Build/Cesium/Cesium.js"></script>
<style>
html, body, #cesiumContainer {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
background: #0b0f1a;
}
#toolbar {
position: absolute;
top: 12px;
left: 12px;
z-index: 10;
background: rgba(0, 0, 0, 0.65);
color: #fff;
padding: 12px 14px;
border-radius: 8px;
font-family: Arial, sans-serif;
font-size: 13px;
width: 320px;
}
#toolbar h4 {
margin: 0 0 8px 0;
font-size: 14px;
font-weight: 600;
}
#toolbar .field {
margin-top: 8px;
}
#toolbar .row {
display: flex;
justify-content: space-between;
gap: 8px;
}
#toolbar .metric {
display: flex;
justify-content: space-between;
padding: 2px 0;
}
#toolbar select {
width: 100%;
}
.legend {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 6px;
}
.legend span {
display: inline-flex;
align-items: center;
gap: 6px;
}
.swatch {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
}
.swatch.served { background: #50c878; }
.swatch.missed-time { background: #ff5a5a; }
.swatch.missed-capacity { background: #ffa63c; }
.status {
margin-top: 6px;
color: #d0d7e2;
font-size: 12px;
}
</style>
</head>
<body>
<div id="toolbar">
<h4>Waste Simulation (Cesium)</h4>
<div class="field">
<div>Subdivision: <span id="metaSubdivision"></span></div>
<div>Vehicle: <span id="metaVehicle"></span></div>
</div>
<div class="field">
<label for="basemap">Basemap</label>
<select id="basemap">
<option value="osm">OpenStreetMap</option>
<option value="esri">ESRI World Imagery</option>
<option value="carto_light">CARTO Light</option>
<option value="carto_dark">CARTO Dark</option>
</select>
</div>
<div class="field">
<div class="metric"><span>Bins</span><span id="metricBins">-</span></div>
<div class="metric"><span>Served</span><span id="metricServed">-</span></div>
<div class="metric"><span>Missed (time)</span><span id="metricMissedTime">-</span></div>
<div class="metric"><span>Missed (capacity)</span><span id="metricMissedCapacity">-</span></div>
<div class="metric"><span>Route</span><span id="metricRoute">-</span></div>
</div>
<div class="field">
<div class="metric"><span>Organic collected</span><span id="metricOrganic">-</span></div>
<div class="metric"><span>Recyclable collected</span><span id="metricRecyclable">-</span></div>
<div class="metric"><span>Landfill collected</span><span id="metricLandfill">-</span></div>
</div>
<div class="field legend">
<span><i class="swatch served"></i>Served</span>
<span><i class="swatch missed-time"></i>Missed time</span>
<span><i class="swatch missed-capacity"></i>Missed capacity</span>
</div>
<div id="status" class="status">Loading simulation...</div>
</div>
<div id="cesiumContainer"></div>
<script>
Cesium.Ion.defaultAccessToken = "{{ ion_token }}";
const subdivision = "{{ subdivision }}";
const vehicle = "{{ vehicle }}";
document.getElementById("metaSubdivision").textContent = subdivision;
document.getElementById("metaVehicle").textContent = vehicle;
const viewer = new Cesium.Viewer("cesiumContainer", {
imageryProvider: false,
animation: true,
timeline: true,
baseLayerPicker: false,
geocoder: false,
homeButton: false,
navigationHelpButton: false,
sceneModePicker: false,
infoBox: true,
selectionIndicator: true,
shouldAnimate: true,
});
const basemapSelect = document.getElementById("basemap");
const basemapLayers = {};
function getBasemapProvider(key) {
if (key === "osm") {
return new Cesium.OpenStreetMapImageryProvider({
url: "https://tile.openstreetmap.org/",
});
}
if (key === "esri") {
return new Cesium.UrlTemplateImageryProvider({
url: "https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
credit: "Esri",
});
}
if (key === "carto_light") {
return new Cesium.UrlTemplateImageryProvider({
url: "https://cartodb-basemaps-a.global.ssl.fastly.net/light_all/{z}/{x}/{y}.png",
credit: "CARTO",
});
}
if (key === "carto_dark") {
return new Cesium.UrlTemplateImageryProvider({
url: "https://cartodb-basemaps-a.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png",
credit: "CARTO",
});
}
return null;
}
function setBaseLayer(key) {
let layer = basemapLayers[key];
if (!layer) {
const provider = getBasemapProvider(key);
if (!provider) {
return;
}
layer = viewer.imageryLayers.addImageryProvider(provider);
basemapLayers[key] = layer;
}
Object.keys(basemapLayers).forEach((k) => {
basemapLayers[k].show = k === key;
});
viewer.imageryLayers.raiseToTop(layer);
}
basemapSelect.addEventListener("change", () => {
setBaseLayer(basemapSelect.value);
});
setBaseLayer(basemapSelect.value);
async function loadBuildings() {
try {
viewer.terrainProvider = await Cesium.createWorldTerrainAsync();
} catch (err) {
console.error("Failed to load terrain", err);
}
try {
const osmBuildings = await Cesium.createOsmBuildingsAsync();
viewer.scene.primitives.add(osmBuildings);
} catch (err) {
console.error("Failed to load OSM buildings", err);
}
}
function updateMetrics(metrics) {
if (!metrics) return;
document.getElementById("metricBins").textContent = metrics.bins_total ?? "-";
document.getElementById("metricServed").textContent = metrics.bins_served ?? "-";
document.getElementById("metricMissedTime").textContent = metrics.bins_missed_time ?? "-";
document.getElementById("metricMissedCapacity").textContent = metrics.bins_missed_capacity ?? "-";
if (metrics.route_km != null && metrics.route_minutes != null) {
document.getElementById("metricRoute").textContent = `${metrics.route_km} km / ${metrics.route_minutes} min`;
}
const waste = metrics.waste_collected || {};
document.getElementById("metricOrganic").textContent = waste.organic != null ? waste.organic.toFixed(1) : "-";
document.getElementById("metricRecyclable").textContent = waste.recyclable != null ? waste.recyclable.toFixed(1) : "-";
document.getElementById("metricLandfill").textContent = waste.landfill != null ? waste.landfill.toFixed(1) : "-";
}
async function loadSimulation() {
const statusEl = document.getElementById("status");
statusEl.textContent = "Loading simulation...";
try {
const resp = await fetch(`/api/simulations/waste/cesium/${encodeURIComponent(subdivision)}/${encodeURIComponent(vehicle)}/`);
const data = await resp.json();
if (!resp.ok || data.ok === false) {
throw new Error(data.message || "Failed to load simulation");
}
updateMetrics(data.metrics);
const czml = data.czml || [];
viewer.dataSources.removeAll();
const dataSource = await Cesium.CzmlDataSource.load(czml);
viewer.dataSources.add(dataSource);
if (dataSource.clock) {
viewer.clock.startTime = dataSource.clock.startTime.clone();
viewer.clock.stopTime = dataSource.clock.stopTime.clone();
viewer.clock.currentTime = dataSource.clock.currentTime.clone();
viewer.clock.multiplier = dataSource.clock.multiplier || 10;
viewer.clock.clockRange = Cesium.ClockRange.LOOP_STOP;
viewer.timeline.zoomTo(viewer.clock.startTime, viewer.clock.stopTime);
}
await viewer.flyTo(dataSource);
statusEl.textContent = "Click bins to inspect waste and timing.";
} catch (err) {
console.error(err);
statusEl.textContent = err.message || "Simulation failed.";
}
}
loadBuildings();
loadSimulation();
</script>
</body>
</html>

20
pxy_simulations/urls.py Normal file
View File

@ -0,0 +1,20 @@
from django.urls import path
from . import views
urlpatterns = [
path("simulations/mvp/", views.simulation_mvp, name="simulations_mvp"),
path(
"simulations/waste/cesium/<str:subdivision>/<str:vehicle>/",
views.simulation_waste_cesium,
name="simulations_waste_cesium",
),
path("api/simulations/health", views.simulations_health, name="simulations_health"),
path("api/simulations/new", views.simulations_new, name="simulations_new"),
path("api/simulations/step", views.simulations_step, name="simulations_step"),
path("api/simulations/state", views.simulations_state, name="simulations_state"),
path(
"api/simulations/waste/cesium/<str:subdivision>/<str:vehicle>/",
views.simulations_waste_cesium_data,
name="simulations_waste_cesium_data",
),
]

96
pxy_simulations/views.py Normal file
View File

@ -0,0 +1,96 @@
from __future__ import annotations
from django.shortcuts import render
from django.conf import settings
from rest_framework.decorators import api_view, throttle_classes
from rest_framework.response import Response
from rest_framework import status
from .services.sim_engine import create_run, get_run
from .services.waste_sim import build_waste_cesium_payload
CESIUM_ION_FALLBACK = (
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9."
"eyJqdGkiOiJkZmM5ZmE1MS0yNWFlLTQ1YjEtOGZiNy1lMmFlOGYyYTI0MjYiLCJpZCI6Mzc0NDg3LCJpYXQiOjE3Njc0MTAxNzN9."
"tFBLet8CEmvfCivzZM5ExfHL752iPQIShw6Pqa49Gkw"
)
CESIUM_ION_TOKEN = getattr(settings, "CESIUM_ION_TOKEN", "") or CESIUM_ION_FALLBACK
def simulation_mvp(request):
return render(request, "pxy_simulations/simulation_mvp.html", {"ion_token": CESIUM_ION_TOKEN})
def _err(code: str, message: str, http_status: int = 400):
return Response({"ok": False, "code": code, "message": message}, status=http_status)
def simulation_waste_cesium(request, subdivision, vehicle):
context = {
"subdivision": subdivision,
"vehicle": vehicle,
"ion_token": CESIUM_ION_TOKEN,
}
return render(request, "pxy_simulations/waste_cesium.html", context)
@api_view(["GET"])
@throttle_classes([])
def simulations_health(request):
return Response({"ok": True, "service": "simulations"})
@api_view(["POST"])
@throttle_classes([])
def simulations_new(request):
run = create_run(request.data or {})
return Response({"ok": True, "run_id": run.run_id, "state": run.state(include_roads=True)})
@api_view(["POST"])
@throttle_classes([])
def simulations_step(request):
payload = request.data or {}
run_id = (payload.get("run_id") or "").strip()
if not run_id:
return _err("missing_run_id", "run_id is required")
run = get_run(run_id)
if run is None:
return _err("not_found", "run not found", http_status=status.HTTP_404_NOT_FOUND)
try:
steps = int(payload.get("steps", 1))
except (TypeError, ValueError):
steps = 1
steps = max(1, min(steps, 200))
run.step(steps=steps)
return Response({"ok": True, "run_id": run.run_id, "state": run.state(include_roads=False)})
@api_view(["GET"])
@throttle_classes([])
def simulations_state(request):
run_id = (request.query_params.get("run_id") or "").strip()
if not run_id:
return _err("missing_run_id", "run_id is required")
run = get_run(run_id)
if run is None:
return _err("not_found", "run not found", http_status=status.HTTP_404_NOT_FOUND)
return Response({"ok": True, "run_id": run.run_id, "state": run.state(include_roads=False)})
@api_view(["GET"])
@throttle_classes([])
def simulations_waste_cesium_data(request, subdivision, vehicle):
try:
payload = build_waste_cesium_payload(subdivision, vehicle, request.query_params.dict())
except ValueError as exc:
return _err("not_found", str(exc), http_status=status.HTTP_404_NOT_FOUND)
except Exception as exc:
return _err("error", str(exc), http_status=status.HTTP_500_INTERNAL_SERVER_ERROR)
return Response({"ok": True, **payload})