chasepy 2.0.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.
- chase/__init__.py +104 -0
- chase/_compat.py +78 -0
- chase/analysis/__init__.py +23 -0
- chase/analysis/contrast.py +70 -0
- chase/analysis/temperature.py +246 -0
- chase/calib/__init__.py +48 -0
- chase/calib/align.py +127 -0
- chase/calib/intensity.py +142 -0
- chase/calib/spatial.py +225 -0
- chase/calib/wavelength.py +149 -0
- chase/cli.py +205 -0
- chase/config.py +130 -0
- chase/core.py +115 -0
- chase/io/__init__.py +25 -0
- chase/io/download.py +146 -0
- chase/io/fits_io.py +147 -0
- chase/io/loader.py +195 -0
- chase/pipeline.py +303 -0
- chase/shifts_cache.py +84 -0
- chase/tui.py +206 -0
- chase/viz/__init__.py +19 -0
- chase/viz/animate.py +224 -0
- chase/viz/diagnostics.py +110 -0
- chasepy-2.0.0.dist-info/METADATA +261 -0
- chasepy-2.0.0.dist-info/RECORD +30 -0
- chasepy-2.0.0.dist-info/WHEEL +5 -0
- chasepy-2.0.0.dist-info/entry_points.txt +3 -0
- chasepy-2.0.0.dist-info/licenses/LICENSE +21 -0
- chasepy-2.0.0.dist-info/licenses/NOTICE +49 -0
- chasepy-2.0.0.dist-info/top_level.txt +1 -0
chase/__init__.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""chase — a modular CHASE/HIS calibration + flare-analysis pipeline.
|
|
2
|
+
|
|
3
|
+
Nothing here is a forced pipeline: every stage is an importable function you can
|
|
4
|
+
call on its own, *and* there is a one-call orchestrator (:func:`run_pipeline`), a
|
|
5
|
+
config-file workflow (:class:`Config`), a CLI (``chase``), and a TUI
|
|
6
|
+
(``chase-tui``) layered on top of the exact same functions.
|
|
7
|
+
|
|
8
|
+
Quick start (SDK)
|
|
9
|
+
-----------------
|
|
10
|
+
>>> import chase
|
|
11
|
+
>>> cube, hdr, wav = chase.load_cube("RSM..._HA.fits")
|
|
12
|
+
>>> seq = chase.load_flare_sequence("/path/to/fits", patch=[940, 1080, 1870, 2040])
|
|
13
|
+
>>> aligned = chase.optical_flow_align(seq["ha_cubes"], reference=seq["align_ref"])
|
|
14
|
+
>>> contrast, _ = chase.contrast_profile(aligned, background=seq["ha_bg"])
|
|
15
|
+
|
|
16
|
+
Quick start (lazy)
|
|
17
|
+
------------------
|
|
18
|
+
>>> from chase import Config, run_pipeline
|
|
19
|
+
>>> run_pipeline(Config(fits_dir="/path/to/fits", patch=[940, 1080, 1870, 2040],
|
|
20
|
+
... contrast=True, temperature="double"))
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from . import _compat # noqa: F401 (lzma stub + headless matplotlib)
|
|
26
|
+
|
|
27
|
+
__version__ = "2.0.0"
|
|
28
|
+
|
|
29
|
+
# --- I/O -------------------------------------------------------------------
|
|
30
|
+
from .io import (
|
|
31
|
+
discover_fits,
|
|
32
|
+
download_file,
|
|
33
|
+
extract_disk_center,
|
|
34
|
+
load_cube,
|
|
35
|
+
load_fits_data,
|
|
36
|
+
load_flare_sequence,
|
|
37
|
+
resolve_inputs,
|
|
38
|
+
save_cube_fits,
|
|
39
|
+
save_npz,
|
|
40
|
+
)
|
|
41
|
+
download = download_file # SDK alias
|
|
42
|
+
|
|
43
|
+
# --- Calibration -----------------------------------------------------------
|
|
44
|
+
from .calib import (
|
|
45
|
+
align_spectrum,
|
|
46
|
+
atlas_calibrate,
|
|
47
|
+
correct_spectral_drift,
|
|
48
|
+
create_optical_flow_solver,
|
|
49
|
+
find_disk_center,
|
|
50
|
+
hough_disk_center,
|
|
51
|
+
integral_scaling,
|
|
52
|
+
normalize_intensity,
|
|
53
|
+
optical_flow_align,
|
|
54
|
+
phase_shift,
|
|
55
|
+
planck_inversion,
|
|
56
|
+
recenter,
|
|
57
|
+
resample_wavelength,
|
|
58
|
+
track_and_crop,
|
|
59
|
+
warp_cube,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# --- Analysis --------------------------------------------------------------
|
|
63
|
+
from .analysis import (
|
|
64
|
+
contrast_profile,
|
|
65
|
+
fe_eb_temperature,
|
|
66
|
+
fe_planck_temperature,
|
|
67
|
+
fe_voigt_temperature,
|
|
68
|
+
halpha_width_map,
|
|
69
|
+
halpha_width_temperature,
|
|
70
|
+
width_to_temperature,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# --- Visualisation ---------------------------------------------------------
|
|
74
|
+
from .viz import (
|
|
75
|
+
make_animation,
|
|
76
|
+
make_contrast_animation,
|
|
77
|
+
make_temperature_animation,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# --- Orchestration ---------------------------------------------------------
|
|
81
|
+
from .config import Config, load_config
|
|
82
|
+
from .pipeline import run_pipeline
|
|
83
|
+
|
|
84
|
+
__all__ = [
|
|
85
|
+
"__version__",
|
|
86
|
+
# io
|
|
87
|
+
"download", "download_file", "resolve_inputs", "discover_fits",
|
|
88
|
+
"load_cube", "load_fits_data", "load_flare_sequence", "extract_disk_center",
|
|
89
|
+
"save_cube_fits", "save_npz",
|
|
90
|
+
# calib
|
|
91
|
+
"find_disk_center", "hough_disk_center", "recenter", "phase_shift",
|
|
92
|
+
"track_and_crop", "optical_flow_align", "warp_cube",
|
|
93
|
+
"create_optical_flow_solver", "resample_wavelength", "correct_spectral_drift",
|
|
94
|
+
"align_spectrum", "normalize_intensity", "integral_scaling",
|
|
95
|
+
"atlas_calibrate", "planck_inversion",
|
|
96
|
+
# analysis
|
|
97
|
+
"contrast_profile", "halpha_width_map", "halpha_width_temperature",
|
|
98
|
+
"width_to_temperature", "fe_planck_temperature", "fe_eb_temperature",
|
|
99
|
+
"fe_voigt_temperature",
|
|
100
|
+
# viz
|
|
101
|
+
"make_animation", "make_contrast_animation", "make_temperature_animation",
|
|
102
|
+
# orchestration
|
|
103
|
+
"Config", "load_config", "run_pipeline",
|
|
104
|
+
]
|
chase/_compat.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Environment-compatibility shims.
|
|
2
|
+
|
|
3
|
+
Importing :mod:`chase._compat` (which happens automatically when you ``import
|
|
4
|
+
chase``) makes the package usable in two awkward-but-common situations:
|
|
5
|
+
|
|
6
|
+
1. **Python builds without the ``_lzma`` C extension.** ``astropy`` imports
|
|
7
|
+
:mod:`lzma` at load time, so on a pyenv/conda interpreter compiled without
|
|
8
|
+
the xz headers an ``import astropy`` explodes with ``ModuleNotFoundError:
|
|
9
|
+
_lzma``. We install a minimal stub *before* astropy is imported. The stub
|
|
10
|
+
carries a real ``__spec__`` because :mod:`importlib` refuses to treat a bare
|
|
11
|
+
``ModuleType`` as a package.
|
|
12
|
+
|
|
13
|
+
2. **Headless matplotlib.** We default ``MPLBACKEND`` to ``Agg`` and point
|
|
14
|
+
``MPLCONFIGDIR`` at a writable per-user cache so figure generation works on a
|
|
15
|
+
server / in CI with no ``$HOME/.config`` write access.
|
|
16
|
+
|
|
17
|
+
Both shims are idempotent and only activate when needed, so importing this on a
|
|
18
|
+
fully-featured interpreter is a no-op.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import os
|
|
24
|
+
import sys
|
|
25
|
+
import tempfile
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
__all__ = ["ensure_lzma_stub", "ensure_headless_matplotlib"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def ensure_lzma_stub() -> bool:
|
|
32
|
+
"""Install a stub :mod:`lzma` module if the real one is unavailable.
|
|
33
|
+
|
|
34
|
+
Returns
|
|
35
|
+
-------
|
|
36
|
+
bool
|
|
37
|
+
``True`` if a stub was installed, ``False`` if the real module works.
|
|
38
|
+
"""
|
|
39
|
+
try: # pragma: no cover - depends on interpreter build
|
|
40
|
+
import lzma # noqa: F401
|
|
41
|
+
|
|
42
|
+
return False
|
|
43
|
+
except ImportError:
|
|
44
|
+
from importlib.machinery import ModuleSpec
|
|
45
|
+
from types import ModuleType
|
|
46
|
+
|
|
47
|
+
stub = ModuleType("lzma")
|
|
48
|
+
stub.__spec__ = ModuleSpec("lzma", loader=None)
|
|
49
|
+
|
|
50
|
+
class _MissingLZMAFile: # noqa: D401 - mirror of v50 stub
|
|
51
|
+
def __init__(self, *args, **kwargs):
|
|
52
|
+
raise RuntimeError(
|
|
53
|
+
"lzma is unavailable in this Python build; rebuild Python "
|
|
54
|
+
"with the xz/liblzma headers to read .xz-compressed FITS."
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
stub.LZMAFile = _MissingLZMAFile
|
|
58
|
+
sys.modules["lzma"] = stub
|
|
59
|
+
return True
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def ensure_headless_matplotlib(config_dir: str | os.PathLike | None = None) -> None:
|
|
63
|
+
"""Configure matplotlib for headless rendering (Agg + writable cache)."""
|
|
64
|
+
os.environ.setdefault("MPLBACKEND", "Agg")
|
|
65
|
+
if "MPLCONFIGDIR" not in os.environ:
|
|
66
|
+
if config_dir is None:
|
|
67
|
+
config_dir = Path(tempfile.gettempdir()) / "chase_mplconfig"
|
|
68
|
+
cfg = Path(config_dir)
|
|
69
|
+
try:
|
|
70
|
+
cfg.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
os.environ["MPLCONFIGDIR"] = str(cfg)
|
|
72
|
+
except OSError: # pragma: no cover - fall back to matplotlib default
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# Apply on import; safe and idempotent.
|
|
77
|
+
ensure_lzma_stub()
|
|
78
|
+
ensure_headless_matplotlib()
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Science analysis: contrast profiles and temperature maps."""
|
|
2
|
+
|
|
3
|
+
from .contrast import contrast_profile
|
|
4
|
+
from .temperature import (
|
|
5
|
+
fe_eb_temperature,
|
|
6
|
+
fe_planck_temperature,
|
|
7
|
+
fe_voigt_temperature,
|
|
8
|
+
halpha_width_map,
|
|
9
|
+
halpha_width_temperature,
|
|
10
|
+
measure_halpha_width,
|
|
11
|
+
width_to_temperature,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"contrast_profile",
|
|
16
|
+
"halpha_width_map",
|
|
17
|
+
"halpha_width_temperature",
|
|
18
|
+
"measure_halpha_width",
|
|
19
|
+
"width_to_temperature",
|
|
20
|
+
"fe_planck_temperature",
|
|
21
|
+
"fe_eb_temperature",
|
|
22
|
+
"fe_voigt_temperature",
|
|
23
|
+
]
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Spectral contrast profile (wavelength vs time).
|
|
2
|
+
|
|
3
|
+
The contrast map averages an aligned Hα cube over the spatial axes to get one
|
|
4
|
+
spectrum per frame, then expresses each as a fractional excess over a quiet-Sun
|
|
5
|
+
baseline. The physically correct definition uses a **separate background patch**::
|
|
6
|
+
|
|
7
|
+
contrast(t, λ) = (flare(t, λ) − bg(t, λ)) / bg(t, λ) − [same at frame 0]
|
|
8
|
+
|
|
9
|
+
which removes instrumental throughput drift that a naive ``frame_t/frame_0 − 1``
|
|
10
|
+
leaves in. When no background patch is supplied it falls back to the naive form.
|
|
11
|
+
|
|
12
|
+
In the resulting wavelength-vs-time image the erupting CME shows up as a red
|
|
13
|
+
Doppler feature in the Hα wing — the science hook for the A&A letter.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
__all__ = ["contrast_profile"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def contrast_profile(
|
|
26
|
+
ha_cubes: np.ndarray,
|
|
27
|
+
background: Optional[np.ndarray] = None,
|
|
28
|
+
baseline_frame: int = 0,
|
|
29
|
+
correct_drift: bool = True,
|
|
30
|
+
):
|
|
31
|
+
"""Compute the wavelength-vs-time contrast profile of an aligned cube.
|
|
32
|
+
|
|
33
|
+
Parameters
|
|
34
|
+
----------
|
|
35
|
+
ha_cubes : ndarray (nframes, nchannels, H, W)
|
|
36
|
+
Aligned Hα spectral stack.
|
|
37
|
+
background : ndarray (nframes, nchannels, H, W) or None, optional
|
|
38
|
+
Quiet-Sun background patch (same shape). If given, the
|
|
39
|
+
``(flare − bg)/bg`` form is used; otherwise ``frame_t/frame_0 − 1``.
|
|
40
|
+
baseline_frame : int, optional
|
|
41
|
+
Frame whose profile is subtracted as the pre-flare baseline.
|
|
42
|
+
correct_drift : bool, optional
|
|
43
|
+
Apply 1-D spectral-drift correction before averaging.
|
|
44
|
+
|
|
45
|
+
Returns
|
|
46
|
+
-------
|
|
47
|
+
contrast : ndarray (nframes, nchannels)
|
|
48
|
+
The contrast map (time along axis 0, wavelength along axis 1).
|
|
49
|
+
mean_spec : ndarray (nframes, nchannels)
|
|
50
|
+
Spatially-averaged flare spectra (post drift-correction).
|
|
51
|
+
"""
|
|
52
|
+
cube = ha_cubes
|
|
53
|
+
if correct_drift:
|
|
54
|
+
from ..calib.wavelength import correct_spectral_drift
|
|
55
|
+
|
|
56
|
+
cube, _ = correct_spectral_drift(cube, verbose=False)
|
|
57
|
+
|
|
58
|
+
mean_spec = np.mean(cube, axis=(2, 3)) # (nframes, nch)
|
|
59
|
+
|
|
60
|
+
if background is not None:
|
|
61
|
+
mean_bg = np.mean(background, axis=(2, 3))
|
|
62
|
+
mean_bg = np.where(mean_bg == 0, 1.0, mean_bg)
|
|
63
|
+
contrast = (mean_spec - mean_bg) / mean_bg
|
|
64
|
+
contrast = contrast - contrast[baseline_frame]
|
|
65
|
+
else:
|
|
66
|
+
ref = mean_spec[baseline_frame].copy()
|
|
67
|
+
ref[ref == 0] = 1.0
|
|
68
|
+
contrast = mean_spec / ref[np.newaxis, :] - 1.0
|
|
69
|
+
|
|
70
|
+
return contrast, mean_spec
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""Temperature maps — split by line physics.
|
|
2
|
+
|
|
3
|
+
Two regimes, two methods:
|
|
4
|
+
|
|
5
|
+
* **Hα → chromospheric temperature** via the line *width* (Molnar et al. 2019,
|
|
6
|
+
ApJ 881, 99). Hα forms out of LTE, so a Planck inversion is invalid; instead
|
|
7
|
+
the half-depth width maps onto an ALMA brightness temperature,
|
|
8
|
+
``T[K] = (width[Å] − 0.553) / 6.12e-5``.
|
|
9
|
+
|
|
10
|
+
* **Fe I → photospheric temperature** via a Planck inversion. Fe I 6569 Å is a
|
|
11
|
+
photospheric LTE line, so ``B_ν(T) = I_core`` holds once the data is on an
|
|
12
|
+
absolute radiance scale (see :func:`chase.calib.atlas_calibrate`). Three core
|
|
13
|
+
estimators are offered:
|
|
14
|
+
- :func:`fe_planck_temperature` — Gaussian fit to the line core (default);
|
|
15
|
+
- :func:`fe_eb_temperature` — Eddington–Barbier per-wavelength inversion;
|
|
16
|
+
- :func:`fe_voigt_temperature` — Voigt fit (robust when the Fe line is
|
|
17
|
+
under-sampled).
|
|
18
|
+
|
|
19
|
+
References
|
|
20
|
+
----------
|
|
21
|
+
Molnar et al. 2019, ApJ 881, 99 (10.3847/1538-4357/ab2ba3).
|
|
22
|
+
Eddington–Barbier: aa21259-13 (A&A 2013).
|
|
23
|
+
ISPy FTS atlas: ISP-SST/ISPy.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from typing import Tuple
|
|
29
|
+
|
|
30
|
+
import numpy as np
|
|
31
|
+
|
|
32
|
+
from ..calib.intensity import planck_inversion
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"measure_halpha_width",
|
|
36
|
+
"halpha_width_map",
|
|
37
|
+
"width_to_temperature",
|
|
38
|
+
"halpha_width_temperature",
|
|
39
|
+
"fe_planck_temperature",
|
|
40
|
+
"fe_eb_temperature",
|
|
41
|
+
"fe_voigt_temperature",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
_MOLNAR_OFFSET = 0.553
|
|
45
|
+
_MOLNAR_SLOPE = 6.12e-5
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# Hα width → chromospheric temperature (Molnar 2019)
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
def _find_crossing(spectrum, wavelengths, indices, level):
|
|
52
|
+
for j in range(len(indices) - 1):
|
|
53
|
+
i0, i1 = indices[j], indices[j + 1]
|
|
54
|
+
s0, s1 = spectrum[i0], spectrum[i1]
|
|
55
|
+
if (s0 <= level <= s1) or (s1 <= level <= s0):
|
|
56
|
+
if s1 == s0:
|
|
57
|
+
return wavelengths[i0]
|
|
58
|
+
frac = (level - s0) / (s1 - s0)
|
|
59
|
+
return wavelengths[i0] + frac * (wavelengths[i1] - wavelengths[i0])
|
|
60
|
+
return np.nan
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def measure_halpha_width(spectrum, wavelengths, core_wavelength=6562.8, wing_offset=1.0):
|
|
64
|
+
"""Half-depth Hα width (Å) of one spectrum (Molnar 2019 definition).
|
|
65
|
+
|
|
66
|
+
Finds the core minimum, measures the depth relative to the mean intensity at
|
|
67
|
+
``±wing_offset`` Å, and returns the wavelength separation where the profile
|
|
68
|
+
crosses the half-depth level. Returns NaN if the measurement is ill-defined.
|
|
69
|
+
"""
|
|
70
|
+
from scipy.interpolate import interp1d
|
|
71
|
+
|
|
72
|
+
core_mask = np.abs(wavelengths - core_wavelength) < 0.5
|
|
73
|
+
if core_mask.sum() < 3:
|
|
74
|
+
return np.nan
|
|
75
|
+
region = np.where(core_mask)[0]
|
|
76
|
+
local_min = region[np.argmin(spectrum[region])]
|
|
77
|
+
core_wav = wavelengths[local_min]
|
|
78
|
+
i_core = spectrum[local_min]
|
|
79
|
+
|
|
80
|
+
wb, wr = core_wav - wing_offset, core_wav + wing_offset
|
|
81
|
+
if wb < wavelengths[0] or wr > wavelengths[-1]:
|
|
82
|
+
return np.nan
|
|
83
|
+
f = interp1d(wavelengths, spectrum, kind="linear")
|
|
84
|
+
depth = 0.5 * (float(f(wb)) + float(f(wr))) - i_core
|
|
85
|
+
if depth <= 0:
|
|
86
|
+
return np.nan
|
|
87
|
+
level = i_core + 0.5 * depth
|
|
88
|
+
|
|
89
|
+
blue = np.where(wavelengths < core_wav)[0][::-1]
|
|
90
|
+
red = np.where(wavelengths > core_wav)[0]
|
|
91
|
+
if len(blue) < 2 or len(red) < 2:
|
|
92
|
+
return np.nan
|
|
93
|
+
bx = _find_crossing(spectrum, wavelengths, blue, level)
|
|
94
|
+
rx = _find_crossing(spectrum, wavelengths, red, level)
|
|
95
|
+
if np.isnan(bx) or np.isnan(rx):
|
|
96
|
+
return np.nan
|
|
97
|
+
return rx - bx
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def width_to_temperature(width):
|
|
101
|
+
"""Molnar 2019: ``T[K] = (width[Å] − 0.553) / 6.12e-5``."""
|
|
102
|
+
return (width - _MOLNAR_OFFSET) / _MOLNAR_SLOPE
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def halpha_width_map(ha_cube, wavelengths, core_wavelength=6562.8) -> np.ndarray:
|
|
106
|
+
"""Per-pixel Hα width map (Å) for one frame's cube ``(nch, H, W)``."""
|
|
107
|
+
nch, H, W = ha_cube.shape
|
|
108
|
+
width = np.full((H, W), np.nan, dtype=np.float32)
|
|
109
|
+
for y in range(H):
|
|
110
|
+
for x in range(W):
|
|
111
|
+
spec = ha_cube[:, y, x]
|
|
112
|
+
if np.any(np.isnan(spec)) or np.all(spec == 0):
|
|
113
|
+
continue
|
|
114
|
+
width[y, x] = measure_halpha_width(spec, wavelengths, core_wavelength)
|
|
115
|
+
return width
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def halpha_width_temperature(
|
|
119
|
+
ha_cube, wavelengths, core_wavelength=6562.8, t_min=4000.0, t_max=20000.0
|
|
120
|
+
) -> Tuple[np.ndarray, np.ndarray]:
|
|
121
|
+
"""Chromospheric temperature map from Hα width (Molnar 2019).
|
|
122
|
+
|
|
123
|
+
Returns ``(temperature_map, width_map)``; temperatures outside
|
|
124
|
+
``[t_min, t_max]`` are masked to NaN.
|
|
125
|
+
"""
|
|
126
|
+
width = halpha_width_map(ha_cube, wavelengths, core_wavelength)
|
|
127
|
+
temp = width_to_temperature(width)
|
|
128
|
+
temp[(temp < t_min) | (temp > t_max)] = np.nan
|
|
129
|
+
return temp, width
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
# Fe I → photospheric temperature (Planck) — three estimators
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
def _gaussian(x, amp, mu, sigma, offset):
|
|
136
|
+
return offset + amp * np.exp(-0.5 * ((x - mu) / sigma) ** 2)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _fit_fe_core_gaussian(spectrum, wavelengths, n_lowest=6):
|
|
140
|
+
from scipy.optimize import curve_fit
|
|
141
|
+
|
|
142
|
+
if np.any(np.isnan(spectrum)) or np.all(spectrum == 0):
|
|
143
|
+
return np.nan
|
|
144
|
+
lowest = np.sort(np.argsort(spectrum)[:n_lowest])
|
|
145
|
+
wav_fit, spec_fit = wavelengths[lowest], spectrum[lowest]
|
|
146
|
+
cont = spectrum.max()
|
|
147
|
+
try:
|
|
148
|
+
popt, _ = curve_fit(
|
|
149
|
+
_gaussian, wav_fit, spec_fit,
|
|
150
|
+
p0=[spec_fit.min() - cont, wav_fit[np.argmin(spec_fit)], 0.05, cont],
|
|
151
|
+
maxfev=2000,
|
|
152
|
+
)
|
|
153
|
+
i_core = popt[3] + popt[0]
|
|
154
|
+
return float(i_core) if i_core > 0 else np.nan
|
|
155
|
+
except (RuntimeError, ValueError):
|
|
156
|
+
return np.nan
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def fe_planck_temperature(fe_cube, wavelengths, ifact, t_min=3000.0, t_max=8000.0):
|
|
160
|
+
"""Photospheric T from a Gaussian-core Planck inversion of Fe I.
|
|
161
|
+
|
|
162
|
+
For each pixel: Gaussian-fit the line core to the ~6 lowest channels, scale
|
|
163
|
+
DN→radiance with ``ifact`` (from :func:`chase.calib.atlas_calibrate`), and
|
|
164
|
+
invert the Planck function at the Fe core wavelength.
|
|
165
|
+
|
|
166
|
+
Returns ``(temperature_map, core_intensity_map)``.
|
|
167
|
+
"""
|
|
168
|
+
nch, H, W = fe_cube.shape
|
|
169
|
+
core_int = np.full((H, W), np.nan, dtype=np.float32)
|
|
170
|
+
for y in range(H):
|
|
171
|
+
for x in range(W):
|
|
172
|
+
core_int[y, x] = _fit_fe_core_gaussian(fe_cube[:, y, x], wavelengths)
|
|
173
|
+
fe_core_wav = wavelengths[np.argmin(np.mean(fe_cube, axis=(1, 2)))]
|
|
174
|
+
temp = planck_inversion(core_int * ifact, wavelength_ang=fe_core_wav).astype(np.float32)
|
|
175
|
+
temp[(temp < t_min) | (temp > t_max)] = np.nan
|
|
176
|
+
return temp, core_int
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def fe_eb_temperature(fe_cube, wavelengths, ifact, t_min=3000.0, t_max=8000.0):
|
|
180
|
+
"""Eddington–Barbier photospheric T (per-wavelength Planck inversion).
|
|
181
|
+
|
|
182
|
+
At disk centre (μ=1) ``I(λ) = B(T(τ_λ=1), λ)``, so every wavelength probes a
|
|
183
|
+
different height. Returns ``(temp_cube, temp_core, temp_continuum)`` where
|
|
184
|
+
``temp_cube`` is ``(nch, H, W)``, ``temp_core`` samples the per-pixel minimum,
|
|
185
|
+
and ``temp_continuum`` averages the last three channels.
|
|
186
|
+
"""
|
|
187
|
+
nch, H, W = fe_cube.shape
|
|
188
|
+
I_cgs = fe_cube.astype(np.float64) * ifact
|
|
189
|
+
temp_cube = np.empty_like(I_cgs, dtype=np.float32)
|
|
190
|
+
for ich in range(nch):
|
|
191
|
+
temp_cube[ich] = planck_inversion(I_cgs[ich], wavelengths[ich]).astype(np.float32)
|
|
192
|
+
core_ch = np.argmin(fe_cube, axis=0)
|
|
193
|
+
temp_core = np.take_along_axis(temp_cube, core_ch[np.newaxis], axis=0)[0]
|
|
194
|
+
temp_cont = np.mean(temp_cube[-3:], axis=0)
|
|
195
|
+
for arr in (temp_core, temp_cont):
|
|
196
|
+
arr[(arr < t_min) | (arr > t_max)] = np.nan
|
|
197
|
+
return temp_cube, temp_core, temp_cont
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _fit_fe_voigt(spectrum, wavelengths):
|
|
201
|
+
from scipy.optimize import curve_fit
|
|
202
|
+
from scipy.special import voigt_profile
|
|
203
|
+
|
|
204
|
+
if np.any(np.isnan(spectrum)) or np.all(spectrum == 0):
|
|
205
|
+
return np.nan, np.nan
|
|
206
|
+
cont = float(np.mean(spectrum[-3:]))
|
|
207
|
+
core_idx = int(np.argmin(spectrum))
|
|
208
|
+
|
|
209
|
+
def model(wav, amp, center, sigma, gamma, continuum, slope):
|
|
210
|
+
v = voigt_profile(wav - center, sigma, gamma)
|
|
211
|
+
vpeak = voigt_profile(0.0, sigma, gamma)
|
|
212
|
+
return (continuum + slope * (wav - center)) - amp * v / vpeak
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
popt, _ = curve_fit(
|
|
216
|
+
model, wavelengths, spectrum,
|
|
217
|
+
p0=[cont - spectrum[core_idx], wavelengths[core_idx], 0.05, 0.02, cont, 0.0],
|
|
218
|
+
bounds=(
|
|
219
|
+
[0, wavelengths[0], 0.001, 0.001, 0, -2000],
|
|
220
|
+
[cont * 2, wavelengths[-1], 0.5, 0.5, cont * 2, 2000],
|
|
221
|
+
),
|
|
222
|
+
maxfev=5000,
|
|
223
|
+
)
|
|
224
|
+
i_core = popt[4] - popt[0]
|
|
225
|
+
return (float(i_core), float(popt[1])) if i_core > 0 else (np.nan, np.nan)
|
|
226
|
+
except (RuntimeError, ValueError):
|
|
227
|
+
return np.nan, np.nan
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def fe_voigt_temperature(fe_cube, wavelengths, ifact, t_min=3000.0, t_max=8000.0):
|
|
231
|
+
"""Photospheric T from a Voigt-core Planck inversion of Fe I.
|
|
232
|
+
|
|
233
|
+
Robust when the Fe I line is under-sampled (a Gaussian can miss the true
|
|
234
|
+
core). Returns ``(temperature_map, core_intensity_map, center_map)``.
|
|
235
|
+
"""
|
|
236
|
+
nch, H, W = fe_cube.shape
|
|
237
|
+
core_map = np.full((H, W), np.nan, dtype=np.float32)
|
|
238
|
+
center_map = np.full((H, W), np.nan, dtype=np.float32)
|
|
239
|
+
for y in range(H):
|
|
240
|
+
for x in range(W):
|
|
241
|
+
ic, cen = _fit_fe_voigt(fe_cube[:, y, x].astype(np.float64), wavelengths)
|
|
242
|
+
core_map[y, x], center_map[y, x] = ic, cen
|
|
243
|
+
median_center = float(np.nanmedian(center_map))
|
|
244
|
+
temp = planck_inversion(core_map * ifact, median_center).astype(np.float32)
|
|
245
|
+
temp[(temp < t_min) | (temp > t_max)] = np.nan
|
|
246
|
+
return temp, core_map, center_map
|
chase/calib/__init__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Calibration stages: spatial, alignment, wavelength, intensity.
|
|
2
|
+
|
|
3
|
+
Every function here is usable on its own — nothing forces a full pipeline.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .spatial import (
|
|
7
|
+
find_disk_center,
|
|
8
|
+
hough_disk_center,
|
|
9
|
+
phase_shift,
|
|
10
|
+
recenter,
|
|
11
|
+
track_and_crop,
|
|
12
|
+
)
|
|
13
|
+
from .align import create_optical_flow_solver, optical_flow_align, warp_cube
|
|
14
|
+
from .wavelength import (
|
|
15
|
+
align_spectrum,
|
|
16
|
+
correct_spectral_drift,
|
|
17
|
+
cross_correlation_shift,
|
|
18
|
+
resample_wavelength,
|
|
19
|
+
)
|
|
20
|
+
from .intensity import (
|
|
21
|
+
atlas_calibrate,
|
|
22
|
+
integral_scaling,
|
|
23
|
+
normalize_intensity,
|
|
24
|
+
planck_inversion,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
# spatial
|
|
29
|
+
"find_disk_center",
|
|
30
|
+
"hough_disk_center",
|
|
31
|
+
"phase_shift",
|
|
32
|
+
"recenter",
|
|
33
|
+
"track_and_crop",
|
|
34
|
+
# align
|
|
35
|
+
"create_optical_flow_solver",
|
|
36
|
+
"optical_flow_align",
|
|
37
|
+
"warp_cube",
|
|
38
|
+
# wavelength
|
|
39
|
+
"align_spectrum",
|
|
40
|
+
"correct_spectral_drift",
|
|
41
|
+
"cross_correlation_shift",
|
|
42
|
+
"resample_wavelength",
|
|
43
|
+
# intensity
|
|
44
|
+
"atlas_calibrate",
|
|
45
|
+
"integral_scaling",
|
|
46
|
+
"normalize_intensity",
|
|
47
|
+
"planck_inversion",
|
|
48
|
+
]
|
chase/calib/align.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Fine alignment via dense optical flow.
|
|
2
|
+
|
|
3
|
+
Integer crop-tracking (:func:`chase.calib.spatial.track_and_crop`) removes whole
|
|
4
|
+
pixel jitter; residual sub-pixel wobble is removed here with a dense optical-flow
|
|
5
|
+
warp.
|
|
6
|
+
|
|
7
|
+
The flow field is computed **once per frame** from a single flare-free reference
|
|
8
|
+
channel converted to ``uint8`` (TV-L1 if OpenCV's ``optflow`` module is present,
|
|
9
|
+
else Farneback). The resulting displacement map is then applied with
|
|
10
|
+
``cv2.remap`` (cubic) to **every** wavelength channel of the float32 science
|
|
11
|
+
cubes — the science data never leaves float precision.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
__all__ = ["create_optical_flow_solver", "calculate_optical_flow", "warp_cube", "optical_flow_align"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def create_optical_flow_solver():
|
|
24
|
+
"""Return a TV-L1 optical-flow solver, or ``None`` to signal Farneback."""
|
|
25
|
+
import cv2
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
return cv2.optflow.DualTVL1OpticalFlow_create()
|
|
29
|
+
except AttributeError:
|
|
30
|
+
try:
|
|
31
|
+
return cv2.DualTVL1OpticalFlow_create()
|
|
32
|
+
except AttributeError:
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def calculate_optical_flow(ref_u8: np.ndarray, cur_u8: np.ndarray, solver=None) -> np.ndarray:
|
|
37
|
+
"""Compute the dense flow field mapping ``cur`` onto ``ref`` (both uint8)."""
|
|
38
|
+
import cv2
|
|
39
|
+
|
|
40
|
+
if solver is not None:
|
|
41
|
+
return solver.calc(ref_u8, cur_u8, None)
|
|
42
|
+
return cv2.calcOpticalFlowFarneback(
|
|
43
|
+
ref_u8, cur_u8, None,
|
|
44
|
+
pyr_scale=0.5, levels=3, winsize=15, iterations=3,
|
|
45
|
+
poly_n=5, poly_sigma=1.2, flags=0,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _to_u8(img: np.ndarray) -> np.ndarray:
|
|
50
|
+
import cv2
|
|
51
|
+
|
|
52
|
+
return cv2.normalize(img, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def warp_cube(cube: np.ndarray, map_x: np.ndarray, map_y: np.ndarray) -> np.ndarray:
|
|
56
|
+
"""Apply a precomputed remap to every channel of a cube (cubic, reflect)."""
|
|
57
|
+
import cv2
|
|
58
|
+
|
|
59
|
+
out = np.empty_like(cube)
|
|
60
|
+
for ch in range(cube.shape[0]):
|
|
61
|
+
out[ch] = cv2.remap(
|
|
62
|
+
cube[ch], map_x, map_y,
|
|
63
|
+
interpolation=cv2.INTER_CUBIC, borderMode=cv2.BORDER_REFLECT,
|
|
64
|
+
)
|
|
65
|
+
return out
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def optical_flow_align(
|
|
69
|
+
cubes: np.ndarray,
|
|
70
|
+
reference: Optional[np.ndarray] = None,
|
|
71
|
+
ref_idx: int = 0,
|
|
72
|
+
method: str = "tvl1",
|
|
73
|
+
extra_cubes: Optional[np.ndarray] = None,
|
|
74
|
+
verbose: bool = False,
|
|
75
|
+
):
|
|
76
|
+
"""Stabilise a frame sequence with optical-flow warping.
|
|
77
|
+
|
|
78
|
+
Parameters
|
|
79
|
+
----------
|
|
80
|
+
cubes : ndarray (nframes, nchannels, H, W)
|
|
81
|
+
Science cubes to warp (e.g. the Hα stack). Stays float32.
|
|
82
|
+
reference : ndarray (nframes, H, W) or None, optional
|
|
83
|
+
Per-frame flare-free image used to *compute* the flow. If None, the last
|
|
84
|
+
channel of ``cubes`` is used. Pass the Fe I last wavelength here to
|
|
85
|
+
avoid stabilising the eruption away.
|
|
86
|
+
ref_idx : int, optional
|
|
87
|
+
Index of the reference frame everything is aligned to.
|
|
88
|
+
method : {"tvl1", "farneback"}, optional
|
|
89
|
+
Optical-flow algorithm (TV-L1 falls back to Farneback if unavailable).
|
|
90
|
+
extra_cubes : ndarray (nframes, nchannels2, H, W) or None, optional
|
|
91
|
+
A second stack (e.g. Fe I) warped with the *same* per-frame map.
|
|
92
|
+
verbose : bool, optional
|
|
93
|
+
|
|
94
|
+
Returns
|
|
95
|
+
-------
|
|
96
|
+
aligned : ndarray, same shape as ``cubes``
|
|
97
|
+
aligned_extra : ndarray or None
|
|
98
|
+
Present only when ``extra_cubes`` is given.
|
|
99
|
+
"""
|
|
100
|
+
import cv2 # noqa: F401 (ensure available early)
|
|
101
|
+
|
|
102
|
+
nframes, nch, H, W = cubes.shape
|
|
103
|
+
if reference is None:
|
|
104
|
+
reference = cubes[:, -1, :, :]
|
|
105
|
+
|
|
106
|
+
solver = create_optical_flow_solver() if method == "tvl1" else None
|
|
107
|
+
ref_u8 = _to_u8(reference[ref_idx])
|
|
108
|
+
|
|
109
|
+
X, Y = np.meshgrid(np.arange(W, dtype=np.float32), np.arange(H, dtype=np.float32))
|
|
110
|
+
|
|
111
|
+
aligned = np.empty_like(cubes)
|
|
112
|
+
aligned_extra = np.empty_like(extra_cubes) if extra_cubes is not None else None
|
|
113
|
+
|
|
114
|
+
for i in range(nframes):
|
|
115
|
+
cur_u8 = _to_u8(reference[i])
|
|
116
|
+
flow = calculate_optical_flow(ref_u8, cur_u8, solver)
|
|
117
|
+
map_x = (X + flow[..., 0]).astype(np.float32)
|
|
118
|
+
map_y = (Y + flow[..., 1]).astype(np.float32)
|
|
119
|
+
aligned[i] = warp_cube(cubes[i], map_x, map_y)
|
|
120
|
+
if aligned_extra is not None:
|
|
121
|
+
aligned_extra[i] = warp_cube(extra_cubes[i], map_x, map_y)
|
|
122
|
+
if verbose:
|
|
123
|
+
print(f" optical-flow frame {i}/{nframes - 1} aligned")
|
|
124
|
+
|
|
125
|
+
if aligned_extra is not None:
|
|
126
|
+
return aligned, aligned_extra
|
|
127
|
+
return aligned
|