✨ clean up lot of stuff
This commit is contained in:
@@ -6,6 +6,10 @@ the system, and records motor + pendulum angles and velocities at ~50 Hz.
|
||||
Saves a compressed numpy archive (.npz) that the optimizer can replay
|
||||
in simulation to fit physics parameters.
|
||||
|
||||
Serial protocol (same as SerialRunner):
|
||||
S,<ms>,<motor_rad>,<motor_vel>,<pend_rad>,<pend_vel>,<motor_speed>
|
||||
(7 comma-separated fields — firmware sends SI units)
|
||||
|
||||
Usage:
|
||||
python -m src.sysid.capture \
|
||||
--robot-path assets/rotary_cartpole \
|
||||
@@ -17,7 +21,6 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
import threading
|
||||
import time
|
||||
@@ -27,7 +30,6 @@ from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import structlog
|
||||
import yaml
|
||||
|
||||
log = structlog.get_logger()
|
||||
|
||||
@@ -36,25 +38,24 @@ log = structlog.get_logger()
|
||||
|
||||
|
||||
def _parse_state_line(line: str) -> dict[str, Any] | None:
|
||||
"""Parse an ``S,…`` state line from the ESP32."""
|
||||
"""Parse an ``S,…`` state line from the ESP32.
|
||||
|
||||
Format: S,<ms>,<motor_rad>,<motor_vel>,<pend_rad>,<pend_vel>,<motor_speed>
|
||||
(7 comma-separated fields, firmware sends SI units)
|
||||
"""
|
||||
if not line.startswith("S,"):
|
||||
return None
|
||||
parts = line.split(",")
|
||||
if len(parts) < 12:
|
||||
if len(parts) < 7:
|
||||
return None
|
||||
try:
|
||||
return {
|
||||
"timestamp_ms": int(parts[1]),
|
||||
"encoder_count": int(parts[2]),
|
||||
"rpm": float(parts[3]),
|
||||
"motor_speed": int(parts[4]),
|
||||
"at_limit": bool(int(parts[5])),
|
||||
"pendulum_angle": float(parts[6]),
|
||||
"pendulum_velocity": float(parts[7]),
|
||||
"target_speed": int(parts[8]),
|
||||
"braking": bool(int(parts[9])),
|
||||
"enc_vel_cps": float(parts[10]),
|
||||
"pendulum_ok": bool(int(parts[11])),
|
||||
"motor_rad": float(parts[2]),
|
||||
"motor_vel": float(parts[3]),
|
||||
"pend_rad": float(parts[4]),
|
||||
"pend_vel": float(parts[5]),
|
||||
"motor_speed": int(parts[6]),
|
||||
}
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
@@ -64,7 +65,12 @@ def _parse_state_line(line: str) -> dict[str, Any] | None:
|
||||
|
||||
|
||||
class _SerialReader:
|
||||
"""Minimal background reader for the ESP32 serial stream."""
|
||||
"""Minimal background reader for the ESP32 serial stream.
|
||||
|
||||
Uses a sequence counter so ``read_blocking()`` guarantees it returns
|
||||
a *new* state line (not a stale repeat). This keeps the capture
|
||||
loop locked to the firmware's 50 Hz tick.
|
||||
"""
|
||||
|
||||
def __init__(self, port: str, baud: int = 115200):
|
||||
import serial as _serial
|
||||
@@ -75,14 +81,16 @@ class _SerialReader:
|
||||
self.ser.reset_input_buffer()
|
||||
|
||||
self._latest: dict[str, Any] = {}
|
||||
self._seq: int = 0 # incremented on every new state line
|
||||
self._lock = threading.Lock()
|
||||
self._event = threading.Event()
|
||||
self._cond = threading.Condition(self._lock)
|
||||
self._running = True
|
||||
|
||||
self._thread = threading.Thread(target=self._reader_loop, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def _reader_loop(self) -> None:
|
||||
_debug_count = 0
|
||||
while self._running:
|
||||
try:
|
||||
if self.ser.in_waiting:
|
||||
@@ -91,11 +99,16 @@ class _SerialReader:
|
||||
.decode("utf-8", errors="ignore")
|
||||
.strip()
|
||||
)
|
||||
# Debug: log first 10 raw lines so we can see what the firmware sends.
|
||||
if _debug_count < 10 and line:
|
||||
log.info("serial_raw_line", line=repr(line), count=_debug_count)
|
||||
_debug_count += 1
|
||||
parsed = _parse_state_line(line)
|
||||
if parsed is not None:
|
||||
with self._lock:
|
||||
with self._cond:
|
||||
self._latest = parsed
|
||||
self._event.set()
|
||||
self._seq += 1
|
||||
self._cond.notify_all()
|
||||
else:
|
||||
time.sleep(0.001)
|
||||
except (OSError, self._serial_mod.SerialException):
|
||||
@@ -108,14 +121,19 @@ class _SerialReader:
|
||||
except (OSError, self._serial_mod.SerialException):
|
||||
log.critical("serial_send_failed", cmd=cmd)
|
||||
|
||||
def read(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
return dict(self._latest)
|
||||
|
||||
def read_blocking(self, timeout: float = 0.1) -> dict[str, Any]:
|
||||
self._event.clear()
|
||||
self._event.wait(timeout=timeout)
|
||||
return self.read()
|
||||
"""Wait until a *new* state line arrives, then return it.
|
||||
|
||||
Uses a sequence counter to guarantee the returned state is
|
||||
different from whatever was available before this call.
|
||||
"""
|
||||
with self._cond:
|
||||
seq_before = self._seq
|
||||
if not self._cond.wait_for(
|
||||
lambda: self._seq > seq_before, timeout=timeout
|
||||
):
|
||||
return {} # timeout — no new data
|
||||
return dict(self._latest)
|
||||
|
||||
def close(self) -> None:
|
||||
self._running = False
|
||||
@@ -139,7 +157,7 @@ class _PRBSExcitation:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
amplitude: int = 180,
|
||||
amplitude: int = 150,
|
||||
hold_min_ms: int = 50,
|
||||
hold_max_ms: int = 300,
|
||||
):
|
||||
@@ -169,42 +187,39 @@ def capture(
|
||||
port: str = "/dev/cu.usbserial-0001",
|
||||
baud: int = 115200,
|
||||
duration: float = 20.0,
|
||||
amplitude: int = 180,
|
||||
amplitude: int = 150,
|
||||
hold_min_ms: int = 50,
|
||||
hold_max_ms: int = 300,
|
||||
dt: float = 0.02,
|
||||
motor_angle_limit_deg: float = 90.0,
|
||||
) -> Path:
|
||||
"""Run the capture procedure and return the path to the saved .npz file.
|
||||
|
||||
The capture loop is **stream-driven**: it blocks on each incoming
|
||||
state line from the firmware (which arrives at 50 Hz), sends the
|
||||
next motor command immediately, and records both.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
robot_path : path to robot asset directory (contains hardware.yaml)
|
||||
robot_path : path to robot asset directory
|
||||
port : serial port for ESP32
|
||||
baud : baud rate
|
||||
duration : capture duration in seconds
|
||||
amplitude : max PWM magnitude for excitation (0–255)
|
||||
amplitude : max PWM magnitude for excitation
|
||||
hold_min_ms / hold_max_ms : random hold time range (ms)
|
||||
dt : target sampling period (seconds), default 50 Hz
|
||||
dt : nominal sample period for buffer sizing (seconds)
|
||||
motor_angle_limit_deg : safety limit for motor angle
|
||||
"""
|
||||
robot_path = Path(robot_path).resolve()
|
||||
|
||||
# Load hardware config for encoder conversion + safety.
|
||||
hw_yaml = robot_path / "hardware.yaml"
|
||||
if not hw_yaml.exists():
|
||||
raise FileNotFoundError(f"hardware.yaml not found in {robot_path}")
|
||||
raw_hw = yaml.safe_load(hw_yaml.read_text())
|
||||
ppr = raw_hw.get("encoder", {}).get("ppr", 11)
|
||||
gear_ratio = raw_hw.get("encoder", {}).get("gear_ratio", 30.0)
|
||||
counts_per_rev: float = ppr * gear_ratio * 4.0
|
||||
max_motor_deg = raw_hw.get("safety", {}).get("max_motor_angle_deg", 90.0)
|
||||
max_motor_rad = math.radians(max_motor_deg) if max_motor_deg > 0 else 0.0
|
||||
max_motor_rad = math.radians(motor_angle_limit_deg) if motor_angle_limit_deg > 0 else 0.0
|
||||
|
||||
# Connect.
|
||||
reader = _SerialReader(port, baud)
|
||||
excitation = _PRBSExcitation(amplitude, hold_min_ms, hold_max_ms)
|
||||
|
||||
# Prepare recording buffers.
|
||||
max_samples = int(duration / dt) + 500 # headroom
|
||||
# Prepare recording buffers (generous headroom).
|
||||
max_samples = int(duration / dt) + 500
|
||||
rec_time = np.zeros(max_samples, dtype=np.float64)
|
||||
rec_action = np.zeros(max_samples, dtype=np.float64)
|
||||
rec_motor_angle = np.zeros(max_samples, dtype=np.float64)
|
||||
@@ -222,63 +237,96 @@ def capture(
|
||||
duration=duration,
|
||||
amplitude=amplitude,
|
||||
hold_range_ms=f"{hold_min_ms}–{hold_max_ms}",
|
||||
dt=dt,
|
||||
mode="stream-driven (firmware clock)",
|
||||
)
|
||||
|
||||
idx = 0
|
||||
pwm = 0
|
||||
last_esp_ms = -1 # firmware timestamp of last recorded sample
|
||||
no_data_count = 0 # consecutive timeouts with no data
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
while True:
|
||||
loop_start = time.monotonic()
|
||||
elapsed = loop_start - t0
|
||||
# Block until the firmware sends the next state line (~20 ms).
|
||||
# Timeout at 100 ms prevents hanging if the ESP32 disconnects.
|
||||
state = reader.read_blocking(timeout=0.1)
|
||||
if not state:
|
||||
no_data_count += 1
|
||||
if no_data_count == 30: # 3 seconds with no data
|
||||
log.warning(
|
||||
"no_data_received",
|
||||
msg="No state lines from firmware after 3s. "
|
||||
"Check: is the ESP32 powered? Is it running the right firmware? "
|
||||
"Try pressing the RESET button.",
|
||||
)
|
||||
if no_data_count == 100: # 10 seconds
|
||||
log.critical(
|
||||
"no_data_timeout",
|
||||
msg="No data for 10s — aborting capture.",
|
||||
)
|
||||
break
|
||||
continue # no data yet — retry
|
||||
no_data_count = 0
|
||||
|
||||
# Deduplicate: the firmware may send multiple state lines per
|
||||
# tick (e.g. M-command echo + tick). Only record one sample
|
||||
# per unique firmware timestamp.
|
||||
esp_ms = state.get("timestamp_ms", 0)
|
||||
if esp_ms == last_esp_ms:
|
||||
continue
|
||||
last_esp_ms = esp_ms
|
||||
|
||||
elapsed = time.monotonic() - t0
|
||||
if elapsed >= duration:
|
||||
break
|
||||
|
||||
# Get excitation PWM.
|
||||
# Get excitation PWM for the NEXT tick.
|
||||
pwm = excitation()
|
||||
|
||||
# Safety: reverse/zero if near motor limit.
|
||||
state = reader.read()
|
||||
if state:
|
||||
motor_angle_rad = (
|
||||
state.get("encoder_count", 0) / counts_per_rev * 2.0 * math.pi
|
||||
)
|
||||
if max_motor_rad > 0:
|
||||
margin = max_motor_rad * 0.85 # start braking at 85%
|
||||
if motor_angle_rad > margin and pwm > 0:
|
||||
pwm = -abs(pwm) # reverse
|
||||
elif motor_angle_rad < -margin and pwm < 0:
|
||||
pwm = abs(pwm) # reverse
|
||||
# Safety: keep the arm well within its mechanical range.
|
||||
# Firmware sends motor angle in radians — use directly.
|
||||
motor_angle_rad = state.get("motor_rad", 0.0)
|
||||
if max_motor_rad > 0:
|
||||
ratio = motor_angle_rad / max_motor_rad # signed, -1..+1
|
||||
abs_ratio = abs(ratio)
|
||||
|
||||
# Send command.
|
||||
if abs_ratio > 0.90:
|
||||
# Deep in the danger zone — force a strong return.
|
||||
brake_strength = min(1.0, (abs_ratio - 0.90) / 0.10) # 0→1
|
||||
brake_pwm = int(amplitude * (0.5 + 0.5 * brake_strength))
|
||||
pwm = -brake_pwm if ratio > 0 else brake_pwm
|
||||
elif abs_ratio > 0.70:
|
||||
# Soft zone — only allow actions pointing back to centre.
|
||||
if ratio > 0 and pwm > 0:
|
||||
pwm = -abs(pwm)
|
||||
elif ratio < 0 and pwm < 0:
|
||||
pwm = abs(pwm)
|
||||
|
||||
# Send command immediately — it will take effect on the next tick.
|
||||
reader.send(f"M{pwm}")
|
||||
|
||||
# Wait for fresh data.
|
||||
time.sleep(max(0, dt - (time.monotonic() - loop_start) - 0.005))
|
||||
state = reader.read_blocking(timeout=dt)
|
||||
# Record this tick's state + the action the motor *actually*
|
||||
# received. Firmware sends SI units — use directly.
|
||||
motor_angle = state.get("motor_rad", 0.0)
|
||||
motor_vel = state.get("motor_vel", 0.0)
|
||||
pend_angle = state.get("pend_rad", 0.0)
|
||||
pend_vel = state.get("pend_vel", 0.0)
|
||||
# Firmware constrains to ±255; normalise to [-1, 1].
|
||||
applied = state.get("motor_speed", 0)
|
||||
action_norm = max(-255, min(255, applied)) / 255.0
|
||||
|
||||
if state:
|
||||
enc = state.get("encoder_count", 0)
|
||||
motor_angle = enc / counts_per_rev * 2.0 * math.pi
|
||||
motor_vel = (
|
||||
state.get("enc_vel_cps", 0.0) / counts_per_rev * 2.0 * math.pi
|
||||
)
|
||||
pend_angle = math.radians(state.get("pendulum_angle", 0.0))
|
||||
pend_vel = math.radians(state.get("pendulum_velocity", 0.0))
|
||||
if idx < max_samples:
|
||||
rec_time[idx] = elapsed
|
||||
rec_action[idx] = action_norm
|
||||
rec_motor_angle[idx] = motor_angle
|
||||
rec_motor_vel[idx] = motor_vel
|
||||
rec_pend_angle[idx] = pend_angle
|
||||
rec_pend_vel[idx] = pend_vel
|
||||
idx += 1
|
||||
else:
|
||||
break # buffer full
|
||||
|
||||
# Normalised action: PWM / 255 → [-1, 1]
|
||||
action_norm = pwm / 255.0
|
||||
|
||||
if idx < max_samples:
|
||||
rec_time[idx] = elapsed
|
||||
rec_action[idx] = action_norm
|
||||
rec_motor_angle[idx] = motor_angle
|
||||
rec_motor_vel[idx] = motor_vel
|
||||
rec_pend_angle[idx] = pend_angle
|
||||
rec_pend_vel[idx] = pend_vel
|
||||
idx += 1
|
||||
|
||||
# Progress.
|
||||
# Progress (every 50 samples ≈ once per second at 50 Hz).
|
||||
if idx % 50 == 0:
|
||||
log.info(
|
||||
"capture_progress",
|
||||
@@ -287,11 +335,6 @@ def capture(
|
||||
pwm=pwm,
|
||||
)
|
||||
|
||||
# Pace to dt.
|
||||
remaining = dt - (time.monotonic() - loop_start)
|
||||
if remaining > 0:
|
||||
time.sleep(remaining)
|
||||
|
||||
finally:
|
||||
reader.send("M0")
|
||||
reader.close()
|
||||
@@ -339,7 +382,7 @@ def main() -> None:
|
||||
"--robot-path",
|
||||
type=str,
|
||||
default="assets/rotary_cartpole",
|
||||
help="Path to robot asset directory (contains hardware.yaml)",
|
||||
help="Path to robot asset directory",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--port",
|
||||
@@ -352,7 +395,8 @@ def main() -> None:
|
||||
"--duration", type=float, default=20.0, help="Capture duration (s)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--amplitude", type=int, default=180, help="Max PWM magnitude (0–255)"
|
||||
"--amplitude", type=int, default=150,
|
||||
help="Max PWM magnitude (should not exceed firmware MAX_MOTOR_SPEED=150)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--hold-min-ms", type=int, default=50, help="Min hold time (ms)"
|
||||
@@ -361,7 +405,11 @@ def main() -> None:
|
||||
"--hold-max-ms", type=int, default=300, help="Max hold time (ms)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dt", type=float, default=0.02, help="Sample period (s)"
|
||||
"--dt", type=float, default=0.02, help="Nominal sample period for buffer sizing (s)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--motor-angle-limit", type=float, default=90.0,
|
||||
help="Motor angle safety limit in degrees (0 = disabled)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -374,6 +422,7 @@ def main() -> None:
|
||||
hold_min_ms=args.hold_min_ms,
|
||||
hold_max_ms=args.hold_max_ms,
|
||||
dt=args.dt,
|
||||
motor_angle_limit_deg=args.motor_angle_limit,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user