Skip to content

Fitting¤

ModelFit

camino.ModelFit ¤

Bases: Base

Base class for fitting model parameters to a single exposure.

Source code in camino.py
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
class ModelFit(zdx.Base):
    """Base class for fitting model parameters to a single exposure."""

    @abstractmethod
    def __call__(self, model, exposure):
        """Compute the forward model for one exposure."""
        pass

    def get_key(self, exposure, param):
        """Map a model parameter name to the exposure-specific storage key."""
        match param:
            case "positions":
                return exposure.key
            case "aberrations":
                return exposure.key
            case "defocus":
                return exposure.key
            case "pupil_delta":
                return exposure.key
            case "spectrum":
                return f"{exposure.filter}"
            case "fluxes":
                return exposure.key
            case _:
                raise ValueError(f"Parameter {param} has no key")

    def map_param(self, exposure, param):
        """Return the fully-qualified parameter name for a fit item."""
        if param in [
            "fluxes",
            "positions",
            "spectrum",
            "aberrations",
            "defocus",
        ]:
            return f"{param}.{exposure.get_key(param)}"
        return param

    def update_optics_zernikes(self, model, exposure):
        """Apply Zernike-aberration and defocus updates to the optics model."""
        optics = model.optics
        if "aberrations" in model.params.keys():
            coefficients = model.aberrations[self.get_key(exposure, "aberrations")]
            _ = lax.stop_gradient(coefficients[0, 0])
            optics = optics.set("pupil.coefficients", coefficients)

        if "defocus" in model.params.keys():
            disp = model.defocus[self.get_key(exposure, "defocus")]
            optics = optics.set("defocus", disp)

        return optics

__call__(model, exposure) abstractmethod ¤

Compute the forward model for one exposure.

Source code in camino.py
1055
1056
1057
1058
@abstractmethod
def __call__(self, model, exposure):
    """Compute the forward model for one exposure."""
    pass

get_key(exposure, param) ¤

Map a model parameter name to the exposure-specific storage key.

Source code in camino.py
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
def get_key(self, exposure, param):
    """Map a model parameter name to the exposure-specific storage key."""
    match param:
        case "positions":
            return exposure.key
        case "aberrations":
            return exposure.key
        case "defocus":
            return exposure.key
        case "pupil_delta":
            return exposure.key
        case "spectrum":
            return f"{exposure.filter}"
        case "fluxes":
            return exposure.key
        case _:
            raise ValueError(f"Parameter {param} has no key")

map_param(exposure, param) ¤

Return the fully-qualified parameter name for a fit item.

Source code in camino.py
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
def map_param(self, exposure, param):
    """Return the fully-qualified parameter name for a fit item."""
    if param in [
        "fluxes",
        "positions",
        "spectrum",
        "aberrations",
        "defocus",
    ]:
        return f"{param}.{exposure.get_key(param)}"
    return param

update_optics_zernikes(model, exposure) ¤

Apply Zernike-aberration and defocus updates to the optics model.

Source code in camino.py
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
def update_optics_zernikes(self, model, exposure):
    """Apply Zernike-aberration and defocus updates to the optics model."""
    optics = model.optics
    if "aberrations" in model.params.keys():
        coefficients = model.aberrations[self.get_key(exposure, "aberrations")]
        _ = lax.stop_gradient(coefficients[0, 0])
        optics = optics.set("pupil.coefficients", coefficients)

    if "defocus" in model.params.keys():
        disp = model.defocus[self.get_key(exposure, "defocus")]
        optics = optics.set("defocus", disp)

    return optics
SinglePointFilterFit

camino.SinglePointFilterFit ¤

Bases: ModelFit

Pixel-basis fitter for point-source PSFs.

Source code in camino.py
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
class SinglePointFilterFit(ModelFit):
    """Pixel-basis fitter for point-source PSFs."""

    source: dl.Telescope = eqx.field(static=True)
    nwavels: int = eqx.field(static=True)

    def __init__(self, nwavels: int = 1):
        """Initialise the fitter with a single-point source and a wavelength grid."""
        self.source = dl.PointSource(wavelengths=[1.0])
        self.nwavels = int(nwavels)

    def update_optics(self, model, exposure):
        """Update the optics with pupil amplitude, OPD, and optional defocus terms."""
        optics = model.optics

        # ----------------------------
        # 1) Start from the current pupil transmission
        # ----------------------------
        base_amp = optics.layers["pupil"].transmission  # P0(x)
        amp = base_amp

        # ----------------------------
        # 2) Optional: learn pupil amplitude via pupil_delta
        # ----------------------------
        # --- pupil amplitude correction ---
        if "pupil_delta" in model.params.keys():
            base_amp = optics.layers["pupil"].transmission  # A0(x)
            pupil_mask = base_amp > 0

            delta = model.pupil_delta

            # scale: choose eps so raw delta stays O(1)
            eps_amp = 0.05  # 5% per unit in log-space (tune)
            amp = base_amp * jnp.exp(eps_amp * delta)

            # hard zero outside pupil
            amp = jnp.where(pupil_mask, amp, 0.0)

            # IMPORTANT: renormalize mean amplitude inside pupil to 1
            mean_amp = jnp.sum(amp) / (jnp.sum(pupil_mask) + 1e-12)
            amp = amp / (mean_amp + 1e-12)

            optics = optics.set("pupil.transmission", amp)

        # ----------------------------
        # 3) OPD (aberrations) + pupil-plane shear
        # ----------------------------
        if "aberrations" in model.params.keys():
            opd_map = model.aberrations[self.get_key(exposure, "aberrations")]
        else:
            # if you ever call update_optics without aberrations present
            opd_map = optics.layers["pupil"].opd

        # --- Apply pupil-plane shear as a coordinate warp of pupil-plane arrays ---
        if "pupil_shear" in model.params.keys():
            s_raw = model.pupil_shear

            # scale raw -> physical dimensionless shear coefficient
            # Start small; tune later (1e-4 to 1e-2 are typical exploration ranges)
            eps_shear = 1e-3
            shx = eps_shear * s_raw
            shy = 0.0

            # Shear pupil transmission (smooth is better for gradients)
            # If you want *strict* binary display later, threshold outside optimisation.
            amp = apply_pupil_shear(amp, shx=shx, shy=shy, order=1, cval=0.0)

            # Shear OPD map
            opd_map = apply_pupil_shear(opd_map, shx=shx, shy=shy, order=1, cval=0.0)

        # ----------------------------
        # 4) Piston removal using the (possibly sheared) pupil mask
        # ----------------------------
        pupil_mask = amp > 0
        mean_val = jnp.sum(opd_map * pupil_mask) / (jnp.sum(pupil_mask) + 1e-12)
        opd_map = opd_map - mean_val

        # ----------------------------
        # 5) Write back into optics
        # ----------------------------
        optics = optics.set("pupil.transmission", amp)
        optics = optics.set("pupil.opd", opd_map)

        # ----------------------------
        # 6) Defocus with optional global scale
        # ----------------------------
        if "defocus" in model.params.keys():
            disp = model.defocus[
                self.get_key(exposure, "defocus")
            ]  # nominal defocus param

            scale = 1.0
            if "defocus_scale" in model.params.keys():
                k_raw = model.defocus_scale
                eps_k = 5e-3
                scale = 1.0 + eps_k * k_raw

            optics = optics.set("defocus", disp * scale)

        return optics

    # Forward model
    def __call__(self, model, exposure):
        source = self.source
        nw = self.nwavels

        # 1) Flux
        log_flux = model.get(exposure.fit.map_param(exposure, "fluxes"))
        flux = jnp.exp(log_flux * jnp.log(10.0))
        source = source.set("flux", flux)

        # 2) Position
        pos = model.get(exposure.fit.map_param(exposure, "positions"))
        source = source.set("position", pos * dlu.arcsec2rad(0.031))

        # 3) Polynomial spectrum in log10 space
        wv, filt = calc_throughput(exposure.filter, nwavels=nw)
        wv = jnp.asarray(wv)  # shape (nw,)
        filt = jnp.asarray(filt)
        filt = filt / (jnp.sum(filt) + 1e-12)  # base: normalised filter throughput

        # --- Get polynomial coefficients for this exposure ---
        if "spectrum" in model.params.keys():
            spec_param = exposure.fit.map_param(exposure, "spectrum")
            coeffs = model.get(spec_param)
            coeffs = jnp.atleast_1d(coeffs)  # ensure 1D, handles (1,) or (2,) etc
        else:
            coeffs = jnp.zeros((1,), dtype=jnp.float64)  # default = flat in log10
            # (log10_I = 0 → I = 1)

        # --- Build dimensionless wavelength coordinate ---
        lambda0 = jnp.mean(wv)
        x = (wv - lambda0) / (lambda0 + 1e-12)  # shape (nw,)

        # --- Evaluate log10 intensity p(x) and convert to linear ---
        log10_I = eval_poly_log10(x, coeffs)  # shape (nw,)

        # (Optional, but nice): remove the intercept so polynomial only changes shape,
        # and the overall normalisation is left to the flux parameter.
        log10_I = log10_I - jnp.mean(log10_I)

        I = jnp.power(10.0, log10_I)
        I = jnp.where(jnp.isfinite(I), I, 0.0)  # paranoia against NaN/inf
        I = jnp.clip(I, 0.0, jnp.inf)

        # --- Combine source SED with filter throughput ---
        weights = filt * I
        weights = weights / (jnp.sum(weights) + 1e-12)  # PSF weights sum to 1

        source = source.set("spectrum", dl.Spectrum(wv, weights))

        # 4) Optics, PSF and shear
        optics = self.update_optics(model, exposure)
        psfs = optics.model(source, return_psf=True)
        data = psfs.data

        if data.ndim == 3:
            # shape: (nwavels, ny, nx) -> integrate over wavelength
            psf = data.sum(axis=0)
        elif data.ndim == 2:
            # already a 2D PSF (ny, nx)
            psf = data
        else:
            raise ValueError(
                f"Unexpected PSF data ndim={data.ndim}, shape={data.shape}"
            )

        pixel_scale = psfs.pixel_scale.mean()

        if "jitter_raw" in model.params.keys():
            # map raw -> positive jitter in arcsec (choose a scale that makes raw~O(1))
            # e.g. 1 mas = 1e-3 arcsec
            jitter_arcsec = 1e-3 * jnn.softplus(model.jitter_raw)  # >= 0
            jitter_rad = dlu.arcsec2rad(jitter_arcsec)

            sigma_pix = jitter_rad / (pixel_scale + 1e-30)
            psf = gaussian_blur_fft(psf, sigma_pix)

        # if "primary_shear" in model.params.keys():
        #     shear_raw = model.primary_shear  # scalar
        #     eps_shear = 7.1e-3                 # physical shear = eps_shear * raw
        #     shx = eps_shear * shear_raw
        #     psf = apply_shear(psf, shx=shx, shy=0.0)

        if "radial_scale" in model.params.keys():
            raw = model.radial_scale  # scalar
            eps_rs = 5e-3  # 0.5% per unit
            scale = 1.0 + eps_rs * raw
            psf = radial_zoom(psf, scale)

        psf_obj = dl.PSF(psf, pixel_scale)
        return dlu.downsample(psf_obj.data, 4, mean=False)

__init__(nwavels=1) ¤

Initialise the fitter with a single-point source and a wavelength grid.

Source code in camino.py
1114
1115
1116
1117
def __init__(self, nwavels: int = 1):
    """Initialise the fitter with a single-point source and a wavelength grid."""
    self.source = dl.PointSource(wavelengths=[1.0])
    self.nwavels = int(nwavels)

update_optics(model, exposure) ¤

Update the optics with pupil amplitude, OPD, and optional defocus terms.

Source code in camino.py
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
def update_optics(self, model, exposure):
    """Update the optics with pupil amplitude, OPD, and optional defocus terms."""
    optics = model.optics

    # ----------------------------
    # 1) Start from the current pupil transmission
    # ----------------------------
    base_amp = optics.layers["pupil"].transmission  # P0(x)
    amp = base_amp

    # ----------------------------
    # 2) Optional: learn pupil amplitude via pupil_delta
    # ----------------------------
    # --- pupil amplitude correction ---
    if "pupil_delta" in model.params.keys():
        base_amp = optics.layers["pupil"].transmission  # A0(x)
        pupil_mask = base_amp > 0

        delta = model.pupil_delta

        # scale: choose eps so raw delta stays O(1)
        eps_amp = 0.05  # 5% per unit in log-space (tune)
        amp = base_amp * jnp.exp(eps_amp * delta)

        # hard zero outside pupil
        amp = jnp.where(pupil_mask, amp, 0.0)

        # IMPORTANT: renormalize mean amplitude inside pupil to 1
        mean_amp = jnp.sum(amp) / (jnp.sum(pupil_mask) + 1e-12)
        amp = amp / (mean_amp + 1e-12)

        optics = optics.set("pupil.transmission", amp)

    # ----------------------------
    # 3) OPD (aberrations) + pupil-plane shear
    # ----------------------------
    if "aberrations" in model.params.keys():
        opd_map = model.aberrations[self.get_key(exposure, "aberrations")]
    else:
        # if you ever call update_optics without aberrations present
        opd_map = optics.layers["pupil"].opd

    # --- Apply pupil-plane shear as a coordinate warp of pupil-plane arrays ---
    if "pupil_shear" in model.params.keys():
        s_raw = model.pupil_shear

        # scale raw -> physical dimensionless shear coefficient
        # Start small; tune later (1e-4 to 1e-2 are typical exploration ranges)
        eps_shear = 1e-3
        shx = eps_shear * s_raw
        shy = 0.0

        # Shear pupil transmission (smooth is better for gradients)
        # If you want *strict* binary display later, threshold outside optimisation.
        amp = apply_pupil_shear(amp, shx=shx, shy=shy, order=1, cval=0.0)

        # Shear OPD map
        opd_map = apply_pupil_shear(opd_map, shx=shx, shy=shy, order=1, cval=0.0)

    # ----------------------------
    # 4) Piston removal using the (possibly sheared) pupil mask
    # ----------------------------
    pupil_mask = amp > 0
    mean_val = jnp.sum(opd_map * pupil_mask) / (jnp.sum(pupil_mask) + 1e-12)
    opd_map = opd_map - mean_val

    # ----------------------------
    # 5) Write back into optics
    # ----------------------------
    optics = optics.set("pupil.transmission", amp)
    optics = optics.set("pupil.opd", opd_map)

    # ----------------------------
    # 6) Defocus with optional global scale
    # ----------------------------
    if "defocus" in model.params.keys():
        disp = model.defocus[
            self.get_key(exposure, "defocus")
        ]  # nominal defocus param

        scale = 1.0
        if "defocus_scale" in model.params.keys():
            k_raw = model.defocus_scale
            eps_k = 5e-3
            scale = 1.0 + eps_k * k_raw

        optics = optics.set("defocus", disp * scale)

    return optics
BaseModeller

camino.BaseModeller ¤

Bases: Base

Mixin that exposes a nested parameter dictionary through attribute access.

Source code in camino.py
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
class BaseModeller(zdx.Base):
    """Mixin that exposes a nested parameter dictionary through attribute access."""

    params: dict

    def __init__(self, params):
        """Store a parameter dictionary on the model object."""
        self.params = params

    def __getattr__(self, key):
        """Resolve parameter values without forcing a custom __getattribute__ path."""
        if key in self.params:
            return self.params[key]
        for k, val in self.params.items():
            if hasattr(val, key):
                return getattr(val, key)
        raise AttributeError(
            f"Attribute {key} not found in params of {self.__class__.__name__} object"
        )

    def __getitem__(self, key):
        """Fetch a nested parameter value by key, returning a dict of matches."""
        values = {}
        for param, item in self.params.items():
            if isinstance(item, dict) and key in item.keys():
                values[param] = item[key]

        return values

__getattr__(key) ¤

Resolve parameter values without forcing a custom getattribute path.

Source code in camino.py
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
def __getattr__(self, key):
    """Resolve parameter values without forcing a custom __getattribute__ path."""
    if key in self.params:
        return self.params[key]
    for k, val in self.params.items():
        if hasattr(val, key):
            return getattr(val, key)
    raise AttributeError(
        f"Attribute {key} not found in params of {self.__class__.__name__} object"
    )

__getitem__(key) ¤

Fetch a nested parameter value by key, returning a dict of matches.

Source code in camino.py
1329
1330
1331
1332
1333
1334
1335
1336
def __getitem__(self, key):
    """Fetch a nested parameter value by key, returning a dict of matches."""
    values = {}
    for param, item in self.params.items():
        if isinstance(item, dict) and key in item.keys():
            values[param] = item[key]

    return values

__init__(params) ¤

Store a parameter dictionary on the model object.

Source code in camino.py
1314
1315
1316
def __init__(self, params):
    """Store a parameter dictionary on the model object."""
    self.params = params
ModelParams

camino.ModelParams ¤

Bases: BaseModeller

Source code in camino.py
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
class ModelParams(BaseModeller):

    def __getitem__(self, key):
        return self.params[key]

    def __getattr__(self, key):

        # Make the object act like a real dictionary
        if hasattr(self.params, key):
            return getattr(self.params, key)

        if key in self.params.keys():
            return self.params[key]

        for sub_key, val in self.params.items():
            if hasattr(val, key):
                return getattr(val, key)

        raise AttributeError(
            f"Attribute {key} not found in params of {self.__class__.__name__} object"
        )

    def replace(self, values):
        # Takes in a super-set class and updates this class with input values
        return self.set(
            "params", dict([(param, getattr(values, param)) for param in self.keys()])
        )

    def from_model(self, values):
        return self.set(
            "params", dict([(param, values.get(param)) for param in self.keys()])
        )

    def __add__(self, values):
        matched = self.replace(values)
        return jax.tree.map(lambda x, y: x + y, self, matched)

    def __iadd__(self, values):
        return self.__add__(values)

    def __mul__(self, values):
        matched = self.replace(values)
        return jax.tree.map(lambda x, y: x * y, self, matched)

    def __imul__(self, values):
        return self.__mul__(values)

    def map(self, fn):
        return jax.tree.map(lambda x: fn(x), self)

    def inject(self, other):
        # Injects the values of this class into another class
        return other.set(list(self.keys()), list(self.values()))

    def partition(self, params):
        """params can be a model params object or a list of keys"""
        if isinstance(params, ModelParams):
            params = list(params.params.keys())
        return (
            ModelParams({param: self[param] for param in params}),
            ModelParams(
                {param: self[param] for param in self.keys() if param not in params}
            ),
        )

    def combine(self, params2):
        return ModelParams({**self.params, **params2.params})

    def jacfwd(self, fn, n_batch=1):
        X, unravel_fn = ravel_pytree(self)
        Xs = jnp.array_split(X, n_batch)
        rebuild = lambda X_batch, index: X.at[index : index + len(X_batch)].set(X_batch)
        lens = jnp.cumsum(jnp.asarray([len(x) for x in Xs], dtype=jnp.int32))[:-1]
        starts = jnp.concatenate([jnp.asarray([0], dtype=jnp.int32), lens])

        @eqx.filter_jacfwd
        def batched_jac_fn(x, index):
            model_params = unravel_fn(rebuild(x, index))
            return eqx.filter_jit(fn)(model_params)

        return jnp.concatenate(
            [batched_jac_fn(x, index) for x, index in zip(Xs, starts)], axis=-1
        )

partition(params) ¤

params can be a model params object or a list of keys

Source code in camino.py
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
def partition(self, params):
    """params can be a model params object or a list of keys"""
    if isinstance(params, ModelParams):
        params = list(params.params.keys())
    return (
        ModelParams({param: self[param] for param in params}),
        ModelParams(
            {param: self[param] for param in self.keys() if param not in params}
        ),
    )
set_array

camino.set_array(pytree) ¤

Convert floating-point leaves in a pytree to the active JAX dtype.

Source code in camino.py
1342
1343
1344
1345
1346
1347
def set_array(pytree):
    """Convert floating-point leaves in a pytree to the active JAX dtype."""
    dtype = jnp.float64 if jax.config.x64_enabled else jnp.float32
    floats, other = eqx.partition(pytree, eqx.is_inexact_array_like)
    floats = jtu.tree_map(lambda x: jnp.asarray(x, dtype=dtype), floats)
    return eqx.combine(floats, other)