- 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>
162 lines
5.6 KiB
Python
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
|