"
+
+
+def write_dashboard_html(run_dir: str | Path, *, robot_control_token: str | None = None) -> Path:
+ root = Path(run_dir)
+ state = _read_json(root / "state.json")
+ report = _read_json(root / "report.json")
+ authoring = load_map_authoring(
+ root,
+ site_id=str((state.get("site") or {}).get("site_id") or ""),
+ ).model_dump(mode="json")
+ html_path = root / "dashboard.html"
+ html_path.write_text(
+ render_dashboard_html(
+ state,
+ report,
+ robot_control_token=robot_control_token,
+ authoring=authoring,
+ qr_events=get_latest_qr_events(root),
+ ),
+ encoding="utf-8",
+ )
+ return html_path
+
+
+def _read_json(path: Path) -> dict[str, Any]:
+ return json.loads(path.read_text(encoding="utf-8"))
+
+
+def _latest_fact_value(observations: list[dict[str, Any]], key: str) -> object | None:
+ for observation in reversed(observations):
+ facts = observation.get("facts") or {}
+ if key in facts:
+ return facts[key]
+ return None
+
+
+def _to_float(value: object) -> float | None:
+ if isinstance(value, bool):
+ return None
+ if isinstance(value, int | float):
+ return float(value)
+ if isinstance(value, str):
+ try:
+ return float(value)
+ except ValueError:
+ return None
+ return None
+
+
+def _to_bool(value: object) -> bool | None:
+ if isinstance(value, bool):
+ return value
+ if isinstance(value, str):
+ normalized = value.strip().lower()
+ if normalized in {"true", "yes", "1", "clear"}:
+ return True
+ if normalized in {"false", "no", "0", "blocked"}:
+ return False
+ return None
diff --git a/dimos/experimental/dogops/detector.py b/dimos/experimental/dogops/detector.py
new file mode 100644
index 0000000000..35c7f2a99a
--- /dev/null
+++ b/dimos/experimental/dogops/detector.py
@@ -0,0 +1,159 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+from dimos.experimental.dogops.config_loader import DEFAULT_SITE, load_site_config
+from dimos.experimental.dogops.models import SiteConfig
+from dimos.experimental.dogops.tag_registry import DogOpsTagRegistry, TagRegistration
+
+
+@dataclass(frozen=True)
+class DetectedTag:
+ tag_id: int
+ entity_id: str | None
+ entity_kind: str | None
+ zone_id: str | None
+ corners: tuple[tuple[float, float], ...] = ()
+ center_px: tuple[float, float] | None = None
+ area_px: float | None = None
+ frame_id: str | None = None
+ confidence: float = 1.0
+ source: str = "simulation"
+
+
+class DogOpsTagDetector:
+ def __init__(self, site: SiteConfig | None = None) -> None:
+ self.site = site or load_site_config(DEFAULT_SITE)
+ self.registry = DogOpsTagRegistry(self.site)
+
+ def detect_simulated(self, tag_ids: list[int], *, source: str = "simulation") -> list[DetectedTag]:
+ return [self._detected_from_registration(tag_id, source=source) for tag_id in tag_ids]
+
+ def detect_image(self, image_path: str | Path) -> list[DetectedTag]:
+ cv2 = _import_cv2()
+ image = cv2.imread(str(image_path))
+ if image is None:
+ raise ValueError(f"failed to read image: {image_path}")
+ return self.detect_array(image, source="cv2.imread")
+
+ def detect_dimos_image(self, image: Any) -> list[DetectedTag]:
+ array, frame_id = _opencv_array_from_image(image)
+ return self.detect_array(array, source="dimos.color_image", frame_id=frame_id)
+
+ def detect_array(
+ self,
+ image: Any,
+ *,
+ source: str = "cv2.aruco",
+ frame_id: str | None = None,
+ ) -> list[DetectedTag]:
+ cv2 = _import_cv2()
+ aruco = cv2.aruco
+ dictionary = aruco.getPredefinedDictionary(aruco.DICT_APRILTAG_36h11)
+ parameters = aruco.DetectorParameters()
+ gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) if len(image.shape) == 3 else image
+ if hasattr(aruco, "ArucoDetector"):
+ detector = aruco.ArucoDetector(dictionary, parameters)
+ corners, ids, _ = detector.detectMarkers(gray)
+ else:
+ corners, ids, _ = aruco.detectMarkers(gray, dictionary, parameters=parameters)
+ if ids is None:
+ return []
+ detections: list[DetectedTag] = []
+ for index, raw_id in enumerate(ids.flatten().tolist()):
+ tag_corners = tuple(
+ (float(point[0]), float(point[1])) for point in corners[index].reshape(-1, 2)
+ )
+ detections.append(
+ self._detected_from_registration(
+ int(raw_id),
+ corners=tag_corners,
+ source=source,
+ frame_id=frame_id,
+ )
+ )
+ return detections
+
+ def _detected_from_registration(
+ self,
+ tag_id: int,
+ *,
+ corners: tuple[tuple[float, float], ...] = (),
+ frame_id: str | None = None,
+ source: str,
+ ) -> DetectedTag:
+ registration = self.registry.get(tag_id)
+ return _detected_tag(
+ tag_id,
+ registration,
+ corners=corners,
+ frame_id=frame_id,
+ source=source,
+ )
+
+
+def _detected_tag(
+ tag_id: int,
+ registration: TagRegistration | None,
+ *,
+ corners: tuple[tuple[float, float], ...],
+ frame_id: str | None,
+ source: str,
+) -> DetectedTag:
+ center_px = _corner_center(corners)
+ area_px = _corner_area(corners)
+ return DetectedTag(
+ tag_id=tag_id,
+ entity_id=registration.entity_id if registration else None,
+ entity_kind=registration.entity_kind if registration else None,
+ zone_id=registration.zone_id if registration else None,
+ corners=corners,
+ center_px=center_px,
+ area_px=area_px,
+ frame_id=frame_id,
+ confidence=1.0,
+ source=source,
+ )
+
+
+def _opencv_array_from_image(image: Any) -> tuple[Any, str | None]:
+ frame_id = getattr(image, "frame_id", None)
+ if hasattr(image, "to_opencv"):
+ return image.to_opencv(), frame_id
+ data = getattr(image, "data", None)
+ if data is not None and hasattr(data, "shape"):
+ return data, frame_id
+ return image, frame_id
+
+
+def _corner_center(corners: tuple[tuple[float, float], ...]) -> tuple[float, float] | None:
+ if not corners:
+ return None
+ x = sum(point[0] for point in corners) / len(corners)
+ y = sum(point[1] for point in corners) / len(corners)
+ return (x, y)
+
+
+def _corner_area(corners: tuple[tuple[float, float], ...]) -> float | None:
+ if len(corners) < 3:
+ return None
+ area = 0.0
+ for index, point in enumerate(corners):
+ next_point = corners[(index + 1) % len(corners)]
+ area += point[0] * next_point[1] - next_point[0] * point[1]
+ return abs(area) / 2.0
+
+
+def _import_cv2() -> Any:
+ try:
+ import cv2
+ except ModuleNotFoundError as exc:
+ raise RuntimeError(
+ "OpenCV aruco support is not installed. Install the optional vision extra: "
+ "uv run --extra vision ..."
+ ) from exc
+ if not hasattr(cv2, "aruco"):
+ raise RuntimeError("Installed OpenCV package does not include cv2.aruco")
+ return cv2
diff --git a/dimos/experimental/dogops/gemini_vision.py b/dimos/experimental/dogops/gemini_vision.py
new file mode 100644
index 0000000000..a210d20d04
--- /dev/null
+++ b/dimos/experimental/dogops/gemini_vision.py
@@ -0,0 +1,287 @@
+from __future__ import annotations
+
+import base64
+import json
+import os
+from pathlib import Path
+from typing import Any, Literal
+from urllib import error, request
+
+from pydantic import Field, ValidationError, field_validator
+
+from dimos.experimental.dogops.models import DogOpsModel
+
+
+GEMINI_API_URL = "https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent"
+SUPPORTED_INLINE_IMAGE_MIME_TYPES = {"image/jpeg", "image/png", "image/webp"}
+
+
+class GeminiImageInspection(DogOpsModel):
+ schema_version: int = 1
+ ok: bool
+ summary: str
+ current_description: str
+ baseline_description: str | None = None
+ changed: bool
+ change_summary: str
+ change_type: Literal[
+ "no_change",
+ "object_added",
+ "object_removed",
+ "moved",
+ "damaged",
+ "blocked",
+ "unclear",
+ "other",
+ ]
+ severity: Literal["info", "p3", "p2", "p1"]
+ confidence: float
+ observations: list[str] = Field(default_factory=list)
+ possible_incident: bool = False
+ recommended_action: str = ""
+ compared_evidence_ids: list[str] = Field(default_factory=list)
+ limitations: list[str] = Field(default_factory=list)
+
+ @field_validator("confidence")
+ @classmethod
+ def confidence_in_range(cls, value: float) -> float:
+ result = float(value)
+ if result < 0.0:
+ return 0.0
+ if result > 1.0:
+ return 1.0
+ return result
+
+
+class GeminiVisionResult(DogOpsModel):
+ ok: bool
+ status: Literal[
+ "completed",
+ "gemini_unavailable",
+ "image_missing",
+ "unsupported_mime_type",
+ "request_too_large",
+ "api_error",
+ "invalid_response",
+ ]
+ message: str
+ model: str
+ inspection: GeminiImageInspection | None = None
+ raw_response: dict[str, Any] | None = None
+
+
+def gemini_image_inspection_schema() -> dict[str, Any]:
+ return {
+ "type": "object",
+ "properties": {
+ "schema_version": {"type": "integer"},
+ "ok": {"type": "boolean"},
+ "summary": {"type": "string"},
+ "current_description": {"type": "string"},
+ "baseline_description": {"type": ["string", "null"]},
+ "changed": {"type": "boolean"},
+ "change_summary": {"type": "string"},
+ "change_type": {
+ "type": "string",
+ "enum": [
+ "no_change",
+ "object_added",
+ "object_removed",
+ "moved",
+ "damaged",
+ "blocked",
+ "unclear",
+ "other",
+ ],
+ },
+ "severity": {"type": "string", "enum": ["info", "p3", "p2", "p1"]},
+ "confidence": {"type": "number"},
+ "observations": {"type": "array", "items": {"type": "string"}},
+ "possible_incident": {"type": "boolean"},
+ "recommended_action": {"type": "string"},
+ "compared_evidence_ids": {"type": "array", "items": {"type": "string"}},
+ "limitations": {"type": "array", "items": {"type": "string"}},
+ },
+ "required": [
+ "ok",
+ "summary",
+ "current_description",
+ "changed",
+ "change_summary",
+ "change_type",
+ "severity",
+ "confidence",
+ "observations",
+ "possible_incident",
+ "recommended_action",
+ "limitations",
+ ],
+ }
+
+
+def inspect_images_with_gemini(
+ *,
+ current_image_path: str | Path,
+ current_mime_type: str,
+ current_evidence_id: str,
+ prompt: str,
+ model: str = "gemini-2.5-flash",
+ baseline_image_path: str | Path | None = None,
+ baseline_mime_type: str | None = None,
+ baseline_evidence_id: str | None = None,
+ max_image_bytes_inline: int = 20_000_000,
+ route_context: dict[str, Any] | None = None,
+ api_key: str | None = None,
+ timeout_s: float = 30.0,
+) -> GeminiVisionResult:
+ api_key = api_key if api_key is not None else os.environ.get("GEMINI_API_KEY")
+ if not api_key:
+ return GeminiVisionResult(
+ ok=False,
+ status="gemini_unavailable",
+ message="GEMINI_API_KEY is not configured",
+ model=model,
+ )
+
+ current_path = Path(current_image_path)
+ if not current_path.exists() or not current_path.is_file():
+ return GeminiVisionResult(
+ ok=False,
+ status="image_missing",
+ message="current image evidence file is missing",
+ model=model,
+ )
+ if current_mime_type not in SUPPORTED_INLINE_IMAGE_MIME_TYPES:
+ return GeminiVisionResult(
+ ok=False,
+ status="unsupported_mime_type",
+ message=f"unsupported current image MIME type: {current_mime_type}",
+ model=model,
+ )
+
+ image_parts: list[dict[str, Any]] = []
+ try:
+ image_parts.append(_inline_image_part(current_path, current_mime_type, max_image_bytes_inline))
+ if baseline_image_path is not None:
+ baseline_path = Path(baseline_image_path)
+ if not baseline_path.exists() or not baseline_path.is_file():
+ return GeminiVisionResult(
+ ok=False,
+ status="image_missing",
+ message="baseline image evidence file is missing",
+ model=model,
+ )
+ baseline_type = baseline_mime_type or "application/octet-stream"
+ if baseline_type not in SUPPORTED_INLINE_IMAGE_MIME_TYPES:
+ return GeminiVisionResult(
+ ok=False,
+ status="unsupported_mime_type",
+ message=f"unsupported baseline image MIME type: {baseline_type}",
+ model=model,
+ )
+ image_parts.append(_inline_image_part(baseline_path, baseline_type, max_image_bytes_inline))
+ except ValueError as exc:
+ return GeminiVisionResult(
+ ok=False,
+ status="request_too_large",
+ message=str(exc),
+ model=model,
+ )
+
+ request_payload = {
+ "contents": [
+ {
+ "parts": [
+ {
+ "text": _prompt_text(
+ prompt=prompt,
+ has_baseline=baseline_image_path is not None,
+ route_context=route_context or {},
+ )
+ },
+ *image_parts,
+ ]
+ }
+ ],
+ "generationConfig": {
+ "responseMimeType": "application/json",
+ "responseJsonSchema": gemini_image_inspection_schema(),
+ },
+ }
+ http_request = request.Request(
+ GEMINI_API_URL.format(model=model),
+ data=json.dumps(request_payload).encode("utf-8"),
+ headers={
+ "Content-Type": "application/json",
+ "x-goog-api-key": api_key,
+ },
+ method="POST",
+ )
+ try:
+ with request.urlopen(http_request, timeout=timeout_s) as response:
+ raw = json.loads(response.read().decode("utf-8"))
+ except (error.HTTPError, error.URLError, TimeoutError, json.JSONDecodeError) as exc:
+ return GeminiVisionResult(
+ ok=False,
+ status="api_error",
+ message=f"Gemini request failed: {exc.__class__.__name__}",
+ model=model,
+ )
+
+ try:
+ text = raw["candidates"][0]["content"]["parts"][0]["text"]
+ parsed = json.loads(text)
+ parsed["compared_evidence_ids"] = [
+ item
+ for item in [current_evidence_id, baseline_evidence_id]
+ if item
+ ]
+ inspection = GeminiImageInspection.model_validate(parsed)
+ except (KeyError, IndexError, TypeError, json.JSONDecodeError, ValidationError) as exc:
+ return GeminiVisionResult(
+ ok=False,
+ status="invalid_response",
+ message=f"Gemini returned invalid structured output: {exc.__class__.__name__}",
+ model=model,
+ raw_response=raw if isinstance(raw, dict) else None,
+ )
+
+ return GeminiVisionResult(
+ ok=True,
+ status="completed",
+ message=inspection.summary,
+ model=model,
+ inspection=inspection,
+ raw_response=raw,
+ )
+
+
+def _inline_image_part(path: Path, mime_type: str, max_image_bytes_inline: int) -> dict[str, Any]:
+ size = path.stat().st_size
+ if size > max_image_bytes_inline:
+ raise ValueError(f"image evidence exceeds inline Gemini limit: {size} bytes")
+ return {
+ "inlineData": {
+ "mimeType": mime_type,
+ "data": base64.b64encode(path.read_bytes()).decode("ascii"),
+ }
+ }
+
+
+def _prompt_text(*, prompt: str, has_baseline: bool, route_context: dict[str, Any]) -> str:
+ comparison_instruction = (
+ "Compare the current image with the baseline image from the same waypoint."
+ if has_baseline
+ else "Inspect only the current image because no baseline was available."
+ )
+ return "\n".join(
+ [
+ prompt,
+ comparison_instruction,
+ "Be conservative. Do not infer identities or people.",
+ "Do not claim a change unless visible evidence supports it.",
+ "Use facility, asset, package, and work-order language.",
+ "Return only JSON matching the provided schema.",
+ f"Route context JSON: {json.dumps(route_context, sort_keys=True)}",
+ ]
+ )
diff --git a/dimos/experimental/dogops/heatmap_runs.py b/dimos/experimental/dogops/heatmap_runs.py
new file mode 100644
index 0000000000..b934aaf0aa
--- /dev/null
+++ b/dimos/experimental/dogops/heatmap_runs.py
@@ -0,0 +1,333 @@
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from collections.abc import Callable
+import time
+from types import SimpleNamespace
+from typing import Any, Literal
+
+from pydantic import Field
+
+from dimos.experimental.dogops.models import DogOpsModel
+from dimos.experimental.dogops.route_run_store import RouteRunStore, new_route_run_id
+
+
+HEATMAP_RUN_ID = "GATHER_HEATMAP"
+HEATMAP_RUN_LABEL = "Gather Heatmap"
+HEATMAP_DIRNAME = "heatmaps"
+LATEST_HEATMAP_FILENAME = "latest_heatmap.json"
+MAX_GATHER_DURATION_S = 30.0
+DEFAULT_SAMPLE_INTERVAL_S = 1.0
+
+
+class HeatmapRunEvent(DogOpsModel):
+ id: str
+ ts: float
+ kind: str = "heatmap"
+ state: str
+ waypoint_id: str | None = None
+ action_id: str | None = None
+ target_id: str | None = None
+ x: float | None = None
+ y: float | None = None
+ error_m: float | None = None
+ retries: int = 0
+ guided: bool = False
+ payload: dict[str, Any] = Field(default_factory=dict)
+ note: str = ""
+
+
+class HeatmapRunState(DogOpsModel):
+ run_id: str
+ route_run_id: str
+ route_id: str = HEATMAP_RUN_ID
+ state: Literal["running", "completed", "failed"]
+ started_at: float
+ completed_at: float | None = None
+ frame: str = "map"
+ transport: str = "dimos_costmap_snapshot"
+ active_waypoint_id: str | None = None
+ active_action_id: str | None = None
+ waypoints_total: int = 0
+ waypoints_reached: int = 0
+ last_error: str | None = None
+ reach_radius_m: float = 0.0
+ waypoint_timeout_s: float = 0.0
+ max_retries: int = 0
+ events: list[HeatmapRunEvent] = Field(default_factory=list)
+
+
+def gather_heatmap_run(
+ run_dir: str | Path,
+ *,
+ live_snapshot: dict[str, Any],
+ live_snapshot_reader: Callable[[], dict[str, Any]] | None = None,
+ area_id: str = "",
+ duration_s: float = 0.0,
+ sample_interval_s: float = DEFAULT_SAMPLE_INTERVAL_S,
+ sleep_fn: Callable[[float], None] = time.sleep,
+ now: float | None = None,
+) -> dict[str, Any]:
+ root = Path(run_dir)
+ started_at = now or time.time()
+ route_run_id = new_route_run_id(HEATMAP_RUN_ID, now=started_at)
+ duration_s = min(max(0.0, float(duration_s or 0.0)), MAX_GATHER_DURATION_S)
+ snapshots = _sample_live_snapshots(
+ live_snapshot,
+ live_snapshot_reader=live_snapshot_reader,
+ duration_s=duration_s,
+ sample_interval_s=sample_interval_s,
+ sleep_fn=sleep_fn,
+ )
+ live_snapshot = _merge_live_snapshots(snapshots)
+ costmap = live_snapshot.get("costmap") if isinstance(live_snapshot, dict) else None
+ cells = costmap.get("cells") if isinstance(costmap, dict) else None
+ has_costmap = isinstance(cells, list) and bool(cells)
+ state = HeatmapRunState(
+ run_id=root.name,
+ route_run_id=route_run_id,
+ state="running",
+ started_at=started_at,
+ frame=str((costmap or {}).get("frame") or "map"),
+ transport="dimos_costmap_snapshot",
+ events=[
+ HeatmapRunEvent(
+ id="heatmap-started",
+ ts=started_at,
+ state="started",
+ target_id=area_id or None,
+ payload={"area_id": area_id, "duration_s": duration_s},
+ note="Gather heatmap run started",
+ )
+ ],
+ )
+ route = SimpleNamespace(
+ id=HEATMAP_RUN_ID,
+ label=HEATMAP_RUN_LABEL,
+ mission_id="gather_heatmap",
+ )
+ route_snapshot = {
+ "id": HEATMAP_RUN_ID,
+ "label": HEATMAP_RUN_LABEL,
+ "mission_id": "gather_heatmap",
+ "run_kind": "gather_heatmap",
+ "area_id": area_id,
+ "duration_s": duration_s,
+ "waypoints": [],
+ }
+ store = RouteRunStore(root)
+ store.create_route_run(
+ route_run_id=route_run_id,
+ dogops_run_id=root.name,
+ route=route,
+ state=state,
+ dry_run=False,
+ route_snapshot=route_snapshot,
+ )
+
+ completed_at = time.time()
+ state.completed_at = completed_at
+ if has_costmap:
+ snapshot = _heatmap_snapshot_payload(
+ route_run_id=route_run_id,
+ area_id=area_id,
+ duration_s=duration_s,
+ live_snapshot=live_snapshot,
+ costmap=costmap,
+ collected_at=completed_at,
+ )
+ snapshot_path = _write_heatmap_snapshot(root, route_run_id, snapshot)
+ state.state = "completed"
+ state.events.append(
+ HeatmapRunEvent(
+ id="heatmap-collected",
+ ts=completed_at,
+ state="completed",
+ target_id=area_id or None,
+ payload={
+ "area_id": area_id,
+ "cells": len(cells),
+ "path": str(snapshot_path),
+ "source": costmap.get("source") or live_snapshot.get("source"),
+ },
+ note=f"Gathered heatmap with {len(cells)} cells",
+ )
+ )
+ store.sync_execution_state(state)
+ evidence = store.record_evidence(
+ route_run_id=route_run_id,
+ event_id=f"{route_run_id}-heatmap-collected",
+ observation_id=None,
+ kind="costmap_snapshot",
+ path=snapshot_path,
+ mime_type="application/json",
+ metadata={
+ "area_id": area_id,
+ "cells": len(cells),
+ "source": costmap.get("source") or live_snapshot.get("source"),
+ },
+ )
+ return {
+ "ok": True,
+ "run_kind": "gather_heatmap",
+ "route_run_id": route_run_id,
+ "state": state.model_dump(mode="json"),
+ "heatmap": snapshot,
+ "snapshot_path": str(snapshot_path),
+ "evidence": evidence,
+ }
+
+ state.state = "failed"
+ state.last_error = "No live DimOS costmap cells are available to gather."
+ state.events.append(
+ HeatmapRunEvent(
+ id="heatmap-failed",
+ ts=completed_at,
+ state="failed",
+ target_id=area_id or None,
+ payload={"area_id": area_id, "live_status": live_snapshot.get("status")},
+ note=state.last_error,
+ )
+ )
+ store.sync_execution_state(state)
+ return {
+ "ok": False,
+ "run_kind": "gather_heatmap",
+ "error": "heatmap_unavailable",
+ "message": state.last_error,
+ "route_run_id": route_run_id,
+ "state": state.model_dump(mode="json"),
+ }
+
+
+def latest_heatmap_snapshot(run_dir: str | Path) -> dict[str, Any] | None:
+ path = Path(run_dir) / HEATMAP_DIRNAME / LATEST_HEATMAP_FILENAME
+ return _read_heatmap_snapshot(path)
+
+
+def heatmap_snapshot_for_route_run(
+ run_dir: str | Path,
+ route_run_id: str,
+ *,
+ evidence: list[dict[str, Any]] | None = None,
+) -> dict[str, Any] | None:
+ root = Path(run_dir)
+ candidates = [
+ Path(str(item.get("path")))
+ for item in evidence or []
+ if item.get("kind") == "costmap_snapshot" and item.get("path")
+ ]
+ candidates.append(root / HEATMAP_DIRNAME / f"{route_run_id}.json")
+ for path in candidates:
+ snapshot = _read_heatmap_snapshot(path)
+ if snapshot is not None:
+ return snapshot
+ return None
+
+
+def _read_heatmap_snapshot(path: Path) -> dict[str, Any] | None:
+ if not path.exists():
+ return None
+ try:
+ payload = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ return None
+ return payload if isinstance(payload, dict) else None
+
+
+def _write_heatmap_snapshot(
+ run_dir: Path,
+ route_run_id: str,
+ snapshot: dict[str, Any],
+) -> Path:
+ heatmap_dir = run_dir / HEATMAP_DIRNAME
+ heatmap_dir.mkdir(parents=True, exist_ok=True)
+ snapshot_path = heatmap_dir / f"{route_run_id}.json"
+ raw = json.dumps(snapshot, indent=2, sort_keys=True) + "\n"
+ snapshot_path.write_text(raw, encoding="utf-8")
+ (heatmap_dir / LATEST_HEATMAP_FILENAME).write_text(raw, encoding="utf-8")
+ return snapshot_path
+
+
+def _heatmap_snapshot_payload(
+ *,
+ route_run_id: str,
+ area_id: str,
+ duration_s: float,
+ live_snapshot: dict[str, Any],
+ costmap: dict[str, Any],
+ collected_at: float,
+) -> dict[str, Any]:
+ return {
+ "schema_version": 1,
+ "run_kind": "gather_heatmap",
+ "route_run_id": route_run_id,
+ "area_id": area_id,
+ "duration_s": duration_s,
+ "collected_at": collected_at,
+ "source": costmap.get("source") or live_snapshot.get("source") or "DimOS live costmap",
+ "status": live_snapshot.get("status"),
+ "robot_pose": live_snapshot.get("robot_pose"),
+ "target": live_snapshot.get("target"),
+ "costmap": costmap,
+ }
+
+
+def _sample_live_snapshots(
+ initial_snapshot: dict[str, Any],
+ *,
+ live_snapshot_reader: Callable[[], dict[str, Any]] | None,
+ duration_s: float,
+ sample_interval_s: float,
+ sleep_fn: Callable[[float], None],
+) -> list[dict[str, Any]]:
+ snapshots = [initial_snapshot]
+ if live_snapshot_reader is None or duration_s <= 0:
+ return snapshots
+ interval = max(0.1, float(sample_interval_s or DEFAULT_SAMPLE_INTERVAL_S))
+ deadline = time.time() + duration_s
+ while True:
+ remaining = deadline - time.time()
+ if remaining <= 0:
+ break
+ sleep_fn(min(interval, remaining))
+ try:
+ snapshot = live_snapshot_reader()
+ except Exception:
+ continue
+ if isinstance(snapshot, dict):
+ snapshots.append(snapshot)
+ return snapshots
+
+
+def _merge_live_snapshots(snapshots: list[dict[str, Any]]) -> dict[str, Any]:
+ if not snapshots:
+ return {}
+ latest = dict(snapshots[-1])
+ costmaps = [
+ snapshot.get("costmap")
+ for snapshot in snapshots
+ if isinstance(snapshot.get("costmap"), dict)
+ ]
+ if not costmaps:
+ return latest
+ cells_by_key: dict[tuple[float, float, float, float], dict[str, Any]] = {}
+ for costmap in costmaps:
+ for cell in costmap.get("cells") or []:
+ if not isinstance(cell, dict):
+ continue
+ key = (
+ round(float(cell.get("x") or 0.0), 4),
+ round(float(cell.get("y") or 0.0), 4),
+ round(float(cell.get("width") or 0.0), 4),
+ round(float(cell.get("height") or 0.0), 4),
+ )
+ previous = cells_by_key.get(key)
+ if previous is None or float(cell.get("cost") or 0.0) > float(previous.get("cost") or 0.0):
+ cells_by_key[key] = dict(cell)
+ merged_costmap = dict(costmaps[-1])
+ merged_costmap["cells"] = list(cells_by_key.values())
+ latest["costmap"] = merged_costmap
+ latest["samples"] = len(snapshots)
+ return latest
diff --git a/dimos/experimental/dogops/live_camera.py b/dimos/experimental/dogops/live_camera.py
new file mode 100644
index 0000000000..95bcb00e43
--- /dev/null
+++ b/dimos/experimental/dogops/live_camera.py
@@ -0,0 +1,138 @@
+from __future__ import annotations
+
+import base64
+import os
+from pathlib import Path
+import sys
+import threading
+import time
+from typing import Any
+
+
+DEFAULT_DIMOS_ROOT = os.environ.get("DIMOS_ROOT", "")
+DEFAULT_COLOR_IMAGE_TOPIC = os.environ.get("DOGOPS_CAMERA_TOPIC", "/color_image")
+LIVE_CAMERA_MAX_AGE_S = 5.0
+
+
+class DogOpsLiveCameraAdapter:
+ """Best-effort bridge from the DimOS color_image topic into dashboard JPEGs."""
+
+ def __init__(self, *, topic: str = DEFAULT_COLOR_IMAGE_TOPIC) -> None:
+ self.topic = topic
+ self._lock = threading.RLock()
+ self._started = False
+ self._error = ""
+ self._unsubscribe: Any | None = None
+ self._transport: Any | None = None
+ self._latest: tuple[float, Any] | None = None
+
+ def status(self) -> dict[str, Any]:
+ self.start()
+ with self._lock:
+ latest = self._latest
+ error = self._error
+ now = time.time()
+ age_s = now - latest[0] if latest is not None else None
+ fresh = age_s is not None and age_s <= LIVE_CAMERA_MAX_AGE_S
+ frame = latest[1] if fresh else None
+ return {
+ "ok": fresh,
+ "source": "DimOS color_image",
+ "topic": self.topic,
+ "status": "receiving" if fresh else "stale_frame" if latest is not None else "waiting_for_frame",
+ "error": error,
+ "received": fresh,
+ "stale": latest is not None and not fresh,
+ "age_s": round(age_s, 3) if age_s is not None else None,
+ "width": int(getattr(frame, "width", 0) or 0) if frame is not None else None,
+ "height": int(getattr(frame, "height", 0) or 0) if frame is not None else None,
+ "format": _format_name(getattr(frame, "format", None)) if frame is not None else None,
+ "frame_id": str(getattr(frame, "frame_id", "") or "") if frame is not None else "",
+ }
+
+ def frame_jpeg(self, *, quality: int = 75) -> bytes | None:
+ self.start()
+ with self._lock:
+ latest = self._latest
+ if latest is None or time.time() - latest[0] > LIVE_CAMERA_MAX_AGE_S:
+ return None
+ frame = latest[1]
+ if hasattr(frame, "to_base64"):
+ return base64.b64decode(frame.to_base64(quality=quality))
+ raise TypeError(f"latest color_image frame is not a DimOS Image: {type(frame)!r}")
+
+ def start(self) -> None:
+ with self._lock:
+ if self._started:
+ return
+ self._started = True
+ try:
+ JpegLcmTransport, Image = _import_dimos_camera_types()
+ transport = JpegLcmTransport(self.topic, Image)
+ unsubscribe = transport.subscribe(self._record)
+ except Exception as exc:
+ with self._lock:
+ self._error = (
+ "DimOS camera imports or subscription unavailable in this Python environment. "
+ f"Run the dashboard from the full DimOS checkout/env or set DOGOPS_CAMERA_TOPIC. {exc}"
+ )
+ return
+
+ with self._lock:
+ self._transport = transport
+ self._unsubscribe = unsubscribe
+
+ def _record(self, msg: Any) -> None:
+ with self._lock:
+ self._latest = (time.time(), msg)
+ self._error = ""
+
+ def stop(self) -> None:
+ with self._lock:
+ unsubscribe = self._unsubscribe
+ transport = self._transport
+ self._unsubscribe = None
+ self._transport = None
+ self._latest = None
+ self._started = False
+ if unsubscribe is not None:
+ try:
+ unsubscribe()
+ except Exception:
+ pass
+ if transport is not None:
+ try:
+ transport.stop()
+ except Exception:
+ pass
+
+
+def _import_dimos_camera_types() -> tuple[Any, Any]:
+ try:
+ from dimos.core.transport import JpegLcmTransport
+ from dimos.msgs.sensor_msgs.Image import Image
+ except ModuleNotFoundError:
+ _extend_dimos_package_path()
+ from dimos.core.transport import JpegLcmTransport
+ from dimos.msgs.sensor_msgs.Image import Image
+ return JpegLcmTransport, Image
+
+
+def _extend_dimos_package_path() -> None:
+ if not DEFAULT_DIMOS_ROOT:
+ return
+ dimos_root = Path(DEFAULT_DIMOS_ROOT).expanduser()
+ package_root = dimos_root / "dimos"
+ if not package_root.exists():
+ return
+ if str(dimos_root) not in sys.path:
+ sys.path.append(str(dimos_root))
+ import dimos
+
+ package_path = getattr(dimos, "__path__", None)
+ if package_path is not None and str(package_root) not in package_path:
+ package_path.append(str(package_root))
+
+
+def _format_name(value: Any) -> str:
+ return str(getattr(value, "value", value) or "")
diff --git a/dimos/experimental/dogops/live_map.py b/dimos/experimental/dogops/live_map.py
new file mode 100644
index 0000000000..cbaa189b11
--- /dev/null
+++ b/dimos/experimental/dogops/live_map.py
@@ -0,0 +1,275 @@
+from __future__ import annotations
+
+import math
+import os
+from pathlib import Path
+import sys
+import threading
+import time
+from typing import Any
+
+
+LIVE_TOPICS = {
+ "global_costmap": "/global_costmap",
+ "navigation_costmap": "/navigation_costmap",
+ "odom": "/odom",
+ "path": "/path",
+ "target": "/target",
+ "goal_request": "/goal_request",
+ "clicked_point": "/clicked_point",
+}
+LIVE_TOPIC_MAX_AGE_S = 5.0
+
+
+class DogOpsLiveMapAdapter:
+ """Bridge DimOS navigation topics into the DogOps mission-map payload."""
+
+ def __init__(self) -> None:
+ self._lock = threading.RLock()
+ self._started = False
+ self._error = ""
+ self._unsubscribers: list[Any] = []
+ self._transports: list[Any] = []
+ self._latest: dict[str, tuple[float, Any]] = {}
+
+ def snapshot(self) -> dict[str, Any]:
+ self.start()
+ with self._lock:
+ recorded = dict(self._latest)
+ error = self._error
+ now = time.time()
+ latest = {
+ name: item
+ for name, item in recorded.items()
+ if now - item[0] <= LIVE_TOPIC_MAX_AGE_S
+ }
+ topics = {
+ name: {
+ "topic": topic,
+ "received": name in latest,
+ "age_s": round(now - recorded[name][0], 3) if name in recorded else None,
+ "stale": name in recorded and name not in latest,
+ }
+ for name, topic in LIVE_TOPICS.items()
+ }
+ costmap_msg = _latest_first(latest, "navigation_costmap", "global_costmap")
+ path_msg = _latest_value(latest, "path")
+ odom_msg = _latest_value(latest, "odom")
+ target_msg = _latest_first(latest, "target", "goal_request", "clicked_point")
+ path = _path_to_points(path_msg) if path_msg is not None else []
+ ok = any(item["received"] for item in topics.values())
+ return {
+ "ok": ok,
+ "source": "DimOS live LCM topics",
+ "status": "receiving" if ok else "waiting_for_topics",
+ "error": error,
+ "topics": topics,
+ "costmap": _grid_to_costmap(costmap_msg) if costmap_msg is not None else None,
+ "path": path,
+ "route": _path_to_route(path),
+ "robot_pose": _pose_to_map_pose(odom_msg, source="odom") if odom_msg is not None else None,
+ "target": _pose_to_map_pose(target_msg, source="target") if target_msg is not None else None,
+ }
+
+ def start(self) -> None:
+ with self._lock:
+ if self._started:
+ return
+ self._started = True
+ try:
+ LCMTransport, OccupancyGrid, Path, PoseStamped, PointStamped = _import_dimos_topic_types()
+ except Exception as exc:
+ with self._lock:
+ self._error = (
+ "DimOS topic imports unavailable in this Python environment. "
+ f"Run from the full DimOS checkout/env or install its deps. {exc}"
+ )
+ return
+
+ specs = {
+ "global_costmap": (LIVE_TOPICS["global_costmap"], OccupancyGrid),
+ "navigation_costmap": (LIVE_TOPICS["navigation_costmap"], OccupancyGrid),
+ "odom": (LIVE_TOPICS["odom"], PoseStamped),
+ "path": (LIVE_TOPICS["path"], Path),
+ "target": (LIVE_TOPICS["target"], PoseStamped),
+ "goal_request": (LIVE_TOPICS["goal_request"], PoseStamped),
+ "clicked_point": (LIVE_TOPICS["clicked_point"], PointStamped),
+ }
+ for name, (topic, msg_type) in specs.items():
+ try:
+ transport = LCMTransport(topic, msg_type)
+ unsubscribe = transport.subscribe(lambda msg, item=name: self._record(item, msg))
+ self._unsubscribers.append(unsubscribe)
+ self._transports.append(transport)
+ except Exception as exc:
+ with self._lock:
+ self._error = f"Failed subscribing to {topic}: {exc}"
+
+ def stop(self) -> None:
+ with self._lock:
+ unsubscribers = list(self._unsubscribers)
+ transports = list(self._transports)
+ self._unsubscribers.clear()
+ self._transports.clear()
+ self._latest.clear()
+ self._started = False
+ for unsubscribe in unsubscribers:
+ try:
+ unsubscribe()
+ except Exception:
+ pass
+ for transport in transports:
+ try:
+ transport.stop()
+ except Exception:
+ pass
+
+ def _record(self, name: str, msg: Any) -> None:
+ with self._lock:
+ self._latest[name] = (time.time(), msg)
+
+
+def _import_dimos_topic_types() -> tuple[Any, Any, Any, Any, Any]:
+ try:
+ from dimos.core.transport import LCMTransport
+ from dimos.msgs.geometry_msgs.PointStamped import PointStamped
+ from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped
+ from dimos.msgs.nav_msgs.OccupancyGrid import OccupancyGrid
+ from dimos.msgs.nav_msgs.Path import Path
+ except ModuleNotFoundError:
+ _extend_dimos_package_path()
+ from dimos.core.transport import LCMTransport
+ from dimos.msgs.geometry_msgs.PointStamped import PointStamped
+ from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped
+ from dimos.msgs.nav_msgs.OccupancyGrid import OccupancyGrid
+ from dimos.msgs.nav_msgs.Path import Path
+ return LCMTransport, OccupancyGrid, Path, PoseStamped, PointStamped
+
+
+def _extend_dimos_package_path() -> None:
+ root = os.environ.get("DIMOS_ROOT")
+ if not root:
+ return
+ dimos_root = Path(root).expanduser()
+ package_root = dimos_root / "dimos"
+ if not package_root.exists():
+ return
+ if str(dimos_root) not in sys.path:
+ sys.path.append(str(dimos_root))
+ import dimos
+
+ package_path = getattr(dimos, "__path__", None)
+ if package_path is not None and str(package_root) not in package_path:
+ package_path.append(str(package_root))
+
+
+def _latest_value(latest: dict[str, tuple[float, Any]], name: str) -> Any | None:
+ item = latest.get(name)
+ return item[1] if item is not None else None
+
+
+def _latest_first(latest: dict[str, tuple[float, Any]], *names: str) -> Any | None:
+ available = [latest[name] for name in names if name in latest]
+ if not available:
+ return None
+ return max(available, key=lambda item: item[0])[1]
+
+
+def _grid_to_costmap(msg: Any, *, max_columns: int = 48, max_rows: int = 32) -> dict[str, Any]:
+ width = int(getattr(msg, "width", 0) or getattr(getattr(msg, "info", None), "width", 0) or 0)
+ height = int(getattr(msg, "height", 0) or getattr(getattr(msg, "info", None), "height", 0) or 0)
+ resolution = float(
+ getattr(msg, "resolution", 0.05)
+ or getattr(getattr(msg, "info", None), "resolution", 0.05)
+ or 0.05
+ )
+ origin = getattr(getattr(msg, "info", None), "origin", None) or getattr(msg, "origin", None)
+ origin_x = float(getattr(getattr(origin, "position", None), "x", 0.0) or 0.0)
+ origin_y = float(getattr(getattr(origin, "position", None), "y", 0.0) or 0.0)
+ grid = getattr(msg, "grid", None)
+ columns = min(max_columns, width) if width > 0 else 0
+ rows = min(max_rows, height) if height > 0 else 0
+ if grid is None or width <= 0 or height <= 0 or columns <= 0 or rows <= 0:
+ return {"source": "DimOS live costmap", "columns": 0, "rows": 0, "cells": []}
+
+ cells: list[dict[str, float]] = []
+ for row in range(rows):
+ y0 = math.floor(row * height / rows)
+ y1 = math.floor((row + 1) * height / rows)
+ for column in range(columns):
+ x0 = math.floor(column * width / columns)
+ x1 = math.floor((column + 1) * width / columns)
+ cells.append(
+ {
+ "x": origin_x + x0 * resolution,
+ "y": origin_y + y0 * resolution,
+ "width": (x1 - x0) * resolution,
+ "height": (y1 - y0) * resolution,
+ "cost": _block_cost(grid, x0, x1, y0, y1),
+ }
+ )
+ return {
+ "source": "DimOS live costmap",
+ "columns": columns,
+ "rows": rows,
+ "resolution_m": resolution,
+ "cells": cells,
+ }
+
+
+def _block_cost(grid: Any, x0: int, x1: int, y0: int, y1: int) -> float:
+ best = 0.0
+ for y in range(y0, y1):
+ for x in range(x0, x1):
+ try:
+ value = float(grid[y][x])
+ except Exception:
+ value = -1.0
+ if value < 0:
+ continue
+ best = max(best, min(1.0, value / 100.0))
+ return best
+
+
+def _path_to_points(msg: Any) -> list[dict[str, Any]]:
+ points: list[dict[str, Any]] = []
+ for pose in getattr(msg, "poses", []) or []:
+ point = _pose_to_map_pose(pose, source="path")
+ if point is not None:
+ points.append(point)
+ return points
+
+
+def _path_to_route(path: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ return [
+ {
+ "target_id": f"LIVE-PATH-{index + 1:03d}",
+ "x": point["x"],
+ "y": point["y"],
+ "success": True,
+ "guided": False,
+ "retries": 0,
+ "note": "DimOS planner path",
+ }
+ for index, point in enumerate(path)
+ ]
+
+
+def _pose_to_map_pose(msg: Any, *, source: str) -> dict[str, Any] | None:
+ if msg is None:
+ return None
+ x = getattr(msg, "x", None)
+ y = getattr(msg, "y", None)
+ if x is None or y is None:
+ position = getattr(msg, "position", None)
+ x = getattr(position, "x", None)
+ y = getattr(position, "y", None)
+ if x is None or y is None:
+ return None
+ yaw = getattr(msg, "yaw", None)
+ return {
+ "x": float(x),
+ "y": float(y),
+ "theta_deg": math.degrees(float(yaw)) if yaw is not None else None,
+ "source": source,
+ }
diff --git a/dimos/experimental/dogops/map_authoring.py b/dimos/experimental/dogops/map_authoring.py
new file mode 100644
index 0000000000..df00984c57
--- /dev/null
+++ b/dimos/experimental/dogops/map_authoring.py
@@ -0,0 +1,429 @@
+from __future__ import annotations
+
+import json
+import math
+import os
+import subprocess
+import threading
+import time
+from pathlib import Path
+from typing import Any, Literal
+
+import yaml
+from pydantic import Field, ValidationError, field_validator, model_validator
+
+from dimos.experimental.dogops.models import DogOpsModel
+from dimos.experimental.dogops.route_actions import EditableRouteAction
+
+
+AUTHORING_FILENAME = "map_authoring.json"
+AUTHORING_SCHEMA_VERSION = 1
+
+
+class EditableMapPoint(DogOpsModel):
+ x: float
+ y: float
+ theta_deg: float | None = None
+ source: Literal[
+ "site_config",
+ "dashboard_edit",
+ "observation",
+ "live_topic",
+ "qr_cargo_event",
+ ] = "dashboard_edit"
+
+ @field_validator("x", "y", "theta_deg")
+ @classmethod
+ def finite_coordinate(cls, value: float | None) -> float | None:
+ if value is None:
+ return None
+ result = float(value)
+ if not math.isfinite(result):
+ raise ValueError("coordinate must be finite")
+ return result
+
+
+class EditableMapEntity(DogOpsModel):
+ id: str
+ kind: Literal["zone", "asset", "package", "checkpoint"]
+ label: str
+ pose: EditableMapPoint
+ tag_id: int | None = None
+ zone_id: str | None = None
+ source_id: str | None = None
+
+ @field_validator("id", "label")
+ @classmethod
+ def required_text(cls, value: str) -> str:
+ result = str(value).strip()
+ if not result:
+ raise ValueError("value must not be empty")
+ return result
+
+
+class EditableNoGoShape(DogOpsModel):
+ id: str
+ label: str
+ shape: Literal["rectangle", "polygon"] = "rectangle"
+ points: list[EditableMapPoint]
+ enabled: bool = True
+ dimos_constraint_status: Literal["not_supported", "pending", "published", "failed"] = (
+ "not_supported"
+ )
+
+ @field_validator("id", "label")
+ @classmethod
+ def required_text(cls, value: str) -> str:
+ result = str(value).strip()
+ if not result:
+ raise ValueError("value must not be empty")
+ return result
+
+ @model_validator(mode="after")
+ def validate_points(self) -> "EditableNoGoShape":
+ minimum = 2 if self.shape == "rectangle" else 3
+ if len(self.points) < minimum:
+ raise ValueError(f"{self.shape} requires at least {minimum} points")
+ return self
+
+
+class EditableRouteWaypoint(DogOpsModel):
+ id: str
+ label: str
+ pose: EditableMapPoint
+ target_id: str | None = None
+ required: bool = True
+ actions: list[EditableRouteAction] = Field(default_factory=list)
+
+ @field_validator("id", "label")
+ @classmethod
+ def required_text(cls, value: str) -> str:
+ result = str(value).strip()
+ if not result:
+ raise ValueError("value must not be empty")
+ return result
+
+
+class EditableRoute(DogOpsModel):
+ id: str
+ label: str
+ waypoints: list[EditableRouteWaypoint] = Field(default_factory=list)
+ mission_id: str | None = None
+
+ @field_validator("id", "label")
+ @classmethod
+ def required_text(cls, value: str) -> str:
+ result = str(value).strip()
+ if not result:
+ raise ValueError("value must not be empty")
+ return result
+
+ @model_validator(mode="after")
+ def validate_waypoint_ids(self) -> "EditableRoute":
+ _require_unique([waypoint.id for waypoint in self.waypoints], "route waypoint id")
+ return self
+
+
+class EditableIncidentLocation(DogOpsModel):
+ incident_id: str
+ entity_id: str | None = None
+ pose: EditableMapPoint
+ evidence_observation_ids: list[str] = Field(default_factory=list)
+
+ @field_validator("incident_id")
+ @classmethod
+ def required_text(cls, value: str) -> str:
+ result = str(value).strip()
+ if not result:
+ raise ValueError("incident_id must not be empty")
+ return result
+
+
+class EditableTagBinding(DogOpsModel):
+ tag_id: int
+ entity_id: str
+ label: str
+ binding_kind: Literal["zone", "asset", "package", "checkpoint"]
+
+ @field_validator("entity_id", "label")
+ @classmethod
+ def required_text(cls, value: str) -> str:
+ result = str(value).strip()
+ if not result:
+ raise ValueError("value must not be empty")
+ return result
+
+
+class MapAuthoringState(DogOpsModel):
+ schema_version: int = AUTHORING_SCHEMA_VERSION
+ site_id: str = ""
+ frame: str = "world"
+ updated_at: float = Field(default_factory=time.time)
+ home: EditableMapPoint | None = None
+ selected_route_id: str | None = None
+ entities: list[EditableMapEntity] = Field(default_factory=list)
+ no_go_shapes: list[EditableNoGoShape] = Field(default_factory=list)
+ routes: list[EditableRoute] = Field(default_factory=list)
+ incident_locations: list[EditableIncidentLocation] = Field(default_factory=list)
+ tag_bindings: list[EditableTagBinding] = Field(default_factory=list)
+
+ @model_validator(mode="after")
+ def validate_unique_keys(self) -> "MapAuthoringState":
+ _require_unique([entity.id for entity in self.entities], "entity id")
+ _require_unique([shape.id for shape in self.no_go_shapes], "no-go shape id")
+ _require_unique([route.id for route in self.routes], "route id")
+ _require_unique(
+ [location.incident_id for location in self.incident_locations],
+ "incident id",
+ )
+ _require_unique([binding.tag_id for binding in self.tag_bindings], "tag id")
+ if self.selected_route_id and self.selected_route_id not in {
+ route.id for route in self.routes
+ }:
+ raise ValueError(f"unknown selected route id: {self.selected_route_id}")
+ return self
+
+ def touch(self) -> "MapAuthoringState":
+ self.updated_at = time.time()
+ return self
+
+
+def authoring_path(run_dir: str | Path) -> Path:
+ return Path(run_dir) / AUTHORING_FILENAME
+
+
+def default_authoring(site_id: str = "") -> MapAuthoringState:
+ return MapAuthoringState(site_id=site_id)
+
+
+def load_map_authoring(run_dir: str | Path, *, site_id: str = "") -> MapAuthoringState:
+ path = authoring_path(run_dir)
+ if not path.exists():
+ return default_authoring(site_id=site_id)
+ try:
+ payload = json.loads(path.read_text(encoding="utf-8"))
+ except json.JSONDecodeError as exc:
+ raise ValueError(f"invalid map authoring JSON: {path}") from exc
+ if not isinstance(payload, dict):
+ raise ValueError(f"map authoring file must contain an object: {path}")
+ if site_id and not payload.get("site_id"):
+ payload["site_id"] = site_id
+ return MapAuthoringState.model_validate(payload)
+
+
+def save_map_authoring(run_dir: str | Path, authoring: MapAuthoringState) -> Path:
+ path = authoring_path(run_dir)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ authoring.touch()
+ raw = json.dumps(authoring.model_dump(mode="json"), indent=2, sort_keys=True)
+ tmp_path = path.with_name(
+ f"{path.name}.{os.getpid()}.{threading.get_ident()}.{time.time_ns()}.tmp"
+ )
+ tmp_path.write_text(raw + "\n", encoding="utf-8")
+ tmp_path.replace(path)
+ return path
+
+
+def replace_entity(
+ authoring: MapAuthoringState,
+ entity: EditableMapEntity,
+) -> MapAuthoringState:
+ authoring.entities = [
+ existing for existing in authoring.entities if existing.id != entity.id
+ ]
+ authoring.entities.append(entity)
+ return _validated(authoring)
+
+
+def delete_entity(authoring: MapAuthoringState, entity_id: str) -> MapAuthoringState:
+ authoring.entities = [entity for entity in authoring.entities if entity.id != entity_id]
+ return _validated(authoring)
+
+
+def replace_no_go_shape(
+ authoring: MapAuthoringState,
+ shape: EditableNoGoShape,
+) -> MapAuthoringState:
+ authoring.no_go_shapes = [
+ existing for existing in authoring.no_go_shapes if existing.id != shape.id
+ ]
+ authoring.no_go_shapes.append(shape)
+ return _validated(authoring)
+
+
+def delete_no_go_shape(authoring: MapAuthoringState, shape_id: str) -> MapAuthoringState:
+ authoring.no_go_shapes = [
+ shape for shape in authoring.no_go_shapes if shape.id != shape_id
+ ]
+ return _validated(authoring)
+
+
+def replace_route(authoring: MapAuthoringState, route: EditableRoute) -> MapAuthoringState:
+ authoring.routes = [existing for existing in authoring.routes if existing.id != route.id]
+ authoring.routes.append(route)
+ if authoring.selected_route_id is None:
+ authoring.selected_route_id = route.id
+ return _validated(authoring)
+
+
+def delete_route(authoring: MapAuthoringState, route_id: str) -> MapAuthoringState:
+ authoring.routes = [route for route in authoring.routes if route.id != route_id]
+ if authoring.selected_route_id == route_id:
+ authoring.selected_route_id = authoring.routes[0].id if authoring.routes else None
+ return _validated(authoring)
+
+
+def select_route(authoring: MapAuthoringState, route_id: str | None) -> MapAuthoringState:
+ authoring.selected_route_id = route_id
+ return _validated(authoring)
+
+
+def replace_incident_location(
+ authoring: MapAuthoringState,
+ location: EditableIncidentLocation,
+) -> MapAuthoringState:
+ authoring.incident_locations = [
+ existing
+ for existing in authoring.incident_locations
+ if existing.incident_id != location.incident_id
+ ]
+ authoring.incident_locations.append(location)
+ return _validated(authoring)
+
+
+def replace_tag_binding(
+ authoring: MapAuthoringState,
+ binding: EditableTagBinding,
+) -> MapAuthoringState:
+ authoring.tag_bindings = [
+ existing for existing in authoring.tag_bindings if existing.tag_id != binding.tag_id
+ ]
+ authoring.tag_bindings.append(binding)
+ return _validated(authoring)
+
+
+def delete_tag_binding(authoring: MapAuthoringState, tag_id: int) -> MapAuthoringState:
+ authoring.tag_bindings = [
+ binding for binding in authoring.tag_bindings if binding.tag_id != tag_id
+ ]
+ return _validated(authoring)
+
+
+def export_authoring_yaml(
+ run_dir: str | Path,
+ authoring: MapAuthoringState,
+) -> dict[str, str]:
+ export_dir = Path(run_dir) / "exports"
+ export_dir.mkdir(parents=True, exist_ok=True)
+ site_path = export_dir / "site_authoring.yaml"
+ mission_path = export_dir / "mission_authoring.yaml"
+ payload = authoring.model_dump(mode="json")
+ site_payload = {
+ "site_id": authoring.site_id,
+ "frame": authoring.frame,
+ "home": payload["home"],
+ "entities": payload["entities"],
+ "no_go_shapes": payload["no_go_shapes"],
+ "tag_bindings": payload["tag_bindings"],
+ }
+ mission_payload = {
+ "site_id": authoring.site_id,
+ "selected_route_id": authoring.selected_route_id,
+ "routes": payload["routes"],
+ "mission_steps": _mission_steps_for_selected_route(authoring),
+ "incident_locations": payload["incident_locations"],
+ }
+ site_path.write_text(yaml.safe_dump(site_payload, sort_keys=False), encoding="utf-8")
+ mission_path.write_text(
+ yaml.safe_dump(mission_payload, sort_keys=False),
+ encoding="utf-8",
+ )
+ return {"site": str(site_path), "mission": str(mission_path)}
+
+
+def publish_no_go_constraints(
+ authoring: MapAuthoringState,
+ *,
+ command: str | None = None,
+ timeout_s: float = 5.0,
+) -> MapAuthoringState:
+ publisher = command or os.environ.get("DOGOPS_NO_GO_PUBLISH_COMMAND")
+ enabled_shapes = [shape for shape in authoring.no_go_shapes if shape.enabled]
+ if not enabled_shapes:
+ return authoring
+ if not publisher:
+ for shape in enabled_shapes:
+ shape.dimos_constraint_status = "not_supported"
+ return _validated(authoring)
+
+ payload = json.dumps(
+ {
+ "site_id": authoring.site_id,
+ "frame": authoring.frame,
+ "no_go_shapes": [
+ shape.model_dump(mode="json") for shape in enabled_shapes
+ ],
+ },
+ separators=(",", ":"),
+ )
+ try:
+ result = subprocess.run(
+ publisher,
+ input=payload,
+ capture_output=True,
+ check=False,
+ shell=True,
+ text=True,
+ timeout=timeout_s,
+ )
+ except Exception:
+ for shape in enabled_shapes:
+ shape.dimos_constraint_status = "failed"
+ return _validated(authoring)
+
+ status = "published" if result.returncode == 0 else "failed"
+ for shape in enabled_shapes:
+ shape.dimos_constraint_status = status
+ return _validated(authoring)
+
+
+def validation_error_message(exc: ValidationError | ValueError) -> str:
+ if isinstance(exc, ValidationError):
+ errors = exc.errors()
+ if errors:
+ return str(errors[0].get("msg") or errors[0])
+ return str(exc)
+
+
+def _mission_steps_for_selected_route(authoring: MapAuthoringState) -> list[dict[str, Any]]:
+ route = next(
+ (item for item in authoring.routes if item.id == authoring.selected_route_id),
+ authoring.routes[0] if authoring.routes else None,
+ )
+ if route is None:
+ return []
+ steps: list[dict[str, Any]] = []
+ for index, waypoint in enumerate(route.waypoints, 1):
+ steps.append(
+ {
+ "id": waypoint.id,
+ "action": "goto",
+ "target_id": waypoint.target_id or waypoint.id,
+ "required": waypoint.required,
+ "pose_hint": waypoint.pose.model_dump(mode="json"),
+ "sequence": index,
+ }
+ )
+ return steps
+
+
+def _validated(authoring: MapAuthoringState) -> MapAuthoringState:
+ return MapAuthoringState.model_validate(authoring.model_dump(mode="json"))
+
+
+def _require_unique(values: list[Any], label: str) -> None:
+ seen: set[Any] = set()
+ for value in values:
+ if value in seen:
+ raise ValueError(f"duplicate {label}: {value}")
+ seen.add(value)
diff --git a/dimos/experimental/dogops/map_snapshot.py b/dimos/experimental/dogops/map_snapshot.py
new file mode 100644
index 0000000000..137eda0382
--- /dev/null
+++ b/dimos/experimental/dogops/map_snapshot.py
@@ -0,0 +1,388 @@
+from __future__ import annotations
+
+import math
+from typing import Any
+
+
+DEFAULT_RERUN_URL = (
+ "http://127.0.0.1:9878/?url=rerun%2Bhttp%3A%2F%2F127.0.0.1%3A9877%2Fproxy"
+)
+DEFAULT_DIMOS_ROOT = "/Users/chris/Documents/Workspace/dimos"
+DEFAULT_LIVE_ROBOT_IP = "192.168.123.161"
+
+
+def build_map_snapshot(
+ state: dict[str, Any],
+ report: dict[str, Any] | None = None,
+ *,
+ mode: str = "demo",
+ live_overlay: dict[str, Any] | None = None,
+ rerun_url: str | None = None,
+ demo_rerun_url: str | None = None,
+ live_rerun_url: str | None = None,
+ live_robot_ip: str | None = None,
+) -> dict[str, Any]:
+ site = state.get("site") or {}
+ zones = _zones(site)
+ zone_by_id = {zone["id"]: zone for zone in zones}
+ assets = _assets(site, zone_by_id)
+ entity_by_id = {**zone_by_id, **{asset["id"]: asset for asset in assets}}
+ packages = _packages(site, state, report or {}, zone_by_id)
+ observations = _observations(state)
+ route = _route(state, entity_by_id)
+ robot_pose = _latest_pose(observations, route, zone_by_id)
+ target = None
+ overlay = live_overlay if mode == "live" else None
+ if overlay:
+ route = overlay.get("route") or route
+ robot_pose = overlay.get("robot_pose") or robot_pose
+ target = overlay.get("target")
+ points = [
+ {"x": item["x"], "y": item["y"]}
+ for group in (zones, assets, packages, observations)
+ for item in group
+ if item.get("x") is not None and item.get("y") is not None
+ ]
+ for group in (route, [target] if target else []):
+ for item in group:
+ if item and item.get("x") is not None and item.get("y") is not None:
+ points.append({"x": item["x"], "y": item["y"]})
+ if robot_pose:
+ points.append(robot_pose)
+
+ modes = build_view_modes(
+ demo_rerun_url=demo_rerun_url or rerun_url,
+ live_rerun_url=live_rerun_url or rerun_url,
+ live_robot_ip=live_robot_ip,
+ )
+ bounds = _bounds(points)
+ costmap = (overlay or {}).get("costmap") or _costmap(bounds, zones, assets, packages, route)
+ live_status = overlay or {
+ "ok": False,
+ "source": "DimOS live LCM topics",
+ "status": "not_requested" if mode != "live" else "waiting_for_topics",
+ "topics": {},
+ "error": "",
+ }
+ return {
+ "run": state.get("run") or {},
+ "site": {
+ "id": site.get("site_id"),
+ "name": site.get("site_name"),
+ "tag_family": site.get("tag_family"),
+ "marker_length_m": site.get("marker_length_m"),
+ },
+ "bounds": bounds,
+ "zones": zones,
+ "assets": assets,
+ "packages": packages,
+ "observations": observations,
+ "route": route,
+ "robot_pose": robot_pose,
+ "target": target,
+ "costmap": costmap,
+ "streams": {
+ "semantic_source": "DogOps run state",
+ "live_source": "DimOS live LCM topics",
+ "rerun_url": modes["demo"]["rerun_url"],
+ },
+ "mode": mode,
+ "default_mode": "demo",
+ "modes": modes,
+ "live": live_status,
+ "counts": {
+ "zones": len(zones),
+ "assets": len(assets),
+ "packages": len(packages),
+ "observations_with_pose": len(observations),
+ "route_events": len(route),
+ "costmap_cells": len(costmap.get("cells") or []),
+ },
+ }
+
+
+def build_view_modes(
+ *,
+ demo_rerun_url: str | None = None,
+ live_rerun_url: str | None = None,
+ live_robot_ip: str | None = None,
+) -> dict[str, dict[str, str]]:
+ robot_ip = live_robot_ip or DEFAULT_LIVE_ROBOT_IP
+ demo_url = demo_rerun_url or DEFAULT_RERUN_URL
+ live_url = live_rerun_url or DEFAULT_RERUN_URL
+ return {
+ "demo": {
+ "label": "Demo Replay",
+ "badge": "Replay",
+ "rerun_url": demo_url,
+ "summary": "Replay the full DimOS Go2 stack to prove heatmap, costmap, path, pose, and camera rendering without touching hardware.",
+ "command": (
+ f"cd {DEFAULT_DIMOS_ROOT}\n"
+ "uv run dimos --replay --rerun-web --rerun-open web "
+ "run unitree-go2-dogops --daemon"
+ ),
+ "control_note": "DogOps controls stay available for UI smoke; robot motion is replay/simulated.",
+ },
+ "live": {
+ "label": "Live Dog",
+ "badge": "Live",
+ "rerun_url": live_url,
+ "summary": "Use the same DimOS map/costmap/path/camera streams while DogOps sends commands to the real Go2 control path.",
+ "command": (
+ f"cd {DEFAULT_DIMOS_ROOT}\n"
+ f"uv run dimos --robot-ip {robot_ip} --rerun-web --rerun-open web "
+ "run unitree-go2-dogops --daemon"
+ ),
+ "control_note": "Dashboard controls call DogOps/DimOS endpoints; Rerun is visualization only.",
+ },
+ }
+
+
+def _zones(site: dict[str, Any]) -> list[dict[str, Any]]:
+ zones: list[dict[str, Any]] = []
+ for zone in site.get("zones") or []:
+ pose = zone.get("pose_hint") or {}
+ zones.append(
+ {
+ "id": zone.get("id"),
+ "label": zone.get("display_name") or zone.get("id"),
+ "kind": zone.get("zone_kind"),
+ "tag_id": zone.get("tag_id"),
+ "x": _float_or_none(pose.get("x")),
+ "y": _float_or_none(pose.get("y")),
+ "theta_deg": _float_or_none(pose.get("theta_deg")),
+ "radius_m": float(zone.get("radius_m") or 0.8),
+ "no_go": bool(zone.get("no_go")),
+ "source": pose.get("source") or "site_config",
+ }
+ )
+ return zones
+
+
+def _assets(site: dict[str, Any], zone_by_id: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
+ assets: list[dict[str, Any]] = []
+ zone_offsets: dict[str, int] = {}
+ for asset in site.get("assets") or []:
+ zone_id = asset.get("zone_id")
+ zone = zone_by_id.get(zone_id or "")
+ x, y = _offset_from_zone(zone, zone_offsets, zone_id or "", radius=0.34)
+ assets.append(
+ {
+ "id": asset.get("id"),
+ "label": asset.get("display_name") or asset.get("id"),
+ "kind": asset.get("asset_kind"),
+ "zone_id": zone_id,
+ "tag_id": asset.get("tag_id"),
+ "x": x,
+ "y": y,
+ "expected_clear": asset.get("expected_clear"),
+ "expected_status": asset.get("expected_status"),
+ }
+ )
+ return assets
+
+
+def _packages(
+ site: dict[str, Any],
+ state: dict[str, Any],
+ report: dict[str, Any],
+ zone_by_id: dict[str, dict[str, Any]],
+) -> list[dict[str, Any]]:
+ statuses = state.get("package_statuses") or {}
+ report_packages = {item.get("package_id"): item for item in report.get("packages") or []}
+ packages: list[dict[str, Any]] = []
+ zone_offsets: dict[str, int] = {}
+ for package in site.get("packages") or []:
+ package_id = package.get("id")
+ status = statuses.get(package_id) or report_packages.get(package_id) or {}
+ expected_zone_id = status.get("expected_zone_id") or package.get("expected_zone_id")
+ observed_zone_id = status.get("observed_zone_id")
+ display_zone_id = observed_zone_id or expected_zone_id
+ zone = zone_by_id.get(display_zone_id or "")
+ x, y = _offset_from_zone(zone, zone_offsets, display_zone_id or "", radius=0.62)
+ packages.append(
+ {
+ "id": package_id,
+ "label": package.get("display_name") or package_id,
+ "tag_id": package.get("tag_id"),
+ "expected_zone_id": expected_zone_id,
+ "observed_zone_id": observed_zone_id,
+ "state": status.get("state") or "expected",
+ "blocks_asset_id": status.get("blocks_asset_id"),
+ "x": x,
+ "y": y,
+ }
+ )
+ return packages
+
+
+def _observations(state: dict[str, Any]) -> list[dict[str, Any]]:
+ observations: list[dict[str, Any]] = []
+ for obs in state.get("observations") or []:
+ pose = obs.get("pose") or {}
+ x = _float_or_none(pose.get("x"))
+ y = _float_or_none(pose.get("y"))
+ if x is None or y is None:
+ continue
+ observations.append(
+ {
+ "id": obs.get("id"),
+ "entity_id": obs.get("entity_id"),
+ "zone_id": obs.get("zone_id"),
+ "tag_id": obs.get("tag_id"),
+ "x": x,
+ "y": y,
+ "theta_deg": _float_or_none(pose.get("theta_deg")),
+ "source": obs.get("source") or pose.get("source") or "observation",
+ }
+ )
+ return observations
+
+
+def _route(state: dict[str, Any], entity_by_id: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
+ route: list[dict[str, Any]] = []
+ for event in state.get("nav_events") or []:
+ target_id = event.get("target_id")
+ target = entity_by_id.get(target_id or "")
+ route.append(
+ {
+ "id": event.get("id"),
+ "action": event.get("action"),
+ "target_id": target_id,
+ "success": bool(event.get("success", True)),
+ "guided": bool(event.get("guided")),
+ "retries": int(event.get("retries") or 0),
+ "elapsed_s": float(event.get("elapsed_s") or 0.0),
+ "note": event.get("note") or "",
+ "x": target.get("x") if target else None,
+ "y": target.get("y") if target else None,
+ }
+ )
+ return route
+
+
+def _latest_pose(
+ observations: list[dict[str, Any]],
+ route: list[dict[str, Any]],
+ zone_by_id: dict[str, dict[str, Any]],
+) -> dict[str, Any] | None:
+ if observations:
+ latest = observations[-1]
+ return {"x": latest["x"], "y": latest["y"], "source": latest["source"]}
+ for event in reversed(route):
+ if event.get("success") and event.get("x") is not None and event.get("y") is not None:
+ return {"x": event["x"], "y": event["y"], "source": "last_nav_event"}
+ home = zone_by_id.get("HOME")
+ if home and home.get("x") is not None and home.get("y") is not None:
+ return {"x": home["x"], "y": home["y"], "source": "HOME"}
+ return None
+
+
+def _offset_from_zone(
+ zone: dict[str, Any] | None,
+ zone_offsets: dict[str, int],
+ zone_id: str,
+ *,
+ radius: float,
+) -> tuple[float | None, float | None]:
+ if zone is None or zone.get("x") is None or zone.get("y") is None:
+ return None, None
+ index = zone_offsets.get(zone_id, 0)
+ zone_offsets[zone_id] = index + 1
+ angle = index * (math.pi * 0.55)
+ return float(zone["x"]) + math.cos(angle) * radius, float(zone["y"]) + math.sin(angle) * radius
+
+
+def _bounds(points: list[dict[str, Any]]) -> dict[str, float]:
+ if not points:
+ return {"min_x": -1.0, "max_x": 1.0, "min_y": -1.0, "max_y": 1.0}
+ xs = [float(point["x"]) for point in points if point.get("x") is not None]
+ ys = [float(point["y"]) for point in points if point.get("y") is not None]
+ return {
+ "min_x": min(xs) - 0.8,
+ "max_x": max(xs) + 0.8,
+ "min_y": min(ys) - 0.8,
+ "max_y": max(ys) + 0.8,
+ }
+
+
+def _costmap(
+ bounds: dict[str, float],
+ zones: list[dict[str, Any]],
+ assets: list[dict[str, Any]],
+ packages: list[dict[str, Any]],
+ route: list[dict[str, Any]],
+) -> dict[str, Any]:
+ columns = 24
+ rows = 16
+ width = bounds["max_x"] - bounds["min_x"]
+ height = bounds["max_y"] - bounds["min_y"]
+ cell_w = width / columns
+ cell_h = height / rows
+ cells: list[dict[str, float]] = []
+ blocked_packages = [
+ package
+ for package in packages
+ if package.get("state") in {"missing", "wrong_zone", "blocked"}
+ or package.get("blocks_asset_id")
+ ]
+ no_go_zones = [zone for zone in zones if zone.get("no_go")]
+
+ for row in range(rows):
+ for column in range(columns):
+ x = bounds["min_x"] + (column + 0.5) * cell_w
+ y = bounds["min_y"] + (row + 0.5) * cell_h
+ cost = 0.08
+ for zone in no_go_zones:
+ cost = max(cost, _radial_cost(x, y, zone, radius_m=1.1, peak=0.95))
+ for package in blocked_packages:
+ cost = max(cost, _radial_cost(x, y, package, radius_m=0.9, peak=0.82))
+ for asset in assets:
+ if asset.get("expected_clear") is False:
+ cost = max(cost, _radial_cost(x, y, asset, radius_m=0.65, peak=0.65))
+ route_bonus = max(
+ (_radial_cost(x, y, event, radius_m=0.45, peak=0.24) for event in route),
+ default=0.0,
+ )
+ cells.append(
+ {
+ "x": bounds["min_x"] + column * cell_w,
+ "y": bounds["min_y"] + row * cell_h,
+ "width": cell_w,
+ "height": cell_h,
+ "cost": min(1.0, max(cost, route_bonus)),
+ }
+ )
+ return {
+ "source": "DogOps-generated navigation costmap",
+ "columns": columns,
+ "rows": rows,
+ "cells": cells,
+ }
+
+
+def _radial_cost(
+ x: float,
+ y: float,
+ point: dict[str, Any],
+ *,
+ radius_m: float,
+ peak: float,
+) -> float:
+ point_x = point.get("x")
+ point_y = point.get("y")
+ if point_x is None or point_y is None:
+ return 0.0
+ distance = math.hypot(x - float(point_x), y - float(point_y))
+ if distance >= radius_m:
+ return 0.0
+ return peak * (1.0 - distance / radius_m)
+
+
+def _float_or_none(value: Any) -> float | None:
+ if value is None:
+ return None
+ try:
+ return float(value)
+ except (TypeError, ValueError):
+ return None
diff --git a/dimos/experimental/dogops/mission_engine.py b/dimos/experimental/dogops/mission_engine.py
new file mode 100644
index 0000000000..ed482060fb
--- /dev/null
+++ b/dimos/experimental/dogops/mission_engine.py
@@ -0,0 +1,278 @@
+from __future__ import annotations
+
+from pathlib import Path
+import time
+
+from dimos.experimental.dogops.config_loader import load_dogops_config
+from dimos.experimental.dogops.models import (
+ DogOpsConfig,
+ DogOpsState,
+ Incident,
+ IncidentState,
+ IncidentType,
+ MissionState,
+ NavEvent,
+ Observation,
+ PackageState,
+ Severity,
+ SimulationObservation,
+ WorkOrder,
+ WorkOrderState,
+)
+from dimos.experimental.dogops.nav_eval import summarize_nav_events
+from dimos.experimental.dogops.report import assert_report_has_closed_loop
+from dimos.experimental.dogops.store import DogOpsStore
+
+
+class OfflineMissionEngine:
+ def __init__(self, config: DogOpsConfig, out_dir: str | Path) -> None:
+ self.config = config
+ self.store = DogOpsStore(
+ out_dir,
+ site=config.site,
+ manifest=config.manifest,
+ policy=config.policy,
+ mission=config.mission,
+ )
+ self._obs_count = 0
+ self._nav_count = 0
+
+ def run(self, *, verify_closed_loop: bool = True) -> DogOpsState:
+ started_at = time.time()
+ run = self.store.create_run(self.config.mission.mission_id, started_at=started_at)
+ state = self.store.state
+ assert state is not None
+
+ self._record_nav_events(run.id)
+ self._localize_home(run.id)
+ self._scan("scan_inbound", run.id, state)
+ self._scan("inspect_cooling", run.id, state)
+ self._open_blocked_cooling_if_needed(run.id, state)
+ self._open_missing_package_incidents(run.id, state)
+ self._mark_work_order_ready("WO-001", state)
+ self._scan("verify_cooling_after_fix", run.id, state, source="simulated_human_fix")
+ self._verify_work_order("WO-001", state)
+ self._scan("scan_qa_hold", run.id, state)
+
+ state.nav_summary = summarize_nav_events(run.id, state.nav_events)
+ summary = "Closed INC-001 after simulated human remediation; PKG-103 remains missing."
+ self.store.finish_run(run.id, MissionState.done, summary, ended_at=time.time())
+ self.store.write_state(run.id)
+ self.store.write_report(run.id)
+ if verify_closed_loop:
+ assert_report_has_closed_loop(state)
+ return state
+
+ def _record_nav_events(self, run_id: str) -> None:
+ for sim_event in self.config.mission.nav_simulation.events:
+ self._nav_count += 1
+ event = NavEvent(
+ id=f"NAV-{self._nav_count:03d}",
+ run_id=run_id,
+ ts=time.time(),
+ action=sim_event.action,
+ target_id=sim_event.target_id,
+ success=sim_event.success,
+ elapsed_s=sim_event.elapsed_s,
+ retries=sim_event.retries,
+ guided=sim_event.guided or self.config.mission.nav_simulation.guided,
+ error_m=sim_event.error_m,
+ note=sim_event.note,
+ )
+ self.store.append_nav_event(event)
+
+ def _localize_home(self, run_id: str) -> None:
+ self._obs_count += 1
+ obs = Observation(
+ id=f"OBS-{self._obs_count:03d}",
+ ts=time.time(),
+ run_id=run_id,
+ entity_id="HOME",
+ tag_id=10,
+ zone_id="HOME",
+ facts={"localized": True},
+ confidence=1.0,
+ source="simulation",
+ )
+ self.store.append_observation(obs)
+
+ def _scan(
+ self,
+ key: str,
+ run_id: str,
+ state: DogOpsState,
+ *,
+ source: str = "simulation",
+ ) -> Observation:
+ sim_obs = self.config.mission.simulation_observations.get(key)
+ if sim_obs is None:
+ raise KeyError(f"missing simulation observation: {key}")
+ obs = self._observation_from_simulation(key, run_id, sim_obs, source=source)
+ self.store.append_observation(obs)
+ self._apply_observation_facts(obs, state)
+ return obs
+
+ def _observation_from_simulation(
+ self, key: str, run_id: str, sim_obs: SimulationObservation, *, source: str
+ ) -> Observation:
+ self._obs_count += 1
+ tag_to_entity = self.config.site.entity_for_tag()
+ primary_entity_id = None
+ primary_tag_id = sim_obs.visible_tag_ids[0] if sim_obs.visible_tag_ids else None
+ if primary_tag_id is not None and primary_tag_id in tag_to_entity:
+ primary_entity_id = tag_to_entity[primary_tag_id].id
+ facts: dict[str, bool | str | int | float] = {
+ "scan_key": key,
+ "visible_tag_ids": ",".join(str(tag_id) for tag_id in sim_obs.visible_tag_ids),
+ }
+ facts.update(sim_obs.facts)
+ return Observation(
+ id=f"OBS-{self._obs_count:03d}",
+ ts=time.time(),
+ run_id=run_id,
+ entity_id=primary_entity_id,
+ tag_id=primary_tag_id,
+ zone_id=sim_obs.zone_id,
+ facts=facts,
+ confidence=1.0,
+ source=source,
+ )
+
+ def _apply_observation_facts(self, obs: Observation, state: DogOpsState) -> None:
+ for key, value in obs.facts.items():
+ if key.endswith(".zone_id"):
+ package_id = key.removesuffix(".zone_id")
+ status = state.package_statuses.get(package_id)
+ if status is None or not isinstance(value, str):
+ continue
+ previous_zone = status.observed_zone_id
+ status.observed_zone_id = value
+ if value == status.expected_zone_id:
+ status.state = PackageState.found_ok
+ status.blocks_asset_id = None
+ else:
+ status.state = PackageState.wrong_zone
+ if package_id == "PKG-104" and previous_zone in {"RACK_ROW_A", "COOLING_1"}:
+ if value == "QA_HOLD":
+ state.what_changed.append(
+ "PKG-104 moved from COOLING_1/RACK_ROW_A to QA_HOLD; INC-001 resolved."
+ )
+ elif key.endswith(".blocks_asset_id"):
+ package_id = key.removesuffix(".blocks_asset_id")
+ status = state.package_statuses.get(package_id)
+ if status is None or not isinstance(value, str):
+ continue
+ status.blocks_asset_id = value
+
+ def _open_blocked_cooling_if_needed(self, run_id: str, state: DogOpsState) -> None:
+ pkg_104 = state.package_statuses.get("PKG-104")
+ if pkg_104 is None or pkg_104.blocks_asset_id != "COOLING_1":
+ return
+ if any(incident.id == "INC-001" for incident in state.incidents):
+ return
+ rule = self.config.policy.rule_for_type(IncidentType.blocked_cooling)
+ severity = Severity(rule.severity) if rule else Severity.P1
+ action = (
+ rule.recommended_action
+ if rule
+ else "Move blocking package to QA_HOLD and verify COOLING_1 is clear."
+ )
+ incident = Incident(
+ id="INC-001",
+ run_id=run_id,
+ ts_open=time.time(),
+ severity=severity,
+ type=IncidentType.blocked_cooling,
+ entity_id="COOLING_1",
+ related_package_id="PKG-104",
+ state=IncidentState.open,
+ title="PKG-104 wrong zone and blocking COOLING_1",
+ evidence_observation_ids=[obs.id for obs in state.observations if obs.zone_id == "RACK_ROW_A"],
+ recommended_action=action,
+ )
+ work_order = WorkOrder(
+ id="WO-001",
+ incident_id=incident.id,
+ requested_action=action,
+ state=WorkOrderState.assigned,
+ )
+ self.store.append_incident(incident)
+ self.store.append_work_order(work_order)
+
+ def _open_missing_package_incidents(self, run_id: str, state: DogOpsState) -> None:
+ for package_id, status in state.package_statuses.items():
+ if status.observed_zone_id is not None:
+ continue
+ if any(incident.related_package_id == package_id for incident in state.incidents):
+ continue
+ status.state = PackageState.missing
+ rule = self.config.policy.rule_for_type(IncidentType.missing_package)
+ incident = Incident(
+ id="INC-002",
+ run_id=run_id,
+ ts_open=time.time(),
+ severity=Severity(rule.severity) if rule else Severity.P2,
+ type=IncidentType.missing_package,
+ entity_id=package_id,
+ related_package_id=package_id,
+ state=IncidentState.open,
+ title=f"{package_id} missing from inbound scan",
+ evidence_observation_ids=[obs.id for obs in state.observations],
+ recommended_action=(
+ rule.recommended_action if rule else "Search inbound dock and QA_HOLD."
+ ),
+ )
+ self.store.append_incident(incident)
+
+ def _mark_work_order_ready(self, work_order_id: str, state: DogOpsState) -> None:
+ work_order = self._get_work_order(work_order_id, state)
+ work_order.state = WorkOrderState.ready_to_verify
+ self.store.update_work_order(work_order)
+ incident = self._get_incident(work_order.incident_id, state)
+ incident.state = IncidentState.ready_to_verify
+ self.store.update_incident(incident)
+
+ def _verify_work_order(self, work_order_id: str, state: DogOpsState) -> None:
+ work_order = self._get_work_order(work_order_id, state)
+ incident = self._get_incident(work_order.incident_id, state)
+ pkg_104 = state.package_statuses["PKG-104"]
+ cooling_clear = any(
+ obs.facts.get("COOLING_1.clearance_clear") is True for obs in state.observations
+ )
+ if pkg_104.observed_zone_id == "QA_HOLD" and cooling_clear:
+ incident.state = IncidentState.resolved
+ incident.ts_closed = time.time()
+ work_order.state = WorkOrderState.verified_closed
+ work_order.verification_observation_ids = [
+ obs.id for obs in state.observations if obs.source == "simulated_human_fix"
+ ]
+ self.store.update_incident(incident)
+ self.store.update_work_order(work_order)
+
+ @staticmethod
+ def _get_incident(incident_id: str, state: DogOpsState) -> Incident:
+ for incident in state.incidents:
+ if incident.id == incident_id:
+ return incident
+ raise KeyError(incident_id)
+
+ @staticmethod
+ def _get_work_order(work_order_id: str, state: DogOpsState) -> WorkOrder:
+ for work_order in state.work_orders:
+ if work_order.id == work_order_id:
+ return work_order
+ raise KeyError(work_order_id)
+
+
+def run_offline_simulation(
+ *,
+ site: str | Path = "examples/dogops/site_demo.yaml",
+ manifest: str | Path = "examples/dogops/manifest_demo.yaml",
+ mission: str | Path = "examples/dogops/mission_demo.yaml",
+ policy: str | Path = "examples/dogops/policy_demo.yaml",
+ out: str | Path = ".dogops/runs/latest",
+ verify_closed_loop: bool = True,
+) -> DogOpsState:
+ config = load_dogops_config(site, manifest, mission, policy)
+ engine = OfflineMissionEngine(config, out)
+ return engine.run(verify_closed_loop=verify_closed_loop)
diff --git a/dimos/experimental/dogops/models.py b/dimos/experimental/dogops/models.py
new file mode 100644
index 0000000000..d18017014d
--- /dev/null
+++ b/dimos/experimental/dogops/models.py
@@ -0,0 +1,363 @@
+from __future__ import annotations
+
+from enum import Enum
+from pathlib import Path
+from typing import Any, Literal
+
+from pydantic import BaseModel, ConfigDict, Field
+
+
+class DogOpsModel(BaseModel):
+ model_config = ConfigDict(extra="ignore", use_enum_values=True)
+
+
+class EntityKind(str, Enum):
+ zone = "zone"
+ asset = "asset"
+ package = "package"
+ dock = "dock"
+ portal = "portal"
+
+
+class ZoneKind(str, Enum):
+ home = "home"
+ inbound_dock = "inbound_dock"
+ qa_hold = "qa_hold"
+ rack_row = "rack_row"
+ aisle = "aisle"
+ no_go = "no_go"
+ dock = "dock"
+ portal = "portal"
+
+
+class AssetKind(str, Enum):
+ cooling_clearance = "cooling_clearance"
+ rack_status = "rack_status"
+ aisle_clearance = "aisle_clearance"
+ safety_station = "safety_station"
+ temperature_station = "temperature_station"
+
+
+class PackageState(str, Enum):
+ expected = "expected"
+ found_ok = "found_ok"
+ wrong_zone = "wrong_zone"
+ missing = "missing"
+ damaged = "damaged"
+ blocking_asset = "blocking_asset"
+ unknown = "unknown"
+
+
+class IncidentType(str, Enum):
+ blocked_cooling = "blocked_cooling"
+ wrong_zone = "wrong_zone"
+ missing_package = "missing_package"
+ damaged_package = "damaged_package"
+ blocked_aisle = "blocked_aisle"
+ no_go_breach = "no_go_breach"
+ high_temperature = "high_temperature"
+ unknown = "unknown"
+
+
+class Severity(str, Enum):
+ P1 = "P1"
+ P2 = "P2"
+ P3 = "P3"
+ INFO = "INFO"
+
+
+class IncidentState(str, Enum):
+ open = "open"
+ acked = "acked"
+ ready_to_verify = "ready_to_verify"
+ resolved = "resolved"
+ unresolved = "unresolved"
+ false_positive = "false_positive"
+
+
+class WorkOrderState(str, Enum):
+ open = "open"
+ assigned = "assigned"
+ ready_to_verify = "ready_to_verify"
+ verified_closed = "verified_closed"
+ blocked = "blocked"
+ cancelled = "cancelled"
+
+
+class MissionState(str, Enum):
+ init = "init"
+ running = "running"
+ waiting_for_human = "waiting_for_human"
+ verifying = "verifying"
+ done = "done"
+ failed = "failed"
+ stopped = "stopped"
+
+
+class NavAction(str, Enum):
+ goto = "goto"
+ scan = "scan"
+ search_tag = "search_tag"
+ rotate = "rotate"
+ step_back = "step_back"
+ guided = "guided"
+ dock_align = "dock_align"
+ portal_entry = "portal_entry"
+
+
+class Pose2D(DogOpsModel):
+ x: float | None = None
+ y: float | None = None
+ theta_deg: float | None = None
+ frame: str = "world"
+ source: str = "unknown"
+
+
+class SiteEntity(DogOpsModel):
+ id: str
+ kind: EntityKind
+ tag_id: int | None = None
+ display_name: str
+ zone_id: str | None = None
+ expected_state: dict[str, Any] = Field(default_factory=dict)
+ severity_if_failed: Severity = Severity.P3
+ notes: str = ""
+
+
+class Zone(SiteEntity):
+ kind: Literal[EntityKind.zone] = EntityKind.zone
+ zone_kind: ZoneKind
+ pose_hint: Pose2D | None = None
+ radius_m: float = 0.8
+ no_go: bool = False
+
+
+class Asset(SiteEntity):
+ kind: Literal[EntityKind.asset] = EntityKind.asset
+ asset_kind: AssetKind
+ expected_clear: bool | None = None
+ expected_status: str | None = None
+ blocking_package_ids: list[str] = Field(default_factory=list)
+
+
+class Package(SiteEntity):
+ kind: Literal[EntityKind.package] = EntityKind.package
+ expected_zone_id: str
+ expected_condition: str = "ok"
+
+
+class SpecialEntity(SiteEntity):
+ kind: EntityKind
+
+
+class SiteConfig(DogOpsModel):
+ site_id: str
+ site_name: str = ""
+ tag_family: str = "tag36h11"
+ marker_length_m: float
+ zones: list[Zone] = Field(default_factory=list)
+ assets: list[Asset] = Field(default_factory=list)
+ packages: list[Package] = Field(default_factory=list)
+ special_entities: dict[str, SpecialEntity] = Field(default_factory=dict)
+
+ def package_by_id(self) -> dict[str, Package]:
+ return {pkg.id: pkg for pkg in self.packages}
+
+ def asset_by_id(self) -> dict[str, Asset]:
+ return {asset.id: asset for asset in self.assets}
+
+ def zone_by_id(self) -> dict[str, Zone]:
+ return {zone.id: zone for zone in self.zones}
+
+ def entity_for_tag(self) -> dict[int, SiteEntity]:
+ entities: list[SiteEntity] = [*self.zones, *self.assets, *self.packages]
+ entities.extend(self.special_entities.values())
+ return {entity.tag_id: entity for entity in entities if entity.tag_id is not None}
+
+
+class ManifestItem(DogOpsModel):
+ package_id: str
+ expected_zone_id: str
+ expected_condition: str = "ok"
+
+
+class Manifest(DogOpsModel):
+ manifest_id: str
+ items: list[ManifestItem]
+
+ def item_by_package_id(self) -> dict[str, ManifestItem]:
+ return {item.package_id: item for item in self.items}
+
+
+class PolicyRule(DogOpsModel):
+ id: str
+ severity: Severity = Severity.P3
+ incident_type: IncidentType = IncidentType.unknown
+ description: str
+ condition: dict[str, Any] = Field(default_factory=dict)
+ recommended_action: str = ""
+
+
+class PolicyConfig(DogOpsModel):
+ policy_id: str
+ rules: list[PolicyRule] = Field(default_factory=list)
+
+ def rule_for_type(self, incident_type: IncidentType | str) -> PolicyRule | None:
+ type_value = incident_type.value if isinstance(incident_type, IncidentType) else incident_type
+ for rule in self.rules:
+ if rule.incident_type == type_value:
+ return rule
+ return None
+
+
+class MissionStep(DogOpsModel):
+ id: str
+ action: str
+ target_id: str
+ timeout_s: float = 30.0
+ required: bool = True
+
+
+class SimulationObservation(DogOpsModel):
+ zone_id: str
+ visible_tag_ids: list[int] = Field(default_factory=list)
+ facts: dict[str, bool | str | int | float] = Field(default_factory=dict)
+
+
+class NavSimulationEvent(DogOpsModel):
+ target_id: str
+ action: NavAction = NavAction.goto
+ success: bool = True
+ elapsed_s: float = 0.0
+ retries: int = 0
+ guided: bool = False
+ error_m: float | None = None
+ note: str = ""
+
+
+class NavSimulation(DogOpsModel):
+ guided: bool = False
+ events: list[NavSimulationEvent] = Field(default_factory=list)
+
+
+class MissionConfig(DogOpsModel):
+ mission_id: str
+ display_name: str
+ steps: list[MissionStep]
+ verify_after_human: bool = True
+ simulation_observations: dict[str, SimulationObservation] = Field(default_factory=dict)
+ nav_simulation: NavSimulation = Field(default_factory=NavSimulation)
+
+
+class Observation(DogOpsModel):
+ id: str
+ ts: float
+ run_id: str
+ entity_id: str | None = None
+ tag_id: int | None = None
+ zone_id: str | None = None
+ pose: Pose2D | None = None
+ image_path: str | None = None
+ facts: dict[str, bool | str | int | float] = Field(default_factory=dict)
+ confidence: float = 1.0
+ source: str = "simulation"
+
+
+class Incident(DogOpsModel):
+ id: str
+ run_id: str
+ ts_open: float
+ ts_closed: float | None = None
+ severity: Severity
+ type: IncidentType
+ entity_id: str
+ related_package_id: str | None = None
+ state: IncidentState
+ title: str
+ evidence_observation_ids: list[str] = Field(default_factory=list)
+ recommended_action: str = ""
+
+
+class WorkOrder(DogOpsModel):
+ id: str
+ incident_id: str
+ requested_action: str
+ assignee: str = "human_operator"
+ state: WorkOrderState
+ verification_observation_ids: list[str] = Field(default_factory=list)
+
+
+class NavEvent(DogOpsModel):
+ id: str
+ run_id: str
+ ts: float
+ action: NavAction
+ target_id: str | None = None
+ success: bool = True
+ elapsed_s: float = 0.0
+ retries: int = 0
+ guided: bool = False
+ error_m: float | None = None
+ note: str = ""
+
+
+class NavSummary(DogOpsModel):
+ run_id: str
+ waypoints_total: int
+ waypoints_reached: int
+ waypoints_failed: int
+ success_rate: float
+ route_targets: int = 0
+ unique_targets_reached: int = 0
+ route_coverage: float = 0.0
+ retries_total: int
+ guided_interventions: int
+ guided_fallback_used: bool = False
+ tag_reacquisition_attempts: int
+ tag_reacquisition_successes: int
+ tag_reacquisition_rate: float = 0.0
+ mean_elapsed_s: float
+ worst_target_id: str | None = None
+ safety_stops: int = 0
+ notes: list[str] = Field(default_factory=list)
+
+
+class MissionRun(DogOpsModel):
+ id: str
+ mission_id: str
+ started_at: float
+ ended_at: float | None = None
+ state: MissionState
+ current_step_id: str | None = None
+ summary: str = ""
+
+
+class PackageStatus(DogOpsModel):
+ package_id: str
+ expected_zone_id: str
+ observed_zone_id: str | None = None
+ state: PackageState = PackageState.expected
+ blocks_asset_id: str | None = None
+
+
+class DogOpsState(DogOpsModel):
+ run: MissionRun
+ site: SiteConfig
+ manifest: Manifest
+ policy: PolicyConfig
+ mission: MissionConfig
+ package_statuses: dict[str, PackageStatus] = Field(default_factory=dict)
+ observations: list[Observation] = Field(default_factory=list)
+ incidents: list[Incident] = Field(default_factory=list)
+ work_orders: list[WorkOrder] = Field(default_factory=list)
+ nav_events: list[NavEvent] = Field(default_factory=list)
+ nav_summary: NavSummary | None = None
+ what_changed: list[str] = Field(default_factory=list)
+
+
+class DogOpsConfig(DogOpsModel):
+ site: SiteConfig
+ manifest: Manifest
+ policy: PolicyConfig
+ mission: MissionConfig
+ paths: dict[str, Path]
diff --git a/dimos/experimental/dogops/nav_eval.py b/dimos/experimental/dogops/nav_eval.py
new file mode 100644
index 0000000000..00144d5460
--- /dev/null
+++ b/dimos/experimental/dogops/nav_eval.py
@@ -0,0 +1,71 @@
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from statistics import mean
+
+from dimos.experimental.dogops.models import NavAction, NavEvent, NavSummary
+from dimos.experimental.dogops.store import DogOpsStore
+
+try: # pragma: no cover - exercised only inside a full DimOS checkout.
+ from dimos.core.module import Module
+except ModuleNotFoundError:
+
+ class Module:
+ def __init__(self, **_: object) -> None:
+ pass
+
+ @classmethod
+ def blueprint(cls, **kwargs: object) -> dict[str, object]:
+ return {"module": cls.__name__, "kwargs": kwargs}
+
+
+def summarize_nav_events(run_id: str, events: list[NavEvent]) -> NavSummary:
+ waypoint_events = [event for event in events if event.action == NavAction.goto]
+ reached = [event for event in waypoint_events if event.success]
+ failed = [event for event in waypoint_events if not event.success]
+ elapsed_values = [event.elapsed_s for event in waypoint_events if event.elapsed_s > 0]
+ tag_attempts = [
+ event
+ for event in events
+ if event.action == NavAction.search_tag or "tag" in event.note.lower()
+ ]
+ guided = [event for event in events if event.guided or event.action == NavAction.guided]
+ worst = max(waypoint_events, key=lambda event: event.elapsed_s, default=None)
+ notes = [event.note for event in events if event.note]
+ route_targets = {event.target_id for event in waypoint_events if event.target_id is not None}
+ reached_targets = {event.target_id for event in reached if event.target_id is not None}
+ tag_successes = len([event for event in tag_attempts if event.success])
+ return NavSummary(
+ run_id=run_id,
+ waypoints_total=len(waypoint_events),
+ waypoints_reached=len(reached),
+ waypoints_failed=len(failed),
+ success_rate=(len(reached) / len(waypoint_events)) if waypoint_events else 0.0,
+ route_targets=len(route_targets),
+ unique_targets_reached=len(reached_targets),
+ route_coverage=(len(reached_targets) / len(route_targets)) if route_targets else 0.0,
+ retries_total=sum(event.retries for event in events),
+ guided_interventions=len(guided),
+ guided_fallback_used=bool(guided),
+ tag_reacquisition_attempts=len(tag_attempts),
+ tag_reacquisition_successes=tag_successes,
+ tag_reacquisition_rate=(tag_successes / len(tag_attempts)) if tag_attempts else 0.0,
+ mean_elapsed_s=mean(elapsed_values) if elapsed_values else 0.0,
+ worst_target_id=worst.target_id if worst else None,
+ safety_stops=len([event for event in events if "safety" in event.note.lower()]),
+ notes=notes,
+ )
+
+
+class DogOpsNavEvalModule(Module):
+ def __init__(self, **_: object) -> None:
+ if _:
+ super().__init__(**_)
+
+ def summarize_run(self, run_dir: str | Path = ".dogops/runs/latest") -> str:
+ store = DogOpsStore.load_existing(run_dir)
+ state = store.state
+ assert state is not None
+ summary = summarize_nav_events(state.run.id, state.nav_events)
+ return json.dumps(summary.model_dump(mode="json"), sort_keys=True)
diff --git a/dimos/experimental/dogops/observation_module.py b/dimos/experimental/dogops/observation_module.py
new file mode 100644
index 0000000000..135292b19b
--- /dev/null
+++ b/dimos/experimental/dogops/observation_module.py
@@ -0,0 +1,164 @@
+from __future__ import annotations
+
+import time
+from pathlib import Path
+from typing import Any
+
+from dimos.experimental.dogops.config_loader import DEFAULT_SITE, load_site_config
+from dimos.experimental.dogops.detector import DetectedTag, DogOpsTagDetector
+from dimos.experimental.dogops.models import Observation
+
+try: # pragma: no cover - exercised only inside a full DimOS checkout.
+ from reactivex.disposable import Disposable as RxDisposable
+except ModuleNotFoundError: # pragma: no cover - local project-pack fallback.
+ RxDisposable = None
+
+try: # pragma: no cover - exercised only inside a full DimOS checkout.
+ from dimos.core.module import Module
+except ModuleNotFoundError:
+
+ class Module:
+ def __init__(self, **_: object) -> None:
+ pass
+
+ @classmethod
+ def blueprint(cls, **kwargs: object) -> dict[str, object]:
+ return {"module": cls.__name__, "kwargs": kwargs}
+
+ def start(self) -> None:
+ pass
+
+ def stop(self) -> None:
+ pass
+
+
+try: # pragma: no cover - exercised only inside a full DimOS checkout.
+ from dimos.core.stream import In
+ from dimos.msgs.sensor_msgs.Image import Image
+except ModuleNotFoundError: # pragma: no cover - local project-pack fallback.
+
+ class In:
+ def __class_getitem__(cls, _: object) -> type[In]:
+ return cls
+
+ Image = Any # type: ignore[misc, assignment]
+
+
+class DogOpsObservationModule(Module):
+ color_image: In[Image]
+
+ def __init__(self, *, site_path: str | Path = DEFAULT_SITE, **_: object) -> None:
+ super().__init__(**_)
+ self.site_path = Path(site_path)
+ self.site = load_site_config(self.site_path)
+ self.detector = DogOpsTagDetector(self.site)
+ self._latest_image: object | None = None
+ self._latest_image_received_at: float | None = None
+
+ def start(self) -> None: # pragma: no cover - lifecycle exercised in full DimOS.
+ super().start()
+ color_image = getattr(self, "color_image", None)
+ if color_image is not None and hasattr(color_image, "subscribe"):
+ subscription = color_image.subscribe(self.ingest_camera_image)
+ _register_subscription(self, subscription)
+
+ def ingest_camera_image(self, image: object) -> None:
+ self._latest_image = image
+ self._latest_image_received_at = time.time()
+
+ def observe_simulated_tags(
+ self,
+ tag_ids: list[int],
+ *,
+ zone_id: str,
+ run_id: str = "simulated",
+ ) -> list[Observation]:
+ return [
+ self._observation_for_detection(detection, zone_id=zone_id, run_id=run_id)
+ for detection in self.detector.detect_simulated(tag_ids)
+ ]
+
+ def observe_image(
+ self,
+ image_path: str | Path,
+ *,
+ zone_id: str,
+ run_id: str = "image",
+ ) -> list[Observation]:
+ return [
+ self._observation_for_detection(detection, zone_id=zone_id, run_id=run_id)
+ for detection in self.detector.detect_image(image_path)
+ ]
+
+ def observe_latest_image(
+ self,
+ *,
+ zone_id: str,
+ run_id: str = "camera",
+ ) -> list[Observation]:
+ if self._latest_image is None:
+ return []
+ return [
+ self._observation_for_detection(detection, zone_id=zone_id, run_id=run_id)
+ for detection in self.detector.detect_dimos_image(self._latest_image)
+ ]
+
+ def image_stream_status(self) -> dict[str, object]:
+ if self._latest_image is not None and self._latest_image_received_at is not None:
+ shape = getattr(self._latest_image, "shape", None)
+ data = getattr(self._latest_image, "data", None)
+ if shape is None and data is not None:
+ shape = getattr(data, "shape", None)
+ return {
+ "ok": True,
+ "mode": "latest_frame",
+ "frame_age_s": round(time.time() - self._latest_image_received_at, 3),
+ "frame_id": getattr(self._latest_image, "frame_id", None),
+ "shape": [int(item) for item in shape] if shape is not None else None,
+ }
+ return {
+ "ok": False,
+ "mode": "not_subscribed",
+ "fallback": "use observe_simulated_tags or observe_image",
+ }
+
+ @staticmethod
+ def _observation_for_detection(
+ detection: DetectedTag,
+ *,
+ zone_id: str,
+ run_id: str,
+ ) -> Observation:
+ return Observation(
+ id=f"TAG-{detection.tag_id}",
+ ts=time.time(),
+ run_id=run_id,
+ entity_id=detection.entity_id,
+ tag_id=detection.tag_id,
+ zone_id=zone_id,
+ facts={
+ "entity_kind": detection.entity_kind or "unknown",
+ "detection_source": detection.source,
+ "frame_id": detection.frame_id or "",
+ "center_px": _format_point(detection.center_px),
+ "area_px": round(detection.area_px, 3) if detection.area_px is not None else 0.0,
+ },
+ confidence=detection.confidence,
+ source=detection.source,
+ )
+
+
+def _format_point(point: tuple[float, float] | None) -> str:
+ if point is None:
+ return ""
+ return f"{point[0]:.1f},{point[1]:.1f}"
+
+
+def _register_subscription(owner: object, subscription: object) -> None:
+ register = getattr(owner, "register_disposable", None)
+ if not callable(register):
+ return
+ if callable(subscription) and RxDisposable is not None:
+ register(RxDisposable(subscription))
+ return
+ register(subscription)
diff --git a/dimos/experimental/dogops/qr_cargo.py b/dimos/experimental/dogops/qr_cargo.py
new file mode 100644
index 0000000000..674a7eefec
--- /dev/null
+++ b/dimos/experimental/dogops/qr_cargo.py
@@ -0,0 +1,221 @@
+from __future__ import annotations
+
+import json
+import math
+import os
+from pathlib import Path
+import time
+from typing import Any
+import uuid
+
+
+QR_EVENTS_FILENAME = "qr_events.jsonl"
+QR_CARGO_STATE_FILENAME = "qr_cargo_state.json"
+QR_CARGO_STATE_SCHEMA_VERSION = 1
+QR_LATEST_LIMIT = 50
+QR_REQUIRED_PAYLOAD_FIELDS = (
+ "warehouse_id",
+ "location_node_id",
+ "cargo_id",
+ "task",
+)
+
+
+def validate_qr_payload(payload: dict[str, Any]) -> tuple[bool, list[str]]:
+ errors: list[str] = []
+ if not isinstance(payload, dict):
+ return False, ["qr_payload must be an object"]
+ if payload.get("type") != "cargo":
+ errors.append("qr_payload.type must be cargo")
+ for field in QR_REQUIRED_PAYLOAD_FIELDS:
+ if not _non_empty_text(payload.get(field)):
+ errors.append(f"qr_payload.{field} is required")
+ return not errors, errors
+
+
+def normalize_qr_event(event: dict[str, Any]) -> dict[str, Any]:
+ if not isinstance(event, dict):
+ raise ValueError("QR event must be an object")
+
+ status = str(event.get("status") or "").strip()
+ if not status:
+ raise ValueError("event.status is required")
+
+ action_policy = str(event.get("action_policy") or "report_only").strip() or "report_only"
+ if action_policy != "report_only":
+ raise ValueError("event.action_policy must be report_only")
+
+ qr_payload, qr_payload_raw = _qr_payload_from_event(event)
+ ok, errors = validate_qr_payload(qr_payload)
+ if not ok:
+ raise ValueError("; ".join(errors))
+
+ timestamp = _float_or_none(event.get("timestamp"))
+ if timestamp is None:
+ timestamp = time.time()
+
+ event_id = str(event.get("event_id") or "").strip()
+ if not event_id:
+ event_id = _new_event_id(timestamp, qr_payload)
+
+ return {
+ "event_id": event_id,
+ "timestamp": timestamp,
+ "source": str(event.get("source") or "unknown"),
+ "status": status,
+ "qr_payload_raw": qr_payload_raw,
+ "qr_payload": qr_payload,
+ "robot_pose_at_detection": _normalize_robot_pose(
+ event.get("robot_pose_at_detection")
+ ),
+ "bbox_px": _normalize_bbox(event.get("bbox_px")),
+ "action_policy": action_policy,
+ }
+
+
+def load_qr_events(run_dir: Path) -> list[dict[str, Any]]:
+ path = qr_events_path(run_dir)
+ if not path.exists():
+ return []
+ events: list[dict[str, Any]] = []
+ for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
+ raw = line.strip()
+ if not raw:
+ continue
+ try:
+ event = json.loads(raw)
+ except json.JSONDecodeError as exc:
+ raise ValueError(f"invalid QR event JSON on line {line_number}: {path}") from exc
+ if not isinstance(event, dict):
+ raise ValueError(f"QR event line {line_number} must contain an object: {path}")
+ events.append(event)
+ return events
+
+
+def append_qr_event(run_dir: Path, event: dict[str, Any]) -> dict[str, Any]:
+ root = Path(run_dir)
+ root.mkdir(parents=True, exist_ok=True)
+ normalized = normalize_qr_event(event)
+ path = qr_events_path(root)
+ with path.open("a", encoding="utf-8") as handle:
+ handle.write(json.dumps(normalized, sort_keys=True) + "\n")
+ events = load_qr_events(root)
+ _write_qr_cargo_state(root, events)
+ return normalized
+
+
+def get_latest_qr_events(run_dir: Path, limit: int = QR_LATEST_LIMIT) -> list[dict[str, Any]]:
+ events = load_qr_events(run_dir)
+ safe_limit = max(1, int(limit or QR_LATEST_LIMIT))
+ return list(reversed(events[-safe_limit:]))
+
+
+def get_qr_event(run_dir: Path, event_id: str) -> dict[str, Any] | None:
+ for event in reversed(load_qr_events(run_dir)):
+ if str(event.get("event_id") or "") == event_id:
+ return event
+ return None
+
+
+def qr_events_path(run_dir: str | Path) -> Path:
+ return Path(run_dir) / QR_EVENTS_FILENAME
+
+
+def qr_cargo_state_path(run_dir: str | Path) -> Path:
+ return Path(run_dir) / QR_CARGO_STATE_FILENAME
+
+
+def _write_qr_cargo_state(run_dir: Path, events: list[dict[str, Any]]) -> None:
+ latest_by_cargo_id: dict[str, dict[str, Any]] = {}
+ for event in reversed(events):
+ payload = event.get("qr_payload") if isinstance(event.get("qr_payload"), dict) else {}
+ cargo_id = str(payload.get("cargo_id") or "")
+ if cargo_id and cargo_id not in latest_by_cargo_id:
+ latest_by_cargo_id[cargo_id] = event
+
+ state = {
+ "schema_version": QR_CARGO_STATE_SCHEMA_VERSION,
+ "updated_at": time.time(),
+ "events_total": len(events),
+ "latest_events": list(reversed(events[-QR_LATEST_LIMIT:])),
+ "latest_by_cargo_id": latest_by_cargo_id,
+ }
+ path = qr_cargo_state_path(run_dir)
+ raw = json.dumps(state, indent=2, sort_keys=True)
+ tmp_path = path.with_name(
+ f"{path.name}.{os.getpid()}.{time.time_ns()}.tmp"
+ )
+ tmp_path.write_text(raw + "\n", encoding="utf-8")
+ tmp_path.replace(path)
+
+
+def _qr_payload_from_event(event: dict[str, Any]) -> tuple[dict[str, Any], str]:
+ payload = event.get("qr_payload")
+ raw = event.get("qr_payload_raw")
+
+ if isinstance(payload, dict):
+ payload_dict = dict(payload)
+ payload_raw = raw if isinstance(raw, str) else json.dumps(payload_dict, sort_keys=True)
+ return payload_dict, payload_raw
+
+ if isinstance(payload, str):
+ payload_dict = _decode_payload_string(payload, "qr_payload")
+ return payload_dict, raw if isinstance(raw, str) else payload
+
+ if isinstance(raw, str):
+ return _decode_payload_string(raw, "qr_payload_raw"), raw
+
+ raise ValueError("event.qr_payload or event.qr_payload_raw is required")
+
+
+def _decode_payload_string(raw: str, field: str) -> dict[str, Any]:
+ try:
+ payload = json.loads(raw)
+ except json.JSONDecodeError as exc:
+ raise ValueError(f"event.{field} must be valid JSON") from exc
+ if not isinstance(payload, dict):
+ raise ValueError(f"event.{field} must decode to an object")
+ return payload
+
+
+def _normalize_robot_pose(value: Any) -> dict[str, Any]:
+ pose = value if isinstance(value, dict) else {}
+ return {
+ "frame": str(pose.get("frame") or "map"),
+ "x": _float_or_none(pose.get("x")),
+ "y": _float_or_none(pose.get("y")),
+ "yaw": _float_or_none(pose.get("yaw")),
+ }
+
+
+def _normalize_bbox(value: Any) -> list[list[float]] | None:
+ if not isinstance(value, list):
+ return None
+ points: list[list[float]] = []
+ for point in value:
+ if not isinstance(point, list | tuple) or len(point) != 2:
+ return None
+ x = _float_or_none(point[0])
+ y = _float_or_none(point[1])
+ if x is None or y is None:
+ return None
+ points.append([x, y])
+ return points or None
+
+
+def _float_or_none(value: Any) -> float | None:
+ try:
+ result = float(value)
+ except (TypeError, ValueError):
+ return None
+ return result if math.isfinite(result) else None
+
+
+def _non_empty_text(value: Any) -> bool:
+ return isinstance(value, str) and bool(value.strip())
+
+
+def _new_event_id(timestamp: float, payload: dict[str, Any]) -> str:
+ cargo_id = str(payload.get("cargo_id") or "cargo").strip() or "cargo"
+ cargo_slug = "".join(ch if ch.isalnum() else "-" for ch in cargo_id.lower()).strip("-")
+ return f"qr-{cargo_slug}-{int(timestamp * 1000)}-{uuid.uuid4().hex[:8]}"
diff --git a/dimos/experimental/dogops/report.py b/dimos/experimental/dogops/report.py
new file mode 100644
index 0000000000..5d145da408
--- /dev/null
+++ b/dimos/experimental/dogops/report.py
@@ -0,0 +1,234 @@
+from __future__ import annotations
+
+from dimos.experimental.dogops.models import (
+ DogOpsState,
+ IncidentState,
+ IncidentType,
+ NavAction,
+ Observation,
+ PackageState,
+ SiteEntity,
+ WorkOrderState,
+)
+
+
+def _value(value: object) -> object:
+ return getattr(value, "value", value)
+
+
+def build_checkpoint_verifications(state: DogOpsState) -> list[dict[str, object]]:
+ entities_by_id: dict[str, SiteEntity] = {
+ **state.site.zone_by_id(),
+ **state.site.asset_by_id(),
+ **state.site.package_by_id(),
+ }
+ entities_by_id.update(state.site.special_entities)
+ checkpoints: list[dict[str, object]] = []
+ seen_targets: set[str] = set()
+
+ for event in state.nav_events:
+ if event.action != NavAction.goto or not event.target_id:
+ continue
+ target_id = event.target_id
+ if target_id in seen_targets:
+ continue
+ seen_targets.add(target_id)
+ entity = entities_by_id.get(target_id)
+ expected_tag_id = entity.tag_id if entity is not None else None
+ matching_observation = _first_observation_with_tag(state.observations, expected_tag_id)
+ checkpoints.append(
+ {
+ "target_id": target_id,
+ "expected_tag_id": expected_tag_id,
+ "verified": matching_observation is not None,
+ "observation_id": matching_observation.id if matching_observation else None,
+ }
+ )
+
+ return checkpoints
+
+
+def build_report_data(state: DogOpsState) -> dict[str, object]:
+ package_statuses = list(state.package_statuses.values())
+ current_exception_package_ids = {
+ status.package_id
+ for status in package_statuses
+ if status.state
+ in {PackageState.missing, PackageState.wrong_zone, PackageState.blocking_asset}
+ }
+ incident_exception_package_ids = {
+ incident.related_package_id
+ for incident in state.incidents
+ if incident.related_package_id is not None
+ and incident.type
+ in {
+ IncidentType.blocked_cooling,
+ IncidentType.wrong_zone,
+ IncidentType.missing_package,
+ IncidentType.damaged_package,
+ }
+ }
+ manifest_exception_package_ids = current_exception_package_ids | incident_exception_package_ids
+ verified_work_orders = [
+ work_order
+ for work_order in state.work_orders
+ if work_order.state == WorkOrderState.verified_closed
+ ]
+ open_incidents = [
+ incident for incident in state.incidents if incident.state != IncidentState.resolved
+ ]
+ resolved_incidents = [
+ incident for incident in state.incidents if incident.state == IncidentState.resolved
+ ]
+ nav = state.nav_summary
+ checkpoints = build_checkpoint_verifications(state)
+ return {
+ "run_id": state.run.id,
+ "mission_id": state.run.mission_id,
+ "mission_state": _value(state.run.state),
+ "summary": state.run.summary,
+ "packages_expected": len(package_statuses),
+ "packages_observed": len(
+ [status for status in package_statuses if status.observed_zone_id is not None]
+ ),
+ "manifest_exceptions": len(manifest_exception_package_ids),
+ "incidents_opened": len(state.incidents),
+ "incidents_resolved": len(resolved_incidents),
+ "work_orders_verified_closed": len(verified_work_orders),
+ "open_issues": [
+ {
+ "incident_id": incident.id,
+ "type": _value(incident.type),
+ "entity_id": incident.entity_id,
+ "related_package_id": incident.related_package_id,
+ "state": _value(incident.state),
+ }
+ for incident in open_incidents
+ ],
+ "what_changed": state.what_changed,
+ "nav_summary": nav.model_dump(mode="json") if nav else None,
+ "checkpoints_total": len(checkpoints),
+ "checkpoints_verified": len([checkpoint for checkpoint in checkpoints if checkpoint["verified"]]),
+ "checkpoint_verifications": checkpoints,
+ "packages": [status.model_dump(mode="json") for status in package_statuses],
+ "incidents": [incident.model_dump(mode="json") for incident in state.incidents],
+ "work_orders": [
+ work_order.model_dump(mode="json") for work_order in state.work_orders
+ ],
+ }
+
+
+def render_report_markdown(state: DogOpsState) -> str:
+ data = build_report_data(state)
+ nav = state.nav_summary
+ lines = [
+ "DOGOPS RUN REPORT",
+ f"Mission: {state.run.mission_id}",
+ (
+ "Packages scanned: "
+ f"{data['packages_observed']} observed / {data['packages_expected']} expected"
+ ),
+ f"Manifest exceptions: {data['manifest_exceptions']}",
+ f"Incidents opened: {data['incidents_opened']}",
+ f"Work orders verified closed: {data['work_orders_verified_closed']}",
+ ]
+
+ open_issues = data["open_issues"]
+ if open_issues:
+ issue_text = ", ".join(
+ f"{issue['related_package_id'] or issue['entity_id']} {issue['type']}"
+ for issue in open_issues # type: ignore[union-attr]
+ )
+ lines.append(f"Open issue: {issue_text}")
+ else:
+ lines.append("Open issue: none")
+
+ if nav:
+ lines.append(
+ "Nav: "
+ f"{nav.waypoints_reached}/{nav.waypoints_total} waypoints reached, "
+ f"{nav.tag_reacquisition_successes} tag-search recovery, "
+ f"{nav.safety_stops} safety stops"
+ )
+ for note in nav.notes:
+ lines.append(f"Nav evidence: {note}")
+ lines.append(
+ "Checkpoints: "
+ f"{data['checkpoints_verified']}/{data['checkpoints_total']} tag sign-ins verified"
+ )
+
+ for changed in state.what_changed:
+ lines.append(f"What changed: {changed}")
+
+ lines.extend(["", "Incidents:"])
+ for incident in state.incidents:
+ lines.append(
+ "- "
+ f"{incident.id} {_value(incident.severity)} {_value(incident.type)}: "
+ f"{incident.title} [{_value(incident.state)}]"
+ )
+
+ lines.extend(["", "Packages:"])
+ for status in state.package_statuses.values():
+ location = status.observed_zone_id or "not observed"
+ blocker = f", blocks {status.blocks_asset_id}" if status.blocks_asset_id else ""
+ lines.append(
+ f"- {status.package_id}: {_value(status.state)}, expected {status.expected_zone_id}, "
+ f"observed {location}{blocker}"
+ )
+
+ return "\n".join(lines) + "\n"
+
+
+def _first_observation_with_tag(
+ observations: list[Observation],
+ tag_id: int | None,
+) -> Observation | None:
+ if tag_id is None:
+ return None
+ for observation in observations:
+ if tag_id in _observation_tag_ids(observation):
+ return observation
+ return None
+
+
+def _observation_tag_ids(observation: Observation) -> set[int]:
+ tag_ids = set()
+ if observation.tag_id is not None:
+ tag_ids.add(observation.tag_id)
+ raw_visible = observation.facts.get("visible_tag_ids")
+ if isinstance(raw_visible, str):
+ for item in raw_visible.split(","):
+ item = item.strip()
+ if not item:
+ continue
+ try:
+ tag_ids.add(int(item))
+ except ValueError:
+ continue
+ elif isinstance(raw_visible, int):
+ tag_ids.add(raw_visible)
+ return tag_ids
+
+
+def assert_report_has_closed_loop(state: DogOpsState) -> None:
+ incidents_by_id = {incident.id: incident for incident in state.incidents}
+ work_orders_by_id = {work_order.id: work_order for work_order in state.work_orders}
+ pkg_103 = state.package_statuses.get("PKG-103")
+ pkg_104 = state.package_statuses.get("PKG-104")
+ if incidents_by_id.get("INC-001") is None:
+ raise AssertionError("INC-001 was not opened")
+ if incidents_by_id["INC-001"].state != IncidentState.resolved:
+ raise AssertionError("INC-001 was not verified closed")
+ if work_orders_by_id.get("WO-001") is None:
+ raise AssertionError("WO-001 was not opened")
+ if work_orders_by_id["WO-001"].state != WorkOrderState.verified_closed:
+ raise AssertionError("WO-001 was not verified closed")
+ if not pkg_103 or pkg_103.state != PackageState.missing:
+ raise AssertionError("PKG-103 must remain missing/open")
+ if not pkg_104 or pkg_104.observed_zone_id != "QA_HOLD":
+ raise AssertionError("PKG-104 must be moved to QA_HOLD")
+ if not state.nav_summary:
+ raise AssertionError("nav summary is missing")
+ if not any(incident.type == IncidentType.missing_package for incident in state.incidents):
+ raise AssertionError("missing package incident is missing")
diff --git a/dimos/experimental/dogops/route_actions.py b/dimos/experimental/dogops/route_actions.py
new file mode 100644
index 0000000000..88d313aebd
--- /dev/null
+++ b/dimos/experimental/dogops/route_actions.py
@@ -0,0 +1,510 @@
+from __future__ import annotations
+
+from collections.abc import Callable
+from pathlib import Path
+import json
+import os
+import shutil
+import struct
+from typing import Any, Literal
+import zlib
+
+from pydantic import Field
+
+from dimos.experimental.dogops.models import DogOpsModel
+
+
+RouteActionKind = Literal[
+ "scan_tags",
+ "scan_qr",
+ "capture_image",
+ "gemini_inspect_image",
+ "inspect_asset",
+ "verify_work_order",
+ "wait",
+ "operator_prompt",
+]
+
+
+class EditableRouteAction(DogOpsModel):
+ id: str
+ kind: RouteActionKind
+ label: str | None = None
+ required: bool = True
+ timeout_s: float = 5.0
+ args: dict[str, Any] = Field(default_factory=dict)
+
+
+class RouteActionResult(DogOpsModel):
+ ok: bool
+ state: Literal["completed", "failed", "skipped"] = "completed"
+ note: str = ""
+ payload: dict[str, Any] = Field(default_factory=dict)
+ evidence: list[dict[str, Any]] = Field(default_factory=list)
+
+
+ScanZoneHandler = Callable[[str], str | dict[str, Any]]
+CaptureImageHandler = Callable[[dict[str, Any]], dict[str, Any] | None]
+
+
+def execute_route_action(
+ action: EditableRouteAction,
+ *,
+ run_dir: str | Path,
+ route_run_id: str,
+ waypoint_id: str,
+ route_id: str | None = None,
+ target_id: str | None = None,
+ pose: dict[str, Any] | None = None,
+ scan_zone_handler: ScanZoneHandler | None = None,
+ capture_image_handler: CaptureImageHandler | None = None,
+) -> RouteActionResult:
+ if action.kind == "scan_tags":
+ expected = [int(tag) for tag in action.args.get("expected", [])]
+ return RouteActionResult(
+ ok=True,
+ note=f"tag scan demo result: {len(expected)} expected",
+ payload={"expected_tag_ids": expected, "detected_tag_ids": expected, "source": "demo"},
+ evidence=[
+ {
+ "kind": "tag_detection",
+ "path": None,
+ "mime_type": "application/json",
+ "metadata": {
+ "source": "demo",
+ "expected_tag_ids": expected,
+ "detected_tag_ids": expected,
+ },
+ }
+ ],
+ )
+ if action.kind == "scan_qr":
+ expected = [str(item) for item in action.args.get("expected", [])]
+ zone_id = _scan_zone_id_for_action(action, target_id=target_id, waypoint_id=waypoint_id)
+ if scan_zone_handler is not None and zone_id:
+ scan_result = _call_scan_zone(scan_zone_handler, zone_id)
+ if scan_result.get("ok") is True:
+ return _qr_result_from_scan_zone(expected, scan_result)
+ return RouteActionResult(
+ ok=False,
+ state="failed",
+ note=f"QR scan zone failed: {scan_result.get('error') or 'scan_zone_failed'}",
+ payload={"source": "scan_zone", "zone_id": zone_id, "scan_zone": scan_result},
+ )
+ payloads = expected
+ if not payloads and action.args.get("payload"):
+ payloads = [str(action.args["payload"])]
+ if not payloads:
+ return RouteActionResult(
+ ok=False,
+ state="failed",
+ note="QR scan configured without expected payloads",
+ payload={"source": "not_configured"},
+ )
+ return RouteActionResult(
+ ok=True,
+ note=f"QR scan demo result: {len(payloads)} payloads",
+ payload={"expected_payloads": payloads, "detected_payloads": payloads, "source": "demo"},
+ evidence=[
+ {
+ "kind": "qr_detection",
+ "path": None,
+ "mime_type": "application/json",
+ "metadata": {
+ "source": "demo",
+ "expected_payloads": payloads,
+ "detected_payloads": payloads,
+ },
+ }
+ ],
+ )
+ if action.kind == "capture_image":
+ image_path, source, mime_type, capture_metadata = _capture_image_path(
+ run_dir=Path(run_dir),
+ route_run_id=route_run_id,
+ waypoint_id=waypoint_id,
+ action=action,
+ capture_image_handler=capture_image_handler,
+ )
+ return RouteActionResult(
+ ok=True,
+ note=(
+ "live Go2 camera image captured"
+ if source == "go2_camera_live"
+ else (
+ "configured Go2 camera image captured"
+ if source == "go2_camera_configured"
+ else "placeholder dog-camera image captured"
+ )
+ ),
+ payload={"source": source, "path": str(image_path)},
+ evidence=[
+ {
+ "kind": "image",
+ "path": str(image_path),
+ "mime_type": mime_type,
+ "metadata": {
+ "source": source,
+ "route_run_id": route_run_id,
+ "route_id": route_id,
+ "waypoint_id": waypoint_id,
+ "action_id": action.id,
+ "target": action.args.get("target") or target_id or waypoint_id,
+ "pose": pose,
+ **capture_metadata,
+ },
+ }
+ ],
+ )
+ if action.kind == "gemini_inspect_image":
+ return _execute_gemini_inspect_image(
+ action,
+ run_dir=Path(run_dir),
+ route_run_id=route_run_id,
+ waypoint_id=waypoint_id,
+ route_id=route_id,
+ target_id=target_id,
+ pose=pose,
+ )
+ if action.kind == "wait":
+ return RouteActionResult(
+ ok=True,
+ note=f"wait completed ({float(action.args.get('seconds', 0.0)):.1f}s demo)",
+ payload={"seconds": float(action.args.get("seconds", 0.0)), "source": "demo"},
+ )
+ if action.kind in {"inspect_asset", "verify_work_order", "operator_prompt"}:
+ return RouteActionResult(
+ ok=True,
+ note=f"{action.kind} completed by deterministic demo handler",
+ payload={"source": "demo", **action.args},
+ )
+ return RouteActionResult(ok=False, state="failed", note=f"unsupported action: {action.kind}")
+
+
+def _scan_zone_id_for_action(
+ action: EditableRouteAction,
+ *,
+ target_id: str | None,
+ waypoint_id: str,
+) -> str:
+ for key in ("zone_id", "target_id", "target"):
+ value = action.args.get(key)
+ if isinstance(value, str) and value.strip():
+ return value.strip()
+ return (target_id or waypoint_id).strip()
+
+
+def _call_scan_zone(handler: ScanZoneHandler, zone_id: str) -> dict[str, Any]:
+ raw = handler(zone_id)
+ if isinstance(raw, str):
+ payload = json.loads(raw)
+ else:
+ payload = raw
+ if not isinstance(payload, dict):
+ raise ValueError("scan_zone handler returned a non-object payload")
+ return payload
+
+
+def _qr_result_from_scan_zone(
+ expected: list[str],
+ scan_result: dict[str, Any],
+) -> RouteActionResult:
+ detected_payloads = [str(item) for item in scan_result.get("package_ids") or []]
+ detected_tag_ids = [int(tag) for tag in scan_result.get("visible_tag_ids") or []]
+ zone_id = str(scan_result.get("zone_id") or "")
+ source = str(scan_result.get("source") or "scan_zone")
+ return RouteActionResult(
+ ok=True,
+ note=(
+ f"QR scan used scan_zone {zone_id} via {source}: "
+ f"{len(detected_payloads)} package payloads"
+ ),
+ payload={
+ "expected_payloads": expected,
+ "detected_payloads": detected_payloads,
+ "detected_tag_ids": detected_tag_ids,
+ "source": "scan_zone",
+ "scan_zone_source": source,
+ "zone_id": zone_id,
+ "scan_zone": scan_result,
+ },
+ evidence=[
+ {
+ "kind": "qr_detection",
+ "path": None,
+ "mime_type": "application/json",
+ "metadata": {
+ "source": "scan_zone",
+ "scan_zone_source": source,
+ "zone_id": zone_id,
+ "expected_payloads": expected,
+ "detected_payloads": detected_payloads,
+ "detected_tag_ids": detected_tag_ids,
+ "scan_zone": scan_result,
+ },
+ }
+ ],
+ )
+
+
+def _execute_gemini_inspect_image(
+ action: EditableRouteAction,
+ *,
+ run_dir: Path,
+ route_run_id: str,
+ waypoint_id: str,
+ route_id: str | None,
+ target_id: str | None,
+ pose: dict[str, Any] | None,
+) -> RouteActionResult:
+ from dimos.experimental.dogops.gemini_vision import inspect_images_with_gemini
+ from dimos.experimental.dogops.route_run_store import RouteRunStore
+
+ store = RouteRunStore(run_dir)
+ current = store.image_evidence_for_route_run_waypoint(
+ route_run_id=route_run_id,
+ waypoint_id=waypoint_id,
+ )
+ if current is None or not current.get("path"):
+ return RouteActionResult(
+ ok=False,
+ state="failed",
+ note="Gemini inspect needs a preceding capture_image action at this waypoint",
+ payload={
+ "source": "gemini",
+ "status": "image_missing",
+ "waypoint_id": waypoint_id,
+ "recommendation": "add capture_image before gemini_inspect_image",
+ },
+ )
+
+ require_baseline = bool(action.args.get("require_baseline", False))
+ baseline_policy = str(action.args.get("baseline_policy") or "same_waypoint_latest_previous")
+ baseline = store.latest_image_evidence_for_waypoint(
+ waypoint_id=waypoint_id,
+ exclude_route_run_id=route_run_id,
+ route_id=route_id,
+ target_id=str(action.args.get("target") or target_id or ""),
+ pose=pose,
+ baseline_policy=baseline_policy,
+ )
+ if baseline is None and require_baseline:
+ return RouteActionResult(
+ ok=False,
+ state="failed",
+ note="Gemini inspect required a baseline image but none was available",
+ payload={
+ "source": "gemini",
+ "status": "baseline_missing",
+ "baseline_policy": baseline_policy,
+ "waypoint_id": waypoint_id,
+ },
+ )
+
+ model = str(action.args.get("model") or "gemini-2.5-flash")
+ max_inline = int(action.args.get("max_image_bytes_inline") or 20_000_000)
+ prompt = str(
+ action.args.get("prompt")
+ or "Inspect this waypoint for physical changes, safety issues, package changes, and work-order evidence."
+ )
+ baseline_path = baseline.get("path") if baseline else None
+ gemini = inspect_images_with_gemini(
+ current_image_path=str(current["path"]),
+ current_mime_type=str(current.get("mime_type") or ""),
+ current_evidence_id=str(current["evidence_id"]),
+ baseline_image_path=str(baseline_path) if baseline_path else None,
+ baseline_mime_type=str(baseline.get("mime_type") or "") if baseline else None,
+ baseline_evidence_id=str(baseline.get("evidence_id")) if baseline else None,
+ prompt=prompt,
+ model=model,
+ max_image_bytes_inline=max_inline,
+ route_context={
+ "route_id": route_id,
+ "route_run_id": route_run_id,
+ "waypoint_id": waypoint_id,
+ "target_id": target_id,
+ "baseline_policy": baseline_policy,
+ "baseline_match": baseline.get("baseline_match") if baseline else None,
+ },
+ )
+ payload = {
+ "source": "gemini",
+ "status": gemini.status,
+ "model": model,
+ "current_evidence_id": current["evidence_id"],
+ "baseline_policy": baseline_policy,
+ "baseline_match": baseline.get("baseline_match") if baseline else "none",
+ "baseline_evidence_id": baseline.get("evidence_id") if baseline else None,
+ "baseline_route_run_id": baseline.get("route_run_id") if baseline else None,
+ "baseline_created_at": baseline.get("created_at") if baseline else None,
+ "waypoint_id": waypoint_id,
+ "action_id": action.id,
+ }
+ if not gemini.ok or gemini.inspection is None:
+ payload["message"] = gemini.message
+ return RouteActionResult(
+ ok=True,
+ state="skipped",
+ note=gemini.message,
+ payload=payload,
+ )
+
+ analysis_path = _write_gemini_analysis(
+ run_dir=run_dir,
+ route_run_id=route_run_id,
+ waypoint_id=waypoint_id,
+ action_id=action.id,
+ payload={
+ "schema_version": 1,
+ "analysis": gemini.inspection.model_dump(mode="json"),
+ "metadata": payload,
+ },
+ )
+ analysis = gemini.inspection.model_dump(mode="json")
+ return RouteActionResult(
+ ok=True,
+ state="completed",
+ note=analysis.get("summary") or "Gemini image inspection completed",
+ payload={**payload, "analysis": analysis},
+ evidence=[
+ {
+ "kind": "gemini_vision_analysis",
+ "path": str(analysis_path),
+ "mime_type": "application/json",
+ "metadata": {
+ **payload,
+ "changed": analysis.get("changed"),
+ "summary": analysis.get("summary"),
+ "change_summary": analysis.get("change_summary"),
+ "severity": analysis.get("severity"),
+ "confidence": analysis.get("confidence"),
+ "possible_incident": analysis.get("possible_incident"),
+ },
+ }
+ ],
+ )
+
+
+def _write_gemini_analysis(
+ *,
+ run_dir: Path,
+ route_run_id: str,
+ waypoint_id: str,
+ action_id: str,
+ payload: dict[str, Any],
+) -> Path:
+ evidence_dir = run_dir / "route_runs" / route_run_id / "evidence"
+ evidence_dir.mkdir(parents=True, exist_ok=True)
+ path = evidence_dir / f"{waypoint_id}-{action_id}-gemini.json"
+ path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
+ return path
+
+
+def _capture_image_path(
+ *,
+ run_dir: Path,
+ route_run_id: str,
+ waypoint_id: str,
+ action: EditableRouteAction,
+ capture_image_handler: CaptureImageHandler | None = None,
+) -> tuple[Path, str, str, dict[str, Any]]:
+ evidence_dir = run_dir / "route_runs" / route_run_id / "evidence"
+ if capture_image_handler is not None:
+ captured = capture_image_handler(
+ {
+ "evidence_dir": evidence_dir,
+ "route_run_id": route_run_id,
+ "waypoint_id": waypoint_id,
+ "action_id": action.id,
+ "action": action.model_dump(mode="json"),
+ }
+ )
+ if captured:
+ return (
+ Path(str(captured["path"])),
+ str(captured.get("source") or "go2_camera_live"),
+ str(captured.get("mime_type") or "image/png"),
+ dict(captured.get("metadata") or {}),
+ )
+ configured = action.args.get("image_path") or os.environ.get("DOGOPS_GO2_CAMERA_IMAGE_PATH")
+ if configured:
+ source = Path(str(configured)).expanduser()
+ if source.exists() and source.is_file():
+ evidence_dir.mkdir(parents=True, exist_ok=True)
+ suffix = source.suffix or ".img"
+ destination = evidence_dir / f"{waypoint_id}-{action.id}{suffix}"
+ shutil.copyfile(source, destination)
+ return destination, "go2_camera_configured", _mime_type_for_suffix(suffix), {}
+ return (
+ _write_placeholder_image(
+ run_dir=run_dir,
+ route_run_id=route_run_id,
+ waypoint_id=waypoint_id,
+ action=action,
+ ),
+ "demo_placeholder",
+ "image/png",
+ {},
+ )
+
+
+def _mime_type_for_suffix(suffix: str) -> str:
+ return {
+ ".jpg": "image/jpeg",
+ ".jpeg": "image/jpeg",
+ ".png": "image/png",
+ ".webp": "image/webp",
+ ".svg": "image/svg+xml",
+ }.get(suffix.lower(), "application/octet-stream")
+
+
+def _write_placeholder_image(
+ *,
+ run_dir: Path,
+ route_run_id: str,
+ waypoint_id: str,
+ action: EditableRouteAction,
+) -> Path:
+ evidence_dir = run_dir / "route_runs" / route_run_id / "evidence"
+ evidence_dir.mkdir(parents=True, exist_ok=True)
+ path = evidence_dir / f"{waypoint_id}-{action.id}.png"
+ label = f"target={action.args.get('target') or waypoint_id}; action={action.label or action.id}"
+ _write_placeholder_png(path, label=label)
+ return path
+
+
+def _write_placeholder_png(path: Path, *, label: str) -> None:
+ width = 640
+ height = 360
+ label_seed = sum(label.encode("utf-8")) % 80
+ rows = []
+ for y in range(height):
+ row = bytearray()
+ for x in range(width):
+ aisle = abs(x - width // 2) < 58 + y // 8
+ stripe = abs(x - (width // 2 - 95 - y // 9)) < 4 or abs(x - (width // 2 + 95 + y // 9)) < 4
+ machine = (60 < x < 210 or 430 < x < 585) and 80 < y < 260
+ if stripe:
+ color = (242, 196, 63)
+ elif aisle:
+ base = 90 + (y * 70 // height)
+ color = (base, base + 5, base + 10)
+ elif machine:
+ color = (55 + label_seed // 3, 75, 88)
+ else:
+ color = (27, 39 + label_seed // 8, 52)
+ row.extend(color)
+ rows.append(b"\x00" + bytes(row))
+ raw = b"".join(rows)
+ path.write_bytes(
+ b"\x89PNG\r\n\x1a\n"
+ + _png_chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0))
+ + _png_chunk(b"tEXt", b"Description\x00DogOps demo placeholder camera frame")
+ + _png_chunk(b"IDAT", zlib.compress(raw, level=9))
+ + _png_chunk(b"IEND", b"")
+ )
+
+
+def _png_chunk(kind: bytes, data: bytes) -> bytes:
+ checksum = zlib.crc32(kind + data) & 0xFFFFFFFF
+ return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", checksum)
diff --git a/dimos/experimental/dogops/route_executor.py b/dimos/experimental/dogops/route_executor.py
new file mode 100644
index 0000000000..e3b6a8bb12
--- /dev/null
+++ b/dimos/experimental/dogops/route_executor.py
@@ -0,0 +1,876 @@
+from __future__ import annotations
+
+from collections.abc import Callable
+import json
+import math
+import os
+from pathlib import Path
+import threading
+import time
+from typing import Any, Literal, Protocol
+
+from pydantic import Field, field_validator
+
+from dimos.experimental.dogops.live_map import LIVE_TOPIC_MAX_AGE_S
+from dimos.experimental.dogops.map_authoring import (
+ EditableRoute,
+ EditableRouteWaypoint,
+ load_map_authoring,
+)
+from dimos.experimental.dogops.models import DogOpsModel, NavAction, NavEvent
+from dimos.experimental.dogops.nav_eval import summarize_nav_events
+from dimos.experimental.dogops.route_actions import (
+ CaptureImageHandler,
+ EditableRouteAction,
+ ScanZoneHandler,
+ execute_route_action,
+)
+from dimos.experimental.dogops.route_run_store import RouteRunStore, new_route_run_id
+from dimos.experimental.dogops.store import DogOpsStore
+
+
+ROUTE_EXECUTION_FILENAME = "route_execution.json"
+ROUTE_EXECUTION_LOCK_FILENAME = "route_execution.lock"
+GOAL_CONFIRM_RADIUS_M = 0.75
+PROGRESS_EPSILON_M = 0.05
+RouteExecutionEventState = Literal[
+ "queued",
+ "sent",
+ "accepted",
+ "started",
+ "completed",
+ "reached",
+ "timeout",
+ "failed",
+ "skipped",
+ "stopped",
+]
+
+
+class RouteExecutionError(ValueError):
+ pass
+
+
+class GoalPublisher(Protocol):
+ transport_name: str
+
+ def publish_goal(self, *, x: float, y: float, z: float, frame_id: str) -> dict[str, Any]:
+ ...
+
+
+StopHandler = Callable[[], Any]
+
+
+class CallableGoalPublisher:
+ def __init__(
+ self,
+ handler: Callable[[float, float, float, str], Any],
+ *,
+ transport_name: str = "handler",
+ ) -> None:
+ self._handler = handler
+ self.transport_name = transport_name
+
+ def publish_goal(self, *, x: float, y: float, z: float, frame_id: str) -> dict[str, Any]:
+ result = self._handler(x, y, z, frame_id)
+ return {"transport": self.transport_name, "result": result}
+
+
+class ClickedPointGoalPublisher:
+ transport_name = "clicked_point"
+
+ def __init__(self, publisher: Any, point_type: type[Any]) -> None:
+ self._publisher = publisher
+ self._point_type = point_type
+
+ def publish_goal(self, *, x: float, y: float, z: float, frame_id: str) -> dict[str, Any]:
+ if self._publisher is None or not hasattr(self._publisher, "publish"):
+ raise RouteExecutionError("DogOps follow_route needs the DimOS clicked_point stream.")
+ point = self._point_type(ts=time.time(), frame_id=frame_id, x=x, y=y, z=z)
+ self._publisher.publish(point)
+ return {"transport": self.transport_name}
+
+
+class RouteExecutionEvent(DogOpsModel):
+ id: str
+ ts: float
+ route_id: str
+ waypoint_id: str
+ target_id: str | None = None
+ x: float
+ y: float
+ state: RouteExecutionEventState
+ elapsed_s: float = 0.0
+ error_m: float | None = None
+ retries: int = 0
+ guided: bool = False
+ note: str = ""
+ kind: Literal["route", "waypoint", "navigation", "action", "observation", "evidence", "incident", "system"] = "waypoint"
+ action_id: str | None = None
+ payload: dict[str, Any] = Field(default_factory=dict)
+
+
+class RouteExecutionState(DogOpsModel):
+ run_id: str
+ route_run_id: str | None = None
+ route_id: str = ""
+ state: Literal["idle", "running", "paused", "completed", "failed", "stopped"] = "idle"
+ started_at: float | None = None
+ completed_at: float | None = None
+ active_waypoint_id: str | None = None
+ active_action_id: str | None = None
+ active_index: int = 0
+ stop_requested: bool = False
+ frame: str = "map"
+ reach_radius_m: float = 0.35
+ waypoint_timeout_s: float = 20.0
+ max_retries: int = 1
+ transport: str = "unconfigured"
+ last_error: str | None = None
+ waypoints_total: int = 0
+ waypoints_reached: int = 0
+ events: list[RouteExecutionEvent] = Field(default_factory=list)
+
+ @field_validator("reach_radius_m", "waypoint_timeout_s")
+ @classmethod
+ def positive_float(cls, value: float) -> float:
+ result = float(value)
+ if not math.isfinite(result) or result <= 0:
+ raise ValueError("value must be a positive finite number")
+ return result
+
+
+class DogOpsRouteExecutor:
+ def __init__(
+ self,
+ run_dir: str | Path,
+ *,
+ goal_publisher: GoalPublisher | None = None,
+ live_snapshot_reader: Callable[[], dict[str, Any]] | None = None,
+ stop_handler: StopHandler | None = None,
+ scan_zone_handler: ScanZoneHandler | None = None,
+ capture_image_handler: CaptureImageHandler | None = None,
+ frame: str = "map",
+ reach_radius_m: float = 0.35,
+ waypoint_timeout_s: float = 20.0,
+ max_retries: int = 1,
+ no_progress_timeout_s: float | None = None,
+ require_goal_confirmation: bool = True,
+ poll_interval_s: float = 0.2,
+ time_fn: Callable[[], float] = time.time,
+ sleep_fn: Callable[[float], None] = time.sleep,
+ ) -> None:
+ self.run_dir = Path(run_dir)
+ self.goal_publisher = goal_publisher
+ self.live_snapshot_reader = live_snapshot_reader
+ self.stop_handler = stop_handler
+ self.scan_zone_handler = scan_zone_handler
+ self.capture_image_handler = capture_image_handler
+ self.frame = frame or "map"
+ self.reach_radius_m = reach_radius_m
+ self.waypoint_timeout_s = waypoint_timeout_s
+ self.max_retries = max_retries
+ self.require_goal_confirmation = require_goal_confirmation
+ self.no_progress_timeout_s = (
+ no_progress_timeout_s
+ if no_progress_timeout_s is not None
+ else min(5.0, max(1.0, waypoint_timeout_s / 2.0))
+ )
+ self.poll_interval_s = poll_interval_s
+ self.time_fn = time_fn
+ self.sleep_fn = sleep_fn
+
+ def follow_route(self, route_id: str | None = None, *, dry_run: bool = False) -> RouteExecutionState:
+ with route_execution_lock(self.run_dir):
+ route, frame = self._resolve_route(route_id)
+ route = self._route_with_default_actions(route)
+ self._validate_route(route)
+ started_at = self.time_fn()
+ route_run_id = new_route_run_id(route.id, now=started_at)
+ state = RouteExecutionState(
+ run_id=self.run_id,
+ route_run_id=route_run_id,
+ route_id=route.id,
+ state="running",
+ started_at=started_at,
+ frame=frame,
+ reach_radius_m=self.reach_radius_m,
+ waypoint_timeout_s=self.waypoint_timeout_s,
+ max_retries=self.max_retries,
+ transport="dry_run" if dry_run else self._transport_name,
+ waypoints_total=len(route.waypoints),
+ )
+ route_run_store = RouteRunStore(self.run_dir)
+ route_run_store.create_route_run(
+ route_run_id=route_run_id,
+ dogops_run_id=self.run_id,
+ route=route,
+ state=state,
+ dry_run=dry_run,
+ route_snapshot=route.model_dump(mode="json"),
+ )
+ save_route_execution(self.run_dir, state)
+ route_run_store.sync_execution_state(state)
+
+ for index, waypoint in enumerate(route.waypoints):
+ if load_route_execution(self.run_dir, run_id=self.run_id).stop_requested:
+ state = self._stop_state(state, waypoint, started_at)
+ break
+ state.active_index = index
+ state.active_waypoint_id = waypoint.id
+ queued = self._event(state, waypoint, "queued", started_at, note="waypoint queued")
+ state.events.append(queued)
+ save_route_execution(self.run_dir, state)
+ route_run_store.sync_execution_state(state)
+ if dry_run:
+ state = self._execute_waypoint_actions(state, waypoint, started_at, route_run_store)
+ route_run_store.sync_execution_state(state)
+ if state.state in {"failed", "stopped"}:
+ break
+ continue
+ state = self._execute_waypoint(state, waypoint, started_at)
+ route_run_store.sync_execution_state(state)
+ if state.state in {"failed", "stopped"}:
+ break
+ state = self._execute_waypoint_actions(state, waypoint, started_at, route_run_store)
+ route_run_store.sync_execution_state(state)
+ if state.state in {"failed", "stopped"}:
+ break
+
+ if dry_run:
+ if state.state == "running":
+ state.state = "completed"
+ state.active_waypoint_id = None
+ state.completed_at = self.time_fn()
+ save_route_execution(self.run_dir, state)
+ route_run_store.sync_execution_state(state)
+ return state
+
+ if state.state == "running":
+ state.state = "completed"
+ state.active_waypoint_id = None
+ state.completed_at = self.time_fn()
+ save_route_execution(self.run_dir, state)
+ route_run_store.sync_execution_state(state)
+ self._append_nav_events(state)
+ return state
+
+ def stop_route(self) -> RouteExecutionState:
+ state = request_route_stop(self.run_dir, run_id=self.run_id, now=self.time_fn)
+ if self.stop_handler is not None:
+ try:
+ self.stop_handler()
+ except Exception as exc:
+ state.last_error = f"route stop requested; stop handler failed: {exc}"
+ save_route_execution(self.run_dir, state)
+ elif state.state == "stopped":
+ state.last_error = "route stop requested; no navigation stop handler configured"
+ save_route_execution(self.run_dir, state)
+ RouteRunStore(self.run_dir).sync_execution_state(state)
+ self._append_nav_events(state)
+ return state
+
+ def status(self) -> RouteExecutionState:
+ return load_route_execution(self.run_dir, run_id=self.run_id)
+
+ @property
+ def run_id(self) -> str:
+ return self.run_dir.name
+
+ @property
+ def _transport_name(self) -> str:
+ if self.goal_publisher is None:
+ return "unconfigured"
+ return getattr(self.goal_publisher, "transport_name", self.goal_publisher.__class__.__name__)
+
+ def _resolve_route(self, route_id: str | None) -> tuple[EditableRoute, str]:
+ authoring = load_map_authoring(self.run_dir)
+ resolved_id = route_id or authoring.selected_route_id
+ if not resolved_id:
+ raise RouteExecutionError("no route_id supplied and no authored route is selected")
+ for route in authoring.routes:
+ if route.id == resolved_id:
+ return route, authoring.frame or self.frame
+ raise RouteExecutionError(f"unknown route_id: {resolved_id}")
+
+ def _validate_route(self, route: EditableRoute) -> None:
+ if not route.waypoints:
+ raise RouteExecutionError(f"route {route.id} has no waypoints")
+ for waypoint in route.waypoints:
+ if not math.isfinite(waypoint.pose.x) or not math.isfinite(waypoint.pose.y):
+ raise RouteExecutionError(f"waypoint {waypoint.id} has invalid coordinates")
+
+ def _route_with_default_actions(self, route: EditableRoute) -> EditableRoute:
+ state_path = self.run_dir / "state.json"
+ if not state_path.exists():
+ return route
+ store = DogOpsStore.load_existing(self.run_dir)
+ state = store.state
+ assert state is not None
+ steps_by_target: dict[str, list[Any]] = {}
+ for step in state.mission.steps:
+ steps_by_target.setdefault(step.target_id, []).append(step)
+ route_with_actions = route.model_copy(deep=True)
+ for waypoint in route_with_actions.waypoints:
+ if waypoint.actions:
+ continue
+ target_id = waypoint.target_id or waypoint.id
+ actions = []
+ for step in steps_by_target.get(target_id, []):
+ actions.extend(_mission_step_actions(state, step))
+ waypoint.actions = actions
+ return route_with_actions
+
+ def _execute_waypoint(
+ self,
+ state: RouteExecutionState,
+ waypoint: EditableRouteWaypoint,
+ started_at: float,
+ ) -> RouteExecutionState:
+ if self.goal_publisher is None:
+ state.state = "failed"
+ state.last_error = "navigation publisher unavailable"
+ state.events.append(
+ self._event(state, waypoint, "failed", started_at, note=state.last_error)
+ )
+ state.completed_at = self.time_fn()
+ save_route_execution(self.run_dir, state)
+ return state
+
+ best_error_m: float | None = None
+ for retry in range(self.max_retries + 1):
+ waypoint_started = self.time_fn()
+ try:
+ publish_result = self.goal_publisher.publish_goal(
+ x=waypoint.pose.x,
+ y=waypoint.pose.y,
+ z=0.0,
+ frame_id=state.frame,
+ )
+ except Exception as exc:
+ state.state = "failed"
+ state.last_error = f"goal publish failed: {exc}"
+ state.events.append(
+ self._event(
+ state,
+ waypoint,
+ "failed",
+ started_at,
+ retries=retry,
+ note=state.last_error,
+ )
+ )
+ state.completed_at = self.time_fn()
+ save_route_execution(self.run_dir, state)
+ return state
+
+ state.events.append(
+ self._event(
+ state,
+ waypoint,
+ "sent",
+ started_at,
+ retries=retry,
+ note=f"sent via {publish_result.get('transport') or self._transport_name}",
+ )
+ )
+ save_route_execution(self.run_dir, state)
+
+ reached, error_m, note = self._wait_until_reached(waypoint, waypoint_started)
+ best_error_m = error_m if best_error_m is None else min(best_error_m, error_m or best_error_m)
+ if reached:
+ state.waypoints_reached += 1
+ state.events.append(
+ self._event(
+ state,
+ waypoint,
+ "reached",
+ started_at,
+ retries=retry,
+ error_m=error_m,
+ note=note,
+ )
+ )
+ save_route_execution(self.run_dir, state)
+ return state
+ if load_route_execution(self.run_dir, run_id=self.run_id).stop_requested:
+ return self._stop_state(state, waypoint, started_at)
+
+ state.state = "failed"
+ state.last_error = note
+ state.events.append(
+ self._event(
+ state,
+ waypoint,
+ "timeout",
+ started_at,
+ retries=self.max_retries,
+ error_m=best_error_m,
+ note=note,
+ )
+ )
+ state.completed_at = self.time_fn()
+ save_route_execution(self.run_dir, state)
+ return state
+
+ def _execute_waypoint_actions(
+ self,
+ state: RouteExecutionState,
+ waypoint: EditableRouteWaypoint,
+ started_at: float,
+ route_run_store: RouteRunStore,
+ ) -> RouteExecutionState:
+ for action in waypoint.actions:
+ if load_route_execution(self.run_dir, run_id=self.run_id).stop_requested:
+ return self._stop_state(state, waypoint, started_at)
+ state.active_action_id = action.id
+ state.events.append(
+ self._action_event(state, waypoint, action, "started", started_at, note="action started")
+ )
+ save_route_execution(self.run_dir, state)
+ route_run_store.sync_execution_state(state)
+ try:
+ result = execute_route_action(
+ action,
+ run_dir=self.run_dir,
+ route_run_id=state.route_run_id or "",
+ waypoint_id=waypoint.id,
+ route_id=state.route_id,
+ target_id=waypoint.target_id or waypoint.id,
+ pose=waypoint.pose.model_dump(mode="json"),
+ scan_zone_handler=self.scan_zone_handler,
+ capture_image_handler=self.capture_image_handler,
+ )
+ result_ok = result.ok
+ result_note = result.note
+ result_payload = result.payload
+ result_evidence = result.evidence
+ except Exception as exc:
+ result_ok = False
+ result_note = f"action {action.id} failed: {exc}"
+ result_payload = {"error": exc.__class__.__name__, "source": "exception"}
+ result_evidence = []
+ event_state: RouteExecutionEventState = result.state if result_ok else "failed"
+ state.events.append(
+ self._action_event(
+ state,
+ waypoint,
+ action,
+ event_state,
+ started_at,
+ note=result_note,
+ payload=result_payload,
+ )
+ )
+ save_route_execution(self.run_dir, state)
+ route_run_store.sync_execution_state(state)
+ action_event_id = f"{state.route_run_id}-{state.events[-1].id}" if state.route_run_id else state.events[-1].id
+ recorded_evidence: list[dict[str, Any]] = []
+ for evidence in result_evidence:
+ recorded_evidence.append(route_run_store.record_evidence(
+ route_run_id=state.route_run_id or "",
+ event_id=action_event_id,
+ observation_id=evidence.get("observation_id"),
+ kind=str(evidence.get("kind") or "evidence"),
+ path=evidence.get("path"),
+ metadata=evidence.get("metadata") or {},
+ mime_type=evidence.get("mime_type"),
+ ))
+ if recorded_evidence:
+ state.events[-1].payload["evidence"] = recorded_evidence
+ analysis_evidence = [
+ item for item in recorded_evidence if item.get("kind") == "gemini_vision_analysis"
+ ]
+ if analysis_evidence:
+ state.events[-1].payload["analysis_evidence_id"] = analysis_evidence[0]["evidence_id"]
+ save_route_execution(self.run_dir, state)
+ route_run_store.sync_execution_state(state)
+ if not result_ok and action.required:
+ state.state = "failed"
+ state.last_error = result_note or f"required action failed: {action.id}"
+ state.completed_at = self.time_fn()
+ save_route_execution(self.run_dir, state)
+ route_run_store.sync_execution_state(state)
+ return state
+ state.active_action_id = None
+ save_route_execution(self.run_dir, state)
+ return state
+
+ def _wait_until_reached(
+ self,
+ waypoint: EditableRouteWaypoint,
+ waypoint_started: float,
+ ) -> tuple[bool, float | None, str]:
+ if self.live_snapshot_reader is None:
+ return False, None, "no live odom reader configured"
+ best_error_m: float | None = None
+ last_progress_at = waypoint_started
+ goal_confirmed = False
+ note = "timeout waiting for odom"
+ while self.time_fn() - waypoint_started <= self.waypoint_timeout_s:
+ snapshot = self.live_snapshot_reader()
+ odom, odom_age_s = route_feedback_from_snapshot(snapshot)
+ goal_confirmed = (
+ True
+ if not self.require_goal_confirmation
+ else goal_confirmed or route_goal_confirmed(snapshot, waypoint)
+ )
+ if odom is None:
+ note = "no odom received"
+ elif odom_age_s is not None and odom_age_s > LIVE_TOPIC_MAX_AGE_S:
+ note = f"odom stale: {odom_age_s:.1f}s"
+ else:
+ error_m = math.hypot(odom["x"] - waypoint.pose.x, odom["y"] - waypoint.pose.y)
+ if best_error_m is None or error_m < best_error_m - PROGRESS_EPSILON_M:
+ best_error_m = error_m
+ last_progress_at = self.time_fn()
+ note = (
+ f"odom error {error_m:.2f}m; "
+ f"goal {'confirmed' if goal_confirmed else 'unconfirmed'}"
+ )
+ if error_m <= self.reach_radius_m and goal_confirmed:
+ return True, error_m, note
+ if self.time_fn() - last_progress_at >= self.no_progress_timeout_s:
+ return False, best_error_m, "no progress toward waypoint"
+ if load_route_execution(self.run_dir, run_id=self.run_id).stop_requested:
+ return False, best_error_m, "stop requested"
+ self.sleep_fn(self.poll_interval_s)
+ return False, best_error_m, note
+
+ def _event(
+ self,
+ state: RouteExecutionState,
+ waypoint: EditableRouteWaypoint,
+ event_state: RouteExecutionEventState,
+ started_at: float,
+ *,
+ retries: int = 0,
+ error_m: float | None = None,
+ note: str = "",
+ ) -> RouteExecutionEvent:
+ return RouteExecutionEvent(
+ id=f"RTE-{len(state.events) + 1:03d}",
+ ts=self.time_fn(),
+ route_id=state.route_id,
+ waypoint_id=waypoint.id,
+ target_id=waypoint.target_id or waypoint.id,
+ x=waypoint.pose.x,
+ y=waypoint.pose.y,
+ state=event_state,
+ elapsed_s=max(0.0, self.time_fn() - started_at),
+ error_m=error_m,
+ retries=retries,
+ guided=False,
+ note=note,
+ )
+
+ def _action_event(
+ self,
+ state: RouteExecutionState,
+ waypoint: EditableRouteWaypoint,
+ action: EditableRouteAction,
+ event_state: RouteExecutionEventState,
+ started_at: float,
+ *,
+ note: str = "",
+ payload: dict[str, Any] | None = None,
+ ) -> RouteExecutionEvent:
+ return RouteExecutionEvent(
+ id=f"RTE-{len(state.events) + 1:03d}",
+ ts=self.time_fn(),
+ route_id=state.route_id,
+ waypoint_id=waypoint.id,
+ target_id=waypoint.target_id or waypoint.id,
+ x=waypoint.pose.x,
+ y=waypoint.pose.y,
+ state=event_state,
+ elapsed_s=max(0.0, self.time_fn() - started_at),
+ guided=action.kind == "operator_prompt",
+ note=note,
+ kind="action",
+ action_id=action.id,
+ payload={"kind": action.kind, **(payload or {})},
+ )
+
+ def _stop_state(
+ self,
+ state: RouteExecutionState,
+ waypoint: EditableRouteWaypoint,
+ started_at: float,
+ ) -> RouteExecutionState:
+ state.state = "stopped"
+ state.stop_requested = True
+ state.last_error = "stop requested"
+ state.events.append(self._event(state, waypoint, "stopped", started_at, note="stop requested"))
+ state.completed_at = self.time_fn()
+ save_route_execution(self.run_dir, state)
+ RouteRunStore(self.run_dir).sync_execution_state(state)
+ self._append_nav_events(state)
+ return state
+
+ def _append_nav_events(self, route_state: RouteExecutionState) -> None:
+ state_path = self.run_dir / "state.json"
+ if not state_path.exists():
+ return
+ store = DogOpsStore.load_existing(self.run_dir)
+ state = store.state
+ assert state is not None
+ existing_events = {(event.ts, event.note) for event in state.nav_events}
+ for event in route_state.events:
+ if event.state not in {"reached", "timeout", "failed", "stopped"}:
+ continue
+ note = (
+ f"live route {route_state.route_id}: {event.state} "
+ f"{event.target_id or event.waypoint_id} via {route_state.transport}; {event.note}"
+ )
+ existing_key = (event.ts, note)
+ if existing_key in existing_events:
+ continue
+ nav_event = NavEvent(
+ id=f"NAV-{len(state.nav_events) + 1:03d}",
+ run_id=state.run.id,
+ ts=event.ts,
+ action=NavAction.goto,
+ target_id=event.target_id,
+ success=event.state == "reached",
+ elapsed_s=event.elapsed_s,
+ retries=event.retries,
+ guided=event.guided,
+ error_m=event.error_m,
+ note=note,
+ )
+ store.append_nav_event(nav_event)
+ existing_events.add(existing_key)
+ state.nav_summary = summarize_nav_events(state.run.id, state.nav_events)
+ store.write_state(state.run.id)
+ store.write_report(state.run.id)
+
+
+def route_execution_path(run_dir: str | Path) -> Path:
+ return Path(run_dir) / ROUTE_EXECUTION_FILENAME
+
+
+def route_execution_lock_path(run_dir: str | Path) -> Path:
+ return Path(run_dir) / ROUTE_EXECUTION_LOCK_FILENAME
+
+
+class route_execution_lock:
+ def __init__(self, run_dir: str | Path) -> None:
+ self.run_dir = Path(run_dir)
+ self.path = route_execution_lock_path(run_dir)
+
+ def __enter__(self) -> None:
+ self.run_dir.mkdir(parents=True, exist_ok=True)
+ try:
+ fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
+ except FileExistsError as exc:
+ state = load_route_execution(self.run_dir)
+ if state.state == "running":
+ raise RouteExecutionError(f"route {state.route_id} is already running") from exc
+ self.path.unlink(missing_ok=True)
+ fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
+ handle.write(f"{os.getpid()} {threading.get_ident()} {time.time()}\n")
+
+ def __exit__(self, exc_type: object, exc: object, traceback: object) -> None:
+ self.path.unlink(missing_ok=True)
+
+
+def load_route_execution(run_dir: str | Path, *, run_id: str | None = None) -> RouteExecutionState:
+ path = route_execution_path(run_dir)
+ if not path.exists():
+ return RouteExecutionState(run_id=run_id or Path(run_dir).name)
+ payload = json.loads(path.read_text(encoding="utf-8"))
+ state = RouteExecutionState.model_validate(payload)
+ if run_id is not None and state.run_id != run_id:
+ raise RouteExecutionError(f"loaded route execution for {state.run_id}, expected {run_id}")
+ return state
+
+
+def save_route_execution(run_dir: str | Path, state: RouteExecutionState) -> Path:
+ path = route_execution_path(run_dir)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ raw = json.dumps(state.model_dump(mode="json"), indent=2, sort_keys=True)
+ tmp_path = path.with_name(
+ f"{path.name}.{os.getpid()}.{threading.get_ident()}.{time.time_ns()}.tmp"
+ )
+ tmp_path.write_text(raw + "\n", encoding="utf-8")
+ tmp_path.replace(path)
+ return path
+
+
+def route_goal_confirmed(snapshot: dict[str, Any], waypoint: EditableRouteWaypoint) -> bool:
+ for key in ("target", "goal_request", "clicked_point"):
+ pose = snapshot.get(key)
+ if _pose_matches_waypoint(pose, waypoint):
+ return True
+ path = snapshot.get("path")
+ return isinstance(path, list) and bool(path)
+
+
+def request_route_stop(
+ run_dir: str | Path,
+ *,
+ run_id: str | None = None,
+ now: Callable[[], float] = time.time,
+) -> RouteExecutionState:
+ state = load_route_execution(run_dir, run_id=run_id)
+ if state.state not in {"completed", "failed", "stopped"}:
+ state.stop_requested = True
+ state.state = "stopped"
+ state.completed_at = now()
+ _append_stop_event_to_state(state, now=now)
+ save_route_execution(run_dir, state)
+ RouteRunStore(run_dir).sync_execution_state(state)
+ return state
+
+
+def _append_stop_event_to_state(state: RouteExecutionState, *, now: Callable[[], float]) -> None:
+ if state.state != "stopped" or any(event.state == "stopped" for event in state.events):
+ return
+ previous = state.events[-1] if state.events else None
+ if previous is None:
+ return
+ state.events.append(
+ RouteExecutionEvent(
+ id=f"RTE-{len(state.events) + 1:03d}",
+ ts=now(),
+ route_id=state.route_id,
+ waypoint_id=state.active_waypoint_id or previous.waypoint_id,
+ target_id=previous.target_id,
+ x=previous.x,
+ y=previous.y,
+ state="stopped",
+ elapsed_s=previous.elapsed_s,
+ retries=previous.retries,
+ guided=previous.guided,
+ note="stop requested",
+ kind="system",
+ )
+ )
+
+
+def route_feedback_from_snapshot(snapshot: dict[str, Any]) -> tuple[dict[str, float] | None, float | None]:
+ pose = snapshot.get("robot_pose") or snapshot.get("pose")
+ if not isinstance(pose, dict):
+ return None, None
+ try:
+ x = float(pose["x"])
+ y = float(pose["y"])
+ except (KeyError, TypeError, ValueError):
+ return None, None
+ if not math.isfinite(x) or not math.isfinite(y):
+ return None, None
+ age_s = None
+ topics = snapshot.get("topics")
+ if isinstance(topics, dict):
+ odom = topics.get("odom")
+ if isinstance(odom, dict) and odom.get("age_s") is not None:
+ try:
+ age_s = float(odom["age_s"])
+ except (TypeError, ValueError):
+ age_s = None
+ return {"x": x, "y": y}, age_s
+
+
+def _mission_step_actions(state: Any, step: Any) -> list[EditableRouteAction]:
+ base_args = {"target_id": step.target_id, "mission_action": step.action}
+ sim_obs = state.mission.simulation_observations.get(step.id)
+ if step.action == "scan_zone":
+ visible_tags = list(sim_obs.visible_tag_ids) if sim_obs is not None else []
+ qr_payloads = _qr_payloads_for_observation(sim_obs)
+ actions = [
+ EditableRouteAction(
+ id=f"{step.id}_tags",
+ kind="scan_tags",
+ label="scan_tags",
+ required=step.required,
+ timeout_s=step.timeout_s,
+ args={**base_args, "expected": visible_tags},
+ )
+ ]
+ if qr_payloads:
+ actions.append(
+ EditableRouteAction(
+ id=f"{step.id}_qr",
+ kind="scan_qr",
+ label="scan_qr",
+ required=False,
+ timeout_s=step.timeout_s,
+ args={**base_args, "expected": qr_payloads},
+ )
+ )
+ return actions
+ if step.action == "inspect_asset":
+ return [
+ EditableRouteAction(
+ id=f"{step.id}_image",
+ kind="capture_image",
+ label="capture_image",
+ required=False,
+ timeout_s=step.timeout_s,
+ args={**base_args, "target": step.target_id},
+ ),
+ EditableRouteAction(
+ id=step.id,
+ kind="inspect_asset",
+ label=step.action,
+ required=step.required,
+ timeout_s=step.timeout_s,
+ args=base_args,
+ ),
+ ]
+ if step.action == "verify_work_order":
+ return [
+ EditableRouteAction(
+ id=f"{step.id}_image",
+ kind="capture_image",
+ label="capture_image",
+ required=False,
+ timeout_s=step.timeout_s,
+ args={**base_args, "target": step.target_id},
+ ),
+ EditableRouteAction(
+ id=step.id,
+ kind="verify_work_order",
+ label=step.action,
+ required=step.required,
+ timeout_s=step.timeout_s,
+ args=base_args,
+ ),
+ ]
+ if step.action == "wait_for_human_fix":
+ return [
+ EditableRouteAction(
+ id=step.id,
+ kind="operator_prompt",
+ label=step.action,
+ required=step.required,
+ timeout_s=step.timeout_s,
+ args=base_args,
+ )
+ ]
+ return []
+
+
+def _qr_payloads_for_observation(sim_obs: Any) -> list[str]:
+ if sim_obs is None:
+ return []
+ payloads: list[str] = []
+ for key in sim_obs.facts:
+ if key.startswith("PKG-") and key.endswith(".zone_id"):
+ payloads.append(key.removesuffix(".zone_id"))
+ return sorted(set(payloads))
+
+
+def _pose_matches_waypoint(pose: Any, waypoint: EditableRouteWaypoint) -> bool:
+ if not isinstance(pose, dict):
+ return False
+ try:
+ x = float(pose["x"])
+ y = float(pose["y"])
+ except (KeyError, TypeError, ValueError):
+ return False
+ if not math.isfinite(x) or not math.isfinite(y):
+ return False
+ return math.hypot(x - waypoint.pose.x, y - waypoint.pose.y) <= GOAL_CONFIRM_RADIUS_M
diff --git a/dimos/experimental/dogops/route_run_store.py b/dimos/experimental/dogops/route_run_store.py
new file mode 100644
index 0000000000..4d64fb5f84
--- /dev/null
+++ b/dimos/experimental/dogops/route_run_store.py
@@ -0,0 +1,665 @@
+from __future__ import annotations
+
+from pathlib import Path
+import json
+import sqlite3
+import time
+from typing import Any
+import uuid
+
+
+ROUTE_RUN_DB_FILENAME = "dogops.sqlite"
+
+
+def dogops_history_root(run_dir: str | Path) -> Path:
+ path = Path(run_dir).resolve()
+ if path.parent.name == "runs" and path.parent.parent.name == ".dogops":
+ return path.parent.parent
+ return path.parent
+
+
+def route_run_db_path(run_dir: str | Path) -> Path:
+ return dogops_history_root(run_dir) / ROUTE_RUN_DB_FILENAME
+
+
+def new_route_run_id(route_id: str, *, now: float | None = None) -> str:
+ timestamp = time.strftime("%Y%m%d-%H%M%S", time.localtime(now or time.time()))
+ suffix = uuid.uuid4().hex[:8]
+ slug = "".join(ch if ch.isalnum() or ch in {"-", "_"} else "-" for ch in route_id).strip("-")
+ return f"RR-{timestamp}-{slug or 'route'}-{suffix}"
+
+
+class RouteRunStore:
+ def __init__(self, run_dir: str | Path, *, db_path: str | Path | None = None) -> None:
+ self.run_dir = Path(run_dir)
+ self.db_path = Path(db_path) if db_path is not None else route_run_db_path(self.run_dir)
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
+ self._ensure_schema()
+
+ def create_route_run(
+ self,
+ *,
+ route_run_id: str,
+ dogops_run_id: str,
+ route: Any,
+ state: Any,
+ dry_run: bool,
+ route_snapshot: dict[str, Any],
+ ) -> None:
+ with self._connect() as conn:
+ conn.execute(
+ """
+ INSERT INTO route_runs (
+ route_run_id, dogops_run_id, run_dir, route_id, route_label, mission_id,
+ state, started_at, completed_at, dry_run, transport, frame,
+ selected_route_snapshot_json, active_waypoint_id, active_action_id,
+ waypoints_total, waypoints_reached, actions_total, actions_completed,
+ last_error, summary
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ route_run_id,
+ dogops_run_id,
+ str(self.run_dir),
+ route.id,
+ route.label,
+ route.mission_id,
+ state.state,
+ state.started_at,
+ state.completed_at,
+ 1 if dry_run else 0,
+ state.transport,
+ state.frame,
+ json.dumps(route_snapshot, sort_keys=True),
+ state.active_waypoint_id,
+ getattr(state, "active_action_id", None),
+ state.waypoints_total,
+ state.waypoints_reached,
+ _route_action_count(route_snapshot),
+ 0,
+ state.last_error,
+ "",
+ ),
+ )
+
+ def sync_execution_state(self, state: Any) -> None:
+ route_run_id = getattr(state, "route_run_id", None)
+ if not route_run_id:
+ return
+ events = [event.model_dump(mode="json") for event in state.events]
+ actions_completed = sum(
+ 1 for event in events if event.get("kind") == "action" and event.get("state") == "completed"
+ )
+ with self._connect() as conn:
+ conn.execute(
+ """
+ UPDATE route_runs
+ SET state = ?, completed_at = ?, active_waypoint_id = ?, active_action_id = ?,
+ waypoints_total = ?, waypoints_reached = ?, actions_completed = ?,
+ last_error = ?, summary = ?
+ WHERE route_run_id = ?
+ """,
+ (
+ state.state,
+ state.completed_at,
+ state.active_waypoint_id,
+ getattr(state, "active_action_id", None),
+ state.waypoints_total,
+ state.waypoints_reached,
+ actions_completed,
+ state.last_error,
+ _summary_for_state(state),
+ route_run_id,
+ ),
+ )
+ for sequence, event in enumerate(events, 1):
+ event_id = f"{route_run_id}-{event['id']}"
+ conn.execute(
+ """
+ INSERT OR REPLACE INTO route_run_events (
+ event_id, route_run_id, ts, sequence, kind, state, waypoint_id,
+ action_id, target_id, x, y, error_m, retries, guided, payload_json, note
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ event_id,
+ route_run_id,
+ event["ts"],
+ sequence,
+ event.get("kind") or "waypoint",
+ event["state"],
+ event.get("waypoint_id"),
+ event.get("action_id"),
+ event.get("target_id"),
+ event.get("x"),
+ event.get("y"),
+ event.get("error_m"),
+ event.get("retries") or 0,
+ 1 if event.get("guided") else 0,
+ json.dumps(event.get("payload") or {}, sort_keys=True),
+ event.get("note") or "",
+ ),
+ )
+ self._write_route_run_exports(route_run_id, state)
+
+ def record_evidence(
+ self,
+ *,
+ route_run_id: str,
+ event_id: str | None,
+ observation_id: str | None,
+ kind: str,
+ path: str | Path | None,
+ metadata: dict[str, Any] | None = None,
+ mime_type: str | None = None,
+ ) -> dict[str, Any]:
+ evidence_id = f"EVD-{uuid.uuid4().hex[:10]}"
+ created_at = time.time()
+ evidence_path = str(path) if path is not None else None
+ with self._connect() as conn:
+ conn.execute(
+ """
+ INSERT INTO route_run_evidence (
+ evidence_id, route_run_id, event_id, observation_id, kind, path, uri,
+ sha256, mime_type, metadata_json, created_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ evidence_id,
+ route_run_id,
+ event_id,
+ observation_id,
+ kind,
+ evidence_path,
+ None,
+ None,
+ mime_type,
+ json.dumps(metadata or {}, sort_keys=True),
+ created_at,
+ ),
+ )
+ self._append_evidence_export(route_run_id, {
+ "evidence_id": evidence_id,
+ "route_run_id": route_run_id,
+ "event_id": event_id,
+ "observation_id": observation_id,
+ "kind": kind,
+ "path": evidence_path,
+ "mime_type": mime_type,
+ "metadata": metadata or {},
+ "created_at": created_at,
+ })
+ return {
+ "evidence_id": evidence_id,
+ "route_run_id": route_run_id,
+ "event_id": event_id,
+ "observation_id": observation_id,
+ "kind": kind,
+ "path": evidence_path,
+ "mime_type": mime_type,
+ "metadata": metadata or {},
+ "created_at": created_at,
+ }
+
+ def replace_timeline_events(
+ self,
+ dogops_run_id: str,
+ rows: list[dict[str, Any]],
+ *,
+ route_run_id: str | None = None,
+ ) -> None:
+ with self._connect() as conn:
+ if route_run_id is None:
+ conn.execute("DELETE FROM dogops_timeline_events WHERE dogops_run_id = ?", (dogops_run_id,))
+ else:
+ conn.execute(
+ "DELETE FROM dogops_timeline_events WHERE dogops_run_id = ? AND route_run_id = ?",
+ (dogops_run_id, route_run_id),
+ )
+ for sequence, row in enumerate(rows, 1):
+ row_route_run_id = row.get("route_run_id") or route_run_id or ""
+ source_event_id = str(row.get("event_id") or f"TL-{sequence:04d}")
+ event_id = f"{dogops_run_id}:{row_route_run_id or 'run'}:{source_event_id}"
+ conn.execute(
+ """
+ INSERT OR REPLACE INTO dogops_timeline_events (
+ event_id, dogops_run_id, route_run_id, ts, sequence, kind, state,
+ target_id, note, payload_json
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ event_id,
+ dogops_run_id,
+ row_route_run_id or None,
+ float(row.get("ts") or 0.0),
+ int(row.get("sequence") or sequence),
+ str(row.get("kind") or "system"),
+ str(row.get("state") or ""),
+ row.get("target_id"),
+ str(row.get("note") or ""),
+ json.dumps(row.get("payload") or {}, sort_keys=True),
+ ),
+ )
+
+ def timeline_events(
+ self,
+ *,
+ dogops_run_id: str | None = None,
+ route_run_id: str | None = None,
+ ) -> list[dict[str, Any]]:
+ filters = []
+ args: list[Any] = []
+ if dogops_run_id is not None:
+ filters.append("dogops_run_id = ?")
+ args.append(dogops_run_id)
+ if route_run_id is not None:
+ filters.append("(route_run_id = ? OR route_run_id IS NULL)")
+ args.append(route_run_id)
+ where = f"WHERE {' AND '.join(filters)}" if filters else ""
+ with self._connect() as conn:
+ rows = conn.execute(
+ f"""
+ SELECT * FROM dogops_timeline_events
+ {where}
+ ORDER BY ts ASC, sequence ASC, event_id ASC
+ """,
+ args,
+ ).fetchall()
+ return [_row_to_dict(row) for row in rows]
+
+ def list_route_runs(self, *, limit: int = 50) -> list[dict[str, Any]]:
+ with self._connect() as conn:
+ rows = conn.execute(
+ """
+ SELECT * FROM route_runs
+ ORDER BY started_at DESC, route_run_id DESC
+ LIMIT ?
+ """,
+ (limit,),
+ ).fetchall()
+ return [_row_to_dict(row) for row in rows]
+
+ def current_route_run(self) -> dict[str, Any] | None:
+ with self._connect() as conn:
+ row = conn.execute(
+ """
+ SELECT * FROM route_runs
+ WHERE dogops_run_id = ?
+ ORDER BY
+ CASE state WHEN 'running' THEN 0 WHEN 'queued' THEN 1 ELSE 2 END,
+ started_at DESC
+ LIMIT 1
+ """,
+ (self.run_dir.name,),
+ ).fetchone()
+ return _row_to_dict(row) if row is not None else None
+
+ def route_run_detail(self, route_run_id: str) -> dict[str, Any] | None:
+ with self._connect() as conn:
+ row = conn.execute(
+ "SELECT * FROM route_runs WHERE route_run_id = ?",
+ (route_run_id,),
+ ).fetchone()
+ return _row_to_dict(row) if row is not None else None
+
+ def route_run_events(self, route_run_id: str) -> list[dict[str, Any]]:
+ with self._connect() as conn:
+ rows = conn.execute(
+ """
+ SELECT * FROM route_run_events
+ WHERE route_run_id = ?
+ ORDER BY sequence ASC
+ """,
+ (route_run_id,),
+ ).fetchall()
+ return [_row_to_dict(row) for row in rows]
+
+ def route_run_evidence(self, route_run_id: str) -> list[dict[str, Any]]:
+ with self._connect() as conn:
+ rows = conn.execute(
+ """
+ SELECT * FROM route_run_evidence
+ WHERE route_run_id = ?
+ ORDER BY created_at ASC
+ """,
+ (route_run_id,),
+ ).fetchall()
+ return [_row_to_dict(row) for row in rows]
+
+ def image_evidence_for_route_run_waypoint(
+ self,
+ *,
+ route_run_id: str,
+ waypoint_id: str,
+ ) -> dict[str, Any] | None:
+ rows = [
+ row
+ for row in self._image_evidence_rows()
+ if row["route_run_id"] == route_run_id
+ and row.get("metadata", {}).get("waypoint_id") == waypoint_id
+ ]
+ if not rows:
+ return None
+ return max(rows, key=lambda row: float(row.get("created_at") or 0.0))
+
+ def latest_image_evidence_for_waypoint(
+ self,
+ *,
+ waypoint_id: str,
+ exclude_route_run_id: str,
+ route_id: str | None = None,
+ target_id: str | None = None,
+ pose: dict[str, Any] | None = None,
+ pose_tolerance_m: float = 0.3,
+ baseline_policy: str = "same_waypoint_latest_previous",
+ ) -> dict[str, Any] | None:
+ current_started_at = self._route_run_started_at(exclude_route_run_id)
+ rows = [
+ row
+ for row in self._image_evidence_rows()
+ if row["route_run_id"] != exclude_route_run_id
+ and (
+ current_started_at is None
+ or float(row.get("started_at") or 0.0) < current_started_at
+ )
+ ]
+ same_waypoint = [
+ row for row in rows if row.get("metadata", {}).get("waypoint_id") == waypoint_id
+ ]
+ if same_waypoint:
+ same_route_waypoint = [
+ row for row in same_waypoint if route_id and row.get("route_id") == route_id
+ ]
+ result = _select_same_waypoint_baseline(
+ same_route_waypoint or same_waypoint,
+ exclude_route_run_id=exclude_route_run_id,
+ route_id=route_id,
+ baseline_policy=baseline_policy,
+ current_started_at=current_started_at,
+ )
+ if not result.get("baseline_match"):
+ result["baseline_match"] = (
+ "same_route_waypoint"
+ if route_id and result.get("route_id") == route_id
+ else "same_waypoint"
+ )
+ return result
+
+ if target_id:
+ same_target = [
+ row for row in rows if row.get("metadata", {}).get("target") == target_id
+ ]
+ if same_target:
+ result = max(same_target, key=_evidence_sort_key)
+ result["baseline_match"] = "same_target"
+ return result
+
+ if pose:
+ nearest = _nearest_pose_evidence(rows, pose=pose, tolerance_m=pose_tolerance_m)
+ if nearest is not None:
+ nearest["baseline_match"] = "nearest_pose"
+ return nearest
+ return None
+
+ def _image_evidence_rows(self) -> list[dict[str, Any]]:
+ with self._connect() as conn:
+ rows = conn.execute(
+ """
+ SELECT route_run_evidence.*, route_runs.route_id, route_runs.started_at
+ FROM route_run_evidence
+ JOIN route_runs ON route_runs.route_run_id = route_run_evidence.route_run_id
+ WHERE route_run_evidence.kind = 'image'
+ ORDER BY route_runs.started_at DESC, route_run_evidence.created_at DESC
+ """
+ ).fetchall()
+ return [_row_to_dict(row) for row in rows]
+
+ def _route_run_started_at(self, route_run_id: str) -> float | None:
+ with self._connect() as conn:
+ row = conn.execute(
+ "SELECT started_at FROM route_runs WHERE route_run_id = ?",
+ (route_run_id,),
+ ).fetchone()
+ if row is None:
+ return None
+ try:
+ return float(row["started_at"])
+ except (KeyError, TypeError, ValueError):
+ return None
+
+ def _write_route_run_exports(self, route_run_id: str, state: Any) -> None:
+ run_dir = self.run_dir / "route_runs" / route_run_id
+ run_dir.mkdir(parents=True, exist_ok=True)
+ run_payload = state.model_dump(mode="json")
+ (run_dir / "route_run.json").write_text(
+ json.dumps(run_payload, indent=2, sort_keys=True) + "\n",
+ encoding="utf-8",
+ )
+ with (run_dir / "events.jsonl").open("w", encoding="utf-8") as handle:
+ for event in run_payload.get("events") or []:
+ handle.write(json.dumps(event, sort_keys=True) + "\n")
+ (run_dir / "evidence").mkdir(exist_ok=True)
+
+ def _append_evidence_export(self, route_run_id: str, payload: dict[str, Any]) -> None:
+ run_dir = self.run_dir / "route_runs" / route_run_id
+ run_dir.mkdir(parents=True, exist_ok=True)
+ with (run_dir / "evidence.jsonl").open("a", encoding="utf-8") as handle:
+ handle.write(json.dumps(payload, sort_keys=True) + "\n")
+
+ def _connect(self) -> sqlite3.Connection:
+ conn = sqlite3.connect(self.db_path)
+ conn.row_factory = sqlite3.Row
+ return conn
+
+ def _ensure_schema(self) -> None:
+ with self._connect() as conn:
+ conn.executescript(
+ """
+ CREATE TABLE IF NOT EXISTS route_runs (
+ route_run_id TEXT PRIMARY KEY,
+ dogops_run_id TEXT NOT NULL,
+ run_dir TEXT NOT NULL,
+ route_id TEXT NOT NULL,
+ route_label TEXT,
+ mission_id TEXT,
+ state TEXT NOT NULL,
+ started_at REAL NOT NULL,
+ completed_at REAL,
+ operator_id TEXT,
+ dry_run INTEGER NOT NULL DEFAULT 0,
+ transport TEXT NOT NULL,
+ frame TEXT NOT NULL,
+ selected_route_snapshot_json TEXT NOT NULL,
+ active_waypoint_id TEXT,
+ active_action_id TEXT,
+ waypoints_total INTEGER NOT NULL DEFAULT 0,
+ waypoints_reached INTEGER NOT NULL DEFAULT 0,
+ actions_total INTEGER NOT NULL DEFAULT 0,
+ actions_completed INTEGER NOT NULL DEFAULT 0,
+ last_error TEXT,
+ summary TEXT NOT NULL DEFAULT ''
+ );
+
+ CREATE TABLE IF NOT EXISTS route_run_events (
+ event_id TEXT PRIMARY KEY,
+ route_run_id TEXT NOT NULL,
+ ts REAL NOT NULL,
+ sequence INTEGER NOT NULL,
+ kind TEXT NOT NULL,
+ state TEXT NOT NULL,
+ waypoint_id TEXT,
+ action_id TEXT,
+ target_id TEXT,
+ x REAL,
+ y REAL,
+ error_m REAL,
+ retries INTEGER NOT NULL DEFAULT 0,
+ guided INTEGER NOT NULL DEFAULT 0,
+ payload_json TEXT NOT NULL DEFAULT '{}',
+ note TEXT NOT NULL DEFAULT '',
+ FOREIGN KEY(route_run_id) REFERENCES route_runs(route_run_id)
+ );
+
+ CREATE TABLE IF NOT EXISTS route_run_evidence (
+ evidence_id TEXT PRIMARY KEY,
+ route_run_id TEXT NOT NULL,
+ event_id TEXT,
+ observation_id TEXT,
+ kind TEXT NOT NULL,
+ path TEXT,
+ uri TEXT,
+ sha256 TEXT,
+ mime_type TEXT,
+ metadata_json TEXT NOT NULL DEFAULT '{}',
+ created_at REAL NOT NULL,
+ FOREIGN KEY(route_run_id) REFERENCES route_runs(route_run_id),
+ FOREIGN KEY(event_id) REFERENCES route_run_events(event_id)
+ );
+
+ CREATE TABLE IF NOT EXISTS dogops_timeline_events (
+ event_id TEXT PRIMARY KEY,
+ dogops_run_id TEXT NOT NULL,
+ route_run_id TEXT,
+ ts REAL NOT NULL,
+ sequence INTEGER NOT NULL,
+ kind TEXT NOT NULL,
+ state TEXT NOT NULL,
+ target_id TEXT,
+ note TEXT NOT NULL DEFAULT '',
+ payload_json TEXT NOT NULL DEFAULT '{}'
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_route_runs_started_at
+ ON route_runs(started_at DESC);
+ CREATE INDEX IF NOT EXISTS idx_route_runs_dogops_run_id
+ ON route_runs(dogops_run_id, started_at DESC);
+ CREATE INDEX IF NOT EXISTS idx_route_runs_route_id
+ ON route_runs(route_id, started_at DESC);
+ CREATE INDEX IF NOT EXISTS idx_route_run_events_run_sequence
+ ON route_run_events(route_run_id, sequence);
+ CREATE INDEX IF NOT EXISTS idx_route_run_evidence_run
+ ON route_run_evidence(route_run_id, created_at);
+ CREATE INDEX IF NOT EXISTS idx_dogops_timeline_run
+ ON dogops_timeline_events(dogops_run_id, ts, sequence);
+ CREATE INDEX IF NOT EXISTS idx_dogops_timeline_route_run
+ ON dogops_timeline_events(route_run_id, ts, sequence);
+ """
+ )
+
+
+def _route_action_count(route_snapshot: dict[str, Any]) -> int:
+ return sum(len(waypoint.get("actions") or []) for waypoint in route_snapshot.get("waypoints") or [])
+
+
+def _summary_for_state(state: Any) -> str:
+ return (
+ f"{state.state}: {state.waypoints_reached}/{state.waypoints_total} waypoints"
+ + (f"; {state.last_error}" if state.last_error else "")
+ )
+
+
+def _row_to_dict(row: sqlite3.Row | None) -> dict[str, Any]:
+ if row is None:
+ return {}
+ result = dict(row)
+ for key in ("selected_route_snapshot_json", "payload_json", "metadata_json"):
+ if key in result:
+ raw = result.pop(key)
+ target = key.removesuffix("_json")
+ try:
+ result[target] = json.loads(raw or "{}")
+ except json.JSONDecodeError:
+ result[target] = {}
+ return result
+
+
+def _evidence_sort_key(row: dict[str, Any]) -> tuple[float, float, str]:
+ return (
+ float(row.get("started_at") or 0.0),
+ float(row.get("created_at") or 0.0),
+ str(row.get("evidence_id") or ""),
+ )
+
+
+def _nearest_pose_evidence(
+ rows: list[dict[str, Any]],
+ *,
+ pose: dict[str, Any],
+ tolerance_m: float,
+) -> dict[str, Any] | None:
+ try:
+ x = float(pose["x"])
+ y = float(pose["y"])
+ except (KeyError, TypeError, ValueError):
+ return None
+ candidates: list[tuple[float, dict[str, Any]]] = []
+ for row in rows:
+ metadata_pose = row.get("metadata", {}).get("pose")
+ if not isinstance(metadata_pose, dict):
+ continue
+ try:
+ dx = float(metadata_pose["x"]) - x
+ dy = float(metadata_pose["y"]) - y
+ except (KeyError, TypeError, ValueError):
+ continue
+ distance = (dx * dx + dy * dy) ** 0.5
+ if distance <= tolerance_m:
+ candidates.append((distance, row))
+ if not candidates:
+ return None
+ candidates.sort(key=lambda item: (item[0], -_evidence_sort_key(item[1])[0]))
+ return candidates[0][1]
+
+
+def _select_same_waypoint_baseline(
+ rows: list[dict[str, Any]],
+ *,
+ exclude_route_run_id: str,
+ route_id: str | None,
+ baseline_policy: str,
+ current_started_at: float | None,
+) -> dict[str, Any]:
+ if baseline_policy in {"yesterday", "previous_calendar_day"} and current_started_at is not None:
+ previous_day = time.localtime(current_started_at - 24 * 60 * 60)
+ previous_day_rows = [
+ row
+ for row in rows
+ if _same_local_day(float(row.get("started_at") or 0.0), previous_day)
+ ]
+ if previous_day_rows:
+ result = max(previous_day_rows, key=_evidence_sort_key)
+ result["baseline_match"] = (
+ "previous_day_same_route_waypoint"
+ if route_id and result.get("route_id") == route_id
+ else "previous_day_same_waypoint"
+ )
+ return result
+
+ window_start = current_started_at - 36 * 60 * 60
+ window_end = current_started_at - 12 * 60 * 60
+ window_rows = [
+ row
+ for row in rows
+ if window_start <= float(row.get("started_at") or 0.0) <= window_end
+ ]
+ if window_rows:
+ result = max(window_rows, key=_evidence_sort_key)
+ result["baseline_match"] = (
+ "previous_day_window_same_route_waypoint"
+ if route_id and result.get("route_id") == route_id
+ else "previous_day_window_same_waypoint"
+ )
+ return result
+
+ result = max(rows, key=_evidence_sort_key)
+ if baseline_policy in {"yesterday", "previous_calendar_day"}:
+ result["baseline_match"] = "latest_previous_same_waypoint"
+ return result
+
+
+def _same_local_day(timestamp: float, target_day: time.struct_time) -> bool:
+ candidate = time.localtime(timestamp)
+ return (
+ candidate.tm_year == target_day.tm_year
+ and candidate.tm_yday == target_day.tm_yday
+ )
diff --git a/dimos/experimental/dogops/skills.py b/dimos/experimental/dogops/skills.py
new file mode 100644
index 0000000000..03ee8a76f6
--- /dev/null
+++ b/dimos/experimental/dogops/skills.py
@@ -0,0 +1,1296 @@
+from __future__ import annotations
+
+from collections.abc import Callable
+import json
+import math
+from pathlib import Path
+import struct
+import time
+from typing import Any, TypeVar
+import zlib
+
+from dimos.experimental.dogops.config_loader import (
+ DEFAULT_MANIFEST,
+ DEFAULT_MISSION,
+ DEFAULT_POLICY,
+ DEFAULT_SITE,
+ load_manifest as read_manifest,
+ load_mission as read_mission,
+ load_site_config as read_site_config,
+)
+from dimos.experimental.dogops.detector import DetectedTag, DogOpsTagDetector
+from dimos.experimental.dogops.heatmap_runs import gather_heatmap_run
+from dimos.experimental.dogops.mission_engine import run_offline_simulation
+from dimos.experimental.dogops.models import (
+ Asset,
+ DogOpsState,
+ Incident,
+ IncidentState,
+ IncidentType,
+ MissionState,
+ Observation,
+ PackageState,
+ Severity,
+ WorkOrder,
+ WorkOrderState,
+)
+from dimos.experimental.dogops.report import build_report_data
+from dimos.experimental.dogops.live_map import DogOpsLiveMapAdapter
+from dimos.experimental.dogops.route_executor import (
+ CallableGoalPublisher,
+ ClickedPointGoalPublisher,
+ DogOpsRouteExecutor,
+ RouteExecutionError,
+)
+from dimos.experimental.dogops.store import DogOpsStore
+
+try: # pragma: no cover - exercised only inside a full DimOS checkout.
+ from reactivex.disposable import Disposable as RxDisposable
+except ModuleNotFoundError: # pragma: no cover - local project-pack fallback.
+ RxDisposable = None
+
+try: # pragma: no cover - exercised only inside a full DimOS checkout.
+ from dimos.agents.annotation import skill
+except ModuleNotFoundError: # pragma: no cover - fallback behavior is tested through methods.
+ F = TypeVar("F", bound=Callable[..., Any])
+
+ def skill(func: F | None = None, **metadata: object) -> F | Callable[[F], F]:
+ def decorate(inner: F) -> F:
+ inner.__dogops_skill__ = True # type: ignore[attr-defined]
+ inner.__dogops_skill_metadata__ = metadata # type: ignore[attr-defined]
+ return inner
+
+ if func is None:
+ return decorate
+ return decorate(func)
+
+
+try: # pragma: no cover - exercised only inside a full DimOS checkout.
+ from dimos.core.module import Module
+except ModuleNotFoundError:
+
+ class Module:
+ def __init__(self, **_: object) -> None:
+ pass
+
+ @classmethod
+ def blueprint(cls, **kwargs: object) -> dict[str, object]:
+ return {"module": cls.__name__, "kwargs": kwargs}
+
+ def start(self) -> None:
+ pass
+
+ def stop(self) -> None:
+ pass
+
+
+try: # pragma: no cover - exercised only inside a full DimOS checkout.
+ from dimos.core.stream import In, Out
+ from dimos.msgs.geometry_msgs.PointStamped import PointStamped
+ from dimos.msgs.sensor_msgs.Image import Image
+except ModuleNotFoundError: # pragma: no cover - local project-pack fallback.
+
+ class In:
+ def __class_getitem__(cls, _: object) -> type[In]:
+ return cls
+
+ class Out:
+ def __class_getitem__(cls, _: object) -> type[Out]:
+ return cls
+
+ class PointStamped:
+ def __init__(
+ self,
+ *,
+ ts: float,
+ frame_id: str,
+ x: float,
+ y: float,
+ z: float,
+ ) -> None:
+ self.ts = ts
+ self.frame_id = frame_id
+ self.x = x
+ self.y = y
+ self.z = z
+
+ Image = Any # type: ignore[misc, assignment]
+
+
+class DogOpsSkillContainer(Module):
+ """Deterministic SiteOps skills exposed through DimOS MCP or direct tests."""
+
+ color_image: In[Image]
+ clicked_point: Out[PointStamped]
+
+ def __init__(
+ self,
+ *,
+ site_path: str | Path = DEFAULT_SITE,
+ manifest_path: str | Path = DEFAULT_MANIFEST,
+ mission_path: str | Path = DEFAULT_MISSION,
+ policy_path: str | Path = DEFAULT_POLICY,
+ run_dir: str | Path = ".dogops/runs/latest",
+ go_to_handler: Callable[[float, float, float, str], object] | None = None,
+ route_stop_handler: Callable[[], object] | None = None,
+ live_map_adapter: DogOpsLiveMapAdapter | None = None,
+ runtime_module: bool = False,
+ **_: object,
+ ) -> None:
+ if runtime_module or _:
+ super().__init__(**_)
+ self.site_path = Path(site_path)
+ self.manifest_path = Path(manifest_path)
+ self.mission_path = Path(mission_path)
+ self.policy_path = Path(policy_path)
+ self.run_dir = Path(run_dir)
+ self._go_to_handler = go_to_handler
+ self._route_stop_handler = route_stop_handler
+ self._live_map_adapter = live_map_adapter
+ self._latest_camera_image: object | None = None
+ self._latest_camera_received_at: float | None = None
+
+ def start(self) -> None: # pragma: no cover - lifecycle exercised in full DimOS.
+ super().start()
+ color_image = getattr(self, "color_image", None)
+ if color_image is not None and hasattr(color_image, "subscribe"):
+ subscription = color_image.subscribe(self.ingest_camera_image)
+ _register_subscription(self, subscription)
+
+ def ingest_camera_image(self, image: object) -> None:
+ self._latest_camera_image = image
+ self._latest_camera_received_at = time.time()
+
+ @skill
+ def load_site_config(self, path: str = str(DEFAULT_SITE)) -> str:
+ """Load a DogOps site configuration file and report inventory counts."""
+ self.site_path = Path(path)
+ site = read_site_config(self.site_path)
+ return _json(
+ ok=True,
+ skill="load_site_config",
+ site_id=site.site_id,
+ zones=len(site.zones),
+ assets=len(site.assets),
+ packages=len(site.packages),
+ )
+
+ @skill
+ def load_manifest(self, path: str = str(DEFAULT_MANIFEST)) -> str:
+ """Load a receiving manifest file and report the package count."""
+ self.manifest_path = Path(path)
+ manifest = read_manifest(self.manifest_path)
+ return _json(
+ ok=True,
+ skill="load_manifest",
+ manifest_id=manifest.manifest_id,
+ packages=len(manifest.items),
+ )
+
+ @skill
+ def load_mission(self, path: str = str(DEFAULT_MISSION)) -> str:
+ """Load a DogOps mission plan and report its step count."""
+ self.mission_path = Path(path)
+ mission = read_mission(self.mission_path)
+ return _json(
+ ok=True,
+ skill="load_mission",
+ mission_id=mission.mission_id,
+ steps=len(mission.steps),
+ )
+
+ @skill
+ def run_mission(self, mission_id: str = "receiving_sre_demo") -> str:
+ """Run the deterministic DogOps mission simulation."""
+ mission = read_mission(self.mission_path)
+ if mission.mission_id != mission_id:
+ return _json(
+ ok=False,
+ skill="run_mission",
+ error="unknown_mission",
+ mission_id=mission_id,
+ configured_mission_id=mission.mission_id,
+ )
+ state = run_offline_simulation(
+ site=self.site_path,
+ manifest=self.manifest_path,
+ mission=self.mission_path,
+ policy=self.policy_path,
+ out=self.run_dir,
+ )
+ return _json(
+ ok=True,
+ skill="run_mission",
+ run_id=state.run.id,
+ mission_id=state.run.mission_id,
+ state=state.run.state,
+ report=str(self.run_dir / "report.md"),
+ summary=state.run.summary,
+ )
+
+ @skill
+ def go_to(self, x: float, y: float, z: float = 0.0, frame_id: str = "map") -> str:
+ """Send a navigation target through the DimOS clicked point stream."""
+ try:
+ x_f, y_f, z_f = _finite_point(x, y, z)
+ except ValueError as exc:
+ return _json(
+ ok=False,
+ skill="go_to",
+ error="invalid_go_to_target",
+ message=str(exc),
+ )
+ frame = frame_id.strip() or "map"
+ if self._go_to_handler is not None:
+ result = self._go_to_handler(x_f, y_f, z_f, frame)
+ return _json(
+ ok=True,
+ skill="go_to",
+ transport="handler",
+ x=x_f,
+ y=y_f,
+ z=z_f,
+ frame_id=frame,
+ result=result,
+ )
+
+ publisher = getattr(self, "clicked_point", None)
+ if publisher is None or not hasattr(publisher, "publish"):
+ return _json(
+ ok=False,
+ skill="go_to",
+ error="navigation_stream_unavailable",
+ message="DogOps go_to needs the DimOS clicked_point stream.",
+ x=x_f,
+ y=y_f,
+ z=z_f,
+ frame_id=frame,
+ )
+
+ point = PointStamped(ts=time.time(), frame_id=frame, x=x_f, y=y_f, z=z_f)
+ publisher.publish(point)
+ return _json(
+ ok=True,
+ skill="go_to",
+ transport="clicked_point",
+ x=x_f,
+ y=y_f,
+ z=z_f,
+ frame_id=frame,
+ )
+
+ @skill
+ def follow_route(self, route_id: str | None = None, dry_run: bool = False) -> str:
+ """Follow the selected authored DogOps route waypoint by waypoint."""
+ publisher = self._route_goal_publisher()
+ if not dry_run and publisher is None:
+ return _json(
+ ok=False,
+ skill="follow_route",
+ error="navigation_stream_unavailable",
+ message="DogOps follow_route needs the DimOS clicked_point stream or a handler.",
+ )
+ live_adapter = self._live_map_adapter or DogOpsLiveMapAdapter()
+ executor = DogOpsRouteExecutor(
+ self.run_dir,
+ goal_publisher=publisher,
+ stop_handler=self._route_stop_handler,
+ scan_zone_handler=self.scan_zone,
+ capture_image_handler=self._capture_latest_camera_image,
+ live_snapshot_reader=live_adapter.snapshot if not dry_run else None,
+ )
+ try:
+ state = executor.follow_route(route_id, dry_run=dry_run)
+ except RouteExecutionError as exc:
+ return _json(
+ ok=False,
+ skill="follow_route",
+ error="route_execution_rejected",
+ message=str(exc),
+ )
+ return _json(
+ ok=state.state == "completed",
+ skill="follow_route",
+ route_id=state.route_id,
+ state=state.state,
+ active_waypoint_id=state.active_waypoint_id,
+ waypoints_total=state.waypoints_total,
+ waypoints_reached=state.waypoints_reached,
+ transport=state.transport,
+ dry_run=dry_run,
+ last_error=state.last_error,
+ route_execution=state.model_dump(mode="json"),
+ )
+
+ @skill
+ def stop_route(self) -> str:
+ """Request that a running DogOps authored route stop."""
+ executor = DogOpsRouteExecutor(self.run_dir, stop_handler=self._route_stop_handler)
+ try:
+ state = executor.stop_route()
+ except RouteExecutionError as exc:
+ return _json(
+ ok=False,
+ skill="stop_route",
+ error="route_execution_rejected",
+ message=str(exc),
+ )
+ return _json(
+ ok=state.last_error is None,
+ skill="stop_route",
+ route_id=state.route_id,
+ state=state.state,
+ active_waypoint_id=state.active_waypoint_id,
+ last_error=state.last_error,
+ route_execution=state.model_dump(mode="json"),
+ )
+
+ @skill
+ def route_status(self) -> str:
+ """Return the current DogOps authored-route execution state."""
+ executor = DogOpsRouteExecutor(self.run_dir)
+ state = executor.status()
+ return _json(
+ ok=True,
+ skill="route_status",
+ route_id=state.route_id,
+ state=state.state,
+ active_waypoint_id=state.active_waypoint_id,
+ waypoints_total=state.waypoints_total,
+ waypoints_reached=state.waypoints_reached,
+ transport=state.transport,
+ last_error=state.last_error,
+ route_execution=state.model_dump(mode="json"),
+ )
+
+ @skill
+ def gather_heatmap(self, area_id: str = "", duration_s: float = 0.0) -> str:
+ """Snapshot the current DimOS costmap as a DogOps gather-heatmap run."""
+ live_adapter = self._live_map_adapter or DogOpsLiveMapAdapter()
+ result = gather_heatmap_run(
+ self.run_dir,
+ live_snapshot=live_adapter.snapshot(),
+ live_snapshot_reader=live_adapter.snapshot,
+ area_id=area_id,
+ duration_s=duration_s,
+ )
+ return _json(skill="gather_heatmap", **result)
+
+ @skill
+ def scan_zone(self, zone_id: str) -> str:
+ """Scan a zone using the live camera when available, otherwise deterministic fixtures."""
+ camera_result = self._scan_zone_with_latest_camera(zone_id)
+ if camera_result is not None:
+ return camera_result
+
+ mission = read_mission(self.mission_path)
+ observations = [
+ obs for obs in mission.simulation_observations.values() if obs.zone_id == zone_id
+ ]
+ if not observations:
+ return _json(ok=False, skill="scan_zone", error="unknown_zone", zone_id=zone_id)
+ visible_tag_ids = sorted({tag for obs in observations for tag in obs.visible_tag_ids})
+ package_ids = sorted(
+ key.removesuffix(".zone_id")
+ for obs in observations
+ for key in obs.facts
+ if key.startswith("PKG-") and key.endswith(".zone_id")
+ )
+ return _json(
+ ok=True,
+ skill="scan_zone",
+ zone_id=zone_id,
+ visible_tag_ids=visible_tag_ids,
+ package_ids=package_ids,
+ source="simulation",
+ )
+
+ @skill
+ def camera_stream_status(self) -> str:
+ """Return whether DogOps has received a live camera frame."""
+ return _json(skill="camera_stream_status", **self._camera_stream_status())
+
+ def _capture_latest_camera_image(self, context: dict[str, Any]) -> dict[str, Any] | None:
+ if self._latest_camera_image is None:
+ raise RuntimeError("no live Go2 camera frame is available for capture_image")
+ action = context.get("action") if isinstance(context.get("action"), dict) else {}
+ action_args = action.get("args") if isinstance(action, dict) and isinstance(action.get("args"), dict) else {}
+ max_age_s = _positive_float(action_args.get("max_camera_frame_age_s"), default=2.0)
+ frame_age_s = self._camera_frame_age_s()
+ if frame_age_s is None or frame_age_s > max_age_s:
+ raise RuntimeError(
+ f"latest Go2 camera frame is stale for capture_image: age={frame_age_s}s max={max_age_s}s"
+ )
+ evidence_dir = Path(context["evidence_dir"])
+ evidence_dir.mkdir(parents=True, exist_ok=True)
+ waypoint_id = str(context["waypoint_id"])
+ action_id = str(context["action_id"])
+ path = evidence_dir / f"{waypoint_id}-{action_id}.png"
+ _write_camera_frame_png(path, self._latest_camera_image)
+ return {
+ "path": str(path),
+ "source": "go2_camera_live",
+ "mime_type": "image/png",
+ "metadata": {
+ "camera_frame_age_s": frame_age_s,
+ "camera_frame_id": getattr(self._latest_camera_image, "frame_id", None),
+ "camera_shape": _image_shape(self._latest_camera_image),
+ "camera_encoding": getattr(self._latest_camera_image, "encoding", None),
+ },
+ }
+
+ @skill
+ def read_gauge(self, asset_id: str = "TEMP_1") -> str:
+ """Read or simulate a gauge value for a configured asset."""
+ site = read_site_config(self.site_path)
+ asset = site.asset_by_id().get(asset_id)
+ if asset is None:
+ return _json(ok=False, skill="read_gauge", error="unknown_asset", asset_id=asset_id)
+ state = self._load_state_if_exists()
+ raw_reading, evidence_id = _latest_fact(state, f"{asset_id}.temperature_c")
+ threshold = _to_float(asset.expected_state.get("max_celsius"))
+ reading_celsius = _to_float(raw_reading)
+ source = "observation" if evidence_id is not None else "deterministic_expected_state"
+ if reading_celsius is None:
+ reading_celsius = _to_float(asset.expected_state.get("current_celsius"))
+ if reading_celsius is None and threshold is not None:
+ reading_celsius = round(threshold - 2.0, 1)
+ within_threshold = (
+ None if reading_celsius is None or threshold is None else reading_celsius <= threshold
+ )
+ status = asset.expected_status
+ if within_threshold is True:
+ status = "below_threshold"
+ elif within_threshold is False:
+ status = "above_threshold"
+ return _json(
+ ok=True,
+ skill="read_gauge",
+ asset_id=asset.id,
+ display_name=asset.display_name,
+ tag_id=asset.tag_id,
+ reading_celsius=reading_celsius,
+ max_celsius=threshold,
+ within_threshold=within_threshold,
+ state=status or "unknown",
+ evidence_observation_id=evidence_id,
+ source=source,
+ summary=(
+ f"{asset.id} reading {reading_celsius}C under {threshold}C."
+ if reading_celsius is not None and threshold is not None
+ else f"{asset.id} gauge read from expected state."
+ ),
+ )
+
+ @skill
+ def check_clearance(self, asset_id: str) -> str:
+ """Check whether an asset clearance is blocked."""
+ site = read_site_config(self.site_path)
+ asset = site.asset_by_id().get(asset_id)
+ if asset is None:
+ return _json(
+ ok=False,
+ skill="check_clearance",
+ error="unknown_asset",
+ asset_id=asset_id,
+ )
+ state = self._load_state_if_exists()
+ snapshot = _clearance_snapshot(asset, state)
+ return _json(
+ ok=True,
+ skill="check_clearance",
+ asset_id=asset.id,
+ display_name=asset.display_name,
+ tag_id=asset.tag_id,
+ expected_clear=asset.expected_clear,
+ **snapshot,
+ )
+
+ @skill
+ def detect_blocked_aisle(self, zone_id: str = "AISLE_1") -> str:
+ """Detect whether a configured aisle is blocked."""
+ site = read_site_config(self.site_path)
+ asset = site.asset_by_id().get(zone_id)
+ if asset is None:
+ asset = next(
+ (
+ candidate
+ for candidate in site.assets
+ if candidate.zone_id == zone_id
+ and candidate.asset_kind == "aisle_clearance"
+ ),
+ None,
+ )
+ if asset is None:
+ return _json(
+ ok=False,
+ skill="detect_blocked_aisle",
+ error="unknown_aisle",
+ zone_id=zone_id,
+ )
+ state = self._load_state_if_exists()
+ snapshot = _clearance_snapshot(asset, state)
+ open_blocked_incident = _has_open_incident(state, asset.id, IncidentType.blocked_aisle)
+ blocked = snapshot["clearance_clear"] is False or open_blocked_incident
+ return _json(
+ ok=True,
+ skill="detect_blocked_aisle",
+ zone_id=zone_id,
+ asset_id=asset.id,
+ display_name=asset.display_name,
+ blocked=blocked,
+ blocked_reason="blocked_aisle_incident" if open_blocked_incident else None,
+ **snapshot,
+ )
+
+ @skill
+ def scan_receiving_manifest(self, zone_id: str = "INBOUND_DOCK") -> str:
+ """Scan a receiving zone and compare observations with the manifest."""
+ site = read_site_config(self.site_path)
+ if zone_id not in site.zone_by_id():
+ return _json(
+ ok=False,
+ skill="scan_receiving_manifest",
+ error="unknown_zone",
+ zone_id=zone_id,
+ )
+ manifest = read_manifest(self.manifest_path)
+ mission = read_mission(self.mission_path)
+ state = self._load_state_if_exists()
+ expected_package_ids = sorted(
+ item.package_id for item in manifest.items if item.expected_zone_id == zone_id
+ )
+ observed_package_ids = _observed_packages_for_zone(state, mission, zone_id)
+ missing_package_ids = sorted(set(expected_package_ids) - set(observed_package_ids))
+ unexpected_package_ids = sorted(set(observed_package_ids) - set(expected_package_ids))
+ visible_tag_ids = _visible_tags_for_zone(state, mission, zone_id)
+ evidence_observation_ids = (
+ [obs.id for obs in state.observations if obs.zone_id == zone_id] if state else []
+ )
+ manifest_exceptions = len(missing_package_ids) + len(unexpected_package_ids)
+ return _json(
+ ok=True,
+ skill="scan_receiving_manifest",
+ zone_id=zone_id,
+ expected_package_ids=expected_package_ids,
+ observed_package_ids=observed_package_ids,
+ missing_package_ids=missing_package_ids,
+ unexpected_package_ids=unexpected_package_ids,
+ manifest_exceptions=manifest_exceptions,
+ visible_tag_ids=visible_tag_ids,
+ evidence_observation_ids=evidence_observation_ids,
+ summary=(
+ f"{len(observed_package_ids)}/{len(expected_package_ids)} expected packages "
+ f"observed at {zone_id}."
+ ),
+ )
+
+ @skill
+ def inspect_asset(self, asset_id: str) -> str:
+ """Inspect a configured asset and return its current issue state."""
+ site = read_site_config(self.site_path)
+ asset = site.asset_by_id().get(asset_id)
+ if asset is None:
+ return _json(ok=False, skill="inspect_asset", error="unknown_asset", asset_id=asset_id)
+ incidents = []
+ if self._state_file().exists():
+ state = DogOpsStore.load_existing(self.run_dir).state
+ assert state is not None
+ incidents = [
+ incident.model_dump(mode="json")
+ for incident in state.incidents
+ if incident.entity_id == asset_id
+ ]
+ return _json(
+ ok=True,
+ skill="inspect_asset",
+ asset_id=asset.id,
+ display_name=asset.display_name,
+ expected_clear=asset.expected_clear,
+ incidents=incidents,
+ )
+
+ @skill
+ def reconcile_manifest(self) -> str:
+ """Reconcile the current run against the receiving manifest."""
+ store = self._require_store("reconcile_manifest")
+ if isinstance(store, str):
+ return store
+ state = store.state
+ assert state is not None
+ report = build_report_data(state)
+ return _json(
+ ok=True,
+ skill="reconcile_manifest",
+ run_id=state.run.id,
+ packages_expected=report["packages_expected"],
+ packages_observed=report["packages_observed"],
+ manifest_exceptions=report["manifest_exceptions"],
+ open_issues=report["open_issues"],
+ )
+
+ @skill
+ def open_work_order(self, entity_id: str, issue_type: str) -> str:
+ """Open or return a work order for a DogOps incident."""
+ store = self._require_store("open_work_order")
+ if isinstance(store, str):
+ return store
+ state = store.state
+ assert state is not None
+ for incident in state.incidents:
+ if incident.entity_id == entity_id and incident.type == issue_type:
+ work_order = _work_order_for_incident(state.work_orders, incident.id)
+ return _json(
+ ok=True,
+ skill="open_work_order",
+ incident_id=incident.id,
+ work_order_id=work_order.id if work_order else None,
+ state=incident.state,
+ summary="Existing work order returned.",
+ )
+
+ incident_type = IncidentType(issue_type)
+ rule = state.policy.rule_for_type(incident_type)
+ incident = Incident(
+ id=f"INC-{len(state.incidents) + 1:03d}",
+ run_id=state.run.id,
+ ts_open=time.time(),
+ severity=Severity(rule.severity) if rule else Severity.P2,
+ type=incident_type,
+ entity_id=entity_id,
+ related_package_id=entity_id if entity_id.startswith("PKG-") else None,
+ state=IncidentState.open,
+ title=f"{entity_id} {issue_type}",
+ recommended_action=rule.recommended_action if rule else "Review and remediate.",
+ )
+ work_order = WorkOrder(
+ id=f"WO-{len(state.work_orders) + 1:03d}",
+ incident_id=incident.id,
+ requested_action=incident.recommended_action,
+ state=WorkOrderState.assigned,
+ )
+ store.append_incident(incident)
+ store.append_work_order(work_order)
+ store.write_state(state.run.id)
+ store.write_report(state.run.id)
+ return _json(
+ ok=True,
+ skill="open_work_order",
+ incident_id=incident.id,
+ work_order_id=work_order.id,
+ state=incident.state,
+ )
+
+ @skill
+ def mark_ready_to_verify(self, work_order_id: str) -> str:
+ """Mark a work order ready for robot verification."""
+ store = self._require_store("mark_ready_to_verify")
+ if isinstance(store, str):
+ return store
+ state = store.state
+ assert state is not None
+ work_order = _find_work_order(state.work_orders, work_order_id)
+ if work_order is None:
+ return _json(
+ ok=False,
+ skill="mark_ready_to_verify",
+ error="unknown_work_order",
+ work_order_id=work_order_id,
+ )
+ if work_order.state != WorkOrderState.verified_closed:
+ work_order.state = WorkOrderState.ready_to_verify
+ incident = _find_incident(state.incidents, work_order.incident_id)
+ if incident is not None and incident.state != IncidentState.resolved:
+ incident.state = IncidentState.ready_to_verify
+ store.update_incident(incident)
+ store.update_work_order(work_order)
+ store.write_state(state.run.id)
+ store.write_report(state.run.id)
+ return _json(
+ ok=True,
+ skill="mark_ready_to_verify",
+ work_order_id=work_order.id,
+ state=work_order.state,
+ )
+
+ @skill
+ def verify_work_order(self, work_order_id: str) -> str:
+ """Verify a ready work order and close it if resolved."""
+ store = self._require_store("verify_work_order")
+ if isinstance(store, str):
+ return store
+ state = store.state
+ assert state is not None
+ work_order = _find_work_order(state.work_orders, work_order_id)
+ if work_order is None:
+ return _json(
+ ok=False,
+ skill="verify_work_order",
+ error="unknown_work_order",
+ work_order_id=work_order_id,
+ )
+ incident = _find_incident(state.incidents, work_order.incident_id)
+ if work_order.state != WorkOrderState.verified_closed:
+ work_order.state = WorkOrderState.verified_closed
+ if incident is not None:
+ incident.state = IncidentState.resolved
+ incident.ts_closed = time.time()
+ store.update_incident(incident)
+ store.update_work_order(work_order)
+ store.write_state(state.run.id)
+ store.write_report(state.run.id)
+ return _json(
+ ok=True,
+ skill="verify_work_order",
+ work_order_id=work_order.id,
+ state=work_order.state,
+ summary=f"{incident.entity_id if incident else work_order.id} verified closed.",
+ )
+
+ @skill
+ def what_changed(self, since_run_id: str | None = None) -> str:
+ """Summarize operational changes in the current DogOps run."""
+ store = self._require_store("what_changed")
+ if isinstance(store, str):
+ return store
+ state = store.state
+ assert state is not None
+ if since_run_id is not None and since_run_id != state.run.id:
+ return _json(
+ ok=False,
+ skill="what_changed",
+ error="unknown_run",
+ since_run_id=since_run_id,
+ current_run_id=state.run.id,
+ )
+ return _json(ok=True, skill="what_changed", run_id=state.run.id, changes=state.what_changed)
+
+ @skill
+ def nav_eval_report(self, run_id: str | None = None) -> str:
+ """Return navigation evaluation metrics for a DogOps run."""
+ store = self._require_store("nav_eval_report")
+ if isinstance(store, str):
+ return store
+ state = store.state
+ assert state is not None
+ if run_id is not None and run_id != state.run.id:
+ return _json(
+ ok=False,
+ skill="nav_eval_report",
+ error="unknown_run",
+ run_id=run_id,
+ current_run_id=state.run.id,
+ )
+ return _json(
+ ok=True,
+ skill="nav_eval_report",
+ run_id=state.run.id,
+ nav_summary=state.nav_summary.model_dump(mode="json") if state.nav_summary else None,
+ )
+
+ @skill
+ def dock_align(self, dock_id: str = "DOCK_1") -> str:
+ """Report simulated AprilTag dock alignment readiness."""
+ site = read_site_config(self.site_path)
+ if not any(entity.id == dock_id for entity in site.special_entities.values()):
+ return _json(ok=False, skill="dock_align", error="unknown_dock", dock_id=dock_id)
+ return _json(
+ ok=True,
+ skill="dock_align",
+ dock_id=dock_id,
+ simulated=True,
+ aligned=True,
+ guided=False,
+ )
+
+ @skill
+ def portal_entry(self, portal_id: str = "PORTAL_1") -> str:
+ """Report simulated readiness for gated portal entry."""
+ site = read_site_config(self.site_path)
+ if not any(entity.id == portal_id for entity in site.special_entities.values()):
+ return _json(ok=False, skill="portal_entry", error="unknown_portal", portal_id=portal_id)
+ return _json(
+ ok=True,
+ skill="portal_entry",
+ portal_id=portal_id,
+ simulated=True,
+ door_open=True,
+ entered=True,
+ guided=False,
+ )
+
+ @skill
+ def stop_mission(self) -> str:
+ """Stop the current DogOps mission run if one is active."""
+ if not self._state_file().exists():
+ return _json(ok=True, skill="stop_mission", state="not_started")
+ store = DogOpsStore.load_existing(self.run_dir)
+ state = store.state
+ assert state is not None
+ if state.run.state not in {MissionState.done, MissionState.failed, MissionState.stopped}:
+ store.finish_run(
+ state.run.id,
+ MissionState.stopped,
+ "Mission stopped by DogOpsSkillContainer.",
+ ended_at=time.time(),
+ )
+ return _json(ok=True, skill="stop_mission", run_id=state.run.id, state=state.run.state)
+
+ def _state_file(self) -> Path:
+ return self.run_dir / "state.json"
+
+ def _load_state_if_exists(self) -> DogOpsState | None:
+ if not self._state_file().exists():
+ return None
+ store = DogOpsStore.load_existing(self.run_dir)
+ assert store.state is not None
+ return store.state
+
+ def _scan_zone_with_latest_camera(self, zone_id: str) -> str | None:
+ if self._latest_camera_image is None:
+ return None
+
+ site = read_site_config(self.site_path)
+ if zone_id not in site.zone_by_id():
+ return _json(ok=False, skill="scan_zone", error="unknown_zone", zone_id=zone_id)
+
+ try:
+ detections = DogOpsTagDetector(site).detect_dimos_image(self._latest_camera_image)
+ except RuntimeError as exc:
+ return _json(
+ ok=False,
+ skill="scan_zone",
+ error="camera_detector_unavailable",
+ message=str(exc),
+ zone_id=zone_id,
+ source="camera",
+ )
+
+ visible_tag_ids = sorted({detection.tag_id for detection in detections})
+ package_ids = _package_ids_from_detections(detections)
+ evidence_observation_ids = self._persist_camera_observations(
+ detections,
+ zone_id=zone_id,
+ )
+ return _json(
+ ok=True,
+ skill="scan_zone",
+ zone_id=zone_id,
+ visible_tag_ids=visible_tag_ids,
+ package_ids=package_ids,
+ source="camera",
+ detection_count=len(detections),
+ camera_frame_age_s=self._camera_frame_age_s(),
+ evidence_observation_ids=evidence_observation_ids,
+ )
+
+ def _persist_camera_observations(
+ self,
+ detections: list[DetectedTag],
+ *,
+ zone_id: str,
+ ) -> list[str]:
+ if not detections or not self._state_file().exists():
+ return []
+
+ store = DogOpsStore.load_existing(self.run_dir)
+ state = store.state
+ assert state is not None
+ evidence_ids: list[str] = []
+ observed_at = time.time()
+ for index, detection in enumerate(detections, start=1):
+ observation = _camera_observation(
+ detection,
+ zone_id=zone_id,
+ run_id=state.run.id,
+ observed_at=observed_at,
+ index=index,
+ )
+ store.append_observation(observation)
+ _apply_observation_package_facts(state, observation)
+ evidence_ids.append(observation.id)
+
+ store.write_state(state.run.id)
+ store.write_report(state.run.id)
+ return evidence_ids
+
+ def _camera_stream_status(self) -> dict[str, object]:
+ if self._latest_camera_image is None:
+ return {
+ "ok": False,
+ "mode": "not_subscribed",
+ "fallback": "scan_zone uses deterministic fixtures until a camera frame arrives",
+ }
+ return {
+ "ok": True,
+ "mode": "latest_frame",
+ "frame_age_s": self._camera_frame_age_s(),
+ "frame_id": getattr(self._latest_camera_image, "frame_id", None),
+ "shape": _image_shape(self._latest_camera_image),
+ }
+
+ def _camera_frame_age_s(self) -> float | None:
+ if self._latest_camera_received_at is None:
+ return None
+ return round(time.time() - self._latest_camera_received_at, 3)
+
+ def _require_store(self, skill_name: str) -> DogOpsStore | str:
+ if not self._state_file().exists():
+ return _json(ok=False, skill=skill_name, error="missing_run", run_dir=str(self.run_dir))
+ return DogOpsStore.load_existing(self.run_dir)
+
+ def _route_goal_publisher(self) -> CallableGoalPublisher | ClickedPointGoalPublisher | None:
+ if self._go_to_handler is not None:
+ return CallableGoalPublisher(self._go_to_handler)
+ publisher = getattr(self, "clicked_point", None)
+ if publisher is None or not hasattr(publisher, "publish"):
+ return None
+ return ClickedPointGoalPublisher(publisher, PointStamped)
+
+
+def _find_incident(incidents: list[Incident], incident_id: str) -> Incident | None:
+ for incident in incidents:
+ if incident.id == incident_id:
+ return incident
+ return None
+
+
+def _camera_observation(
+ detection: DetectedTag,
+ *,
+ zone_id: str,
+ run_id: str,
+ observed_at: float,
+ index: int,
+) -> Observation:
+ facts = _camera_detection_facts(detection, zone_id)
+ return Observation(
+ id=f"CAM-{int(observed_at * 1000)}-{index:02d}-{detection.tag_id}",
+ ts=observed_at,
+ run_id=run_id,
+ entity_id=detection.entity_id,
+ tag_id=detection.tag_id,
+ zone_id=zone_id,
+ facts=facts,
+ confidence=detection.confidence,
+ source=detection.source,
+ )
+
+
+def _camera_detection_facts(
+ detection: DetectedTag,
+ zone_id: str,
+) -> dict[str, bool | str | int | float]:
+ facts: dict[str, bool | str | int | float] = {
+ "entity_kind": detection.entity_kind or "unknown",
+ "detection_source": detection.source,
+ "frame_id": detection.frame_id or "",
+ "visible_tag_ids": str(detection.tag_id),
+ "center_px": _format_point(detection.center_px),
+ "area_px": round(detection.area_px, 3) if detection.area_px is not None else 0.0,
+ }
+ if detection.entity_id and detection.entity_id.startswith("PKG-"):
+ facts[f"{detection.entity_id}.zone_id"] = zone_id
+ return facts
+
+
+def _apply_observation_package_facts(state: DogOpsState, obs: Observation) -> None:
+ for key, value in obs.facts.items():
+ if not key.endswith(".zone_id"):
+ continue
+ package_id = key.removesuffix(".zone_id")
+ status = state.package_statuses.get(package_id)
+ if status is None or not isinstance(value, str):
+ continue
+ previous_zone = status.observed_zone_id
+ status.observed_zone_id = value
+ if value == status.expected_zone_id:
+ status.state = PackageState.found_ok
+ status.blocks_asset_id = None
+ else:
+ status.state = PackageState.wrong_zone
+ if package_id == "PKG-104" and previous_zone in {"RACK_ROW_A", "COOLING_1"}:
+ if value == "QA_HOLD":
+ state.what_changed.append(
+ "PKG-104 moved from COOLING_1/RACK_ROW_A to QA_HOLD; INC-001 resolved."
+ )
+
+
+def _package_ids_from_detections(detections: list[DetectedTag]) -> list[str]:
+ return sorted(
+ detection.entity_id
+ for detection in detections
+ if detection.entity_id is not None and detection.entity_id.startswith("PKG-")
+ )
+
+
+def _image_shape(image: object) -> list[int] | None:
+ shape = getattr(image, "shape", None)
+ data = getattr(image, "data", None)
+ if shape is None and data is not None:
+ shape = getattr(data, "shape", None)
+ if shape is None:
+ return None
+ return [int(item) for item in shape]
+
+
+def _write_camera_frame_png(path: Path, image: object) -> None:
+ array = _camera_array_from_image(image)
+ shape = getattr(array, "shape", None)
+ if shape is None and hasattr(image, "shape"):
+ shape = getattr(image, "shape")
+ if shape is None or len(shape) < 2:
+ raise RuntimeError("latest camera frame does not expose a 2D image shape")
+
+ height = int(shape[0])
+ width = int(shape[1])
+ channels = int(shape[2]) if len(shape) > 2 else 1
+ encoding = str(getattr(image, "encoding", "") or "").lower()
+ rows = []
+ for y in range(height):
+ row = bytearray()
+ for x in range(width):
+ row.extend(_rgb_pixel(array[y][x], channels=channels, encoding=encoding))
+ rows.append(b"\x00" + bytes(row))
+ raw = b"".join(rows)
+ path.write_bytes(
+ b"\x89PNG\r\n\x1a\n"
+ + _png_chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0))
+ + _png_chunk(b"tEXt", b"Description\x00DogOps live Go2 camera frame")
+ + _png_chunk(b"IDAT", zlib.compress(raw, level=6))
+ + _png_chunk(b"IEND", b"")
+ )
+
+
+def _camera_array_from_image(image: object) -> Any:
+ if hasattr(image, "to_opencv"):
+ return image.to_opencv() # type: ignore[attr-defined]
+ data = getattr(image, "data", None)
+ if data is not None and hasattr(data, "shape"):
+ return data
+ return image
+
+
+def _rgb_pixel(pixel: object, *, channels: int, encoding: str) -> bytes:
+ if channels <= 1:
+ value = _uint8(pixel)
+ return bytes((value, value, value))
+ values = [_uint8(pixel[index]) for index in range(min(channels, 4))] # type: ignore[index]
+ if len(values) < 3:
+ value = values[0] if values else 0
+ return bytes((value, value, value))
+ if "rgb" in encoding and "bgr" not in encoding:
+ red, green, blue = values[:3]
+ else:
+ blue, green, red = values[:3]
+ return bytes((red, green, blue))
+
+
+def _uint8(value: object) -> int:
+ result = int(value) # type: ignore[arg-type]
+ return max(0, min(255, result))
+
+
+def _positive_float(value: object, *, default: float) -> float:
+ try:
+ result = float(value) # type: ignore[arg-type]
+ except (TypeError, ValueError):
+ return default
+ if not math.isfinite(result) or result <= 0:
+ return default
+ return result
+
+
+def _png_chunk(kind: bytes, data: bytes) -> bytes:
+ checksum = zlib.crc32(kind + data) & 0xFFFFFFFF
+ return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", checksum)
+
+
+def _format_point(point: tuple[float, float] | None) -> str:
+ if point is None:
+ return ""
+ return f"{point[0]:.1f},{point[1]:.1f}"
+
+
+def _register_subscription(owner: object, subscription: object) -> None:
+ register = getattr(owner, "register_disposable", None)
+ if not callable(register):
+ return
+ if callable(subscription) and RxDisposable is not None:
+ register(RxDisposable(subscription))
+ return
+ register(subscription)
+
+
+def _find_work_order(work_orders: list[WorkOrder], work_order_id: str) -> WorkOrder | None:
+ for work_order in work_orders:
+ if work_order.id == work_order_id:
+ return work_order
+ return None
+
+
+def _finite_point(x: float, y: float, z: float) -> tuple[float, float, float]:
+ try:
+ point = (float(x), float(y), float(z))
+ except (TypeError, ValueError) as exc:
+ raise ValueError("go_to requires numeric x, y, and z") from exc
+ if not all(math.isfinite(value) for value in point):
+ raise ValueError("go_to target must be finite")
+ return point
+
+
+def _work_order_for_incident(work_orders: list[WorkOrder], incident_id: str) -> WorkOrder | None:
+ for work_order in work_orders:
+ if work_order.incident_id == incident_id:
+ return work_order
+ return None
+
+
+def _clearance_snapshot(asset: Asset, state: DogOpsState | None) -> dict[str, object]:
+ raw_clear, evidence_id = _latest_fact(state, f"{asset.id}.clearance_clear")
+ clearance_clear = _to_bool(raw_clear)
+ if clearance_clear is None:
+ clearance_clear = asset.expected_clear
+ blocking_package_ids = _blocking_package_ids(state, asset.id)
+ if not blocking_package_ids:
+ blocking_package_ids = sorted(asset.blocking_package_ids)
+ if blocking_package_ids:
+ clearance_clear = False
+ state_label = (
+ "clear" if clearance_clear is True else "blocked" if clearance_clear is False else "unknown"
+ )
+ return {
+ "clearance_clear": clearance_clear,
+ "state": state_label,
+ "blocking_package_ids": blocking_package_ids,
+ "evidence_observation_id": evidence_id,
+ }
+
+
+def _latest_fact(
+ state: DogOpsState | None, key: str
+) -> tuple[bool | str | int | float | None, str | None]:
+ if state is None:
+ return None, None
+ for obs in reversed(state.observations):
+ if key in obs.facts:
+ return obs.facts[key], obs.id
+ return None, None
+
+
+def _blocking_package_ids(state: DogOpsState | None, asset_id: str) -> list[str]:
+ if state is None:
+ return []
+ return sorted(
+ status.package_id
+ for status in state.package_statuses.values()
+ if status.blocks_asset_id == asset_id
+ )
+
+
+def _has_open_incident(
+ state: DogOpsState | None, entity_id: str, incident_type: IncidentType
+) -> bool:
+ if state is None:
+ return False
+ return any(
+ incident.entity_id == entity_id
+ and incident.type == incident_type
+ and incident.state != IncidentState.resolved
+ for incident in state.incidents
+ )
+
+
+def _observed_packages_for_zone(
+ state: DogOpsState | None, mission: object, zone_id: str
+) -> list[str]:
+ package_ids: set[str] = set()
+ if state is not None:
+ package_ids.update(
+ status.package_id
+ for status in state.package_statuses.values()
+ if status.observed_zone_id == zone_id
+ )
+ for obs in state.observations:
+ if obs.zone_id == zone_id:
+ package_ids.update(_package_ids_from_observation(obs, zone_id))
+ return sorted(package_ids)
+
+ observations = mission.simulation_observations.values()
+ for obs in observations:
+ if obs.zone_id == zone_id:
+ package_ids.update(_package_ids_from_facts(obs.facts, zone_id))
+ return sorted(package_ids)
+
+
+def _visible_tags_for_zone(
+ state: DogOpsState | None, mission: object, zone_id: str
+) -> list[int]:
+ tag_ids: set[int] = set()
+ if state is not None:
+ for obs in state.observations:
+ if obs.zone_id == zone_id:
+ tag_ids.update(_observation_tag_ids(obs))
+ return sorted(tag_ids)
+
+ observations = mission.simulation_observations.values()
+ for obs in observations:
+ if obs.zone_id == zone_id:
+ tag_ids.update(obs.visible_tag_ids)
+ return sorted(tag_ids)
+
+
+def _package_ids_from_observation(obs: Observation, zone_id: str) -> set[str]:
+ return _package_ids_from_facts(obs.facts, zone_id)
+
+
+def _package_ids_from_facts(
+ facts: dict[str, bool | str | int | float], zone_id: str
+) -> set[str]:
+ return {
+ key.removesuffix(".zone_id")
+ for key, value in facts.items()
+ if key.startswith("PKG-") and key.endswith(".zone_id") and value == zone_id
+ }
+
+
+def _observation_tag_ids(obs: Observation) -> set[int]:
+ tag_ids: set[int] = set()
+ if obs.tag_id is not None:
+ tag_ids.add(obs.tag_id)
+ raw_tag_ids = obs.facts.get("visible_tag_ids")
+ if isinstance(raw_tag_ids, str):
+ for item in raw_tag_ids.split(","):
+ item = item.strip()
+ if item:
+ tag_ids.add(int(item))
+ return tag_ids
+
+
+def _to_float(value: object) -> float | None:
+ if isinstance(value, bool):
+ return None
+ if isinstance(value, int | float):
+ return float(value)
+ if isinstance(value, str):
+ try:
+ return float(value)
+ except ValueError:
+ return None
+ return None
+
+
+def _to_bool(value: object) -> bool | None:
+ if isinstance(value, bool):
+ return value
+ if isinstance(value, str):
+ normalized = value.strip().lower()
+ if normalized in {"true", "yes", "1", "clear"}:
+ return True
+ if normalized in {"false", "no", "0", "blocked"}:
+ return False
+ return None
+
+
+def _json(**payload: object) -> str:
+ return json.dumps(payload, sort_keys=True)
diff --git a/dimos/experimental/dogops/store.py b/dimos/experimental/dogops/store.py
new file mode 100644
index 0000000000..376b64d701
--- /dev/null
+++ b/dimos/experimental/dogops/store.py
@@ -0,0 +1,175 @@
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+from dimos.experimental.dogops.models import (
+ DogOpsState,
+ Incident,
+ Manifest,
+ MissionConfig,
+ MissionRun,
+ MissionState,
+ NavEvent,
+ Observation,
+ PackageStatus,
+ PolicyConfig,
+ SiteConfig,
+ WorkOrder,
+)
+from dimos.experimental.dogops.report import build_report_data, render_report_markdown
+
+
+class DogOpsStore:
+ """JSON/JSONL run store for deterministic offline and demo runs."""
+
+ def __init__(
+ self,
+ root: str | Path,
+ *,
+ site: SiteConfig,
+ manifest: Manifest,
+ policy: PolicyConfig,
+ mission: MissionConfig,
+ ) -> None:
+ self.root = Path(root)
+ self.evidence_dir = self.root / "evidence"
+ self.site = site
+ self.manifest = manifest
+ self.policy = policy
+ self.mission = mission
+ self.state: DogOpsState | None = None
+
+ def create_run(self, mission_id: str, started_at: float) -> MissionRun:
+ self.root.mkdir(parents=True, exist_ok=True)
+ self.evidence_dir.mkdir(parents=True, exist_ok=True)
+ run = MissionRun(
+ id=self.root.name,
+ mission_id=mission_id,
+ started_at=started_at,
+ state=MissionState.running,
+ summary="DogOps offline simulation running",
+ )
+ package_statuses = {
+ item.package_id: PackageStatus(
+ package_id=item.package_id,
+ expected_zone_id=item.expected_zone_id,
+ )
+ for item in self.manifest.items
+ }
+ self.state = DogOpsState(
+ run=run,
+ site=self.site,
+ manifest=self.manifest,
+ policy=self.policy,
+ mission=self.mission,
+ package_statuses=package_statuses,
+ )
+ self._write_json(self.root / "run.json", run.model_dump(mode="json"))
+ self.write_state(run.id)
+ return run
+
+ def finish_run(self, run_id: str, state: MissionState, summary: str, ended_at: float) -> MissionRun:
+ dogops_state = self._require_state(run_id)
+ dogops_state.run.state = state
+ dogops_state.run.summary = summary
+ dogops_state.run.ended_at = ended_at
+ dogops_state.run.current_step_id = None
+ self._write_json(self.root / "run.json", dogops_state.run.model_dump(mode="json"))
+ self.write_state(run_id)
+ return dogops_state.run
+
+ def append_observation(self, obs: Observation) -> None:
+ state = self._require_state(obs.run_id)
+ state.observations.append(obs)
+ self._append_jsonl("observations.jsonl", obs.model_dump(mode="json"))
+
+ def append_incident(self, incident: Incident) -> None:
+ state = self._require_state(incident.run_id)
+ state.incidents.append(incident)
+ self._append_jsonl("incidents.jsonl", incident.model_dump(mode="json"))
+
+ def update_incident(self, incident: Incident) -> None:
+ state = self._require_state(incident.run_id)
+ state.incidents = [incident if item.id == incident.id else item for item in state.incidents]
+ self._rewrite_jsonl("incidents.jsonl", [item.model_dump(mode="json") for item in state.incidents])
+
+ def append_work_order(self, work_order: WorkOrder) -> None:
+ state = self._require_state_by_any_run()
+ state.work_orders.append(work_order)
+ self._append_jsonl("work_orders.jsonl", work_order.model_dump(mode="json"))
+
+ def update_work_order(self, work_order: WorkOrder) -> None:
+ state = self._require_state_by_any_run()
+ state.work_orders = [
+ work_order if item.id == work_order.id else item for item in state.work_orders
+ ]
+ self._rewrite_jsonl(
+ "work_orders.jsonl", [item.model_dump(mode="json") for item in state.work_orders]
+ )
+
+ def append_nav_event(self, nav_event: NavEvent) -> None:
+ state = self._require_state(nav_event.run_id)
+ state.nav_events.append(nav_event)
+ self._append_jsonl("nav_events.jsonl", nav_event.model_dump(mode="json"))
+
+ def write_state(self, run_id: str) -> Path:
+ state = self._require_state(run_id)
+ path = self.root / "state.json"
+ self._write_json(path, state.model_dump(mode="json"))
+ return path
+
+ def write_report(self, run_id: str) -> tuple[Path, Path]:
+ state = self._require_state(run_id)
+ report_json = self.root / "report.json"
+ report_md = self.root / "report.md"
+ self._write_json(report_json, build_report_data(state))
+ report_md.write_text(render_report_markdown(state), encoding="utf-8")
+ return report_json, report_md
+
+ def load_state(self, run_id: str | None = None) -> DogOpsState:
+ path = self.root / "state.json"
+ state = DogOpsState.model_validate_json(path.read_text(encoding="utf-8"))
+ if run_id is not None and state.run.id != run_id:
+ raise ValueError(f"loaded run {state.run.id!r}, expected {run_id!r}")
+ self.state = state
+ return state
+
+ @classmethod
+ def load_existing(cls, root: str | Path) -> DogOpsStore:
+ path = Path(root)
+ state = DogOpsState.model_validate_json((path / "state.json").read_text(encoding="utf-8"))
+ store = cls(
+ path,
+ site=state.site,
+ manifest=state.manifest,
+ policy=state.policy,
+ mission=state.mission,
+ )
+ store.state = state
+ return store
+
+ def _require_state(self, run_id: str) -> DogOpsState:
+ if self.state is None:
+ raise RuntimeError("DogOps run has not been created")
+ if self.state.run.id != run_id:
+ raise ValueError(f"active run {self.state.run.id!r} does not match {run_id!r}")
+ return self.state
+
+ def _require_state_by_any_run(self) -> DogOpsState:
+ if self.state is None:
+ raise RuntimeError("DogOps run has not been created")
+ return self.state
+
+ def _append_jsonl(self, filename: str, payload: dict[str, object]) -> None:
+ with (self.root / filename).open("a", encoding="utf-8") as handle:
+ handle.write(json.dumps(payload, sort_keys=True) + "\n")
+
+ def _rewrite_jsonl(self, filename: str, rows: list[dict[str, object]]) -> None:
+ with (self.root / filename).open("w", encoding="utf-8") as handle:
+ for row in rows:
+ handle.write(json.dumps(row, sort_keys=True) + "\n")
+
+ @staticmethod
+ def _write_json(path: Path, payload: dict[str, object]) -> None:
+ path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
diff --git a/dimos/experimental/dogops/tag_registry.py b/dimos/experimental/dogops/tag_registry.py
new file mode 100644
index 0000000000..cbd208408d
--- /dev/null
+++ b/dimos/experimental/dogops/tag_registry.py
@@ -0,0 +1,45 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from dimos.experimental.dogops.models import SiteConfig, SiteEntity
+
+
+@dataclass(frozen=True)
+class TagRegistration:
+ tag_id: int
+ entity_id: str
+ entity_kind: str
+ display_name: str
+ zone_id: str | None
+
+
+class DogOpsTagRegistry:
+ def __init__(self, site: SiteConfig) -> None:
+ self.site = site
+ self._by_tag_id = {
+ tag_id: _registration_for_entity(tag_id, entity)
+ for tag_id, entity in site.entity_for_tag().items()
+ }
+
+ def get(self, tag_id: int) -> TagRegistration | None:
+ return self._by_tag_id.get(tag_id)
+
+ def require(self, tag_id: int) -> TagRegistration:
+ registration = self.get(tag_id)
+ if registration is None:
+ raise KeyError(f"unknown DogOps tag id: {tag_id}")
+ return registration
+
+ def all(self) -> list[TagRegistration]:
+ return [self._by_tag_id[tag_id] for tag_id in sorted(self._by_tag_id)]
+
+
+def _registration_for_entity(tag_id: int, entity: SiteEntity) -> TagRegistration:
+ return TagRegistration(
+ tag_id=tag_id,
+ entity_id=entity.id,
+ entity_kind=str(entity.kind),
+ display_name=entity.display_name,
+ zone_id=entity.zone_id,
+ )
diff --git a/dimos/experimental/dogops/test_blueprints.py b/dimos/experimental/dogops/test_blueprints.py
new file mode 100644
index 0000000000..1d20bbae13
--- /dev/null
+++ b/dimos/experimental/dogops/test_blueprints.py
@@ -0,0 +1,21 @@
+from dimos.experimental.dogops.blueprints import (
+ DogOpsBlueprintMetadata,
+ unitree_go2_dogops,
+)
+from dimos.robot.unitree.go2.blueprints.agentic.unitree_go2_dogops import (
+ unitree_go2_dogops as exported_unitree_go2_dogops,
+)
+
+
+def test_unitree_go2_dogops_blueprint_fallback_is_no_key() -> None:
+ blueprint_text = repr(exported_unitree_go2_dogops)
+ assert "DogOpsSkillContainer" in blueprint_text
+ assert "DogOpsObservationModule" in blueprint_text
+ assert "DogOpsDashboardModule" in blueprint_text
+ assert "DogOpsNavEvalModule" in blueprint_text
+ assert "McpServer" in blueprint_text
+ assert "McpClient" not in blueprint_text
+
+ if isinstance(unitree_go2_dogops, DogOpsBlueprintMetadata):
+ assert unitree_go2_dogops.name == "unitree-go2-dogops"
+ assert unitree_go2_dogops.requires_mcp_client is False
diff --git a/dimos/experimental/dogops/test_cli_smoke.py b/dimos/experimental/dogops/test_cli_smoke.py
new file mode 100644
index 0000000000..0cfb084aa0
--- /dev/null
+++ b/dimos/experimental/dogops/test_cli_smoke.py
@@ -0,0 +1,34 @@
+from __future__ import annotations
+
+import subprocess
+import sys
+
+
+def test_cli_validate_and_simulate(tmp_path) -> None:
+ validate = subprocess.run(
+ [sys.executable, "-m", "dimos.experimental.dogops.cli", "validate"],
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+ assert "validated site=dogops_demo_site" in validate.stdout
+
+ run_dir = tmp_path / "latest"
+ simulate = subprocess.run(
+ [
+ sys.executable,
+ "-m",
+ "dimos.experimental.dogops.cli",
+ "simulate",
+ "--out",
+ str(run_dir),
+ ],
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+ assert "state=done" in simulate.stdout
+ assert (run_dir / "report.md").is_file()
+ assert "PKG-104 wrong zone and blocking COOLING_1" in (run_dir / "report.md").read_text(
+ encoding="utf-8"
+ )
diff --git a/dimos/experimental/dogops/test_config_loader.py b/dimos/experimental/dogops/test_config_loader.py
new file mode 100644
index 0000000000..7e8b12d925
--- /dev/null
+++ b/dimos/experimental/dogops/test_config_loader.py
@@ -0,0 +1,15 @@
+from dimos.experimental.dogops.config_loader import load_dogops_config
+
+
+def test_load_dogops_demo_config() -> None:
+ config = load_dogops_config()
+
+ assert config.site.site_id == "dogops_demo_site"
+ assert config.site.tag_family == "tag36h11"
+ assert config.site.marker_length_m == 0.14
+ assert config.site.package_by_id()["PKG-104"].expected_zone_id == "QA_HOLD"
+ assert config.manifest.manifest_id == "inbound_manifest_demo"
+ assert len(config.manifest.items) == 4
+ assert config.policy.rule_for_type("blocked_cooling") is not None
+ assert config.mission.mission_id == "receiving_sre_demo"
+ assert "inspect_cooling" in config.mission.simulation_observations
diff --git a/dimos/experimental/dogops/test_dashboard.py b/dimos/experimental/dogops/test_dashboard.py
new file mode 100644
index 0000000000..39242a65a6
--- /dev/null
+++ b/dimos/experimental/dogops/test_dashboard.py
@@ -0,0 +1,2686 @@
+from __future__ import annotations
+
+from html import unescape
+import json
+import math
+from pathlib import Path
+import re
+import sys
+import threading
+import time
+from typing import Any
+import urllib.request
+
+import pytest
+
+from dimos.experimental.dogops import dashboard, dashboard_static, live_camera
+from dimos.experimental.dogops.dashboard import DogOpsDashboardModule, make_dashboard_server
+from dimos.experimental.dogops.dashboard_static import (
+ build_map_data,
+ build_poi_data,
+ build_route_data,
+ write_dashboard_html,
+)
+from dimos.experimental.dogops.live_map import (
+ DogOpsLiveMapAdapter,
+ LIVE_TOPIC_MAX_AGE_S,
+ _extend_dimos_package_path,
+ _grid_to_costmap,
+)
+from dimos.experimental.dogops.map_authoring import (
+ EditableMapEntity,
+ EditableMapPoint,
+ EditableRoute,
+ EditableRouteWaypoint,
+ MapAuthoringState,
+ save_map_authoring,
+)
+from dimos.experimental.dogops.mission_engine import run_offline_simulation
+from dimos.experimental.dogops.route_executor import DogOpsRouteExecutor, save_route_execution
+from dimos.experimental.dogops.route_actions import EditableRouteAction
+from dimos.experimental.dogops.route_run_store import RouteRunStore
+
+
+def _get_json(url: str) -> dict[str, object]:
+ with urllib.request.urlopen(url, timeout=5) as response:
+ return json.loads(response.read().decode("utf-8"))
+
+
+def _get_json_with_status(
+ url: str,
+ *,
+ headers: dict[str, str] | None = None,
+) -> tuple[int, dict[str, object]]:
+ request = urllib.request.Request(url, headers=headers or {}, method="GET")
+ try:
+ with urllib.request.urlopen(request, timeout=5) as response:
+ return response.status, json.loads(response.read().decode("utf-8"))
+ except urllib.error.HTTPError as exc:
+ return exc.status, json.loads(exc.read().decode("utf-8"))
+
+
+def _post_json(
+ url: str,
+ payload: dict[str, Any],
+ *,
+ headers: dict[str, str] | None = None,
+) -> tuple[int, dict[str, object]]:
+ request_headers = {"Content-Type": "application/json", **(headers or {})}
+ request = urllib.request.Request(
+ url,
+ data=json.dumps(payload).encode("utf-8"),
+ headers=request_headers,
+ method="POST",
+ )
+ try:
+ with urllib.request.urlopen(request, timeout=5) as response:
+ return response.status, json.loads(response.read().decode("utf-8"))
+ except urllib.error.HTTPError as exc:
+ return exc.status, json.loads(exc.read().decode("utf-8"))
+
+
+def _put_json(
+ url: str,
+ payload: dict[str, Any],
+ *,
+ headers: dict[str, str] | None = None,
+) -> tuple[int, dict[str, object]]:
+ request_headers = {"Content-Type": "application/json", **(headers or {})}
+ request = urllib.request.Request(
+ url,
+ data=json.dumps(payload).encode("utf-8"),
+ headers=request_headers,
+ method="PUT",
+ )
+ try:
+ with urllib.request.urlopen(request, timeout=5) as response:
+ return response.status, json.loads(response.read().decode("utf-8"))
+ except urllib.error.HTTPError as exc:
+ return exc.status, json.loads(exc.read().decode("utf-8"))
+
+
+def _delete_json(
+ url: str,
+ *,
+ headers: dict[str, str] | None = None,
+) -> tuple[int, dict[str, object]]:
+ request = urllib.request.Request(url, headers=headers or {}, method="DELETE")
+ try:
+ with urllib.request.urlopen(request, timeout=5) as response:
+ return response.status, json.loads(response.read().decode("utf-8"))
+ except urllib.error.HTTPError as exc:
+ return exc.status, json.loads(exc.read().decode("utf-8"))
+
+
+def _robot_headers(server) -> dict[str, str]:
+ return {
+ dashboard.ROBOT_CONTROL_TOKEN_HEADER: server.RequestHandlerClass.robot_control_token,
+ }
+
+
+def test_dashboard_static_html_contains_closed_loop_result(tmp_path) -> None:
+ run_dir = tmp_path / "latest"
+ run_offline_simulation(out=run_dir)
+
+ html_path = write_dashboard_html(run_dir)
+ content = html_path.read_text(encoding="utf-8")
+
+ assert "DogOps SiteOps Agent" in content
+ assert "Mission Map" in content
+ assert "DimOS Camera" in content
+ assert "Waiting for /color_image" in content
+ assert 'data-camera-frame' in content
+ assert "/api/camera/frame.jpg" in content
+ assert '
' in content
+ assert content.index('
') < content.index('
')
+ assert content.index('
Mission Map
') < content.index('
DimOS Camera
')
+ assert content.index('
DimOS Camera
') < content.index('
Run Summary
')
+ assert 'data-map-surface' in content
+ assert "map-route" in content
+ assert "map-free-cell" in content
+ assert "map-live-cost-cell" in content
+ assert "map-dimos-path" in content
+ assert 'data-map-layer="heatmap"' in content
+ assert 'data-live-heatmap' in content
+ assert 'data-live-path' in content
+ assert 'data-live-target' in content
+ assert "refreshDimOSMap" in content
+ assert 'data-map-action="gather_heatmap"' in content
+ assert 'data-map-action="scan_zone"' in content
+ assert "/api/map/heatmap/gather" in content
+ assert "/api/robot/scan_zone" in content
+ assert 'data-map-edit-label-row' in content
+ assert 'data-map-edit-route-row' in content
+ assert 'data-map-edit-action="dry_run_route"' in content
+ assert 'data-map-edit-action="run_route"' in content
+ assert 'data-map-edit-action="stop_route"' in content
+ assert 'data-map-edit-action="heatmap_run"' in content
+ assert 'data-route-action-row' in content
+ assert 'data-route-action-kind="capture_image"' in content
+ assert 'data-route-action-kind="gemini_inspect_image"' in content
+ assert 'data-saved-images' in content
+ assert 'data-route-action-kind="scan_qr"' in content
+ assert 'data-route-action-kind="scan_tags"' in content
+ assert 'data-route-action-kind="wait"' in content
+ assert 'data-route-action-kind="inspect_asset"' in content
+ assert 'data-route-action-kind="verify_work_order"' in content
+ assert 'data-route-action-kind="operator_prompt"' in content
+ assert "Saved Routes" in content
+ assert 'data-route-table' in content
+ assert 'data-route-table-action="select"' in content
+ assert 'data-route-table-action="rename"' in content
+ assert 'data-route-table-action="duplicate"' in content
+ assert 'data-route-table-action="delete"' in content
+ assert "route-actions-subrow" in content
+ assert "routeActionRows(route)" in content
+ assert "handleRouteTableAction" in content
+ assert 'class="map-route-stop-marker"' in content
+ assert 'circle.setAttribute("r", "18")' in content
+ assert 'circle.setAttribute("r", "9")' in content
+ assert 'run.route_id === "GATHER_HEATMAP"' in content
+ assert "Costmap snapshot" in content
+ assert "new Set(Object.keys(routeActionLabels))" in content
+ assert "routeActionArgs(kind, waypoint)" in content
+ assert 'data-route-execution-status' in content
+ assert "/api/map/routes/follow" in content
+ assert "/api/map/routes/stop" in content
+ assert "/api/map/routes/status" in content
+ assert "runSelectedRoute(true)" in content
+ assert "runSelectedRoute(false)" in content
+ assert "addActionToSelectedRouteWaypoint" in content
+ assert "Dry run" in content
+ assert "Live" in content
+ assert "map-point" in content
+ assert "map-robot-core" in content
+ assert "free grid" in content
+ assert "tag return" in content
+ assert "no-go cost" in content
+ assert 'data-rerun-surface' in content
+ assert 'data-rerun-connect' in content
+ assert 'data-rerun-frame' in content
+ assert 'data-rerun-url=' in content
+ assert 'data-rerun-web-link' in content
+ assert "Rerun Web Visualization" in content
+ assert "connectRerunSurface" in content
+ assert 'data-map-command-status' in content
+ assert 'data-map-action="arm_go_to"' in content
+ assert 'data-map-edit-mode="home"' in content
+ assert 'data-map-edit-mode="no_go"' in content
+ assert 'data-map-edit-action="use_observation"' in content
+ assert 'data-map-edit-action="delete_selected"' in content
+ assert 'data-map-edit-action="route_select"' in content
+ assert 'data-map-edit-action="route_up"' in content
+ assert 'data-map-edit-action="route_down"' in content
+ assert 'data-map-edit-action="publish_no_go"' in content
+ assert 'data-map-edit-action="export"' in content
+ assert 'data-map-edit-label-row' in content
+ assert 'data-map-edit-route-row' in content
+ assert 'data-map-route-summary' in content
+ assert "Selected route: none. Next: Route1" in content
+ assert "AUTHORED_ROUTE" not in content
+ assert 'data-map-authoring-status' in content
+ assert "/api/map/authoring" in content
+ assert "/api/map/entities" in content
+ assert "/api/map/no_go_shapes" in content
+ assert "/api/map/no_go_shapes/publish" in content
+ assert "/api/map/tag_bindings" in content
+ assert "/from_observation" in content
+ assert "if (!current.selected_route_id) return null" in content
+ assert "selected_route_id: route.id" in content
+ assert 'data-go-to-marker' in content
+ assert "/api/robot/go_to" in content
+ assert "worldFromSvgEvent" in content
+ assert "map-zone no-go" not in content
+ assert "OBS-003" in content
+ assert "PKG-104" in content
+ assert "INC-001" in content
+ assert "Navigation Eval" in content
+ assert "Route / POI Evidence" in content
+ assert "Route Stops" in content
+ assert "POI Evidence" in content
+ assert "Robot Control" in content
+ assert "Checkpoint Sign-In" not in content
+ assert "Mission Timeline" not in content
+ assert "What Changed" not in content
+ assert "Current Run Timeline" in content
+ assert "Current Timeline" not in content
+ assert 'class="scan-strip"' not in content
+ assert "Tag Sign-In" in content
+ assert "OBS-005" in content
+ assert 'data-command="forward"' in content
+ assert 'data-command="hard_stop"' in content
+ assert 'data-command="yaw_left" data-key-hint="Q"' in content
+ assert 'data-command="yaw_right" data-key-hint="E"' in content
+ assert 'data-key-hint="W / Up"' in content
+ assert 'data-key-hint="Space / Esc"' in content
+ assert 'data-keyboard-map' in content
+ assert '["KeyW", "forward"]' in content
+ assert '["KeyQ", "yaw_left"]' in content
+ assert '["KeyE", "yaw_right"]' in content
+ assert '["ArrowDown", "backward"]' in content
+ assert '["Space", "hard_stop"]' in content
+ assert '["Escape", "hard_stop"]' in content
+ assert "shouldIgnoreKeyboardEvent" in content
+ assert 'data-posture="wake"' in content
+ assert 'data-posture="sleep"' in content
+ assert 'data-motion="nudge"' in content
+ assert 'data-motion="step"' in content
+ assert 'data-motion="walk"' in content
+ assert "X-DogOps-Control-Token" in content
+
+
+def test_dashboard_static_embeds_full_authoring_state(tmp_path) -> None:
+ run_dir = tmp_path / "latest"
+ run_offline_simulation(out=run_dir)
+ save_map_authoring(
+ run_dir,
+ MapAuthoringState(
+ site_id="dogops_demo_site",
+ entities=[
+ EditableMapEntity(
+ id="CHECKPOINT_X",
+ kind="checkpoint",
+ label="Checkpoint X",
+ pose=EditableMapPoint(x=1.0, y=2.0),
+ )
+ ],
+ routes=[
+ EditableRoute(
+ id="ROUTE_X",
+ label="Route X",
+ waypoints=[
+ EditableRouteWaypoint(
+ id="WP_X",
+ label="Waypoint X",
+ pose=EditableMapPoint(x=3.0, y=4.0),
+ )
+ ],
+ )
+ ],
+ ),
+ )
+
+ content = write_dashboard_html(run_dir).read_text(encoding="utf-8")
+ match = re.search(r'data-map-authoring="([^"]+)"', content)
+ assert match is not None
+ authoring = json.loads(unescape(match.group(1)))
+
+ assert authoring["entities"][0]["id"] == "CHECKPOINT_X"
+ assert authoring["routes"][0]["waypoints"][0]["id"] == "WP_X"
+ assert not isinstance(authoring["entities"], int)
+ assert not isinstance(authoring["routes"], int)
+
+
+def test_dashboard_map_layer_controls_match_svg_layers(tmp_path) -> None:
+ run_dir = tmp_path / "latest"
+ run_offline_simulation(out=run_dir)
+
+ html_path = write_dashboard_html(run_dir)
+ content = html_path.read_text(encoding="utf-8")
+
+ controls = set(re.findall(r'data-map-layer="([^"]+)"', content))
+ layers = set(re.findall(r'data-layer="([^"]+)"', content))
+ assert controls == {"semantic", "heatmap", "path", "robot", "qr"}
+ assert controls <= layers
+ assert 'querySelectorAll(`[data-layer="${layer}"]`)' in content
+ assert 'item.toggleAttribute("hidden", !pressed)' in content
+ assert "let dimosRobotPoseActive = false" in content
+ assert "if (dimosRobotPoseActive) return" in content
+ assert "let liveOverlayBounds = null" in content
+ assert "if (data.bounds) liveOverlayBounds = data.bounds" in content
+ assert "const projectWorldPoint = (x, y) => projectLivePose({x, y})" in content
+ assert "const projectLiveOverlayPoint = (x, y) => projectLiveOverlayPose({x, y})" in content
+
+
+def test_dashboard_map_controls_are_grouped_near_legend(tmp_path) -> None:
+ run_dir = tmp_path / "latest"
+ run_offline_simulation(out=run_dir)
+
+ html_path = write_dashboard_html(run_dir)
+ content = html_path.read_text(encoding="utf-8")
+
+ label_row = re.search(r'
(.*?)
', content)
+ route_row = re.search(r'
(.*?)
', content)
+ assert label_row is not None
+ assert route_row is not None
+ assert 'data-map-edit-mode="zone"' in label_row.group(1)
+ assert 'data-map-edit-mode="asset"' in label_row.group(1)
+ assert 'data-map-edit-mode="no_go"' in label_row.group(1)
+ assert 'data-map-edit-mode="route"' not in label_row.group(1)
+ assert 'data-map-edit-mode="route"' in route_row.group(1)
+ assert 'data-map-edit-action="route_select"' in route_row.group(1)
+ assert 'data-map-edit-action="run_route"' in route_row.group(1)
+ assert 'data-map-edit-action="heatmap_run"' in route_row.group(1)
+ assert 'data-map-edit-action="route_add_action"' not in route_row.group(1)
+ assert 'data-map-edit-action="route_down"' in route_row.group(1)
+ assert 'data-map-route-summary' in route_row.group(1)
+ assert '