corona3d 0.1.2__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.
- corona3d/__init__.py +23 -0
- corona3d/__main__.py +3 -0
- corona3d/_extras.py +37 -0
- corona3d/animate.py +223 -0
- corona3d/api.py +176 -0
- corona3d/bursts.py +110 -0
- corona3d/cli.py +483 -0
- corona3d/default_config.json +143 -0
- corona3d/doctor.py +75 -0
- corona3d/euv.py +168 -0
- corona3d/events/2024-05-09.json +22 -0
- corona3d/events.py +81 -0
- corona3d/exporting.py +220 -0
- corona3d/fetch.py +147 -0
- corona3d/gcs.py +840 -0
- corona3d/geometry.py +138 -0
- corona3d/heights.py +221 -0
- corona3d/layers.py +5 -0
- corona3d/mast_model.py +84 -0
- corona3d/nrh.py +145 -0
- corona3d/ovro_lwa.py +222 -0
- corona3d/paths.py +122 -0
- corona3d/pb.py +598 -0
- corona3d/pipeline.py +589 -0
- corona3d/radio.py +118 -0
- corona3d/reconstruction.py +705 -0
- corona3d/report_sections.py +22 -0
- corona3d/scattering.py +126 -0
- corona3d/shock.py +870 -0
- corona3d/shockphys.py +490 -0
- corona3d/solo.py +183 -0
- corona3d/sources.py +216 -0
- corona3d/spectra.py +768 -0
- corona3d/whitelight.py +172 -0
- corona3d-0.1.2.dist-info/METADATA +216 -0
- corona3d-0.1.2.dist-info/RECORD +40 -0
- corona3d-0.1.2.dist-info/WHEEL +5 -0
- corona3d-0.1.2.dist-info/entry_points.txt +2 -0
- corona3d-0.1.2.dist-info/licenses/LICENSE +21 -0
- corona3d-0.1.2.dist-info/top_level.txt +1 -0
corona3d/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""corona3d: physics-based, reproducible three-dimensional reconstruction of the solar corona,
|
|
2
|
+
coronal mass ejections, and radio sources for any event from public MHD, coronagraph, and radio
|
|
3
|
+
data, with its own field-line tracer, a parameter-free selection of the streamer loops, an automated
|
|
4
|
+
GCS fit with a stated quality, and a record of what was measured and what was assumed."""
|
|
5
|
+
__version__ = "0.1.2"
|
|
6
|
+
|
|
7
|
+
from .layers import LAYERS, ON # noqa: E402
|
|
8
|
+
from .paths import config as load_config # noqa: E402
|
|
9
|
+
|
|
10
|
+
__all__ = ["LAYERS", "ON", "make_figure", "reconstruct", "newkirk_r", "load_config", "build", "export",
|
|
11
|
+
"Reconstruction", "__version__"]
|
|
12
|
+
|
|
13
|
+
_LAZY = {"make_figure": "reconstruction", "reconstruct": "reconstruction", "newkirk_r": "reconstruction",
|
|
14
|
+
"build": "api", "Reconstruction": "api", "export": "exporting"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def __getattr__(name):
|
|
18
|
+
"""The functions that need PyVista, SunPy, or VTK are imported only when first used."""
|
|
19
|
+
if name in _LAZY:
|
|
20
|
+
from importlib import import_module
|
|
21
|
+
mod = import_module(f".{_LAZY[name]}", __name__)
|
|
22
|
+
return getattr(mod, name)
|
|
23
|
+
raise AttributeError(name)
|
corona3d/__main__.py
ADDED
corona3d/_extras.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Optional dependencies: each heavy package is imported where it is first needed, and a missing one
|
|
2
|
+
stops with the name of the extra that installs it (`pip install "corona3d[render]"`)."""
|
|
3
|
+
from importlib import import_module
|
|
4
|
+
|
|
5
|
+
EXTRAS = { # extra: the packages (import name, pip name) it installs
|
|
6
|
+
"render": [("pyvista", "pyvista"), ("PyThea", "PyThea")],
|
|
7
|
+
"gcs": [("PyThea", "PyThea"), ("skimage", "scikit-image")],
|
|
8
|
+
"radio": [("h5py", "h5py")],
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MissingExtra(ImportError):
|
|
13
|
+
"""An optional package is not installed; the message names the extra."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def need(extra, module, what):
|
|
17
|
+
"""Import `module` for `what`; on failure raise MissingExtra naming the extra to install."""
|
|
18
|
+
try:
|
|
19
|
+
return import_module(module)
|
|
20
|
+
except ImportError as e:
|
|
21
|
+
raise MissingExtra(f"{what} needs the package {module}, which is not installed. "
|
|
22
|
+
f"Install it with: pip install \"corona3d[{extra}]\" (or corona3d[all])") from e
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def status():
|
|
26
|
+
"""{extra: {package: version or None}} for the optional extras."""
|
|
27
|
+
from importlib import metadata
|
|
28
|
+
out = {}
|
|
29
|
+
for extra, pk in EXTRAS.items():
|
|
30
|
+
out[extra] = {}
|
|
31
|
+
for mod, pip in pk:
|
|
32
|
+
try:
|
|
33
|
+
import_module(mod)
|
|
34
|
+
out[extra][pip] = metadata.version(pip)
|
|
35
|
+
except Exception:
|
|
36
|
+
out[extra][pip] = None
|
|
37
|
+
return out
|
corona3d/animate.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Animations of the reconstruction (`corona3d animate`): one frame per time, as a GIF and an MP4.
|
|
2
|
+
|
|
3
|
+
Every frame is the figure of `corona3d render` at the frame time: the shell has the apex height that a height
|
|
4
|
+
law gives for that time, and the radio sources accumulate up to it. The model surface and the field lines are
|
|
5
|
+
computed once, the cameras, the colour limits, the panel scale, and the radio time range are fixed for the whole
|
|
6
|
+
animation, and the frame time is written on the figure. A GIF holds 256 colours, so one palette is built for all
|
|
7
|
+
frames (`shared_palette`).
|
|
8
|
+
"""
|
|
9
|
+
import csv
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
from datetime import timedelta
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
R_SUN_KM = 695700.0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def parse_height_points(text):
|
|
21
|
+
"""'2013-10-25T15:00=1.931,2013-10-25T15:24=5.538' -> [(datetime, height [R_sun])]."""
|
|
22
|
+
from .whitelight import utc
|
|
23
|
+
try:
|
|
24
|
+
return [(utc(t), float(h)) for t, h in (x.rsplit("=", 1) for x in text.split(","))]
|
|
25
|
+
except ValueError:
|
|
26
|
+
raise ValueError(f"{text!r}: give TIME=HEIGHT pairs, e.g. 2013-10-25T15:00=1.931,2013-10-25T15:24=5.538")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def read_heights(path):
|
|
30
|
+
"""Measured heights from a CSV file of two columns, time (ISO) and apex height [R_sun]; a header line and
|
|
31
|
+
lines starting with # are skipped."""
|
|
32
|
+
from .whitelight import utc
|
|
33
|
+
pts = []
|
|
34
|
+
for row in csv.reader(l for l in Path(path).read_text(encoding="utf-8").splitlines() if l.strip() and l[0] != "#"):
|
|
35
|
+
try:
|
|
36
|
+
pts.append((utc(row[0].strip()), float(row[1])))
|
|
37
|
+
except (ValueError, IndexError):
|
|
38
|
+
if pts:
|
|
39
|
+
raise ValueError(f"{path}: cannot read the line {row}") # only the header may be unreadable
|
|
40
|
+
if len(pts) < 2:
|
|
41
|
+
raise ValueError(f"{path}: at least two (time, height) lines are needed")
|
|
42
|
+
return sorted(pts)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def height_law(p, fits=None, speed_km_s=None, anchor=None, table=None):
|
|
46
|
+
"""Function from a time (datetime) to the apex height of the shell [R_sun].
|
|
47
|
+
|
|
48
|
+
table: measured (time, height) points (EUV or Solar Orbiter), interpolated linearly, valid only between the
|
|
49
|
+
first and the last of them. speed_km_s: constant speed from anchor, one (time, height) point (default: the
|
|
50
|
+
shell of the configuration at its time). fits: two (time, height) points, for example the two GCS fits, with
|
|
51
|
+
a straight line through them (also beyond them). Default: the line through the shell `gcs` at the time of
|
|
52
|
+
the reconstruction and the shell `gcs_wl` at its own time, the two fits of the configuration."""
|
|
53
|
+
from .whitelight import utc
|
|
54
|
+
if table:
|
|
55
|
+
t, h = zip(*sorted(table))
|
|
56
|
+
s = np.array([(x - t[0]).total_seconds() for x in t])
|
|
57
|
+
def law(time):
|
|
58
|
+
x = (time - t[0]).total_seconds()
|
|
59
|
+
if not s[0] <= x <= s[-1]:
|
|
60
|
+
raise ValueError(f"{time:%Y-%m-%d %H:%M:%S} UT is outside the measured heights ({t[0]:%H:%M:%S} to "
|
|
61
|
+
f"{t[-1]:%H:%M:%S} UT); change --start and --end, or use a line or a speed")
|
|
62
|
+
return float(np.interp(x, s, h))
|
|
63
|
+
return law
|
|
64
|
+
if speed_km_s is not None:
|
|
65
|
+
t0, h0 = anchor or (utc((p.get("gcs_fit") or {}).get("t_out") or p["time"]), p["gcs"]["height"])
|
|
66
|
+
return lambda time: h0 + speed_km_s * (time - t0).total_seconds() / R_SUN_KM
|
|
67
|
+
if fits is None:
|
|
68
|
+
g, w = p["gcs"], p.get("gcs_wl")
|
|
69
|
+
if not w or "time" not in w:
|
|
70
|
+
raise ValueError("the configuration has one shell only: give --height-fits, --speed, or --heights")
|
|
71
|
+
fits = [(utc((p.get("gcs_fit") or {}).get("t_out") or p["time"]), g["height"]), (utc(w["time"]), w["height"])]
|
|
72
|
+
(t0, h0), (t1, h1) = fits
|
|
73
|
+
if t0 == t1:
|
|
74
|
+
raise ValueError("the two fits are at the same time")
|
|
75
|
+
return lambda time: h0 + (h1 - h0) * (time - t0).total_seconds() / (t1 - t0).total_seconds()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def frame_times(start, end, step_s):
|
|
79
|
+
"""Times from start to end (inclusive) in steps of step_s seconds."""
|
|
80
|
+
if step_s <= 0 or end < start:
|
|
81
|
+
raise ValueError("give --step above zero and --end not before --start")
|
|
82
|
+
n = int((end - start).total_seconds() // step_s)
|
|
83
|
+
return [start + timedelta(seconds=step_s * i) for i in range(n + 1)]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def reserved_colours(p, n_max=200):
|
|
87
|
+
"""The colours that every frame needs exactly, as uint8 (n x 3): the v_A colour map (Spectral_r, 40 steps)
|
|
88
|
+
and two copies in the shade of the surface (0.75 and 0.5 of the brightness), the light-to-dark ramp of every
|
|
89
|
+
radio colour (12 steps), the shell colour, the purple of the streamer lines, black, and white."""
|
|
90
|
+
from matplotlib import colormaps, colors
|
|
91
|
+
from .reconstruction import chain_colours
|
|
92
|
+
sp = colormaps["Spectral_r"](np.linspace(0, 1, 40))[:, :3]
|
|
93
|
+
single = np.array([colors.to_rgb(c) for c in (p["mesh_rings"]["color"], "#9a6fc0", "#000000", "#ffffff")])
|
|
94
|
+
base = p.get("radio_colors") or []
|
|
95
|
+
n_ramp = min(12, max(2, (n_max - 120 - len(single)) // max(len(base), 1)))
|
|
96
|
+
ramps = [chain_colours(c, np.linspace(0, 1, n_ramp)) for c in base]
|
|
97
|
+
return (np.vstack([sp, sp * 0.75, sp * 0.5, *ramps, single]) * 255).round().astype(np.uint8)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def shared_palette(frames, reserved, n_total=256):
|
|
101
|
+
"""One 256-colour palette image for all frames: the reserved colours, then a median cut of the frames for
|
|
102
|
+
the rest. Mostly grey frames would make a palette of each frame spend its colours on greys and shift the
|
|
103
|
+
v_A and radio colours from frame to frame."""
|
|
104
|
+
from PIL import Image
|
|
105
|
+
n_rest = n_total - len(reserved)
|
|
106
|
+
sample = np.vstack([f[::2, ::2] if f.shape[0] * f.shape[1] > 10**6 else f for f in frames])
|
|
107
|
+
both = Image.fromarray(np.ascontiguousarray(sample).reshape(-1, sample.shape[1], 3))
|
|
108
|
+
rest = both.quantize(colors=n_rest, method=Image.Quantize.MEDIANCUT, dither=Image.Dither.NONE).convert("RGB")
|
|
109
|
+
rest = np.unique(np.asarray(rest).reshape(-1, 3), axis=0)[:n_rest]
|
|
110
|
+
pal = np.vstack([reserved, rest])
|
|
111
|
+
pimg = Image.new("P", (1, 1))
|
|
112
|
+
pimg.putpalette(list(pal.ravel()) + [0] * (768 - pal.size))
|
|
113
|
+
return pimg
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def save_gif(frames, path, fps, reserved, scale=0.5):
|
|
117
|
+
"""Write the RGB frames (uint8 arrays) as a GIF with one shared palette and no dithering."""
|
|
118
|
+
from PIL import Image
|
|
119
|
+
imgs = [Image.fromarray(f) for f in frames]
|
|
120
|
+
if scale != 1:
|
|
121
|
+
imgs = [i.resize((max(1, round(i.width * scale)), max(1, round(i.height * scale))), Image.Resampling.LANCZOS)
|
|
122
|
+
for i in imgs]
|
|
123
|
+
pimg = shared_palette([np.asarray(i) for i in imgs], reserved)
|
|
124
|
+
q = [i.quantize(palette=pimg, dither=Image.Dither.NONE) for i in imgs]
|
|
125
|
+
q[0].save(path, save_all=True, append_images=q[1:], duration=round(1000 / fps), loop=0, optimize=False)
|
|
126
|
+
return Path(path)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def ffmpeg_exe():
|
|
130
|
+
"""The ffmpeg of the package imageio-ffmpeg, or else the one on the path."""
|
|
131
|
+
try:
|
|
132
|
+
import imageio_ffmpeg
|
|
133
|
+
return imageio_ffmpeg.get_ffmpeg_exe()
|
|
134
|
+
except Exception:
|
|
135
|
+
exe = shutil.which("ffmpeg")
|
|
136
|
+
if exe:
|
|
137
|
+
return exe
|
|
138
|
+
raise RuntimeError("an MP4 needs ffmpeg: install it with pip install \"corona3d[animate]\" (the package "
|
|
139
|
+
"imageio-ffmpeg) or put ffmpeg on the path")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def save_mp4(frames, path, fps):
|
|
143
|
+
"""Write the RGB frames as an H.264 MP4 (libx264, yuv420p; the size is cut to even numbers)."""
|
|
144
|
+
h, w = frames[0].shape[0] // 2 * 2, frames[0].shape[1] // 2 * 2
|
|
145
|
+
cmd = [ffmpeg_exe(), "-y", "-loglevel", "error", "-f", "rawvideo", "-pix_fmt", "rgb24", "-s", f"{w}x{h}",
|
|
146
|
+
"-r", str(fps), "-i", "-", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "18",
|
|
147
|
+
"-movflags", "+faststart", str(path)]
|
|
148
|
+
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
149
|
+
try:
|
|
150
|
+
for f in frames:
|
|
151
|
+
proc.stdin.write(np.ascontiguousarray(f[:h, :w]).tobytes())
|
|
152
|
+
proc.stdin.close()
|
|
153
|
+
err = proc.stderr.read().decode(errors="replace")
|
|
154
|
+
finally:
|
|
155
|
+
proc.wait()
|
|
156
|
+
if proc.returncode:
|
|
157
|
+
raise RuntimeError(f"ffmpeg failed: {err[-500:]}")
|
|
158
|
+
return Path(path)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def animate(out="figures/corona3d_animation", data_root=".", start=None, end=None, step_s=None, fps=2.0,
|
|
162
|
+
gif=True, mp4=False, half=None, params=None, panels=None, layout="landscape", law=None,
|
|
163
|
+
gif_scale=0.5, **layers):
|
|
164
|
+
"""Render one frame per time from start to end (ISO texts; default: the radio images of the configuration)
|
|
165
|
+
and write out.gif and/or out.mp4 (for layout "separate", one animation per view, out_VIEW.gif).
|
|
166
|
+
law: function from a time to the apex height (height_law; default: the line through the two shells of
|
|
167
|
+
the configuration). The other arguments are those of make_figure. Returns the list of files."""
|
|
168
|
+
from . import reconstruction as rc
|
|
169
|
+
from .layers import ON
|
|
170
|
+
from .paths import config, merge
|
|
171
|
+
from .whitelight import utc
|
|
172
|
+
bad = set(layers) - set(rc.LAYERS)
|
|
173
|
+
if bad:
|
|
174
|
+
raise ValueError(f"unknown layers {sorted(bad)}; choose from {', '.join(rc.LAYERS)}")
|
|
175
|
+
if half is not None and half <= 0:
|
|
176
|
+
raise ValueError("half must be positive")
|
|
177
|
+
on = dict(ON, **layers)
|
|
178
|
+
p0 = merge(config(data_root), params)
|
|
179
|
+
rc.check_opacity(dict(p0["opacity"], **({"wl": p0["wl_opacity"]} if "wl_opacity" in p0 else {})))
|
|
180
|
+
ri = p0["radio_images"] if on["radio"] else []
|
|
181
|
+
if start is None or end is None:
|
|
182
|
+
if not ri:
|
|
183
|
+
raise ValueError("give --start and --end")
|
|
184
|
+
start = start or min((rs["start"] for rs in ri), key=utc)
|
|
185
|
+
end = end or max((rs["end"] for rs in ri), key=utc)
|
|
186
|
+
step_s = step_s or (ri[0]["step_s"] if ri else 60.0)
|
|
187
|
+
times = frame_times(utc(start), utc(end), step_s)
|
|
188
|
+
law = law or height_law(p0)
|
|
189
|
+
rc.select_views(p0, panels)
|
|
190
|
+
out = Path(out) if Path(out).is_absolute() else Path(data_root) / out
|
|
191
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
192
|
+
# one colour scale of the radio time for every frame, from the first radio image to the last frame
|
|
193
|
+
rng = p0.get("radio_time_range") or ([min((rs["start"] for rs in ri), key=utc), times[-1].isoformat()] if ri else None)
|
|
194
|
+
cache, frames = {}, []
|
|
195
|
+
for t in times: # pass 1: the layers of every frame
|
|
196
|
+
iso = t.isoformat()
|
|
197
|
+
h = law(t)
|
|
198
|
+
if h <= 1.0:
|
|
199
|
+
raise ValueError(f"{iso}: the height law gives {h:.2f} R_sun, below the solar surface")
|
|
200
|
+
pf = dict(p0, time=iso, anim=dict(height=h), radio_time_range=rng, _png_dir=None,
|
|
201
|
+
radio_images=[dict(rs, end=iso) for rs in p0["radio_images"]])
|
|
202
|
+
sc = rc.reconstruct(pf, on, data_root, cache)
|
|
203
|
+
sc["times"]["frame"] = iso
|
|
204
|
+
frames.append((pf, sc))
|
|
205
|
+
print(f"{iso[11:19]} UT: shell height {h:.3f} R_sun")
|
|
206
|
+
# a coronagraph image keeps one grey scale: the largest 98th percentile of all frames
|
|
207
|
+
clim = {i: max(np.nanpercentile(np.abs(sc[i]["wl"]), 98) for _, sc in frames) for i in rc.WL if on[i]}
|
|
208
|
+
pix = {}
|
|
209
|
+
for pf, sc in frames: # pass 2: the panels, with fixed cameras and limits
|
|
210
|
+
pf["wl_clim"] = clim
|
|
211
|
+
for key, fig in rc.compose(sc, on, pf, half, panels, layout):
|
|
212
|
+
pix.setdefault(key, []).append(rc.fig_array(fig))
|
|
213
|
+
res = reserved_colours(p0)
|
|
214
|
+
files = []
|
|
215
|
+
for key, arrs in pix.items():
|
|
216
|
+
base = out if key is None else out.with_name(f"{out.name}_{key}")
|
|
217
|
+
if gif or not mp4:
|
|
218
|
+
files.append(save_gif(arrs, base.with_name(base.name + ".gif"), fps, res, gif_scale))
|
|
219
|
+
if mp4:
|
|
220
|
+
files.append(save_mp4(arrs, base.with_name(base.name + ".mp4"), fps))
|
|
221
|
+
for f in files:
|
|
222
|
+
print("saved", f)
|
|
223
|
+
return files
|
corona3d/api.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Analysis without the figure: every layer of the reconstruction as arrays.
|
|
2
|
+
|
|
3
|
+
``build`` reads the working directory's configuration and returns a :class:`Reconstruction`,
|
|
4
|
+
which holds the density surface, the field lines, the CME shell, the radio sources, and the
|
|
5
|
+
observers as NumPy arrays. The layers are those of ``corona3d render`` (they are built by the
|
|
6
|
+
same code), and ``corona3d.exporting`` writes them to files.
|
|
7
|
+
|
|
8
|
+
Coordinate frame and units of every position: Heliographic Carrington Cartesian, centred on the
|
|
9
|
+
Sun, X towards Carrington longitude 0, Z towards solar north, in solar radii (R_sun). The frame
|
|
10
|
+
rotates with the Sun; the observers are given by their Carrington longitude and latitude at the
|
|
11
|
+
time of the reconstruction. Plane-of-sky positions (``x_rsun``, ``y_rsun``) are seen from Earth,
|
|
12
|
+
x towards the west and y towards solar north.
|
|
13
|
+
"""
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
from .layers import LAYERS, ON
|
|
19
|
+
from .paths import config as load_config
|
|
20
|
+
|
|
21
|
+
__all__ = ["Reconstruction", "build", "load_config", "carrington_angles", "FRAME", "UNITS"]
|
|
22
|
+
|
|
23
|
+
FRAME = "Heliographic Carrington Cartesian, Sun-centred, X to longitude 0, Z to solar north"
|
|
24
|
+
UNITS = {"position": "R_sun", "angle": "deg", "va": "km/s", "ne": "cm^-3", "tb": "K"}
|
|
25
|
+
RADIO_COLUMNS = ("instrument", "freq_mhz", "time_utc", "x_rsun", "y_rsun", "x_arcsec", "y_arcsec", "tb_k",
|
|
26
|
+
"r_rsun", "height_rsun", "x", "y", "z", "lon_carrington_deg", "lat_deg",
|
|
27
|
+
"lon_stonyhurst_deg")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Reconstruction:
|
|
32
|
+
"""The layers of one reconstruction (``None`` or empty when a layer is switched off).
|
|
33
|
+
|
|
34
|
+
Attributes
|
|
35
|
+
----------
|
|
36
|
+
config : dict
|
|
37
|
+
The configuration used (defaults, config.json, and the parameters passed to ``build``).
|
|
38
|
+
layers : dict
|
|
39
|
+
Layer name -> bool, as used.
|
|
40
|
+
surface : dict or None
|
|
41
|
+
``vertices`` (n, 3) [R_sun, Carrington Cartesian], ``faces`` (m, 3) indices of triangles,
|
|
42
|
+
``va_kms`` (n,) Alfven speed [km/s] and ``ne_cm3`` (n,) electron density [cm^-3] per
|
|
43
|
+
vertex (the iso-surface of ``ne_level``, rotated by ``model_dlon``).
|
|
44
|
+
lines : dict
|
|
45
|
+
``open``, ``closed``, ``streamer``: lists of (k, 3) arrays [R_sun, Carrington Cartesian],
|
|
46
|
+
drawn within ``line_r_max``. The streamer lines are the closed lines through the CME shell.
|
|
47
|
+
gcs : dict
|
|
48
|
+
Name (``time``, and ``image`` when ``gcs_wl`` is configured) -> dict with ``time`` (ISO)
|
|
49
|
+
and ``rings`` (n_axis, n_circle, 3) [R_sun, Carrington Cartesian].
|
|
50
|
+
radio : dict
|
|
51
|
+
Columns ``RADIO_COLUMNS`` as equal-length arrays, one row per source position. Rows of
|
|
52
|
+
``radio_sources`` of the configuration have ``tb_k`` = NaN and an empty time.
|
|
53
|
+
observers : dict
|
|
54
|
+
``earth`` and ``stereo`` -> ``lon_deg``, ``lat_deg`` (Carrington) and ``direction``
|
|
55
|
+
(unit vector from Sun centre towards the observer, Carrington Cartesian).
|
|
56
|
+
whitelight : dict
|
|
57
|
+
Instrument -> ``points`` (n, 3) [R_sun], ``wl`` (n,), ``dims`` (3,), ``time``, ``base_time``.
|
|
58
|
+
times : dict
|
|
59
|
+
Times used by the layers, as returned by ``reconstruct``.
|
|
60
|
+
"""
|
|
61
|
+
config: dict
|
|
62
|
+
layers: dict
|
|
63
|
+
surface: dict = None
|
|
64
|
+
lines: dict = field(default_factory=lambda: dict(open=[], closed=[], streamer=[]))
|
|
65
|
+
gcs: dict = field(default_factory=dict)
|
|
66
|
+
radio: dict = field(default_factory=dict)
|
|
67
|
+
observers: dict = field(default_factory=dict)
|
|
68
|
+
whitelight: dict = field(default_factory=dict)
|
|
69
|
+
times: dict = field(default_factory=dict)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def carrington_angles(xyz):
|
|
73
|
+
"""Carrington longitude and latitude of points.
|
|
74
|
+
|
|
75
|
+
Parameters
|
|
76
|
+
----------
|
|
77
|
+
xyz : array_like, shape (n, 3)
|
|
78
|
+
Positions [R_sun], Carrington Cartesian.
|
|
79
|
+
|
|
80
|
+
Returns
|
|
81
|
+
-------
|
|
82
|
+
lon, lat, r : ndarray, shape (n,)
|
|
83
|
+
Longitude in [0, 360) deg, latitude in deg, distance from Sun centre in R_sun.
|
|
84
|
+
"""
|
|
85
|
+
xyz = np.atleast_2d(xyz)
|
|
86
|
+
r = np.linalg.norm(xyz, axis=1)
|
|
87
|
+
return (np.degrees(np.arctan2(xyz[:, 1], xyz[:, 0])) % 360.0,
|
|
88
|
+
np.degrees(np.arcsin(np.clip(xyz[:, 2] / r, -1, 1))), r)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _observers(p):
|
|
92
|
+
from .geometry import unit_vec
|
|
93
|
+
return {k: dict(lon_deg=float(p[k][0]), lat_deg=float(p[k][1]), direction=unit_vec(*p[k]))
|
|
94
|
+
for k in ("earth", "stereo")}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _radio_table(sc, p):
|
|
98
|
+
"""Radio positions with their sky-plane and Carrington coordinates (radio_sources first)."""
|
|
99
|
+
from astropy import units as u
|
|
100
|
+
from sunpy.coordinates import sun
|
|
101
|
+
rows = []
|
|
102
|
+
rs = sun.angular_radius(p["time"]).to_value(u.arcsec)
|
|
103
|
+
for s, (f, xyz, _) in zip(p["radio_sources"], sc.get("radio", [])[:len(p["radio_sources"])]):
|
|
104
|
+
k = 1.0 / rs if s.get("unit") == "arcsec" else 1.0
|
|
105
|
+
for x, y, q in zip(np.atleast_1d(s["x"]) * k, np.atleast_1d(s["y"]) * k, xyz):
|
|
106
|
+
rows.append(("user", f, "", x, y, np.nan, q))
|
|
107
|
+
for b in sc.get("radio_blobs", []):
|
|
108
|
+
for t, x, y, tb, q in zip(b["time"], b["x"], b["y"], b["tb"], b["xyz"]):
|
|
109
|
+
rows.append((b["instrument"], b["freq"], t.isoformat(), x, y, tb, q))
|
|
110
|
+
cols = {c: [] for c in RADIO_COLUMNS}
|
|
111
|
+
if rows:
|
|
112
|
+
xyz = np.array([r[6] for r in rows])
|
|
113
|
+
lon, lat, r = carrington_angles(xyz)
|
|
114
|
+
for (inst, f, t, x, y, tb, _), q, lo, la, rr in zip(rows, xyz, lon, lat, r):
|
|
115
|
+
vals = (inst, f, t, x, y, x * rs, y * rs, tb, rr, rr - 1.0, *q, lo, la,
|
|
116
|
+
(lo - p["earth"][0] + 180.0) % 360.0 - 180.0)
|
|
117
|
+
for c, v in zip(RADIO_COLUMNS, vals):
|
|
118
|
+
cols[c].append(v)
|
|
119
|
+
return {c: np.array(v, dtype=str if c in ("instrument", "time_utc") else float) for c, v in cols.items()}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def build(home=".", params=None, **layers):
|
|
123
|
+
"""Build the reconstruction of a working directory and return its layers as arrays.
|
|
124
|
+
|
|
125
|
+
Parameters
|
|
126
|
+
----------
|
|
127
|
+
home : str or Path
|
|
128
|
+
Working directory (its ``config.json`` is read; the data are in ``data/`` below it).
|
|
129
|
+
params : dict, optional
|
|
130
|
+
Configuration keys that replace those of ``config.json`` (nested keys may be partial).
|
|
131
|
+
**layers : bool
|
|
132
|
+
Any of ``LAYERS`` as True or False; the defaults are those of ``corona3d render``
|
|
133
|
+
(the coronagraph layers and ``radio`` are off). ``sphere`` has no data and is ignored.
|
|
134
|
+
|
|
135
|
+
Returns
|
|
136
|
+
-------
|
|
137
|
+
Reconstruction
|
|
138
|
+
Positions in R_sun (Carrington Cartesian), Alfven speed in km/s, density in cm^-3,
|
|
139
|
+
angles in degrees; see :class:`Reconstruction` for the shapes.
|
|
140
|
+
|
|
141
|
+
Raises
|
|
142
|
+
------
|
|
143
|
+
ValueError
|
|
144
|
+
For a layer name that is not in ``LAYERS``.
|
|
145
|
+
FileNotFoundError
|
|
146
|
+
When a layer needs data that are missing (``corona3d fetch``).
|
|
147
|
+
"""
|
|
148
|
+
from . import reconstruction as rc
|
|
149
|
+
from .paths import merge
|
|
150
|
+
bad = set(layers) - set(LAYERS)
|
|
151
|
+
if bad:
|
|
152
|
+
raise ValueError(f"unknown layers {sorted(bad)}; choose from {', '.join(LAYERS)}")
|
|
153
|
+
on = dict(ON, **layers)
|
|
154
|
+
p = merge(load_config(home), params)
|
|
155
|
+
sc = rc.reconstruct(p, dict(on, gcs=False), home)
|
|
156
|
+
rec = Reconstruction(config=p, layers=on, times=sc["times"], observers=_observers(p))
|
|
157
|
+
if "surface" in sc:
|
|
158
|
+
s = sc["surface"]
|
|
159
|
+
rec.surface = dict(vertices=np.asarray(s.points, float), faces=s.faces.reshape(-1, 4)[:, 1:].copy(),
|
|
160
|
+
va_kms=np.asarray(s["va"], float), ne_cm3=np.asarray(s["ne"], float))
|
|
161
|
+
for k in rec.lines:
|
|
162
|
+
rec.lines[k] = sc.get(k, [])
|
|
163
|
+
if on["gcs"]:
|
|
164
|
+
rec.gcs["time"] = dict(time=p["time"], rings=rc.shell_of(p["gcs"], p, p["time"]))
|
|
165
|
+
if p.get("gcs_wl"):
|
|
166
|
+
t = p["gcs_wl"]["time"]
|
|
167
|
+
rec.gcs["image"] = dict(time=t, rings=rc.shell_of(p["gcs_wl"], p, t))
|
|
168
|
+
if on["radio"]:
|
|
169
|
+
rec.radio = _radio_table(sc, p)
|
|
170
|
+
for inst in rc.WL:
|
|
171
|
+
if on[inst]:
|
|
172
|
+
g = sc[inst]
|
|
173
|
+
rec.whitelight[inst] = dict(points=np.asarray(g.points, float), wl=np.asarray(g["wl"], float),
|
|
174
|
+
dims=np.array(g.dimensions), time=sc["times"][inst][0].isoformat(),
|
|
175
|
+
base_time=sc["times"][inst][1].isoformat())
|
|
176
|
+
return rec
|
corona3d/bursts.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Type II and type III radio bursts of a day from the edited event list of NOAA SWPC.
|
|
2
|
+
|
|
3
|
+
When `corona3d event` is given only a date, the burst times of that day are listed and the user
|
|
4
|
+
chooses one with `--burst N`. The source is the daily file `YYYYMMDDevents.txt` of the SWPC edited
|
|
5
|
+
events (patrol reports of ground radio observatories, code RSP), kept by NOAA NCEI since 1996 and reachable without a login at
|
|
6
|
+
https://www.ngdc.noaa.gov/stp/space-weather/swpc-products/daily_reports/solar_event_reports/ ;
|
|
7
|
+
the recent files are also on ftp://ftp.swpc.noaa.gov/pub/indices/events/ (used as a fallback).
|
|
8
|
+
A line of type RSP gives the begin and end time [UT], the observatory, the frequency range [MHz],
|
|
9
|
+
the burst type (II, III, IV, V, VI, CTM with an intensity class) and, for type II, the shock speed [km/s].
|
|
10
|
+
"""
|
|
11
|
+
import re
|
|
12
|
+
import urllib.request
|
|
13
|
+
from datetime import datetime, timedelta
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
NCEI = ("https://www.ngdc.noaa.gov/stp/space-weather/swpc-products/daily_reports/solar_event_reports/"
|
|
17
|
+
"{y}/{m}/{d}events.txt")
|
|
18
|
+
FTP = "ftp://ftp.swpc.noaa.gov/pub/indices/events/{d}events.txt"
|
|
19
|
+
TYPES = ("II", "III")
|
|
20
|
+
DATE = re.compile(r"\d{4}-\d{2}-\d{2}")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def is_date(text):
|
|
24
|
+
"""True for a date without a time, e.g. 2024-05-09."""
|
|
25
|
+
return bool(DATE.fullmatch(text.strip()))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def parse(text, day):
|
|
29
|
+
"""Burst rows [dict(type, begin, end, obs, freq_mhz, speed_km_s)] of the RSP lines of a SWPC
|
|
30
|
+
event file for the date `day` (datetime.date or 'YYYY-MM-DD'), in time order. A begin or end
|
|
31
|
+
time may carry a flag letter (A, B, D, U) and an end of '////' is missing; the end then is None."""
|
|
32
|
+
day = datetime.fromisoformat(str(day)).date()
|
|
33
|
+
rows = []
|
|
34
|
+
for line in text.splitlines():
|
|
35
|
+
p = line.split()
|
|
36
|
+
if len(p) < 3 or line.startswith(("#", ":")) or "RSP" not in p:
|
|
37
|
+
continue
|
|
38
|
+
i = p.index("RSP")
|
|
39
|
+
if i < 5 or i + 2 >= len(p): # ..., begin, max, end, observatory, quality, RSP, range, type
|
|
40
|
+
continue
|
|
41
|
+
begin, end = (re.sub(r"^[A-Z]", "", p[i - k]) for k in (5, 3))
|
|
42
|
+
if not re.fullmatch(r"\d{4}", begin):
|
|
43
|
+
continue
|
|
44
|
+
midnight = datetime.combine(day, datetime.min.time())
|
|
45
|
+
clock = lambda t: midnight + timedelta(hours=int(t[:2]), minutes=int(t[2:]))
|
|
46
|
+
kind = p[i + 2].split("/")[0]
|
|
47
|
+
speed = float(p[i + 3]) if kind == "II" and len(p) > i + 3 and re.fullmatch(r"\d+(\.\d+)?", p[i + 3]) else None
|
|
48
|
+
t_end = clock(end) if re.fullmatch(r"\d{4}", end) else None
|
|
49
|
+
if t_end is not None and t_end < clock(begin): # the burst crosses midnight
|
|
50
|
+
t_end += timedelta(days=1)
|
|
51
|
+
rows.append(dict(type=kind, begin=clock(begin), end=t_end,
|
|
52
|
+
obs=p[i - 2], freq_mhz=p[i + 1], speed_km_s=speed))
|
|
53
|
+
return sorted(rows, key=lambda r: r["begin"])
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def select(rows, types=TYPES):
|
|
57
|
+
"""The rows whose burst type is in `types` (default II and III)."""
|
|
58
|
+
return [r for r in rows if r["type"] in types]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def fetch(day, home=None, opener=urllib.request.urlopen):
|
|
62
|
+
"""Text of the SWPC event file of `day`, from the cache folder data/bursts of `home` when present;
|
|
63
|
+
otherwise from NCEI and, if that fails, from the SWPC FTP server. Raises RuntimeError with both reasons."""
|
|
64
|
+
day = datetime.fromisoformat(str(day)).date()
|
|
65
|
+
d = f"{day:%Y%m%d}"
|
|
66
|
+
cache = Path(home) / "data" / "bursts" / f"{d}events.txt" if home else None
|
|
67
|
+
if cache and cache.exists():
|
|
68
|
+
return cache.read_text(encoding="utf-8", errors="replace")
|
|
69
|
+
why = []
|
|
70
|
+
for url in (NCEI.format(y=f"{day:%Y}", m=f"{day:%m}", d=d), FTP.format(d=d)):
|
|
71
|
+
try:
|
|
72
|
+
with opener(url, timeout=60) as r:
|
|
73
|
+
text = r.read().decode("utf-8", errors="replace")
|
|
74
|
+
if "Edited Events" not in text:
|
|
75
|
+
raise ValueError("not an event file")
|
|
76
|
+
if cache:
|
|
77
|
+
cache.parent.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
cache.write_text(text, encoding="utf-8")
|
|
79
|
+
return text
|
|
80
|
+
except Exception as e:
|
|
81
|
+
why.append(f"{url}: {e}")
|
|
82
|
+
raise RuntimeError(f"the SWPC event list of {day} could not be fetched ({'; '.join(why)}); "
|
|
83
|
+
"give --time with the time of the burst instead")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def listing(rows):
|
|
87
|
+
"""Numbered text lines of burst rows, for the user to choose from with --burst."""
|
|
88
|
+
out = []
|
|
89
|
+
for n, r in enumerate(rows, 1):
|
|
90
|
+
end = f"{r['end']:%H:%M}" if r["end"] else "----"
|
|
91
|
+
sp = f", shock speed {r['speed_km_s']:g} km/s" if r["speed_km_s"] else ""
|
|
92
|
+
out.append(f" {n:2d} type {r['type']:<3s} {r['begin']:%H:%M} to {end} UT {r['freq_mhz']} MHz {r['obs']}{sp}")
|
|
93
|
+
return out
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def choose(date, burst, home=None, types=TYPES, opener=urllib.request.urlopen, log=print):
|
|
97
|
+
"""Time (ISO text, minutes) of burst number `burst` (1-based) of `date`; with burst None the
|
|
98
|
+
candidates are listed and None is returned."""
|
|
99
|
+
rows = select(parse(fetch(date, home, opener), date), types)
|
|
100
|
+
if not rows:
|
|
101
|
+
raise RuntimeError(f"no type {'/'.join(types)} burst in the SWPC list of {date}; give --time")
|
|
102
|
+
if burst is None:
|
|
103
|
+
log(f"Type {'/'.join(types)} bursts of {date} (SWPC edited events; times are UT):")
|
|
104
|
+
for t in listing(rows):
|
|
105
|
+
log(t)
|
|
106
|
+
log("Choose one with --burst N; its begin time becomes --time (use --time to give another).")
|
|
107
|
+
return None
|
|
108
|
+
if not 1 <= burst <= len(rows):
|
|
109
|
+
raise RuntimeError(f"--burst {burst}: the list of {date} has {len(rows)} bursts")
|
|
110
|
+
return f"{rows[burst - 1]['begin']:%Y-%m-%dT%H:%M}"
|