Unified the two separate sysid codepaths (motor-only and full-system) into a single module that optimizes all 28 parameters jointly: - 13 motor params (asymmetric gear, damping, friction, deadzone, Stribeck boost, action bias, filter tau, armature, ctrl_limit) - 15 pendulum/arm params (mass, CoM, inertia, joint dynamics) Key changes: - Added stribeck_friction_boost, stribeck_vel, action_bias to ActuatorConfig (robot.py) and MJX runner - Created shared src/sysid/preprocess.py (SG velocity recomputation) - Rewrote src/sysid/rollout.py with unified MOTOR_PARAMS + PENDULUM_PARAMS spec and PARAM_SETS dict for flexible subset optimization - Updated optimize.py, export.py, visualize.py to use unified params (removed all LOCKED_MOTOR_PARAMS references) - Removed src/sysid/motor/ module and scripts/motor_sysid.py Net: -1383 lines, zero code duplication between motor and full-system sysid.
249 lines
7.5 KiB
Python
249 lines
7.5 KiB
Python
"""Visualise system identification results — real vs simulated trajectories.
|
|
|
|
Loads a recording and runs simulation with both the original and tuned
|
|
parameters, then plots a 4-panel comparison (motor angle, motor vel,
|
|
pendulum angle, pendulum vel) over time.
|
|
|
|
Usage:
|
|
python -m src.sysid.visualize \
|
|
--robot-path assets/rotary_cartpole \
|
|
--recording assets/rotary_cartpole/recordings/capture_20260311_120000.npz
|
|
|
|
# Also compare with tuned parameters:
|
|
python -m src.sysid.visualize \
|
|
--robot-path assets/rotary_cartpole \
|
|
--recording <file>.npz \
|
|
--result assets/rotary_cartpole/sysid_result.json
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import structlog
|
|
|
|
log = structlog.get_logger()
|
|
|
|
|
|
def _run_sim(
|
|
robot_path: Path,
|
|
params: dict[str, float],
|
|
recording: dict[str, np.ndarray],
|
|
window_duration: float,
|
|
sim_dt: float,
|
|
substeps: int,
|
|
) -> dict[str, np.ndarray]:
|
|
"""Run windowed or open-loop rollout depending on window_duration."""
|
|
from src.sysid.rollout import rollout, windowed_rollout
|
|
|
|
if window_duration > 0:
|
|
return windowed_rollout(
|
|
robot_path=robot_path, params=params, recording=recording,
|
|
window_duration=window_duration, sim_dt=sim_dt,
|
|
substeps=substeps,
|
|
)
|
|
return rollout(
|
|
robot_path=robot_path, params=params, actions=recording["action"],
|
|
substeps=substeps,
|
|
)
|
|
|
|
|
|
def visualize(
|
|
robot_path: str | Path,
|
|
recording_path: str | Path,
|
|
result_path: str | Path | None = None,
|
|
sim_dt: float = 0.002,
|
|
substeps: int = 10,
|
|
window_duration: float = 0.5,
|
|
save_path: str | Path | None = None,
|
|
show: bool = True,
|
|
) -> None:
|
|
"""Generate comparison plot."""
|
|
import matplotlib.pyplot as plt
|
|
|
|
from src.sysid.rollout import (
|
|
ROTARY_CARTPOLE_PARAMS,
|
|
defaults_vector,
|
|
params_to_dict,
|
|
)
|
|
|
|
robot_path = Path(robot_path).resolve()
|
|
recording = dict(np.load(recording_path))
|
|
|
|
sim_kwargs = dict(
|
|
robot_path=robot_path, recording=recording,
|
|
window_duration=window_duration, sim_dt=sim_dt,
|
|
substeps=substeps,
|
|
)
|
|
|
|
t = recording["time"]
|
|
actions = recording["action"]
|
|
|
|
# ── Simulate with default parameters ─────────────────────────
|
|
default_params = params_to_dict(
|
|
defaults_vector(ROTARY_CARTPOLE_PARAMS), ROTARY_CARTPOLE_PARAMS
|
|
)
|
|
log.info("simulating_default_params", windowed=window_duration > 0)
|
|
sim_default = _run_sim(params=default_params, **sim_kwargs)
|
|
|
|
# ── Simulate with tuned parameters (if available) ────────────
|
|
# Resolve result path (explicit or auto-detect).
|
|
if result_path is None:
|
|
auto = robot_path / "sysid_result.json"
|
|
if auto.exists():
|
|
result_path = auto
|
|
|
|
sim_tuned = None
|
|
tuned_cost = None
|
|
if result_path is not None:
|
|
result_path = Path(result_path)
|
|
if result_path.exists():
|
|
result = json.loads(result_path.read_text())
|
|
tuned_params = result.get("best_params", {})
|
|
tuned_cost = result.get("best_cost")
|
|
log.info("simulating_tuned_params", cost=tuned_cost)
|
|
sim_tuned = _run_sim(params=tuned_params, **sim_kwargs)
|
|
else:
|
|
log.warning("result_file_not_found", path=str(result_path))
|
|
|
|
# ── Plot ─────────────────────────────────────────────────────
|
|
fig, axes = plt.subplots(5, 1, figsize=(14, 12), sharex=True)
|
|
|
|
channels = [
|
|
("motor_angle", "Motor Angle (rad)"),
|
|
("motor_vel", "Motor Velocity (rad/s)"),
|
|
("pendulum_angle", "Pendulum Angle (rad)"),
|
|
("pendulum_vel", "Pendulum Velocity (rad/s)"),
|
|
]
|
|
|
|
for ax, (key, ylabel) in zip(axes[:4], channels):
|
|
real = recording[key]
|
|
|
|
ax.plot(t, real, "k-", linewidth=1.2, alpha=0.8, label="Real")
|
|
ax.plot(
|
|
t,
|
|
sim_default[key],
|
|
"--",
|
|
color="#d62728",
|
|
linewidth=1.0,
|
|
alpha=0.7,
|
|
label="Sim (original)",
|
|
)
|
|
if sim_tuned is not None:
|
|
ax.plot(
|
|
t,
|
|
sim_tuned[key],
|
|
"--",
|
|
color="#2ca02c",
|
|
linewidth=1.0,
|
|
alpha=0.7,
|
|
label="Sim (tuned)",
|
|
)
|
|
|
|
ax.set_ylabel(ylabel)
|
|
ax.legend(loc="upper right", fontsize=8)
|
|
ax.grid(True, alpha=0.3)
|
|
|
|
# Action plot (bottom panel).
|
|
axes[4].plot(t, actions, "b-", linewidth=0.8, alpha=0.6)
|
|
axes[4].set_ylabel("Action (norm)")
|
|
axes[4].set_xlabel("Time (s)")
|
|
axes[4].grid(True, alpha=0.3)
|
|
axes[4].set_ylim(-1.1, 1.1)
|
|
|
|
# Title with cost info.
|
|
title = "System Identification — Real vs Simulated Trajectories"
|
|
if tuned_cost is not None:
|
|
# Compute original cost for comparison.
|
|
from src.sysid.optimize import cost_function
|
|
|
|
orig_cost = cost_function(
|
|
defaults_vector(ROTARY_CARTPOLE_PARAMS),
|
|
recording,
|
|
robot_path,
|
|
ROTARY_CARTPOLE_PARAMS,
|
|
sim_dt=sim_dt,
|
|
substeps=substeps,
|
|
window_duration=window_duration,
|
|
)
|
|
title += f"\nOriginal cost: {orig_cost:.4f} → Tuned cost: {tuned_cost:.4f}"
|
|
improvement = (1.0 - tuned_cost / orig_cost) * 100 if orig_cost > 0 else 0
|
|
title += f" ({improvement:+.1f}%)"
|
|
|
|
fig.suptitle(title, fontsize=12)
|
|
plt.tight_layout()
|
|
|
|
if save_path:
|
|
save_path = Path(save_path)
|
|
fig.savefig(str(save_path), dpi=150, bbox_inches="tight")
|
|
log.info("figure_saved", path=str(save_path))
|
|
|
|
if show:
|
|
plt.show()
|
|
else:
|
|
plt.close(fig)
|
|
|
|
|
|
# ── CLI entry point ──────────────────────────────────────────────────
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(
|
|
description="Visualise system identification results."
|
|
)
|
|
parser.add_argument(
|
|
"--robot-path",
|
|
type=str,
|
|
default="assets/rotary_cartpole",
|
|
)
|
|
parser.add_argument(
|
|
"--recording",
|
|
type=str,
|
|
required=True,
|
|
help="Path to .npz recording file",
|
|
)
|
|
parser.add_argument(
|
|
"--result",
|
|
type=str,
|
|
default=None,
|
|
help="Path to sysid_result.json (auto-detected if omitted)",
|
|
)
|
|
parser.add_argument("--sim-dt", type=float, default=0.002)
|
|
parser.add_argument("--substeps", type=int, default=10)
|
|
parser.add_argument(
|
|
"--window-duration",
|
|
type=float,
|
|
default=0.5,
|
|
help="Shooting window length in seconds (0 = open-loop)",
|
|
)
|
|
parser.add_argument(
|
|
"--save",
|
|
type=str,
|
|
default=None,
|
|
help="Save figure to this path (PNG, PDF, …)",
|
|
)
|
|
parser.add_argument(
|
|
"--no-show",
|
|
action="store_true",
|
|
help="Don't show interactive window (useful for CI)",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
visualize(
|
|
robot_path=args.robot_path,
|
|
recording_path=args.recording,
|
|
result_path=args.result,
|
|
sim_dt=args.sim_dt,
|
|
substeps=args.substeps,
|
|
window_duration=args.window_duration,
|
|
save_path=args.save,
|
|
show=not args.no_show,
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|