Instrument Primer — NIRISS GR150R / F150W#

This notebook explains how the NIRISS GR150R grism disperses light onto the detector. It covers the active diffraction orders, their sensitivity, trace geometry, dispersion law, and which source positions contribute to a given image stamp. No forward-operator build is required — all computations use config.get_trace() directly.

Dependency note: Trace geometry is provided by the grismagic package via spectrex.InstrumentConfig. The dispersion coefficients come from the GR150R.F150W.220725.conf calibration file.

Setup#

from pathlib import Path

import matplotlib.pyplot as plt
import numpy as np

import spectrex
from spectrex import EigenspectraBasis, InstrumentConfig
from spectrex.instrument import _ORDER_LETTER_TO_INT

# ── Paths ────────────────────────────────────────────────────────────────────
TESTDATA = Path('../testdata')
IMAGE_SHAPE = (500, 20)   # detector stamp used throughout
SOURCE_I, SOURCE_J = 250, 10  # reference source position

# ── Colour palette (consistent across all figures) ───────────────────────────
ORDER_COLOURS = {'A': '#4da6ff', 'B': '#ff9f40', 'C': '#c08cff'}
ORDER_LABELS  = {'A': 'Order A (1st, dispersed)',
                 'B': 'Order B (0th, undispersed)',
                 'C': 'Order C (2nd, dispersed)'}

# ── Load instrument config ────────────────────────────────────────────────────
config = InstrumentConfig.from_files(
    conf_path=TESTDATA / 'Config Files' / 'GR150R.F150W.220725.conf',
    wavelengthrange_path=TESTDATA / 'jwst_niriss_wavelengthrange_0002.asdf',
    sensitivity_dir=TESTDATA / 'SenseConfig' / 'wfss-grism-configuration',
    filter_name='F150W',
    n_wavelengths=150,
)

basis = EigenspectraBasis.from_csv(
    TESTDATA / 'eigenspectra_kurucz.csv',
    config.wavelengths,
)

# Active orders: those with calibrated sensitivity
active_orders = [o for o in config.orders if o in config.sensitivity]

# ── Summary table ────────────────────────────────────────────────────────────
print(f'spectrex {spectrex.__version__}')
print(f'Grism : {config.grism}')
print(f'Filter: {config.filter_name}')
print(f'λ range: {config.wavelengths[0]:.0f}{config.wavelengths[-1]:.0f} Å  '
      f'({len(config.wavelengths)} samples)')
print()
print(f'{"Order":<8}{"Integer":<10}{"Physical meaning":<28}{"Has sensitivity"}')
print('-' * 60)
meaning = {0: '0th (undispersed)', 1: '1st (dispersed)', 2: '2nd (dispersed)'}
for o in config.orders:
    idx = _ORDER_LETTER_TO_INT.get(o, '?')
    has_sens = 'yes' if o in config.sensitivity else 'no'
    print(f'{o:<8}{str(idx):<10}{meaning.get(idx, "unknown"):<28}{has_sens}')
spectrex 0.2.1.dev18+gc6f5edaa6.d20260506
Grism : GR150R
Filter: F150W
λ range: 12900 – 17100 Å  (150 samples)

Order   Integer   Physical meaning            Has sensitivity
------------------------------------------------------------
A       1         1st (dispersed)             yes
B       0         0th (undispersed)           yes
C       2         2nd (dispersed)             yes
D       ?         unknown                     no
E       ?         unknown                     no

Diffraction orders & sensitivity#

The GR150R grism produces several diffraction orders simultaneously. Three carry calibrated flux sensitivity: order A (1st order, dispersed spectrum), order B (0th order — the undispersed image of the source), and order C (2nd order, further dispersed). Orders D and E exist geometrically but have no measured sensitivity and are excluded from the forward model.

The plot below shows how much flux each order captures as a function of wavelength. The total flux fraction (area under each curve) is roughly equal across A, B, and C — meaning order B carries ~12 % of the total source flux, concentrated into just 4–5 detector pixels instead of the ~91 pixels used by order A. This concentration is the physical origin of the isolated bright spots visible in multi-source dispersed images.

fig, ax = plt.subplots(figsize=(7, 3.5))

for order in active_orders:
    sens = config.sensitivity[order]
    total_frac = sens.sum() / sum(config.sensitivity[o].sum() for o in active_orders)
    ax.plot(
        config.wavelengths, sens,
        color=ORDER_COLOURS[order],
        label=f'{ORDER_LABELS[order]}  ({100*total_frac:.0f}% of total)',
        lw=1.8,
    )

ax.set_xlabel('Wavelength (Å)')
ax.set_ylabel('Normalised sensitivity')
ax.set_title('Per-order sensitivity — GR150R / F150W')
ax.legend(fontsize=9)
ax.set_xlim(config.wavelengths[0], config.wavelengths[-1])
fig.tight_layout()
plt.show()
../_images/14d23560ef60a6d1c6cce5bdad6c325c155fd06001fce6bd90f4c5b46e547955.png

Trace geometry#

For a source at detector position \((i, j)\), each diffraction order maps the source light onto a different region of the detector. For GR150R the dispersion direction is along detector rows (the long axis). The column displacement is small (sub-pixel to ~2 px) and nearly wavelength-independent.

The figure below shows a 500 × 20 pixel stamp. The white dot marks the reference source at row 250, column 10. Coloured boxes show where each order’s trace lands; the table summarises the offsets.

Why is Order B visible in dense-field images? Order B has a row offset of approximately −216 px relative to its source. For the reference source at row 250, Order B lands at rows 34–37 — still within the 500-row stamp. More generally, any source at row > 216 will deposit an undispersed spot within the image. In a dense field with ~900 sources spread over all 500 rows, roughly 55 % of sources (rows 217–499) contribute in-frame Order B spots. Because each spot is ~18× brighter per pixel than the dispersed Order A streak from the same source, these undispersed spots dominate the per-pixel brightness and appear as isolated bright pixels in the total image.

n_rows, n_cols = IMAGE_SHAPE

# Compute trace footprints for the reference source
footprints = {}
for order in active_orders:
    x_trace, y_trace = config.get_trace(float(SOURCE_I), float(SOURCE_J), order=order)
    x_pix = np.round(x_trace).astype(int)
    y_pix = np.round(y_trace).astype(int)
    mask = (x_pix >= 0) & (x_pix < n_rows) & (y_pix >= 0) & (y_pix < n_cols)
    footprints[order] = dict(
        x_trace=x_trace, y_trace=y_trace,
        x_pix=x_pix, y_pix=y_pix, mask=mask,
        row_offset_min=float(x_trace[0]  - SOURCE_I),
        row_offset_max=float(x_trace[-1] - SOURCE_I),
        col_offset=float(np.mean(y_trace) - SOURCE_J),
        n_valid=int(mask.sum()),
        n_unique=len(set(zip(x_pix[mask], y_pix[mask]))) if mask.any() else 0,
    )

# ── Figure: annotated stamp ───────────────────────────────────────────────────
fig, ax = plt.subplots(figsize=(4, 8))
ax.set_facecolor('#0d0d1a')
ax.set_xlim(-0.5, n_cols - 0.5)
ax.set_ylim(-0.5, n_rows - 0.5)
ax.set_xlabel('Column (detector)')
ax.set_ylabel('Row (detector)')
ax.set_title(f'Trace footprints — source at ({SOURCE_I}, {SOURCE_J})')

# Source position
ax.scatter([SOURCE_J], [SOURCE_I], color='white', s=60, zorder=5, label='Source')

# Order A and B: draw rectangles over actual pixel footprint
for order in ['A', 'B']:
    fp = footprints[order]
    if fp['n_valid'] == 0:
        continue
    col = ORDER_COLOURS[order]
    xp = fp['x_pix'][fp['mask']]
    yp = fp['y_pix'][fp['mask']]
    row_lo, row_hi = xp.min() - 0.5, xp.max() + 0.5
    col_lo, col_hi = yp.min() - 0.5, yp.max() + 0.5
    from matplotlib.patches import Rectangle
    rect = Rectangle(
        (col_lo, row_lo), col_hi - col_lo, row_hi - row_lo,
        linewidth=1.5, edgecolor=col, facecolor=col, alpha=0.25,
    )
    ax.add_patch(rect)
    ax.annotate(
        ORDER_LABELS[order],
        xy=((col_lo + col_hi) / 2, (row_lo + row_hi) / 2),
        fontsize=7, color=col, ha='center', va='center',
    )

# Order C: arrow pointing off top
ax.annotate(
    '', xy=(SOURCE_J, n_rows - 1), xytext=(SOURCE_J, SOURCE_I),
    arrowprops=dict(arrowstyle='->', color=ORDER_COLOURS['C'], lw=1.5),
)
ax.text(
    SOURCE_J + 1, n_rows - 10,
    'Order C (2nd)\nexits frame',
    color=ORDER_COLOURS['C'], fontsize=7,
)

ax.legend(loc='lower right', fontsize=8)
fig.tight_layout()
plt.show()

# ── Summary table ────────────────────────────────────────────────────────────
print(f'Source position: row={SOURCE_I}, col={SOURCE_J}')
print(f'{"Order":<8}{"Integer":<10}{"Row offset range":<24}{"Col offset":<14}{"Unique pixels"}')
print('-' * 70)
for order in active_orders:
    fp = footprints[order]
    idx = _ORDER_LETTER_TO_INT[order]
    if fp['n_valid'] > 0:
        rng_str = f"{fp['row_offset_min']:+.0f} to {fp['row_offset_max']:+.0f}"
    else:
        rng_str = 'out of frame'
    print(f"{order:<8}{str(idx):<10}{rng_str:<24}{fp['col_offset']:+.2f} px{'':<6}{fp['n_unique']}")
../_images/19d228a592b840733f9ce77f76835040e727779967b114226c454202c40d8b75.png
Source position: row=250, col=10
Order   Integer   Row offset range        Col offset    Unique pixels
----------------------------------------------------------------------
A       1         +57 to +147             -1.15 px      91
B       0         -216 to -213            -2.28 px      4
C       2         out of frame            -0.16 px      0

Dispersion solution#

The row offset is not constant — it depends on wavelength. This wavelength-to-row-offset mapping is called the dispersion solution. For order A the offset increases from ~+57 px (blue, 12 900 Å) to ~+147 px (red, 17 100 Å), spreading the spectrum across ~90 detector rows. Order C disperses even further (+324 to +501 rows) and exits the 500-row stamp for all but the bluest sources. Order B’s near-zero spread (~3.5 px total) confirms it is the undispersed (0th) order: all wavelengths land at nearly the same detector row, creating the compact bright spot seen in §4.

fig, ax = plt.subplots(figsize=(7, 4))

for order in active_orders:
    x_trace, _ = config.get_trace(float(SOURCE_I), float(SOURCE_J), order=order)
    row_offsets = x_trace - SOURCE_I
    ax.plot(
        config.wavelengths, row_offsets,
        color=ORDER_COLOURS[order],
        label=ORDER_LABELS[order],
        lw=1.8,
    )

n_rows = IMAGE_SHAPE[0]
# Frame boundaries for the reference source
ax.axhline(0, color='white', lw=0.6, ls='--', alpha=0.4, label='Source row')
ax.axhline(-SOURCE_I, color='grey', lw=0.8, ls=':', label='Frame bottom (row 0)')
ax.axhline(n_rows - 1 - SOURCE_I, color='grey', lw=0.8, ls='-.',
           label=f'Frame top (row {n_rows-1})')
ax.fill_between(
    config.wavelengths,
    -SOURCE_I, n_rows - 1 - SOURCE_I,
    alpha=0.05, color='white',
)

ax.set_xlabel('Wavelength (Å)')
ax.set_ylabel('Row offset from source (px)')
ax.set_title('Dispersion solution — row offset vs λ')
ax.legend(fontsize=8)
ax.set_xlim(config.wavelengths[0], config.wavelengths[-1])
fig.tight_layout()
plt.show()
../_images/a9af957c2302c22480dfe3e4022dad3cb581d58ec19d25fea629f3fc5966cf6a.png

Single-source dispersed image#

Placing a single point source at \((250, 10)\) with a stellar spectrum template illustrates what each order contributes to the detector. The total image (leftmost panel) is the sum of all order contributions.

Important: each panel uses an independent colour scale. If a shared scale were used, Order A’s dispersed streak (~91 pixels at low flux per pixel) would be invisible next to Order B’s concentrated spot (~4 pixels at 18× higher flux per pixel). Per-panel scaling lets you see the shape of each order’s contribution. The quantitative brightness comparison is in the printed summary below the figure.

The computation builds per-order images directly from get_trace() without constructing the full forward operator matrix.

n_rows, n_cols = IMAGE_SHAPE

# Source spectrum: negate first PCA component → all-positive stellar template
a_k = np.zeros(basis.n_components)
a_k[0] = -1.0
assert np.all(basis.reconstruct(a_k) >= 0), 'Spectrum must be non-negative'

# Build per-order dispersed images
order_images = {}
for order in active_orders:
    img = np.zeros((n_rows, n_cols))
    sens = config.sensitivity[order]
    x_trace, y_trace = config.get_trace(
        float(SOURCE_I), float(SOURCE_J), order=order
    )
    x_pix = np.round(x_trace).astype(int)
    y_pix = np.round(y_trace).astype(int)
    mask = (
        (x_pix >= 0) & (x_pix < n_rows)
        & (y_pix >= 0) & (y_pix < n_cols)
    )
    if mask.any():
        # flux_lam[i] = sensitivity-weighted reconstructed flux at wavelength mask[i]
        flux_lam = (basis.components[mask, :] @ a_k) * sens[mask]  # (n_valid,)
        np.add.at(img, (x_pix[mask], y_pix[mask]), flux_lam)
    order_images[order] = img

total_image = sum(order_images.values())

# ── Figure — each panel has its own colour scale ────────────────────────────
# Shared vmax would hide Order A (18× dimmer per pixel than Order B).
# Per-panel scaling shows the spatial structure of each order independently.
panels = [('Total', total_image)] + [(ORDER_LABELS[o], order_images[o]) for o in active_orders]
fig, axes = plt.subplots(1, 4, figsize=(14, 5))
kw = dict(origin='lower', aspect='auto', interpolation='nearest', cmap='inferno', vmin=0)

for ax, (title, img) in zip(axes, panels):
    im = ax.imshow(img, **kw)  # vmax auto-set per panel
    ax.set_title(title, fontsize=9)
    ax.set_xlabel('Column')
    ax.set_ylabel('Row')
    fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04, label='flux')

fig.suptitle(
    f'Single source at ({SOURCE_I}, {SOURCE_J}) — per-order breakdown',
    y=1.01
)
fig.tight_layout()
plt.show()

# Per-pixel brightness ratio: order B vs order A
n_unique_A = len(set(zip(
    *np.where(order_images['A'] > 0)
)))
n_unique_B = len(set(zip(
    *np.where(order_images['B'] > 0)
)))
flux_A = order_images['A'][order_images['A'] > 0].sum()
flux_B = order_images['B'][order_images['B'] > 0].sum()
ratio = (flux_B / n_unique_B) / (flux_A / n_unique_A)
print(f'Order A: total flux = {flux_A:.4f} over {n_unique_A} pixels')
print(f'Order B: total flux = {flux_B:.4f} over {n_unique_B} pixels')
print(f'Order B is {ratio:.1f}× brighter per pixel than order A')
../_images/2f3c028d3e1c09dd0595e259de8794bba195f8d5b434677307540138df14e785.png
Order A: total flux = 0.0005 over 91 pixels
Order B: total flux = 0.0004 over 4 pixels
Order B is 18.3× brighter per pixel than order A

10-source aggregate#

Extending to 10 sources spread across the stamp shows the same effect at field scale. Four sources sit at rows ≤ 216, so their Order B spot falls outside the frame and only Order A contributes to the image. The remaining six sources are at rows > 216; their Order B spots land at row 216, well away from the corresponding Order A streak.

White crosses mark source positions. In the Order B panel the bright spots are visually disconnected from the Order A streaks in the centre of the stamp — exactly the isolated bright pixels seen in real multi-source NIRISS exposures.

# 10 sources: 4 with row ≤ 216 (Order B out of frame),
#             6 with row > 216  (Order B in-frame at row − 216)
MULTI_SOURCES = [
    ( 50,  3),
    (100, 16),
    (160,  8),
    (210, 12),
    (225,  5),
    (255, 10),
    (290,  2),
    (310, 14),
    (330,  7),
    (345, 18),
]

# Same stellar template used in the single-source cell above
a_k_multi = np.zeros(basis.n_components)
a_k_multi[0] = -1.0

# Accumulate per-order dispersed images over all sources
multi_order_images = {o: np.zeros((n_rows, n_cols)) for o in active_orders}

for src_i, src_j in MULTI_SOURCES:
    for order in active_orders:
        img  = multi_order_images[order]
        sens = config.sensitivity[order]
        x_trace, y_trace = config.get_trace(float(src_i), float(src_j), order=order)
        x_pix = np.round(x_trace).astype(int)
        y_pix = np.round(y_trace).astype(int)
        mask = (
            (x_pix >= 0) & (x_pix < n_rows)
            & (y_pix >= 0) & (y_pix < n_cols)
        )
        if mask.any():
            flux_lam = (basis.components[mask, :] @ a_k_multi) * sens[mask]
            np.add.at(img, (x_pix[mask], y_pix[mask]), flux_lam)

multi_total = sum(multi_order_images.values())

src_rows = [s[0] for s in MULTI_SOURCES]
src_cols = [s[1] for s in MULTI_SOURCES]

# ── Figure ────────────────────────────────────────────────────────────────────
panels_multi = (
    [('Total', multi_total)]
    + [(ORDER_LABELS[o], multi_order_images[o]) for o in active_orders]
)
fig, axes = plt.subplots(1, 4, figsize=(14, 5), sharex=True, sharey=True)
kw = dict(origin='lower', aspect='auto', interpolation='nearest', cmap='inferno', vmin=0)

for ax, (title, img) in zip(axes, panels_multi):
    im = ax.imshow(np.ma.masked_less_equal(img, 0), **kw)
    ax.scatter(src_cols, src_rows, marker='+', s=60, c='r', linewidths=1.0)
    ax.set_title(title, fontsize=9)
    ax.set_xlabel('Column')
    fig.colorbar(im, ax=ax, fraction=0.046, label='flux', orientation='horizontal')
axes[0].set_ylabel('Row')

fig.suptitle(
    '10 sources — per-order aggregate  (+ = source position)',
    y=1.01,
)
fig.tight_layout()
plt.show()
../_images/d40a2165eab84be821fbb7f9f2694ed24de06f35c4dc1c4a4fceff7a98295603.png

Source coverage map#

Not every source position contributes fully to the dispersed image. Sources near the image edges have their traces clipped: some wavelengths land outside the stamp and are lost. The maps below show, for each order, the fraction of wavelengths whose trace falls within the 500 × 20 stamp.

Key features to notice:

  • Order A: sources at rows 0–350 are fully covered; rows 350–442 are partially covered (red-end wavelengths exit the top); rows 443–499 contribute nothing.

  • Order B: sources above row ~216 are fully covered; below row ~216 the (negative) offset pushes the trace off the bottom of the frame.

  • Order C: almost entirely out-of-frame for this 500-row stamp — only sources in the bottom ~175 rows contribute any wavelengths.

n_rows, n_cols = IMAGE_SHAPE
n_wav = len(config.wavelengths)

# Vectorised coverage computation: 1 get_trace call per (order, column)
# Row offsets are constant across source rows (verified: 0.00 px variation),
# so we sample from the centre row and broadcast over all rows.
coverage = {}
for order in active_orders:
    cov = np.zeros((n_rows, n_cols))
    for j in range(n_cols):
        x_trace, y_trace = config.get_trace(
            float(n_rows // 2), float(j), order=order
        )
        # Row offsets relative to the reference row
        row_offsets = np.round(x_trace - n_rows // 2).astype(int)  # (n_wav,)
        col_pix = np.round(y_trace).astype(int)                    # (n_wav,)
        col_mask = (col_pix >= 0) & (col_pix < n_cols)            # (n_wav,)
        # For each source row i: x_pix[λ] = i + row_offsets[λ]
        i_arr = np.arange(n_rows)[:, np.newaxis]                   # (n_rows, 1)
        x_pix_2d = i_arr + row_offsets[np.newaxis, :]              # (n_rows, n_wav)
        row_mask_2d = (x_pix_2d >= 0) & (x_pix_2d < n_rows)       # (n_rows, n_wav)
        combined = row_mask_2d & col_mask[np.newaxis, :]           # (n_rows, n_wav)
        cov[:, j] = combined.mean(axis=1)
    coverage[order] = cov

# ── Figure ───────────────────────────────────────────────────────────────────
fig, axes = plt.subplots(1, 3, figsize=(12, 5))
kw_cov = dict(origin='lower', aspect='auto', vmin=0, vmax=1, cmap='viridis')

for ax, order in zip(axes, active_orders):
    im = ax.imshow(coverage[order], **kw_cov)
    ax.set_title(ORDER_LABELS[order], fontsize=9)
    ax.set_xlabel('Source column')
    ax.set_ylabel('Source row')
    fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04,
                 label='fraction of λ in bounds')

fig.suptitle(
    'Source coverage — fraction of wavelengths with in-frame trace'
    f'\n(500 × 20 stamp, {n_wav} wavelength samples)',
    y=1.02,
)
fig.tight_layout()
plt.show()

# Summary counts
for order in active_orders:
    cov = coverage[order]
    full    = np.sum(cov == 1.0)
    partial = np.sum((cov > 0) & (cov < 1.0))
    zero    = np.sum(cov == 0.0)
    print(f'Order {order}: fully covered={full:5d}  '
          f'partial={partial:5d}  zero={zero:5d}')
../_images/34d9b1db05d2492e61f43d45a039c23d87464ee75753e0f943eeddbd034d0fec.png
Order A: fully covered= 6707  partial= 1710  zero= 1583
Order B: fully covered= 5112  partial=   54  zero= 4834
Order C: fully covered=    0  partial= 3520  zero= 6480