"""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