Feat/fil comb #306
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user