diff --git a/config/config.yml b/config/config.yml index 086de3f1..9c05c274 100644 --- a/config/config.yml +++ b/config/config.yml @@ -10,4 +10,29 @@ mmdirectory: "C:/Program Files/Micro-Manager-1.4" switchbot: name: room_light address: "EC:6F:04:06:5B:23" - timeout: 20.0 \ No newline at end of file + timeout: 20.0 + +# ACUITYnano Precision Thermal Controller (Peltier/TEC, 0.0–99.9 °C). When this +# block is present the device layer registers a `temperature` device and the +# Devices tab shows a setpoint control; plans can also block on it via +# `bps.mv(temperature, 20.0)`. Remove/comment the block to skip registration. +# +# backend: mqtt talks to the controller over the vendor's MQTT bridge +# (acuitynano_precision_thermalizer_api). With no broker/port/user/password +# keys it uses the vendor package's embedded HiveMQ Cloud defaults — set them +# here only to point at a different broker. The vendor package must be +# installed on the device-layer machine (not on PyPI). +temperature: + name: temperature + backend: serial # serial | mqtt | mock — USB serial is the working + # link on this machine (MQTT cloud is firewalled) + com_port: "COM8" + baud_rate: 115200 + stabilize_timeout: 600 # seconds to wait for "[ SYSTEM LOCKED ]" on a blocking set + feedback_peltier: false # true = control off the peltier sensor instead of water + # MQTT alternative (vendor HiveMQ Cloud defaults; needs outbound TLS :8883): + # backend: mqtt + # broker: "your-broker.example.com" # optional — overrides the embedded default + # port: 8883 + # user: "username" + # password: "secret" \ No newline at end of file diff --git a/gently/hardware/dispim/device_factory.py b/gently/hardware/dispim/device_factory.py index 44399076..c70e68b0 100644 --- a/gently/hardware/dispim/device_factory.py +++ b/gently/hardware/dispim/device_factory.py @@ -34,6 +34,7 @@ def create_devices_from_mmcore(core: pymmcore.CMMCore, - lightsheet_snap: DiSPIMLightSheetSnap - scanner: DiSPIMScanner - piezo: DiSPIMPiezo + - fdrive: DiSPIMFDrive (SPIM head Z / F axis) Example ------- @@ -67,6 +68,7 @@ def create_devices_from_mmcore(core: pymmcore.CMMCore, 'camera_name': 'HamCam1', 'scanner_name': 'Scanner:AB:33', 'piezo_name': 'PiezoStage:P:34', + 'fdrive_name': 'ZStage:V:37', 'bottom_camera_name': 'Bottom PCO', 'led_name': 'LED:X:31' } @@ -127,6 +129,13 @@ def create_devices_from_mmcore(core: pymmcore.CMMCore, except Exception as e: logger.warning("Could not create XY stage: %s", e) + try: + from .devices import DiSPIMFDrive + devices['fdrive'] = DiSPIMFDrive(name=cfg['fdrive_name'], core=core) + logger.info("Created F-drive (SPIM head): %s", cfg['fdrive_name']) + except Exception as e: + logger.warning("Could not create F-drive (SPIM head): %s", e) + try: if cfg.get('led_name'): from .devices import DiSPIMLED diff --git a/gently/hardware/dispim/device_layer.py b/gently/hardware/dispim/device_layer.py index 3b50aded..53495cf6 100644 --- a/gently/hardware/dispim/device_layer.py +++ b/gently/hardware/dispim/device_layer.py @@ -177,6 +177,11 @@ def __init__( 'focus_sweep_plan', 'calibrate_piezo_galvo_plan', 'multi_embryo_calibration_workflow', + # Slow F-drive traverse (25000 -> ~50 um) + fine focus scan + the + # XY view-registration loop all hold MMCore for a while. + 'spim_head_focus_descent_plan', + 'register_views_xy_plan', + 'spim_head_focus_and_align_plan', }) async def initialize(self): @@ -448,6 +453,20 @@ def _load_plans(self): except ImportError: logger.info("Main acquisition plans not available") + # SPIM head focus + dual-view registration plans + try: + from .plans.acquisition import ( + spim_head_focus_descent_plan, + register_views_xy_plan, + spim_head_focus_and_align_plan, + ) + self.plans['spim_head_focus_descent_plan'] = spim_head_focus_descent_plan + self.plans['register_views_xy_plan'] = register_views_xy_plan + self.plans['spim_head_focus_and_align_plan'] = spim_head_focus_and_align_plan + logger.info("Loaded SPIM head focus plans") + except ImportError: + logger.info("SPIM head focus plans not available") + def _resolve_device_args(self, kwargs: Dict[str, Any]) -> Dict[str, Any]: """Replace device name strings with actual device objects""" resolved = {} diff --git a/gently/hardware/dispim/devices/piezo.py b/gently/hardware/dispim/devices/piezo.py index 7103814b..91161e12 100644 --- a/gently/hardware/dispim/devices/piezo.py +++ b/gently/hardware/dispim/devices/piezo.py @@ -15,36 +15,80 @@ logger = logging.getLogger(__name__) +# ========================================================================= +# SPIM-HEAD F-DRIVE HARDWARE SAFETY LIMITS — absolute MMCore micrometres. +# +# Layer-1 software fence for the SPIM head: the ASI Tiger "ZStage:V:37" +# axis, which the ASIdiSPIM plugin labels "SPIM Head Height" (the F axis). +# This is the drive that LOWERS the objectives into the dish to hunt for +# embryos ("Start Hunting") and RAISES them clear for sample loading +# ("Load Sample"). Every F-drive move planned by any layer above (Bluesky +# plans, agent orchestrators, UI tools) is bounded here. These are NOT +# constructor kwargs and DiSPIMFDrive exposes no setter — no layer above +# can widen them. +# +# F_DRIVE_MIN_UM collision-critical FLOOR. Smaller F drives the head +# DOWN toward the sample/holder; this hard stop keeps the +# objectives off the dish. +# F_DRIVE_MAX_UM fully-raised "Load Sample" ceiling. +# +# Update only after physically verifying head travel on the rig (drive the +# F axis to each extreme by hand, confirm no collision, read the absolute +# MMCore position) and then editing the constants below. +# +# SCOPE: this is a SOFTWARE-ONLY fence. Unlike the XY stage (see +# devices/stage.py, which also pushes ASI Tiger firmware soft-limits) we do +# NOT write these to the controller, so a physical joystick move can still +# drive the head past these bounds — they bind code-issued moves only. +# ========================================================================= +F_DRIVE_MIN_UM: float = 30.0 +F_DRIVE_MAX_UM: float = 25000.0 + + class DiSPIMFDrive: """ DiSPIM F-drive (SPIM Head motor) - works with bps.mv(fdrive, position) - ASI Tiger V:37 axis - controls F-axis module for lowering objectives - Device-agnostic: any plan that moves a positioner will work with this device + ASI Tiger "ZStage:V:37" axis — the ASIdiSPIM "SPIM Head Height" / F + axis that lowers the objectives to hunt for embryos and raises them to + load a sample. Device-agnostic: any plan that moves a positioner works. + + Hard travel bounds are the module-level F_DRIVE_MIN_UM / F_DRIVE_MAX_UM + constants. They are not constructor kwargs and cannot be widened from + above — see the safety-limit note above this class. """ def __init__(self, name: str, core: pymmcore.CMMCore, - limits: Tuple[float, float] = (20.0, 25000.0)): + move_timeout_s: float = 120.0): self.name = name self.core = core self.parent = None # Required for Bluesky - self._limits = limits + # Full-travel F moves (e.g. 25000 -> 5000 um: "Load Sample" -> approach) + # are slow; the per-move Status timeout must comfortably exceed the + # longest traverse or bps.mv would error mid-move while the stage is + # still travelling. Configurable for unusually slow controllers. + self._move_timeout_s = float(move_timeout_s) self.tolerance = 0.1 # µm @property - def limits(self): - return self._limits + def limits(self) -> Tuple[float, float]: + """Read-only view of the hardware safety limits (module constants).""" + return (F_DRIVE_MIN_UM, F_DRIVE_MAX_UM) def set(self, position): """Move F-drive to position - called by bps.mv()""" position = float(position) position = round(position, 2) # Round to 0.01 μm precision - # Safety check - if not (self._limits[0] <= position <= self._limits[1]): - raise ValueError(f"Position {position} outside limits {self._limits}") + # Hardware safety check — pinned to the module-level F_DRIVE_*_UM + # constants; nothing above this layer can widen them. + if not (F_DRIVE_MIN_UM <= position <= F_DRIVE_MAX_UM): + raise ValueError( + f"F-drive position {position} outside hardware limits " + f"[{F_DRIVE_MIN_UM}, {F_DRIVE_MAX_UM}]" + ) - status = Status(obj=self, timeout=30) + status = Status(obj=self, timeout=self._move_timeout_s) def wait(): try: diff --git a/gently/hardware/dispim/devices/test_temp_usb.py b/gently/hardware/dispim/devices/test_temp_usb.py new file mode 100644 index 00000000..f6394aee --- /dev/null +++ b/gently/hardware/dispim/devices/test_temp_usb.py @@ -0,0 +1,90 @@ +import serial +import time +import threading + +class AcuityNanoPrecisionThermalizerSerial: + def __init__(self, com_port, baud_rate=115200): + self.telemetry = { + "target": 20.0, + "water": 20.0, + "peltier": 20.0, + "state": "DISCONNECTED", + "errors": "0" + } + self.running = True + self.ser = serial.Serial(com_port, baud_rate, timeout=0.1) + time.sleep(2) + + self.thread = threading.Thread(target=self._read_loop, daemon=True) + self.thread.start() + + def _read_loop(self): + while self.running and self.ser.is_open: + try: + if self.ser.in_waiting: + line = self.ser.readline().decode('utf-8', errors='ignore').strip() + if "=" in line: + key, val = line.split("=", 1) + if key == "TARGET": self.telemetry["target"] = float(val) + elif key == "WATER": self.telemetry["water"] = float(val) + elif key == "ACTUAL": self.telemetry["peltier"] = float(val) + elif key == "STATE": self.telemetry["state"] = val + elif key == "ERRORS": self.telemetry["errors"] = val + except Exception: + pass + time.sleep(0.01) + + def close(self): + self.running = False + if self.ser.is_open: + self.ser.close() + + def set_temperature(self, target_celsius): + if 0.0 <= target_celsius <= 99.9: + cmd = f"TEMP={target_celsius}\n" + self.ser.write(cmd.encode('utf-8')) + else: + raise ValueError("Target must be between 0.0 and 99.9 C") + + def enable_tec(self, enable=True): + val = "1" if enable else "0" + cmd = f"ENABLE={val}\n" + self.ser.write(cmd.encode('utf-8')) + + def set_feedback_sensor(self, use_peltier=False): + val = "1" if use_peltier else "0" + cmd = f"SENSOR={val}\n" + self.ser.write(cmd.encode('utf-8')) + + def get_water_temp(self): + return self.telemetry["water"] + + def get_system_state(self): + return self.telemetry["state"] + + def wait_for_target(self, timeout_seconds=300): + start = time.time() + while time.time() - start < timeout_seconds: + if "[ SYSTEM LOCKED ]" in self.telemetry["state"]: + return True + time.sleep(0.5) + return False + +if __name__ == "__main__": + import time + + print("Connecting to ACUITYnano...") + acuity = AcuityNanoPrecisionThermalizerSerial("COM8") + + print("Commanding 37.0 C...") + acuity.set_temperature(37.0) + acuity.enable_tec(True) + + print("Waiting for thermal stabilization...") + if acuity.wait_for_target(timeout_seconds=600): + print(f"System locked at {acuity.get_water_temp()} C!") + # Trigger external camera or syringe pump here + else: + print("Timeout reached before system stabilized.") + + acuity.close() \ No newline at end of file diff --git a/gently/hardware/dispim/devices/test_temperature_controller.py b/gently/hardware/dispim/devices/test_temperature_controller.py new file mode 100644 index 00000000..a7efddfe --- /dev/null +++ b/gently/hardware/dispim/devices/test_temperature_controller.py @@ -0,0 +1,99 @@ +""" +ACUITYnano Third-Party Integration SDK +Provides a clean, object-oriented API for external software automation. +""" +import paho.mqtt.client as mqtt +import time +import threading + +class AcuityNanoPrecisionThermalizerAPI: + def __init__(self, broker="d0246aa97d194c9da52a19e6f46063eb.s1.eu.hivemq.cloud", port=8883, user="acuitynano", password="Bg984V!@wfhBrkp"): + self.prefix = "acuitynano_hhmi_shroff_diSPIM_001" + self.telemetry = { + "target": 20.0, + "water": 20.0, + "peltier": 20.0, + "state": "DISCONNECTED", + "errors": "0" + } + + try: + self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2) + except AttributeError: + self.client = mqtt.Client() + + self.client.username_pw_set(user, password) + self.client.tls_set() + self.client.on_connect = self._on_connect + self.client.on_message = self._on_message + + self.thread = threading.Thread(target=self._start_loop, daemon=True) + self.thread.start() + time.sleep(2) + + def _start_loop(self): + self.client.connect(broker, port, 60) + self.client.loop_forever() + + def _on_connect(self, client, userdata, flags, rc, properties=None): + self.client.subscribe(f"{self.prefix}/telemetry/#") + + def _on_message(self, client, userdata, msg): + topic = msg.topic.split("/")[-1] + payload = msg.payload.decode('utf-8') + + if topic == "target": self.telemetry["target"] = float(payload) + elif topic == "water": self.telemetry["water"] = float(payload) + elif topic == "actual": self.telemetry["peltier"] = float(payload) + elif topic == "state": self.telemetry["state"] = payload + elif topic == "errors": self.telemetry["errors"] = payload + + def set_temperature(self, target_celsius): + if 0.0 <= target_celsius <= 99.9: + self.client.publish(f"{self.prefix}/cmd/temp", str(target_celsius)) + else: + raise ValueError("Target must be between 0.0 and 99.9 C") + + def enable_tec(self, enable=True): + val = "1" if enable else "0" + self.client.publish(f"{self.prefix}/cmd/enable", val) + + def set_feedback_sensor(self, use_peltier=False): + val = "1" if use_peltier else "0" + self.client.publish(f"{self.prefix}/cmd/sensor", val) + + def get_water_temp(self): + return self.telemetry["water"] + + def get_peltier_temp(self): + return self.telemetry["peltier"] + + def get_system_state(self): + return self.telemetry["state"] + + def wait_for_target(self, timeout_seconds=300): + start = time.time() + while time.time() - start < timeout_seconds: + if "[ SYSTEM LOCKED ]" in self.telemetry["state"]: + return True + time.sleep(0.5) + return False + + + +if __name__ == "__main__": + + import time + from acuitynano_precision_thermalizer_api import AcuityNanoPrecisionThermalizerAPI + + acuity = AcuityNanoPrecisionThermalizerAPI() + print("Commanding ACUITYnano to 37.0 C...") + acuity.set_temperature(30.0) + acuity.enable_tec(True) + + print("Waiting for thermal stabilization...") + if acuity.wait_for_target(timeout_seconds=600): + print(f"System locked at {acuity.get_water_temp()} C!") + # Trigger image acquisition here + else: + print("Timeout reached.") diff --git a/gently/hardware/dispim/plans/acquisition.py b/gently/hardware/dispim/plans/acquisition.py index 8b8ffb35..9c08c8be 100644 --- a/gently/hardware/dispim/plans/acquisition.py +++ b/gently/hardware/dispim/plans/acquisition.py @@ -256,8 +256,9 @@ def get_stage_position_plan(xy_stage): >>> current_pos = yield from get_stage_position_plan(xy_stage) >>> print(f"Stage at: {current_pos}") """ - result = yield from bps.rd(xy_stage) - return result[xy_stage.name]['value'] + # bps.rd returns the device's single reading value directly (here the + # [x, y] position array), not a {name: {value}} dict. + return (yield from bps.rd(xy_stage)) def move_to_pixel_plan(xy_stage, @@ -1337,3 +1338,292 @@ def get_light_source_power_plan( 'pct': value, 'success': True, } + + +# ============================================================================ +# SPIM HEAD FOCUS +# Bring the SPIM head down onto an XY-positioned embryo, lock focus, then +# register the two objective views on top of each other via the XY stage. +# ============================================================================ + +def spim_head_focus_descent_plan( + fdrive, + camera, + led=None, + *, + traverse_to_um: float = 5000.0, + coarse_step_um: float = 1000.0, + coarse_stop_um: float = 500.0, + fine_top_um: float = 150.0, + fine_bottom_um: float = 35.0, + fine_step_um: float = 5.0, + focus_algorithm: str = "volath", + coarse_settle_s: float = 2.0, + fine_settle_s: float = 0.5, + led_state: str = "Open", + detect_roi: bool = True, +) -> Generator[Any, Any, dict]: + """ + Bring the SPIM head down onto an (already XY-positioned) embryo and lock focus. + + The head parks fully raised (~25000 um, "Load Sample") and the embryo only + comes into focus near the bottom of travel (~50 um), so almost the whole + descent is empty space -- and full-travel F moves are slow. Rather than scan + the entire 25000->50 range, this descends in three phases: + + 1. Traverse -- one fast move down to ``traverse_to_um`` (default 5000). No imaging. + 2. Coarse -- step down by ``coarse_step_um`` (1000) to ``coarse_stop_um`` + (~500), settling at each step. No focus decision yet. + 3. Fine -- bounded sweep-and-fit from ``fine_top_um`` (150) down to + ``fine_bottom_um`` (35, >= the 30 um hard floor) in + ``fine_step_um`` (5) steps: snap ``camera`` + score each + frame, fit the focus curve (gently.analysis.focus), and + move to the best-focus position. + + ``led`` (transmitted illumination) is opened for the descent and ALWAYS + closed afterwards (finalize), mirroring the bottom camera's LED discipline. + + Every F move is clamped to ``fdrive.limits`` (30-25000 um); the 30 um floor + is the hard collision stop and ``fine_bottom_um`` must sit above it. + + Parameters + ---------- + fdrive : DiSPIMFDrive + SPIM-head F-drive positioner (``bps.mv(fdrive, z)``). + camera : DiSPIMCamera + SPIM objective camera; ``camera.snap()`` returns the frame to score. + led : DiSPIMLED, optional + Transmitted-light source. Opened during the descent, closed after. + focus_algorithm : str + Focus metric: 'volath' (default), 'gradient', 'variance', 'fft_bandpass'. + + Returns + ------- + dict + success, best_position_um, best_score, r_squared, start_position_um, + and fine_curve (list of (z_um, score) tuples). + """ + from gently.analysis.focus import ( + FocusAnalysisConfig, FocusDataPoint, analyze_focus_sweep, score_single_image, + ) + + lo, hi = fdrive.limits + config = FocusAnalysisConfig(algorithm=focus_algorithm) + out: Dict[str, Any] = {} + + def _clamp(z: float) -> float: + return max(lo, min(hi, float(z))) + + def _grab_and_score(): + frame = camera.snap() + # A single SPIM snap may be a side-by-side dual view; score the brighter + # objective half (focus is per-objective). Heuristic: width >> height. + if frame.ndim == 2 and frame.shape[1] >= frame.shape[0] * 1.5: + view = select_best_camera_view(frame) + else: + view = frame + score, roi = score_single_image(view, config, detect_roi=detect_roi) + return view, score, roi + + def inner(): + # bps.rd returns the device's single reading value directly (a float + # here) in this bluesky version -- not a {name: {value}} dict. + start_z = float((yield from bps.rd(fdrive))) + logger.info("[SPIM head focus] start %.1f um, floor %.0f um", start_z, lo) + + if led is not None: + yield from bps.mv(led, led_state) + + # Phase 1 -- fast traverse (skip if already below the traverse height) + z = start_z + if z > traverse_to_um: + z = _clamp(traverse_to_um) + logger.info("[SPIM head focus] traverse -> %.0f um (slow move)", z) + yield from bps.mv(fdrive, z) + yield from bps.sleep(coarse_settle_s) + + # Phase 2 -- coarse stepped descent to ~coarse_stop_um + while z > coarse_stop_um + 1e-6: + z = _clamp(max(coarse_stop_um, z - coarse_step_um)) + logger.info("[SPIM head focus] coarse -> %.0f um", z) + yield from bps.mv(fdrive, z) + yield from bps.sleep(coarse_settle_s) + + # Phase 3 -- fine bounded sweep-and-fit through the focus window + top, bottom = _clamp(fine_top_um), _clamp(fine_bottom_um) + n = max(3, int(round(abs(top - bottom) / max(fine_step_um, 1e-6))) + 1) + positions = [_clamp(p) for p in np.linspace(top, bottom, n)] # descending + logger.info("[SPIM head focus] fine scan %.0f->%.0f um in %d steps", top, bottom, n) + + sweep = [] + for zp in positions: + yield from bps.mv(fdrive, zp) + yield from bps.sleep(fine_settle_s) + view, score, roi = _grab_and_score() + sweep.append(FocusDataPoint(position=zp, score=score, image=view, roi=roi)) + logger.debug("[SPIM head focus] z=%.1f score=%.3g", zp, score) + + result = analyze_focus_sweep(sweep, config) + if result.success: + best = _clamp(result.best_position) + else: + best = _clamp(max(sweep, key=lambda d: d.score).position) + + yield from bps.mv(fdrive, best) + yield from bps.sleep(fine_settle_s) + + out.update( + success=bool(result.success), + best_position_um=float(best), + best_score=float(result.best_score), + r_squared=float(result.r_squared), + start_position_um=start_z, + fine_curve=[(float(d.position), float(d.score)) for d in sweep], + ) + logger.info("[SPIM head focus] locked %.1f um (score %.3g, R2=%.3f, fit=%s)", + best, result.best_score, result.r_squared, result.success) + + def cleanup(): + if led is not None: + yield from bps.mv(led, "Closed") + + yield from bpp.finalize_wrapper(inner(), cleanup()) + return out + + +def register_views_xy_plan( + camera, + xy_stage, + *, + view_pixel_size_um: float, + tolerance_px: float = 20.0, + max_iters: int = 4, + settle_s: float = 0.5, + led=None, + led_state: str = "Open", + reference_view: int = 0, + x_sign: float = -1.0, + y_sign: float = 1.0, +) -> Generator[Any, Any, dict]: + """ + Register the two SPIM objective views on top of each other via the XY stage. + + Snaps ``camera`` (a side-by-side dual view: left = view A, right = view B), + detects the embryo centroid in each half, and moves the XY stage to bring + the embryo to the centre of the ``reference_view`` (0 = A/left, 1 = B/right). + The residual offset in the *other* view is recorded each iteration as the + registration error. Iterates up to ``max_iters`` or until the reference + view is within ``tolerance_px``. + + The view->stage mapping is the rig-specific part and likely needs tuning: + - ``view_pixel_size_um`` is the effective um/pixel of the OBJECTIVE view + (NOT the bottom camera) -- required. + - ``x_sign`` / ``y_sign`` flip the stage axes to match the camera + orientation. Defaults follow the bottom-camera convention (X inverted); + confirm them on the rig from the reported per-iteration residuals (if + the offset grows instead of shrinking, flip the offending sign). + + XY moves are bounded by the stage's own hardware limits, so a mis-tuned + sign fails loudly (limit error) rather than driving anywhere unsafe. + + Returns + ------- + dict + converged (bool), iterations (per-iter offsets per view, in px), + final_stage_um, and (if nothing was found) error. + """ + from gently.core.coordinates import pixel_displacement_to_stage_movement + + out: Dict[str, Any] = {"converged": False, "iterations": []} + + def _views(frame): + if frame.ndim == 2 and frame.shape[1] >= frame.shape[0] * 1.5: + mid = frame.shape[1] // 2 + return [frame[:, :mid], frame[:, mid:]] + return [frame] + + def _centroid_offset(view): + # (dx, dy) of the embryo centroid from the view centre, in pixels; + # None if no embryo (detect_embryo_roi falls back to the whole frame). + y0, y1, x0, x1 = detect_embryo_roi(view) + h, w = view.shape + if (y1 - y0) >= h and (x1 - x0) >= w: + return None + cx, cy = (x0 + x1) / 2.0, (y0 + y1) / 2.0 + return (cx - w / 2.0, cy - h / 2.0) + + def inner(): + if led is not None: + yield from bps.mv(led, led_state) + + for _ in range(max_iters): + frame = camera.snap() + views = _views(frame) + offsets = [_centroid_offset(v) for v in views] + out["iterations"].append({ + "offsets_px": [None if o is None else (float(o[0]), float(o[1])) for o in offsets] + }) + + ref = offsets[reference_view] if reference_view < len(offsets) else None + if ref is None: + seen = [o for o in offsets if o is not None] + if not seen: + out["error"] = "no embryo detected in any view" + break + ref = seen[0] + + if max(abs(ref[0]), abs(ref[1])) <= tolerance_px: + out["converged"] = True + break + + dx_um, dy_um = pixel_displacement_to_stage_movement(ref[0], ref[1], view_pixel_size_um) + cur = yield from bps.rd(xy_stage) # -> [x, y] um (single reading value) + target = [cur[0] + x_sign * dx_um, cur[1] + y_sign * dy_um] + yield from bps.mov(xy_stage, target) + yield from bps.sleep(settle_s) + + final = yield from bps.rd(xy_stage) + out["final_stage_um"] = [float(final[0]), float(final[1])] + + def cleanup(): + if led is not None: + yield from bps.mv(led, "Closed") + + yield from bpp.finalize_wrapper(inner(), cleanup()) + return out + + +def spim_head_focus_and_align_plan( + fdrive, + camera, + xy_stage, + led=None, + *, + view_pixel_size_um: Optional[float] = None, + align: bool = True, + focus_kwargs: Optional[Dict] = None, + align_kwargs: Optional[Dict] = None, +) -> Generator[Any, Any, dict]: + """ + Full SPIM-head focus workflow for one XY-positioned embryo. + + Descends and locks focus (``spim_head_focus_descent_plan``), then registers + the two objective views via the XY stage (``register_views_xy_plan``). + + ``align=True`` requires ``view_pixel_size_um`` (objective-view um/pixel). + """ + out: Dict[str, Any] = {} + out["focus"] = yield from spim_head_focus_descent_plan( + fdrive, camera, led=led, **(focus_kwargs or {}) + ) + if align: + if view_pixel_size_um is None: + raise ValueError( + "align=True requires view_pixel_size_um " + "(objective-view um/pixel calibration)" + ) + out["align"] = yield from register_views_xy_plan( + camera, xy_stage, view_pixel_size_um=view_pixel_size_um, + led=led, **(align_kwargs or {}), + ) + return out diff --git a/gently/ui/web/routes/data.py b/gently/ui/web/routes/data.py index 3133ec60..c8c0ebe7 100644 --- a/gently/ui/web/routes/data.py +++ b/gently/ui/web/routes/data.py @@ -304,6 +304,61 @@ async def set_room_light(payload: dict = Body(...)): raise HTTPException(status_code=502, detail=res.get("error", "room light command failed")) return {"state": res.get("state", state)} + @router.get("/api/devices/temperature/status") + async def get_temperature_status(): + """Live water temperature, setpoint, and lock state (cheap to poll). + + Cached at the device layer (no per-call hardware round trip), so the + Devices header can poll it like the room light. ``available`` is false + when no controller is configured/connected, which hides the control. + """ + client = _resolve_client() + if client is None: + return {"available": False, "state": "unknown"} + try: + res = await client.get_temperature() + except Exception as exc: + logger.debug("temperature status fetch failed: %s", exc) + return {"available": False, "state": "unknown"} + return { + "available": bool(res.get("success", False)), + "temperature_c": res.get("temperature_c"), + "setpoint_c": res.get("setpoint_c"), + "state": res.get("state", "unknown"), + "peltier_c": res.get("peltier_c"), + } + + @router.post("/api/devices/temperature/set", + dependencies=[Depends(require_control)]) + async def set_temperature(payload: dict = Body(...)): + """Command the temperature setpoint. Body: {"target_c": float}. + + Non-blocking: the controller ramps and the status poll reflects progress + (and the SYSTEM LOCKED state once it stabilizes). + """ + try: + target = float(payload.get("target_c")) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail="target_c must be a number") + if not (0.0 <= target <= 99.9): + raise HTTPException(status_code=400, detail="target_c must be between 0.0 and 99.9 C") + client = _resolve_client() + if client is None: + raise HTTPException(status_code=503, detail="Microscope not connected") + try: + res = await client.set_temperature(target) + except Exception as exc: + logger.exception("Temperature command failed") + raise HTTPException(status_code=502, detail=f"temperature command failed: {exc}") + if not res.get("success"): + raise HTTPException(status_code=502, detail=res.get("error", "temperature command failed")) + return { + "target_c": res.get("target_c", target), + "temperature_c": res.get("temperature_c"), + "state": res.get("state", "unknown"), + "waited": res.get("waited", False), + } + @router.get("/api/calibration") async def list_calibration(embryo_id: Optional[str] = None): """Get calibration images""" diff --git a/gently/ui/web/static/css/main.css b/gently/ui/web/static/css/main.css index fdafcc1d..10bc6f62 100644 --- a/gently/ui/web/static/css/main.css +++ b/gently/ui/web/static/css/main.css @@ -9671,6 +9671,71 @@ body.modal-open { } .devices-room-light.is-busy { opacity: 0.65; cursor: progress; } +/* --- Temperature controller ------------------------------------------- */ +/* Readout + setpoint input + Set, styled as a pill to sit beside the + room-light toggle in the Devices header. Hidden until a controller is + available (mirrors the room light). */ +.devices-temp { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.12rem 0.28rem 0.12rem 0.5rem; + border: 1px solid var(--map-overlay-edge); + background: var(--map-overlay-bg); + border-radius: 999px; + color: var(--map-ink-mute); + font-family: inherit; + font-size: 0.65rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; +} +.devices-temp[hidden] { display: none; } +.devices-temp-icon { display: inline-flex; align-items: center; color: var(--map-ink-mute); transition: color 0.15s, filter 0.15s; } +/* "locked" — cool glow once the controller reports SYSTEM LOCKED */ +.devices-temp.is-locked { border-color: rgba(90, 200, 250, 0.6); color: #5ac8fa; background: rgba(90, 200, 250, 0.1); } +.devices-temp.is-locked .devices-temp-icon { color: #5ac8fa; filter: drop-shadow(0 0 5px rgba(90, 200, 250, 0.6)); } +.devices-temp.is-busy { opacity: 0.7; cursor: progress; } +.devices-temp-readout { + font-variant-numeric: tabular-nums; + letter-spacing: 0.02em; + min-width: 3.4em; + text-align: right; + white-space: nowrap; +} +.devices-temp-input { + width: 3.4em; + padding: 0.1rem 0.3rem; + border: 1px solid var(--map-overlay-edge); + background: var(--map-paper, rgba(0, 0, 0, 0.2)); + border-radius: 6px; + color: var(--map-ink); + font-family: inherit; + font-size: 0.72rem; + font-variant-numeric: tabular-nums; + text-align: right; + -moz-appearance: textfield; +} +.devices-temp-input:focus { outline: none; border-color: var(--map-accent); } +.devices-temp-input::-webkit-outer-spin-button, +.devices-temp-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } +.devices-temp-set { + padding: 0.16rem 0.5rem; + border: 1px solid var(--map-overlay-edge); + background: var(--map-overlay-bg); + border-radius: 999px; + color: var(--map-ink-mute); + font-family: inherit; + font-size: 0.62rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + cursor: pointer; + transition: color 0.15s, border-color 0.15s, background 0.15s; +} +.devices-temp-set:hover:not(:disabled) { border-color: var(--map-accent); color: var(--map-ink); } +.devices-temp-set:disabled { opacity: 0.5; cursor: default; } + /* --- Containers ------------------------------------------------------- */ .devices-view { display: flex; flex-direction: column; flex: 1; min-height: 0; } .devices-view-details { gap: 1rem; } diff --git a/gently/ui/web/static/js/devices.js b/gently/ui/web/static/js/devices.js index 27bc12c3..caa1bf48 100644 --- a/gently/ui/web/static/js/devices.js +++ b/gently/ui/web/static/js/devices.js @@ -81,6 +81,13 @@ const DevicesManager = (function () { let _roomLightBusy = false; let _roomLightTimer = null; + // Temperature-controller panel DOM + state + let _tempEl, _tempReadout, _tempInput, _tempSet; + let _tempState = 'unknown'; + let _tempAvailable = false; + let _tempBusy = false; + let _tempTimer = null; + let _lastTs = 0; let _previousTs = 0; let _lastWallTs = 0; @@ -147,6 +154,11 @@ const DevicesManager = (function () { _roomLightToggle = document.getElementById('devices-room-light-toggle'); _roomLightLabel = document.getElementById('devices-room-light-label'); + _tempEl = document.getElementById('devices-temp'); + _tempReadout = document.getElementById('devices-temp-readout'); + _tempInput = document.getElementById('devices-temp-input'); + _tempSet = document.getElementById('devices-temp-set'); + // Recompute the scale bar caption whenever the canvas resizes. if (_mapSvg && window.ResizeObserver) { new ResizeObserver(() => updateScalebar()).observe(_mapSvg); @@ -1369,6 +1381,111 @@ const DevicesManager = (function () { _roomLightTimer = setInterval(loadRoomLightStatus, 15000); } + // ===================================================================== + // Temperature controller (ACUITYnano) — readout + setpoint + // ===================================================================== + + function fmtTemp(v) { + return (v === null || v === undefined || isNaN(v)) ? '—' : Number(v).toFixed(1) + '°'; + } + + function applyTemperature(data) { + _tempAvailable = !!(data && data.available); + if (!_tempEl) return; + _tempEl.hidden = !_tempAvailable; + if (!_tempAvailable) return; + _tempState = (data && data.state) || 'unknown'; + const locked = /LOCK/i.test(_tempState); + _tempEl.classList.toggle('is-locked', locked); + if (_tempBusy) return; // a set() is in flight; leave its transient label + const cur = fmtTemp(data.temperature_c); + const hasSp = data.setpoint_c !== null && data.setpoint_c !== undefined; + const sp = hasSp ? fmtTemp(data.setpoint_c) : null; + _tempReadout.textContent = sp ? (cur + ' → ' + sp) : cur; + _tempReadout.title = 'Water ' + cur + (sp ? (', setpoint ' + sp) : '') + + (locked ? ' (locked)' : ''); + // Seed the input with the current setpoint once, while untouched, so the + // operator sees where it is before nudging it. + if (_tempInput && document.activeElement !== _tempInput && _tempInput.value === '' && hasSp) { + _tempInput.value = Number(data.setpoint_c).toFixed(1); + } + } + + async function loadTemperatureStatus() { + if (!_tempEl || _tempBusy) return; + try { + const res = await fetch('/api/devices/temperature/status'); + if (!res.ok) { applyTemperature({ available: false }); return; } + applyTemperature(await res.json()); + } catch (err) { + console.debug('temperature status fetch failed:', err); + applyTemperature({ available: false }); + } + } + + async function setTemperature() { + if (!_tempEl || _tempBusy || !_tempAvailable) return; + const target = parseFloat(_tempInput && _tempInput.value); + if (isNaN(target) || target < 0 || target > 99.9) { + _tempReadout.textContent = '0–99.9 °C'; + setTimeout(loadTemperatureStatus, 1500); + return; + } + _tempBusy = true; + _tempEl.classList.add('is-busy'); + if (_tempSet) _tempSet.disabled = true; + _tempReadout.textContent = 'Set ' + target.toFixed(1) + '°…'; + + // Settle back to the resolved state, or surface a transient message + // (insufficient control / error) for 2 s before reverting. + const finish = (msg) => { + _tempBusy = false; + _tempEl.classList.remove('is-busy'); + if (_tempSet) _tempSet.disabled = false; + if (msg) { + _tempReadout.textContent = msg; + setTimeout(loadTemperatureStatus, 2000); + } else { + loadTemperatureStatus(); // controller ramps; poll shows progress + } + }; + + try { + const res = await fetch('/api/devices/temperature/set', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ target_c: target }), + }); + if (res.status === 401 || res.status === 403) { finish('Need control'); return; } + if (!res.ok) { + console.error('temperature set failed:', await res.text()); + finish('Error'); + return; + } + await res.json(); + finish(null); + } catch (err) { + console.error('temperature set failed:', err); + finish('Error'); + } + } + + function setupTemperature() { + if (!_tempEl) return; + if (_tempSet) _tempSet.addEventListener('click', setTemperature); + if (_tempInput) { + _tempInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); setTemperature(); } + }); + } + loadTemperatureStatus(); + // Periodic refresh: the setpoint can also change from agent plans, and a + // commanded ramp settles over time. Status is cached at the device layer, + // so polling is cheap; it also reveals the control once the layer connects. + if (_tempTimer) clearInterval(_tempTimer); + _tempTimer = setInterval(loadTemperatureStatus, 15000); + } + // ===================================================================== // View switching // ===================================================================== @@ -1429,6 +1546,7 @@ const DevicesManager = (function () { setupViewSwitcher(); setupCameraWiring(); setupRoomLight(); + setupTemperature(); loadCoverslip(); loadEmbryosSnapshot(); switchView(_currentView); diff --git a/gently/ui/web/templates/index.html b/gently/ui/web/templates/index.html index e8164eca..85a60345 100644 --- a/gently/ui/web/templates/index.html +++ b/gently/ui/web/templates/index.html @@ -304,6 +304,20 @@

Device
+