Newer
Older
SmTIAS-LightSim / tests / render / test_runner_ensure_variant.py
"""render/runner.py の ensure_variant / seed 受け渡しの仕様ベーステスト.

仕様出典:
- docs/04_SPEC/SPEC_01_アーキテクチャ設計.md(render/runner 層の責務: variant 設定と mi.render)
- docs/04_SPEC/SPEC_02_シーン定義仕様.md「render」セクション(seed を含むレンダ設定)
- docs/02_ENV/ENV_04_開発コマンド.md「研究室サーバでの実行」
  (Mitsuba の variant はプロセス内で 1 度しか設定できない.変えるにはプロセスを作り直す)

検証する仕様:
- ensure_variant(variant) は同一 variant で何度呼んでも例外にならない(2 回目以降は無操作)
- 既に別の variant が設定済みなら警告ログを出して続行する(例外にしない)
- スイープがレンダ前にレイキャストを目的の variant で走らせられるよう,
  render_scene から独立して呼べる
- render_scene は config.render.seed を mi.render に渡す

テスト方針(testing.md):
- variant はプロセス内で 1 度しか設定できないため,グローバル状態はフィクスチャで戻す
- 実 Mitsuba を使う経路は scalar_rgb(tests/conftest.py が設定済みの variant)で確認し,
  「別 variant 要求時の警告」「seed の受け渡し」はモックで確認する
"""

from __future__ import annotations

import logging
from pathlib import Path
from unittest.mock import MagicMock, patch

import numpy as np
import pytest

import sm_tias_lightsim.render.runner as runner_module
from sm_tias_lightsim.scene.schema import SceneConfig


@pytest.fixture(autouse=True)
def reset_runner_variant():
    """各テスト前後に runner._current_variant をリセットする(テスト間の干渉防止)."""
    original = runner_module._current_variant
    runner_module._current_variant = None
    yield
    runner_module._current_variant = original


def _make_scene_config(variant: str = "scalar_rgb", seed: int | None = None) -> SceneConfig:
    """最小限の SceneConfig(実 STL 不要 — build_scene をモックするため)."""
    render: dict = {"variant": variant, "integrator": "path", "max_depth": 2, "spp": 1}
    if seed is not None:
        render["seed"] = seed
    data = {
        "name": "test_ensure_variant",
        "geometry": {
            "meshes": [{"id": "dummy", "path": "dummy.stl", "unit": "mm", "material": "white"}]
        },
        "materials": {"white": {"type": "diffuse", "reflectance": 0.9}},
        "lights": [
            {
                "id": "led",
                "type": "point",
                "position": [0.0, 0.0, 100.0],
                "intensity": 1.0,
                "profile": "lambertian",
            }
        ],
        "sensor": {
            "type": "perspective",
            "position": [0.0, 0.0, 200.0],
            "target": [0.0, 0.0, 0.0],
            "up": [0.0, 1.0, 0.0],
            "fov_deg": 60.0,
            "film": {"width": 4, "height": 4},
        },
        "render": render,
    }
    return SceneConfig.model_validate(data)


# ---------------------------------------------------------------------------
# ensure_variant: 実 Mitsuba(scalar_rgb)での振る舞い
# ---------------------------------------------------------------------------


def test_ensure_variant_is_idempotent_for_same_variant():
    """同じ variant で 2 回呼んでも例外にならないこと(2 回目以降は無操作).

    仕様: Mitsuba の set_variant はプロセス内 1 度だけ.スイープは条件ごとに
         ensure_variant を呼ぶので,2 回目以降が失敗してはならない.
    """
    runner_module.ensure_variant("scalar_rgb")
    runner_module.ensure_variant("scalar_rgb")
    import mitsuba as mi

    assert mi.variant() == "scalar_rgb"


def test_ensure_variant_records_current_variant():
    """1 度目の呼び出しで内部状態(_current_variant)が記録されること."""
    assert runner_module._current_variant is None
    runner_module.ensure_variant("scalar_rgb")
    assert runner_module._current_variant == "scalar_rgb"


def test_ensure_variant_does_not_call_set_variant_twice():
    """2 回目の呼び出しでは mi.set_variant を呼ばないこと."""
    dummy_mi = MagicMock()
    dummy_mi.variant.return_value = "scalar_rgb"
    with (
        patch("sm_tias_lightsim.render.runner.log_variant_status"),
        patch.dict("sys.modules", {"mitsuba": dummy_mi}),
    ):
        runner_module.ensure_variant("scalar_rgb")
        runner_module.ensure_variant("scalar_rgb")
    assert dummy_mi.set_variant.call_count == 1


# ---------------------------------------------------------------------------
# ensure_variant: 異なる variant 要求時
# ---------------------------------------------------------------------------


def test_ensure_variant_warns_when_different_variant_requested(caplog):
    """別の variant を要求すると警告ログを出して続行すること(例外にしない).

    仕様: 同一プロセスで variant は切り替えられない.要求は無視し,
         プロセスを作り直すよう促す警告を出す.
    """
    dummy_mi = MagicMock()
    dummy_mi.variant.return_value = "scalar_rgb"
    with (
        patch("sm_tias_lightsim.render.runner.log_variant_status"),
        patch.dict("sys.modules", {"mitsuba": dummy_mi}),
    ):
        runner_module.ensure_variant("scalar_rgb")
        with caplog.at_level(logging.WARNING, logger="sm_tias_lightsim.render.runner"):
            runner_module.ensure_variant("llvm_ad_rgb")

    warnings = [r for r in caplog.records if r.levelno >= logging.WARNING]
    assert warnings, "別 variant 要求時に WARNING ログが出ること"
    assert dummy_mi.set_variant.call_count == 1, "2 度目の set_variant は呼ばないこと"


def test_ensure_variant_keeps_first_variant_after_conflicting_request():
    """別 variant を要求しても最初に設定した variant が維持されること."""
    dummy_mi = MagicMock()
    dummy_mi.variant.return_value = "scalar_rgb"
    with (
        patch("sm_tias_lightsim.render.runner.log_variant_status"),
        patch.dict("sys.modules", {"mitsuba": dummy_mi}),
    ):
        runner_module.ensure_variant("scalar_rgb")
        runner_module.ensure_variant("cuda_ad_rgb")
    assert runner_module._current_variant == "scalar_rgb"


def test_ensure_variant_calls_ensure_drjit_libllvm_path_for_llvm_variant():
    """llvm_* 要求時は set_variant 前に DRJIT_LIBLLVM_PATH を探索すること."""
    dummy_mi = MagicMock()
    dummy_mi.variant.return_value = "llvm_ad_rgb"
    with (
        patch("sm_tias_lightsim.render.runner.ensure_drjit_libllvm_path") as mock_ensure,
        patch("sm_tias_lightsim.render.runner.log_variant_status"),
        patch.dict("sys.modules", {"mitsuba": dummy_mi}),
    ):
        runner_module.ensure_variant("llvm_ad_rgb")
    mock_ensure.assert_called_once()


def test_ensure_variant_skips_llvm_path_lookup_for_scalar_variant():
    """scalar_* 要求時は DRJIT_LIBLLVM_PATH の探索を行わないこと."""
    dummy_mi = MagicMock()
    dummy_mi.variant.return_value = "scalar_rgb"
    with (
        patch("sm_tias_lightsim.render.runner.ensure_drjit_libllvm_path") as mock_ensure,
        patch("sm_tias_lightsim.render.runner.log_variant_status"),
        patch.dict("sys.modules", {"mitsuba": dummy_mi}),
    ):
        runner_module.ensure_variant("scalar_rgb")
    mock_ensure.assert_not_called()


# ---------------------------------------------------------------------------
# render_scene から ensure_variant を通じた variant 設定
# ---------------------------------------------------------------------------


def _render_with_mock(config: SceneConfig) -> MagicMock:
    """build_scene / mitsuba をモックして render_scene を呼び,mitsuba モックを返す."""
    dummy_image = np.zeros((4, 4, 3), dtype=np.float32)
    dummy_mi = MagicMock()
    dummy_mi.variant.return_value = config.render.variant
    # numpy.array() でそのまま配列になるよう実配列を返す(mi.render の戻り値の代役)
    dummy_mi.render.return_value = dummy_image
    with (
        patch("sm_tias_lightsim.render.runner.ensure_drjit_libllvm_path"),
        patch(
            "sm_tias_lightsim.render.runner.build_scene",
            return_value=({"type": "scene"}, "/tmp/fake"),
        ),
        patch("sm_tias_lightsim.render.runner.log_variant_status"),
        patch.dict("sys.modules", {"mitsuba": dummy_mi}),
        patch("shutil.rmtree"),
    ):
        runner_module.render_scene(config, project_root=Path("/tmp"))
    return dummy_mi


def test_render_scene_sets_variant_from_config():
    """render_scene が config.render.variant で variant を設定すること."""
    _render_with_mock(_make_scene_config(variant="scalar_rgb"))
    assert runner_module._current_variant == "scalar_rgb"


def test_render_scene_passes_seed_to_mi_render():
    """render_scene が config.render.seed を mi.render に渡すこと.

    仕様: 同一条件の再レンダで別のノイズ実現を得るため,seed はレンダに伝わる必要がある.
    """
    dummy_mi = _render_with_mock(_make_scene_config(variant="scalar_rgb", seed=3))
    _, kwargs = dummy_mi.render.call_args
    assert kwargs["seed"] == 3


def test_render_scene_passes_default_seed_zero():
    """seed 未指定(既定 0)でも mi.render に seed=0 が渡ること."""
    dummy_mi = _render_with_mock(_make_scene_config(variant="scalar_rgb"))
    _, kwargs = dummy_mi.render.call_args
    assert kwargs["seed"] == 0


def test_render_scene_passes_spp_and_seed_together():
    """spp と seed の両方が mi.render に渡ること(片方の追加で他方が落ちない)."""
    dummy_mi = _render_with_mock(_make_scene_config(variant="scalar_rgb", seed=5))
    _, kwargs = dummy_mi.render.call_args
    assert kwargs["spp"] == 1
    assert kwargs["seed"] == 5


@pytest.mark.slow
def test_different_seeds_give_different_images(sphere_stl_in_cwd: Path, minimal_scene_yaml: str):
    """seed を変えると実レンダの画素が変わること(ノイズの実現が変わる).

    仕様: 再現性チェック(同一条件の再レンダ)で seed を振ってノイズフロアを測るため,
         seed が結果に効いていなければならない.
    """
    import yaml

    data = yaml.safe_load(minimal_scene_yaml)
    data["render"]["variant"] = "scalar_rgb"
    data["render"]["spp"] = 4

    data["render"]["seed"] = 0
    img_a = runner_module.render_scene(
        SceneConfig.model_validate(data), project_root=sphere_stl_in_cwd.parent
    )
    data["render"]["seed"] = 1
    img_b = runner_module.render_scene(
        SceneConfig.model_validate(data), project_root=sphere_stl_in_cwd.parent
    )

    assert img_a.shape == img_b.shape
    assert not np.array_equal(img_a, img_b), "seed が違えば画素は一致しないこと"