Files
ParkingRobot/data_process/新版控制器轨迹测试处理/plot_new_controller_experiment.py
T

524 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""为新版4m直线控制器实验CSV生成轨迹、横向/航向误差和速度响应图。"""
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
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
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
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
)
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
)
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",
)
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)
)
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,
)
if np.any(has_control_reference):
reference_speed[~has_control_reference] = np.nan
actual_speed = numeric_column(frame, "StateBodyVxMetersPerSecond")
velocity_valid = (
numeric_column(frame, "StateVelocityEstimateValid", 0.0) > 0.5
)
actual_speed[~velocity_valid] = np.nan
command_speed = numeric_column(frame, "CommandSpeed")
return {
"frame": frame,
"time": time_seconds,
"actual_x": actual_x,
"actual_y": actual_y,
"valid_position": valid_position,
"reference_x": reference_x,
"reference_y": reference_y,
"valid_reference_position": valid_reference_position,
"start": start,
"end": end,
"length": length_meters,
"lateral_error": lateral_error,
"heading_error": heading_error,
"reference_speed": reference_speed,
"actual_speed": actual_speed,
"command_speed": command_speed,
"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:
"""保存并关闭一张实验图。"""
fig.tight_layout()
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]:
"""为单份新版控制器CSV生成四类对比图。"""
data = load_experiment(csv_path)
output_directory.mkdir(parents=True, exist_ok=True)
title = f"{data['controller_name']} - {data['trajectory_name']}"
destinations: list[Path] = []
valid_position = data["valid_position"]
fig, axis = plt.subplots(figsize=(9.0, 6.5))
valid_reference_position = data["valid_reference_position"]
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="参考起终点连线",
)
axis.plot(
data["actual_x"][valid_position],
data["actual_y"][valid_position],
linewidth=1.5,
label="状态估计后的实际轨迹",
)
axis.scatter(*data["start"], color="green", s=45, label="起点")
axis.scatter(*data["end"], color="red", s=45, label="终点")
axis.set_aspect("equal", adjustable="box")
axis.set_xlabel("世界坐标X / m")
axis.set_ylabel("世界坐标Y / m")
axis.set_title(f"期望轨迹与实际轨迹对比\n{title}")
axis.grid(True, alpha=0.3)
axis.legend()
destination = output_directory / f"{csv_path.stem}_trajectory.png"
save_figure(fig, destination, show)
destinations.append(destination)
lateral_mm = data["lateral_error"] * 1000.0
lateral_rmse_mm = finite_rmse(lateral_mm)
fig, axis = plt.subplots(figsize=(10.0, 5.5))
axis.plot(data["time"], lateral_mm, linewidth=1.5)
axis.axhline(0.0, color="black", linewidth=0.8)
axis.set_xlabel("时间 / s")
axis.set_ylabel("横向误差 / mm")
axis.set_title(
f"横向误差(轨迹在车辆左侧为正)\n{title}RMSE={lateral_rmse_mm:.2f}mm"
)
axis.grid(True, alpha=0.3)
destination = output_directory / f"{csv_path.stem}_lateral_error.png"
save_figure(fig, destination, show)
destinations.append(destination)
heading_degrees = np.rad2deg(data["heading_error"])
heading_rmse_degrees = finite_rmse(heading_degrees)
fig, axis = plt.subplots(figsize=(10.0, 5.5))
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"
f"{title}RMSE={heading_rmse_degrees:.3f}°"
)
axis.grid(True, alpha=0.3)
destination = output_directory / f"{csv_path.stem}_heading_error.png"
save_figure(fig, destination, show)
destinations.append(destination)
speed_error = data["actual_speed"] - data["reference_speed"]
speed_rmse = finite_rmse(speed_error)
fig, axis = plt.subplots(figsize=(10.0, 5.8))
axis.plot(
data["time"],
data["reference_speed"],
linewidth=1.8,
label="控制器实际参考速度",
)
axis.plot(
data["time"],
data["command_speed"],
"--",
linewidth=1.3,
label="纵向控制器下发速度",
)
axis.plot(
data["time"],
data["actual_speed"],
linewidth=1.5,
label="状态估计实际车体纵向速度",
)
axis.set_xlabel("时间 / s")
axis.set_ylabel("速度 / (m/s)")
axis.set_title(f"参考速度与实际速度对比\n{title}RMSE={speed_rmse:.4f}m/s")
axis.grid(True, alpha=0.3)
axis.legend()
destination = output_directory / f"{csv_path.stem}_speed_response.png"
save_figure(fig, destination, show)
destinations.append(destination)
print(
f"{csv_path.name}: 横向RMSE={lateral_rmse_mm:.3f}mm, "
f"航向RMSE={heading_rmse_degrees:.4f}°, "
f"速度RMSE={speed_rmse:.5f}m/s"
)
for destination in destinations:
print(f"已生成:{destination}")
return destinations
def discover_csv_files(arguments: list[str]) -> list[Path]:
"""读取命令行文件;未指定时扫描脚本目录及data子目录中的CSV。"""
if arguments:
files = [Path(item).expanduser().resolve() for item in arguments]
else:
files = sorted(SCRIPT_DIR.glob("*.csv"))
files.extend(sorted((SCRIPT_DIR / "data").glob("*.csv")))
files = [path for path in files if path.is_file()]
if not files:
raise FileNotFoundError(
"没有找到CSV;请传入文件路径,或将文件放到脚本目录/data中。"
)
return files
def main() -> None:
"""解析命令行并批量处理新版控制器实验CSV。"""
parser = argparse.ArgumentParser(
description="绘制新版控制器轨迹实验的四类对比图。"
)
parser.add_argument("csv", nargs="*", help="需要处理的CSV文件路径。")
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()