Skip to content

Instrument models

ConstantGain

Bases: GainModel

A scalar gain factor, independent of energy.

The factor lives as factor (an nnx.Param). Its prior is provided via the unified prior dict under the key "instrument.gain.factor" (shared across instrumented obs) or "instrument.gain.factor[*]" / "instrument.gain.factor[obs_name]" (per-obs).

Source code in src/jaxspec/model/instrument.py
33
34
35
36
37
38
39
40
41
42
43
44
45
46
class ConstantGain(GainModel):
    """A scalar gain factor, independent of energy.

    The factor lives as ``factor`` (an ``nnx.Param``). Its prior is provided
    via the unified prior dict under the key ``"instrument.gain.factor"``
    (shared across instrumented obs) or ``"instrument.gain.factor[*]"`` /
    ``"instrument.gain.factor[obs_name]"`` (per-obs).
    """

    def __init__(self):
        self.factor = nnx.Param(jnp.asarray(1.0))

    def __call__(self, energies: ArrayLike) -> ArrayLike:
        return self.factor[...]

ConstantShift

Bases: ShiftModel

An additive energy shift, constant across the spectrum.

The offset lives as offset (an nnx.Param). Its prior is provided via the unified prior dict under the key "instrument.shift.offset" (shared) or "instrument.shift.offset[*]" / "instrument.shift.offset[obs_name]" (per-obs).

Source code in src/jaxspec/model/instrument.py
56
57
58
59
60
61
62
63
64
65
66
67
68
69
class ConstantShift(ShiftModel):
    """An additive energy shift, constant across the spectrum.

    The offset lives as ``offset`` (an ``nnx.Param``). Its prior is provided
    via the unified prior dict under the key ``"instrument.shift.offset"``
    (shared) or ``"instrument.shift.offset[*]"`` / ``"instrument.shift.offset[obs_name]"``
    (per-obs).
    """

    def __init__(self):
        self.offset = nnx.Param(jnp.asarray(0.0))

    def __call__(self, energies: ArrayLike) -> ArrayLike:
        return energies + self.offset[...]

GainModel

Bases: HasDerivedQuantities, Module

Generic gain model. __call__(energies) returns the per-energy gain factor.

Source code in src/jaxspec/model/instrument.py
26
27
28
29
30
class GainModel(HasDerivedQuantities, nnx.Module):
    """Generic gain model. ``__call__(energies)`` returns the per-energy gain factor."""

    @abstractmethod
    def __call__(self, energies: ArrayLike) -> ArrayLike: ...

InstrumentModel

Bases: HasDerivedQuantities, Module

Per-observation instrument response.

Pass as a dict to BayesianModel

BayesianModel(
    spectral_model, prior, observations,
    instrument_model={
        "PN": None,
        "MOS1": InstrumentModel(gain=ConstantGain(), shift=ConstantShift()),
        "MOS2": InstrumentModel(gain=ConstantGain(), shift=ConstantShift()),
    },
)

None entries and omitted observations apply the identity fold (transfer_matrix @ flux), allowing one instrument to serve as the reference.

Parameters:

Name Type Description Default
gain GainModel | None

Optional GainModel (e.g. ConstantGain). When None, no flux scaling is applied.

None
shift ShiftModel | None

Optional ShiftModel (e.g. ConstantShift). When None, the input energies pass through unchanged.

None
Source code in src/jaxspec/model/instrument.py
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 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
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
class InstrumentModel(HasDerivedQuantities, nnx.Module):
    """Per-observation instrument response.

    Pass as a dict to [`BayesianModel`][jaxspec.fit.BayesianModel]

    ```python
    BayesianModel(
        spectral_model, prior, observations,
        instrument_model={
            "PN": None,
            "MOS1": InstrumentModel(gain=ConstantGain(), shift=ConstantShift()),
            "MOS2": InstrumentModel(gain=ConstantGain(), shift=ConstantShift()),
        },
    )
    ```

    ``None`` entries and omitted observations apply the identity fold
    (``transfer_matrix @ flux``), allowing one instrument to serve as the reference.

    Parameters:
        gain: Optional [`GainModel`][jaxspec.model.instrument.GainModel] (e.g. [`ConstantGain`][jaxspec.model.instrument.ConstantGain]). When
            ``None``, no flux scaling is applied.
        shift: Optional [`ShiftModel`][jaxspec.model.instrument.ShiftModel] (e.g. [`ConstantShift`][jaxspec.model.instrument.ConstantShift]). When
            ``None``, the input energies pass through unchanged.
    """

    #: Whether ``fold`` requires separate response components in its cache.
    requires_components = False

    def __init__(self, gain: GainModel | None = None, shift: ShiftModel | None = None):
        self.gain = gain
        self.shift = shift

    def default_prior(self, observation: ObsConfiguration, obs_name: str) -> dict:
        """Return data-dependent default priors scoped to this obs.

        Mirrors [`BackgroundModel.default_prior`][jaxspec.model.background.BackgroundModel.default_prior]:
        subclasses (e.g. pileup models with per-obs dead-time / grade-fraction
        parameters) override this to inject ``[obs_name]``-scoped defaults.
        User prior entries override these defaults.
        """
        return {}

    def apply_shift(self, energies: ArrayLike) -> ArrayLike:
        """Apply ``shift`` to ``energies`` and clip away non-positive values."""
        if self.shift is None:
            return energies
        return jnp.clip(self.shift(energies), min=1e-6)

    def apply_gain(self, flux, energies: ArrayLike):
        """Multiply ``flux`` (or each branch in a pytree) by ``gain``'s factor."""
        if self.gain is None:
            return flux
        factor = jnp.clip(self.gain(energies), min=0.0)
        return jax.tree.map(lambda f: f * factor, flux)

    def _redistribute_to_native(self, spectrum, cache: dict, eval_energies: ArrayLike):
        """Resample ``spectrum`` from ``eval_energies`` onto the response's native grid.

        Shared by every subclass so the shift convention cannot diverge between folds.

        The shift goes on the **target** (native) grid, not the source grid. The spectrum
        was integrated on the *unshifted* ``eval_energies``, so bin ``i`` must collect the
        flux from its shifted band — which matches the no-grid path, where ``flux_func``
        is evaluated on the shifted native energies directly. Shifting the source grid
        instead is the same operation with the opposite sign, and a fit would then
        recover ``-offset``.
        """
        target_energies = self.apply_shift(cache["in_energies"])
        return jax.tree.map(lambda s: redistribute(s, *eval_energies, *target_energies), spectrum)

    def fold(
        self,
        spectrum: ArrayLike,
        cache: dict,
        eval_energies: ArrayLike | None = None,
    ):
        """
        Fold the input spectrum (or branches of a pytree) into the instrument using the pre-computed transfer matrix.
        """

        if eval_energies is not None:
            spectrum = self._redistribute_to_native(spectrum, cache, eval_energies)

        spectrum = jax.tree.map(lambda s: self.apply_gain(s, cache["in_energies"]), spectrum)

        return jax.tree.map(lambda s: jnp.clip(cache["transfer_matrix"] @ s, min=1e-6), spectrum)

apply_gain(flux, energies)

Multiply flux (or each branch in a pytree) by gain's factor.

Source code in src/jaxspec/model/instrument.py
121
122
123
124
125
126
def apply_gain(self, flux, energies: ArrayLike):
    """Multiply ``flux`` (or each branch in a pytree) by ``gain``'s factor."""
    if self.gain is None:
        return flux
    factor = jnp.clip(self.gain(energies), min=0.0)
    return jax.tree.map(lambda f: f * factor, flux)

apply_shift(energies)

Apply shift to energies and clip away non-positive values.

Source code in src/jaxspec/model/instrument.py
115
116
117
118
119
def apply_shift(self, energies: ArrayLike) -> ArrayLike:
    """Apply ``shift`` to ``energies`` and clip away non-positive values."""
    if self.shift is None:
        return energies
    return jnp.clip(self.shift(energies), min=1e-6)

default_prior(observation, obs_name)

Return data-dependent default priors scoped to this obs.

Mirrors BackgroundModel.default_prior: subclasses (e.g. pileup models with per-obs dead-time / grade-fraction parameters) override this to inject [obs_name]-scoped defaults. User prior entries override these defaults.

Source code in src/jaxspec/model/instrument.py
105
106
107
108
109
110
111
112
113
def default_prior(self, observation: ObsConfiguration, obs_name: str) -> dict:
    """Return data-dependent default priors scoped to this obs.

    Mirrors [`BackgroundModel.default_prior`][jaxspec.model.background.BackgroundModel.default_prior]:
    subclasses (e.g. pileup models with per-obs dead-time / grade-fraction
    parameters) override this to inject ``[obs_name]``-scoped defaults.
    User prior entries override these defaults.
    """
    return {}

fold(spectrum, cache, eval_energies=None)

Fold the input spectrum (or branches of a pytree) into the instrument using the pre-computed transfer matrix.

Source code in src/jaxspec/model/instrument.py
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
def fold(
    self,
    spectrum: ArrayLike,
    cache: dict,
    eval_energies: ArrayLike | None = None,
):
    """
    Fold the input spectrum (or branches of a pytree) into the instrument using the pre-computed transfer matrix.
    """

    if eval_energies is not None:
        spectrum = self._redistribute_to_native(spectrum, cache, eval_energies)

    spectrum = jax.tree.map(lambda s: self.apply_gain(s, cache["in_energies"]), spectrum)

    return jax.tree.map(lambda s: jnp.clip(cache["transfer_matrix"] @ s, min=1e-6), spectrum)

PileupModel

Bases: InstrumentModel

Per-observation instrument response including CCD photon pile-up.

The jaxspec counterpart of XSPEC pileup convolution model (Davis 2001) which describes the mixing of photons that reach the same detector region within a single CCD frame, the dominant calibration effect for bright point sources on instruments such as Chandra/ACIS. PileupModel extends InstrumentModel: pile-up is applied to the effective-area–weighted (ARF) spectrum, before the redistribution matrix (RMF) and grouping, matching the ordering of the XSPEC pileup model.

Warning

PileupModel requires an explicit and linear evaluation grid (pass energy_grid=jnp.linspace(low, high, n_bins) to the fitter / BayesianModel).

Pass it per observation just like a plain InstrumentModel:

from jaxspec.fit import MCMCFitter
from jaxspec.model.instrument import ConstantGain, ConstantShift, PileupModel

fitter = MCMCFitter(
    spectral_model,
    prior,
    observations,
    energy_grid=np.linspace(0.2, 11.0, 1_000),
    instrument_model={
        "ACIS": PileupModel(
            gain=ConstantGain(),
            shift=ConstantShift(),
            frame_time=3.2,  # CCD frame time / ACIS EXPTIME (s)
            frac_expo=1.0,  # ARF FRACEXPO keyword
        ),
    },
)

alpha and psf_frac are fitted parameters whose priors are supplied through the prior dictionary like any other instrument parameter, e.g. "instrument.alpha" and "instrument.psf_frac" (see MCMCFitter). The remaining arguments are fixed configuration constants.

Note

Pile-up is non-linear in flux: as noted in the XSPEC pileup documentation, increasing the source normalisation does not scale the predicted count rate linearly, and fluxes should be computed with the pile-up correction removed. The extraction region should be large enough to contain essentially all of the point-source PSF.

Parameters:

Name Type Description Default
gain GainModel | None

Optional energy-independent flux scaling, as for InstrumentModel (e.g. ConstantGain).

None
shift ShiftModel | None

Optional energy shift, as for InstrumentModel (e.g. ConstantShift).

None
frame_time float | None

CCD frame (readout) time in seconds (EXPTIME keyword).

None
frac_expo float | None

Fractional exposure per frame in (0, 1] (FRACEXPO keyword).

None
g0 float | None

Grade correction for single-photon detection.

1.0
npiled int | None

Maximum number of photons piled up in a single frame

5
num_regions float | None

Number of regions over which the piled counts are distributed, 1 for a point source.

1.0

Attributes:

Name Type Description
alpha

Grade-morphing parameter — the fraction of piled events that keep a good grade, with the good-grade fraction of an order-p pile-up taken proportional to alpha ** (p - 1). Fitted; give it a prior under "instrument.alpha".

psf_frac

PSF fraction — only this fraction of the extracted counts is treated for pile-up. Fitted; give it a prior under "instrument.psf_frac".

Source code in src/jaxspec/model/instrument.py
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
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
228
229
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
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
class PileupModel(InstrumentModel):
    """Per-observation instrument response including CCD photon **pile-up**.

    The `jaxspec` counterpart of `XSPEC`
    [`pileup`](https://heasarc.gsfc.nasa.gov/docs/software/xspec/manual/XSmodelPileup.html)
    convolution model ([Davis 2001](https://iopscience.iop.org/article/10.1086/323488/pdf)) which
    describes the mixing of photons that reach the same detector region within a
    single CCD frame, the dominant calibration effect for bright point sources on
    instruments such as Chandra/ACIS. [`PileupModel`][jaxspec.model.instrument.PileupModel] extends
    [`InstrumentModel`][jaxspec.model.instrument.InstrumentModel]: pile-up is
    applied to the effective-area–weighted (ARF) spectrum, *before* the
    redistribution matrix (RMF) and grouping, matching the ordering of the XSPEC
    `pileup` model.

    !!! warning
        [`PileupModel`][jaxspec.model.instrument.PileupModel] requires an explicit and linear evaluation
        grid (pass ``energy_grid=jnp.linspace(low, high, n_bins)`` to the fitter /
        [`BayesianModel`][jaxspec.fit.BayesianModel]).

    Pass it per observation just like a plain
    [`InstrumentModel`][jaxspec.model.instrument.InstrumentModel]:

    ```python
    from jaxspec.fit import MCMCFitter
    from jaxspec.model.instrument import ConstantGain, ConstantShift, PileupModel

    fitter = MCMCFitter(
        spectral_model,
        prior,
        observations,
        energy_grid=np.linspace(0.2, 11.0, 1_000),
        instrument_model={
            "ACIS": PileupModel(
                gain=ConstantGain(),
                shift=ConstantShift(),
                frame_time=3.2,  # CCD frame time / ACIS EXPTIME (s)
                frac_expo=1.0,  # ARF FRACEXPO keyword
            ),
        },
    )
    ```

    `alpha` and `psf_frac` are **fitted** parameters whose
    priors are supplied through the prior dictionary like any other instrument
    parameter, e.g. ``"instrument.alpha"`` and ``"instrument.psf_frac"`` (see
    [`MCMCFitter`][jaxspec.fit.MCMCFitter]). The remaining arguments are **fixed**
    configuration constants.

    !!! note
        Pile-up is **non-linear** in flux: as noted in the
        [XSPEC `pileup` documentation](https://heasarc.gsfc.nasa.gov/docs/software/xspec/manual/XSmodelPileup.html),
        increasing the source normalisation does not scale the predicted count
        rate linearly, and fluxes should be computed with the pile-up correction
        removed. The extraction region should be large enough to contain
        essentially all of the point-source PSF.

    Parameters:
        gain: Optional energy-independent flux scaling, as for
            [`InstrumentModel`][jaxspec.model.instrument.InstrumentModel] (e.g.
            [`ConstantGain`][jaxspec.model.instrument.ConstantGain]).
        shift: Optional energy shift, as for
            [`InstrumentModel`][jaxspec.model.instrument.InstrumentModel] (e.g.
            [`ConstantShift`][jaxspec.model.instrument.ConstantShift]).
        frame_time: CCD frame (readout) time in seconds (``EXPTIME`` keyword).
        frac_expo: Fractional exposure per frame in ``(0, 1]`` (``FRACEXPO`` keyword).
        g0: Grade correction for single-photon detection.
        npiled: Maximum number of photons piled up in a single frame
        num_regions: Number of regions over which the piled counts are distributed, `1` for a point source.

    Attributes:
        alpha: Grade-morphing parameter — the fraction of piled events that keep a good
            grade, with the good-grade fraction of an order-``p`` pile-up taken
            proportional to ``alpha ** (p - 1)``. Fitted; give it a prior under
            ``"instrument.alpha"``.
        psf_frac: PSF fraction — only this fraction of the extracted counts is treated for
            pile-up. Fitted; give it a prior under ``"instrument.psf_frac"``.
    """

    requires_components = True

    def __init__(
        self,
        gain: GainModel | None = None,
        shift: ShiftModel | None = None,
        frac_expo: float | None = None,
        frame_time: float | None = None,
        num_regions: float | None = 1.0,
        g0: float | None = 1.0,
        npiled: int | None = 5,
    ):
        super().__init__(gain=gain, shift=shift)

        if frac_expo is None or frame_time is None:
            raise ValueError(
                "PileupModel requires both `frac_expo` and `frame_time` keyword arguments "
                "(the 'FRACEXPO' and 'EXPTIME' header values)."
            )

        self.alpha = nnx.Param(jnp.asarray(0.5))
        self.psf_frac = nnx.Param(jnp.asarray(0.95))

        self._constants = {
            "frac_expo": frac_expo,
            "frame_time": frame_time,
            "num_regions": num_regions,
            "g0": g0,
            "npiled": int(np.rint(npiled)),
        }

    def fold(
        self,
        spectrum: ArrayLike,
        cache: dict,
        eval_energies: ArrayLike | None = None,
    ):
        if eval_energies is None:
            raise ValueError("Eval energies cannot be None : an energy grid must be provided")

        spectrum = self._redistribute_to_native(spectrum, cache, eval_energies)

        num_regions = self._constants["num_regions"]
        fracexpo = self._constants["frac_expo"]
        frame_time = self._constants["frame_time"]
        g0 = self._constants["g0"]
        npiled = self._constants["npiled"]

        in_energies = cache["in_energies"]
        # Align FFT indices when the uniform energy grid starts above zero.
        bin_width = in_energies[0, 1] - in_energies[0, 0]
        ioff = -jnp.array(in_energies[0, 0] // bin_width, jnp.int_)
        n_orig = in_energies.shape[1]
        shift_idx = jnp.arange(n_orig) + ioff

        def pileup_fold(s):
            arf_s = s * cache["area"]

            psf_frac = self.psf_frac / num_regions / fracexpo
            arf_s_tmp = arf_s * psf_frac
            integ_arf_s = jnp.sum(arf_s_tmp)

            results = arf_s * psf_frac  # term p=1 of the sum

            exp_factor = jnp.exp(-frame_time * integ_arf_s / g0)
            exp_factor = exp_factor * num_regions * fracexpo

            # Normalize to avoid overflow and perform FFT convolutions
            arf_s_tmp = arf_s_tmp / integ_arf_s
            integ_arf_s_n = integ_arf_s  # for renormalization after
            arf_s_fft = jnp.fft.rfft(arf_s_tmp)
            factor = 1

            # Compute FFT with offset
            tmpar = arf_s_tmp[shift_idx]
            arf_s_fft_2 = jnp.fft.rfft(tmpar)

            # Calculate higher order terms
            for i in range(2, npiled + 1):
                integ_arf_s_n = integ_arf_s_n * integ_arf_s  # renormalization factor

                # Convolution via FFT
                conv = jnp.fft.irfft(arf_s_fft * arf_s_fft_2 ** (i - 1), n=n_orig)
                conv = jnp.clip(conv, min=0)

                # Apply grade migration factor
                factor = factor * self.alpha * frame_time / i
                results = results + factor * integ_arf_s_n * conv

            # Apply final corrections
            results = results * exp_factor

            # Handle non-piled fraction
            remaining_frac = 1.0 - self.psf_frac
            results = results + arf_s * jnp.clip(remaining_frac, min=0)

            results = self.apply_gain(results, in_energies)

            return jnp.clip(
                cache["grouping"] @ cache["redistribution"] @ results * cache["exposure"],
                min=1e-10,
            )

        return jax.tree.map(pileup_fold, spectrum)

ShiftModel

Bases: HasDerivedQuantities, Module

Generic shift model. __call__(energies) returns shifted energies.

Source code in src/jaxspec/model/instrument.py
49
50
51
52
53
class ShiftModel(HasDerivedQuantities, nnx.Module):
    """Generic shift model. ``__call__(energies)`` returns shifted energies."""

    @abstractmethod
    def __call__(self, energies: ArrayLike) -> ArrayLike: ...