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_routing',
|
||||
'pxy_sites',
|
||||
"pxy_simulations",
|
||||
|
||||
"rest_framework",
|
||||
"pxy_api",
|
||||
|
||||
@ -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)
|
||||
|
||||
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