Source code for quantum_mind.embodied.runtime

"""Running the system on a robot: a hardware interface, an independent safety filter and a fixed-rate control loop.

Nothing in this module has been run on real hardware. :class:`SimulatedHardware` implements the interface on the simulator so
the loop, the safety filter and the failure handling are tested; a dev-kit driver has to implement :class:`MotorHardware` (three
methods) and be validated on the bench.

The safety filter (:class:`SafetyLayer`) sits between every command and the motor and does **not** depend on the learned model,
the agent, the supervisor or a language model: it clamps the command, limits its rate of change, refuses to drive past soft
position limits, and latches a stop that only a human releases. If the loop raises for any reason the runtime calls
``hardware.stop()`` before the exception leaves it.

Sensors the agent needs: a position encoder (workspace units, -1..1) and an accelerometer on the moving part (``a_mems``,
m/s^2). A drive with an encoder only cannot run :class:`~quantum_mind.embodied.agent.MotorAgent` as it is;
:class:`ControlRuntime` refuses to start rather than run degraded without saying so. The quantum accelerometer (``a_q``) is
optional, and no quantum accelerometer exists on a dev kit: it is simulated.
"""
from __future__ import annotations

from dataclasses import dataclass
from typing import Protocol

import numpy as np

from .agent import MotorAgent
from .brain import Mode, Supervisor
from .state import SensedMotor


[docs] class MotorHardware(Protocol): """What a dev-kit driver must provide."""
[docs] def read(self) -> dict: """``{'t': seconds, 'enc': position in -1..1 or None, 'a_mems': m/s^2 or None, 'a_q': m/s^2 or None}`` (latest values)."""
[docs] def write(self, u: float) -> None: """Send a normalised command in [-1, 1] (the driver maps it to voltage, PWM or current)."""
[docs] def stop(self) -> None: """Cut torque / apply the brake. Must be safe to call at any time, repeatedly."""
[docs] class SimulatedHardware: """The simulator behind the :class:`MotorHardware` interface.""" def __init__(self, faults=(), seed=0, theta0=0.0, drop_telemetry_at=None, **kw): self.sm = SensedMotor(faults=faults, **kw) self.rng = np.random.default_rng(seed) self.sm.reset(theta0, self.rng) self._last = dict(t=0.0, enc=float(theta0), a_mems=0.0, a_q=None) self.stopped, self.drop_at, self._u = False, drop_telemetry_at, 0.0 self.stop_calls = 0 def read(self): return dict(self._last) def write(self, u): self._u = 0.0 if self.stopped else float(u) m = self.sm.step(self._u) if self.drop_at is None or self.sm.t < self.drop_at: self._last = dict(t=self.sm.t, enc=m['enc'], a_mems=m['a_mems'], a_q=m['a_q']) def stop(self): self.stopped = True self.stop_calls += 1 @property def position(self): return self.sm.m.theta
[docs] @dataclass class SafetyLimits: u_max: float = 0.8 # fraction of full command allowed slew: float = 0.25 # largest change of command per control step soft_limit: float = 0.95 # workspace position beyond which outward commands are refused
[docs] class SafetyLayer: """Last line of defence between any command and the motor; independent of the AI.""" def __init__(self, limits=None): self.lim, self.u_prev, self.latched, self.log = limits or SafetyLimits(), 0.0, False, [] def filter(self, u, position): if self.latched: self.u_prev = 0.0 return 0.0 u = float(np.clip(u, -self.lim.u_max, self.lim.u_max)) u = float(np.clip(u, self.u_prev - self.lim.slew, self.u_prev + self.lim.slew)) if position is not None and ((position > self.lim.soft_limit and u > 0) or (position < -self.lim.soft_limit and u < 0)): self.log.append('soft limit') u = 0.0 self.u_prev = u return u def latch(self, reason): self.latched = True self.log.append('latched: ' + reason)
[docs] def release(self): """Human action only.""" self.latched, self.u_prev = False, 0.0
[docs] class ControlRuntime: """Fixed-rate loop: read, estimate, control, supervise, filter for safety, write. One :meth:`step` is one control period.""" def __init__(self, hardware, act, brain=None, safety=None, heal=True, dt=0.05): self.hw, self.act, self.dt = hardware, act, dt self.agent, self.sup, self.safety = MotorAgent(heal=heal, dt=dt), Supervisor(brain=brain, dt=dt), safety or SafetyLayer() self.target, self.th, self.om, self.u, self.started = 0.0, 0.0, 0.0, 0.0, False def start(self): m = self.hw.read() if m.get('enc') is None or m.get('a_mems') is None: raise RuntimeError('the motor agent needs a position encoder and an accelerometer on the moving part; this hardware reports ' 'enc=%r, a_mems=%r' % (m.get('enc'), m.get('a_mems'))) self.agent.reset(m['enc']) self.th, self.om, self.started = m['enc'], 0.0, True def step(self, target=None): if target is not None: self.target = target try: if not self.started: self.start() u = self.sup.limit(self.agent.control(float(self.act(self.target, self.th, self.om)))) u = self.safety.filter(u, self.th) if self.sup.mode == Mode.SAFE_STOP and not self.safety.latched: self.safety.latch('supervisor safe stop') self.hw.stop() self.hw.write(u) m = self.hw.read() fresh = m['t'] > getattr(self, '_t_prev', -1.0) self._t_prev = m['t'] if fresh: self.th, self.om = self.agent.step(dict(enc=m['enc'], a_mems=m['a_mems'], a_q=m['a_q']), u) self.sup.observe(dict(t=self.sup.k * self.dt + self.dt, agent=self.agent, a_q=m['a_q'], fresh=fresh)) self.u = u except BaseException: self.hw.stop() self.safety.latch('exception in the control loop') raise return self.sup.mode
[docs] def run(self, steps, schedule=None): """Run ``steps`` periods; ``schedule`` maps a step index to a new target.""" schedule = schedule or {} for k in range(steps): self.step(schedule.get(k)) return self.sup.mode