diff --git a/scripts/build_led_placement_comparison.py b/scripts/build_led_placement_comparison.py index 1b6ee69..6400ca2 100644 --- a/scripts/build_led_placement_comparison.py +++ b/scripts/build_led_placement_comparison.py @@ -42,11 +42,17 @@ 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, OUTPUT_BASE, REPEAT_PAIR_INDEX, + RING_SPECS, SHAPES, SIZE_PAIRS, THETA_GRID, @@ -56,7 +62,7 @@ plt.rcParams["font.family"] = ["Yu Gothic", "MS Gothic", "DejaVu Sans"] -HARMONICS = (1, 2, 3, 4) +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="--")} @@ -66,10 +72,12 @@ 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", "a2", "b2", "se2", "a3", "b3", "se3", "a4", "b4", "se4", + "a1", "b1", "se1", "m1_abs", "a1_led", "a2", "b2", "se2", "a3", "b3", "se3", + "a4", "b4", "se4", "a8", "b8", "se8", "aN", "mN_abs", ] @@ -114,6 +122,10 @@ 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}"])) @@ -385,6 +397,119 @@ # --------------------------------------------------------------------------- +# --------------------------------------------------------------------------- +# 図 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=== 位置スイープの集約(θ 全域)===") @@ -480,7 +605,10 @@ 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 diff --git a/scripts/sweep_led_placement.py b/scripts/sweep_led_placement.py index 5ecfdfc..85fc621 100644 --- a/scripts/sweep_led_placement.py +++ b/scripts/sweep_led_placement.py @@ -11,6 +11,8 @@ 「真下向き(板が発光面の背後=バッフル不要)」を比べる C. 個数 : θ の輪に N 灯を等方位に置き総光束を保存する(radiance / N) D. 再現性 : 同一条件の再レンダでノイズフロアを測る + E. 方位角 : 立方体の面中央(φ=0)→ 稜寄り(φ=40°)で位置依存が変わるか(追加実験) + F. 真下向き多灯 : バッフル無しの真下向き N=2/4/8 灯(最も簡単な実機構成,追加実験) θ の定義(両形状共通): キャビティ中心 c(球: 球中心,立方体: 立方体中心)から方向 @@ -125,7 +127,19 @@ # D. 再現性(同一条件の追加レンダ回数) REPEATS = 3 -GROUPS = ("position", "direction", "count", "repeat") +# E. 方位角(追加実験): 立方体の面中央(φ=0)→ 稜寄り(φ=40°).球は φ=45° が対照(軸対称). +# φ=90° は m=1 の投影の符号較正用(+Y 面=面中央なので φ=0 と同じ値になるはず) +AZIMUTH_THETA = 90.0 +AZIMUTH_CUBE = [15.0, 30.0, 40.0] +AZIMUTH_SPHERE = [45.0] +AZIMUTH_CALIBRATION = ("cube", 234.0, 90.0) +# F. 真下向き多灯(追加実験): バッフル無しで最も簡単な実機構成.(向き, θ, N) +# 立方体の N=8 は稜(φ=45°)を避けるため方位を 22.5° ずらす +RING_SPECS = [("down", 90.0, 2), ("down", 90.0, 4), ("down", 90.0, 8), ("down", 60.0, 4), + ("board", 90.0, 8)] +CUBE_N8_AZIMUTH = 22.5 + +GROUPS = ("position", "direction", "count", "repeat", "azimuth", "ring") # 評価板の中心(キャビティ側の面)= 板向き LED の照準点 BOARD_CENTER = np.array([0.0, 0.0, BOARD_SURFACE_Z]) @@ -135,7 +149,7 @@ BAFFLE_RADIUS_CAP_RATIO = 0.6 SUMMARY_COLUMNS = [ - "name", "group", "shape", "size_mm", "theta_deg", "aim", "n_led", "rep", + "name", "group", "shape", "size_mm", "theta_deg", "aim", "n_led", "rep", "azimuth_deg", "cavity_area_mm2", "led_x_mm", "led_y_mm", "led_z_mm", "led_standoff_mm", "tilt_deg", "baffled", "baffle_radius_mm", "baffle_embed_mm", "board_front_frac", "occlusion_blocked_frac", "occlusion_total", @@ -157,13 +171,18 @@ aim: str n_led: int rep: int = 1 + azimuth_deg: float = 0.0 @property def base_name(self) -> str: - """幾何(メッシュ・バッフル)を共有する名前(再レンダ条件は seed だけが違う).""" + """幾何(メッシュ・バッフル)を共有する名前(再レンダ条件は seed だけが違う). + + 方位角は 0 以外のときだけ付ける(追加実験より前の条件名を変えないため). + """ prefix = "r" if self.shape == "sphere" else "L" + azimuth = f"_az{self.azimuth_deg:03.0f}" if self.azimuth_deg else "" return (f"{self.shape}_{prefix}{self.size:g}_th{self.theta_deg:03.0f}" - f"_{self.aim}_n{self.n_led}") + f"_{self.aim}_n{self.n_led}{azimuth}") @property def name(self) -> str: @@ -172,8 +191,8 @@ @property def azimuths_deg(self) -> list[float]: - """各 LED の方位角(1 灯目は +X 側,以降等間隔).""" - return [360.0 * k / self.n_led for k in range(self.n_led)] + """各 LED の方位角(1 灯目は azimuth_deg,以降等間隔).""" + return [self.azimuth_deg + 360.0 * k / self.n_led for k in range(self.n_led)] def shape_sizes() -> list[tuple[str, float]]: @@ -211,6 +230,16 @@ size = SIZE_PAIRS[REPEAT_PAIR_INDEX][shape_index] for rep in range(2, REPEATS + 2): add(Condition("repeat", shape, size, COUNT_THETA, "board", 1, rep)) + for shape, size in shape_sizes(): + for azimuth in AZIMUTH_CUBE if shape == "cube" else AZIMUTH_SPHERE: + add(Condition("azimuth", shape, size, AZIMUTH_THETA, "board", 1, + azimuth_deg=azimuth)) + add(Condition("azimuth", *AZIMUTH_CALIBRATION[:2], AZIMUTH_THETA, "board", 1, + azimuth_deg=AZIMUTH_CALIBRATION[2])) + for shape, size in shape_sizes(): + for aim, theta, n in RING_SPECS: + azimuth = CUBE_N8_AZIMUTH if (shape == "cube" and n == 8) else 0.0 + add(Condition("ring", shape, size, theta, aim, n, azimuth_deg=azimuth)) return result @@ -614,6 +643,7 @@ row: dict = { "name": cond.name, "group": cond.group, "shape": cond.shape, "size_mm": cond.size, "theta_deg": cond.theta_deg, "aim": cond.aim, "n_led": cond.n_led, "rep": cond.rep, + "azimuth_deg": cond.azimuth_deg, } row.update(record) row["render_variant"] = variant diff --git a/tests/scripts/test_sweep_led_placement.py b/tests/scripts/test_sweep_led_placement.py index c952595..a171888 100644 --- a/tests/scripts/test_sweep_led_placement.py +++ b/tests/scripts/test_sweep_led_placement.py @@ -9,7 +9,7 @@ - スクリプト冒頭の条件設計(位置 θ グリッド / 向き / 個数 / 再現性の 4 グループ) 検証する仕様: -- conditions() が設計どおりの条件集合(198 条件・名前が一意・重要な順)を返す +- conditions() が設計どおりの条件集合(255 条件・名前が一意・重要な順)を返す - --list で条件名の一覧だけを表示して終了する(レンダしない) - prepare_condition() が条件ごとのシーン YAML を作り,再レンダ条件(rep>1)は 幾何を共有したまま render.seed = rep-1 の別 YAML になる @@ -73,13 +73,14 @@ def test_conditions_total_count(sweep): - """全条件数が 198 であること. + """全条件数が 255 であること. 仕様: 位置 2 形状 × 4 サイズ × 13θ = 104,個数 2 × 4 × (3 + 2) = 40, 向き 2 × 4 × 3θ × 3 向き = 72 のうち θ=60/90/120 の板向き 1 灯 24 条件は - 位置グループと重複するので 48,再現性 2 形状 × 3 回 = 6.合計 198. + 位置グループと重複するので 48,再現性 2 形状 × 3 回 = 6, + 方位角 立方体 3 × 4 + 球 1 × 4 + 較正 1 = 17,真下向き多灯 2 × 4 × 5 = 40.合計 255. """ - assert len(sweep.conditions()) == 198 + assert len(sweep.conditions()) == 255 def test_condition_names_are_unique(sweep): @@ -107,7 +108,7 @@ def test_shapes_are_balanced(sweep): """球と立方体が同数であること(同内面積ペアでの比較が目的).""" - conds = sweep.conditions() + conds = [c for c in sweep.conditions() if c.group != "azimuth"] # 方位角群だけ数が非対称 assert sum(c.shape == "sphere" for c in conds) == sum(c.shape == "cube" for c in conds) @@ -151,8 +152,8 @@ monkeypatch.setattr(sys, "argv", ["sweep_led_placement.py", "--list"]) assert sweep.main() == 0 out = capsys.readouterr().out - assert "198 条件" in out - assert out.count("\n") == 199 # 条件名 198 行 + 件数 1 行 + assert "255 条件" in out + assert out.count("\n") == 256 # 条件名 255 行 + 件数 1 行 def test_list_respects_shape_and_group_filters(sweep, monkeypatch, capsys):