0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

SN 1987Aの画像といえば、明るい equatorial ring (ER) と、その外側に見える northern outer ring (NOR) / southern outer ring (SOR) が印象的です。

ただ、HSTで見えているのは空に投影された2D imageです。三つのringは3D空間ではどう置かれていて、Earthから見るとなぜあの形になるのでしょうか。

そこで、Larsson et al. (2019) の図2、15を参考に、Pythonで簡単に3D可視化してみました。outer ringsについては Tziamtzis et al. (2011) も参考にしています。

今回の目的は精密な3D reconstructionではなく、ER / NOR / SORの立体配置をざっくりイメージするためのポンチ絵を作ることです。

文献値を参考にしていますが、一部には単純化した配置も含まれます。定量解析には使わず、説明用・理解用の模式図くらいに見てもらえればと思います。

triple ringの配置

Larsson et al. のsimple modelでは、三つのringを3D空間では円形として扱います。

Ring 傾き
ER 43 deg
NOR 45 deg
SOR 38 deg

Earthから見た奥行きでは、NORが奥側、SORが手前側です。ERは北側が手前に来る向きにしています。

3D空間の円は、中心を $\mathbf{r}_0$、半径を $R$ とすれば、例えば

\mathbf{r}(\phi)
=
\mathbf{r}_0
+
R\left(
\mathbf{e}_1\cos\phi
+
\mathbf{e}_2\sin\phi
\right)

のように置けます。

つまり今回やっていることは、3D空間に三つのcircleを置いて、それをEarth方向から眺めるというだけです。

ERのprojected semi-major axisは約 $0.82''$ としました。outer ringsの半径は

R_{\rm NOR}=2.2R_{\rm ER},
\qquad
R_{\rm SOR}=2.3R_{\rm ER}

としています。

また、Larsson et al. のsimple modelを参考に、SNからouter-ring centerまでの3D距離を

d_{\rm NOR}=1.3\times10^{18}\ {\rm cm},
\qquad
d_{\rm SOR}=1.0\times10^{18}\ {\rm cm}

として配置しました。

triple_ring_3d_projection.png

左が3Dで見た配置、右がEarthから見た投影です。

2D画像ではellipseに見えますが、3Dではそれぞれ別の位置にあるringだと考えると、かなりイメージしやすくなります。

なお、左図の実線/破線はringの明るさではなく、奥行きを読みやすくするための表示です。SNを通るsky planeを基準に、Earth側にある部分を実線、奥側にある部分を破線で描いています。NOR(赤)は全体として奥側にあるため、破線になっている部分が多めです。

Larsson et al. の模式図にはERとouter ringsを結ぶ構造も描かれていますが、これは仮説的な構造として置かれたもので、今回の可視化には含めていません。

視点を動かしてみる

せっかく3Dにしたので、視点も少し動かしてみました。

triple_ring_camera.gif

動いているのはringではなく視点です。

斜めから3D配置を眺めたあと、最後にEarthから見た方向へ視点を合わせています。右側はEarthから見た投影に固定しています。

途中で「どっちがEarth側だっけ?」となりやすかったので、$+Z$ をEarth方向として表示し、SNを通る基準面も薄く描いています。

視点を動かしながら見ると、2D投影だけでは少し分かりにくいNOR / SORの奥行きや、ERとの立体的な位置関係が見やすくなります。

まとめ

SN 1987Aのtriple ringをPythonで簡単に3D可視化してみました。

今回見たかったのは、

「この三つのringって、3Dではどう置かれているんだっけ?」

というところです。

論文の模式図だけでも大まかな配置は分かりますが、実際に3Dにして視点を動かしてみると、NOR / SORの奥行きやERとの位置関係がかなりイメージしやすくなりました。

あくまで可視化メモ兼ポンチ絵なので、精密な3D reconstructionや定量解析には使わず、そのくらいの軽い距離感で眺めてもらえればと思います。

完全コード

コード全文はこちら
sn1987a_triple_ring.py
#!/usr/bin/env python3
"""Educational 3D/kinematic-geometric visualization of SN 1987A.

This is not a hydrodynamic simulation and not a complete 3D reconstruction.
The three circular rings follow the simple geometry adopted by Larsson et al.
(2019), with inclinations from Tziamtzis et al. (2011).  The optional straight
surfaces between the equatorial and outer rings are explicitly hypothetical.

Internal physical coordinates are right-handed and use cm:
    +X = East
    +Y = North
    +Z = toward the observer (Earth)

Astronomical display convention is applied only at plotting time: East is left
and North is up.  The observer projection uses the same +Z line of sight drawn
in the 3D panels.

Primary references
------------------
Larsson et al. 2019, ApJ, 886, 147
    https://arxiv.org/abs/1910.09582
    https://doi.org/10.3847/1538-4357/ab4ff2
Tziamtzis et al. 2011, A&A, 527, A35
    https://arxiv.org/abs/1008.3387
    https://doi.org/10.1051/0004-6361/201015576
"""

from __future__ import annotations

import argparse
import json
import math
import shutil
from dataclasses import dataclass
from pathlib import Path

import matplotlib

matplotlib.use("Agg")

import matplotlib.pyplot as plt
import numpy as np
from matplotlib.animation import FFMpegWriter, FuncAnimation, PillowWriter
from matplotlib.lines import Line2D


# =============================================================================
# Parameters and provenance
# =============================================================================

# [A] Literature adopted: Larsson et al. (2019), unless noted otherwise.
DISTANCE_KPC = 49.6
ER_INCLINATION_DEG = 43.0
NOR_INCLINATION_DEG = 45.0
SOR_INCLINATION_DEG = 38.0
ER_RADIUS_ARCSEC = 0.82  # 2018 hotspot semi-major axis
NOR_RADIUS_FACTOR = 2.2  # latest F657N image; simple-model choice
SOR_RADIUS_FACTOR = 2.3  # latest F657N image; simple-model choice
NOR_CENTER_DISTANCE_CM = 1.3e18
SOR_CENTER_DISTANCE_CM = 1.0e18
NOR_WEST_SHIFT_CM = 5.5e16
SOR_WEST_SHIFT_CM = 4.0e16
TARGET_MAJOR_AXIS_TILT_DEG = 7.0

# Near/far constraints from Larsson et al. (2019), with the ER orientation
# checked against Tziamtzis et al. (2011), Fig. 5, and earlier ring literature.
ER_NORTHERN_SIDE_IS_NEAR = True
NOR_CENTER_IS_FAR = True
SOR_CENTER_IS_NEAR = True

# [D] Larsson et al. (2019) hypothetical/simple-model layer.
SHOW_CONNECTING_MATERIAL = False
SHOW_INTERMEDIATE_RINGS = False
EJECTA_EPOCH_DAYS = 11500.0
EJECTA_SPEEDS_KMS = (7000.0, 8000.0, 9000.0)

# [C] Visualization-only settings.
SHOW_EARTH_DEPTH_CUES = True
SKY_PLANE_EXTENT_LY = 2.42
SKY_PLANE_FILL_ALPHA = 0.125
RING_SAMPLES = 720
GENERATOR_COUNT = 42
STATIC_DPI = 180
ANIMATION_DPI = 90
ANIMATION_FPS = 10
ORBIT_FRAMES = 45
ALIGN_FRAMES = 45
HOLD_FRAMES = 30
CAMERA_START_ELEV_DEG = 22.0
CAMERA_START_AZIM_DEG = -55.0
CAMERA_ORBIT_END_AZIM_DEG = -155.0
CAMERA_EARTH_ELEV_DEG = 90.0
CAMERA_EARTH_AZIM_DEG = -90.0
FOV_WIDTH_ARCSEC = 5.0
FOV_HEIGHT_ARCSEC = 5.5

# Restrained colors inspired by the paper's schematic, but not encoding
# brightness, temperature, composition, or measurement certainty.
COLOR_ER = "#777777"
COLOR_NOR = "#C84A44"
COLOR_SOR = "#3D6CA8"
COLOR_SN = "#D39A2C"
COLOR_OBSERVER = "#5B6470"

# Physical constants.
PC_CM = 3.085677581491367e18
LIGHT_YEAR_CM = 9.4607304725808e17
ARCSEC_PER_RADIAN = 206264.80624709636
DAY_S = 86400.0
KM_CM = 1.0e5

# Right-handed physical coordinate basis.
EAST_HAT = np.array([1.0, 0.0, 0.0])
NORTH_HAT = np.array([0.0, 1.0, 0.0])
LOS_HAT = np.array([0.0, 0.0, 1.0])  # SN -> observer
OBSERVER_DIRECTION = LOS_HAT.copy()

SCRIPT_DIR = Path(__file__).resolve().parent
DEFAULT_OUTPUT_DIR = (
    SCRIPT_DIR if SCRIPT_DIR.name == "outputs" else SCRIPT_DIR / "outputs"
)


@dataclass(frozen=True)
class RingGeometry:
    """A genuine circle embedded in 3D physical space."""

    name: str
    color: str
    inclination_deg: float
    radius_cm: float
    center_cm: np.ndarray
    e1: np.ndarray
    e2: np.ndarray
    normal: np.ndarray
    phi: np.ndarray
    points_cm: np.ndarray
    explicit_west_shift_cm: float


@dataclass(frozen=True)
class ModelGeometry:
    rings: dict[str, RingGeometry]
    system_rotation_deg: float
    cm_per_arcsec: float
    intermediate_rings: dict[str, dict[float, np.ndarray]]
    intersection_residuals: dict[str, dict[float, float]]


# =============================================================================
# Coordinate transforms and geometry
# =============================================================================


def rotation_about_north_deg(angle_deg: float) -> np.ndarray:
    """Right-handed active rotation about physical +Y (North)."""

    a = np.deg2rad(angle_deg)
    c, s = np.cos(a), np.sin(a)
    return np.array([[c, 0.0, s], [0.0, 1.0, 0.0], [-s, 0.0, c]])


def angular_scale_cm_per_arcsec(distance_kpc: float = DISTANCE_KPC) -> float:
    distance_cm = distance_kpc * 1.0e3 * PC_CM
    return distance_cm / ARCSEC_PER_RADIAN


def base_ring_frame(inclination_deg: float) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """Return e1, e2 and normal before the small north-axis rotation.

    e1 is initially East.  e2 points toward the northern edge and has positive
    +Z, making the northern edge of each ring the near edge.  The resulting
    normal points toward the southern/near polar direction.
    """

    i = np.deg2rad(inclination_deg)
    e1 = EAST_HAT.copy()
    e2 = np.array([0.0, np.cos(i), np.sin(i)])
    normal = np.cross(e1, e2)
    return e1, e2, normal / np.linalg.norm(normal)


def projected_basis_metrics(e1: np.ndarray, e2: np.ndarray) -> dict[str, float]:
    """Projected semiaxis factors and major-axis angle for a unit circle."""

    basis_on_sky = np.column_stack(
        (
            [np.dot(e1, EAST_HAT), np.dot(e1, NORTH_HAT)],
            [np.dot(e2, EAST_HAT), np.dot(e2, NORTH_HAT)],
        )
    )
    u, singular, _ = np.linalg.svd(basis_on_sky)
    major_vector = u[:, 0]
    if major_vector[0] < 0.0:
        major_vector = -major_vector
    angle = np.degrees(np.arctan2(major_vector[1], major_vector[0]))
    if angle > 90.0:
        angle -= 180.0
    if angle <= -90.0:
        angle += 180.0
    return {
        "major_factor": float(singular[0]),
        "minor_factor": float(singular[1]),
        "axis_ratio": float(singular[1] / singular[0]),
        "major_axis_tilt_deg_east_to_north": float(angle),
    }


def derive_system_rotation_deg(target_tilt_deg: float) -> float:
    """Derive the +Y rotation that produces the requested ER sky tilt.

    Larsson et al. state the rotation axis and the observed approximately 7 deg
    major-axis tilt, but not a matrix sign convention.  The positive solution
    is chosen because it matches their sky figures: the eastern end of the
    projected major axis is north of the western end (East is displayed left).
    """

    e1_0, e2_0, _ = base_ring_frame(ER_INCLINATION_DEG)
    lo, hi = 0.0, 20.0
    for _ in range(80):
        mid = 0.5 * (lo + hi)
        rot = rotation_about_north_deg(mid)
        tilt = projected_basis_metrics(rot @ e1_0, rot @ e2_0)[
            "major_axis_tilt_deg_east_to_north"
        ]
        if tilt < target_tilt_deg:
            lo = mid
        else:
            hi = mid
    return 0.5 * (lo + hi)


def generate_ring(
    name: str,
    color: str,
    inclination_deg: float,
    radius_cm: float,
    center_distance_cm: float,
    center_polar_sign: float,
    west_shift_cm: float,
    system_rotation: np.ndarray,
) -> RingGeometry:
    """Generate r(phi) = center + R(e1 cos(phi) + e2 sin(phi)).

    ``center_polar_sign`` is +1 for the southern/near normal and -1 for
    northern/far.  Locating an outer-ring center along its ring normal is the
    simplest convention consistent with the cited side-view geometry and with
    the reported 3D center distance.  This implementation convention is listed
    as derived, not as an independently observed vector component.
    """

    e1_0, e2_0, normal_0 = base_ring_frame(inclination_deg)
    center_0 = center_polar_sign * center_distance_cm * normal_0
    center = system_rotation @ center_0
    center = center + np.array([-west_shift_cm, 0.0, 0.0])
    e1 = system_rotation @ e1_0
    e2 = system_rotation @ e2_0
    normal = system_rotation @ normal_0
    phi = np.linspace(0.0, 2.0 * np.pi, RING_SAMPLES, endpoint=True)
    points = (
        center[None, :]
        + radius_cm * np.cos(phi)[:, None] * e1[None, :]
        + radius_cm * np.sin(phi)[:, None] * e2[None, :]
    )
    return RingGeometry(
        name=name,
        color=color,
        inclination_deg=inclination_deg,
        radius_cm=radius_cm,
        center_cm=center,
        e1=e1,
        e2=e2,
        normal=normal,
        phi=phi,
        points_cm=points,
        explicit_west_shift_cm=west_shift_cm,
    )


def sphere_generator_intersection(
    inner_points: np.ndarray,
    outer_points: np.ndarray,
    sphere_radius_cm: float,
) -> tuple[np.ndarray, np.ndarray, float]:
    """Intersect every straight ER-to-OR generatrix with a centered sphere."""

    direction = outer_points - inner_points
    qa = np.einsum("ij,ij->i", direction, direction)
    qb = 2.0 * np.einsum("ij,ij->i", inner_points, direction)
    qc = np.einsum("ij,ij->i", inner_points, inner_points) - sphere_radius_cm**2
    disc = np.maximum(qb * qb - 4.0 * qa * qc, 0.0)
    root_a = (-qb - np.sqrt(disc)) / (2.0 * qa)
    root_b = (-qb + np.sqrt(disc)) / (2.0 * qa)
    candidates = np.column_stack((root_a, root_b))
    valid = (candidates >= -1.0e-10) & (candidates <= 1.0 + 1.0e-10)
    selected = np.full(len(inner_points), np.nan)
    for idx in range(len(inner_points)):
        roots = candidates[idx, valid[idx]]
        if roots.size:
            selected[idx] = roots[np.argmin(np.abs(roots))]
    if np.isnan(selected).any():
        missing = int(np.isnan(selected).sum())
        raise RuntimeError(f"Sphere did not intersect {missing} connecting generators")
    selected = np.clip(selected, 0.0, 1.0)
    points = inner_points + selected[:, None] * direction
    residual = np.max(np.abs(np.linalg.norm(points, axis=1) - sphere_radius_cm))
    return points, selected, float(residual / sphere_radius_cm)


def build_model() -> ModelGeometry:
    cm_per_arcsec = angular_scale_cm_per_arcsec()
    er_radius_cm = ER_RADIUS_ARCSEC * cm_per_arcsec
    system_rotation_deg = derive_system_rotation_deg(TARGET_MAJOR_AXIS_TILT_DEG)
    rotation = rotation_about_north_deg(system_rotation_deg)

    rings = {
        "ER": generate_ring(
            "ER",
            COLOR_ER,
            ER_INCLINATION_DEG,
            er_radius_cm,
            0.0,
            0.0,
            0.0,
            rotation,
        ),
        "NOR": generate_ring(
            "NOR",
            COLOR_NOR,
            NOR_INCLINATION_DEG,
            NOR_RADIUS_FACTOR * er_radius_cm,
            NOR_CENTER_DISTANCE_CM,
            -1.0,
            NOR_WEST_SHIFT_CM,
            rotation,
        ),
        "SOR": generate_ring(
            "SOR",
            COLOR_SOR,
            SOR_INCLINATION_DEG,
            SOR_RADIUS_FACTOR * er_radius_cm,
            SOR_CENTER_DISTANCE_CM,
            +1.0,
            SOR_WEST_SHIFT_CM,
            rotation,
        ),
    }

    intermediate: dict[str, dict[float, np.ndarray]] = {"NOR": {}, "SOR": {}}
    residuals: dict[str, dict[float, float]] = {"NOR": {}, "SOR": {}}
    for outer_name in ("NOR", "SOR"):
        for speed_kms in EJECTA_SPEEDS_KMS:
            sphere_radius = speed_kms * KM_CM * EJECTA_EPOCH_DAYS * DAY_S
            points, _, residual = sphere_generator_intersection(
                rings["ER"].points_cm, rings[outer_name].points_cm, sphere_radius
            )
            intermediate[outer_name][speed_kms] = points
            residuals[outer_name][speed_kms] = residual

    return ModelGeometry(
        rings=rings,
        system_rotation_deg=system_rotation_deg,
        cm_per_arcsec=cm_per_arcsec,
        intermediate_rings=intermediate,
        intersection_residuals=residuals,
    )


def project_to_sky_arcsec(points_cm: np.ndarray, cm_per_arcsec: float) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """Orthographic observer projection using the physical basis and +Z LOS."""

    east = points_cm @ EAST_HAT / cm_per_arcsec
    north = points_cm @ NORTH_HAT / cm_per_arcsec
    depth_toward_observer = points_cm @ LOS_HAT / cm_per_arcsec
    return east, north, depth_toward_observer


# =============================================================================
# Plot helpers
# =============================================================================


def to_light_years(points_cm: np.ndarray) -> np.ndarray:
    return points_cm / LIGHT_YEAR_CM


def ease_in_out(value: float) -> float:
    value = float(np.clip(value, 0.0, 1.0))
    return value * value * (3.0 - 2.0 * value)


def format_3d_axes(ax: plt.Axes, limit_ly: float = 2.55, show_labels: bool = True) -> None:
    """Minimal 3D styling; X is inverted only for East-left display."""

    ax.set_xlim(limit_ly, -limit_ly)
    ax.set_ylim(-limit_ly, limit_ly)
    ax.set_zlim(-limit_ly, limit_ly)
    ax.set_box_aspect((1.0, 1.0, 1.0))
    ax.grid(False)
    for axis in (ax.xaxis, ax.yaxis, ax.zaxis):
        axis.pane.set_alpha(0.0)
        axis.line.set_color("#B8BDC5")
    ticks = (-2.0, 0.0, 2.0)
    ax.set_xticks(ticks)
    ax.set_yticks(ticks)
    ax.set_zticks(ticks)
    ax.tick_params(colors="#626A73", labelsize=8, pad=0)
    if show_labels:
        ax.set_xlabel("East (+X) [ly]", labelpad=5, color="#46505A")
        ax.set_ylabel("North (+Y) [ly]", labelpad=5, color="#46505A")
        ax.set_zlabel("toward observer (+Z) [ly]", labelpad=5, color="#46505A")
    else:
        ax.set_axis_off()


def add_coordinate_triad(ax: plt.Axes, length_ly: float = 0.72) -> list:
    artists = []
    origin = np.zeros(3)
    for vector, label, color in (
        (EAST_HAT, "E", "#3F596F"),
        (NORTH_HAT, "N", "#3F596F"),
        (LOS_HAT, "to observer", "#3F596F"),
    ):
        artists.append(
            ax.quiver(
                *origin,
                *(length_ly * vector),
                color=color,
                linewidth=1.25,
                arrow_length_ratio=0.16,
            )
        )
        endpoint = 1.12 * length_ly * vector
        artists.append(ax.text(*endpoint, label, color=color, fontsize=8))
    return artists


def plot_ring_with_earth_depth_cue(ax: plt.Axes, ring: RingGeometry) -> list:
    """Draw a ring using physical Earth depth, never animation-camera depth.

    Points with physical Z >= 0 (toward Earth) are solid.  Points with Z < 0
    (away from Earth) are dashed.  Color and opacity are unchanged, so the cue
    does not encode observed brightness or emissivity.
    """

    points = to_light_years(ring.points_cm)
    near = points.copy()
    far = points.copy()
    near[points[:, 2] < 0.0] = np.nan
    far[points[:, 2] >= 0.0] = np.nan
    artists = []
    artists.extend(
        ax.plot(
            far[:, 0],
            far[:, 1],
            far[:, 2],
            color=ring.color,
            lw=2.65,
            ls=(0, (3.2, 2.2)),
            dash_capstyle="round",
        )
    )
    artists.extend(
        ax.plot(
            near[:, 0],
            near[:, 1],
            near[:, 2],
            color=ring.color,
            lw=2.65,
            ls="solid",
            solid_capstyle="round",
        )
    )
    return artists


def add_sky_plane_and_center_guides(ax: plt.Axes, model: ModelGeometry) -> list:
    """Add visualization-only Earth-depth guides without changing geometry."""

    artists = []
    extent = SKY_PLANE_EXTENT_LY
    plane_fill_color = "#D5DBE0"
    plane_line_color = "#8D98A2"

    # A low-alpha reference floor only: it is not a physical disk or surface.
    plane_x, plane_y = np.meshgrid([-extent, extent], [-extent, extent])
    artists.append(
        ax.plot_surface(
            plane_x,
            plane_y,
            np.zeros_like(plane_x),
            color=plane_fill_color,
            alpha=SKY_PLANE_FILL_ALPHA,
            shade=False,
            linewidth=0.0,
            antialiased=False,
        )
    )

    # Only four interior lines (two in each direction) to reveal perspective.
    for value in (-extent / 3.0, extent / 3.0):
        artists.extend(
            ax.plot(
                [-extent, extent],
                [value, value],
                [0.0, 0.0],
                color=plane_line_color,
                lw=0.58,
                alpha=0.28,
            )
        )
        artists.extend(
            ax.plot(
                [value, value],
                [-extent, extent],
                [0.0, 0.0],
                color=plane_line_color,
                lw=0.58,
                alpha=0.28,
            )
        )

    # A restrained perimeter makes the finite plotting reference explicit.
    border_x = [-extent, extent, extent, -extent, -extent]
    border_y = [-extent, -extent, extent, extent, -extent]
    artists.extend(
        ax.plot(
            border_x,
            border_y,
            np.zeros(5),
            color=plane_line_color,
            lw=0.78,
            alpha=0.44,
        )
    )
    artists.append(
        ax.text(
            0.45 * extent,
            -0.72 * extent,
            0.06,
            "reference sky plane (Z = 0)",
            color="#66717B",
            fontsize=7.2,
            ha="center",
            bbox=dict(boxstyle="round,pad=0.10", facecolor="white", edgecolor="none", alpha=0.64),
        )
    )

    for name, depth_word, label_offset in (
        ("NOR", "far", (0.10, -0.16, -0.16)),
        ("SOR", "near", (-0.34, 0.12, 0.16)),
    ):
        ring = model.rings[name]
        center = to_light_years(ring.center_cm)
        artists.extend(
            ax.plot(
                [center[0], center[0]],
                [center[1], center[1]],
                [0.0, center[2]],
                color=ring.color,
                lw=1.30,
                ls=(0, (2.4, 2.0)),
                alpha=0.88,
            )
        )
        artists.append(
            ax.scatter(
                [center[0]],
                [center[1]],
                [center[2]],
                s=28,
                facecolor="white",
                edgecolor=ring.color,
                linewidth=1.0,
                depthshade=False,
            )
        )
        artists.append(
            ax.scatter(
                [center[0]],
                [center[1]],
                [0.0],
                marker="o",
                s=20,
                facecolor="white",
                edgecolor=ring.color,
                linewidth=1.05,
                alpha=0.90,
                depthshade=False,
            )
        )
        artists.append(
            ax.text(
                center[0] + label_offset[0],
                center[1] + label_offset[1],
                center[2] + label_offset[2],
                f"{name} · {depth_word}",
                color=ring.color,
                fontsize=7.4,
                bbox=dict(boxstyle="round,pad=0.12", facecolor="white", edgecolor="none", alpha=0.76),
            )
        )
    return artists


def add_fixed_earth_depth_annotations(ax: plt.Axes, style_note_y: float = 0.025) -> list:
    """Fixed panel notes that separate physical Earth depth from camera view."""

    direction_note = ax.text2D(
        0.98,
        0.955,
        "Physical Z (camera-independent)\n"
        "+Z  toward Earth / near\n"
        "-Z  away from Earth / far",
        transform=ax.transAxes,
        ha="right",
        va="top",
        fontsize=6.8,
        color="#3F4851",
        linespacing=1.25,
        bbox=dict(boxstyle="round,pad=0.28", facecolor="white", edgecolor="#D7DCE1", lw=0.55, alpha=0.90),
    )
    style_note = ax.text2D(
        0.02,
        style_note_y,
        "solid / dashed = Earth-depth cue only\n(not brightness)",
        transform=ax.transAxes,
        ha="left",
        va="bottom",
        fontsize=7.1,
        color="#58626C",
        linespacing=1.2,
        bbox=dict(boxstyle="round,pad=0.24", facecolor="white", edgecolor="none", alpha=0.82),
    )
    return [direction_note, style_note]


def add_sky_direction_arrows(ax: plt.Axes) -> None:
    arrow = dict(arrowstyle="-|>", color="#37414A", lw=1.1, mutation_scale=9)
    ax.annotate("", xy=(0.84, 0.12), xytext=(0.92, 0.12), xycoords="axes fraction", arrowprops=arrow)
    ax.text(0.815, 0.105, "E", transform=ax.transAxes, fontsize=9, color="#37414A")
    ax.annotate("", xy=(0.92, 0.22), xytext=(0.92, 0.12), xycoords="axes fraction", arrowprops=arrow)
    ax.text(0.905, 0.235, "N", transform=ax.transAxes, fontsize=9, color="#37414A")


def plot_connecting_generators_3d(ax: plt.Axes, er: RingGeometry, outer: RingGeometry) -> None:
    indices = np.linspace(0, len(er.points_cm) - 1, GENERATOR_COUNT, endpoint=False).astype(int)
    for idx in indices:
        segment = np.vstack((er.points_cm[idx], outer.points_cm[idx])) / LIGHT_YEAR_CM
        ax.plot(
            segment[:, 0],
            segment[:, 1],
            segment[:, 2],
            color=outer.color,
            ls=(0, (2.2, 3.2)),
            lw=0.75,
            alpha=0.22,
        )


def plot_scene_3d(
    ax: plt.Axes,
    model: ModelGeometry,
    show_connecting: bool,
    show_intermediate: bool,
    show_axes: bool = True,
    show_legend: bool = True,
    show_depth_cues: bool = False,
) -> dict[str, object]:
    rings = model.rings
    for ring in rings.values():
        if show_depth_cues:
            plot_ring_with_earth_depth_cue(ax, ring)
        else:
            p = to_light_years(ring.points_cm)
            ax.plot(p[:, 0], p[:, 1], p[:, 2], color=ring.color, lw=2.65, solid_capstyle="round")

    depth_artists = []
    if show_depth_cues:
        depth_artists.extend(add_sky_plane_and_center_guides(ax, model))
        depth_artists.extend(
            add_fixed_earth_depth_annotations(
                ax,
                style_note_y=0.12 if show_axes else 0.025,
            )
        )

    if show_connecting:
        plot_connecting_generators_3d(ax, rings["ER"], rings["NOR"])
        plot_connecting_generators_3d(ax, rings["ER"], rings["SOR"])

    if show_intermediate:
        alphas = (0.45, 0.65, 0.9)
        for outer_name in ("NOR", "SOR"):
            for alpha, speed in zip(alphas, EJECTA_SPEEDS_KMS):
                p = to_light_years(model.intermediate_rings[outer_name][speed])
                ax.plot(
                    p[:, 0],
                    p[:, 1],
                    p[:, 2],
                    color=rings[outer_name].color,
                    lw=1.35,
                    ls=(0, (3.2, 2.6)),
                    alpha=alpha,
                )

    sn = ax.scatter([0.0], [0.0], [0.0], marker="*", s=95, color=COLOR_SN, edgecolor="white", linewidth=0.7, zorder=20)
    observer_distance = 2.38
    observer_line = ax.plot(
        [0.0, 0.0],
        [0.0, 0.0],
        [0.0, observer_distance],
        color=COLOR_OBSERVER,
        lw=1.05,
        ls=(0, (3.0, 3.2)),
        alpha=0.66,
    )[0]
    observer = ax.scatter(
        [0.0],
        [0.0],
        [observer_distance],
        s=52,
        color=COLOR_OBSERVER,
        edgecolor="white",
        linewidth=0.8,
        alpha=0.88,
    )
    observer_text = ax.text(0.0, 0.0, observer_distance + 0.13, "Earth / observer", color=COLOR_OBSERVER, fontsize=8, ha="center")
    observer_arrow = ax.quiver(
        0.0,
        0.0,
        observer_distance - 0.56,
        0.0,
        0.0,
        0.43,
        color=COLOR_OBSERVER,
        linewidth=1.05,
        arrow_length_ratio=0.28,
    )

    format_3d_axes(ax, show_labels=show_axes)
    if show_axes and not show_depth_cues:
        add_coordinate_triad(ax)

    if show_legend:
        handles = [
            Line2D([0], [0], color=COLOR_ER, lw=2.7, label="ER"),
            Line2D([0], [0], color=COLOR_NOR, lw=2.7, label="NOR (far-side center)"),
            Line2D([0], [0], color=COLOR_SOR, lw=2.7, label="SOR (near-side center)"),
        ]
        if show_connecting:
            handles.append(
                Line2D(
                    [0],
                    [0],
                    color="#7C8792",
                    lw=1.1,
                    ls=(0, (2.2, 3.2)),
                    label="hypothetical connecting material",
                )
            )
        if show_intermediate:
            handles.append(
                Line2D(
                    [0],
                    [0],
                    color="#7C8792",
                    lw=1.3,
                    ls=(0, (3.2, 2.6)),
                    label="model sphere intersections (IRs)",
                )
            )
        ax.legend(handles=handles, loc="upper left", frameon=False, fontsize=8)

    return {
        "sn": sn,
        "observer_line": observer_line,
        "observer": observer,
        "observer_text": observer_text,
        "observer_arrow": observer_arrow,
        "depth_artists": depth_artists,
    }


def plot_sky_projection(
    ax: plt.Axes,
    model: ModelGeometry,
    show_connecting: bool,
    show_intermediate: bool,
    show_legend: bool = True,
) -> None:
    rings = model.rings
    for ring in rings.values():
        east, north, _ = project_to_sky_arcsec(ring.points_cm, model.cm_per_arcsec)
        ax.plot(east, north, color=ring.color, lw=2.65, solid_capstyle="round")

    if show_connecting:
        indices = np.linspace(0, RING_SAMPLES - 1, GENERATOR_COUNT, endpoint=False).astype(int)
        for outer_name in ("NOR", "SOR"):
            outer = rings[outer_name]
            for idx in indices:
                segment = np.vstack((rings["ER"].points_cm[idx], outer.points_cm[idx]))
                east, north, _ = project_to_sky_arcsec(segment, model.cm_per_arcsec)
                ax.plot(east, north, color=outer.color, lw=0.7, ls=(0, (2.2, 3.2)), alpha=0.18)

    if show_intermediate:
        alphas = (0.42, 0.64, 0.9)
        for outer_name in ("NOR", "SOR"):
            for alpha, speed in zip(alphas, EJECTA_SPEEDS_KMS):
                east, north, _ = project_to_sky_arcsec(
                    model.intermediate_rings[outer_name][speed], model.cm_per_arcsec
                )
                ax.plot(
                    east,
                    north,
                    color=rings[outer_name].color,
                    lw=1.3,
                    ls=(0, (3.2, 2.6)),
                    alpha=alpha,
                )

    ax.scatter([0.0], [0.0], marker="*", s=86, color=COLOR_SN, edgecolor="white", linewidth=0.7, zorder=20)
    ax.text(0.08, 0.08, "SN", fontsize=8, color="#775D20")

    for name, offset in (("NOR", (-0.07, 0.18)), ("SOR", (-0.07, -0.28)), ("ER", (0.10, -0.08))):
        center = rings[name].center_cm
        east, north, _ = project_to_sky_arcsec(center[None, :], model.cm_per_arcsec)
        ax.text(east[0] + offset[0], north[0] + offset[1], name, color=rings[name].color, fontsize=9, weight="semibold")

    # Astronomical display convention: positive East is deliberately on left.
    ax.set_xlim(FOV_WIDTH_ARCSEC / 2.0, -FOV_WIDTH_ARCSEC / 2.0)
    ax.set_ylim(-FOV_HEIGHT_ARCSEC / 2.0, FOV_HEIGHT_ARCSEC / 2.0)
    ax.set_aspect("equal", adjustable="box")
    ax.set_xlabel("RA offset (East +) [arcsec]")
    ax.set_ylabel("Dec offset (North +) [arcsec]")
    ax.grid(color="#D8DDE2", lw=0.55, alpha=0.55)
    ax.spines[["top", "right"]].set_visible(False)
    add_sky_direction_arrows(ax)

    if show_legend:
        handles = [
            Line2D([0], [0], color=COLOR_ER, lw=2.7, label="ER"),
            Line2D([0], [0], color=COLOR_NOR, lw=2.7, label="NOR"),
            Line2D([0], [0], color=COLOR_SOR, lw=2.7, label="SOR"),
        ]
        if show_connecting:
            handles.append(
                Line2D([0], [0], color="#7C8792", lw=1.0, ls=(0, (2.2, 3.2)), label="hypothetical connectors")
            )
        if show_intermediate:
            handles.append(
                Line2D([0], [0], color="#7C8792", lw=1.2, ls=(0, (3.2, 2.6)), label="model IRs")
            )
        ax.legend(handles=handles, loc="upper right", frameon=False, fontsize=8)


# =============================================================================
# Static figures and animation
# =============================================================================


def create_static_figure(
    model: ModelGeometry,
    output_path: Path,
    show_connecting: bool,
    show_intermediate: bool,
) -> None:
    fig = plt.figure(figsize=(15.0, 7.25), facecolor="white")
    grid = fig.add_gridspec(1, 2, width_ratios=(1.10, 1.0), wspace=0.16)
    ax3d = fig.add_subplot(grid[0, 0], projection="3d")
    ax2d = fig.add_subplot(grid[0, 1])
    plot_scene_3d(
        ax3d,
        model,
        show_connecting,
        show_intermediate,
        show_axes=True,
        show_legend=True,
        show_depth_cues=SHOW_EARTH_DEPTH_CUES,
    )
    ax3d.view_init(elev=22.0, azim=-62.0)
    ax3d.set_title("3D geometry", pad=8, fontsize=13, weight="semibold")
    plot_sky_projection(ax2d, model, show_connecting, show_intermediate, show_legend=True)
    ax2d.set_title("Projection as viewed from Earth", pad=12, fontsize=13, weight="semibold")
    fig.suptitle("SN 1987A triple-ring system — simple kinematic-geometric model", fontsize=16, y=0.975, weight="semibold")
    caveat = (
        "Circular rings: literature-adopted simple geometry.  "
        + (
            "Dashed connectors are hypothetical, not an observed 3D surface."
            if show_connecting
            else "Hypothetical ER–OR connecting material is not shown (toggle is off)."
        )
    )
    fig.text(0.5, 0.018, caveat, ha="center", va="bottom", fontsize=9, color="#5E6670")
    fig.savefig(output_path, dpi=STATIC_DPI, bbox_inches="tight", facecolor="white")
    plt.close(fig)


def create_depth_representative_frame(
    model: ModelGeometry,
    output_path: Path,
    show_connecting: bool,
    show_intermediate: bool,
) -> None:
    """Save a representative frame using the same layout as the animation."""

    fig = plt.figure(figsize=(12.0, 5.6), facecolor="white")
    grid = fig.add_gridspec(1, 2, width_ratios=(1.08, 1.0), wspace=0.12)
    ax3d = fig.add_subplot(grid[0, 0], projection="3d")
    ax2d = fig.add_subplot(grid[0, 1])
    plot_scene_3d(
        ax3d,
        model,
        show_connecting,
        show_intermediate,
        show_axes=False,
        show_legend=False,
        show_depth_cues=True,
    )
    ax3d.view_init(elev=22.0, azim=-68.0)
    ax3d.text2D(
        0.03,
        0.955,
        "Camera: oblique view",
        transform=ax3d.transAxes,
        va="top",
        fontsize=8.1,
        color="#3F4851",
        bbox=dict(boxstyle="round,pad=0.28", facecolor="white", edgecolor="none", alpha=0.82),
    )
    plot_sky_projection(ax2d, model, show_connecting, show_intermediate, show_legend=False)
    ax2d.set_title("Fixed Earth-view projection", fontsize=11.5, weight="semibold")
    fig.suptitle("SN 1987A: physical Earth-depth cues in the 3D view", y=0.975, fontsize=14, weight="semibold")
    fig.text(
        0.5,
        0.018,
        "The camera is arbitrary; near/far is always defined by physical Z relative to the Z=0 sky plane.",
        ha="center",
        fontsize=8.8,
        color="#5E6670",
    )
    fig.savefig(output_path, dpi=STATIC_DPI, bbox_inches="tight", facecolor="white")
    plt.close(fig)


def create_schematic(
    model: ModelGeometry,
    output_path: Path,
    show_connecting: bool,
    show_intermediate: bool,
) -> None:
    fig = plt.figure(figsize=(9.0, 7.4), facecolor="white")
    ax = fig.add_subplot(111, projection="3d")
    plot_scene_3d(ax, model, show_connecting, show_intermediate, show_axes=False, show_legend=True)
    ax.view_init(elev=18.0, azim=-60.0)
    ax.set_title("SN 1987A triple-ring geometry", fontsize=16, weight="semibold", pad=2)
    fig.text(
        0.5,
        0.035,
        "Camera view is schematic; rings are 3D circles.  Earth lies along physical +Z.",
        ha="center",
        fontsize=9,
        color="#5E6670",
    )
    if show_connecting:
        fig.text(
            0.5,
            0.012,
            "Dashed ER–OR lines: hypothetical connecting material in the Larsson et al. simple model.",
            ha="center",
            fontsize=8.5,
            color="#6A7178",
        )
    fig.savefig(output_path, dpi=STATIC_DPI, bbox_inches="tight", facecolor="white")
    plt.close(fig)


def camera_for_frame(frame: int) -> tuple[float, float, str]:
    if frame < ORBIT_FRAMES:
        t = frame / max(ORBIT_FRAMES - 1, 1)
        eased = ease_in_out(t)
        azim = CAMERA_START_AZIM_DEG + (CAMERA_ORBIT_END_AZIM_DEG - CAMERA_START_AZIM_DEG) * eased
        elev = CAMERA_START_ELEV_DEG + 6.0 * np.sin(np.pi * t)
        return elev, azim, "Camera orbiting\n(rings and physical +Z remain fixed)"
    if frame < ORBIT_FRAMES + ALIGN_FRAMES:
        t = (frame - ORBIT_FRAMES) / max(ALIGN_FRAMES - 1, 1)
        eased = ease_in_out(t)
        elev = CAMERA_START_ELEV_DEG + (CAMERA_EARTH_ELEV_DEG - CAMERA_START_ELEV_DEG) * eased
        azim = CAMERA_ORBIT_END_AZIM_DEG + (CAMERA_EARTH_AZIM_DEG - CAMERA_ORBIT_END_AZIM_DEG) * eased
        return elev, azim, "Camera moving toward Earth LOS (+Z)"
    return CAMERA_EARTH_ELEV_DEG, CAMERA_EARTH_AZIM_DEG, "Camera aligned with Earth LOS\nNorth up, East left"


def create_animation(
    model: ModelGeometry,
    gif_path: Path,
    mp4_path: Path,
    show_connecting: bool,
    show_intermediate: bool,
    make_mp4: bool,
) -> bool:
    fig = plt.figure(figsize=(12.0, 5.6), facecolor="white")
    grid = fig.add_gridspec(1, 2, width_ratios=(1.08, 1.0), wspace=0.12)
    ax3d = fig.add_subplot(grid[0, 0], projection="3d")
    ax2d = fig.add_subplot(grid[0, 1])
    plot_scene_3d(
        ax3d,
        model,
        show_connecting,
        show_intermediate,
        show_axes=False,
        show_legend=False,
        show_depth_cues=True,
    )
    plot_sky_projection(ax2d, model, show_connecting, show_intermediate, show_legend=False)
    ax2d.set_title("Fixed Earth-view projection", fontsize=11.5, weight="semibold")
    status = ax3d.text2D(
        0.02,
        0.96,
        "",
        transform=ax3d.transAxes,
        va="top",
        fontsize=8.1,
        color="#3F4851",
        bbox=dict(boxstyle="round,pad=0.3", facecolor="white", edgecolor="none", alpha=0.82),
    )
    fig.suptitle("SN 1987A: moving the camera from 3D to the observer's view", y=0.975, fontsize=14, weight="semibold")
    fig.text(
        0.5,
        0.018,
        "Only the camera moves; all physical ring coordinates remain fixed.",
        ha="center",
        fontsize=9,
        color="#5E6670",
    )

    total_frames = ORBIT_FRAMES + ALIGN_FRAMES + HOLD_FRAMES

    def update(frame: int):
        elev, azim, label = camera_for_frame(frame)
        ax3d.view_init(elev=elev, azim=azim)
        status.set_text(label)
        return (status,)

    animation = FuncAnimation(fig, update, frames=total_frames, interval=1000.0 / ANIMATION_FPS, blit=False)
    animation.save(gif_path, writer=PillowWriter(fps=ANIMATION_FPS), dpi=ANIMATION_DPI)

    mp4_written = False
    if make_mp4 and shutil.which("ffmpeg"):
        try:
            animation.save(
                mp4_path,
                writer=FFMpegWriter(
                    fps=ANIMATION_FPS,
                    codec="libx264",
                    bitrate=2200,
                    extra_args=[
                        "-vf",
                        "pad=ceil(iw/2)*2:ceil(ih/2)*2",
                        "-pix_fmt",
                        "yuv420p",
                        "-movflags",
                        "+faststart",
                    ],
                ),
                dpi=ANIMATION_DPI,
            )
            mp4_written = True
        except Exception as exc:  # GIF remains a valid fallback.
            mp4_path.unlink(missing_ok=True)
            print(f"Warning: MP4 encoding failed; GIF was retained ({exc})")
    else:
        # Avoid leaving a stale MP4 from an earlier run with different settings.
        mp4_path.unlink(missing_ok=True)
    plt.close(fig)
    return mp4_written


# =============================================================================
# Validation
# =============================================================================


def add_check(
    checks: list[dict[str, object]],
    check_id: str,
    description: str,
    passed: bool,
    measured: object,
    expected: object,
    tolerance: object | None = None,
) -> None:
    entry: dict[str, object] = {
        "id": check_id,
        "description": description,
        "status": "PASS" if bool(passed) else "FAIL",
        "measured": measured,
        "expected": expected,
    }
    if tolerance is not None:
        entry["tolerance"] = tolerance
    checks.append(entry)


def validate_model(model: ModelGeometry) -> dict[str, object]:
    rings = model.rings
    checks: list[dict[str, object]] = []
    metrics = {name: projected_basis_metrics(ring.e1, ring.e2) for name, ring in rings.items()}

    for name in ("ER", "NOR", "SOR"):
        expected_ratio = abs(math.cos(math.radians(rings[name].inclination_deg)))
        measured_ratio = metrics[name]["axis_ratio"]
        add_check(
            checks,
            f"{name.lower()}_axis_ratio",
            f"{name} projected axis ratio is approximately cos(inclination)",
            abs(measured_ratio - expected_ratio) < 0.01,
            measured_ratio,
            expected_ratio,
            0.01,
        )

    er_major_arcsec = rings["ER"].radius_cm * metrics["ER"]["major_factor"] / model.cm_per_arcsec
    add_check(
        checks,
        "er_projected_radius",
        "ER projected semi-major radius",
        abs(er_major_arcsec - ER_RADIUS_ARCSEC) < 1.0e-6,
        er_major_arcsec,
        ER_RADIUS_ARCSEC,
        1.0e-6,
    )

    nor_ratio = rings["NOR"].radius_cm / rings["ER"].radius_cm
    sor_ratio = rings["SOR"].radius_cm / rings["ER"].radius_cm
    add_check(checks, "nor_radius_ratio", "NOR/ER intrinsic radius ratio", abs(nor_ratio - NOR_RADIUS_FACTOR) < 1.0e-12, nor_ratio, NOR_RADIUS_FACTOR, 1.0e-12)
    add_check(checks, "sor_radius_ratio", "SOR/ER intrinsic radius ratio", abs(sor_ratio - SOR_RADIUS_FACTOR) < 1.0e-12, sor_ratio, SOR_RADIUS_FACTOR, 1.0e-12)

    nor_depth = float(np.dot(rings["NOR"].center_cm, LOS_HAT))
    sor_depth = float(np.dot(rings["SOR"].center_cm, LOS_HAT))
    add_check(checks, "nor_far_side", "NOR center is on the far side (-Z)", nor_depth < 0.0, nor_depth, "< 0 cm")
    add_check(checks, "sor_near_side", "SOR center is on the near side (+Z)", sor_depth > 0.0, sor_depth, "> 0 cm")

    er = rings["ER"]
    north_index = int(np.argmax(er.points_cm[:, 1]))
    north_edge_depth = float(er.points_cm[north_index, 2])
    add_check(checks, "er_northern_edge_near", "ER northern edge is nearer the observer", north_edge_depth > 0.0, north_edge_depth, "> 0 cm")

    for name, expected_shift in (("NOR", NOR_WEST_SHIFT_CM), ("SOR", SOR_WEST_SHIFT_CM)):
        explicit_east_component = -rings[name].explicit_west_shift_cm
        add_check(
            checks,
            f"{name.lower()}_west_shift_sign",
            f"{name} explicit center correction points West (-X)",
            explicit_east_component < 0.0,
            explicit_east_component,
            f"-{expected_shift:.3e} cm along +X/East",
        )

    measured_tilt = metrics["ER"]["major_axis_tilt_deg_east_to_north"]
    add_check(
        checks,
        "major_axis_tilt",
        "ER projected major-axis tilt (east end north of west end)",
        abs(measured_tilt - TARGET_MAJOR_AXIS_TILT_DEG) < 0.01,
        measured_tilt,
        TARGET_MAJOR_AXIS_TILT_DEG,
        0.01,
    )

    er_radius_ly = rings["ER"].radius_cm / LIGHT_YEAR_CM
    add_check(
        checks,
        "physical_angular_scale",
        "0.82 arcsec at 49.6 kpc is consistent with the approximate 0.6 ly description",
        abs(er_radius_ly - 0.6) / 0.6 < 0.10,
        er_radius_ly,
        "approximately 0.6 ly",
        "10% (the literature value is rounded)",
    )

    add_check(
        checks,
        "los_identity",
        "3D observer arrow and 2D projection use the same LOS vector",
        bool(np.array_equal(OBSERVER_DIRECTION, LOS_HAT)),
        OBSERVER_DIRECTION.tolist(),
        LOS_HAT.tolist(),
    )
    add_check(
        checks,
        "sky_display_convention",
        "Plot limits encode North up and East left",
        True,
        {"x_limits_arcsec": [FOV_WIDTH_ARCSEC / 2.0, -FOV_WIDTH_ARCSEC / 2.0], "y_limits_arcsec": [-FOV_HEIGHT_ARCSEC / 2.0, FOV_HEIGHT_ARCSEC / 2.0]},
        "descending East axis; ascending North axis",
    )

    maximum_intersection_residual = max(
        residual for side in model.intersection_residuals.values() for residual in side.values()
    )
    add_check(
        checks,
        "intermediate_ring_sphere_intersections",
        "Optional IR curves are numerical intersections, not hand-placed rings",
        maximum_intersection_residual < 1.0e-12,
        maximum_intersection_residual,
        "relative sphere-radius residual < 1e-12",
        1.0e-12,
    )

    center_summary = {}
    for name in ("NOR", "SOR"):
        east, north, depth = project_to_sky_arcsec(rings[name].center_cm[None, :], model.cm_per_arcsec)
        center_summary[name] = {
            "east_arcsec": float(east[0]),
            "north_arcsec": float(north[0]),
            "depth_toward_observer_arcsec_equivalent": float(depth[0]),
            "center_distance_cm_after_explicit_west_shift": float(np.linalg.norm(rings[name].center_cm)),
        }

    output = {
        "model_scope": "Educational kinematic-geometric visualization; not hydrodynamics and not a complete 3D reconstruction.",
        "coordinate_system": {
            "handedness": "right-handed",
            "+X": "East",
            "+Y": "North",
            "+Z": "toward observer",
            "observer_projection": "orthographic along +Z onto the X-Y sky plane",
            "display": "North up, East left (x-axis inverted only when plotted)",
        },
        "sources": {
            "primary": "Larsson et al. 2019, ApJ 886, 147, doi:10.3847/1538-4357/ab4ff2",
            "supporting": "Tziamtzis et al. 2011, A&A 527, A35, doi:10.1051/0004-6361/201015576",
        },
        "derived": {
            "cm_per_arcsec": model.cm_per_arcsec,
            "er_radius_cm": rings["ER"].radius_cm,
            "er_radius_light_year": er_radius_ly,
            "nor_radius_arcsec": rings["NOR"].radius_cm / model.cm_per_arcsec,
            "sor_radius_arcsec": rings["SOR"].radius_cm / model.cm_per_arcsec,
            "system_rotation_about_north_deg": model.system_rotation_deg,
            "outer_ring_projected_centers": center_summary,
            "ejecta_sphere_radii_cm": {
                str(int(speed)): speed * KM_CM * EJECTA_EPOCH_DAYS * DAY_S for speed in EJECTA_SPEEDS_KMS
            },
        },
        "projected_ring_metrics": metrics,
        "checks": checks,
        "all_passed": all(check["status"] == "PASS" for check in checks),
    }
    return output


def write_validation(validation: dict[str, object], output_path: Path) -> None:
    output_path.write_text(json.dumps(validation, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
    print("\nValidation")
    print("=" * 78)
    for check in validation["checks"]:
        print(f"{check['status']:>4}  {check['id']:<42} {check['description']}")
    print("=" * 78)
    print(f"all_passed = {validation['all_passed']}")


# =============================================================================
# Command-line entry point
# =============================================================================


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT_DIR, help="Directory for generated artifacts")
    parser.add_argument(
        "--show-connecting-material",
        action=argparse.BooleanOptionalAction,
        default=SHOW_CONNECTING_MATERIAL,
        help="Show the explicitly hypothetical straight ER-to-OR connecting material",
    )
    parser.add_argument(
        "--show-intermediate-rings",
        action=argparse.BooleanOptionalAction,
        default=SHOW_INTERMEDIATE_RINGS,
        help="Show sphere/generator intersections for 7000, 8000 and 9000 km/s ejecta",
    )
    parser.add_argument("--skip-animation", action="store_true", help="Generate static figures and validation only")
    parser.add_argument("--no-mp4", action="store_true", help="Do not attempt MP4 output")
    return parser.parse_args()


def main() -> None:
    args = parse_args()
    output_dir = args.output_dir.resolve()
    output_dir.mkdir(parents=True, exist_ok=True)
    model = build_model()

    print("SN 1987A triple-ring simple geometry")
    print(f"Internal coordinates: +X East, +Y North, +Z toward observer; unit = cm")
    print(f"Angular scale: {model.cm_per_arcsec:.6e} cm arcsec^-1 at {DISTANCE_KPC:.1f} kpc")
    print(f"Derived ER radius: {model.rings['ER'].radius_cm:.6e} cm = {model.rings['ER'].radius_cm / LIGHT_YEAR_CM:.4f} ly")
    print(f"Derived +Y system rotation: {model.system_rotation_deg:.6f} deg for a {TARGET_MAJOR_AXIS_TILT_DEG:.1f} deg sky tilt")
    print(f"Hypothetical connecting material shown: {args.show_connecting_material}")
    print(f"Intermediate model rings shown: {args.show_intermediate_rings}")

    validation = validate_model(model)
    write_validation(validation, output_dir / "validation.json")
    if not validation["all_passed"]:
        raise RuntimeError("One or more geometry validations failed; outputs were not finalized")

    create_static_figure(
        model,
        output_dir / "triple_ring_3d_projection.png",
        args.show_connecting_material,
        args.show_intermediate_rings,
    )
    create_depth_representative_frame(
        model,
        output_dir / "triple_ring_camera_depth_frame.png",
        args.show_connecting_material,
        args.show_intermediate_rings,
    )
    create_schematic(
        model,
        output_dir / "triple_ring_schematic.png",
        args.show_connecting_material,
        args.show_intermediate_rings,
    )

    mp4_written = False
    if not args.skip_animation:
        mp4_written = create_animation(
            model,
            output_dir / "triple_ring_camera.gif",
            output_dir / "triple_ring_camera.mp4",
            args.show_connecting_material,
            args.show_intermediate_rings,
            make_mp4=not args.no_mp4,
        )

    print("\nGenerated")
    print(f"- {output_dir / 'triple_ring_3d_projection.png'}")
    print(f"- {output_dir / 'triple_ring_camera_depth_frame.png'}")
    print(f"- {output_dir / 'triple_ring_schematic.png'}")
    print(f"- {output_dir / 'validation.json'}")
    if not args.skip_animation:
        print(f"- {output_dir / 'triple_ring_camera.gif'}")
        if mp4_written:
            print(f"- {output_dir / 'triple_ring_camera.mp4'}")
        else:
            print("- MP4 skipped: ffmpeg was unavailable or --no-mp4 was used")


if __name__ == "__main__":
    main()

基本的にはスクリプトを保存して、

python sn1987a_triple_ring.py

と実行すれば、本文で示した静止画とanimationを生成できます。

ギャラリー

今回作った静止画やanimationをまとめています。

https://drive.google.com/drive/folders/1WPtAmV6bZw7AcRhoM09FSLp5ASTT-z8J?usp=sharing

研究紹介や発表資料、説明用のポンチ絵など、使えそうなものがあればご自由にお使いください。

ただし、本文でも書いた通り精密な3D reconstructionや定量解析を目的としたものではないので、その点だけご注意ください。

参考文献

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?