Skip to content

About

Tools for FY-Yield NEXAFS data reduction

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

3 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 

Repository files navigation

fyn-reduce

Tools for Fluorescence Yield (FY) NEXAFS data reduction collected at Beamline 11.0.1.2 (RSOXS) at the Advanced Light Source (ALS).


Installation

pip install -e .

For development dependencies (linting, type checking, tests):

pip install -e ".[dev,test]"

Overview

A typical reduction workflow has three steps:

  1. Load an I0 reference scan from an SMS text file
  2. Load a sample as a series of FITS image files
  3. Define an ROI and call reduce() to get the normalized FY spectrum

Loading an I0 Reference Scan

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'.


Loading a Sample

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()

Defining a Region of Interest (ROI)

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 pixels

The 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 red

Adjust 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.


Dark Subtraction Modes

The background is removed in up to two stages. Stage 1 always runs; stage 2 is opt-in.

Stage 1 — dark reference frame (always applied)

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)

Stage 2 — in-frame dark region (optional)

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.

Choosing a mode

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

Dezinger Parameters

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.


Running the Reduction

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"]]

Energy offset

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()

Saving Results

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)

Complete Example

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)

About

Tools for FY-Yield NEXAFS data reduction

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages