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