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.
- eclipse/__init__.py +95 -0
- eclipse/analysis.py +853 -0
- eclipse/atmosphere.py +859 -0
- eclipse/band.py +477 -0
- eclipse/cli.py +138 -0
- eclipse/config.py +1710 -0
- eclipse/continuum.py +413 -0
- eclipse/data/eis_response/EIS_EffArea_A.004 +61 -0
- eclipse/data/eis_response/EIS_EffArea_B.004 +60 -0
- eclipse/data/eis_response/EIS_EffArea_B.005 +64 -0
- eclipse/data/eis_response/README_dz2025 +50 -0
- eclipse/data/eis_response/fit_eis_ea_2023-05-04_smooth.sav +0 -0
- eclipse/data/eis_response/source.txt +33 -0
- eclipse/data/science_cases.yaml +365 -0
- eclipse/data/throughput/grating_reflection_efficiency.dat +25 -0
- eclipse/data/throughput/primary_mirror_coating_reflectance.dat +25 -0
- eclipse/data/throughput/source.txt +6 -0
- eclipse/data/throughput/throughput_aluminium_1000_angstrom.dat +503 -0
- eclipse/data/throughput/throughput_aluminium_oxide_1000_angstrom.dat +503 -0
- eclipse/data/throughput/throughput_carbon_1000_angstrom.dat +503 -0
- eclipse/data_processing.py +904 -0
- eclipse/deprecated.py +161 -0
- eclipse/eis_calibration.py +573 -0
- eclipse/extern/__init__.py +0 -0
- eclipse/extern/mpfit.py +2388 -0
- eclipse/fitting.py +1734 -0
- eclipse/frame.py +594 -0
- eclipse/full_frame.py +944 -0
- eclipse/main.py +1646 -0
- eclipse/monte_carlo.py +660 -0
- eclipse/pinhole_diffraction.py +409 -0
- eclipse/psf.py +6 -0
- eclipse/radiometric.py +1328 -0
- eclipse/raster.py +869 -0
- eclipse/readout.py +1002 -0
- eclipse/resolution_budget.py +108 -0
- eclipse/results_file.py +1226 -0
- eclipse/sampling.py +351 -0
- eclipse/science_cases.py +364 -0
- eclipse/synthesis.py +2492 -0
- eclipse/synthesis_cli.py +12 -0
- eclipse/synthesis_file.py +882 -0
- eclipse/utils.py +1145 -0
- eclipse-0.13.0.dist-info/METADATA +70 -0
- eclipse-0.13.0.dist-info/RECORD +50 -0
- eclipse-0.13.0.dist-info/WHEEL +5 -0
- eclipse-0.13.0.dist-info/entry_points.txt +7 -0
- eclipse-0.13.0.dist-info/licenses/LICENSE +3 -0
- eclipse-0.13.0.dist-info/top_level.txt +2 -0
- 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
|
+
)
|