Source code for quantum_mind.embodied.motor
"""A single DC-motor joint: simulated dynamics, a noisy encoder and a privileged PD expert."""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
[docs]
@dataclass
class OneMotor:
"""One position-controlled motor joint (simulated).
``dtheta = omega``, ``domega = gain*u - damping*omega - coulomb*tanh(omega/0.02)`` with the command ``u`` clipped
to [-1, 1]. The joint position is normalised to the workspace [-1, 1]. The encoder reads the position with Gaussian
noise; the velocity estimate is a filtered finite difference of the noisy readings, as on a real drive.
``randomise`` draws the gain and damping per episode (domain randomisation), so a policy cannot memorise one plant.
"""
gain: float = 8.0
damping: float = 2.0
coulomb: float = 0.3
dt: float = 0.05
encoder_noise: float = 0.005
velocity_filter: float = 0.4
randomise: bool = True
def reset(self, theta0, rng):
self.rng = rng
self.g = self.gain * (rng.uniform(0.8, 1.2) if self.randomise else 1.0)
self.b = self.damping * (rng.uniform(0.8, 1.2) if self.randomise else 1.0)
self.theta, self.omega = float(theta0), 0.0
self.ext = 0.0 # unmodelled external acceleration (contact, payload), set by the caller
self._last = self.theta + self.encoder_noise * rng.standard_normal()
self._vel = 0.0
return self.measure(first=True)
[docs]
def measure(self, first=False):
"""Noisy ``(theta, omega)`` as the controller sees them."""
if first:
return self._last, 0.0
z = self.theta + self.encoder_noise * self.rng.standard_normal()
raw = (z - self._last) / self.dt
self._vel += self.velocity_filter * (raw - self._vel)
self._last = z
return z, self._vel
def step(self, u):
u = float(np.clip(u, -1.0, 1.0))
acc = self.g * u - self.b * self.omega - self.coulomb * np.tanh(self.omega / 0.02) + self.ext
w0 = self.omega
self.omega += self.dt * acc
nxt = self.theta + self.dt * self.omega
if abs(nxt) > 1.0: # hard stop at the end of the workspace: position held, velocity lost
nxt, self.omega = float(np.sign(nxt)), 0.0
self.theta = float(nxt)
self.acc = (self.omega - w0) / self.dt # the acceleration an accelerometer on the carriage actually reads
return self.measure()
[docs]
def expert_command(target, theta, omega, kp=3.0, kd=1.0):
"""Saturated PD command toward ``target`` (used as the demonstrator; it sees the true target)."""
return np.clip(kp * (np.asarray(target, float) - theta) - kd * omega, -1.0, 1.0)