eclipse 0.13.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.
Files changed (50) hide show
  1. eclipse/__init__.py +95 -0
  2. eclipse/analysis.py +853 -0
  3. eclipse/atmosphere.py +859 -0
  4. eclipse/band.py +477 -0
  5. eclipse/cli.py +138 -0
  6. eclipse/config.py +1710 -0
  7. eclipse/continuum.py +413 -0
  8. eclipse/data/eis_response/EIS_EffArea_A.004 +61 -0
  9. eclipse/data/eis_response/EIS_EffArea_B.004 +60 -0
  10. eclipse/data/eis_response/EIS_EffArea_B.005 +64 -0
  11. eclipse/data/eis_response/README_dz2025 +50 -0
  12. eclipse/data/eis_response/fit_eis_ea_2023-05-04_smooth.sav +0 -0
  13. eclipse/data/eis_response/source.txt +33 -0
  14. eclipse/data/science_cases.yaml +365 -0
  15. eclipse/data/throughput/grating_reflection_efficiency.dat +25 -0
  16. eclipse/data/throughput/primary_mirror_coating_reflectance.dat +25 -0
  17. eclipse/data/throughput/source.txt +6 -0
  18. eclipse/data/throughput/throughput_aluminium_1000_angstrom.dat +503 -0
  19. eclipse/data/throughput/throughput_aluminium_oxide_1000_angstrom.dat +503 -0
  20. eclipse/data/throughput/throughput_carbon_1000_angstrom.dat +503 -0
  21. eclipse/data_processing.py +904 -0
  22. eclipse/deprecated.py +161 -0
  23. eclipse/eis_calibration.py +573 -0
  24. eclipse/extern/__init__.py +0 -0
  25. eclipse/extern/mpfit.py +2388 -0
  26. eclipse/fitting.py +1734 -0
  27. eclipse/frame.py +594 -0
  28. eclipse/full_frame.py +944 -0
  29. eclipse/main.py +1646 -0
  30. eclipse/monte_carlo.py +660 -0
  31. eclipse/pinhole_diffraction.py +409 -0
  32. eclipse/psf.py +6 -0
  33. eclipse/radiometric.py +1328 -0
  34. eclipse/raster.py +869 -0
  35. eclipse/readout.py +1002 -0
  36. eclipse/resolution_budget.py +108 -0
  37. eclipse/results_file.py +1226 -0
  38. eclipse/sampling.py +351 -0
  39. eclipse/science_cases.py +364 -0
  40. eclipse/synthesis.py +2492 -0
  41. eclipse/synthesis_cli.py +12 -0
  42. eclipse/synthesis_file.py +882 -0
  43. eclipse/utils.py +1145 -0
  44. eclipse-0.13.0.dist-info/METADATA +70 -0
  45. eclipse-0.13.0.dist-info/RECORD +50 -0
  46. eclipse-0.13.0.dist-info/WHEEL +5 -0
  47. eclipse-0.13.0.dist-info/entry_points.txt +7 -0
  48. eclipse-0.13.0.dist-info/licenses/LICENSE +3 -0
  49. eclipse-0.13.0.dist-info/top_level.txt +2 -0
  50. euvst_response/__init__.py +129 -0
eclipse/sampling.py ADDED
@@ -0,0 +1,351 @@
1
+ """
2
+ Laying a scene onto detector pixels.
3
+
4
+ A pixel records the mean over its extent of the cells' radiance, each cell
5
+ uniform, blurred by the optics. The weight of cell b in pixel p is
6
+
7
+ W[p, b] = (1 / |p|) int_p dx int_b dx' h(x - x')
8
+
9
+ for blur h, along each axis in turn. For a Gaussian blur, alone or
10
+ convolved with a rectangle such as a slit's image, these integrals have
11
+ closed forms, so the weights are exact for any cell, pixel and blur sizes.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import numpy as np
16
+ from scipy import sparse
17
+ from scipy.special import erfc
18
+
19
+ # A Gaussian's weight beyond this many sigma is below 1e-30 of its total.
20
+ _REACH_IN_SIGMA = 12.0
21
+
22
+
23
+ def _antiderivative(u, sigma, order: int) -> np.ndarray:
24
+ """
25
+ Antiderivative of order `order` of a unit Gaussian of standard deviation
26
+ `sigma`, zero at minus infinity.
27
+
28
+ `order` is 1 (the cumulative distribution), 2 or 3. Where `sigma` is 0
29
+ the Gaussian is a unit spike. `sigma` is one value or one per element of
30
+ `u`.
31
+ """
32
+ u = np.asarray(u, dtype=float)
33
+ sigma = np.broadcast_to(np.asarray(sigma, dtype=float), u.shape)
34
+ spike = sigma == 0.0
35
+ s = np.where(spike, 1.0, sigma)
36
+ cdf = 0.5 * erfc(-u / (np.sqrt(2.0) * s))
37
+ if order == 1:
38
+ smooth = cdf
39
+ sharp = np.where(u > 0, 1.0, np.where(u == 0, 0.5, 0.0))
40
+ else:
41
+ density = np.exp(-0.5 * (u / s) ** 2) / (np.sqrt(2.0 * np.pi) * s)
42
+ positive = np.maximum(u, 0.0)
43
+ if order == 2:
44
+ smooth = u * cdf + s**2 * density
45
+ sharp = positive
46
+ else:
47
+ smooth = (u**2 + s**2) / 2 * cdf + s**2 * u / 2 * density
48
+ sharp = positive**2 / 2
49
+ return np.where(spike, sharp, smooth) if spike.any() else smooth
50
+
51
+
52
+ def _blurred_antiderivative(u, sigma, width, order: int) -> np.ndarray:
53
+ """
54
+ :func:`_antiderivative` of the Gaussian convolved with a rectangle
55
+ `width` wide, such as a slit's image, where `width` is above 0. `sigma`
56
+ and `width` are each one value or one per element of `u`.
57
+ """
58
+ u = np.asarray(u, dtype=float)
59
+ width = np.broadcast_to(np.asarray(width, dtype=float), u.shape)
60
+ plain = _antiderivative(u, sigma, order)
61
+ boxed = width > 0
62
+ if not boxed.any():
63
+ return plain
64
+ w = np.where(boxed, width, 1.0)
65
+ within = (_antiderivative(u + w / 2, sigma, order + 1)
66
+ - _antiderivative(u - w / 2, sigma, order + 1)) / w
67
+ return np.where(boxed, within, plain)
68
+
69
+
70
+ def light_onto_pixels(cell_low, cell_high, pixel_edges, sigma=0.0,
71
+ width=0.0) -> sparse.csr_matrix:
72
+ """
73
+ Share of each cell's blurred light recorded by each pixel.
74
+
75
+ Each cell's light is spread evenly from `cell_low` to `cell_high` and
76
+ blurred by a Gaussian of `sigma` convolved with a rectangle `width`
77
+ wide. The shares are exact integrals of the blur over the cell and the
78
+ pixel, computed only for the pixels the blur reaches from each cell.
79
+ Cells may overlap and leave gaps; pixels are contiguous.
80
+
81
+ Parameters
82
+ ----------
83
+ cell_low, cell_high : array
84
+ Ends of each cell, in one unit.
85
+ pixel_edges : array
86
+ Pixel edges, increasing, in the same unit.
87
+ sigma, width : float or array
88
+ Blur, one value or one per cell, in the same unit; 0 for none.
89
+
90
+ Returns
91
+ -------
92
+ scipy.sparse.csr_matrix
93
+ Shape `(n_pixels, n_cells)`. The shares of a cell whose light all
94
+ lands within the pixels add up to 1.
95
+ """
96
+ low = np.atleast_1d(np.asarray(cell_low, dtype=float))
97
+ high = np.atleast_1d(np.asarray(cell_high, dtype=float))
98
+ edges = np.asarray(pixel_edges, dtype=float)
99
+ sigma = np.broadcast_to(np.asarray(sigma, dtype=float), low.shape)
100
+ width = np.broadcast_to(np.asarray(width, dtype=float), low.shape)
101
+ reach = _REACH_IN_SIGMA * sigma + width
102
+ # Pixels each cell's blurred light reaches: from the first pixel ending
103
+ # above where it starts, to just before the first starting above where
104
+ # it ends.
105
+ first = np.searchsorted(edges[1:], low - reach, side="right")
106
+ last = np.searchsorted(edges[:-1], high + reach, side="left")
107
+ count = np.maximum(last - first, 0)
108
+ cell = np.repeat(np.arange(low.size), count)
109
+ pixel = first[cell] + np.arange(count.sum()) - np.repeat(np.cumsum(count) - count, count)
110
+ p_low, p_high = edges[pixel], edges[pixel + 1]
111
+ b_low, b_high = low[cell], high[cell]
112
+ s, w = sigma[cell], width[cell]
113
+
114
+ def corner(p, b):
115
+ return _blurred_antiderivative(p - b, s, w, 2)
116
+
117
+ total = (corner(p_high, b_low) - corner(p_high, b_high)
118
+ - corner(p_low, b_low) + corner(p_low, b_high))
119
+ # The closed forms cancel to rounding error where the true share is 0.
120
+ share = np.maximum(total, 0.0) / (b_high - b_low)
121
+ return sparse.csr_matrix((share, (pixel, cell)), shape=(edges.size - 1, low.size))
122
+
123
+
124
+ def points_onto_pixels(position, pixel_edges, sigma, width=0.0) -> sparse.csr_matrix:
125
+ """
126
+ Share of each point's blurred light recorded by each pixel.
127
+
128
+ This is :func:`light_onto_pixels` for light all at one place, such as a
129
+ line at its centre blurred by its own width.
130
+
131
+ Parameters
132
+ ----------
133
+ position : array
134
+ Position of each point, in one unit.
135
+ pixel_edges : array
136
+ Pixel edges, increasing, in the same unit.
137
+ sigma, width : float or array
138
+ Blur, a Gaussian of `sigma` convolved with a rectangle `width` wide,
139
+ one value or one per point, in the same unit. `sigma` is above 0
140
+ where `width` is 0.
141
+
142
+ Returns
143
+ -------
144
+ scipy.sparse.csr_matrix
145
+ Shape `(n_pixels, n_points)`.
146
+ """
147
+ position = np.atleast_1d(np.asarray(position, dtype=float))
148
+ edges = np.asarray(pixel_edges, dtype=float)
149
+ sigma = np.broadcast_to(np.asarray(sigma, dtype=float), position.shape)
150
+ width = np.broadcast_to(np.asarray(width, dtype=float), position.shape)
151
+ reach = _REACH_IN_SIGMA * sigma + width
152
+ first = np.searchsorted(edges[1:], position - reach, side="right")
153
+ last = np.searchsorted(edges[:-1], position + reach, side="left")
154
+ count = np.maximum(last - first, 0)
155
+ point = np.repeat(np.arange(position.size), count)
156
+ pixel = first[point] + np.arange(count.sum()) - np.repeat(np.cumsum(count) - count, count)
157
+ x, s, w = position[point], sigma[point], width[point]
158
+ share = (_blurred_antiderivative(edges[pixel + 1] - x, s, w, 1)
159
+ - _blurred_antiderivative(edges[pixel] - x, s, w, 1))
160
+ return sparse.csr_matrix((np.maximum(share, 0.0), (pixel, point)),
161
+ shape=(edges.size - 1, position.size))
162
+
163
+
164
+ def photons_onto_pixels(share: sparse.spmatrix, photons: np.ndarray,
165
+ wavelength: np.ndarray) -> tuple:
166
+ """
167
+ Photons laid onto pixels, with the wavelengths of their mean and root mean square energies.
168
+
169
+ Parameters
170
+ ----------
171
+ share : scipy.sparse matrix
172
+ Each cell's share in each pixel, from :func:`light_onto_pixels`.
173
+ photons : np.ndarray
174
+ Photons in each cell, any number of spectra with the cells on the
175
+ last axis.
176
+ wavelength : np.ndarray
177
+ Wavelength of each cell's photons.
178
+
179
+ Returns
180
+ -------
181
+ tuple
182
+ `(on_pixels, photon_wavelength, rms_wavelength)`, all with the pixels
183
+ on the last axis. A photon frees electrons in a detector by its
184
+ energy, so `photon_wavelength` is the wavelength of the mean energy
185
+ of the photons a pixel receives, and `rms_wavelength` that of their
186
+ root mean square energy, which sets how far their electrons spread.
187
+ Both are in the unit of `wavelength`, and NaN where no photons
188
+ arrive, or only photons below the smallest normal float.
189
+ """
190
+ photons = np.asarray(photons, dtype=float)
191
+ flat = photons.reshape(-1, photons.shape[-1])
192
+ wavelength = np.asarray(wavelength, dtype=float)
193
+ on, photon_wavelength, rms_wavelength = _carry_photon_wavelengths(
194
+ lambda cells: np.asarray(share @ cells.T).T, flat, wavelength, wavelength, np.nan)
195
+ shape = photons.shape[:-1] + (share.shape[0],)
196
+ return on.reshape(shape), photon_wavelength.reshape(shape), rms_wavelength.reshape(shape)
197
+
198
+
199
+ def _carry_photon_wavelengths(carry, photons, mean, rms, fill) -> tuple:
200
+ """
201
+ Photons moved by `carry`, with the wavelengths of the mean and root mean
202
+ square energies of the photons where they land.
203
+
204
+ A photon's energy goes as one over its wavelength. Photons whose mean
205
+ energy is that of wavelength `mean`, and whose root mean square energy
206
+ is that of `rms`, hold an energy that goes as photons / mean and a
207
+ squared energy that goes as photons / rms**2. Both add up like the
208
+ photons, so any linear map `carry` moves them as it moves the photons.
209
+
210
+ Parameters
211
+ ----------
212
+ carry : callable
213
+ Linear map from an array shaped as `photons` to where they land.
214
+ photons : np.ndarray
215
+ Photons.
216
+ mean, rms : np.ndarray
217
+ Wavelengths of the mean and root mean square energies of `photons`,
218
+ broadcasting against them.
219
+ fill : float or np.ndarray
220
+ Wavelength for both where no photons land, broadcasting against
221
+ where they land. Also used where the photons that land are below
222
+ the smallest normal float, as their ratios then have too few
223
+ significant bits to mean anything.
224
+
225
+ Returns
226
+ -------
227
+ tuple
228
+ `(moved, mean, rms)`.
229
+ """
230
+ photons = np.asarray(photons, dtype=float)
231
+ mean = np.broadcast_to(np.asarray(mean, dtype=float), photons.shape)
232
+ rms = np.broadcast_to(np.asarray(rms, dtype=float), photons.shape)
233
+ # The wavelengths are taken relative to the largest, so the energies
234
+ # carried are about the size of the photons. Divided by wavelengths in
235
+ # cm they would be up to 1e12 times larger, and could stay above zero
236
+ # where the photons round to zero.
237
+ scale = np.max(mean, initial=0.0, where=np.isfinite(mean))
238
+ scale = scale if scale > 0 else 1.0
239
+ moved = carry(photons)
240
+ energy = carry(photons * np.divide(scale, mean, out=np.zeros(photons.shape), where=mean > 0))
241
+ square = carry(photons * np.divide(scale, rms, out=np.zeros(photons.shape), where=rms > 0)**2)
242
+ empty = np.broadcast_to(np.asarray(fill, dtype=float), moved.shape)
243
+ tiny = np.finfo(float).tiny
244
+ with_mean = (moved >= tiny) & (energy >= tiny)
245
+ with_rms = (moved >= tiny) & (square >= tiny)
246
+ ratio = np.divide(moved, energy, out=np.ones(moved.shape), where=with_mean)
247
+ moved_mean = np.where(with_mean, scale * ratio, empty)
248
+ ratio = np.divide(moved, square, out=np.ones(moved.shape), where=with_rms)
249
+ moved_rms = np.where(with_rms, scale * np.sqrt(ratio), empty)
250
+ return moved, moved_mean, moved_rms
251
+
252
+
253
+ def pixel_weights(cell_edges, pixel_edges, sigma: float = 0.0, width: float = 0.0,
254
+ extend: bool = False) -> np.ndarray:
255
+ """
256
+ Weight of each cell in each pixel: the mean over the pixel of the cell's
257
+ light, uniform over the cell, after blurring.
258
+
259
+ Parameters
260
+ ----------
261
+ cell_edges, pixel_edges : array
262
+ Cell and pixel edges along one axis, increasing, in one unit.
263
+ sigma : float
264
+ Standard deviation of the Gaussian blur, in the same unit; 0 for
265
+ none.
266
+ width : float
267
+ Width of a rectangle convolved with the Gaussian, such as a slit's
268
+ image, in the same unit; 0 for none.
269
+ extend : bool
270
+ Extend the first and last cells outward as far as the blur reaches,
271
+ for a scene that continues past its edges.
272
+
273
+ Returns
274
+ -------
275
+ np.ndarray
276
+ Shape `(n_pixels, n_cells)`. With no blur each row is the fraction
277
+ of the pixel each cell covers.
278
+ """
279
+ cells = np.asarray(cell_edges, dtype=float)
280
+ pixels = np.asarray(pixel_edges, dtype=float)
281
+ reach = _REACH_IN_SIGMA * sigma + width
282
+ if extend and reach > 0:
283
+ beyond = reach + (pixels[-1] - pixels[0]) + (cells[-1] - cells[0])
284
+ cells = cells.copy()
285
+ cells[0] = min(cells[0], pixels[0]) - beyond
286
+ cells[-1] = max(cells[-1], pixels[-1]) + beyond
287
+
288
+ # Each cell's share of its light in a pixel, turned into the mean over
289
+ # the pixel of the cell's light per unit length.
290
+ share = light_onto_pixels(cells[:-1], cells[1:], pixels, sigma, width)
291
+ weights = sparse.diags(1.0 / np.diff(pixels)) @ share @ sparse.diags(np.diff(cells))
292
+ return weights.toarray()
293
+
294
+
295
+ def pixel_weights_through(cell_edges, pixel_edges, openings, sigma: float = 0.0,
296
+ extend: bool = False) -> np.ndarray:
297
+ """
298
+ Pixel weights for light passing only through `openings`.
299
+
300
+ As :func:`pixel_weights`, for a slit with gaps in it. The blur comes before the openings,
301
+ as the optics in front of a slit blur the image the slit then cuts. Each
302
+ pixel still holds the mean over its whole extent, so a pixel half
303
+ covered by a gap holds half the light, and a pixel inside a gap none.
304
+
305
+ Parameters
306
+ ----------
307
+ cell_edges, pixel_edges, sigma, extend
308
+ As for :func:`pixel_weights`.
309
+ openings : array
310
+ `(n, 2)` start and end of each opening, in the unit of the edges,
311
+ increasing and not overlapping.
312
+
313
+ Returns
314
+ -------
315
+ np.ndarray
316
+ Weights, `(n_pixels, n_cells)`.
317
+ """
318
+ pixels = np.asarray(pixel_edges, dtype=float)
319
+ openings = np.asarray(openings, dtype=float).reshape(-1, 2)
320
+ if np.any(openings[:, 1] <= openings[:, 0]) or np.any(openings[1:, 0] < openings[:-1, 1]):
321
+ raise ValueError("The openings must each end after they start, in increasing "
322
+ "order, without overlapping.")
323
+ # Cut the pixels at every opening edge inside them, so each piece is wholly
324
+ # open or closed, then average the open pieces' weights over their pixel.
325
+ cuts = openings[(openings > pixels[0]) & (openings < pixels[-1])]
326
+ pieces = np.union1d(pixels, cuts)
327
+ middles = (pieces[:-1] + pieces[1:]) / 2
328
+ is_open = np.any((middles[:, np.newaxis] > openings[:, 0])
329
+ & (middles[:, np.newaxis] < openings[:, 1]), axis=1)
330
+ weights = pixel_weights(cell_edges, pieces, sigma, extend=extend)
331
+ pixel = np.searchsorted(pixels, middles, side="right") - 1
332
+ out = np.zeros((pixels.size - 1, weights.shape[1]))
333
+ np.add.at(out, pixel, weights * (np.diff(pieces) * is_open)[:, np.newaxis])
334
+ return out / np.diff(pixels)[:, np.newaxis]
335
+
336
+
337
+ def centred_edges(n: int, pitch: float, centre: float = 0.0) -> np.ndarray:
338
+ """Edges of `n` pixels of width `pitch`, centred on `centre`."""
339
+ return centre + (np.arange(n + 1) - n / 2) * pitch
340
+
341
+
342
+ def onto_pixels(data: np.ndarray, rows: np.ndarray, columns: np.ndarray,
343
+ wavelengths: np.ndarray) -> np.ndarray:
344
+ """
345
+ `data`, shaped `(y, x, wavelength)`, laid onto pixels by the weights
346
+ along each axis from :func:`pixel_weights`.
347
+ """
348
+ out = np.tensordot(data, wavelengths, axes=([2], [1])) # (y, x, l)
349
+ out = np.tensordot(out, columns, axes=([1], [1])) # (y, l, q)
350
+ out = np.tensordot(rows, out, axes=([1], [0])) # (p, l, q)
351
+ return np.ascontiguousarray(np.moveaxis(out, 2, 1)) # (p, q, l)