From a271b4331854c853781255df5b803f5b9d64d8c5 Mon Sep 17 00:00:00 2001 From: Ekaropolus Date: Sat, 3 Jan 2026 03:10:53 -0600 Subject: [PATCH] Simulation MVP and Waste management --- polisplexity/settings.py | 1 + polisplexity/urls.py | 3 +- pxy_simulations/__init__.py | 0 pxy_simulations/apps.py | 6 + pxy_simulations/services/__init__.py | 0 pxy_simulations/services/sim_engine.py | 508 ++++++++++++++++++ pxy_simulations/services/waste_sim.py | 400 ++++++++++++++ .../pxy_simulations/simulation_mvp.html | 456 ++++++++++++++++ .../pxy_simulations/waste_cesium.html | 256 +++++++++ pxy_simulations/urls.py | 20 + pxy_simulations/views.py | 96 ++++ 11 files changed, 1745 insertions(+), 1 deletion(-) create mode 100644 pxy_simulations/__init__.py create mode 100644 pxy_simulations/apps.py create mode 100644 pxy_simulations/services/__init__.py create mode 100644 pxy_simulations/services/sim_engine.py create mode 100644 pxy_simulations/services/waste_sim.py create mode 100644 pxy_simulations/templates/pxy_simulations/simulation_mvp.html create mode 100644 pxy_simulations/templates/pxy_simulations/waste_cesium.html create mode 100644 pxy_simulations/urls.py create mode 100644 pxy_simulations/views.py diff --git a/polisplexity/settings.py b/polisplexity/settings.py index cc287a6..d1a23d1 100644 --- a/polisplexity/settings.py +++ b/polisplexity/settings.py @@ -64,6 +64,7 @@ INSTALLED_APPS = [ 'pxy_sami', 'pxy_routing', 'pxy_sites', + "pxy_simulations", "rest_framework", "pxy_api", diff --git a/polisplexity/urls.py b/polisplexity/urls.py index 4d2787e..e2ad43b 100644 --- a/polisplexity/urls.py +++ b/polisplexity/urls.py @@ -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) \ No newline at end of file + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/pxy_simulations/__init__.py b/pxy_simulations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pxy_simulations/apps.py b/pxy_simulations/apps.py new file mode 100644 index 0000000..f6070e7 --- /dev/null +++ b/pxy_simulations/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class PxySimulationsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "pxy_simulations" diff --git a/pxy_simulations/services/__init__.py b/pxy_simulations/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pxy_simulations/services/sim_engine.py b/pxy_simulations/services/sim_engine.py new file mode 100644 index 0000000..a6b022d --- /dev/null +++ b/pxy_simulations/services/sim_engine.py @@ -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) diff --git a/pxy_simulations/services/waste_sim.py b/pxy_simulations/services/waste_sim.py new file mode 100644 index 0000000..c76fd24 --- /dev/null +++ b/pxy_simulations/services/waste_sim.py @@ -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"" + f"" + f"" + f"" + f"" + f"" + f"" + f"" + f"
Status{b['status']}
Arrival{b['arrival_s'] / 60.0:.1f} min
Ready{b['ready_s'] / 60.0:.1f} min
Walk{b['walk_s'] / 60.0:.1f} min
Organic{b['waste']['organic']}
Recyclable{b['waste']['recyclable']}
Landfill{b['waste']['landfill']}
" + ) + 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, + }, + } diff --git a/pxy_simulations/templates/pxy_simulations/simulation_mvp.html b/pxy_simulations/templates/pxy_simulations/simulation_mvp.html new file mode 100644 index 0000000..dfc8ae3 --- /dev/null +++ b/pxy_simulations/templates/pxy_simulations/simulation_mvp.html @@ -0,0 +1,456 @@ +{% extends "pxy_dashboard/partials/base.html" %} +{% load static %} + +{% block title %}Simulation MVP (Cesium){% endblock %} + +{% block extra_css %} + + +{% endblock %} + +{% block content %} +
+
+
+

Simulation MVP (Cesium)

+
Residents move toward their nearest service node, now rendered in Cesium.
+
+
+ +
+
+
+
+
Live State
+
+
+
+
+
+
+ +
+
+
+
Controls
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + + +
+
Ready.
+
+
+ +
+
+
Metrics
+
+
+
Step-
+
Residents-
+
Services-
+
Served-
+
Avg Distance-
+
Max Distance-
+
+
+
+
+
+{% endblock %} + +{% block extra_js %} + + +{% endblock %} diff --git a/pxy_simulations/templates/pxy_simulations/waste_cesium.html b/pxy_simulations/templates/pxy_simulations/waste_cesium.html new file mode 100644 index 0000000..69f7b52 --- /dev/null +++ b/pxy_simulations/templates/pxy_simulations/waste_cesium.html @@ -0,0 +1,256 @@ + + + + + Waste Simulation (Cesium) + + + + + +
+

Waste Simulation (Cesium)

+
+
Subdivision:
+
Vehicle:
+
+
+ + +
+
+
Bins-
+
Served-
+
Missed (time)-
+
Missed (capacity)-
+
Route-
+
+
+
Organic collected-
+
Recyclable collected-
+
Landfill collected-
+
+
+ Served + Missed time + Missed capacity +
+
Loading simulation...
+
+
+ + + + diff --git a/pxy_simulations/urls.py b/pxy_simulations/urls.py new file mode 100644 index 0000000..67232c2 --- /dev/null +++ b/pxy_simulations/urls.py @@ -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///", + 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///", + views.simulations_waste_cesium_data, + name="simulations_waste_cesium_data", + ), +] diff --git a/pxy_simulations/views.py b/pxy_simulations/views.py new file mode 100644 index 0000000..0c2e305 --- /dev/null +++ b/pxy_simulations/views.py @@ -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})