PingPongRealWorldRL / predictor.py
KurisiTOMA's picture
chore: publish H2 real-world RL system
d8290eb verified
Raw History Blame Contribute Delete
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()