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.
- muse/__init__.py +17 -0
- muse/_version.py +24 -0
- muse/conftest.py +26 -0
- muse/data/README.rst +6 -0
- muse/data/__init__.py +106 -0
- muse/data/tests/__init__.py +0 -0
- muse/data/tests/test_data.py +25 -0
- muse/instrument/__init__.py +24 -0
- muse/instrument/linelist.py +361 -0
- muse/instrument/migration.py +72 -0
- muse/instrument/radiometry.py +174 -0
- muse/instrument/response.py +293 -0
- muse/instrument/response_io.py +479 -0
- muse/instrument/spectral.py +510 -0
- muse/instrument/tests/__init__.py +0 -0
- muse/instrument/tests/test_figures.py +91 -0
- muse/instrument/tests/test_linelist.py +220 -0
- muse/instrument/tests/test_migration.py +61 -0
- muse/instrument/tests/test_radiometry.py +247 -0
- muse/instrument/tests/test_response.py +506 -0
- muse/instrument/tests/test_response_io.py +600 -0
- muse/instrument/tests/test_spectral.py +618 -0
- muse/log.py +49 -0
- muse/synthesis/__init__.py +15 -0
- muse/synthesis/_backends.py +113 -0
- muse/synthesis/synthesis.py +261 -0
- muse/synthesis/tests/__init__.py +0 -0
- muse/synthesis/tests/test_backends.py +47 -0
- muse/synthesis/tests/test_figures.py +78 -0
- muse/synthesis/tests/test_synthesis.py +413 -0
- muse/synthesis/tests/test_utils.py +348 -0
- muse/synthesis/utils.py +441 -0
- muse/tests/__init__.py +0 -0
- muse/tests/figure_hashes_mpl_3111_ft_2143_astropy_801.json +13 -0
- muse/tests/helpers.py +397 -0
- muse/tests/test_documentation.py +18 -0
- muse/tests/test_figures.py +52 -0
- muse/tests/test_helpers.py +41 -0
- muse/tests/test_variables.py +244 -0
- muse/transforms/__init__.py +3 -0
- muse/transforms/tests/__init__.py +0 -0
- muse/transforms/tests/test_figures.py +19 -0
- muse/transforms/tests/test_transforms.py +359 -0
- muse/transforms/transforms.py +373 -0
- muse/utils/__init__.py +8 -0
- muse/utils/documentation.py +67 -0
- muse/utils/tests/__init__.py +0 -0
- muse/utils/tests/test_documentation.py +31 -0
- muse/utils/tests/test_utils.py +203 -0
- muse/utils/utils.py +295 -0
- muse/variables.py +138 -0
- muse/variables_schema.py +664 -0
- muse/version.py +21 -0
- muse-0.2.0.dist-info/METADATA +123 -0
- muse-0.2.0.dist-info/RECORD +58 -0
- muse-0.2.0.dist-info/WHEEL +5 -0
- muse-0.2.0.dist-info/licenses/licenses/LICENSE.rst +25 -0
- 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
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
|