Tools for Fluorescence Yield (FY) NEXAFS data reduction collected at Beamline 11.0.1.2 (RSOXS) at the Advanced Light Source (ALS).
pip install -e .For development dependencies (linting, type checking, tests):
pip install -e ".[dev,test]"A typical reduction workflow has three steps:
- Load an I0 reference scan from an SMS text file
- Load a sample as a series of FITS image files
- Define an ROI and call
reduce()to get the normalized FY spectrum
The I0 reference is a Single Motor Scan (SMS) text file collected without a
sample in the beam. Load it with load_sms_file:
from pathlib import Path
from fyn_reduce import load_sms_file
i0 = load_sms_file(Path("/data/i0_scan.txt"))The returned DataFrame contains one row per energy point. The columns used
during reduction are 'Beamline Energy', 'Photodiode', and 'AI 3 Izero'.
Sample data is stored as a series of FITS images, one per energy point plus any
dark frames. Pass all files for a single sample to FYLoader:
from pathlib import Path
from fyn_reduce import FYLoader, load_sms_file
i0 = load_sms_file(Path("/data/i0_scan.txt"))
files = sorted(Path("/data/sampleA_001/").glob("*.fits"))
loader = FYLoader(files=files, i0=i0, name="sampleA_001")Files are loaded automatically on construction (autoload=True by default).
To defer loading:
loader = FYLoader(files=files, i0=i0, name="sampleA_001", autoload=False)
# ... do other work ...
loader.load()The ROI controls which pixels on the detector are summed to compute the FY signal. It is defined as a center point and a size in detector pixel coordinates.
loader.roi_center = (512, 512) # (x, y) center pixel
loader.roi_size = (500, 500) # (x_range, y_range) in pixelsThe defaults — center (512, 512), size (500, 500) — cover most of a
1024 × 1024 detector. A smaller ROI isolates signal from background:
# Tight ROI around the fluorescence spot
loader.roi_center = (480, 530)
loader.roi_size = (200, 200)The ROI need not be square. x is the horizontal (column) axis and y is the
vertical (row) axis. The pixel slice applied is:
x: [ roi_center[0] - roi_size[0]/2, roi_center[0] + roi_size[0]/2 ]
y: [ roi_center[1] - roi_size[1]/2, roi_center[1] + roi_size[1]/2 ]
To find good ROI coordinates, plot a frame with the current ROI drawn on it:
loader.plot(0) # frame 0 of the loaded series, ROI outlined in redAdjust roi_center / roi_size and re-plot until the box sits on the
fluorescence spot. plot() accepts vmin, vmax, cmap, and an existing
ax if you want to control the display.
The background is removed in up to two stages. Stage 1 always runs; stage 2 is opt-in.
Dark frames (CCD shutter closed) are identified automatically from the
CCD Camera Shutter Inhibit flag in each FITS header. The dark frame is
subtracted from every light frame pixel by pixel, which cancels the
camera's repeatable fixed-pattern noise.
When a series contains more than one dark frame the last one is used — the first frame read out of this camera is known to be unrepresentatively bright. A warning is logged if the darks disagree by more than 2%.
spectrum = loader.reduce()To supply your own dark image instead of using one from the series:
from fyn_reduce import load_fits_file
dark_image, _ = load_fits_file(Path("/data/dark_001.fits"))
spectrum = loader.reduce(external_dark=dark_image)A dark frame cannot capture stray light that leaks into the chamber only while
the shutter is open. To remove that as well, define a second ROI over a
blocked region of the detector and pass use_dark_roi=True:
loader.roi_dark_center = (300, 300) # (x, y) center of the blocked region
loader.roi_dark_size = (400, 200) # (x_range, y_range) in pixels
spectrum = loader.reduce(use_dark_roi=True)This region is measured on the already pattern-corrected frame, so it sees only the stray light. The two ROIs need not be the same size — the region is converted to a per-pixel rate and scaled to the signal ROI's pixel count before subtraction.
plot() outlines the dark ROI too (dashed cyan) once it is set, so you can
confirm it sits on a genuinely blocked area:
loader.plot(0)A warning is logged if the two ROIs overlap, since that would subtract sample signal from itself.
| Call | Stage 1 | Stage 2 | Use when |
|---|---|---|---|
reduce() |
✅ | — | The dark frame captures all background |
reduce(use_dark_roi=True) |
✅ | ✅ | Stray light is present while the shutter is open |
reduce(external_dark=img) |
✅ | — | The dark reference comes from another file |
reduce(external_dark=img, use_dark_roi=True) |
✅ | ✅ | Both of the above |
Hot pixels (cosmic rays, detector artifacts) are removed before the ROI sum using a median filter. Two parameters control sensitivity:
loader.diz_threshold = 10.0 # ratio above which a pixel is replaced
loader.diz_size = 3 # median filter kernel size (pixels)A lower diz_threshold removes more pixels; a higher value is more
conservative. The default of 10.0 is suitable for most data.
Call reduce() after setting the ROI and dezinger parameters:
spectrum = loader.reduce()The result is a pandas.DataFrame with three columns:
| Column | Description |
|---|---|
Energy |
Beamline energy (eV) |
FY |
Normalized fluorescence yield (arb. units) |
FY_err |
Counting-statistics error on FY (same units) |
Every intermediate of the calculation — raw ROI sums, the background actually
subtracted, pixel counts, and the normalization factors — is also stored on the
loader as loader.data, one row per light frame:
spectrum = loader.reduce(use_dark_roi=True)
loader.data[["Energy", "i_light", "i_stray", "i_fy", "norm", "scale"]]If the beamline energy axis needs a calibration correction:
loader.energy_offset = 0.3 # eV; added to every frame energy before reduction
spectrum = loader.reduce()Save as a tab-delimited .dat file with a lowercase header row:
spectrum.rename(columns={"Energy": "energy", "FY": "fy", "FY_err": "fy_err"}) \
.to_csv("sampleA_001.dat", sep="\t", index=False)from pathlib import Path
import matplotlib.pyplot as plt
from fyn_reduce import FYLoader, load_sms_file
data_dir = Path("/data/20260507")
# Load I0 reference
i0 = load_sms_file(data_dir / "i0_scan.txt")
# Load sample
files = sorted((data_dir / "sampleA_001").glob("*.fits"))
loader = FYLoader(files=files, i0=i0, name="sampleA_001")
# Set ROI (check placement with loader.plot(0))
loader.roi_center = (480, 530)
loader.roi_size = (200, 200)
# Reduce. Stage 1 (dark frame, pixel-by-pixel) always runs.
spectrum = loader.reduce()
# Or, to also remove stray light using a blocked region of the detector:
# loader.roi_dark_center = (100, 100)
# loader.roi_dark_size = (150, 150)
# spectrum = loader.reduce(use_dark_roi=True)
# Plot
fig, ax = plt.subplots()
ax.errorbar(spectrum["Energy"], spectrum["FY"], yerr=spectrum["FY_err"], fmt="o-")
ax.set_xlabel("Beamline Energy (eV)")
ax.set_ylabel("FY (arb. units)")
ax.set_title("sampleA_001")
plt.show()
# Save
spectrum.rename(columns={"Energy": "energy", "FY": "fy", "FY_err": "fy_err"}) \
.to_csv(data_dir / "sampleA_001.dat", sep="\t", index=False)