Simulation MVP and Waste management
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
parent
c8034ee5d2
commit
a271b43318
@ -64,6 +64,7 @@ INSTALLED_APPS = [
|
|||||||
'pxy_sami',
|
'pxy_sami',
|
||||||
'pxy_routing',
|
'pxy_routing',
|
||||||
'pxy_sites',
|
'pxy_sites',
|
||||||
|
"pxy_simulations",
|
||||||
|
|
||||||
"rest_framework",
|
"rest_framework",
|
||||||
"pxy_api",
|
"pxy_api",
|
||||||
|
|||||||
@ -53,6 +53,7 @@ urlpatterns = [
|
|||||||
path("", include("pxy_contracts.urls")),
|
path("", include("pxy_contracts.urls")),
|
||||||
|
|
||||||
path('', include('pxy_agents_coral.urls')),
|
path('', include('pxy_agents_coral.urls')),
|
||||||
|
path("", include("pxy_simulations.urls")),
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -63,4 +64,4 @@ urlpatterns = [
|
|||||||
|
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
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)
|
||||||
|
|||||||
0
pxy_simulations/__init__.py
Normal file
0
pxy_simulations/__init__.py
Normal file
6
pxy_simulations/apps.py
Normal file
6
pxy_simulations/apps.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class PxySimulationsConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "pxy_simulations"
|
||||||
0
pxy_simulations/services/__init__.py
Normal file
0
pxy_simulations/services/__init__.py
Normal file
508
pxy_simulations/services/sim_engine.py
Normal file
508
pxy_simulations/services/sim_engine.py
Normal 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)
|
||||||
400
pxy_simulations/services/waste_sim.py
Normal file
400
pxy_simulations/services/waste_sim.py
Normal 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,
|
||||||
|
},
|
||||||
|
}
|
||||||
456
pxy_simulations/templates/pxy_simulations/simulation_mvp.html
Normal file
456
pxy_simulations/templates/pxy_simulations/simulation_mvp.html
Normal 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 %}
|
||||||
256
pxy_simulations/templates/pxy_simulations/waste_cesium.html
Normal file
256
pxy_simulations/templates/pxy_simulations/waste_cesium.html
Normal 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
20
pxy_simulations/urls.py
Normal 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
96
pxy_simulations/views.py
Normal 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})
|
||||||
Loading…
x
Reference in New Issue
Block a user