"""GR rotation axis: the T3R configuration and move policy for scanning. Qt-free façade over hardware.t3r_driver.T3RDriver that owns the drive settings the scan depends on, so the engine never re-derives them. """ from __future__ import annotations import logging from dataclasses import dataclass logger = logging.getLogger(__name__) @dataclass(frozen=True) class RotationSettings: """Drive settings for the GR axis during a scan.""" microsteps: int = 8 # microsteps/full-step on the GR axis (ch3) velocity: int = 4000 # steps/s for inter-angle moves accel: int = 2000 # steps/s² for inter-angle moves run_current_ma: int = 1200 # drive current while moving hold_current_ma: int = 400 # standstill current ihold_delay: int = 6 # run→hold current ramp delay (TMC IHOLDDELAY units) # The sample rotates CW instead of CCW to clear wiring and avoid a stall. rotation_sign: int = -1 DEFAULT_ROTATION = RotationSettings() class RotationAxis: """Blocking rotation control for the GR axis. ``configure()`` must run before any move: ``steps_for_angle()`` assumes the configured microstep setting, so the device has to be told to match rather than trusting whatever the T3R panel or firmware default left it at. """ def __init__(self, driver, settings: RotationSettings = DEFAULT_ROTATION): self.driver = driver self.settings = settings self._current_deg = 0.0 @property def is_available(self) -> bool: return self.driver is not None and self.driver.is_open @property def current_deg(self) -> float: return self._current_deg def configure(self) -> None: s = self.settings ch = self.driver.GR_AXIS_CH self.driver.set_microstep(ch, s.microsteps) self.driver.set_current(ch, s.run_current_ma, s.hold_current_ma, s.ihold_delay) self.driver.enable(ch) def estimate_move_secs(self, delta_deg: float) -> float: """Trapezoidal move time: cruise + accel/decel ramps.""" s = self.settings steps = abs(self.driver.steps_for_angle(delta_deg, s.microsteps)) return steps / s.velocity + s.velocity / s.accel def rotate_to(self, angle_deg: float, timeout_margin_s: float = 5.0) -> float: """Rotate to an absolute angle and block until the move completes. Returns the estimated move time (for status reporting). Waits on the driver's MOTION_DONE event rather than sleeping for a guessed duration; falls back to the estimate only if the event never arrives. """ delta_deg = angle_deg - self._current_deg if abs(delta_deg) <= 0.001: return 0.0 s = self.settings est_secs = self.estimate_move_secs(delta_deg) self.driver.rotate_stage(delta_deg, s.microsteps, s.velocity, s.accel) if not self.driver.wait_motion_done(self.driver.GR_AXIS_CH, est_secs + timeout_margin_s): logger.warning( "GR axis did not report MOTION_DONE within %.1f s for a " "%.1f° move; continuing", est_secs + timeout_margin_s, delta_deg) self._current_deg = angle_deg return est_secs def return_to_zero(self) -> float: return self.rotate_to(0.0)