diff --git a/csaxs_bec/bec_ipython_client/plugins/cSAXS/filter_transmission.py b/csaxs_bec/bec_ipython_client/plugins/cSAXS/filter_transmission.py index 7174b5af..ea1e3a80 100644 --- a/csaxs_bec/bec_ipython_client/plugins/cSAXS/filter_transmission.py +++ b/csaxs_bec/bec_ipython_client/plugins/cSAXS/filter_transmission.py @@ -74,19 +74,30 @@ class cSAXSFilterTransmission: # ----------------------------------------- # Exposure-box filter configuration (4 x 6) # - # Current hardware (per your message): - # Unit 1 (filter_array_1_x): out, Si400, Ge300, Ti800, Zr20 - # Unit 2 (filter_array_2_x): out, Si200, Si3200, Ti400, Cu20 - # Unit 3 (filter_array_3_x): out, Si100, Si1600, Ti200, Ti3200, Fe5 - # Unit 4 (filter_array_4_x): out, Si50, Si800, Ti100, Ti1600, Ti20 + # Thicknesses are the values from filter.mac rev 1.19, i.e. effective + # thicknesses measured at 18.58 keV rather than nominal foil thicknesses. + # They are used at every energy, which assumes the tabulated attenuation + # lengths are right and only the thickness was uncertain -- the same + # assumption spec made. The odd Si values are measurements, not typos. + # + # Unit 1 (filter_array_1_x): out, Si345.6, Ge300, Ti800, Zr20 + # Unit 2 (filter_array_2_x): out, Si234.6, Si3303, Ti400, Cu20 + # Unit 3 (filter_array_3_x): out, Si137.5, Si1661.5, Ti200, Ti3200, Fe5 + # Unit 4 (filter_array_4_x): out, Si50, Si833.8, Ti100, Ti1600, Ti20 # # Positions 1..6 = [out, m1, m2, m3, m4, m5] # Each entry: ((mat1, th1_um), (mat2, th2_um), enabled_bool) + # + # 'enabled' means the position exists and its transmission can be computed. + # Keeping the Zr/Cu calibration foils enabled is deliberate: they are real + # filters, so _fil_trans_report must account for them when one is parked in + # the beam for an edge scan. Excluding them from automatic selection is a + # separate concern -- see _EXCLUDE_FROM_SEARCH. # ----------------------------------------- _FILTERS: List[Tuple[Tuple[str, float], Tuple[str, float], bool]] = [ # Unit 1 (("none", 0.0), ("none", 0.0), True), # out - (("si", 400.0), ("none", 0.0), True), # Si400 + (("si", 345.6), ("none", 0.0), True), # Si345.6 (("ge", 300.0), ("none", 0.0), True), # Ge300 (("ti", 800.0), ("none", 0.0), True), # Ti800 (("zr", 20.0), ("none", 0.0), True), # Zr20 @@ -94,16 +105,16 @@ class cSAXSFilterTransmission: # Unit 2 (("none", 0.0), ("none", 0.0), True), # out - (("si", 200.0), ("none", 0.0), True), # Si200 - (("si", 3200.0), ("none", 0.0), True), # Si3200 + (("si", 234.6), ("none", 0.0), True), # Si234.6 + (("si", 3303.0), ("none", 0.0), True), # Si3303 (("ti", 400.0), ("none", 0.0), True), # Ti400 (("cu", 20.0), ("none", 0.0), True), # Cu20 (("none", 0.0), ("none", 0.0), False), # unused # Unit 3 (("none", 0.0), ("none", 0.0), True), # out - (("si", 100.0), ("none", 0.0), True), # Si100 - (("si", 1600.0), ("none", 0.0), True), # Si1600 + (("si", 137.5), ("none", 0.0), True), # Si137.5 + (("si", 1661.5), ("none", 0.0), True), # Si1661.5 (("ti", 200.0), ("none", 0.0), True), # Ti200 (("ti", 3200.0), ("none", 0.0), True), # Ti3200 (("fe", 5.0), ("none", 0.0), False), # Fe5 (disabled unless data file provided) @@ -111,12 +122,20 @@ class cSAXSFilterTransmission: # Unit 4 (("none", 0.0), ("none", 0.0), True), # out (("si", 50.0), ("none", 0.0), True), # Si50 - (("si", 800.0), ("none", 0.0), True), # Si800 + (("si", 833.8), ("none", 0.0), True), # Si833.8 (("ti", 100.0), ("none", 0.0), True), # Ti100 (("ti", 1600.0), ("none", 0.0), True), # Ti1600 (("ti", 20.0), ("none", 0.0), True), # Ti20 ] + # Calibration foils, not attenuators. filter.mac flagged these "disabled" so + # that selection by transmission would not pick them, while leaving them + # directly addressable via fil_comb -- they are exactly what you park in the + # beam for a K-edge scan. Zr in particular has its edge at 18.0 keV, so near + # that energy its transmission moves with any small energy change. + # (unit, position), both 1-based. + _EXCLUDE_FROM_SEARCH = frozenset({(1, 5), (2, 5)}) # Zr20, Cu20 + _UNITS = 4 _PER_UNIT = 6 @@ -203,45 +222,9 @@ class cSAXSFilterTransmission: # Only allow fil_trans < 1 when DMM is in THROUGH (both) # and CCM energy > 1 keV. fil_trans(1) is always allowed. # ------------------------------------------------------- - if transmission < 1.0: - try: - dmm_trans = float(epics_get("X12SA-OP-DMM-EMLS-3010:THRU")) - except Exception: - dmm_trans = -1 - try: - dmm_rot = float(epics_get("X12SA-OP-DMM-EMLS-3030:THRU")) - except Exception: - dmm_rot = -1 - try: - ccm_energy = float(epics_get("X12SA-OP-CCM1:ENERGY-GET")) - except Exception: - ccm_energy = -1 - - allowed = (dmm_trans == 1) and (dmm_rot == 1) and (ccm_energy > 1) - - if not allowed: - print("\n⚠️ SAFETY WARNING: Reducing transmission (< 1) typically requires:") - print(" - DMM translation in THROUGH (THRU == 1)") - print(" - DMM rotation in THROUGH (THRU == 1)") - print(" - CCM energy > 1 keV") - print("\nCurrent state:") - print(f" DMM translation THRU : {dmm_trans}") - print(f" DMM rotation THRU : {dmm_rot}") - print(f" CCM energy (keV) : {ccm_energy}") - - # Ask user (default = NO) - if hasattr(self, "OMNYTools") and hasattr(self.OMNYTools, "yesno"): - proceed = self.OMNYTools.yesno( - "Conditions not satisfied. Proceed anyway?", - default="n", - ) - else: - # Safe fallback - proceed = False - - if not proceed: - print("Aborted. Transmission unchanged.") - return None + if transmission < 1.0 and not self._attenuation_allowed(): + print("Aborted. Transmission unchanged.") + return None # --- Energy handling (EPICS only) --- if energy_kev is None: @@ -297,6 +280,155 @@ class cSAXSFilterTransmission: self._execute_combination(best, energy_kev) return None + def fil_comb( + self, + *positions, + energy_kev: Optional[float] = None, + print_only: bool = True, + ) -> Optional[None]: + """ + Set the exposure-box filters to an explicit combination. + + Takes one slot number per unit, 1..6, with 1 = out -- the same numbering + as the SPEC fil_comb. Use this when a particular filter is wanted; use + fil_trans when a particular transmission is wanted. + + The four slot numbers may also be given as a single code, which is the + form fil_trans prints and _fil_trans_report matches, so a reported + combination can be pasted straight back in. + + Examples: + csaxs.fil_comb(1, 4, 1, 1) # Ti 400 only + csaxs.fil_comb(1411) # the same thing + csaxs.fil_comb(1, 2, 3, 1) # Si 234.6 (unit 2) + Si 1661.5 (unit 3) + csaxs.fil_comb(1111) # all out + + Safety: identical to fil_trans. Any combination with transmission < 1 + requires DMM through and the CCM active, or an explicit confirmation. + + Args: + *positions: four slot numbers, one per unit, 1..6 (1 = out). A single + list, tuple, or four-digit code such as 1411 is also accepted. + energy_kev: photon energy. Read from the CCM energy PV if omitted. + print_only: dry run first and prompt before moving. Defaults to True. + """ + if not positions: + print("\nUsage example:") + print(" csaxs.fil_comb(1, 4, 1, 1) # one slot number per unit, 1 = out") + print(" csaxs.fil_comb(1411) # or the same as a single code") + print(" Use csaxs.fil_trans(T) to select by transmission instead.") + print("\nCurrent filter transmission:") + self._fil_trans_report(energy_kev=energy_kev) + return None + + # Accept fil_comb(1411) and fil_comb("1411") -- the same four-digit code + # that fil_trans prints and _fil_trans_report matches -- as well as + # fil_comb(1, 4, 1, 1) and fil_comb([1, 4, 1, 1]). + if len(positions) == 1: + only = positions[0] + if isinstance(only, (list, tuple)): + positions = tuple(only) + elif isinstance(only, (int, str)) and not isinstance(only, bool): + digits = str(only).strip() + if not digits.isdigit(): + raise ValueError( + f"Cannot read {only!r} as a filter combination. Give one slot " + f"number per unit, e.g. fil_comb(1, 4, 1, 1) or fil_comb(1411)." + ) + if len(digits) != self._UNITS: + raise ValueError( + f"A filter code has exactly {self._UNITS} digits, one per unit; " + f"got {digits!r}. Slot numbers run 1..{self._PER_UNIT} with 1 = out, " + f"so fil_comb(1411) is unit 2 at position 4 and the rest out." + ) + positions = tuple(digits) + + # --- Energy handling (EPICS only) --- + if energy_kev is None: + try: + energy_kev = float(epics_get("X12SA-OP-CCM1:ENERGY-GET")) + except Exception as exc: + raise RuntimeError( + "Energy not specified and could not read EPICS PV " + "'X12SA-OP-CCM1:ENERGY-GET'." + ) from exc + else: + energy_kev = float(energy_kev) + + # Validate everything before the safety prompt, so a typo never reaches it. + comb = self._combination_from_positions(positions, energy_kev) + + if comb["transmission"] < 1.0 and not self._attenuation_allowed(): + print("Aborted. Filters unchanged.") + return None + + print("\nExposure-box filter combination request") + print("-" * 60) + print(f"Requested combination : {comb['code']}") + print(f"Photon energy : {energy_kev:.3f} keV") + print(f"Mode : {'PRINT ONLY' if print_only else 'EXECUTE'}") + print("-" * 60) + + self._print_combination(comb, energy_kev, header="Requested combination") + + if print_only: + print("\n[DRY RUN] No motion executed yet.") + if hasattr(self, "OMNYTools") and hasattr(self.OMNYTools, "yesno"): + if self.OMNYTools.yesno( + "Execute motion to the requested filter combination now?", "y" + ): + self._execute_combination(comb, energy_kev) + else: + print("Execution skipped.") + else: + print("No interactive prompt available. Execution skipped (print_only=True).") + return None + + self._execute_combination(comb, energy_kev) + return None + + # ----------------------------- + # Safety + # ----------------------------- + def _attenuation_allowed(self) -> bool: + """ + Check the beamline is in a state where inserting attenuators is sensible. + + Requires DMM translation and rotation in THROUGH and the CCM active. + Returns True to proceed. When the conditions are not met the user is + prompted, defaulting to NO. + """ + try: + dmm_trans = float(epics_get("X12SA-OP-DMM-EMLS-3010:THRU")) + except Exception: + dmm_trans = -1 + try: + dmm_rot = float(epics_get("X12SA-OP-DMM-EMLS-3030:THRU")) + except Exception: + dmm_rot = -1 + try: + ccm_energy = float(epics_get("X12SA-OP-CCM1:ENERGY-GET")) + except Exception: + ccm_energy = -1 + + if (dmm_trans == 1) and (dmm_rot == 1) and (ccm_energy > 1): + return True + + print("\n\u26a0\ufe0f SAFETY WARNING: Reducing transmission (< 1) typically requires:") + print(" - DMM translation in THROUGH (THRU == 1)") + print(" - DMM rotation in THROUGH (THRU == 1)") + print(" - CCM energy > 1 keV") + print("\nCurrent state:") + print(f" DMM translation THRU : {dmm_trans}") + print(f" DMM rotation THRU : {dmm_rot}") + print(f" CCM energy (keV) : {ccm_energy}") + + if hasattr(self, "OMNYTools") and hasattr(self.OMNYTools, "yesno"): + return bool( + self.OMNYTools.yesno("Conditions not satisfied. Proceed anyway?", default="n") + ) + return False + # ----------------------------- # Physics helpers # ----------------------------- @@ -431,10 +563,16 @@ class cSAXSFilterTransmission: for u in range(self._UNITS) ] - # Precompute per-position transmissions + # Precompute per-position transmissions. Positions excluded from the + # search are treated exactly like disabled ones here, i.e. skipped. per_pos_T: List[List[Optional[float]]] = [ - [self._position_transmission(pos_entry, energy_kev) for pos_entry in unit] - for unit in units + [ + None + if (u + 1, i + 1) in self._EXCLUDE_FROM_SEARCH + else self._position_transmission(pos_entry, energy_kev) + for i, pos_entry in enumerate(unit) + ] + for u, unit in enumerate(units) ] combos: List[dict] = [] @@ -477,6 +615,57 @@ class cSAXSFilterTransmission: combos.sort(key=lambda c: c["transmission"]) # ascending return combos + def _combination_from_positions(self, positions, energy_kev: float) -> dict: + """ + Build a combination dict, same shape as the entries from _all_combinations, + from explicit per-unit slot numbers 1..6. + + Every slot is validated before anything moves, so a typo costs nothing. + """ + if len(positions) != self._UNITS: + raise ValueError( + f"fil_comb expects {self._UNITS} positions, got {len(positions)}." + ) + + indices = [] + for unit_idx, pos in enumerate(positions): + try: + pos_int = int(pos) + except Exception: + raise ValueError( + f"Unit {unit_idx + 1}: position must be an integer 1..{self._PER_UNIT}." + ) + if not 1 <= pos_int <= self._PER_UNIT: + raise ValueError( + f"Unit {unit_idx + 1}: position {pos_int} out of range 1..{self._PER_UNIT}." + ) + indices.append(pos_int - 1) + + mats = [] + transmission = 1.0 + for unit_idx, idx in enumerate(indices): + entry = self._FILTERS[unit_idx * self._PER_UNIT + idx] + (mat1, th1), (mat2, th2), enabled = entry + if not enabled: + raise ValueError( + f"Unit {unit_idx + 1} position {idx + 1} is disabled in the filter table." + ) + if self._POSITIONS_USER[unit_idx][idx] is None: + raise ValueError( + f"Unit {unit_idx + 1} position {idx + 1} has no defined coordinate." + ) + transmission *= self._position_transmission(entry, energy_kev) + mats.append( + ((mat1, th1), (mat2, th2) if (mat2 != "none" and th2 > 0.0) else None) + ) + + return { + "code": "".join(str(i + 1) for i in indices), + "indices": indices, + "materials": mats, + "transmission": transmission, + } + def _find_best_combination(self, target_T: float, energy_kev: float) -> dict: """Pick combination with transmission closest to target.""" combos = self._all_combinations(energy_kev)