Skip to content

Commit ffce30c

Browse files
committed
add examples/andes_yjh_sampl.py to measure and plot the sampling in ANDES YJH
1 parent 8ea43df commit ffce30c

1 file changed

Lines changed: 326 additions & 0 deletions

File tree

examples/andes_yjh_sampl.py

Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
"""
2+
ANDES_YJH sampling test: extract individual fibers from a custom-illuminated
3+
LFC frame and measure FWHM of the synthetic LFC peaks across the detector.
4+
5+
Re-uses the tracing from andes_yjh.py (loads the traces saved by that script).
6+
The trace file contains both grouped and per-fiber traces, so we can select
7+
individual fibers 1, 38 and 75 by their fiber_idx.
8+
9+
{channel}_LFC_tcb.fits has only fibers 1, 38 and 75 illuminated with sharp
10+
synthetic LFC lines. Extracting those three fibers gives us a sampling probe
11+
near the bottom, middle and top of each spectral order.
12+
13+
The FWHM (in pixels) of the Gaussian fits is a direct measure of the optical
14+
design's spectral sampling. This script runs the same analysis for all three
15+
ANDES_YJH bands (Y, J, H).
16+
"""
17+
18+
import os
19+
20+
import matplotlib.pyplot as plt
21+
import numpy as np
22+
from astropy.io import fits
23+
from scipy.interpolate import griddata
24+
from scipy.optimize import curve_fit
25+
from scipy.signal import find_peaks
26+
from scipy.stats import binned_statistic_2d
27+
28+
from pyreduce.configuration import load_config
29+
from pyreduce.pipeline import Pipeline
30+
31+
# --- Configuration ---
32+
INSTRUMENT_NAME = "ANDES_YJH"
33+
CHANNELS = ["Y", "J", "H"]
34+
# Fibers to extract (1-based, as stored in Trace.fiber_idx).
35+
FIBERS_TO_EXTRACT = [1, 38, 75]
36+
37+
DATA_DIR = os.environ.get("REDUCE_DATA", os.path.expanduser("~/REDUCE_DATA"))
38+
PLOT = int(os.environ.get("PYREDUCE_PLOT", "0"))
39+
40+
SIGMA_TO_FWHM = 2.0 * np.sqrt(2.0 * np.log(2.0))
41+
42+
43+
def gaussian(x, amp, mu, sigma, baseline):
44+
return baseline + amp * np.exp(-0.5 * ((x - mu) / sigma) ** 2)
45+
46+
47+
def fit_peaks(spec, prominence_frac=0.1, window=6):
48+
"""Find LFC peaks and fit each with a Gaussian. Returns (x_peaks, fwhms)."""
49+
flux = np.asarray(spec, dtype=float)
50+
mask = np.isfinite(flux)
51+
if mask.sum() < 50:
52+
return np.array([]), np.array([])
53+
54+
idx = np.arange(flux.size)
55+
valid = idx[mask]
56+
fmax = np.nanmax(flux[mask])
57+
if not np.isfinite(fmax) or fmax <= 0:
58+
return np.array([]), np.array([])
59+
60+
peaks, _ = find_peaks(
61+
np.where(mask, flux, 0.0),
62+
prominence=prominence_frac * fmax,
63+
distance=4,
64+
)
65+
lo, hi = valid.min() + window, valid.max() - window
66+
peaks = peaks[(peaks >= lo) & (peaks <= hi)]
67+
68+
x_out, fwhm_out = [], []
69+
for p in peaks:
70+
xs = idx[p - window : p + window + 1].astype(float)
71+
ys = flux[p - window : p + window + 1]
72+
m = np.isfinite(ys)
73+
if m.sum() < 5:
74+
continue
75+
xs = xs[m]
76+
ys = ys[m]
77+
baseline0 = np.min(ys)
78+
amp0 = np.max(ys) - baseline0
79+
p0 = [amp0, float(p), 1.5, baseline0]
80+
try:
81+
popt, _ = curve_fit(gaussian, xs, ys, p0=p0, maxfev=2000)
82+
except (RuntimeError, ValueError):
83+
continue
84+
sigma = abs(popt[2])
85+
if not np.isfinite(sigma) or sigma <= 0 or sigma > 5:
86+
continue
87+
x_out.append(popt[1])
88+
fwhm_out.append(sigma * SIGMA_TO_FWHM)
89+
90+
return np.array(x_out), np.array(fwhm_out)
91+
92+
93+
def process_channel(channel: str) -> bool:
94+
"""Run the sampling analysis for a single ANDES_YJH band. Returns True on success."""
95+
print(f"\n{'=' * 60}\nChannel {channel}\n{'=' * 60}")
96+
97+
raw_dir = os.path.join(DATA_DIR, "ANDES", channel)
98+
output_dir = os.path.join(DATA_DIR, "ANDES", "reduced", channel)
99+
file_even = os.path.join(raw_dir, f"{channel}_FF_even_1s.fits")
100+
file_odd = os.path.join(raw_dir, f"{channel}_FF_odd_1s.fits")
101+
lfc_file = os.path.join(raw_dir, f"{channel}_LFC_tcb.fits")
102+
103+
if not os.path.exists(lfc_file):
104+
print(f" Skipping: LFC file not found ({lfc_file})")
105+
return False
106+
107+
# --- Create Pipeline ---
108+
config = load_config(None, INSTRUMENT_NAME)
109+
# Narrow aperture so we don't bleed over unlit neighbours (~2.2 px spacing).
110+
config["science"]["extraction_height"] = 3
111+
# Disable outlier rejection: the LFC peaks are sharp and bright, the rejector
112+
# can clip their cores and distort the measured line profiles.
113+
config["science"]["extraction_reject"] = 0
114+
115+
pipe = Pipeline(
116+
instrument=INSTRUMENT_NAME,
117+
channel=channel,
118+
output_dir=output_dir,
119+
target="ANDES_sampling",
120+
config=config,
121+
plot=PLOT,
122+
)
123+
124+
# --- Load traces from the previous andes_yjh.py run ---
125+
print("Loading traces from previous run...")
126+
try:
127+
trace_objects = pipe._run_step("trace", None, load_only=True)
128+
except FileNotFoundError:
129+
print(f" Skipping: trace file not found for channel {channel}")
130+
print(f" Run examples/andes_yjh.py with channel={channel} first.")
131+
return False
132+
pipe._data["trace"] = trace_objects
133+
134+
ind = [t for t in trace_objects if t.group is None and t.fiber_idx is not None]
135+
orders = sorted({t.m for t in ind}, reverse=True)
136+
print(f" Loaded {len(trace_objects)} traces ({len(ind)} individual fibers)")
137+
print(f" Orders: m = {orders[0]}..{orders[-1]} ({len(orders)} total)")
138+
print(
139+
f" Used source flats: {os.path.basename(file_even)}, {os.path.basename(file_odd)}"
140+
)
141+
142+
# --- Select only the requested fibers for the science step ---
143+
pipe.instrument.config.fibers.use["science"] = list(FIBERS_TO_EXTRACT)
144+
145+
# --- Extract the LFC frame ---
146+
print(f"Extracting fibers {FIBERS_TO_EXTRACT} from {os.path.basename(lfc_file)}...")
147+
result = pipe.extract([lfc_file]).run()
148+
_heads, all_spectra = result["science"]
149+
spectra = all_spectra[0]
150+
print(f" Extracted {len(spectra)} spectra")
151+
152+
# --- Peak finding + Gaussian FWHM analysis ---
153+
print("Fitting LFC peaks with Gaussians...")
154+
155+
trace_lookup = {
156+
(t.m, t.fiber_idx): t
157+
for t in trace_objects
158+
if t.group is None and t.fiber_idx is not None
159+
}
160+
161+
per_fiber: dict[int, list[tuple[int, np.ndarray, np.ndarray, np.ndarray]]] = {
162+
f: [] for f in FIBERS_TO_EXTRACT
163+
}
164+
for s in spectra:
165+
if s.fiber_idx not in per_fiber:
166+
continue
167+
xp, fw = fit_peaks(s.spec)
168+
t = trace_lookup.get((s.m, s.fiber_idx))
169+
yp = t.y_at_x(xp) if t is not None and xp.size else np.array([])
170+
per_fiber[s.fiber_idx].append((s.m, xp, yp, fw))
171+
172+
# Per-fiber summary
173+
print(f"\nFWHM summary (pixels) for {channel}:")
174+
print(f" {'fiber':>5} {'npeaks':>6} {'median':>7} {'mean':>7} {'std':>6}")
175+
for fiber in FIBERS_TO_EXTRACT:
176+
all_fw_f = (
177+
np.concatenate([fw for _, _, _, fw in per_fiber[fiber]])
178+
if per_fiber[fiber]
179+
else np.array([])
180+
)
181+
if all_fw_f.size == 0:
182+
print(f" {fiber:>5} {0:>6} - - -")
183+
continue
184+
print(
185+
f" {fiber:>5} {all_fw_f.size:>6} {np.median(all_fw_f):>7.3f} "
186+
f"{np.mean(all_fw_f):>7.3f} {np.std(all_fw_f):>6.3f}"
187+
)
188+
189+
# --- Diagnostic plots: spectrum sample + FWHM vs x ---
190+
fig, (ax_spec, ax_fwhm) = plt.subplots(2, 1, figsize=(11, 8))
191+
192+
ref_order = orders[len(orders) // 2]
193+
for fiber in FIBERS_TO_EXTRACT:
194+
match = [s for s in spectra if s.fiber_idx == fiber and s.m == ref_order]
195+
if not match:
196+
continue
197+
ax_spec.plot(match[0].spec, lw=0.7, label=f"fiber {fiber}")
198+
ax_spec.set_title(f"{channel}-band LFC spectrum, order m={ref_order}")
199+
ax_spec.set_xlabel("x [pixel]")
200+
ax_spec.set_ylabel("flux")
201+
ax_spec.legend(loc="upper right")
202+
203+
colors = {1: "tab:blue", 38: "tab:orange", 75: "tab:green"}
204+
for fiber in FIBERS_TO_EXTRACT:
205+
all_x_f, all_fw_f = [], []
206+
for _m, xp, _yp, fw in per_fiber[fiber]:
207+
all_x_f.append(xp)
208+
all_fw_f.append(fw)
209+
if not all_x_f:
210+
continue
211+
all_x_f = np.concatenate(all_x_f)
212+
all_fw_f = np.concatenate(all_fw_f)
213+
ax_fwhm.scatter(
214+
all_x_f,
215+
all_fw_f,
216+
s=6,
217+
alpha=0.5,
218+
color=colors.get(fiber, "k"),
219+
label=f"fiber {fiber}",
220+
)
221+
ax_fwhm.axhline(2.0, ls=":", color="k", lw=0.8, label="Nyquist (2 px)")
222+
ax_fwhm.set_xlabel("x [pixel]")
223+
ax_fwhm.set_ylabel("FWHM [pixel]")
224+
ax_fwhm.set_title(f"{channel}-band LFC line FWHM across the detector (all orders)")
225+
ax_fwhm.legend(loc="upper right")
226+
ax_fwhm.set_ylim(0, None)
227+
228+
fig.tight_layout()
229+
out_png = os.path.join(output_dir, f"andes_{channel.lower()}_sampl_fwhm.png")
230+
fig.savefig(out_png, dpi=120)
231+
print(f"\nSaved diagnostic plot: {out_png}")
232+
233+
# --- 2D interpolated FWHM map across the full detector ---
234+
all_x = np.concatenate(
235+
[xp for lst in per_fiber.values() for _m, xp, _yp, _fw in lst if xp.size]
236+
)
237+
all_y = np.concatenate(
238+
[yp for lst in per_fiber.values() for _m, _xp, yp, _fw in lst if yp.size]
239+
)
240+
all_fw = np.concatenate(
241+
[fw for lst in per_fiber.values() for _m, _xp, _yp, fw in lst if fw.size]
242+
)
243+
244+
with fits.open(lfc_file) as hdu:
245+
nrow, ncol = hdu[0].data.shape
246+
247+
# Median-bin raw peak measurements to suppress per-peak fit noise.
248+
bin_size = 256
249+
nbx = ncol // bin_size
250+
nby = nrow // bin_size
251+
stat, xedges, yedges, _ = binned_statistic_2d(
252+
all_x,
253+
all_y,
254+
all_fw,
255+
statistic="median",
256+
bins=[nbx, nby],
257+
range=[[0, ncol], [0, nrow]],
258+
)
259+
xcen = 0.5 * (xedges[:-1] + xedges[1:])
260+
ycen = 0.5 * (yedges[:-1] + yedges[1:])
261+
262+
valid = np.isfinite(stat)
263+
XB, YB = np.meshgrid(xcen, ycen, indexing="ij")
264+
pts = np.column_stack([XB[valid], YB[valid]])
265+
vals = stat[valid]
266+
267+
grid_step = 32
268+
gx = np.arange(0, ncol, grid_step)
269+
gy = np.arange(0, nrow, grid_step)
270+
GX, GY = np.meshgrid(gx, gy)
271+
272+
fwhm_map = griddata(pts, vals, (GX, GY), method="linear")
273+
274+
fig2, ax_map = plt.subplots(figsize=(9, 8))
275+
vmin, vmax = np.nanpercentile(fwhm_map, [2, 98])
276+
im = ax_map.imshow(
277+
fwhm_map,
278+
origin="lower",
279+
extent=(0, ncol, 0, nrow),
280+
aspect="equal",
281+
cmap="viridis",
282+
vmin=vmin,
283+
vmax=vmax,
284+
interpolation="bilinear",
285+
)
286+
cs = ax_map.contour(
287+
GX,
288+
GY,
289+
fwhm_map,
290+
levels=np.arange(1.6, 3.01, 0.1),
291+
colors="white",
292+
linewidths=0.7,
293+
alpha=0.8,
294+
)
295+
ax_map.clabel(cs, inline=True, fontsize=8, fmt="%.1f")
296+
ax_map.scatter(all_x, all_y, s=1, color="k", alpha=0.15)
297+
298+
cbar = fig2.colorbar(im, ax=ax_map, shrink=0.85)
299+
cbar.set_label("FWHM [pixel]")
300+
ax_map.set_xlabel("x [pixel] (dispersion)")
301+
ax_map.set_ylabel("y [pixel] (cross-dispersion)")
302+
ax_map.set_title(f"{channel}-band LFC FWHM map (linear interpolation)")
303+
ax_map.set_xlim(0, ncol)
304+
ax_map.set_ylim(0, nrow)
305+
306+
fig2.tight_layout()
307+
out_map_png = os.path.join(
308+
output_dir, f"andes_{channel.lower()}_sampl_fwhm_map.png"
309+
)
310+
fig2.savefig(out_map_png, dpi=120)
311+
print(f"Saved FWHM map: {out_map_png}")
312+
313+
return True
314+
315+
316+
# --- Main loop over all ANDES_YJH bands ---
317+
processed = []
318+
for ch in CHANNELS:
319+
if process_channel(ch):
320+
processed.append(ch)
321+
322+
print(f"\n{'=' * 60}")
323+
print(f"Done. Processed channels: {processed}")
324+
325+
if PLOT:
326+
plt.show()

0 commit comments

Comments
 (0)