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
@@ -0,0 +1,904 @@
1
+ """
2
+ Data processing functions for atmosphere cubes and spectral resampling.
3
+ """
4
+
5
+ from __future__ import annotations
6
+ from pathlib import Path
7
+ import numpy as np
8
+ import astropy.units as u
9
+ import astropy.constants as const
10
+ from ndcube import NDCube
11
+ from astropy.wcs import WCS
12
+ from scipy.sparse import csr_matrix
13
+ from scipy.special import erf
14
+ from tqdm import tqdm
15
+ from .pinhole_diffraction import _filter_transmission, _pinholes_on
16
+ from .radiometric import (_carry_photon_meta, _spectral_response, across_slit_fwhm,
17
+ photons_per_energy, spatial_psf_fwhm, spectral_psf_margin)
18
+ from .radiometric import slit_image_width, spectral_blur_fwhm, spectral_psf_fwhm # noqa: F401
19
+ from .sampling import (centred_edges, light_onto_pixels, photons_onto_pixels, pixel_weights,
20
+ pixel_weights_through)
21
+ from .utils import (_bin_edges, _unpickle, distance_to_angle, _fwhm_to_sigma, has_wrong_velocity_sign,
22
+ onto_wavelength_bins)
23
+
24
+
25
+ def load_atmosphere(pkl_file: str, metadata_line: str = None) -> tuple:
26
+ """
27
+ Read one spectral window of a synthesis file as a single cube, blends included.
28
+
29
+ Every line in the file within the window of `metadata_line` is added
30
+ onto that line's wavelengths at its own brightness. This is what
31
+ `eclipse` observes for `reference_line`. Synthesis pickles from ECLIPSE
32
+ 0.11.0 and earlier can also be read. Reading a pickle runs any code it
33
+ holds, so only read pickles you trust.
34
+
35
+ Parameters
36
+ ----------
37
+ pkl_file : str
38
+ Synthesis file or synthesis pickle.
39
+ metadata_line : str, optional
40
+ Line whose window to read. Default the file's first line.
41
+
42
+ Returns
43
+ -------
44
+ summed_cube : NDCube
45
+ Spectra of the window, indexed [y, x, wavelength].
46
+ dynamic_mode_info : dict
47
+ Settings of the deprecated dynamic mode the file was made in, or
48
+ `{"enabled": False}` for a synthesis of one snapshot.
49
+ """
50
+ from .atmosphere import _is_pickle
51
+ from .synthesis_file import (is_synthesis_file, read_synthesis, read_synthesis_products,
52
+ synthesis_line_names)
53
+
54
+ if is_synthesis_file(pkl_file):
55
+ names = synthesis_line_names(pkl_file)
56
+ if metadata_line is None and names:
57
+ metadata_line = names[0]
58
+ synthesis = read_synthesis(pkl_file, metadata_line)
59
+ if not synthesis.evenly_spaced(metadata_line):
60
+ raise ValueError(
61
+ f"{pkl_file}: the wavelengths of {metadata_line} are not evenly spaced, which "
62
+ f"the WCS of an NDCube cannot describe. Read the file with read_synthesis.")
63
+ dynamic_mode_info = read_synthesis_products(pkl_file, keys="dynamic_mode").get(
64
+ "dynamic_mode", {"enabled": False})
65
+ return (synthesis.summed_cube(metadata_line, meta={"dynamic_mode": dynamic_mode_info}),
66
+ dynamic_mode_info)
67
+
68
+ if not _is_pickle(Path(pkl_file)):
69
+ raise ValueError(
70
+ f"{pkl_file} is neither a synthesis file, which is HDF5, nor a synthesis pickle "
71
+ f"as older versions of ECLIPSE wrote.")
72
+ with open(pkl_file, "rb") as f:
73
+ tmp = _unpickle(f)
74
+
75
+ # Handle new synthesis format
76
+ if "line_cubes" not in tmp:
77
+ raise ValueError("File does not contain synthesis results with line_cubes")
78
+
79
+ line_cubes = tmp["line_cubes"]
80
+ if not line_cubes:
81
+ raise ValueError("No line cubes found in synthesis results")
82
+
83
+ # Get dynamic mode info if present
84
+ dynamic_mode_info = tmp.get("dynamic_mode", {"enabled": False})
85
+
86
+ # Get the line names
87
+ line_names = list(line_cubes.keys())
88
+
89
+ # Choose metadata source line
90
+ if metadata_line is None:
91
+ metadata_line = line_names[0]
92
+ elif metadata_line not in line_names:
93
+ raise ValueError(f"Metadata line '{metadata_line}' not found. Available lines: {line_names}")
94
+
95
+ check_old_line_cubes({metadata_line: line_cubes[metadata_line]}, pkl_file)
96
+
97
+ summed_cube = sum_line_cubes(line_cubes, metadata_line)
98
+ summed_cube.meta["dynamic_mode"] = dynamic_mode_info
99
+ return summed_cube, dynamic_mode_info
100
+
101
+
102
+ def check_old_line_cubes(line_cubes: dict, path) -> None:
103
+ """Refuse line cubes written by older ECLIPSE versions in a form now misread."""
104
+ for cube in line_cubes.values():
105
+ meta = cube.meta or {}
106
+ # Refuse files from before the axis order changed (issue #12). They
107
+ # store (x, y, wavelength), not (y, x, wavelength), so would come out
108
+ # transposed. The WCS axis order tells the two apart.
109
+ old_first_spatial = {"z": "SOLY", "x": "SOLZ", "y": "SOLZ"}
110
+ axis = meta.get("integration_axis")
111
+ if axis in old_first_spatial and cube.wcs.wcs.ctype[1] == old_first_spatial[axis]:
112
+ raise ValueError(
113
+ f"{path} was written by an older ECLIPSE that stored cubes "
114
+ "as (x, y, wavelength). Cubes are now (y, x, wavelength). "
115
+ "Re-run the synthesis with this version to regenerate the file."
116
+ )
117
+
118
+ # Refuse files with the old velocity convention, which has the
119
+ # opposite sign for views along x and z.
120
+ if has_wrong_velocity_sign(meta):
121
+ axis = meta.get("integration_axis", "z")
122
+ raise ValueError(
123
+ f"{path} was written by an older ECLIPSE, which took the "
124
+ "line-of-sight velocity straight from the simulation's velocity "
125
+ "component. ECLIPSE now uses the velocity away from the observer. "
126
+ f"For this view along {axis}, the two conventions have opposite "
127
+ "signs. Re-run the synthesis with this version to regenerate "
128
+ "the file."
129
+ )
130
+
131
+
132
+ def sum_line_cubes(line_cubes: dict, reference_line: str) -> NDCube:
133
+ """
134
+ Every line's cube summed onto the wavelength grid of `reference_line`.
135
+
136
+ Each cube is averaged onto the reference bins, conserving flux and
137
+ taken as zero outside its own grid, so lines outside the reference
138
+ window add nothing. The result keeps the reference cube's WCS, unit and
139
+ metadata, plus the names of the lines it holds.
140
+ """
141
+ ref_cube = line_cubes[reference_line]
142
+ ref_wavelengths = ref_cube.axis_world_coords_values(-1)[0]
143
+ line_names = list(line_cubes.keys())
144
+
145
+ # Get spatial dimensions from reference cube
146
+ ny, nx, nw = ref_cube.data.shape
147
+
148
+ # Initialize summed data with the reference wavelength grid
149
+ summed_data = np.zeros((ny, nx, nw))
150
+
151
+ for line_name, cube in tqdm(line_cubes.items(), desc="Summing line cubes", unit="line", leave=False):
152
+ # Get wavelength grid for this cube
153
+ cube_wavelengths = cube.axis_world_coords_values(-1)[0]
154
+
155
+ # Check spatial dimensions match
156
+ ny_cube, nx_cube, _ = cube.data.shape
157
+ if ny_cube != ny or nx_cube != nx:
158
+ raise ValueError(f"Spatial dimensions mismatch for {line_name}: expected ({ny}, {nx}), got ({ny_cube}, {nx_cube})")
159
+
160
+ summed_data += onto_wavelength_bins(cube.data,
161
+ cube_wavelengths.to_value(ref_wavelengths.unit),
162
+ ref_wavelengths.value)
163
+
164
+ # Create new metadata combining info from all lines
165
+ combined_meta = ref_cube.meta.copy()
166
+ combined_meta.update({
167
+ "combined_lines": line_names,
168
+ "n_lines": len(line_names),
169
+ "metadata_source": reference_line,
170
+ "summed_intensity": True,
171
+ })
172
+
173
+ # Create the summed cube using the reference cube's WCS
174
+ return NDCube(
175
+ summed_data,
176
+ wcs=ref_cube.wcs,
177
+ unit=ref_cube.unit,
178
+ meta=combined_meta
179
+ )
180
+
181
+
182
+ def resample_ndcube_spectral_axis(ndcube, spectral_axis, output_resolution, ncpu=-1):
183
+ """
184
+ Resample the spectral axis of an NDCube conserving flux, as in :func:`resample_spectra`.
185
+
186
+ Parameters
187
+ ----------
188
+ ndcube : NDCube
189
+ Input cube.
190
+ spectral_axis : int
191
+ Index of the spectral axis (e.g. 0, 1 or 2).
192
+ output_resolution : astropy.units.Quantity
193
+ Output spectral resolution (e.g. 0.01 * u.nm).
194
+ ncpu : int, optional
195
+ Unused, kept for existing calls. The resampling is one matrix
196
+ product, which uses the threads numpy is given.
197
+
198
+ Returns
199
+ -------
200
+ NDCube
201
+ New cube with the spectral axis resampled.
202
+ """
203
+ # Get the world coordinates of the spectral axis
204
+ spectral_world = ndcube.axis_world_coords_values(spectral_axis)[0]
205
+
206
+ # Move spectral axis last, as resample_spectra expects
207
+ data = np.moveaxis(ndcube.data, spectral_axis, -1)
208
+ resampled, new_spec_grid = resample_spectra(data, spectral_world, output_resolution)
209
+
210
+ # Move spectral axis back to original position
211
+ resampled = np.moveaxis(resampled, -1, spectral_axis)
212
+
213
+ # Update WCS for new spectral axis
214
+ new_wcs = ndcube.wcs.deepcopy()
215
+
216
+ wcs_axis = new_wcs.wcs.naxis - 1 - spectral_axis # Reverse axis order for WCS
217
+ unit = new_wcs.wcs.cunit[wcs_axis]
218
+ (new_wcs.wcs.crpix[wcs_axis], new_wcs.wcs.crval[wcs_axis],
219
+ new_wcs.wcs.cdelt[wcs_axis]) = _even_grid_wcs(new_spec_grid, unit)
220
+
221
+ return NDCube(resampled, wcs=new_wcs, unit=ndcube.unit, meta=ndcube.meta)
222
+
223
+
224
+ def _even_grid_wcs(grid: u.Quantity, unit) -> tuple:
225
+ """CRPIX, CRVAL and CDELT, in `unit`, of the evenly spaced `grid`."""
226
+ cdelt = (grid[1] - grid[0]).to_value(unit)
227
+ # The reference pixel is the centre of the axis, between two pixels for
228
+ # an even number of them. CRVAL must be the wavelength at that point, not
229
+ # at the pixel below, or the axis is labelled half a pixel low and every
230
+ # fitted velocity comes out half a pixel blue.
231
+ center_pixel = (len(grid) + 1) / 2 # 1-based index (FITS convention)
232
+ return center_pixel, grid[0].to_value(unit) + (center_pixel - 1) * cdelt, cdelt
233
+
234
+
235
+ def resample_spectra(data: np.ndarray, spectral_world: u.Quantity,
236
+ output_resolution: u.Quantity) -> tuple:
237
+ """
238
+ Spectra resampled onto an evenly spaced wavelength grid, conserving flux.
239
+
240
+ Each input wavelength covers the interval halfway to its neighbours, and
241
+ each new pixel gets the mean of what overlaps it, so the integral over
242
+ wavelength is kept exactly, out to the outermost intervals.
243
+
244
+ Parameters
245
+ ----------
246
+ data : np.ndarray
247
+ Any number of spectra, with wavelength on the last axis.
248
+ spectral_world : astropy.units.Quantity
249
+ Increasing wavelength of each pixel along the last axis, not
250
+ necessarily evenly spaced.
251
+ output_resolution : astropy.units.Quantity
252
+ Spacing of the new grid. The grid starts at the first wavelength and
253
+ steps in whole pixels until it passes the last, so its last pixel
254
+ can lie beyond the outermost interval and stay empty. Where the
255
+ outermost intervals of a coarse grid reach past either end, whole
256
+ pixels are added there too.
257
+
258
+ Returns
259
+ -------
260
+ tuple
261
+ `(resampled, new_grid)`: the spectra on the new grid, wavelength
262
+ last, and the new grid in the unit of `output_resolution`.
263
+ """
264
+ centres = spectral_world.to_value(output_resolution.unit)
265
+ grid = _spectral_grid(centres, output_resolution.value)
266
+ resampled = onto_wavelength_bins(data.reshape(-1, data.shape[-1]), centres, grid)
267
+ return resampled.reshape(data.shape[:-1] + grid.shape), grid * output_resolution.unit
268
+
269
+
270
+ def _spectral_grid(centres: np.ndarray, step: float, margin: int = 0) -> np.ndarray:
271
+ """
272
+ Detector wavelength pixels for spectra at `centres`.
273
+
274
+ The pixels run from the first wavelength in steps of `step` until past
275
+ the last, as described in :func:`resample_spectra`, with `margin` more
276
+ at each end.
277
+ """
278
+ grid = np.arange(centres.min(), centres.max() + step, step)
279
+ # Where the input is coarser than the pixels at its ends, its outermost
280
+ # intervals reach past the first and last pixels, and their light would
281
+ # be lost, so pixels are added either side without moving the grid. A
282
+ # reach under a part in 1e9 of a pixel is rounding.
283
+ edges = _bin_edges(centres)
284
+ below = margin + max(0, int(np.ceil((grid[0] - step / 2 - edges[0]) / step - 1e-9)))
285
+ above = margin + max(0, int(np.ceil((edges[-1] - (grid[-1] + step / 2)) / step - 1e-9)))
286
+ return np.concatenate([grid[0] - step * np.arange(below, 0, -1), grid,
287
+ grid[-1] + step * np.arange(1, above + 1)])
288
+
289
+
290
+ def _whole_pixels(extent: u.Quantity, pitch: u.Quantity) -> int:
291
+ """
292
+ Number of whole pixels of `pitch` in `extent`.
293
+
294
+ A field of view of a whole number of pixels, as round angles often are,
295
+ is off by a rounding error after conversion to a length and back. A
296
+ count within a part in 1e9 of the next whole number is therefore taken
297
+ as reaching it, so the last pixel is not lost.
298
+ """
299
+ ratio = (extent / pitch).decompose().value
300
+ return int(np.floor(ratio * (1 + 1e-9)))
301
+
302
+
303
+ def reproject_ndcube_heliocentric_to_helioprojective(new_cube_spec, sim, det, ncpu=-1, *,
304
+ sigma_x=0.0, sigma_y=0.0, extend=False,
305
+ openings=None):
306
+ """Reproject an NDCube from heliocentric to helioprojective coordinates.
307
+
308
+ Pixels are the plate scale apart along the slit and the slit width
309
+ apart across it; a raster keeps its own columns. Each pixel holds the
310
+ mean of the cells over its footprint, each cell taken as uniform, so
311
+ structure finer than a pixel is averaged as the detector averages it.
312
+
313
+ Parameters
314
+ ----------
315
+ new_cube_spec : NDCube
316
+ Input NDCube in heliocentric coordinates
317
+ sim : Simulation
318
+ Simulation configuration object
319
+ det : Detector
320
+ Detector configuration object
321
+ ncpu : int, optional
322
+ Unused, kept for existing calls. The averaging is a matrix product,
323
+ which uses the threads numpy is given.
324
+ sigma_x, sigma_y : float, optional
325
+ Standard deviations in arcsec of a Gaussian blur across and along
326
+ the slit, applied to the scene before pixel averaging. A raster gets
327
+ no blur across the slit, as its columns have already been through
328
+ the slit.
329
+ extend : bool, optional
330
+ Extend the scene past its edges with its edge cells, for the blur
331
+ to bring in.
332
+ openings : array, optional
333
+ `(n, 2)` stretches where light gets through the slit, in arcsec along
334
+ it from the middle of the scene, as from `Telescope_EUVST.slit_openings`.
335
+ The blur along the slit comes before them. Default None, for a slit
336
+ open along the whole scene.
337
+ """
338
+
339
+ ny, nx, _ = new_cube_spec.shape
340
+ wcs_hc = new_cube_spec.wcs
341
+
342
+ dx = wcs_hc.wcs.cdelt[1] * wcs_hc.wcs.cunit[1]
343
+ dy = wcs_hc.wcs.cdelt[2] * wcs_hc.wcs.cunit[2]
344
+ x_angle = distance_to_angle(dx)
345
+ y_angle = distance_to_angle(dy)
346
+
347
+ # The reference pixel is put at the middle of each spatial axis below, so
348
+ # CRVAL must be the input's coordinate there, wherever the input's own
349
+ # reference pixel is.
350
+ crval_x_hc = (wcs_hc.wcs.crval[1] + ((nx + 1) / 2 - wcs_hc.wcs.crpix[1])
351
+ * wcs_hc.wcs.cdelt[1]) * u.Unit(wcs_hc.wcs.cunit[1])
352
+ crval_y_hc = (wcs_hc.wcs.crval[2] + ((ny + 1) / 2 - wcs_hc.wcs.crpix[2])
353
+ * wcs_hc.wcs.cdelt[2]) * u.Unit(wcs_hc.wcs.cunit[2])
354
+ crval_x_hp = distance_to_angle(crval_x_hc).to_value(u.arcsec)
355
+ crval_y_hp = distance_to_angle(crval_y_hc).to_value(u.arcsec)
356
+
357
+ wcs_hp = WCS(naxis=3)
358
+ wcs_hp.wcs.ctype = [wcs_hc.wcs.ctype[0], 'HPLN-TAN', 'HPLT-TAN']
359
+ wcs_hp.wcs.cunit = [wcs_hc.wcs.cunit[0], 'arcsec', 'arcsec']
360
+ wcs_hp.wcs.crpix = [wcs_hc.wcs.crpix[0],
361
+ (nx + 1) / 2,
362
+ (ny + 1) / 2]
363
+ wcs_hp.wcs.crval = [wcs_hc.wcs.crval[0], crval_x_hp, crval_y_hp]
364
+ wcs_hp.wcs.cdelt = [wcs_hc.wcs.cdelt[0], x_angle.to_value(u.arcsec), y_angle.to_value(u.arcsec)]
365
+ new_cube_spec_hp = NDCube(new_cube_spec.data, wcs=wcs_hp, unit=new_cube_spec.unit, meta=new_cube_spec.meta)
366
+
367
+ ny_in, nx_in, nl_in = new_cube_spec_hp.shape
368
+ fov_x = nx_in * x_angle
369
+ fov_y = ny_in * y_angle
370
+ # A raster already has one column per slit position, so its scan axis is
371
+ # kept and only the slit axis is put on the plate scale.
372
+ raster = bool((new_cube_spec.meta or {}).get("raster"))
373
+ pitch_x = x_angle if raster else sim.slit_width
374
+ pitch_y = det.plate_scale_angle
375
+ nx_out = nx_in if raster else _whole_pixels(fov_x, pitch_x)
376
+ ny_out = _whole_pixels(fov_y, pitch_y)
377
+ if nx_out < 1 or ny_out < 1:
378
+ raise ValueError(
379
+ f"The field of view, {fov_x.to(u.arcsec):.3f} by {fov_y.to(u.arcsec):.3f}, "
380
+ f"is smaller than one detector pixel ({pitch_x.to(u.arcsec):.3f} along the "
381
+ f"scan, {(pitch_y * u.pix).to(u.arcsec):.3f} along the slit), so nothing "
382
+ f"would be left after rebinning.")
383
+
384
+ crpix_spec = (nl_in + 1) / 2
385
+ crpix_y = (ny_out + 1) / 2
386
+ crpix_x = (nx_out + 1) / 2
387
+
388
+ wcs_tgt = WCS(naxis=3)
389
+ wcs_tgt.wcs.ctype = [wcs_hc.wcs.ctype[0], 'HPLN-TAN', 'HPLT-TAN']
390
+ wcs_tgt.wcs.cunit = [wcs_hc.wcs.cunit[0], 'arcsec', 'arcsec']
391
+ wcs_tgt.wcs.crpix = [crpix_spec, crpix_x, crpix_y]
392
+ wcs_tgt.wcs.crval = [wcs_hc.wcs.crval[0], crval_x_hp, crval_y_hp]
393
+ wcs_tgt.wcs.cdelt = [wcs_hc.wcs.cdelt[0],
394
+ pitch_x.to_value(u.arcsec),
395
+ (det.plate_scale_angle * u.pix).to_value(u.arcsec)]
396
+
397
+ # Each pixel holds the mean of the uniform cells over its footprint,
398
+ # after the optics blur if given. Both grids are centred on the middle
399
+ # of the input.
400
+ cell_rows = centred_edges(ny_in, y_angle.to_value(u.arcsec))
401
+ pixel_rows = centred_edges(ny_out, (pitch_y * u.pix).to_value(u.arcsec))
402
+ if openings is None:
403
+ rows = pixel_weights(cell_rows, pixel_rows, sigma_y, extend=extend)
404
+ else:
405
+ rows = pixel_weights_through(cell_rows, pixel_rows, openings, sigma_y, extend=extend)
406
+ columns = pixel_weights(centred_edges(nx_in, x_angle.to_value(u.arcsec)),
407
+ centred_edges(nx_out, pitch_x.to_value(u.arcsec)),
408
+ 0.0 if raster else sigma_x, extend=extend)
409
+ # Each pixel covers only nearby cells, so the weights are stored sparse.
410
+ rows, columns = csr_matrix(rows), csr_matrix(columns)
411
+
412
+ def onto_pixels(cube):
413
+ cube = (rows @ cube.reshape(ny_in, -1)).reshape(ny_out, nx_in, nl_in)
414
+ cube = (columns @ np.moveaxis(cube, 1, 0).reshape(nx_in, -1)).reshape(
415
+ nx_out, ny_out, nl_in)
416
+ return np.ascontiguousarray(np.moveaxis(cube, 0, 1))
417
+
418
+ # At one wavelength the photons scale with the data, so the photon
419
+ # metadata is carried with the same weights.
420
+ data, meta = _carry_photon_meta(onto_pixels, new_cube_spec)
421
+ meta = dict(meta or {})
422
+ # Put the WCS in wcslib's own units, metres and degrees, as reprojected
423
+ # cubes are expected to be.
424
+ wcs_tgt.wcs.set()
425
+ # Record the scene's extent along the slit, which gives the rows it
426
+ # covers at any plate scale, for main to check off-chip binning against,
427
+ # and whether the slit's ends and fiducials were cut out.
428
+ return NDCube(data, wcs=wcs_tgt, unit=new_cube_spec.unit,
429
+ meta={**meta, "fov_along_slit": fov_y.to(u.arcsec),
430
+ **({"slit_mask_applied": True} if openings is not None else {})})
431
+
432
+
433
+ def _through_the_optics(tel, sim) -> bool:
434
+ """Whether the focusing optics' PSF goes into the rebinning."""
435
+ if tel is None or not sim.psf:
436
+ return False
437
+ if tel.psf_type.lower() != "gaussian":
438
+ raise ValueError(f"Unsupported PSF type: {tel.psf_type}. Supported: 'gaussian'.")
439
+ return True
440
+
441
+
442
+ def _spectra_on_the_detector(data: np.ndarray, spectral_world: u.Quantity, det, sim,
443
+ tel) -> tuple:
444
+ """
445
+ Spectra laid onto the detector's wavelength pixels in one step.
446
+
447
+ The spectra go from their own wavelengths onto the pixels, through the
448
+ spectral PSF if `sim.psf` is on. Blurring them on the pixels, as the
449
+ Monte Carlo does for a cube without `psf_applied`, moves a line narrower
450
+ than a pixel toward its middle; here each wavelength keeps its place.
451
+ The pixels extend :func:`~eclipse.radiometric.spectral_psf_margin`
452
+ further at each end, as :func:`pad_spectral_axis` does for a PSF on the
453
+ pixels.
454
+
455
+ Each wavelength's light is converted to photons at that wavelength,
456
+ with the telescope's area and the photon energy there
457
+ (:func:`~eclipse.radiometric.photons_per_energy`), before the
458
+ blur. The Monte Carlo counts a pixel's photons at the pixel's
459
+ wavelength, so the returned spectra are the radiance that gives each
460
+ pixel its photons that way. A photon frees electrons by its own energy,
461
+ so the wavelengths of the mean and RMS photon energies in each pixel are
462
+ returned too. With the pinholes on, so are the share of the photons'
463
+ light the filter passed, and the wavelengths of the mean and RMS
464
+ energies of the light it blocked, which a pinhole lets through, each
465
+ worked out at its own wavelength.
466
+
467
+ Returns `(spectra, grid, meta)`, with the grid in cm and `meta` the
468
+ cube's photon `meta` entries, named as in
469
+ :func:`~eclipse.radiometric._carry_photon_meta`.
470
+ """
471
+ step = (det.wvl_res * u.pix).to_value(u.cm)
472
+ centres = spectral_world.to_value(u.cm)
473
+ grid = _spectral_grid(centres, step, spectral_psf_margin(tel, det, sim.slit_width))
474
+ spectra, meta = _cells_onto_pixels(data, centres, _bin_edges(centres), grid, det, sim, tel)
475
+ return spectra, grid * u.cm, meta
476
+
477
+
478
+ def _cells_onto_pixels(data, centres: np.ndarray, cells: np.ndarray, grid: np.ndarray, det,
479
+ sim, tel) -> tuple:
480
+ """
481
+ Radiance in wavelength cells laid onto the pixels centred on `grid`.
482
+
483
+ The steps of :func:`_spectra_on_the_detector`, with the cells and the
484
+ pixels given. `data` is the mean radiance in each cell, with the cells
485
+ on its last axis, `centres` the cells' wavelengths and `cells` their
486
+ edges, all in cm. Returns `(spectra, meta)`.
487
+ """
488
+ step = (det.wvl_res * u.pix).to_value(u.cm)
489
+ sigma = width = 0.0
490
+ if sim.psf:
491
+ sigma, width = _spectral_response(tel, det, sim.slit_width, sim.spectral_psf)
492
+ sigma, width = sigma * step, width * step
493
+ pixel_edges = np.concatenate([grid - step / 2, [grid[-1] + step / 2]])
494
+ share = light_onto_pixels(cells[:-1], cells[1:], pixel_edges, sigma, width)
495
+ # Photons from each cell's light at its own wavelength, zero where the
496
+ # telescope's tables do not reach.
497
+ own = np.nan_to_num(photons_per_energy(tel, centres * u.cm).value)
498
+ in_cells = np.asarray(data, dtype=float) * (np.diff(cells) * own)
499
+ photons, photon_wavelength, rms_wavelength = photons_onto_pixels(share, in_cells, centres)
500
+ # Radiance giving each pixel's photons at the pixel's own wavelength. A
501
+ # pixel no light reaches keeps its own wavelength.
502
+ pixel = photons_per_energy(tel, grid * u.cm).value
503
+ spectra = np.divide(photons, step * pixel, out=np.zeros(photons.shape),
504
+ where=pixel > 0)
505
+
506
+ def with_own(wavelength):
507
+ return np.where(np.isfinite(wavelength), wavelength, grid) * u.cm
508
+
509
+ meta = {"photon_wavelength": with_own(photon_wavelength),
510
+ "photon_rms_wavelength": with_own(rms_wavelength)}
511
+ if _pinholes_on(sim):
512
+ # Photons the filter blocked from each cell's light, which a pinhole
513
+ # lets through: the passed photons divided by the transmission, less
514
+ # the passed photons.
515
+ passed = _filter_transmission(tel, centres * u.cm)
516
+ blocked = in_cells * np.divide(1.0 - passed, passed, out=np.zeros(passed.shape),
517
+ where=passed > 0)
518
+ on_pixels, blocked_wavelength, blocked_rms = photons_onto_pixels(share, blocked, centres)
519
+ before = photons + on_pixels
520
+ meta.update({
521
+ "photon_filter_transmission": np.divide(photons, before, out=np.ones(before.shape),
522
+ where=before > 0),
523
+ "blocked_photon_wavelength": with_own(blocked_wavelength),
524
+ "blocked_photon_rms_wavelength": with_own(blocked_rms)})
525
+ return spectra, meta
526
+
527
+
528
+ def _slit_openings(tel, sim) -> dict:
529
+ """
530
+ Stretches where light gets through the slit, for
531
+ `reproject_ndcube_heliocentric_to_helioprojective`, when `sim.slit_mask`
532
+ is on.
533
+ """
534
+ if not getattr(sim, "slit_mask", False):
535
+ return {}
536
+ return {"openings": _openings_along_the_slit(tel, sim.slit_width)}
537
+
538
+
539
+ def _openings_along_the_slit(tel, slit_width: u.Quantity) -> np.ndarray:
540
+ """`(n, 2)` stretches of the slit that let light through, in arcsec from its centre."""
541
+ if tel is None or not hasattr(tel, "slit_openings"):
542
+ raise ValueError("slit_mask needs the SWC telescope, which describes the slit's "
543
+ "ends and fiducials.")
544
+ return tel.slit_openings(slit_width).to_value(u.arcsec)
545
+
546
+
547
+ def _spatial_blur(tel, det, sim) -> dict:
548
+ """
549
+ Blur along and across the slit, as standard deviations in arcsec, for
550
+ :func:`reproject_ndcube_heliocentric_to_helioprojective`.
551
+ """
552
+ along = (_fwhm_to_sigma(spatial_psf_fwhm(tel, det))
553
+ * (det.plate_scale_angle * u.pix).to_value(u.arcsec))
554
+ across = across_slit_fwhm(tel)
555
+ return {"sigma_y": along,
556
+ "sigma_x": 0.0 if across is None else _fwhm_to_sigma(across.to_value(u.arcsec)),
557
+ "extend": sim.psf_boundary == "replicate"}
558
+
559
+
560
+ def rebin_atmosphere(cube_sim, det, sim, use_dask=False, *, tel=None):
561
+ """
562
+ Rebin synthetic atmosphere cube to instrument resolution and spatial sampling.
563
+
564
+ Each detector pixel holds the mean of the scene over its footprint, in
565
+ wavelength and in space. Given `tel`, each wavelength's light is counted
566
+ in photons at its own wavelength. The cube's `meta` then holds
567
+ `photon_wavelength` and `photon_rms_wavelength`, the wavelengths of the
568
+ mean and RMS energies of each pixel's photons, and, with the pinholes of
569
+ `sim` on, what they need of the light the filter blocked. With `sim.psf`
570
+ on as well, the focusing optics' PSF blurs the scene on its own grids
571
+ before the pixels average it, which is exact however fine the scene is.
572
+ `meta` records this in `psf_applied`, so the Monte Carlo does not blur
573
+ it again.
574
+
575
+ Parameters
576
+ ----------
577
+ cube_sim : NDCube
578
+ Input synthetic atmosphere cube
579
+ det : Detector_SWC or Detector_EIS
580
+ Detector configuration
581
+ sim : Simulation
582
+ Simulation configuration
583
+ use_dask : bool, optional
584
+ Unused, kept for existing calls.
585
+ tel : Telescope_EUVST or Telescope_EIS, optional
586
+ Telescope the Monte Carlo observes through. Without it, the cube is
587
+ the scene on the pixels, as for the ground truth.
588
+
589
+ Returns
590
+ -------
591
+ NDCube
592
+ Rebinned cube at instrument resolution
593
+ """
594
+ print(" Spectral rebinning to instrument resolution (ny,nx,*nl*)...")
595
+ if tel is None:
596
+ cube_spec = resample_ndcube_spectral_axis(cube_sim, spectral_axis=2,
597
+ output_resolution=det.wvl_res*u.pix,
598
+ ncpu=sim.ncpu)
599
+ print(" Spatially rebinning to plate scale (*ny*,nx,nl) and slit width (ny,*nx*,nl)...")
600
+ return reproject_ndcube_heliocentric_to_helioprojective(cube_spec, sim, det,
601
+ ncpu=sim.ncpu,
602
+ **_slit_openings(tel, sim))
603
+
604
+ blurred = _through_the_optics(tel, sim)
605
+ data, grid, photon_meta = _spectra_on_the_detector(
606
+ cube_sim.data, cube_sim.axis_world_coords_values(2)[0], det, sim, tel)
607
+ wcs = cube_sim.wcs.deepcopy()
608
+ unit = wcs.wcs.cunit[0]
609
+ wcs.wcs.crpix[0], wcs.wcs.crval[0], wcs.wcs.cdelt[0] = _even_grid_wcs(grid, unit)
610
+ cube_spec = NDCube(data, wcs=wcs, unit=cube_sim.unit,
611
+ meta={**(cube_sim.meta or {}), **photon_meta,
612
+ **({"psf_applied": True} if blurred else {})})
613
+ print(" Spatially rebinning to plate scale (*ny*,nx,nl) and slit width (ny,*nx*,nl)...")
614
+ return reproject_ndcube_heliocentric_to_helioprojective(
615
+ cube_spec, sim, det, ncpu=sim.ncpu, **(_spatial_blur(tel, det, sim) if blurred else {}),
616
+ **_slit_openings(tel, sim))
617
+
618
+
619
+ def rebin_spectra(synthesis, reference_line: str, det, sim, summed=None, meta=None, *,
620
+ tel=None) -> NDCube:
621
+ """
622
+ A synthesis file's spectra at instrument resolution and spatial sampling.
623
+
624
+ The lines reaching the window of `reference_line` are summed on its
625
+ wavelengths, as in :func:`sum_line_cubes`. They then go through the
626
+ same two steps as in :func:`rebin_atmosphere`, with wavelengths from the
627
+ file rather than a WCS: a flux-conserving resampling straight from their
628
+ own grid, which need not be evenly spaced, then rebinning onto the plate
629
+ scale and the slit.
630
+
631
+ Parameters
632
+ ----------
633
+ synthesis : eclipse.synthesis_file.Synthesis
634
+ Spectra read from a synthesis file.
635
+ reference_line : str
636
+ Line whose window is observed and whose velocity is measured.
637
+ det : Detector_SWC or Detector_EIS
638
+ Detector configuration
639
+ sim : Simulation
640
+ Simulation configuration
641
+ summed : u.Quantity, optional
642
+ `synthesis.summed(reference_line)`, if already computed.
643
+ meta : dict, optional
644
+ Cube metadata beyond what
645
+ :meth:`~eclipse.synthesis_file.Synthesis.summed_meta` gives,
646
+ such as the synthesis's `dynamic_mode`, or a time series' `raster`
647
+ entries, whose columns are its exposures and are kept as they are.
648
+ tel : Telescope_EUVST or Telescope_EIS, optional
649
+ Telescope, as for :func:`rebin_atmosphere`.
650
+
651
+ Returns
652
+ -------
653
+ NDCube
654
+ Rebinned cube at instrument resolution
655
+ """
656
+ print(" Spectral rebinning to instrument resolution (ny,nx,*nl*)...")
657
+ reference = synthesis.lines[reference_line]
658
+ radiance = synthesis.summed(reference_line) if summed is None else summed
659
+ blurred = _through_the_optics(tel, sim)
660
+ if tel is not None:
661
+ data, grid, photon_meta = _spectra_on_the_detector(
662
+ radiance.value, reference.wavelength, det, sim, tel)
663
+ meta = {**(meta or {}), **photon_meta,
664
+ **({"psf_applied": True} if blurred else {})}
665
+ else:
666
+ data, grid = resample_spectra(radiance.value, reference.wavelength, det.wvl_res * u.pix)
667
+
668
+ # Synthesis cube WCS: wavelength in cm, then x and y on the Sun in Mm,
669
+ # each referenced to the middle of its axis.
670
+ ny, nx, _ = data.shape
671
+ crpix_wave, crval_wave, cdelt_wave = _even_grid_wcs(grid, u.cm)
672
+ wcs = WCS(naxis=3)
673
+ wcs.wcs.ctype = ["WAVE", "SOLX", "SOLY"]
674
+ wcs.wcs.cunit = ["cm", "Mm", "Mm"]
675
+ wcs.wcs.crpix = [crpix_wave, (nx + 1) / 2, (ny + 1) / 2]
676
+ wcs.wcs.crval = [crval_wave, synthesis.centre("x").to_value(u.Mm),
677
+ synthesis.centre("y").to_value(u.Mm)]
678
+ wcs.wcs.cdelt = [cdelt_wave, synthesis.pixel_size("x").to_value(u.Mm),
679
+ synthesis.pixel_size("y").to_value(u.Mm)]
680
+ # A synthesis file holds the spectra as the observer sees them, so the
681
+ # Doppler shifts have the sign the fits expect.
682
+ cube_spec = NDCube(data, wcs=wcs, unit=radiance.unit,
683
+ meta={**synthesis.summed_meta(reference_line), **(meta or {})})
684
+
685
+ print(" Spatially rebinning to plate scale (*ny*,nx,nl) and slit width (ny,*nx*,nl)...")
686
+ return reproject_ndcube_heliocentric_to_helioprojective(
687
+ cube_spec, sim, det, ncpu=sim.ncpu, **(_spatial_blur(tel, det, sim) if blurred else {}),
688
+ **_slit_openings(tel, sim))
689
+
690
+
691
+ def pad_spectral_axis(cube: NDCube, n: int) -> NDCube:
692
+ """
693
+ `cube` with `n` empty pixels added at each end of its wavelength axis.
694
+
695
+ Wavelength is the last data axis, as in every detector-grid cube, and
696
+ so the first WCS axis, since the WCS lists axes in reverse. The
697
+ reference pixel moves with the data, so existing pixels keep their
698
+ wavelengths. Used to widen a synthesis window for a spectral PSF that
699
+ reaches beyond its margin
700
+ (:func:`~eclipse.radiometric.spectral_psf_margin`).
701
+ """
702
+ if n < 0:
703
+ raise ValueError(f"Cannot pad by a negative number of pixels, got {n}.")
704
+ if n == 0:
705
+ return cube
706
+ wcs = cube.wcs.deepcopy()
707
+ if not wcs.wcs.ctype[0].startswith("WAVE"):
708
+ raise ValueError(
709
+ f"Expected wavelength on the last data axis, which is the first WCS "
710
+ f"axis, but the WCS axes are {list(wcs.wcs.ctype)}.")
711
+ wcs.wcs.crpix[0] += n
712
+ data = np.pad(cube.data, [(0, 0)] * (cube.data.ndim - 1) + [(n, n)])
713
+ return NDCube(data, wcs=wcs, unit=cube.unit, meta=cube.meta)
714
+
715
+
716
+ # Cells per detector pixel a uniform-intensity line is laid on, with the PSF.
717
+ # With 64 the pixels change by under 2e-5 of the peak, and the photon
718
+ # wavelengths by under 1e-4 pixel, against 256.
719
+ _CELLS_PER_PIXEL = 64
720
+
721
+
722
+ def _line_in_cells(centre: float, sigma: float, n_pix_half: int, step: float,
723
+ offset: float = 0.0) -> tuple:
724
+ """
725
+ Cell edges over `2 * n_pix_half + 1` pixels of width `step` centred on
726
+ `centre`, `_CELLS_PER_PIXEL` cells to a pixel, and the share of a
727
+ Gaussian line of `sigma` pixels in each.
728
+
729
+ The line is `offset` pixels above `centre`. A `sigma` of 0 puts the
730
+ line in the cell holding its centre, or half in each cell if it sits on
731
+ the edge between two.
732
+ """
733
+ in_pixels = (np.arange((2 * n_pix_half + 1) * _CELLS_PER_PIXEL + 1) / _CELLS_PER_PIXEL
734
+ - n_pix_half - 0.5)
735
+ from_line = in_pixels - offset
736
+ if sigma > 0:
737
+ below = 0.5 * erf(from_line / (np.sqrt(2.0) * sigma))
738
+ else:
739
+ below = 0.5 * np.sign(from_line)
740
+ return centre + in_pixels * step, np.diff(below)
741
+
742
+
743
+ def create_uniform_intensity_cube(
744
+ total_intensity: u.Quantity,
745
+ rest_wavelength: u.Quantity,
746
+ thermal_width: u.Quantity,
747
+ det,
748
+ sim,
749
+ n_sigma_extent: float = 8.0,
750
+ n_slit_pixels: int = 1,
751
+ tel=None,
752
+ ) -> NDCube:
753
+ """
754
+ Make a cube of one Gaussian line of known total intensity on the detector pixels.
755
+
756
+ This is the cube `eclipse` observes for a single line of known intensity,
757
+ ready for `monte_carlo` or `simulate_once`. Without the PSF, each
758
+ wavelength pixel holds the line integrated over that pixel, so the cube
759
+ holds all of the line within its wavelengths, however narrow the line.
760
+ The tails beyond `n_sigma_extent` are left out.
761
+
762
+ With `tel` given and `sim.psf` on, the line is laid onto the pixels as
763
+ a synthesis is by :func:`rebin_spectra`: its photons are counted at
764
+ their own wavelengths, then blurred by the spectral response
765
+ (:func:`~eclipse.radiometric.spectral_psf_fwhm`, with
766
+ `sim.spectral_psf`) and integrated over the pixels. The pixels then hold
767
+ the radiance that gives each pixel's photons at the pixel's wavelength,
768
+ so they add up to `total_intensity` closely but not exactly, as the
769
+ effective area and photon energy change across the line. `meta` holds the photon
770
+ entries this gives, such as `photon_wavelength`, records the blur in
771
+ `psf_applied`, so the Monte Carlo does not blur it again, and records
772
+ the response in `spectral_response`. The line is uniform along the
773
+ slit, so it needs no blur there. Observe the cube with the same
774
+ telescope, slit and `spectral_psf`.
775
+
776
+ Parameters
777
+ ----------
778
+ total_intensity : u.Quantity
779
+ Total line intensity, such as `5000 * u.erg / (u.s * u.cm**2 * u.sr)`.
780
+ rest_wavelength : u.Quantity
781
+ Rest wavelength, such as `195.119 * u.AA`.
782
+ thermal_width : u.Quantity
783
+ Line width as a 1-sigma velocity, such as `20 * u.km / u.s`.
784
+ det : Detector_SWC or Detector_EIS
785
+ Detector whose pixels the cube is on.
786
+ sim : Simulation
787
+ Simulation, which sets the slit, `psf` and `spectral_psf`.
788
+ n_sigma_extent : float, optional
789
+ Number of sigma of the line to hold either side of its centre.
790
+ Default 8.
791
+ n_slit_pixels : int, optional
792
+ Number of identical pixels along the slit. Set it to the off-chip
793
+ binning, so each binned pixel sums that many, each with its own
794
+ noise. Default 1.
795
+ tel : Telescope_EUVST or Telescope_EIS, optional
796
+ Telescope. If given, the wavelength range is wide enough to hold the
797
+ line after the spectral PSF blurs it, and with `sim.psf` on the line
798
+ is blurred by it. Give it if the PSF will be on. Default None.
799
+
800
+ Returns
801
+ -------
802
+ NDCube
803
+ Cube of shape (`n_slit_pixels`, 1, wavelength), in
804
+ erg / (s cm2 sr cm).
805
+ """
806
+ if n_slit_pixels < 1:
807
+ raise ValueError(f"n_slit_pixels must be >= 1, got {n_slit_pixels}")
808
+
809
+ # --- Spectral grid --------------------------------------------------
810
+ lam0 = rest_wavelength.to(u.cm)
811
+
812
+ # Convert velocity width to wavelength width: sigma_lam = lam0 * v / c
813
+ sigma_lam = (lam0 * thermal_width / const.c).to(u.cm)
814
+
815
+ # Detector pixel pitch in cm
816
+ dlam = det.wvl_res.to(u.cm / u.pix) * u.pix # strip per-pixel to cm
817
+
818
+ # The grid must hold the line as measured, not as emitted, so add the
819
+ # spectral PSF to the thermal width in quadrature, as Gaussian widths
820
+ # add. The PSF is often the broader: at the default 20 km/s the line is
821
+ # 0.77 pixels against a PSF of 1.08. The grid is widened with psf on or
822
+ # off, so the line with and without the PSF is on the same pixels. The
823
+ # PSF is the one for this slit, with the slit added in quadrature, which
824
+ # is at least as broad as the slit convolved with the optics, so the
825
+ # grid holds the line under either spectral_psf.
826
+ sigma_total = sigma_lam
827
+ if tel is not None:
828
+ sigma_psf = _spectral_response(tel, det, sim.slit_width, "quadrature")[0] * dlam
829
+ sigma_total = np.sqrt(sigma_lam**2 + sigma_psf**2)
830
+
831
+ half_range = n_sigma_extent * sigma_total
832
+ n_pix_half = int(np.ceil((half_range / dlam).decompose().value))
833
+ n_lam = 2 * n_pix_half + 1 # always odd, centred on rest wavelength
834
+
835
+ # --- Gaussian profile -----------------------------------------------
836
+ # Without the PSF, each pixel holds the line integrated between its edges
837
+ # and divided by its width, as resample_spectra gives a synthesised
838
+ # spectrum, so the pixels sum to total_intensity less the tails beyond
839
+ # n_sigma_extent, whatever the line width. Sampling the Gaussian at pixel centres only
840
+ # does that for a sigma above about half a pixel: a line of 0.3 pixels
841
+ # centred on a pixel, such as Fe VIII 185.21 at its formation
842
+ # temperature, would come out 35 per cent too bright. Edges are counted
843
+ # in pixels from the line centre, since absolute wavelengths would lose
844
+ # a part in 1e12 of a pixel to rounding. Each pixel's upper edge is the
845
+ # next one's lower edge, so nothing is counted twice or missed.
846
+ unit = u.erg / (u.s * u.cm**2 * u.sr * u.cm)
847
+ blurred = _through_the_optics(tel, sim)
848
+ photon_meta = {}
849
+ if blurred:
850
+ # The line goes onto the pixels as a synthesis does: the photons of
851
+ # each cell at its own wavelength, then the spectral response. Each
852
+ # cell holds the line integrated over it, and the cells are fine
853
+ # enough that the result does not depend on them. The pixels then
854
+ # hold the radiance that gives their photons at their own
855
+ # wavelengths, so they add up to the intensity only where the
856
+ # effective area and photon energy do not change across the line.
857
+ response = _spectral_response(tel, det, sim.slit_width, sim.spectral_psf)
858
+ step = dlam.to_value(u.cm)
859
+ cells, share = _line_in_cells(lam0.value, (sigma_lam / dlam).decompose().value,
860
+ n_pix_half, step)
861
+ radiance = total_intensity.to_value(unit * u.cm) * share / np.diff(cells)
862
+ grid = lam0.value + (np.arange(n_lam) - n_pix_half) * step
863
+ profile, photon_meta = _cells_onto_pixels(radiance, 0.5 * (cells[:-1] + cells[1:]),
864
+ cells, grid, det, sim, tel)
865
+ profile = profile * unit
866
+ else:
867
+ edges = ((np.arange(n_lam + 1) - n_pix_half - 0.5) * dlam
868
+ / (np.sqrt(2.0) * sigma_lam)).decompose().value
869
+ share = 0.5 * np.diff(erf(edges))
870
+ profile = (total_intensity * share / dlam).to(unit)
871
+ # Tile the profile along the slit axis. Every slit pixel holds the same
872
+ # intensity but gets its own noise downstream, which rebin_slit_offchip
873
+ # needs to sum them.
874
+ data = np.tile(profile.value, (n_slit_pixels, 1, 1)) # shape (n_slit_pixels, 1, n_lam)
875
+ photon_meta = {key: np.tile(value, (n_slit_pixels, 1, 1))
876
+ for key, value in photon_meta.items()}
877
+
878
+ # --- WCS (matches reproject_ndcube output format) --------------------
879
+ # Axes: WAVE (cm), HPLN-TAN (arcsec), HPLT-TAN (arcsec)
880
+ wcs = WCS(naxis=3)
881
+ wcs.wcs.ctype = ["WAVE", "HPLN-TAN", "HPLT-TAN"]
882
+ wcs.wcs.cunit = ["cm", "arcsec", "arcsec"]
883
+ wcs.wcs.crpix = [(n_lam + 1) / 2, 1.0, (n_slit_pixels + 1) / 2]
884
+ wcs.wcs.crval = [lam0.value, 0.0, 0.0]
885
+ wcs.wcs.cdelt = [
886
+ dlam.to_value(u.cm),
887
+ sim.slit_width.to_value(u.arcsec),
888
+ (det.plate_scale_angle * u.pix).to_value(u.arcsec),
889
+ ]
890
+
891
+ return NDCube(
892
+ data=data,
893
+ wcs=wcs,
894
+ unit=unit,
895
+ meta={
896
+ "rest_wav": rest_wavelength,
897
+ "uniform_intensity": total_intensity,
898
+ "thermal_width": thermal_width,
899
+ "line_name": "uniform",
900
+ "uniform_mode": True,
901
+ **photon_meta,
902
+ **({"psf_applied": True, "spectral_response": response} if blurred else {}),
903
+ },
904
+ )