Source code for phobos.classes.deformable_mirror

import numpy as np
import os
import json
import time
import warnings
from pathlib import Path
from typing import Dict, List, Optional, Sequence, Union

from .. import bmc

from ..utils import Singleton
from .config import Config

[docs] class DM(metaclass=Singleton): """ Singleton Class to represent a deformable mirror (DM) in the optical system. Hardware ranges (bench reference) ------------------------------- The DM has segment-dependent stroke limits. On the PHOBos bench, the injection segments typically used for coupling (e.g. around segments 111-114, 135-138, 145-148 depending on the mapping in ``injection.segments``) have piston ranges of roughly: - Segments 111-114: piston in [-2520, 264] nm (delta ~2784 nm) - Segments 145-148: piston in [-2557, 214] nm (delta ~2771 nm) - Segments 135-138: piston in [-2530, 230] nm (delta ~2760 nm) Tip/tilt ranges depend on the current piston working point. Around typical injection working pistons (-1128 nm / -1150 nm), absolute tip/tilt can reach approximately ±5.4 mrad in both axes. In normal operation, tip/tilt values are usually kept below ~1.5 mrad for alignment stability. Notes ----- - For authoritative limits, always query the controller via :meth:`Segment.get_piston_range`, :meth:`Segment.get_tip_range`, and :meth:`Segment.get_tilt_range`. - Software-side safety clamping is applied in :meth:`Segment.set_piston`. Attributes ---------- segments : list[Segment] List of segments of the DM. """ DEFAULT_CONFIG_PATH = Path(__file__).parent.parent.parent / "config" / "DM" / "DM_config.json" N_SEGMENTS = 169 _initialized = False
[docs] def __init__(self, config_path:str = DEFAULT_CONFIG_PATH): """ Initialize the DM using global configuration. """ # If already initialized, return existing instance if self._initialized: return self._initialized = True # Initialize the DM with the given serial number self.bmcdm = bmc.BmcDm() self.bmcdm.open_dm(Config().get('dm.serial_number')) self.segments = [Segment(i) for i in range(DM.N_SEGMENTS)] time.sleep(Config().get('dm.stabilization_time', 0.001))
# Properties -------------------------------------------------------------- @property def mid_piston(self): """ The middle piston position of the DM (in nm). """ return int(np.mean(Config().get('dm.piston_range'))) @mid_piston.setter def mid_piston(self, value): raise ValueError("DM().mid_piston is read-only.") # Specific methods -------------------------------------------------------- def __iter__(self): """ Iterate over the segments of the DM. Yields ------- Segment The segments of the DM. """ return iter(self.segments) def __getitem__(self, index) -> 'Segment': """ Get a segment by its index. Parameters ---------- index : int Index of the segment to get. Returns ------- Segment The segment at the given index. """ try: index = int(index) except ValueError: raise TypeError("Index must be an integer.") if index < 0 or index >= len(self.segments): raise IndexError("Index out of range.") return self.segments[index] def __len__(self) -> int: """ Get the number of segments in the DM. Returns ------- int The number of segments in the DM. """ return len(self.segments) def __del__(self): """ Close the DM connection when the object is deleted. """ self.bmcdm.close_dm() for segment in self.segments: del segment print(f"DM with serial number {Config().get('dm.serial_number')} closed.") #Config -------------------------------------------------------------------
[docs] def save_config(self, path:str = DEFAULT_CONFIG_PATH) -> None: """ Save the current configuration of the DM. Parameters ---------- path : str Path to the configuration file. """ config = { "serial_number": Config().get('dm.serial_number'), "segments": {} } for segment in self.segments: config["segments"][segment.id] = { "piston": segment.piston, "tip": segment.tip, "tilt": segment.tilt } with open(path, 'w') as f: json.dump(config, f, indent=4) print(f"Configuration saved to {path}")
[docs] def load_config(self, config_path:str = DEFAULT_CONFIG_PATH): """ Load the configuration of the DM from a JSON file. Parameters ---------- config_path : str Path to the configuration file. """ if not os.path.exists(config_path): raise FileNotFoundError(f"Config file not found: {config_path}") print(f"Loading config file: {config_path}.") with open(config_path, 'r') as f: config = json.load(f) for segment_id, segment_config in config["segments"].items(): segment = self.segments[int(segment_id)] segment.set_ptt(segment_config["piston"], segment_config["tip"], segment_config["tilt"]) print("Configuration loaded")
# ------------------------------------------------------------------ # Backward-compatible injection helpers (delegate to Injection()) # ------------------------------------------------------------------ # The old DM API used **1-based** input numbers; the new Injection # singleton uses **0-based** channels. The thin wrappers below # convert automatically and emit a DeprecationWarning. @staticmethod def _segments_to_channels(segments): """Convert 1-based segment numbers (old DM API) to 0-based channels.""" if segments is None: return None if isinstance(segments, (int, np.integer)): return int(segments) - 1 return [int(s) - 1 for s in segments]
[docs] def off(self, segments=None): """Deprecated – use ``Injection().off()`` instead. .. deprecated:: Will be removed in a future version. """ warnings.warn( "DM.off() is deprecated; use Injection().off() instead.", DeprecationWarning, stacklevel=2, ) from .injection import Injection Injection().off(self._segments_to_channels(segments))
[docs] def zero(self, segments=None): """Deprecated – use ``Injection().zero()`` instead. .. deprecated:: Will be removed in a future version. """ warnings.warn( "DM.zero() is deprecated; use Injection().zero() instead.", DeprecationWarning, stacklevel=2, ) from .injection import Injection Injection().zero(self._segments_to_channels(segments))
[docs] def flat(self, segments=None): """Deprecated – use ``Injection().flat()`` instead. .. deprecated:: Will be removed in a future version. """ warnings.warn( "DM.flat() is deprecated; use Injection().flat() instead.", DeprecationWarning, stacklevel=2, ) from .injection import Injection Injection().flat(self._segments_to_channels(segments))
[docs] def max(self, segments=None): """Deprecated – use ``Injection().max()`` instead. .. deprecated:: Will be removed in a future version. """ warnings.warn( "DM.max() is deprecated; use Injection().max() instead.", DeprecationWarning, stacklevel=2, ) from .injection import Injection Injection().max(self._segments_to_channels(segments))
[docs] def balanced(self, segments=None): """Deprecated – use ``Injection().balanced()`` instead. .. deprecated:: Will be removed in a future version. """ warnings.warn( "DM.balanced() is deprecated; use Injection().balanced() instead.", DeprecationWarning, stacklevel=2, ) from .injection import Injection Injection().balanced(self._segments_to_channels(segments))
# Backward compatibility: previous API used max_flat() for flat.
[docs] def max_flat(self, segments=None): """Deprecated alias for :meth:`flat` (kept for backward compatibility).""" warnings.warn( "DM.max_flat() is deprecated; use Injection().flat() instead.", DeprecationWarning, stacklevel=2, ) from .injection import Injection Injection().flat(self._segments_to_channels(segments))
[docs] def get_injection_maps(self, *args, **kwargs): """Deprecated – use ``Injection().get_injection_maps()`` instead. .. deprecated:: Will be removed in a future version. """ warnings.warn( "DM.get_injection_maps() is deprecated; use Injection().get_injection_maps() instead.", DeprecationWarning, stacklevel=2, ) from .injection import Injection return Injection().get_injection_maps(*args, **kwargs)
#============================================================================== # Segment class #==============================================================================
[docs] class Segment(): """ Class to represent a segment of the deformable mirror (DM). Attributes ---------- dm : DM The DM to which the segment belongs. id : int The ID of the segment. piston : float The piston value of the segment in nm. tip : float The tip value of the segment in milliradians. tilt : float The tilt value of the segment in milliradians. """ __slots__ = ['id', 'piston', 'tip', 'tilt'] _instances = {} # Constructors ------------------------------------------------------------ def __new__(cls, id: int, *args, **kwargs): # Check if channel is valid if not (0 <= id <= DM.N_SEGMENTS): raise ValueError(f" Invalid channel number {id}. Must be between 1 and {DM.N_SEGMENTS}.") # Return cached instance if it exists if id not in cls._instances: cls._instances[id] = super(Segment, cls).__new__(cls) return cls._instances[id]
[docs] def __init__(self, id:int): """ Initialize the segment with the given DM and ID. Parameters ---------- id : int The ID of the segment. """ # If already initialized (from cache), skip if hasattr(self, 'id'): return self.id = id self.piston = 0 self.tip = 0 self.tilt = 0
# piston ------------------------------------------------------------------
[docs] def set_piston(self, value) -> str: """ Set the piston value of the segment. Parameters ---------- value : float The piston value to set in nm. Returns ------- str The response of the mirror. """ # Clamp to hardware limits (query current range because it can depend on # the current segment state). try: p_min, p_max = self.get_piston_range() value = float(np.clip(float(value), float(p_min), float(p_max))) except Exception: # If range query fails (e.g. sandbox/mock), proceed without clamping. value = float(value) self.piston = value response = DM().bmcdm.set_segment(self.id, value, self.tip, self.tilt, True, True) time.sleep(Config().get('dm.stabilization_time')) # Stabilization delay for BMC hardware return response
[docs] def set_phase(self, phase: float, lam: float = 1550.0) -> str: """Set the segment piston using a phase command. This converts a phase (radians) into an optical path difference expressed as a segment piston (nm). The command uses the configured piston range to define the phase origin and direction: - phase 0 rad corresponds to the center of the piston range, i.e. mean(``Config().get('dm.piston_range')``) - phase in [0, pi] moves the piston more negative ("recedes") - phase in (pi, 2*pi) moves the piston more positive ("advances") The phase is first wrapped modulo 2*pi. Parameters ---------- phase : float Phase command in radians. lam : float, optional Wavelength in nanometers. Default is 1550. Returns ------- str Hardware response string from the DM controller. """ # Get corresponding distance distance = phase * lam / (2 * np.pi) # Get zero position zero = int(np.mean(Config().get('dm.piston_range'))) # Position to set position = zero + distance # Check if position is within range if position < Config().get('dm.piston_range')[0] or position > Config().get('dm.piston_range')[1]: raise ValueError(f"Position {position} is outside the configured range") return self.set_piston(position)
[docs] def get_piston(self) -> float: """ Get the piston value of the segment. Returns ------- float The piston value of the segment in nm. """ return self.piston
[docs] def get_piston_range(self) -> list[float]: """ Get the piston range of the segment. Returns ------- list[float] The piston range ([min, max]) of the segment in nm. """ return DM().bmcdm.get_segment_range(self.id, bmc.DM_Piston, self.piston, self.tip, self.tilt, True)
# tip ---------------------------------------------------------------------
[docs] def set_tip(self, value: float) -> str: """ Set the tip value of the segment. Parameters ---------- value : float The tip value to set in milliradians. Returns ------- str The response of the mirror. """ self.tip = value / 1000.0 response = DM().bmcdm.set_segment(self.id, self.piston, self.tip, self.tilt, True, True) time.sleep(Config().get('dm.stabilization_time')) # Stabilization delay for BMC hardware return response
[docs] def get_tip(self) -> float: """ Get the tip value of the segment. Returns ------- float The tip value of the segment in milliradians. """ return self.tip * 1000.0
[docs] def get_tip_range(self) -> list[float]: """ Get the tip range of the segment. Returns ------- list[float] The tip range ([min, max]) of the segment in radians. """ return DM().bmcdm.get_segment_range(self.id, bmc.DM_XTilt, self.piston, self.tip, self.tilt, True)
# tilt --------------------------------------------------------------------
[docs] def set_tilt(self, value: float) -> str: """ Set the tilt value of the segment. Parameters ---------- value : float The tilt value to set in milliradians. Returns ------- str The response of the mirror. """ self.tilt = value / 1000.0 response = DM().bmcdm.set_segment(self.id, self.piston, self.tip, self.tilt, True, True) time.sleep(Config().get('dm.stabilization_time')) # Stabilization delay for BMC hardware return response
[docs] def get_tilt(self) -> float: """ Get the tilt value of the segment. Returns ------- float The tilt value of the segment in milliradians. """ return self.tilt * 1000.0
[docs] def get_tilt_range(self) -> list[float]: """ Get the tilt range of the segment. Returns ------- list[float] The tilt range ([min, max]) of the segment in radians. """ return DM().bmcdm.get_segment_range(self.id, bmc.DM_YTilt, self.piston, self.tip, self.tilt, True)
# ptt ---------------------------------------------------------------------
[docs] def set_ptt(self, piston: float, tip: float, tilt: float) -> tuple[str]: """ Get the tip-tilt value of the segment. Parameters ---------- piston : float The piston value to set in nm. tip : float The tip value to set in milliradians. tilt : float The tilt value to set in milliradians. Returns ------- str The response of the mirror for the piston change. str The response of the mirror for the tip change. str The response of the mirror for the tilt change. """ tip = tip / 1000. tilt = tilt / 1000. self.piston = piston self.tip = tip self.tilt = tilt response = DM().bmcdm.set_segment(self.id, self.piston, self.tip, self.tilt, True, True) time.sleep(Config().get('dm.stabilization_time')) # Stabilization delay for BMC hardware return response
[docs] def get_ptt(self) -> tuple[float, float, float]: """ Get the tip-tilt value of the segment. Returns ------- float The piston value of the segment in nm. float The tip value of the segment in milliradians. float The tilt value of the segment in milliradians. """ # Inline conversion faster than method calls return self.piston, self.tip * 1000.0, self.tilt * 1000.0