from __future__ import annotations import json import mimetypes import os import queue import re import threading import time import urllib.parse from http import HTTPStatus from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from typing import Any, Callable import simpy from tern import Tern from transition import NODES_N, TRANSITIONS def active_edges() -> set[tuple[str, str, str]]: """Graph the live engine drives.""" return NODES_N PIN_ID = "0" DEFAULT_HOST = "127.0.0.1" DEFAULT_PORT = 8765 FRONTEND_DIR = Path(__file__).with_name("frontend").joinpath("dist") SESSION_ID_RE = re.compile(r"[^a-zA-Z0-9_-]+") def normalize_pin_value(value: object) -> str: text = str(value if value is not None else "N").strip() if text in {"1", "+"} or text.lower() == "true": return "+" if text in {"-1", "0", "-"} or text.lower() == "false": return "0" if text in {"", "N", "?", "unknown"}: return "N" return text def tern_event_value(value: Tern) -> str: match value: case Tern.Y: return "+" case Tern.N: return "0" case Tern.U: return "N" def event_value_to_tern(value: object) -> Tern: match normalize_pin_value(value): case "+": return Tern.Y case "0": return Tern.N case _: return Tern.U def snapshot_values(values: dict[str, Tern]) -> dict[str, str]: return {name: tern_event_value(value) for name, value in sorted(values.items())} def graph_topology(values: dict[str, Tern]) -> dict[str, Any]: current_values = snapshot_values(values) edges: list[dict[str, str]] = [] for index, (nn, uu, yy) in enumerate(sorted(active_edges())): edges.append( { "id": f"{uu}->{nn}:0:{index}", "source": uu, "target": nn, "label": "0", } ) edges.append( { "id": f"{uu}->{yy}:1:{index}", "source": uu, "target": yy, "label": "+", } ) return { "nodes": [ { "id": name, "label": name, "pins": [PIN_ID], "value": value, } for name, value in current_values.items() ], "edges": edges, "values": current_values, } def transition_char(value: object) -> str: """Event value ("+"/"0"/"N") -> TRANSITIONS char ("1"/"0"/"N").""" return str(event_value_to_tern(value)) def char_event_value(char: str) -> str: """TRANSITIONS char ("1"/"0"/"N") -> event value ("+"/"0"/"N").""" return tern_event_value(Tern.from_string(char)) def derive_triples(topology: dict[str, Any]) -> list[tuple[str, str, str]]: """Invert graph_topology(): a node with a "0" out-edge (-> nn) and a "+" out-edge (-> yy) is a transition node, yielding the triple (nn, source, yy). """ zero_target: dict[str, str] = {} plus_target: dict[str, str] = {} for edge in topology.get("edges", []): source = edge.get("source") target = edge.get("target") if not source or not target: continue label = normalize_pin_value(edge.get("label", "")) if label == "0": zero_target.setdefault(source, target) elif label == "+": plus_target.setdefault(source, target) triples = { (zero_target[source], source, plus_target[source]) for source in zero_target if source in plus_target } return sorted(triples, key=lambda triple: (triple[1], triple[0], triple[2])) class EventHub: def __init__(self): self._lock = threading.Lock() self._subscribers: set[queue.Queue[dict[str, Any]]] = set() self._latest: dict[str, Any] | None = None def subscribe(self) -> queue.Queue[dict[str, Any]]: events: queue.Queue[dict[str, Any]] = queue.Queue(maxsize=100) with self._lock: self._subscribers.add(events) if self._latest is not None: events.put_nowait(self._latest) return events def unsubscribe(self, events: queue.Queue[dict[str, Any]]) -> None: with self._lock: self._subscribers.discard(events) def publish(self, event: dict[str, Any]) -> None: with self._lock: self._latest = event subscribers = list(self._subscribers) for events in subscribers: try: events.put_nowait(event) except queue.Full: try: _ = events.get_nowait() except queue.Empty: pass try: events.put_nowait(event) except queue.Full: pass class GraphSession: def __init__( self, session_id: str, name: str, topology: dict[str, Any], *, editable: bool = True, kind: str = "user", on_value_update: Callable[[str, str, str], None] | None = None, ): self.id = session_id self.name = name self.kind = kind self.editable = editable self.created_at = time.time() self.event_hub = EventHub() self._lock = threading.Lock() self._topology = self._normalize_topology(topology) self._values = self._values_from_topology(self._topology, topology.get("values", {})) self._frame = 0 self._on_value_update = on_value_update self._triples = derive_triples(self._topology) self.steppable = kind != "simpy" and bool(self._triples) self._publish_initial() def summary(self) -> dict[str, Any]: with self._lock: return { "id": self.id, "name": self.name, "kind": self.kind, "editable": self.editable, "steppable": self.steppable, "createdAt": self.created_at, "frame": self._frame, "nodeCount": len(self._topology["nodes"]), "edgeCount": len(self._topology["edges"]), } def graph(self) -> dict[str, Any]: with self._lock: topology = json.loads(json.dumps(self._topology)) values = self._public_values() for node in topology["nodes"]: node_values = self._values.get(node["id"], {}) node["value"] = node_values.get(PIN_ID, "N") topology["values"] = values return topology def set_value(self, node_id: str, pin_id: str, value: object, *, time_value: float | None = None) -> dict[str, Any]: normalized_value = normalize_pin_value(value) with self._lock: if not self.editable: raise ValueError("session is not editable") node = self._ensure_node(node_id, pin_id) old_value = node.get(pin_id, "N") if old_value == normalized_value: return { "frame": self._frame, "time": time_value, "changed": [], } node[pin_id] = normalized_value self._frame += 1 event = { "frame": self._frame, "changed": [ { "node": node_id, "pin": pin_id, "from": old_value, "to": normalized_value, } ], } if time_value is not None: event["time"] = time_value if self._on_value_update is not None: self._on_value_update(node_id, pin_id, normalized_value) self.event_hub.publish(event) return event def step(self, *, time_value: float | None = None) -> dict[str, Any]: """Advance the simulation by one tick across all derived triples. Triples are applied in deterministic order, each reading the current (possibly already-mutated) values — mirroring the live engine's sequential, shared-state update within a tick. """ with self._lock: if not self.steppable: raise ValueError("session is not steppable") changed: list[dict[str, str]] = [] for nn, uu, yy in self._triples: old = "".join(transition_char(self._ensure_node(name, PIN_ID)[PIN_ID]) for name in (nn, uu, yy)) new = TRANSITIONS.get(old) if new is None: continue for name, char in zip((nn, uu, yy), new): node = self._ensure_node(name, PIN_ID) next_value = char_event_value(char) old_value = node[PIN_ID] if old_value == next_value: continue node[PIN_ID] = next_value changed.append({"node": name, "pin": PIN_ID, "from": old_value, "to": next_value}) self._frame += 1 event: dict[str, Any] = {"frame": self._frame, "changed": changed} if time_value is not None: event["time"] = time_value self.event_hub.publish(event) return event def sync_external_values( self, values: dict[str, str], *, frame: int, time_value: float, changed: list[dict[str, str]], ) -> None: with self._lock: self._frame = frame for node, value in values.items(): self._ensure_node(node, PIN_ID)[PIN_ID] = normalize_pin_value(value) if changed: self.event_hub.publish( { "frame": frame, "time": time_value, "changed": changed, } ) def _publish_initial(self) -> None: changed = [] with self._lock: for node, pins in sorted(self._values.items()): for pin, value in sorted(pins.items()): changed.append( { "node": node, "pin": pin, "from": value, "to": value, } ) self.event_hub.publish( { "frame": 0, "changed": changed, } ) def _ensure_node(self, node_id: str, pin_id: str) -> dict[str, str]: if node_id not in self._values: self._values[node_id] = {} self._topology["nodes"].append( { "id": node_id, "label": node_id, "pins": [pin_id], "value": "N", } ) if pin_id not in self._values[node_id]: self._values[node_id][pin_id] = "N" node_definition = next((node for node in self._topology["nodes"] if node["id"] == node_id), None) if node_definition is not None: pins = node_definition.setdefault("pins", []) if pin_id not in pins: pins.append(pin_id) return self._values[node_id] def _public_values(self) -> dict[str, str | dict[str, str]]: result: dict[str, str | dict[str, str]] = {} for node, pins in self._values.items(): if set(pins) == {PIN_ID}: result[node] = pins[PIN_ID] else: result[node] = dict(sorted(pins.items())) return result def _normalize_topology(self, topology: dict[str, Any]) -> dict[str, Any]: nodes_by_id: dict[str, dict[str, Any]] = {} for raw_node in topology.get("nodes", []): node_id = str(raw_node.get("id", "")).strip() if not node_id: continue pins = [str(pin) for pin in raw_node.get("pins", [PIN_ID]) if str(pin)] nodes_by_id[node_id] = { "id": node_id, "label": str(raw_node.get("label") or node_id), "pins": pins or [PIN_ID], "value": normalize_pin_value(raw_node.get("value", "N")), } edges: list[dict[str, str]] = [] for index, raw_edge in enumerate(topology.get("edges", [])): source = str(raw_edge.get("source", "")).strip() target = str(raw_edge.get("target", "")).strip() if not source or not target: continue nodes_by_id.setdefault(source, {"id": source, "label": source, "pins": [PIN_ID], "value": "N"}) nodes_by_id.setdefault(target, {"id": target, "label": target, "pins": [PIN_ID], "value": "N"}) edge_id = str(raw_edge.get("id") or f"{source}->{target}:{index}") edges.append( { "id": edge_id, "source": source, "target": target, "label": str(raw_edge.get("label") or ""), } ) return { "nodes": [nodes_by_id[node_id] for node_id in sorted(nodes_by_id)], "edges": edges, } def _values_from_topology(self, topology: dict[str, Any], raw_values: object) -> dict[str, dict[str, str]]: values: dict[str, dict[str, str]] = {} for node in topology["nodes"]: node_id = node["id"] values[node_id] = {} raw_value = raw_values.get(node_id, node.get("value", "N")) if isinstance(raw_values, dict) else node.get("value", "N") if isinstance(raw_value, dict): for pin, value in raw_value.items(): values[node_id][str(pin)] = normalize_pin_value(value) for pin in node.get("pins", [PIN_ID]): values[node_id].setdefault(str(pin), normalize_pin_value(raw_value if not isinstance(raw_value, dict) else "N")) return values class SessionRegistry: def __init__(self, simpy_values: dict[str, Tern]): self._lock = threading.Lock() self._sessions: dict[str, GraphSession] = {} self.simpy_values = simpy_values self.simpy_session = GraphSession( "simpy", "SimPy live", graph_topology(simpy_values), kind="simpy", on_value_update=self._update_simpy_value, ) self._sessions[self.simpy_session.id] = self.simpy_session def list_sessions(self) -> dict[str, Any]: with self._lock: sessions = [session.summary() for session in self._sessions.values()] return {"sessions": sorted(sessions, key=lambda session: (session["kind"] != "simpy", session["createdAt"]))} def get(self, session_id: str) -> GraphSession: with self._lock: session = self._sessions.get(session_id) if session is None: raise KeyError(session_id) return session def create(self, name: str, topology: dict[str, Any]) -> GraphSession: base_id = SESSION_ID_RE.sub("-", name.strip().lower()).strip("-") or "graph" session_id = base_id with self._lock: suffix = 2 while session_id in self._sessions: session_id = f"{base_id}-{suffix}" suffix += 1 session = GraphSession(session_id, name.strip() or session_id, topology) self._sessions[session_id] = session return session def sync_simpy(self, values: dict[str, str], *, frame: int, time_value: float, changed: list[dict[str, str]]) -> None: self.simpy_session.sync_external_values(values, frame=frame, time_value=time_value, changed=changed) def _update_simpy_value(self, node_id: str, pin_id: str, value: str) -> None: if pin_id != PIN_ID: return if node_id in self.simpy_values: self.simpy_values[node_id] = event_value_to_tern(value) class GraphHTTPServer(ThreadingHTTPServer): allow_reuse_address = True daemon_threads = True def __init__(self, address: tuple[str, int], values: dict[str, Tern], registry: SessionRegistry): super().__init__(address, GraphRequestHandler) self.values = values self.registry = registry self.frontend_dir = FRONTEND_DIR.resolve() class GraphRequestHandler(BaseHTTPRequestHandler): server: GraphHTTPServer def do_OPTIONS(self): self.send_response(HTTPStatus.NO_CONTENT) self._send_cors_headers() self.end_headers() def do_GET(self): path = urllib.parse.urlparse(self.path).path if path == "/api/graph": self._send_json(self.server.registry.get("simpy").graph()) return if path == "/api/events": self._send_events(self.server.registry.get("simpy")) return if path == "/api/sessions": self._send_json(self.server.registry.list_sessions()) return session_id, action = self._session_route(path) if session_id and action == "graph": try: self._send_json(self.server.registry.get(session_id).graph()) except KeyError: self._send_error_json(HTTPStatus.NOT_FOUND, "session not found") return if session_id and action == "events": try: self._send_events(self.server.registry.get(session_id)) except KeyError: self._send_error_json(HTTPStatus.NOT_FOUND, "session not found") return self._send_static(path) def do_POST(self): path = urllib.parse.urlparse(self.path).path if path == "/api/sessions": try: payload = self._read_json() except ValueError as error: self._send_error_json(HTTPStatus.BAD_REQUEST, str(error)) return topology = payload.get("graph") if not isinstance(topology, dict): self._send_error_json(HTTPStatus.BAD_REQUEST, "expected graph topology") return session = self.server.registry.create(str(payload.get("name") or "Graph"), topology) self._send_json({"session": session.summary(), "graph": session.graph()}, status=HTTPStatus.CREATED) return session_id, action = self._session_route(path) if session_id and action == "step": try: event = self.server.registry.get(session_id).step() except KeyError: self._send_error_json(HTTPStatus.NOT_FOUND, "session not found") return except ValueError as error: self._send_error_json(HTTPStatus.BAD_REQUEST, str(error)) return self._send_json({"event": event}) return self._send_error_json(HTTPStatus.NOT_FOUND, "unknown endpoint") def do_PATCH(self): path = urllib.parse.urlparse(self.path).path session_id, action = self._session_route(path) if session_id and action == "values": try: payload = self._read_json() except ValueError as error: self._send_error_json(HTTPStatus.BAD_REQUEST, str(error)) return try: event = self.server.registry.get(session_id).set_value( str(payload.get("node") or ""), str(payload.get("pin") or PIN_ID), payload.get("value", "N"), ) except KeyError: self._send_error_json(HTTPStatus.NOT_FOUND, "session not found") return except ValueError as error: self._send_error_json(HTTPStatus.BAD_REQUEST, str(error)) return self._send_json({"event": event}) return self._send_error_json(HTTPStatus.NOT_FOUND, "unknown endpoint") def log_message(self, format: str, *args: object) -> None: return def _session_route(self, path: str) -> tuple[str | None, str | None]: parts = [urllib.parse.unquote(part) for part in path.strip("/").split("/")] if len(parts) == 4 and parts[:2] == ["api", "sessions"]: return parts[2], parts[3] return None, None def _read_json(self) -> dict[str, Any]: length = int(self.headers.get("Content-Length", "0")) body = self.rfile.read(length).decode("utf-8") if length else "{}" try: payload = json.loads(body) except json.JSONDecodeError as error: raise ValueError(f"invalid JSON: {error}") from error if not isinstance(payload, dict): raise ValueError("expected JSON object") return payload def _send_json(self, payload: dict[str, Any], *, status: HTTPStatus = HTTPStatus.OK) -> None: body = json.dumps(payload, ensure_ascii=True).encode("utf-8") self.send_response(status) self._send_cors_headers() self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def _send_error_json(self, status: HTTPStatus, message: str) -> None: self._send_json({"error": message}, status=status) def _send_events(self, session: GraphSession) -> None: self.send_response(HTTPStatus.OK) self._send_cors_headers() self.send_header("Content-Type", "text/event-stream; charset=utf-8") self.send_header("Cache-Control", "no-cache") self.send_header("Connection", "keep-alive") self.send_header("X-Accel-Buffering", "no") self.end_headers() events = session.event_hub.subscribe() try: while True: try: event = events.get(timeout=15) self._write_sse("frame", event) except queue.Empty: self.wfile.write(b": ping\n\n") self.wfile.flush() except (BrokenPipeError, ConnectionResetError, OSError): pass finally: session.event_hub.unsubscribe(events) def _write_sse(self, event_name: str, payload: dict[str, Any]) -> None: data = json.dumps(payload, ensure_ascii=True) self.wfile.write(f"event: {event_name}\n".encode("utf-8")) self.wfile.write(f"data: {data}\n\n".encode("utf-8")) self.wfile.flush() def _send_static(self, path: str) -> None: if not self.server.frontend_dir.exists(): self._send_missing_frontend() return requested = "/index.html" if path == "/" else path target = (self.server.frontend_dir / requested.lstrip("/")).resolve() try: target.relative_to(self.server.frontend_dir) except ValueError: self.send_error(HTTPStatus.FORBIDDEN) return if target.is_dir(): target = target / "index.html" if not target.exists(): target = self.server.frontend_dir / "index.html" if not target.exists(): self._send_missing_frontend() return content = target.read_bytes() content_type = mimetypes.guess_type(target.name)[0] or "application/octet-stream" if target.suffix == ".js": content_type = "text/javascript" self.send_response(HTTPStatus.OK) self.send_header("Content-Type", content_type) self.send_header("Content-Length", str(len(content))) self.end_headers() self.wfile.write(content) def _send_missing_frontend(self) -> None: body = ( "Frontend is not built" "
" "

Frontend is not built

" "

Run cd frontend && npm install && npm run build.

" "
" ).encode("utf-8") self.send_response(HTTPStatus.SERVICE_UNAVAILABLE) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def _send_cors_headers(self) -> None: self.send_header("Access-Control-Allow-Origin", "*") self.send_header("Access-Control-Allow-Methods", "GET, POST, PATCH, OPTIONS") self.send_header("Access-Control-Allow-Headers", "Content-Type") _server_lock = threading.Lock() _server: GraphHTTPServer | None = None _server_url: str | None = None _registry: SessionRegistry | None = None def ensure_graph_server(values: dict[str, Tern]) -> str: global _registry global _server global _server_url with _server_lock: if _server is not None and _server_url is not None: return _server_url host = os.environ.get("SIMPY_TERNS_GRAPH_HOST", DEFAULT_HOST) port = int(os.environ.get("SIMPY_TERNS_GRAPH_PORT", DEFAULT_PORT)) _registry = SessionRegistry(values) for candidate_port in range(port, port + 50): try: server = GraphHTTPServer((host, candidate_port), values, _registry) break except OSError: continue else: raise RuntimeError(f"could not bind graph server starting at {host}:{port}") _server = server _server_url = f"http://{host}:{server.server_port}" threading.Thread( target=server.serve_forever, name="graph-http-server", daemon=True, ).start() return _server_url def render_graph_process( env: simpy.Environment, values: dict[str, Tern], ): url = ensure_graph_server(values) print(f"[graph] {url}") last_values: dict[str, str] | None = None frame_number = 0 while True: current_values = snapshot_values(values) if last_values is None: changed = [ {"node": node, "pin": PIN_ID, "from": value, "to": value} for node, value in current_values.items() ] else: changed = [ { "node": node, "pin": PIN_ID, "from": last_values.get(node, "N"), "to": value, } for node, value in current_values.items() if last_values.get(node) != value ] if changed: frame_number += 1 if _registry is not None: _registry.sync_simpy( current_values, frame=frame_number, time_value=env.now, changed=changed, ) last_values = current_values yield env.timeout(0.1)