From ffb49b664b4e476427058145ea714552b0240b87 Mon Sep 17 00:00:00 2001 From: menzel Date: Sun, 30 Aug 2026 12:42:12 +0200 Subject: [PATCH] feat(filters): add fil_comb for explicit filter combinations fil_trans selects by transmission, which is the wrong tool when a particular foil is wanted: it optimises against the thickness table and which combination wins is not something to rely on. The alternative was four hand-copied stage coordinates, where the out positions differ per unit (25.0 / 25.5 / 25.8 / 25.0) and getting one wrong leaves the stage off any tabulated slot -- _fil_trans_report matches within 0.1, so the combination then reports as unidentified. fil_comb takes one slot number per unit, 1..6 with 1 = out, as the SPEC fil_comb did, and looks the coordinates up. It reuses _position_transmission, _print_combination and _execute_combination unchanged, so it reports and moves exactly as fil_trans does, including the dry run and the default-yes prompt. Every slot is validated before the safety prompt is reached, so a typo raises rather than putting a question about an unexecutable combination in front of someone. Disabled slots -- Fe5 and the redundant opens -- raise too: _all_combinations skips them, so fil_comb must refuse them rather than move somewhere fil_trans would never choose. _attenuation_allowed is added here but not yet wired into fil_trans; that is the next commit, so this one cannot change existing behaviour. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017kLGTqTyXzTT4swt3CfTkV --- .../plugins/cSAXS/filter_transmission.py | 176 ++++++++++++++++++ 1 file changed, 176 insertions(+) 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..b7a4015b 100644 --- a/csaxs_bec/bec_ipython_client/plugins/cSAXS/filter_transmission.py +++ b/csaxs_bec/bec_ipython_client/plugins/cSAXS/filter_transmission.py @@ -297,6 +297,131 @@ 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. + + Examples: + csaxs.fil_comb(1, 4, 1, 1) # Ti 400 only + csaxs.fil_comb(1, 2, 3, 1) # Si 200 (unit 2) + Si 1600 (unit 3) + csaxs.fil_comb(1, 1, 1, 1) # 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 or tuple of four 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(" 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([1, 4, 1, 1]) as well as fil_comb(1, 4, 1, 1) + if len(positions) == 1 and isinstance(positions[0], (list, tuple)): + positions = tuple(positions[0]) + + # --- 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 # ----------------------------- @@ -477,6 +602,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)