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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017kLGTqTyXzTT4swt3CfTkV
This commit is contained in:
2026-08-30 13:50:49 +02:00
committed by x12sa
co-authored by Claude Opus 5
parent 732c77222f
commit ffb49b664b
@@ -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)