muse 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. muse/__init__.py +17 -0
  2. muse/_version.py +24 -0
  3. muse/conftest.py +26 -0
  4. muse/data/README.rst +6 -0
  5. muse/data/__init__.py +106 -0
  6. muse/data/tests/__init__.py +0 -0
  7. muse/data/tests/test_data.py +25 -0
  8. muse/instrument/__init__.py +24 -0
  9. muse/instrument/linelist.py +361 -0
  10. muse/instrument/migration.py +72 -0
  11. muse/instrument/radiometry.py +174 -0
  12. muse/instrument/response.py +293 -0
  13. muse/instrument/response_io.py +479 -0
  14. muse/instrument/spectral.py +510 -0
  15. muse/instrument/tests/__init__.py +0 -0
  16. muse/instrument/tests/test_figures.py +91 -0
  17. muse/instrument/tests/test_linelist.py +220 -0
  18. muse/instrument/tests/test_migration.py +61 -0
  19. muse/instrument/tests/test_radiometry.py +247 -0
  20. muse/instrument/tests/test_response.py +506 -0
  21. muse/instrument/tests/test_response_io.py +600 -0
  22. muse/instrument/tests/test_spectral.py +618 -0
  23. muse/log.py +49 -0
  24. muse/synthesis/__init__.py +15 -0
  25. muse/synthesis/_backends.py +113 -0
  26. muse/synthesis/synthesis.py +261 -0
  27. muse/synthesis/tests/__init__.py +0 -0
  28. muse/synthesis/tests/test_backends.py +47 -0
  29. muse/synthesis/tests/test_figures.py +78 -0
  30. muse/synthesis/tests/test_synthesis.py +413 -0
  31. muse/synthesis/tests/test_utils.py +348 -0
  32. muse/synthesis/utils.py +441 -0
  33. muse/tests/__init__.py +0 -0
  34. muse/tests/figure_hashes_mpl_3111_ft_2143_astropy_801.json +13 -0
  35. muse/tests/helpers.py +397 -0
  36. muse/tests/test_documentation.py +18 -0
  37. muse/tests/test_figures.py +52 -0
  38. muse/tests/test_helpers.py +41 -0
  39. muse/tests/test_variables.py +244 -0
  40. muse/transforms/__init__.py +3 -0
  41. muse/transforms/tests/__init__.py +0 -0
  42. muse/transforms/tests/test_figures.py +19 -0
  43. muse/transforms/tests/test_transforms.py +359 -0
  44. muse/transforms/transforms.py +373 -0
  45. muse/utils/__init__.py +8 -0
  46. muse/utils/documentation.py +67 -0
  47. muse/utils/tests/__init__.py +0 -0
  48. muse/utils/tests/test_documentation.py +31 -0
  49. muse/utils/tests/test_utils.py +203 -0
  50. muse/utils/utils.py +295 -0
  51. muse/variables.py +138 -0
  52. muse/variables_schema.py +664 -0
  53. muse/version.py +21 -0
  54. muse-0.2.0.dist-info/METADATA +123 -0
  55. muse-0.2.0.dist-info/RECORD +58 -0
  56. muse-0.2.0.dist-info/WHEEL +5 -0
  57. muse-0.2.0.dist-info/licenses/licenses/LICENSE.rst +25 -0
  58. muse-0.2.0.dist-info/top_level.txt +1 -0
muse/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ """
2
+ ====
3
+ muse
4
+ ====
5
+
6
+ A Python library focused on interfacing with data (real or synthetic) for
7
+ NASA's Medium-Class Explorers (MIDEX) Multi-slit Solar Explorer (MUSE).
8
+
9
+ * `Homepage <https://muse.lmsal.com/>`__
10
+ * `NASA Homepage <https://science.nasa.gov/mission/muse/>`__
11
+ * `Documentation <https://muse-lmsal.readthedocs.io/>`__
12
+
13
+ """
14
+
15
+ from .version import version as __version__
16
+
17
+ __all__ = ["__version__"]
muse/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.2.0'
22
+ __version_tuple__ = version_tuple = (0, 2, 0)
23
+
24
+ __commit_id__ = commit_id = 'gc9eabe1e2'
muse/conftest.py ADDED
@@ -0,0 +1,26 @@
1
+ import pytest
2
+
3
+ from muse.tests.helpers import fake_response, fake_vdem, fake_vdem_offgrid
4
+ from muse.transforms.transforms import reshape_x_to_slit_step
5
+
6
+
7
+ @pytest.fixture(scope="session")
8
+ def response():
9
+ return fake_response()
10
+
11
+
12
+ @pytest.fixture
13
+ def raster(vdem):
14
+ # Function-scoped: tests del attrs on the result, which must not leak across tests.
15
+ # nslits=35, nraster=11 are the defaults, so the recorded HISTORY string is unchanged.
16
+ return reshape_x_to_slit_step(vdem)
17
+
18
+
19
+ @pytest.fixture(scope="session")
20
+ def vdem():
21
+ return fake_vdem()
22
+
23
+
24
+ @pytest.fixture(scope="session")
25
+ def vdem_offgrid():
26
+ return fake_vdem_offgrid()
muse/data/README.rst ADDED
@@ -0,0 +1,6 @@
1
+ Data directory
2
+ ==============
3
+
4
+ This directory contains data files included with the package source
5
+ code distribution. Note that this is intended only for relatively small files
6
+ - large files should be externally hosted and downloaded as needed.
muse/data/__init__.py ADDED
@@ -0,0 +1,106 @@
1
+ """
2
+ Helpers for downloading the example data used by the documentation gallery.
3
+ """
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+ __all__ = ["fetch_example_data"]
9
+
10
+ _SAMPLE_DATA_URL = "https://github.com/LM-SAL/muse-sample-data/releases/download/v1/"
11
+
12
+ #: File name -> (download URL, SHA-256 hash, cache subdirectory).
13
+ _REGISTRY = {
14
+ "muse_example_vdem.zarr": (
15
+ f"{_SAMPLE_DATA_URL}muse_example_vdem.zarr.tar.gz",
16
+ "sha256:9736243c13be83b62986095f2cdfc9884c9eae941ff0498c2c2d37551b079c50",
17
+ "muse_example_vdem",
18
+ ),
19
+ "aia_chianti_line_list_94_Fe_sun_coronal_2021_chianti.nc": (
20
+ f"{_SAMPLE_DATA_URL}aia_chianti_line_list_94_Fe_sun_coronal_2021_chianti.nc",
21
+ "sha256:f76fe28b1325e809ea4ec61ba916af447826220f436464f1b39d5000af12274e",
22
+ "chianti_line_lists",
23
+ ),
24
+ "eis_chianti_line_list_195_FeXII_sun_coronal_2021_chianti.nc": (
25
+ f"{_SAMPLE_DATA_URL}eis_chianti_line_list_195_FeXII_sun_coronal_2021_chianti.nc",
26
+ "sha256:47d046bcfd2c8f4a6ca6417e5a3a26d0ce49abb5a4d3389720dd579a736c35b9",
27
+ "chianti_line_lists",
28
+ ),
29
+ "eis_chianti_line_list_174_175_FeX_sun_coronal_2021_chianti_density.nc": (
30
+ f"{_SAMPLE_DATA_URL}eis_chianti_line_list_174_175_FeX_sun_coronal_2021_chianti_density.nc",
31
+ "sha256:c5ee652cee96c1224c338a526fedea631d85fe2bb37499c2ab6254991d9e081c",
32
+ "chianti_line_lists",
33
+ ),
34
+ "muse_sg_response_108_FeXIX108.355_FeXXI108.117_sun_coronal_2021_chianti_effarea.zarr": (
35
+ f"{_SAMPLE_DATA_URL}muse_sg_response_108_FeXIX108.355_FeXXI108.117_sun_coronal_2021_chianti_effarea.zarr.tar.gz",
36
+ "sha256:dccba701ae6bbf9043d2d1a29ed6794dc3e88c00c6f757efecb3be2ce6cf10c9",
37
+ "synthesis_tutorial",
38
+ ),
39
+ "muse_sg_response_171_FeIX171.073_sun_coronal_2021_chianti_effarea.zarr": (
40
+ f"{_SAMPLE_DATA_URL}muse_sg_response_171_FeIX171.073_sun_coronal_2021_chianti_effarea.zarr.tar.gz",
41
+ "sha256:d93ae1eae64de9d1e33964c3a834374fae2af0d04d5a5378c487cf7379de5e16",
42
+ "synthesis_tutorial",
43
+ ),
44
+ "muse_sg_response_284_FeXV284.163_sun_coronal_2021_chianti_effarea.zarr": (
45
+ f"{_SAMPLE_DATA_URL}muse_sg_response_284_FeXV284.163_sun_coronal_2021_chianti_effarea.zarr.tar.gz",
46
+ "sha256:79b58e21d44aa0b92250fad69a90c095d687d4cc72078c80e0cdbe178e1aea98",
47
+ "synthesis_tutorial",
48
+ ),
49
+ "EIS_EffArea_B.005": (
50
+ "https://hesperia.gsfc.nasa.gov/ssw/hinode/eis/response/EIS_EffArea_B.005",
51
+ "sha256:b56e5c2873b10bdc9bd31d0f342e5ecc0491ff752ece5807f1d8de2f94d15d64",
52
+ "eis_calibration",
53
+ ),
54
+ "muse_synthetic_spectra.nc": (
55
+ f"{_SAMPLE_DATA_URL}muse_synthetic_spectra.nc",
56
+ "sha256:9b9ca430c298a719f7d0b0cb8ff66aa6c9b8d9c91299799d6d506a8d4cd79f58",
57
+ "synthesis_tutorial",
58
+ ),
59
+ }
60
+
61
+
62
+ def fetch_example_data(name):
63
+ """
64
+ Download and cache one of the example data files used by the documentation gallery.
65
+
66
+ A VDEM or synthetic spectrum already present in the synthesis-tutorial
67
+ output directory (``MUSE_SYNTHESIS_TUTORIAL_OUTPUT_DIR``, defaulting to
68
+ ``examples/synthesis_tutorial/artifacts``) is returned without downloading.
69
+ Every other file comes from the published copy.
70
+
71
+ Parameters
72
+ ----------
73
+ name : `str`
74
+ Name of the file to fetch, e.g. ``"muse_example_vdem.zarr"``.
75
+
76
+ Returns
77
+ -------
78
+ `pathlib.Path`
79
+ Path to the local copy of the file.
80
+ """
81
+ if name not in _REGISTRY:
82
+ msg = f"{name!r} is not a known example data file, expected one of: {sorted(_REGISTRY)}"
83
+ raise ValueError(msg)
84
+ if name in {"muse_example_vdem.zarr", "muse_synthetic_spectra.nc"}:
85
+ local_dir = Path(os.environ.get("MUSE_SYNTHESIS_TUTORIAL_OUTPUT_DIR", "examples/synthesis_tutorial/artifacts"))
86
+ local_path = local_dir / name
87
+ if local_path.exists():
88
+ return local_path
89
+ try:
90
+ import pooch
91
+ except ImportError:
92
+ msg = "pooch is required to download the example data, install it with `pip install pooch`"
93
+ raise ImportError(msg) from None
94
+ url, known_hash, subdir = _REGISTRY[name]
95
+ cache = Path(pooch.os_cache("muse"))
96
+ if name.endswith(".zarr"):
97
+ # Zarr stores are directories, published as tarballs; retrieve extracts once.
98
+ pooch.retrieve(
99
+ url,
100
+ known_hash=known_hash,
101
+ fname=f"{name}.tar.gz",
102
+ path=cache,
103
+ processor=pooch.Untar(extract_dir=subdir),
104
+ )
105
+ return cache / subdir / name
106
+ return Path(pooch.retrieve(url, known_hash=known_hash, fname=name, path=cache / subdir))
File without changes
@@ -0,0 +1,25 @@
1
+ import pytest
2
+
3
+ from muse.data import _REGISTRY, fetch_example_data
4
+
5
+
6
+ def test_unknown_name_raises():
7
+ with pytest.raises(ValueError, match="not a known example data file"):
8
+ fetch_example_data("nope.nc")
9
+
10
+
11
+ def test_registry_entries_well_formed():
12
+ for url, known_hash, subdir in _REGISTRY.values():
13
+ assert url.startswith("https://")
14
+ assert known_hash.startswith("sha256:")
15
+ assert len(known_hash) == len("sha256:") + 64
16
+ assert subdir
17
+
18
+
19
+ @pytest.mark.parametrize("name", ["muse_example_vdem.zarr", "muse_synthetic_spectra.nc"])
20
+ def test_local_tutorial_output_wins(name, tmp_path, monkeypatch):
21
+ monkeypatch.setenv("MUSE_SYNTHESIS_TUTORIAL_OUTPUT_DIR", str(tmp_path))
22
+ local_path = tmp_path / name
23
+ local_path.touch()
24
+
25
+ assert fetch_example_data(name) == local_path
@@ -0,0 +1,24 @@
1
+ from muse.instrument.linelist import create_chianti_line_list
2
+ from muse.instrument.migration import migrate_response
3
+ from muse.instrument.radiometry import transform_response_units
4
+ from muse.instrument.response import map_response_to_ci_detector, map_response_to_sg_detector
5
+ from muse.instrument.response_io import (
6
+ align_response_and_vdem,
7
+ load_and_concat_responses,
8
+ read_response,
9
+ save_response,
10
+ )
11
+ from muse.instrument.spectral import create_spectral_response
12
+
13
+ __all__ = [
14
+ "align_response_and_vdem",
15
+ "create_chianti_line_list",
16
+ "create_spectral_response",
17
+ "load_and_concat_responses",
18
+ "map_response_to_ci_detector",
19
+ "map_response_to_sg_detector",
20
+ "migrate_response",
21
+ "read_response",
22
+ "save_response",
23
+ "transform_response_units",
24
+ ]
@@ -0,0 +1,361 @@
1
+ """
2
+ CHIANTI line lists with contribution functions (GOFNT).
3
+ """
4
+
5
+ import os
6
+ import re
7
+ import warnings
8
+ from types import ModuleType
9
+ from numbers import Real
10
+ from pathlib import Path
11
+ from importlib import reload
12
+
13
+ import numpy as np
14
+ import xarray as xr
15
+
16
+ import astropy.units as u
17
+
18
+ from muse.utils.utils import add_history
19
+
20
+ __all__ = ["create_chianti_line_list"]
21
+
22
+
23
+ def create_chianti_line_list(
24
+ temperature: xr.DataArray,
25
+ density: xr.DataArray | None = None,
26
+ pressure: xr.DataArray | None = None,
27
+ abundance: str | None = None,
28
+ wavelength_range: u.Quantity | None = None,
29
+ minimum_abundance: float | None = None,
30
+ element_list: list[str] | None = None,
31
+ ion_list: list[str] | None = None,
32
+ ) -> xr.Dataset:
33
+ """
34
+ Generate a line list with contribution functions using ChiantiPy.
35
+
36
+ Parameters
37
+ ----------
38
+ temperature : `xarray.DataArray`
39
+ Temperature array with an `astropy.units.Quantity` payload convertible
40
+ to K and a ``logT`` dimension.
41
+ density : `xarray.DataArray`, optional
42
+ Electron density array with an `astropy.units.Quantity` payload
43
+ convertible to cm^-3. Mutually exclusive with ``pressure``. The output
44
+ carries this grid on a ``logD`` dimension whose coordinate is
45
+ ``log10(density)``.
46
+ pressure : `xarray.DataArray`, optional
47
+ Electron pressure array with an `astropy.units.Quantity` payload
48
+ convertible to K cm^-3. Mutually exclusive with ``density``.
49
+ abundance : `str`, optional
50
+ CHIANTI abundance name, e.g. ``"sun_coronal_2021_chianti"``. If not
51
+ given, ChiantiPy's configured default applies
52
+ (``sun_photospheric_2021_asplund`` unless a ``chiantirc`` file overrides
53
+ it); the resolved name is recorded in the ``abundance`` attribute.
54
+ wavelength_range : `astropy.units.Quantity`
55
+ Two-element wavelength range convertible to Angstroms.
56
+ minimum_abundance : `float`, optional
57
+ Finite positive minimum elemental abundance to keep. Mutually exclusive
58
+ with ``element_list`` and ``ion_list``.
59
+ element_list : `list` of `str`, optional
60
+ CHIANTI element symbols to include, such as ``"fe"`` and ``"o"``.
61
+ Mutually exclusive with ``ion_list`` and ``minimum_abundance``.
62
+ ion_list : `list` of `str`, optional
63
+ CHIANTI ion names to include, such as ``"fe_9"``. Mutually exclusive
64
+ with ``element_list`` and ``minimum_abundance``.
65
+
66
+ Returns
67
+ -------
68
+ `xarray.Dataset`
69
+ Line list with contribution functions and per-transition metadata.
70
+
71
+ Notes
72
+ -----
73
+ The ``XUVTOP`` environment variable must point to a local CHIANTI database.
74
+ ChiantiPy's ``gui`` default is forced off for headless batch jobs.
75
+ """
76
+ temperature, plasma_grid, wavelength_range = _validate_line_list_inputs(
77
+ temperature, density, pressure, wavelength_range
78
+ )
79
+ minimum_abundance, element_list, ion_list = _validate_species_selection(minimum_abundance, element_list, ion_list)
80
+
81
+ chiantipy_version, ch = _initialize_chianti()
82
+
83
+ if density is not None:
84
+ plasma_grid = plasma_grid.rename({plasma_grid.dims[0]: "logD"})
85
+ temperature_bc, density_bc = xr.broadcast(temperature, plasma_grid)
86
+ extra_coord_name = "logD"
87
+ extra_coord = np.log10(plasma_grid.data)
88
+ else:
89
+ density_bc = plasma_grid / temperature
90
+ temperature_bc = temperature.broadcast_like(density_bc)
91
+ extra_coord_name = plasma_grid.dims[0]
92
+ extra_coord = plasma_grid.data
93
+ temperature_flat = temperature_bc.data.reshape(-1)
94
+ density_flat = density_bc.data.reshape(-1)
95
+
96
+ chianti_kwargs = {
97
+ "em": 1.0,
98
+ "abundance": abundance,
99
+ "allLines": True,
100
+ "keepIons": True,
101
+ "minAbund": minimum_abundance,
102
+ "ionList": ion_list,
103
+ "elementList": element_list,
104
+ }
105
+ # ChiantiPy cannot descale collision strengths for 1-element arrays; hand it scalars.
106
+ chianti_temperature = temperature_flat.item() if temperature_flat.size == 1 else temperature_flat
107
+ chianti_density = density_flat.item() if density_flat.size == 1 else density_flat
108
+ bunch = ch.bunch(chianti_temperature, chianti_density, wavelength_range, **chianti_kwargs)
109
+ abundance = getattr(bunch, "AbundanceName", abundance)
110
+ if abundance is not None:
111
+ abundance = Path(abundance).stem
112
+
113
+ line_list = _chianti_bunch_to_dataset(
114
+ bunch,
115
+ temperature=temperature,
116
+ temperature_bc=temperature_bc,
117
+ extra_coords={extra_coord_name: extra_coord},
118
+ wavelength_range=wavelength_range,
119
+ chiantipy_version=chiantipy_version,
120
+ )
121
+ if line_list.sizes["trans_index"] == 0:
122
+ msg = "CHIANTI returned no lines; check wavelength_range and the species selection"
123
+ raise ValueError(msg)
124
+ add_history(line_list, locals(), create_chianti_line_list)
125
+ return line_list
126
+
127
+
128
+ def _initialize_chianti() -> tuple[str, ModuleType]:
129
+ with warnings.catch_warnings():
130
+ warnings.simplefilter("ignore", RuntimeWarning)
131
+ try:
132
+ import ChiantiPy # noqa: PLC0415
133
+ except ImportError:
134
+ msg = "ChiantiPy is required for this function, install it with `pip install muse[chianti]`"
135
+ raise ImportError(msg) from None
136
+
137
+ xuvtop = os.environ.get("XUVTOP")
138
+ if xuvtop is None:
139
+ msg = (
140
+ "The XUVTOP environment variable is not set; ChiantiPy cannot locate the CHIANTI database. "
141
+ "Point it at a local copy of the database, e.g. `export XUVTOP=/path/to/chianti/dbase` "
142
+ "(available from https://www.chiantidatabase.org)."
143
+ )
144
+ raise OSError(msg)
145
+
146
+ with warnings.catch_warnings():
147
+ # Without a chiantirc file, ChiantiPy evaluates os.path.isfile(False)
148
+ # at import, which raises a RuntimeWarning on Python >= 3.14.
149
+ warnings.simplefilter("ignore", RuntimeWarning)
150
+ # ChiantiPy imports its optional ipyparallel implementation from core.
151
+ warnings.filterwarnings(
152
+ "ignore",
153
+ message=r"ipyparallel not found\.",
154
+ category=UserWarning,
155
+ module=r"ChiantiPy\.core\.IpyMspectrum",
156
+ )
157
+ import ChiantiPy.tools.data as chdata # noqa: PLC0415
158
+
159
+ if not hasattr(chdata, "Defaults") or getattr(chdata, "Xuvtop", None) != xuvtop:
160
+ chdata = reload(chdata)
161
+ import ChiantiPy.core as ch # noqa: PLC0415
162
+
163
+ if not hasattr(chdata, "Defaults"):
164
+ msg = f"ChiantiPy could not initialize the CHIANTI database at {xuvtop}"
165
+ raise OSError(msg)
166
+
167
+ chdata.Defaults["gui"] = False
168
+ return ChiantiPy.__version__, ch
169
+
170
+
171
+ def _chianti_bunch_to_dataset(
172
+ bunch,
173
+ *,
174
+ temperature: xr.DataArray,
175
+ temperature_bc: xr.DataArray,
176
+ extra_coords: dict[str, np.ndarray],
177
+ wavelength_range: tuple[float, float],
178
+ chiantipy_version: str,
179
+ ) -> xr.Dataset:
180
+ if getattr(bunch, "Intensity", None) is None or len(bunch.Intensity["wvl"]) == 0:
181
+ msg = "CHIANTI returned no lines; check wavelength_range and the species selection"
182
+ raise ValueError(msg)
183
+
184
+ import ChiantiPy.tools.io as chio # noqa: PLC0415
185
+
186
+ ion_names = bunch.Intensity["ionS"]
187
+ name_info = {
188
+ ion: {"spectroscopic": bunch.IonInstances[ion].Spectroscopic, "Z": bunch.IonInstances[ion].Z}
189
+ for ion in np.unique(ion_names)
190
+ }
191
+ per_transition = {
192
+ "ion_name": ion_names,
193
+ "wavelength": bunch.Intensity["wvl"],
194
+ "lower_level_label": bunch.Intensity["pretty1"],
195
+ "upper_level_label": bunch.Intensity["pretty2"],
196
+ "lower_level_index": bunch.Intensity["lvl1"],
197
+ "upper_level_index": bunch.Intensity["lvl2"],
198
+ "spectroscopic_name": np.array([name_info[ion]["spectroscopic"] for ion in ion_names]),
199
+ "atomic_number": np.array([name_info[ion]["Z"] for ion in ion_names]),
200
+ "observed": bunch.Intensity["obs"] == "Y",
201
+ }
202
+ line_list = xr.Dataset({name: ("trans_index", values) for name, values in per_transition.items()})
203
+
204
+ gofnt_values = bunch.Intensity["intensity"].reshape((*temperature_bc.data.shape, -1))
205
+ line_list["gofnt"] = xr.DataArray(
206
+ gofnt_values,
207
+ dims=(*temperature_bc.dims, "trans_index"),
208
+ coords={"logT": np.log10(temperature), **extra_coords},
209
+ )
210
+ line_list["logT_peak"] = np.log10(temperature[{"logT": line_list.gofnt.argmax(dim="logT")}])
211
+ line_list["full_name"] = (
212
+ line_list.spectroscopic_name.astype(object) + " " + line_list.wavelength.astype(str).astype(object)
213
+ )
214
+
215
+ line_list.attrs["Chiantipy"] = chiantipy_version
216
+ line_list.attrs["Chianti"] = chio.versionRead()
217
+ line_list.wavelength.attrs["units"] = str(u.AA)
218
+ line_list.gofnt.attrs["units"] = "erg cm3 / (s sr)"
219
+ line_list.logT.attrs["units"] = str(u.dex(u.K))
220
+ if "logD" in line_list.coords:
221
+ line_list.logD.attrs["units"] = str(u.dex(u.cm**-3))
222
+
223
+ in_range = (line_list.wavelength >= wavelength_range[0]) & (line_list.wavelength <= wavelength_range[1])
224
+ return line_list.isel(trans_index=in_range)
225
+
226
+
227
+ def _validate_line_list_inputs(
228
+ temperature: xr.DataArray,
229
+ density: xr.DataArray | None,
230
+ pressure: xr.DataArray | None,
231
+ wavelength_range: u.Quantity | None,
232
+ ) -> tuple[xr.DataArray, xr.DataArray, tuple[float, float]]:
233
+ if density is None and pressure is None:
234
+ msg = "Specify density or pressure"
235
+ raise ValueError(msg)
236
+ if density is not None and pressure is not None:
237
+ msg = "density and pressure are mutually exclusive"
238
+ raise ValueError(msg)
239
+
240
+ temperature = _validate_positive_data_array(temperature, "temperature", u.K, dimension="logT")
241
+ if density is not None:
242
+ name = "density"
243
+ plasma_grid = _validate_positive_data_array(density, name, u.cm**-3)
244
+ else:
245
+ name = "pressure"
246
+ plasma_grid = _validate_positive_data_array(pressure, name, u.K / u.cm**3)
247
+ if plasma_grid.dims[0] in ("logT", "trans_index"):
248
+ msg = f"{name} dimension must not be named {plasma_grid.dims[0]!r}"
249
+ raise ValueError(msg)
250
+
251
+ return temperature, plasma_grid, _validate_wavelength_range(wavelength_range)
252
+
253
+
254
+ def _validate_wavelength_range(wavelength_range: u.Quantity | None) -> tuple[float, float]:
255
+ if not isinstance(wavelength_range, u.Quantity):
256
+ msg = "wavelength_range must be an astropy.units.Quantity convertible to Angstrom"
257
+ raise TypeError(msg)
258
+ try:
259
+ values = np.asarray(wavelength_range.to_value(u.AA), dtype=float)
260
+ except u.UnitConversionError as exc:
261
+ msg = "wavelength_range units must be convertible to Angstrom"
262
+ raise ValueError(msg) from exc
263
+ if values.shape != (2,):
264
+ msg = "wavelength_range must contain exactly two values"
265
+ raise ValueError(msg)
266
+ if not np.all(np.isfinite(values)):
267
+ msg = "wavelength_range must contain only finite values"
268
+ raise ValueError(msg)
269
+ lower, upper = values
270
+ if lower >= upper:
271
+ msg = "wavelength_range must be in increasing order"
272
+ raise ValueError(msg)
273
+ return float(lower), float(upper)
274
+
275
+
276
+ def _validate_positive_data_array(
277
+ values: xr.DataArray, name: str, unit: u.UnitBase, *, dimension: str | None = None
278
+ ) -> xr.DataArray:
279
+ if not isinstance(values, xr.DataArray):
280
+ msg = f"{name} must be an xarray.DataArray"
281
+ raise TypeError(msg)
282
+ expected_dims = (dimension,) if dimension is not None else None
283
+ if (expected_dims is not None and values.dims != expected_dims) or (expected_dims is None and values.ndim != 1):
284
+ qualifier = f"one-dimensional {dimension}" if dimension is not None else "one-dimensional"
285
+ msg = f"{name} must be a {qualifier} array"
286
+ raise ValueError(msg)
287
+ if values.size == 0:
288
+ msg = f"{name} must not be empty"
289
+ raise ValueError(msg)
290
+ if not isinstance(values.data, u.Quantity):
291
+ msg = f"{name} data must be an astropy.units.Quantity convertible to {unit}"
292
+ raise TypeError(msg)
293
+ try:
294
+ data = values.data.to_value(unit)
295
+ except u.UnitConversionError as exc:
296
+ msg = f"{name} units must be convertible to {unit}"
297
+ raise ValueError(msg) from exc
298
+ if not np.all(np.isfinite(data)):
299
+ msg = f"{name} must contain only finite values"
300
+ raise ValueError(msg)
301
+ if np.any(data <= 0):
302
+ msg = f"{name} must contain only positive values"
303
+ raise ValueError(msg)
304
+ return values.copy(data=data)
305
+
306
+
307
+ def _normalize_species_names(values: list[str] | tuple[str, ...], name: str, pattern: str) -> list[str]:
308
+ if isinstance(values, str) or not isinstance(values, list | tuple):
309
+ msg = f"{name} must be a list of strings"
310
+ raise TypeError(msg)
311
+ if not values or any(not isinstance(value, str) or not value.strip() for value in values):
312
+ msg = f"{name} must contain unique, non-empty strings"
313
+ raise ValueError(msg)
314
+ normalized = [value.strip().lower() for value in values]
315
+ if len(set(normalized)) != len(normalized):
316
+ msg = f"{name} must contain unique, non-empty strings"
317
+ raise ValueError(msg)
318
+ normalized.sort()
319
+ invalid = [value for value in normalized if re.fullmatch(pattern, value) is None]
320
+ if invalid:
321
+ msg = f"invalid {name}: {', '.join(invalid)}"
322
+ raise ValueError(msg)
323
+ return normalized
324
+
325
+
326
+ def _validate_species_selection(
327
+ minimum_abundance: float | None, element_list: list[str] | None, ion_list: list[str] | None
328
+ ) -> tuple[float | None, list[str] | None, list[str] | None]:
329
+ """
330
+ Require exactly one species selection and normalize it.
331
+
332
+ Returns the ``(minimum_abundance, element_list, ion_list)`` triple with the two
333
+ unselected entries set to `None`.
334
+ """
335
+ given = [
336
+ name
337
+ for name, value in (
338
+ ("minimum_abundance", minimum_abundance),
339
+ ("element_list", element_list),
340
+ ("ion_list", ion_list),
341
+ )
342
+ if value is not None
343
+ ]
344
+ if not given:
345
+ msg = "Specify minimum_abundance, element_list, or ion_list"
346
+ raise ValueError(msg)
347
+ if len(given) > 1:
348
+ msg = f"{', '.join(given)} are mutually exclusive; give only one"
349
+ raise ValueError(msg)
350
+ if element_list is not None:
351
+ return None, _normalize_species_names(element_list, "element_list", r"[a-z]{1,2}"), None
352
+ if ion_list is not None:
353
+ return None, None, _normalize_species_names(ion_list, "ion_list", r"[a-z]{1,2}_[1-9][0-9]*d?")
354
+ if isinstance(minimum_abundance, bool) or not isinstance(minimum_abundance, Real):
355
+ msg = "minimum_abundance must be a real number"
356
+ raise TypeError(msg)
357
+ minimum_abundance = float(minimum_abundance)
358
+ if not np.isfinite(minimum_abundance) or minimum_abundance <= 0:
359
+ msg = "minimum_abundance must be finite and positive"
360
+ raise ValueError(msg)
361
+ return minimum_abundance, None, None
@@ -0,0 +1,72 @@
1
+ from pathlib import Path
2
+ from tempfile import TemporaryDirectory
3
+
4
+ import xarray as xr
5
+
6
+ from muse.instrument import response_io
7
+
8
+ __all__ = ["migrate_response"]
9
+
10
+
11
+ def migrate_response(source: str | Path, destination: str | Path) -> tuple[str, str]:
12
+ """
13
+ Migrate a legacy response to canonical names at a new destination.
14
+
15
+ The migrated response is staged beside the destination and becomes visible
16
+ only after every stored value has been verified against the source.
17
+
18
+ Parameters
19
+ ----------
20
+ source : `str` or `pathlib.Path`
21
+ Existing response file or Zarr store.
22
+ destination : `str` or `pathlib.Path`
23
+ New ``.zarr`` or NetCDF destination.
24
+
25
+ Returns
26
+ -------
27
+ before : `str`
28
+ Human-readable source schema.
29
+ after : `str`
30
+ Human-readable migrated schema.
31
+ """
32
+ source = Path(source)
33
+ destination = Path(destination)
34
+ if not source.exists():
35
+ msg = f"Response does not exist: {source}"
36
+ raise ValueError(msg)
37
+ if destination.exists():
38
+ msg = f"Refusing to overwrite existing response: {destination}"
39
+ raise ValueError(msg)
40
+ if not destination.parent.is_dir():
41
+ msg = f"Destination directory does not exist: {destination.parent}"
42
+ raise ValueError(msg)
43
+
44
+ with response_io._open_response_file(source, chunked=True) as opened:
45
+ before = _schema(opened)
46
+ canonical = response_io._canonicalize_response_names(opened)
47
+ with TemporaryDirectory(prefix=f".{destination.name}-", dir=destination.parent) as temporary_directory:
48
+ staged = Path(temporary_directory) / destination.name
49
+ response_io.save_response(canonical, staged)
50
+ with response_io._open_response_file(staged, chunked=True) as written:
51
+ _verify_values(canonical, written)
52
+ after = _schema(written)
53
+ if destination.exists():
54
+ msg = f"Refusing to overwrite existing response: {destination}"
55
+ raise ValueError(msg)
56
+ staged.replace(destination)
57
+ return before, after
58
+
59
+
60
+ def _schema(response: xr.Dataset) -> str:
61
+ dimensions = ", ".join(f"{name}={size}" for name, size in response.sizes.items())
62
+ data_variables = ", ".join(f"{name}{response[name].dims}" for name in response.data_vars)
63
+ coordinates = ", ".join(f"{name}{response[name].dims}" for name in response.coords)
64
+ return f"dimensions: {dimensions}\ndata variables: {data_variables}\ncoordinates: {coordinates}"
65
+
66
+
67
+ def _verify_values(expected: xr.Dataset, actual: xr.Dataset) -> None:
68
+ try:
69
+ xr.testing.assert_identical(expected, actual)
70
+ except AssertionError as exc:
71
+ msg = "Migrated response does not match the canonical source"
72
+ raise ValueError(msg) from exc