Newer
Older
SmTIAS-LightSim / scripts / build_led_placement_comparison.py
"""照明配置スイープの解析: 位置・向き・個数への依存を球と立方体で比較する(TEST_18).

sweep_led_placement.py の出力(output/led_placement_sweep/<条件>/)を読み,条件ごとに

1. **方位フーリエ成分**(輪帯ごとの逆分散重み集約): 1 灯目が +X 方位にあるので
   m=1 の符号付き係数 a1 が「LED 側がどれだけ明るい/暗いか」,N 灯の等方位配置では
   m=N の係数 aN が「灯数の痕跡」を直接与える.直交成分 b_m はノイズ対照
2. **構造 CoV**(MC ノイズを差し引いた CoV)と輪帯 min/max
3. **輝度重心**(metrics.csv)

を求め,次の 3 つの図と集約表を出力する.

- position_sensitivity.png : θ に対する感度曲線(形状 × サイズ)と,配置依存の
  大きさをまとめた指標(θ 全域での構造 CoV の幅・|a1| の最大)の内面積依存
- direction_comparison.png : 向き(板向き / 壁法線向き / 真下向き)の比較
- count_comparison.png     : 灯数の比較(構造 CoV・|m1|・|mN|)
- position_maps.png        : 代表ペアの輝度マップ(θ を横に並べる)

実行方法:
    uv run python scripts/build_led_placement_comparison.py
"""

from __future__ import annotations

import argparse
import csv
import json
import sys
from pathlib import Path

import matplotlib
import numpy as np

matplotlib.use("Agg")
import matplotlib.pyplot as plt  # noqa: E402

sys.path.insert(0, str(Path(__file__).resolve().parent))
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))

import sweep_led_placement as sweep  # noqa: E402
from analyze_fourfold_component import aggregate, load_roi, ring_harmonics  # noqa: E402
from build_radial_comparison import radial_stats, ring_min_max_ratio  # noqa: E402
from build_radius_sweep_visual import decompose_cov, normalized_map  # noqa: E402
from sweep_led_placement import (  # noqa: E402
    AIMS,
    AZIMUTH_CALIBRATION,
    AZIMUTH_CUBE,
    AZIMUTH_SPHERE,
    AZIMUTH_THETA,
    COUNT_N,
    COUNT_THETA,
    CUBE_N8_AZIMUTH,
    DIRECTION_THETAS,
    MATERIALS,
    REPEAT_PAIR_INDEX,
    RING_SPECS,
    SHAPES,
    SIZE_PAIRS,
    THETA_GRID,
    collect_rows,
    set_material,
    write_summary,
)

OUTPUT_BASE = sweep.OUTPUT_BASE

plt.rcParams["font.family"] = ["Yu Gothic", "MS Gothic", "DejaVu Sans"]

HARMONICS = (1, 2, 3, 4, 8)  # 8 は真下向き多灯(N=8)の痕跡用
SHAPE_COLOR = {"sphere": "tab:blue", "cube": "tab:red"}
SHAPE_LABEL = {"sphere": "球", "cube": "立方体"}
SHAPE_STYLE = {"sphere": dict(marker="o", ls="-"), "cube": dict(marker="s", ls="--")}
AIM_LABEL = {"board": "板中心向き", "normal": "壁法線向き", "down": "真下向き"}
MAP_THETAS = [0.0, 30.0, 60.0, 90.0, 120.0]
MAP_PAIR_INDEX = 2  # 輝度マップに使う代表ペア(r=170 / L=234)

ANALYSIS_COLUMNS = [
    "name", "group", "shape", "size_mm", "pair", "theta_deg", "aim", "n_led", "rep",
    "azimuth_deg",
    "cavity_area_mm2", "baffle_radius_mm", "baffle_ratio", "occlusion_blocked_frac",
    "raw_mean_roi", "cov", "structure_cov", "noise_cov", "ring_min_max",
    "centroid_x", "centroid_y", "centroid_offset",
    "a1", "b1", "se1", "m1_abs", "a1_led", "a2", "b2", "se2", "a3", "b3", "se3",
    "a4", "b4", "se4", "a8", "b8", "se8",
    "aN", "mN_abs",
]


def size_label(shape: str, size: float) -> str:
    """条件ラベル用のサイズ表記."""
    return f"{'r' if shape == 'sphere' else 'L'}={size:g}"


def pair_size(pair: tuple[float, float], shape: str) -> float:
    """同内面積ペアから該当形状のサイズ(球=内半径 / 立方体=内寸)を取り出す."""
    return pair[SHAPES.index(shape)]


def shape_size_pairs() -> list[tuple[str, int, float]]:
    """(形状, ペア番号, サイズ) を全組み合わせで返す(ペア番号は色分けに使う)."""
    return [(shape, i, pair_size(pair, shape))
            for shape in SHAPES for i, pair in enumerate(SIZE_PAIRS)]


def pair_index(shape: str, size: float) -> int:
    """サイズペアの番号(0=最小)を返す(該当なしは -1)."""
    for i, pair in enumerate(SIZE_PAIRS):
        if pair_size(pair, shape) == size:
            return i
    return -1


def analyze_condition(row: dict) -> dict | None:
    """1 条件の輝度マップから非対称・均一性の指標を集める."""
    directory = OUTPUT_BASE / row["name"]
    lum = directory / "luminance.npy"
    if not lum.exists():
        return None
    result = {k: row.get(k) for k in ("name", "group", "shape", "size_mm", "theta_deg", "aim",
                                      "n_led", "rep", "cavity_area_mm2", "baffle_radius_mm",
                                      "occlusion_blocked_frac", "raw_mean_roi", "cov")}
    result["pair"] = pair_index(row["shape"], row["size_mm"])
    result["baffle_ratio"] = row["baffle_radius_mm"] / row["size_mm"]
    rings = ring_harmonics(load_roi(lum), HARMONICS)
    for m in HARMONICS:
        result[f"a{m}"], result[f"se{m}"] = aggregate(rings, f"a{m}")
        result[f"b{m}"], _ = aggregate(rings, f"b{m}")
    result["m1_abs"] = float(np.hypot(result["a1"], result["b1"]))
    # 1 灯目の方位 φ への m=1 投影(φ=0 なら a1 そのもの.符号は較正条件 φ=90° で確認する)
    result["azimuth_deg"] = float(row.get("azimuth_deg", 0.0) or 0.0)
    phi = np.radians(result["azimuth_deg"])
    result["a1_led"] = float(result["a1"] * np.cos(phi) + result["b1"] * np.sin(phi))
    n = int(row["n_led"])
    result["aN"] = result[f"a{n}"]
    result["mN_abs"] = float(np.hypot(result[f"a{n}"], result[f"b{n}"]))
    _, structure, noise = decompose_cov(lum)
    centers, means, _ = radial_stats(lum, 20)
    result["structure_cov"] = structure
    result["noise_cov"] = noise
    result["ring_min_max"] = ring_min_max_ratio(centers, means)
    result["centroid_x"] = row.get("intensity_centroid_x_norm", float("nan"))
    result["centroid_y"] = row.get("intensity_centroid_y_norm", float("nan"))
    result["centroid_offset"] = row.get("intensity_centroid_offset_norm", float("nan"))
    return result


def select(results: list[dict], **conds) -> list[dict]:
    """条件で絞り込む(値が None のキーは無視)."""
    out = []
    for r in results:
        if all(v is None or r.get(k) == v for k, v in conds.items()):
            out.append(r)
    return out


def size_colors(n: int) -> np.ndarray:
    """サイズペアごとの色(0=最小ペア)."""
    return plt.cm.viridis(np.linspace(0.0, 0.85, n))


def position_series(results: list[dict], shape: str, size: float) -> list[dict]:
    """位置スイープの 1 系列(同じ形状・サイズ)を θ 昇順で取り出す."""
    return sorted(select(results, group="position", shape=shape, size_mm=size),
                  key=lambda r: r["theta_deg"])


# ---------------------------------------------------------------------------
# 図 1: 位置の感度曲線
# ---------------------------------------------------------------------------


def robustness_summary(results: list[dict]) -> list[dict]:
    """位置スイープについて,形状 × ペアごとに配置依存の大きさを集約する."""
    rows = []
    for shape, i, size in shape_size_pairs():
        pos = position_series(results, shape, size)
        if len(pos) < 3:
            continue
        struct = np.array([r["structure_cov"] for r in pos])
        a1 = np.array([r["a1"] for r in pos])
        on_axis = next((r for r in pos if r["theta_deg"] == 0.0), None)
        rows.append({
            "shape": shape, "pair": i, "size_mm": size,
            "area": pos[0]["cavity_area_mm2"], "n_theta": len(pos),
            "struct_onaxis": on_axis["structure_cov"] if on_axis else float("nan"),
            "struct_mean": float(struct.mean()),
            "struct_max": float(struct.max()),
            "struct_range": float(struct.max() - struct.min()),
            "struct_std": float(struct.std(ddof=1)),
            "a1_absmax": float(np.abs(a1).max()),
            "a1_absmean": float(np.abs(a1).mean()),
            "theta_worst": float(pos[int(np.argmax(struct))]["theta_deg"]),
        })
    return rows


def draw_position(results: list[dict], summary: list[dict], output: Path) -> None:
    """θ に対する感度曲線と,配置依存の集約指標の内面積依存を描く."""
    fig, axes = plt.subplots(2, 3, figsize=(20, 11))
    colors = size_colors(len(SIZE_PAIRS))
    for shape, i, size in shape_size_pairs():
        pos = position_series(results, shape, size)
        if not pos:
            continue
        theta = [r["theta_deg"] for r in pos]
        label = f"{SHAPE_LABEL[shape]} {size_label(shape, size)}"
        style = dict(SHAPE_STYLE[shape], color=colors[i], ms=4, lw=1.4, label=label)
        axes[0, 0].errorbar(theta, [r["a1"] * 100 for r in pos],
                            yerr=[r["se1"] * 100 for r in pos], capsize=2, **style)
        axes[0, 1].plot(theta, [r["structure_cov"] for r in pos], **style)
        axes[0, 2].plot(theta, [r["centroid_x"] for r in pos], **style)
    axes[0, 0].set_ylabel("a1(輪帯平均比 %・正=LED 側が明るい)")
    axes[0, 0].set_title("m=1 非対称 vs LED 極角 θ", fontsize=11)
    axes[0, 1].set_ylabel("構造 CoV")
    axes[0, 1].set_title("構造 CoV vs θ(低いほど均一)", fontsize=11)
    axes[0, 2].set_ylabel("輝度重心 X(正規化)")
    axes[0, 2].set_title("輝度重心の X オフセット vs θ", fontsize=11)
    for ax in axes[0]:
        ax.axhline(0.0, color="gray", lw=0.8, ls="--")
        ax.set_xlabel("LED 極角 θ [deg](0=底の中心・90=側壁の中段・120=板の直下)")
        ax.set_xticks(THETA_GRID[::2])
        ax.grid(alpha=0.3)
        ax.legend(fontsize=7, ncol=2)

    for key, ax, title, ylabel in (
        ("struct_range", axes[1, 0], "配置依存の幅: 構造 CoV の max−min(θ 全域)",
         "構造 CoV の幅"),
        ("a1_absmax", axes[1, 1], "非対称の最大: max|a1|(θ 全域)", "max|a1| [%]"),
        ("struct_max", axes[1, 2], "最悪配置の構造 CoV(点線は軸上 θ=0)", "構造 CoV"),
    ):
        for shape in SHAPES:
            rows = [r for r in summary if r["shape"] == shape]
            scale = 100.0 if key == "a1_absmax" else 1.0
            ax.plot([r["area"] for r in rows], [r[key] * scale for r in rows],
                    color=SHAPE_COLOR[shape], label=f"{SHAPE_LABEL[shape]}",
                    **SHAPE_STYLE[shape])
            if key == "struct_max":
                ax.plot([r["area"] for r in rows], [r["struct_onaxis"] for r in rows],
                        color=SHAPE_COLOR[shape], marker=SHAPE_STYLE[shape]["marker"],
                        ls=":", alpha=0.6, label=f"{SHAPE_LABEL[shape]}(軸上)")
        ax.set_xscale("log")
        ax.set_xlabel("キャビティ内面積 (mm²・対数)")
        ax.set_ylabel(ylabel)
        ax.set_title(title, fontsize=11)
        ax.grid(alpha=0.3, which="both")
        ax.legend(fontsize=8)
    fig.suptitle(
        "照明位置への依存(1 灯・板中心向き・バッフル付き): 球 vs 同内面積の立方体",
        fontsize=15,
    )
    fig.tight_layout()
    fig.savefig(output, dpi=100)
    plt.close(fig)
    print(f"出力: {output}")


def draw_position_maps(results: list[dict], output: Path) -> None:
    """代表ペアの輝度マップを θ ごとに並べる(輪帯ピーク=1.0・共通スケール)."""
    fig, axes = plt.subplots(2, len(MAP_THETAS), figsize=(3.6 * len(MAP_THETAS), 7.4))
    picked: dict[tuple[str, float], dict] = {}
    maps: dict[tuple[str, float], np.ndarray] = {}
    for shape in SHAPES:
        size = pair_size(SIZE_PAIRS[MAP_PAIR_INDEX], shape)
        for theta in MAP_THETAS:
            rows = select(results, group="position", shape=shape, size_mm=size, theta_deg=theta)
            if rows:
                picked[(shape, theta)] = rows[0]
                maps[(shape, theta)] = normalized_map(OUTPUT_BASE / rows[0]["name"]
                                                     / "luminance.npy")
    if not maps:
        plt.close(fig)
        return
    vmin = float(np.percentile(np.concatenate([m.ravel() for m in maps.values()]), 0.5))
    image = None
    for row_idx, shape in enumerate(SHAPES):
        size = pair_size(SIZE_PAIRS[MAP_PAIR_INDEX], shape)
        for col, theta in enumerate(MAP_THETAS):
            ax = axes[row_idx, col]
            ax.axis("off")
            data = maps.get((shape, theta))
            if data is None:
                continue
            image = ax.imshow(data, cmap="hot", vmin=vmin, vmax=1.0)
            row = picked[(shape, theta)]
            ax.set_title(f"{SHAPE_LABEL[shape]} {size_label(shape, size)} θ={theta:g}°\n"
                         f"a1={row['a1'] * 100:+.2f}%  構造CoV={row['structure_cov']:.4f}",
                         fontsize=9)
    if image is not None:
        fig.colorbar(image, ax=axes.ravel().tolist(), fraction=0.02, label="輪帯ピーク=1.0")
    fig.suptitle("LED 極角 θ による輝度マップの変化(LED は +X=画像右側.輪帯ピーク正規化)",
                 fontsize=13)
    fig.savefig(output, dpi=100)
    plt.close(fig)
    print(f"出力: {output}")


# ---------------------------------------------------------------------------
# 図 2: 向き
# ---------------------------------------------------------------------------


def draw_direction(results: list[dict], output: Path) -> None:
    """向き(板中心 / 壁法線 / 真下)の比較を θ × サイズで描く."""
    fig, axes = plt.subplots(2, len(DIRECTION_THETAS), figsize=(6.2 * len(DIRECTION_THETAS), 10))
    colors = size_colors(len(SIZE_PAIRS))
    width = 0.09
    for col, theta in enumerate(DIRECTION_THETAS):
        for metric_row, (key, ylabel, scale) in enumerate((
            ("structure_cov", "構造 CoV", 1.0), ("m1_abs", "|m1|(輪帯平均比 %)", 100.0),
        )):
            ax = axes[metric_row, col]
            for a_idx, aim in enumerate(AIMS):
                for i, pair in enumerate(SIZE_PAIRS):
                    for s_idx, shape in enumerate(SHAPES):
                        size = pair[s_idx]
                        rows = select(results, shape=shape, size_mm=size, theta_deg=theta,
                                      aim=aim, n_led=1, rep=1)
                        if not rows:
                            continue
                        x = a_idx + (i * 2 + s_idx - 3.5) * width
                        hatch = "" if shape == "sphere" else "//"
                        ax.bar(x, rows[0][key] * scale, width=width, color=colors[i],
                               edgecolor=SHAPE_COLOR[shape], hatch=hatch, lw=1.0)
                        if rows[0]["baffle_ratio"] > 0.3:
                            ax.text(x, rows[0][key] * scale, "†", ha="center", va="bottom",
                                    fontsize=8)
            ax.set_xticks(range(len(AIMS)))
            ax.set_xticklabels([AIM_LABEL[a] for a in AIMS])
            ax.set_ylabel(ylabel)
            ax.set_title(f"θ={theta:g}°: {ylabel}", fontsize=11)
            ax.grid(alpha=0.3, axis="y")
    handles = [plt.Rectangle((0, 0), 1, 1, color=colors[i],
                             label=f"ペア{i + 1}: r={p[0]:g} / L={p[1]:g}")
               for i, p in enumerate(SIZE_PAIRS)]
    handles += [
        plt.Rectangle((0, 0), 1, 1, fc="white", ec=SHAPE_COLOR["sphere"], label="球(枠青)"),
        plt.Rectangle((0, 0), 1, 1, fc="white", ec=SHAPE_COLOR["cube"], hatch="//",
                      label="立方体(枠赤・斜線)"),
    ]
    axes[0, 0].legend(handles=handles, fontsize=8, loc="upper left")
    fig.suptitle("LED の向きの影響(1 灯).† = バッフル半径がキャビティ寸法の 30% 超"
                 "(板向き・法線向きはバッフル付き,真下向きは板が背後でバッフル無し)",
                 fontsize=13)
    fig.tight_layout()
    fig.savefig(output, dpi=100)
    plt.close(fig)
    print(f"出力: {output}")


# ---------------------------------------------------------------------------
# 図 3: 灯数
# ---------------------------------------------------------------------------


def draw_count(results: list[dict], output: Path) -> None:
    """灯数 N の効果(θ=90°)と N=4 の θ 依存を描く."""
    fig, axes = plt.subplots(1, 3, figsize=(20, 6))
    colors = size_colors(len(SIZE_PAIRS))
    ns = [1] + COUNT_N
    for shape, i, size in shape_size_pairs():
        rows = [select(results, shape=shape, size_mm=size, theta_deg=COUNT_THETA,
                       aim="board", n_led=n, rep=1) for n in ns]
        xs = [n for n, r in zip(ns, rows, strict=True) if r]
        picked = [r[0] for r in rows if r]
        if not picked:
            continue
        label = f"{SHAPE_LABEL[shape]} {size_label(shape, size)}"
        style = dict(SHAPE_STYLE[shape], color=colors[i], ms=5, lw=1.4, label=label)
        axes[0].plot(xs, [r["structure_cov"] for r in picked], **style)
        axes[1].plot(xs, [r["m1_abs"] * 100 for r in picked], **style)
        axes[1].plot(xs, [r["mN_abs"] * 100 for r in picked], marker="x", ls=":",
                     color=colors[i], lw=1.0, alpha=0.8)
        n4 = sorted(select(results, shape=shape, size_mm=size, aim="board", n_led=4, rep=1),
                    key=lambda r: r["theta_deg"])
        if n4:
            axes[2].plot([r["theta_deg"] for r in n4], [r["structure_cov"] for r in n4], **style)
    axes[0].set_xlabel("灯数 N(θ=90° の輪に等方位・総光束一定)")
    axes[0].set_ylabel("構造 CoV")
    axes[0].set_title("灯数 vs 構造 CoV", fontsize=11)
    axes[1].set_xlabel("灯数 N")
    axes[1].set_ylabel("方位成分(輪帯平均比 %)")
    axes[1].set_title("|m1|(実線)と |m=N|(点線 ×)vs 灯数", fontsize=11)
    axes[2].set_xlabel("LED 極角 θ [deg]")
    axes[2].set_ylabel("構造 CoV")
    axes[2].set_title("N=4 の θ 依存(θ=60/90/120)", fontsize=11)
    for ax in axes:
        ax.grid(alpha=0.3)
        ax.legend(fontsize=7, ncol=2)
    axes[0].set_xticks(ns)
    axes[1].set_xticks(ns)
    fig.suptitle("灯数の効果: 多灯化で配置の痕跡はどれだけ消えるか(板中心向き・各灯にバッフル)",
                 fontsize=14)
    fig.tight_layout()
    fig.savefig(output, dpi=100)
    plt.close(fig)
    print(f"出力: {output}")


# ---------------------------------------------------------------------------
# 表
# ---------------------------------------------------------------------------


# ---------------------------------------------------------------------------
# 図 4・5: 追加実験(方位角・真下向き多灯)
# ---------------------------------------------------------------------------


def draw_azimuth(results: list[dict], output: Path) -> None:
    """立方体の面中央(φ=0)→ 稜寄り(φ=40°)の方位角依存を描く(球 φ=45° は対照)."""
    fig, axes = plt.subplots(1, 2, figsize=(14, 5.5))
    colors = size_colors(len(SIZE_PAIRS))
    for shape, i, size in shape_size_pairs():
        phis = [0.0] + (AZIMUTH_CUBE if shape == "cube" else AZIMUTH_SPHERE)
        rows = [select(results, shape=shape, size_mm=size, theta_deg=AZIMUTH_THETA,
                       aim="board", n_led=1, rep=1, azimuth_deg=phi) for phi in phis]
        xs = [phi for phi, r in zip(phis, rows, strict=True) if r]
        picked = [r[0] for r in rows if r]
        if len(picked) < 2:
            continue
        style = dict(SHAPE_STYLE[shape], color=colors[i], ms=5, lw=1.4,
                     label=f"{SHAPE_LABEL[shape]} {size_label(shape, size)}")
        axes[0].plot(xs, [r["structure_cov"] for r in picked], **style)
        axes[1].plot(xs, [r["m1_abs"] * 100 for r in picked], **style)
    axes[0].set_ylabel("構造 CoV")
    axes[0].set_title("方位角 φ vs 構造 CoV(θ=90°・1 灯・板中心向き)", fontsize=11)
    axes[1].set_ylabel("|m1|(輪帯平均比 %)")
    axes[1].set_title("方位角 φ vs 非対称 |m1|", fontsize=11)
    for ax in axes:
        ax.set_xlabel("LED 方位角 φ [deg](立方体: 0=面の中央・45=稜.球は対照)")
        ax.grid(alpha=0.3)
        ax.legend(fontsize=7, ncol=2)
    fig.suptitle("追加実験 E: 立方体の面のどこに LED を置くか(稜に寄せると悪化するか)",
                 fontsize=13)
    fig.tight_layout()
    fig.savefig(output, dpi=100)
    plt.close(fig)
    print(f"出力: {output}")


def draw_ring(results: list[dict], output: Path) -> None:
    """真下向き(バッフル無し)多灯と板向き(バッフル付き)多灯を灯数で比べる."""
    fig, axes = plt.subplots(1, 3, figsize=(20, 6))
    colors = size_colors(len(SIZE_PAIRS))
    ns = [1, 2, 4, 8]
    for shape, i, size in shape_size_pairs():
        for aim, ax_idx, ls in (("down", 0, "-"), ("board", 1, "--")):
            picked = []
            for n in ns:
                azimuth = CUBE_N8_AZIMUTH if (shape == "cube" and n == 8) else 0.0
                rows = select(results, shape=shape, size_mm=size, theta_deg=COUNT_THETA,
                              aim=aim, n_led=n, rep=1, azimuth_deg=azimuth)
                if rows:
                    picked.append((n, rows[0]))
            if len(picked) < 2:
                continue
            style = dict(marker=SHAPE_STYLE[shape]["marker"], ls=ls, color=colors[i], ms=5,
                         lw=1.4, label=f"{SHAPE_LABEL[shape]} {size_label(shape, size)}")
            axes[ax_idx].plot([n for n, _ in picked], [r["structure_cov"] for _, r in picked],
                              **style)
            if aim == "down":
                axes[2].plot([n for n, _ in picked], [r["raw_mean_roi"] for _, r in picked],
                             **style)
    axes[0].set_title("真下向き(バッフル無し): 灯数 vs 構造 CoV", fontsize=11)
    axes[1].set_title("板中心向き(各灯にバッフル): 灯数 vs 構造 CoV", fontsize=11)
    axes[2].set_title("真下向き: 灯数 vs 生 mean(総光束一定)", fontsize=11)
    for ax in axes[:2]:
        ax.set_ylabel("構造 CoV")
    axes[2].set_ylabel("ROI 生 mean")
    for ax in axes:
        ax.set_xlabel("灯数 N(θ=90° の輪に等方位・総光束一定)")
        ax.set_xticks(ns)
        ax.grid(alpha=0.3)
        ax.legend(fontsize=7, ncol=2)
    fig.suptitle("追加実験 F: 真下向き多灯(バッフル無し)は実機で最も簡単な構成として成立するか",
                 fontsize=13)
    fig.tight_layout()
    fig.savefig(output, dpi=100)
    plt.close(fig)
    print(f"出力: {output}")


def print_extra_tables(results: list[dict]) -> None:
    """追加実験(方位角・真下向き多灯)の表を出す."""
    shape, size, phi_cal = AZIMUTH_CALIBRATION
    calib = select(results, shape=shape, size_mm=size, theta_deg=AZIMUTH_THETA, aim="board",
                   n_led=1, rep=1, azimuth_deg=phi_cal)
    base = select(results, shape=shape, size_mm=size, theta_deg=AZIMUTH_THETA, aim="board",
                  n_led=1, rep=1, azimuth_deg=0.0)
    print("\n=== 方位角(θ=90°・1 灯・板中心向き)===")
    if calib and base:
        print(f"  符号較正 φ={phi_cal:g}°: a1={calib[0]['a1'] * 100:+.3f}% "
              f"b1={calib[0]['b1'] * 100:+.3f}% → 投影 a1_led={calib[0]['a1_led'] * 100:+.3f}%"
              f"(φ=0 の a1={base[0]['a1'] * 100:+.3f}%)")
    print(f"{'条件':>36} {'φ':>4} {'a1_led [%]':>10} {'|m1| [%]':>9} {'構造CoV':>8} "
          f"{'輪帯min/max':>11}")
    for r in sorted(select(results, theta_deg=AZIMUTH_THETA, aim="board", n_led=1, rep=1),
                    key=lambda r: (r["shape"], r["size_mm"], r["azimuth_deg"])):
        print(f"{r['name']:>36} {r['azimuth_deg']:4.0f} {r['a1_led'] * 100:10.3f} "
              f"{r['m1_abs'] * 100:9.3f} {r['structure_cov']:8.4f} {r['ring_min_max']:11.4f}")

    print("\n=== 真下向き多灯(バッフル無し)と板向き多灯 ===")
    print(f"{'条件':>36} {'|m1| [%]':>9} {'|mN| [%]':>9} {'構造CoV':>8} {'輪帯min/max':>11} "
          f"{'生mean':>7}")
    ring_specs = {(aim, theta, n) for aim, theta, n in RING_SPECS}
    for r in sorted(select(results, rep=1),
                    key=lambda r: (r["shape"], r["size_mm"], r["aim"], r["theta_deg"],
                                   r["n_led"])):
        key = (r["aim"], r["theta_deg"], int(r["n_led"]))
        single_down = r["aim"] == "down" and r["n_led"] == 1 and r["theta_deg"] in (60.0, 90.0)
        if key not in ring_specs and not single_down:
            continue
        print(f"{r['name']:>36} {r['m1_abs'] * 100:9.3f} {r['mN_abs'] * 100:9.3f} "
              f"{r['structure_cov']:8.4f} {r['ring_min_max']:11.4f} {r['raw_mean_roi']:7.3f}")


def print_tables(results: list[dict], summary: list[dict]) -> None:
    """集約表を標準出力に書く."""
    print("\n=== 位置スイープの集約(θ 全域)===")
    print(f"{'形状':>6} {'サイズ':>7} {'内面積':>9} {'θ数':>3} {'構造CoV軸上':>11} {'平均':>8} "
          f"{'最大':>8} {'幅':>8} {'max|a1|%':>9} {'最悪θ':>6}")
    for r in summary:
        print(f"{SHAPE_LABEL[r['shape']]:>6} {r['size_mm']:7g} {r['area']:9.0f} {r['n_theta']:3d} "
              f"{r['struct_onaxis']:11.4f} {r['struct_mean']:8.4f} {r['struct_max']:8.4f} "
              f"{r['struct_range']:8.4f} {r['a1_absmax'] * 100:9.2f} {r['theta_worst']:6.0f}")

    print("\n=== 位置スイープの全条件 ===")
    print(f"{'条件':>30} {'a1 [%]':>14} {'b1 [%]':>8} {'構造CoV':>8} {'ノイズ':>7} "
          f"{'輪帯min/max':>11} {'重心X':>8} {'生mean':>7} {'遮蔽':>6}")
    for r in sorted(select(results, group="position"),
                    key=lambda r: (r["shape"], r["size_mm"], r["theta_deg"])):
        occl = r["occlusion_blocked_frac"]
        occl = float(occl) if occl not in ("", None) else float("nan")
        print(f"{r['name']:>30} {r['a1'] * 100:7.3f}±{r['se1'] * 100:5.3f} {r['b1'] * 100:8.3f} "
              f"{r['structure_cov']:8.4f} {r['noise_cov']:7.4f} {r['ring_min_max']:11.4f} "
              f"{r['centroid_x']:8.4f} {r['raw_mean_roi']:7.3f} {occl:6.3f}")

    print("\n=== 向き(1 灯)===")
    print(f"{'条件':>30} {'バッフルR/寸法':>13} {'a1 [%]':>8} {'|m1| [%]':>9} {'構造CoV':>8} "
          f"{'生mean':>7}")
    aimed = select(results, group="direction") + select(results, group="position", n_led=1)
    for r in sorted(aimed, key=lambda r: (r["shape"], r["size_mm"], r["theta_deg"], r["aim"])):
        if r["theta_deg"] not in DIRECTION_THETAS:
            continue
        print(f"{r['name']:>30} {r['baffle_ratio']:13.3f} {r['a1'] * 100:8.3f} "
              f"{r['m1_abs'] * 100:9.3f} {r['structure_cov']:8.4f} {r['raw_mean_roi']:7.3f}")

    print("\n=== 灯数 ===")
    print(f"{'条件':>30} {'|m1| [%]':>9} {'aN [%]':>8} {'|mN| [%]':>9} {'構造CoV':>8} "
          f"{'生mean':>7}")
    for r in sorted(select(results, aim="board", rep=1),
                    key=lambda r: (r["shape"], r["size_mm"], r["theta_deg"], r["n_led"])):
        if r["n_led"] == 1 and r["theta_deg"] not in (COUNT_THETA, 60.0, 120.0):
            continue
        print(f"{r['name']:>30} {r['m1_abs'] * 100:9.3f} {r['aN'] * 100:8.3f} "
              f"{r['mN_abs'] * 100:9.3f} {r['structure_cov']:8.4f} {r['raw_mean_roi']:7.3f}")

    print("\n=== 再現性(同一条件の再レンダ)===")
    for shape in SHAPES:
        # 再現性ペアの同一条件(rep=1 の元条件 + 再レンダ)をまとめて統計を取る
        reps = select(results, shape=shape, pair=REPEAT_PAIR_INDEX, theta_deg=COUNT_THETA,
                      aim="board", n_led=1)
        if len(reps) < 2:
            continue
        struct = np.array([r["structure_cov"] for r in reps])
        a1 = np.array([r["a1"] for r in reps])
        print(f"  {SHAPE_LABEL[shape]} {size_label(shape, reps[0]['size_mm'])} n={len(reps)}: "
              f"構造CoV {struct.mean():.4f} ± {struct.std(ddof=1):.4f}, "
              f"a1 {a1.mean() * 100:.3f} ± {a1.std(ddof=1) * 100:.3f} %")


def write_analysis_csv(results: list[dict], summary: list[dict]) -> None:
    """条件別の解析値と位置スイープの集約を CSV に書く."""
    path = OUTPUT_BASE / "analysis.csv"
    with path.open("w", newline="", encoding="utf-8") as fh:
        writer = csv.DictWriter(fh, fieldnames=ANALYSIS_COLUMNS)
        writer.writeheader()
        for r in results:
            writer.writerow({k: r.get(k, "") for k in ANALYSIS_COLUMNS})
    path = OUTPUT_BASE / "position_summary.csv"
    if summary:
        with path.open("w", newline="", encoding="utf-8") as fh:
            writer = csv.DictWriter(fh, fieldnames=list(summary[0].keys()))
            writer.writeheader()
            writer.writerows(summary)
    (OUTPUT_BASE / "analysis.json").write_text(
        json.dumps({"results": results, "position_summary": summary}, ensure_ascii=False,
                   indent=1),
        encoding="utf-8",
    )


def main() -> int:
    """照明配置スイープの解析図と集約表を生成する."""
    parser = argparse.ArgumentParser(description="照明配置スイープの解析図を生成")
    parser.add_argument("--no-figures", action="store_true", help="表と CSV だけ出す")
    parser.add_argument("--material", choices=tuple(MATERIALS), default="legacy",
                        help="解析する材質系列(スイープの --material と同じ)")
    args = parser.parse_args()
    global OUTPUT_BASE  # noqa: PLW0603
    set_material(args.material)
    OUTPUT_BASE = sweep.OUTPUT_BASE

    write_summary()
    rows = collect_rows()
    results = [res for res in (analyze_condition(r) for r in rows) if res is not None]
    if not results:
        print("エラー: スイープの結果がありません(先にレンダを実行してください)")
        return 1
    summary = robustness_summary(results)
    write_analysis_csv(results, summary)
    if not args.no_figures:
        draw_position(results, summary, OUTPUT_BASE / "position_sensitivity.png")
        draw_position_maps(results, OUTPUT_BASE / "position_maps.png")
        draw_direction(results, OUTPUT_BASE / "direction_comparison.png")
        draw_count(results, OUTPUT_BASE / "count_comparison.png")
        draw_azimuth(results, OUTPUT_BASE / "azimuth_comparison.png")
        draw_ring(results, OUTPUT_BASE / "ring_comparison.png")
    print_tables(results, summary)
    print_extra_tables(results)
    return 0


if __name__ == "__main__":
    sys.exit(main())