"""Web-facing session state machine for current sim2real deployment.""" from __future__ import annotations import threading import time import traceback import queue from collections import deque from dataclasses import asdict, dataclass, field from enum import Enum from pathlib import Path from typing import Any, Callable, Dict, Optional import numpy as np from tools.logger import LogBundle from input_dev.remote_uart import RemoteCommandSource class Stage(str, Enum): DISCONNECTED = "DISCONNECTED" CONNECTING = "CONNECTING" CONNECTED = "CONNECTED" ENABLING = "ENABLING" ENABLED = "ENABLED" JOINT_TEST = "JOINT_TEST" CALIBRATING = "CALIBRATING" STARTING_UP = "STARTING_UP" STAND_HOLD = "STAND_HOLD" RUNTIME = "RUNTIME" FAULTED = "FAULTED" ESTOPPED = "ESTOPPED" @dataclass class SessionStatus: stage: str = Stage.DISCONNECTED.value detail: str = "" last_event: str = "" busy: bool = False cmd: list = field(default_factory=lambda: [0.0, 0.0, 0.0]) input_mode: str = "WEB" remote_takeover_active: bool = False remote_takeover_allowed: bool = False remote_soft_estop: bool = False remote_status: Dict[str, Any] = field(default_factory=dict) last_state: Optional[Dict[str, Any]] = None log_dir: Optional[str] = None fault_reason: Optional[str] = None last_error: Optional[str] = None last_traceback: Optional[str] = None diagnostics: Dict[str, Any] = field(default_factory=dict) class RobotSession: JOINT_LABELS = LogBundle.JOINT_LABELS DEFAULT_ACTION_SCALE = np.array( [ 0.125, 0.25, 0.25, 0.125, 0.25, 0.25, 0.125, 0.25, 0.25, 0.125, 0.25, 0.25, 5.0, 5.0, 5.0, 5.0, ], dtype=np.float32, ) def __init__( self, cfg: Dict[str, Any], cfg_path: Path, driver_factory_real: Callable, driver_factory_dry: Callable, ): self.cfg = cfg self.cfg_path = cfg_path self.driver_factory_real = driver_factory_real self.driver_factory_dry = driver_factory_dry self.lock = threading.RLock() self.status = SessionStatus() self.io = None self.runner = None self.guard = None self.safety = None self.initializer = None self.stand_balance = None self.logger = None self._stop_runtime = threading.Event() self._cmd_lock = threading.Lock() self._cmd = np.zeros(3, dtype=np.float32) self._filtered_cmd = np.zeros(3, dtype=np.float32) self._last_raw_cmd = np.zeros(3, dtype=np.float32) self._estop = False self._busy_thread: Optional[threading.Thread] = None self._stand_target = None self._remote_source: Optional[RemoteCommandSource] = None self._remote_takeover_active: bool = False self._remote_soft_estop: bool = False self._remote_poll_error_count: int = 0 self._remote_last_error: Optional[str] = None self._stop_poll = threading.Event() self._poll_thread: Optional[threading.Thread] = None self._event_listeners: list = [] self._recent_events = deque(maxlen=300) self._listener_lock = threading.Lock() self._last_runtime_ts: float = 0.0 self._last_poll_ts: float = 0.0 self._last_command_ts: float = 0.0 self._runtime_loop_count: int = 0 self._runtime_overrun_count: int = 0 self._runtime_overrun_max_ms: float = 0.0 self._runtime_policy_stale_count: int = 0 self._runtime_policy_stale_max_ms: float = 0.0 self._last_loop_profile: Dict[str, float] = {} self._latest_target_info: Dict[str, Any] = {} self._latest_target: Optional[np.ndarray] = None self._poll_error_count: int = 0 self._api_error_count: int = 0 self._disconnecting: bool = False self._target_queue = queue.Queue(maxsize=1) self._state_lock = threading.Lock() self._latest_hardware_state = None self._policy_exception = None self._motor_exception = None self._status_exception = None self._runtime_error_event = threading.Event() def _policy_action_scale(self) -> np.ndarray: values = self.cfg.get("policy", {}).get("action_scale", self.DEFAULT_ACTION_SCALE.tolist()) action_scale = np.asarray(values, dtype=np.float32) if action_scale.shape != (16,): raise ValueError(f"policy.action_scale must be 16 values, got shape {action_scale.shape}") return action_scale def _diag_snapshot(self) -> Dict[str, Any]: return { "runtime_active": bool(self.status.stage == Stage.RUNTIME.value and not self._stop_runtime.is_set()), "runtime_loop_count": int(self._runtime_loop_count), "runtime_overrun_count": int(self._runtime_overrun_count), "runtime_overrun_max_ms": round(float(self._runtime_overrun_max_ms), 3), "runtime_policy_stale_count": int(self._runtime_policy_stale_count), "runtime_policy_stale_max_ms": round(float(self._runtime_policy_stale_max_ms), 3), "last_loop_profile": dict(self._last_loop_profile), "latest_target": self._latest_target_snapshot(), "filtered_cmd": self._filtered_cmd.tolist(), "raw_cmd": self._last_raw_cmd.tolist(), "last_runtime_age_s": round(time.time() - self._last_runtime_ts, 3) if self._last_runtime_ts else None, "last_poll_age_s": round(time.time() - self._last_poll_ts, 3) if self._last_poll_ts else None, "last_command_age_s": round(time.time() - self._last_command_ts, 3) if self._last_command_ts else None, "poll_thread_alive": bool(self._poll_thread and self._poll_thread.is_alive()), "busy_thread_alive": bool(self._busy_thread and self._busy_thread.is_alive()), "estop": bool(self._estop), "poll_error_count": int(self._poll_error_count), "api_error_count": int(self._api_error_count), "remote_poll_error_count": int(self._remote_poll_error_count), "remote_last_error": self._remote_last_error, "remote_takeover_active": bool(self._remote_takeover_active), "remote_soft_estop": bool(self._remote_soft_estop), "zero_cmd_suppression": ( bool(getattr(self.runner, "enable_zero_cmd_suppression", False)) if self.runner is not None else None ), "policy_path": str(getattr(self.runner, "policy_path", "")) if self.runner is not None else None, "stand_balance": self._stand_balance_snapshot(), } def _stand_balance_snapshot(self) -> Dict[str, Any]: if self.stand_balance is None: return {"enabled": False} debug = self.stand_balance.last_debug return { "enabled": bool(self.stand_balance.enabled), "pitch_compensation_enabled": bool(getattr(debug, "pitch_compensation_enabled", False)), "roll_deg": float(np.degrees(debug.roll)), "pitch_deg": float(np.degrees(debug.pitch)), "roll_rate_deg_s": float(np.degrees(debug.roll_rate)), "pitch_rate_deg_s": float(np.degrees(debug.pitch_rate)), "hip_base": float(debug.hip_base), "knee_base": float(debug.knee_base), "roll_corr": float(debug.roll_corr), "pitch_corr": float(debug.pitch_corr), "stable": bool(debug.stable), } def _policy_release_cfg(self) -> Dict[str, float]: policy_cfg = self.cfg.get("policy", {}) return { "command_hold_s": max(float(policy_cfg.get("release_command_hold_s", 0.12)), 0.0), "posture_max_err": max(float(policy_cfg.get("release_posture_max_err", 0.35)), 0.0), "target_blend_s": max(float(policy_cfg.get("release_target_blend_s", 0.30)), 1e-3), } def _compute_release_metrics(self, state: Dict[str, Any], hold_target: np.ndarray, cmd: np.ndarray) -> Dict[str, float]: joint_pos = np.asarray(state["joint_pos"], dtype=np.float32) default_pose = np.asarray(self.runner.default_dof_pos, dtype=np.float32) hold_target = np.asarray(hold_target, dtype=np.float32) planar_cmd, yaw_cmd = self.runner.command_activation_metrics(cmd) return { "planar_cmd": float(planar_cmd), "yaw_cmd": float(yaw_cmd), "max_hold_err": float(np.max(np.abs(joint_pos[:12] - hold_target[:12]))), "max_default_err": float(np.max(np.abs(joint_pos[:12] - default_pose[:12]))), "max_hold_default_gap": float(np.max(np.abs(hold_target[:12] - default_pose[:12]))), } def _blend_runtime_target( self, hold_target: np.ndarray, policy_target: np.ndarray, release_alpha: float, target_blend_s: float, control_dt: float, ) -> np.ndarray: blend = min(1.0, release_alpha * (self.runner.command_release_s / max(target_blend_s, control_dt))) return ((1.0 - blend) * hold_target + blend * policy_target).astype(np.float32) def _compute_target_error_metrics( self, state: Dict[str, Any], hold_target: np.ndarray, policy_target: np.ndarray, ) -> Dict[str, float]: joint_pos = np.asarray(state["joint_pos"], dtype=np.float32) hold_target = np.asarray(hold_target, dtype=np.float32) policy_target = np.asarray(policy_target, dtype=np.float32) return { "hold_target_max_err": float(np.max(np.abs(joint_pos[:12] - hold_target[:12]))), "policy_target_max_err": float(np.max(np.abs(joint_pos[:12] - policy_target[:12]))), "hold_policy_max_gap": float(np.max(np.abs(hold_target[:12] - policy_target[:12]))), } def _record_loop_profile(self, profile: Dict[str, float]) -> Dict[str, float]: compact = {k: round(float(v), 3) for k, v in profile.items()} self._last_loop_profile = compact return compact def _note_overrun(self, over_ms: float) -> None: self._runtime_overrun_count += 1 self._runtime_overrun_max_ms = max(self._runtime_overrun_max_ms, float(over_ms)) def _note_policy_stale(self, age_ms: float) -> None: self._runtime_policy_stale_count += 1 self._runtime_policy_stale_max_ms = max(self._runtime_policy_stale_max_ms, float(age_ms)) def _build_last_state( self, *, state: Dict[str, Any], target: np.ndarray, raw: np.ndarray, projected_gravity: np.ndarray, cmd: np.ndarray, loop_dt_ms: float, phase: str, safety_level: int = 0, guard_level: int = 0, safety_reason: str = "", guard_reason: str = "", zero_command: bool = True, runtime_released: bool = False, release_alpha: float = 0.0, release_active_hold_s: float = 0.0, release_max_hold_err: float = 0.0, loop_profile: Optional[Dict[str, float]] = None, extra: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: motor_diag = state.get("motor_stale", {}) or {} extra_dict = extra or {} raw_cmd_list = extra_dict.get("raw_cmd", [0.0, 0.0, 0.0]) filtered_cmd_list = cmd.tolist() payload: Dict[str, Any] = { "joint_pos": state["joint_pos"].tolist(), "joint_vel": state["joint_vel"].tolist(), "joint_torque": state["joint_torque"].tolist(), "target": target.tolist(), "raw": raw.tolist(), "gyro": state["imu_gyro"].tolist(), "proj_gravity": projected_gravity.tolist(), "imu_age_ms": float(state["imu_age_ms"]), "imu_fresh": bool(state.get("imu_fresh", False)), "odom": state.get("odom"), "loop_dt_ms": float(loop_dt_ms), "loop_profile": loop_profile or {}, "holdover_total": int(self.io.hw.holdover_total), "safety_level": int(safety_level), "guard_level": int(guard_level), "phase": phase, "cmd": cmd.tolist(), "raw_cmd": raw_cmd_list, "filtered_cmd": filtered_cmd_list, "safety_reason": safety_reason, "guard_reason": guard_reason, "zero_command": bool(zero_command), "runtime_released": bool(runtime_released), "release_alpha": float(release_alpha), "release_active_hold_s": float(release_active_hold_s), "release_max_hold_err": float(release_max_hold_err), "per_motor_stale": motor_diag.get("per_motor_stale", [0] * 16), "motor_fresh_count": int(motor_diag.get("fresh_count", 16)), "motor_fresh_by_update_count": int(motor_diag.get("fresh_by_update_count", 0)), "motor_fresh_by_value_change": int(motor_diag.get("fresh_by_value_change", 0)), "motor_update_counts": motor_diag.get("update_counts", [0] * 16), "motor_temperatures": motor_diag.get("temperatures", [0.0] * 16), "motor_fault_codes": motor_diag.get("fault_codes", [0] * 16), "motor_mode_states": motor_diag.get("mode_states", [0] * 16), "latest_target": self._latest_target_snapshot(), } if extra: payload.update(extra) return payload def _signal_stats(self, obs: Optional[np.ndarray], raw: Optional[np.ndarray], scaled: Optional[np.ndarray]) -> Dict[str, float]: stats: Dict[str, float] = {} if obs is not None and np.asarray(obs).size: obs_arr = np.asarray(obs, dtype=np.float32) stats.update( { "obs_min": float(np.min(obs_arr)), "obs_max": float(np.max(obs_arr)), "obs_abs_max": float(np.max(np.abs(obs_arr))), } ) if raw is not None and np.asarray(raw).size: raw_arr = np.asarray(raw, dtype=np.float32) stats.update( { "raw_min": float(np.min(raw_arr)), "raw_max": float(np.max(raw_arr)), "raw_abs_max": float(np.max(np.abs(raw_arr))), } ) if scaled is not None and np.asarray(scaled).size: scaled_arr = np.asarray(scaled, dtype=np.float32) stats.update( { "scaled_min": float(np.min(scaled_arr)), "scaled_max": float(np.max(scaled_arr)), "scaled_abs_max": float(np.max(np.abs(scaled_arr))), } ) return stats def _update_latest_target(self, target: np.ndarray, source: str) -> None: target = np.asarray(target, dtype=np.float32) now = time.time() delta_max = 0.0 if self._latest_target is not None and self._latest_target.shape == target.shape: delta_max = float(np.max(np.abs(target - self._latest_target))) self._latest_target = target.copy() self._latest_target_info = { "source": source, "t": now, "delta_max": delta_max, "target_abs_max": float(np.max(np.abs(target))) if target.size else 0.0, } def _latest_target_snapshot(self) -> Dict[str, Any]: if not self._latest_target_info: return {} snap = dict(self._latest_target_info) snap["age_ms"] = (time.time() - float(snap.get("t", time.time()))) * 1000.0 return snap def _filter_command(self, raw_cmd: np.ndarray, dt: float) -> np.ndarray: raw_cmd = np.asarray(raw_cmd, dtype=np.float32) self._last_raw_cmd = raw_cmd.copy() cfg = self.cfg.get("command_filter", {}) or {} if not bool(cfg.get("enabled", False)): self._filtered_cmd = raw_cmd.copy() return raw_cmd limits = np.array( [ float(cfg.get("max_vx_acc", 1.0)), float(cfg.get("max_vy_acc", 1.0)), float(cfg.get("max_yaw_acc", 1.5)), ], dtype=np.float32, ) max_delta = np.maximum(limits * max(float(dt), 1e-3), 0.0) delta = np.clip(raw_cmd - self._filtered_cmd, -max_delta, max_delta) self._filtered_cmd = (self._filtered_cmd + delta).astype(np.float32) return self._filtered_cmd.copy() def _remote_cfg(self) -> Dict[str, Any]: return dict(self.cfg.get("remote", {}) or {}) def _build_remote_source(self) -> Optional[RemoteCommandSource]: remote_cfg = self._remote_cfg() port = str(remote_cfg.get("port") or "").strip() if not remote_cfg.get("enabled", False) or not port: return None return RemoteCommandSource( port=port, baudrate=int(remote_cfg.get("baudrate", 100000)), timeout=float(remote_cfg.get("timeout", 0.02)), axis_deadzone=int(remote_cfg.get("axis_deadzone", 50)), active_threshold=int(remote_cfg.get("active_threshold", 50)), axis_full_scale=float(remote_cfg.get("axis_full_scale", 660.0)), max_vx=float(remote_cfg.get("max_vx", self.cfg["controller"]["max_vx"])), max_vy=float(remote_cfg.get("max_vy", self.cfg["controller"]["max_vy"])), max_yaw=float(remote_cfg.get("max_yaw_rate", self.cfg["controller"]["max_yaw_rate"])), invert_vx=bool(remote_cfg.get("invert_vx", False)), invert_vy=bool(remote_cfg.get("invert_vy", False)), invert_yaw=bool(remote_cfg.get("invert_yaw", False)), ) def _remote_takeover_allowed(self) -> bool: return bool( self.status.stage == Stage.RUNTIME.value and self._remote_source is not None ) def _refresh_input_status(self) -> None: remote_status = {} if self._remote_source is not None: remote_status = self._remote_source.get_status() remote_status["available"] = True remote_status["last_error"] = self._remote_last_error else: remote_status = {"available": False, "last_error": self._remote_last_error} self.status.input_mode = "REMOTE" if self._remote_takeover_active else "WEB" self.status.remote_takeover_active = bool(self._remote_takeover_active) self.status.remote_takeover_allowed = bool(self._remote_takeover_allowed()) self.status.remote_soft_estop = bool(self._remote_soft_estop) self.status.remote_status = remote_status def _poll_remote(self) -> None: if self._remote_source is None: self._remote_soft_estop = False self._refresh_input_status() return try: remote_state = self._remote_source.poll() self._remote_last_error = None self._remote_soft_estop = bool(remote_state.estop_requested) except Exception as exc: self._remote_poll_error_count += 1 self._remote_last_error = f"{type(exc).__name__}: {exc}" self._remote_soft_estop = False if self._remote_poll_error_count <= 3 or self._remote_poll_error_count % 20 == 0: self._broadcast({"kind": "REMOTE_POLL_ERROR", "error": self._remote_last_error}) self._refresh_input_status() def _update_diag_locked(self) -> None: self._refresh_input_status() self.status.diagnostics = self._diag_snapshot() def note_api_error(self) -> None: with self.lock: self._api_error_count += 1 self._update_diag_locked() def _set_fault(self, exc: Exception, tb: str) -> None: error_text = f"{type(exc).__name__}: {exc}" with self.lock: self.status.fault_reason = error_text self.status.last_error = error_text self.status.last_traceback = tb self._update_diag_locked() def get_status(self) -> Dict[str, Any]: with self.lock: self._update_diag_locked() return asdict(self.status) def get_debug_snapshot(self) -> Dict[str, Any]: return {"status": self.get_status(), "recent_events": list(self._recent_events)} def _set(self, **kwargs): with self.lock: for key, value in kwargs.items(): setattr(self.status, key, value) self._update_diag_locked() snapshot = asdict(self.status) self._broadcast({"kind": "STATUS", **snapshot}) def _set_stage(self, stage: Stage, detail: str = ""): self._set(stage=stage.value, detail=detail) def _build_action_diag( self, *, state: Dict[str, Any], raw: np.ndarray, scaled: np.ndarray, tentative: np.ndarray, cmd: np.ndarray, zero_command: bool, runtime_released: bool, release_alpha: float, safety_details: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: details = dict(safety_details or {}) joint_indices = list(details.get("joint_indices", [])) joint_pos = np.asarray(state["joint_pos"], dtype=np.float32) default_pose = np.asarray(self.runner.default_dof_pos, dtype=np.float32) pos_err = tentative - joint_pos leg_offset = tentative[:12] - default_pose[:12] diag: Dict[str, Any] = { "joint_indices": joint_indices, "joint_names": [self.JOINT_LABELS[i] for i in joint_indices if 0 <= i < len(self.JOINT_LABELS)], "cmd": cmd.tolist(), "zero_command": bool(zero_command), "runtime_released": bool(runtime_released), "release_alpha": float(release_alpha), "max_raw": float(np.max(np.abs(raw))) if raw.size else 0.0, "max_scaled": float(np.max(np.abs(scaled[:12]))) if scaled.size else 0.0, "max_target": float(np.max(np.abs(tentative[:12]))) if tentative.size else 0.0, } if joint_indices: primary = int(joint_indices[0]) diag.update( { "primary_joint_index": primary, "primary_joint_name": self.JOINT_LABELS[primary], "primary_target": float(tentative[primary]), "primary_default": float(default_pose[primary]), "primary_measured": float(joint_pos[primary]), "primary_pos_err": float(pos_err[primary]), "primary_raw": float(raw[primary]), "primary_scaled": float(scaled[primary]), } ) if primary < 12: diag["primary_leg_offset"] = float(leg_offset[primary]) details.update(diag) return details def add_listener(self, q: "queue.Queue"): with self._listener_lock: self._event_listeners.append(q) for event in list(self._recent_events): try: q.put_nowait(event) except Exception: pass def remove_listener(self, q: "queue.Queue"): with self._listener_lock: if q in self._event_listeners: self._event_listeners.remove(q) def _broadcast(self, event: Dict[str, Any]): payload = dict(event) payload["t"] = time.time() self._recent_events.append(payload) with self._listener_lock: for listener in list(self._event_listeners): try: listener.put_nowait(payload) except Exception: pass def _run_async(self, fn, *args, **kwargs) -> bool: with self.lock: if self.status.busy: return False self.status.busy = True self.status.fault_reason = None self.status.last_error = None self.status.last_traceback = None self._update_diag_locked() def _wrap(): try: fn(*args, **kwargs) except Exception as exc: tb = traceback.format_exc() print(f"\n[Background Task Error] {fn.__name__}") print(tb) self._set_fault(exc, tb) self._broadcast({"kind": "BG_TASK_ERROR", "fn": fn.__name__, "error": str(exc), "traceback": tb}) self._set_stage(Stage.FAULTED, detail=str(exc)) try: if self.io: self.io.damping_brake() except Exception: pass finally: with self.lock: self.status.busy = False self._update_diag_locked() self._broadcast({"kind": "BG_TASK_DONE", "fn": fn.__name__}) self._busy_thread = threading.Thread(target=_wrap, daemon=True) self._busy_thread.start() return True def connect(self, dry_run: bool = False): return self._run_async(self._do_connect, dry_run) def _do_connect(self, dry_run: bool): if self.status.stage != Stage.DISCONNECTED.value: self._broadcast({"kind": "WARN", "msg": "already connected"}) return self._set_stage(Stage.CONNECTING, "connecting hardware") from interface.real_io import RealIO from safety.runtime_guard import RuntimeGuard from safety.safety_monitor import SafetyMonitor from startup.pose_initializer import PoseInitializer from startup.stand_balance import StandBalanceController from tools.logger import LogBundle cfg = self.cfg control_dt = 1.0 / float(cfg["control_freq"]) motor_dt = 1.0 / float(cfg.get("motor_freq", 200)) driver_factory = self.driver_factory_dry() if dry_run else self.driver_factory_real() self.logger = LogBundle(cfg["log_dir"]) self._set(log_dir=str(self.logger.dir)) self.logger.event( "CONFIG_LOADED", config_path=str(self.cfg_path), dry_run=dry_run, control_freq=cfg["control_freq"], motor_model=cfg["motor_model"], ) self.io = RealIO( driver_factory=driver_factory, motor_model=cfg["motor_model"], can1_port=cfg["can1_port"], can2_port=cfg["can2_port"], imu_lib_path=cfg.get("imu_lib_path"), control_dt=control_dt, motor_dt=motor_dt, kp_leg=cfg["controller"]["kp_leg"], kd_leg=cfg["controller"]["kd_leg"], hold_kp_leg=cfg["controller"].get("hold_kp_leg", cfg["controller"]["kp_leg"]), hold_kd_leg=cfg["controller"].get("hold_kd_leg", cfg["controller"]["kd_leg"]), kd_wheel=cfg["controller"]["kd_wheel"], debug=cfg.get("debug", False), dry_run=dry_run, ) self.guard = RuntimeGuard( max_ang_vel=cfg["safety"]["max_ang_vel"], max_tilt_z=cfg["safety"]["max_tilt_z"], imu_age_warn_ms=cfg["safety"].get("imu_age_warn_ms", 60.0), imu_age_stop_ms=cfg["safety"].get("imu_age_stop_ms", 200.0), ) self.safety = SafetyMonitor( max_target_offset=cfg["safety"]["max_target_offset"], max_ang_vel=cfg["safety"]["max_ang_vel"], max_tilt_z=cfg["safety"]["max_tilt_z"], clip_to_brake=cfg["safety"].get("clip_to_brake", 0), hard_target_offset=cfg["safety"].get("hard_target_offset", 1.2), ) self.initializer = PoseInitializer( self.io, control_dt=control_dt, transition_time_min=cfg["startup"].get("transition_time_min", 2.0), transition_time_max=cfg["startup"].get("transition_time_max", 6.0), transition_seconds_per_rad=cfg["startup"].get("transition_seconds_per_rad", 1.5), hold_time=cfg["startup"]["hold_time"], settle_pos_threshold=cfg["startup"]["settle_pos_threshold"], settle_vel_threshold=cfg["startup"]["settle_vel_threshold"], timeout_extra=cfg["startup"].get("timeout_extra", 3.0), imu_fresh_wait_s=cfg["startup"].get("imu_fresh_wait_s", 1.0), progress_log_interval=cfg["startup"]["progress_log_interval"], ramp_kp_time=cfg["startup"].get("ramp_kp_time", 1.0), soft_hold_duration=cfg["startup"].get("soft_hold_duration", 1.0), max_dev_warn=cfg["startup"].get("max_dev_warn", 1.5), max_dev_abort=cfg["startup"].get("max_dev_abort", 3.0), ) self.stand_balance = StandBalanceController(cfg.get("stand_balance", {}), control_dt=control_dt) self._remote_source = self._build_remote_source() self._remote_takeover_active = False self._remote_soft_estop = False self._remote_last_error = None class _WebEstop: def __init__(self, owner): self.owner = owner def is_estop_triggered(self): return self.owner._estop self.initializer.attach(self.logger, self.guard, _WebEstop(self)) self.io.connect(imu_timeout_ms=cfg.get("imu_start_timeout_ms", 8000)) if self._remote_source is not None: try: self._remote_source.open() self.logger.event("REMOTE_CONNECTED", port=self._remote_source.port) self._poll_remote() except Exception as exc: print(f"[RobotSession] 警告: 无法打开遥控器串口 {self._remote_source.port} ({exc})。已自动禁用遥控。") self._remote_source = None self.logger.event("CAN_IMU_CONNECTED", initial_gravity=self.io.imu.initial_gravity) self._set_stage(Stage.CONNECTED, "hardware connected") def disconnect(self): return self._run_async(self._do_disconnect) def _do_disconnect(self): if self._disconnecting: return self._disconnecting = True self._stop_runtime.set() self._stop_state_poll() time.sleep(0.05) try: if self.io: self.io.damping_brake() except Exception: pass try: if self._remote_source: self._remote_source.close() except Exception: pass try: if self._remote_source: self._remote_source.close() except Exception: pass try: if self.io: self.io.disconnect() except Exception: pass if self.logger: self.logger.event("HARDWARE_DISCONNECTED") self.logger.close() self.io = None self.runner = None self.logger = None self._stand_target = None self._remote_source = None self._remote_takeover_active = False self._remote_soft_estop = False self._remote_last_error = None self._set_stage(Stage.DISCONNECTED, "hardware disconnected") self._disconnecting = False def enable_motors(self): return self._run_async(self._do_enable) def _do_enable(self): if self.status.stage not in (Stage.CONNECTED.value, Stage.STAND_HOLD.value, Stage.FAULTED.value): return self._set_stage(Stage.ENABLING, "enabling motors") self.io.enable_motors() self.logger.event("MOTORS_ENABLED") time.sleep(0.5) self._set_stage(Stage.ENABLED, "motors enabled") self._start_state_poll() def _start_state_poll(self): self._stop_poll.clear() if self._poll_thread and self._poll_thread.is_alive(): return def _poll_loop(): while not self._stop_poll.is_set(): if self.status.stage == Stage.RUNTIME.value: time.sleep(0.2) continue try: stage = self.status.stage self._poll_remote() if stage == Stage.ENABLED.value: self.io.hw.passive_poll() state = self.io.read_state() if stage == Stage.STAND_HOLD.value and self.stand_balance is not None and self.stand_balance.enabled: self._stand_target = self.stand_balance.compute_target(state, np.zeros(3, dtype=np.float32)) self.io.hold_pose(self._stand_target, kp_scale=1.0) self._update_latest_target(self._stand_target, "stand_balance") elif stage == Stage.STAND_HOLD.value and self._stand_target is not None: self.io.hold_pose(self._stand_target, kp_scale=1.0) self._update_latest_target(self._stand_target, "stand_hold") motor_diag = state.get("motor_stale", {}) self._last_poll_ts = time.time() self._set( last_state={ "joint_pos": state["joint_pos"].tolist(), "joint_vel": state["joint_vel"].tolist(), "joint_torque": state["joint_torque"].tolist(), "target": self._stand_target.tolist() if self._stand_target is not None else [0.0] * 16, "raw": [0.0] * 16, "gyro": state["imu_gyro"].tolist(), "proj_gravity": state["projected_gravity"].tolist(), "imu_age_ms": float(state["imu_age_ms"]), "loop_dt_ms": 0.0, "holdover_total": int(getattr(self.io.hw, "holdover_total", 0)), "safety_level": 0, "guard_level": 0, "phase": "POLL", "stand_balance": self._stand_balance_snapshot(), "latest_target": self._latest_target_snapshot(), "per_motor_stale": motor_diag.get("per_motor_stale", [0] * 16), } ) except Exception as exc: self._poll_error_count += 1 self._broadcast({"kind": "POLL_ERROR", "error": str(exc), "traceback": traceback.format_exc()}) time.sleep(0.2) self._poll_thread = threading.Thread(target=_poll_loop, daemon=True) self._poll_thread.start() def _stop_state_poll(self): self._stop_poll.set() thread = self._poll_thread if thread and thread.is_alive() and thread is not threading.current_thread(): thread.join(timeout=0.5) self._poll_thread = None def disable_motors(self): return self._run_async(self._do_disable) def _do_disable(self): self._stop_state_poll() try: self.io.damping_brake() except Exception: pass time.sleep(0.05) self.io.disable_motors() self.logger.event("MOTORS_DISABLED") self._set_stage(Stage.CONNECTED, "motors disabled") def startup(self): return self._run_async(self._do_startup) def _do_startup(self): from startup.pose_initializer import PoseInitFailed, STAND_POSE if self.status.stage != Stage.ENABLED.value: raise RuntimeError("startup requires ENABLED") self._set_stage(Stage.STARTING_UP, detail="transition to stand pose") try: target = self.initializer.transition_to_stand_from_current(target_pose=STAND_POSE) self._stand_target = target if self.stand_balance is not None and self.stand_balance.enabled: self.logger.event("STAND_BALANCE_BEGIN") self.stand_balance.reset() stable_deadline = time.perf_counter() + 6.0 while time.perf_counter() < stable_deadline: state = self.io.read_state() self._stand_target = self.stand_balance.compute_target(state, np.zeros(3, dtype=np.float32)) self.io.hold_pose(self._stand_target, kp_scale=1.0) if self.stand_balance.is_stable(): debug = self.stand_balance.last_debug self.logger.event( "STAND_BALANCE_STABLE", roll_deg=float(np.degrees(debug.roll)), pitch_deg=float(np.degrees(debug.pitch)), pitch_corr=float(debug.pitch_corr), pitch_compensation_enabled=bool(debug.pitch_compensation_enabled), ) break time.sleep(1.0 / float(self.cfg["control_freq"])) self.logger.event("STAND_BALANCE_END") self._set_stage(Stage.STAND_HOLD, detail="stand-balance hold active") else: self._set_stage(Stage.STAND_HOLD, detail="holding stand pose with PD") except PoseInitFailed as exc: self.logger.event("POSE_INIT_FAILED", error=str(exc)) try: self.io.damping_brake() except Exception: pass self._set_stage(Stage.FAULTED, detail=str(exc)) raise def _run_policy_loop_wrap(self, control_dt: float, require_active_command: bool): from safety.runtime_guard import GuardLevel from safety.safety_monitor import SafetyLevel try: next_exec = time.perf_counter() runtime_released = not require_active_command release_cfg = self._policy_release_cfg() release_active_time = 0.0 release_block_reason = "active command required" if require_active_command else "" while not self._stop_runtime.is_set() and not self._runtime_error_event.is_set(): if getattr(self, "_debug_hang_policy", False): time.sleep(0.1) continue with self._cmd_lock: web_cmd = self._cmd.copy() self._poll_remote() raw_cmd = self._remote_source.get_command() if self._remote_takeover_active and self._remote_source is not None else web_cmd cmd = self._filter_command(raw_cmd, control_dt) with self._state_lock: state = self._latest_hardware_state if state is None: next_exec += control_dt slack = next_exec - time.perf_counter() if slack > 0: time.sleep(slack) continue obs = self.io.get_obs_policy(state, cmd, self.runner.default_dof_pos, self.runner.last_actions) zero_command = self.runner._is_zero_command(cmd, state["imu_gyro"]) if np.any(np.isnan(obs)) or np.any(np.isinf(obs)): raise ValueError("Observation vector contains NaN or Inf values") if self._remote_takeover_active and self._remote_soft_estop: raise RuntimeError("Remote soft estop triggered") if not runtime_released and zero_command: raw = np.zeros(16, dtype=np.float32) scaled = np.zeros(16, dtype=np.float32) target_hold = self.stand_balance.compute_target(state, np.zeros(3, dtype=np.float32)) if self.stand_balance is not None and self.stand_balance.enabled else self.runner.default_dof_pos.copy() tentative = target_hold.copy().astype(np.float32) policy_target = self.runner.default_dof_pos.copy() release_active_time = 0.0 release_block_reason = "zero command" runtime_blend_ratio = 0.0 else: target_hold = self.stand_balance.compute_target(state, np.zeros(3, dtype=np.float32)) if self.stand_balance is not None and self.stand_balance.enabled else self.runner.default_dof_pos.copy() release_metrics = self._compute_release_metrics(state, target_hold, cmd) if not runtime_released: release_active_time += control_dt if self.runner.is_command_active(cmd) else 0.0 active_ready = release_active_time >= release_cfg["command_hold_s"] posture_ready = release_metrics["max_hold_err"] <= release_cfg["posture_max_err"] if active_ready and posture_ready: runtime_released = True self.logger.event( "RUNTIME_COMMAND_RELEASED", cmd=cmd.tolist(), active_hold_s=release_active_time, max_hold_err=release_metrics["max_hold_err"], max_default_err=release_metrics["max_default_err"], max_hold_default_gap=release_metrics["max_hold_default_gap"], ) else: reasons = [] if not active_ready: reasons.append(f"cmd_hold<{release_cfg['command_hold_s']:.2f}s") if not posture_ready: reasons.append(f"hold_err>{release_cfg['posture_max_err']:.3f}") release_block_reason = ",".join(reasons) if not runtime_released: raw = np.zeros(16, dtype=np.float32) scaled = np.zeros(16, dtype=np.float32) tentative = target_hold.copy().astype(np.float32) policy_target = self.runner.default_dof_pos.copy() runtime_blend_ratio = 0.0 else: scaled, raw = self.runner.step(obs, control_dt) if np.any(np.isnan(raw)) or np.any(np.isinf(raw)): raise ValueError("Policy action contains NaN or Inf values") policy_target = (scaled + self.runner.default_dof_pos).astype(np.float32) tentative = self._blend_runtime_target( target_hold, policy_target, float(getattr(self.runner, "_command_release_alpha", 0.0)), release_cfg["target_blend_s"], control_dt, ) scaled = tentative - self.runner.default_dof_pos runtime_blend_ratio = min( 1.0, float(getattr(self.runner, "_command_release_alpha", 0.0)) * (self.runner.command_release_s / max(release_cfg["target_blend_s"], control_dt)), ) joint_cmd = { "time": time.perf_counter(), "target": tentative, "raw": raw, "scaled": scaled, "policy_target": policy_target, "zero_command": zero_command, "runtime_released": runtime_released, "release_alpha": float(getattr(self.runner, "_command_release_alpha", 0.0)), "release_active_time": release_active_time, "release_block_reason": release_block_reason, "runtime_blend_ratio": runtime_blend_ratio, "obs": obs, "cmd": cmd.copy(), "raw_cmd": raw_cmd.copy(), } try: self._target_queue.put_nowait(joint_cmd) except queue.Full: try: self._target_queue.get_nowait() self._target_queue.put_nowait(joint_cmd) except Exception: pass next_exec += control_dt slack = next_exec - time.perf_counter() if slack > 0: time.sleep(slack) except Exception as exc: self._policy_exception = exc self._policy_traceback = traceback.format_exc() self._runtime_error_event.set() def _run_motor_loop_wrap(self, motor_dt: float, policy_timeout_s: float, policy_stale_warn_s: float, log_every: int): from safety.runtime_guard import GuardLevel from safety.safety_monitor import SafetyLevel try: next_exec = time.perf_counter() loop_count = 0 last_target = None last_target_time = 0.0 last_stale_log_time = 0.0 last_overrun_log_time = 0.0 self._last_runtime_ts = time.time() self._runtime_loop_count = 0 self._last_loop_dt_ms = 0.0 self._last_loop_profile_snapshot = {} log_decimation = max(1, int(round(0.02 / motor_dt))) if motor_dt < 0.02 else 1 while not self._stop_runtime.is_set() and not self._runtime_error_event.is_set(): loop_t0 = time.perf_counter() loop_profile = {} def mark_profile(name: str) -> None: nonlocal profile_last now_profile = time.perf_counter() loop_profile[name] = (now_profile - profile_last) * 1000.0 profile_last = now_profile profile_last = loop_t0 state = self.io.read_state() mark_profile("read_state_ms") with self._state_lock: self._latest_hardware_state = state try: joint_cmd = self._target_queue.get_nowait() last_target = joint_cmd last_target_time = joint_cmd["time"] except queue.Empty: pass current_time = time.perf_counter() if last_target is None: actual_target = self.io.hold_pose(self._stand_target, kp_scale=1.0) self._update_latest_target(actual_target, "runtime_zero_hold") mark_profile("hold_pose_ms") if loop_count * motor_dt > 2.0: raise TimeoutError("Initial policy target wait timeout") raw = np.zeros(16, dtype=np.float32) scaled = np.zeros(16, dtype=np.float32) projected_gravity = state["projected_gravity"] safety_level = 0 guard_level = 0 safety_reason = "" guard_reason = "" zero_command = True runtime_released = False release_alpha = 0.0 release_active_time = 0.0 release_max_hold_err = 0.0 cmd = np.zeros(3, dtype=np.float32) raw_cmd = np.zeros(3, dtype=np.float32) extra = { "raw_cmd": raw_cmd.tolist(), } else: age_s = current_time - last_target_time if age_s > policy_stale_warn_s: age_ms = age_s * 1000.0 self._note_policy_stale(age_ms) if current_time - last_stale_log_time > 1.0: self.logger.event("POLICY_TARGET_STALE", age_ms=age_ms, timeout_ms=policy_timeout_s * 1000.0) last_stale_log_time = current_time if age_s > policy_timeout_s: self.logger.event("POLICY_TIMEOUT", age_ms=age_s * 1000.0) raise TimeoutError(f"Policy target age {age_s*1000.0:.1f}ms exceeds safety limit {policy_timeout_s*1000.0:.1f}ms") tentative = last_target["target"] raw = last_target["raw"] scaled = last_target["scaled"] policy_target = last_target["policy_target"] zero_command = last_target["zero_command"] runtime_released = last_target["runtime_released"] release_alpha = last_target["release_alpha"] release_active_time = last_target["release_active_time"] obs = last_target["obs"] cmd = last_target["cmd"] raw_cmd = last_target["raw_cmd"] runtime_blend_ratio = last_target["runtime_blend_ratio"] projected_gravity = state["projected_gravity"] guard_decision = self.guard.check( imu_gyro=state["imu_gyro"], projected_gravity=projected_gravity, imu_age_ms=float(state["imu_age_ms"]), estop_triggered=self._estop, extra_nan_arrays=(raw, tentative), ) mark_profile("safety_ms") if guard_decision.level == GuardLevel.STOP: self.logger.event("GUARD_STOP", phase="RUNTIME", reason=guard_decision.reason) raise RuntimeError(f"RuntimeGuard STOP: {guard_decision.reason}") safety_decision = self.safety.check( target_pose=tentative, default_pose=self.runner.default_dof_pos, imu_gyro=state["imu_gyro"], projected_gravity=projected_gravity, estop_triggered=self._estop, ) if safety_decision.level == SafetyLevel.ESTOP: self.logger.event("SAFETY_ESTOP", reason=safety_decision.message) raise RuntimeError(f"SafetyMonitor ESTOP: {safety_decision.message}") if safety_decision.level == SafetyLevel.BRAKE: safety_diag = self._build_action_diag( state=state, raw=raw, scaled=scaled, tentative=tentative, cmd=cmd, zero_command=zero_command, runtime_released=runtime_released, release_alpha=release_alpha, safety_details=safety_decision.details, ) self.logger.event("SAFETY_BRAKE", reason=safety_decision.message, details=safety_diag) raise RuntimeError(f"SafetyMonitor BRAKE: {safety_decision.message}") if safety_decision.level == SafetyLevel.CLIP and safety_decision.clipped_target is not None: scaled = safety_decision.clipped_target - self.runner.default_dof_pos tentative = safety_decision.clipped_target if runtime_released or not zero_command: actual_target = self.io.send_actions(scaled, self.runner.default_dof_pos) self._update_latest_target(actual_target, "runtime_policy") mark_profile("send_actions_ms") else: actual_target = self.io.hold_pose(tentative, kp_scale=1.0) self._update_latest_target(actual_target, "runtime_zero_hold") mark_profile("hold_pose_ms") loop_dt_ms = (time.perf_counter() - loop_t0) * 1000.0 self._last_runtime_ts = time.time() self._runtime_loop_count += 1 self._last_loop_dt_ms = loop_dt_ms self._last_loop_profile_snapshot = loop_profile.copy() release_max_hold_err = float(np.max(np.abs(state["joint_pos"][:12] - tentative[:12]))) target_metrics = self._compute_target_error_metrics(state, tentative, policy_target) extra = self._signal_stats(obs, raw, scaled) extra.update({ "raw_cmd": raw_cmd.tolist(), "max_raw": float(np.max(np.abs(raw))), "clip_joint_indices": (safety_decision.details or {}).get("joint_indices", []), "clip_joint_names": (safety_decision.details or {}).get("joint_names", []), "clip_primary_joint": (safety_decision.details or {}).get("primary_joint_name"), "clip_primary_target": (safety_decision.details or {}).get("primary_target"), "clip_primary_measured": (safety_decision.details or {}).get("primary_measured"), "clip_primary_default": (safety_decision.details or {}).get("primary_default"), "clip_primary_pos_err": (safety_decision.details or {}).get("primary_pos_err"), "clip_primary_raw": (safety_decision.details or {}).get("primary_raw"), "clip_primary_scaled": (safety_decision.details or {}).get("primary_scaled"), }) safety_level = int(safety_decision.level) guard_level = int(guard_decision.level) safety_reason = safety_decision.message guard_reason = guard_decision.reason if log_every and (loop_count % (log_every * log_decimation) == 0): motor_diag = state.get("motor_stale", {}) or {} stand_diag = self._stand_balance_snapshot() self.logger.state( phase="RUNTIME", joint_pos=state["joint_pos"], joint_vel=state["joint_vel"], joint_torque=state["joint_torque"], target_pose=actual_target, raw_action=raw, gyro=state["imu_gyro"], accel=state["imu_accel"], quat=state["quat_wxyz"], proj_gravity=projected_gravity, command=cmd, imu_age_ms=float(state["imu_age_ms"]), loop_dt_ms=loop_dt_ms, safety_level=safety_level, guard_level=guard_level, holdover=int(motor_diag.get("holdover_this_frame", 0)), stale_max=int(motor_diag.get("stale_max", 0)), fresh_count=int(motor_diag.get("fresh_count", 16)), kp_leg_cmd=float(self.io.kp_leg), kd_leg_cmd=float(self.io.kd_leg), kd_wheel_cmd=float(self.io.kd_wheel), runtime_release_alpha=release_alpha, runtime_release_hold_s=release_active_time, runtime_blend_ratio=runtime_blend_ratio, hold_target_max_err=target_metrics["hold_target_max_err"], policy_target_max_err=target_metrics["policy_target_max_err"], hold_policy_max_gap=target_metrics["hold_policy_max_gap"], stand_roll_deg=float(stand_diag.get("roll_deg", 0.0)), stand_pitch_deg=float(stand_diag.get("pitch_deg", 0.0)), stand_roll_corr=float(stand_diag.get("roll_corr", 0.0)), stand_pitch_corr=float(stand_diag.get("pitch_corr", 0.0)), stand_pitch_comp_enabled=bool(stand_diag.get("pitch_compensation_enabled", False)), target_source="runtime_blend" if runtime_blend_ratio < 0.999 else "runtime_policy", clip_primary_joint=str((safety_decision.details or {}).get("primary_joint_name", "")), clip_primary_target=float((safety_decision.details or {}).get("primary_target", 0.0) or 0.0), clip_primary_measured=float((safety_decision.details or {}).get("primary_measured", 0.0) or 0.0), clip_primary_default=float((safety_decision.details or {}).get("primary_default", 0.0) or 0.0), clip_primary_pos_err=float((safety_decision.details or {}).get("primary_pos_err", 0.0) or 0.0), clip_primary_raw=float((safety_decision.details or {}).get("primary_raw", 0.0) or 0.0), clip_primary_scaled=float((safety_decision.details or {}).get("primary_scaled", 0.0) or 0.0), safety_reason=safety_reason, guard_reason=guard_reason, ) mark_profile("log_ms") with self._state_lock: self._latest_motor_diagnostics = { "actual_target": actual_target, "raw": raw, "scaled": scaled, "projected_gravity": projected_gravity, "safety_level": safety_level, "guard_level": guard_level, "safety_reason": safety_reason, "guard_reason": guard_reason, "zero_command": zero_command, "runtime_released": runtime_released, "release_alpha": release_alpha, "release_active_time": release_active_time, "release_max_hold_err": release_max_hold_err, "extra": extra, "cmd": cmd, } loop_profile["total_ms"] = (time.perf_counter() - loop_t0) * 1000.0 self._record_loop_profile(loop_profile) next_exec += motor_dt slack = next_exec - time.perf_counter() if slack > 0: coarse = slack - 0.002 if coarse > 0: time.sleep(coarse) micro_slack = next_exec - time.perf_counter() if micro_slack > 0: time.sleep(min(micro_slack, 0.001)) elif slack < -motor_dt: over_ms = -slack * 1000.0 self._note_overrun(over_ms) if time.perf_counter() - last_overrun_log_time > 0.5: self.logger.event("LOOP_OVERRUN", over_ms=over_ms) last_overrun_log_time = time.perf_counter() next_exec = time.perf_counter() loop_count += 1 except Exception as exc: self._motor_exception = exc self._motor_traceback = traceback.format_exc() self._runtime_error_event.set() def _run_status_loop_wrap(self, status_dt: float): try: while not self._stop_runtime.is_set() and not self._runtime_error_event.is_set(): start_time = time.perf_counter() with self._state_lock: state = self._latest_hardware_state diag = getattr(self, "_latest_motor_diagnostics", None) if state is not None and diag is not None: loop_profile = getattr(self, "_last_loop_profile_snapshot", {}) loop_dt_ms = getattr(self, "_last_loop_dt_ms", 0.0) self._set( cmd=diag["cmd"].tolist(), last_state=self._build_last_state( state=state, target=diag["actual_target"], raw=diag["raw"], projected_gravity=diag["projected_gravity"], cmd=diag["cmd"], loop_dt_ms=loop_dt_ms, phase="RUNTIME", safety_level=diag["safety_level"], guard_level=diag["guard_level"], safety_reason=diag["safety_reason"], guard_reason=diag["guard_reason"], zero_command=diag["zero_command"], runtime_released=diag["runtime_released"], release_alpha=diag["release_alpha"], release_active_hold_s=diag["release_active_time"], release_max_hold_err=diag["release_max_hold_err"], loop_profile=loop_profile, extra=diag["extra"], ) ) elapsed = time.perf_counter() - start_time sleep_time = max(0.0, status_dt - elapsed) time.sleep(sleep_time) except Exception as exc: self._status_exception = exc self._status_traceback = traceback.format_exc() self._runtime_error_event.set() def runtime_start(self, policy_path: Optional[str] = None): return self._run_async(self._do_runtime_start, policy_path) def _do_runtime_start(self, policy_path: Optional[str]): from policy.policy_runner import PolicyRunner, resolve_policy_path from safety.runtime_guard import GuardLevel from safety.safety_monitor import SafetyLevel if self.status.stage != Stage.STAND_HOLD.value: raise RuntimeError("runtime start requires STAND_HOLD") sim2real_root = Path(__file__).resolve().parents[1] resolved_policy = resolve_policy_path(policy_path, sim2real_root) if not resolved_policy.exists(): raise RuntimeError(f"policy not found: {resolved_policy}") self.runner = PolicyRunner( resolved_policy, enable_zero_cmd_suppression=self.cfg.get("policy", {}).get("enable_zero_cmd_suppression", True), hold_zero_command_pose=self.cfg.get("policy", {}).get("hold_zero_command_pose", True), command_release_s=self.cfg.get("policy", {}).get("command_release_s", 0.35), action_scale=self._policy_action_scale(), zero_cmd_use_yaw_rate=self.cfg.get("policy", {}).get("zero_cmd_use_yaw_rate", False), clip_obs=self.cfg.get("policy", {}).get("clip_obs", 100.0), ) self.safety.reset() require_active_command = self.cfg.get("policy", {}).get("require_active_command_to_release", True) self.logger.event("POLICY_LOADED", path=str(resolved_policy)) self._stop_state_poll() control_dt = 1.0 / float(self.cfg.get("policy_freq", 50)) target = self._stand_target self.logger.event("PRIME_BEGIN") zero_cmd = np.zeros(3, dtype=np.float32) for index in range(1): if self.stand_balance is not None and self.stand_balance.enabled: state = self.io.read_state() target = self.stand_balance.compute_target(state, zero_cmd) self.io.hold_pose(target, kp_scale=1.0) else: self.io.hold_pose(target, kp_scale=1.0) state = self.io.read_state() obs = self.io.get_obs_policy(state, zero_cmd, self.runner.default_dof_pos, self.runner.last_actions) if index == 0: self.runner.reset(prime_obs=obs) time.sleep(control_dt) self.logger.event("PRIME_END") state = self.io.read_state() projected_gravity = state["projected_gravity"] if abs(projected_gravity[0]) > 0.5 or abs(projected_gravity[1]) > 0.5: error_message = ( f"IMU frame mismatch or body tilt too large: " f"gravity projection X={projected_gravity[0]:.2f}, Y={projected_gravity[1]:.2f}" ) self.logger.event("GUARD_STOP", phase="STARTUP", reason=error_message) self._set_stage(Stage.FAULTED, error_message) return self.logger.event("HISTORY_PRIMED", initial_obs=obs) self._stop_runtime.clear() self._runtime_loop_count = 0 self._runtime_overrun_count = 0 self._runtime_overrun_max_ms = 0.0 self._runtime_policy_stale_count = 0 self._runtime_policy_stale_max_ms = 0.0 self._filtered_cmd[:] = 0.0 self._last_raw_cmd[:] = 0.0 self._latest_target = None self._latest_target_info = {} self._remote_takeover_active = False self._remote_soft_estop = False self._set_stage(Stage.RUNTIME, detail="Decoupled policy & motor loop active") self.logger.event("RUNTIME_BEGIN") # 启动解耦的多 Loop # 1. 清空解耦队列与状态缓存 while not self._target_queue.empty(): try: self._target_queue.get_nowait() except Exception: break self._latest_hardware_state = state self._policy_exception = None self._motor_exception = None self._status_exception = None self._runtime_error_event.clear() # 2. 读取配置频率 motor_dt = 1.0 / float(self.cfg.get("motor_freq", 200)) status_dt = 1.0 / float(self.cfg.get("status_freq", 10)) policy_timeout_s = float(self.cfg.get("policy_timeout_ms", 150.0)) / 1000.0 policy_stale_warn_s = float(self.cfg.get("policy_stale_warn_ms", 60.0)) / 1000.0 log_every = int(self.cfg.get("log_every", 1)) # 3. 创建并启动线程 policy_thread = threading.Thread( target=self._run_policy_loop_wrap, args=(control_dt, require_active_command), name="PolicyLoop", daemon=True ) motor_thread = threading.Thread( target=self._run_motor_loop_wrap, args=(motor_dt, policy_timeout_s, policy_stale_warn_s, log_every), name="MotorLoop", daemon=True ) status_thread = threading.Thread( target=self._run_status_loop_wrap, args=(status_dt,), name="StatusLoop", daemon=True ) policy_thread.start() motor_thread.start() status_thread.start() # 4. 主工作监控忙等,直到用户触发 stop 或是线程抛出异常 try: while not self._stop_runtime.is_set() and not self._runtime_error_event.is_set(): time.sleep(0.05) if self._runtime_error_event.is_set(): for exc, tb, name in [ (self._motor_exception, getattr(self, "_motor_traceback", ""), "MotorLoop"), (self._policy_exception, getattr(self, "_policy_traceback", ""), "PolicyLoop"), (self._status_exception, getattr(self, "_status_traceback", ""), "StatusLoop"), ]: if exc is not None: raise exc finally: # 5. 确保通知所有工作线程退出并等待它们 self._stop_runtime.set() policy_thread.join(timeout=1.0) motor_thread.join(timeout=1.0) status_thread.join(timeout=1.0) if self.status.stage == Stage.RUNTIME.value: self.logger.event("RUNTIME_STOP") self._remote_takeover_active = False self._remote_soft_estop = False self._filtered_cmd[:] = 0.0 self._last_raw_cmd[:] = 0.0 self._latest_target = None self._latest_target_info = {} self._set_stage(Stage.STAND_HOLD, detail="runtime stopped, back to stand-balance hold") self._start_state_poll() def runtime_stop(self): self._stop_runtime.set() return True def estop(self): self._estop = True try: if self.io: self.io.damping_brake() except Exception: pass self._stop_runtime.set() if self.logger: self.logger.event("USER_ESTOP_WEB") self._set_stage(Stage.ESTOPPED, detail="web emergency stop") return True def reset_estop(self): self._estop = False self._set(detail="estop cleared") return True def set_remote_takeover(self, enabled: bool): enabled = bool(enabled) if enabled: if self.status.stage != Stage.RUNTIME.value: raise RuntimeError("remote takeover requires RUNTIME") if self._remote_source is None: raise RuntimeError("remote controller not configured") self._poll_remote() self._remote_takeover_active = True self._filtered_cmd[:] = 0.0 self._last_raw_cmd[:] = 0.0 if self.logger: self.logger.event("REMOTE_TAKEOVER_ENABLED", port=self._remote_source.port) self._set(detail="remote takeover enabled") return True self._remote_takeover_active = False self._remote_soft_estop = False self._filtered_cmd[:] = 0.0 self._last_raw_cmd[:] = 0.0 if self.logger: self.logger.event("REMOTE_TAKEOVER_DISABLED") self._set(detail="remote takeover disabled") return True def set_command(self, vx: float, vy: float, yaw: float): with self._cmd_lock: self._cmd[0] = float(vx) self._cmd[1] = float(vy) self._cmd[2] = float(yaw) self._last_command_ts = time.time() self._set(cmd=self._cmd.tolist()) return True def test_motor(self, leg: str, joint: str, delta_rad: float, kp: float, kd: float, duration_s: float): return self._run_async(self._do_test_motor, leg, joint, delta_rad, kp, kd, duration_s) def _do_test_motor(self, leg: str, joint: str, delta_rad: float, kp: float, kd: float, duration_s: float): if self.io is None: raise RuntimeError("hardware not connected") if self.status.stage not in (Stage.ENABLED.value, Stage.STAND_HOLD.value, Stage.FAULTED.value): raise RuntimeError("test motor requires ENABLED/STAND_HOLD/FAULTED") joint_key = (str(leg), str(joint)) if joint_key not in self.io.hw.mapper.SIM_INDEX_MAP: raise RuntimeError(f"unknown joint: {leg}_{joint}") idx = self.io.hw.mapper.SIM_INDEX_MAP[joint_key] base_pose = self.io.read_measured_pose().astype(np.float32) target_pose = base_pose.copy() target_pose[idx] += float(delta_rad) prev_stage = self.status.stage self._set_stage(Stage.JOINT_TEST, detail=f"testing {leg}_{joint}") if self.logger: self.logger.event( "JOINT_TEST_BEGIN", joint=f"{leg}_{joint}", joint_index=idx, delta_rad=float(delta_rad), kp=float(kp), kd=float(kd), duration_s=float(duration_s), start_pos=float(base_pose[idx]), target_pos=float(target_pose[idx]), ) self._stop_state_poll() next_exec = time.perf_counter() deadline = next_exec + max(float(duration_s), 0.1) samples = [] while time.perf_counter() < deadline: state = self.io.read_state() measured = float(state["joint_pos"][idx]) error = float(target_pose[idx] - measured) torque = float(state["joint_torque"][idx]) vel = float(state["joint_vel"][idx]) samples.append((measured, error, vel, torque)) self.io.hw.send_control(target_pose, float(kp), float(kd), self.io.kd_wheel) next_exec += 1.0 / float(self.cfg["control_freq"]) slack = next_exec - time.perf_counter() if slack > 0: time.sleep(slack) self.io.hold_pose(base_pose, kp_scale=1.0) final_state = self.io.read_state() final_measured = float(final_state["joint_pos"][idx]) if self.logger: self.logger.event( "JOINT_TEST_END", joint=f"{leg}_{joint}", joint_index=idx, final_pos=final_measured, final_err=float(target_pose[idx] - final_measured), max_abs_err=float(max(abs(s[1]) for s in samples) if samples else 0.0), max_abs_vel=float(max(abs(s[2]) for s in samples) if samples else 0.0), max_abs_tau=float(max(abs(s[3]) for s in samples) if samples else 0.0), ) self._set(stage=prev_stage, detail=f"joint test {leg}_{joint} done") if prev_stage in (Stage.ENABLED.value, Stage.STAND_HOLD.value): self._start_state_poll() def list_logs(self): log_root = Path(self.cfg.get("log_dir", "logs")) if not log_root.exists(): return [] out = [] for directory in sorted(log_root.iterdir(), reverse=True): if not directory.is_dir(): continue state_path = directory / "state.csv" events_path = directory / "events.jsonl" out.append( { "id": directory.name, "state_csv": state_path.exists(), "events_jsonl": events_path.exists(), "size_kb": ( (state_path.stat().st_size + events_path.stat().st_size) // 1024 if state_path.exists() and events_path.exists() else 0 ), } ) return out