from __future__ import annotations
import logging
from typing import Any, TYPE_CHECKING
import numpy as np
import xraylib
from ewokscore import Task
from ewokscore.model import BaseInputModel
from ewokscore.model import BaseOutputModel
from pydantic import Field
from xoppylib.sources.xoppy_bm_wiggler import xoppy_calc_bm
from xoppylib.sources.xoppy_bm_wiggler import xoppy_calc_wiggler_on_aperture
logger = logging.getLogger(__name__)
_NIST_ALIASES = {
"air": "Air, Dry (near sea level)",
"water": "Water, Liquid",
"kapton": "Kapton Polyimide Film",
}
try:
for n in xraylib.GetCompoundDataNISTList():
_NIST_ALIASES[n] = n
_NIST_ALIASES[n.lower()] = n
_NIST_ALIASES[n.replace(" ", "")] = n
_NIST_ALIASES[n.lower().replace(" ", "")] = n
except Exception as e:
logger.debug(
"Could not load NIST compound list from xraylib; using base aliases only: %s",
e,
)
def _is_number(x) -> bool:
try:
float(x)
return True
except Exception:
return False
if TYPE_CHECKING: # pragma: no cover - typing-only import
pass
try:
from ewokscore.missing_data import MissingData as _MissingDataRuntime # type: ignore[assignment]
except Exception: # pragma: no cover - fallback for optional dependency
_MissingDataRuntime = None # type: ignore[assignment]
def _is_missing_data(value: Any) -> bool:
if value is None:
return False
if _MissingDataRuntime is not None and isinstance(value, _MissingDataRuntime):
return True
return value.__class__.__name__ == "MissingData"
def _canonical_compound_name(name: str) -> str | None:
if not name:
return None
key1 = name.strip()
key2 = key1.lower()
key3 = key2.replace(" ", "")
return _NIST_ALIASES.get(key1) or _NIST_ALIASES.get(key2) or _NIST_ALIASES.get(key3)
def _mu_over_rho_element(Z: int, E_keV: np.ndarray) -> np.ndarray:
# cm^2/g
return np.array([xraylib.CS_Total(Z, e) for e in E_keV], dtype=float)
def _mu_over_rho_compound_from_nist(
cname: str, E_keV: np.ndarray
) -> tuple[np.ndarray, float | None]:
"""
Return (mu_over_rho [cm^2/g], density [g/cm^3 or None]) for a NIST compound name.
mu/ρ is computed as the mass-fraction weighted sum of elemental mu/ρ.
"""
data = xraylib.GetCompoundDataNISTByName(cname)
Zs = data["Elements"]
w = data["massFractions"]
mu_over_rho = np.zeros_like(E_keV, dtype=float)
for Z, wf in zip(Zs, w):
mu_over_rho += wf * _mu_over_rho_element(int(Z), E_keV)
return mu_over_rho, float(data["density"])
def _resolve_mu_density(
material: str, density_g_cm3: Any, E_keV: np.ndarray
) -> tuple[np.ndarray, float]:
"""
Resolve mass attenuation (mu/ρ) and density for either an element or a NIST compound.
- If density is '?', None or missing, we auto-fill:
* elements: xraylib.ElementDensity(Z)
* compounds: NIST density
"""
try:
Z = xraylib.SymbolToAtomicNumber(material)
except Exception as e:
logger.debug(
"Material is not an element symbol (%r); trying compound paths: %s",
material,
e,
)
else:
mu_over_rho = _mu_over_rho_element(Z, E_keV)
if _is_number(density_g_cm3):
rho = float(density_g_cm3)
else:
rho = float(xraylib.ElementDensity(Z))
return mu_over_rho, rho
cname = _canonical_compound_name(material)
if cname is not None:
mu_over_rho, rho_nist = _mu_over_rho_compound_from_nist(cname, E_keV)
if _is_number(density_g_cm3):
rho = float(density_g_cm3)
elif rho_nist is not None:
rho = float(rho_nist)
else:
raise ValueError(f"NIST density unavailable for compound '{cname}'")
return mu_over_rho, rho
try:
comp = xraylib.CompoundParser(material)
Zs = comp["Elements"]
w = comp["massFractions"]
mu_over_rho = np.zeros_like(E_keV, dtype=float)
for Z, wf in zip(Zs, w):
mu_over_rho += wf * _mu_over_rho_element(int(Z), E_keV)
rho = float(density_g_cm3) if _is_number(density_g_cm3) else 1.0
return mu_over_rho, rho
except Exception as e:
raise ValueError(f"Unknown material '{material}': {e}")
def _transmission(
material: str, thickness_mm: float, density_g_cm3: Any, energy_eV: np.ndarray
) -> np.ndarray:
if thickness_mm <= 0:
return np.ones_like(energy_eV, dtype=float)
E_keV = np.asarray(energy_eV, dtype=float) / 1e3
mu_over_rho, rho = _resolve_mu_density(
material, density_g_cm3, E_keV
) # cm^2/g, g/cm^3
mu = mu_over_rho * rho # cm^-1
t_cm = float(thickness_mm) / 10.0
return np.exp(-mu * t_cm)
[docs]
class ComputeSpectrumOutputModel(BaseOutputModel):
energy_eV: np.ndarray = Field(
..., description="Photon energy grid (eV), sorted ascending."
)
flux: np.ndarray = Field(
..., description="Flux (phot/s/0.1%bw) as returned by XOPPY."
)
spectral_power: np.ndarray = Field(..., description="Spectral power (W/eV).")
cumulated_power: np.ndarray = Field(..., description="Cumulated power (W).")
[docs]
class ComputeBMSpectrum( # type: ignore[call-arg]
Task,
input_model=ComputeBMSpectrumInputModel,
output_model=ComputeSpectrumOutputModel,
):
"""
Compute a bending-magnet (BM) spectrum using XOPPY's ``xoppy_calc_bm``.
"""
[docs]
def run(self):
TYPE_CALC = self.inputs.TYPE_CALC
VER_DIV = self.inputs.VER_DIV
MACHINE_NAME = self.inputs.MACHINE_NAME
RB_CHOICE = self.inputs.RB_CHOICE
MACHINE_R_M = self.inputs.MACHINE_R_M
BFIELD_T = self.inputs.BFIELD_T
BEAM_ENERGY_GEV = self.inputs.BEAM_ENERGY_GEV
CURRENT_A = self.inputs.CURRENT_A
HOR_DIV_MRAD = self.inputs.HOR_DIV_MRAD
PHOT_ENERGY_MIN = self.inputs.PHOT_ENERGY_MIN
PHOT_ENERGY_MAX = self.inputs.PHOT_ENERGY_MAX
NPOINTS = self.inputs.NPOINTS
LOG_CHOICE = self.inputs.LOG_CHOICE
PSI_MRAD_PLOT = self.inputs.PSI_MRAD_PLOT
PSI_MIN = self.inputs.PSI_MIN
PSI_MAX = self.inputs.PSI_MAX
PSI_NPOINTS = self.inputs.PSI_NPOINTS
FILE_DUMP = self.inputs.FILE_DUMP
a6_T, fm, a, energy_eV = xoppy_calc_bm(
TYPE_CALC=TYPE_CALC,
MACHINE_NAME=MACHINE_NAME,
RB_CHOICE=RB_CHOICE,
MACHINE_R_M=MACHINE_R_M,
BFIELD_T=BFIELD_T,
BEAM_ENERGY_GEV=BEAM_ENERGY_GEV,
CURRENT_A=CURRENT_A,
HOR_DIV_MRAD=HOR_DIV_MRAD,
VER_DIV=VER_DIV,
PHOT_ENERGY_MIN=PHOT_ENERGY_MIN,
PHOT_ENERGY_MAX=PHOT_ENERGY_MAX,
NPOINTS=NPOINTS,
LOG_CHOICE=LOG_CHOICE,
PSI_MRAD_PLOT=PSI_MRAD_PLOT,
PSI_MIN=PSI_MIN,
PSI_MAX=PSI_MAX,
PSI_NPOINTS=PSI_NPOINTS,
FILE_DUMP=FILE_DUMP,
)
if TYPE_CALC != 0 or VER_DIV not in (0, 2):
raise ValueError(
"ComputeBMSpectrum expects TYPE_CALC=0 and VER_DIV in {0,2}"
)
flux = a6_T[:, 5]
spectral_power = a6_T[:, 6]
cum_power = a6_T[:, 7]
order = np.argsort(energy_eV)
self.outputs.energy_eV = energy_eV[order]
self.outputs.flux = flux[order]
self.outputs.spectral_power = spectral_power[order]
self.outputs.cumulated_power = cum_power[order]
[docs]
class ComputeWigglerSpectrum( # type: ignore[call-arg]
Task,
input_model=ComputeWigglerSpectrumInputModel,
output_model=ComputeSpectrumOutputModel,
):
"""
Compute a wiggler spectrum on an aperture using
``xoppy_calc_wiggler_on_aperture``.
"""
[docs]
def run(self):
energy, flux, sp, cum, *_ = xoppy_calc_wiggler_on_aperture(
FIELD=int(self.inputs.FIELD),
NPERIODS=int(self.inputs.NPERIODS),
ULAMBDA=float(self.inputs.ULAMBDA), # m
K=float(self.inputs.K),
ENERGY=float(self.inputs.ENERGY), # GeV
PHOT_ENERGY_MIN=float(self.inputs.PHOT_ENERGY_MIN),
PHOT_ENERGY_MAX=float(self.inputs.PHOT_ENERGY_MAX),
NPOINTS=int(self.inputs.NPOINTS),
NTRAJPOINTS=int(self.inputs.NTRAJPOINTS),
CURRENT=float(self.inputs.CURRENT), # mA
FILE=str(self.inputs.FILE),
SLIT_FLAG=int(self.inputs.SLIT_FLAG),
SLIT_D=float(self.inputs.SLIT_D), # m
SLIT_NY=int(self.inputs.SLIT_NY),
SLIT_WIDTH_H_MM=float(self.inputs.SLIT_WIDTH_H_MM),
SLIT_HEIGHT_V_MM=float(self.inputs.SLIT_HEIGHT_V_MM),
SLIT_CENTER_H_MM=float(self.inputs.SLIT_CENTER_H_MM),
SLIT_CENTER_V_MM=float(self.inputs.SLIT_CENTER_V_MM),
SHIFT_X_FLAG=int(self.inputs.SHIFT_X_FLAG),
SHIFT_X_VALUE=float(self.inputs.SHIFT_X_VALUE), # m
SHIFT_BETAX_FLAG=int(self.inputs.SHIFT_BETAX_FLAG),
SHIFT_BETAX_VALUE=float(self.inputs.SHIFT_BETAX_VALUE),
TRAJ_RESAMPLING_FACTOR=float(self.inputs.TRAJ_RESAMPLING_FACTOR),
SLIT_POINTS_FACTOR=float(self.inputs.SLIT_POINTS_FACTOR),
)
energy = np.asarray(energy, dtype=float)
order = np.argsort(energy)
self.outputs.energy_eV = energy[order]
self.outputs.flux = np.asarray(flux, dtype=float)[order]
self.outputs.spectral_power = np.asarray(sp, dtype=float)[order]
self.outputs.cumulated_power = np.asarray(cum, dtype=float)[order]
[docs]
class ApplyAttenuatorsOutputModel(BaseOutputModel):
energy_eV: np.ndarray = Field(..., description="Same energy grid passed through.")
attenuated_spectral_power: np.ndarray = Field(
..., description="``spectral_power`` (W/eV) multiplied by ``transmission``."
)
transmission: np.ndarray = Field(
..., description="Cumulative transmission of the attenuator stack (0-1)."
)
attenuated_flux: np.ndarray | None = Field(
None,
description=(
"``flux`` (phot/s/0.1%bw) multiplied by ``transmission`` when "
"provided, else None."
),
)
[docs]
class ApplyAttenuators( # type: ignore[call-arg]
Task,
input_model=ApplyAttenuatorsInputModel,
output_model=ApplyAttenuatorsOutputModel,
):
"""
Apply a stack of attenuators to the source spectrum (and optionally flux).
"""
[docs]
def run(self):
energy_eV = self.inputs.energy_eV
sp_in = self.inputs.spectral_power.copy()
flux_in = self.inputs.flux
if _is_missing_data(flux_in):
flux_in = None
attenuators: dict[str, dict[str, Any]] = dict(self.inputs.attenuators)
# order of stacking
if self.inputs.order:
keys: list[str] = list(self.inputs.order)
else:
keys = list(attenuators.keys())
transmission = np.ones_like(energy_eV, dtype=float)
for key in keys:
a = attenuators[key]
T = _transmission(
str(a["material"]),
float(a["thickness_mm"]),
float(a["density_g_cm3"]),
energy_eV,
)
transmission *= T
sp_out = sp_in * transmission
flux_out = flux_in * transmission if flux_in is not None else None
self.outputs.energy_eV = energy_eV
self.outputs.attenuated_spectral_power = sp_out
self.outputs.transmission = transmission
self.outputs.attenuated_flux = flux_out
[docs]
class SpectrumStatsOutputModel(BaseOutputModel):
mean_energy_eV: float = Field(
...,
description=(
"Flux-weighted mean using bin weights: weights = flux * ΔE / (0.001 * E)."
),
)
mean_idx: int = Field(
..., description="Index of energy_eV closest to mean_energy_eV (−1 if N/A)."
)
pic_energy_eV: float = Field(
...,
description=(
"Energy at which flux * ΔE / (0.001 * E) is maximal (NaN if N/A)."
),
)
pic_idx: int = Field(..., description="Index of that maximum (−1 if N/A).")
[docs]
class SpectrumStats( # type: ignore[call-arg]
Task,
input_model=SpectrumStatsInputModel,
output_model=SpectrumStatsOutputModel,
):
"""
Stats on an attenuated spectrum.
"""
[docs]
def run(self):
energy = np.asarray(self.inputs.energy_eV, dtype=float)
flux = np.asarray(self.inputs.attenuated_flux, dtype=float)
if not (energy.shape == flux.shape):
raise ValueError("energy_eV and attenuated_flux must have identical shapes")
mask = np.isfinite(energy) & np.isfinite(flux) & (energy > 0)
if not mask.any():
self.outputs.mean_energy_eV = float("nan")
self.outputs.mean_idx = -1
self.outputs.pic_energy_eV = float("nan")
self.outputs.pic_idx = -1
return
idx_masked = np.flatnonzero(mask)
e = energy[mask]
f = flux[mask]
order = np.argsort(e)
e_sorted = e[order]
f_sorted = f[order]
idx_sorted = idx_masked[order]
if e_sorted.size == 1:
dE = np.array([1.0], dtype=float)
else:
dE = np.empty_like(e_sorted)
dE[1:-1] = 0.5 * (e_sorted[2:] - e_sorted[:-2])
dE[0] = e_sorted[1] - e_sorted[0]
dE[-1] = e_sorted[-1] - e_sorted[-2]
dE = np.clip(dE, 0.0, None)
weights = f_sorted * dE / (1e-3 * e_sorted)
wsum = float(np.sum(weights))
if wsum > 0.0:
mean_energy_eV = float(np.sum(e_sorted * weights) / wsum)
mean_idx = int(np.abs(energy - mean_energy_eV).argmin())
else:
mean_energy_eV = float("nan")
mean_idx = -1
if weights.size > 0:
rel = int(np.argmax(weights))
pic_idx = int(idx_sorted[rel])
pic_energy_eV = float(energy[pic_idx])
else:
pic_idx = -1
pic_energy_eV = float("nan")
self.outputs.mean_energy_eV = mean_energy_eV
self.outputs.mean_idx = mean_idx
self.outputs.pic_energy_eV = pic_energy_eV
self.outputs.pic_idx = pic_idx