"""scripts/sweep_led_placement.py(照明配置スイープ)の仕様ベーステスト.
仕様出典:
- docs/06_TEST/TEST_17_オフ軸LED配置の影響とサイズ依存.md(オフ軸配置の定量.本スイープの前段)
- docs/04_SPEC/SPEC_02_シーン定義仕様.md(シーン YAML スキーマ・render.seed)
- docs/02_ENV/ENV_04_開発コマンド.md「研究室サーバでの実行」
(_tmp YAML のメッシュパスは YAML からの相対パスでなければサーバで読めない.
同じ seed で読み直すと指標がほぼ一致するので,再現性チェックには seed を振る)
- スクリプト冒頭の条件設計(位置 θ グリッド / 向き / 個数 / 再現性の 4 グループ)
検証する仕様:
- conditions() が設計どおりの条件集合(255 条件・名前が一意・重要な順)を返す
- --list で条件名の一覧だけを表示して終了する(レンダしない)
- prepare_condition() が条件ごとのシーン YAML を作り,再レンダ条件(rep>1)は
幾何を共有したまま render.seed = rep-1 の別 YAML になる
- YAML に書くメッシュパスは YAML からの相対パス(POSIX 区切り・ドライブ無し)
テスト方針(testing.md):
- sm-render / sm-evaluate は呼ばない(レンダはしない).幾何生成と YAML だけを見る
- TMP_DIR / MESH_DIR を tmp_path に差し替える
(tests/scripts/test_sweep_relative_mesh_paths.py と同じ流儀)
- 筐体メッシュ生成は球で十数秒かかるので,生成を伴うテストは立方体条件のみを使い,
モジュールスコープのフィクスチャで 1 度だけ生成する
"""
from __future__ import annotations
import importlib.util
import json
import sys
from pathlib import Path, PurePosixPath, PureWindowsPath
from types import ModuleType
from unittest import mock
import pytest
import yaml
_ROOT = Path(__file__).resolve().parents[2]
_SCRIPTS = _ROOT / "scripts"
# 再現性グループで使う立方体条件(スクリプトの SIZE_PAIRS[REPEAT_PAIR_INDEX] と対応)
_CUBE_SIZE = 234.0
def _load(name: str) -> ModuleType:
"""scripts/<name>.py を import する(既に読み込み済みならそれを使う)."""
path = (_SCRIPTS / f"{name}.py").resolve()
cached = sys.modules.get(name)
if cached is not None and Path(getattr(cached, "__file__", "")).resolve() == path:
return cached
spec = importlib.util.spec_from_file_location(name, path)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
sys.modules[name] = module
spec.loader.exec_module(module)
return module
@pytest.fixture(scope="module")
def sweep() -> ModuleType:
return _load("sweep_led_placement")
@pytest.fixture(autouse=True)
def _same_drive(tmp_path):
if tmp_path.resolve().drive.upper() != _ROOT.drive.upper():
pytest.skip("tmp_path とリポジトリが別ドライブのため相対パスで表せない環境")
# ---------------------------------------------------------------------------
# 条件集合
# ---------------------------------------------------------------------------
def test_conditions_total_count(sweep):
"""全条件数が 255 であること.
仕様: 位置 2 形状 × 4 サイズ × 13θ = 104,個数 2 × 4 × (3 + 2) = 40,
向き 2 × 4 × 3θ × 3 向き = 72 のうち θ=60/90/120 の板向き 1 灯 24 条件は
位置グループと重複するので 48,再現性 2 形状 × 3 回 = 6,
方位角 立方体 3 × 4 + 球 1 × 4 + 較正 1 = 17,真下向き多灯 2 × 4 × 5 = 40.合計 255.
"""
assert len(sweep.conditions()) == 255
def test_condition_names_are_unique(sweep):
"""条件名が一意であること(出力ディレクトリ名として使われるため)."""
names = [c.name for c in sweep.conditions()]
assert len(names) == len(set(names))
def test_conditions_cover_all_groups(sweep):
"""4 つのグループがすべて含まれること."""
groups = {c.group for c in sweep.conditions()}
assert groups == set(sweep.GROUPS)
def test_group_counts_match_design(sweep):
"""グループごとの条件数が設計どおりであること."""
from collections import Counter
counts = Counter(c.group for c in sweep.conditions())
assert counts["position"] == 104
assert counts["count"] == 40
assert counts["direction"] == 48
assert counts["repeat"] == 6
def test_shapes_are_balanced(sweep):
"""球と立方体が同数であること(同内面積ペアでの比較が目的)."""
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)
def test_position_group_comes_first(sweep):
"""主実験(位置)が先頭に並ぶこと(途中で止めても主実験が揃うようにする)."""
groups = [c.group for c in sweep.conditions()]
assert groups[0] == "position"
assert set(groups[:104]) == {"position"}
def test_repeat_conditions_share_base_name_and_differ_by_rep(sweep):
"""再現性条件は同じ base_name で rep だけが違うこと(幾何は同一)."""
repeats = [c for c in sweep.conditions() if c.group == "repeat"]
assert repeats, "再現性グループの条件が存在すること"
for cond in repeats:
assert cond.rep > 1
assert cond.name == f"{cond.base_name}_rep{cond.rep}"
def test_position_conditions_use_rep_one(sweep):
"""再現性以外の条件は rep=1(名前に rep が付かない)であること."""
for cond in sweep.conditions():
if cond.group != "repeat":
assert cond.rep == 1
assert cond.name == cond.base_name
def test_azimuths_are_evenly_spaced(sweep):
"""多灯条件の方位角が等間隔で,1 灯目が +X 側であること."""
cond = sweep.Condition("count", "cube", _CUBE_SIZE, 90.0, "board", 4)
assert cond.azimuths_deg == [0.0, 90.0, 180.0, 270.0]
# ---------------------------------------------------------------------------
# --list(レンダせず一覧のみ)
# ---------------------------------------------------------------------------
def test_list_prints_all_condition_names(sweep, monkeypatch, capsys):
"""--list が全条件名と件数を表示して 0 を返すこと(レンダしない)."""
monkeypatch.setattr(sys, "argv", ["sweep_led_placement.py", "--list"])
assert sweep.main() == 0
out = capsys.readouterr().out
assert "255 条件" in out
assert out.count("\n") == 256 # 条件名 255 行 + 件数 1 行
def test_list_respects_shape_and_group_filters(sweep, monkeypatch, capsys):
"""--shapes / --groups で条件を絞れること."""
monkeypatch.setattr(
sys,
"argv",
["sweep_led_placement.py", "--list", "--shapes", "cube", "--groups", "position"],
)
assert sweep.main() == 0
lines = [ln for ln in capsys.readouterr().out.splitlines() if ln]
names = lines[:-1]
assert lines[-1] == "52 条件" # 4 サイズ × 13θ
assert all(name.startswith("cube_") for name in names)
# ---------------------------------------------------------------------------
# prepare_condition(幾何生成 + シーン YAML)
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def prepared(sweep, tmp_path_factory) -> dict[str, tuple[Path, dict]]:
"""立方体条件の rep1 / rep2 / rep3 を 1 度だけ生成する.
筐体メッシュは同じファイルを共有するので,2 件目以降の生成は速い.
"""
base = tmp_path_factory.mktemp("led_placement")
if base.resolve().drive.upper() != _ROOT.drive.upper():
pytest.skip("tmp_path とリポジトリが別ドライブのため相対パスで表せない環境")
conds = [
sweep.Condition("repeat", "cube", _CUBE_SIZE, 90.0, "board", 1, rep)
for rep in (1, 2, 3)
]
with (
mock.patch.object(sweep, "TMP_DIR", base),
mock.patch.object(sweep, "MESH_DIR", base / "meshes"),
):
return {cond.name: sweep.prepare_condition(cond) for cond in conds}
def _load_yaml(path: Path) -> dict:
return yaml.safe_load(path.read_text(encoding="utf-8"))
def test_prepare_writes_scene_yaml_named_after_condition(sweep, prepared):
"""条件ごとに scene_<name>.yaml が作られること."""
for name, (scene_yaml, _) in prepared.items():
assert scene_yaml.name == f"scene_{name}.yaml"
assert scene_yaml.is_file()
def test_repeat_conditions_get_separate_yaml_files(sweep, prepared):
"""再レンダ条件(rep>1)は元の条件とは別の YAML になること."""
paths = {str(scene_yaml) for scene_yaml, _ in prepared.values()}
assert len(paths) == len(prepared)
def test_seed_equals_rep_minus_one(sweep, prepared):
"""render.seed が rep-1 になること.
仕様: seed が同じだとシーンを読み直しても指標が 4 桁まで一致してノイズフロアにならない
(ENV_04「研究室サーバでの実行」の実測).再レンダは seed を振って別実現にする.
"""
for name, (scene_yaml, _) in prepared.items():
rep = int(name.split("_rep")[1]) if "_rep" in name else 1
assert _load_yaml(scene_yaml)["render"]["seed"] == rep - 1, name
def test_first_rep_uses_default_seed_zero(sweep, prepared):
"""rep=1(通常条件)の seed が 0 であること(既定値と同じ)."""
scene_yaml, _ = prepared["cube_L234_th090_board_n1"]
assert _load_yaml(scene_yaml)["render"]["seed"] == 0
def test_repeat_yaml_differs_only_in_seed_and_name(sweep, prepared):
"""再レンダ条件の YAML は seed・name・description 以外が元の条件と同一であること.
仕様: 幾何(筐体・LED・バッフル)は共有し,変えるのは乱数シードだけ.
"""
base = _load_yaml(prepared["cube_L234_th090_board_n1"][0])
rep2 = _load_yaml(prepared["cube_L234_th090_board_n1_rep2"][0])
for key in ("name", "description"):
base.pop(key, None)
rep2.pop(key, None)
assert base["render"].pop("seed") == 0
assert rep2["render"].pop("seed") == 1
assert base == rep2
def test_scene_yaml_is_valid_scene_config(sweep, prepared):
"""生成した YAML が SceneConfig として検証を通ること(SPEC_02 スキーマ)."""
from sm_tias_lightsim.scene.schema import SceneConfig
scene_yaml, _ = prepared["cube_L234_th090_board_n1"]
config = SceneConfig.model_validate(_load_yaml(scene_yaml))
assert config.render.seed == 0
def test_mesh_paths_are_relative_to_yaml(sweep, prepared):
"""メッシュパスが YAML からの相対パス(POSIX 区切り・ドライブ無し)であること.
仕様: Windows 絶対パスの YAML は研究室サーバで読めない(ENV_04 注意).
"""
from sm_tias_lightsim.remote.files import PushEntry, find_yaml_with_windows_paths
scene_yaml, _ = prepared["cube_L234_th090_board_n1"]
meshes = _load_yaml(scene_yaml)["geometry"]["meshes"]
assert meshes, "メッシュが 1 つ以上書かれていること"
for mesh in meshes:
path = mesh["path"]
assert "\\" not in path, f"{mesh['id']}: POSIX 区切りでない: {path}"
assert not PurePosixPath(path).is_absolute(), f"{mesh['id']}: 絶対パス: {path}"
assert not PureWindowsPath(path).drive, f"{mesh['id']}: ドライブ付き: {path}"
assert (scene_yaml.parent / path).resolve().is_file(), f"{mesh['id']}: 実体が無い: {path}"
entry = PushEntry(
path=scene_yaml, arcname=f"configs/_tmp_led_placement/{scene_yaml.name}", size=0
)
assert find_yaml_with_windows_paths([entry]) == []
def test_housing_mesh_is_shared_between_repeats(sweep, prepared):
"""再レンダ条件が同じ筐体メッシュを参照すること(幾何を作り直さない)."""
base_yaml, _ = prepared["cube_L234_th090_board_n1"]
rep_yaml, _ = prepared["cube_L234_th090_board_n1_rep2"]
def housing(path: Path) -> Path:
meshes = _load_yaml(path)["geometry"]["meshes"]
entry = next(m for m in meshes if m["id"] == "cube_housing")
return (path.parent / entry["path"]).resolve()
assert housing(base_yaml) == housing(rep_yaml)
def test_baffled_condition_records_baffle(sweep, prepared):
"""板の前方に発光面がある条件はバッフルを生成し,記録に残すこと."""
_, record = prepared["cube_L234_th090_board_n1"]
assert record["baffled"] == 1
assert record["baffle_radius_mm"] > 0.0
# ---------------------------------------------------------------------------
# variant の解決
# ---------------------------------------------------------------------------
def test_render_variant_prefers_env(sweep, monkeypatch):
"""SM_RENDER_VARIANT がテンプレート YAML の variant より優先されること."""
monkeypatch.setattr(sweep, "_render_variant", None)
monkeypatch.setenv("SM_RENDER_VARIANT", "cuda_ad_rgb")
assert sweep.render_variant() == "cuda_ad_rgb"
def test_render_variant_falls_back_to_template(sweep, monkeypatch):
"""環境変数が無ければテンプレート YAML の render.variant を使うこと."""
monkeypatch.setattr(sweep, "_render_variant", None)
monkeypatch.delenv("SM_RENDER_VARIANT", raising=False)
template = yaml.safe_load(sweep.TEMPLATE_YAML.read_text(encoding="utf-8"))
assert sweep.render_variant() == template["render"]["variant"]
def test_render_variant_is_cached(sweep, monkeypatch):
"""variant は 1 度だけ解決して使い回すこと(条件ごとの YAML 読み直しを避ける)."""
monkeypatch.setattr(sweep, "_render_variant", None)
monkeypatch.setenv("SM_RENDER_VARIANT", "cuda_ad_rgb")
first = sweep.render_variant()
monkeypatch.setenv("SM_RENDER_VARIANT", "scalar_rgb")
assert sweep.render_variant() == first
# ---------------------------------------------------------------------------
# run_job(レンダ + 評価をプロセス内で実行する)
# ---------------------------------------------------------------------------
def _fake_metrics_csv(path: Path) -> None:
"""read_metrics_csv が読む列を持つ metrics.csv を書く."""
columns = [
"cov", "center_periphery_ratio", "gradient_magnitude", "radial_min_max_ratio",
"angular_ratio", "pca_eccentricity", "intensity_centroid_offset_norm",
"max_min_ratio", "intensity_centroid_x_norm", "intensity_centroid_y_norm",
]
header = ",".join(columns)
values = ",".join("0.5" for _ in columns)
path.write_text(f"{header}\n{values}\n", encoding="utf-8")
def test_run_job_reuses_existing_row_json(sweep, tmp_path, monkeypatch):
"""既に row.json がある条件は再レンダせず,その内容をそのまま返すこと.
仕様: スイープは途中で止めて再開できる(既存結果の再利用).
"""
def _must_not_render(*args, **kwargs):
raise AssertionError("既存結果があるときはレンダしないこと")
monkeypatch.setattr(sweep, "OUTPUT_BASE", tmp_path / "output")
monkeypatch.setattr(sweep, "render_yaml", _must_not_render)
monkeypatch.setattr(sweep, "ensure_render_variant", _must_not_render)
cond = sweep.Condition("position", "cube", _CUBE_SIZE, 90.0, "board", 1)
out_dir = tmp_path / "output" / cond.name
out_dir.mkdir(parents=True)
(out_dir / "row.json").write_text(
json.dumps({"name": cond.name, "cov": 0.01}), encoding="utf-8"
)
row, _ = sweep.run_job(cond, tmp_path / "scene.yaml", {"baffled": 0})
assert row == {"name": cond.name, "cov": 0.01}
def test_run_job_renders_and_evaluates_without_figures(sweep, tmp_path, monkeypatch):
"""レンダ → 評価をプロセス内で呼び,評価は figures 無しで行うこと.
仕様: スイープでは図が不要なので save_figures=False で描画時間を省く.
"""
import numpy as np
calls: dict = {}
def fake_render_yaml(scene_yaml, out_dir, variant):
calls["render"] = {"scene": scene_yaml, "out_dir": out_dir, "variant": variant}
np.save(Path(out_dir) / "luminance.npy", np.full((512, 512), 100.0))
return Path(out_dir) / "render.png", Path(out_dir) / "luminance.npy"
def fake_evaluate_image(image, config, out_dir, save_figures=True):
calls["evaluate"] = {"image": image, "config": config, "save_figures": save_figures}
_fake_metrics_csv(Path(out_dir) / "metrics.csv")
return {}
monkeypatch.setattr(sweep, "OUTPUT_BASE", tmp_path / "output")
monkeypatch.setattr(sweep, "render_yaml", fake_render_yaml)
monkeypatch.setattr(sweep, "evaluate_image", fake_evaluate_image)
monkeypatch.setattr(sweep, "ensure_render_variant", lambda: "scalar_rgb")
cond = sweep.Condition("position", "cube", _CUBE_SIZE, 90.0, "board", 1)
row, _ = sweep.run_job(cond, tmp_path / "scene.yaml", {"baffled": 0})
assert calls["evaluate"]["save_figures"] is False
assert calls["render"]["variant"] == "scalar_rgb"
assert row["render_variant"] == "scalar_rgb"
assert row["name"] == cond.name
assert (tmp_path / "output" / cond.name / "row.json").is_file()
def test_run_job_returns_none_on_failure(sweep, tmp_path, monkeypatch):
"""1 条件が失敗しても例外を投げず None を返すこと(スイープ全体を止めない)."""
def boom(*args, **kwargs):
raise RuntimeError("テスト用レンダ失敗")
monkeypatch.setattr(sweep, "OUTPUT_BASE", tmp_path / "output")
monkeypatch.setattr(sweep, "render_yaml", boom)
monkeypatch.setattr(sweep, "ensure_render_variant", lambda: "scalar_rgb")
cond = sweep.Condition("position", "cube", _CUBE_SIZE, 90.0, "board", 1)
row, messages = sweep.run_job(cond, tmp_path / "scene.yaml", {"baffled": 0})
assert row is None
assert any("失敗" in m for m in messages)
assert not (tmp_path / "output" / cond.name / "row.json").exists()