2026-08-10 10:10:21 +08:00
|
|
|
|
"""为新版控制器实验CSV生成包含轨迹、误差、速度和转角的六子图总图。"""
|
2026-08-06 15:14:12 +08:00
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import argparse
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
|
import matplotlib
|
|
|
|
|
|
|
|
|
|
|
|
matplotlib.use("Agg")
|
|
|
|
|
|
import matplotlib.pyplot as plt
|
|
|
|
|
|
import numpy as np
|
|
|
|
|
|
import pandas as pd
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
2026-08-19 17:38:17 +08:00
|
|
|
|
LATERAL_JUMP_THRESHOLD_METERS = 0.03
|
|
|
|
|
|
JUMP_INSET_CONTEXT_SAMPLES = 6
|
|
|
|
|
|
MAXIMUM_PLAUSIBLE_LINEAR_SPEED_METERS_PER_SECOND = 1.20
|
|
|
|
|
|
POSITION_JUMP_MARGIN_METERS = 0.03
|
2026-08-06 15:14:12 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def configure_matplotlib() -> None:
|
|
|
|
|
|
"""配置可显示中文和负号的Matplotlib字体。"""
|
|
|
|
|
|
plt.rcParams["font.sans-serif"] = [
|
|
|
|
|
|
"Microsoft YaHei",
|
|
|
|
|
|
"SimHei",
|
|
|
|
|
|
"Noto Sans CJK SC",
|
|
|
|
|
|
"Arial Unicode MS",
|
|
|
|
|
|
"DejaVu Sans",
|
|
|
|
|
|
]
|
|
|
|
|
|
plt.rcParams["axes.unicode_minus"] = False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def numeric_column(
|
|
|
|
|
|
frame: pd.DataFrame,
|
|
|
|
|
|
name: str,
|
|
|
|
|
|
default: float = np.nan,
|
|
|
|
|
|
) -> np.ndarray:
|
|
|
|
|
|
"""将CSV列安全转换为浮点数组,缺失列使用指定默认值。"""
|
|
|
|
|
|
if name not in frame.columns:
|
|
|
|
|
|
return np.full(len(frame), default, dtype=float)
|
|
|
|
|
|
return pd.to_numeric(frame[name], errors="coerce").to_numpy(
|
|
|
|
|
|
dtype=float,
|
|
|
|
|
|
copy=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def first_finite(values: np.ndarray, default: float) -> float:
|
|
|
|
|
|
"""读取数组中的第一个有限值。"""
|
|
|
|
|
|
finite = values[np.isfinite(values)]
|
|
|
|
|
|
return float(finite[0]) if finite.size else default
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def first_text(frame: pd.DataFrame, name: str, default: str) -> str:
|
|
|
|
|
|
"""读取文本元数据列中的第一个非空值。"""
|
|
|
|
|
|
if name not in frame.columns:
|
|
|
|
|
|
return default
|
|
|
|
|
|
values = frame[name].dropna().astype(str)
|
|
|
|
|
|
values = values[values.str.strip() != ""]
|
|
|
|
|
|
return values.iloc[0] if not values.empty else default
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-19 17:38:17 +08:00
|
|
|
|
def text_column(frame: pd.DataFrame, name: str) -> np.ndarray:
|
|
|
|
|
|
"""读取用于诊断标注的原始文本列,缺失值转换为空字符串。"""
|
|
|
|
|
|
if name not in frame.columns:
|
|
|
|
|
|
return np.full(len(frame), "", dtype=object)
|
|
|
|
|
|
return frame[name].fillna("").astype(str).to_numpy(
|
|
|
|
|
|
dtype=object,
|
|
|
|
|
|
copy=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-06 15:14:12 +08:00
|
|
|
|
def fill_reference_series(
|
|
|
|
|
|
values: np.ndarray,
|
|
|
|
|
|
fallback: np.ndarray,
|
|
|
|
|
|
) -> np.ndarray:
|
|
|
|
|
|
"""前后填充后台采样得到的控制参考值,缺失时使用解析速度曲线。"""
|
|
|
|
|
|
series = pd.Series(values, dtype=float)
|
|
|
|
|
|
filled = series.ffill().bfill().to_numpy(
|
|
|
|
|
|
dtype=float,
|
|
|
|
|
|
copy=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
missing = ~np.isfinite(filled)
|
|
|
|
|
|
filled[missing] = fallback[missing]
|
|
|
|
|
|
return filled
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def planned_motion(
|
|
|
|
|
|
time_seconds: np.ndarray,
|
|
|
|
|
|
length_meters: float,
|
|
|
|
|
|
cruise_speed_mps: float,
|
|
|
|
|
|
acceleration_mps2: float,
|
|
|
|
|
|
deceleration_mps2: float,
|
|
|
|
|
|
) -> tuple[np.ndarray, np.ndarray]:
|
|
|
|
|
|
"""计算从静止出发并在终点静止的梯形或三角形理想时间速度轨迹。"""
|
|
|
|
|
|
acceleration_distance = (
|
|
|
|
|
|
cruise_speed_mps**2 / (2.0 * acceleration_mps2)
|
|
|
|
|
|
)
|
|
|
|
|
|
deceleration_distance = (
|
|
|
|
|
|
cruise_speed_mps**2 / (2.0 * deceleration_mps2)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if acceleration_distance + deceleration_distance <= length_meters:
|
|
|
|
|
|
peak_speed = cruise_speed_mps
|
|
|
|
|
|
else:
|
|
|
|
|
|
peak_speed = np.sqrt(
|
|
|
|
|
|
2.0
|
|
|
|
|
|
* length_meters
|
|
|
|
|
|
/ (1.0 / acceleration_mps2 + 1.0 / deceleration_mps2)
|
|
|
|
|
|
)
|
|
|
|
|
|
acceleration_distance = (
|
|
|
|
|
|
peak_speed**2 / (2.0 * acceleration_mps2)
|
|
|
|
|
|
)
|
|
|
|
|
|
deceleration_distance = (
|
|
|
|
|
|
peak_speed**2 / (2.0 * deceleration_mps2)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
acceleration_time = peak_speed / acceleration_mps2
|
|
|
|
|
|
deceleration_time = peak_speed / deceleration_mps2
|
|
|
|
|
|
cruise_distance = max(
|
|
|
|
|
|
0.0,
|
|
|
|
|
|
length_meters - acceleration_distance - deceleration_distance,
|
|
|
|
|
|
)
|
|
|
|
|
|
cruise_time = cruise_distance / peak_speed
|
|
|
|
|
|
deceleration_start_time = acceleration_time + cruise_time
|
|
|
|
|
|
finish_time = deceleration_start_time + deceleration_time
|
|
|
|
|
|
|
|
|
|
|
|
progress = np.zeros_like(time_seconds, dtype=float)
|
|
|
|
|
|
speed = np.zeros_like(time_seconds, dtype=float)
|
|
|
|
|
|
|
|
|
|
|
|
accelerating = time_seconds <= acceleration_time
|
|
|
|
|
|
progress[accelerating] = (
|
|
|
|
|
|
0.5 * acceleration_mps2 * time_seconds[accelerating] ** 2
|
|
|
|
|
|
)
|
|
|
|
|
|
speed[accelerating] = acceleration_mps2 * time_seconds[accelerating]
|
|
|
|
|
|
|
|
|
|
|
|
cruising = (
|
|
|
|
|
|
(time_seconds > acceleration_time)
|
|
|
|
|
|
& (time_seconds <= deceleration_start_time)
|
|
|
|
|
|
)
|
|
|
|
|
|
progress[cruising] = (
|
|
|
|
|
|
acceleration_distance
|
|
|
|
|
|
+ peak_speed * (time_seconds[cruising] - acceleration_time)
|
|
|
|
|
|
)
|
|
|
|
|
|
speed[cruising] = peak_speed
|
|
|
|
|
|
|
|
|
|
|
|
decelerating = (
|
|
|
|
|
|
(time_seconds > deceleration_start_time)
|
|
|
|
|
|
& (time_seconds <= finish_time)
|
|
|
|
|
|
)
|
|
|
|
|
|
remaining_time = finish_time - time_seconds[decelerating]
|
|
|
|
|
|
progress[decelerating] = (
|
|
|
|
|
|
length_meters
|
|
|
|
|
|
- 0.5 * deceleration_mps2 * remaining_time**2
|
|
|
|
|
|
)
|
|
|
|
|
|
speed[decelerating] = deceleration_mps2 * remaining_time
|
|
|
|
|
|
|
|
|
|
|
|
finished = time_seconds > finish_time
|
|
|
|
|
|
progress[finished] = length_meters
|
|
|
|
|
|
speed[finished] = 0.0
|
|
|
|
|
|
return progress, speed
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def load_experiment(csv_path: Path) -> dict[str, object]:
|
|
|
|
|
|
"""读取新版CSV并构造绘图所需的统一SI单位数据。"""
|
|
|
|
|
|
frame = pd.read_csv(csv_path, encoding="utf-8-sig")
|
|
|
|
|
|
if frame.empty:
|
|
|
|
|
|
raise ValueError("CSV没有任何采样行。")
|
|
|
|
|
|
|
|
|
|
|
|
time_seconds = numeric_column(frame, "ElapsedSeconds")
|
|
|
|
|
|
valid_time = np.isfinite(time_seconds)
|
|
|
|
|
|
frame = frame.loc[valid_time].reset_index(drop=True)
|
|
|
|
|
|
time_seconds = time_seconds[valid_time]
|
|
|
|
|
|
if time_seconds.size < 2:
|
|
|
|
|
|
raise ValueError("CSV中的有效时间采样不足2帧。")
|
|
|
|
|
|
time_seconds = time_seconds - time_seconds[0]
|
|
|
|
|
|
|
|
|
|
|
|
state_x = numeric_column(frame, "StateXMeters")
|
|
|
|
|
|
state_y = numeric_column(frame, "StateYMeters")
|
|
|
|
|
|
has_processed = numeric_column(frame, "HasProcessedState", 0.0) > 0.5
|
|
|
|
|
|
processed_valid = has_processed & np.isfinite(state_x) & np.isfinite(state_y)
|
|
|
|
|
|
|
|
|
|
|
|
raw_x_meters = numeric_column(frame, "DetourX") / 1000.0
|
|
|
|
|
|
raw_y_meters = numeric_column(frame, "DetourY") / 1000.0
|
2026-08-19 17:38:17 +08:00
|
|
|
|
valid_raw_position = (
|
|
|
|
|
|
np.isfinite(raw_x_meters) & np.isfinite(raw_y_meters)
|
|
|
|
|
|
)
|
|
|
|
|
|
detour_tick_raw = text_column(frame, "DetourTickRaw")
|
|
|
|
|
|
detour_l_step = numeric_column(frame, "DetourLStep")
|
2026-08-06 15:14:12 +08:00
|
|
|
|
actual_x = np.where(processed_valid, state_x, raw_x_meters)
|
|
|
|
|
|
actual_y = np.where(processed_valid, state_y, raw_y_meters)
|
|
|
|
|
|
valid_position = np.isfinite(actual_x) & np.isfinite(actual_y)
|
|
|
|
|
|
if np.count_nonzero(valid_position) < 2:
|
|
|
|
|
|
raise ValueError("CSV中没有足够的有效车辆位置。")
|
|
|
|
|
|
|
|
|
|
|
|
start = np.array(
|
|
|
|
|
|
[
|
|
|
|
|
|
first_finite(numeric_column(frame, "ReferenceStartX"), np.nan),
|
|
|
|
|
|
first_finite(numeric_column(frame, "ReferenceStartY"), np.nan),
|
|
|
|
|
|
],
|
|
|
|
|
|
dtype=float,
|
|
|
|
|
|
) / 1000.0
|
|
|
|
|
|
end = np.array(
|
|
|
|
|
|
[
|
|
|
|
|
|
first_finite(numeric_column(frame, "ReferenceEndX"), np.nan),
|
|
|
|
|
|
first_finite(numeric_column(frame, "ReferenceEndY"), np.nan),
|
|
|
|
|
|
],
|
|
|
|
|
|
dtype=float,
|
|
|
|
|
|
) / 1000.0
|
|
|
|
|
|
if not np.all(np.isfinite(start)) or not np.all(np.isfinite(end)):
|
|
|
|
|
|
raise ValueError("CSV缺少有效的参考起点或终点。")
|
|
|
|
|
|
|
|
|
|
|
|
line = end - start
|
|
|
|
|
|
length_meters = float(np.linalg.norm(line))
|
|
|
|
|
|
if length_meters <= 1e-6:
|
|
|
|
|
|
raise ValueError("参考直线长度必须大于0。")
|
|
|
|
|
|
tangent = line / length_meters
|
|
|
|
|
|
left_normal = np.array([-tangent[1], tangent[0]])
|
|
|
|
|
|
displacement = np.column_stack([actual_x, actual_y]) - start
|
|
|
|
|
|
# 与C# TrajectoryProjector保持一致:轨迹位于车辆左侧时为正。
|
|
|
|
|
|
derived_lateral_error = -(displacement @ left_normal)
|
|
|
|
|
|
recorded_lateral_error = numeric_column(
|
|
|
|
|
|
frame,
|
|
|
|
|
|
"ControlLateralErrorMeters",
|
|
|
|
|
|
)
|
|
|
|
|
|
has_control_reference = (
|
|
|
|
|
|
numeric_column(frame, "HasControlReference", 0.0) > 0.5
|
|
|
|
|
|
)
|
2026-08-07 13:00:56 +08:00
|
|
|
|
recorded_lateral_valid = (
|
|
|
|
|
|
has_control_reference & np.isfinite(recorded_lateral_error)
|
|
|
|
|
|
)
|
|
|
|
|
|
lateral_error = (
|
|
|
|
|
|
np.where(recorded_lateral_valid, recorded_lateral_error, np.nan)
|
|
|
|
|
|
if np.any(recorded_lateral_valid)
|
|
|
|
|
|
else derived_lateral_error
|
2026-08-06 15:14:12 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
state_yaw = numeric_column(frame, "StateYawRadians")
|
|
|
|
|
|
raw_yaw = np.deg2rad(numeric_column(frame, "DetourTheta"))
|
|
|
|
|
|
actual_yaw = np.where(
|
|
|
|
|
|
has_processed & np.isfinite(state_yaw),
|
|
|
|
|
|
state_yaw,
|
|
|
|
|
|
raw_yaw,
|
|
|
|
|
|
)
|
|
|
|
|
|
reference_yaw = np.arctan2(tangent[1], tangent[0])
|
|
|
|
|
|
derived_heading_error = np.arctan2(
|
|
|
|
|
|
np.sin(reference_yaw - actual_yaw),
|
|
|
|
|
|
np.cos(reference_yaw - actual_yaw),
|
|
|
|
|
|
)
|
|
|
|
|
|
recorded_heading_error = numeric_column(
|
|
|
|
|
|
frame,
|
|
|
|
|
|
"ControlHeadingErrorRadians",
|
|
|
|
|
|
)
|
2026-08-07 13:00:56 +08:00
|
|
|
|
recorded_heading_valid = (
|
|
|
|
|
|
has_control_reference & np.isfinite(recorded_heading_error)
|
|
|
|
|
|
)
|
|
|
|
|
|
heading_error = (
|
|
|
|
|
|
np.where(recorded_heading_valid, recorded_heading_error, np.nan)
|
|
|
|
|
|
if np.any(recorded_heading_valid)
|
|
|
|
|
|
else derived_heading_error
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 投影定义满足:参考点 = 车体位置 + 横向误差 × 参考航向左法向。
|
|
|
|
|
|
# 因此无需假设轨迹类型,即可从有效控制周期还原车辆实际使用的参考轨迹。
|
|
|
|
|
|
projected_reference_yaw = actual_yaw + heading_error
|
|
|
|
|
|
reference_x = (
|
|
|
|
|
|
actual_x - lateral_error * np.sin(projected_reference_yaw)
|
|
|
|
|
|
)
|
|
|
|
|
|
reference_y = (
|
|
|
|
|
|
actual_y + lateral_error * np.cos(projected_reference_yaw)
|
|
|
|
|
|
)
|
|
|
|
|
|
valid_reference_position = (
|
|
|
|
|
|
has_control_reference
|
|
|
|
|
|
& np.isfinite(reference_x)
|
|
|
|
|
|
& np.isfinite(reference_y)
|
2026-08-06 15:14:12 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-08-19 17:38:17 +08:00
|
|
|
|
# 同时检查Detour是否超出车辆物理运动边界,以及控制状态的横向误差
|
|
|
|
|
|
# 是否发生离散突变。后者能覆盖状态层延迟接受持续定位偏移的情况。
|
|
|
|
|
|
raw_delta_x = np.full(len(frame), np.nan, dtype=float)
|
|
|
|
|
|
raw_delta_y = np.full(len(frame), np.nan, dtype=float)
|
|
|
|
|
|
sample_delta_time = np.full(len(frame), np.nan, dtype=float)
|
|
|
|
|
|
raw_delta_x[1:] = np.diff(raw_x_meters)
|
|
|
|
|
|
raw_delta_y[1:] = np.diff(raw_y_meters)
|
|
|
|
|
|
sample_delta_time[1:] = np.diff(time_seconds)
|
|
|
|
|
|
detour_position_step = np.hypot(raw_delta_x, raw_delta_y)
|
|
|
|
|
|
|
|
|
|
|
|
detour_tick_numeric = numeric_column(frame, "DetourTickRaw")
|
|
|
|
|
|
detour_tick_delta_seconds = np.full(len(frame), np.nan, dtype=float)
|
|
|
|
|
|
detour_tick_delta_seconds[1:] = (
|
|
|
|
|
|
np.diff(detour_tick_numeric) / 10_000_000.0
|
|
|
|
|
|
)
|
|
|
|
|
|
source_delta_time = sample_delta_time.copy()
|
|
|
|
|
|
valid_tick_delta = (
|
|
|
|
|
|
np.isfinite(detour_tick_delta_seconds)
|
|
|
|
|
|
& (detour_tick_delta_seconds > 0.0)
|
|
|
|
|
|
& (detour_tick_delta_seconds <= 0.5)
|
|
|
|
|
|
)
|
|
|
|
|
|
source_delta_time[valid_tick_delta] = (
|
|
|
|
|
|
detour_tick_delta_seconds[valid_tick_delta]
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
consecutive_raw_position_valid = np.zeros(len(frame), dtype=bool)
|
|
|
|
|
|
consecutive_raw_position_valid[1:] = (
|
|
|
|
|
|
valid_raw_position[1:] & valid_raw_position[:-1]
|
|
|
|
|
|
)
|
|
|
|
|
|
maximum_plausible_position_step = (
|
|
|
|
|
|
MAXIMUM_PLAUSIBLE_LINEAR_SPEED_METERS_PER_SECOND
|
|
|
|
|
|
* source_delta_time
|
|
|
|
|
|
+ POSITION_JUMP_MARGIN_METERS
|
|
|
|
|
|
)
|
|
|
|
|
|
raw_detour_jump = (
|
|
|
|
|
|
consecutive_raw_position_valid
|
|
|
|
|
|
& np.isfinite(detour_position_step)
|
|
|
|
|
|
& np.isfinite(source_delta_time)
|
|
|
|
|
|
& (source_delta_time > 0.0)
|
|
|
|
|
|
& (source_delta_time <= 0.5)
|
|
|
|
|
|
& (detour_position_step > maximum_plausible_position_step)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
state_lateral_step = np.full(len(frame), np.nan, dtype=float)
|
|
|
|
|
|
state_lateral_step[1:] = np.diff(lateral_error)
|
|
|
|
|
|
state_lateral_jump = (
|
|
|
|
|
|
np.isfinite(state_lateral_step)
|
|
|
|
|
|
& np.isfinite(sample_delta_time)
|
|
|
|
|
|
& (sample_delta_time > 0.0)
|
|
|
|
|
|
& (sample_delta_time <= 0.5)
|
|
|
|
|
|
& (np.abs(state_lateral_step) >= LATERAL_JUMP_THRESHOLD_METERS)
|
|
|
|
|
|
)
|
|
|
|
|
|
suspected_jump = raw_detour_jump | state_lateral_jump
|
|
|
|
|
|
jump_indices = np.flatnonzero(suspected_jump)
|
|
|
|
|
|
jump_magnitude = np.zeros(len(frame), dtype=float)
|
|
|
|
|
|
jump_magnitude[raw_detour_jump] = detour_position_step[raw_detour_jump]
|
|
|
|
|
|
jump_magnitude[state_lateral_jump] = np.maximum(
|
|
|
|
|
|
jump_magnitude[state_lateral_jump],
|
|
|
|
|
|
np.abs(state_lateral_step[state_lateral_jump]),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-08-06 15:14:12 +08:00
|
|
|
|
cruise_speed = first_finite(
|
|
|
|
|
|
numeric_column(frame, "ReferenceSpeed"),
|
|
|
|
|
|
0.30,
|
|
|
|
|
|
)
|
|
|
|
|
|
acceleration = first_finite(
|
|
|
|
|
|
numeric_column(
|
|
|
|
|
|
frame,
|
|
|
|
|
|
"ReferenceAccelerationMetersPerSecondSquared",
|
|
|
|
|
|
),
|
|
|
|
|
|
0.20,
|
|
|
|
|
|
)
|
|
|
|
|
|
deceleration = first_finite(
|
|
|
|
|
|
numeric_column(
|
|
|
|
|
|
frame,
|
|
|
|
|
|
"ReferenceDecelerationMetersPerSecondSquared",
|
|
|
|
|
|
),
|
|
|
|
|
|
0.20,
|
|
|
|
|
|
)
|
|
|
|
|
|
if acceleration <= 0.0:
|
|
|
|
|
|
acceleration = 0.20
|
|
|
|
|
|
if deceleration <= 0.0:
|
|
|
|
|
|
deceleration = 0.20
|
|
|
|
|
|
|
|
|
|
|
|
_, ideal_speed = planned_motion(
|
|
|
|
|
|
time_seconds,
|
|
|
|
|
|
length_meters,
|
|
|
|
|
|
cruise_speed,
|
|
|
|
|
|
acceleration,
|
|
|
|
|
|
deceleration,
|
|
|
|
|
|
)
|
|
|
|
|
|
reference_speed = fill_reference_series(
|
|
|
|
|
|
numeric_column(frame, "ControlReferenceSpeedMetersPerSecond"),
|
|
|
|
|
|
ideal_speed,
|
|
|
|
|
|
)
|
2026-08-07 13:00:56 +08:00
|
|
|
|
if np.any(has_control_reference):
|
|
|
|
|
|
reference_speed[~has_control_reference] = np.nan
|
2026-08-14 17:45:17 +08:00
|
|
|
|
motion_frame_yaw_radians = np.deg2rad(
|
|
|
|
|
|
numeric_column(frame, "ReferenceMotionFrameYawDegrees", 0.0)
|
|
|
|
|
|
)
|
|
|
|
|
|
motion_direction_cosine = np.cos(motion_frame_yaw_radians)
|
|
|
|
|
|
motion_direction_sine = np.sin(motion_frame_yaw_radians)
|
2026-08-10 10:10:21 +08:00
|
|
|
|
state_body_vx = numeric_column(frame, "StateBodyVxMetersPerSecond")
|
2026-08-14 17:45:17 +08:00
|
|
|
|
state_body_vy = numeric_column(frame, "StateBodyVyMetersPerSecond")
|
2026-08-06 15:14:12 +08:00
|
|
|
|
velocity_valid = (
|
|
|
|
|
|
numeric_column(frame, "StateVelocityEstimateValid", 0.0) > 0.5
|
|
|
|
|
|
)
|
2026-08-10 10:10:21 +08:00
|
|
|
|
state_body_vx[~velocity_valid] = np.nan
|
2026-08-14 17:45:17 +08:00
|
|
|
|
state_body_vy[~velocity_valid] = np.nan
|
|
|
|
|
|
state_motion_speed = (
|
|
|
|
|
|
state_body_vx * motion_direction_cosine
|
|
|
|
|
|
+ state_body_vy * motion_direction_sine
|
|
|
|
|
|
)
|
2026-08-10 10:10:21 +08:00
|
|
|
|
has_velocity_diagnostics = (
|
|
|
|
|
|
numeric_column(frame, "HasVelocityDiagnostics", 0.0) > 0.5
|
|
|
|
|
|
)
|
|
|
|
|
|
detour_speed = numeric_column(
|
|
|
|
|
|
frame,
|
|
|
|
|
|
"DetourEstimatedBodyVxMetersPerSecond",
|
|
|
|
|
|
)
|
|
|
|
|
|
detour_speed_valid = (
|
|
|
|
|
|
has_velocity_diagnostics
|
|
|
|
|
|
& (
|
|
|
|
|
|
numeric_column(
|
|
|
|
|
|
frame,
|
|
|
|
|
|
"DetourVelocityEstimateValid",
|
|
|
|
|
|
0.0,
|
|
|
|
|
|
)
|
|
|
|
|
|
> 0.5
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
detour_speed[~detour_speed_valid] = np.nan
|
2026-08-14 17:45:17 +08:00
|
|
|
|
wheel_raw_body_vx = numeric_column(
|
2026-08-10 10:10:21 +08:00
|
|
|
|
frame,
|
|
|
|
|
|
"WheelFeedbackRawBodyVxMetersPerSecond",
|
|
|
|
|
|
)
|
2026-08-14 17:45:17 +08:00
|
|
|
|
wheel_filtered_body_vx = numeric_column(
|
2026-08-10 10:10:21 +08:00
|
|
|
|
frame,
|
|
|
|
|
|
"WheelFeedbackFilteredBodyVxMetersPerSecond",
|
|
|
|
|
|
)
|
2026-08-14 17:45:17 +08:00
|
|
|
|
wheel_raw_body_vy = numeric_column(
|
|
|
|
|
|
frame,
|
|
|
|
|
|
"WheelFeedbackRawBodyVyMetersPerSecond",
|
|
|
|
|
|
)
|
|
|
|
|
|
wheel_filtered_body_vy = numeric_column(
|
|
|
|
|
|
frame,
|
|
|
|
|
|
"WheelFeedbackFilteredBodyVyMetersPerSecond",
|
|
|
|
|
|
)
|
2026-08-10 10:10:21 +08:00
|
|
|
|
wheel_speed_valid = (
|
|
|
|
|
|
has_velocity_diagnostics
|
|
|
|
|
|
& (
|
|
|
|
|
|
numeric_column(
|
|
|
|
|
|
frame,
|
|
|
|
|
|
"WheelFeedbackVelocityEstimateValid",
|
|
|
|
|
|
0.0,
|
|
|
|
|
|
)
|
|
|
|
|
|
> 0.5
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
2026-08-14 17:45:17 +08:00
|
|
|
|
wheel_raw_speed = (
|
|
|
|
|
|
wheel_raw_body_vx * motion_direction_cosine
|
|
|
|
|
|
+ wheel_raw_body_vy * motion_direction_sine
|
|
|
|
|
|
)
|
|
|
|
|
|
wheel_filtered_speed = (
|
|
|
|
|
|
wheel_filtered_body_vx * motion_direction_cosine
|
|
|
|
|
|
+ wheel_filtered_body_vy * motion_direction_sine
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 兼容尚未记录轮速Vy的旧版β=0实验;非零β缺少Vy时不能伪造投影速度。
|
|
|
|
|
|
body_x_motion = np.abs(motion_direction_sine) <= 1e-12
|
|
|
|
|
|
missing_raw_projection = ~np.isfinite(wheel_raw_speed)
|
|
|
|
|
|
missing_filtered_projection = ~np.isfinite(wheel_filtered_speed)
|
|
|
|
|
|
wheel_raw_speed[body_x_motion & missing_raw_projection] = (
|
|
|
|
|
|
wheel_raw_body_vx[body_x_motion & missing_raw_projection]
|
|
|
|
|
|
)
|
|
|
|
|
|
wheel_filtered_speed[
|
|
|
|
|
|
body_x_motion & missing_filtered_projection
|
|
|
|
|
|
] = wheel_filtered_body_vx[
|
|
|
|
|
|
body_x_motion & missing_filtered_projection
|
|
|
|
|
|
]
|
2026-08-10 10:10:21 +08:00
|
|
|
|
wheel_raw_speed[~wheel_speed_valid] = np.nan
|
|
|
|
|
|
wheel_filtered_speed[~wheel_speed_valid] = np.nan
|
|
|
|
|
|
actual_speed = np.where(
|
|
|
|
|
|
np.isfinite(wheel_filtered_speed),
|
|
|
|
|
|
wheel_filtered_speed,
|
2026-08-14 17:45:17 +08:00
|
|
|
|
state_motion_speed,
|
2026-08-10 10:10:21 +08:00
|
|
|
|
)
|
2026-08-06 15:14:12 +08:00
|
|
|
|
command_speed = numeric_column(frame, "CommandSpeed")
|
|
|
|
|
|
|
2026-08-10 10:10:21 +08:00
|
|
|
|
has_steering_diagnostics = (
|
|
|
|
|
|
numeric_column(frame, "HasSteeringDiagnostics", 0.0) > 0.5
|
|
|
|
|
|
)
|
|
|
|
|
|
steering_angles_degrees = {}
|
|
|
|
|
|
for wheel_name in (
|
|
|
|
|
|
"LeftFront",
|
|
|
|
|
|
"LeftRear",
|
|
|
|
|
|
"RightFront",
|
|
|
|
|
|
"RightRear",
|
|
|
|
|
|
):
|
|
|
|
|
|
values = numeric_column(
|
|
|
|
|
|
frame,
|
|
|
|
|
|
f"ActualSteer{wheel_name}Degrees",
|
|
|
|
|
|
)
|
|
|
|
|
|
values[~has_steering_diagnostics] = np.nan
|
|
|
|
|
|
steering_angles_degrees[wheel_name] = values
|
|
|
|
|
|
|
|
|
|
|
|
has_gcp_command = (
|
|
|
|
|
|
numeric_column(frame, "HasGcpCommand", 0.0) > 0.5
|
|
|
|
|
|
)
|
|
|
|
|
|
front_gcp_degrees = np.rad2deg(
|
|
|
|
|
|
numeric_column(frame, "CommandFrontGcpAngleRadians")
|
|
|
|
|
|
)
|
|
|
|
|
|
rear_gcp_degrees = np.rad2deg(
|
|
|
|
|
|
numeric_column(frame, "CommandRearGcpAngleRadians")
|
|
|
|
|
|
)
|
|
|
|
|
|
front_gcp_degrees[~has_gcp_command] = np.nan
|
|
|
|
|
|
rear_gcp_degrees[~has_gcp_command] = np.nan
|
|
|
|
|
|
|
2026-08-06 15:14:12 +08:00
|
|
|
|
return {
|
|
|
|
|
|
"frame": frame,
|
|
|
|
|
|
"time": time_seconds,
|
|
|
|
|
|
"actual_x": actual_x,
|
|
|
|
|
|
"actual_y": actual_y,
|
|
|
|
|
|
"valid_position": valid_position,
|
2026-08-19 17:38:17 +08:00
|
|
|
|
"raw_x": raw_x_meters,
|
|
|
|
|
|
"raw_y": raw_y_meters,
|
|
|
|
|
|
"valid_raw_position": valid_raw_position,
|
|
|
|
|
|
"detour_tick_raw": detour_tick_raw,
|
|
|
|
|
|
"detour_l_step": detour_l_step,
|
|
|
|
|
|
"detour_position_step": detour_position_step,
|
|
|
|
|
|
"state_lateral_step": state_lateral_step,
|
|
|
|
|
|
"raw_detour_jump": raw_detour_jump,
|
|
|
|
|
|
"state_lateral_jump": state_lateral_jump,
|
|
|
|
|
|
"jump_magnitude": jump_magnitude,
|
|
|
|
|
|
"jump_indices": jump_indices,
|
2026-08-07 13:00:56 +08:00
|
|
|
|
"reference_x": reference_x,
|
|
|
|
|
|
"reference_y": reference_y,
|
|
|
|
|
|
"valid_reference_position": valid_reference_position,
|
2026-08-06 15:14:12 +08:00
|
|
|
|
"start": start,
|
|
|
|
|
|
"end": end,
|
|
|
|
|
|
"length": length_meters,
|
|
|
|
|
|
"lateral_error": lateral_error,
|
|
|
|
|
|
"heading_error": heading_error,
|
|
|
|
|
|
"reference_speed": reference_speed,
|
|
|
|
|
|
"actual_speed": actual_speed,
|
2026-08-10 10:10:21 +08:00
|
|
|
|
"detour_speed": detour_speed,
|
|
|
|
|
|
"wheel_raw_speed": wheel_raw_speed,
|
|
|
|
|
|
"wheel_filtered_speed": wheel_filtered_speed,
|
2026-08-06 15:14:12 +08:00
|
|
|
|
"command_speed": command_speed,
|
2026-08-10 10:10:21 +08:00
|
|
|
|
"steering_angles_degrees": steering_angles_degrees,
|
|
|
|
|
|
"front_gcp_degrees": front_gcp_degrees,
|
|
|
|
|
|
"rear_gcp_degrees": rear_gcp_degrees,
|
2026-08-06 15:14:12 +08:00
|
|
|
|
"controller_name": first_text(
|
|
|
|
|
|
frame,
|
|
|
|
|
|
"ControllerName",
|
|
|
|
|
|
"NewController",
|
|
|
|
|
|
),
|
|
|
|
|
|
"trajectory_name": first_text(
|
|
|
|
|
|
frame,
|
|
|
|
|
|
"TrajectoryName",
|
|
|
|
|
|
"Trajectory",
|
|
|
|
|
|
),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def finite_rmse(values: np.ndarray) -> float:
|
|
|
|
|
|
"""计算忽略无效样本后的均方根值。"""
|
|
|
|
|
|
finite = values[np.isfinite(values)]
|
|
|
|
|
|
return float(np.sqrt(np.mean(finite**2))) if finite.size else np.nan
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def save_figure(
|
|
|
|
|
|
fig: plt.Figure,
|
|
|
|
|
|
destination: Path,
|
|
|
|
|
|
show: bool,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""保存并关闭一张实验图。"""
|
2026-08-10 10:10:21 +08:00
|
|
|
|
fig.tight_layout(rect=(0.0, 0.0, 1.0, 0.97))
|
2026-08-06 15:14:12 +08:00
|
|
|
|
fig.savefig(destination, dpi=300, bbox_inches="tight")
|
|
|
|
|
|
if show:
|
|
|
|
|
|
plt.show()
|
|
|
|
|
|
plt.close(fig)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def plot_experiment(
|
|
|
|
|
|
csv_path: Path,
|
|
|
|
|
|
output_directory: Path,
|
|
|
|
|
|
show: bool,
|
|
|
|
|
|
) -> list[Path]:
|
2026-08-10 10:10:21 +08:00
|
|
|
|
"""为单份新版控制器CSV生成一张包含六个子图的实验总图。"""
|
2026-08-06 15:14:12 +08:00
|
|
|
|
data = load_experiment(csv_path)
|
|
|
|
|
|
output_directory.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
title = f"{data['controller_name']} - {data['trajectory_name']}"
|
2026-08-10 10:10:21 +08:00
|
|
|
|
fig, axes = plt.subplots(3, 2, figsize=(18.0, 16.0))
|
|
|
|
|
|
fig.suptitle(title, fontsize=16)
|
2026-08-06 15:14:12 +08:00
|
|
|
|
|
2026-08-10 10:10:21 +08:00
|
|
|
|
# 1. 期望轨迹与实际轨迹。
|
|
|
|
|
|
axis = axes[0, 0]
|
2026-08-06 15:14:12 +08:00
|
|
|
|
valid_position = data["valid_position"]
|
2026-08-19 17:38:17 +08:00
|
|
|
|
valid_raw_position = data["valid_raw_position"]
|
2026-08-07 13:00:56 +08:00
|
|
|
|
valid_reference_position = data["valid_reference_position"]
|
2026-08-19 17:38:17 +08:00
|
|
|
|
jump_indices = data["jump_indices"]
|
2026-08-07 13:00:56 +08:00
|
|
|
|
if np.count_nonzero(valid_reference_position) >= 2:
|
|
|
|
|
|
axis.plot(
|
|
|
|
|
|
data["reference_x"][valid_reference_position],
|
|
|
|
|
|
data["reference_y"][valid_reference_position],
|
|
|
|
|
|
"--",
|
|
|
|
|
|
linewidth=2.0,
|
|
|
|
|
|
label="控制器实际使用的参考轨迹",
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
axis.plot(
|
|
|
|
|
|
[data["start"][0], data["end"][0]],
|
|
|
|
|
|
[data["start"][1], data["end"][1]],
|
|
|
|
|
|
"--",
|
|
|
|
|
|
linewidth=2.0,
|
|
|
|
|
|
label="参考起终点连线",
|
|
|
|
|
|
)
|
2026-08-19 17:38:17 +08:00
|
|
|
|
axis.plot(
|
|
|
|
|
|
data["raw_x"][valid_raw_position],
|
|
|
|
|
|
data["raw_y"][valid_raw_position],
|
|
|
|
|
|
":",
|
|
|
|
|
|
color="tab:gray",
|
|
|
|
|
|
linewidth=1.2,
|
|
|
|
|
|
alpha=0.85,
|
|
|
|
|
|
label="Detour原始轨迹",
|
|
|
|
|
|
)
|
2026-08-06 15:14:12 +08:00
|
|
|
|
axis.plot(
|
|
|
|
|
|
data["actual_x"][valid_position],
|
|
|
|
|
|
data["actual_y"][valid_position],
|
2026-08-19 17:38:17 +08:00
|
|
|
|
color="tab:orange",
|
2026-08-06 15:14:12 +08:00
|
|
|
|
linewidth=1.5,
|
2026-08-19 17:38:17 +08:00
|
|
|
|
label="控制使用的状态轨迹",
|
2026-08-06 15:14:12 +08:00
|
|
|
|
)
|
2026-08-19 17:38:17 +08:00
|
|
|
|
if jump_indices.size:
|
|
|
|
|
|
axis.scatter(
|
|
|
|
|
|
data["raw_x"][jump_indices],
|
|
|
|
|
|
data["raw_y"][jump_indices],
|
|
|
|
|
|
color="red",
|
|
|
|
|
|
marker="x",
|
|
|
|
|
|
s=65,
|
|
|
|
|
|
linewidths=1.8,
|
|
|
|
|
|
zorder=8,
|
|
|
|
|
|
label="疑似定位/状态突变",
|
|
|
|
|
|
)
|
2026-08-06 15:14:12 +08:00
|
|
|
|
axis.scatter(*data["start"], color="green", s=45, label="起点")
|
|
|
|
|
|
axis.scatter(*data["end"], color="red", s=45, label="终点")
|
2026-08-19 17:38:17 +08:00
|
|
|
|
# 诊断图优先展示厘米级横向变化;横纵轴独立缩放,避免4m行程
|
|
|
|
|
|
# 将数厘米的定位阶跃压缩成几乎不可见的一条细线。
|
|
|
|
|
|
axis.set_aspect("auto")
|
2026-08-06 15:14:12 +08:00
|
|
|
|
axis.set_xlabel("世界坐标X / m")
|
|
|
|
|
|
axis.set_ylabel("世界坐标Y / m")
|
2026-08-19 17:38:17 +08:00
|
|
|
|
axis.set_title("期望轨迹与状态轨迹对比(横纵轴独立缩放)")
|
2026-08-06 15:14:12 +08:00
|
|
|
|
axis.grid(True, alpha=0.3)
|
2026-08-19 17:38:17 +08:00
|
|
|
|
axis.legend(fontsize=7, loc="upper left")
|
|
|
|
|
|
|
|
|
|
|
|
if jump_indices.size:
|
|
|
|
|
|
strongest_jump_index = int(
|
|
|
|
|
|
jump_indices[
|
|
|
|
|
|
np.argmax(
|
|
|
|
|
|
np.abs(
|
|
|
|
|
|
data["jump_magnitude"][jump_indices]
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
]
|
|
|
|
|
|
)
|
|
|
|
|
|
context_start = max(
|
|
|
|
|
|
0,
|
|
|
|
|
|
strongest_jump_index - JUMP_INSET_CONTEXT_SAMPLES,
|
|
|
|
|
|
)
|
|
|
|
|
|
context_end = min(
|
|
|
|
|
|
len(data["time"]),
|
|
|
|
|
|
strongest_jump_index + JUMP_INSET_CONTEXT_SAMPLES + 1,
|
|
|
|
|
|
)
|
|
|
|
|
|
context = np.arange(context_start, context_end)
|
|
|
|
|
|
inset = axis.inset_axes([0.54, 0.08, 0.43, 0.43])
|
|
|
|
|
|
inset.set_zorder(10)
|
|
|
|
|
|
inset.set_facecolor("white")
|
|
|
|
|
|
context_reference_valid = (
|
|
|
|
|
|
data["valid_reference_position"][context]
|
|
|
|
|
|
)
|
|
|
|
|
|
if np.count_nonzero(context_reference_valid) >= 2:
|
|
|
|
|
|
reference_context = context[context_reference_valid]
|
|
|
|
|
|
inset.plot(
|
|
|
|
|
|
data["reference_x"][reference_context],
|
|
|
|
|
|
data["reference_y"][reference_context],
|
|
|
|
|
|
"--",
|
|
|
|
|
|
linewidth=1.2,
|
|
|
|
|
|
color="tab:blue",
|
|
|
|
|
|
)
|
|
|
|
|
|
context_raw_valid = data["valid_raw_position"][context]
|
|
|
|
|
|
raw_context = context[context_raw_valid]
|
|
|
|
|
|
inset.plot(
|
|
|
|
|
|
data["raw_x"][raw_context],
|
|
|
|
|
|
data["raw_y"][raw_context],
|
|
|
|
|
|
":",
|
|
|
|
|
|
linewidth=1.0,
|
|
|
|
|
|
color="tab:gray",
|
|
|
|
|
|
)
|
|
|
|
|
|
context_state_valid = data["valid_position"][context]
|
|
|
|
|
|
state_context = context[context_state_valid]
|
|
|
|
|
|
inset.plot(
|
|
|
|
|
|
data["actual_x"][state_context],
|
|
|
|
|
|
data["actual_y"][state_context],
|
|
|
|
|
|
linewidth=1.2,
|
|
|
|
|
|
color="tab:orange",
|
|
|
|
|
|
)
|
|
|
|
|
|
inset.scatter(
|
|
|
|
|
|
data["raw_x"][strongest_jump_index],
|
|
|
|
|
|
data["raw_y"][strongest_jump_index],
|
|
|
|
|
|
color="red",
|
|
|
|
|
|
marker="x",
|
|
|
|
|
|
s=45,
|
|
|
|
|
|
linewidths=1.5,
|
|
|
|
|
|
zorder=8,
|
|
|
|
|
|
)
|
|
|
|
|
|
jump_descriptions = []
|
|
|
|
|
|
if data["raw_detour_jump"][strongest_jump_index]:
|
|
|
|
|
|
jump_descriptions.append(
|
|
|
|
|
|
"Detour位移="
|
|
|
|
|
|
f"{data['detour_position_step'][strongest_jump_index] * 1000.0:.1f}mm"
|
|
|
|
|
|
)
|
|
|
|
|
|
if data["state_lateral_jump"][strongest_jump_index]:
|
|
|
|
|
|
jump_descriptions.append(
|
|
|
|
|
|
"状态横向Δ="
|
|
|
|
|
|
f"{data['state_lateral_step'][strongest_jump_index] * 1000.0:+.1f}mm"
|
|
|
|
|
|
)
|
|
|
|
|
|
diagnostic_parts = []
|
|
|
|
|
|
detour_tick = data["detour_tick_raw"][strongest_jump_index]
|
|
|
|
|
|
if detour_tick:
|
|
|
|
|
|
diagnostic_parts.append(f"tick={detour_tick}")
|
|
|
|
|
|
detour_l_step = data["detour_l_step"][strongest_jump_index]
|
|
|
|
|
|
if np.isfinite(detour_l_step):
|
|
|
|
|
|
diagnostic_parts.append(f"l_step={detour_l_step:g}")
|
|
|
|
|
|
diagnostic_suffix = (
|
|
|
|
|
|
"\n" + " ".join(diagnostic_parts)
|
|
|
|
|
|
if diagnostic_parts
|
|
|
|
|
|
else ""
|
|
|
|
|
|
)
|
|
|
|
|
|
inset.set_title(
|
|
|
|
|
|
f"最大疑似突变:t={data['time'][strongest_jump_index]:.3f}s\n"
|
|
|
|
|
|
f"{','.join(jump_descriptions)}"
|
|
|
|
|
|
f"{diagnostic_suffix}",
|
|
|
|
|
|
fontsize=7,
|
|
|
|
|
|
)
|
|
|
|
|
|
inset.set_aspect("auto")
|
|
|
|
|
|
inset.tick_params(labelsize=6)
|
|
|
|
|
|
inset.grid(True, alpha=0.25)
|
2026-08-06 15:14:12 +08:00
|
|
|
|
|
2026-08-10 10:10:21 +08:00
|
|
|
|
# 2. 横向误差。
|
2026-08-06 15:14:12 +08:00
|
|
|
|
lateral_mm = data["lateral_error"] * 1000.0
|
|
|
|
|
|
lateral_rmse_mm = finite_rmse(lateral_mm)
|
2026-08-10 10:10:21 +08:00
|
|
|
|
axis = axes[0, 1]
|
2026-08-06 15:14:12 +08:00
|
|
|
|
axis.plot(data["time"], lateral_mm, linewidth=1.5)
|
2026-08-19 17:38:17 +08:00
|
|
|
|
if jump_indices.size:
|
|
|
|
|
|
for jump_index in jump_indices:
|
|
|
|
|
|
axis.axvline(
|
|
|
|
|
|
data["time"][jump_index],
|
|
|
|
|
|
color="red",
|
|
|
|
|
|
linewidth=0.8,
|
|
|
|
|
|
alpha=0.35,
|
|
|
|
|
|
)
|
|
|
|
|
|
valid_jump_error = (
|
|
|
|
|
|
data["state_lateral_jump"][jump_indices]
|
|
|
|
|
|
& np.isfinite(lateral_mm[jump_indices])
|
|
|
|
|
|
)
|
|
|
|
|
|
visible_jump_indices = jump_indices[valid_jump_error]
|
|
|
|
|
|
if visible_jump_indices.size:
|
|
|
|
|
|
axis.scatter(
|
|
|
|
|
|
data["time"][visible_jump_indices],
|
|
|
|
|
|
lateral_mm[visible_jump_indices],
|
|
|
|
|
|
color="red",
|
|
|
|
|
|
marker="x",
|
|
|
|
|
|
s=45,
|
|
|
|
|
|
linewidths=1.5,
|
|
|
|
|
|
zorder=7,
|
|
|
|
|
|
label="控制状态横向突变",
|
|
|
|
|
|
)
|
2026-08-06 15:14:12 +08:00
|
|
|
|
axis.axhline(0.0, color="black", linewidth=0.8)
|
|
|
|
|
|
axis.set_xlabel("时间 / s")
|
|
|
|
|
|
axis.set_ylabel("横向误差 / mm")
|
|
|
|
|
|
axis.set_title(
|
2026-08-10 10:10:21 +08:00
|
|
|
|
"横向误差(轨迹在车辆左侧为正)\n"
|
|
|
|
|
|
f"RMSE={lateral_rmse_mm:.2f}mm"
|
2026-08-06 15:14:12 +08:00
|
|
|
|
)
|
|
|
|
|
|
axis.grid(True, alpha=0.3)
|
2026-08-19 17:38:17 +08:00
|
|
|
|
if jump_indices.size and np.any(
|
|
|
|
|
|
data["state_lateral_jump"][jump_indices]
|
|
|
|
|
|
& np.isfinite(lateral_mm[jump_indices])
|
|
|
|
|
|
):
|
|
|
|
|
|
axis.legend(fontsize=8)
|
2026-08-06 15:14:12 +08:00
|
|
|
|
|
2026-08-10 10:10:21 +08:00
|
|
|
|
# 3. 航向误差。
|
2026-08-06 15:14:12 +08:00
|
|
|
|
heading_degrees = np.rad2deg(data["heading_error"])
|
|
|
|
|
|
heading_rmse_degrees = finite_rmse(heading_degrees)
|
2026-08-10 10:10:21 +08:00
|
|
|
|
axis = axes[1, 0]
|
2026-08-06 15:14:12 +08:00
|
|
|
|
axis.plot(data["time"], heading_degrees, linewidth=1.5)
|
|
|
|
|
|
axis.axhline(0.0, color="black", linewidth=0.8)
|
|
|
|
|
|
axis.set_xlabel("时间 / s")
|
|
|
|
|
|
axis.set_ylabel("航向角偏差 / °")
|
|
|
|
|
|
axis.set_title(
|
|
|
|
|
|
"航向角偏差:参考轨迹航向-实际车体航向(逆时针为正)\n"
|
2026-08-10 10:10:21 +08:00
|
|
|
|
f"RMSE={heading_rmse_degrees:.3f}°"
|
2026-08-06 15:14:12 +08:00
|
|
|
|
)
|
|
|
|
|
|
axis.grid(True, alpha=0.3)
|
|
|
|
|
|
|
2026-08-14 17:45:17 +08:00
|
|
|
|
# 4. 参考、命令、Detour车头分量和沿β投影的轮速解算速度。
|
2026-08-06 15:14:12 +08:00
|
|
|
|
speed_error = data["actual_speed"] - data["reference_speed"]
|
|
|
|
|
|
speed_rmse = finite_rmse(speed_error)
|
2026-08-10 10:10:21 +08:00
|
|
|
|
axis = axes[1, 1]
|
2026-08-06 15:14:12 +08:00
|
|
|
|
axis.plot(
|
|
|
|
|
|
data["time"],
|
|
|
|
|
|
data["reference_speed"],
|
|
|
|
|
|
linewidth=1.8,
|
|
|
|
|
|
label="控制器实际参考速度",
|
|
|
|
|
|
)
|
|
|
|
|
|
axis.plot(
|
|
|
|
|
|
data["time"],
|
|
|
|
|
|
data["command_speed"],
|
|
|
|
|
|
"--",
|
|
|
|
|
|
linewidth=1.3,
|
|
|
|
|
|
label="纵向控制器下发速度",
|
|
|
|
|
|
)
|
2026-08-10 10:10:21 +08:00
|
|
|
|
if np.any(np.isfinite(data["detour_speed"])):
|
|
|
|
|
|
axis.plot(
|
|
|
|
|
|
data["time"],
|
|
|
|
|
|
data["detour_speed"],
|
|
|
|
|
|
":",
|
|
|
|
|
|
linewidth=1.2,
|
2026-08-14 17:45:17 +08:00
|
|
|
|
label="Detour估计Vx(车头分量)",
|
2026-08-10 10:10:21 +08:00
|
|
|
|
)
|
|
|
|
|
|
if np.any(np.isfinite(data["wheel_filtered_speed"])):
|
2026-08-10 17:20:35 +08:00
|
|
|
|
wheel_filtered_valid = np.isfinite(
|
|
|
|
|
|
data["wheel_filtered_speed"]
|
|
|
|
|
|
)
|
|
|
|
|
|
axis.scatter(
|
|
|
|
|
|
data["time"][wheel_filtered_valid],
|
|
|
|
|
|
data["wheel_filtered_speed"][wheel_filtered_valid],
|
|
|
|
|
|
color="tab:red",
|
|
|
|
|
|
s=14,
|
|
|
|
|
|
marker="o",
|
|
|
|
|
|
zorder=5,
|
2026-08-14 17:45:17 +08:00
|
|
|
|
label="轮速解算β方向速度(控制使用)",
|
2026-08-10 10:10:21 +08:00
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
axis.plot(
|
|
|
|
|
|
data["time"],
|
|
|
|
|
|
data["actual_speed"],
|
|
|
|
|
|
linewidth=1.5,
|
|
|
|
|
|
label="控制器实际纵向速度",
|
|
|
|
|
|
)
|
2026-08-06 15:14:12 +08:00
|
|
|
|
axis.set_xlabel("时间 / s")
|
|
|
|
|
|
axis.set_ylabel("速度 / (m/s)")
|
2026-08-10 10:10:21 +08:00
|
|
|
|
axis.set_title(
|
|
|
|
|
|
"参考速度、控制命令与观测速度\n"
|
2026-08-14 17:45:17 +08:00
|
|
|
|
f"轮速β方向速度相对参考速度RMSE={speed_rmse:.4f}m/s"
|
2026-08-10 10:10:21 +08:00
|
|
|
|
)
|
|
|
|
|
|
axis.grid(True, alpha=0.3)
|
|
|
|
|
|
axis.legend(fontsize=8)
|
|
|
|
|
|
|
|
|
|
|
|
# 5. 四个舵轮的实际机械转角。
|
|
|
|
|
|
axis = axes[2, 0]
|
|
|
|
|
|
wheel_labels = {
|
|
|
|
|
|
"LeftFront": "左前轮",
|
|
|
|
|
|
"LeftRear": "左后轮",
|
|
|
|
|
|
"RightFront": "右前轮",
|
|
|
|
|
|
"RightRear": "右后轮",
|
|
|
|
|
|
}
|
|
|
|
|
|
steering_data_available = False
|
|
|
|
|
|
for wheel_name, wheel_label in wheel_labels.items():
|
|
|
|
|
|
wheel_angles = data["steering_angles_degrees"][wheel_name]
|
|
|
|
|
|
if np.any(np.isfinite(wheel_angles)):
|
|
|
|
|
|
steering_data_available = True
|
|
|
|
|
|
axis.plot(
|
|
|
|
|
|
data["time"],
|
|
|
|
|
|
wheel_angles,
|
|
|
|
|
|
linewidth=1.2,
|
|
|
|
|
|
label=wheel_label,
|
|
|
|
|
|
)
|
|
|
|
|
|
if steering_data_available:
|
|
|
|
|
|
axis.axhline(0.0, color="black", linewidth=0.8)
|
|
|
|
|
|
axis.legend(fontsize=8, ncol=2)
|
|
|
|
|
|
else:
|
|
|
|
|
|
axis.text(
|
|
|
|
|
|
0.5,
|
|
|
|
|
|
0.5,
|
|
|
|
|
|
"CSV不含四舵轮转角诊断数据",
|
|
|
|
|
|
ha="center",
|
|
|
|
|
|
va="center",
|
|
|
|
|
|
transform=axis.transAxes,
|
|
|
|
|
|
)
|
|
|
|
|
|
axis.set_xlabel("时间 / s")
|
|
|
|
|
|
axis.set_ylabel("实际舵角 / °")
|
|
|
|
|
|
axis.set_title("四个舵轮实际反馈转角")
|
|
|
|
|
|
axis.grid(True, alpha=0.3)
|
|
|
|
|
|
|
|
|
|
|
|
# 6. 经过角速度限制后实际发送的前、后虚拟GCP转角。
|
|
|
|
|
|
axis = axes[2, 1]
|
|
|
|
|
|
gcp_data_available = (
|
|
|
|
|
|
np.any(np.isfinite(data["front_gcp_degrees"]))
|
|
|
|
|
|
or np.any(np.isfinite(data["rear_gcp_degrees"]))
|
|
|
|
|
|
)
|
|
|
|
|
|
if gcp_data_available:
|
|
|
|
|
|
axis.plot(
|
|
|
|
|
|
data["time"],
|
|
|
|
|
|
data["front_gcp_degrees"],
|
|
|
|
|
|
linewidth=1.4,
|
|
|
|
|
|
label="前GCP",
|
|
|
|
|
|
)
|
|
|
|
|
|
axis.plot(
|
|
|
|
|
|
data["time"],
|
|
|
|
|
|
data["rear_gcp_degrees"],
|
|
|
|
|
|
linewidth=1.4,
|
|
|
|
|
|
label="后GCP",
|
|
|
|
|
|
)
|
|
|
|
|
|
axis.axhline(0.0, color="black", linewidth=0.8)
|
|
|
|
|
|
axis.legend(fontsize=8)
|
|
|
|
|
|
else:
|
|
|
|
|
|
axis.text(
|
|
|
|
|
|
0.5,
|
|
|
|
|
|
0.5,
|
|
|
|
|
|
"CSV不含前后GCP转角数据",
|
|
|
|
|
|
ha="center",
|
|
|
|
|
|
va="center",
|
|
|
|
|
|
transform=axis.transAxes,
|
|
|
|
|
|
)
|
|
|
|
|
|
axis.set_xlabel("时间 / s")
|
|
|
|
|
|
axis.set_ylabel("GCP命令角 / °")
|
|
|
|
|
|
axis.set_title("前后虚拟GCP实际发送转角")
|
2026-08-06 15:14:12 +08:00
|
|
|
|
axis.grid(True, alpha=0.3)
|
2026-08-10 10:10:21 +08:00
|
|
|
|
|
|
|
|
|
|
destination = output_directory / f"{csv_path.stem}_summary_6plots.png"
|
2026-08-06 15:14:12 +08:00
|
|
|
|
save_figure(fig, destination, show)
|
|
|
|
|
|
|
|
|
|
|
|
print(
|
|
|
|
|
|
f"{csv_path.name}: 横向RMSE={lateral_rmse_mm:.3f}mm, "
|
|
|
|
|
|
f"航向RMSE={heading_rmse_degrees:.4f}°, "
|
|
|
|
|
|
f"速度RMSE={speed_rmse:.5f}m/s"
|
|
|
|
|
|
)
|
2026-08-19 17:38:17 +08:00
|
|
|
|
if jump_indices.size:
|
|
|
|
|
|
strongest_jump_index = int(
|
|
|
|
|
|
jump_indices[
|
|
|
|
|
|
np.argmax(
|
|
|
|
|
|
np.abs(
|
|
|
|
|
|
data["jump_magnitude"][jump_indices]
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
]
|
|
|
|
|
|
)
|
|
|
|
|
|
print(
|
|
|
|
|
|
f" 检出{jump_indices.size}个疑似定位/状态突变,"
|
|
|
|
|
|
f"最大幅值={data['jump_magnitude'][strongest_jump_index] * 1000.0:.2f}mm,"
|
|
|
|
|
|
f"时刻={data['time'][strongest_jump_index]:.3f}s"
|
|
|
|
|
|
)
|
2026-08-10 10:10:21 +08:00
|
|
|
|
print(f"已生成六子图总图:{destination}")
|
|
|
|
|
|
return [destination]
|
2026-08-06 15:14:12 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def discover_csv_files(arguments: list[str]) -> list[Path]:
|
2026-08-19 17:38:17 +08:00
|
|
|
|
"""读取命令行文件或目录;目录中只选取非计时CSV。"""
|
2026-08-06 15:14:12 +08:00
|
|
|
|
if arguments:
|
2026-08-19 17:38:17 +08:00
|
|
|
|
files = []
|
|
|
|
|
|
for item in arguments:
|
|
|
|
|
|
path = Path(item).expanduser().resolve()
|
|
|
|
|
|
if path.is_dir():
|
|
|
|
|
|
files.extend(
|
|
|
|
|
|
sorted(
|
|
|
|
|
|
candidate
|
|
|
|
|
|
for candidate in path.glob("*.csv")
|
|
|
|
|
|
if not candidate.stem.endswith("_timing")
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
files.append(path)
|
2026-08-06 15:14:12 +08:00
|
|
|
|
else:
|
2026-08-19 17:38:17 +08:00
|
|
|
|
files = sorted(
|
|
|
|
|
|
path
|
|
|
|
|
|
for path in SCRIPT_DIR.glob("*.csv")
|
|
|
|
|
|
if not path.stem.endswith("_timing")
|
|
|
|
|
|
)
|
|
|
|
|
|
files.extend(
|
|
|
|
|
|
sorted(
|
|
|
|
|
|
path
|
|
|
|
|
|
for path in (SCRIPT_DIR / "data").glob("*.csv")
|
|
|
|
|
|
if not path.stem.endswith("_timing")
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
files = list(dict.fromkeys(path for path in files if path.is_file()))
|
2026-08-06 15:14:12 +08:00
|
|
|
|
if not files:
|
|
|
|
|
|
raise FileNotFoundError(
|
2026-08-19 17:38:17 +08:00
|
|
|
|
"没有找到轨迹CSV;请传入文件、目录,或将文件放到脚本目录/data中。"
|
2026-08-06 15:14:12 +08:00
|
|
|
|
)
|
|
|
|
|
|
return files
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main() -> None:
|
|
|
|
|
|
"""解析命令行并批量处理新版控制器实验CSV。"""
|
|
|
|
|
|
parser = argparse.ArgumentParser(
|
2026-08-10 10:10:21 +08:00
|
|
|
|
description="绘制新版控制器轨迹实验的六子图总图。"
|
2026-08-06 15:14:12 +08:00
|
|
|
|
)
|
2026-08-19 17:38:17 +08:00
|
|
|
|
parser.add_argument(
|
|
|
|
|
|
"csv",
|
|
|
|
|
|
nargs="*",
|
|
|
|
|
|
help="需要处理的轨迹CSV文件或包含轨迹CSV的目录。",
|
|
|
|
|
|
)
|
2026-08-06 15:14:12 +08:00
|
|
|
|
parser.add_argument(
|
|
|
|
|
|
"--output-dir",
|
|
|
|
|
|
help="图片输出目录;默认使用脚本目录/plots。",
|
|
|
|
|
|
)
|
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
|
"--show",
|
|
|
|
|
|
action="store_true",
|
|
|
|
|
|
help="保存图片后同时显示窗口。",
|
|
|
|
|
|
)
|
|
|
|
|
|
arguments = parser.parse_args()
|
|
|
|
|
|
configure_matplotlib()
|
|
|
|
|
|
|
|
|
|
|
|
output_directory = (
|
|
|
|
|
|
Path(arguments.output_dir).expanduser().resolve()
|
|
|
|
|
|
if arguments.output_dir
|
|
|
|
|
|
else SCRIPT_DIR / "plots"
|
|
|
|
|
|
)
|
|
|
|
|
|
failed = 0
|
|
|
|
|
|
for csv_path in discover_csv_files(arguments.csv):
|
|
|
|
|
|
try:
|
|
|
|
|
|
plot_experiment(csv_path, output_directory, arguments.show)
|
|
|
|
|
|
except Exception as exception:
|
|
|
|
|
|
failed += 1
|
|
|
|
|
|
print(f"处理失败:{csv_path}:{exception}")
|
|
|
|
|
|
|
|
|
|
|
|
if failed:
|
|
|
|
|
|
raise SystemExit(f"共有{failed}个CSV处理失败。")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
main()
|