Download predictor.py from BAAI/PingPongRealWorldRL: direct link, hf CLI and curl.
- Browser
- Download file 38.5 kB
-
https://huggingface.co/BAAI/PingPongRealWorldRL/resolve/main/predictor.py
- Command line
-
hf download hf://BAAI/PingPongRealWorldRL/predictor.py
-
curl -L -o predictor.py https://huggingface.co/BAAI/PingPongRealWorldRL/resolve/main/predictor.py
38.5 kB
| #!/usr/bin/env python3 | |
| """Predict real-ball strikes and publish H2 policy-domain commands over ROS 2.""" | |
| import time | |
| import math | |
| import argparse | |
| from collections import Counter | |
| import numpy as np | |
| import zmq | |
| import rclpy | |
| from std_msgs.msg import Float64MultiArray | |
| from runtime.prediction_filters import ( | |
| CausalMarkerBallPhaseGate, | |
| CausalPredictionStabilizer, | |
| LEGACY_V2_HIT_VELOCITY_BIAS, | |
| ) | |
| from runtime.legacy_v2_predictor import HybridTrajectoryPredictor | |
| from runtime.planner_protocol import H2_PLANNER_PACKET_LEN, build_h2_planner_packet | |
| from runtime.provenance import verify_artifacts | |
| def make_latest_sub_socket(context: zmq.Context, endpoint: str) -> zmq.Socket: | |
| """Create a one-message latest-state SUB socket for combined mocap packets.""" | |
| socket = context.socket(zmq.SUB) | |
| socket.setsockopt(zmq.CONFLATE, 1) | |
| socket.setsockopt(zmq.RCVHWM, 1) | |
| socket.setsockopt(zmq.LINGER, 0) | |
| socket.setsockopt_string(zmq.SUBSCRIBE, "") | |
| socket.connect(endpoint) | |
| return socket | |
| def _source_frames_ready( | |
| robot_source_monotonic_s: float, | |
| ball_source_monotonic_s: float, | |
| ) -> bool: | |
| """Return true only after both real mocap sources delivered a first frame.""" | |
| return ( | |
| math.isfinite(float(robot_source_monotonic_s)) | |
| and math.isfinite(float(ball_source_monotonic_s)) | |
| and float(robot_source_monotonic_s) > 0.0 | |
| and float(ball_source_monotonic_s) > 0.0 | |
| ) | |
| class PredictionGateDiagnostics: | |
| """Rate-limited counters for otherwise silent prediction gates.""" | |
| ORDER = ( | |
| "ball_samples", | |
| "velocity_invalid", | |
| "phase_waiting", | |
| "phase_incoming_slow", | |
| "phase_outgoing", | |
| "phase_missed", | |
| "observation_x", | |
| "observation_y", | |
| "observation_z", | |
| "input_pass", | |
| "predictor_no_hit", | |
| "predictor_nonfinite", | |
| "ball_too_low", | |
| "policy_domain", | |
| "stabilizer_warmup", | |
| "stable_prediction", | |
| "wait_lock_far", | |
| "lock_too_late", | |
| "first_lock", | |
| "lock_refresh", | |
| "crossed_without_lock", | |
| ) | |
| def __init__(self, report_interval_s: float) -> None: | |
| self.report_interval_s = max(0.0, float(report_interval_s)) | |
| self.total = Counter() | |
| self._last_report = Counter() | |
| self._last_report_monotonic = time.monotonic() | |
| def mark(self, reason: str) -> None: | |
| self.total[reason] += 1 | |
| def report_if_due(self, *, force: bool = False) -> None: | |
| if self.report_interval_s <= 0.0: | |
| return | |
| now = time.monotonic() | |
| elapsed = now - self._last_report_monotonic | |
| if not force and elapsed < self.report_interval_s: | |
| return | |
| delta = { | |
| key: self.total[key] - self._last_report[key] | |
| for key in self.ORDER | |
| if self.total[key] != self._last_report[key] | |
| } | |
| self._last_report = self.total.copy() | |
| self._last_report_monotonic = now | |
| if not delta: | |
| return | |
| fields = " ".join(f"{key}={value}" for key, value in delta.items()) | |
| print(f"[PREDICT GATES {elapsed:.1f}s] {fields}") | |
| def report_total(self) -> None: | |
| if self.report_interval_s <= 0.0 or not self.total: | |
| return | |
| fields = " ".join( | |
| f"{key}={self.total[key]}" for key in self.ORDER if self.total[key] | |
| ) | |
| print(f"[PREDICT GATES TOTAL] {fields}") | |
| class LegacyV2HitPlanePredictor: | |
| """Expose the fitted V2 model through this node's hit-plane API.""" | |
| def __init__(self) -> None: | |
| self._predictor = HybridTrajectoryPredictor() | |
| def predict_hitting_point( | |
| self, | |
| p_init: np.ndarray, | |
| v_init: np.ndarray, | |
| target_x: float, | |
| ): | |
| trajectory, hit_velocity, flight_time = self._predictor.trajectory_to_x( | |
| p_init, | |
| v_init, | |
| target_x=target_x, | |
| table_contact_in_range=lambda pos: ( | |
| -1.37 <= float(pos[0]) <= 0.0 and abs(float(pos[1])) <= 0.76 | |
| ), | |
| max_time=2.0, | |
| dt=0.005, | |
| ) | |
| hit_position = trajectory[-1] if hit_velocity is not None else None | |
| return hit_position, hit_velocity, flight_time | |
| # ========================================== | |
| # 0. Racket command solver in the world frame | |
| # ========================================== | |
| def _solve_v_outgoing(predictor, p_racket: np.ndarray, p_landing: np.ndarray, | |
| dt_flight: float, n_iter: int = 4) -> np.ndarray: | |
| """Newton iteration: find v_out so the ball reaches p_landing in dt_flight | |
| under the full physics model (drag + spin_az + gravity). | |
| Starts from the parabolic estimate with g_eff, then corrects via: | |
| v += (p_target - p_predicted) / dt_flight | |
| Converges in 3-4 iterations for typical outgoing trajectories (<10 m, <0.6 s). | |
| """ | |
| g_eff = predictor.g + predictor.spin_az | |
| v = (p_landing - p_racket) / dt_flight + np.array([0.0, 0.0, 0.5 * g_eff * dt_flight]) | |
| for _ in range(n_iter): | |
| p_pred, _ = predictor.predict_state_at_time(p_racket, v, dt_flight) | |
| v = v + (p_landing - p_pred) / dt_flight | |
| return v | |
| def calculate_racket_command(p_racket: np.ndarray, v_incoming: np.ndarray, | |
| p_landing: np.ndarray, dt_flight: float, Cr: float = 0.70, | |
| predictor=None): | |
| """Compute required racket velocity for a given outgoing target. | |
| A predictor exposing ``g``, ``spin_az`` and ``predict_state_at_time`` uses | |
| the full iterative flight solve. The standalone legacy-v2 wrapper only | |
| predicts trajectories to an x plane, so it deliberately uses the | |
| parabolic fallback for this compatibility-only racket-velocity field. | |
| """ | |
| supports_outgoing_solver = ( | |
| predictor is not None | |
| and hasattr(predictor, "g") | |
| and hasattr(predictor, "spin_az") | |
| and callable(getattr(predictor, "predict_state_at_time", None)) | |
| ) | |
| if supports_outgoing_solver: | |
| v_outgoing = _solve_v_outgoing(predictor, p_racket, p_landing, dt_flight) | |
| else: | |
| g_eff = 9.81 | |
| v_outgoing = (p_landing - p_racket) / dt_flight + np.array([0.0, 0.0, 0.5 * g_eff * dt_flight]) | |
| v_diff = v_outgoing - v_incoming | |
| norm_v_diff = np.linalg.norm(v_diff) | |
| if norm_v_diff < 1e-6: | |
| return np.zeros(3) | |
| u = v_diff / norm_v_diff | |
| v_racket_mag = (np.dot(v_outgoing, u) + Cr * np.dot(v_incoming, u)) / (1.0 + Cr) | |
| return v_racket_mag * u | |
| # ========================================== | |
| # 1. Fast ball trajectory predictor with table bounce model | |
| # ========================================== | |
| class FastTrajectoryPredictor: | |
| def __init__(self, table_height: float = 0.008): | |
| # Standard 2.7 g ball. Keep Cd/m equal to the 4.0 g calibration so | |
| # free-flight acceleration remains the trajectory-identified value. | |
| self.m = 0.0027 | |
| self.g = 9.81 | |
| # Identified from ball.txt using bidirectional, run-level holdout. | |
| # g + spin_az = 10.150 m/s² for the recorded ball population. | |
| self.spin_az = 0.340 # topspin Magnus downward accel (m/s²) | |
| self.Cd_x = 0.000447525 | |
| self.Cd_y = 0.000684450 | |
| self.Cd_z = 0.000590625 | |
| self.table_height = table_height | |
| # v_rebound = k * v_impact + b, fitted from 503 table impacts. | |
| self.k = np.array([0.735, 0.446, -0.993]) | |
| self.b = np.array([-0.110, -0.020, 0.0]) | |
| def predict_state_at_time(self, p0: np.ndarray, v0: np.ndarray, t: float): | |
| x0, y0, z0 = p0 | |
| Vx0, Vy0, Vz0 = v0 | |
| if t < 1e-9: return np.copy(p0), np.copy(v0) | |
| Vx = self.m * Vx0 / (self.Cd_x * Vx0 * t + self.m) if Vx0 > 0 else self.m * Vx0 / (self.m - self.Cd_x * Vx0 * t) | |
| x = x0 + (self.m / self.Cd_x) * np.log(1 + self.Cd_x * Vx0 * t / self.m) if Vx0 > 0 else x0 - (self.m / self.Cd_x) * np.log(1 - self.Cd_x * Vx0 * t / self.m) | |
| Vy = self.m * Vy0 / (self.Cd_y * Vy0 * t + self.m) if Vy0 > 0 else self.m * Vy0 / (self.m - self.Cd_y * Vy0 * t) | |
| y = y0 + (self.m / self.Cd_y) * np.log(1 + self.Cd_y * Vy0 * t / self.m) if Vy0 > 0 else y0 - (self.m / self.Cd_y) * np.log(1 - self.Cd_y * Vy0 * t / self.m) | |
| g_eff = self.g + self.spin_az | |
| mg_Cd = np.sqrt(self.m * g_eff / self.Cd_z) | |
| sqrt_term = np.sqrt(self.Cd_z * g_eff / self.m) | |
| if Vz0 >= 0: | |
| t_peak = (1 / sqrt_term) * np.arctan(Vz0 / mg_Cd) if mg_Cd > 1e-9 else 0.0 | |
| if t <= t_peak: | |
| Vz = mg_Cd * np.tan(-sqrt_term * t + np.arctan(Vz0 / mg_Cd)) | |
| log_val = np.cos(sqrt_term * t) + (Vz0 / mg_Cd) * np.sin(sqrt_term * t) | |
| z = z0 + (self.m / self.Cd_z) * np.log(log_val) if log_val > 1e-9 else z0 | |
| else: | |
| p_peak, v_peak = self.predict_state_at_time(p0, v0, t_peak) | |
| t_fall = t - t_peak | |
| exp_term = np.exp(2 * t_fall * sqrt_term) | |
| Vz = mg_Cd * (1 - exp_term) / (1 + exp_term) + v_peak[2] | |
| z = p_peak[2] - (self.m / (2 * self.Cd_z)) * np.log(((1 + exp_term) ** 2) / (4 * exp_term)) + v_peak[2] * t_fall | |
| else: | |
| ratio_v = np.clip(-Vz0 / mg_Cd, 0.0, 1.0 - 1e-6) | |
| phase = np.arctanh(ratio_v) | |
| Vz = -mg_Cd * np.tanh(sqrt_term * t + phase) | |
| z = z0 + (mg_Cd / sqrt_term) * (np.log(np.cosh(phase)) - np.log(np.cosh(sqrt_term * t + phase))) | |
| return np.array([x, y, z]), np.array([Vx, Vy, Vz]) | |
| def predict_hitting_point(self, p_init: np.ndarray, v_init: np.ndarray, target_x: float = -1.37, max_time: float = 2.0, dt: float = 0.01): | |
| t_total = 0.0 | |
| t_seg = 0.0 | |
| max_bounces = 3 | |
| bounce_count = 0 | |
| p0, v0 = np.copy(p_init), np.copy(v_init) | |
| p_curr, v_curr = p0, v0 | |
| if p0[2] < self.table_height: | |
| p0[2] = self.table_height | |
| p_curr = p0.copy() | |
| while t_total < max_time: | |
| t_seg += dt | |
| t_total += dt | |
| p_next, v_next = self.predict_state_at_time(p0, v0, t_seg) | |
| if p_curr[0] >= target_x and p_next[0] < target_x: | |
| ratio = (target_x - p_curr[0]) / (p_next[0] - p_curr[0]) | |
| hit_y = p_curr[1] + ratio * (p_next[1] - p_curr[1]) | |
| hit_z = p_curr[2] + ratio * (p_next[2] - p_curr[2]) | |
| hit_pos = np.array([target_x, hit_y, hit_z]) | |
| hit_vx = v_curr[0] + ratio * (v_next[0] - v_curr[0]) | |
| hit_vy = v_curr[1] + ratio * (v_next[1] - v_curr[1]) | |
| hit_vz = v_curr[2] + ratio * (v_next[2] - v_curr[2]) | |
| hit_vel = np.array([hit_vx, hit_vy, hit_vz]) | |
| hit_t = (t_total - dt) + ratio * dt | |
| return hit_pos, hit_vel, hit_t | |
| if p_curr[2] >= self.table_height and p_next[2] < self.table_height: | |
| ratio = (self.table_height - p_curr[2]) / (p_next[2] - p_curr[2]) | |
| bounce_t_seg = (t_seg - dt) + ratio * dt | |
| p_impact, v_impact = self.predict_state_at_time(p0, v0, bounce_t_seg) | |
| p_impact[2] = self.table_height | |
| v_reb = self.k * v_impact + self.b | |
| if abs(v_reb[2]) < 0.1: | |
| return None, None, None | |
| bounce_count += 1 | |
| if bounce_count > max_bounces: | |
| return None, None, None | |
| p0, v0 = p_impact, v_reb | |
| t_seg = 0.0 | |
| t_total = (t_total - dt) + ratio * dt | |
| p_next, v_next = p0, v0 | |
| p_curr, v_curr = p_next, v_next | |
| return None, None, None | |
| # ========================================== | |
| # 2. Main loop | |
| # ========================================== | |
| def main(args=None): | |
| parser = argparse.ArgumentParser(description="Table Tennis Strategy Node") | |
| parser.add_argument("--sim-ball-only", action="store_true", | |
| help="MuJoCo only: ignore robot mocap; publish idle packets before the first ball.") | |
| parser.add_argument("--topic", default="/wbc_racket_command") | |
| parser.add_argument("--zmq-endpoint", default="tcp://127.0.0.1:5556", | |
| help="Adapter endpoint; one-click deployment passes its private endpoint.") | |
| parser.add_argument("--debug-log", default=None, | |
| help="Optional path for command publish logs, e.g. /tmp/tmp.txt") | |
| parser.add_argument("--publish-hz", type=float, default=500.0, | |
| help="Maximum command publish rate. Use <=0 to disable rate limiting.") | |
| parser.add_argument( | |
| "--gate-log-interval", | |
| type=float, | |
| default=1.0, | |
| help="Seconds between prediction-gate summaries. Use <=0 to disable them.", | |
| ) | |
| parser.add_argument( | |
| "--robot-base-origin-world", type=float, nargs=3, default=(-1.87, 0.0, -0.76), | |
| metavar=("X", "Y", "Z"), help="Ready-pose origin in the mocap world frame.", | |
| ) | |
| parser.add_argument( | |
| "--hit-plane-x", type=float, default=0.40, | |
| help="Policy hit plane x in the robot court frame.", | |
| ) | |
| parser.add_argument( | |
| "--hit-y-range", type=float, nargs=2, default=(-0.76, 0.76), | |
| metavar=("MIN", "MAX"), help="Accepted policy hit y range in the robot court frame.", | |
| ) | |
| parser.add_argument( | |
| "--hit-z-range", type=float, nargs=2, default=(0.80, 1.50), | |
| metavar=("MIN", "MAX"), help="Accepted policy hit z range in the robot court frame.", | |
| ) | |
| parser.add_argument( | |
| "--lock-time-threshold", type=float, default=0.52, | |
| help="Start publishing an active strike command below this time-to-hit.", | |
| ) | |
| parser.add_argument( | |
| "--prediction-model", choices=("legacy-v2", "deploy-closd"), default="legacy-v2", | |
| help="Ball flight/bounce model. legacy-v2 matches the default MuJoCo replay model.", | |
| ) | |
| parser.add_argument( | |
| "--incoming-velocity-bias", type=float, nargs=3, default=None, | |
| metavar=("VX", "VY", "VZ"), | |
| help=( | |
| "Additive hit-plane velocity calibration in m/s. The default is " | |
| "the recorded-ball legacy-v2 calibration, or zero for deploy-closd." | |
| ), | |
| ) | |
| cli_args, remaining = parser.parse_known_args(args=args) | |
| hit_y_range = tuple(sorted(map(float, cli_args.hit_y_range))) | |
| hit_z_range = tuple(sorted(map(float, cli_args.hit_z_range))) | |
| if hit_y_range[0] >= hit_y_range[1]: | |
| parser.error("--hit-y-range must have MIN < MAX") | |
| if hit_z_range[0] >= hit_z_range[1]: | |
| parser.error("--hit-z-range must have MIN < MAX") | |
| if cli_args.lock_time_threshold <= 0.0: | |
| parser.error("--lock-time-threshold must be positive") | |
| verified = verify_artifacts() | |
| if "models/real_ball_hybrid_bounce.json" not in verified: | |
| raise RuntimeError("the default calibrated ball model is not pinned in ARTIFACTS.sha256") | |
| rclpy.init(args=remaining) | |
| ros_node = rclpy.create_node('table_tennis_commander_node') | |
| cmd_pub = ros_node.create_publisher(Float64MultiArray, cli_args.topic, 10) | |
| ros_node.get_logger().info( | |
| f"🏓 策略节点启动,将在 {cli_args.topic} 持续发布 H2 v3 指令 (Size=27)..." | |
| ) | |
| ZMQ_POLL_TIMEOUT_MS = 1 | |
| context = zmq.Context() | |
| socket = make_latest_sub_socket(context, cli_args.zmq_endpoint) | |
| poller = zmq.Poller() | |
| poller.register(socket, zmq.POLLIN) | |
| print("[系统初始化] 已连接动捕数据源,等待接收数据...") | |
| predictor = ( | |
| LegacyV2HitPlanePredictor() | |
| if cli_args.prediction_model == "legacy-v2" | |
| else FastTrajectoryPredictor(table_height=0.008) | |
| ) | |
| incoming_velocity_bias = np.asarray( | |
| cli_args.incoming_velocity_bias | |
| if cli_args.incoming_velocity_bias is not None | |
| else ( | |
| LEGACY_V2_HIT_VELOCITY_BIAS | |
| if cli_args.prediction_model == "legacy-v2" | |
| else np.zeros(3, dtype=float) | |
| ), | |
| dtype=float, | |
| ).reshape(3) | |
| ROBOT_BASE_ORIGIN_WORLD = np.asarray(cli_args.robot_base_origin_world, dtype=float) | |
| HIT_Y_RANGE = hit_y_range | |
| HIT_Z_RANGE = hit_z_range | |
| TARGET_HIT_X = float(ROBOT_BASE_ORIGIN_WORLD[0] + cli_args.hit_plane_x) | |
| LOCK_TIME_THRESHOLD = float(cli_args.lock_time_threshold) | |
| marker_phase = CausalMarkerBallPhaseGate(target_hit_x=TARGET_HIT_X) | |
| gate_diagnostics = PredictionGateDiagnostics(cli_args.gate_log_interval) | |
| p_landing_target = np.array([0.685, 0.0, 0.02]) | |
| desired_flight_time = 0.6 | |
| ball_incoming = False | |
| has_crossed_target = False | |
| pending_lock = False | |
| rally_count = 0 | |
| publish_count = 0 | |
| # Once a valid prediction exists, keep publishing the latest predicted | |
| # command instead of falling back to default between rallies. | |
| has_ever_predicted = False | |
| locked_hit_pos = None | |
| locked_absolute_hit_time = None | |
| locked_p_racket_relative = None | |
| locked_v_racket_cmd_relative = None | |
| locked_incoming_vel = None | |
| tentative_hit_pos = None | |
| tentative_hit_vel = None | |
| tentative_absolute_hit_time = None | |
| tentative_stamp = None | |
| tentative_ball_pos = None | |
| last_locked_tentative_stamp = None | |
| prediction_stabilizer = None | |
| domain_reject_reported = False | |
| prev_pos_m = None | |
| prev_mocap_time = None # Used to interpolate the real crossing time. | |
| current_robot_base_pos = np.array([-1.8, 0.0, 0.0]) | |
| current_robot_base_quat = np.array([0.0, 0.0, 0.0, 1.0]) | |
| last_ball_t = float("-inf") | |
| last_robot_t = float("-inf") | |
| last_robot_source_mono = 0.0 | |
| last_ball_source_mono = 0.0 | |
| last_mocap_time = None | |
| wall_at_last_mocap = None | |
| wall_time_at_lock = None | |
| mocap_time_at_lock = None | |
| # Default command published until the first valid strike prediction is | |
| # available. The current mocap base pose is still forwarded each cycle. | |
| # Keep the ROS topic in the same post-hit/frozen TTH domain seen by the | |
| # deployed actor during training. The active command continues through | |
| # zero, then saturates at the checkpoint's frozen-command value. | |
| DEFAULT_T_STRIKE = -0.59 | |
| DEFAULT_P_RACKET_RELATIVE = np.array([cli_args.hit_plane_x, 0.0, 1.0], dtype=float) | |
| DEFAULT_V_RACKET_RELATIVE = np.array([1.1, 0.0, 0.3], dtype=float) | |
| print( | |
| "[策略域] " | |
| f"model={cli_args.prediction_model}, " | |
| f"vin_bias={np.array2string(incoming_velocity_bias, precision=3)}, " | |
| f"world_x={TARGET_HIT_X:.3f}, court_x={cli_args.hit_plane_x:.3f}, " | |
| f"y=[{HIT_Y_RANGE[0]:.2f},{HIT_Y_RANGE[1]:.2f}], " | |
| f"z=[{HIT_Z_RANGE[0]:.2f},{HIT_Z_RANGE[1]:.2f}]" | |
| ) | |
| if cli_args.gate_log_interval > 0.0: | |
| print( | |
| f"[PREDICT GATES] 每 {cli_args.gate_log_interval:.1f}s 汇总一次;" | |
| "计数单位是动捕帧;velocity/phase/observation 表示输入首先未通过的门," | |
| "其余是后续预测与锁定状态,不等同于丢球数量。" | |
| ) | |
| debug_log = open(cli_args.debug_log, "w", buffering=1) if cli_args.debug_log else None | |
| if debug_log is not None: | |
| debug_log.write("# timestamp ball_count t_strike " | |
| "base_x base_y base_z qx qy qz qw " | |
| "p_x p_y p_z v_racket_x v_racket_y v_racket_z " | |
| "v_in_x v_in_y v_in_z\n") | |
| ros_msg = Float64MultiArray() | |
| publish_period = 1.0 / cli_args.publish_hz if cli_args.publish_hz > 0.0 else 0.0 | |
| next_publish_mono = 0.0 | |
| try: | |
| while rclpy.ok(): | |
| latest_robot_msg = None | |
| latest_ball_msg = None | |
| if poller.poll(ZMQ_POLL_TIMEOUT_MS): | |
| try: | |
| msg = socket.recv_pyobj(flags=zmq.NOBLOCK) | |
| except zmq.Again: | |
| msg = None | |
| if msg is not None: | |
| if msg.get("type") == "mocap": | |
| robot_msg = None if cli_args.sim_ball_only else msg.get("robot") | |
| ball_msg = msg.get("ball") | |
| if robot_msg is not None: | |
| robot_t = float(robot_msg["t"]) | |
| if robot_t > last_robot_t: | |
| latest_robot_msg = robot_msg | |
| last_robot_t = robot_t | |
| if ball_msg is not None: | |
| ball_t = float(ball_msg["t"]) | |
| if ball_t > last_ball_t: | |
| latest_ball_msg = ball_msg | |
| last_ball_t = ball_t | |
| if latest_robot_msg is not None: | |
| current_robot_base_pos = np.array(latest_robot_msg['pos'], dtype=float) / 1000.0 | |
| current_robot_base_quat = np.array(latest_robot_msg['quat'], dtype=float) | |
| wall_now = time.time() | |
| mono_now = time.monotonic() | |
| if latest_robot_msg is not None: | |
| last_robot_source_mono = mono_now | |
| if latest_ball_msg is not None: | |
| last_ball_source_mono = mono_now | |
| if latest_ball_msg is not None: | |
| current_time = latest_ball_msg['t'] | |
| pos_m = np.array(latest_ball_msg['pos'], dtype=float) / 1000.0 | |
| vel_m = np.array(latest_ball_msg['vel'], dtype=float) / 1000.0 | |
| gate_diagnostics.mark("ball_samples") | |
| phase_transition = marker_phase.update(current_time, pos_m) | |
| if phase_transition is not None: | |
| previous_phase, current_phase = phase_transition | |
| print( | |
| f"[MARKER PHASE] {previous_phase}->{current_phase} " | |
| f"vx={marker_phase.estimated_vx:+.3f}m/s x={pos_m[0]:+.3f}" | |
| ) | |
| velocity_valid = bool(latest_ball_msg.get("vel_valid", True)) | |
| prediction_input_ready = False | |
| if not velocity_valid: | |
| gate_diagnostics.mark("velocity_invalid") | |
| elif not marker_phase.allow_prediction_update: | |
| phase_reason = { | |
| marker_phase.WAITING: "phase_waiting", | |
| marker_phase.INCOMING: "phase_incoming_slow", | |
| marker_phase.OUTGOING: "phase_outgoing", | |
| marker_phase.MISSED: "phase_missed", | |
| }[marker_phase.state] | |
| gate_diagnostics.mark(phase_reason) | |
| elif not TARGET_HIT_X < pos_m[0] < 1.37: | |
| gate_diagnostics.mark("observation_x") | |
| elif not (-1.525 / 2) < pos_m[1] < (1.525 / 2): | |
| gate_diagnostics.mark("observation_y") | |
| elif not 0.0 < pos_m[2] < 1.0: | |
| gate_diagnostics.mark("observation_z") | |
| else: | |
| gate_diagnostics.mark("input_pass") | |
| prediction_input_ready = True | |
| if prediction_input_ready: | |
| if not ball_incoming: | |
| ball_incoming = True | |
| has_crossed_target = False | |
| pending_lock = False | |
| publish_count = -1 | |
| prev_pos_m = None | |
| prev_mocap_time = None | |
| tentative_hit_pos = None | |
| tentative_hit_vel = None | |
| tentative_absolute_hit_time = None | |
| tentative_stamp = None | |
| tentative_ball_pos = None | |
| last_locked_tentative_stamp = None | |
| prediction_stabilizer = CausalPredictionStabilizer() | |
| domain_reject_reported = False | |
| print(f"\n[追踪中] 发现新来球,开始追踪...") | |
| if pos_m[0] > TARGET_HIT_X: | |
| hit_pos, hit_vel, flight_time = predictor.predict_hitting_point( | |
| pos_m, vel_m, target_x=TARGET_HIT_X | |
| ) | |
| if hit_vel is not None: | |
| hit_vel = np.asarray(hit_vel, dtype=float) + incoming_velocity_bias | |
| if hit_pos is None: | |
| gate_diagnostics.mark("predictor_no_hit") | |
| elif hit_vel is None or flight_time is None or not ( | |
| np.isfinite(hit_pos).all() | |
| and np.isfinite(hit_vel).all() | |
| and math.isfinite(float(flight_time)) | |
| ): | |
| gate_diagnostics.mark("predictor_nonfinite") | |
| hit_pos = None | |
| elif pos_m[2] < 0.1: | |
| gate_diagnostics.mark("ball_too_low") | |
| hit_pos = None | |
| if hit_pos is not None: | |
| hit_pos_relative = hit_pos - ROBOT_BASE_ORIGIN_WORLD | |
| in_policy_domain = ( | |
| HIT_Y_RANGE[0] <= hit_pos_relative[1] <= HIT_Y_RANGE[1] | |
| and HIT_Z_RANGE[0] <= hit_pos_relative[2] <= HIT_Z_RANGE[1] | |
| ) | |
| if not in_policy_domain: | |
| gate_diagnostics.mark("policy_domain") | |
| if not domain_reject_reported: | |
| print( | |
| "[PREDICT REJECT] 击球点超出策略域 | " | |
| f"y={hit_pos_relative[1]:+.3f} " | |
| f"z={hit_pos_relative[2]:+.3f}" | |
| ) | |
| domain_reject_reported = True | |
| hit_pos = None | |
| if hit_pos is not None and prediction_stabilizer is not None: | |
| # Use exactly the same causal median/innovation/EMA | |
| # filter as recorded-ball MuJoCo replay. Filtering | |
| # absolute impact time preserves a smooth countdown; | |
| # filtering hit velocity removes the remaining command | |
| # jerk that the former position-only EMA did not cover. | |
| stable = prediction_stabilizer.update( | |
| current_time, | |
| current_time + flight_time, | |
| hit_pos, | |
| hit_vel, | |
| ) | |
| if stable is None: | |
| gate_diagnostics.mark("stabilizer_warmup") | |
| gate_diagnostics.report_if_due() | |
| continue | |
| gate_diagnostics.mark("stable_prediction") | |
| ( | |
| tentative_absolute_hit_time, | |
| tentative_hit_pos, | |
| tentative_hit_vel, | |
| ) = stable | |
| tentative_stamp = current_time | |
| tentative_ball_pos = pos_m.copy() | |
| # Post-check when the real ball crosses the target_x plane. | |
| if ball_incoming and not has_crossed_target and prev_pos_m is not None: | |
| if prev_pos_m[0] >= TARGET_HIT_X and pos_m[0] < TARGET_HIT_X: | |
| has_crossed_target = True | |
| ball_incoming = False | |
| # Interpolate the real crossing position. | |
| ratio = (TARGET_HIT_X - prev_pos_m[0]) / (pos_m[0] - prev_pos_m[0]) | |
| real_hit_y = prev_pos_m[1] + ratio * (pos_m[1] - prev_pos_m[1]) | |
| real_hit_z = prev_pos_m[2] + ratio * (pos_m[2] - prev_pos_m[2]) | |
| # Interpolate the real crossing mocap timestamp. | |
| if prev_mocap_time is not None: | |
| real_hit_time = prev_mocap_time + ratio * (current_time - prev_mocap_time) | |
| else: | |
| real_hit_time = current_time # fallback | |
| print(f"[Ball #{rally_count}] 事后校验 | " | |
| f"真实穿越 Y={real_hit_y:.4f} Z={real_hit_z:.4f} " | |
| f"T={real_hit_time:.4f}s", end="") | |
| if pending_lock and locked_hit_pos is not None and locked_absolute_hit_time is not None: | |
| error_y = real_hit_y - locked_hit_pos[1] | |
| error_z = real_hit_z - locked_hit_pos[2] | |
| time_error_ms = (locked_absolute_hit_time - real_hit_time) * 1000 | |
| print(f" | 位置误差 {math.hypot(error_y, error_z) * 1000:.1f}mm" | |
| f" (dY={error_y * 1000:+.1f} dZ={error_z * 1000:+.1f})" | |
| f" | 时间误差 {time_error_ms:+.1f}ms") | |
| else: | |
| gate_diagnostics.mark("crossed_without_lock") | |
| print(" | [PREDICT MISS] 穿越击球面前未锁定命令") | |
| if marker_phase.terminal and ball_incoming: | |
| ball_incoming = False | |
| prev_pos_m = pos_m.copy() | |
| prev_mocap_time = current_time # Previous mocap timestamp. | |
| last_mocap_time = current_time | |
| wall_at_last_mocap = wall_now | |
| if ball_incoming and tentative_absolute_hit_time is not None \ | |
| and last_mocap_time is not None and wall_at_last_mocap is not None: | |
| if latest_ball_msg is not None: | |
| estimated_mocap_now = last_mocap_time | |
| else: | |
| estimated_mocap_now = last_mocap_time + (wall_now - wall_at_last_mocap) | |
| t_to_hit = tentative_absolute_hit_time - estimated_mocap_now | |
| first_lock = (not pending_lock) and (0.0 < t_to_hit <= LOCK_TIME_THRESHOLD) | |
| refresh_lock = pending_lock and (tentative_stamp != last_locked_tentative_stamp) | |
| if latest_ball_msg is not None and not pending_lock: | |
| if t_to_hit > LOCK_TIME_THRESHOLD: | |
| gate_diagnostics.mark("wait_lock_far") | |
| elif t_to_hit <= 0.0: | |
| gate_diagnostics.mark("lock_too_late") | |
| if first_lock or refresh_lock: | |
| locked_hit_pos = tentative_hit_pos.copy() | |
| locked_absolute_hit_time = tentative_absolute_hit_time | |
| locked_incoming_vel = tentative_hit_vel.copy() | |
| locked_v_racket_cmd = calculate_racket_command( | |
| p_racket=locked_hit_pos, | |
| v_incoming=tentative_hit_vel, | |
| p_landing=p_landing_target, | |
| dt_flight=desired_flight_time, | |
| Cr=0.70, | |
| predictor=predictor, | |
| ) | |
| locked_p_racket_relative = locked_hit_pos - ROBOT_BASE_ORIGIN_WORLD | |
| locked_v_racket_cmd_relative = locked_v_racket_cmd.copy() | |
| # locked_v_racket_cmd_relative[0] += 0.6 | |
| has_ever_predicted = True | |
| last_locked_tentative_stamp = tentative_stamp | |
| wall_time_at_lock = wall_now | |
| mocap_time_at_lock = estimated_mocap_now | |
| if first_lock: | |
| gate_diagnostics.mark("first_lock") | |
| pending_lock = True | |
| publish_count = 0 | |
| gap_note = "" if latest_ball_msg is not None else "(丢帧期间由时间推算触发)" | |
| bp = tentative_ball_pos | |
| print(f"[追踪中] 首次锁定{gap_note} | t_to_hit={t_to_hit:.3f}s | " | |
| f"击球点 Y={locked_hit_pos[1]:.3f} Z={locked_hit_pos[2]:.3f} | " | |
| f"球位 ({bp[0]:+.3f}, {bp[1]:+.3f}, {bp[2]:+.3f})") | |
| else: | |
| gate_diagnostics.mark("lock_refresh") | |
| gate_diagnostics.report_if_due() | |
| # The planner-v3 packet deliberately rejects absent/non-positive | |
| # source timestamps. Keep the zero sentinels until real frames | |
| # arrive and publish nothing until *both* sources have done so; | |
| # substituting the loop clock here would fabricate freshness. | |
| if not cli_args.sim_ball_only and not _source_frames_ready(last_robot_source_mono, last_ball_source_mono): | |
| continue | |
| if publish_period > 0.0 and mono_now < next_publish_mono: | |
| continue | |
| next_publish_mono = mono_now + publish_period | |
| # Preserve the legacy first 14 values and append the fields needed | |
| # by the learned policy: ball count, predicted incoming velocity at | |
| # the hit plane, and the landing target in the robot court frame. | |
| # Before the first prediction, publish default pose/velocity with | |
| # t_strike=-0.59. Afterward, keep using the latest locked command | |
| # and let t_strike decay down to DEFAULT_T_STRIKE. | |
| publish_t_strike = DEFAULT_T_STRIKE | |
| p_cmd_publish = DEFAULT_P_RACKET_RELATIVE | |
| v_cmd_publish = DEFAULT_V_RACKET_RELATIVE | |
| incoming_vel_publish = np.zeros(3, dtype=float) | |
| has_prediction = ( | |
| has_ever_predicted | |
| and locked_hit_pos is not None | |
| and locked_absolute_hit_time is not None | |
| ) | |
| if has_prediction: | |
| if latest_ball_msg is not None: | |
| dynamic_t_strike = locked_absolute_hit_time - current_time | |
| else: | |
| elapsed_wall = wall_now - wall_time_at_lock | |
| estimated_mocap_time = mocap_time_at_lock + elapsed_wall | |
| dynamic_t_strike = locked_absolute_hit_time - estimated_mocap_time | |
| # Clamp outward t_strike while keeping the latest predicted | |
| # command after the strike time has passed. | |
| publish_t_strike = float(np.clip(dynamic_t_strike, DEFAULT_T_STRIKE, 0.8)) | |
| p_cmd_publish = np.array([ | |
| float(cli_args.hit_plane_x), | |
| float(locked_p_racket_relative[1]), | |
| float(locked_p_racket_relative[2]), | |
| ]) | |
| # Work on a copy: this block runs once per publish cycle, so | |
| # incrementing the stored command in place accumulated the offset | |
| # a non-deterministic number of times between mocap frames. | |
| v_cmd_raw = np.array(locked_v_racket_cmd_relative, dtype=float) | |
| incoming_vel_publish = np.array(locked_incoming_vel, dtype=float) | |
| if abs(p_cmd_publish[1]) > 0.20 and p_cmd_publish[2] < 0.91: | |
| v_cmd_raw[0] += 0.4 | |
| if p_cmd_publish[2] < 0.91: | |
| v_cmd_publish = np.array([ | |
| np.clip(v_cmd_raw[0], 1.31, 2.99), | |
| np.clip(v_cmd_raw[1], -0.09, 0.09), | |
| np.clip(v_cmd_raw[2], 0.01, 1.09) | |
| ]) | |
| else: | |
| v_cmd_publish = np.array([ | |
| np.clip(v_cmd_raw[0], 1.31, 2.99), | |
| np.clip(v_cmd_raw[1], -0.09, 0.09), | |
| np.clip(v_cmd_raw[2], 0.01, 0.39) | |
| ]) | |
| published_ball_count = rally_count + int(has_prediction and publish_count == 0) | |
| landing_xy_relative = p_landing_target[:2] - ROBOT_BASE_ORIGIN_WORLD[:2] | |
| cur_msg = build_h2_planner_packet( | |
| robot_pos_world=current_robot_base_pos, | |
| robot_quat_xyzw=current_robot_base_quat, | |
| time_to_hit_s=publish_t_strike, | |
| hit_pos_robot=p_cmd_publish, | |
| racket_velocity_robot=v_cmd_publish, | |
| command_id=published_ball_count, | |
| incoming_velocity_robot=incoming_vel_publish, | |
| landing_xy_robot=landing_xy_relative, | |
| robot_source_monotonic_s=last_robot_source_mono, | |
| ball_source_monotonic_s=last_ball_source_mono, | |
| robot_base_origin_world=ROBOT_BASE_ORIGIN_WORLD, | |
| allow_missing_sources=cli_args.sim_ball_only, | |
| ).tolist() | |
| if len(cur_msg) != H2_PLANNER_PACKET_LEN: | |
| raise RuntimeError(f"internal H2 planner packet length changed: {len(cur_msg)}") | |
| ros_msg.data = cur_msg | |
| cmd_pub.publish(ros_msg) | |
| if debug_log is not None: | |
| debug_log.write( | |
| f"{wall_now:.4f} ball={published_ball_count} " | |
| f"t_strike={publish_t_strike:.4f} " | |
| f"base=[{cur_msg[0]:.3f},{cur_msg[1]:.3f},{cur_msg[2]:.3f}] " | |
| f"quat=[{cur_msg[3]:.3f},{cur_msg[4]:.3f},{cur_msg[5]:.3f},{cur_msg[6]:.3f}] " | |
| f"p=[{p_cmd_publish[0]:.3f},{p_cmd_publish[1]:.3f},{p_cmd_publish[2]:.3f}] " | |
| f"v_racket=[{v_cmd_publish[0]:.3f},{v_cmd_publish[1]:.3f},{v_cmd_publish[2]:.3f}] " | |
| f"v_in=[{incoming_vel_publish[0]:.3f},{incoming_vel_publish[1]:.3f},{incoming_vel_publish[2]:.3f}]\n" | |
| ) | |
| if has_prediction and publish_count >= 0: | |
| publish_count += 1 | |
| if publish_count == 1: | |
| rally_count += 1 | |
| print(f"\n[Ball #{rally_count}] 首次发布成功 | " | |
| f"击球点 Y={locked_p_racket_relative[1]:.3f} " | |
| f"Z={locked_p_racket_relative[2]:.3f}") | |
| except KeyboardInterrupt: | |
| print("\n\n[系统退出] 检测到中止信号 (Ctrl+C),正在安全关闭。") | |
| finally: | |
| gate_diagnostics.report_total() | |
| if debug_log is not None: | |
| debug_log.close() | |
| socket.close(0) | |
| context.term() | |
| ros_node.destroy_node() | |
| rclpy.try_shutdown() | |
| if __name__ == "__main__": | |
| main() | |