dispcraft.zeroth_dispersion

zeroth_dispersion

0th-order two-blob chromatic separation: shared physics + NN residual.

Ground-test data shows a real, field-position-dependent separation between the rank-1 (blue) and rank-2 (red) blobs (dispcraft.measurement.zeroth_order_separation) that the physical model's m=0 grating term alone doesn't predict (it's exactly zero at m=0). But Stage 5 Phase 3 Step 4 found the prism's own chromatic dispersion (Prism.deviation, unaffected by m) is NOT zero at m=0, and evaluating it with each config's already-frozen first-order parameters (dataclasses.replace(model, m_order=0), no new physics code needed) explains a consistent ~27-29% of the observed separation across all 6 RGS configs -- too consistent to be coincidence. Fitting a single, shared material_k (Stage 3 found it "not identifiable from centroid-position data alone" using only narrow-band first-order data; 0th order's two widely-separated wavelengths, 1206nm/1892nm, supply exactly the missing baseline) against all 6 configs' separations at once (fit_shared_material_k) cuts the remaining RMSE ~11-14x (0.15-0.16mm -> 0.011-0.016mm), landing at material_k≈0.0143 (vs. nominal 0.004), consistent to 3 significant figures across independent per-config fits.

So the "physical model" for 0th order is the same GroundTestModel/ parameters already fit against first-order data, evaluated at a different m_order -- not a separate model. predict_zeroth_order_physical is that shared-physics baseline; fit_zeroth_dispersion_model/ predict_zeroth_dispersion (Steps 1-3, unchanged) are the NN layer on top, now expected to model the residual after this baseline (much smaller than the raw separation they were originally fit to), not the full separation -- mirrors Stage 5 Phase 2's physics-then-NN-residual pattern, except here the "physics" step didn't exist until Step 4 identified it. bgs000_0 was never part of Stage 3/4's calibration set -- excluded from fit_shared_material_k, stays on the Step 3 standalone empirical model. Stage 5 Phase 4 later gave it its own frozen first-order fit (models/bgs000_0_fit.toml), but fusing it here still needs _RANK_WAVELENGTHS_NM generalized from RGS's specific passband to a per-grism one first (open item, Status_Report.md Section 10 item 9).

A real bug caught before trusting the result: the first version of fit_shared_material_k reused dispcraft.calibration.cost_joint directly on absolute-position data, matching the shared-physics prediction to the 0th order's actual absolute centroids. That's a different, much bigger, unrelated problem (Stage 2 explicitly deferred calibrating the physical model against 0th order's absolute position; this step doesn't reopen it) -- the fit landed on a physically meaningless material_k≈-0.017 with several-mm RMSE, an obvious red flag caught by comparing against the ~27-29%-explained finding above before trusting the number. Fixed by fitting the separation instead (predict_zeroth_order_separation_physical), which cancels out the absolute-registration mismatch and isolates the actual chromatic effect -- recovers material_k≈0.0143 as validated during planning.

Steps 1-3 (residual-structure diagnostic, NN architecture comparison, fitting the per-config NN) are in notebooks/5.3-Zeroth_Order_Dispersion.ipynb, Sections 1-11; Step 4 in Sections 12+. Winning NN architecture (Step 2): per-dataset (not pooled per grism instance -- dy's baseline sign depends on tilt direction, not just grism identity, so pooling would average away real signal) joint 2-output MLP on standardized (y0,z0), hidden_layer_sizes=(32,16), alpha=1e-3 -- the same architecture Phase 2 Step 2 picked independently for a different target.

aggregate_by_position

aggregate_by_position(df, value_cols=('dy', 'dz'), decimals=3)

Collapse spectra sharing (nearly) the same field position to one row.

Many spectra share the same (y_nisp, z_nisp) (repeated exposures at one field angle) -- Phase 3 Step 2 found averaging them first reduces noise (within-position std 2-2.3x smaller than between-position std) before training, same rationale as dispcraft.field_calibration .aggregate_by_position for the first-order residual, keyed on exact rounded position here rather than spectra_id (0th order has no repeated-wavelength rows to collapse first).

Source code in dispcraft/zeroth_dispersion.py
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
def aggregate_by_position(df, value_cols=("dy", "dz"), decimals=3):
    """Collapse spectra sharing (nearly) the same field position to one row.

    Many spectra share the same `(y_nisp, z_nisp)` (repeated exposures at
    one field angle) -- Phase 3 Step 2 found averaging them first reduces
    noise (within-position std 2-2.3x smaller than between-position std)
    before training, same rationale as `dispcraft.field_calibration
    .aggregate_by_position` for the first-order residual, keyed on exact
    rounded position here rather than `spectra_id` (0th order has no
    repeated-wavelength rows to collapse first).
    """
    d = df.copy()
    d["_y_key"] = d["y_nisp"].round(decimals)
    d["_z_key"] = d["z_nisp"].round(decimals)
    agg = {"y_nisp": "mean", "z_nisp": "mean", **{c: "mean" for c in value_cols}}
    return d.groupby(["_y_key", "_z_key"]).agg(agg).reset_index(drop=True)

fit_shared_material_k

fit_shared_material_k(dfs, fixed_by_cfg, model, k_init=0.004)

Fit one material_k, shared across every config in dfs, against the 0th-order two-blob separation (not absolute position, see predict_zeroth_order_separation_physical's docstring).

Parameters:
  • dfs (dict, config -> DataFrame with `y_nisp`, `z_nisp`, `dy`, `dz`) –

    columns, one row per spectrum -- e.g. dispcraft.measurement.zeroth_order_separation's output with mad_outlier_mask already applied

  • fixed_by_cfg (dict, same keys as `dfs` -- each config's frozen) –

    physical fit (Tier 1+2+3), material_k excluded (it's the one free parameter here)

  • model (dispcraft.calibration.GroundTestModel (any `m_order` -- swapped) –

    to 0 internally)

  • k_init (float -- optimizer starting point, default: the project's ) –

    nominal material_k (models/stage1_instrument.toml)

Returns:
  • float -- the fit, shared `material_k`
Source code in dispcraft/zeroth_dispersion.py
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
def fit_shared_material_k(dfs, fixed_by_cfg, model, k_init=0.004):
    """Fit one `material_k`, shared across every config in `dfs`, against
    the 0th-order two-blob *separation* (not absolute position, see
    `predict_zeroth_order_separation_physical`'s docstring).

    Parameters
    ----------
    dfs : dict, config -> DataFrame with `y_nisp`, `z_nisp`, `dy`, `dz`
        columns, one row per spectrum -- e.g.
        `dispcraft.measurement.zeroth_order_separation`'s output with
        `mad_outlier_mask` already applied
    fixed_by_cfg : dict, same keys as `dfs` -- each config's frozen
        physical fit (Tier 1+2+3), `material_k` excluded (it's the one
        free parameter here)
    model : dispcraft.calibration.GroundTestModel (any `m_order` -- swapped
        to 0 internally)
    k_init : float -- optimizer starting point, default the project's
        nominal `material_k` (`models/stage1_instrument.toml`)

    Returns
    -------
    float -- the fit, shared `material_k`
    """
    result = minimize(_separation_cost, [k_init], args=(dfs, fixed_by_cfg, model), method="Nelder-Mead")
    return float(result.x[0])

fit_zeroth_dispersion_model

fit_zeroth_dispersion_model(df, mlp_params=None)

Fit the field-dependent NN predicting the 0th-order two-blob separation left over after the shared-physics baseline (module docstring): Step 4 onwards, df["dy"]/df["dz"] should already have predict_zeroth_order_physical's prediction subtracted (Steps 1-3 fit this directly against the raw separation instead, since Step 4's physical baseline didn't exist yet).

Parameters:
  • df (DataFrame with `y_nisp`, `z_nisp`, `dy`, `dz` columns, one row per) –

    spectrum -- e.g. dispcraft.measurement.zeroth_order_separation's output (or that minus the physical baseline), with mad_outlier_mask already applied.

  • mlp_params (dict, default: `DEFAULT_ZEROTH_MLP_PARAMS` ) –
Returns:
  • dispcraft.ml.LitResidualRegressor -- predicts (dy,dz) from
  • `standardize_field(y0,z0)`.
Source code in dispcraft/zeroth_dispersion.py
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
def fit_zeroth_dispersion_model(df, mlp_params=None):
    """Fit the field-dependent NN predicting the 0th-order two-blob
    separation left over after the shared-physics baseline (module
    docstring): Step 4 onwards, `df["dy"]`/`df["dz"]` should already have
    `predict_zeroth_order_physical`'s prediction subtracted (Steps 1-3
    fit this directly against the raw separation instead, since Step 4's
    physical baseline didn't exist yet).

    Parameters
    ----------
    df : DataFrame with `y_nisp`, `z_nisp`, `dy`, `dz` columns, one row per
        spectrum -- e.g. `dispcraft.measurement.zeroth_order_separation`'s
        output (or that minus the physical baseline), with
        `mad_outlier_mask` already applied.
    mlp_params : dict, default `DEFAULT_ZEROTH_MLP_PARAMS`

    Returns
    -------
    dispcraft.ml.LitResidualRegressor -- predicts (dy,dz) from
    `standardize_field(y0,z0)`.
    """
    mlp_params = mlp_params or DEFAULT_ZEROTH_MLP_PARAMS
    field = aggregate_by_position(df)
    X = standardize_field(field["y_nisp"].values, field["z_nisp"].values)
    y = field[["dy", "dz"]].values
    return train_residual_mlp(X, y, mlp_params, n_outputs=2)

mad_outlier_mask

mad_outlier_mask(x, n_mad=5.0)

Boolean mask (True = keep), median-absolute-deviation clip.

Stage 5 Phase 3 Step 1 found some 0th-order spectra have a single unreplicated, mismatched-crossmatch measurement that survives measurement.zeroth_order_separation's median-collapsing (it only protects ranks with more than one exposure) -- producing dz/dy of 20-30mm next to a clean population with MAD*1.4826 of ~0.01mm. A wide n_mad clip isolates exactly these without touching the real signal.

Source code in dispcraft/zeroth_dispersion.py
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
def mad_outlier_mask(x, n_mad=5.0):
    """Boolean mask (True = keep), median-absolute-deviation clip.

    Stage 5 Phase 3 Step 1 found some 0th-order spectra have a single
    unreplicated, mismatched-crossmatch measurement that survives
    `measurement.zeroth_order_separation`'s median-collapsing (it only
    protects ranks with more than one exposure) -- producing `dz`/`dy`
    of 20-30mm next to a clean population with MAD*1.4826 of ~0.01mm. A
    wide `n_mad` clip isolates exactly these without touching the real
    signal.
    """
    x = np.asarray(x, dtype=float)
    mad = np.median(np.abs(x - np.median(x))) * 1.4826
    if mad == 0:
        return np.ones_like(x, dtype=bool)
    return np.abs(x - np.median(x)) <= n_mad * mad

predict_zeroth_dispersion

predict_zeroth_dispersion(y_nisp_mm, z_nisp_mm, nn_model)

(y0,z0) in mm -> (N,2) [dy,dz] predicted two-blob separation residual, mm (add predict_zeroth_order_physical's output for the full Step-4-onwards prediction; the full raw separation itself for Steps 1-3's nn_models, which were fit with no physical baseline).

Source code in dispcraft/zeroth_dispersion.py
144
145
146
147
148
149
150
def predict_zeroth_dispersion(y_nisp_mm, z_nisp_mm, nn_model):
    """(y0,z0) in mm -> (N,2) [dy,dz] predicted two-blob separation
    residual, mm (add `predict_zeroth_order_physical`'s output for the full
    Step-4-onwards prediction; the full raw separation itself for Steps 1-3's
    `nn_model`s, which were fit with no physical baseline)."""
    X = standardize_field(np.asarray(y_nisp_mm), np.asarray(z_nisp_mm))
    return ml_predict(nn_model, X)

predict_zeroth_order_physical

predict_zeroth_order_physical(y_nisp_mm, z_nisp_mm, wavelength_nm, model, params)

Shared-physics 0th-order prediction: predict_centroids at m_order=0 (the prism's own chromatic dispersion, unaffected by m), using the same parameters already fit against first-order data.

Parameters:
  • y_nisp_mm (array-like, same length) –
  • z_nisp_mm (array-like, same length) –
  • wavelength_nm (array-like, same length) –
  • model (dispcraft.calibration.GroundTestModel (any `m_order` -- swapped) –

    to 0 internally)

  • params (dict -- a config's frozen physical fit (Tier 1+2+3), e.g.) –

    models/joint_specific_fit_<cfg>.toml's merged fit.fixed/ fit.result, optionally with material_k overridden (see fit_shared_material_k)

Returns:
  • (2, N) array -- [cent_y, cent_z], in mm (same shape as `predict_centroids`)
Source code in dispcraft/zeroth_dispersion.py
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
def predict_zeroth_order_physical(y_nisp_mm, z_nisp_mm, wavelength_nm, model, params):
    """Shared-physics 0th-order prediction: `predict_centroids` at
    `m_order=0` (the prism's own chromatic dispersion, unaffected by `m`),
    using the same parameters already fit against first-order data.

    Parameters
    ----------
    y_nisp_mm, z_nisp_mm, wavelength_nm : array-like, same length
    model : dispcraft.calibration.GroundTestModel (any `m_order` -- swapped
        to 0 internally)
    params : dict -- a config's frozen physical fit (Tier 1+2+3), e.g.
        `models/joint_specific_fit_<cfg>.toml`'s merged `fit.fixed`/
        `fit.result`, optionally with `material_k` overridden (see
        `fit_shared_material_k`)

    Returns
    -------
    (2, N) array -- [cent_y, cent_z], in mm (same shape as `predict_centroids`)
    """
    model_m0 = dataclasses.replace(model, m_order=0)
    return predict_centroids(y_nisp_mm, z_nisp_mm, wavelength_nm, theta=[], free_names=[], model=model_m0, fixed=params)

predict_zeroth_order_separation_physical

predict_zeroth_order_separation_physical(y_nisp_mm, z_nisp_mm, model, params)

Physics-predicted rank2-rank1 (red-blue) separation at the RGS passband's two edge wavelengths (_RANK_WAVELENGTHS_NM) -- the quantity fit_shared_material_k actually fits against.

Deliberately not absolute position: predict_zeroth_order_physical's absolute prediction (same offset_y_mm/offset_z_mm as first-order data) does not match the 0th order's actual absolute centroids -- checked while developing this step and found off by several mm, a much bigger, unrelated registration problem Stage 2 explicitly deferred. The separation between the two blobs of the same spectrum cancels that out and isolates the actual chromatic effect this step targets.

Returns:
  • (N, 2) array -- [dy, dz], in mm
Source code in dispcraft/zeroth_dispersion.py
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
def predict_zeroth_order_separation_physical(y_nisp_mm, z_nisp_mm, model, params):
    """Physics-predicted rank2-rank1 (red-blue) separation at the RGS
    passband's two edge wavelengths (`_RANK_WAVELENGTHS_NM`) -- the
    quantity `fit_shared_material_k` actually fits against.

    Deliberately *not* absolute position: `predict_zeroth_order_physical`'s
    absolute prediction (same offset_y_mm/offset_z_mm as first-order data)
    does not match the 0th order's actual absolute centroids -- checked
    while developing this step and found off by several mm, a much bigger,
    unrelated registration problem Stage 2 explicitly deferred. The
    *separation* between the two blobs of the same spectrum cancels that
    out and isolates the actual chromatic effect this step targets.

    Returns
    -------
    (N, 2) array -- [dy, dz], in mm
    """
    y_nisp_mm = np.asarray(y_nisp_mm)
    z_nisp_mm = np.asarray(z_nisp_mm)
    n = len(y_nisp_mm)
    pred1 = predict_zeroth_order_physical(y_nisp_mm, z_nisp_mm, np.full(n, _RANK_WAVELENGTHS_NM[0]), model, params)
    pred2 = predict_zeroth_order_physical(y_nisp_mm, z_nisp_mm, np.full(n, _RANK_WAVELENGTHS_NM[1]), model, params)
    return np.stack([pred2[0] - pred1[0], pred2[1] - pred1[1]], axis=1)