Source code for quantum_mind.embodied.agent

"""A monitoring and self-healing agent for the motor.

Not a language-model agent: a supervisory loop with four jobs, run every control step.

1. **Estimate** (state slot): a Kalman filter fuses the encoder with the MEMS accelerometer and, when it is used, the slow quantum
   accelerometer, whose near-bias-free samples keep the MEMS bias calibrated.
2. **Monitor**: the accelerometer measures what the joint really did, so two redundancy checks are available. (a) The encoder is compared
   with a one-step dead-reckoned prediction from the accelerometer: a jump or a dropout is an encoder fault. (b) A single-fault
   least-squares fits of ``accel = gain*u - damping*omega - coulomb*tanh(omega/0.02) + push`` over the last second (one free parameter per
   hypothesis, so the fault types are not confounded).
3. **Diagnose**: an estimate that leaves the nominal band for several steps names the fault: actuator gain loss, friction increase,
   external push, encoder jump, encoder lost, or an unrecoverable actuator.
4. **Heal**: encoder faults mask the encoder and fall back on the accelerometers; plant faults re-linearise the command with the
   fitted plant (``u = (a_des + damping*omega + coulomb*tanh - push) / gain``); an unrecoverable actuator ends in a safe stop.

Only faults of those five kinds are handled. The agent knows the nominal plant (it is the controller's design model), which is the
classical assumption for fault detection; whether that holds on a real drive is a question for hardware.
"""
from __future__ import annotations

import numpy as np

from .state import JointEstimator

NOMINAL = np.array([8.0, 2.0, 0.3, 0.0])        # gain, damping, Coulomb friction, external acceleration (workspace units/s^2)


[docs] class MotorAgent: """Monitor, diagnose and heal one motor. ``heal=False`` only monitors; ``quantum=False`` leaves the quantum accelerometer out.""" def __init__(self, heal=True, quantum=True, dt=0.05, stroke=0.2, window=16, persist=3): self.heal, self.dt, self.s, self.W, self.persist = heal, dt, stroke, window, persist self.mode = 'kalman + MEMS + quantum' if quantum else 'kalman + MEMS' self.quantum = quantum self.est = JointEstimator(self.mode, dt=dt, stroke=stroke) def reset(self, z0): self.est.reset(z0) self.est.quantum_bias = self.quantum self.ref = np.array(NOMINAL) # healthy plant, calibrated online while nothing is wrong self.plant = np.array(NOMINAL) self.cal, self.Pcal = np.array(NOMINAL[:2]), np.diag([4.0, 1.0]) self.buf = [] self.t, self.enc_ok, self.bad_enc, self.none_enc, self.agree = 0.0, True, 0, 0, 0 self.flags, self.counts, self.events, self.safe_stop, self.dead = set(), {}, [], False, 0 self.label = None # latest plant diagnosis; it can be revised as more data arrive self.theta_hat, self.omega_hat = z0, 0.0 # ---- monitor ------------------------------------------------------------------------------------------------------ def _log(self, kind): if kind not in self.flags: self.flags.add(kind) self.events.append((round(self.t, 2), kind))
[docs] def step(self, meas, u): """Feed one set of measurements (and the command that produced them). Returns the state estimate ``(theta, omega)``.""" self.t += self.dt x, dt = self.est.x, self.dt a_corr = meas['a_mems'] / self.s - x[2] # measured acceleration, bias removed enc = meas['enc'] if enc is None: self.none_enc += 1 if self.none_enc >= 2 and self.enc_ok: self.enc_ok = False self._log('encoder_lost') else: self.none_enc = 0 pred = x[0] + x[1] * dt + 0.5 * dt * dt * a_corr # dead-reckoned position jump = abs(enc - pred) > 0.035 + 0.004 * abs(a_corr) # integrator mismatch grows with acceleration self.bad_enc = self.bad_enc + 1 if jump else 0 if self.bad_enc >= 3 and self.enc_ok: self.enc_ok = False self._log('encoder_jump') if self.heal and not self.enc_ok: # masked: let the encoder back in once it agrees with the state again self.agree = self.agree + 1 if abs(enc - self.est.x[0]) < 0.04 else 0 if self.agree >= 10: self.enc_ok, self.agree, self.bad_enc = True, 0, 0 self.est.mode, self.est.plant = self.mode, None self.events.append((round(self.t, 2), 'encoder_recovered')) self.flags -= {'encoder_lost', 'encoder_jump'} if self.heal and (jump or not self.enc_ok): enc = None # never feed a suspect reading to the filter if self.heal and not self.enc_ok: # heal: predict with the diagnosed plant, not the raw accelerometer self.est.mode, self.est.plant = 'kalman + motor model', self.plant theta, omega = self.est.step({**meas, 'enc': enc}, u) self.buf = (self.buf + [(a_corr, float(np.clip(u, -1, 1)), x[1])])[-self.W:] self._calibrate(a_corr, u, x[1]) self._diagnose() self.theta_hat, self.omega_hat = theta, omega return theta, omega
def _calibrate(self, y, u, omega): """Self-calibration: while the encoder is trusted and nothing is flagged, track gain and damping (recursive least squares).""" if not self.enc_ok or ({'actuator_degraded', 'friction_increase', 'external_push'} & self.flags): return phi = np.array([np.clip(u, -1, 1), -omega]) Pphi = self.Pcal @ phi k = Pphi / (0.995 + phi @ Pphi + 0.05 ** 2) self.cal = self.cal + k * (y + NOMINAL[2] * np.tanh(omega / 0.02) - phi @ self.cal) self.Pcal = np.minimum((self.Pcal - np.outer(k, Pphi)) / 0.995, 20.0) self.ref = np.array([self.cal[0], self.cal[1], NOMINAL[2], 0.0]) self.plant = self.ref def _fits(self): """Single-fault least-squares fits over the recent window: ``{fault: (plant, residual sum of squares)}``.""" y, u, w = (np.array(c) for c in zip(*self.buf)) g0, b0, c0, _ = self.ref th = np.tanh(w / 0.02) out = {} r0 = y - (g0 * u - b0 * w - c0 * th) out['none'] = (self.ref, float(r0 @ r0)) if u @ u > 2.0: r = y + b0 * w + c0 * th g = float(u @ r / (u @ u)) out['actuator_degraded'] = (np.array([g, b0, c0, 0.0]), float(((r - g * u) ** 2).sum())) d = float(r0.mean()) out['external_push'] = (np.array([g0, b0, c0, d]), float(((r0 - d) ** 2).sum())) if th @ th > 2.0: r = y - g0 * u + b0 * w c = float(-th @ r / (th @ th)) out['friction_increase'] = (np.array([g0, b0, c, 0.0]), float(((r + c * th) ** 2).sum())) return out def _joint(self): """Ridge fit of gain, Coulomb friction and push together, used to tell apart faults whose single-fault fits are close.""" y, u, w = (np.array(c) for c in zip(*self.buf)) g0, b0, c0, _ = self.ref th = np.tanh(w / 0.02) Phi = np.stack([u, -th, np.ones_like(u)], axis=1) r = y + b0 * w prior, lam = np.array([g0, c0, 0.0]), np.diag([1 / 3.0 ** 2, 1 / 0.7 ** 2, 1 / 1.0 ** 2]) sol = np.linalg.solve(Phi.T @ Phi / 0.05 ** 2 + lam, Phi.T @ r / 0.05 ** 2 + lam @ prior) dev = {'actuator_degraded': (g0 - sol[0]) / (0.35 * g0), 'friction_increase': (sol[1] - c0) / 0.7, 'external_push': abs(sol[2]) / 1.5} return dev, np.array([sol[0], b0, sol[1], sol[2]]) def _diagnose(self): if len(self.buf) < self.W // 2: return fits = self._fits() rss0 = fits['none'][1] best, plant = 'none', self.ref for name, (p, rss) in fits.items(): if name == 'none' or rss > 0.5 * rss0 or rss / len(self.buf) > 0.05: # explain half the misfit AND fit the whole window well continue band = {'actuator_degraded': p[0] < 0.65 * self.ref[0], 'friction_increase': p[2] > 1.0, 'external_push': abs(p[3]) > 1.5}[name] if band and (best == 'none' or rss < fits[best][1]): best, plant = name, p close = [n for n, (p, rss) in fits.items() if n != 'none' and rss <= 0.5 * rss0 and rss / len(self.buf) <= 0.05 and rss <= 1.3 * fits[best][1]] if best != 'none' else [] if len(close) > 1: # ambiguous: let the joint fit decide which parameter moved most dev, joint = self._joint() pick = max(close, key=lambda n: dev[n]) if dev[pick] > 1.0: best, plant = pick, joint plant_faults = ('actuator_degraded', 'friction_increase', 'external_push') self.counts = {k: (self.counts.get(k, 0) + 1 if k == best else 0) for k in plant_faults} if best != 'none' and self.counts[best] >= self.persist: self._log(best) self.label, self.plant = best, plant self.dead = self.dead + 1 if (best == 'actuator_degraded' and plant[0] < 1.2) else 0 if self.dead >= 25: # a transient mis-fit must not end in a latched safe stop self._log('actuator_unrecoverable') self.safe_stop = True # ---- heal ---------------------------------------------------------------------------------------------------------
[docs] def control(self, u_pd): """Turn the controller's command into the one sent to the motor (identity unless healing and a plant fault is diagnosed).""" if not self.heal: return u_pd if self.safe_stop: return 0.0 if not ({'actuator_degraded', 'friction_increase', 'external_push'} & self.flags): return u_pd g, b, c, d = self.plant w = self.omega_hat a_des = self.ref[0] * u_pd - self.ref[1] * w - self.ref[2] * np.tanh(w / 0.02) # what the healthy loop would have done return float(np.clip((a_des + b * w + c * np.tanh(w / 0.02) - d) / max(g, 1.5), -1.0, 1.0))
@property def final_diagnosis(self): """The fault the agent believes at the end: the encoder fault if one was found, else the latest plant diagnosis.""" enc = [f for f in ('encoder_lost', 'encoder_jump') if f in self.flags] return enc[0] if enc else self.label @property def diagnosis(self): return sorted(self.flags)
[docs] def run_fault_episode(act, rng, faults=(), policy='agent', steps=80, switch_at=30, tol=0.0625): """Two-leg move with faults injected: go to target A, then (at ``switch_at``) to target B, which is where the score is taken. ``act(target, theta, omega)`` is the controller (for example ``TinyVLA.act``). ``policy`` is ``'encoder'`` (raw encoder, no agent), ``'kalman'`` (Kalman state estimate, no agent), ``'model'`` (Kalman with the nominal motor model, no agent), ``'monitor'`` (agent that only monitors), ``'agent no quantum'`` or ``'agent'``. """ from .state import SensedMotor sm = SensedMotor(faults=faults) a, b = rng.uniform(-0.9, 0.9, 2) z0 = sm.reset(rng.uniform(-1, 1), rng) if policy in ('encoder', 'kalman', 'model'): modes = {'encoder': 'encoder', 'kalman': 'kalman + MEMS + quantum', 'model': 'kalman + motor model'} agent, est = None, JointEstimator(modes[policy]) est.reset(z0) else: agent = MotorAgent(heal=policy != 'monitor', quantum=policy != 'agent no quantum') agent.reset(z0) th, om, u, peak = z0, 0.0, 0.0, 0.0 for k in range(steps): tgt = a if k < switch_at else b u_pd = float(act(tgt, th, om)) u = agent.control(u_pd) if agent else u_pd meas = sm.step(u) th, om = agent.step(meas, u) if agent else est.step(meas, u) if k >= switch_at: peak = max(peak, abs(sm.m.theta - b)) err = abs(sm.m.theta - b) return {'error': err, 'success': err < tol, 'diagnosis': agent.diagnosis if agent else [], 'events': agent.events if agent else [], 'final': agent.final_diagnosis if agent else None, 'safe_stop': bool(agent and agent.safe_stop)}