dispcraft.calibration

calibration

Physical forward model for Stage 3 ground-test calibration fits.

The ground-test setup differs from dispcraft.optics.trace_instrument's in-flight chain (Telescope -> Collimator -> Grism -> Camera -> Detector): the ATS encoder already reports the source's focal-plane position directly as y_nisp, z_nisp [mm], so the Telescope element's job (sky angle -> focal-plane position) is bypassed, and the measured centroids cent_y, cent_z are already in mm on the R_MOS mosaic frame, not pixels, so the Detector element's pixel conversion is bypassed too -- only its offset (mm-level registration between the model frame and R_MOS) is used, applied as a plain mm shift. See notebooks/3-Intro_ML.ipynb, Section 2, for the full reasoning.

predict_centroids/cost take an explicit theta/free_names split so the same forward model serves single-dataset fits (Stage 3) and joint multi-dataset fits (Stage 4 -- shared vs. dataset-specific parameters, summed via cost_joint below) without change -- only which parameters are free, and how many datasets the cost is summed over, differs. coll_f, cam_f, and material_n0 are part of that same free-parameter space (validated jointly fittable in Stage 4 Phase 3); Stage 3 found material_k not identifiable from centroid-position data alone -- but that used only first-order (single-narrow-wavelength-range) data. Stage 5 Phase 3 Step 4 found 0th-order data (two widely-separated wavelengths, 1206nm/1892nm) supplies exactly the baseline first-order data lacks: material_k fit jointly across all 6 RGS configs' 0th-order two-blob separation (holding every other parameter at its frozen first-order value, m_order=0) landed at 0.0143 (vs. nominal 0.004), consistent to 3 significant figures across independent per-config fits (0.0140-0.0147) -- a genuine, identifiable, shared material property once the right data is used, not per-tier or per-dataset. See dispcraft.zeroth_dispersion.fit_shared_material_k and notebooks/5.3-Zeroth_Order_Dispersion.ipynb. See notebooks/3-Intro_ML .ipynb, Section 2, for the single-dataset reasoning, and notebooks/4.3- Multi_Dataset_Fitting.ipynb for the multi-dataset joint-fit strategy cost_joint/cost_joint_wide/bootstrap_uncertainty_joint/ bootstrap_uncertainty_wide implement.

GroundTestModel dataclass

GroundTestModel(coll, cam, material, m_order, nominal_params)

Fixed context for the ground-test forward model.

Bundles the dispcraft.optics elements built at Stage 1's nominal config alongside the nominal value of every candidate free parameter, so a fit only has to say which parameters vary and their values.

Attributes:
  • coll, cam (Collimator, Camera -- nominal optics; `.f` also seeds) –

    the coll_f/cam_f free parameters below, used directly whenever a fit doesn't override them

  • material (Material -- prism glass; `.n0`/`.k` also seed the) –

    material_n0/material_k free parameters below

  • m_order (int -- grating diffraction order (design constant for) –

    a fit; swap via dataclasses.replace(model, m_order=...) to evaluate the same physical parameters at a different order, e.g. dispcraft.zeroth_dispersion.predict_zeroth_order_physical)

  • nominal_params (dict -- value used for any parameter not listed in a) –

    given fit's free_names: offset_y_mm, offset_z_mm, tilt_deg, A_deg, rho, coll_f, cam_f, material_n0, material_k

bootstrap_uncertainty_joint

bootstrap_uncertainty_joint(theta_star, dfs, free_names, fixed_by_cfg, model, n_boot=30, seed=None)

Bootstrap std + correlation matrix for a cost_joint fit.

Resamples each dataset in dfs independently (own size, with replacement), recombines into cost_joint, and re-minimizes from theta_star for each replicate. Generic over how many datasets dfs holds -- a single-entry dfs reduces this to single-dataset bootstrap uncertainty.

Returns:
  • std( (len(free_names),) array ) –
  • corr( DataFrame, index/columns = free_names ) –
Source code in dispcraft/calibration.py
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
def bootstrap_uncertainty_joint(theta_star, dfs, free_names, fixed_by_cfg, model: GroundTestModel,
                                 n_boot=30, seed=None):
    """Bootstrap std + correlation matrix for a `cost_joint` fit.

    Resamples each dataset in `dfs` independently (own size, with
    replacement), recombines into `cost_joint`, and re-minimizes from
    `theta_star` for each replicate. Generic over how many datasets `dfs`
    holds -- a single-entry `dfs` reduces this to single-dataset bootstrap
    uncertainty.

    Returns
    -------
    std  : (len(free_names),) array
    corr : DataFrame, index/columns = free_names
    """
    rng = np.random.default_rng(seed)
    boots = []
    for _ in range(n_boot):
        resampled = {cfg: dfs[cfg].iloc[rng.integers(0, len(dfs[cfg]), len(dfs[cfg]))] for cfg in dfs}
        r = minimize(cost_joint, theta_star, args=(resampled, free_names, fixed_by_cfg, model),
                     method="Nelder-Mead", options=_JOINT_MINIMIZE_OPTIONS)
        boots.append(r.x)
    boots = np.array(boots)
    std = boots.std(axis=0)
    corr = pd.DataFrame(np.corrcoef(boots.T), index=free_names, columns=free_names)
    return std, corr

bootstrap_uncertainty_wide

bootstrap_uncertainty_wide(theta_star, dfs, shared_names, per_dataset_name, fixed_by_cfg, model, n_boot=30, seed=None)

Bootstrap std + correlation matrix for a cost_joint_wide fit.

Same resampling scheme as bootstrap_uncertainty_joint, applied to cost_joint_wide's shared+per-dataset parameter layout.

Returns:
  • std( (len(shared_names) + len(dfs),) array ) –
  • corr( DataFrame, index/columns = shared_names + one ) –

    "{per_dataset_name}__{cfg}" per dataset in dfs

Source code in dispcraft/calibration.py
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
def bootstrap_uncertainty_wide(theta_star, dfs, shared_names, per_dataset_name, fixed_by_cfg, model: GroundTestModel,
                                n_boot=30, seed=None):
    """Bootstrap std + correlation matrix for a `cost_joint_wide` fit.

    Same resampling scheme as `bootstrap_uncertainty_joint`, applied to
    `cost_joint_wide`'s shared+per-dataset parameter layout.

    Returns
    -------
    std  : (len(shared_names) + len(dfs),) array
    corr : DataFrame, index/columns = shared_names + one
        "{per_dataset_name}__{cfg}" per dataset in dfs
    """
    rng = np.random.default_rng(seed)
    names = shared_names + [f"{per_dataset_name}__{cfg}" for cfg in dfs]
    boots = []
    for _ in range(n_boot):
        resampled = {cfg: dfs[cfg].iloc[rng.integers(0, len(dfs[cfg]), len(dfs[cfg]))] for cfg in dfs}
        r = minimize(cost_joint_wide, theta_star, args=(resampled, shared_names, per_dataset_name, fixed_by_cfg, model),
                     method="Nelder-Mead", options=_BOOTSTRAP_MINIMIZE_OPTIONS)
        boots.append(r.x)
    boots = np.array(boots)
    std = boots.std(axis=0)
    corr = pd.DataFrame(np.corrcoef(boots.T), index=names, columns=names)
    return std, corr

cost

cost(theta, df, free_names, model, fixed=None)

MSE cost over (r_y, r_z), vectorized over all rows of df.

df must have y_nisp, z_nisp, wavelength, cent_y, cent_z columns (e.g. from dispcraft.measurement.median_per_spectrum).

Source code in dispcraft/calibration.py
136
137
138
139
140
141
142
143
144
145
def cost(theta, df, free_names, model: GroundTestModel, fixed=None):
    """MSE cost over (r_y, r_z), vectorized over all rows of df.

    `df` must have `y_nisp`, `z_nisp`, `wavelength`, `cent_y`, `cent_z`
    columns (e.g. from `dispcraft.measurement.median_per_spectrum`).
    """
    pred = predict_centroids(df["y_nisp"], df["z_nisp"], df["wavelength"], theta, free_names, model, fixed)
    r_y = pred[0] - df["cent_y"].values
    r_z = pred[1] - df["cent_z"].values
    return np.mean(r_y**2 + r_z**2)

cost_joint

cost_joint(theta, dfs, free_names, fixed_by_cfg, model)

Unweighted sum of cost(...) over every dataset in dfs.

The core of a joint multi-dataset fit: free_names/theta are shared across all datasets in dfs, while fixed_by_cfg[cfg] supplies whatever each dataset holds at its own value (e.g. an already-resolved tilt/offset from a previous fit tier). A single-entry dfs reduces this to an ordinary single-dataset fit via cost.

No n_points weighting -- datasets with more rows get proportionally more influence over the shared parameters, same as summing their MSEs directly would. Be aware of this if dataset sizes differ substantially.

dfs, fixed_by_cfg : dicts keyed by dataset id, same keys.

Source code in dispcraft/calibration.py
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
def cost_joint(theta, dfs, free_names, fixed_by_cfg, model: GroundTestModel):
    """Unweighted sum of `cost(...)` over every dataset in `dfs`.

    The core of a joint multi-dataset fit: `free_names`/`theta` are shared
    across all datasets in `dfs`, while `fixed_by_cfg[cfg]` supplies
    whatever each dataset holds at its own value (e.g. an already-resolved
    tilt/offset from a previous fit tier). A single-entry `dfs` reduces this
    to an ordinary single-dataset fit via `cost`.

    No `n_points` weighting -- datasets with more rows get proportionally
    more influence over the shared parameters, same as summing their MSEs
    directly would. Be aware of this if dataset sizes differ substantially.

    `dfs`, `fixed_by_cfg` : dicts keyed by dataset id, same keys.
    """
    return sum(cost(theta, dfs[cfg], free_names, model, fixed=fixed_by_cfg[cfg]) for cfg in dfs)

cost_joint_wide

cost_joint_wide(theta, dfs, shared_names, per_dataset_name, fixed_by_cfg, model)

Like cost_joint, but per_dataset_name is free PER DATASET instead of shared, while shared_names stay one shared value across dfs.

theta layout: [*one value per shared_names, *one value of per_dataset_name per dataset in dfs' key order].

Used to check whether a shared parameter is actually identifiable independently of a suspected degenerate partner: free that partner per-dataset instead of assuming it's safe to hold fixed, and see how much the shared parameter's bootstrap correlation with it (via bootstrap_uncertainty_wide) actually drops once datasets with different geometries are fit jointly.

Source code in dispcraft/calibration.py
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
def cost_joint_wide(theta, dfs, shared_names, per_dataset_name, fixed_by_cfg, model: GroundTestModel):
    """Like `cost_joint`, but `per_dataset_name` is free PER DATASET instead
    of shared, while `shared_names` stay one shared value across `dfs`.

    `theta` layout: `[*one value per shared_names, *one value of
    per_dataset_name per dataset in dfs' key order]`.

    Used to check whether a shared parameter is actually identifiable
    independently of a suspected degenerate partner: free that partner
    per-dataset instead of assuming it's safe to hold fixed, and see how
    much the shared parameter's bootstrap correlation with it (via
    `bootstrap_uncertainty_wide`) actually drops once datasets with
    different geometries are fit jointly.
    """
    n_shared = len(shared_names)
    shared_theta = theta[:n_shared]
    total = 0.0
    for i, cfg in enumerate(dfs):
        theta_d = np.concatenate([shared_theta, [theta[n_shared + i]]])
        total += cost(theta_d, dfs[cfg], shared_names + [per_dataset_name], model, fixed=fixed_by_cfg[cfg])
    return total

ground_test_model_from_config

ground_test_model_from_config(base_config)

Build a GroundTestModel from a config shaped like models/stage1_instrument.toml.

Source code in dispcraft/calibration.py
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
def ground_test_model_from_config(base_config: dict) -> GroundTestModel:
    """Build a GroundTestModel from a config shaped like `models/stage1_instrument.toml`."""
    _tel, coll, _grism_nominal, cam, _det = build_instrument_from_config(base_config)
    material = Material(n0=base_config["material"]["n0"], k=base_config["material"]["k"])
    nominal_params = {
        "offset_y_mm": 0.0,
        "offset_z_mm": 0.0,
        "tilt_deg": base_config["grism"]["tilt_deg"],
        "A_deg": base_config["prism"]["A_deg"],
        "rho": base_config["grating"]["rho"],
        "coll_f": coll.f,
        "cam_f": cam.f,
        "material_n0": material.n0,
        "material_k": material.k,
    }
    return GroundTestModel(coll=coll, cam=cam, material=material,
                            m_order=base_config["grating"]["m"], nominal_params=nominal_params)

per_dataset_rmse

per_dataset_rmse(theta, free_names, fixed_by_cfg, dfs, model)

RMSE per dataset at a shared theta, each evaluated against its own fixed_by_cfg[cfg] (e.g. that dataset's own tilt/offsets).

dfs, fixed_by_cfg : dicts keyed by dataset id, same keys.

Source code in dispcraft/calibration.py
152
153
154
155
156
157
158
def per_dataset_rmse(theta, free_names, fixed_by_cfg, dfs, model: GroundTestModel):
    """RMSE per dataset at a shared `theta`, each evaluated against its own
    `fixed_by_cfg[cfg]` (e.g. that dataset's own tilt/offsets).

    `dfs`, `fixed_by_cfg` : dicts keyed by dataset id, same keys.
    """
    return {cfg: float(np.sqrt(cost(theta, dfs[cfg], free_names, model, fixed=fixed_by_cfg[cfg]))) for cfg in dfs}

predict_centroids

predict_centroids(y_nisp_mm, z_nisp_mm, wavelength_nm, theta, free_names, model, fixed=None)

Forward model: (y_nisp, z_nisp, wavelength) -> (cent_y, cent_z), in mm.

Parameters:
  • y_nisp_mm (array-like, same length) –
  • z_nisp_mm (array-like, same length) –
  • wavelength_nm (array-like, same length) –
  • theta
  • free_names (subset of `model.nominal_params`' keys -- which parameters) –

    theta supplies. Everything else is held at model.nominal_params (or at fixed, to override a subset of those constants).

  • model (GroundTestModel) –
  • fixed

    without making it a free parameter (e.g. a fit-specific A_deg)

Returns:
  • (2, N) array -- [cent_y, cent_z], in mm
Source code in dispcraft/calibration.py
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
def predict_centroids(y_nisp_mm, z_nisp_mm, wavelength_nm, theta, free_names, model: GroundTestModel, fixed=None):
    """Forward model: (y_nisp, z_nisp, wavelength) -> (cent_y, cent_z), in mm.

    Parameters
    ----------
    y_nisp_mm, z_nisp_mm, wavelength_nm : array-like, same length
    theta      : free-parameter values, ordered like `free_names`
    free_names : subset of `model.nominal_params`' keys -- which parameters
        `theta` supplies. Everything else is held at `model.nominal_params`
        (or at `fixed`, to override a subset of those constants).
    model      : GroundTestModel -- nominal optics/material/parameter values
    fixed      : dict, optional -- override a subset of nominal_params
        without making it a free parameter (e.g. a fit-specific A_deg)

    Returns
    -------
    (2, N) array -- [cent_y, cent_z], in mm
    """
    p = dict(model.nominal_params)
    if fixed:
        p.update(fixed)
    p.update(dict(zip(free_names, theta)))

    material = Material(n0=p["material_n0"], k=p["material_k"])
    prism = Prism(material=material, A=np.radians(p["A_deg"]))
    grating = Grating(m=model.m_order, rho=p["rho"])
    grism = Grism(prism, grating, tilt=np.radians(p["tilt_deg"]))
    coll = Collimator(f=p["coll_f"])
    cam = Camera(f=p["cam_f"])

    pos_foc = np.stack([np.asarray(y_nisp_mm), np.asarray(z_nisp_mm)]) / 1000.0  # mm -> m
    wavelength_um = np.asarray(wavelength_nm) / 1000.0  # nm -> um
    angle_col = coll.forward(pos_foc)
    angle_gr = grism.forward(angle_col, wavelength_um)
    pos_cam = cam.forward(angle_gr)  # m
    offset_mm = np.array([p["offset_y_mm"], p["offset_z_mm"]])
    return pos_cam * 1000.0 + offset_mm[:, None]  # mm