diff --git a/README.md b/README.md index 8377096..28415c3 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,8 @@ | `detection` | On | Managed OpenCV detection streams | | `tracking` | On | Deterministic color tracking and target hints | | `imu` | On | `IMU`, live ROS 2 orientation, and `IMUViewer` | -| `slam`, `localization` | Off | Optional capability contracts | +| `slam` | Off | Persistent mapping sessions, SLAM Toolbox integration, and map artifacts | +| `localization` | Off | Optional localization capability contracts | ## Main nodes @@ -35,6 +36,7 @@ does not use Warp. - `DepthCamera` and `DepthObstacleWarning` expose capability and safety state after depth processing. - `CameraViewer`, `DepthViewer`, and `IMUViewer` present processed sensor contracts with controls specific to that sensor. - `LiDAR` exposes normalized scans; the optional CUDA `LiDARViewer` is the explicit Warp visualization path. +- `MapEnvironment` attaches to or session-scopes SLAM Toolbox and persists an occupancy map plus pose graph. - `TrackingObject` serves annotated MJPEG, masks, snapshots, and latest detections. - `VLM`, `ReasoningStream`, and `ReasoningDashboard` support OpenAI-compatible, NVIDIA NIM, Anthropic, and local Ollama endpoints. @@ -49,6 +51,10 @@ Add a `Camera` node with `selection: 0` for the first local camera. Sensor templ Managed camera, tracker, reasoning, IMU, and spatial viewers start or update one background service. New frames do not require graph recooks. Worker health and source freshness are reported separately, and stale data is never presented as live. +The `ROSOrin Persist Environment Map` workflow stores a live SLAM Toolbox map +under `/home/ubuntu/Blacknode/maps`. It consumes the existing robot graph and +does not replace ROSOrin's vendor startup configuration. + ## Development ```powershell diff --git a/blacknode-package.toml b/blacknode-package.toml index 7192351..3545145 100644 --- a/blacknode-package.toml +++ b/blacknode-package.toml @@ -1,6 +1,6 @@ [package] name = "blacknode-perception" -version = "0.5.4" +version = "0.5.5" description = "Camera, tracking, VLM, and spatial-perception capabilities organized as selectable components." requires-blacknode = ">=0.3.0" layer = "perception" @@ -134,10 +134,23 @@ imports = ["cv2", "numpy"] requires = [{ package = "blacknode-perception", component = "camera", version = ">=0.2.0,<1.0.0" }] [components.slam] -description = "Mapping and SLAM lifecycle contracts." +description = "Persistent mapping sessions and provider-neutral map artifacts." default = false capabilities = ["perception.slam"] +[components.slam.adapters.ros2] +description = "SLAM Toolbox mapping over an existing or Blacknode-owned ROS 2 session." +default = false +capabilities = ["adapter.perception.slam.ros2"] +nodes = ["components/slam/adapters/ros2/nodes"] +templates = ["components/slam/adapters/ros2/templates"] +node-types = ["MapEnvironment"] + +[components.slam.adapters.ros2.dependencies] +requires = [ + { package = "blacknode-ros2", component = "core", version = ">=0.6.2,<1.0.0" } +] + [components.localization] description = "Localization state, confidence, and recovery contracts." default = false diff --git a/components/slam/README.md b/components/slam/README.md index 4dc25f2..53ed36f 100644 --- a/components/slam/README.md +++ b/components/slam/README.md @@ -1,16 +1,18 @@ -# Slam +# SLAM -Component of `blacknode-perception`. +The optional SLAM component owns persistent mapping sessions and normalized +`blacknode.map-artifact` records. Its ROS 2 adapter provides `MapEnvironment` +for SLAM Toolbox. -Node sources for this component belong in this folder. Until they move here, -nodes claim the component inline: +`MapEnvironment` supports four useful actions: - @node(name="MyNode", component="slam", ...) +- `start` attaches to an existing `/map` publisher or launches only a + Blacknode-owned SLAM Toolbox process when `lifecycle=managed`. +- `save` calls the configured SLAM Toolbox map and pose-graph services and + returns the persisted artifact paths. +- `status` reports the normalized provider state. +- `stop` terminates only the process handle Blacknode started. -Once sources live here, declare the folder in `blacknode-package.toml`: - - [components.slam] - nodes = ["components/slam/nodes"] - -and the inline `component=` argument can be dropped — the loader infers it -from the directory. +For ROSOrin, use `/home/ubuntu/ros2_ws/src/slam/config/slam.yaml` as the managed +SLAM parameters file. The provider does not launch the vendor robot bringup, +edit that workspace, or change boot services. diff --git a/components/slam/adapters/ros2/nodes/__init__.py b/components/slam/adapters/ros2/nodes/__init__.py new file mode 100644 index 0000000..28aad73 --- /dev/null +++ b/components/slam/adapters/ros2/nodes/__init__.py @@ -0,0 +1 @@ +from . import mapping # noqa: F401 diff --git a/components/slam/adapters/ros2/nodes/mapping.py b/components/slam/adapters/ros2/nodes/mapping.py new file mode 100644 index 0000000..3d0fc5f --- /dev/null +++ b/components/slam/adapters/ros2/nodes/mapping.py @@ -0,0 +1,350 @@ +"""Persistent ROS 2 mapping through a session-scoped SLAM Toolbox provider.""" +from __future__ import annotations + +import json +import re +import shlex +import time +from pathlib import Path +from typing import Any + +from blacknode.node import Any as AnyPort +from blacknode.node import Bool, Dict, Enum, Float, Text, node + + +class _LazyROS2Runtime: + """Keep package discovery functional when ROS 2 is not installed.""" + + def __getattr__(self, name: str): + from blacknode.pkg.blacknode_ros2 import ros2_runtime + + return getattr(ros2_runtime, name) + + +rt = _LazyROS2Runtime() +_CATEGORY = "Perception" +_MAP_TYPE = "nav_msgs/msg/OccupancyGrid" +_SAVE_MAP_TYPE = "slam_toolbox/srv/SaveMap" +_SERIALIZE_TYPE = "slam_toolbox/srv/SerializePoseGraph" + + +def _managed_id(value: Any) -> str: + text = re.sub(r"[^A-Za-z0-9_.:-]+", "_", str(value or "rosorin-map").strip()) + return f"blacknode-mapping:{text.strip('_') or 'rosorin-map'}" + + +def _float(ctx: dict, name: str, default: float) -> float: + try: + return float(ctx.get(name) if ctx.get(name) not in (None, "") else default) + except (TypeError, ValueError): + return float(default) + + +def _map_status(map_topic: str) -> dict[str, Any]: + return rt.inspect_topic_interfaces([ + { + "name": "occupancy_map", + "topic": map_topic, + "message_type": _MAP_TYPE, + "required": True, + } + ]) + + +def _owned_status(run_id: str) -> dict[str, Any]: + try: + return rt.ros2_managed_status(run_id) + except Exception as exc: + return {"ok": False, "running": False, "backend": "none", "error": str(exc)} + + +def _provider_state( + *, + map_topic: str, + lifecycle: str, + run_id: str, + status: dict[str, Any], + owned: dict[str, Any], +) -> dict[str, Any]: + return { + "kind": "blacknode.mapping-provider-state", + "schema_version": 1, + "provider": "slam_toolbox", + "transport": "ros2", + "lifecycle": lifecycle, + "run_id": run_id, + "owned_by_blacknode": bool(owned.get("running")), + "ready": bool(status.get("ready")), + "running": bool(status.get("ready") or owned.get("running")), + "backend": str(status.get("backend") or owned.get("backend") or "none"), + "map_topic": map_topic, + "interfaces": list(status.get("interfaces") or []), + "error": str(status.get("error") or owned.get("error") or ""), + "observed_at": time.time(), + } + + +def _status_result(map_topic: str, lifecycle: str, run_id: str) -> tuple[dict[str, Any], dict[str, Any]]: + status = _map_status(map_topic) + owned = _owned_status(run_id) + return _provider_state( + map_topic=map_topic, + lifecycle=lifecycle, + run_id=run_id, + status=status, + owned=owned, + ), status + + +def _service_call(service: str, service_type: str, request: dict[str, Any], timeout: float) -> dict[str, Any]: + return rt.run_ros2( + ["service", "call", service, service_type, json.dumps(request, separators=(",", ":"))], + timeout=timeout, + ) + + +def _safe_map_name(value: Any) -> str: + name = re.sub(r"[^A-Za-z0-9_.-]+", "-", str(value or "map_01").strip()).strip(".-") + return name or "map_01" + + +@node( + name="MapEnvironment", + category=_CATEGORY, + description=( + "Attach to an existing SLAM Toolbox map, start an optional Blacknode-owned mapping " + "session, persist the occupancy map and pose graph, or stop only that owned session." + ), + inputs={ + "trigger": AnyPort, + "action": Enum(["status", "start", "save", "stop"], default="status"), + "lifecycle": Enum(["existing", "managed"], default="existing"), + "run_id": Text(default="rosorin-map"), + "map_topic": Text(default="/map"), + "params_file": Text(default=""), + "launch_package": Text(default="slam_toolbox"), + "launch_file": Text(default="online_sync_launch.py"), + "launch_arguments": Text(default="use_sim_time:=false"), + "wait_seconds": Float(default=10.0), + "save_directory": Text(default="~/Blacknode/maps"), + "map_name": Text(default="map_01"), + "save_map_service": Text(default="/slam_toolbox/save_map"), + "serialize_service": Text(default="/slam_toolbox/serialize_map"), + "serialize_pose_graph": Bool(default=True), + "service_timeout": Float(default=30.0), + }, + outputs={ + "ready": Bool, + "running": Bool, + "owned": Bool, + "provider": Dict, + "map_artifact": Dict, + "report": Text, + }, + primary_inputs=["trigger", "action", "lifecycle", "map_name"], + primary_outputs=["provider", "map_artifact", "report"], +) +def map_environment(ctx: dict) -> dict: + action = str(ctx.get("action") or "status").strip().lower() + lifecycle = str(ctx.get("lifecycle") or "existing").strip().lower() + map_topic = str(ctx.get("map_topic") or "/map").strip() + run_id = _managed_id(ctx.get("run_id")) + empty_artifact: dict[str, Any] = {} + + if action == "start" and lifecycle == "managed": + current, _ = _status_result(map_topic, lifecycle, run_id) + if current["ready"]: + return { + "ready": True, + "running": True, + "owned": bool(current["owned_by_blacknode"]), + "provider": current, + "map_artifact": empty_artifact, + "report": f"mapping attached: {map_topic} is already publishing; existing ROS bringup was left unchanged", + } + params_file = str(ctx.get("params_file") or "").strip() + if not params_file: + return { + "ready": False, + "running": False, + "owned": False, + "provider": current, + "map_artifact": empty_artifact, + "report": "mapping start FAILED: managed lifecycle requires params_file", + } + package = str(ctx.get("launch_package") or "slam_toolbox").strip() + launch_file = str(ctx.get("launch_file") or "online_sync_launch.py").strip() + try: + extra = shlex.split(str(ctx.get("launch_arguments") or "")) + except ValueError as exc: + return { + "ready": False, + "running": False, + "owned": False, + "provider": current, + "map_artifact": empty_artifact, + "report": f"mapping start FAILED: invalid launch_arguments: {exc}", + } + launch = rt.run_ros2_managed( + run_id, + ["launch", package, launch_file, f"slam_params_file:={params_file}", *extra], + ) + if not launch.get("ok"): + current, _ = _status_result(map_topic, lifecycle, run_id) + return { + "ready": False, + "running": False, + "owned": False, + "provider": current, + "map_artifact": empty_artifact, + "report": f"mapping start FAILED: {launch.get('error') or 'could not launch SLAM Toolbox'}", + } + wait_seconds = max(0.0, min(60.0, _float(ctx, "wait_seconds", 10.0))) + waited = rt.wait_for_topic_interfaces( + [{"topic": map_topic, "message_type": _MAP_TYPE, "required": True}], + timeout=wait_seconds, + ) + current = _provider_state( + map_topic=map_topic, + lifecycle=lifecycle, + run_id=run_id, + status=waited, + owned=_owned_status(run_id), + ) + return { + "ready": bool(current["ready"]), + "running": bool(current["running"]), + "owned": bool(current["owned_by_blacknode"]), + "provider": current, + "map_artifact": empty_artifact, + "report": ( + f"mapping running: Blacknode owns {run_id} and {map_topic} is publishing" + if current["ready"] + else f"mapping launched as {run_id}, but {map_topic} was not ready within {wait_seconds:g}s" + ), + } + + if action == "save": + current, status = _status_result(map_topic, lifecycle, run_id) + if not status.get("ready"): + return { + "ready": False, + "running": bool(current["running"]), + "owned": bool(current["owned_by_blacknode"]), + "provider": current, + "map_artifact": empty_artifact, + "report": f"map save FAILED: no live {_MAP_TYPE} publisher on {map_topic}", + } + directory = Path(str(ctx.get("save_directory") or "~/Blacknode/maps")).expanduser().resolve() + directory.mkdir(parents=True, exist_ok=True) + name = _safe_map_name(ctx.get("map_name")) + stem = directory / name + timeout = max(5.0, min(120.0, _float(ctx, "service_timeout", 30.0))) + save = _service_call( + str(ctx.get("save_map_service") or "/slam_toolbox/save_map").strip(), + _SAVE_MAP_TYPE, + {"name": {"data": str(stem)}}, + timeout, + ) + if not save.get("ok"): + return { + "ready": True, + "running": True, + "owned": bool(current["owned_by_blacknode"]), + "provider": current, + "map_artifact": empty_artifact, + "report": f"map save FAILED: {save.get('error') or save.get('stderr') or 'save_map service failed'}", + } + serialized = False + serialize_error = "" + if bool(ctx.get("serialize_pose_graph", True)): + graph = _service_call( + str(ctx.get("serialize_service") or "/slam_toolbox/serialize_map").strip(), + _SERIALIZE_TYPE, + {"filename": str(stem)}, + timeout, + ) + serialized = bool(graph.get("ok")) + serialize_error = str(graph.get("error") or graph.get("stderr") or "") if not serialized else "" + artifact = { + "kind": "blacknode.map-artifact", + "schema_version": 1, + "provider": "slam_toolbox", + "map_name": name, + "directory": str(directory), + "map_yaml": str(stem.with_suffix(".yaml")), + "map_image": str(stem.with_suffix(".pgm")), + "pose_graph": str(stem), + "pose_graph_serialized": serialized, + "frame_id": "map", + "map_topic": map_topic, + "created_at": time.time(), + } + suffix = f"; pose graph not serialized: {serialize_error}" if serialize_error else "" + return { + "ready": True, + "running": True, + "owned": bool(current["owned_by_blacknode"]), + "provider": current, + "map_artifact": artifact, + "report": f"map saved as {stem} and registered as a Blacknode map artifact{suffix}", + } + + if action == "stop": + owned = _owned_status(run_id) + stopped = 0 + if owned.get("running"): + result = rt.stop_ros2_managed(run_id) + if not result.get("ok"): + current, _ = _status_result(map_topic, lifecycle, run_id) + return { + "ready": bool(current["ready"]), + "running": bool(current["running"]), + "owned": bool(current["owned_by_blacknode"]), + "provider": current, + "map_artifact": empty_artifact, + "report": f"mapping stop FAILED: {result.get('error') or 'owned process could not be stopped'}", + } + stopped = int(result.get("stopped") or 0) + current, _ = _status_result(map_topic, lifecycle, run_id) + return { + "ready": bool(current["ready"]), + "running": bool(current["running"]), + "owned": bool(current["owned_by_blacknode"]), + "provider": current, + "map_artifact": empty_artifact, + "report": ( + f"stopped {stopped} Blacknode-owned mapping process; ROSOrin bringup was left unchanged" + if stopped + else "no Blacknode-owned mapping process was running; ROSOrin bringup was left unchanged" + ), + } + + current, status = _status_result(map_topic, lifecycle, run_id) + if action == "start" and lifecycle == "existing": + return { + "ready": bool(current["ready"]), + "running": bool(current["running"]), + "owned": False, + "provider": current, + "map_artifact": empty_artifact, + "report": ( + f"mapping attached: {map_topic} is publishing; existing ROS bringup was left unchanged" + if current["ready"] + else f"mapping unavailable: no live map publisher on {map_topic}; existing ROS bringup was left unchanged" + ), + } + detail = str(status.get("error") or "") + report = ( + f"mapping ready: {map_topic} is publishing through SLAM Toolbox" + if current["ready"] + else f"mapping unavailable: {detail or f'no live map publisher on {map_topic}'}" + ) + return { + "ready": bool(current["ready"]), + "running": bool(current["running"]), + "owned": bool(current["owned_by_blacknode"]), + "provider": current, + "map_artifact": empty_artifact, + "report": report, + } diff --git a/components/slam/adapters/ros2/templates/rosorin-persist-environment-map.json b/components/slam/adapters/ros2/templates/rosorin-persist-environment-map.json new file mode 100644 index 0000000..c5b4f94 --- /dev/null +++ b/components/slam/adapters/ros2/templates/rosorin-persist-environment-map.json @@ -0,0 +1,88 @@ +{ + "kind": "blacknode.workflow", + "schema_version": 1, + "name": "ROSOrin Persist Environment Map", + "saved_at": "2026-08-10T00:00:00", + "entrypoint": { + "node_id": "map_environment", + "port": "map_artifact" + }, + "metadata": { + "template": true, + "description": "Persist the live ROSOrin SLAM Toolbox occupancy map and serialized pose graph as a Blacknode map artifact. The workflow attaches to the existing ROS graph and leaves vendor bringup unchanged.", + "color": "#18a058", + "required_packages": ["blacknode-perception", "blacknode-ros2"], + "required_components": ["blacknode-perception/slam", "blacknode-ros2/core"], + "required_adapters": ["blacknode-perception/slam@ros2"] + }, + "node_meta": { + "map_environment": { + "id": "map_environment", + "type": "MapEnvironment", + "params": { + "action": "save", + "lifecycle": "existing", + "run_id": "rosorin-map", + "map_topic": "/map", + "params_file": "/home/ubuntu/ros2_ws/src/slam/config/slam.yaml", + "launch_package": "slam_toolbox", + "launch_file": "online_sync_launch.py", + "launch_arguments": "use_sim_time:=false", + "wait_seconds": 10.0, + "save_directory": "/home/ubuntu/Blacknode/maps", + "map_name": "map_01", + "save_map_service": "/slam_toolbox/save_map", + "serialize_service": "/slam_toolbox/serialize_map", + "serialize_pose_graph": true, + "service_timeout": 30.0 + }, + "pos": [120, 180], + "inputs": ["trigger", "action", "lifecycle", "run_id", "map_topic", "params_file", "launch_package", "launch_file", "launch_arguments", "wait_seconds", "save_directory", "map_name", "save_map_service", "serialize_service", "serialize_pose_graph", "service_timeout"], + "outputs": ["ready", "running", "owned", "provider", "map_artifact", "report"], + "input_types": { + "trigger": "Any", + "action": "Enum", + "lifecycle": "Enum", + "run_id": "Text", + "map_topic": "Text", + "params_file": "Text", + "launch_package": "Text", + "launch_file": "Text", + "launch_arguments": "Text", + "wait_seconds": "Float", + "save_directory": "Text", + "map_name": "Text", + "save_map_service": "Text", + "serialize_service": "Text", + "serialize_pose_graph": "Bool", + "service_timeout": "Float" + }, + "output_types": { + "ready": "Bool", + "running": "Bool", + "owned": "Bool", + "provider": "Dict", + "map_artifact": "Dict", + "report": "Text" + }, + "input_defaults": { + "action": "status", + "lifecycle": "existing", + "run_id": "rosorin-map", + "map_topic": "/map", + "params_file": "", + "launch_package": "slam_toolbox", + "launch_file": "online_sync_launch.py", + "launch_arguments": "use_sim_time:=false", + "wait_seconds": 10.0, + "save_directory": "~/Blacknode/maps", + "map_name": "map_01", + "save_map_service": "/slam_toolbox/save_map", + "serialize_service": "/slam_toolbox/serialize_map", + "serialize_pose_graph": true, + "service_timeout": 30.0 + } + } + }, + "edges": [] +} diff --git a/tests/test_slam_toolbox_mapping.py b/tests/test_slam_toolbox_mapping.py new file mode 100644 index 0000000..490d29e --- /dev/null +++ b/tests/test_slam_toolbox_mapping.py @@ -0,0 +1,134 @@ +import json +from pathlib import Path +from types import SimpleNamespace + +import blacknode # noqa: F401 +from blacknode.node import _NODE_REGISTRY +from blacknode.packages import _import_nodes_module, _tag_new_package_nodes + + +_NODES = Path(__file__).resolve().parents[1] / "components" / "slam" / "adapters" / "ros2" / "nodes" +_before = dict(_NODE_REGISTRY) +_import_nodes_module("blacknode.pkg.blacknode_perception.slam.adapters.ros2", _NODES) +_tag_new_package_nodes(_before, "blacknode-perception", _NODES, "slam", "ros2") + +from blacknode.pkg.blacknode_perception.slam.adapters.ros2 import mapping + + +def _runtime(*, ready=False, owned=False): + calls = [] + + def inspect(_expectations): + return { + "ok": True, + "ready": ready, + "backend": "native", + "interfaces": [{"topic": "/map", "publishing": ready}], + "missing": [] if ready else ["/map"], + } + + return SimpleNamespace( + calls=calls, + inspect_topic_interfaces=inspect, + ros2_managed_status=lambda _run_id: {"ok": True, "running": owned, "backend": "native"}, + run_ros2_managed=lambda run_id, args: calls.append(("start", run_id, args)) or {"ok": True, "backend": "native"}, + wait_for_topic_interfaces=lambda expectations, timeout: inspect(expectations), + stop_ros2_managed=lambda run_id: calls.append(("stop", run_id)) or {"ok": True, "stopped": 1}, + run_ros2=lambda args, timeout=15.0: calls.append(("run", args, timeout)) or {"ok": True, "backend": "native", "stdout": "ok", "stderr": ""}, + ) + + +def test_map_environment_is_registered_as_ros2_slam_adapter(): + fn = _NODE_REGISTRY["MapEnvironment"] + assert fn._bn_package == "blacknode-perception" + assert fn._bn_component == "slam" + assert fn._bn_adapter == "ros2" + + +def test_existing_mapping_attaches_without_starting_or_stopping(monkeypatch): + fake = _runtime(ready=True, owned=False) + monkeypatch.setattr(mapping, "rt", fake) + + result = _NODE_REGISTRY["MapEnvironment"]({"action": "start", "lifecycle": "existing"}) + + assert result["ready"] is True + assert result["owned"] is False + assert fake.calls == [] + assert "left unchanged" in result["report"] + + +def test_managed_mapping_requires_explicit_params_file(monkeypatch): + fake = _runtime(ready=False, owned=False) + monkeypatch.setattr(mapping, "rt", fake) + + result = _NODE_REGISTRY["MapEnvironment"]({"action": "start", "lifecycle": "managed"}) + + assert result["ready"] is False + assert "requires params_file" in result["report"] + assert fake.calls == [] + + +def test_managed_mapping_launches_only_slam_toolbox(monkeypatch): + fake = _runtime(ready=False, owned=False) + fake.wait_for_topic_interfaces = lambda expectations, timeout: { + "ok": True, "ready": True, "backend": "native", "interfaces": [], "missing": [] + } + fake.ros2_managed_status = lambda _run_id: {"ok": True, "running": True, "backend": "native"} + monkeypatch.setattr(mapping, "rt", fake) + + result = _NODE_REGISTRY["MapEnvironment"]({ + "action": "start", + "lifecycle": "managed", + "params_file": "/home/ubuntu/ros2_ws/src/slam/config/slam.yaml", + "wait_seconds": 0, + }) + + command = fake.calls[0][2] + assert command[:3] == ["launch", "slam_toolbox", "online_sync_launch.py"] + assert "slam_params_file:=/home/ubuntu/ros2_ws/src/slam/config/slam.yaml" in command + assert "robot.launch.py" not in " ".join(command) + assert result["ready"] is True + assert result["owned"] is True + + +def test_stop_never_targets_an_existing_mapping_session(monkeypatch): + fake = _runtime(ready=True, owned=False) + monkeypatch.setattr(mapping, "rt", fake) + + result = _NODE_REGISTRY["MapEnvironment"]({"action": "stop", "lifecycle": "existing"}) + + assert result["owned"] is False + assert not [call for call in fake.calls if call[0] == "stop"] + assert "left unchanged" in result["report"] + + +def test_save_produces_map_artifact_and_pose_graph(monkeypatch, tmp_path): + fake = _runtime(ready=True, owned=False) + monkeypatch.setattr(mapping, "rt", fake) + + result = _NODE_REGISTRY["MapEnvironment"]({ + "action": "save", + "save_directory": str(tmp_path), + "map_name": "work room", + }) + + assert result["map_artifact"]["kind"] == "blacknode.map-artifact" + assert result["map_artifact"]["map_name"] == "work-room" + assert result["map_artifact"]["pose_graph_serialized"] is True + service_calls = [call for call in fake.calls if call[0] == "run"] + assert len(service_calls) == 2 + assert service_calls[0][1][:4] == [ + "service", "call", "/slam_toolbox/save_map", "slam_toolbox/srv/SaveMap" + ] + assert json.loads(service_calls[0][1][4])["name"]["data"].endswith("work-room") + + +def test_rosorin_map_template_is_a_real_persistence_workflow(): + from blacknode.workflow import validate_workflow + + path = _NODES.parent / "templates" / "rosorin-persist-environment-map.json" + workflow = json.loads(path.read_text(encoding="utf-8")) + report = validate_workflow(workflow) + assert report.ok, report.to_dict() + assert workflow["node_meta"]["map_environment"]["params"]["action"] == "save" + assert not any("check" in node["type"].lower() or "test" in node["type"].lower() for node in workflow["node_meta"].values())