From 1646e3cb4a74fbfe540c0d2eccf0a96574bce154 Mon Sep 17 00:00:00 2001 From: menzel Date: Sun, 30 Aug 2026 12:42:12 +0200 Subject: [PATCH 1/4] 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) -- 2.54.0 From 9cf7a9307c01620304a9a36c4590f5a919d28a7e Mon Sep 17 00:00:00 2001 From: menzel Date: Sun, 30 Aug 2026 12:42:51 +0200 Subject: [PATCH 2/4] refactor(filters): share the attenuation interlock between fil_trans and fil_comb The DMM-through / CCM-active check now lives in _attenuation_allowed and fil_trans calls it, so there is one copy rather than two drifting ones. A guard that is duplicated is a guard that gets fixed in one place only. Behaviour is unchanged: the same three PVs are read with the same fallbacks, the same warning is printed, the same default-NO prompt is raised through OMNYTools.yesno, the same "Safe fallback" of refusing when no prompt is available applies, and fil_trans(1) still bypasses the check entirely. Split from the previous commit so that this half can be reverted on its own if it misbehaves at the beamline, without taking fil_comb with it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017kLGTqTyXzTT4swt3CfTkV --- .../plugins/cSAXS/filter_transmission.py | 42 ++----------------- 1 file changed, 3 insertions(+), 39 deletions(-) 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 b7a4015b..3ca0f475 100644 --- a/csaxs_bec/bec_ipython_client/plugins/cSAXS/filter_transmission.py +++ b/csaxs_bec/bec_ipython_client/plugins/cSAXS/filter_transmission.py @@ -203,45 +203,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: -- 2.54.0 From 0874ca3b3870228d73b2b12400ad19eae54abd6f Mon Sep 17 00:00:00 2001 From: menzel Date: Sun, 30 Aug 2026 12:46:12 +0200 Subject: [PATCH 3/4] fix(filters): use the measured thicknesses and keep calibration foils out of fil_trans Two ways the filter table disagreed with filter.mac rev 1.19. The Si thicknesses were nominal where spec used values measured at 18.58 keV: 400/200/3200/100/1600/800 against 345.6/234.6/3303/137.5/ 1661.5/833.8. Si100 vs Si137.5 is 37% out, and at 17 keV the pair Si200+Si1600 gives T=0.060 where the measured Si234.6+Si1661.5 gives 0.052 -- a 16% error, larger than any distinction the search was trying to make between neighbouring combinations. The Zr and Cu calibration foils were selectable. filter.mac flagged them disabled so that selection by transmission could not pick them, and at 17 keV fil_trans(0.05) duly returned 5246 -- a combination built on the Zr foil, 1 keV below its K edge, where its transmission moves with any small change in energy. They are excluded through a new _EXCLUDE_FROM_SEARCH rather than by clearing their 'enabled' flag, because those two things are not the same and the flag is load-bearing elsewhere: _fil_trans_report substitutes T=1.0 for a disabled position, so clearing the flag would have made the report silently ignore a foil parked in the beam -- precisely the state an edge scan puts it in. The foils stay enabled, stay addressable via fil_comb as they were in spec, and are skipped only by the automatic search. At 17 keV fil_trans(0.05) now returns 2146 (Si345.6 + Ti200 + Ti20, T=0.0494) and fil_trans(0.01) returns 2234 (T=0.0099), both matching an independent calculation over the same CXRO tables. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017kLGTqTyXzTT4swt3CfTkV --- .../plugins/cSAXS/filter_transmission.py | 53 ++++++++++++++----- 1 file changed, 39 insertions(+), 14 deletions(-) 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 3ca0f475..29e10193 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 @@ -520,10 +539,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] = [] -- 2.54.0 From d120740dbbeecdc7de20901ab4cf8ec334aa6698 Mon Sep 17 00:00:00 2001 From: menzel Date: Sun, 30 Aug 2026 12:47:26 +0200 Subject: [PATCH 4/4] feat(filters): accept fil_comb(1411) as well as fil_comb(1, 4, 1, 1) The four-digit code is already the currency of this module: it is what _all_combinations builds, what fil_trans prints as the selected combination, and what _fil_trans_report prints for what is currently in the beam. Requiring commas on the way back in meant reading a code off the screen and retyping it as four separate arguments. fil_comb now takes 1411, "1411", (1, 4, 1, 1) or [1, 4, 1, 1], so a reported combination can be pasted straight back. A code of the wrong length is rejected with the length it needs rather than falling through to "expects 4 positions, got 1", which was the unhelpful error a code would have produced before. bool is excluded from the integer branch so fil_comb(True) does not quietly become "True". Digits outside 1..6 are left to the existing per-unit range check, which names the offending unit. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017kLGTqTyXzTT4swt3CfTkV --- .../plugins/cSAXS/filter_transmission.py | 36 +++++++++++++++---- 1 file changed, 30 insertions(+), 6 deletions(-) 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 29e10193..ea1e3a80 100644 --- a/csaxs_bec/bec_ipython_client/plugins/cSAXS/filter_transmission.py +++ b/csaxs_bec/bec_ipython_client/plugins/cSAXS/filter_transmission.py @@ -293,31 +293,55 @@ class cSAXSFilterTransmission: 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(1, 2, 3, 1) # Si 200 (unit 2) + Si 1600 (unit 3) - csaxs.fil_comb(1, 1, 1, 1) # all out + 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 or tuple of four is also accepted. + 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([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]) + # 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: -- 2.54.0