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 ADDED
@@ -0,0 +1,9 @@
1
+ """Solar physics utilities built on named arrays."""
2
+
3
+ from . import spectrum
4
+ from ._version import __version__
5
+
6
+ __all__ = [
7
+ "__version__",
8
+ "spectrum",
9
+ ]
utu/_tests/__init__.py ADDED
File without changes
utu/_tests/test_utu.py ADDED
@@ -0,0 +1,7 @@
1
+ import utu
2
+
3
+
4
+ def test_version():
5
+ """The package reports the version setuptools-scm gave it."""
6
+ assert isinstance(utu.__version__, str)
7
+ assert utu.__version__
utu/_version.py ADDED
@@ -0,0 +1,8 @@
1
+ import importlib.metadata
2
+
3
+ __all__ = [
4
+ "__version__",
5
+ ]
6
+
7
+ __version__ = importlib.metadata.version("utu")
8
+ """The version of this package, taken from the tag it was built from."""
@@ -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
+ [![tests](https://github.com/sun-data/utu/actions/workflows/tests.yml/badge.svg)](https://github.com/sun-data/utu/actions/workflows/tests.yml)
35
+ [![Black](https://github.com/sun-data/utu/actions/workflows/black.yml/badge.svg)](https://github.com/sun-data/utu/actions/workflows/black.yml)
36
+ [![Ruff](https://github.com/sun-data/utu/actions/workflows/ruff.yml/badge.svg)](https://github.com/sun-data/utu/actions/workflows/ruff.yml)
37
+ [![Documentation Status](https://readthedocs.org/projects/utu/badge/?version=latest)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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