Newer
Older
SmTIAS-LightSim / tests / scene / test_audit.py
"""scene/audit.py の仕様ベーステスト.

仕様出典:
- docs/06_TEST/TEST_13_物理モデル健全性の一括監査.md(多重反射の打ち切りバイアス)
- docs/04_SPEC/SPEC_03_基準設計の物理構成.md(LED は間接照明のみ=多重反射が輝度を支配)

打ち切り残差の式 rho^(max_depth - 1) は,積分球(rho=0.8)と基準設計シーン(rho=0.85)の
実レンダで実測した欠損(4.6% / 9.7%)と 1 ポイント程度で一致することを確認して採用した.
"""

from __future__ import annotations

import pytest

from sm_tias_lightsim.scene.audit import (
    check_truncation_bias,
    estimate_truncation_residual,
    material_max_reflectance,
    required_max_depth,
    scene_max_reflectance,
)
from sm_tias_lightsim.scene.schema import (
    BlendBSDFConfig,
    DielectricMaterial,
    DiffuseMaterial,
    RoughConductorBSDFConfig,
    load_scene_yaml,
)

# ---------------------------------------------------------------------------
# material_max_reflectance
# ---------------------------------------------------------------------------


def test_diffuse_scalar_reflectance_returned_as_is():
    """スカラ reflectance がそのまま返ること."""
    mat = DiffuseMaterial(type="diffuse", reflectance=0.85)
    assert material_max_reflectance(mat) == pytest.approx(0.85)


def test_diffuse_rgb_reflectance_returns_max_channel():
    """RGB reflectance では最大チャンネルが返ること.

    多重反射の減衰を支配するのは最も減衰しないチャンネルなので最大値を使う.
    """
    mat = DiffuseMaterial(type="diffuse", reflectance=(0.2, 0.9, 0.5))
    assert material_max_reflectance(mat) == pytest.approx(0.9)


def test_dielectric_has_no_reflectance():
    """誘電体は反射率で表せないため None が返ること."""
    mat = DielectricMaterial(type="dielectric", int_ior=1.5, ext_ior=1.0)
    assert material_max_reflectance(mat) is None


def test_roughconductor_treated_as_perfect_reflector():
    """roughconductor(material="none")は完全反射体 1.0 として扱われること.

    仕様: schema の RoughConductorBSDFConfig は material="none" 固定=完全反射体.
    """
    mat = RoughConductorBSDFConfig(type="roughconductor", alpha=0.1, distribution="ggx")
    assert material_max_reflectance(mat) == pytest.approx(1.0)


def test_blend_returns_max_of_components():
    """blendbsdf は構成要素の最大反射率を返すこと."""
    mat = BlendBSDFConfig(
        type="blendbsdf",
        weight=0.3,
        components=[
            DiffuseMaterial(type="diffuse", reflectance=0.4),
            DiffuseMaterial(type="diffuse", reflectance=0.8),
        ],
    )
    assert material_max_reflectance(mat) == pytest.approx(0.8)


# ---------------------------------------------------------------------------
# estimate_truncation_residual
# ---------------------------------------------------------------------------


def test_truncation_residual_matches_geometric_series():
    """打ち切り残差が rho^(max_depth - 1) であること."""
    assert estimate_truncation_residual(0.85, 16) == pytest.approx(0.85**15)


def test_truncation_residual_decreases_with_depth():
    """max_depth を増やすと残差が単調減少すること."""
    residuals = [estimate_truncation_residual(0.85, d) for d in (8, 16, 32, 64)]
    assert residuals == sorted(residuals, reverse=True)


def test_truncation_residual_is_one_for_depth_one():
    """max_depth=1 では多重反射を全く拾えず残差 1.0 になること."""
    assert estimate_truncation_residual(0.85, 1) == pytest.approx(1.0)


def test_truncation_residual_predicts_measured_deficit_of_baseline():
    """基準設計シーンの実測欠損(9.7%)を 2 ポイント以内で予測すること.

    実測: scene_baseline(rho=0.85)を max_depth=16 と 64 でレンダしたとき
    mean 比 0.9032(= 9.7% 欠損).本式の予測は 0.85^15 = 8.7%.
    """
    predicted = estimate_truncation_residual(0.85, 16)
    assert predicted == pytest.approx(0.097, abs=0.02)


# ---------------------------------------------------------------------------
# required_max_depth
# ---------------------------------------------------------------------------


def test_required_max_depth_achieves_tolerance():
    """返された max_depth で実際に残差が許容値以下になること."""
    rho = 0.85
    d = required_max_depth(rho, tolerance=0.01)
    assert estimate_truncation_residual(rho, d) <= 0.01


def test_required_max_depth_is_minimal():
    """返された max_depth より 1 小さいと許容値を超えること(最小性)."""
    rho = 0.85
    d = required_max_depth(rho, tolerance=0.01)
    assert estimate_truncation_residual(rho, d - 1) > 0.01


def test_required_max_depth_for_perfect_reflector_is_bounded():
    """完全反射体でも無限にならず実用上限が返ること."""
    assert required_max_depth(1.0) == 1024


# ---------------------------------------------------------------------------
# check_truncation_bias
# ---------------------------------------------------------------------------


def _scene_yaml(tmp_path, reflectance: float, max_depth: int):
    """最小シーン YAML を書き出して SceneConfig を返す."""
    import yaml

    cfg = {
        "name": "t",
        "description": "t",
        "geometry": {
            "meshes": [
                {
                    "id": "m",
                    "path": "fixtures/meshes/sphere.stl",
                    "unit": "mm",
                    "material": "wall",
                }
            ]
        },
        "materials": {"wall": {"type": "diffuse", "reflectance": [reflectance] * 3}},
        "lights": [
            {
                "id": "src",
                "type": "point",
                "position": [0.0, 0.0, 0.0],
                "intensity": [1.0, 1.0, 1.0],
                "profile": "isotropic",
            }
        ],
        "sensor": {
            "type": "perspective",
            "position": [0.0, 0.0, -10.0],
            "target": [0.0, 0.0, 0.0],
            "up": [0.0, 1.0, 0.0],
            "fov_deg": 30.0,
            "film": {"width": 8, "height": 8, "rfilter": "box"},
        },
        "render": {
            "variant": "scalar_rgb",
            "integrator": "path",
            "max_depth": max_depth,
            "spp": 1,
        },
    }
    p = tmp_path / "scene.yaml"
    p.write_text(yaml.safe_dump(cfg, allow_unicode=True), encoding="utf-8")
    return load_scene_yaml(p)


def test_check_returns_none_when_depth_is_sufficient(tmp_path):
    """十分な max_depth では警告が出ないこと."""
    cfg = _scene_yaml(tmp_path, reflectance=0.85, max_depth=64)
    assert check_truncation_bias(cfg) is None


def test_check_returns_warning_when_depth_is_insufficient(tmp_path):
    """不足する max_depth では警告文字列が返ること."""
    cfg = _scene_yaml(tmp_path, reflectance=0.85, max_depth=16)
    warning = check_truncation_bias(cfg)
    assert warning is not None
    assert "max_depth" in warning


def test_warning_reports_the_estimated_deficit(tmp_path):
    """警告文に欠損の見積もり(パーセント)が含まれること.

    「気づけない故障モード」を顕在化させるのが目的なので,
    どれだけ過小評価されるかを数値で示す必要がある.
    """
    cfg = _scene_yaml(tmp_path, reflectance=0.85, max_depth=16)
    warning = check_truncation_bias(cfg)
    assert "8.7%" in warning


# ---------------------------------------------------------------------------
# scene_max_reflectance
# ---------------------------------------------------------------------------


def test_scene_max_reflectance_picks_brightest_material(tmp_path):
    """複数材料のうち最大の反射率が採用されること."""
    cfg = _scene_yaml(tmp_path, reflectance=0.85, max_depth=16)
    assert scene_max_reflectance(cfg) == pytest.approx(0.85)