The PSF Dataset

Ground-Test Calibration Data

1. Why Ground Calibration?

Once in space, the instrument cannot be modified.
A design or manufacturing error means wrong science or no science at all.
In-orbit calibration is expensive.
Euclid has a limited mission lifetime (~6 years of survey).
Every hour spent on calibration is not spent observing the sky.
Solution: test on the ground before launch.
  • Verify that the instrument works as designed
  • Check that specifications are met
  • Collect data to build and validate the calibration model

1. The NISP Ground-Test Campaign

Several ground calibration campaigns were conducted. The one with the most data for the dispersion model is the NISP flight model ground test at LAM (Laboratoire d'Astrophysique de Marseille) inside the ERIOS cryogenic vacuum chamber.
NISP is tested as a standalone instrument.
A telescope simulator (off-axis elliptical mirror + pin-hole) replaces the Euclid telescope to feed a collimated point source into NISP.
Consequence for the model
The telescope simulator provides a point source at a known angular position.
The full optical chain becomes:
Telescope Simulator → Collimator → Grism → Camera → Detector

The Ground Test Pipeline

Telescope Simulator  emits a point source at spherical angles (θ, φ)

Collimator  injects a collimated beam into NISP

Grism  disperses the beam — deflection depends on wavelength λ

Camera  focuses the dispersed beam onto the detector

Detector (R_MOS)  records the PSF centroid at position (cent_y, cent_z) [mm]

Input Coordinates: the Telescope Simulator Frame

Telescope simulator
The source position in the telescope field of view is given by two spherical angles:
  • theta — azimuthal angle [degrees]
  • phi — elevation angle [degrees]
Spherical Coordinates

Input: angular source position in the telescope field of view
(θ, φ) measured from the optical axis

Output Coordinates: the Detector Mosaic Frame (R_MOS)

R_MOS detector mosaic y z PSF centroids (cent_y, cent_z) [mm]

R_MOS: detector mosaic frame
(cent_y, cent_z) [mm]

The dispersion model maps (θ, φ, λ) → (y_mosaic, z_mosaic)

2. The Datasets

The calibration data was collected for 7 optical configurations, combining grism choice and grism wheel angle (GWA).

Grism & GWA angle
blue grism BGS000 at 0° — bgs000
red grism RGS000 at 0° — rgs000
red grism RGS180 at 180° — rgs180
nominal GWA position — _0  
GWA tilted −4° — _m4
GWA tilted +4° — _p4
Two types of spectra per configuration

1st order: — many emission lines at known λ → files *_first.csv

0th order: — two compact blobs at the bandpass edges → files *_zeroth.csv
7 configurations × 2 spectrum types = 14 CSV files

1st-Order Spectra (Fabry-Pérot)

The light source is a Fabry-Pérot etalon: it emits a set of narrow emission lines at known wavelengths.
For each source position, we identify each line and record its PSF centroid.
BGS (blue grism):
39 lines from 901 nm to 1353 nm
RGS (red grisms):
33 lines from 1211 nm to 1866 nm
FileGrismGWARows
bgs000_0_first.csvBGS 0°nominal16 467
rgs000_0_first.csvRGS 0°nominal16 501
rgs000_m4_first.csvRGS 0°−4°14 574
rgs000_p4_first.csvRGS 0°+4°13 920
rgs180_0_first.csvRGS 180°nominal19 127
rgs180_m4_first.csvRGS 180°−4°13 988
rgs180_p4_first.csvRGS 180°+4°14 082

0th-Order Spectra (Two-Blob Model)

Each point source also produces a 0th order but it can not be resolved. We see only two compact blobs at the transmission bandpass edges.
Hypothesis:
  • rank = 1 → assigned wavelength 1250 nm (blue edge)
  • rank = 2 → assigned wavelength 1850 nm (red edge)
FileGrismGWARows
bgs000_0_zeroth.csvBGS 0°nominal1 426
rgs000_0_zeroth.csvRGS 0°nominal1 654
rgs000_m4_zeroth.csvRGS 0°−4°1 438
rgs000_p4_zeroth.csvRGS 0°+4°1 398
rgs180_0_zeroth.csvRGS 180°nominal1 914
rgs180_m4_zeroth.csvRGS 180°−4°1 394
rgs180_p4_zeroth.csvRGS 180°+4°1 392

3. Data Format — 1st-Order Files (*_first.csv)

Each row is one identified emission line from one source observation.

ColumnFrameUnitDescription
psf_idUnique PSF identifier (traceability)
spectra_idGroups all lines of the same source observation
y_nispNISP focal planemmSource y-position from ATS encoder
z_nispNISP focal planemmSource z-position from ATS encoder
thetaSimulator framedegreesAzimuthal spherical angle: arctan(y_nisp / f)
phiSimulator framedegreesElevation spherical angle: arctan(z_nisp / f)
wavelengthnmFabry-Pérot etalon line wavelength (vacuum)
cent_yR_MOSmmPSF centroid y on the detector mosaic
cent_zR_MOSmmPSF centroid z on the detector mosaic
sig_yR_MOSmmCentroid uncertainty along y
sig_zR_MOSmmCentroid uncertainty along z

Data Format — 0th-Order Files (*_zeroth.csv)

Each row is one blob (rank 1 or 2) from one source observation.

ColumnFrameUnitDescription
psf_idUnique PSF identifier
spectra_idGroups both blobs of the same source
rank1 = blue edge blob, 2 = red edge blob
wavelengthnmAssigned: 1250 nm (rank 1) or 1850 nm (rank 2)
y_nispNISP focal planemmSource y-position from ATS encoder
z_nispNISP focal planemmSource z-position from ATS encoder
thetaSimulator framedegreesAzimuthal spherical angle
phiSimulator framedegreesElevation spherical angle
cent_yR_MOSmmBlob centroid y on the detector mosaic
cent_zR_MOSmmBlob centroid z on the detector mosaic
sig_yR_MOSmmCentroid uncertainty along y
sig_zR_MOSmmCentroid uncertainty along z

A Quick Word on pandas

pandas is the standard Python library for tabular data.
A DataFrame is a table: rows are observations, columns are variables.
It reads CSV files in one line and makes filtering, grouping, and plotting straightforward.
Core concepts used here
  • pd.read_csv(path) — load a CSV file
  • df["col"] — access a column
  • df[mask] — filter rows by a boolean condition
  • df.groupby(...).median() — aggregate
  • df.describe() — summary statistics
Install
pip install pandas

Documentation
pandas.pydata.org/docs

10-minute intro
10 minutes to pandas

Reading the Data

step 1 — load a 1st-order dataset
import pandas as pd

df = pd.read_csv("data/rgs000_0_first.csv")
print(df.shape)     # (16501, 11)
print(df.columns.tolist())
# ['psf_id', 'spectra_id', 'y_nisp', 'z_nisp', 'wavelength',
#  'cent_y', 'cent_z', 'sig_y', 'sig_z', 'theta', 'phi']
print(df.head())
step 2 — explore
print(df.describe())
# Number of unique source positions
positions = df[["theta", "phi"]].drop_duplicates()
print(f"{len(positions)} unique positions")
# Wavelength range
print(f"lambda: {df['wavelength'].min():.0f} - {df['wavelength'].max():.0f} nm")
Each row is one emission line. Multiple lines share the same spectra_id.

Working with the datasets

group by spectrum (1st order)
# All emission lines from one source observation
spectrum = df[df["spectra_id"] == df["spectra_id"].iloc[0]]
print(spectrum[["wavelength", "cent_y", "cent_z"]])
filter by PSF quality
sig_max = 0.05   # mm
clean = df[(df["sig_y"] < sig_max) & (df["sig_z"] < sig_max)]
print(f"{len(clean)} / {len(df)} rows kept")
load a 0th-order dataset
dz = pd.read_csv("data/rgs000_0_zeroth.csv")
print(dz.columns.tolist())
# ['psf_id','spectra_id','rank','wavelength','y_nisp','z_nisp',
#  'theta','phi','cent_y','cent_z','sig_y','sig_z']

# The two blobs of each source
blob1 = dz[dz["rank"] == 1]   # blue edge (1250 nm)
blob2 = dz[dz["rank"] == 2]   # red edge  (1850 nm)

4. Exercise — Load and Explore

Write a function that loads one 1st-order dataset, filters outliers, and returns a clean DataFrame.

import pandas as pd
import numpy as np

def load_spectra(filepath: str, sig_max: float = 0.05) -> pd.DataFrame:
    """Load and clean a 1st-order spectra dataset.

    Parameters
    ----------
    filepath : str
        Path to a *_first.csv file (e.g. 'data/rgs000_0_first.csv').
    sig_max : float
        Maximum allowed centroid uncertainty (mm).
        Rows with sig_y or sig_z above this are removed.

    Returns
    -------
    pd.DataFrame
        Cleaned DataFrame with columns:
        spectra_id, theta, phi, wavelength, cent_y, cent_z, sig_y, sig_z.

    Hints
    -----
    - Use pd.read_csv to load the file.
    - Build a boolean mask: (sig_y < sig_max) & (sig_z < sig_max).
    - Return df[mask].reset_index(drop=True).
    """
    # TODO: implement this function
    ...

Exercise (continued) — Median per Spectrum

Compute the median centroid per (spectra_id, wavelength) to reduce noise from repeated exposures.

def median_per_spectrum(df: pd.DataFrame) -> pd.DataFrame:
    """Compute the median PSF centroid per (spectra_id, wavelength).

    Parameters
    ----------
    df : pd.DataFrame
        Cleaned DataFrame returned by load_spectra().

    Returns
    -------
    pd.DataFrame
        One row per (spectra_id, wavelength), with columns:
        spectra_id, theta, phi, wavelength, cent_y, cent_z.

    Hints
    -----
    - Use df.groupby(["spectra_id", "wavelength"]).median().
    - Reset the index after groupby.
    - theta and phi are the same for all rows with the same spectra_id.
    """
    # TODO: implement this function
    ...
After this step, each spectra_id has one row per emission line.

Exercise (continued) — 0th-Order Centers

Load a 0th-order dataset and compute the 0th-order center for each source.

def zeroth_order_centers(filepath: str) -> pd.DataFrame:
    """Compute the 0th-order center from the two-blob model.

    Parameters
    ----------
    filepath : str
        Path to a *_zeroth.csv file.

    Returns
    -------
    pd.DataFrame
        One row per source with columns:
        spectra_id, theta, phi, cent_y, cent_z
        where cent_y and cent_z are the mean of the two blob centroids.

    Hints
    -----
    - Load with pd.read_csv.
    - Group by spectra_id and compute mean of cent_y, cent_z.
    - The mean over rank 1 and rank 2 gives the 0th-order center.
    - theta and phi are the same for both ranks: keep with .first().
    """
    # TODO: implement this function
    ...

5. Data Visualization

Before modeling, always look at the data.
Visualization reveals structure, outliers, and the expected behavior.

Key questions to answer visually:

  • Where are the sources in the field of view (θ, φ) in the telescope simulator frame?
  • Where do their PSFs land in R_MOS (cent_y, cent_z)?
  • Is the mapping smooth and regular?
  • How does the GWA angle shift the spectra on the detector?
  • What do the 1st-order and 0th-order positions look like relative to each other?
The final goal: a scatter plot of PSF centroids in R_MOS,
colored by wavelength — showing spectra as colored lines across the detector.

Exercise 1 — Plot Source Positions

Plot the unique source positions in the telescope simulator frame.

import matplotlib.pyplot as plt

def plot_field(df: pd.DataFrame, title: str = "") -> None:
    """Plot unique source positions in the simulator frame.

    Parameters
    ----------
    df : pd.DataFrame
        DataFrame with theta and phi columns.
    title : str
        Plot title.

    Hints
    -----
    - Extract unique positions: df[["theta","phi"]].drop_duplicates()
    - Use plt.scatter(pos["theta"], pos["phi"], s=10).
    - Label the axes: "theta [degrees]", "phi [degrees]".
    - Add plt.grid(True) and plt.title(title).
    - What is the angular range of the field of view?
    """
    # TODO: implement this function
    ...
Expected: a grid of points covering roughly ±0.7° in both axes.

Exercise 2 — Map to Detector

Plot the PSF centroids in R_MOS, colored by wavelength or source angle.

def plot_centroids(df: pd.DataFrame, color_by: str = "wavelength") -> None:
    """Plot PSF centroid positions in R_MOS.

    Parameters
    ----------
    df : pd.DataFrame
        DataFrame with cent_y, cent_z columns.
    color_by : str
        Column name for color coding.
        Try: "wavelength", "theta", "phi".

    Hints
    -----
    - plt.scatter(df["cent_y"], df["cent_z"],
                  c=df[color_by], cmap="rainbow", s=4, theta=0.5)
    - Add plt.colorbar(label=color_by).
    - Label the axes: "cent_y [mm]", "cent_z [mm]".
    - When colored by wavelength, each source traces a line = its spectrum.
    """
    # TODO: implement this function
    ...
Coloring by wavelength reveals individual spectra as rainbow-colored stripes across the detector.

Exercise 3 — Compare GWA Configurations

Overlay multiple configurations to see the effect of GWA angle on spectrum positions.

def compare_gwa(configs: list[str], labels: list[str]) -> None:
    """Overlay centroid maps from different GWA angle configurations.

    Parameters
    ----------
    configs : list[str]
        List of *_first.csv paths.
        e.g. ['data/rgs000_0_first.csv',
               'data/rgs000_m4_first.csv',
               'data/rgs000_p4_first.csv']
    labels : list[str]
        Legend labels for each dataset.

    Hints
    -----
    - For each file: load_spectra -> median_per_spectrum.
    - Plot cent_y vs cent_z for each on the same axes.
    - Use distinct colors and add plt.legend().
    - The GWA angle shifts all spectra along the dispersion direction.
    - What is the shift in mm between _0, _m4 and _p4?
    """
    # TODO: implement this function
    ...

Exercise 4 — 1st Order vs 0th Order

Overlay 1st-order spectra and 0th-order blob positions for the same configuration.

def compare_orders(config: str) -> None:
    """Compare 1st-order and 0th-order positions on the detector.

    Parameters
    ----------
    config : str
        Configuration name, e.g. 'rgs000_0'.

    Hints
    -----
    - Load data/<config>_first.csv  -> 1st-order centroids.
    - Load data/<config>_zeroth.csv -> 0th-order blob positions.
    - Plot both on the same axes with distinct colors/markers.
    - The 0th-order blobs should appear as two compact clusters,
      offset from the dispersed 1st-order spectra (~800 px away).
    - Use plt.legend(['1st order', '0th blob (rank1)', '0th blob (rank2)']).
    """
    # TODO: implement this function
    ...
The 0th order sits ~800 pixels away from the 1st order along the dispersion axis.

Final Goal — Spectra in R_MOS

Plot 1st-order PSF centroids in R_MOS colored by wavelength.

R_MOS detector plane src 1 src 2 src 3 λ min λ max

cent_y vs cent_z colored by wavelength. Each row of dots = one source spectrum (Fabry-Pérot lines).

def plot_spectra(df: pd.DataFrame) -> None:
    """Plot 1st-order PSF centroids in R_MOS colored by wavelength.

    Hints
    -----
    - plt.scatter(df["cent_y"], df["cent_z"],
                  c=df["wavelength"], cmap="rainbow", s=6, theta=0.5)
    - Add colorbar labeled "wavelength [nm]".
    - Try coloring by "theta" or "phi" to see the field structure.
    """
    # TODO: implement this function
    ...

Summary

ConceptKey point
Ground test setupNISP only — telescope simulator provides angular source positions
Optical chainTelescope Simulator → Collimator → Grism → Camera → Detector
Input frameTelescope simulator spherical angles (θ, φ) [degrees]
Output frame (R_MOS)Detector mosaic centroids (cent_y, cent_z) [mm]
1st-order datasets7 files *_first.csv — Fabry-Pérot lines, ~14–19k rows each
0th-order datasets7 files *_zeroth.csv — 2 blobs per source (rank 1 & 2), ~1400–1900 rows each
Configurationsbgs000_0, rgs000_{0,m4,p4}, rgs180_{0,m4,p4}
Model target(θ, φ, λ) → (cent_y, cent_z)
Quality filterRemove rows with sig_y or sig_z above threshold (~0.05 mm)
Next stepImplement and calibrate the physical dispersion model (section 3)