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
|