Stage 2 — Data Introduction (PSF Ground-Test Dataset)¶
Exploration of the NISP FM ground-test PSF dataset (see 2-Intro_data/index.html).
Optical chain: Telescope Simulator → Collimator → Grism → Camera → Detector.
This notebook only loads, cleans, and visualizes the data. Calibrating the physical dispersion model is the next stage, not done here.
1. Setup and Imports¶
from pathlib import Path
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
DATA_DIR = Path("..") / "data"
# 7 optical configurations: grism (BGS/RGS) x GWA angle (nominal / -4 / +4 deg)
CONFIGS = [
"bgs000_0",
"rgs000_0",
"rgs000_m4",
"rgs000_p4",
"rgs180_0",
"rgs180_m4",
"rgs180_p4",
]
2. Data Format¶
Each configuration has a *_first.csv (Fabry-Pérot emission lines, 1st diffraction
order) and a *_zeroth.csv (two-blob model of the unresolved 0th order).
df_first_sample = pd.read_csv(DATA_DIR / "rgs000_0_first.csv")
print("rgs000_0_first.csv", df_first_sample.shape)
print(df_first_sample.columns.tolist())
df_first_sample.head()
rgs000_0_first.csv (16501, 11) ['psf_id', 'spectra_id', 'y_nisp', 'z_nisp', 'wavelength', 'cent_y', 'cent_z', 'sig_y', 'sig_z', 'theta', 'phi']
| psf_id | spectra_id | y_nisp | z_nisp | wavelength | cent_y | cent_z | sig_y | sig_z | theta | phi | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 420312678 | 1104849945 | -150.664001 | -126.503998 | 1211.33 | -76.091584 | -63.748050 | 0.000637 | 0.000637 | -0.704651 | -0.591664 |
| 1 | 588299741 | 1104849945 | -150.664001 | -126.503998 | 1211.33 | -76.092143 | -63.731574 | 0.000637 | 0.000637 | -0.704651 | -0.591664 |
| 2 | 1184322492 | 1104849945 | -150.664001 | -126.503998 | 1211.33 | -76.085949 | -63.761639 | 0.000637 | 0.000637 | -0.704651 | -0.591664 |
| 3 | 333255848 | 1401107208 | -150.664001 | -126.503998 | 1211.33 | -76.039175 | -63.816523 | 0.000664 | 0.000664 | -0.704651 | -0.591664 |
| 4 | 1517986096 | 1401107208 | -150.664001 | -126.503998 | 1211.33 | -76.039677 | -63.800123 | 0.000664 | 0.000664 | -0.704651 | -0.591664 |
df_zeroth_sample = pd.read_csv(DATA_DIR / "rgs000_0_zeroth.csv")
print("rgs000_0_zeroth.csv", df_zeroth_sample.shape)
print(df_zeroth_sample.columns.tolist())
df_zeroth_sample.head()
rgs000_0_zeroth.csv (1654, 12) ['psf_id', 'spectra_id', 'rank', 'wavelength', 'y_nisp', 'z_nisp', 'theta', 'phi', 'cent_y', 'cent_z', 'sig_y', 'sig_z']
| psf_id | spectra_id | rank | wavelength | y_nisp | z_nisp | theta | phi | cent_y | cent_z | sig_y | sig_z | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 228205876 | 3597322 | 1 | 1250.0 | 62.203999 | -126.503998 | 0.290938 | -0.591664 | 30.509892 | -79.010242 | 0.000725 | 0.000725 |
| 1 | 251124106 | 3597322 | 2 | 1850.0 | 62.203999 | -126.503998 | 0.290938 | -0.591664 | 30.512828 | -78.806027 | 0.001714 | 0.001714 |
| 2 | 402503484 | 23821118 | 1 | 1250.0 | 39.848000 | -132.084000 | 0.186377 | -0.617760 | 19.379105 | -81.870634 | 0.000752 | 0.000752 |
| 3 | 150621232 | 23821118 | 2 | 1850.0 | 39.848000 | -132.084000 | 0.186377 | -0.617760 | 19.383125 | -81.661389 | 0.001487 | 0.001487 |
| 4 | 17572612 | 27542896 | 1 | 1250.0 | 120.325996 | 42.000000 | 0.562771 | 0.196442 | 59.285915 | 5.372802 | 0.000257 | 0.000257 |
3. Section 4 — Load and Explore¶
Model target for this dataset: (theta, phi, wavelength) -> (cent_y, cent_z).
load_spectra, median_per_spectrum, and zeroth_order_centers are promoted
to dispcraft/measurement.py (tested in tests/test_measurement.py) — this
notebook just imports and uses them.
from dispcraft.measurement import load_spectra, median_per_spectrum, zeroth_order_centers
# Sanity check: row counts before/after the sig_max quality filter, per config.
first_datasets = {}
for config in CONFIGS:
raw = pd.read_csv(DATA_DIR / f"{config}_first.csv")
filtered = load_spectra(DATA_DIR / f"{config}_first.csv")
first_datasets[config] = filtered
print(f"{config:12s} raw={len(raw):6d} filtered={len(filtered):6d} "
f"spectra={filtered['spectra_id'].nunique()}")
bgs000_0 raw= 16467 filtered= 16023 spectra=155 rgs000_0 raw= 16501 filtered= 15982 spectra=171 rgs000_m4 raw= 14574 filtered= 14316 spectra=149 rgs000_p4 raw= 13920 filtered= 13731 spectra=144 rgs180_0 raw= 19127 filtered= 18587 spectra=201
rgs180_m4 raw= 13988 filtered= 13772 spectra=144 rgs180_p4 raw= 14082 filtered= 13812 spectra=144
4. Section 5 — Data Visualization¶
def plot_field(df, title=""):
"""Scatter unique source positions in telescope-simulator angle space."""
unique = df.drop_duplicates(subset="spectra_id")
fig, ax = plt.subplots(figsize=(5, 5))
ax.scatter(unique["theta"], unique["phi"], s=10)
ax.set_xlabel("theta [deg]")
ax.set_ylabel("phi [deg]")
ax.set_aspect("equal")
ax.set_title(title)
plt.show()
plot_field(first_datasets["rgs000_0"], title="rgs000_0 -- field coverage")
def plot_centroids(df, color_by="wavelength", title=""):
"""Scatter PSF centroids in the detector mosaic frame R_MOS."""
fig, ax = plt.subplots(figsize=(6, 5))
sc = ax.scatter(df["cent_y"], df["cent_z"], c=df[color_by], cmap="rainbow", s=5)
fig.colorbar(sc, ax=ax, label=color_by)
ax.set_xlabel("cent_y [mm]")
ax.set_ylabel("cent_z [mm]")
ax.set_title(title)
plt.show()
plot_centroids(first_datasets["rgs000_0"], title="rgs000_0 -- centroids in R_MOS")
def compare_gwa(configs, labels):
"""Overlay median centroids across GWA settings to show the GWA-induced shift.
Sources are not paired 1:1 across configs (separate exposures), so the
reported shift is a population-mean estimate, not a per-source delta.
"""
fig, ax = plt.subplots(figsize=(6, 5))
means = {}
for config, label in zip(configs, labels):
med = median_per_spectrum(load_spectra(DATA_DIR / f"{config}_first.csv"))
ax.scatter(med["cent_y"], med["cent_z"], s=5, alpha=0.5, label=label)
means[label] = (med["cent_y"].mean(), med["cent_z"].mean())
ax.set_xlabel("cent_y [mm]")
ax.set_ylabel("cent_z [mm]")
ax.legend()
ax.set_title("GWA angle comparison")
plt.show()
ref_label = labels[0]
ref_y, ref_z = means[ref_label]
for label in labels[1:]:
y, z = means[label]
print(f"{label} vs {ref_label}: mean shift dy={y - ref_y:+.3f} mm, "
f"dz={z - ref_z:+.3f} mm")
compare_gwa(["rgs000_0", "rgs000_m4", "rgs000_p4"], ["nominal", "-4 deg", "+4 deg"])
-4 deg vs nominal: mean shift dy=+4.028 mm, dz=+8.410 mm +4 deg vs nominal: mean shift dy=+1.744 mm, dz=+9.459 mm
def compare_orders(config):
"""Overlay 1st- and 0th-order centroids for one config.
1st- and 0th-order rows for the same config share spectra_id (same
source exposure), so per-spectrum separation can be computed directly.
"""
first = median_per_spectrum(load_spectra(DATA_DIR / f"{config}_first.csv"))
zeroth = zeroth_order_centers(DATA_DIR / f"{config}_zeroth.csv")
merged = first.merge(zeroth, on="spectra_id", suffixes=("_1st", "_0th"))
fig, ax = plt.subplots(figsize=(6, 5))
ax.scatter(first["cent_y"], first["cent_z"], s=5, alpha=0.4, label="1st order")
ax.scatter(zeroth["cent_y"], zeroth["cent_z"], s=20, color="black", label="0th order")
ax.set_xlabel("cent_y [mm]")
ax.set_ylabel("cent_z [mm]")
ax.legend()
ax.set_title(f"Order comparison -- {config}")
plt.show()
sep = np.hypot(merged["cent_y_1st"] - merged["cent_y_0th"],
merged["cent_z_1st"] - merged["cent_z_0th"])
print(f"{config}: mean 1st/0th-order separation = {sep.mean():.3f} mm "
f"(n={len(merged)} matched lines)")
compare_orders("rgs000_0")
rgs000_0: mean 1st/0th-order separation = 12.755 mm (n=5328 matched lines)
def plot_spectra(df, title=""):
"""Final view: dispersed centroids in R_MOS colored by wavelength."""
plot_centroids(df, color_by="wavelength", title=title)
plot_spectra(first_datasets["rgs000_0"], title="rgs000_0 -- spectra in R_MOS")
5. Recap¶
- Optical chain: Telescope Simulator → Collimator → Grism → Camera → Detector.
- Input frame:
(theta, phi)(telescope-simulator angles, degrees). Output frame:(cent_y, cent_z)(detector mosaic frame R_MOS, mm). - Model target:
(theta, phi, wavelength) -> (cent_y, cent_z). - Quality filter:
sig_y < 0.05 mmandsig_z < 0.05 mm. - 7 configurations (BGS000/RGS000/RGS180 x GWA nominal/-4/+4), each with a 1st-order (Fabry-Pérot line) and 0th-order (two-blob) dataset.
Data loading/cleaning (load_spectra, median_per_spectrum,
zeroth_order_centers) is promoted to dispcraft/measurement.py, tested in
tests/test_measurement.py — same split as Stage 1: the physically
meaningful, deterministic logic moves to the library, while exploratory
visualization (plot_field, plot_centroids, compare_gwa,
compare_orders, plot_spectra) stays inline in the notebook.
Calibrating the physical dispersion model against this data is the next stage -- not started here.