diff --git a/Makefile b/Makefile index 8b2ba76..15fcf95 100644 --- a/Makefile +++ b/Makefile @@ -57,6 +57,6 @@ test-local: build-local lint-local: python3 tools/validation/check_repository.py - ruff check tools tests + ruff check tools tests packages check-local: lint-local list-local test-local diff --git a/configs/README.md b/configs/README.md index 1096310..3e386d2 100644 --- a/configs/README.md +++ b/configs/README.md @@ -1,5 +1,9 @@ # Configuration Versioned experiment, scenario, agent, task, and simulator configuration belongs -in this domain. Configuration schemas and the MVP examples are introduced with -the components that can validate them; PR 01 does not add speculative examples. +in this domain. [`experiments/mvp.yaml`](experiments/mvp.yaml) is the canonical +schema-version 1 PointNav example validated by `rh_core`. + +Episode initialization uses a complete 3D pose (`x/y/z` in metres and +`roll/pitch/yaw` in radians). PointNav goals are 3D positions without target +orientation semantics. diff --git a/configs/experiments/mvp.yaml b/configs/experiments/mvp.yaml new file mode 100644 index 0000000..ef985da --- /dev/null +++ b/configs/experiments/mvp.yaml @@ -0,0 +1,25 @@ +schema_version: 1 +experiment: + name: go2_keyboard_pointnav + execution_mode: manual + episodes: + - episode_id: "0000" + scenario: warehouse_default + initial_pose: + frame_id: map + x: 1.0 + y: 2.0 + z: 0.4 + roll: 0.0 + pitch: 0.0 + yaw: 0.0 + task: + type: pointnav + goal: + frame_id: map + x: 8.0 + y: 4.0 + z: 0.4 + success_radius_m: 0.5 + timeout_s: 120.0 + seed: 42 diff --git a/docs/architecture-and-development-plan.md b/docs/architecture-and-development-plan.md index ef0f718..c3d3752 100644 --- a/docs/architecture-and-development-plan.md +++ b/docs/architecture-and-development-plan.md @@ -97,7 +97,7 @@ Simulator、Robot、Agent、Task、Evaluation 均通过 ROS contract、配置约 | Agent | 消费标准观测、执行策略、输出命令、agent reset、任务局部状态;MVP 处理键盘输入 | simulator reset、spawn、Episode 调度、评估和持久化 | | Experiment container | control/evaluation plane 的部署边界 | 高频导航数据转发、simulator 或 policy 实现 | | Experiment Orchestrator | Experiment/Episode 权威状态、readiness 等待、reset 顺序、task 分配、start/abort、终止协调、失败策略 | 机器人实时控制、指标公式细节 | -| Task Manager | 加载并校验 EpisodeSpec、构造 PointNav task、start/goal/timeout/success 条件 | 控制 robot、判定组件 readiness、保存结果 | +| Task Manager | 加载并校验 EpisodeSpec、构造 PointNav goal/timeout/success 条件 | 控制 robot、判定组件 readiness、保存结果 | | Evaluator | 订阅 ground truth/episode/task,维护轨迹并计算 metrics、提出终止候选 | 发布 `cmd_vel`、改变 world、决定调度策略 | | Result Recorder | 原子写入 config、metadata、episode spec、events、trajectory、metrics 和 summary | 计算控制或拥有生命周期状态 | | Simulator Backend | Isaac app/extension、timeline、stage、physics、world、原生 ROS 2 Bridge 配置与 backend readiness | Agent、Task/Eval、跨 simulator 的机器人声明 | @@ -218,8 +218,8 @@ Core contract 由 ROS interfaces、配置 schema 和行为测试共同定义;P - `ComponentStatus.msg`:`builtin_interfaces/Time stamp`、`string component_id`、`uint8 state`、`uint32 error_code`、`string detail`、`bool restart_required`;常量 `STARTING/RESETTING/READY/ERROR`。 - `EpisodeState.msg`:`stamp`、`experiment_id`、`episode_id`、`uint64 sequence`、`uint8 state`、`uint8 termination_reason`、`string detail`;定义生命周期和终止原因常量。 -- `PointNavTask.msg`:`experiment_id`、`episode_id`、`geometry_msgs/PoseStamped start`、`goal`、`float64 success_radius_m`、`float64 timeout_s`、`int64 seed`。frame 必须相同且 MVP 为 `map`。 -- `ResetEnv.srv` request:`request_id`、`experiment_id`、`episode_id`、`start`、`seed`;response:`success`、`error_code`、`detail`。 +- `PointNavTask.msg`:`experiment_id`、`episode_id`、`geometry_msgs/PointStamped goal`、`float64 success_radius_m`、`float64 timeout_s`、`int64 seed`。PointNav goal 是 `map` frame 中的 3D position,不表达目标朝向。 +- `ResetEnv.srv` request:`request_id`、`experiment_id`、`episode_id`、`geometry_msgs/PoseStamped initial_pose`、`seed`;initial pose 完整表达 3D position 和 orientation,四元数必须有限且归一化;response:`success`、`error_code`、`detail`。 - `ResetAgent.srv` request:`request_id`、`experiment_id`、`episode_id`;response 同上。 - `StartEpisode.srv` request:`experiment_id`、`episode_id`;response:`accepted`、`detail`。 - `AbortEpisode.srv` request:IDs、`reason`;response:`accepted`、`detail`。 @@ -256,7 +256,8 @@ Experiment └─ ordered Episode instances ├─ immutable EpisodeSpec │ ├─ Scenario/world reference - │ ├─ Task(type=pointnav, start, goal) + │ ├─ Initial pose(x, y, z, roll, pitch, yaw) + │ ├─ Task(type=pointnav, 3D goal) │ ├─ timeout/success radius │ └─ seed ├─ lifecycle state @@ -280,16 +281,23 @@ experiment: episodes: - episode_id: "0000" scenario: warehouse_default + initial_pose: + frame_id: map + x: 1.0 + y: 2.0 + z: 0.4 + roll: 0.0 + pitch: 0.0 + yaw: 0.0 task: type: pointnav - start: {frame_id: map, x: 1.0, y: 2.0, yaw: 0.0} - goal: {frame_id: map, x: 8.0, y: 4.0, yaw: 0.0} + goal: {frame_id: map, x: 8.0, y: 4.0, z: 0.4} success_radius_m: 0.5 timeout_s: 120.0 seed: 42 ``` -配置加载后转换为 typed dataclass,并一次性完成不依赖环境的静态验证:唯一 ID、有限数值、正 timeout/radius 和 frame 一致。静态无效配置直接拒绝启动 Experiment;依赖已加载 world 的检查(例如 start/goal 是否处于有效区域)在对应 Episode 的 PREPARING 阶段执行,失败时以 `INVALID_TASK` 结束该局且不允许机器人运动。 +配置加载后转换为 typed dataclass,并一次性完成不依赖环境的静态验证:唯一 ID、完整且有限的 initial pose、有限的 3D goal、正 timeout/radius 和 frame 一致。配置中的 position 单位为米,roll/pitch/yaw 单位为弧度;RPY 采用绕固定 X/Y/Z 轴的旋转,等价旋转矩阵为 `Rz(yaw) * Ry(pitch) * Rx(roll)`。二维数据源必须在适配层显式补齐缺失的 `z/roll/pitch`(通常置零),再转换成完整 pose。静态无效配置直接拒绝启动 Experiment;依赖已加载 world 的检查(例如 initial pose/goal 是否处于有效区域)在对应 Episode 的 PREPARING 阶段执行,失败时以 `INVALID_TASK` 结束该局且不允许机器人运动。 --- @@ -635,16 +643,16 @@ Env 只有在 Isaac、stage、Go2、physics、ROS bridge、clock、required topi ## Part XIII — PointNav MVP -PointNav EpisodeSpec 必须包含唯一 ID、`map` frame 中的 start/goal、positive success radius、positive timeout、seed 和 execution mode。yaw 用于初始姿态,MVP success 只判断平面位置距离,不要求目标朝向。 +EpisodeSpec 必须包含唯一 ID、`map` frame 中的完整 3D initial pose、PointNav 3D goal、positive success radius、positive timeout、seed 和 execution mode。initial pose 的 orientation 属于环境初始化条件,不属于 PointNav 目标语义;PointNav 不包含目标 orientation,MVP success 判断机器人 tracking point(默认 `base_link` 原点)到 goal 的 3D 欧氏距离。未来若需要目标朝向,应新增语义明确的 PoseNav task/model/interface,而不是重新解释 PointNav 字段。 1. Task Manager 在运动前验证 schema 和有限数值。 -2. Env reset 到 start 并确认 robot 静止;Agent reset;task 直接发布到 Env/Agent/Evaluator。 +2. Env reset 到 initial pose 并确认 robot 静止;Agent reset;task 直接发布到 Env/Agent/Evaluator。 3. Episode 进入 READY,manual 模式等待 `/episode/start`。 4. RUNNING 后 Keyboard Agent 才能驱动;Evaluator 计算 goal distance。 5. 距离首次 `<= success_radius_m` 为 `SUCCESS`;simulation elapsed `>= timeout_s` 为 `TIMEOUT`。 6. 同时发生时优先级为 runtime safety error、user abort、success、timeout;最终 reason 只提交一次,其他候选作为事件保存。 -MVP 不包含 SPL、语义目标、动态场景、复杂碰撞惩罚或 start/goal 自动采样。 +MVP 不包含 SPL、语义目标、动态场景、复杂碰撞惩罚或 initial pose/goal 自动采样。 --- @@ -733,7 +741,7 @@ CPU CI 至少执行 `colcon build`、lint/type checks、unit/interface tests、m **Out of Scope:** physics realism、Gazebo、Isaac compatibility layer。 **Files / Modules:** `tests/fixtures/mock_env/`、integration tests。 **ROS Interfaces:** Env status/reset、Episode state、`/clock`、`/robot/cmd_vel`、odom/TF。 -**Tests:** readiness、idempotent reset、start pose、non-RUNNING zero gate、freeze/reset failure injection。 +**Tests:** readiness、idempotent reset、完整 initial pose、non-RUNNING zero gate、freeze/reset failure injection。 **Acceptance Criteria:** headless 测试 < 30 s 且 deterministic;fixture 明确标注不可作为 simulator 产品实现。 **Dependencies:** PR 04。 **Risks:** mock 与真实 contract 偏离;接口测试共享同一 black-box suite。 @@ -772,7 +780,7 @@ CPU CI 至少执行 `colcon build`、lint/type checks、unit/interface tests、m **Out of Scope:** evaluator aggregation、自动 goal sampling、其他 Task。 **Files / Modules:** `tasks/pointnav/`、configs、tests。 **ROS Interfaces:** publish `PointNavTask`,验证 direct subscribers 和 transient-local behavior。 -**Tests:** start/goal/frame/radius/timeout、late subscriber、episode mismatch。 +**Tests:** 3D goal/frame/radius/timeout、late subscriber、episode mismatch。 **Acceptance Criteria:** Env/Agent/Evaluator 可直接获得同一不可变 task;非法 goal 在运动前拒绝。 **Dependencies:** PR 03、PR 04。 **Risks:** Task 与 Eval 耦合;只共享 typed spec/termination predicate,不共享 recorder。 diff --git a/packages/README.md b/packages/README.md index 3aeaf3d..07fb504 100644 --- a/packages/README.md +++ b/packages/README.md @@ -4,6 +4,9 @@ This domain contains shared RoboHarness platform and communication packages. Packages must depend toward stable layers and must not statically depend on a specific simulator, robot, agent, task, or evaluator implementation. -The first platform package is [`rh_interfaces`](rh_interfaces/README.md), which -owns the implementation-independent ROS wire contract. `rh_core`, `rh_ros`, -and `rh_experiment` are introduced by their roadmap PRs. +- [`rh_interfaces`](rh_interfaces/README.md) owns the implementation-independent + ROS wire contract. +- [`rh_core`](rh_core/README.md) owns ROS-independent configuration, domain + models, lifecycle rules, and termination policy. + +`rh_ros` and `rh_experiment` are introduced by their roadmap PRs. diff --git a/packages/rh_core/README.md b/packages/rh_core/README.md new file mode 100644 index 0000000..00ff8e9 --- /dev/null +++ b/packages/rh_core/README.md @@ -0,0 +1,99 @@ +# `rh_core` + +`rh_core` is the ROS-independent domain layer of RoboHarness. It defines what +an Experiment means, which static configuration is valid, which lifecycle +transitions are legal, and how competing termination candidates are resolved. +It does not communicate with ROS or execute an Experiment. + +## Responsibilities + +- Immutable typed models for versioned Experiment, Episode, and PointNav input. +- Strict YAML decoding with unknown-field and duplicate-key rejection. +- Static validation that does not require a loaded simulator world. +- Pure Experiment and Episode lifecycle transition guards. +- Deterministic Episode termination priority. +- Structured errors containing a machine-readable code, field path, and + human-readable message. + +The package must not import `rclpy`, `rh_interfaces`, Isaac Sim, or any concrete +Simulator, Agent, Task, or Evaluator implementation. ROS message conversion +belongs to the future `rh_ros` package. + +## Configuration schema version 1 + +The canonical example is +[`configs/experiments/mvp.yaml`](../../configs/experiments/mvp.yaml). A document +contains an Experiment name, execution mode, and a non-empty ordered list of +Episode specifications. Each MVP Episode references a scenario and contains an +immutable PointNav task plus a signed 64-bit reproducibility seed. + +The loader validates: + +- exact known fields and `schema_version: 1`; +- non-empty identifiers, scenario references, and Experiment names; +- unique Episode IDs; +- `manual` or `automatic` execution mode; +- PointNav as the only MVP task type; +- finite initial-pose position/orientation, goal, radius, and timeout values; +- positive radius and timeout; +- matching `map` initial-pose/goal frames; and +- an integer seed in the ROS `int64` range. + +The Episode initial state is a full 3D robot pose: position is expressed in +metres and roll, pitch, and yaw in radians. RPY means fixed-axis rotations about +X, Y, and Z, with the equivalent rotation matrix +`Rz(yaw) * Ry(pitch) * Rx(roll)`. A source that only provides planar data must +explicitly adapt the absent `z`, roll, and pitch dimensions, normally to zero. +The ROS adapter converts this representation to a normalized `PoseStamped` +quaternion. + +PointNav contains only a 3D goal position. It has no desired target orientation, +and success uses the 3D Euclidean distance from the configured robot tracking +point (MVP: the `base_link` origin) to the goal. PoseNav, if later required, will +be a separate task model and interface rather than an extension hidden inside +these PointNav fields. + +Checks that require a loaded world, such as collision-free or reachable poses, +remain an Environment/Task concern during Episode `PREPARING`. + +```python +from rh_core import ConfigError, load_experiment_config + +try: + config = load_experiment_config("configs/experiments/mvp.yaml") +except ConfigError as error: + for issue in error.issues: + print(issue.as_dict()) +``` + +## Lifecycle rules + +Experiment progression is: + +```text +CREATED -> STARTING -> RUNNING -> FINALIZING -> FINISHED + \---------- errors ----------> FAILED +``` + +Episode progression is: + +```text +PREPARING -> READY -> RUNNING -> TERMINATING -> FINISHED + | | | + +----------+---------+---- early termination +``` + +Entering `TERMINATING` requires a non-`NONE` reason. The reason is committed +once, retained through `FINISHED`, and cannot be replaced. Every accepted +Episode transition returns a new immutable snapshot with `sequence + 1`. + +Termination candidates use this priority: + +```text +ENV_ERROR > AGENT_ERROR > FAILURE > INVALID_TASK + > ABORTED > SUCCESS > TIMEOUT +``` + +This refines the architectural rule “runtime safety error, user abort, success, +timeout” with deterministic ordering inside the failure tier. The Environment +wins ties because it is the final motion-safety boundary. diff --git a/packages/rh_core/package.xml b/packages/rh_core/package.xml new file mode 100644 index 0000000..bd302fa --- /dev/null +++ b/packages/rh_core/package.xml @@ -0,0 +1,20 @@ + + + + rh_core + 0.0.0 + ROS-independent domain models and rules for RoboHarness. + Staaaaaaaaar + + NOASSERTION + + ament_python + + python3-yaml + + python3-pytest + + + ament_python + + diff --git a/packages/rh_core/resource/rh_core b/packages/rh_core/resource/rh_core new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/packages/rh_core/resource/rh_core @@ -0,0 +1 @@ + diff --git a/packages/rh_core/rh_core/__init__.py b/packages/rh_core/rh_core/__init__.py new file mode 100644 index 0000000..2115439 --- /dev/null +++ b/packages/rh_core/rh_core/__init__.py @@ -0,0 +1,41 @@ +"""ROS-independent RoboHarness domain models and rules.""" + +from rh_core.config import load_experiment_config, parse_experiment_config +from rh_core.errors import ConfigError, ErrorCode, LifecycleError, ValidationIssue +from rh_core.lifecycle import EpisodeLifecycle, ExperimentLifecycle +from rh_core.models import ( + EpisodeSpec, + EpisodeState, + ExecutionMode, + ExperimentConfig, + ExperimentSpec, + ExperimentState, + Point3D, + PointNavTaskSpec, + Pose3D, + TerminationReason, +) +from rh_core.termination import TERMINATION_PRIORITY, resolve_termination_reason + +__all__ = [ + "ConfigError", + "EpisodeLifecycle", + "EpisodeSpec", + "EpisodeState", + "ErrorCode", + "ExecutionMode", + "ExperimentConfig", + "ExperimentLifecycle", + "ExperimentSpec", + "ExperimentState", + "LifecycleError", + "Point3D", + "PointNavTaskSpec", + "Pose3D", + "TERMINATION_PRIORITY", + "TerminationReason", + "ValidationIssue", + "load_experiment_config", + "parse_experiment_config", + "resolve_termination_reason", +] diff --git a/packages/rh_core/rh_core/config.py b/packages/rh_core/rh_core/config.py new file mode 100644 index 0000000..9289465 --- /dev/null +++ b/packages/rh_core/rh_core/config.py @@ -0,0 +1,459 @@ +"""Strict YAML loading and static validation for Experiment configuration.""" + +from __future__ import annotations + +import math +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +import yaml +from yaml.nodes import MappingNode + +from rh_core.errors import ConfigError, ErrorCode, ValidationIssue +from rh_core.models import ( + EpisodeSpec, + ExecutionMode, + ExperimentConfig, + ExperimentSpec, + Point3D, + PointNavTaskSpec, + Pose3D, +) + +SUPPORTED_SCHEMA_VERSION = 1 +MVP_FRAME_ID = "map" +_INT64_MIN = -(2**63) +_INT64_MAX = 2**63 - 1 + + +class _DuplicateKeyError(yaml.YAMLError): + def __init__(self, key: object) -> None: + self.key = key + super().__init__(f"duplicate YAML mapping key: {key!r}") + + +class _UniqueKeySafeLoader(yaml.SafeLoader): + """SafeLoader variant that rejects silent mapping-key replacement.""" + + +def _construct_unique_mapping( + loader: _UniqueKeySafeLoader, + node: MappingNode, + deep: bool = False, +) -> dict[object, object]: + loader.flatten_mapping(node) + result: dict[object, object] = {} + for key_node, value_node in node.value: + key = loader.construct_object(key_node, deep=deep) + try: + duplicate = key in result + except TypeError as exc: + raise yaml.constructor.ConstructorError( + "while constructing a mapping", + node.start_mark, + "found an unhashable key", + key_node.start_mark, + ) from exc + if duplicate: + raise _DuplicateKeyError(key) + result[key] = loader.construct_object(value_node, deep=deep) + return result + + +_UniqueKeySafeLoader.add_constructor( + yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, + _construct_unique_mapping, +) + + +class _Validator: + def __init__(self) -> None: + self.issues: list[ValidationIssue] = [] + + def add(self, code: ErrorCode, path: str, message: str) -> None: + self.issues.append(ValidationIssue(code=code, path=path, message=message)) + + def mapping( + self, + value: object, + path: str, + *, + required: frozenset[str], + allowed: frozenset[str], + ) -> Mapping[str, Any] | None: + if not isinstance(value, Mapping): + self.add(ErrorCode.TYPE_MISMATCH, path, "expected a mapping") + return None + + valid_keys: set[str] = set() + for key in value: + if not isinstance(key, str): + self.add( + ErrorCode.TYPE_MISMATCH, + path, + f"mapping keys must be strings, got {type(key).__name__}", + ) + continue + valid_keys.add(key) + if key not in allowed: + self.add(ErrorCode.UNKNOWN_FIELD, f"{path}.{key}", "unknown field") + + for key in sorted(required - valid_keys): + self.add(ErrorCode.MISSING_FIELD, f"{path}.{key}", "required field is missing") + return value + + def string(self, value: object, path: str) -> str | None: + if not isinstance(value, str): + self.add(ErrorCode.TYPE_MISMATCH, path, "expected a string") + return None + if not value.strip(): + self.add(ErrorCode.INVALID_VALUE, path, "must not be empty") + return None + return value + + def number(self, value: object, path: str) -> float | None: + if isinstance(value, bool) or not isinstance(value, int | float): + self.add(ErrorCode.TYPE_MISMATCH, path, "expected a number") + return None + try: + converted = float(value) + except OverflowError: + self.add(ErrorCode.INVALID_VALUE, path, "must be finite") + return None + if not math.isfinite(converted): + self.add(ErrorCode.INVALID_VALUE, path, "must be finite") + return None + return converted + + def int64(self, value: object, path: str) -> int | None: + if isinstance(value, bool) or not isinstance(value, int): + self.add(ErrorCode.TYPE_MISMATCH, path, "expected an integer") + return None + if not _INT64_MIN <= value <= _INT64_MAX: + self.add(ErrorCode.INVALID_VALUE, path, "must fit in a signed 64-bit integer") + return None + return value + + +def _parse_point(value: object, path: str, validator: _Validator) -> Point3D | None: + issue_count = len(validator.issues) + data = validator.mapping( + value, + path, + required=frozenset({"frame_id", "x", "y", "z"}), + allowed=frozenset({"frame_id", "x", "y", "z"}), + ) + if data is None: + return None + + frame_id = ( + validator.string(data["frame_id"], f"{path}.frame_id") + if "frame_id" in data + else None + ) + x = validator.number(data["x"], f"{path}.x") if "x" in data else None + y = validator.number(data["y"], f"{path}.y") if "y" in data else None + z = validator.number(data["z"], f"{path}.z") if "z" in data else None + + if len(validator.issues) != issue_count: + return None + assert frame_id is not None and x is not None and y is not None and z is not None + return Point3D(frame_id=frame_id, x=x, y=y, z=z) + + +def _parse_pose(value: object, path: str, validator: _Validator) -> Pose3D | None: + issue_count = len(validator.issues) + fields = frozenset({"frame_id", "x", "y", "z", "roll", "pitch", "yaw"}) + data = validator.mapping(value, path, required=fields, allowed=fields) + if data is None: + return None + + frame_id = ( + validator.string(data["frame_id"], f"{path}.frame_id") + if "frame_id" in data + else None + ) + x = validator.number(data["x"], f"{path}.x") if "x" in data else None + y = validator.number(data["y"], f"{path}.y") if "y" in data else None + z = validator.number(data["z"], f"{path}.z") if "z" in data else None + roll = validator.number(data["roll"], f"{path}.roll") if "roll" in data else None + pitch = ( + validator.number(data["pitch"], f"{path}.pitch") if "pitch" in data else None + ) + yaw = validator.number(data["yaw"], f"{path}.yaw") if "yaw" in data else None + + if len(validator.issues) != issue_count: + return None + assert ( + frame_id is not None + and x is not None + and y is not None + and z is not None + and roll is not None + and pitch is not None + and yaw is not None + ) + return Pose3D( + frame_id=frame_id, + x=x, + y=y, + z=z, + roll=roll, + pitch=pitch, + yaw=yaw, + ) + + +def _parse_task( + value: object, + path: str, + validator: _Validator, +) -> PointNavTaskSpec | None: + issue_count = len(validator.issues) + fields = frozenset({"type", "goal", "success_radius_m", "timeout_s"}) + data = validator.mapping(value, path, required=fields, allowed=fields) + if data is None: + return None + + task_type = validator.string(data["type"], f"{path}.type") if "type" in data else None + if task_type is not None and task_type != "pointnav": + validator.add(ErrorCode.INVALID_VALUE, f"{path}.type", "only 'pointnav' is supported") + + goal = _parse_point(data["goal"], f"{path}.goal", validator) if "goal" in data else None + radius = ( + validator.number(data["success_radius_m"], f"{path}.success_radius_m") + if "success_radius_m" in data + else None + ) + timeout = ( + validator.number(data["timeout_s"], f"{path}.timeout_s") + if "timeout_s" in data + else None + ) + + if radius is not None and radius <= 0.0: + validator.add( + ErrorCode.INVALID_VALUE, + f"{path}.success_radius_m", + "must be greater than zero", + ) + if timeout is not None and timeout <= 0.0: + validator.add(ErrorCode.INVALID_VALUE, f"{path}.timeout_s", "must be greater than zero") + + if goal is not None: + if goal.frame_id != MVP_FRAME_ID: + validator.add( + ErrorCode.UNSUPPORTED_FRAME, + f"{path}.goal.frame_id", + f"MVP requires frame_id '{MVP_FRAME_ID}'", + ) + + if len(validator.issues) != issue_count: + return None + assert goal is not None and radius is not None and timeout is not None + return PointNavTaskSpec( + goal=goal, + success_radius_m=radius, + timeout_s=timeout, + ) + + +def _parse_episode(value: object, index: int, validator: _Validator) -> EpisodeSpec | None: + path = f"experiment.episodes[{index}]" + issue_count = len(validator.issues) + fields = frozenset({"episode_id", "scenario", "initial_pose", "task", "seed"}) + data = validator.mapping(value, path, required=fields, allowed=fields) + if data is None: + return None + + episode_id = ( + validator.string(data["episode_id"], f"{path}.episode_id") + if "episode_id" in data + else None + ) + scenario = ( + validator.string(data["scenario"], f"{path}.scenario") + if "scenario" in data + else None + ) + initial_pose = ( + _parse_pose(data["initial_pose"], f"{path}.initial_pose", validator) + if "initial_pose" in data + else None + ) + task = _parse_task(data["task"], f"{path}.task", validator) if "task" in data else None + seed = validator.int64(data["seed"], f"{path}.seed") if "seed" in data else None + + if len(validator.issues) != issue_count: + return None + if initial_pose is not None: + if initial_pose.frame_id != MVP_FRAME_ID: + validator.add( + ErrorCode.UNSUPPORTED_FRAME, + f"{path}.initial_pose.frame_id", + f"MVP requires frame_id '{MVP_FRAME_ID}'", + ) + if task is not None and initial_pose.frame_id != task.goal.frame_id: + validator.add( + ErrorCode.FRAME_MISMATCH, + path, + "initial_pose and task goal frame_id values must match", + ) + + if len(validator.issues) != issue_count: + return None + assert ( + episode_id is not None + and scenario is not None + and initial_pose is not None + and task is not None + and seed is not None + ) + return EpisodeSpec( + episode_id=episode_id, + scenario=scenario, + initial_pose=initial_pose, + task=task, + seed=seed, + ) + + +def _parse_experiment(value: object, validator: _Validator) -> ExperimentSpec | None: + path = "experiment" + issue_count = len(validator.issues) + fields = frozenset({"name", "execution_mode", "episodes"}) + data = validator.mapping(value, path, required=fields, allowed=fields) + if data is None: + return None + + name = validator.string(data["name"], f"{path}.name") if "name" in data else None + + mode_value = ( + validator.string(data["execution_mode"], f"{path}.execution_mode") + if "execution_mode" in data + else None + ) + mode: ExecutionMode | None = None + if mode_value is not None: + try: + mode = ExecutionMode(mode_value) + except ValueError: + validator.add( + ErrorCode.INVALID_VALUE, + f"{path}.execution_mode", + "must be 'manual' or 'automatic'", + ) + + indexed_episodes: list[tuple[int, EpisodeSpec]] = [] + raw_episodes = data.get("episodes") + if "episodes" in data: + if not isinstance(raw_episodes, list): + validator.add(ErrorCode.TYPE_MISMATCH, f"{path}.episodes", "expected a list") + elif not raw_episodes: + validator.add(ErrorCode.INVALID_VALUE, f"{path}.episodes", "must not be empty") + else: + for index, raw_episode in enumerate(raw_episodes): + episode = _parse_episode(raw_episode, index, validator) + if episode is not None: + indexed_episodes.append((index, episode)) + + first_index_by_id: dict[str, int] = {} + for source_index, episode in indexed_episodes: + first_index = first_index_by_id.setdefault(episode.episode_id, source_index) + if first_index != source_index: + validator.add( + ErrorCode.DUPLICATE_EPISODE_ID, + f"{path}.episodes[{source_index}].episode_id", + f"duplicates experiment.episodes[{first_index}].episode_id", + ) + + if len(validator.issues) != issue_count: + return None + assert name is not None and mode is not None and indexed_episodes + episodes = tuple(episode for _, episode in indexed_episodes) + return ExperimentSpec(name=name, execution_mode=mode, episodes=episodes) + + +def parse_experiment_config(document: object) -> ExperimentConfig: + """Validate a decoded YAML document and return immutable typed models.""" + + validator = _Validator() + fields = frozenset({"schema_version", "experiment"}) + root = validator.mapping(document, "$", required=fields, allowed=fields) + if root is None: + raise ConfigError(validator.issues) + + schema_version = ( + validator.int64(root["schema_version"], "schema_version") + if "schema_version" in root + else None + ) + if schema_version is not None and schema_version != SUPPORTED_SCHEMA_VERSION: + validator.add( + ErrorCode.UNSUPPORTED_SCHEMA_VERSION, + "schema_version", + f"supported version is {SUPPORTED_SCHEMA_VERSION}", + ) + + experiment = ( + _parse_experiment(root["experiment"], validator) if "experiment" in root else None + ) + + if validator.issues: + raise ConfigError(validator.issues) + assert schema_version is not None and experiment is not None + return ExperimentConfig(schema_version=schema_version, experiment=experiment) + + +def load_experiment_config(path: str | Path) -> ExperimentConfig: + """Load one UTF-8 YAML file and return a validated immutable configuration.""" + + config_path = Path(path) + try: + text = config_path.read_text(encoding="utf-8") + except FileNotFoundError as exc: + raise ConfigError( + ( + ValidationIssue( + code=ErrorCode.FILE_NOT_FOUND, + path=str(config_path), + message="configuration file does not exist", + ), + ) + ) from exc + except OSError as exc: + raise ConfigError( + ( + ValidationIssue( + code=ErrorCode.FILE_READ_ERROR, + path=str(config_path), + message=str(exc), + ), + ) + ) from exc + + try: + document = yaml.load(text, Loader=_UniqueKeySafeLoader) + except _DuplicateKeyError as exc: + raise ConfigError( + ( + ValidationIssue( + code=ErrorCode.YAML_DUPLICATE_KEY, + path=str(config_path), + message=f"duplicate mapping key {exc.key!r}", + ), + ) + ) from exc + except yaml.YAMLError as exc: + raise ConfigError( + ( + ValidationIssue( + code=ErrorCode.YAML_SYNTAX, + path=str(config_path), + message=str(exc), + ), + ) + ) from exc + + return parse_experiment_config(document) diff --git a/packages/rh_core/rh_core/errors.py b/packages/rh_core/rh_core/errors.py new file mode 100644 index 0000000..00346fd --- /dev/null +++ b/packages/rh_core/rh_core/errors.py @@ -0,0 +1,65 @@ +"""Structured errors returned by RoboHarness core rules.""" + +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass +from enum import Enum + + +class ErrorCode(str, Enum): + """Stable machine-readable categories for core failures.""" + + FILE_NOT_FOUND = "file_not_found" + FILE_READ_ERROR = "file_read_error" + YAML_SYNTAX = "yaml_syntax" + YAML_DUPLICATE_KEY = "yaml_duplicate_key" + TYPE_MISMATCH = "type_mismatch" + MISSING_FIELD = "missing_field" + UNKNOWN_FIELD = "unknown_field" + UNSUPPORTED_SCHEMA_VERSION = "unsupported_schema_version" + INVALID_VALUE = "invalid_value" + DUPLICATE_EPISODE_ID = "duplicate_episode_id" + FRAME_MISMATCH = "frame_mismatch" + UNSUPPORTED_FRAME = "unsupported_frame" + INVALID_TRANSITION = "invalid_transition" + TERMINATION_REQUIRED = "termination_required" + TERMINATION_ALREADY_COMMITTED = "termination_already_committed" + UNEXPECTED_TERMINATION_REASON = "unexpected_termination_reason" + + +@dataclass(frozen=True, slots=True) +class ValidationIssue: + """One precise validation or lifecycle failure.""" + + code: ErrorCode + path: str + message: str + + def as_dict(self) -> dict[str, str]: + """Return a serialization-friendly representation.""" + + return {"code": self.code.value, "path": self.path, "message": self.message} + + +class CoreError(ValueError): + """Base exception carrying one or more structured issues.""" + + def __init__(self, issues: Iterable[ValidationIssue]) -> None: + self.issues = tuple(issues) + if not self.issues: + raise ValueError("CoreError requires at least one issue") + super().__init__(self._format_issues()) + + def _format_issues(self) -> str: + return "; ".join( + f"{issue.code.value} at {issue.path}: {issue.message}" for issue in self.issues + ) + + +class ConfigError(CoreError): + """Configuration could not be loaded or statically validated.""" + + +class LifecycleError(CoreError): + """A requested domain-state transition violated the lifecycle contract.""" diff --git a/packages/rh_core/rh_core/lifecycle.py b/packages/rh_core/rh_core/lifecycle.py new file mode 100644 index 0000000..9b34aa4 --- /dev/null +++ b/packages/rh_core/rh_core/lifecycle.py @@ -0,0 +1,197 @@ +"""Pure lifecycle transition guards for Experiments and Episodes.""" + +from __future__ import annotations + +from dataclasses import dataclass, replace + +from rh_core.errors import ErrorCode, LifecycleError, ValidationIssue +from rh_core.models import EpisodeState, ExperimentState, TerminationReason + +_EXPERIMENT_TRANSITIONS: dict[ExperimentState, frozenset[ExperimentState]] = { + ExperimentState.CREATED: frozenset({ExperimentState.STARTING}), + ExperimentState.STARTING: frozenset({ExperimentState.RUNNING, ExperimentState.FAILED}), + ExperimentState.RUNNING: frozenset({ExperimentState.FINALIZING, ExperimentState.FAILED}), + ExperimentState.FINALIZING: frozenset( + {ExperimentState.FINISHED, ExperimentState.FAILED} + ), + ExperimentState.FINISHED: frozenset(), + ExperimentState.FAILED: frozenset(), +} + +_EPISODE_TRANSITIONS: dict[EpisodeState, frozenset[EpisodeState]] = { + EpisodeState.PREPARING: frozenset({EpisodeState.READY, EpisodeState.TERMINATING}), + EpisodeState.READY: frozenset({EpisodeState.RUNNING, EpisodeState.TERMINATING}), + EpisodeState.RUNNING: frozenset({EpisodeState.TERMINATING}), + EpisodeState.TERMINATING: frozenset({EpisodeState.FINISHED}), + EpisodeState.FINISHED: frozenset(), +} +_UINT64_MAX = 2**64 - 1 + + +def _lifecycle_error(code: ErrorCode, path: str, message: str) -> LifecycleError: + return LifecycleError((ValidationIssue(code=code, path=path, message=message),)) + + +@dataclass(frozen=True, slots=True) +class ExperimentLifecycle: + """Immutable snapshot of Experiment lifecycle state.""" + + state: ExperimentState = ExperimentState.CREATED + + def __post_init__(self) -> None: + if not isinstance(self.state, ExperimentState): + raise _lifecycle_error( + ErrorCode.TYPE_MISMATCH, + "experiment.state", + "state must be an ExperimentState", + ) + + def transition(self, target: ExperimentState) -> ExperimentLifecycle: + """Return the next snapshot or reject an illegal transition.""" + + if not isinstance(target, ExperimentState): + raise _lifecycle_error( + ErrorCode.TYPE_MISMATCH, + "experiment.state", + "target must be an ExperimentState", + ) + if target not in _EXPERIMENT_TRANSITIONS[self.state]: + raise _lifecycle_error( + ErrorCode.INVALID_TRANSITION, + "experiment.state", + f"cannot transition from {self.state.name} to {target.name}", + ) + return replace(self, state=target) + + +@dataclass(frozen=True, slots=True) +class EpisodeLifecycle: + """Immutable Episode state with a monotonic transition sequence.""" + + episode_id: str + state: EpisodeState = EpisodeState.PREPARING + termination_reason: TerminationReason = TerminationReason.NONE + sequence: int = 0 + + def __post_init__(self) -> None: + path = f"episode[{self.episode_id}]" + if not isinstance(self.episode_id, str): + raise _lifecycle_error( + ErrorCode.TYPE_MISMATCH, + "episode.id", + "episode_id must be a string", + ) + if not self.episode_id.strip(): + raise _lifecycle_error( + ErrorCode.INVALID_VALUE, + "episode.id", + "episode_id must not be empty", + ) + if not isinstance(self.state, EpisodeState): + raise _lifecycle_error( + ErrorCode.TYPE_MISMATCH, + f"{path}.state", + "state must be an EpisodeState", + ) + if not isinstance(self.termination_reason, TerminationReason): + raise _lifecycle_error( + ErrorCode.TYPE_MISMATCH, + f"{path}.termination_reason", + "termination_reason must be a TerminationReason", + ) + if isinstance(self.sequence, bool) or not isinstance(self.sequence, int): + raise _lifecycle_error( + ErrorCode.TYPE_MISMATCH, + f"{path}.sequence", + "sequence must be an integer", + ) + if not 0 <= self.sequence <= _UINT64_MAX: + raise _lifecycle_error( + ErrorCode.INVALID_VALUE, + f"{path}.sequence", + "sequence must fit in an unsigned 64-bit integer", + ) + + reason_is_committed = self.termination_reason is not TerminationReason.NONE + state_is_terminal = self.state in {EpisodeState.TERMINATING, EpisodeState.FINISHED} + if state_is_terminal and not reason_is_committed: + raise _lifecycle_error( + ErrorCode.TERMINATION_REQUIRED, + f"{path}.termination_reason", + f"state {self.state.name} requires a non-NONE reason", + ) + if not state_is_terminal and reason_is_committed: + raise _lifecycle_error( + ErrorCode.UNEXPECTED_TERMINATION_REASON, + f"{path}.termination_reason", + f"state {self.state.name} cannot carry a committed reason", + ) + + def transition( + self, + target: EpisodeState, + *, + termination_reason: TerminationReason | None = None, + ) -> EpisodeLifecycle: + """Return the next snapshot while enforcing one-time termination commit.""" + + path = f"episode[{self.episode_id}].state" + if not isinstance(target, EpisodeState): + raise _lifecycle_error( + ErrorCode.TYPE_MISMATCH, + path, + "target must be an EpisodeState", + ) + if termination_reason is not None and not isinstance( + termination_reason, TerminationReason + ): + raise _lifecycle_error( + ErrorCode.TYPE_MISMATCH, + f"episode[{self.episode_id}].termination_reason", + "termination_reason must be a TerminationReason", + ) + if target not in _EPISODE_TRANSITIONS[self.state]: + raise _lifecycle_error( + ErrorCode.INVALID_TRANSITION, + path, + f"cannot transition from {self.state.name} to {target.name}", + ) + + if self.termination_reason is not TerminationReason.NONE: + if termination_reason is not None: + raise _lifecycle_error( + ErrorCode.TERMINATION_ALREADY_COMMITTED, + f"episode[{self.episode_id}].termination_reason", + f"termination reason is already {self.termination_reason.name}", + ) + next_reason = self.termination_reason + elif target is EpisodeState.TERMINATING: + if termination_reason in (None, TerminationReason.NONE): + raise _lifecycle_error( + ErrorCode.TERMINATION_REQUIRED, + f"episode[{self.episode_id}].termination_reason", + "entering TERMINATING requires a non-NONE reason", + ) + next_reason = termination_reason + elif termination_reason is not None: + raise _lifecycle_error( + ErrorCode.UNEXPECTED_TERMINATION_REASON, + f"episode[{self.episode_id}].termination_reason", + "a reason can only be committed when entering TERMINATING", + ) + else: + next_reason = TerminationReason.NONE + + if self.sequence == _UINT64_MAX: + raise _lifecycle_error( + ErrorCode.INVALID_VALUE, + f"episode[{self.episode_id}].sequence", + "sequence cannot be incremented beyond uint64", + ) + + return replace( + self, + state=target, + termination_reason=next_reason, + sequence=self.sequence + 1, + ) diff --git a/packages/rh_core/rh_core/models.py b/packages/rh_core/rh_core/models.py new file mode 100644 index 0000000..7e01cd8 --- /dev/null +++ b/packages/rh_core/rh_core/models.py @@ -0,0 +1,107 @@ +"""Typed domain models shared by RoboHarness core behavior.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum, IntEnum + + +class ExecutionMode(str, Enum): + """How a READY Episode is triggered.""" + + MANUAL = "manual" + AUTOMATIC = "automatic" + + +class ExperimentState(IntEnum): + """Authoritative lifecycle of one Experiment run.""" + + CREATED = 0 + STARTING = 1 + RUNNING = 2 + FINALIZING = 3 + FINISHED = 4 + FAILED = 5 + + +class EpisodeState(IntEnum): + """Authoritative lifecycle of one Episode.""" + + PREPARING = 0 + READY = 1 + RUNNING = 2 + TERMINATING = 3 + FINISHED = 4 + + +class TerminationReason(IntEnum): + """Why an Episode stopped; orthogonal to its lifecycle state.""" + + NONE = 0 + SUCCESS = 1 + TIMEOUT = 2 + ABORTED = 3 + FAILURE = 4 + ENV_ERROR = 5 + AGENT_ERROR = 6 + INVALID_TASK = 7 + + +@dataclass(frozen=True, slots=True) +class Point3D: + """Three-dimensional point expressed in a named coordinate frame.""" + + frame_id: str + x: float + y: float + z: float + + +@dataclass(frozen=True, slots=True) +class Pose3D: + """Robot pose using metres and fixed-axis X-Y-Z roll/pitch/yaw radians.""" + + frame_id: str + x: float + y: float + z: float + roll: float + pitch: float + yaw: float + + +@dataclass(frozen=True, slots=True) +class PointNavTaskSpec: + """Immutable static input for a PointNav Episode.""" + + goal: Point3D + success_radius_m: float + timeout_s: float + + +@dataclass(frozen=True, slots=True) +class EpisodeSpec: + """Validated and immutable description of one Episode.""" + + episode_id: str + scenario: str + initial_pose: Pose3D + task: PointNavTaskSpec + seed: int + + +@dataclass(frozen=True, slots=True) +class ExperimentSpec: + """Ordered collection of Episode specifications.""" + + name: str + execution_mode: ExecutionMode + episodes: tuple[EpisodeSpec, ...] + + +@dataclass(frozen=True, slots=True) +class ExperimentConfig: + """Versioned, validated root configuration document.""" + + schema_version: int + experiment: ExperimentSpec diff --git a/packages/rh_core/rh_core/py.typed b/packages/rh_core/rh_core/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/packages/rh_core/rh_core/py.typed @@ -0,0 +1 @@ + diff --git a/packages/rh_core/rh_core/termination.py b/packages/rh_core/rh_core/termination.py new file mode 100644 index 0000000..065848d --- /dev/null +++ b/packages/rh_core/rh_core/termination.py @@ -0,0 +1,38 @@ +"""Deterministic policy for competing Episode termination candidates.""" + +from __future__ import annotations + +from collections.abc import Iterable + +from rh_core.models import TerminationReason + +# Safety/infrastructure failures outrank operator abort, success, and timeout. +# Within the failure tier, the Environment wins because it is the final motion +# safety boundary. Existing order is domain behavior and must be changed with care. +TERMINATION_PRIORITY: tuple[TerminationReason, ...] = ( + TerminationReason.ENV_ERROR, + TerminationReason.AGENT_ERROR, + TerminationReason.FAILURE, + TerminationReason.INVALID_TASK, + TerminationReason.ABORTED, + TerminationReason.SUCCESS, + TerminationReason.TIMEOUT, +) + +_PRIORITY_RANK = {reason: rank for rank, reason in enumerate(TERMINATION_PRIORITY)} + + +def resolve_termination_reason( + candidates: Iterable[TerminationReason], +) -> TerminationReason: + """Select one authoritative reason, or NONE when no candidate exists.""" + + effective: set[TerminationReason] = set() + for reason in candidates: + if not isinstance(reason, TerminationReason): + raise TypeError("termination candidates must be TerminationReason values") + if reason is not TerminationReason.NONE: + effective.add(reason) + if not effective: + return TerminationReason.NONE + return min(effective, key=_PRIORITY_RANK.__getitem__) diff --git a/packages/rh_core/setup.cfg b/packages/rh_core/setup.cfg new file mode 100644 index 0000000..62f1a2e --- /dev/null +++ b/packages/rh_core/setup.cfg @@ -0,0 +1,5 @@ +[develop] +script_dir=$base/lib/rh_core + +[install] +install_scripts=$base/lib/rh_core diff --git a/packages/rh_core/setup.py b/packages/rh_core/setup.py new file mode 100644 index 0000000..0e91101 --- /dev/null +++ b/packages/rh_core/setup.py @@ -0,0 +1,21 @@ +from setuptools import find_packages, setup + +package_name = "rh_core" + +setup( + name=package_name, + version="0.0.0", + packages=find_packages(exclude=("test",)), + data_files=[ + ("share/ament_index/resource_index/packages", [f"resource/{package_name}"]), + (f"share/{package_name}", ["package.xml"]), + ], + package_data={package_name: ["py.typed"]}, + install_requires=["PyYAML", "setuptools"], + zip_safe=True, + maintainer="Staaaaaaaaar", + maintainer_email="2300012435@stu.pku.edu.cn", + description="ROS-independent domain models and rules for RoboHarness.", + license="NOASSERTION", + tests_require=["pytest"], +) diff --git a/packages/rh_core/test/conftest.py b/packages/rh_core/test/conftest.py new file mode 100644 index 0000000..5fffbfc --- /dev/null +++ b/packages/rh_core/test/conftest.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from typing import Any + +import pytest + + +@pytest.fixture +def valid_document() -> dict[str, Any]: + return { + "schema_version": 1, + "experiment": { + "name": "go2_keyboard_pointnav", + "execution_mode": "manual", + "episodes": [ + { + "episode_id": "0000", + "scenario": "warehouse_default", + "initial_pose": { + "frame_id": "map", + "x": 1.0, + "y": 2.0, + "z": 0.4, + "roll": 0.0, + "pitch": 0.0, + "yaw": 0.0, + }, + "task": { + "type": "pointnav", + "goal": {"frame_id": "map", "x": 8.0, "y": 4.0, "z": 0.4}, + "success_radius_m": 0.5, + "timeout_s": 120.0, + }, + "seed": 42, + } + ], + }, + } diff --git a/packages/rh_core/test/test_config.py b/packages/rh_core/test/test_config.py new file mode 100644 index 0000000..7b437ca --- /dev/null +++ b/packages/rh_core/test/test_config.py @@ -0,0 +1,212 @@ +from __future__ import annotations + +from copy import deepcopy +from dataclasses import FrozenInstanceError +from pathlib import Path +from typing import Any + +import pytest + +from rh_core import ConfigError, ErrorCode, ExecutionMode +from rh_core.config import load_experiment_config, parse_experiment_config + + +def issue_codes(error: ConfigError) -> set[ErrorCode]: + return {issue.code for issue in error.issues} + + +def test_canonical_mvp_config_loads_to_immutable_models() -> None: + repository_root = Path(__file__).resolve().parents[3] + config = load_experiment_config(repository_root / "configs/experiments/mvp.yaml") + + assert config.schema_version == 1 + assert config.experiment.name == "go2_keyboard_pointnav" + assert config.experiment.execution_mode is ExecutionMode.MANUAL + assert isinstance(config.experiment.episodes, tuple) + assert config.experiment.episodes[0].initial_pose.frame_id == "map" + assert config.experiment.episodes[0].initial_pose.yaw == 0.0 + assert config.experiment.episodes[0].task.goal.z == 0.4 + assert config.experiment.episodes[0].seed == 42 + + with pytest.raises(FrozenInstanceError): + config.experiment.name = "changed" # type: ignore[misc] + + +def test_automatic_execution_mode_is_supported(valid_document: dict[str, Any]) -> None: + valid_document["experiment"]["execution_mode"] = "automatic" + config = parse_experiment_config(valid_document) + assert config.experiment.execution_mode is ExecutionMode.AUTOMATIC + + +@pytest.mark.parametrize("schema_version", [0, 2, -1]) +def test_unknown_schema_version_is_rejected( + valid_document: dict[str, Any], schema_version: int +) -> None: + valid_document["schema_version"] = schema_version + with pytest.raises(ConfigError) as caught: + parse_experiment_config(valid_document) + assert ErrorCode.UNSUPPORTED_SCHEMA_VERSION in issue_codes(caught.value) + + +def test_missing_and_unknown_fields_are_reported_together(valid_document: dict[str, Any]) -> None: + del valid_document["experiment"]["name"] + valid_document["experiment"]["typo"] = True + + with pytest.raises(ConfigError) as caught: + parse_experiment_config(valid_document) + + issues = {(issue.code, issue.path) for issue in caught.value.issues} + assert (ErrorCode.MISSING_FIELD, "experiment.name") in issues + assert (ErrorCode.UNKNOWN_FIELD, "experiment.typo") in issues + + +def test_duplicate_episode_ids_are_rejected(valid_document: dict[str, Any]) -> None: + duplicate = deepcopy(valid_document["experiment"]["episodes"][0]) + valid_document["experiment"]["episodes"].append(duplicate) + + with pytest.raises(ConfigError) as caught: + parse_experiment_config(valid_document) + + issue = next( + issue for issue in caught.value.issues if issue.code is ErrorCode.DUPLICATE_EPISODE_ID + ) + assert issue.path == "experiment.episodes[1].episode_id" + + +@pytest.mark.parametrize("value", [float("nan"), float("inf"), float("-inf")]) +@pytest.mark.parametrize("field", ["x", "y", "z"]) +def test_non_finite_goal_values_are_rejected( + valid_document: dict[str, Any], field: str, value: float +) -> None: + valid_document["experiment"]["episodes"][0]["task"]["goal"][field] = value + with pytest.raises(ConfigError) as caught: + parse_experiment_config(valid_document) + assert ErrorCode.INVALID_VALUE in issue_codes(caught.value) + + +def test_unrepresentably_large_number_is_structured(valid_document: dict[str, Any]) -> None: + valid_document["experiment"]["episodes"][0]["task"]["goal"]["x"] = 10**10000 + with pytest.raises(ConfigError) as caught: + parse_experiment_config(valid_document) + assert ErrorCode.INVALID_VALUE in issue_codes(caught.value) + + +@pytest.mark.parametrize("field", ["success_radius_m", "timeout_s"]) +@pytest.mark.parametrize("value", [0, -1.0, float("nan"), float("inf")]) +def test_positive_finite_task_limits_are_required( + valid_document: dict[str, Any], field: str, value: float +) -> None: + valid_document["experiment"]["episodes"][0]["task"][field] = value + with pytest.raises(ConfigError) as caught: + parse_experiment_config(valid_document) + assert ErrorCode.INVALID_VALUE in issue_codes(caught.value) + + +def test_frame_mismatch_and_non_map_frames_are_reported(valid_document: dict[str, Any]) -> None: + episode = valid_document["experiment"]["episodes"][0] + episode["initial_pose"]["frame_id"] = "odom" + + with pytest.raises(ConfigError) as caught: + parse_experiment_config(valid_document) + + assert ErrorCode.FRAME_MISMATCH in issue_codes(caught.value) + assert ErrorCode.UNSUPPORTED_FRAME in issue_codes(caught.value) + + +@pytest.mark.parametrize("value", [True, 1.5, 2**63, -(2**63) - 1]) +def test_seed_must_be_int64(valid_document: dict[str, Any], value: object) -> None: + valid_document["experiment"]["episodes"][0]["seed"] = value + with pytest.raises(ConfigError) as caught: + parse_experiment_config(valid_document) + assert issue_codes(caught.value) & {ErrorCode.TYPE_MISMATCH, ErrorCode.INVALID_VALUE} + + +@pytest.mark.parametrize("path", ["success_radius_m", "timeout_s"]) +def test_boolean_is_not_accepted_as_numeric_value( + valid_document: dict[str, Any], path: str +) -> None: + valid_document["experiment"]["episodes"][0]["task"][path] = True + with pytest.raises(ConfigError) as caught: + parse_experiment_config(valid_document) + assert ErrorCode.TYPE_MISMATCH in issue_codes(caught.value) + + +def test_unsupported_task_type_is_rejected(valid_document: dict[str, Any]) -> None: + valid_document["experiment"]["episodes"][0]["task"]["type"] = "coverage" + with pytest.raises(ConfigError) as caught: + parse_experiment_config(valid_document) + assert ErrorCode.INVALID_VALUE in issue_codes(caught.value) + + +def test_orientation_field_is_rejected_for_pointnav( + valid_document: dict[str, Any], +) -> None: + valid_document["experiment"]["episodes"][0]["task"]["goal"]["yaw"] = 1.0 + with pytest.raises(ConfigError) as caught: + parse_experiment_config(valid_document) + assert (ErrorCode.UNKNOWN_FIELD, "experiment.episodes[0].task.goal.yaw") in { + (issue.code, issue.path) for issue in caught.value.issues + } + + +@pytest.mark.parametrize("field", ["x", "y", "z", "roll", "pitch", "yaw"]) +@pytest.mark.parametrize("value", [float("nan"), float("inf"), float("-inf")]) +def test_initial_pose_requires_finite_position_and_orientation( + valid_document: dict[str, Any], field: str, value: float +) -> None: + valid_document["experiment"]["episodes"][0]["initial_pose"][field] = value + with pytest.raises(ConfigError) as caught: + parse_experiment_config(valid_document) + assert ErrorCode.INVALID_VALUE in issue_codes(caught.value) + + +def test_root_must_be_a_mapping() -> None: + with pytest.raises(ConfigError) as caught: + parse_experiment_config([]) + assert caught.value.issues[0].code is ErrorCode.TYPE_MISMATCH + assert caught.value.issues[0].path == "$" + + +def test_yaml_duplicate_key_is_rejected(tmp_path: Path) -> None: + path = tmp_path / "duplicate.yaml" + path.write_text("schema_version: 1\nschema_version: 1\n", encoding="utf-8") + with pytest.raises(ConfigError) as caught: + load_experiment_config(path) + assert caught.value.issues[0].code is ErrorCode.YAML_DUPLICATE_KEY + + +def test_invalid_yaml_is_structured(tmp_path: Path) -> None: + path = tmp_path / "invalid.yaml" + path.write_text("experiment: [\n", encoding="utf-8") + with pytest.raises(ConfigError) as caught: + load_experiment_config(path) + assert caught.value.issues[0].code is ErrorCode.YAML_SYNTAX + + +def test_missing_file_is_structured(tmp_path: Path) -> None: + path = tmp_path / "missing.yaml" + with pytest.raises(ConfigError) as caught: + load_experiment_config(path) + assert caught.value.issues[0].as_dict() == { + "code": "file_not_found", + "path": str(path), + "message": "configuration file does not exist", + } + + +def test_duplicate_id_path_keeps_original_index_when_prior_episode_is_invalid( + valid_document: dict[str, Any], +) -> None: + first = deepcopy(valid_document["experiment"]["episodes"][0]) + invalid = deepcopy(first) + invalid["episode_id"] = "" + duplicate = deepcopy(first) + valid_document["experiment"]["episodes"] = [first, invalid, duplicate] + + with pytest.raises(ConfigError) as caught: + parse_experiment_config(valid_document) + + duplicate_issue = next( + issue for issue in caught.value.issues if issue.code is ErrorCode.DUPLICATE_EPISODE_ID + ) + assert duplicate_issue.path == "experiment.episodes[2].episode_id" diff --git a/packages/rh_core/test/test_lifecycle.py b/packages/rh_core/test/test_lifecycle.py new file mode 100644 index 0000000..90bb815 --- /dev/null +++ b/packages/rh_core/test/test_lifecycle.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +from dataclasses import FrozenInstanceError + +import pytest + +from rh_core import ( + EpisodeLifecycle, + EpisodeState, + ErrorCode, + ExperimentLifecycle, + ExperimentState, + LifecycleError, + TerminationReason, +) + + +def assert_error_code(caught: pytest.ExceptionInfo[LifecycleError], code: ErrorCode) -> None: + assert caught.value.issues[0].code is code + + +def test_experiment_happy_path() -> None: + lifecycle = ExperimentLifecycle() + for target in ( + ExperimentState.STARTING, + ExperimentState.RUNNING, + ExperimentState.FINALIZING, + ExperimentState.FINISHED, + ): + lifecycle = lifecycle.transition(target) + assert lifecycle.state is ExperimentState.FINISHED + + +@pytest.mark.parametrize( + ("initial", "target"), + [ + (ExperimentState.CREATED, ExperimentState.RUNNING), + (ExperimentState.STARTING, ExperimentState.FINISHED), + (ExperimentState.RUNNING, ExperimentState.FINISHED), + (ExperimentState.FINALIZING, ExperimentState.RUNNING), + (ExperimentState.FINISHED, ExperimentState.STARTING), + (ExperimentState.FAILED, ExperimentState.STARTING), + ], +) +def test_illegal_experiment_transitions_are_rejected( + initial: ExperimentState, target: ExperimentState +) -> None: + with pytest.raises(LifecycleError) as caught: + ExperimentLifecycle(initial).transition(target) + assert_error_code(caught, ErrorCode.INVALID_TRANSITION) + + +@pytest.mark.parametrize( + "initial", + [ExperimentState.STARTING, ExperimentState.RUNNING, ExperimentState.FINALIZING], +) +def test_active_experiment_can_fail(initial: ExperimentState) -> None: + lifecycle = ExperimentLifecycle(initial).transition(ExperimentState.FAILED) + assert lifecycle.state is ExperimentState.FAILED + + +def test_episode_happy_path_increments_sequence() -> None: + lifecycle = EpisodeLifecycle("0000") + lifecycle = lifecycle.transition(EpisodeState.READY) + lifecycle = lifecycle.transition(EpisodeState.RUNNING) + lifecycle = lifecycle.transition( + EpisodeState.TERMINATING, + termination_reason=TerminationReason.SUCCESS, + ) + lifecycle = lifecycle.transition(EpisodeState.FINISHED) + + assert lifecycle.sequence == 4 + assert lifecycle.state is EpisodeState.FINISHED + assert lifecycle.termination_reason is TerminationReason.SUCCESS + + +@pytest.mark.parametrize("initial", [EpisodeState.PREPARING, EpisodeState.READY]) +def test_episode_can_terminate_before_running(initial: EpisodeState) -> None: + lifecycle = EpisodeLifecycle("0000", state=initial) + lifecycle = lifecycle.transition( + EpisodeState.TERMINATING, + termination_reason=TerminationReason.INVALID_TASK, + ) + assert lifecycle.termination_reason is TerminationReason.INVALID_TASK + + +def test_entering_terminating_requires_reason() -> None: + with pytest.raises(LifecycleError) as caught: + EpisodeLifecycle("0000", state=EpisodeState.RUNNING).transition( + EpisodeState.TERMINATING + ) + assert_error_code(caught, ErrorCode.TERMINATION_REQUIRED) + + +def test_reason_cannot_be_committed_before_terminating() -> None: + with pytest.raises(LifecycleError) as caught: + EpisodeLifecycle("0000").transition( + EpisodeState.READY, + termination_reason=TerminationReason.SUCCESS, + ) + assert_error_code(caught, ErrorCode.UNEXPECTED_TERMINATION_REASON) + + +def test_reason_cannot_be_committed_twice() -> None: + lifecycle = EpisodeLifecycle("0000", state=EpisodeState.RUNNING).transition( + EpisodeState.TERMINATING, + termination_reason=TerminationReason.TIMEOUT, + ) + with pytest.raises(LifecycleError) as caught: + lifecycle.transition( + EpisodeState.FINISHED, + termination_reason=TerminationReason.SUCCESS, + ) + assert_error_code(caught, ErrorCode.TERMINATION_ALREADY_COMMITTED) + + +@pytest.mark.parametrize( + ("initial", "target"), + [ + (EpisodeState.PREPARING, EpisodeState.RUNNING), + (EpisodeState.READY, EpisodeState.FINISHED), + (EpisodeState.RUNNING, EpisodeState.READY), + (EpisodeState.TERMINATING, EpisodeState.RUNNING), + (EpisodeState.FINISHED, EpisodeState.PREPARING), + ], +) +def test_illegal_episode_transitions_are_rejected( + initial: EpisodeState, target: EpisodeState +) -> None: + reason = ( + TerminationReason.FAILURE + if initial in {EpisodeState.TERMINATING, EpisodeState.FINISHED} + else TerminationReason.NONE + ) + with pytest.raises(LifecycleError) as caught: + EpisodeLifecycle("0000", state=initial, termination_reason=reason).transition(target) + assert_error_code(caught, ErrorCode.INVALID_TRANSITION) + + +def test_lifecycle_snapshots_are_immutable() -> None: + lifecycle = EpisodeLifecycle("0000") + with pytest.raises(FrozenInstanceError): + lifecycle.sequence = 2 # type: ignore[misc] + + +def test_raw_integer_target_is_rejected() -> None: + with pytest.raises(LifecycleError) as caught: + EpisodeLifecycle("0000").transition(1) # type: ignore[arg-type] + assert_error_code(caught, ErrorCode.TYPE_MISMATCH) + + +@pytest.mark.parametrize("state", [EpisodeState.TERMINATING, EpisodeState.FINISHED]) +def test_terminal_snapshot_requires_reason(state: EpisodeState) -> None: + with pytest.raises(LifecycleError) as caught: + EpisodeLifecycle("0000", state=state) + assert_error_code(caught, ErrorCode.TERMINATION_REQUIRED) + + +def test_non_terminal_snapshot_rejects_committed_reason() -> None: + with pytest.raises(LifecycleError) as caught: + EpisodeLifecycle("0000", termination_reason=TerminationReason.SUCCESS) + assert_error_code(caught, ErrorCode.UNEXPECTED_TERMINATION_REASON) + + +@pytest.mark.parametrize("sequence", [-1, 2**64, True, 1.5]) +def test_sequence_must_be_uint64(sequence: object) -> None: + with pytest.raises(LifecycleError) as caught: + EpisodeLifecycle("0000", sequence=sequence) # type: ignore[arg-type] + issue = caught.value.issues[0] + assert issue.code in {ErrorCode.TYPE_MISMATCH, ErrorCode.INVALID_VALUE} + + +def test_sequence_cannot_overflow() -> None: + lifecycle = EpisodeLifecycle("0000", sequence=2**64 - 1) + with pytest.raises(LifecycleError) as caught: + lifecycle.transition(EpisodeState.READY) + assert_error_code(caught, ErrorCode.INVALID_VALUE) diff --git a/packages/rh_core/test/test_termination.py b/packages/rh_core/test/test_termination.py new file mode 100644 index 0000000..efe57ce --- /dev/null +++ b/packages/rh_core/test/test_termination.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import pytest + +from rh_core import TerminationReason, resolve_termination_reason + + +def test_no_candidate_resolves_to_none() -> None: + assert resolve_termination_reason([]) is TerminationReason.NONE + assert resolve_termination_reason([TerminationReason.NONE]) is TerminationReason.NONE + + +@pytest.mark.parametrize( + ("higher", "lower"), + [ + (TerminationReason.ENV_ERROR, TerminationReason.AGENT_ERROR), + (TerminationReason.AGENT_ERROR, TerminationReason.FAILURE), + (TerminationReason.FAILURE, TerminationReason.INVALID_TASK), + (TerminationReason.INVALID_TASK, TerminationReason.ABORTED), + (TerminationReason.ABORTED, TerminationReason.SUCCESS), + (TerminationReason.SUCCESS, TerminationReason.TIMEOUT), + ], +) +def test_priority_is_deterministic( + higher: TerminationReason, lower: TerminationReason +) -> None: + assert resolve_termination_reason([lower, higher]) is higher + assert resolve_termination_reason([higher, lower]) is higher + + +def test_duplicate_candidates_do_not_change_result() -> None: + assert resolve_termination_reason( + [TerminationReason.TIMEOUT, TerminationReason.SUCCESS, TerminationReason.SUCCESS] + ) is TerminationReason.SUCCESS + + +def test_raw_integer_candidate_is_rejected() -> None: + with pytest.raises(TypeError, match="TerminationReason"): + resolve_termination_reason([1]) # type: ignore[list-item] diff --git a/packages/rh_interfaces/README.md b/packages/rh_interfaces/README.md index 4385aef..74d2c9e 100644 --- a/packages/rh_interfaces/README.md +++ b/packages/rh_interfaces/README.md @@ -27,8 +27,15 @@ does not wrap those observations in a generic message. - Identifiers and request IDs are opaque non-empty strings. Validation belongs to the core/runtime layers rather than the generated interface classes. -- `PointNavTask.start`, `PointNavTask.goal`, and `ResetEnv.start` use the `map` - frame in the MVP. Task start and goal frames must match. +- `PointNavTask.goal` and `ResetEnv.initial_pose` use the `map` frame in the MVP. +- `ResetEnv.initial_pose` is the complete 3D robot initialization pose. Its ROS + orientation is a normalized quaternion; adapters for planar sources explicitly + supply zero `z`, roll, and pitch before conversion. +- PointNav describes a 3D target position and does not impose a target + orientation. A future PoseNav task must introduce explicit pose semantics + rather than reinterpreting the PointNav goal. +- PointNav success distance is the 3D Euclidean distance in metres from the + configured robot tracking point (MVP: the `base_link` origin) to the goal. - Durations use seconds, distances use metres, and Episode metrics use simulation time and the `map` frame. - `ComponentStatus.stamp` records the last transition time. Repeated status diff --git a/packages/rh_interfaces/msg/PointNavTask.msg b/packages/rh_interfaces/msg/PointNavTask.msg index a1ebf54..d7d9423 100644 --- a/packages/rh_interfaces/msg/PointNavTask.msg +++ b/packages/rh_interfaces/msg/PointNavTask.msg @@ -1,12 +1,13 @@ -# Immutable PointNav task snapshot. start and goal must use the same frame. -# RoboHarness MVP requires that frame to be "map". +# Immutable PointNav task snapshot. PointNav specifies a 3D target position, +# not a desired target orientation. A future PoseNav task must use a distinct +# interface. The MVP requires the goal frame to be "map". string experiment_id string episode_id -geometry_msgs/PoseStamped start -geometry_msgs/PoseStamped goal +geometry_msgs/PointStamped goal -# Goal is reached when planar distance is less than or equal to this value. +# Goal is reached when the 3D Euclidean distance from the configured robot +# tracking point (MVP: base_link origin) is less than or equal to this value. float64 success_radius_m # Episode timeout measured in simulation seconds. diff --git a/packages/rh_interfaces/srv/ResetEnv.srv b/packages/rh_interfaces/srv/ResetEnv.srv index b839c98..5239a9c 100644 --- a/packages/rh_interfaces/srv/ResetEnv.srv +++ b/packages/rh_interfaces/srv/ResetEnv.srv @@ -3,8 +3,9 @@ string request_id string experiment_id string episode_id -# Requested robot root pose. RoboHarness MVP requires the "map" frame. -geometry_msgs/PoseStamped start +# Requested robot root pose. RoboHarness MVP requires the "map" frame. The +# quaternion must be finite and normalized by the caller/adapter. +geometry_msgs/PoseStamped initial_pose # Reproducibility seed supplied to the environment backend. int64 seed diff --git a/pyproject.toml b/pyproject.toml index 3614226..3f43382 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,9 @@ extend-exclude = [".build"] [tool.ruff.lint] select = ["E", "F", "I", "UP"] +[tool.ruff.lint.isort] +known-first-party = ["rh_core"] + [tool.pytest.ini_options] addopts = "-ra" testpaths = ["tests"] diff --git a/tests/contracts/README.md b/tests/contracts/README.md index 8aed671..fc0e8e7 100644 --- a/tests/contracts/README.md +++ b/tests/contracts/README.md @@ -4,3 +4,8 @@ This domain contains black-box tests for public RoboHarness contracts. The `rh_interfaces_contract_tests` package verifies generated C++ and Python ROS 2 types, stable field/constant definitions, serialization round trips, and the interface package dependency boundary. + +`rh_core_contract_tests` verifies that the ROS-independent domain enums remain +aligned with the wire constants, that the initial 3D pose and PointNav goal map +to their wire contracts without dimensional loss, and that core source and +manifest dependencies remain ROS-free. diff --git a/tests/contracts/rh_core_contract_tests/CMakeLists.txt b/tests/contracts/rh_core_contract_tests/CMakeLists.txt new file mode 100644 index 0000000..eb621e6 --- /dev/null +++ b/tests/contracts/rh_core_contract_tests/CMakeLists.txt @@ -0,0 +1,11 @@ +cmake_minimum_required(VERSION 3.8) +project(rh_core_contract_tests) + +find_package(ament_cmake REQUIRED) +find_package(ament_cmake_pytest REQUIRED) + +ament_add_pytest_test(test_core_interface_alignment test/test_core_interface_alignment.py + TIMEOUT 30 +) + +ament_package() diff --git a/tests/contracts/rh_core_contract_tests/package.xml b/tests/contracts/rh_core_contract_tests/package.xml new file mode 100644 index 0000000..1e11290 --- /dev/null +++ b/tests/contracts/rh_core_contract_tests/package.xml @@ -0,0 +1,21 @@ + + + + rh_core_contract_tests + 0.0.0 + Dependency and ROS-interface alignment tests for rh_core. + Staaaaaaaaar + + NOASSERTION + + ament_cmake + + ament_cmake_pytest + ament_index_python + rh_core + rh_interfaces + + + ament_cmake + + diff --git a/tests/contracts/rh_core_contract_tests/test/test_core_interface_alignment.py b/tests/contracts/rh_core_contract_tests/test/test_core_interface_alignment.py new file mode 100644 index 0000000..9d87222 --- /dev/null +++ b/tests/contracts/rh_core_contract_tests/test/test_core_interface_alignment.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import ast +import xml.etree.ElementTree as ET +from pathlib import Path + +from ament_index_python.packages import get_package_share_directory +from rh_interfaces.msg import EpisodeState as EpisodeStateMessage +from rh_interfaces.msg import PointNavTask +from rh_interfaces.srv import ResetEnv + +import rh_core +from rh_core import ( + EpisodeSpec, + EpisodeState, + Point3D, + PointNavTaskSpec, + Pose3D, + TerminationReason, +) + + +def test_episode_lifecycle_values_match_wire_contract() -> None: + for name in ("PREPARING", "READY", "RUNNING", "TERMINATING", "FINISHED"): + assert getattr(EpisodeState, name).value == getattr(EpisodeStateMessage, name) + + +def test_termination_values_match_wire_contract() -> None: + for name in ( + "NONE", + "SUCCESS", + "TIMEOUT", + "ABORTED", + "FAILURE", + "ENV_ERROR", + "AGENT_ERROR", + "INVALID_TASK", + ): + assert getattr(TerminationReason, name).value == getattr(EpisodeStateMessage, name) + + +def test_pointnav_model_maps_losslessly_to_pr2_message() -> None: + task = PointNavTaskSpec( + goal=Point3D(frame_id="map", x=8.0, y=4.0, z=1.5), + success_radius_m=0.5, + timeout_s=120.0, + ) + episode = EpisodeSpec( + episode_id="0000", + scenario="warehouse_default", + initial_pose=Pose3D( + frame_id="map", + x=1.0, + y=2.0, + z=0.4, + roll=0.0, + pitch=0.0, + yaw=0.5, + ), + task=task, + seed=42, + ) + + message = PointNavTask() + message.experiment_id = "experiment-runtime-id" + message.episode_id = episode.episode_id + message.goal.header.frame_id = task.goal.frame_id + message.goal.point.x = task.goal.x + message.goal.point.y = task.goal.y + message.goal.point.z = task.goal.z + message.success_radius_m = task.success_radius_m + message.timeout_s = task.timeout_s + message.seed = episode.seed + + assert message.episode_id == "0000" + assert message.goal.point.y == 4.0 + assert message.goal.point.z == 1.5 + assert message.success_radius_m == 0.5 + assert message.timeout_s == 120.0 + assert message.seed == 42 + + reset = ResetEnv.Request() + reset.request_id = "reset-0000" + reset.experiment_id = message.experiment_id + reset.episode_id = episode.episode_id + reset.initial_pose.header.frame_id = episode.initial_pose.frame_id + reset.initial_pose.pose.position.x = episode.initial_pose.x + reset.initial_pose.pose.position.y = episode.initial_pose.y + reset.initial_pose.pose.position.z = episode.initial_pose.z + + assert reset.initial_pose.header.frame_id == "map" + assert reset.initial_pose.pose.position.z == 0.4 + + +def test_core_manifest_has_no_ros_or_implementation_dependencies() -> None: + manifest = Path(get_package_share_directory("rh_core")) / "package.xml" + root = ET.parse(manifest).getroot() + dependency_tags = { + "buildtool_depend", + "build_depend", + "build_export_depend", + "depend", + "exec_depend", + } + dependencies = { + element.text.strip() + for element in root + if element.tag in dependency_tags and element.text is not None + } + assert dependencies == {"ament_python", "python3-yaml"} + + +def test_core_source_does_not_import_ros_or_implementations() -> None: + forbidden_roots = { + "rclpy", + "rh_interfaces", + "rh_ros", + "rh_experiment", + "isaacsim", + } + source_directory = Path(rh_core.__file__).resolve().parent + violations: list[str] = [] + + for source_path in sorted(source_directory.glob("*.py")): + tree = ast.parse(source_path.read_text(encoding="utf-8"), filename=str(source_path)) + for node in ast.walk(tree): + imported_roots: list[str] = [] + if isinstance(node, ast.Import): + imported_roots = [alias.name.split(".", maxsplit=1)[0] for alias in node.names] + elif isinstance(node, ast.ImportFrom) and node.module: + imported_roots = [node.module.split(".", maxsplit=1)[0]] + for imported_root in imported_roots: + if imported_root in forbidden_roots: + violations.append(f"{source_path.name}:{node.lineno}: {imported_root}") + + assert violations == [] diff --git a/tests/contracts/rh_interfaces_contract_tests/test/test_cpp_typesupport.cpp b/tests/contracts/rh_interfaces_contract_tests/test/test_cpp_typesupport.cpp index baec43c..d9faf07 100644 --- a/tests/contracts/rh_interfaces_contract_tests/test/test_cpp_typesupport.cpp +++ b/tests/contracts/rh_interfaces_contract_tests/test/test_cpp_typesupport.cpp @@ -89,15 +89,22 @@ TEST(RhInterfacesContract, GeneratedCppTypesAreUsable) EXPECT_EQ(state.sequence, 4u); rh_interfaces::msg::PointNavTask task; - task.start.header.frame_id = "map"; task.goal.header.frame_id = "map"; + task.goal.point.y = 4.0; + task.goal.point.z = 1.5; task.success_radius_m = 0.5; + EXPECT_DOUBLE_EQ(task.goal.point.y, 4.0); + EXPECT_DOUBLE_EQ(task.goal.point.z, 1.5); EXPECT_DOUBLE_EQ(task.success_radius_m, 0.5); rh_interfaces::srv::ResetEnv::Request reset_request; reset_request.request_id = "request-1"; - reset_request.start.header.frame_id = "map"; - EXPECT_EQ(reset_request.start.header.frame_id, "map"); + reset_request.initial_pose.header.frame_id = "map"; + reset_request.initial_pose.pose.position.x = 1.0; + reset_request.initial_pose.pose.orientation.w = 1.0; + EXPECT_EQ(reset_request.initial_pose.header.frame_id, "map"); + EXPECT_DOUBLE_EQ(reset_request.initial_pose.pose.position.x, 1.0); + EXPECT_DOUBLE_EQ(reset_request.initial_pose.pose.orientation.w, 1.0); rh_interfaces::srv::ResetAgent::Response reset_response; reset_response.success = true; diff --git a/tests/contracts/rh_interfaces_contract_tests/test/test_python_contract.py b/tests/contracts/rh_interfaces_contract_tests/test/test_python_contract.py index 183269c..ab0f174 100644 --- a/tests/contracts/rh_interfaces_contract_tests/test/test_python_contract.py +++ b/tests/contracts/rh_interfaces_contract_tests/test/test_python_contract.py @@ -78,8 +78,7 @@ def test_stable_numeric_constants() -> None: { "experiment_id": "string", "episode_id": "string", - "start": "geometry_msgs/PoseStamped", - "goal": "geometry_msgs/PoseStamped", + "goal": "geometry_msgs/PointStamped", "success_radius_m": "double", "timeout_s": "double", "seed": "int64", @@ -104,7 +103,7 @@ def test_stable_numeric_constants() -> None: "request_id": "string", "experiment_id": "string", "episode_id": "string", - "start": "geometry_msgs/PoseStamped", + "initial_pose": "geometry_msgs/PoseStamped", "seed": "int64", }, ),