✨ clean up lot of stuff
This commit is contained in:
@@ -4,10 +4,15 @@ Minimises the trajectory-matching cost between a MuJoCo rollout and a
|
||||
recorded real-robot sequence. Uses the ``cmaes`` package (pure-Python
|
||||
CMA-ES with native box-constraint support).
|
||||
|
||||
Motor parameters are **locked** from the motor-only sysid — only
|
||||
pendulum/arm inertial parameters, joint dynamics, and ctrl_limit are
|
||||
optimised. Velocities are optionally preprocessed with Savitzky-Golay
|
||||
differentiation for cleaner targets.
|
||||
|
||||
Usage:
|
||||
python -m src.sysid.optimize \
|
||||
--robot-path assets/rotary_cartpole \
|
||||
--recording assets/rotary_cartpole/recordings/capture_20260311_120000.npz
|
||||
--recording assets/rotary_cartpole/recordings/capture_20260314_000435.npz
|
||||
|
||||
# Shorter run for testing:
|
||||
python -m src.sysid.optimize \
|
||||
@@ -28,6 +33,8 @@ import numpy as np
|
||||
import structlog
|
||||
|
||||
from src.sysid.rollout import (
|
||||
LOCKED_MOTOR_PARAMS,
|
||||
PARAM_SETS,
|
||||
ROTARY_CARTPOLE_PARAMS,
|
||||
ParamSpec,
|
||||
bounds_arrays,
|
||||
@@ -40,6 +47,65 @@ from src.sysid.rollout import (
|
||||
log = structlog.get_logger()
|
||||
|
||||
|
||||
# ── Velocity preprocessing ───────────────────────────────────────────
|
||||
|
||||
|
||||
def _preprocess_recording(
|
||||
recording: dict[str, np.ndarray],
|
||||
preprocess_vel: bool = True,
|
||||
sg_window: int = 7,
|
||||
sg_polyorder: int = 3,
|
||||
) -> dict[str, np.ndarray]:
|
||||
"""Optionally recompute velocities using Savitzky-Golay differentiation.
|
||||
|
||||
Applies SG filtering to both motor_vel and pendulum_vel, replacing
|
||||
the noisy firmware finite-difference velocities with smooth
|
||||
analytical derivatives of the (clean) angle signals.
|
||||
"""
|
||||
if not preprocess_vel:
|
||||
return recording
|
||||
|
||||
from scipy.signal import savgol_filter
|
||||
|
||||
rec = dict(recording)
|
||||
times = rec["time"]
|
||||
dt = float(np.mean(np.diff(times)))
|
||||
|
||||
# Motor velocity.
|
||||
rec["motor_vel_raw"] = rec["motor_vel"].copy()
|
||||
rec["motor_vel"] = savgol_filter(
|
||||
rec["motor_angle"],
|
||||
window_length=sg_window,
|
||||
polyorder=sg_polyorder,
|
||||
deriv=1,
|
||||
delta=dt,
|
||||
)
|
||||
|
||||
# Pendulum velocity.
|
||||
rec["pendulum_vel_raw"] = rec["pendulum_vel"].copy()
|
||||
rec["pendulum_vel"] = savgol_filter(
|
||||
rec["pendulum_angle"],
|
||||
window_length=sg_window,
|
||||
polyorder=sg_polyorder,
|
||||
deriv=1,
|
||||
delta=dt,
|
||||
)
|
||||
|
||||
motor_noise = np.std(rec["motor_vel_raw"] - rec["motor_vel"])
|
||||
pend_noise = np.std(rec["pendulum_vel_raw"] - rec["pendulum_vel"])
|
||||
log.info(
|
||||
"velocity_preprocessed",
|
||||
method="savgol",
|
||||
sg_window=sg_window,
|
||||
sg_polyorder=sg_polyorder,
|
||||
dt_ms=f"{dt*1000:.1f}",
|
||||
motor_noise_std=f"{motor_noise:.3f} rad/s",
|
||||
pend_noise_std=f"{pend_noise:.3f} rad/s",
|
||||
)
|
||||
|
||||
return rec
|
||||
|
||||
|
||||
# ── Cost function ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -63,18 +129,35 @@ def _compute_trajectory_cost(
|
||||
recording: dict[str, np.ndarray],
|
||||
pos_weight: float = 1.0,
|
||||
vel_weight: float = 0.1,
|
||||
pendulum_scale: float = 3.0,
|
||||
vel_outlier_threshold: float = 20.0,
|
||||
) -> float:
|
||||
"""Weighted MSE between sim and real trajectories."""
|
||||
"""Weighted MSE between sim and real trajectories.
|
||||
|
||||
pendulum_scale multiplies the pendulum terms relative to motor terms.
|
||||
|
||||
Samples where the *real* pendulum velocity exceeds
|
||||
``vel_outlier_threshold`` (rad/s) are excluded from the velocity
|
||||
terms. These are encoder-wrap artefacts (pendulum swinging past
|
||||
vertical) that no simulator can reproduce.
|
||||
"""
|
||||
motor_err = _angle_diff(sim["motor_angle"], recording["motor_angle"])
|
||||
pend_err = _angle_diff(sim["pendulum_angle"], recording["pendulum_angle"])
|
||||
motor_vel_err = sim["motor_vel"] - recording["motor_vel"]
|
||||
pend_vel_err = sim["pendulum_vel"] - recording["pendulum_vel"]
|
||||
|
||||
# Mask out encoder-wrap velocity spikes so the optimizer doesn't
|
||||
# waste capacity fitting artefacts.
|
||||
valid = np.abs(recording["pendulum_vel"]) < vel_outlier_threshold
|
||||
if valid.sum() < len(valid):
|
||||
motor_vel_err = motor_vel_err[valid]
|
||||
pend_vel_err = pend_vel_err[valid]
|
||||
|
||||
return float(
|
||||
pos_weight * np.mean(motor_err**2)
|
||||
+ pos_weight * np.mean(pend_err**2)
|
||||
+ pos_weight * pendulum_scale * np.mean(pend_err**2)
|
||||
+ vel_weight * np.mean(motor_vel_err**2)
|
||||
+ vel_weight * np.mean(pend_vel_err**2)
|
||||
+ vel_weight * pendulum_scale * np.mean(pend_vel_err**2)
|
||||
)
|
||||
|
||||
|
||||
@@ -87,7 +170,9 @@ def cost_function(
|
||||
substeps: int = 10,
|
||||
pos_weight: float = 1.0,
|
||||
vel_weight: float = 0.1,
|
||||
pendulum_scale: float = 3.0,
|
||||
window_duration: float = 0.5,
|
||||
motor_params: dict[str, float] | None = None,
|
||||
) -> float:
|
||||
"""Compute trajectory-matching cost for a candidate parameter vector.
|
||||
|
||||
@@ -113,21 +198,56 @@ def cost_function(
|
||||
window_duration=window_duration,
|
||||
sim_dt=sim_dt,
|
||||
substeps=substeps,
|
||||
motor_params=motor_params,
|
||||
)
|
||||
else:
|
||||
sim = rollout(
|
||||
robot_path=robot_path,
|
||||
params=params,
|
||||
actions=recording["action"],
|
||||
timesteps=recording["time"],
|
||||
sim_dt=sim_dt,
|
||||
substeps=substeps,
|
||||
motor_params=motor_params,
|
||||
)
|
||||
except Exception as exc:
|
||||
log.warning("rollout_failed", error=str(exc))
|
||||
return 1e6
|
||||
|
||||
return _compute_trajectory_cost(sim, recording, pos_weight, vel_weight)
|
||||
# Check for NaN in sim output.
|
||||
for key in ("motor_angle", "motor_vel", "pendulum_angle", "pendulum_vel"):
|
||||
if np.any(~np.isfinite(sim[key])):
|
||||
return 1e6
|
||||
|
||||
return _compute_trajectory_cost(sim, recording, pos_weight, vel_weight, pendulum_scale)
|
||||
|
||||
|
||||
# ── Parallel evaluation helper (module-level for pickling) ───────────
|
||||
|
||||
# Shared state set by the parent process before spawning workers.
|
||||
_par_recording: dict[str, np.ndarray] = {}
|
||||
_par_robot_path: Path = Path(".")
|
||||
_par_specs: list[ParamSpec] = []
|
||||
_par_kwargs: dict = {}
|
||||
|
||||
|
||||
def _init_worker(recording, robot_path, specs, kwargs):
|
||||
"""Initialiser run once per worker process."""
|
||||
global _par_recording, _par_robot_path, _par_specs, _par_kwargs
|
||||
_par_recording = recording
|
||||
_par_robot_path = robot_path
|
||||
_par_specs = specs
|
||||
_par_kwargs = kwargs
|
||||
|
||||
|
||||
def _eval_candidate(x_natural: np.ndarray) -> float:
|
||||
"""Evaluate a single candidate — called in worker processes."""
|
||||
return cost_function(
|
||||
x_natural,
|
||||
_par_recording,
|
||||
_par_robot_path,
|
||||
_par_specs,
|
||||
**_par_kwargs,
|
||||
)
|
||||
|
||||
|
||||
# ── CMA-ES optimisation loop ────────────────────────────────────────
|
||||
@@ -144,17 +264,25 @@ def optimize(
|
||||
substeps: int = 10,
|
||||
pos_weight: float = 1.0,
|
||||
vel_weight: float = 0.1,
|
||||
pendulum_scale: float = 3.0,
|
||||
window_duration: float = 0.5,
|
||||
seed: int = 42,
|
||||
preprocess_vel: bool = True,
|
||||
sg_window: int = 7,
|
||||
sg_polyorder: int = 3,
|
||||
) -> dict:
|
||||
"""Run CMA-ES optimisation and return results.
|
||||
|
||||
Motor parameters are locked from the motor-only sysid.
|
||||
Only pendulum/arm parameters are optimised.
|
||||
|
||||
Returns a dict with:
|
||||
best_params: dict[str, float]
|
||||
best_cost: float
|
||||
history: list of (generation, best_cost) tuples
|
||||
recording: str (path used)
|
||||
specs: list of param names
|
||||
motor_params: dict of locked motor params
|
||||
"""
|
||||
from cmaes import CMA
|
||||
|
||||
@@ -164,8 +292,24 @@ def optimize(
|
||||
if specs is None:
|
||||
specs = ROTARY_CARTPOLE_PARAMS
|
||||
|
||||
motor_params = dict(LOCKED_MOTOR_PARAMS)
|
||||
log.info(
|
||||
"motor_params_locked",
|
||||
n_params=len(motor_params),
|
||||
gear_avg=f"{(motor_params['actuator_gear_pos'] + motor_params['actuator_gear_neg']) / 2:.4f}",
|
||||
)
|
||||
|
||||
# Load recording.
|
||||
recording = dict(np.load(recording_path))
|
||||
|
||||
# Preprocessing: SG velocity recomputation.
|
||||
recording = _preprocess_recording(
|
||||
recording,
|
||||
preprocess_vel=preprocess_vel,
|
||||
sg_window=sg_window,
|
||||
sg_polyorder=sg_polyorder,
|
||||
)
|
||||
|
||||
n_samples = len(recording["time"])
|
||||
duration = recording["time"][-1] - recording["time"][0]
|
||||
n_windows = max(1, int(duration / window_duration)) if window_duration > 0 else 1
|
||||
@@ -219,28 +363,54 @@ def optimize(
|
||||
|
||||
t0 = time.monotonic()
|
||||
|
||||
# ── Parallel evaluation setup ────────────────────────────────
|
||||
# Each candidate is independent — evaluate them in parallel using
|
||||
# a process pool. Falls back to sequential if n_workers=1.
|
||||
import multiprocessing as mp
|
||||
n_workers = max(1, mp.cpu_count() - 1) # leave 1 core free
|
||||
|
||||
eval_kwargs = dict(
|
||||
sim_dt=sim_dt,
|
||||
substeps=substeps,
|
||||
pos_weight=pos_weight,
|
||||
vel_weight=vel_weight,
|
||||
pendulum_scale=pendulum_scale,
|
||||
window_duration=window_duration,
|
||||
motor_params=motor_params,
|
||||
)
|
||||
|
||||
log.info("parallel_workers", n_workers=n_workers)
|
||||
|
||||
# Create a persistent pool (avoids per-generation fork overhead).
|
||||
pool = None
|
||||
if n_workers > 1:
|
||||
pool = mp.Pool(
|
||||
n_workers,
|
||||
initializer=_init_worker,
|
||||
initargs=(recording, robot_path, specs, eval_kwargs),
|
||||
)
|
||||
|
||||
for gen in range(max_generations):
|
||||
solutions = []
|
||||
# Ask all candidates first.
|
||||
candidates_normed = []
|
||||
candidates_natural = []
|
||||
for _ in range(optimizer.population_size):
|
||||
x_normed = optimizer.ask()
|
||||
x_natural = from_normed(x_normed)
|
||||
|
||||
# Clip to bounds (CMA-ES can slightly exceed with sampling noise).
|
||||
x_natural = np.clip(x_natural, lo, hi)
|
||||
candidates_normed.append(x_normed)
|
||||
candidates_natural.append(x_natural)
|
||||
|
||||
c = cost_function(
|
||||
x_natural,
|
||||
recording,
|
||||
robot_path,
|
||||
specs,
|
||||
sim_dt=sim_dt,
|
||||
substeps=substeps,
|
||||
pos_weight=pos_weight,
|
||||
vel_weight=vel_weight,
|
||||
window_duration=window_duration,
|
||||
)
|
||||
solutions.append((x_normed, c))
|
||||
# Evaluate in parallel.
|
||||
if pool is not None:
|
||||
costs = pool.map(_eval_candidate, candidates_natural)
|
||||
else:
|
||||
costs = [cost_function(
|
||||
x, recording, robot_path, specs, **eval_kwargs
|
||||
) for x in candidates_natural]
|
||||
|
||||
solutions = list(zip(candidates_normed, costs))
|
||||
for x_natural, c in zip(candidates_natural, costs):
|
||||
if c < best_cost:
|
||||
best_cost = c
|
||||
best_params_vec = x_natural.copy()
|
||||
@@ -259,6 +429,12 @@ def optimize(
|
||||
)
|
||||
|
||||
total_time = time.monotonic() - t0
|
||||
|
||||
# Clean up process pool.
|
||||
if pool is not None:
|
||||
pool.close()
|
||||
pool.join()
|
||||
|
||||
best_params = params_to_dict(best_params_vec, specs)
|
||||
|
||||
log.info(
|
||||
@@ -289,6 +465,8 @@ def optimize(
|
||||
"recording": str(recording_path),
|
||||
"param_names": [s.name for s in specs],
|
||||
"defaults": {s.name: s.default for s in specs},
|
||||
"motor_params": motor_params,
|
||||
"preprocess_vel": preprocess_vel,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
@@ -319,6 +497,8 @@ def main() -> None:
|
||||
parser.add_argument("--substeps", type=int, default=10)
|
||||
parser.add_argument("--pos-weight", type=float, default=1.0)
|
||||
parser.add_argument("--vel-weight", type=float, default=0.1)
|
||||
parser.add_argument("--pendulum-scale", type=float, default=3.0,
|
||||
help="Multiplier for pendulum terms relative to motor (default 3.0)")
|
||||
parser.add_argument(
|
||||
"--window-duration",
|
||||
type=float,
|
||||
@@ -331,11 +511,30 @@ def main() -> None:
|
||||
action="store_true",
|
||||
help="Skip exporting tuned files (results JSON only)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-preprocess-vel",
|
||||
action="store_true",
|
||||
help="Skip Savitzky-Golay velocity preprocessing",
|
||||
)
|
||||
parser.add_argument("--sg-window", type=int, default=7,
|
||||
help="Savitzky-Golay window length (odd, default 7)")
|
||||
parser.add_argument("--sg-polyorder", type=int, default=3,
|
||||
help="Savitzky-Golay polynomial order (default 3)")
|
||||
parser.add_argument(
|
||||
"--param-set",
|
||||
type=str,
|
||||
default="full",
|
||||
choices=list(PARAM_SETS.keys()),
|
||||
help="Parameter set to optimize: 'reduced' (6 params, fast) or 'full' (15 params)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
specs = PARAM_SETS[args.param_set]
|
||||
|
||||
result = optimize(
|
||||
robot_path=args.robot_path,
|
||||
recording_path=args.recording,
|
||||
specs=specs,
|
||||
sigma0=args.sigma0,
|
||||
population_size=args.population_size,
|
||||
max_generations=args.max_generations,
|
||||
@@ -343,8 +542,12 @@ def main() -> None:
|
||||
substeps=args.substeps,
|
||||
pos_weight=args.pos_weight,
|
||||
vel_weight=args.vel_weight,
|
||||
pendulum_scale=args.pendulum_scale,
|
||||
window_duration=args.window_duration,
|
||||
seed=args.seed,
|
||||
preprocess_vel=not args.no_preprocess_vel,
|
||||
sg_window=args.sg_window,
|
||||
sg_polyorder=args.sg_polyorder,
|
||||
)
|
||||
|
||||
# Save results JSON.
|
||||
@@ -369,6 +572,7 @@ def main() -> None:
|
||||
export_tuned_files(
|
||||
robot_path=args.robot_path,
|
||||
params=result["best_params"],
|
||||
motor_params=result.get("motor_params"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user