"""照明配置スイープの解析: 位置・向き・個数への依存を球と立方体で比較する(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,
)
from sm_tias_lightsim.evaluation.fullimage import calc_fullimage_min_max_ratio # noqa: E402
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 # 輝度マップに使う代表ペア(球 φ340 / 立方体 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", "fullimage_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:
"""条件ラベル用のサイズ表記(球は内径 φ=2r で表す.内部パラメータは半径のまま)."""
return f"φ{2 * size:g}" if shape == "sphere" else f"L={size:g}"
def pair_size(pair: tuple[float, float], shape: str) -> float:
"""同内面積ペアから該当形状のサイズ(球=内半径 r / 立方体=内寸 L)を取り出す(表示は φ=2r)."""
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["fullimage_min_max"] = calc_fullimage_min_max_ratio(
load_roi(lum))["fullimage_min_max_ratio"]
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}: φ{2 * 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())