Feat/fil comb #306

Merged
menzel merged 4 commits from feat/fil-comb into main 2026-08-30 12:58:16 +02:00
@@ -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)