utu 0.1.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.
- utu/__init__.py +9 -0
- utu/_tests/__init__.py +0 -0
- utu/_tests/test_utu.py +7 -0
- utu/_version.py +8 -0
- utu/spectrum/__init__.py +17 -0
- utu/spectrum/_lines.py +404 -0
- utu/spectrum/_names.py +90 -0
- utu/spectrum/_plots.py +180 -0
- utu/spectrum/_tests/__init__.py +0 -0
- utu/spectrum/_tests/test_lines.py +181 -0
- utu/spectrum/_tests/test_names.py +34 -0
- utu/spectrum/_tests/test_plots.py +83 -0
- utu-0.1.0.dist-info/METADATA +52 -0
- utu-0.1.0.dist-info/RECORD +17 -0
- utu-0.1.0.dist-info/WHEEL +5 -0
- utu-0.1.0.dist-info/licenses/LICENSE +28 -0
- utu-0.1.0.dist-info/top_level.txt +1 -0
utu/__init__.py
ADDED
utu/_tests/__init__.py
ADDED
|
File without changes
|
utu/_tests/test_utu.py
ADDED
utu/_version.py
ADDED
utu/spectrum/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""The spectrum of an optically thin plasma, from the CHIANTI atomic database."""
|
|
2
|
+
|
|
3
|
+
from ._lines import (
|
|
4
|
+
contribution_function,
|
|
5
|
+
ions,
|
|
6
|
+
lines,
|
|
7
|
+
)
|
|
8
|
+
from ._names import spectroscopic
|
|
9
|
+
from ._plots import stem
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"contribution_function",
|
|
13
|
+
"ions",
|
|
14
|
+
"lines",
|
|
15
|
+
"spectroscopic",
|
|
16
|
+
"stem",
|
|
17
|
+
]
|
utu/spectrum/_lines.py
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
"""The emission lines of an optically thin plasma."""
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
|
|
5
|
+
import astropy.units as u
|
|
6
|
+
import fiasco
|
|
7
|
+
import named_arrays as na
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"contribution_function",
|
|
12
|
+
"ions",
|
|
13
|
+
"lines",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def ions(
|
|
18
|
+
wavelength_min: None | u.Quantity | na.AbstractScalar = None,
|
|
19
|
+
wavelength_max: None | u.Quantity | na.AbstractScalar = None,
|
|
20
|
+
abundance_min: float = 1e-5,
|
|
21
|
+
**kwargs: object,
|
|
22
|
+
) -> list[str]:
|
|
23
|
+
r"""
|
|
24
|
+
Find the ions worth computing over a range of wavelengths.
|
|
25
|
+
|
|
26
|
+
An ion is worth computing if its element is abundant enough to contribute
|
|
27
|
+
and if it has a line in the range at all. Reading the line list of an ion
|
|
28
|
+
is cheap; solving its level populations is not, and this is how the
|
|
29
|
+
second is avoided for ions which cannot matter.
|
|
30
|
+
|
|
31
|
+
Parameters
|
|
32
|
+
----------
|
|
33
|
+
wavelength_min
|
|
34
|
+
The shortest wavelength worth looking at.
|
|
35
|
+
If :obj:`None` (the default), there is no lower bound.
|
|
36
|
+
wavelength_max
|
|
37
|
+
The longest wavelength worth looking at.
|
|
38
|
+
If :obj:`None` (the default), there is no upper bound.
|
|
39
|
+
abundance_min
|
|
40
|
+
The abundance, relative to hydrogen, below which an element is not
|
|
41
|
+
worth including.
|
|
42
|
+
kwargs
|
|
43
|
+
Additional arguments passed to :class:`fiasco.Ion`.
|
|
44
|
+
|
|
45
|
+
Notes
|
|
46
|
+
-----
|
|
47
|
+
Which ions come back is a statement about the database as much as about
|
|
48
|
+
the wavelengths: an ion the database does not describe cannot be
|
|
49
|
+
returned, and the databases built for a documentation page or a test
|
|
50
|
+
suite describe only a few.
|
|
51
|
+
|
|
52
|
+
The database is read on the first call and remembered afterwards, so the
|
|
53
|
+
first call takes a few seconds and the rest take none. What is
|
|
54
|
+
remembered is the wavelengths of the lines of the ions abundant enough
|
|
55
|
+
to pass ``abundance_min``, a few megabytes.
|
|
56
|
+
|
|
57
|
+
Examples
|
|
58
|
+
--------
|
|
59
|
+
The line ESIS was built to observe is a line of :math:`\mathrm{O\,V}`.
|
|
60
|
+
|
|
61
|
+
.. jupyter-execute::
|
|
62
|
+
|
|
63
|
+
import astropy.units as u
|
|
64
|
+
import utu
|
|
65
|
+
|
|
66
|
+
"O 5" in utu.spectrum.ions(
|
|
67
|
+
wavelength_min=629 * u.AA,
|
|
68
|
+
wavelength_max=630 * u.AA,
|
|
69
|
+
)
|
|
70
|
+
"""
|
|
71
|
+
wavelength_min = _quantity(wavelength_min)
|
|
72
|
+
wavelength_max = _quantity(wavelength_max)
|
|
73
|
+
|
|
74
|
+
result = []
|
|
75
|
+
|
|
76
|
+
for name, w in _catalog(abundance_min, **kwargs).items():
|
|
77
|
+
where = np.ones(w.shape, dtype=bool)
|
|
78
|
+
if wavelength_min is not None:
|
|
79
|
+
where = where & (w > wavelength_min)
|
|
80
|
+
if wavelength_max is not None:
|
|
81
|
+
where = where & (w < wavelength_max)
|
|
82
|
+
if not np.any(where):
|
|
83
|
+
continue
|
|
84
|
+
result.append(name)
|
|
85
|
+
|
|
86
|
+
return result
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _quantity(
|
|
90
|
+
value: None | u.Quantity | na.AbstractScalar,
|
|
91
|
+
) -> None | u.Quantity:
|
|
92
|
+
"""
|
|
93
|
+
A value as a plain quantity, however it was given.
|
|
94
|
+
|
|
95
|
+
:mod:`fiasco` is not a named-arrays library, and neither are the line
|
|
96
|
+
lists it returns, so anything handed to it or compared against it has to
|
|
97
|
+
shed its axes on the way.
|
|
98
|
+
"""
|
|
99
|
+
if isinstance(value, na.AbstractArray):
|
|
100
|
+
return value.ndarray
|
|
101
|
+
return value
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@functools.cache
|
|
105
|
+
def _catalog(
|
|
106
|
+
abundance_min: float,
|
|
107
|
+
**kwargs: object,
|
|
108
|
+
) -> dict[str, u.Quantity]:
|
|
109
|
+
"""
|
|
110
|
+
The wavelengths of every line of every ion abundant enough to matter.
|
|
111
|
+
|
|
112
|
+
Read once and remembered afterwards. The database does not change while
|
|
113
|
+
a program runs, and reading it is where nearly all the time of
|
|
114
|
+
:func:`ions` goes: five hundred ions at about thirty milliseconds each,
|
|
115
|
+
almost none of it spent on the file.
|
|
116
|
+
"""
|
|
117
|
+
result = {}
|
|
118
|
+
|
|
119
|
+
for name in fiasco.list_ions():
|
|
120
|
+
try:
|
|
121
|
+
ion = fiasco.Ion(name, 1 * u.MK, **kwargs)
|
|
122
|
+
|
|
123
|
+
if ion.abundance is None or ion.abundance < abundance_min:
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
transitions = ion.transitions
|
|
127
|
+
if transitions is None: # pragma: nocover
|
|
128
|
+
continue
|
|
129
|
+
|
|
130
|
+
wavelength = transitions.wavelength
|
|
131
|
+
|
|
132
|
+
except Exception:
|
|
133
|
+
# an ion the database cannot describe is an ion which cannot
|
|
134
|
+
# contribute, and there are a handful of them
|
|
135
|
+
continue
|
|
136
|
+
|
|
137
|
+
result[str(name)] = wavelength
|
|
138
|
+
|
|
139
|
+
return result
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
_ions = ions
|
|
143
|
+
"""
|
|
144
|
+
A private alias for :func:`ions`, so that :func:`lines` can take a parameter
|
|
145
|
+
of that name without hiding the function it falls back on.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def contribution_function(
|
|
150
|
+
ion: fiasco.Ion,
|
|
151
|
+
density: u.Quantity | na.AbstractScalar,
|
|
152
|
+
axis_temperature: str,
|
|
153
|
+
axis: str = "line",
|
|
154
|
+
proton_electron_ratio: None | u.Quantity | na.AbstractScalar = None,
|
|
155
|
+
) -> na.FunctionArray:
|
|
156
|
+
"""
|
|
157
|
+
Compute the contribution function of every line of an ion.
|
|
158
|
+
|
|
159
|
+
Returned as a function of wavelength, so that a line and its strength
|
|
160
|
+
cannot come apart. They are separate arrays underneath, of different
|
|
161
|
+
lengths whenever an ion has a two-photon transition, and pairing them by
|
|
162
|
+
hand is a way to label every line with its neighbor's wavelength.
|
|
163
|
+
|
|
164
|
+
Whether the temperatures and the densities are taken in pairs or as a
|
|
165
|
+
grid is decided by their axes. A density which shares the axis of the
|
|
166
|
+
temperature describes one density per temperature, an isobaric
|
|
167
|
+
atmosphere for instance, and is computed as such. A density on an axis of
|
|
168
|
+
its own describes every density at every temperature, and costs as many
|
|
169
|
+
times more.
|
|
170
|
+
|
|
171
|
+
Parameters
|
|
172
|
+
----------
|
|
173
|
+
ion
|
|
174
|
+
The ion to compute the contribution function of.
|
|
175
|
+
density
|
|
176
|
+
The number density of electrons.
|
|
177
|
+
axis_temperature
|
|
178
|
+
The name of the axis of the temperature of ``ion``.
|
|
179
|
+
axis
|
|
180
|
+
The name to give the axis along the lines of the result.
|
|
181
|
+
proton_electron_ratio
|
|
182
|
+
The ratio of protons to electrons at each temperature of ``ion``.
|
|
183
|
+
If :obj:`None` (the default), :mod:`fiasco` computes it, which walks
|
|
184
|
+
the whole database and takes an order of magnitude longer than the
|
|
185
|
+
rest of this function put together. It depends on the temperature
|
|
186
|
+
and on nothing else, so a caller with more than one ion should
|
|
187
|
+
compute it once with :func:`fiasco.proton_electron_ratio` and pass
|
|
188
|
+
it here. Doing so primes the cache of ``ion`` with the value it
|
|
189
|
+
would otherwise have computed for itself.
|
|
190
|
+
|
|
191
|
+
Examples
|
|
192
|
+
--------
|
|
193
|
+
The contribution function of the line ESIS was built to observe, which
|
|
194
|
+
peaks at the temperature the line is formed at.
|
|
195
|
+
|
|
196
|
+
.. jupyter-execute::
|
|
197
|
+
|
|
198
|
+
import astropy.units as u
|
|
199
|
+
import fiasco
|
|
200
|
+
import matplotlib.pyplot as plt
|
|
201
|
+
import named_arrays as na
|
|
202
|
+
import numpy as np
|
|
203
|
+
import utu
|
|
204
|
+
|
|
205
|
+
axis = "temperature"
|
|
206
|
+
temperature = na.geomspace(1e4, 1e7, axis=axis, num=61) * u.K
|
|
207
|
+
|
|
208
|
+
ion = fiasco.Ion("O 5", temperature.ndarray)
|
|
209
|
+
|
|
210
|
+
result = utu.spectrum.contribution_function(
|
|
211
|
+
ion=ion,
|
|
212
|
+
density=1e15 * u.K / u.cm ** 3 / temperature,
|
|
213
|
+
axis_temperature=axis,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
# the strongest line of the ion, at 629.7 angstroms
|
|
217
|
+
index = np.argmax(result.outputs.max(axis), axis="line")
|
|
218
|
+
|
|
219
|
+
fig, ax = plt.subplots(constrained_layout=True)
|
|
220
|
+
na.plt.plot(
|
|
221
|
+
temperature,
|
|
222
|
+
result.outputs[index],
|
|
223
|
+
ax=ax,
|
|
224
|
+
axis=axis,
|
|
225
|
+
)
|
|
226
|
+
ax.set_xscale("log")
|
|
227
|
+
ax.set_xlabel(f"temperature ({temperature.unit:latex_inline})")
|
|
228
|
+
ax.set_ylabel(f"$G(T)$ ({result.outputs.unit:latex_inline})")
|
|
229
|
+
"""
|
|
230
|
+
if proton_electron_ratio is not None:
|
|
231
|
+
ion.__dict__["proton_electron_ratio"] = _quantity(proton_electron_ratio)
|
|
232
|
+
|
|
233
|
+
axis_density = tuple(na.shape(density))
|
|
234
|
+
|
|
235
|
+
coupled = tuple(axis_density) == (axis_temperature,)
|
|
236
|
+
|
|
237
|
+
result = ion.contribution_function(
|
|
238
|
+
na.as_named_array(density).ndarray,
|
|
239
|
+
couple_density_to_temperature=coupled,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
# the last axis of the result runs over the bound-bound transitions, so
|
|
243
|
+
# that is the wavelength which belongs to it
|
|
244
|
+
transitions = ion.transitions
|
|
245
|
+
wavelength = transitions.wavelength[transitions.is_bound_bound]
|
|
246
|
+
|
|
247
|
+
axes = (axis_temperature, "_density", axis)
|
|
248
|
+
result = na.ScalarArray(result, axes=axes)
|
|
249
|
+
if coupled:
|
|
250
|
+
result = result[{"_density": 0}]
|
|
251
|
+
|
|
252
|
+
return na.FunctionArray(
|
|
253
|
+
inputs=na.ScalarArray(wavelength, axes=(axis,)),
|
|
254
|
+
outputs=result,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def lines(
|
|
259
|
+
temperature: na.AbstractScalar,
|
|
260
|
+
density: u.Quantity | na.AbstractScalar,
|
|
261
|
+
emission_measure: u.Quantity | na.AbstractScalar,
|
|
262
|
+
wavelength_min: None | u.Quantity | na.AbstractScalar = None,
|
|
263
|
+
wavelength_max: None | u.Quantity | na.AbstractScalar = None,
|
|
264
|
+
ions: None | list[str] = None,
|
|
265
|
+
proton_electron_ratio: None | u.Quantity | na.AbstractScalar = None,
|
|
266
|
+
axis_temperature: str = "temperature",
|
|
267
|
+
axis: str = "line",
|
|
268
|
+
**kwargs: object,
|
|
269
|
+
) -> na.FunctionArray:
|
|
270
|
+
"""
|
|
271
|
+
Compute the emission lines of an optically thin plasma, brightest first.
|
|
272
|
+
|
|
273
|
+
Every line of every ion abundant enough to contribute, with the intensity
|
|
274
|
+
it would have from a plasma with the given emission measure.
|
|
275
|
+
|
|
276
|
+
The wavelength and the ion of a line are components of the inputs of the
|
|
277
|
+
result, and its intensity is the output, so that sorting or selecting
|
|
278
|
+
lines carries all three together.
|
|
279
|
+
|
|
280
|
+
Parameters
|
|
281
|
+
----------
|
|
282
|
+
temperature
|
|
283
|
+
The temperatures of the plasma.
|
|
284
|
+
density
|
|
285
|
+
The number density of electrons. A density which shares the axis of
|
|
286
|
+
the temperature is one density per temperature, an isobaric
|
|
287
|
+
atmosphere for instance; a density on its own axis is every density
|
|
288
|
+
at every temperature.
|
|
289
|
+
emission_measure
|
|
290
|
+
How much plasma there is at each temperature.
|
|
291
|
+
wavelength_min
|
|
292
|
+
The shortest wavelength worth computing.
|
|
293
|
+
If :obj:`None` (the default), there is no lower bound.
|
|
294
|
+
wavelength_max
|
|
295
|
+
The longest wavelength worth computing.
|
|
296
|
+
If :obj:`None` (the default), there is no upper bound.
|
|
297
|
+
ions
|
|
298
|
+
The ions to compute the lines of, named as :mod:`fiasco` names them.
|
|
299
|
+
If :obj:`None` (the default), they are found with :func:`ions`, which
|
|
300
|
+
is every ion the database describes with a line in ``wavelength``.
|
|
301
|
+
Naming them is how a result is made to depend on the ions rather
|
|
302
|
+
than on which of them the database at hand happens to hold.
|
|
303
|
+
proton_electron_ratio
|
|
304
|
+
The ratio of protons to electrons at each temperature.
|
|
305
|
+
If :obj:`None` (the default), it is computed here, once, and given
|
|
306
|
+
to every ion. Pass it to compute more than one spectrum over one
|
|
307
|
+
grid of temperatures without paying for it again.
|
|
308
|
+
axis_temperature
|
|
309
|
+
The name of the axis of ``temperature``.
|
|
310
|
+
axis
|
|
311
|
+
The name to give the axis along the lines of the result.
|
|
312
|
+
kwargs
|
|
313
|
+
Additional arguments passed to :class:`fiasco.Ion`.
|
|
314
|
+
|
|
315
|
+
Examples
|
|
316
|
+
--------
|
|
317
|
+
The brightest lines of two ions, from a plasma spread evenly over a
|
|
318
|
+
decade of temperature.
|
|
319
|
+
|
|
320
|
+
.. jupyter-execute::
|
|
321
|
+
|
|
322
|
+
import astropy.units as u
|
|
323
|
+
import named_arrays as na
|
|
324
|
+
import utu
|
|
325
|
+
|
|
326
|
+
temperature = na.geomspace(1e5, 1e6, axis="temperature", num=11) * u.K
|
|
327
|
+
|
|
328
|
+
result = utu.spectrum.lines(
|
|
329
|
+
temperature=temperature,
|
|
330
|
+
density=1e15 * u.K / u.cm ** 3 / temperature,
|
|
331
|
+
emission_measure=1e27 / u.cm ** 5,
|
|
332
|
+
wavelength_min=550 * u.AA,
|
|
333
|
+
wavelength_max=680 * u.AA,
|
|
334
|
+
ions=["O 5", "Mg 10"],
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
result[{"line": slice(4)}]
|
|
338
|
+
"""
|
|
339
|
+
t = na.as_named_array(temperature).ndarray
|
|
340
|
+
|
|
341
|
+
# The ratio of protons to electrons depends on the temperature and on
|
|
342
|
+
# nothing else, and computing it walks the entire database. Computed
|
|
343
|
+
# once here and handed to every ion, which is most of what makes this
|
|
344
|
+
# bearable.
|
|
345
|
+
if proton_electron_ratio is None:
|
|
346
|
+
proton_electron_ratio = fiasco.proton_electron_ratio(t, **kwargs)
|
|
347
|
+
|
|
348
|
+
wavelength_all = []
|
|
349
|
+
intensity_all = []
|
|
350
|
+
ion_all = []
|
|
351
|
+
|
|
352
|
+
if ions is None:
|
|
353
|
+
ions = _ions(
|
|
354
|
+
wavelength_min=wavelength_min,
|
|
355
|
+
wavelength_max=wavelength_max,
|
|
356
|
+
**kwargs,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
for name in ions:
|
|
360
|
+
try:
|
|
361
|
+
g = contribution_function(
|
|
362
|
+
ion=fiasco.Ion(name, t, **kwargs),
|
|
363
|
+
density=density,
|
|
364
|
+
axis_temperature=axis_temperature,
|
|
365
|
+
axis=axis,
|
|
366
|
+
proton_electron_ratio=proton_electron_ratio,
|
|
367
|
+
)
|
|
368
|
+
except Exception: # pragma: nocover
|
|
369
|
+
# an ion whose atomic model the database cannot complete
|
|
370
|
+
continue
|
|
371
|
+
|
|
372
|
+
intensity = (g.outputs * emission_measure).sum(axis_temperature)
|
|
373
|
+
|
|
374
|
+
w = g.inputs
|
|
375
|
+
where = None
|
|
376
|
+
if wavelength_min is not None:
|
|
377
|
+
where = w > wavelength_min
|
|
378
|
+
if wavelength_max is not None:
|
|
379
|
+
below = w < wavelength_max
|
|
380
|
+
where = below if where is None else where & below
|
|
381
|
+
if where is not None:
|
|
382
|
+
w, intensity = w[where], intensity[where]
|
|
383
|
+
|
|
384
|
+
wavelength_all.append(w)
|
|
385
|
+
intensity_all.append(intensity)
|
|
386
|
+
ion_all.append(na.ScalarArray(np.array([name] * w.size), axes=(axis,)))
|
|
387
|
+
|
|
388
|
+
result = na.FunctionArray(
|
|
389
|
+
inputs=na.CartesianNdVectorArray(
|
|
390
|
+
components={
|
|
391
|
+
"wavelength": na.concatenate(wavelength_all, axis=axis),
|
|
392
|
+
"ion": na.concatenate(ion_all, axis=axis),
|
|
393
|
+
},
|
|
394
|
+
),
|
|
395
|
+
outputs=na.concatenate(intensity_all, axis=axis),
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
# Brightest first, carrying the wavelength and the ion of each line along
|
|
399
|
+
# with its intensity. `argsort` gives back the index of each axis by
|
|
400
|
+
# name, which is what `__getitem__` takes.
|
|
401
|
+
order = np.argsort(result.outputs, axis=axis)
|
|
402
|
+
result = result[order]
|
|
403
|
+
|
|
404
|
+
return result[{axis: slice(None, None, -1)}]
|
utu/spectrum/_names.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""The names of ions, written the way spectroscopists write them."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
import named_arrays as na
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"spectroscopic",
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
_numeral = (
|
|
12
|
+
(1000, "M"),
|
|
13
|
+
(900, "CM"),
|
|
14
|
+
(500, "D"),
|
|
15
|
+
(400, "CD"),
|
|
16
|
+
(100, "C"),
|
|
17
|
+
(90, "XC"),
|
|
18
|
+
(50, "L"),
|
|
19
|
+
(40, "XL"),
|
|
20
|
+
(10, "X"),
|
|
21
|
+
(9, "IX"),
|
|
22
|
+
(5, "V"),
|
|
23
|
+
(4, "IV"),
|
|
24
|
+
(1, "I"),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _roman(number: int) -> str:
|
|
29
|
+
"""Write a positive integer as a Roman numeral."""
|
|
30
|
+
result = ""
|
|
31
|
+
for value, numeral in _numeral:
|
|
32
|
+
count, number = divmod(number, value)
|
|
33
|
+
result += numeral * count
|
|
34
|
+
return result
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _spectroscopic(name: str, latex: bool) -> str:
|
|
38
|
+
element, _, stage = str(name).partition(" ")
|
|
39
|
+
element = element.capitalize()
|
|
40
|
+
numeral = _roman(int(stage))
|
|
41
|
+
if latex:
|
|
42
|
+
return rf"{element}\,\textsc{{{numeral.lower()}}}"
|
|
43
|
+
return f"{element} {numeral}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def spectroscopic(
|
|
47
|
+
ion: str | na.AbstractScalar,
|
|
48
|
+
latex: bool = False,
|
|
49
|
+
) -> str | na.AbstractScalar:
|
|
50
|
+
r"""
|
|
51
|
+
Write the name of an ion the way a spectroscopist writes it.
|
|
52
|
+
|
|
53
|
+
The charge state is a Roman numeral, one greater than the charge, so
|
|
54
|
+
that the neutral atom is ``I``. This is how :mod:`fiasco` numbers its
|
|
55
|
+
ions as well, only in Arabic numerals, so ``O 5`` becomes ``O V``.
|
|
56
|
+
|
|
57
|
+
Parameters
|
|
58
|
+
----------
|
|
59
|
+
ion
|
|
60
|
+
The name of an ion, or an array of them, as :mod:`fiasco` writes it.
|
|
61
|
+
latex
|
|
62
|
+
Whether to write the numeral as LaTeX small capitals, which is how
|
|
63
|
+
it is set in print.
|
|
64
|
+
|
|
65
|
+
Examples
|
|
66
|
+
--------
|
|
67
|
+
The ion ESIS was built to observe.
|
|
68
|
+
|
|
69
|
+
.. jupyter-execute::
|
|
70
|
+
|
|
71
|
+
import utu
|
|
72
|
+
|
|
73
|
+
utu.spectrum.spectroscopic("O 5")
|
|
74
|
+
|
|
75
|
+
And as it would be set in a journal.
|
|
76
|
+
|
|
77
|
+
.. jupyter-execute::
|
|
78
|
+
|
|
79
|
+
utu.spectrum.spectroscopic("O 5", latex=True)
|
|
80
|
+
"""
|
|
81
|
+
if isinstance(ion, str):
|
|
82
|
+
return _spectroscopic(ion, latex=latex)
|
|
83
|
+
|
|
84
|
+
ion = na.as_named_array(ion)
|
|
85
|
+
result = np.array([_spectroscopic(i, latex=latex) for i in ion.ndarray.flat])
|
|
86
|
+
|
|
87
|
+
return na.ScalarArray(
|
|
88
|
+
ndarray=result.reshape(ion.ndarray.shape),
|
|
89
|
+
axes=ion.axes,
|
|
90
|
+
)
|
utu/spectrum/_plots.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Drawing the emission lines of a spectrum."""
|
|
2
|
+
|
|
3
|
+
import adjustText
|
|
4
|
+
import matplotlib.axes
|
|
5
|
+
import matplotlib.pyplot as plt
|
|
6
|
+
import matplotlib.text
|
|
7
|
+
import named_arrays as na
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from ._names import spectroscopic
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"stem",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def stem(
|
|
18
|
+
spectrum: na.FunctionArray,
|
|
19
|
+
ax: None | matplotlib.axes.Axes = None,
|
|
20
|
+
num_label: None | int = None,
|
|
21
|
+
latex: bool = False,
|
|
22
|
+
headroom: float = 1.45,
|
|
23
|
+
axis: str = "line",
|
|
24
|
+
kwargs_line: None | dict = None,
|
|
25
|
+
kwargs_text: None | dict = None,
|
|
26
|
+
kwargs_adjust: None | dict = None,
|
|
27
|
+
) -> list[matplotlib.text.Text]:
|
|
28
|
+
"""
|
|
29
|
+
Draw a spectrum as a stem from zero for each line, and label the
|
|
30
|
+
brightest of them with the ion which emitted them.
|
|
31
|
+
|
|
32
|
+
A spectrum of lines is mostly empty, and what matters about it is which
|
|
33
|
+
line is where and how much brighter it is than its neighbours. Drawn
|
|
34
|
+
this way it is the picture an instrument is designed against.
|
|
35
|
+
|
|
36
|
+
The labels are moved apart so that none covers another or crosses a line
|
|
37
|
+
it does not belong to. Each line is handed to the solver as points along
|
|
38
|
+
its length rather than as a point at its tip, more of them for a
|
|
39
|
+
brighter line, which is what keeps a label from being pushed across one.
|
|
40
|
+
|
|
41
|
+
Parameters
|
|
42
|
+
----------
|
|
43
|
+
spectrum
|
|
44
|
+
The lines to draw, as :func:`~utu.spectrum.lines` returns them: a
|
|
45
|
+
wavelength and an ion for each line, and its intensity. Every line
|
|
46
|
+
given is drawn, so slice it first to draw fewer.
|
|
47
|
+
ax
|
|
48
|
+
The axes to draw on. If :obj:`None` (the default), the current axes.
|
|
49
|
+
num_label
|
|
50
|
+
How many of the brightest lines to label.
|
|
51
|
+
If :obj:`None` (the default), all of them are labelled.
|
|
52
|
+
latex
|
|
53
|
+
Whether to set the name of each ion in LaTeX, which needs the axes
|
|
54
|
+
to be rendering text through LaTeX to come out right.
|
|
55
|
+
headroom
|
|
56
|
+
How much taller than the brightest line to make the axes, so that
|
|
57
|
+
the labels have somewhere to be pushed into.
|
|
58
|
+
axis
|
|
59
|
+
The name of the axis along the lines of ``spectrum``.
|
|
60
|
+
kwargs_line
|
|
61
|
+
Additional arguments passed to :meth:`matplotlib.axes.Axes.vlines`.
|
|
62
|
+
kwargs_text
|
|
63
|
+
Additional arguments passed to :meth:`matplotlib.axes.Axes.text`.
|
|
64
|
+
kwargs_adjust
|
|
65
|
+
Additional arguments passed to :func:`adjustText.adjust_text`.
|
|
66
|
+
|
|
67
|
+
Returns
|
|
68
|
+
-------
|
|
69
|
+
The label of each line that was labelled, in the order they were drawn.
|
|
70
|
+
|
|
71
|
+
Examples
|
|
72
|
+
--------
|
|
73
|
+
The brightest lines of two ions, from a plasma spread evenly over a
|
|
74
|
+
decade of temperature.
|
|
75
|
+
|
|
76
|
+
.. jupyter-execute::
|
|
77
|
+
|
|
78
|
+
import astropy.units as u
|
|
79
|
+
import matplotlib.pyplot as plt
|
|
80
|
+
import named_arrays as na
|
|
81
|
+
import utu
|
|
82
|
+
|
|
83
|
+
temperature = na.geomspace(1e5, 1e6, axis="temperature", num=11) * u.K
|
|
84
|
+
|
|
85
|
+
result = utu.spectrum.lines(
|
|
86
|
+
temperature=temperature,
|
|
87
|
+
density=1e15 * u.K / u.cm ** 3 / temperature,
|
|
88
|
+
emission_measure=1e27 / u.cm ** 5,
|
|
89
|
+
wavelength_min=550 * u.AA,
|
|
90
|
+
wavelength_max=680 * u.AA,
|
|
91
|
+
ions=["O 5", "Mg 10"],
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
fig, ax = plt.subplots(figsize=(6, 3), constrained_layout=True)
|
|
95
|
+
utu.spectrum.stem(result[{"line": slice(6)}], ax=ax)
|
|
96
|
+
ax.set_xlabel(f"wavelength ({u.AA:latex_inline})")
|
|
97
|
+
ax.set_ylabel(f"intensity ({result.outputs.unit:latex_inline})");
|
|
98
|
+
"""
|
|
99
|
+
if ax is None: # pragma: nocover
|
|
100
|
+
ax = plt.gca()
|
|
101
|
+
|
|
102
|
+
kwargs_line = kwargs_line if kwargs_line is not None else {}
|
|
103
|
+
kwargs_text = kwargs_text if kwargs_text is not None else {}
|
|
104
|
+
kwargs_adjust = kwargs_adjust if kwargs_adjust is not None else {}
|
|
105
|
+
|
|
106
|
+
wavelength = na.value(spectrum.inputs.wavelength).ndarray
|
|
107
|
+
intensity = na.value(spectrum.outputs).ndarray
|
|
108
|
+
|
|
109
|
+
ax.vlines(
|
|
110
|
+
x=wavelength,
|
|
111
|
+
ymin=0,
|
|
112
|
+
ymax=intensity,
|
|
113
|
+
**{"color": "black", "linewidth": 1} | kwargs_line,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# room above the tallest line for the labels to be pushed into
|
|
117
|
+
ax.set_ylim(0, intensity.max() * headroom)
|
|
118
|
+
|
|
119
|
+
# Every line is given to the solver as points along its length, more of
|
|
120
|
+
# them for a brighter line, so that a label is pushed away from a line
|
|
121
|
+
# it would otherwise cross rather than only from the tip of it.
|
|
122
|
+
x_static = []
|
|
123
|
+
y_static = []
|
|
124
|
+
for i in range(intensity.size):
|
|
125
|
+
num = max(int(100 * intensity[i] / intensity.max()), 3)
|
|
126
|
+
y = np.linspace(0, intensity[i], num=num)
|
|
127
|
+
y_static.append(y)
|
|
128
|
+
x_static.append(np.broadcast_to(wavelength[i], y.shape))
|
|
129
|
+
|
|
130
|
+
# brightest first, so that taking the first few takes the brightest few
|
|
131
|
+
order = np.argsort(spectrum.outputs, axis=axis)
|
|
132
|
+
brightest = spectrum[order][{axis: slice(None, None, -1)}]
|
|
133
|
+
brightest = brightest[{axis: slice(num_label)}]
|
|
134
|
+
|
|
135
|
+
x_label = na.value(brightest.inputs.wavelength).ndarray
|
|
136
|
+
y_label = na.value(brightest.outputs).ndarray
|
|
137
|
+
name = spectroscopic(brightest.inputs.ion, latex=latex).ndarray
|
|
138
|
+
|
|
139
|
+
text = [
|
|
140
|
+
ax.text(
|
|
141
|
+
x=x_label[i],
|
|
142
|
+
y=y_label[i],
|
|
143
|
+
s=f"{name[i]} {x_label[i]:.1f}",
|
|
144
|
+
**{
|
|
145
|
+
"ha": "center",
|
|
146
|
+
"va": "bottom",
|
|
147
|
+
"bbox": {
|
|
148
|
+
"facecolor": "white",
|
|
149
|
+
"edgecolor": "none",
|
|
150
|
+
"alpha": 0.75,
|
|
151
|
+
"pad": 0.8,
|
|
152
|
+
},
|
|
153
|
+
}
|
|
154
|
+
| kwargs_text,
|
|
155
|
+
)
|
|
156
|
+
for i in range(x_label.size)
|
|
157
|
+
]
|
|
158
|
+
|
|
159
|
+
adjustText.adjust_text(
|
|
160
|
+
texts=text,
|
|
161
|
+
x=np.concatenate(x_static),
|
|
162
|
+
y=np.concatenate(y_static),
|
|
163
|
+
ax=ax,
|
|
164
|
+
**{
|
|
165
|
+
"arrowprops": {
|
|
166
|
+
"arrowstyle": "-",
|
|
167
|
+
"connectionstyle": "arc3",
|
|
168
|
+
"alpha": 0.5,
|
|
169
|
+
"linewidth": 0.5,
|
|
170
|
+
},
|
|
171
|
+
"force_static": (0.4, 0.6),
|
|
172
|
+
"force_text": (0.4, 0.6),
|
|
173
|
+
"expand": (1.15, 1.4),
|
|
174
|
+
"max_move": (30, 30),
|
|
175
|
+
"time_lim": 10,
|
|
176
|
+
}
|
|
177
|
+
| kwargs_adjust,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
return text
|
|
File without changes
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import astropy.units as u
|
|
2
|
+
import fiasco
|
|
3
|
+
import named_arrays as na
|
|
4
|
+
import numpy as np
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
import utu
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _database() -> bool:
|
|
11
|
+
"""Whether the CHIANTI database has been downloaded on this machine."""
|
|
12
|
+
try:
|
|
13
|
+
return len(fiasco.list_ions()) > 0
|
|
14
|
+
except Exception: # pragma: nocover
|
|
15
|
+
return False
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
needs_database = pytest.mark.skipif(
|
|
19
|
+
not _database(),
|
|
20
|
+
reason="the CHIANTI database is not available",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# A window around the O V line, narrow enough that only a handful of ions
|
|
24
|
+
# qualify. Every ion which does costs a level population solve, and six of
|
|
25
|
+
# them prove as much about sorting and filtering as twenty five do.
|
|
26
|
+
# Given as named scalars rather than as plain quantities, since both are
|
|
27
|
+
# allowed and only one of them would otherwise be tried.
|
|
28
|
+
wavelength_min = na.ScalarArray(629.72 * u.AA)
|
|
29
|
+
wavelength_max = na.ScalarArray(629.75 * u.AA)
|
|
30
|
+
|
|
31
|
+
temperature = na.ScalarArray(
|
|
32
|
+
ndarray=10 ** np.arange(4.0, 7.0, 0.5) * u.K,
|
|
33
|
+
axes=("temperature",),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
density = na.ScalarArray(
|
|
37
|
+
ndarray=1e15 * u.K / u.cm**3 / temperature.ndarray,
|
|
38
|
+
axes=("temperature",),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
emission_measure = na.ScalarArray(
|
|
42
|
+
ndarray=1e27 / u.cm**5 * np.ones(temperature.shape["temperature"]),
|
|
43
|
+
axes=("temperature",),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# Each of the fixtures below walks the whole database, which takes an order
|
|
48
|
+
# of magnitude longer than anything the tests do with the result. They are
|
|
49
|
+
# scoped to the session so that the suite pays for each walk once.
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@pytest.fixture(scope="session")
|
|
53
|
+
def ions_all() -> list[str]:
|
|
54
|
+
"""Every ion the database describes."""
|
|
55
|
+
return utu.spectrum.ions()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@pytest.fixture(scope="session")
|
|
59
|
+
def ions_window() -> list[str]:
|
|
60
|
+
"""Every ion with a line in the window above."""
|
|
61
|
+
return utu.spectrum.ions(
|
|
62
|
+
wavelength_min=wavelength_min,
|
|
63
|
+
wavelength_max=wavelength_max,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@pytest.fixture(scope="session")
|
|
68
|
+
def proton_electron_ratio() -> u.Quantity:
|
|
69
|
+
"""The ratio of protons to electrons at each temperature above."""
|
|
70
|
+
return fiasco.proton_electron_ratio(temperature.ndarray)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@needs_database
|
|
74
|
+
def test_ions(ions_window: list[str], ions_all: list[str]):
|
|
75
|
+
assert ions_window
|
|
76
|
+
for name in ions_window:
|
|
77
|
+
assert isinstance(name, str)
|
|
78
|
+
|
|
79
|
+
# the line this window was chosen for
|
|
80
|
+
assert "O 5" in ions_window
|
|
81
|
+
|
|
82
|
+
# and asking for every ion gives more of them
|
|
83
|
+
assert len(ions_window) < len(ions_all)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@needs_database
|
|
87
|
+
def test_contribution_function(proton_electron_ratio: u.Quantity):
|
|
88
|
+
ion = fiasco.Ion("O 5", temperature.ndarray)
|
|
89
|
+
|
|
90
|
+
result = utu.spectrum.contribution_function(
|
|
91
|
+
ion=ion,
|
|
92
|
+
density=density,
|
|
93
|
+
axis_temperature="temperature",
|
|
94
|
+
proton_electron_ratio=proton_electron_ratio,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
assert isinstance(result, na.FunctionArray)
|
|
98
|
+
assert na.unit(result.inputs).is_equivalent(u.AA)
|
|
99
|
+
assert "temperature" in na.shape(result.outputs)
|
|
100
|
+
assert "line" in na.shape(result.outputs)
|
|
101
|
+
|
|
102
|
+
# a density which shares the axis of the temperature is one density per
|
|
103
|
+
# temperature, so the result carries no axis of its own for it
|
|
104
|
+
assert "_density" not in na.shape(result.outputs)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@needs_database
|
|
108
|
+
def test_contribution_function_grid(proton_electron_ratio: u.Quantity):
|
|
109
|
+
"""A density on its own axis is every density at every temperature."""
|
|
110
|
+
ion = fiasco.Ion("O 5", temperature.ndarray)
|
|
111
|
+
|
|
112
|
+
density_grid = na.ScalarArray(
|
|
113
|
+
ndarray=np.array([1e9, 1e10]) / u.cm**3,
|
|
114
|
+
axes=("density",),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
result = utu.spectrum.contribution_function(
|
|
118
|
+
ion=ion,
|
|
119
|
+
density=density_grid,
|
|
120
|
+
axis_temperature="temperature",
|
|
121
|
+
proton_electron_ratio=proton_electron_ratio,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
shape = na.shape(result.outputs)
|
|
125
|
+
assert shape["temperature"] == temperature.shape["temperature"]
|
|
126
|
+
assert shape["_density"] == 2
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@needs_database
|
|
130
|
+
def test_lines():
|
|
131
|
+
"""The whole of it, with nothing given that can be worked out."""
|
|
132
|
+
result = utu.spectrum.lines(
|
|
133
|
+
temperature=temperature,
|
|
134
|
+
density=density,
|
|
135
|
+
emission_measure=emission_measure,
|
|
136
|
+
wavelength_min=wavelength_min,
|
|
137
|
+
wavelength_max=wavelength_max,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
assert isinstance(result, na.FunctionArray)
|
|
141
|
+
|
|
142
|
+
w = result.inputs.wavelength
|
|
143
|
+
ion = result.inputs.ion
|
|
144
|
+
intensity = result.outputs
|
|
145
|
+
|
|
146
|
+
# the wavelength, the ion, and the intensity of a line are one array
|
|
147
|
+
assert na.shape(w) == na.shape(ion) == na.shape(intensity)
|
|
148
|
+
|
|
149
|
+
assert np.all(w > wavelength_min)
|
|
150
|
+
assert np.all(w < wavelength_max)
|
|
151
|
+
|
|
152
|
+
# brightest first
|
|
153
|
+
d = np.diff(na.value(intensity).ndarray)
|
|
154
|
+
assert np.all(d <= 0)
|
|
155
|
+
|
|
156
|
+
# and the brightest line in this window is the one it was chosen for
|
|
157
|
+
brightest = {"line": 0}
|
|
158
|
+
assert str(ion[brightest].ndarray) == "O 5"
|
|
159
|
+
assert np.isclose(w[brightest].ndarray.to_value(u.AA), 629.733, atol=1e-2)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@needs_database
|
|
163
|
+
def test_lines_ions(
|
|
164
|
+
ions_window: list[str],
|
|
165
|
+
proton_electron_ratio: u.Quantity,
|
|
166
|
+
):
|
|
167
|
+
"""Naming the ions is what keeps a result from depending on the database."""
|
|
168
|
+
result = utu.spectrum.lines(
|
|
169
|
+
temperature=temperature,
|
|
170
|
+
density=density,
|
|
171
|
+
emission_measure=emission_measure,
|
|
172
|
+
wavelength_min=wavelength_min,
|
|
173
|
+
wavelength_max=wavelength_max,
|
|
174
|
+
ions=["O 5"],
|
|
175
|
+
proton_electron_ratio=proton_electron_ratio,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
assert np.all(result.inputs.ion == "O 5")
|
|
179
|
+
|
|
180
|
+
# and there were other ions to be had in this window
|
|
181
|
+
assert len(ions_window) > 1
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pytest
|
|
3
|
+
|
|
4
|
+
import named_arrays as na
|
|
5
|
+
import utu
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@pytest.mark.parametrize(
|
|
9
|
+
argnames="ion,expected,expected_latex",
|
|
10
|
+
argvalues=[
|
|
11
|
+
("He 1", "He I", r"He\,\textsc{i}"),
|
|
12
|
+
("O 5", "O V", r"O\,\textsc{v}"),
|
|
13
|
+
("Mg 10", "Mg X", r"Mg\,\textsc{x}"),
|
|
14
|
+
("S 4", "S IV", r"S\,\textsc{iv}"),
|
|
15
|
+
("Fe 24", "Fe XXIV", r"Fe\,\textsc{xxiv}"),
|
|
16
|
+
],
|
|
17
|
+
)
|
|
18
|
+
def test_spectroscopic(ion: str, expected: str, expected_latex: str):
|
|
19
|
+
assert utu.spectrum.spectroscopic(ion) == expected
|
|
20
|
+
assert utu.spectrum.spectroscopic(ion, latex=True) == expected_latex
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_spectroscopic_array():
|
|
24
|
+
"""An array of names keeps its shape and its axes."""
|
|
25
|
+
ion = na.ScalarArray(
|
|
26
|
+
ndarray=np.array([["O 5", "Mg 10"], ["He 1", "S 4"]]),
|
|
27
|
+
axes=("x", "y"),
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
result = utu.spectrum.spectroscopic(ion)
|
|
31
|
+
|
|
32
|
+
assert na.shape(result) == na.shape(ion)
|
|
33
|
+
assert result[{"x": 0, "y": 0}].ndarray == "O V"
|
|
34
|
+
assert result[{"x": 1, "y": 1}].ndarray == "S IV"
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import astropy.units as u
|
|
2
|
+
import matplotlib
|
|
3
|
+
import matplotlib.pyplot as plt
|
|
4
|
+
import named_arrays as na
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
import utu
|
|
9
|
+
|
|
10
|
+
matplotlib.use("agg")
|
|
11
|
+
|
|
12
|
+
# A spectrum written down rather than computed, so that these tests say
|
|
13
|
+
# nothing about the atomic database and do not need it to run.
|
|
14
|
+
spectrum = na.FunctionArray(
|
|
15
|
+
inputs=na.CartesianNdVectorArray(
|
|
16
|
+
components={
|
|
17
|
+
"wavelength": na.ScalarArray(
|
|
18
|
+
ndarray=np.array([584.3, 609.8, 629.7]) * u.AA,
|
|
19
|
+
axes=("line",),
|
|
20
|
+
),
|
|
21
|
+
"ion": na.ScalarArray(
|
|
22
|
+
ndarray=np.array(["He 1", "Mg 10", "O 5"]),
|
|
23
|
+
axes=("line",),
|
|
24
|
+
),
|
|
25
|
+
},
|
|
26
|
+
),
|
|
27
|
+
outputs=na.ScalarArray(
|
|
28
|
+
ndarray=np.array([10.0, 30.0, 100.0]) * u.erg / u.s / u.cm**2,
|
|
29
|
+
axes=("line",),
|
|
30
|
+
),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@pytest.mark.parametrize("num_label", [None, 1, 2])
|
|
35
|
+
def test_stem(num_label: None | int):
|
|
36
|
+
fig, ax = plt.subplots()
|
|
37
|
+
|
|
38
|
+
result = utu.spectrum.stem(spectrum, ax=ax, num_label=num_label)
|
|
39
|
+
|
|
40
|
+
expected = num_label if num_label is not None else spectrum.shape["line"]
|
|
41
|
+
assert len(result) == expected
|
|
42
|
+
|
|
43
|
+
# the brightest line is labelled first, whichever order it was given in
|
|
44
|
+
assert result[0].get_text().startswith("O V")
|
|
45
|
+
|
|
46
|
+
# every line is drawn, whether or not it is labelled
|
|
47
|
+
segments = [s for c in ax.collections for s in c.get_segments()]
|
|
48
|
+
assert len(segments) == spectrum.shape["line"]
|
|
49
|
+
|
|
50
|
+
# and there is room above the tallest of them for the labels
|
|
51
|
+
assert ax.get_ylim()[1] > 100
|
|
52
|
+
|
|
53
|
+
plt.close(fig)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_stem_latex():
|
|
57
|
+
"""The name of an ion, as it would be set in a journal."""
|
|
58
|
+
fig, ax = plt.subplots()
|
|
59
|
+
|
|
60
|
+
result = utu.spectrum.stem(spectrum, ax=ax, num_label=1, latex=True)
|
|
61
|
+
|
|
62
|
+
assert result[0].get_text().startswith(r"O\,\textsc{v}")
|
|
63
|
+
|
|
64
|
+
plt.close(fig)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_stem_kwargs():
|
|
68
|
+
"""Every default the caller might disagree with can be replaced."""
|
|
69
|
+
fig, ax = plt.subplots()
|
|
70
|
+
|
|
71
|
+
result = utu.spectrum.stem(
|
|
72
|
+
spectrum,
|
|
73
|
+
ax=ax,
|
|
74
|
+
num_label=1,
|
|
75
|
+
kwargs_line={"color": "red"},
|
|
76
|
+
kwargs_text={"fontsize": 5},
|
|
77
|
+
kwargs_adjust={"time_lim": 1},
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
assert result[0].get_fontsize() == 5
|
|
81
|
+
assert np.all(ax.collections[0].get_color() == np.array([[1, 0, 0, 1]]))
|
|
82
|
+
|
|
83
|
+
plt.close(fig)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: utu
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A Python library of solar physics utilities built on named arrays
|
|
5
|
+
Author-email: "Roy T. Smart" <roytsmart@gmail.com>
|
|
6
|
+
License-Expression: BSD-3-Clause
|
|
7
|
+
Project-URL: Homepage, https://github.com/sun-data/utu
|
|
8
|
+
Project-URL: Documentation, https://utu.readthedocs.io
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Requires-Python: >=3.12
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Requires-Dist: numpy
|
|
14
|
+
Requires-Dist: astropy
|
|
15
|
+
Requires-Dist: matplotlib
|
|
16
|
+
Requires-Dist: adjustText
|
|
17
|
+
Requires-Dist: named-arrays~=2.8
|
|
18
|
+
Requires-Dist: fiasco~=0.8.2
|
|
19
|
+
Provides-Extra: test
|
|
20
|
+
Requires-Dist: pytest; extra == "test"
|
|
21
|
+
Requires-Dist: pytest-doctestplus; extra == "test"
|
|
22
|
+
Provides-Extra: doc
|
|
23
|
+
Requires-Dist: matplotlib; extra == "doc"
|
|
24
|
+
Requires-Dist: graphviz; extra == "doc"
|
|
25
|
+
Requires-Dist: pydata-sphinx-theme; extra == "doc"
|
|
26
|
+
Requires-Dist: ipykernel; extra == "doc"
|
|
27
|
+
Requires-Dist: jupyter-sphinx; extra == "doc"
|
|
28
|
+
Requires-Dist: sphinx-favicon; extra == "doc"
|
|
29
|
+
Requires-Dist: sphinx-codeautolink; extra == "doc"
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# utu
|
|
33
|
+
|
|
34
|
+
[](https://github.com/sun-data/utu/actions/workflows/tests.yml)
|
|
35
|
+
[](https://github.com/sun-data/utu/actions/workflows/black.yml)
|
|
36
|
+
[](https://github.com/sun-data/utu/actions/workflows/ruff.yml)
|
|
37
|
+
[](https://utu.readthedocs.io/en/latest/?badge=latest)
|
|
38
|
+
|
|
39
|
+
A Python library of solar physics utilities built on
|
|
40
|
+
[named arrays](https://github.com/sun-data/named-arrays).
|
|
41
|
+
|
|
42
|
+
Named for the Sumerian god of the sun.
|
|
43
|
+
|
|
44
|
+
## Documentation
|
|
45
|
+
|
|
46
|
+
The documentation is at [utu.readthedocs.io](https://utu.readthedocs.io/en/latest/).
|
|
47
|
+
|
|
48
|
+
## `utu.spectrum`
|
|
49
|
+
|
|
50
|
+
The emission lines of an optically thin plasma, computed from the CHIANTI
|
|
51
|
+
atomic database through [fiasco](https://github.com/wtbarnes/fiasco), and
|
|
52
|
+
returned as named arrays.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
utu/__init__.py,sha256=QIcAyF2Gbk3WN5knkAnXK1MiUo-XW-Vy55S5Rr3xBGU,161
|
|
2
|
+
utu/_version.py,sha256=7JjVb-xSpAQiq4NIZy8IlFzG_wu7BbNsO78dKYqGQBI,182
|
|
3
|
+
utu/_tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
utu/_tests/test_utu.py,sha256=QJaQqEqHI3TzrtH34ofzzlqmukw3Ki15xcCDva5Ijl8,170
|
|
5
|
+
utu/spectrum/__init__.py,sha256=_qWFMJ_rLmiAQ-LlrSSRV-jKh2tW3YLvfghPA7j-yeE,316
|
|
6
|
+
utu/spectrum/_lines.py,sha256=SmkBhQ_LJyC_hjZQy_SXD8ZXiR20NxJ33SjkDMI8Z6A,13695
|
|
7
|
+
utu/spectrum/_names.py,sha256=ixlHgaNyPypVqFQkIZYR1RmMqwaPPmoflZkr7WaiZe0,2106
|
|
8
|
+
utu/spectrum/_plots.py,sha256=PlaA4r-2k_X95rJ8POdVgBWM133p9kCNEUzWqrJfIrE,5996
|
|
9
|
+
utu/spectrum/_tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
utu/spectrum/_tests/test_lines.py,sha256=qrNd_bb6Cd1-q29gpu5PlWqFBaeUs9nTsBLMglitV4Y,5399
|
|
11
|
+
utu/spectrum/_tests/test_names.py,sha256=oww8IAx7vGMUu1SMUOY6JcN6aV4ddX1A4vasXhHo7GI,1006
|
|
12
|
+
utu/spectrum/_tests/test_plots.py,sha256=mkPVyuLx_-ZTNSh2IYKSXpiQ6yERk7t3oY5Fo3cRIDI,2315
|
|
13
|
+
utu-0.1.0.dist-info/licenses/LICENSE,sha256=7g-UZoXrsIORYtW9htZUtHR85BXqO2pWRCoEQxnGTUY,1488
|
|
14
|
+
utu-0.1.0.dist-info/METADATA,sha256=Vbo3p1_0cMp7rM_o03R5ngSXqBwrDFx9J-HGhR-NS1o,2100
|
|
15
|
+
utu-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
16
|
+
utu-0.1.0.dist-info/top_level.txt,sha256=liQQdfMTlISJzyng_tpRLbcDrPMutG5a1J1njO28Z_c,4
|
|
17
|
+
utu-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Roy T. Smart
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
16
|
+
contributors may be used to endorse or promote products derived from
|
|
17
|
+
this software without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
22
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
23
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
24
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
25
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
26
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
27
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING OUT OF THE USE OF THIS
|
|
28
|
+
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
utu
|