Remodel/packages/engine/tests/unit/test_runner.py
Mannu e6dc39aa33 [S1-T12/T13] P&L revenue breakdown + collapsible rows + UI polish
- Engine: Add ppa_revenue_cr, mcp_revenue_cr, tariff, units to PnLRow
- Engine: Split PPA vs MCP revenue in P&L computation
- Web: Collapsible rows for PPA/MCP Revenue and Opex
- Web: Highlighted rows (Total Revenue, EBITDA, EBIT, PBT, PAT)
- Web: Units above Tariff in breakdown, bg-blue-50 highlight
- Fix sticky column z-index for horizontal scroll
- CLAUDE.md: Add project documentation

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 10:42:36 +05:30

162 lines
5.6 KiB
Python

"""S4 integration tests: scenario runner + CLI solve-tariff."""
import json
from pathlib import Path
import pytest
from remodel_engine.schemas.generation import SolarConfig, WindConfig
from remodel_engine.schemas.scenario import ScenarioInput, SolverConfig
def _solar_input(tariff: float = 3.5) -> ScenarioInput:
return ScenarioInput(
project={"name": "TestSolar", "capacity_solar_mwp": 10.0, "cod_year": 2027}, # type: ignore[arg-type]
solar=SolarConfig(location_id="RJ", capacity_dc_mwp=10.0, capacity_ac_mw=8.0),
solver=SolverConfig(mode="fixed_tariff", fixed_tariff=tariff),
)
def _wind_input(tariff: float = 3.5) -> ScenarioInput:
return ScenarioInput(
project={"name": "TestWind", "capacity_wind_mw": 10.0, "cod_year": 2027}, # type: ignore[arg-type]
wind=WindConfig(location_id="RJ", capacity_mw=10.0),
solver=SolverConfig(mode="fixed_tariff", fixed_tariff=tariff),
)
# ---------------------------------------------------------------------------
# Runner smoke tests (fixed_tariff to avoid slow solver)
# ---------------------------------------------------------------------------
def test_runner_solar_fixed_tariff_returns_success() -> None:
from remodel_engine.scenarios.runner import run_scenario
result = run_scenario(_solar_input(3.5))
assert result.status == "success"
def test_runner_solar_kpis_populated() -> None:
from remodel_engine.scenarios.runner import run_scenario
result = run_scenario(_solar_input(3.5))
assert result.kpis.total_capex_cr is not None
assert result.kpis.solar_y1_cuf is not None
assert result.kpis.solar_y1_cuf > 0.0
def test_runner_solar_financials_not_none() -> None:
from remodel_engine.scenarios.runner import run_scenario
result = run_scenario(_solar_input(3.5))
assert result.financials is not None
assert len(result.financials.pnl) == 25
assert len(result.financials.cfs) == 25
assert len(result.financials.bs) == 25
def test_runner_solar_debt_schedule_length() -> None:
from remodel_engine.scenarios.runner import run_scenario
result = run_scenario(_solar_input(3.5))
assert len(result.debt_schedule) == 25
def test_runner_solar_irr_metrics_present() -> None:
from remodel_engine.scenarios.runner import run_scenario
result = run_scenario(_solar_input(3.5))
assert result.irr_metrics is not None
# With zero capex defaults, project_irr may be None (no negative cashflow for IRR)
assert result.irr_metrics.lcoe_inr_per_kwh is not None
def test_runner_wind_fixed_tariff_returns_success() -> None:
from remodel_engine.scenarios.runner import run_scenario
result = run_scenario(_wind_input(3.5))
assert result.status == "success"
assert result.kpis.wind_y1_plf is not None
assert result.kpis.wind_y1_plf > 0.0
def test_runner_runtime_recorded() -> None:
from remodel_engine.scenarios.runner import run_scenario
result = run_scenario(_solar_input(3.5))
assert result.runtime_s > 0.0
def test_runner_higher_tariff_higher_npv() -> None:
from remodel_engine.scenarios.runner import run_scenario
r_lo = run_scenario(_solar_input(2.5))
r_hi = run_scenario(_solar_input(5.0))
npv_lo = r_lo.irr_metrics.project_npv_cr or 0.0
npv_hi = r_hi.irr_metrics.project_npv_cr or 0.0
assert npv_hi > npv_lo
def test_runner_solve_tariff_mode_converges() -> None:
"""Tariff solver should return a plausible tariff for a small solar project."""
from remodel_engine.scenarios.runner import run_scenario
inp = ScenarioInput(
project={"name": "SolverTest", "capacity_solar_mwp": 10.0}, # type: ignore[arg-type]
solar=SolarConfig(location_id="RJ", capacity_dc_mwp=10.0, capacity_ac_mw=8.0),
solver=SolverConfig(mode="solve_tariff", target_equity_irr=0.16),
)
result = run_scenario(inp)
assert result.status == "success"
assert result.solved_tariff is not None
assert 2.0 <= result.solved_tariff <= 8.0
def test_runner_result_serializable() -> None:
"""ScenarioResult must round-trip through model_dump (used for API persistence)."""
from remodel_engine.scenarios.runner import run_scenario
result = run_scenario(_solar_input(3.5))
d = result.model_dump()
assert d["status"] == "success"
assert isinstance(d["kpis"], dict)
# ---------------------------------------------------------------------------
# Parity gate placeholder (S4-T10)
# ---------------------------------------------------------------------------
@pytest.mark.skip(reason="Parity gate: requires Excel reference fixture not yet committed")
def test_parity_gate_solar_tariff() -> None:
"""Solved tariff must match Excel reference within 0.5%."""
pass
# ---------------------------------------------------------------------------
# CLI: solve-tariff command (S4-T11)
# ---------------------------------------------------------------------------
def test_cli_solve_tariff(tmp_path: Path) -> None:
from typer.testing import CliRunner
from remodel_engine.cli import app
runner = CliRunner()
scenario = {
"project": {"name": "CLI_Test", "capacity_solar_mwp": 10.0},
"solar": {"location_id": "RJ", "capacity_dc_mwp": 10.0, "capacity_ac_mw": 8.0},
"solver": {"mode": "fixed_tariff", "fixed_tariff": 3.5},
}
inp = tmp_path / "scenario.json"
inp.write_text(json.dumps(scenario))
out = tmp_path / "result.json"
result = runner.invoke(app, ["solve-tariff", "--input", str(inp), "--output", str(out)])
assert result.exit_code == 0, result.output
assert out.exists()
data = json.loads(out.read_text())
assert "equity_irr" in data
assert "solved_tariff" in data