pygspice 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
gspice/__init__.py ADDED
@@ -0,0 +1,80 @@
1
+ """Gaussian pixelwise conditional estimation for spectra."""
2
+
3
+ import logging
4
+ import warnings
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from . import legacy
8
+ from ._blocks import fit_blocks
9
+ from ._datasets import Simulation, simulate
10
+ from ._diagnostics import Calibration, calibration, finite_sample_factor, noise_audit
11
+ from ._model import Blocks, LowRank, Model, fit, load, save
12
+ from ._predict import Prediction, Repaired, infill, pixelwise
13
+ from ._repair import repair
14
+ from ._spectra import scale, snr, unscale
15
+
16
+ # Without a handler, Python prints the records of level WARNING and above to stderr.
17
+ # With one, the application decides where they go.
18
+ logging.getLogger("gspice").addHandler(logging.NullHandler())
19
+
20
+ __version__ = "2.0.0"
21
+
22
+ __all__ = [
23
+ "Blocks",
24
+ "Calibration",
25
+ "LowRank",
26
+ "Model",
27
+ "Prediction",
28
+ "Repaired",
29
+ "Simulation",
30
+ "calibration",
31
+ "finite_sample_factor",
32
+ "fit",
33
+ "fit_blocks",
34
+ "infill",
35
+ "legacy",
36
+ "load",
37
+ "noise_audit",
38
+ "pixelwise",
39
+ "repair",
40
+ "save",
41
+ "scale",
42
+ "simulate",
43
+ "snr",
44
+ "unscale",
45
+ ]
46
+
47
+ # The 2022 names that gspice no longer defines, with their replacements in the new API
48
+ # (design section 7.2). None: the function has no replacement outside gspice.legacy.
49
+ _REPLACEMENT = {
50
+ "standard_scale": "scale",
51
+ "covar": "fit",
52
+ "covar_iter_mask": "fit",
53
+ "get_chimask": "pixelwise",
54
+ "gp_interp": "pixelwise",
55
+ "gaussian_estimate": "infill",
56
+ "cholesky_inv": None,
57
+ "submatrix_inv": None,
58
+ "submatrix_inv_mult": None,
59
+ "maskinterp1": None,
60
+ "maskinterp": None,
61
+ }
62
+
63
+
64
+ # PEP 562: gspice.<name> of a 2022 name returns gspice.legacy.<name> with a
65
+ # DeprecationWarning. Type checkers do not see the hook, so they report a misspelled or
66
+ # deprecated name instead of typing every attribute of gspice as Any.
67
+ if not TYPE_CHECKING:
68
+
69
+ def __getattr__(name: str) -> Any:
70
+ if name not in _REPLACEMENT:
71
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
72
+ use = f"gspice.legacy.{name}"
73
+ if replacement := _REPLACEMENT[name]:
74
+ use = f"gspice.{replacement}, or {use} to keep the 2022 results"
75
+ warnings.warn(
76
+ f"gspice.{name} is deprecated and will be removed in 3.0.0. Use {use}.",
77
+ DeprecationWarning,
78
+ stacklevel=2,
79
+ )
80
+ return getattr(legacy, name)
gspice/_blocks.py ADDED
@@ -0,0 +1,308 @@
1
+ import logging
2
+ import math
3
+ import time
4
+ from fractions import Fraction
5
+ from typing import Any, NamedTuple
6
+
7
+ import numpy as np
8
+ from numpy.linalg import LinAlgError
9
+ from numpy.typing import ArrayLike, NDArray
10
+ from scipy.linalg import lapack
11
+
12
+ from ._linalg import factor_pattern, factor_prior
13
+ from ._model import NGUARD_MAX, Blocks, Model, submatrix_of
14
+ from ._predict import filled, noisy, spans, validated, working
15
+ from ._spectra import (
16
+ Integer,
17
+ Progress,
18
+ Real,
19
+ check_progress,
20
+ integer,
21
+ real,
22
+ to_flux,
23
+ to_working,
24
+ )
25
+
26
+ _logger = logging.getLogger("gspice")
27
+
28
+
29
+ def fit_blocks(
30
+ model: Model,
31
+ flux: ArrayLike,
32
+ ivar: ArrayLike,
33
+ mask: ArrayLike | None = None,
34
+ *,
35
+ width: Integer = 150,
36
+ stride: Integer = 75,
37
+ nguard: Integer = 20,
38
+ percentile: Real = 99.9,
39
+ progress: Progress | None = None,
40
+ ) -> Model:
41
+ r"""Return the model with block thresholds from clean spectra.
42
+
43
+ Block :math:`j` holds the ``width`` pixels from ``starts[j]``. The starts are 0,
44
+ ``stride``, 2 ``stride`` and so on, up to npix − ``width``. If the last of them is
45
+ smaller than npix − ``width``, the start npix − ``width`` follows. The defaults
46
+ give 31 blocks at npix 2400. fit_blocks first replaces the bad pixels by their
47
+ conditional mean, as `infill` does. It does not detect outliers by z.
48
+
49
+ Take a spectrum in S/N bin :math:`b`, with :math:`x` the working vector of its
50
+ infilled flux (see `Model`) and :math:`P` the inverse of ``cov[b]``. Let :math:`A`
51
+ be a block, :math:`G` the pixels within ``nguard`` of :math:`A`, clipped at the
52
+ ends, and :math:`O` the pixels outside :math:`G`. Then
53
+
54
+ .. math::
55
+
56
+ \chi^2_A = \frac{r_A^\mathsf{T} C_{AA}^{-1} r_A}{|A|}, \qquad r_A = x_A - \mu_A,
57
+
58
+ where :math:`\mu_A` and :math:`C_{AA} = (P_{GG}^{-1})_{AA}` are the conditional mean
59
+ and covariance of :math:`x_A` given :math:`x_O`. The guard pixels
60
+ :math:`G \setminus A` are thus marginalized. Where :math:`G` covers all pixels,
61
+ :math:`\mu_A` and :math:`C_{AA}` are those of the prior, 0 and ``cov[b]`` at
62
+ :math:`A`, computed without :math:`P`.
63
+
64
+ ``thresholds[b, j]`` is ``np.percentile`` (linear) of :math:`\chi^2_A` of block
65
+ :math:`j`, at :math:`p` = ``percentile``, over the :math:`m` spectra of bin
66
+ :math:`b`. A block without an artifact then exceeds its threshold with the
67
+ probability
68
+
69
+ .. math::
70
+
71
+ \frac{m (1 - p/100) + p/100}{m + 1}
72
+
73
+ if :math:`(m - 1) p/100` is an integer. Otherwise the probability is at most this
74
+ value, for a density that falls beyond the threshold. For :math:`p = 99.9`, it is
75
+ 0.20% at :math:`m = 1000` and 0.12% at :math:`m = 5000`. `repair` also infills the
76
+ pixels that z flags, which lowers this rate. Use clean held-out spectra.
77
+
78
+ For a noise-aware model, :math:`x` is in flux units. The covariance :math:`C_s` of
79
+ each spectrum (see `Model`) then replaces ``cov[b]`` in its infill and its blocks.
80
+
81
+ Parameters
82
+ ----------
83
+ model : Model
84
+ The model.
85
+ flux, ivar, mask : array_like, shape (nspec, npix)
86
+ As in `scale`, with the npix of ``model``. Clean spectra, with at least
87
+ :math:`m` spectra in each bin such that :math:`m (100 - p) \ge 100`. The check
88
+ reads :math:`p` as the decimal ``Fraction(str(p))``: 1000 spectra at
89
+ :math:`p = 99.9`.
90
+ width : int, optional
91
+ The number of pixels per block, :math:`|A|`; from 1 to npix.
92
+ stride : int, optional
93
+ The distance between the starts of consecutive blocks (pixels); 1 or more.
94
+ nguard : int, optional
95
+ The half-width of the guard window around each block (pixels); from 0 to
96
+ :math:`2^{63} - 1`, as for `Blocks`.
97
+ percentile : float, optional
98
+ The percentile :math:`p` of the thresholds; :math:`0 < p < 100`.
99
+ progress : callable, optional
100
+ ``progress(stage, done, total)``, called after each chunk of spectra, with the
101
+ stage ``"fit_blocks"``, the number of spectra done and their total. For a
102
+ noise-aware model, a chunk is one spectrum.
103
+
104
+ Returns
105
+ -------
106
+ Model
107
+ A new model, equal to ``model`` except for ``blocks``: a `Blocks` that holds the
108
+ starts, width, nguard and thresholds.
109
+
110
+ Raises
111
+ ------
112
+ TypeError
113
+ If ``model`` is not a Model, or ``width``, ``stride`` or ``nguard`` is not an
114
+ integer. If ``percentile`` is not a real number, or ``progress`` is not callable
115
+ or None. If ``flux``, ``ivar`` or ``mask`` has a dtype that `scale` does not
116
+ allow.
117
+ ValueError
118
+ If ``flux``, ``ivar`` or ``mask`` has a shape or a value that `scale` does not
119
+ allow. If ``flux`` has another npix than ``model``, or a spectrum has no good
120
+ pixel. If ``width``, ``stride``, ``nguard`` or ``percentile`` is outside its
121
+ range. If a bin has fewer spectra than :math:`p` needs; the message names the
122
+ bin and its count. If a threshold is not finite or is 0, as for spectra equal to
123
+ their conditional mean.
124
+ numpy.linalg.LinAlgError
125
+ If the covariance of a bin fails the check of its precision matrix, as in
126
+ `pixelwise`, and the :math:`G` of a block does not cover all pixels.
127
+ If :math:`P_{GG}` of a block is not positive definite. If ``cov[b]`` at
128
+ :math:`T = A` fails that check, for a block whose :math:`G` covers all pixels.
129
+ Also as in `infill`, for the bad pixels. The message names the bin, and the
130
+ block or the row of a pattern. For a noise-aware model, as in `infill`, with
131
+ :math:`C_s` in place of ``cov[b]``.
132
+
133
+ Notes
134
+ -----
135
+ Cost:
136
+
137
+ - :math:`\mathrm{npix}^3` flops for :math:`P` at the first use of a bin, then
138
+ :math:`|G|^3/3` flops per bin and block.
139
+ - About :math:`2\,\mathrm{npix}^2 + \sum_A |G|^2` flops per spectrum, plus the
140
+ `infill` of the bad pixels, of the mean only.
141
+ - The factors of all bins take :math:`\mathrm{nbin} \sum_A |G|^2 \times 8` bytes
142
+ during the scan: 8.8 MB per bin at npix 2400 with the defaults, and 649 MB at
143
+ ``stride=1``.
144
+ - Beyond the inputs, and their float64 copies if they are long double, fit_blocks
145
+ holds a bool array of the input shape. It also holds the :math:`\chi^2_A` of the
146
+ spectra of one bin (nblock float64 per spectrum), and a few float64 arrays of one
147
+ chunk of ``max(1, 2**24 // npix)`` spectra.
148
+ - The new model shares the arrays of ``model``, its precision matrices and its guard
149
+ weights (see `Model`).
150
+ - Unless every block covers all pixels with its guard, a noise-aware model forms
151
+ :math:`P_s` and the factors of the blocks for each spectrum. :math:`P_s` takes
152
+ :math:`\mathrm{npix}^3` flops for a dense ``cov[0]``, or :math:`\mathrm{npix}^2 K`
153
+ for a `LowRank` of rank :math:`K`. Otherwise the infill of the bad pixels forms no
154
+ :math:`P_s`, and costs as in `infill`.
155
+ """
156
+ start = time.perf_counter()
157
+ width = integer(width, "width", 1)
158
+ stride = integer(stride, "stride", 1)
159
+ nguard = integer(nguard, "nguard", 0, NGUARD_MAX)
160
+ percentile = real(percentile, "percentile")
161
+ if not 0 < percentile < 100:
162
+ raise ValueError(
163
+ f"percentile: expected a number > 0 and < 100, got {percentile}"
164
+ )
165
+ check_progress(progress)
166
+ flux, ivar, bad = validated(model, flux, ivar, mask, inflate=False)
167
+ nspec, npix = flux.shape
168
+ if width > npix:
169
+ raise ValueError(
170
+ f"width: expected an integer <= {npix}, the pixels of model, got {width}"
171
+ )
172
+ nbin = len(model.cov)
173
+ bins = model._bins(flux, ivar, bad)
174
+ # Decision A7, in exact arithmetic with the decimal that prints as percentile: in
175
+ # float arithmetic, 99.9 would need 1001 spectra.
176
+ p = Fraction(str(percentile))
177
+ for b, m in enumerate(np.bincount(bins, minlength=nbin).tolist()):
178
+ if m * (100 - p) < 100:
179
+ raise ValueError(
180
+ f"fit_blocks: percentile {percentile} needs at least "
181
+ f"{math.ceil(100 / (100 - p))} spectra in each S/N bin, got {m} in "
182
+ f"bin {b}"
183
+ )
184
+ # A stride above npix gives the starts of npix, and keeps arange in int64.
185
+ starts = np.append(np.arange(0, npix - width, min(stride, npix)), npix - width)
186
+ # The factors of every bin, and P of the bins with bad pixels for their infill,
187
+ # before any spectrum, so that a covariance that fails a check raises at once. A
188
+ # noise model forms them for each spectrum.
189
+ factors: list[list[BlockFactor]] = []
190
+ if not model.noise:
191
+ factors = [block_factors(model, b, starts, width, nguard) for b in range(nbin)]
192
+ for b in np.unique(bins[bad.any(axis=1)]).tolist():
193
+ model._precision(b)
194
+ thresholds = np.empty((nbin, starts.size))
195
+ done = 0
196
+ for b in range(nbin):
197
+ rows = np.flatnonzero(bins == b)
198
+ chi2 = np.empty((rows.size, starts.size))
199
+ for c in spans(model, rows.size, npix):
200
+ spectra = rows[c]
201
+ cover = (
202
+ noisy(model, ivar[spectra], bad[spectra], int(spectra[0]))
203
+ if model.noise
204
+ else model
205
+ )
206
+ F = (
207
+ block_factors(cover, 0, starts, width, nguard)
208
+ if model.noise
209
+ else factors[b]
210
+ )
211
+ X = _scan_vectors(model, cover, b, flux, ivar, bad, spectra)
212
+ chi2[c] = block_chi2(F, X)
213
+ del cover, F
214
+ done += spectra.size
215
+ if progress is not None:
216
+ progress("fit_blocks", done, nspec)
217
+ thresholds[b] = np.percentile(chi2, percentile, axis=0)
218
+ _logger.debug(
219
+ "fit_blocks: %d spectra, %d blocks in %.3f s",
220
+ nspec,
221
+ starts.size,
222
+ time.perf_counter() - start,
223
+ )
224
+ return model._with_blocks(Blocks(starts, width, nguard, thresholds))
225
+
226
+
227
+ def _scan_vectors(
228
+ model: Model,
229
+ cover: Model,
230
+ b: int,
231
+ flux: NDArray[Any],
232
+ ivar: NDArray[Any],
233
+ bad: NDArray[np.bool_],
234
+ spectra: NDArray[np.intp],
235
+ ) -> NDArray[np.float64]:
236
+ # The working vectors x of the infilled flux of the spectra of bin b, under the
237
+ # covariance of cover.
238
+ bins = np.full(spectra.size, b)
239
+ G = bad[spectra]
240
+ X, a = working(model, flux[spectra], ivar[spectra], G, bins)
241
+ X = filled(cover, X, bins, spectra, G, G)
242
+ # Through flux units, as the docstring of fit_blocks defines x.
243
+ center = 0.0 if model.center is None else model.center
244
+ x = np.array(flux[spectra], dtype=np.float64)
245
+ to_flux(X, a, model.mean[b], center, out=X)
246
+ x[G] = X[G]
247
+ to_working(x, a, center, model.mean[b])
248
+ return x
249
+
250
+
251
+ class BlockFactor(NamedTuple):
252
+ # Block A and G, the pixels within nguard of A clipped at the ends; order lists the
253
+ # pixels of G, counted from G.start, as (G \ A, A). R is the lower Cholesky factor
254
+ # of P_GG in that order, with P the precision matrix of the bin. Where G covers all
255
+ # pixels, P is None and R is the factor of C_AA, the prior (design section 4.2).
256
+ A: slice
257
+ G: slice
258
+ order: NDArray[np.intp]
259
+ R: NDArray[np.float64]
260
+ P: NDArray[np.float64] | None
261
+
262
+
263
+ def block_factors(
264
+ model: Model, b: int, starts: NDArray[np.int64], width: int, nguard: int
265
+ ) -> list[BlockFactor]:
266
+ npix = model.mean.shape[1]
267
+ factors = []
268
+ # Python integers, so that a large nguard cannot overflow.
269
+ for k, start in enumerate(starts.tolist()):
270
+ A = slice(start, start + width)
271
+ G = slice(max(start - nguard, 0), min(start + width + nguard, npix))
272
+ order = np.r_[G.start : A.start, A.stop : G.stop, A] - G.start
273
+ P = None if G.stop - G.start == npix else model._precision(b)
274
+ try:
275
+ if P is None:
276
+ R = factor_prior(submatrix_of(model.cov[b], np.arange(A.start, A.stop)))
277
+ else:
278
+ R = factor_pattern(P[G, G][np.ix_(order, order)])
279
+ except LinAlgError as error:
280
+ raise LinAlgError(f"{model._name(b)} is {error}, for block {k}") from None
281
+ factors.append(BlockFactor(A, G, order, R, P))
282
+ return factors
283
+
284
+
285
+ def block_chi2(
286
+ factors: list[BlockFactor], X: NDArray[np.float64]
287
+ ) -> NDArray[np.float64]:
288
+ # chi2_A (nspec, nblock) of the working vectors X of spectra of the bin of factors.
289
+ chi2 = np.empty((len(X), len(factors)))
290
+ Y = None
291
+ for k, (A, G, order, R, P) in enumerate(factors):
292
+ width = A.stop - A.start
293
+ if P is None:
294
+ # ||L^-1 x_A||^2 with L L^T = C_AA, the prior covariance of x_A.
295
+ w, _ = lapack.dtrtrs(R, X[:, A].T, lower=1)
296
+ else:
297
+ if Y is None:
298
+ # (P x)^T of every spectrum, once for all blocks: P is symmetric.
299
+ Y = X @ P
300
+ # y = R^-1 (P x)_G in the order of R. As R R^T = P_GG and r_G = x_G - mu_G
301
+ # = P_GG^-1 (P x)_G, R^T r_G = y, so R_AA^T r_A = y_A (R^T is upper
302
+ # triangular) and chi2_A |A| = ||y_A||^2. The guard pixels enter y as R^T
303
+ # x_(G \ A), which is 0 in the rows of A, so an outlier there cancels to
304
+ # rounding (test_blocks bounds it).
305
+ w, _ = lapack.dtrtrs(R, np.take(Y, G.start + order, axis=1).T, lower=1)
306
+ w = w[-width:]
307
+ chi2[:, k] = np.sum(w**2, axis=0) / width
308
+ return chi2
gspice/_datasets.py ADDED
@@ -0,0 +1,237 @@
1
+ import math
2
+ from dataclasses import dataclass
3
+
4
+ import numpy as np
5
+ from numpy.typing import NDArray
6
+ from scipy.special import expit, logit
7
+
8
+ from ._spectra import Boolean, Integer, Real, boolean, check_rng, integer, real
9
+
10
+ MAX_DEPTH = 0.95
11
+
12
+
13
+ @dataclass(frozen=True, eq=False)
14
+ class Simulation:
15
+ r"""Synthetic spectra from `simulate`, with their noise-free flux and artifacts.
16
+
17
+ The arrays that `simulate` returns are read-only.
18
+
19
+ Attributes
20
+ ----------
21
+ flux : ndarray, shape (nspec, npix)
22
+ The flux, with noise and artifacts (flux).
23
+ ivar : ndarray, shape (nspec, npix)
24
+ The inverse variance of the noise, :math:`1/\sigma^2` (flux⁻²).
25
+ truth : ndarray, shape (nspec, npix)
26
+ The noise-free flux without artifacts (flux).
27
+ artifacts : ndarray of bool, shape (nspec, npix)
28
+ The pixels that an injected artifact covers.
29
+ snr : ndarray, shape (nspec,)
30
+ The continuum signal-to-noise ratio that each spectrum was drawn with
31
+ (unit-free).
32
+ labels : ndarray of int64, shape (nspec,)
33
+ The population of each spectrum: 0, or 1 for the giants.
34
+ """
35
+
36
+ flux: NDArray[np.float64]
37
+ ivar: NDArray[np.float64]
38
+ truth: NDArray[np.float64]
39
+ artifacts: NDArray[np.bool_]
40
+ snr: NDArray[np.float64]
41
+ labels: NDArray[np.int64]
42
+
43
+
44
+ def simulate(
45
+ nspec: Integer,
46
+ npix: Integer = 500,
47
+ *,
48
+ rng: np.random.Generator,
49
+ artifacts: Real = 0.0,
50
+ gaussian: Boolean = False,
51
+ ) -> Simulation:
52
+ r"""Synthetic continuum-normalized spectra for tests and examples (no network).
53
+
54
+ Each spectrum has the continuum 1 and
55
+ :math:`\mathrm{nline} = \lceil \mathrm{npix}/25 \rceil` absorption lines
56
+ :math:`j = 1, \dots, \mathrm{nline}` at evenly spaced centres. Line :math:`j` has a
57
+ Gaussian profile :math:`p_j`, with a standard deviation from 1.5 to 4 pixels.
58
+ The lines depend only on npix. Two latent parameters :math:`\theta` set the depths.
59
+ A spectrum is in population 1, the giants, with the probability 0.3, and then has
60
+ :math:`\theta \sim N((0, 2), I)`. Otherwise :math:`\theta \sim N(0, I)`.
61
+ Line :math:`j` has the depth
62
+
63
+ .. math::
64
+
65
+ \mathrm{depth}_j = 0.95\,\mathrm{expit}\bigl(\mathrm{logit}(d_j / 0.95)
66
+ + r_j \cdot \theta\bigr),
67
+
68
+ with :math:`d_j` from 0.1 to 0.6 its depth at :math:`\theta = 0`, and
69
+ :math:`r_j = 0.5 (\cos j, \sin j)`. The depths are thus correlated, in (0, 0.95),
70
+ and not Gaussian. Then :math:`\mathrm{truth} = 1 - \sum_j \mathrm{depth}_j\, p_j`,
71
+ in (0, 1]. The continuum S/N has the density
72
+ :math:`p(\mathrm{S/N}) \propto (\mathrm{S/N})^{-2.5}` on [30, 400]. The noise is
73
+ Gaussian with :math:`\sigma = \sqrt{\mathrm{truth}} / \mathrm{snr}`.
74
+
75
+ With ``gaussian=True``, :math:`\mathrm{truth} = \mu + J \theta` with
76
+ :math:`\theta \sim N(0, I)`. Here :math:`\mu` is the truth of population 0 at
77
+ :math:`\theta = 0`, and the columns of :math:`J` are its derivatives there.
78
+ Every spectrum has the label 0 and the S/N 50, and :math:`\sigma = \sqrt{\mu} / 50`.
79
+ So ``ivar`` is equal for all spectra. Without artifacts,
80
+ :math:`\mathrm{flux} \sim N(\mu, J J^\mathsf{T} + \mathrm{diag}(\sigma^2))`, and the
81
+ scaled spectra (see `scale`) are exactly Gaussian. truth can then leave (0, 1].
82
+
83
+ ``round(artifacts * nspec)`` spectra (half to even), chosen at random, get one
84
+ artifact each. Its type is drawn uniformly from three:
85
+
86
+ - A cosmic ray adds :math:`h \sigma` to each of 1 to 3 adjacent pixels, with
87
+ :math:`h` log-uniform on [5, 50].
88
+ - A bad column sets the flux of 5 to 20 adjacent pixels to 2.
89
+ - A shifted segment of 200 to 600 adjacent pixels :math:`i` has the flux
90
+ :math:`\mathrm{truth}(i - d)` plus the noise. The shift :math:`d` is from 0.5 to 3
91
+ pixels, of either sign. truth is interpolated linearly, and held constant beyond
92
+ its ends.
93
+
94
+ An artifact covers at most npix − 1 pixels, or 1 if npix is 1. Its start is drawn
95
+ uniformly from the starts where it fits. For one state of ``rng``, every value of
96
+ ``artifacts`` gives the same ``ivar``, ``truth``, ``snr`` and ``labels``. It also
97
+ gives the same ``flux`` outside the pixels in ``Simulation.artifacts``.
98
+
99
+ Parameters
100
+ ----------
101
+ nspec : int
102
+ The number of spectra; 0 or more.
103
+ npix : int, optional
104
+ The number of pixels; 1 or more.
105
+ rng : numpy.random.Generator
106
+ The source of all randomness. With the same libraries and thread settings, the
107
+ same state gives the same result bit for bit.
108
+ artifacts : float, optional
109
+ The fraction of spectra with an artifact; from 0 to 1.
110
+ gaussian : bool, optional
111
+ Draw exactly Gaussian spectra, as above.
112
+
113
+ Returns
114
+ -------
115
+ Simulation
116
+ The spectra.
117
+
118
+ Raises
119
+ ------
120
+ TypeError
121
+ If an argument has a type that is not allowed above.
122
+ ValueError
123
+ If an argument has a value that is not allowed above.
124
+
125
+ Notes
126
+ -----
127
+ Cost: :math:`O(\mathrm{nspec}\,\mathrm{npix})` time and memory, plus the nline
128
+ profiles of npix values each. With ``gaussian=False``, the line sum adds
129
+ :math:`2\,\mathrm{nspec}\,\mathrm{nline}\,\mathrm{npix}` flops.
130
+ """
131
+ nspec = integer(nspec, "nspec", 0)
132
+ npix = integer(npix, "npix", 1)
133
+ check_rng(rng)
134
+ artifacts = real(artifacts, "artifacts")
135
+ if not 0 <= artifacts <= 1:
136
+ raise ValueError(f"artifacts: expected a number from 0 to 1, got {artifacts}")
137
+ gaussian = boolean(gaussian, "gaussian")
138
+ profiles, depth, response = _lines(npix)
139
+ if gaussian:
140
+ mu, J = _linearization(profiles, depth, response)
141
+ truth = mu + rng.standard_normal((nspec, 2)) @ J.T
142
+ labels = np.zeros(nspec, dtype=np.int64)
143
+ # Near 47.0, the median of the S/N law below.
144
+ snr = np.full(nspec, 50.0)
145
+ ivar = np.square(snr)[:, np.newaxis] / mu
146
+ else:
147
+ labels = (rng.random(nspec) < 0.3).astype(np.int64)
148
+ theta = rng.standard_normal((nspec, 2)) + np.outer(labels, (0.0, 2.0))
149
+ depths = MAX_DEPTH * expit(logit(depth / MAX_DEPTH) + theta @ response.T)
150
+ truth = 1 - depths @ profiles
151
+ # The inverse of the distribution function of the S/N, in a form where u = 0
152
+ # gives 30 exactly and u < 1 stays below 400 by about 3.5e-15 relative.
153
+ u = rng.random(nspec)
154
+ snr = 30 * (1 - u * (1 - (30 / 400) ** 1.5)) ** (-1 / 1.5)
155
+ ivar = np.square(snr)[:, np.newaxis] / truth
156
+ # In place, so that the noise holds one temporary of the size of flux.
157
+ flux = rng.standard_normal((nspec, npix))
158
+ flux /= np.sqrt(ivar)
159
+ flux += truth
160
+ flagged = _inject(flux, ivar, truth, rng, round(artifacts * nspec))
161
+ for array in (flux, ivar, truth, flagged, snr, labels):
162
+ array.flags.writeable = False
163
+ return Simulation(flux, ivar, truth, flagged, snr, labels)
164
+
165
+
166
+ def _lines(
167
+ npix: int,
168
+ ) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]:
169
+ # The profiles (nline, npix), the depths at theta = 0 (nline,) and the responses
170
+ # (nline, 2). The fractional parts of k sqrt(2) and k sqrt(3) spread the widths and
171
+ # the depths evenly over their ranges, and the response turns by 1 radian per line.
172
+ # From k = 1, every line, also a single one, depends on both latent parameters.
173
+ nline = math.ceil(npix / 25)
174
+ k = np.arange(1, nline + 1, dtype=np.float64)
175
+ width = 1.5 + 2.5 * (k * math.sqrt(2) % 1)
176
+ depth = 0.1 + 0.5 * (k * math.sqrt(3) % 1)
177
+ # The pixels span [-0.5, npix - 0.5], and the centres divide it evenly.
178
+ centre = (k - 0.5) * npix / nline - 0.5
179
+ offset = (np.arange(npix) - centre[:, np.newaxis]) / width[:, np.newaxis]
180
+ # 0 beyond 30 widths, where a profile is below 1e-195. No sum changes, because every
181
+ # pixel lies within 8.4 widths of a line, and nothing underflows: subnormal tails
182
+ # would raise under a caller's errstate and slow the line sum twofold.
183
+ profiles = np.exp(
184
+ -0.5 * np.square(offset), out=np.zeros_like(offset), where=np.abs(offset) < 30
185
+ )
186
+ response = 0.5 * np.column_stack((np.cos(k), np.sin(k)))
187
+ return profiles, depth, response
188
+
189
+
190
+ def _linearization(
191
+ profiles: NDArray[np.float64],
192
+ depth: NDArray[np.float64],
193
+ response: NDArray[np.float64],
194
+ ) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
195
+ # mu (npix,) and J (npix, 2). At theta = 0, the depth of line k is d_k, and its
196
+ # derivative is d_k (1 - d_k / MAX_DEPTH) r_k.
197
+ slope = depth * (1 - depth / MAX_DEPTH)
198
+ return 1 - depth @ profiles, -profiles.T @ (slope[:, np.newaxis] * response)
199
+
200
+
201
+ def _inject(
202
+ flux: NDArray[np.float64],
203
+ ivar: NDArray[np.float64],
204
+ truth: NDArray[np.float64],
205
+ rng: np.random.Generator,
206
+ count: int,
207
+ ) -> NDArray[np.bool_]:
208
+ # Adds the artifacts to flux in place, and returns the pixels that they cover.
209
+ nspec, npix = flux.shape
210
+ rows = rng.choice(nspec, count, replace=False)
211
+ # 0, 1 and 2: a cosmic ray, a bad column and a shifted block.
212
+ kinds = rng.integers(3, size=count)
213
+ shortest, longest = np.array([(1, 3), (5, 20), (200, 600)])[kinds].T
214
+ widths = np.minimum(
215
+ rng.integers(shortest, longest, endpoint=True), max(npix - 1, 1)
216
+ )
217
+ starts = rng.integers(npix - widths, endpoint=True)
218
+ # u sets the height of a cosmic ray and the size of the shift of a block.
219
+ u = rng.random(count)
220
+ heights = 5 * 10**u
221
+ shifts = rng.choice((-1.0, 1.0), count) * (0.5 + 2.5 * u)
222
+ pixels = np.arange(npix)
223
+ flagged = np.zeros((nspec, npix), dtype=bool)
224
+ for s, kind, start, width, height, shift in zip(
225
+ rows, kinds, starts, widths, heights, shifts, strict=True
226
+ ):
227
+ run = slice(start, start + width)
228
+ flagged[s, run] = True
229
+ if kind == 0:
230
+ flux[s, run] += height / np.sqrt(ivar[s, run])
231
+ elif kind == 1:
232
+ flux[s, run] = 2.0
233
+ else:
234
+ # The noise stays; the truth under it moves.
235
+ shifted = np.interp(pixels[run] - shift, pixels, truth[s])
236
+ flux[s, run] += shifted - truth[s, run]
237
+ return flagged