Source code for cubemarspycan.sim.plant

"""A minimal mechanical plant, output-side.

``J * omega_dot = tau - b * omega - tau_load``, integrated semi-implicitly.

It exists so the simulator produces motion that is *plausible* rather than scripted: a
position step overshoots, velocity is genuinely non-zero, and a bad unwrapper or a sign
error has something to fail against. A simulator that always reported zero velocity would
pass tests that should fail.
"""

from __future__ import annotations

from dataclasses import dataclass

# AK40-10: 97.35 g*cm^2 rotor inertia, reflected through 10:1 is ~9.7e-4 kg*m^2 at the
# output. Rounded, plus a little for the gearbox and a token load.
DEFAULT_INERTIA = 1.0e-3
DEFAULT_DAMPING = 2.0e-3

FIRMWARE_LOOP_DT = 1.0e-4
"""Inner-loop period of the fake firmware, 10 kHz.

The real driver closes its impedance loop internally at tens of kilohertz off the last
setpoint it received - it does *not* recompute only when a CAN frame arrives. Modelling
it the naive way makes the simulator ring at the control period and turns every gain test
into a measurement of the integrator instead of the protocol."""


[docs] @dataclass class Plant: """Rigid single-axis plant at the output shaft.""" inertia: float = DEFAULT_INERTIA damping: float = DEFAULT_DAMPING position: float = 0.0 velocity: float = 0.0 load_torque: float = 0.0 """Constant external torque, e.g. gravity on a lever.""" limit_lo: float | None = None """Hard stop in the negative direction, rad. ``None`` for free travel.""" limit_hi: float | None = None """Hard stop in the positive direction, rad."""
[docs] def step(self, dt: float, torque: float) -> None: """One explicit Euler step. Callers choose the rate; see FIRMWARE_LOOP_DT.""" if dt <= 0.0: return accel = (torque - self.damping * self.velocity - self.load_torque) / self.inertia self.velocity += accel * dt self.position += self.velocity * dt # Inelastic hard stops: the mechanism reaches the end of its travel and the # motor pushes against it. That is what a homing routine looks for. if self.limit_hi is not None and self.position >= self.limit_hi: self.position = self.limit_hi self.velocity = min(self.velocity, 0.0) if self.limit_lo is not None and self.position <= self.limit_lo: self.position = self.limit_lo self.velocity = max(self.velocity, 0.0)
@property def at_limit(self) -> bool: """Whether the position is resting on either hard stop. True while a stop is being pushed against, which is the condition a homing routine looks for. ``limit_lo``/``limit_hi`` default to ``None``, so a plant with free travel always reports ``False``. """ return (self.limit_hi is not None and self.position >= self.limit_hi) or ( self.limit_lo is not None and self.position <= self.limit_lo )
[docs] def zero_here(self) -> None: """Shift the origin to here, carrying any travel limits with it.""" if self.limit_hi is not None: self.limit_hi -= self.position if self.limit_lo is not None: self.limit_lo -= self.position self.position = 0.0