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 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
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
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}"