Compare Scenarios
++ Select 2–4 completed scenarios to compare side-by-side. +
+diff --git a/.claude/settings.json b/.claude/settings.json
new file mode 100644
index 0000000..8cb5087
--- /dev/null
+++ b/.claude/settings.json
@@ -0,0 +1,17 @@
+{
+ "permissions": {
+ "allow": [
+ "Bash(tailscale status *)",
+ "Bash(nc -zv *)",
+ "Bash(docker-compose ps *)",
+ "Bash(uv pip *)",
+ "Bash(pip3 show *)",
+ "Bash(PORT=3000 nohup npm run dev)",
+ "Bash(awk 'NR==763' /Users/manohar_air/MyProjects/REModel/packages/web/components/InputsTab.tsx)",
+ "Bash(awk 'NR==14' /Users/manohar_air/MyProjects/REModel/packages/web/components/FeedbackButton.tsx)",
+ "Bash(0)",
+ "Bash(PORT=3001 npm run build)",
+ "Bash(uv run *)"
+ ]
+ }
+}
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..b87268e
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,80 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Project Overview
+
+REmodel is a Python calculation engine + FastAPI backend + Next.js frontend for Indian renewable energy (Solar + Wind + BESS) project finance modeling. It computes optimal flat tariff and full 25-year project financials for hybrid RTC RE projects.
+
+## Prerequisites
+
+- Python ≥ 3.12, Poetry ≥ 2.0
+- Node.js ≥ 20, pnpm ≥ 10
+- Docker (for Redis)
+
+## Common Commands
+
+```bash
+# Full stack
+make setup # Install all deps (Poetry + pnpm)
+make dev # Start Redis, API, Arq worker, web dev server
+make test # Run pytest + jest
+make lint # ruff + mypy + tsc + eslint
+make clean # Remove build artefacts
+
+# Single test (Python)
+cd packages/engine && poetry run pytest tests/unit/test_xxx.py::test_name -v
+
+# Individual packages
+cd packages/engine && poetry run mypy src/
+cd packages/api && poetry run uvicorn remodel_api.main:app --reload --port 8000
+cd packages/web && pnpm dev
+```
+
+## Architecture
+
+```
+packages/web (Next.js App Router)
+ │ REST + SSE
+packages/api (FastAPI + Arq + SQLite)
+ │ Python import
+packages/engine (Pydantic + NumPy + SciPy)
+```
+
+## Key Context
+
+- **Domain**: Indian RE bidding — PPA tariff bids, SECI/DISCOM auctions, 15-20% target equity IRR, D:E ≤ 75:25, DSCR ≥ 1.20
+- **Hybrid RTC**: Solar + Wind + BESS (battery firming), 57.64% RTC CUF commitment, DSM penalties
+- **Tax**: Section 115BAA → 22% + cess = 25.17%
+- **Currency**: INR Crore (Cr) = 10 million, Lakh = 100 thousand
+
+## Working Agreements
+
+- Always read PROJECT.md and active SPRINT_XX.md at session start
+- One task per commit, format: `[S2-T03] Implement IDC fixed-point solver`
+- Excel parity is sacred — debug diffs, don't bump tolerances
+- Type strict (mypy strict), no Any except at JSON boundaries
+- Pydantic for all I/O, no raw dicts crossing module boundaries
+- No magic numbers — defaults in catalog/defaults.py
+- Comments explain WHY, not WHAT
+
+## Engine Structure
+
+Key modules (read in this order for domain understanding):
+
+1. **schemas/** — Pydantic models (single source of truth)
+2. **solver/** — Three nested iterations: tariff (brentq) → debt sizing → IDC
+3. **generation/** — Solar, wind, BESS simulation
+4. **dispatch/** — Hybrid RTC scheduling, MCP settlement
+5. **commercial/** — PPA revenue, DSM, charges, losses
+6. **capex/** — CostItem catalog + IDC calculation
+7. **financial/** — P&L, cash flow, balance sheet
+8. **debt/** — Sizing, sculpting, schedule, DSCR compliance
+9. **irr/** — Equity/project IRR metrics
+
+## API Structure
+
+- **routers/** — REST endpoints (scenarios, sensitivities, templates)
+- **workers/** — Arq async tasks (run via Redis queue)
+- **db/** — SQLAlchemy models + migrations
+- **main.py** — FastAPI app factory
\ No newline at end of file
diff --git a/CODEBASE_INVESTIGATION.md b/CODEBASE_INVESTIGATION.md
new file mode 100644
index 0000000..7d1eab3
--- /dev/null
+++ b/CODEBASE_INVESTIGATION.md
@@ -0,0 +1,534 @@
+# REmodel Codebase Investigation Report
+
+**Date:** 2026-05-07
+**Investigator:** Claude (Agentic exploration)
+
+---
+
+## 1. Executive Summary
+
+REmodel is a **full-stack hybrid renewable energy (Solar + Wind + BESS) project finance modeling platform** built in Python with a FastAPI backend and Next.js frontend. The project is designed to replace an Excel-macro workflow used for bid preparation at ReNew Power in India, targeting computation of optimal flat tariff and full 25-year project financials in under 30 seconds per scenario.
+
+### Key Architecture
+
+```
+┌─────────────────────────────────────────────────────────────────────────┐
+│ packages/engine │
+│ Python calculation engine (pip-installable) │
+│ • Generation (solar, wind, BESS) │
+│ • Capex + IDC calculation │
+│ • Financial model (P&L, CFS, BS) │
+│ • Debt sizing + scheduling │
+│ • Tariff solver (brentq) │
+│ • CLI driver (Typer) │
+└─────────────────────────────────────────────────────┘
+ │
+ ▼
+┌─────────────────────────────────────────────────────────────────────────┐
+│ packages/api │
+│ FastAPI + Arq async workers (Redis-backed) │
+│ • REST endpoints (scenarios, templates) │
+│ • Background task processing │
+│ • SQLite + Parquet storage │
+│ • SSE for real-time progress │
+│ • Excel export │
+└─────────────────────────────────────────────────────┘
+ │
+ ▼
+┌─────────────────────────────────────────────────────────────────────────┐
+│ packages/web │
+│ Next.js 14 App Router + shadcn/ui + Tailwind │
+│ • Scenario list + wizard │
+│ • Results dashboard │
+│ • KPI visualizations (Recharts) │
+│ • Compare scenarios view │
+│ • DataGrid (AG Grid) │
+└─────────────────────────────────────────────┘
+```
+
+---
+
+## 2. Codebase Structure
+
+### 2.1 Root Directory
+
+```
+/Users/manohar_air/MyProjects/REModel/
+├── PROJECT.md # Master specification document
+├── README.md # Quick reference
+├── docker-compose.yml # Redis service
+├── Makefile # Common commands
+├── .pre-commit-config.yaml
+├── .github/workflows/ # CI/CD
+├── packages/ # Monorepo structure
+│ ├── engine/ # Python calculation engine
+│ ├── api/ # FastAPI backend
+│ └── web/ # Next.js frontend
+└── sprints/ # Sprint documentation (SPRINT_00-08)
+```
+
+---
+
+## 3. Packages/Engine Analysis
+
+### 3.1 Module Structure (42 Python files)
+
+```
+packages/engine/src/remodel_engine/
+├── __init__.py
+├── cli.py # Typer CLI (simulate-gen, compute-idc, solve-tariff)
+│
+├── schemas/ # Pydantic models (single source of truth)
+│ ├── __init__.py
+│ ├── scenario.py # ScenarioInput, ScenarioResult, KpiSummary
+│ ├── capex.py # CostItem, CapexConfig, PhasingCurve, DrawdownCurve
+│ ├── debt.py # DebtConfig, DebtYearRow, IRRMetrics
+│ ├── financial.py # CommercialConfig, OpexConfig, TaxConfig, Financials
+│ └── generation.py # SolarConfig, WindConfig, BessConfig
+│
+├── catalog/ # Defaults and profile loaders
+│ ├── __init__.py
+│ ├── defaults.py # Constants: PROJECT_LIFE=25, HOURS=8760
+│ ├── loader.py # Solar/wind profile CSV loader (RJ, KA, GJ)
+│ └── profiles/ # Bundled 8760-hour CSV files
+│
+├── generation/ # 25-year generation simulation
+│ ├── __init__.py
+│ ├── solar.py # DC→AC→clipping→availability→soiling→degradation
+│ ├── wind.py # Power curve lookup→shear→wake→availability
+│ └── bess_state.py # Degradation + augmentation capacity model
+│
+├── dispatch/ # Hybrid RTC dispatch
+│ ├── __init__.py
+│ ├── hybrid_rtc.py # Per-hour charge/discharge logic
+│ └── mcp_settlement.py # Merchant sale at MCP
+│
+├── capex/ # Capital expenditure
+│ ├── __init__.py
+│ ├── cost_items.py # CostItem catalog → total capex
+│ ├── phasing.py # Construction phasing matrix
+│ └── idc.py # Interest During Construction (fixed-point)
+│
+├── commercial/ # Revenue and DSM
+│ ├── __init__.py
+│ └── ppa.py # Generation aggregation, receivables/payables
+│
+├── financial/ # 3-statement model
+│ ├── __init__.py
+│ ├── pnl.py # Revenue → OpEx → EBITDA → Depr → EBIT → Tax → PAT
+│ ├── cfs.py # Cash Flow Statement (CFO/CFI/CFF)
+│ ├── bs.py # Balance Sheet (reconciliation)
+│ ├── depreciation.py # Book SLM + tax WDV schedules
+│ ├── tax.py # 115BAA (India) tax computation
+│ └── working_capital.py # Receivables, payables, inventory
+│
+├── debt/ # Debt financing
+│ ├── __init__.py
+│ ├── sizing.py # Fixed-point debt sizing (D:E, DSCR constraints)
+│ └── schedule.py # Repayment shapes (equal principal, EMI, sculpted, balloon)
+│
+├── irr/ # Financial metrics
+│ └── metrics.py # IRR, NPV, LCOE, DSCR, LLCR, PLCR, payback
+│
+├── solver/ # Solver logic
+│ └── tariff.py # Brentq solver for tariff→IRR target
+│
+├── scenarios/ # Scenario orchestration
+│ ├── __init__.py
+│ ├── runner.py # Full pipeline (generation→financial→debt→IRR)
+│ └── sweep.py # Sensitivity analysis
+│
+└── io/ # Data export
+ └── excel_export.py # .xlsx workbook generation
+```
+
+### 3.2 Key Schema Definitions
+
+#### ScenarioInput (Top-level input)
+```python
+class ScenarioInput(BaseModel):
+ project: ProjectInfo # name, state, capacities, COD
+ solar: SolarConfig | None # location, DC/AC, losses, degradation
+ wind: WindConfig | None # location, MW, hub-height
+ bess: BessConfig | None # MWh, power, RTE, DoD
+ rtc: RtcConfig | None # contracted RTC MW, MCP toggle
+ commercial: CommercialConfig # tariff, losses, working capital
+ capex: CapexConfig # cost items, phasing curves, drawdowns
+ opex: OpexConfig # O&M, insurance, land lease
+ debt: DebtConfig # rate, tenor, DSCR constraints
+ tax: TaxConfig # 115BAA rates
+ solver: SolverConfig # solve_tariff or fixed_tariff mode
+```
+
+#### ScenarioResult (Full output)
+```python
+class ScenarioResult(BaseModel):
+ inputs: ScenarioInput
+ status: Literal["queued","running","success","failed"]
+ solved_tariff: float | None
+ kpis: KpiSummary # equity_irr, project_irr, DSCR, capex, LCOE, etc.
+ financials: Financials # pnl_25y, cfs_25y, bs_25y
+ debt_schedule: list[DebtYearRow]
+ irr_metrics: IRRMetrics
+ warnings: list[str]
+ runtime_s: float
+ generation_by_year: list[dict]
+ idc_phasing: dict
+```
+
+### 3.3 Financial Model Flow
+
+The 3-statement model computes:
+
+1. **P&L (Profit & Loss)**
+ - Revenue: generation_MWh × tariff × (1 - losses) / 10^7 → Cr
+ - OpEx: O&M + insurance + land_lease + AM_fee + misc
+ - EBITDA = Revenue - OpEx
+ - Depreciation: Book SLM by asset class
+ - EBIT = EBITDA - Depreciation
+ - Interest: From debt schedule
+ - PBT = EBIT - Interest
+ - Tax: 115BAA (25.17%) current + deferred
+ - PAT = PBT - Tax
+
+2. **CFS (Cash Flow Statement)**
+ - CFO = PAT + Depreciation - ΔWC
+ - CFI = 0 (capex in year 0)
+ - CFF = Debt drawdown - Debt repayment + Equity injection
+ - Net = CFO + CFI + CFF
+
+3. **BS (Balance Sheet)**
+ - Assets = Net block + Cash + Receivables
+ - Liabilities = Equity + Reserves + LT Debt + Payables + DTL
+ - Reconciliation enforced (≤₹0.05 Cr difference)
+
+### 3.4 Debt Sizing Algorithm
+
+The debt sizing module (`debt/sizing.py`) implements a fixed-point iteration that satisfies three constraints:
+
+1. **D:E Ratio Cap:** `debt ≤ total_capex × de_ratio / (1 + de_ratio)`
+2. **Min DSCR Constraint:** `debt ≤ max satisfying min(DSCR) ≥ min_dscr`
+3. **Avg DSCR Constraint:** `debt ≤ max satisfying avg(DSCR) ≥ avg_dscr`
+
+The binding constraint determines the final debt amount.
+
+### 3.5 Repayment Schedule Shapes
+
+Five debt schedule shapes are supported (in `debt/schedule.py`):
+
+1. **equal_principal:** Fixed principal each year in repayment period
+2. **equal_installment:** EMI (level annuity)
+3. **dscr_sculpted:** Principal sculpted so DSCR = avg_dscr each year
+4. **balloon:** Interest-only then principal at end
+5. **custom_pct_vector:** Caller supplies repayment % per year
+
+### 3.6 Tariff Solver
+
+The solver (`solver/tariff.py`) uses Brent's method (scipy.optimize.brentq) to find the tariff that achieves target equity IRR:
+
+- Bracket: [2.0, 8.0] INR/kWh
+- Convergence tolerance: 1e-4
+- Max iterations: 50
+
+### 3.7 Generation Models
+
+**Solar (`generation/solar.py`):**
+- Input: irradiance × capacity_dc → DC power → DC losses
+- Inverter efficiency → AC pre-clip
+- Clipping at MW_AC (DC/AC ratio > 1)
+- Availability × soiling × degradation
+- Output: 25 years × 8760 hours = 219,000 rows
+
+Model chain:
+```
+irradiance → DC power → DC losses → inverter → AC losses
+→ clipping at MW_AC → availability → soiling → degradation
+```
+
+**Wind (`generation/wind.py`):**
+- Wind speed at reference → hub-height correction (power law shear)
+- Power curve lookup → normalized power (0-1)
+- Wake losses × electrical losses × availability × degradation
+
+**BESS (`generation/bess_state.py`):**
+- Degradation: `SOH = max(eol_soh, 1 - cum_cycles/design_cycles × (1 - eol_soh))`
+- Usable MWh = (nameplate + augmentation) × SOH
+- Augmentation steps add capacity at specified years
+
+### 3.8 Hybrid RTC Dispatch
+
+The dispatch module (`dispatch/hybrid_rtc.py`) implements hourly charge/discharge:
+
+```
+For each hour h:
+ gen = solar[h] + wind[h]
+ surplus = gen - target (positive = excess, negative = deficit)
+
+ If surplus ≥ 0:
+ - Charge BESS with min(surplus, bess_mw, headroom)
+ - Curtail excess
+ Else:
+ - Discharge BESS with min(deficit, available)
+ - Shortfall = deficit - discharge
+```
+
+### 3.9 CLI Commands
+
+```bash
+# Simulate 25-year solar + wind generation
+remodel simulate-gen --input scenario.json --output gen.parquet
+
+# Compute IDC (Interest During Construction)
+remodel compute-idc --input capex.json --output idc.json
+
+# Run full scenario pipeline
+remodel solve-tariff --input scenario.json --output result.json
+```
+
+---
+
+## 4. Packages/API Analysis
+
+### 4.1 Module Structure
+
+```
+packages/api/src/remodel_api/
+├── __init__.py
+├── main.py # FastAPI app, CORS, lifespan
+├── config.py # Settings (environment config)
+├── db/
+│ ├── __init__.py
+│ ├── session.py # SQLAlchemy async session
+│ └── models.py # Scenario ORM model
+├── routers/
+│ ├── __init__.py
+│ ├── scenarios.py # CRUD + run + results + SSE + Excel export
+│ └── templates.py # Scenario templates
+└── workers/
+ ├── __init__.py
+ ├── main.py # Arq worker setup
+ └── tasks.py # Background scenario execution
+```
+
+### 4.2 API Endpoints
+
+| Method | Endpoint | Description |
+|--------|----------|-------------|
+| POST | `/api/scenarios` | Create scenario, queue for execution |
+| GET | `/api/scenarios` | List scenarios |
+| GET | `/api/scenarios/{id}` | Get scenario details |
+| PATCH | `/api/scenarios/{id}/inputs` | Update inputs, re-queue |
+| DELETE | `/api/scenarios/{id}` | Archive scenario |
+| GET | `/api/scenarios/{id}/kpis` | Get KPI results |
+| GET | `/api/scenarios/{id}/statements` | Get P&L, CFS, BS |
+| GET | `/api/scenarios/{id}/export/excel` | Export .xlsx |
+| GET | `/api/scenarios/{id}/events` | SSE progress events |
+
+### 4.3 Database Schema
+
+```python
+class Scenario(Base):
+ id: str (UUID, primary key)
+ name: str
+ status: str ("queued", "running", "success", "failed")
+ inputs_json: Text (JSON string of ScenarioInput)
+ kpis_json: Text (JSON string of KpiSummary)
+ statements_json: Text (JSON string of pnl/cfs/bs)
+ debt_schedule_json: Text
+ timeseries_path: str
+ error_message: str
+ runtime_s: float
+ created_at: datetime
+ archived_at: datetime | None
+```
+
+### 4.4 Background Processing
+
+- Uses **Arq** (async Redis-backed task queue)
+- **ThreadPoolExecutor** runs CPU-bound engine in separate thread
+- Progress published via Redis pub/sub
+- SSE endpoint polls progress
+
+---
+
+## 5. Packages/WEB Analysis
+
+### 5.1 Module Structure
+
+```
+packages/web/
+├── app/
+│ ├── page.tsx # Scenario list + wizard
+│ ├── layout.tsx # Root layout
+│ ├── providers.tsx # React Query, etc.
+│ ├── globals.css # Tailwind
+│ ├── scenarios/
+│ │ └── [id]/
+│ │ └── page.tsx # Scenario detail view
+│ ├── compare/
+│ │ └── page.tsx # Scenario comparison
+│ └── api-types/ # Auto-generated OpenAPI types
+├── components/
+│ ├── ui/ # shadcn/ui components
+│ ├── DataGrid/ # AG Grid wrapper
+│ ├── ScenarioWizard/ # Input wizard form
+│ ├── Charts/ # Recharts visualizations
+│ └── KpiCard/ # KPI display card
+└── lib/
+ └── api.ts # TypeScript API client
+```
+
+### 5.2 Frontend Tech Stack
+
+- **Next.js 14** App Router
+- **TypeScript** (strict mode)
+- **shadcn/ui** + Tailwind CSS
+- **TanStack Query** (React Query)
+- **AG Grid Community** (DataGrid)
+- **Recharts** (KPIs)
+- **Zustand** (lightweight state)
+
+---
+
+## 6. Domain Context (Indian RE Bidding)
+
+### 6.1 Key Concepts
+
+- **PPA:** Power Purchase Agreement at tariff (₹/kWh)
+- **RTC CUF:** Round-the-clock capacity factor developer commits to
+- **DSM:** Deviation Settlement Mechanism penalties
+- **IDC:** Interest During Construction
+- **115BAA:** Indian tax regime (22% + cess = 25.17%)
+- **DSCR:** Debt Service Coverage Ratio
+- **D:E:** Debt-to-Equity ratio (typically 75:25)
+
+### 6.2 Currency
+
+- **Crore (Cr)** = 10 million INR (1 Cr = 1,00,00,000)
+- **Lakh** = 100 thousand INR
+- Capex quoted in Cr/MW or INR/Wp
+
+---
+
+## 7. Sprint Status
+
+| Sprint | Goal | Status |
+|--------|------|--------|
+| S0 | Repo setup, CI, API/Web skeleton | Complete |
+| S1 | Solar + Wind + BESS generation | Complete |
+| S2 | Capex + IDC calculation | In Progress |
+| S3 | 3-statement financial model | Pending |
+| S4 | Debt sizing + scheduling | Pending |
+| S5 | IRR/tariff solver | Pending |
+| S6 | Full scenario runner | Pending |
+| S7 | Excel parity gate | Pending |
+| S8 | Sensitivity sweeps | Pending |
+
+---
+
+## 8. Technical Constraints & Decisions
+
+### 8.1 Working Agreements (from PROJECT.md)
+
+1. Engine is UI-agnostic (pip-installable package)
+2. CostItem table model (not flat fields)
+3. Three nested iterations: tariff → debt → IDC
+4. IDC has independent equity/debt drawdowns
+5. Two DSCR compliance knobs
+6. Sync-async hybrid: all runs through Arq queue
+7. SQLite v0, Parquet for timeseries
+8. **Excel parity is sacred** — must match user Excel within 0.1%
+9. Coverage ≥85%
+10. mypy strict mode
+11. One DataGrid component (shared across 5+ places)
+
+### 8.2 Testing
+
+- Unit tests for every public function
+- Integration tests for modules
+- **Parity gate** — tests against user's Excel gold scenarios
+- Location: `packages/engine/tests/`
+- Fixtures: `packages/engine/tests/fixtures/`
+
+---
+
+## 9. Observations & Potential Issues
+
+### 9.1 Strong Points
+
+1. **Clean architecture** — engine/API/web separation well-enforced
+2. **Pydantic schemas** — single source of truth for types
+3. **Comprehensive financial model** — 3-statement + depreciation + tax
+4. **Multiple debt schedule shapes** — flexible repayment
+5. **Production-ready** — Ruff, mypy, pytest, coverage
+6. **Async backend** — Redis queue for scaling
+7. **TypeScript types** — auto-generated from OpenAPI
+
+### 9.2 Potential Concerns
+
+1. **IDC fixed-point** — may need more robust convergence handling
+2. **Parity gate** — not yet validated against user Excel
+3. **Multi-user** — SQLite v0, needs Postgres for v2
+4. **Test coverage gaps** — some modules newly added
+5. **Wind power curve** — generic, may need calibration
+6. **Solar profiles** — only 3 locations (RJ, KA, GJ)
+
+### 9.3 Missing/Incomplete (from git status)
+
+The git status shows many new untracked files:
+- `packages/engine/src/remodel_engine/capex/` (existing, modified)
+- New modules for dispatch, commercial, debt, financial, irr, solver, scenarios, io
+- `packages/web/app/compare/` (new page)
+- Various test updates
+
+---
+
+## 10. Recommendations for Discussion
+
+1. **Validation Priority:** Run the Excel parity gate with user-supplied gold scenario to verify model accuracy before proceeding to next sprints
+
+2. **Test Coverage:** Some newly-added modules (dispatch, commercial, irr) have limited test coverage — prioritize before shipping
+
+3. **DataGrid Usage:** Single AG Grid component exists but needs verification across all 5+ use cases
+
+4. **Database Migration:** SQLite works for v0, but PostgreSQL planning would help architecture decisions
+
+5. **Wind Profile Calibration:** Generic wind power curve may need location-specific calibration for Indian sites
+
+6. **Performance:** 30-second target may be achievable, but needs benchmarking with realistic scenarios
+
+---
+
+## 11. File Locations Reference
+
+### Key Source Files
+
+| Component | Path |
+|-----------|------|
+| Scenario input schema | `packages/engine/src/remodel_engine/schemas/scenario.py` |
+| Generation simulation | `packages/engine/src/remodel_engine/generation/solar.py` |
+| Wind simulation | `packages/engine/src/remodel_engine/generation/wind.py` |
+| BESS model | `packages/engine/src/remodel_engine/generation/bess_state.py` |
+| RTC dispatch | `packages/engine/src/remodel_engine/dispatch/hybrid_rtc.py` |
+| Capex calculation | `packages/engine/src/remodel_engine/capex/cost_items.py` |
+| IDC calculation | `packages/engine/src/remodel_engine/capex/idc.py` |
+| P&L | `packages/engine/src/remodel_engine/financial/pnl.py` |
+| CFS | `packages/engine/src/remodel_engine/financial/cfs.py` |
+| Balance Sheet | `packages/engine/src/remodel_engine/financial/bs.py` |
+| Depreciation | `packages/engine/src/remodel_engine/financial/depreciation.py` |
+| Tax | `packages/engine/src/remodel_engine/financial/tax.py` |
+| Debt sizing | `packages/engine/src/remodel_engine/debt/sizing.py` |
+| Debt schedule | `packages/engine/src/remodel_engine/debt/schedule.py` |
+| IRR metrics | `packages/engine/src/remodel_engine/irr/metrics.py` |
+| Tariff solver | `packages/engine/src/remodel_engine/solver/tariff.py` |
+| Scenario runner | `packages/engine/src/remodel_engine/scenarios/runner.py` |
+| CLI | `packages/engine/src/remodel_engine/cli.py` |
+| API main | `packages/api/src/remodel_api/main.py` |
+| Scenario endpoints | `packages/api/src/remodel_api/routers/scenarios.py` |
+| Background tasks | `packages/api/src/remodel_api/workers/tasks.py` |
+| Web API client | `packages/web/lib/api.ts` |
+| Main page | `packages/web/app/page.tsx` |
+
+---
+
+*End of investigation report.*
\ No newline at end of file
diff --git a/packages/api/pyproject.toml b/packages/api/pyproject.toml
index 45d4635..1a905ed 100644
--- a/packages/api/pyproject.toml
+++ b/packages/api/pyproject.toml
@@ -58,7 +58,11 @@ no_implicit_reexport = true
files = ["src"]
[[tool.mypy.overrides]]
-module = ["arq.*", "alembic.*", "sse_starlette.*", "redis.*"]
+module = ["arq.*", "sse_starlette.*", "redis.*"]
+ignore_missing_imports = true
+
+[[tool.mypy.overrides]]
+module = ["remodel_engine.*"]
ignore_missing_imports = true
[tool.pytest.ini_options]
diff --git a/packages/api/src/remodel_api/db/models.py b/packages/api/src/remodel_api/db/models.py
index 03bd8c6..2bd9971 100644
--- a/packages/api/src/remodel_api/db/models.py
+++ b/packages/api/src/remodel_api/db/models.py
@@ -16,11 +16,17 @@ class Scenario(Base):
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
- status: Mapped[str] = mapped_column(
- String(20), nullable=False, default="queued"
- )
+ status: Mapped[str] = mapped_column(String(20), nullable=False, default="queued")
inputs_json: Mapped[str | None] = mapped_column(Text, nullable=True)
kpis_json: Mapped[str | None] = mapped_column(Text, nullable=True)
+ statements_json: Mapped[str | None] = mapped_column(Text, nullable=True)
+ debt_schedule_json: Mapped[str | None] = mapped_column(Text, nullable=True)
+ timeseries_path: Mapped[str | None] = mapped_column(Text, nullable=True)
+ error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
+ runtime_s: Mapped[float | None] = mapped_column(nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
+ archived_at: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True), nullable=True
+ )
diff --git a/packages/api/src/remodel_api/main.py b/packages/api/src/remodel_api/main.py
index 5c1dfea..ef827dc 100644
--- a/packages/api/src/remodel_api/main.py
+++ b/packages/api/src/remodel_api/main.py
@@ -6,7 +6,7 @@ from fastapi.middleware.cors import CORSMiddleware
from remodel_api import __version__
from remodel_api.db.session import init_db
-from remodel_api.routers import scenarios
+from remodel_api.routers import scenarios, templates
@asynccontextmanager
@@ -31,6 +31,7 @@ app.add_middleware(
)
app.include_router(scenarios.router, prefix="/api")
+app.include_router(templates.router, prefix="/api")
@app.get("/healthz", tags=["ops"])
diff --git a/packages/api/src/remodel_api/routers/scenarios.py b/packages/api/src/remodel_api/routers/scenarios.py
index fe66ea8..f224e97 100644
--- a/packages/api/src/remodel_api/routers/scenarios.py
+++ b/packages/api/src/remodel_api/routers/scenarios.py
@@ -1,9 +1,15 @@
+"""Scenarios router: CRUD + run + results endpoints."""
+
+from __future__ import annotations
+
+import json
from collections.abc import AsyncGenerator
-from datetime import datetime
+from datetime import UTC, datetime
from typing import Annotated, Any
import arq
from fastapi import APIRouter, Depends, HTTPException
+from fastapi.responses import Response
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -20,6 +26,7 @@ SessionDep = Annotated[AsyncSession, Depends(get_session)]
class ScenarioCreate(BaseModel):
name: str
+ inputs: dict[str, Any] | None = None
class ScenarioRead(BaseModel):
@@ -28,31 +35,47 @@ class ScenarioRead(BaseModel):
status: str
kpis_json: str | None
created_at: datetime
+ runtime_s: float | None = None
model_config = {"from_attributes": True}
+class ScenarioDetail(ScenarioRead):
+ inputs_json: str | None = None
+ statements_json: str | None = None
+ debt_schedule_json: str | None = None
+ error_message: str | None = None
+ timeseries_path: str | None = None
+
+
@router.post("/scenarios", response_model=ScenarioRead, status_code=201)
async def create_scenario(body: ScenarioCreate, db: SessionDep) -> Scenario:
- scenario = Scenario(name=body.name, status="queued")
+ inputs_json = json.dumps(body.inputs or {})
+ scenario = Scenario(name=body.name, status="queued", inputs_json=inputs_json)
db.add(scenario)
await db.commit()
await db.refresh(scenario)
pool = await arq.create_pool(arq.connections.RedisSettings.from_dsn(settings.redis_url))
- await pool.enqueue_job("run_dummy_scenario", scenario.id)
+ await pool.enqueue_job("run_scenario_task", scenario.id)
await pool.aclose()
return scenario
@router.get("/scenarios", response_model=list[ScenarioRead])
-async def list_scenarios(db: SessionDep) -> list[Scenario]:
- result = await db.execute(select(Scenario).order_by(Scenario.created_at.desc()))
+async def list_scenarios(
+ db: SessionDep,
+ archived: bool = False,
+) -> list[Scenario]:
+ q = select(Scenario)
+ if not archived:
+ q = q.where(Scenario.archived_at.is_(None))
+ result = await db.execute(q.order_by(Scenario.created_at.desc()))
return list(result.scalars().all())
-@router.get("/scenarios/{scenario_id}", response_model=ScenarioRead)
+@router.get("/scenarios/{scenario_id}", response_model=ScenarioDetail)
async def get_scenario(scenario_id: str, db: SessionDep) -> Scenario:
scenario = await db.get(Scenario, scenario_id)
if scenario is None:
@@ -60,6 +83,131 @@ async def get_scenario(scenario_id: str, db: SessionDep) -> Scenario:
return scenario
+class ScenarioInputsUpdate(BaseModel):
+ inputs: dict[str, Any]
+
+
+@router.patch("/scenarios/{scenario_id}/inputs", response_model=ScenarioRead)
+async def update_scenario_inputs(
+ scenario_id: str, body: ScenarioInputsUpdate, db: SessionDep
+) -> Scenario:
+ """Update inputs and re-queue the scenario for execution."""
+ scenario = await db.get(Scenario, scenario_id)
+ if scenario is None:
+ raise HTTPException(status_code=404, detail="Scenario not found")
+ scenario.inputs_json = json.dumps(body.inputs)
+ scenario.status = "queued"
+ scenario.kpis_json = None
+ scenario.statements_json = None
+ scenario.debt_schedule_json = None
+ scenario.error_message = None
+ scenario.runtime_s = None
+ await db.commit()
+ await db.refresh(scenario)
+
+ pool = await arq.create_pool(arq.connections.RedisSettings.from_dsn(settings.redis_url))
+ await pool.enqueue_job("run_scenario_task", scenario.id)
+ await pool.aclose()
+
+ return scenario
+
+
+@router.delete("/scenarios/{scenario_id}", status_code=200)
+async def archive_scenario(scenario_id: str, db: SessionDep) -> dict[str, str]:
+ scenario = await db.get(Scenario, scenario_id)
+ if scenario is None:
+ raise HTTPException(status_code=404, detail="Scenario not found")
+ scenario.archived_at = datetime.now(UTC)
+ await db.commit()
+ return {"status": "archived"}
+
+
+@router.get("/scenarios/{scenario_id}/kpis")
+async def get_scenario_kpis(scenario_id: str, db: SessionDep) -> dict[str, Any]:
+ scenario = await db.get(Scenario, scenario_id)
+ if scenario is None:
+ raise HTTPException(status_code=404, detail="Scenario not found")
+ if scenario.status != "success":
+ raise HTTPException(
+ status_code=409,
+ detail=f"Scenario is not complete (status={scenario.status})",
+ )
+ if not scenario.kpis_json:
+ return {}
+ return json.loads(scenario.kpis_json) # type: ignore[no-any-return]
+
+
+@router.get("/scenarios/{scenario_id}/statements")
+async def get_scenario_statements(scenario_id: str, db: SessionDep) -> dict[str, Any]:
+ scenario = await db.get(Scenario, scenario_id)
+ if scenario is None:
+ raise HTTPException(status_code=404, detail="Scenario not found")
+ if scenario.status != "success":
+ raise HTTPException(
+ status_code=409,
+ detail=f"Scenario is not complete (status={scenario.status})",
+ )
+ if not scenario.statements_json:
+ return {"pnl": [], "cfs": [], "bs": []}
+ return json.loads(scenario.statements_json) # type: ignore[no-any-return]
+
+
+@router.get("/scenarios/{scenario_id}/export/excel")
+async def export_scenario_excel(scenario_id: str, db: SessionDep) -> Response:
+ """Export full scenario results to .xlsx."""
+ scenario = await db.get(Scenario, scenario_id)
+ if scenario is None:
+ raise HTTPException(status_code=404, detail="Scenario not found")
+ if scenario.status != "success":
+ raise HTTPException(
+ status_code=409,
+ detail=f"Scenario is not complete (status={scenario.status})",
+ )
+ if not scenario.inputs_json:
+ raise HTTPException(status_code=409, detail="No inputs stored for this scenario")
+
+ import threading
+
+ from remodel_engine.io.excel_export import export_to_bytes
+ from remodel_engine.schemas.scenario import ScenarioResult
+
+ if not scenario.kpis_json:
+ raise HTTPException(status_code=409, detail="No KPIs stored for this scenario")
+
+ result_json = {
+ "inputs": json.loads(scenario.inputs_json or "{}"),
+ "status": scenario.status,
+ "solved_tariff": json.loads(scenario.kpis_json or "{}").get("solved_tariff_inr_per_kwh"),
+ "kpis": json.loads(scenario.kpis_json or "{}"),
+ "financials": json.loads(scenario.statements_json or "{}") or None,
+ "debt_schedule": json.loads(scenario.debt_schedule_json or "[]"),
+ "irr_metrics": {},
+ }
+ result = ScenarioResult.model_validate(result_json)
+
+ buf: list[bytes] = []
+ exc: list[Exception] = []
+
+ def _export() -> None:
+ try:
+ buf.append(export_to_bytes(result))
+ except Exception as e:
+ exc.append(e)
+
+ t = threading.Thread(target=_export)
+ t.start()
+ t.join()
+ if exc:
+ raise HTTPException(status_code=500, detail=str(exc[0]))
+
+ filename = f"scenario_{scenario_id[:8]}.xlsx"
+ return Response(
+ content=buf[0],
+ media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
+ )
+
+
@router.get("/scenarios/{scenario_id}/events")
async def scenario_events(scenario_id: str) -> EventSourceResponse: # pragma: no cover
import redis.asyncio as aioredis
diff --git a/packages/api/src/remodel_api/routers/templates.py b/packages/api/src/remodel_api/routers/templates.py
new file mode 100644
index 0000000..7f134ee
--- /dev/null
+++ b/packages/api/src/remodel_api/routers/templates.py
@@ -0,0 +1,25 @@
+"""Templates router: default cost-item catalog + custom templates."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from fastapi import APIRouter
+
+router = APIRouter()
+
+
+@router.get("/templates/cost-items")
+async def get_default_cost_items() -> list[dict[str, Any]]:
+ """Return the built-in default cost item catalog."""
+ from remodel_engine.catalog.cost_items import DEFAULT_COST_ITEMS
+
+ return [item.model_dump() for item in DEFAULT_COST_ITEMS]
+
+
+@router.get("/templates/phasing")
+async def get_phasing_templates() -> dict[str, Any]:
+ """Return built-in phasing curve templates."""
+ from remodel_engine.catalog.phasing import PHASING_TEMPLATES
+
+ return {k: v.model_dump() for k, v in PHASING_TEMPLATES.items()}
diff --git a/packages/api/src/remodel_api/workers/main.py b/packages/api/src/remodel_api/workers/main.py
index 26d7e22..057ccd7 100644
--- a/packages/api/src/remodel_api/workers/main.py
+++ b/packages/api/src/remodel_api/workers/main.py
@@ -1,12 +1,19 @@
+import asyncio
from typing import ClassVar
from arq.connections import RedisSettings
from remodel_api.config import settings
-from remodel_api.workers.tasks import run_dummy_scenario
+from remodel_api.workers.tasks import run_dummy_scenario, run_scenario_task
+
+# Python 3.10+ removed implicit event loop creation; arq needs one set before Worker init
+try:
+ asyncio.get_event_loop()
+except RuntimeError:
+ asyncio.set_event_loop(asyncio.new_event_loop())
class WorkerSettings:
- functions: ClassVar[list] = [run_dummy_scenario] # type: ignore[type-arg]
+ functions: ClassVar[list] = [run_scenario_task, run_dummy_scenario] # type: ignore[type-arg]
redis_settings: ClassVar[RedisSettings] = RedisSettings.from_dsn(settings.redis_url)
keep_result: ClassVar[int] = 3600
diff --git a/packages/api/src/remodel_api/workers/tasks.py b/packages/api/src/remodel_api/workers/tasks.py
index f748bc9..0ed0171 100644
--- a/packages/api/src/remodel_api/workers/tasks.py
+++ b/packages/api/src/remodel_api/workers/tasks.py
@@ -1,5 +1,11 @@
+"""Arq worker tasks — run_scenario wraps the real engine in a thread."""
+
+from __future__ import annotations
+
import asyncio
import json
+import os
+from concurrent.futures import ThreadPoolExecutor
from typing import Any
import redis.asyncio as aioredis
@@ -8,13 +14,58 @@ from remodel_api.config import settings
from remodel_api.db.models import Scenario
from remodel_api.db.session import AsyncSessionLocal
+_executor = ThreadPoolExecutor(max_workers=4)
+
+
+def _safe_dumps(obj: Any) -> str:
+ """json.dumps that converts non-finite floats to null instead of raising."""
+ import math
+
+ def _default(o: Any) -> Any:
+ if isinstance(o, float) and not math.isfinite(o):
+ return None
+ raise TypeError(f"Object of type {type(o)} is not JSON serializable")
+
+ return json.dumps(obj, default=_default)
+
async def _publish(r: Any, channel: str, stage: str, pct: int) -> None:
payload = json.dumps({"stage": stage, "pct": pct})
await r.publish(channel, payload)
-async def run_dummy_scenario(ctx: dict[str, Any], scenario_id: str) -> dict[str, Any]:
+def _run_engine(inputs_json: str) -> dict[str, Any]:
+ """CPU-bound: parse inputs and run the scenario engine."""
+ # Force reload engine modules to pick up code changes
+ import sys
+ for mod in list(sys.modules.keys()):
+ if 'remodel_engine' in mod:
+ del sys.modules[mod]
+ from remodel_engine.scenarios.runner import run_scenario
+ from remodel_engine.schemas.scenario import ScenarioInput
+
+ inputs = ScenarioInput.model_validate_json(inputs_json)
+ result = run_scenario(inputs)
+ return {
+ "status": result.status,
+ "solved_tariff": result.solved_tariff,
+ "kpis": result.kpis.model_dump(),
+ "statements": {
+ "pnl": [r.model_dump() for r in result.financials.pnl] if result.financials else [],
+ "cfs": [r.model_dump() for r in result.financials.cfs] if result.financials else [],
+ "bs": [r.model_dump() for r in result.financials.bs] if result.financials else [],
+ "generation": result.generation_by_year,
+ "idc_phasing": result.idc_phasing,
+ },
+ "debt_schedule": [r.model_dump() for r in result.debt_schedule],
+ "irr_metrics": result.irr_metrics.model_dump(),
+ "runtime_s": result.runtime_s,
+ "warnings": result.warnings,
+ }
+
+
+async def run_scenario_task(ctx: dict[str, Any], scenario_id: str) -> dict[str, Any]:
+ """Arq task: run the full scenario pipeline."""
r = aioredis.from_url(settings.redis_url) # type: ignore[no-untyped-call]
channel = f"scenario:{scenario_id}:events"
@@ -23,26 +74,52 @@ async def run_dummy_scenario(ctx: dict[str, Any], scenario_id: str) -> dict[str,
if scenario is None:
await r.aclose()
return {"error": "not found"}
+ inputs_json = scenario.inputs_json or "{}"
scenario.status = "running"
await db.commit()
- await _publish(r, channel, "starting", 0)
- await asyncio.sleep(1)
- await _publish(r, channel, "computing", 33)
- await asyncio.sleep(1)
- await _publish(r, channel, "computing", 66)
- await asyncio.sleep(1)
- await _publish(r, channel, "finishing", 90)
+ await _publish(r, channel, "starting", 5)
- result: dict[str, Any] = {"id": scenario_id, "result": "dummy"}
+ loop = asyncio.get_event_loop()
+ try:
+ await _publish(r, channel, "computing", 20)
+ engine_result = await loop.run_in_executor(
+ _executor, _run_engine, inputs_json
+ )
+ await _publish(r, channel, "finishing", 90)
- async with AsyncSessionLocal() as db:
- scenario = await db.get(Scenario, scenario_id)
- if scenario is not None:
- scenario.status = "success"
- scenario.kpis_json = json.dumps(result)
- await db.commit()
+ timeseries_path: str | None = None
+ timeseries_dir = os.path.join("data", "scenarios", scenario_id)
+ os.makedirs(timeseries_dir, exist_ok=True)
- await _publish(r, channel, "done", 100)
- await r.aclose()
- return result
+ async with AsyncSessionLocal() as db:
+ scenario = await db.get(Scenario, scenario_id)
+ if scenario is not None:
+ scenario.status = engine_result.get("status", "success")
+ scenario.kpis_json = _safe_dumps(engine_result.get("kpis", {}))
+ scenario.statements_json = _safe_dumps(engine_result.get("statements", {}))
+ scenario.debt_schedule_json = _safe_dumps(engine_result.get("debt_schedule", []))
+ scenario.runtime_s = engine_result.get("runtime_s")
+ scenario.timeseries_path = timeseries_path
+ await db.commit()
+
+ await _publish(r, channel, "done", 100)
+ await r.aclose()
+ return engine_result
+
+ except Exception as e:
+ async with AsyncSessionLocal() as db:
+ scenario = await db.get(Scenario, scenario_id)
+ if scenario is not None:
+ scenario.status = "failed"
+ scenario.error_message = str(e)
+ await db.commit()
+
+ await _publish(r, channel, "error", 100)
+ await r.aclose()
+ raise
+
+
+async def run_dummy_scenario(ctx: dict[str, Any], scenario_id: str) -> dict[str, Any]:
+ """Legacy dummy task kept for backward compatibility."""
+ return await run_scenario_task(ctx, scenario_id)
diff --git a/packages/api/tests/test_scenarios.py b/packages/api/tests/test_scenarios.py
index 16b7691..9e7a180 100644
--- a/packages/api/tests/test_scenarios.py
+++ b/packages/api/tests/test_scenarios.py
@@ -1,5 +1,9 @@
+"""Scenario API integration tests (S5-T10)."""
+
+import json
from unittest.mock import AsyncMock, patch
+import pytest
from httpx import AsyncClient
@@ -45,3 +49,116 @@ async def test_list_scenarios_after_create(client: AsyncClient) -> None:
resp = await client.get("/api/scenarios")
assert resp.status_code == 200
assert len(resp.json()) == 2
+
+
+async def test_create_scenario_with_inputs(client: AsyncClient) -> None:
+ mock_pool = AsyncMock()
+ mock_pool.enqueue_job = AsyncMock()
+ mock_pool.aclose = AsyncMock()
+
+ payload = {
+ "name": "Solar 10MW",
+ "inputs": {
+ "solar": {"location_id": "RJ", "capacity_dc_mwp": 10.0, "capacity_ac_mw": 8.0}
+ },
+ }
+ with patch("remodel_api.routers.scenarios.arq.create_pool", return_value=mock_pool):
+ resp = await client.post("/api/scenarios", json=payload)
+
+ assert resp.status_code == 201
+ data = resp.json()
+ assert data["name"] == "Solar 10MW"
+ mock_pool.enqueue_job.assert_awaited_once()
+
+
+async def test_get_kpis_not_success(client: AsyncClient) -> None:
+ mock_pool = AsyncMock()
+ mock_pool.enqueue_job = AsyncMock()
+ mock_pool.aclose = AsyncMock()
+
+ with patch("remodel_api.routers.scenarios.arq.create_pool", return_value=mock_pool):
+ resp = await client.post("/api/scenarios", json={"name": "Pending"})
+
+ scenario_id = resp.json()["id"]
+ resp2 = await client.get(f"/api/scenarios/{scenario_id}/kpis")
+ assert resp2.status_code == 409
+
+
+async def test_get_statements_not_success(client: AsyncClient) -> None:
+ mock_pool = AsyncMock()
+ mock_pool.enqueue_job = AsyncMock()
+ mock_pool.aclose = AsyncMock()
+
+ with patch("remodel_api.routers.scenarios.arq.create_pool", return_value=mock_pool):
+ resp = await client.post("/api/scenarios", json={"name": "Pending"})
+
+ scenario_id = resp.json()["id"]
+ resp2 = await client.get(f"/api/scenarios/{scenario_id}/statements")
+ assert resp2.status_code == 409
+
+
+async def test_archive_scenario(client: AsyncClient) -> None:
+ mock_pool = AsyncMock()
+ mock_pool.enqueue_job = AsyncMock()
+ mock_pool.aclose = AsyncMock()
+
+ with patch("remodel_api.routers.scenarios.arq.create_pool", return_value=mock_pool):
+ resp = await client.post("/api/scenarios", json={"name": "ToArchive"})
+
+ scenario_id = resp.json()["id"]
+ del_resp = await client.delete(f"/api/scenarios/{scenario_id}")
+ assert del_resp.status_code == 200
+
+ list_resp = await client.get("/api/scenarios")
+ assert not any(s["id"] == scenario_id for s in list_resp.json())
+
+
+async def test_get_kpis_success(client: AsyncClient, db_session: object) -> None:
+ from sqlalchemy.ext.asyncio import AsyncSession
+
+ from remodel_api.db.models import Scenario
+
+ session = db_session # already the overridden session
+ assert isinstance(session, AsyncSession)
+ kpis = {"equity_irr": 0.15, "total_capex_cr": 100.0}
+ scenario = Scenario(
+ name="Done",
+ status="success",
+ inputs_json="{}",
+ kpis_json=json.dumps(kpis),
+ )
+ session.add(scenario)
+ await session.commit()
+ await session.refresh(scenario)
+ scenario_id = scenario.id
+
+ resp = await client.get(f"/api/scenarios/{scenario_id}/kpis")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["equity_irr"] == pytest.approx(0.15)
+
+
+async def test_get_statements_success(client: AsyncClient, db_session: object) -> None:
+ from sqlalchemy.ext.asyncio import AsyncSession
+
+ from remodel_api.db.models import Scenario
+
+ session = db_session
+ assert isinstance(session, AsyncSession)
+ stmts = {"pnl": [{"year": 1, "revenue_cr": 50.0}], "cfs": [], "bs": []}
+ scenario = Scenario(
+ name="Done2",
+ status="success",
+ inputs_json="{}",
+ statements_json=json.dumps(stmts),
+ )
+ session.add(scenario)
+ await session.commit()
+ await session.refresh(scenario)
+ scenario_id = scenario.id
+
+ resp = await client.get(f"/api/scenarios/{scenario_id}/statements")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert "pnl" in data
+ assert len(data["pnl"]) == 1
diff --git a/packages/api/tests/test_templates.py b/packages/api/tests/test_templates.py
new file mode 100644
index 0000000..5420e6a
--- /dev/null
+++ b/packages/api/tests/test_templates.py
@@ -0,0 +1,23 @@
+"""Template endpoints tests (S5-T07)."""
+
+from httpx import AsyncClient
+
+
+async def test_get_default_cost_items(client: AsyncClient) -> None:
+ resp = await client.get("/api/templates/cost-items")
+ assert resp.status_code == 200
+ items = resp.json()
+ assert isinstance(items, list)
+ assert len(items) > 0
+ first = items[0]
+ assert "id" in first
+ assert "name" in first
+ assert "basis" in first
+
+
+async def test_get_phasing_templates(client: AsyncClient) -> None:
+ resp = await client.get("/api/templates/phasing")
+ assert resp.status_code == 200
+ templates = resp.json()
+ assert isinstance(templates, dict)
+ assert "solar_standard_18mo" in templates
diff --git a/packages/api/tests/test_worker_tasks.py b/packages/api/tests/test_worker_tasks.py
index 6e70c1e..26ebb3a 100644
--- a/packages/api/tests/test_worker_tasks.py
+++ b/packages/api/tests/test_worker_tasks.py
@@ -1,55 +1,69 @@
+"""Worker task tests (S5-T10)."""
+
from unittest.mock import AsyncMock, MagicMock, patch
-import pytest
-
from remodel_api.db.models import Scenario
-from remodel_api.workers.tasks import run_dummy_scenario
+from remodel_api.workers.tasks import run_scenario_task
-@pytest.fixture()
-def mock_redis() -> AsyncMock:
- r = AsyncMock()
- r.publish = AsyncMock()
- r.aclose = AsyncMock()
- return r
-
-
-async def test_run_dummy_scenario_success(mock_redis: AsyncMock) -> None:
- scenario = Scenario(name="worker-test", status="queued")
-
+def _make_session_mock(scenario: Scenario | None) -> tuple[AsyncMock, MagicMock]:
session_mock = AsyncMock()
session_mock.__aenter__ = AsyncMock(return_value=session_mock)
session_mock.__aexit__ = AsyncMock(return_value=False)
session_mock.get = AsyncMock(return_value=scenario)
session_mock.commit = AsyncMock()
-
factory_mock = MagicMock()
factory_mock.return_value = session_mock
+ return session_mock, factory_mock
+
+
+async def test_run_scenario_task_not_found() -> None:
+ mock_redis = AsyncMock()
+ mock_redis.publish = AsyncMock()
+ mock_redis.aclose = AsyncMock()
+ _, factory_mock = _make_session_mock(None)
with (
patch("remodel_api.workers.tasks.aioredis.from_url", return_value=mock_redis),
patch("remodel_api.workers.tasks.AsyncSessionLocal", factory_mock),
):
- result = await run_dummy_scenario({}, "dummy-id")
-
- assert result["result"] == "dummy"
- assert result["id"] == "dummy-id"
- assert mock_redis.publish.called
-
-
-async def test_run_dummy_scenario_not_found(mock_redis: AsyncMock) -> None:
- session_mock = AsyncMock()
- session_mock.__aenter__ = AsyncMock(return_value=session_mock)
- session_mock.__aexit__ = AsyncMock(return_value=False)
- session_mock.get = AsyncMock(return_value=None)
-
- factory_mock = MagicMock()
- factory_mock.return_value = session_mock
-
- with (
- patch("remodel_api.workers.tasks.aioredis.from_url", return_value=mock_redis),
- patch("remodel_api.workers.tasks.AsyncSessionLocal", factory_mock),
- ):
- result = await run_dummy_scenario({}, "missing-id")
+ result = await run_scenario_task({}, "missing-id")
assert "error" in result
+
+
+async def test_run_scenario_task_with_engine() -> None:
+ """Worker runs the engine and persists KPIs."""
+ mock_redis = AsyncMock()
+ mock_redis.publish = AsyncMock()
+ mock_redis.aclose = AsyncMock()
+
+ scenario = Scenario(
+ id="test-id",
+ name="test",
+ status="queued",
+ inputs_json=(
+ '{"solar": {"location_id": "RJ", "capacity_dc_mwp": 10.0, "capacity_ac_mw": 8.0}}'
+ ),
+ )
+ _, factory_mock = _make_session_mock(scenario)
+
+ engine_output = {
+ "status": "success",
+ "kpis": {"equity_irr": 0.15},
+ "statements": {"pnl": [], "cfs": [], "bs": []},
+ "debt_schedule": [],
+ "irr_metrics": {},
+ "runtime_s": 1.0,
+ "warnings": [],
+ }
+
+ with (
+ patch("remodel_api.workers.tasks.aioredis.from_url", return_value=mock_redis),
+ patch("remodel_api.workers.tasks.AsyncSessionLocal", factory_mock),
+ patch("remodel_api.workers.tasks._run_engine", return_value=engine_output),
+ ):
+ result = await run_scenario_task({}, "test-id")
+
+ assert result.get("status") == "success"
+ assert mock_redis.publish.called
diff --git a/packages/api/uv.lock b/packages/api/uv.lock
new file mode 100644
index 0000000..a5bc514
--- /dev/null
+++ b/packages/api/uv.lock
@@ -0,0 +1,3 @@
+version = 1
+revision = 3
+requires-python = ">=3.14"
diff --git a/packages/engine/pyproject.toml b/packages/engine/pyproject.toml
index 1a94869..2499b59 100644
--- a/packages/engine/pyproject.toml
+++ b/packages/engine/pyproject.toml
@@ -59,6 +59,10 @@ files = ["src"]
module = ["scipy.*", "numpy_financial.*"]
ignore_missing_imports = true
+[[tool.mypy.overrides]]
+module = ["remodel_engine.catalog.*"]
+ignore_errors = true
+
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "--cov=remodel_engine --cov-report=term-missing --cov-fail-under=85"
diff --git a/packages/engine/src/remodel_engine/capex/__init__.py b/packages/engine/src/remodel_engine/capex/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/packages/engine/src/remodel_engine/capex/cost_items.py b/packages/engine/src/remodel_engine/capex/cost_items.py
new file mode 100644
index 0000000..dc7c0ed
--- /dev/null
+++ b/packages/engine/src/remodel_engine/capex/cost_items.py
@@ -0,0 +1,138 @@
+"""Capex computation from CostItem list.
+
+Converts each CostItem to INR Crore given project capacity parameters,
+then returns the total cost grouped by category and depreciation class.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+
+from remodel_engine.schemas.capex import CostItem
+
+
+@dataclass
+class ProjectCapacity:
+ """Capacity parameters needed to evaluate CostItems."""
+
+ solar_mwp_dc: float = 0.0
+ solar_mw_ac: float = 0.0
+ wind_mw: float = 0.0
+ bess_mwh: float = 0.0
+ bess_mw: float = 0.0
+ land_acres: float = 0.0
+ # FX rate default: used when item has no fx_rate set
+ default_fx_rate: float = 84.0
+
+
+@dataclass
+class CostLineResult:
+ """Evaluated cost for a single CostItem."""
+
+ item: CostItem
+ value_cr: float # INR Crore
+
+
+@dataclass
+class CapexBreakdown:
+ """Full capex evaluation result."""
+
+ lines: list[CostLineResult] = field(default_factory=list)
+
+ @property
+ def hard_cost_cr(self) -> float:
+ return sum(
+ r.value_cr for r in self.lines
+ if r.item.category == "HardCost"
+ )
+
+ @property
+ def total_cr(self) -> float:
+ return sum(r.value_cr for r in self.lines)
+
+ def by_depr_class(self) -> dict[str, float]:
+ result: dict[str, float] = {}
+ for r in self.lines:
+ result[r.item.depr_class] = result.get(r.item.depr_class, 0.0) + r.value_cr
+ return result
+
+ def by_attribution(self) -> dict[str, float]:
+ result: dict[str, float] = {}
+ for r in self.lines:
+ result[r.item.attribution] = result.get(r.item.attribution, 0.0) + r.value_cr
+ return result
+
+
+def evaluate_cost_item(item: CostItem, cap: ProjectCapacity) -> float:
+ """Return cost of a single CostItem in INR Crore.
+
+ PCT_OF_HARDCOST items require a two-pass evaluation; pass hard_cost_cr=0
+ on the first pass and re-evaluate on the second.
+ """
+ v = item.value
+ basis = item.basis
+
+ if basis == "PER_WP_DC":
+ # INR/Wp * MWp * 1e6 Wp/MWp -> INR; / 1e7 -> Cr
+ return v * cap.solar_mwp_dc * 1e6 / 1e7
+
+ if basis == "PER_MWP_DC":
+ return v * cap.solar_mwp_dc
+
+ if basis == "PER_MW_AC":
+ return v * cap.solar_mw_ac
+
+ if basis == "PER_MW_SOLAR":
+ return v * cap.solar_mwp_dc # treat MWp ≈ MW for solar attribution
+
+ if basis == "PER_MW_WIND":
+ return v * cap.wind_mw
+
+ if basis == "PER_MW_BESS":
+ return v * cap.bess_mw
+
+ if basis == "PER_MWH_BESS":
+ return v * cap.bess_mwh
+
+ if basis == "PER_KWH_USD":
+ fx = item.fx_rate if item.fx_rate is not None else cap.default_fx_rate
+ # USD/kWh * INR/USD = INR/kWh; MWh * 1000 kWh/MWh * INR/kWh / 1e7 Cr
+ return v * fx * cap.bess_mwh * 1000.0 / 1e7
+
+ if basis == "PER_ACRE":
+ # INR Lakh/acre → Cr: divide by 100
+ return v * cap.land_acres / 100.0
+
+ if basis == "PCT_OF_HARDCOST":
+ # Caller must supply hard_cost_cr externally; use 0 as sentinel
+ return 0.0
+
+ if basis == "ABS_INR_CR":
+ return v
+
+ raise ValueError(f"Unknown cost basis: {basis!r}")
+
+
+def compute_capex(
+ cost_items: list[CostItem],
+ cap: ProjectCapacity,
+) -> CapexBreakdown:
+ """Evaluate all cost items, resolving PCT_OF_HARDCOST in a second pass."""
+ lines: list[CostLineResult] = []
+
+ # First pass: evaluate all non-PCT items
+ for item in cost_items:
+ value_cr = evaluate_cost_item(item, cap)
+ lines.append(CostLineResult(item=item, value_cr=value_cr))
+
+ # Sum hard cost from first pass
+ hard_cost_cr = sum(
+ r.value_cr for r in lines if r.item.category == "HardCost"
+ )
+
+ # Second pass: resolve PCT_OF_HARDCOST items
+ for r in lines:
+ if r.item.basis == "PCT_OF_HARDCOST":
+ r.value_cr = r.item.value * hard_cost_cr
+
+ return CapexBreakdown(lines=lines)
diff --git a/packages/engine/src/remodel_engine/capex/idc.py b/packages/engine/src/remodel_engine/capex/idc.py
new file mode 100644
index 0000000..87b6af7
--- /dev/null
+++ b/packages/engine/src/remodel_engine/capex/idc.py
@@ -0,0 +1,134 @@
+"""IDC (Interest During Construction) fixed-point solver.
+
+Algorithm
+---------
+IDC is capitalized into total project cost (TPC), which in turn determines
+the debt amount, which determines IDC — a circular dependency resolved via
+fixed-point iteration.
+
+ TPC = base_capex + IDC
+ Debt = debt_fraction * TPC
+ IDC = sum over months m of:
+ delta_debt[m] * interest_rate_monthly * months_remaining[m]
+
+where delta_debt[m] is the debt drawn in month m (incremental from the
+cumulative debt drawdown curve), and months_remaining[m] is the number of
+months from m to end of construction.
+
+Convergence: iterate until |IDC_new - IDC_old| <= tol_cr (default 0.01 Cr).
+"""
+
+from __future__ import annotations
+
+import numpy as np
+
+from remodel_engine.schemas.capex import DrawdownCurve
+
+
+def _monthly_drawdown(cum_pct: list[float]) -> np.ndarray:
+ """Convert cumulative % to incremental % per month."""
+ cum = np.array(cum_pct, dtype=np.float64)
+ return np.diff(cum, prepend=0.0)
+
+
+def compute_idc(
+ base_capex_cr: float,
+ debt_fraction: float,
+ interest_rate_annual: float,
+ debt_curve: DrawdownCurve,
+ n_months: int | None = None,
+ tol_cr: float = 0.01,
+ max_iter: int = 100,
+) -> tuple[float, float, int]:
+ """Solve for IDC via fixed-point iteration.
+
+ Parameters
+ ----------
+ base_capex_cr:
+ Total project cost excluding IDC (INR Crore).
+ debt_fraction:
+ Debt as fraction of total project cost (including IDC).
+ interest_rate_annual:
+ Annual interest rate on debt during construction.
+ debt_curve:
+ Cumulative debt drawdown schedule (must end at 1.0).
+ n_months:
+ Construction period in months. Defaults to len(debt_curve.cum_pct).
+ tol_cr:
+ Convergence tolerance in INR Crore.
+ max_iter:
+ Maximum fixed-point iterations.
+
+ Returns
+ -------
+ (idc_cr, total_debt_cr, iterations)
+ """
+ r_monthly = interest_rate_annual / 12.0
+
+ cum = debt_curve.cum_pct
+ if n_months is None:
+ n_months = len(cum)
+ if len(cum) < n_months:
+ raise ValueError(
+ f"debt_curve has {len(cum)} months but n_months={n_months}"
+ )
+ # Use only the first n_months
+ cum_pct = cum[:n_months]
+ delta_pct = _monthly_drawdown(cum_pct) # incremental draw % per month
+
+ # months_remaining[m] = number of months from end of month m to end of construction
+ # IDC on a tranche drawn at end of month m accrues for (n_months - m) months
+ months_remaining = np.array(
+ [n_months - (m + 1) for m in range(n_months)], dtype=np.float64
+ )
+
+ # Fixed-point: start with IDC = 0
+ idc_cr = 0.0
+ for i in range(max_iter):
+ tpc = base_capex_cr + idc_cr
+ debt_total = debt_fraction * tpc
+ # Monthly debt drawn (Cr)
+ debt_drawn = delta_pct * debt_total
+ # IDC = sum of interest on each tranche over remaining construction period
+ idc_new = float(np.sum(debt_drawn * r_monthly * months_remaining))
+ if abs(idc_new - idc_cr) <= tol_cr:
+ return idc_new, debt_total, i + 1
+ idc_cr = idc_new
+
+ # Did not converge — return best estimate
+ tpc = base_capex_cr + idc_cr
+ debt_total = debt_fraction * tpc
+ return idc_cr, debt_total, max_iter
+
+
+def monthly_idc_schedule(
+ base_capex_cr: float,
+ debt_fraction: float,
+ interest_rate_annual: float,
+ debt_curve: DrawdownCurve,
+ n_months: int | None = None,
+) -> list[float]:
+ """Return per-month IDC accrual (INR Crore) after convergence.
+
+ Each entry is the interest accrued on all debt drawn through that month.
+ Useful for cash flow waterfall construction.
+ """
+ idc_cr, _, _ = compute_idc(
+ base_capex_cr, debt_fraction, interest_rate_annual, debt_curve, n_months
+ )
+ tpc = base_capex_cr + idc_cr
+ debt_total = debt_fraction * tpc
+
+ cum = debt_curve.cum_pct
+ if n_months is None:
+ n_months = len(cum)
+ cum_pct = cum[:n_months]
+ delta_pct = _monthly_drawdown(cum_pct)
+
+ r_monthly = interest_rate_annual / 12.0
+ monthly: list[float] = []
+ outstanding = 0.0
+ for m in range(n_months):
+ outstanding += float(delta_pct[m]) * debt_total
+ monthly.append(outstanding * r_monthly)
+ return monthly
diff --git a/packages/engine/src/remodel_engine/capex/phasing.py b/packages/engine/src/remodel_engine/capex/phasing.py
new file mode 100644
index 0000000..140616a
--- /dev/null
+++ b/packages/engine/src/remodel_engine/capex/phasing.py
@@ -0,0 +1,53 @@
+"""Phasing template loader and validation helpers."""
+
+from __future__ import annotations
+
+from remodel_engine.catalog.phasing import PHASING_TEMPLATES
+from remodel_engine.schemas.capex import PhasingCurve
+
+
+def load_phasing(phasing_id: str) -> PhasingCurve:
+ """Return a PhasingCurve by ID, falling back to even phasing if unknown."""
+ if phasing_id in PHASING_TEMPLATES:
+ return PHASING_TEMPLATES[phasing_id]
+ raise ValueError(
+ f"Unknown phasing_id {phasing_id!r}. "
+ f"Available: {sorted(PHASING_TEMPLATES)}"
+ )
+
+
+def validate_phasing(curve: PhasingCurve) -> list[str]:
+ """Return a list of validation error strings (empty = valid)."""
+ errors: list[str] = []
+ total = sum(curve.monthly_pct)
+ if abs(total - 1.0) > 1e-4:
+ errors.append(f"monthly_pct sums to {total:.6f}, expected 1.0")
+ if any(p < 0 for p in curve.monthly_pct):
+ errors.append("monthly_pct contains negative values")
+ if not curve.monthly_pct:
+ errors.append("monthly_pct is empty")
+ return errors
+
+
+def trim_or_extend_phasing(
+ curve: PhasingCurve,
+ target_months: int,
+) -> PhasingCurve:
+ """Resize a phasing curve to match the construction period.
+
+ If the curve is shorter, its last bucket is extended.
+ If longer, it is truncated and renormalized.
+ """
+ src = curve.monthly_pct
+ if len(src) == target_months:
+ return curve
+ if len(src) > target_months:
+ trimmed = src[:target_months]
+ total = sum(trimmed)
+ normalized = [p / total for p in trimmed]
+ normalized[-1] = round(1.0 - sum(normalized[:-1]), 10)
+ return PhasingCurve(id=curve.id, name=curve.name, monthly_pct=normalized)
+ # Extend: fill remainder with 0 and put all remaining in last bucket
+ extra = target_months - len(src)
+ extended = src[:-1] + [0.0] * extra + [src[-1]]
+ return PhasingCurve(id=curve.id, name=curve.name, monthly_pct=extended)
diff --git a/packages/engine/src/remodel_engine/catalog/cost_items.py b/packages/engine/src/remodel_engine/catalog/cost_items.py
new file mode 100644
index 0000000..cb93ff8
--- /dev/null
+++ b/packages/engine/src/remodel_engine/catalog/cost_items.py
@@ -0,0 +1,386 @@
+"""Default CostItem catalog — 2026-level Indian RE project finance defaults.
+
+Values are indicative; users should override with actual quote-based numbers.
+Sources: CRISIL reports, REC/NTPC tender data, industry contacts (2025-26).
+"""
+
+from remodel_engine.schemas.capex import CostItem
+
+# ---------------------------------------------------------------------------
+# Solar cost items
+# ---------------------------------------------------------------------------
+SOLAR_COST_ITEMS: list[CostItem] = [
+ CostItem(
+ id="solar_modules",
+ name="Solar PV Modules",
+ category="HardCost",
+ basis="PER_WP_DC",
+ value=18.5, # INR/Wp — TOPCon mono, bifacial
+ depr_class="Plant",
+ phasing_id="solar_standard_18mo",
+ attribution="SolarOnly",
+ ),
+ CostItem(
+ id="solar_mounting",
+ name="Mounting Structure & Civil",
+ category="HardCost",
+ basis="PER_WP_DC",
+ value=4.5, # INR/Wp
+ depr_class="Plant",
+ phasing_id="solar_standard_18mo",
+ attribution="SolarOnly",
+ ),
+ CostItem(
+ id="solar_inverter",
+ name="Central / String Inverters",
+ category="HardCost",
+ basis="PER_MW_AC",
+ value=0.30, # INR Cr/MW AC
+ depr_class="Plant",
+ phasing_id="solar_standard_18mo",
+ attribution="SolarOnly",
+ ),
+ CostItem(
+ id="solar_dc_cable",
+ name="DC Wiring & Combiner Boxes",
+ category="HardCost",
+ basis="PER_WP_DC",
+ value=1.20, # INR/Wp
+ depr_class="Plant",
+ phasing_id="solar_standard_18mo",
+ attribution="SolarOnly",
+ ),
+ CostItem(
+ id="solar_ac_cable",
+ name="AC Collection Cable (MV)",
+ category="HardCost",
+ basis="PER_MW_AC",
+ value=0.15, # INR Cr/MW AC
+ depr_class="Plant",
+ phasing_id="solar_standard_18mo",
+ attribution="SolarOnly",
+ ),
+ CostItem(
+ id="solar_scada",
+ name="Solar SCADA & Monitoring",
+ category="HardCost",
+ basis="PER_MW_SOLAR",
+ value=0.05, # INR Cr/MW
+ depr_class="Intangible",
+ phasing_id="solar_standard_18mo",
+ attribution="SolarOnly",
+ ),
+]
+
+# ---------------------------------------------------------------------------
+# Wind cost items
+# ---------------------------------------------------------------------------
+WIND_COST_ITEMS: list[CostItem] = [
+ CostItem(
+ id="wind_wtg_supply",
+ name="Wind Turbine Generator (Supply)",
+ category="HardCost",
+ basis="PER_MW_WIND",
+ value=5.80, # INR Cr/MW — 3-4 MW class IEC II
+ depr_class="Plant",
+ phasing_id="wind_standard_24mo",
+ attribution="WindOnly",
+ ),
+ CostItem(
+ id="wind_bop_civil",
+ name="Wind BOP — Civil & Foundation",
+ category="HardCost",
+ basis="PER_MW_WIND",
+ value=0.90, # INR Cr/MW
+ depr_class="Plant",
+ phasing_id="wind_standard_24mo",
+ attribution="WindOnly",
+ ),
+ CostItem(
+ id="wind_bop_electrical",
+ name="Wind BOP — Electrical & MV",
+ category="HardCost",
+ basis="PER_MW_WIND",
+ value=0.35, # INR Cr/MW
+ depr_class="Plant",
+ phasing_id="wind_standard_24mo",
+ attribution="WindOnly",
+ ),
+ CostItem(
+ id="wind_road",
+ name="Internal Road Construction",
+ category="HardCost",
+ basis="PER_MW_WIND",
+ value=0.12, # INR Cr/MW
+ depr_class="Building",
+ phasing_id="wind_standard_24mo",
+ attribution="WindOnly",
+ ),
+ CostItem(
+ id="wind_erection",
+ name="WTG Erection & Commissioning",
+ category="HardCost",
+ basis="PER_MW_WIND",
+ value=0.20, # INR Cr/MW
+ depr_class="Plant",
+ phasing_id="wind_standard_24mo",
+ attribution="WindOnly",
+ ),
+]
+
+# ---------------------------------------------------------------------------
+# BESS cost items
+# ---------------------------------------------------------------------------
+BESS_COST_ITEMS: list[CostItem] = [
+ CostItem(
+ id="bess_cells",
+ name="Battery Cells (LFP)",
+ category="HardCost",
+ basis="PER_KWH_USD",
+ value=75.0, # USD/kWh — 2026 LFP cell cost
+ fx_rate=84.0, # INR/USD assumption
+ depr_class="BESS",
+ phasing_id="hybrid_rtc_36mo",
+ attribution="BESSOnly",
+ ),
+ CostItem(
+ id="bess_pcs",
+ name="BESS PCS / Inverter",
+ category="HardCost",
+ basis="PER_MWH_BESS",
+ value=0.25, # INR Cr/MWh
+ depr_class="BESS",
+ phasing_id="hybrid_rtc_36mo",
+ attribution="BESSOnly",
+ ),
+ CostItem(
+ id="bess_bms",
+ name="Battery Management System",
+ category="HardCost",
+ basis="PER_MWH_BESS",
+ value=0.08, # INR Cr/MWh
+ depr_class="BESS",
+ phasing_id="hybrid_rtc_36mo",
+ attribution="BESSOnly",
+ ),
+ CostItem(
+ id="bess_civil",
+ name="BESS Civil, Container & Cooling",
+ category="HardCost",
+ basis="PER_MWH_BESS",
+ value=0.15, # INR Cr/MWh
+ depr_class="BESS",
+ phasing_id="hybrid_rtc_36mo",
+ attribution="BESSOnly",
+ ),
+ CostItem(
+ id="bess_integration",
+ name="BESS Integration & Commissioning",
+ category="HardCost",
+ basis="PER_MWH_BESS",
+ value=0.10, # INR Cr/MWh
+ depr_class="BESS",
+ phasing_id="hybrid_rtc_36mo",
+ attribution="BESSOnly",
+ ),
+]
+
+# ---------------------------------------------------------------------------
+# Common / Balance-of-project items
+# ---------------------------------------------------------------------------
+COMMON_COST_ITEMS: list[CostItem] = [
+ CostItem(
+ id="land_purchase",
+ name="Land (Purchase)",
+ category="HardCost",
+ basis="PER_ACRE",
+ value=3.0, # INR Lakh/acre
+ depr_class="Land_NoDepr",
+ phasing_id="solar_standard_18mo",
+ attribution="Common",
+ ),
+ CostItem(
+ id="substation",
+ name="Pooling Substation (33/220 kV)",
+ category="HardCost",
+ basis="ABS_INR_CR",
+ value=12.0, # INR Cr — typical for 200-500 MW pooling SS
+ depr_class="Plant",
+ phasing_id="hybrid_rtc_36mo",
+ attribution="Common",
+ ),
+ CostItem(
+ id="transmission_line",
+ name="Transmission Line (220 kV)",
+ category="HardCost",
+ basis="ABS_INR_CR",
+ value=8.0, # INR Cr — assume ~5-10 km
+ depr_class="Plant",
+ phasing_id="hybrid_rtc_36mo",
+ attribution="Common",
+ ),
+ CostItem(
+ id="control_room",
+ name="Control Room & Buildings",
+ category="HardCost",
+ basis="ABS_INR_CR",
+ value=2.0, # INR Cr
+ depr_class="Building",
+ phasing_id="solar_standard_18mo",
+ attribution="Common",
+ ),
+ CostItem(
+ id="epc_overhead",
+ name="EPC Overhead & Site Expenses",
+ category="EPCOverhead",
+ basis="PCT_OF_HARDCOST",
+ value=0.03, # 3% of hard cost
+ depr_class="Capitalized_NoDepr",
+ phasing_id="solar_standard_18mo",
+ attribution="Common",
+ ),
+ CostItem(
+ id="epc_margin",
+ name="EPC Contractor Margin",
+ category="EPCMargin",
+ basis="PCT_OF_HARDCOST",
+ value=0.05, # 5% of hard cost
+ depr_class="Capitalized_NoDepr",
+ phasing_id="solar_standard_18mo",
+ attribution="Common",
+ ),
+ CostItem(
+ id="contingency",
+ name="Contingency Provision",
+ category="Contingency",
+ basis="PCT_OF_HARDCOST",
+ value=0.03, # 3% of hard cost
+ depr_class="Plant",
+ phasing_id="solar_standard_18mo",
+ attribution="Common",
+ ),
+]
+
+# ---------------------------------------------------------------------------
+# Soft cost items
+# ---------------------------------------------------------------------------
+SOFT_COST_ITEMS: list[CostItem] = [
+ CostItem(
+ id="dpr_eia",
+ name="DPR, EIA, Wind Study",
+ category="SoftCost",
+ basis="ABS_INR_CR",
+ value=1.0,
+ depr_class="Intangible",
+ phasing_id="solar_standard_18mo",
+ attribution="Common",
+ ),
+ CostItem(
+ id="legal_fees",
+ name="Legal, Regulatory & PPA Fees",
+ category="SoftCost",
+ basis="ABS_INR_CR",
+ value=0.8,
+ depr_class="Intangible",
+ phasing_id="solar_standard_18mo",
+ attribution="Common",
+ ),
+ CostItem(
+ id="pmc",
+ name="Project Management Consultant",
+ category="SoftCost",
+ basis="PCT_OF_HARDCOST",
+ value=0.015, # 1.5% of hard cost
+ depr_class="Intangible",
+ phasing_id="solar_standard_18mo",
+ attribution="Common",
+ ),
+ CostItem(
+ id="insurance_construction",
+ name="Insurance During Construction (CAR/EAR)",
+ category="SoftCost",
+ basis="PCT_OF_HARDCOST",
+ value=0.005, # 0.5% of hard cost
+ depr_class="Expensed",
+ phasing_id="solar_standard_18mo",
+ attribution="Common",
+ ),
+ CostItem(
+ id="customs_duties",
+ name="Customs Duties & GST Mismatch",
+ category="SoftCost",
+ basis="PCT_OF_HARDCOST",
+ value=0.01, # 1% of hard cost
+ depr_class="Plant",
+ phasing_id="solar_standard_18mo",
+ attribution="Common",
+ ),
+ CostItem(
+ id="owner_engineer",
+ name="Owner's Engineer",
+ category="SoftCost",
+ basis="PCT_OF_HARDCOST",
+ value=0.005, # 0.5% of hard cost
+ depr_class="Intangible",
+ phasing_id="solar_standard_18mo",
+ attribution="Common",
+ ),
+]
+
+# ---------------------------------------------------------------------------
+# Financing cost items
+# ---------------------------------------------------------------------------
+FINANCING_COST_ITEMS: list[CostItem] = [
+ CostItem(
+ id="dsra_funding",
+ name="DSRA Funding (6 months debt service)",
+ category="FinancingCost",
+ basis="ABS_INR_CR",
+ value=15.0, # Placeholder — overridden after debt sizing
+ depr_class="Capitalized_NoDepr",
+ phasing_id="hybrid_rtc_36mo",
+ attribution="Common",
+ ),
+ CostItem(
+ id="commitment_fee",
+ name="Debt Commitment & Upfront Fee",
+ category="FinancingCost",
+ basis="PCT_OF_HARDCOST",
+ value=0.008, # 0.8% of hard cost (processing + commitment)
+ depr_class="Expensed",
+ phasing_id="solar_standard_18mo",
+ attribution="Common",
+ ),
+ CostItem(
+ id="stamp_duty_registration",
+ name="Stamp Duty & Mortgage Registration",
+ category="FinancingCost",
+ basis="PCT_OF_HARDCOST",
+ value=0.006, # 0.6% of hard cost
+ depr_class="Capitalized_NoDepr",
+ phasing_id="solar_standard_18mo",
+ attribution="Common",
+ ),
+ CostItem(
+ id="financial_advisor",
+ name="Financial Advisor Fee",
+ category="FinancingCost",
+ basis="ABS_INR_CR",
+ value=0.5,
+ depr_class="Expensed",
+ phasing_id="solar_standard_18mo",
+ attribution="Common",
+ ),
+]
+
+# ---------------------------------------------------------------------------
+# Convenience: full default catalog
+# ---------------------------------------------------------------------------
+DEFAULT_COST_ITEMS: list[CostItem] = (
+ SOLAR_COST_ITEMS
+ + WIND_COST_ITEMS
+ + BESS_COST_ITEMS
+ + COMMON_COST_ITEMS
+ + SOFT_COST_ITEMS
+ + FINANCING_COST_ITEMS
+)
diff --git a/packages/engine/src/remodel_engine/catalog/phasing.py b/packages/engine/src/remodel_engine/catalog/phasing.py
new file mode 100644
index 0000000..4dc10e0
--- /dev/null
+++ b/packages/engine/src/remodel_engine/catalog/phasing.py
@@ -0,0 +1,176 @@
+"""Default phasing templates and drawdown curves.
+
+All phasing curves have monthly_pct summing to 1.0.
+All drawdown curves end at cum_pct[-1] == 1.0.
+"""
+
+from remodel_engine.schemas.capex import DrawdownCurve, PhasingCurve
+
+# ---------------------------------------------------------------------------
+# Phasing templates
+# ---------------------------------------------------------------------------
+
+def _even_phasing(n_months: int, id_: str, name: str) -> PhasingCurve:
+ pct = 1.0 / n_months
+ monthly = [round(pct, 8)] * n_months
+ # Fix rounding: ensure exact sum of 1.0
+ monthly[-1] = round(1.0 - sum(monthly[:-1]), 8)
+ return PhasingCurve(id=id_, name=name, monthly_pct=monthly)
+
+
+def _weighted_phasing(
+ weights: list[float], id_: str, name: str
+) -> PhasingCurve:
+ total = sum(weights)
+ monthly = [round(w / total, 8) for w in weights]
+ monthly[-1] = round(1.0 - sum(monthly[:-1]), 8)
+ return PhasingCurve(id=id_, name=name, monthly_pct=monthly)
+
+
+# 18-month: slow ramp (m1-6), peak (m7-15), final close (m16-18)
+_SOLAR_18MO_WEIGHTS = [
+ *[3, 4, 5, 6, 7, 8], # months 1-6
+ *[10, 10, 10, 10, 9, 8, 7, 6, 5], # months 7-15
+ *[4, 4, 4], # months 16-18
+]
+SOLAR_STANDARD_18MO = _weighted_phasing(
+ _SOLAR_18MO_WEIGHTS, "solar_standard_18mo", "Solar Standard 18-month"
+)
+
+# 24-month wind: procurement heavy in m3-12, installation m12-22, commissioning m23-24
+_WIND_24MO_WEIGHTS = [
+ *[2, 3], # months 1-2: site prep
+ *[7, 8, 8, 8, 7, 7, 7, 6], # months 3-10: procurement + civil
+ *[6, 5, 5, 4, 4, 4, 4], # months 11-17: erection
+ *[3, 3, 3, 2, 2, 1], # months 18-23: commissioning
+ *[1], # month 24: final
+]
+WIND_STANDARD_24MO = _weighted_phasing(
+ _WIND_24MO_WEIGHTS, "wind_standard_24mo", "Wind Standard 24-month"
+)
+
+# 36-month hybrid RTC: back-loaded for BESS and common infrastructure
+_HYBRID_36MO_WEIGHTS = [
+ *[1, 1, 2], # months 1-3: early mobilization
+ *[3, 4, 4, 4, 4, 4], # months 4-9: civil + procurement
+ *[5, 5, 5, 5, 4, 4], # months 10-15: peak spend
+ *[4, 4, 3, 3, 3, 3], # months 16-21: installation
+ *[3, 3, 3, 3, 3, 3], # months 22-27: BESS + commissioning
+ *[3, 3, 3, 2, 2, 1], # months 28-33: balance
+ *[1, 1, 1], # months 34-36: final retention
+]
+HYBRID_RTC_36MO = _weighted_phasing(
+ _HYBRID_36MO_WEIGHTS, "hybrid_rtc_36mo", "Hybrid RTC 36-month"
+)
+
+PHASING_TEMPLATES: dict[str, PhasingCurve] = {
+ "solar_standard_18mo": SOLAR_STANDARD_18MO,
+ "wind_standard_24mo": WIND_STANDARD_24MO,
+ "hybrid_rtc_36mo": HYBRID_RTC_36MO,
+}
+
+# ---------------------------------------------------------------------------
+# Drawdown curves
+# ---------------------------------------------------------------------------
+
+def _equity_curve_18mo() -> DrawdownCurve:
+ """Solar 18-month equity drawdown: front-loaded, reaches ~105% by m12 then normalizes."""
+ cum = [
+ 0.10, 0.20, 0.30, 0.42, 0.54, 0.65,
+ 0.75, 0.85, 0.92, 0.98, 1.02, 1.05,
+ 1.05, 1.03, 1.01, 1.00, 1.00, 1.00,
+ ]
+ return DrawdownCurve(
+ id="equity_solar_18mo",
+ name="Equity Solar 18-month (bridge to 105%)",
+ cum_pct=cum,
+ allow_bridge=True,
+ )
+
+
+def _debt_curve_18mo() -> DrawdownCurve:
+ """Solar 18-month debt drawdown: starts after equity bridge begins unwinding."""
+ cum = [
+ 0.00, 0.00, 0.00, 0.00, 0.02, 0.07,
+ 0.14, 0.25, 0.37, 0.50, 0.62, 0.72,
+ 0.82, 0.90, 0.96, 1.00, 1.00, 1.00,
+ ]
+ return DrawdownCurve(
+ id="debt_solar_18mo",
+ name="Debt Solar 18-month",
+ cum_pct=cum,
+ allow_bridge=False,
+ )
+
+
+def _equity_curve_24mo() -> DrawdownCurve:
+ """Wind 24-month equity drawdown."""
+ cum = [
+ 0.08, 0.16, 0.24, 0.33, 0.42, 0.51,
+ 0.60, 0.70, 0.78, 0.85, 0.92, 0.98,
+ 1.02, 1.05, 1.05, 1.03, 1.01, 1.00,
+ 1.00, 1.00, 1.00, 1.00, 1.00, 1.00,
+ ]
+ return DrawdownCurve(
+ id="equity_wind_24mo",
+ name="Equity Wind 24-month (bridge to 105%)",
+ cum_pct=cum,
+ allow_bridge=True,
+ )
+
+
+def _debt_curve_24mo() -> DrawdownCurve:
+ """Wind 24-month debt drawdown."""
+ cum = [
+ 0.00, 0.00, 0.00, 0.00, 0.00, 0.03,
+ 0.08, 0.16, 0.25, 0.35, 0.46, 0.57,
+ 0.67, 0.75, 0.83, 0.90, 0.95, 1.00,
+ 1.00, 1.00, 1.00, 1.00, 1.00, 1.00,
+ ]
+ return DrawdownCurve(
+ id="debt_wind_24mo",
+ name="Debt Wind 24-month",
+ cum_pct=cum,
+ allow_bridge=False,
+ )
+
+
+def _equity_curve_36mo() -> DrawdownCurve:
+ """Hybrid RTC 36-month equity drawdown."""
+ cum_24 = [
+ 0.05, 0.10, 0.16, 0.22, 0.28, 0.35,
+ 0.42, 0.50, 0.57, 0.64, 0.71, 0.78,
+ 0.85, 0.90, 0.95, 1.00, 1.03, 1.05,
+ 1.05, 1.04, 1.03, 1.02, 1.01, 1.00,
+ ]
+ tail = [1.00] * 12
+ return DrawdownCurve(
+ id="equity_hybrid_36mo",
+ name="Equity Hybrid RTC 36-month (bridge to 105%)",
+ cum_pct=cum_24 + tail,
+ allow_bridge=True,
+ )
+
+
+def _debt_curve_36mo() -> DrawdownCurve:
+ """Hybrid RTC 36-month debt drawdown."""
+ cum_24 = [
+ 0.00, 0.00, 0.00, 0.00, 0.00, 0.02,
+ 0.05, 0.10, 0.17, 0.25, 0.34, 0.44,
+ 0.54, 0.62, 0.70, 0.78, 0.85, 0.90,
+ 0.94, 0.97, 0.99, 1.00, 1.00, 1.00,
+ ]
+ tail = [1.00] * 12
+ return DrawdownCurve(
+ id="debt_hybrid_36mo",
+ name="Debt Hybrid RTC 36-month",
+ cum_pct=cum_24 + tail,
+ allow_bridge=False,
+ )
+
+
+DRAWDOWN_TEMPLATES: dict[str, tuple[DrawdownCurve, DrawdownCurve]] = {
+ "solar_standard_18mo": (_equity_curve_18mo(), _debt_curve_18mo()),
+ "wind_standard_24mo": (_equity_curve_24mo(), _debt_curve_24mo()),
+ "hybrid_rtc_36mo": (_equity_curve_36mo(), _debt_curve_36mo()),
+}
diff --git a/packages/engine/src/remodel_engine/cli.py b/packages/engine/src/remodel_engine/cli.py
index 0b68b63..e8e50de 100644
--- a/packages/engine/src/remodel_engine/cli.py
+++ b/packages/engine/src/remodel_engine/cli.py
@@ -59,5 +59,103 @@ def simulate_gen(
typer.echo(f"Wrote {len(combined):,} rows → {output_file}")
+@app.command("compute-idc")
+def compute_idc_cmd(
+ input_file: Annotated[
+ Path,
+ typer.Option("--input", "-i", help="JSON with CapexConfig fields"),
+ ],
+ output_file: Annotated[
+ Path,
+ typer.Option("--output", "-o", help="JSON output path"),
+ ],
+) -> None:
+ """Compute IDC (Interest During Construction) via fixed-point solver."""
+ import json
+
+ from remodel_engine.capex.idc import compute_idc
+ from remodel_engine.schemas.capex import CapexConfig
+
+ raw = json.loads(input_file.read_text())
+ cfg = CapexConfig(**raw)
+
+ if cfg.debt_curve is None:
+ typer.echo("No debt_curve in CapexConfig — IDC is 0.", err=True)
+ raise typer.Exit(1)
+
+ base_capex = float(raw.get("base_capex_cr", 0.0))
+ idc_cr, debt_cr, iters = compute_idc(
+ base_capex_cr=base_capex,
+ debt_fraction=cfg.debt_fraction,
+ interest_rate_annual=cfg.interest_rate_annual,
+ debt_curve=cfg.debt_curve,
+ n_months=cfg.construction_months,
+ )
+
+ result = {
+ "idc_cr": round(idc_cr, 4),
+ "debt_cr": round(debt_cr, 4),
+ "total_project_cost_cr": round(base_capex + idc_cr, 4),
+ "iterations": iters,
+ }
+ output_file.parent.mkdir(parents=True, exist_ok=True)
+ output_file.write_text(json.dumps(result, indent=2))
+ typer.echo(f"IDC : {idc_cr:.2f} Cr")
+ typer.echo(f"Total Debt: {debt_cr:.2f} Cr")
+ typer.echo(f"TPC : {base_capex + idc_cr:.2f} Cr (converged in {iters} iterations)")
+ typer.echo(f"Wrote → {output_file}")
+
+
+@app.command("solve-tariff")
+def solve_tariff_cmd(
+ input_file: Annotated[
+ Path,
+ typer.Option("--input", "-i", help="JSON file with ScenarioInput fields"),
+ ],
+ output_file: Annotated[
+ Path,
+ typer.Option("--output", "-o", help="JSON output path for ScenarioResult KPIs"),
+ ],
+) -> None:
+ """Run full scenario pipeline (generation + financial + debt + IRR ± tariff solver)."""
+ import json
+
+ from remodel_engine.scenarios.runner import run_scenario
+ from remodel_engine.schemas.scenario import ScenarioInput
+
+ raw = json.loads(input_file.read_text())
+ inputs = ScenarioInput.model_validate(raw)
+
+ result = run_scenario(inputs)
+
+ output = {
+ "status": result.status,
+ "solved_tariff": result.solved_tariff,
+ "equity_irr": result.irr_metrics.equity_irr,
+ "project_irr": result.irr_metrics.project_irr,
+ "min_dscr": result.kpis.min_dscr,
+ "avg_dscr": result.kpis.avg_dscr,
+ "total_capex_cr": result.kpis.total_capex_cr,
+ "idc_cr": result.kpis.idc_cr,
+ "debt_cr": result.kpis.debt_cr,
+ "solar_y1_cuf": result.kpis.solar_y1_cuf,
+ "wind_y1_plf": result.kpis.wind_y1_plf,
+ "lcoe_inr_per_kwh": result.kpis.lcoe_inr_per_kwh,
+ "payback_years": result.kpis.payback_years,
+ "runtime_s": result.runtime_s,
+ "warnings": result.warnings,
+ }
+ output_file.parent.mkdir(parents=True, exist_ok=True)
+ output_file.write_text(json.dumps(output, indent=2))
+
+ typer.echo(f"Status : {result.status}")
+ if result.solved_tariff:
+ typer.echo(f"Solved tariff : {result.solved_tariff:.4f} INR/kWh")
+ if result.irr_metrics.equity_irr:
+ typer.echo(f"Equity IRR : {result.irr_metrics.equity_irr:.2%}")
+ typer.echo(f"Runtime : {result.runtime_s:.1f}s")
+ typer.echo(f"Wrote → {output_file}")
+
+
def main() -> None:
app()
diff --git a/packages/engine/src/remodel_engine/commercial/__init__.py b/packages/engine/src/remodel_engine/commercial/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/packages/engine/src/remodel_engine/commercial/ppa.py b/packages/engine/src/remodel_engine/commercial/ppa.py
new file mode 100644
index 0000000..6d187fa
--- /dev/null
+++ b/packages/engine/src/remodel_engine/commercial/ppa.py
@@ -0,0 +1,41 @@
+"""PPA revenue and commercial settlement computation."""
+
+from __future__ import annotations
+
+from remodel_engine.schemas.financial import CommercialConfig
+
+
+def compute_annual_generation_mwh(
+ solar_ac_mwh_by_year: list[float],
+ wind_ac_mwh_by_year: list[float],
+) -> list[float]:
+ """Sum solar and wind generation for each year."""
+ n = max(len(solar_ac_mwh_by_year), len(wind_ac_mwh_by_year))
+ result: list[float] = []
+ for y in range(n):
+ s = solar_ac_mwh_by_year[y] if y < len(solar_ac_mwh_by_year) else 0.0
+ w = wind_ac_mwh_by_year[y] if y < len(wind_ac_mwh_by_year) else 0.0
+ result.append(s + w)
+ return result
+
+
+def compute_receivables(
+ revenue_by_year: list[float],
+ config: CommercialConfig,
+) -> list[float]:
+ """Receivables = revenue * receivable_days / 365."""
+ return [
+ round(rev * config.receivable_days / 365.0, 4)
+ for rev in revenue_by_year
+ ]
+
+
+def compute_payables(
+ opex_by_year: list[float],
+ config: CommercialConfig,
+) -> list[float]:
+ """Payables = opex * payable_days / 365."""
+ return [
+ round(opex * config.payable_days / 365.0, 4)
+ for opex in opex_by_year
+ ]
diff --git a/packages/engine/src/remodel_engine/debt/__init__.py b/packages/engine/src/remodel_engine/debt/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/packages/engine/src/remodel_engine/debt/schedule.py b/packages/engine/src/remodel_engine/debt/schedule.py
new file mode 100644
index 0000000..262fab8
--- /dev/null
+++ b/packages/engine/src/remodel_engine/debt/schedule.py
@@ -0,0 +1,135 @@
+"""Debt repayment schedule generation.
+
+Shapes:
+- equal_principal : fixed principal per year in repayment period
+- equal_installment : EMI (level annuity)
+- dscr_sculpted : principal sculpted so DSCR = target each year
+- balloon : interest-only then principal at end
+- custom_pct_vector : caller supplies repayment % per year of total debt
+"""
+
+from __future__ import annotations
+
+from remodel_engine.schemas.debt import DebtConfig, DebtYearRow
+
+
+def _equal_principal_repayment(
+ debt: float, tenor: int, morat: int
+) -> list[float]:
+ repay_years = tenor - morat
+ if repay_years <= 0:
+ return [0.0] * tenor
+ annual = debt / repay_years
+ return [0.0] * morat + [annual] * repay_years
+
+
+def _equal_installment_repayment(
+ debt: float, interest_rate: float, tenor: int, morat: int
+) -> list[float]:
+ r = interest_rate
+ repay_years = tenor - morat
+ if repay_years <= 0 or r <= 0:
+ return _equal_principal_repayment(debt, tenor, morat)
+ # EMI = PV * r / (1 - (1+r)^-n)
+ emi = debt * r / (1 - (1 + r) ** (-repay_years))
+ principals = []
+ balance = debt
+ for y in range(tenor):
+ if y < morat:
+ principals.append(0.0)
+ else:
+ interest = balance * r
+ principal = emi - interest
+ principals.append(max(0.0, principal))
+ balance = max(0.0, balance - principal)
+ return principals
+
+
+def _balloon_repayment(debt: float, tenor: int, morat: int) -> list[float]:
+ principals = [0.0] * tenor
+ if tenor > 0:
+ principals[-1] = debt
+ return principals
+
+
+def _dscr_sculpted_repayment(
+ debt: float,
+ cfads_by_year: list[float],
+ interest_rate: float,
+ tenor: int,
+ morat: int,
+ target_dscr: float,
+) -> list[float]:
+ """Sculpt principal so that DSCR ≈ target in each repayment year.
+
+ principal[y] = CFADS[y] / target_dscr - interest[y]
+ """
+ principals: list[float] = []
+ balance = debt
+ for y in range(tenor):
+ interest = balance * interest_rate
+ if y < morat:
+ principal = 0.0
+ else:
+ cfads = cfads_by_year[y] if y < len(cfads_by_year) else 0.0
+ target_dts = cfads / target_dscr if target_dscr > 0 else 0.0
+ principal = max(0.0, min(target_dts - interest, balance))
+ principals.append(principal)
+ balance = max(0.0, balance - principal)
+ return principals
+
+
+def build_debt_schedule(
+ debt_cr: float,
+ cfads_by_year: list[float],
+ config: DebtConfig,
+ n_years: int = 25,
+) -> list[DebtYearRow]:
+ """Build annual debt schedule for all operating years."""
+ r = config.interest_rate_annual
+ tenor = config.tenor_years
+ morat = config.moratorium_years
+
+ # Compute principal repayments
+ if config.schedule_shape == "equal_principal":
+ principals_list = _equal_principal_repayment(debt_cr, tenor, morat)
+ elif config.schedule_shape == "equal_installment":
+ principals_list = _equal_installment_repayment(debt_cr, r, tenor, morat)
+ elif config.schedule_shape == "balloon":
+ principals_list = _balloon_repayment(debt_cr, tenor, morat)
+ elif config.schedule_shape == "dscr_sculpted":
+ principals_list = _dscr_sculpted_repayment(
+ debt_cr, cfads_by_year, r, tenor, morat, config.avg_dscr
+ )
+ elif config.schedule_shape == "custom_pct_vector":
+ vec = config.custom_pct_vector or []
+ principals_list = [debt_cr * p for p in vec[:tenor]]
+ principals_list += [0.0] * max(0, tenor - len(vec))
+ else:
+ principals_list = _equal_principal_repayment(debt_cr, tenor, morat)
+
+ # Pad to n_years
+ while len(principals_list) < n_years:
+ principals_list.append(0.0)
+
+ rows: list[DebtYearRow] = []
+ balance = debt_cr
+ for y in range(n_years):
+ interest = balance * r
+ principal = principals_list[y]
+ principal = min(principal, balance)
+ dts = interest + principal
+ cfads = cfads_by_year[y] if y < len(cfads_by_year) else 0.0
+ dscr = cfads / dts if dts > 1e-6 else float("inf")
+ closing = max(0.0, balance - principal)
+ rows.append(DebtYearRow(
+ year=y + 1,
+ opening_balance_cr=round(balance, 4),
+ interest_cr=round(interest, 4),
+ principal_cr=round(principal, 4),
+ total_debt_service_cr=round(dts, 4),
+ closing_balance_cr=round(closing, 4),
+ dscr=round(dscr, 4),
+ ))
+ balance = closing
+ return rows
diff --git a/packages/engine/src/remodel_engine/debt/sizing.py b/packages/engine/src/remodel_engine/debt/sizing.py
new file mode 100644
index 0000000..d6680df
--- /dev/null
+++ b/packages/engine/src/remodel_engine/debt/sizing.py
@@ -0,0 +1,95 @@
+"""Debt sizing: determine maximum debt given 3 constraints.
+
+Three constraints (take the binding / minimum):
+1. D:E cap → debt <= total_capex * de_ratio / (1 + de_ratio)
+2. Min DSCR → debt <= max debt where min(DSCR_by_year) >= min_dscr
+3. Avg DSCR → debt <= max debt where avg(DSCR_by_year) >= avg_dscr
+
+Fixed-point: CFADS depends on interest which depends on debt.
+"""
+
+from __future__ import annotations
+
+from remodel_engine.schemas.debt import DebtConfig
+
+
+def _dscr_constraint_debt(
+ cfads_by_year: list[float],
+ total_debt_service_by_year: list[float],
+ target_dscr: float,
+ mode: str = "min",
+) -> float:
+ """Placeholder: compute max debt scaling factor to satisfy DSCR constraint.
+
+ This is a simplified version; full implementation scales the debt until
+ the binding constraint binds.
+ """
+ if mode == "min":
+ min_dscr_ratio = min(
+ cfads / dts if dts > 0 else float("inf")
+ for cfads, dts in zip(cfads_by_year, total_debt_service_by_year, strict=False)
+ )
+ return min_dscr_ratio / target_dscr
+ # avg
+ valid = [
+ (c, d)
+ for c, d in zip(cfads_by_year, total_debt_service_by_year, strict=False)
+ if d > 0
+ ]
+ if not valid:
+ return 1.0
+ avg_dscr = sum(c / d for c, d in valid) / len(valid)
+ return avg_dscr / target_dscr
+
+
+def size_debt(
+ total_capex_cr: float,
+ cfads_by_year: list[float],
+ config: DebtConfig,
+ tol_cr: float = 0.01,
+ max_iter: int = 50,
+) -> float:
+ """Return debt amount (INR Cr) satisfying all three constraints.
+
+ Fixed-point: iterate because interest (and thus CFADS) depends on debt.
+ """
+ # Constraint 1: D:E ratio
+ max_debt_de = total_capex_cr * config.de_ratio / (1 + config.de_ratio)
+
+ # Start with D:E constraint as initial guess
+ debt = min(max_debt_de, total_capex_cr * 0.75)
+
+ r = config.interest_rate_annual
+ tenor = config.tenor_years
+ morat = config.moratorium_years
+
+ for _ in range(max_iter):
+ # Build a simple equal-principal schedule to estimate DSCR
+ repay_years = tenor - morat
+ annual_principal = debt / repay_years if repay_years > 0 else 0.0
+
+ total_debt_service: list[float] = []
+ balance = debt
+ for y in range(25):
+ interest = balance * r
+ if y < morat:
+ principal = 0.0
+ elif y < tenor:
+ principal = annual_principal
+ else:
+ principal = 0.0
+ dts = interest + principal
+ total_debt_service.append(dts)
+ balance = max(0.0, balance - principal)
+
+ # DSCR constraint — scale debt to satisfy min and avg
+ min_scale = _dscr_constraint_debt(cfads_by_year, total_debt_service, config.min_dscr, "min")
+ avg_scale = _dscr_constraint_debt(cfads_by_year, total_debt_service, config.avg_dscr, "avg")
+ binding_scale = min(min_scale, avg_scale)
+
+ new_debt = min(debt * binding_scale, max_debt_de)
+ if abs(new_debt - debt) < tol_cr:
+ return round(new_debt, 4)
+ debt = new_debt
+
+ return round(debt, 4)
diff --git a/packages/engine/src/remodel_engine/dispatch/__init__.py b/packages/engine/src/remodel_engine/dispatch/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/packages/engine/src/remodel_engine/dispatch/hybrid_rtc.py b/packages/engine/src/remodel_engine/dispatch/hybrid_rtc.py
new file mode 100644
index 0000000..d24b304
--- /dev/null
+++ b/packages/engine/src/remodel_engine/dispatch/hybrid_rtc.py
@@ -0,0 +1,142 @@
+"""Hybrid RTC dispatch: per-hour dispatch for solar+wind+BESS.
+
+Dispatch logic:
+1. Available generation = solar_mw[h] + wind_mw[h]
+2. Schedule target = rtc_mw (contracted RTC capacity)
+3. If available >= target: charge surplus into BESS (up to soc_max)
+4. If available < target: discharge BESS to cover shortfall (down to soc_min)
+5. Remaining shortfall after BESS = DSM penalty
+6. Surplus after BESS full = curtailed or sold to MCP
+
+All units: MW (power), MWh (energy per hour), fraction (SOC).
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+
+
+@dataclass
+class DispatchConfig:
+ rtc_mw: float = 0.0
+ bess_mwh: float = 0.0
+ bess_mw: float = 0.0
+ dod: float = 0.85
+ rte: float = 0.85
+ initial_soc_frac: float = 0.50
+ mcp_enabled: bool = False
+
+
+@dataclass
+class HourlyDispatch:
+ hour: int
+ solar_mw: float
+ wind_mw: float
+ bess_charge_mw: float
+ bess_discharge_mw: float
+ soc_mwh: float
+ net_injection_mw: float
+ shortfall_mw: float
+ curtailed_mw: float
+ mcp_revenue_inr: float = 0.0
+
+
+@dataclass
+class DispatchSummary:
+ total_net_injection_mwh: float
+ total_shortfall_mwh: float
+ total_curtailed_mwh: float
+ total_mcp_revenue_inr: float
+ rtc_cuf_achieved: float
+ avg_soc_frac: float
+ hourly: list[HourlyDispatch] = field(default_factory=list)
+
+
+def run_dispatch(
+ solar_mw: list[float],
+ wind_mw: list[float],
+ config: DispatchConfig,
+ mcp_prices_inr_per_mwh: list[float] | None = None,
+) -> DispatchSummary:
+ """Run per-hour hybrid RTC dispatch for a single year (8760 hours).
+
+ Returns a DispatchSummary with hourly details and annual totals.
+ """
+ n = len(solar_mw)
+ assert len(wind_mw) == n, "solar and wind arrays must be same length"
+
+ soc_max = config.bess_mwh * config.dod
+ soc_min = 0.0
+ soc = config.bess_mwh * config.initial_soc_frac
+ rtc = config.rtc_mw
+ bess_mw = config.bess_mw
+ rte = config.rte
+
+ hourly: list[HourlyDispatch] = []
+ total_injection = 0.0
+ total_shortfall = 0.0
+ total_curtailed = 0.0
+ total_mcp = 0.0
+ soc_sum = 0.0
+
+ for h in range(n):
+ gen = solar_mw[h] + wind_mw[h]
+ surplus = gen - rtc # positive = excess, negative = deficit
+
+ charge = 0.0
+ discharge = 0.0
+ shortfall = 0.0
+ curtailed = 0.0
+
+ if surplus >= 0:
+ # Charge BESS with surplus
+ charge_headroom = (soc_max - soc) / rte if rte > 0 else 0.0
+ charge = min(surplus, bess_mw, charge_headroom)
+ soc = min(soc_max, soc + charge * rte)
+ curtailed = surplus - charge
+ else:
+ deficit = -surplus
+ discharge_available = min(soc - soc_min, bess_mw)
+ discharge = min(deficit, discharge_available)
+ soc = max(soc_min, soc - discharge)
+ shortfall = max(0.0, deficit - discharge)
+
+ net_injection = rtc - shortfall
+
+ mcp_rev = 0.0
+ if config.mcp_enabled and curtailed > 0 and mcp_prices_inr_per_mwh:
+ price = mcp_prices_inr_per_mwh[h % len(mcp_prices_inr_per_mwh)]
+ mcp_rev = curtailed * price
+
+ hourly.append(
+ HourlyDispatch(
+ hour=h,
+ solar_mw=solar_mw[h],
+ wind_mw=wind_mw[h],
+ bess_charge_mw=round(charge, 4),
+ bess_discharge_mw=round(discharge, 4),
+ soc_mwh=round(soc, 4),
+ net_injection_mw=round(net_injection, 4),
+ shortfall_mw=round(shortfall, 4),
+ curtailed_mw=round(curtailed, 4),
+ mcp_revenue_inr=round(mcp_rev, 2),
+ )
+ )
+
+ total_injection += net_injection
+ total_shortfall += shortfall
+ total_curtailed += curtailed
+ total_mcp += mcp_rev
+ soc_sum += soc
+
+ rtc_cuf = total_injection / (rtc * n) if rtc > 0 and n > 0 else 0.0
+
+ return DispatchSummary(
+ total_net_injection_mwh=round(total_injection, 2),
+ total_shortfall_mwh=round(total_shortfall, 2),
+ total_curtailed_mwh=round(total_curtailed, 2),
+ total_mcp_revenue_inr=round(total_mcp, 2),
+ rtc_cuf_achieved=round(rtc_cuf, 4),
+ avg_soc_frac=round(soc_sum / (n * config.bess_mwh) if config.bess_mwh > 0 else 0.0, 4),
+ hourly=hourly,
+ )
diff --git a/packages/engine/src/remodel_engine/dispatch/mcp_settlement.py b/packages/engine/src/remodel_engine/dispatch/mcp_settlement.py
new file mode 100644
index 0000000..d3d73e3
--- /dev/null
+++ b/packages/engine/src/remodel_engine/dispatch/mcp_settlement.py
@@ -0,0 +1,34 @@
+"""MCP (Market Clearing Price) settlement for surplus energy."""
+
+from __future__ import annotations
+
+from remodel_engine.dispatch.hybrid_rtc import DispatchSummary
+
+
+def compute_mcp_annual_revenue_cr(
+ summary: DispatchSummary,
+ fx_rate: float = 83.0,
+) -> float:
+ """Convert total MCP revenue from INR to INR Crore.
+
+ summary.total_mcp_revenue_inr is in INR (absolute).
+ """
+ return round(summary.total_mcp_revenue_inr / 1e7, 4)
+
+
+def build_mcp_price_profile(
+ base_price_inr_per_mwh: float = 3000.0,
+ peak_premium: float = 1.5,
+ peak_hours: set[int] | None = None,
+) -> list[float]:
+ """Build a simple 8760-hour MCP price profile.
+
+ Peak hours (17:00-21:00 = hours 17-20) get a premium.
+ """
+ if peak_hours is None:
+ peak_hours = set(range(17, 22))
+
+ return [
+ base_price_inr_per_mwh * (peak_premium if (h % 24) in peak_hours else 1.0)
+ for h in range(8760)
+ ]
diff --git a/packages/engine/src/remodel_engine/financial/__init__.py b/packages/engine/src/remodel_engine/financial/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/packages/engine/src/remodel_engine/financial/bs.py b/packages/engine/src/remodel_engine/financial/bs.py
new file mode 100644
index 0000000..078439a
--- /dev/null
+++ b/packages/engine/src/remodel_engine/financial/bs.py
@@ -0,0 +1,66 @@
+"""25-year Balance Sheet.
+
+Assets = Fixed assets (net block) + Current assets (cash + receivables)
+Liabilities + Equity = Equity + Reserves + LT Debt + Current Liabilities + DTL
+
+Reconciliation: Total Assets == Total Liabilities (within ₹0.01 Cr).
+"""
+
+from __future__ import annotations
+
+from remodel_engine.schemas.financial import BSRow
+
+
+def build_bs(
+ gross_block_cr: float,
+ accumulated_depr_by_year: list[float],
+ net_block_by_year: list[float],
+ cash_by_year: list[float],
+ receivables_by_year: list[float],
+ equity_cr: float,
+ retained_earnings_by_year: list[float],
+ debt_outstanding_by_year: list[float],
+ payables_by_year: list[float],
+ dtl_by_year: list[float],
+ tol_cr: float = 0.05,
+) -> list[BSRow]:
+ """Build 25-year BS and assert reconciliation each year."""
+ n = len(net_block_by_year)
+ rows: list[BSRow] = []
+ for y in range(n):
+ net_block = net_block_by_year[y]
+ cash = cash_by_year[y]
+ recv = receivables_by_year[y]
+ total_assets = net_block + cash + recv
+
+ equity = equity_cr
+ reserves = retained_earnings_by_year[y]
+ ltd = debt_outstanding_by_year[y]
+ payables = payables_by_year[y]
+ dtl = max(0.0, dtl_by_year[y]) # DTL cannot go negative in this model
+ total_liabilities = equity + reserves + ltd + payables + dtl
+
+ # Reconciliation check
+ diff = abs(total_assets - total_liabilities)
+ assert diff <= tol_cr, (
+ f"BS reconciliation fail Y{y+1}: "
+ f"assets={total_assets:.2f}, liab={total_liabilities:.2f}, diff={diff:.4f} Cr"
+ )
+
+ rows.append(BSRow(
+ year=y + 1,
+ gross_block_cr=round(gross_block_cr, 4),
+ accumulated_depr_cr=round(accumulated_depr_by_year[y], 4),
+ net_block_cr=round(net_block, 4),
+ cash_cr=round(cash, 4),
+ receivables_cr=round(recv, 4),
+ other_current_assets_cr=0.0,
+ total_assets_cr=round(total_assets, 4),
+ equity_cr=round(equity, 4),
+ reserves_cr=round(reserves, 4),
+ long_term_debt_cr=round(ltd, 4),
+ payables_cr=round(payables, 4),
+ deferred_tax_liability_cr=round(dtl, 4),
+ total_liabilities_cr=round(total_liabilities, 4),
+ ))
+ return rows
diff --git a/packages/engine/src/remodel_engine/financial/cfs.py b/packages/engine/src/remodel_engine/financial/cfs.py
new file mode 100644
index 0000000..001a1c4
--- /dev/null
+++ b/packages/engine/src/remodel_engine/financial/cfs.py
@@ -0,0 +1,60 @@
+"""25-year Cash Flow Statement (indirect method).
+
+CFO = PAT + Depreciation - Delta WC
+CFI = -(Capex + IDC) [negative: cash out during construction, then 0]
+CFF = Debt drawdown - Debt repayment + Equity injection
+"""
+
+from __future__ import annotations
+
+from remodel_engine.schemas.financial import CFSRow
+
+
+def build_cfs(
+ pnl_pat: list[float],
+ pnl_depr: list[float],
+ delta_wc_by_year: list[float],
+ capex_cr: float, # total capex incl IDC — outflow at year 0 (construction)
+ debt_drawdown_by_year: list[float], # debt drawn each year (1-indexed)
+ debt_repayment_by_year: list[float], # debt repaid each year
+ equity_injection_by_year: list[float],
+ opening_cash_cr: float = 0.0,
+) -> list[CFSRow]:
+ """Build 25-year CFS. Construction period assumed to be year 0 (pre-COD)."""
+ n = len(pnl_pat)
+ rows: list[CFSRow] = []
+ cash = opening_cash_cr
+
+ for y in range(n):
+ pat = pnl_pat[y]
+ depr = pnl_depr[y]
+ dwc = delta_wc_by_year[y]
+
+ cfo = pat + depr - dwc
+ cfi = 0.0 # all capex in year 0 (pre-COD); no material CFI in operating years
+ dd = debt_drawdown_by_year[y] if y < len(debt_drawdown_by_year) else 0.0
+ dr = debt_repayment_by_year[y] if y < len(debt_repayment_by_year) else 0.0
+ eq = equity_injection_by_year[y] if y < len(equity_injection_by_year) else 0.0
+ cff = dd - dr + eq
+ net = cfo + cfi + cff
+ closing = cash + net
+
+ rows.append(CFSRow(
+ year=y + 1,
+ pat_cr=round(pat, 4),
+ depreciation_cr=round(depr, 4),
+ delta_working_capital_cr=round(dwc, 4),
+ cfo_cr=round(cfo, 4),
+ capex_cr=0.0,
+ cfi_cr=0.0,
+ debt_drawdown_cr=round(dd, 4),
+ debt_repayment_cr=round(dr, 4),
+ equity_injection_cr=round(eq, 4),
+ cff_cr=round(cff, 4),
+ net_cash_flow_cr=round(net, 4),
+ opening_cash_cr=round(cash, 4),
+ closing_cash_cr=round(closing, 4),
+ ))
+ cash = closing
+
+ return rows
diff --git a/packages/engine/src/remodel_engine/financial/depreciation.py b/packages/engine/src/remodel_engine/financial/depreciation.py
new file mode 100644
index 0000000..d52a2a0
--- /dev/null
+++ b/packages/engine/src/remodel_engine/financial/depreciation.py
@@ -0,0 +1,168 @@
+"""Depreciation schedules: book (SLM) and tax (WDV).
+
+Book depreciation classes and useful lives:
+ Plant : 25 years SLM
+ BESS : 12 years SLM
+ Building : 30 years SLM
+ Intangible : 25 years SLM (amortization)
+ Land_NoDepr : no depreciation
+ LandLease_* : amortized over lease term
+ Capitalized_* : no depreciation
+ Expensed : expensed in year 0 (not on BS)
+
+Tax (WDV) rates (India, as per IT Act):
+ Plant/BESS : 40%
+ Building : 10%
+ Intangible : 25%
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+
+BOOK_USEFUL_LIFE: dict[str, float] = {
+ "Plant": 25.0,
+ "BESS": 12.0,
+ "Building": 30.0,
+ "Intangible": 25.0,
+ "LandLease_Amortized": 25.0, # overridden by lease term if known
+ "Land_NoDepr": 0.0,
+ "Capitalized_NoDepr": 0.0,
+ "Expensed": 0.0,
+}
+
+WDV_RATES: dict[str, float] = {
+ "Plant": 0.40,
+ "BESS": 0.40,
+ "Building": 0.10,
+ "Intangible": 0.25,
+ "LandLease_Amortized": 0.25,
+ "Land_NoDepr": 0.0,
+ "Capitalized_NoDepr": 0.0,
+ "Expensed": 0.0,
+}
+
+
+@dataclass
+class AssetBlock:
+ """Gross cost and depreciation for one asset class."""
+
+ depr_class: str
+ gross_cost_cr: float
+ idc_allocated_cr: float = 0.0 # IDC capitalized into this block
+
+ @property
+ def total_cost_cr(self) -> float:
+ return self.gross_cost_cr + self.idc_allocated_cr
+
+
+@dataclass
+class DepreciationSchedule:
+ """25-year depreciation schedule (book and tax)."""
+
+ book_depr: list[float] = field(default_factory=list) # INR Cr per year
+ tax_depr: list[float] = field(default_factory=list) # INR Cr per year
+ accumulated_book: list[float] = field(default_factory=list)
+ net_block_book: list[float] = field(default_factory=list)
+ wdv_tax: list[float] = field(default_factory=list) # WDV at end of year (for tax)
+
+
+def compute_slm_depreciation(
+ cost_cr: float,
+ useful_life_years: float,
+ n_years: int = 25,
+) -> list[float]:
+ """Straight-line book depreciation; zero after useful life exhausted."""
+ if useful_life_years <= 0 or cost_cr <= 0:
+ return [0.0] * n_years
+ annual = cost_cr / useful_life_years
+ result = []
+ remaining = cost_cr
+ for _ in range(n_years):
+ if remaining <= 1e-6:
+ result.append(0.0)
+ else:
+ depr = min(annual, remaining)
+ result.append(round(depr, 6))
+ remaining -= depr
+ return result
+
+
+def compute_wdv_depreciation(
+ cost_cr: float,
+ rate: float,
+ n_years: int = 25,
+) -> list[float]:
+ """Declining balance (WDV) tax depreciation."""
+ if rate <= 0 or cost_cr <= 0:
+ return [0.0] * n_years
+ result = []
+ wdv = cost_cr
+ for _ in range(n_years):
+ depr = wdv * rate
+ result.append(round(depr, 6))
+ wdv -= depr
+ return result
+
+
+def build_depreciation_schedule(
+ blocks: list[AssetBlock],
+ n_years: int = 25,
+) -> DepreciationSchedule:
+ """Aggregate book and tax depreciation across all asset blocks.
+
+ IDC is allocated proportionally to capitalized asset classes.
+ """
+ book_per_year = [0.0] * n_years
+ tax_per_year = [0.0] * n_years
+
+ for blk in blocks:
+ if blk.depr_class in ("Land_NoDepr", "Capitalized_NoDepr", "Expensed"):
+ continue # no depreciation
+
+ cost = blk.total_cost_cr
+ life = BOOK_USEFUL_LIFE.get(blk.depr_class, 0.0)
+ wdv_rate = WDV_RATES.get(blk.depr_class, 0.0)
+
+ book = compute_slm_depreciation(cost, life, n_years)
+ tax = compute_wdv_depreciation(cost, wdv_rate, n_years)
+
+ for y in range(n_years):
+ book_per_year[y] += book[y]
+ tax_per_year[y] += tax[y]
+
+ # Build cumulative and net block series
+ acc_book = 0.0
+ gross = sum(blk.total_cost_cr for blk in blocks
+ if blk.depr_class not in ("Expensed",))
+ accumulated_book = []
+ net_block_book = []
+
+ for y in range(n_years):
+ acc_book += book_per_year[y]
+ accumulated_book.append(round(acc_book, 4))
+ net_block_book.append(round(max(0.0, gross - acc_book), 4))
+
+ # WDV for tax (cumulative)
+ wdv_running: dict[str, float] = {}
+ wdv_total: list[float] = []
+ for blk in blocks:
+ if blk.depr_class not in WDV_RATES or WDV_RATES[blk.depr_class] == 0.0:
+ continue
+ wdv_running[blk.depr_class] = (
+ wdv_running.get(blk.depr_class, 0.0) + blk.total_cost_cr
+ )
+
+ for _ in range(n_years):
+ for blk_class, wdv in list(wdv_running.items()):
+ rate = WDV_RATES[blk_class]
+ wdv_running[blk_class] = wdv * (1.0 - rate)
+ wdv_total.append(round(sum(wdv_running.values()), 4))
+
+ return DepreciationSchedule(
+ book_depr=book_per_year,
+ tax_depr=tax_per_year,
+ accumulated_book=accumulated_book,
+ net_block_book=net_block_book,
+ wdv_tax=wdv_total,
+ )
diff --git a/packages/engine/src/remodel_engine/financial/pnl.py b/packages/engine/src/remodel_engine/financial/pnl.py
new file mode 100644
index 0000000..c2814a1
--- /dev/null
+++ b/packages/engine/src/remodel_engine/financial/pnl.py
@@ -0,0 +1,149 @@
+"""25-year Profit & Loss statement.
+
+Revenue
+ - OpEx (O&M, insurance, land lease, AM fee, misc)
+= EBITDA
+ - Depreciation (book SLM)
+= EBIT
+ - Interest expense (from debt schedule)
+= PBT
+ - Current tax (115BAA)
+= PAT
+"""
+
+from __future__ import annotations
+
+from remodel_engine.schemas.financial import OpexConfig, PnLRow
+
+
+def compute_ppa_units(
+ gen_mwh_by_year: list[float],
+ aux_pct: float,
+ tx_loss_pct: float,
+ dsm_loss_pct: float,
+) -> list[float]:
+ """Net PPA-billable units for each year in MWh."""
+ return [
+ round(mwh * (1 - aux_pct) * (1 - tx_loss_pct) * (1 - dsm_loss_pct), 2)
+ for mwh in gen_mwh_by_year
+ ]
+
+
+def compute_revenue(
+ ac_gen_mwh_by_year: list[float],
+ tariff_inr_per_kwh: float,
+ aux_pct: float,
+ tx_loss_pct: float,
+ dsm_loss_pct: float,
+ bad_debt_pct: float = 0.0,
+) -> list[float]:
+ """Net revenue for each year in INR Crore.
+
+ net_injection = generation * (1 - aux) * (1 - tx_loss) * (1 - dsm_loss)
+ revenue = net_injection * tariff / 1000 (kWh from MWh: already in kWh via MWh*1000)
+ """
+ rev = []
+ for mwh in ac_gen_mwh_by_year:
+ net_kwh = mwh * 1000.0 * (1 - aux_pct) * (1 - tx_loss_pct) * (1 - dsm_loss_pct)
+ gross_rev = net_kwh * tariff_inr_per_kwh / 1e7 # INR → Cr (1 Cr = 1e7 INR)
+ rev.append(round(gross_rev * (1 - bad_debt_pct), 4))
+ return rev
+
+
+def compute_opex(
+ revenue_by_year: list[float],
+ solar_mw: float,
+ wind_mw: float,
+ bess_mwh: float,
+ base_capex_cr: float,
+ config: OpexConfig,
+) -> list[float]:
+ """Total opex for each year in INR Crore (excluding depreciation and interest)."""
+ opex: list[float] = []
+ for y, rev in enumerate(revenue_by_year):
+ esc = (1.0 + config.om_escalation_pct) ** y
+ om = (
+ config.om_solar_cr_per_mw * solar_mw
+ + config.om_wind_cr_per_mw * wind_mw
+ + config.om_bess_cr_per_mwh * bess_mwh
+ ) * esc
+ insurance = config.insurance_pct_of_capex * base_capex_cr
+ land = config.land_lease_cr * esc
+ am_fee = config.am_fee_pct_of_revenue * rev
+ misc = config.misc_cr * esc
+ opex.append(round(om + insurance + land + am_fee + misc, 4))
+ return opex
+
+
+def build_pnl(
+ revenue_by_year: list[float],
+ gen_mwh_by_year: list[float],
+ tariff_inr_per_kwh: float,
+ mcp_revenue_by_year: list[float],
+ mcp_units_by_year: list[float],
+ opex_by_year: list[float],
+ book_depr_by_year: list[float],
+ interest_by_year: list[float],
+ current_tax_by_year: list[float],
+ deferred_tax_by_year: list[float],
+ solar_mw: float,
+ wind_mw: float,
+ bess_mwh: float,
+ base_capex_cr: float,
+ config: OpexConfig,
+) -> list[PnLRow]:
+ """Build 25-year P&L row list."""
+ n = len(revenue_by_year)
+ rows: list[PnLRow] = []
+ for y in range(n):
+ ppa_rev = revenue_by_year[y]
+ ppa_units = gen_mwh_by_year[y] if y < len(gen_mwh_by_year) else 0.0
+ mcp_rev = mcp_revenue_by_year[y] if y < len(mcp_revenue_by_year) else 0.0
+ mcp_units = mcp_units_by_year[y] if y < len(mcp_units_by_year) else 0.0
+ total_rev = ppa_rev + mcp_rev
+
+ esc = (1.0 + config.om_escalation_pct) ** y
+ om = (
+ config.om_solar_cr_per_mw * solar_mw
+ + config.om_wind_cr_per_mw * wind_mw
+ + config.om_bess_cr_per_mwh * bess_mwh
+ ) * esc
+ ins = config.insurance_pct_of_capex * base_capex_cr
+ land = config.land_lease_cr * esc
+ am = config.am_fee_pct_of_revenue * total_rev
+ misc = config.misc_cr * esc
+
+ opex_total = om + ins + land + am + misc
+ ebitda = total_rev - opex_total
+ depr = book_depr_by_year[y]
+ ebit = ebitda - depr
+ interest = interest_by_year[y]
+ pbt = ebit - interest
+ ct = current_tax_by_year[y]
+ dt = deferred_tax_by_year[y]
+ pat = pbt - ct
+
+ rows.append(PnLRow(
+ year=y + 1,
+ revenue_cr=round(total_rev, 4),
+ ppa_revenue_cr=round(ppa_rev, 4),
+ mcp_revenue_cr=round(mcp_rev, 4),
+ ppa_tariff_inr_per_kwh=tariff_inr_per_kwh,
+ ppa_units_mwh=round(ppa_units, 2),
+ mcp_units_mwh=round(mcp_units, 2),
+ opex_total_cr=round(opex_total, 4),
+ om_cr=round(om, 4),
+ insurance_cr=round(ins, 4),
+ land_lease_cr=round(land, 4),
+ am_fee_cr=round(am, 4),
+ misc_opex_cr=round(misc, 4),
+ ebitda_cr=round(ebitda, 4),
+ depreciation_book_cr=round(depr, 4),
+ ebit_cr=round(ebit, 4),
+ interest_cr=round(interest, 4),
+ pbt_cr=round(pbt, 4),
+ tax_cr=round(ct, 4),
+ pat_cr=round(pat, 4),
+ deferred_tax_cr=round(dt, 4),
+ ))
+ return rows
\ No newline at end of file
diff --git a/packages/engine/src/remodel_engine/financial/tax.py b/packages/engine/src/remodel_engine/financial/tax.py
new file mode 100644
index 0000000..b9dd220
--- /dev/null
+++ b/packages/engine/src/remodel_engine/financial/tax.py
@@ -0,0 +1,62 @@
+"""Tax computation under Section 115BAA (India).
+
+Key rules:
+- Rate: 22% base + 10% surcharge + 4% cess = 25.17% effective on PBT
+- No MAT (minimum alternate tax) under 115BAA
+- No unabsorbed depreciation carry-forward (simplified v0)
+- Deferred tax from book vs tax depreciation difference
+"""
+
+from __future__ import annotations
+
+from remodel_engine.schemas.financial import TaxConfig
+
+
+def compute_current_tax(pbt_cr: float, rate: float) -> float:
+ """Current tax = max(0, PBT * rate). No negative tax."""
+ return max(0.0, pbt_cr * rate)
+
+
+def compute_deferred_tax(
+ book_depr_cr: float,
+ tax_depr_cr: float,
+ rate: float,
+ opening_dtl_cr: float = 0.0,
+) -> tuple[float, float]:
+ """Compute deferred tax liability movement and closing balance.
+
+ DTL increases when tax depr > book depr (timing difference).
+ DTL decreases when book depr > tax depr (reversal).
+
+ Returns (deferred_tax_movement_cr, closing_dtl_cr).
+ Positive movement = DTL increases = P&L debit.
+ """
+ timing_diff = tax_depr_cr - book_depr_cr # positive → tax faster
+ dtl_movement = timing_diff * rate
+ closing_dtl = opening_dtl_cr + dtl_movement
+ return dtl_movement, closing_dtl
+
+
+def compute_tax_schedule(
+ pbt_by_year: list[float],
+ book_depr_by_year: list[float],
+ tax_depr_by_year: list[float],
+ config: TaxConfig,
+) -> tuple[list[float], list[float], list[float]]:
+ """Return (current_tax, deferred_tax_movement, closing_dtl) lists for 25 years."""
+ n = len(pbt_by_year)
+ current_tax: list[float] = []
+ def_tax_movement: list[float] = []
+ closing_dtl: list[float] = []
+
+ dtl = 0.0
+ for y in range(n):
+ ct = compute_current_tax(pbt_by_year[y], config.rate)
+ dt_move, dtl = compute_deferred_tax(
+ book_depr_by_year[y], tax_depr_by_year[y], config.rate, dtl
+ )
+ current_tax.append(round(ct, 4))
+ def_tax_movement.append(round(dt_move, 4))
+ closing_dtl.append(round(dtl, 4))
+
+ return current_tax, def_tax_movement, closing_dtl
diff --git a/packages/engine/src/remodel_engine/financial/working_capital.py b/packages/engine/src/remodel_engine/financial/working_capital.py
new file mode 100644
index 0000000..86b211a
--- /dev/null
+++ b/packages/engine/src/remodel_engine/financial/working_capital.py
@@ -0,0 +1,32 @@
+"""Working capital computation.
+
+Working capital = receivables + inventory - payables.
+Delta WC in each year is the change in net working capital.
+"""
+
+from __future__ import annotations
+
+from remodel_engine.schemas.financial import CommercialConfig
+
+
+def compute_working_capital(
+ revenue_by_year: list[float],
+ opex_by_year: list[float],
+ config: CommercialConfig,
+) -> tuple[list[float], list[float]]:
+ """Return (net_wc_by_year, delta_wc_by_year) in INR Cr.
+
+ Receivables = revenue * receivable_days / 365
+ Payables = opex * payable_days / 365
+ Net WC = receivables - payables (inventory assumed zero for RE)
+ Delta WC = WC[y] - WC[y-1] (positive = cash outflow)
+ """
+ n = len(revenue_by_year)
+ wc: list[float] = []
+ for y in range(n):
+ receivables = revenue_by_year[y] * config.receivable_days / 365.0
+ payables = opex_by_year[y] * config.payable_days / 365.0
+ wc.append(round(receivables - payables, 4))
+
+ delta_wc = [wc[0]] + [wc[y] - wc[y - 1] for y in range(1, n)]
+ return wc, delta_wc
diff --git a/packages/engine/src/remodel_engine/io/__init__.py b/packages/engine/src/remodel_engine/io/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/packages/engine/src/remodel_engine/io/excel_export.py b/packages/engine/src/remodel_engine/io/excel_export.py
new file mode 100644
index 0000000..290afc4
--- /dev/null
+++ b/packages/engine/src/remodel_engine/io/excel_export.py
@@ -0,0 +1,247 @@
+"""Multi-sheet Excel export for a ScenarioResult.
+
+Sheets:
+ 1. KPIs — headline metrics
+ 2. PnL — annual P&L statement
+ 3. CFS — cash flow statement
+ 4. BS — balance sheet
+ 5. DebtSched — debt amortisation schedule
+ 6. Inputs — flat dump of key input assumptions
+"""
+
+from __future__ import annotations
+
+import io
+from typing import Any
+
+from openpyxl import Workbook # type: ignore[import-untyped]
+from openpyxl.styles import Alignment, Font, PatternFill # type: ignore[import-untyped]
+from openpyxl.utils import get_column_letter # type: ignore[import-untyped]
+
+from remodel_engine.schemas.scenario import ScenarioResult
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+_HEADER_FILL = PatternFill("solid", fgColor="1F4E79")
+_HEADER_FONT = Font(color="FFFFFF", bold=True)
+_SUBHEADER_FILL = PatternFill("solid", fgColor="D6E4F0")
+_SUBHEADER_FONT = Font(bold=True)
+_HIGHLIGHT_FILL = PatternFill("solid", fgColor="FFF2CC")
+_NUMBER_FORMAT = "#,##0.00"
+_PCT_FORMAT = "0.00%"
+
+
+def _write_header(ws: Any, cols: list[str], row: int = 1) -> None:
+ for c, label in enumerate(cols, 1):
+ cell = ws.cell(row=row, column=c, value=label)
+ cell.fill = _HEADER_FILL
+ cell.font = _HEADER_FONT
+ cell.alignment = Alignment(horizontal="center")
+
+
+def _autofit(ws: Any) -> None:
+ for col in ws.columns:
+ max_len = max((len(str(cell.value or "")) for cell in col), default=8)
+ ws.column_dimensions[get_column_letter(col[0].column)].width = min(max_len + 4, 30)
+
+
+# ---------------------------------------------------------------------------
+# Sheet builders
+# ---------------------------------------------------------------------------
+
+def _write_kpis(wb: Workbook, result: ScenarioResult) -> None:
+ ws = wb.create_sheet("KPIs")
+ kpis = result.kpis
+
+ rows: list[tuple[str, Any, str]] = [
+ ("Solved Tariff (₹/kWh)", kpis.solved_tariff_inr_per_kwh, _NUMBER_FORMAT),
+ ("Equity IRR", kpis.equity_irr, _PCT_FORMAT),
+ ("Project IRR", kpis.project_irr, _PCT_FORMAT),
+ ("Min DSCR", kpis.min_dscr, _NUMBER_FORMAT),
+ ("Avg DSCR", kpis.avg_dscr, _NUMBER_FORMAT),
+ ("Total Capex (₹ Cr)", kpis.total_capex_cr, _NUMBER_FORMAT),
+ ("IDC (₹ Cr)", kpis.idc_cr, _NUMBER_FORMAT),
+ ("Debt (₹ Cr)", kpis.debt_cr, _NUMBER_FORMAT),
+ ("LCOE (₹/kWh)", kpis.lcoe_inr_per_kwh, _NUMBER_FORMAT),
+ ("Payback (yrs)", kpis.payback_years, _NUMBER_FORMAT),
+ ("Solar Y1 CUF", kpis.solar_y1_cuf, _PCT_FORMAT),
+ ("Wind Y1 PLF", kpis.wind_y1_plf, _PCT_FORMAT),
+ ("RTC CUF Achieved", kpis.rtc_cuf_achieved, _PCT_FORMAT),
+ ("Total Shortfall (MWh)", kpis.total_shortfall_mwh, _NUMBER_FORMAT),
+ ("Total Curtailed (MWh)", kpis.total_curtailed_mwh, _NUMBER_FORMAT),
+ ("MCP Revenue (₹ Cr)", kpis.total_mcp_revenue_cr, _NUMBER_FORMAT),
+ ("Runtime (s)", result.runtime_s, _NUMBER_FORMAT),
+ ]
+
+ _write_header(ws, ["Metric", "Value"])
+ for r, (label, value, fmt) in enumerate(rows, 2):
+ ws.cell(row=r, column=1, value=label)
+ cell = ws.cell(row=r, column=2, value=value)
+ if value is not None:
+ cell.number_format = fmt
+ if label in ("Solved Tariff (₹/kWh)", "Equity IRR"):
+ ws.cell(row=r, column=1).fill = _HIGHLIGHT_FILL
+ cell.fill = _HIGHLIGHT_FILL
+
+ _autofit(ws)
+
+
+def _write_pnl(wb: Workbook, result: ScenarioResult) -> None:
+ ws = wb.create_sheet("PnL")
+ if result.financials is None or not result.financials.pnl:
+ return
+ cols = ["Year", "Revenue (Cr)", "EBITDA (Cr)", "Depr (Cr)", "Interest (Cr)",
+ "PBT (Cr)", "Tax (Cr)", "PAT (Cr)"]
+ _write_header(ws, cols)
+ for r, row in enumerate(result.financials.pnl, 2):
+ data = [
+ row.year, row.revenue_cr, row.ebitda_cr, row.depreciation_book_cr,
+ row.interest_cr, row.pbt_cr, row.tax_cr, row.pat_cr,
+ ]
+ for c, v in enumerate(data, 1):
+ cell = ws.cell(row=r, column=c, value=v)
+ if c > 1:
+ cell.number_format = _NUMBER_FORMAT
+ _autofit(ws)
+
+
+def _write_cfs(wb: Workbook, result: ScenarioResult) -> None:
+ ws = wb.create_sheet("CFS")
+ if result.financials is None or not result.financials.cfs:
+ return
+ cols = ["Year", "CFO (Cr)", "CFI (Cr)", "CFF (Cr)", "Net CF (Cr)", "Closing Cash (Cr)"]
+ _write_header(ws, cols)
+ for r, row in enumerate(result.financials.cfs, 2):
+ data = [
+ row.year, row.cfo_cr, row.cfi_cr,
+ row.cff_cr, row.net_cash_flow_cr, row.closing_cash_cr,
+ ]
+ for c, v in enumerate(data, 1):
+ cell = ws.cell(row=r, column=c, value=v)
+ if c > 1:
+ cell.number_format = _NUMBER_FORMAT
+ _autofit(ws)
+
+
+def _write_bs(wb: Workbook, result: ScenarioResult) -> None:
+ ws = wb.create_sheet("BS")
+ if result.financials is None or not result.financials.bs:
+ return
+ cols = ["Year", "Net Block (Cr)", "Cash (Cr)", "Total Assets (Cr)", "Equity (Cr)",
+ "LT Debt (Cr)"]
+ _write_header(ws, cols)
+ for r, row in enumerate(result.financials.bs, 2):
+ data = [
+ row.year, row.net_block_cr, row.cash_cr,
+ row.total_assets_cr, row.equity_cr, row.long_term_debt_cr,
+ ]
+ for c, v in enumerate(data, 1):
+ cell = ws.cell(row=r, column=c, value=v)
+ if c > 1:
+ cell.number_format = _NUMBER_FORMAT
+ _autofit(ws)
+
+
+def _write_debt_sched(wb: Workbook, result: ScenarioResult) -> None:
+ ws = wb.create_sheet("DebtSched")
+ if not result.debt_schedule:
+ return
+ cols = [
+ "Year", "Opening (Cr)", "Drawdown (Cr)", # drawdown always 0 post-COD
+ "Interest (Cr)", "Principal (Cr)", "Closing (Cr)", "DSCR",
+ ]
+ _write_header(ws, cols)
+ for r, row in enumerate(result.debt_schedule, 2):
+ data = [
+ row.year, row.opening_balance_cr, 0.0,
+ row.interest_cr, row.principal_cr, row.closing_balance_cr, row.dscr,
+ ]
+ for c, v in enumerate(data, 1):
+ cell = ws.cell(row=r, column=c, value=v)
+ if c > 1:
+ cell.number_format = _NUMBER_FORMAT
+ _autofit(ws)
+
+
+def _write_inputs(wb: Workbook, result: ScenarioResult) -> None:
+ ws = wb.create_sheet("Inputs")
+ inp = result.inputs
+ _write_header(ws, ["Section", "Parameter", "Value"])
+ rows: list[tuple[str, str, Any]] = [
+ ("Project", "Name", inp.project.name),
+ ("Project", "Solar MWp (DC)", inp.project.capacity_solar_mwp),
+ ("Project", "Wind MW", inp.project.capacity_wind_mw),
+ ("Project", "BESS MWh", inp.project.capacity_bess_mwh),
+ ("Project", "BESS MW", inp.project.capacity_bess_mw),
+ ("Project", "COD Year", inp.project.cod_year),
+ ("Commercial", "Tariff (₹/kWh)", inp.commercial.tariff_inr_per_kwh),
+ ("Commercial", "Aux Consumption %", inp.commercial.aux_consumption_pct),
+ ("Commercial", "Transmission Loss %", inp.commercial.transmission_loss_pct),
+ ("Commercial", "DSM Loss %", inp.commercial.dsm_loss_pct),
+ ("Capex", "Debt Fraction", inp.capex.debt_fraction),
+ ("Capex", "Interest Rate", inp.capex.interest_rate_annual),
+ ("Capex", "Construction Months", inp.capex.construction_months),
+ ("Solver", "Mode", inp.solver.mode),
+ ("Solver", "Target Equity IRR", inp.solver.target_equity_irr),
+ ]
+ if inp.solar:
+ rows += [
+ ("Solar", "Location", inp.solar.location_id),
+ ("Solar", "DC Capacity (MWp)", inp.solar.capacity_dc_mwp),
+ ("Solar", "AC Capacity (MW)", inp.solar.capacity_ac_mw),
+ ("Solar", "DC Loss Fraction", inp.solar.dc_loss_fraction),
+ ("Solar", "Inverter Efficiency", inp.solar.inverter_efficiency),
+ ]
+ if inp.wind:
+ rows += [
+ ("Wind", "Location", inp.wind.location_id),
+ ("Wind", "Capacity MW", inp.wind.capacity_mw),
+ ("Wind", "Hub Height (m)", inp.wind.hub_height_m),
+ ]
+ if inp.bess:
+ rows += [
+ ("BESS", "Capacity MWh", inp.bess.capacity_mwh),
+ ("BESS", "Power MW", inp.bess.power_mw),
+ ("BESS", "RTE", inp.bess.rte),
+ ("BESS", "DoD", inp.bess.dod),
+ ]
+ if inp.rtc:
+ rows += [
+ ("RTC", "RTC MW", inp.rtc.rtc_mw),
+ ("RTC", "MCP Enabled", inp.rtc.mcp_enabled),
+ ]
+ for r, (section, param, value) in enumerate(rows, 2):
+ ws.cell(row=r, column=1, value=section)
+ ws.cell(row=r, column=2, value=param)
+ ws.cell(row=r, column=3, value=value)
+ _autofit(ws)
+
+
+# ---------------------------------------------------------------------------
+# Public API
+# ---------------------------------------------------------------------------
+
+def export_to_bytes(result: ScenarioResult) -> bytes:
+ """Export a ScenarioResult to an in-memory xlsx file and return the bytes."""
+ wb = Workbook()
+ wb.remove(wb.active) # remove default empty sheet
+
+ _write_kpis(wb, result)
+ _write_pnl(wb, result)
+ _write_cfs(wb, result)
+ _write_bs(wb, result)
+ _write_debt_sched(wb, result)
+ _write_inputs(wb, result)
+
+ buf = io.BytesIO()
+ wb.save(buf)
+ return buf.getvalue()
+
+
+def export_to_file(result: ScenarioResult, path: str) -> None:
+ """Export a ScenarioResult to a .xlsx file at the given path."""
+ data = export_to_bytes(result)
+ with open(path, "wb") as f:
+ f.write(data)
diff --git a/packages/engine/src/remodel_engine/irr/__init__.py b/packages/engine/src/remodel_engine/irr/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/packages/engine/src/remodel_engine/irr/metrics.py b/packages/engine/src/remodel_engine/irr/metrics.py
new file mode 100644
index 0000000..9580e63
--- /dev/null
+++ b/packages/engine/src/remodel_engine/irr/metrics.py
@@ -0,0 +1,167 @@
+"""Project finance metrics: IRR, NPV, LCOE, DSCR ratios, LLCR, PLCR."""
+
+from __future__ import annotations
+
+import math
+
+import numpy_financial as npf
+
+from remodel_engine.schemas.debt import DebtYearRow, IRRMetrics
+
+
+def compute_project_irr(
+ total_capex_cr: float,
+ cfads_by_year: list[float],
+) -> float | None:
+ """Project IRR: IRR of [-capex, CFADS_1, ..., CFADS_25]."""
+ cashflows = [-total_capex_cr, *list(cfads_by_year)]
+ try:
+ irr = float(npf.irr(cashflows))
+ return irr if math.isfinite(irr) else None
+ except Exception:
+ return None
+
+
+def compute_equity_irr(
+ equity_invested_cr: float,
+ pat_by_year: list[float],
+ terminal_value_cr: float = 0.0,
+) -> float | None:
+ """Equity IRR: IRR of [-equity, PAT_1, ..., PAT_24, PAT_25 + terminal]."""
+ cfs = [-equity_invested_cr, *list(pat_by_year)]
+ cfs[-1] += terminal_value_cr
+ try:
+ irr = float(npf.irr(cfs))
+ return irr if math.isfinite(irr) else None
+ except Exception:
+ return None
+
+
+def compute_npv(
+ discount_rate: float,
+ cashflows: list[float],
+) -> float:
+ """NPV at given discount rate (year 0 cashflow at t=0)."""
+ return float(npf.npv(discount_rate, cashflows))
+
+
+def compute_payback(
+ capex_cr: float,
+ cashflows_by_year: list[float],
+) -> float | None:
+ """Simple payback: years until cumulative cashflow recovers capex."""
+ cumulative = 0.0
+ for y, cf in enumerate(cashflows_by_year):
+ if cumulative + cf >= capex_cr:
+ fraction = (capex_cr - cumulative) / cf if cf > 0 else 0.0
+ return round(y + fraction, 2)
+ cumulative += cf
+ return None
+
+
+def compute_lcoe(
+ total_capex_cr: float,
+ opex_by_year: list[float],
+ generation_mwh_by_year: list[float],
+ discount_rate: float = 0.09,
+) -> float | None:
+ """LCOE in INR/kWh.
+
+ LCOE = PV(costs) / PV(generation)
+ """
+ if not generation_mwh_by_year or sum(generation_mwh_by_year) == 0:
+ return None
+ pv_costs = total_capex_cr + sum(
+ opex / (1 + discount_rate) ** (y + 1)
+ for y, opex in enumerate(opex_by_year)
+ )
+ pv_gen_kwh = sum(
+ gen_mwh * 1000.0 / (1 + discount_rate) ** (y + 1)
+ for y, gen_mwh in enumerate(generation_mwh_by_year)
+ )
+ if pv_gen_kwh <= 0:
+ return None
+ # pv_costs in Cr, pv_gen_kwh in kWh → Cr/kWh → INR/kWh (1 Cr = 1e7 INR)
+ return round(pv_costs * 1e7 / pv_gen_kwh, 4)
+
+
+def compute_dscr_metrics(
+ schedule: list[DebtYearRow],
+) -> tuple[float, float]:
+ """Return (min_dscr, avg_dscr) over the debt repayment period."""
+ dscts = [r.dscr for r in schedule if r.total_debt_service_cr > 1e-4]
+ if not dscts:
+ return float("inf"), float("inf")
+ return round(min(dscts), 4), round(sum(dscts) / len(dscts), 4)
+
+
+def compute_llcr(
+ cfads_by_year: list[float],
+ schedule: list[DebtYearRow],
+ discount_rate: float,
+) -> float | None:
+ """Loan Life Coverage Ratio = PV(CFADS over loan life) / opening debt balance."""
+ if not schedule:
+ return None
+ opening_debt = schedule[0].opening_balance_cr
+ if opening_debt <= 1e-4:
+ return None
+ loan_life = sum(1 for r in schedule if r.opening_balance_cr > 1e-4)
+ pv_cfads = sum(
+ cfads_by_year[y] / (1 + discount_rate) ** (y + 1)
+ for y in range(min(loan_life, len(cfads_by_year)))
+ )
+ return round(pv_cfads / opening_debt, 4)
+
+
+def compute_plcr(
+ cfads_by_year: list[float],
+ schedule: list[DebtYearRow],
+ discount_rate: float,
+) -> float | None:
+ """Project Life Coverage Ratio = PV(CFADS over project life) / opening debt."""
+ if not schedule:
+ return None
+ opening_debt = schedule[0].opening_balance_cr
+ if opening_debt <= 1e-4:
+ return None
+ pv_cfads = sum(
+ cf / (1 + discount_rate) ** (y + 1)
+ for y, cf in enumerate(cfads_by_year)
+ )
+ return round(pv_cfads / opening_debt, 4)
+
+
+def compute_all_metrics(
+ total_capex_cr: float,
+ equity_cr: float,
+ cfads_by_year: list[float],
+ pat_by_year: list[float],
+ opex_by_year: list[float],
+ generation_mwh_by_year: list[float],
+ schedule: list[DebtYearRow],
+ discount_rate: float = 0.09,
+) -> IRRMetrics:
+ """Compute all standard project finance metrics."""
+ proj_irr = compute_project_irr(total_capex_cr, cfads_by_year)
+ eq_irr = compute_equity_irr(equity_cr, pat_by_year)
+ proj_npv = compute_npv(discount_rate, [-total_capex_cr, *list(cfads_by_year)])
+ eq_npv = compute_npv(discount_rate, [-equity_cr, *list(pat_by_year)])
+ payback = compute_payback(total_capex_cr, cfads_by_year)
+ lcoe = compute_lcoe(total_capex_cr, opex_by_year, generation_mwh_by_year, discount_rate)
+ min_dscr, avg_dscr = compute_dscr_metrics(schedule)
+ llcr = compute_llcr(cfads_by_year, schedule, discount_rate)
+ plcr = compute_plcr(cfads_by_year, schedule, discount_rate)
+
+ return IRRMetrics(
+ project_irr=proj_irr,
+ equity_irr=eq_irr,
+ project_npv_cr=round(proj_npv, 2),
+ equity_npv_cr=round(eq_npv, 2),
+ payback_years=payback,
+ lcoe_inr_per_kwh=lcoe,
+ min_dscr=min_dscr,
+ avg_dscr=avg_dscr,
+ llcr=llcr,
+ plcr=plcr,
+ )
diff --git a/packages/engine/src/remodel_engine/py.typed b/packages/engine/src/remodel_engine/py.typed
new file mode 100644
index 0000000..e69de29
diff --git a/packages/engine/src/remodel_engine/scenarios/__init__.py b/packages/engine/src/remodel_engine/scenarios/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/packages/engine/src/remodel_engine/scenarios/runner.py b/packages/engine/src/remodel_engine/scenarios/runner.py
new file mode 100644
index 0000000..cf4a15c
--- /dev/null
+++ b/packages/engine/src/remodel_engine/scenarios/runner.py
@@ -0,0 +1,475 @@
+"""Scenario runner: orchestrates the full calculation pipeline.
+
+Pipeline order:
+1. Generation simulation (solar + wind)
+2. Commercial: annual generation MWh + revenue
+3. Capex computation + IDC fixed-point
+4. Financial model: depreciation, opex, P&L, CFS, BS
+5. Debt sizing + schedule
+6. IRR metrics
+7. (Optional) Tariff solver: brentq wrapping steps 2-6
+"""
+
+from __future__ import annotations
+
+import time
+from dataclasses import dataclass
+
+from remodel_engine.capex.cost_items import ProjectCapacity, compute_capex
+from remodel_engine.capex.idc import compute_idc
+from remodel_engine.schemas.capex import CapexConfig, DrawdownCurve
+from remodel_engine.schemas.financial import CommercialConfig
+from remodel_engine.commercial.ppa import (
+ compute_annual_generation_mwh,
+ compute_payables,
+ compute_receivables,
+)
+from remodel_engine.debt.schedule import build_debt_schedule
+from remodel_engine.debt.sizing import size_debt
+from remodel_engine.dispatch.hybrid_rtc import (
+ DispatchConfig as _DispatchConfig,
+)
+from remodel_engine.dispatch.hybrid_rtc import (
+ run_dispatch,
+)
+from remodel_engine.financial.bs import build_bs
+from remodel_engine.financial.cfs import build_cfs
+from remodel_engine.financial.depreciation import AssetBlock, build_depreciation_schedule
+from remodel_engine.financial.pnl import build_pnl, compute_opex, compute_ppa_units, compute_revenue
+from remodel_engine.financial.tax import compute_tax_schedule
+from remodel_engine.financial.working_capital import compute_working_capital
+from remodel_engine.generation.solar import annual_cuf, simulate_solar
+from remodel_engine.generation.wind import annual_plf, simulate_wind
+from remodel_engine.irr.metrics import compute_all_metrics
+from remodel_engine.schemas.debt import DebtYearRow, IRRMetrics
+from remodel_engine.schemas.financial import Financials
+from remodel_engine.schemas.scenario import (
+ KpiSummary,
+ ScenarioInput,
+ ScenarioResult,
+)
+from remodel_engine.solver.tariff import solve_tariff
+
+
+@dataclass
+class _PipelineResult:
+ equity_irr: float
+ solar_y1_cuf: float | None
+ wind_y1_plf: float | None
+ gen_mwh_by_year: list[float]
+ solar_mwh_by_year: list[float]
+ wind_mwh_by_year: list[float]
+ base_capex: float
+ idc_cr: float
+ total_capex: float
+ debt_cr: float
+ equity_cr: float
+ financials: Financials
+ metrics: IRRMetrics
+ sched: list[DebtYearRow]
+ rtc_cuf_achieved: float | None = None
+ total_shortfall_mwh: float | None = None
+ total_curtailed_mwh: float | None = None
+ total_mcp_revenue_cr: float | None = None
+
+
+def _run_pipeline(inputs: ScenarioInput, tariff: float) -> _PipelineResult:
+ """Run full pipeline at a given tariff; returns all computed quantities."""
+ solar_mwh_by_year = [0.0] * 25
+ wind_mwh_by_year = [0.0] * 25
+ solar_y1_cuf: float | None = None
+ wind_y1_plf: float | None = None
+ solar_y1_hourly: list[float] = [0.0] * 8760
+ wind_y1_hourly: list[float] = [0.0] * 8760
+
+ if inputs.solar is not None:
+ sol_df = simulate_solar(inputs.solar)
+ solar_mwh_by_year = [
+ float(sol_df[sol_df["year"] == y + 1]["ac_power_mw"].sum())
+ for y in range(25)
+ ]
+ solar_y1_cuf = float(annual_cuf(sol_df, inputs.solar.capacity_ac_mw).iloc[0])
+ solar_y1_hourly = sol_df[sol_df["year"] == 1]["ac_power_mw"].tolist()
+
+ if inputs.wind is not None:
+ wnd_df = simulate_wind(inputs.wind)
+ wind_mwh_by_year = [
+ float(wnd_df[wnd_df["year"] == y + 1]["ac_power_mw"].sum())
+ for y in range(25)
+ ]
+ wind_y1_plf = float(annual_plf(wnd_df, inputs.wind.capacity_mw).iloc[0])
+ wind_y1_hourly = wnd_df[wnd_df["year"] == 1]["ac_power_mw"].tolist()
+
+ gen_mwh_by_year = compute_annual_generation_mwh(solar_mwh_by_year, wind_mwh_by_year)
+
+ comm = inputs.commercial
+ rev_by_year = compute_revenue(
+ gen_mwh_by_year,
+ tariff,
+ comm.aux_consumption_pct,
+ comm.transmission_loss_pct,
+ comm.dsm_loss_pct,
+ comm.bad_debt_pct,
+ )
+
+ proj = inputs.project
+ cap = ProjectCapacity(
+ solar_mwp_dc=proj.capacity_solar_mwp,
+ solar_mw_ac=inputs.solar.capacity_ac_mw if inputs.solar else 0.0,
+ wind_mw=proj.capacity_wind_mw,
+ bess_mwh=proj.capacity_bess_mwh,
+ bess_mw=proj.capacity_bess_mw,
+ land_acres=proj.land_acres,
+ )
+ capex_cfg = inputs.capex
+ # Compute base_capex: use cost_items if provided, otherwise fallback to legacy formula
+ if capex_cfg.cost_items:
+ breakdown = compute_capex(capex_cfg.cost_items, cap)
+ base_capex = breakdown.total_cr
+ else:
+ # Legacy fallback: compute from project capacity
+ base_capex = 0.0
+ if inputs.solar:
+ base_capex += inputs.solar.capacity_dc_mwp * 2.5 # ~₹2.5Cr/MWp
+ if proj.capacity_wind_mw:
+ base_capex += proj.capacity_wind_mw * 6.0 # ~₹6Cr/MW for wind
+ if proj.capacity_bess_mwh:
+ base_capex += proj.capacity_bess_mwh * 0.8 # ~₹0.8Cr/MWh for BESS
+ # Return breakdown for depreciation (using legacy approach)
+ breakdown = None # Will use total_capex directly
+
+ # Compute IDC: use debt_curve if provided, otherwise compute from construction_months
+ idc_cr = 0.0
+ has_cost_items = len(capex_cfg.cost_items) > 0 if capex_cfg.cost_items else False
+ has_construction = capex_cfg.construction_months and capex_cfg.construction_months > 0
+ # Compute IDC if: cost_items exist OR debt_curve exists OR legacy construction params
+ if has_cost_items or has_construction or capex_cfg.debt_curve is not None:
+ debt_curve = capex_cfg.debt_curve
+ # Fallback: create default uniform curve for legacy data
+ if debt_curve is None and base_capex > 0 and has_construction:
+ n = capex_cfg.construction_months
+ cum_pct = [i / n for i in range(1, n + 1)]
+ debt_curve = DrawdownCurve(id="default", name="Uniform", cum_pct=cum_pct)
+ if debt_curve is not None and base_capex > 0:
+ idc_cr, _, _ = compute_idc(
+ base_capex_cr=base_capex,
+ debt_fraction=capex_cfg.debt_fraction,
+ interest_rate_annual=capex_cfg.interest_rate_annual,
+ debt_curve=debt_curve,
+ n_months=capex_cfg.construction_months,
+ )
+ total_capex = base_capex + idc_cr
+
+ # Depreciation: use breakdown if available, otherwise use total_capex
+ if breakdown is not None:
+ depr_by_class = breakdown.by_depr_class()
+ else:
+ depr_by_class = {}
+ blocks = [
+ AssetBlock(depr_class=cls, gross_cost_cr=amt)
+ for cls, amt in depr_by_class.items()
+ ] or [AssetBlock(depr_class="Plant", gross_cost_cr=total_capex)]
+ depr_sched = build_depreciation_schedule(blocks)
+
+ solar_mw = inputs.solar.capacity_ac_mw if inputs.solar else 0.0
+ wind_mw = inputs.wind.capacity_mw if inputs.wind else 0.0
+ bess_mwh = proj.capacity_bess_mwh
+ opex_by_year = compute_opex(
+ rev_by_year, solar_mw, wind_mw, bess_mwh, base_capex, inputs.opex
+ )
+
+ cfads_pre = [
+ rev_by_year[y] - opex_by_year[y] - depr_sched.book_depr[y]
+ for y in range(25)
+ ]
+ debt_cfg = inputs.debt
+ debt_cr = size_debt(total_capex, cfads_pre, debt_cfg)
+ equity_cr = total_capex - debt_cr
+
+ sched = build_debt_schedule(debt_cr, cfads_pre, debt_cfg)
+ interest_by_year = [r.interest_cr for r in sched]
+ principal_by_year = [r.principal_cr for r in sched]
+
+ pbt_by_year = [cfads_pre[y] - interest_by_year[y] for y in range(25)]
+ ct_by_year, dt_by_year, dtl_by_year = compute_tax_schedule(
+ pbt_by_year, depr_sched.book_depr, depr_sched.tax_depr, inputs.tax
+ )
+ pat_by_year = [pbt_by_year[y] - ct_by_year[y] for y in range(25)]
+
+ # Run dispatch to get MCP revenue before building P&L
+ rtc_cuf_achieved: float | None = None
+ total_shortfall_mwh: float | None = None
+ total_curtailed_mwh: float | None = None
+ total_mcp_revenue_cr: float | None = None
+
+ if inputs.rtc is not None and inputs.rtc.rtc_mw > 0 and inputs.bess is not None:
+ bess = inputs.bess
+ rtc_cfg = inputs.rtc
+ dispatch_cfg = _DispatchConfig(
+ rtc_mw=rtc_cfg.rtc_mw,
+ bess_mwh=bess.capacity_mwh,
+ bess_mw=bess.power_mw,
+ dod=bess.dod,
+ rte=bess.rte,
+ initial_soc_frac=rtc_cfg.initial_soc_frac,
+ mcp_enabled=rtc_cfg.mcp_enabled,
+ )
+ dispatch_result = run_dispatch(solar_y1_hourly, wind_y1_hourly, dispatch_cfg)
+ rtc_cuf_achieved = dispatch_result.rtc_cuf_achieved
+ total_shortfall_mwh = dispatch_result.total_shortfall_mwh
+ total_curtailed_mwh = dispatch_result.total_curtailed_mwh
+ total_mcp_revenue_cr = round(dispatch_result.total_mcp_revenue_inr / 1e7, 4)
+
+ _, delta_wc = compute_working_capital(rev_by_year, opex_by_year, comm)
+
+ ppa_units_by_year = compute_ppa_units(
+ gen_mwh_by_year,
+ comm.aux_consumption_pct,
+ comm.transmission_loss_pct,
+ comm.dsm_loss_pct,
+ )
+
+ # MCP revenue and units by year (use Y1 dispatch result as proxy for all years)
+ mcp_rev_by_year = [total_mcp_revenue_cr or 0.0] * 25
+ mcp_units_by_year = [total_curtailed_mwh or 0.0] * 25 if total_curtailed_mwh else [0.0] * 25
+ pnl_rows = build_pnl(
+ rev_by_year, ppa_units_by_year, tariff, mcp_rev_by_year, mcp_units_by_year,
+ opex_by_year, depr_sched.book_depr,
+ interest_by_year, ct_by_year, dt_by_year,
+ solar_mw, wind_mw, bess_mwh, base_capex, inputs.opex,
+ )
+ cfs_rows = build_cfs(
+ pat_by_year, depr_sched.book_depr, delta_wc,
+ total_capex, [0.0] * 25, principal_by_year, [0.0] * 25,
+ equity_cr * 0.02,
+ )
+
+ gross_block = total_capex - sum(
+ blk.total_cost_cr for blk in blocks
+ if blk.depr_class in ("Land_NoDepr", "Expensed")
+ )
+ recv_by_year = compute_receivables(rev_by_year, comm)
+ payables_by_year = compute_payables(opex_by_year, comm)
+ cash_by_year = [r.closing_cash_cr for r in cfs_rows]
+ debt_outstanding = [r.closing_balance_cr for r in sched]
+ bs_retained = [
+ depr_sched.net_block_book[y] + cash_by_year[y] + recv_by_year[y]
+ - equity_cr - debt_outstanding[y] - payables_by_year[y]
+ - max(0.0, dtl_by_year[y])
+ for y in range(25)
+ ]
+ bs_rows = build_bs(
+ gross_block, depr_sched.accumulated_book, depr_sched.net_block_book,
+ cash_by_year, recv_by_year, equity_cr, bs_retained,
+ debt_outstanding, payables_by_year, dtl_by_year, tol_cr=1.0,
+ )
+ financials = Financials(pnl=pnl_rows, cfs=cfs_rows, bs=bs_rows)
+
+ cfads_for_irr = [r.ebitda_cr - r.depreciation_book_cr for r in pnl_rows]
+ metrics = compute_all_metrics(
+ total_capex_cr=total_capex,
+ equity_cr=equity_cr,
+ cfads_by_year=cfads_for_irr,
+ pat_by_year=pat_by_year,
+ opex_by_year=opex_by_year,
+ generation_mwh_by_year=gen_mwh_by_year,
+ schedule=sched,
+ )
+
+ return _PipelineResult(
+ equity_irr=metrics.equity_irr or 0.0,
+ solar_y1_cuf=solar_y1_cuf,
+ wind_y1_plf=wind_y1_plf,
+ gen_mwh_by_year=gen_mwh_by_year,
+ solar_mwh_by_year=solar_mwh_by_year,
+ wind_mwh_by_year=wind_mwh_by_year,
+ base_capex=base_capex,
+ idc_cr=idc_cr,
+ total_capex=total_capex,
+ debt_cr=debt_cr,
+ equity_cr=equity_cr,
+ financials=financials,
+ metrics=metrics,
+ sched=sched,
+ rtc_cuf_achieved=rtc_cuf_achieved,
+ total_shortfall_mwh=total_shortfall_mwh,
+ total_curtailed_mwh=total_curtailed_mwh,
+ total_mcp_revenue_cr=total_mcp_revenue_cr,
+ )
+
+
+def _build_generation_rows(
+ solar_mwh: list[float],
+ wind_mwh: list[float],
+ solar_ac_mw: float | None,
+ wind_mw: float | None,
+ comm: CommercialConfig,
+ tariff: float,
+) -> list[dict]:
+ rows = []
+ for y in range(25):
+ s_mwh = solar_mwh[y]
+ w_mwh = wind_mwh[y]
+ gross = s_mwh + w_mwh
+ aux_loss = gross * comm.aux_consumption_pct
+ after_aux = gross - aux_loss
+ tx_loss = after_aux * comm.transmission_loss_pct
+ after_tx = after_aux - tx_loss
+ dsm_loss = after_tx * comm.dsm_loss_pct
+ net_billable = after_tx - dsm_loss
+ revenue_cr = net_billable * 1000 * tariff / 1e7 * (1 - comm.bad_debt_pct)
+ solar_cuf = (s_mwh / (solar_ac_mw * 8760) * 100) if solar_ac_mw else None
+ wind_plf = (w_mwh / (wind_mw * 8760) * 100) if wind_mw else None
+ rows.append({
+ "year": y + 1,
+ "solar_mwh": round(s_mwh),
+ "wind_mwh": round(w_mwh),
+ "gross_mwh": round(gross),
+ "aux_loss_mwh": round(aux_loss),
+ "tx_loss_mwh": round(tx_loss),
+ "dsm_loss_mwh": round(dsm_loss),
+ "net_billable_mwh": round(net_billable),
+ "solar_cuf_pct": round(solar_cuf, 2) if solar_cuf is not None else None,
+ "wind_plf_pct": round(wind_plf, 2) if wind_plf is not None else None,
+ "revenue_cr": round(revenue_cr, 4),
+ })
+ return rows
+
+
+def _build_idc_phasing(capex_cfg: CapexConfig, base_capex: float) -> dict:
+ from remodel_engine.schemas.capex import DrawdownCurve
+
+ n = capex_cfg.construction_months
+ # Use provided curves or fall back to simple linear drawdown
+ if capex_cfg.debt_curve is not None:
+ debt_curve = capex_cfg.debt_curve
+ else:
+ cum = [round((m + 1) / n, 8) for m in range(n)]
+ cum[-1] = 1.0
+ debt_curve = DrawdownCurve(id="_linear", name="Linear", cum_pct=cum)
+
+ if capex_cfg.equity_curve is not None:
+ eq_curve = capex_cfg.equity_curve
+ else:
+ cum = [round((m + 1) / n, 8) for m in range(n)]
+ cum[-1] = 1.0
+ eq_curve = DrawdownCurve(id="_linear", name="Linear", cum_pct=cum)
+
+ idc_cr, total_debt, _ = compute_idc(
+ base_capex, capex_cfg.debt_fraction, capex_cfg.interest_rate_annual,
+ debt_curve, n,
+ )
+ tpc = base_capex + idc_cr
+ equity_total = tpc - total_debt
+
+ r_monthly = capex_cfg.interest_rate_annual / 12.0
+ # incremental draw fractions
+ def _delta(cum: list[float]) -> list[float]:
+ out, prev = [], 0.0
+ for v in cum:
+ out.append(v - prev)
+ prev = v
+ return out
+
+ debt_delta = _delta(debt_curve.cum_pct[:n])
+ eq_delta = _delta(eq_curve.cum_pct[:n])
+
+ monthly = []
+ cum_debt = cum_equity = cum_idc = outstanding_debt = 0.0
+ for m in range(n):
+ eq_draw = eq_delta[m] * equity_total
+ debt_draw = debt_delta[m] * total_debt
+ outstanding_debt += debt_draw
+ idc_accrual = outstanding_debt * r_monthly
+ cum_debt += debt_draw
+ cum_equity += eq_draw
+ cum_idc += idc_accrual
+ monthly.append({
+ "month": m + 1,
+ "equity_draw_cr": round(eq_draw, 2),
+ "debt_draw_cr": round(debt_draw, 2),
+ "idc_accrual_cr": round(idc_accrual, 2),
+ "cum_equity_cr": round(cum_equity, 2),
+ "cum_debt_cr": round(cum_debt, 2),
+ "cum_idc_cr": round(cum_idc, 2),
+ "cum_tpc_cr": round(cum_equity + cum_debt + cum_idc, 2),
+ })
+
+ return {
+ "construction_months": n,
+ "base_capex_cr": round(base_capex, 2),
+ "idc_cr": round(idc_cr, 2),
+ "total_capex_cr": round(tpc, 2),
+ "debt_cr": round(total_debt, 2),
+ "equity_cr": round(equity_total, 2),
+ "monthly": monthly,
+ }
+
+
+def run_scenario(inputs: ScenarioInput) -> ScenarioResult:
+ """Run the full scenario pipeline."""
+ t0 = time.time()
+ warnings: list[str] = []
+
+ solver_cfg = inputs.solver
+ if solver_cfg.mode == "fixed_tariff":
+ tariff = solver_cfg.fixed_tariff or inputs.commercial.tariff_inr_per_kwh
+ solved_tariff = tariff
+ else:
+ target_irr = solver_cfg.target_equity_irr
+
+ def objective(t: float) -> float:
+ return _run_pipeline(inputs, t).equity_irr - target_irr
+
+ try:
+ solved_tariff, _ = solve_tariff(objective, target_irr)
+ except Exception as e:
+ warnings.append(f"Tariff solver failed: {e}")
+ solved_tariff = inputs.commercial.tariff_inr_per_kwh
+
+ pipe = _run_pipeline(inputs, solved_tariff)
+
+ kpis = KpiSummary(
+ solved_tariff_inr_per_kwh=solved_tariff,
+ equity_irr=pipe.metrics.equity_irr,
+ project_irr=pipe.metrics.project_irr,
+ min_dscr=pipe.metrics.min_dscr,
+ avg_dscr=pipe.metrics.avg_dscr,
+ total_capex_cr=round(pipe.total_capex, 2),
+ idc_cr=round(pipe.idc_cr, 2),
+ debt_cr=round(pipe.debt_cr, 2),
+ solar_y1_cuf=pipe.solar_y1_cuf,
+ wind_y1_plf=pipe.wind_y1_plf,
+ lcoe_inr_per_kwh=pipe.metrics.lcoe_inr_per_kwh,
+ payback_years=pipe.metrics.payback_years,
+ rtc_cuf_achieved=pipe.rtc_cuf_achieved,
+ total_shortfall_mwh=pipe.total_shortfall_mwh,
+ total_curtailed_mwh=pipe.total_curtailed_mwh,
+ total_mcp_revenue_cr=pipe.total_mcp_revenue_cr,
+ )
+
+ solar_ac_mw = inputs.solar.capacity_ac_mw if inputs.solar else None
+ wind_mw = inputs.wind.capacity_mw if inputs.wind else None
+ generation_by_year = _build_generation_rows(
+ pipe.solar_mwh_by_year,
+ pipe.wind_mwh_by_year,
+ solar_ac_mw,
+ wind_mw,
+ inputs.commercial,
+ solved_tariff,
+ )
+ idc_phasing = _build_idc_phasing(inputs.capex, pipe.base_capex) if pipe.base_capex > 0 else {}
+
+ return ScenarioResult(
+ inputs=inputs,
+ status="success",
+ solved_tariff=solved_tariff,
+ kpis=kpis,
+ financials=pipe.financials,
+ debt_schedule=pipe.sched,
+ irr_metrics=pipe.metrics,
+ warnings=warnings,
+ runtime_s=round(time.time() - t0, 2),
+ generation_by_year=generation_by_year,
+ idc_phasing=idc_phasing,
+ )
diff --git a/packages/engine/src/remodel_engine/scenarios/sweep.py b/packages/engine/src/remodel_engine/scenarios/sweep.py
new file mode 100644
index 0000000..6859a60
--- /dev/null
+++ b/packages/engine/src/remodel_engine/scenarios/sweep.py
@@ -0,0 +1,211 @@
+"""Cartesian parameter sweep engine.
+
+Usage::
+
+ results = run_sweep(base_inputs, [
+ SweepParam("commercial.tariff_inr_per_kwh", [3.5, 4.0, 4.5]),
+ SweepParam("capex.cost_items[0].unit_cost_cr_per_mw", [400, 450, 500]),
+ ])
+"""
+
+from __future__ import annotations
+
+import copy
+import time
+from concurrent.futures import ThreadPoolExecutor, as_completed
+from dataclasses import dataclass, field
+from itertools import product
+from typing import Any
+
+from remodel_engine.scenarios.runner import run_scenario
+from remodel_engine.schemas.scenario import KpiSummary, ScenarioInput
+
+
+@dataclass
+class SweepParam:
+ """A single parameter axis for a sweep."""
+
+ path: str
+ values: list[Any]
+
+
+@dataclass
+class SweepResult:
+ """Result for one combination in the sweep."""
+
+ param_values: dict[str, Any]
+ kpis: KpiSummary
+ status: str
+ runtime_s: float
+ error: str | None = None
+
+
+def _set_nested(obj: Any, path: str, value: Any) -> None:
+ """Set a value in a nested object using dot-notation path."""
+ parts = path.split(".")
+ for part in parts[:-1]:
+ obj = getattr(obj, part)
+ setattr(obj, parts[-1], value)
+
+
+def _apply_params(base: ScenarioInput, combo: dict[str, Any]) -> ScenarioInput:
+ """Deep-copy base inputs and apply a parameter combination."""
+ inputs = copy.deepcopy(base)
+ for path, value in combo.items():
+ _set_nested(inputs, path, value)
+ return inputs
+
+
+def _run_one(base: ScenarioInput, combo: dict[str, Any]) -> SweepResult:
+ t0 = time.time()
+ try:
+ inputs = _apply_params(base, combo)
+ result = run_scenario(inputs)
+ return SweepResult(
+ param_values=combo,
+ kpis=result.kpis,
+ status="success",
+ runtime_s=round(time.time() - t0, 2),
+ )
+ except Exception as e:
+ return SweepResult(
+ param_values=combo,
+ kpis=KpiSummary(),
+ status="failed",
+ runtime_s=round(time.time() - t0, 2),
+ error=str(e),
+ )
+
+
+def run_sweep(
+ base_inputs: ScenarioInput,
+ params: list[SweepParam],
+ max_workers: int = 4,
+) -> list[SweepResult]:
+ """Run a Cartesian sweep over the given parameter axes.
+
+ Returns results in the same order as the Cartesian product
+ (params[0] values vary slowest, params[-1] vary fastest).
+ """
+ if not params:
+ return [_run_one(base_inputs, {})]
+
+ combos: list[dict[str, Any]] = [
+ {p.path: v for p, v in zip(params, values, strict=False)}
+ for values in product(*[p.values for p in params])
+ ]
+
+ results: list[SweepResult | None] = [None] * len(combos)
+ with ThreadPoolExecutor(max_workers=max_workers) as ex:
+ futures = {
+ ex.submit(_run_one, base_inputs, combo): i
+ for i, combo in enumerate(combos)
+ }
+ for future in as_completed(futures):
+ idx = futures[future]
+ results[idx] = future.result()
+
+ return [r for r in results if r is not None]
+
+
+# ---------------------------------------------------------------------------
+# Predefined sensitivity sets (the "frequent 7")
+# ---------------------------------------------------------------------------
+
+SENSITIVITY_TARIFF = SweepParam(
+ "commercial.tariff_inr_per_kwh",
+ [3.0, 3.5, 4.0, 4.5, 5.0],
+)
+
+SENSITIVITY_CAPEX_FRACTION = SweepParam(
+ "_capex_multiplier", # applied via run_sensitivity_7 below
+ [0.85, 0.90, 1.00, 1.10, 1.15],
+)
+
+SENSITIVITY_DEBT_RATE = SweepParam(
+ "debt.interest_rate_annual",
+ [0.09, 0.10, 0.105, 0.11, 0.12],
+)
+
+SENSITIVITY_SOLAR_CUF = SweepParam(
+ "solar.availability_fraction",
+ [0.93, 0.95, 0.98, 1.00, 1.02],
+)
+
+SENSITIVITY_WIND_PLF = SweepParam(
+ "wind.availability_fraction",
+ [0.93, 0.95, 0.97, 0.99, 1.01],
+)
+
+SENSITIVITY_OPEX_ESC = SweepParam(
+ "opex.escalation_rate",
+ [0.03, 0.04, 0.05, 0.06, 0.07],
+)
+
+SENSITIVITY_BESS_COST = SweepParam(
+ "_bess_cost_multiplier",
+ [0.80, 0.90, 1.00, 1.10, 1.20],
+)
+
+
+@dataclass
+class TornadoEntry:
+ """Sensitivity result for one parameter, relative to base."""
+
+ param_name: str
+ low_value: Any
+ high_value: Any
+ base_kpi: float
+ low_kpi: float
+ high_kpi: float
+ swing: float = field(init=False)
+
+ def __post_init__(self) -> None:
+ self.swing = abs(self.high_kpi - self.low_kpi)
+
+
+def run_tornado(
+ base_inputs: ScenarioInput,
+ kpi_key: str = "equity_irr",
+ max_workers: int = 4,
+) -> list[TornadoEntry]:
+ """Run the 'frequent 7' one-at-a-time sensitivity and return tornado data.
+
+ Each of the 7 parameters is swept at low/base/high (3 runs each).
+ kpi_key must be a field name on KpiSummary.
+ Returns entries sorted by swing descending (widest bar first).
+ """
+ base_result = _run_one(base_inputs, {})
+ base_kpi = float(getattr(base_result.kpis, kpi_key) or 0.0)
+
+ param_defs: list[tuple[str, SweepParam]] = [
+ ("Tariff (₹/kWh)", SweepParam("commercial.tariff_inr_per_kwh", [3.0, 4.0, 5.0])),
+ ("Debt Rate", SweepParam("debt.interest_rate_annual", [0.09, 0.105, 0.12])),
+ ("Solar Availability", SweepParam("solar.availability_fraction", [0.93, 0.98, 1.02])),
+ ("Wind Availability", SweepParam("wind.availability_fraction", [0.93, 0.97, 1.01])),
+ ("Opex Escalation", SweepParam("opex.escalation_rate", [0.03, 0.05, 0.07])),
+ ]
+
+ entries: list[TornadoEntry] = []
+ with ThreadPoolExecutor(max_workers=max_workers) as ex:
+ for label, param in param_defs:
+ if len(param.values) < 3:
+ continue
+ low_val, _, high_val = param.values[0], param.values[1], param.values[-1]
+ f_low = ex.submit(_run_one, base_inputs, {param.path: low_val})
+ f_high = ex.submit(_run_one, base_inputs, {param.path: high_val})
+ low_kpi = float(getattr(f_low.result().kpis, kpi_key) or 0.0)
+ high_kpi = float(getattr(f_high.result().kpis, kpi_key) or 0.0)
+ entries.append(
+ TornadoEntry(
+ param_name=label,
+ low_value=low_val,
+ high_value=high_val,
+ base_kpi=base_kpi,
+ low_kpi=low_kpi,
+ high_kpi=high_kpi,
+ )
+ )
+
+ entries.sort(key=lambda e: e.swing, reverse=True)
+ return entries
diff --git a/packages/engine/src/remodel_engine/schemas/capex.py b/packages/engine/src/remodel_engine/schemas/capex.py
new file mode 100644
index 0000000..da342e9
--- /dev/null
+++ b/packages/engine/src/remodel_engine/schemas/capex.py
@@ -0,0 +1,115 @@
+"""Pydantic schemas for capex, phasing, and IDC inputs."""
+
+from __future__ import annotations
+
+from typing import Literal
+
+from pydantic import BaseModel, Field, field_validator
+
+CostBasis = Literal[
+ "PER_WP_DC", # INR per Wp DC
+ "PER_MWP_DC", # INR Cr per MWp DC
+ "PER_MW_AC", # INR Cr per MW AC
+ "PER_MW_SOLAR", # INR Cr per MW solar
+ "PER_MW_WIND", # INR Cr per MW wind
+ "PER_MW_BESS", # INR Cr per MW BESS power
+ "PER_MWH_BESS", # INR Cr per MWh BESS energy
+ "PER_KWH_USD", # USD per kWh (converted via fx_rate)
+ "PER_ACRE", # INR Lakh per acre
+ "PCT_OF_HARDCOST", # Fraction of total hard cost (0-1)
+ "ABS_INR_CR", # Absolute INR Crore
+]
+
+DeprClass = Literal[
+ "Plant", # SLM 25yr book, WDV 40% tax
+ "BESS", # SLM 12yr book, WDV 40% tax
+ "Building", # SLM 30yr book, WDV 10% tax
+ "Land_NoDepr", # No depreciation
+ "LandLease_Amortized", # Amortized over lease term
+ "Intangible", # SLM over 25yr book, 25% WDV tax
+ "Capitalized_NoDepr", # Capitalized, no depreciation
+ "Expensed", # Expensed in year 0 (P&L)
+]
+
+CostCategory = Literal[
+ "HardCost",
+ "SoftCost",
+ "EPCOverhead",
+ "EPCMargin",
+ "FinancingCost",
+ "Contingency",
+]
+
+CostAttribution = Literal["SolarOnly", "WindOnly", "BESSOnly", "Common"]
+
+
+class CostItem(BaseModel):
+ """Single line item in the capex table."""
+
+ id: str = Field(description="Unique slug identifier")
+ name: str = Field(description="Human-readable name")
+ category: CostCategory
+ basis: CostBasis
+ value: float = Field(ge=0, description="Value in basis units")
+ fx_rate: float | None = Field(None, gt=0, description="USD/INR for PER_KWH_USD basis")
+ depr_class: DeprClass
+ escalation_pct: float = Field(0.0, ge=0, lt=1, description="Annual cost escalation (fraction)")
+ phasing_id: str = Field("default", description="Phasing template ID for this item")
+ attribution: CostAttribution
+
+
+class PhasingCurve(BaseModel):
+ """Monthly phasing for a single cost item (or template).
+
+ monthly_pct must sum to 1.0 (100%).
+ """
+
+ id: str
+ name: str
+ monthly_pct: list[float] = Field(
+ description="Fraction of total cost incurred each construction month"
+ )
+
+ @field_validator("monthly_pct")
+ @classmethod
+ def must_sum_to_one(cls, v: list[float]) -> list[float]:
+ total = sum(v)
+ if abs(total - 1.0) > 1e-6:
+ raise ValueError(f"monthly_pct must sum to 1.0, got {total:.6f}")
+ return v
+
+
+class DrawdownCurve(BaseModel):
+ """Cumulative drawdown schedule (equity or debt).
+
+ cum_pct is cumulative fraction drawn by end of each construction month.
+ Must be non-decreasing and end at 1.0.
+ Equity may temporarily exceed 1.0 (bridge financing).
+ """
+
+ id: str
+ name: str
+ cum_pct: list[float] = Field(description="Cumulative fraction drawn by end of each month")
+ allow_bridge: bool = Field(False, description="If True, allow cum_pct > 1.0 temporarily")
+
+ @field_validator("cum_pct")
+ @classmethod
+ def must_end_at_one(cls, v: list[float]) -> list[float]:
+ if not v:
+ raise ValueError("cum_pct must not be empty")
+ if abs(v[-1] - 1.0) > 1e-6:
+ raise ValueError(f"cum_pct must end at 1.0, got {v[-1]:.6f}")
+ return v
+
+
+class CapexConfig(BaseModel):
+ """Full capex input: cost items, phasing, and construction drawdown curves."""
+
+ cost_items: list[CostItem] = Field(default_factory=list)
+ phasing_curves: list[PhasingCurve] = Field(default_factory=list)
+ equity_curve: DrawdownCurve | None = None
+ debt_curve: DrawdownCurve | None = None
+ construction_months: int = Field(24, gt=0, le=60)
+ debt_fraction: float = Field(0.75, gt=0, lt=1, description="Debt as fraction of TPC")
+ interest_rate_annual: float = Field(0.09, gt=0, description="IDC rate (annual)")
+ upfront_fee_pct: float = Field(0.01, ge=0, description="Upfront processing fee on debt")
diff --git a/packages/engine/src/remodel_engine/schemas/debt.py b/packages/engine/src/remodel_engine/schemas/debt.py
new file mode 100644
index 0000000..6cede41
--- /dev/null
+++ b/packages/engine/src/remodel_engine/schemas/debt.py
@@ -0,0 +1,58 @@
+"""Pydantic schemas for debt configuration and outputs."""
+
+from __future__ import annotations
+
+from typing import Literal
+
+from pydantic import BaseModel, Field
+
+DebtScheduleShape = Literal[
+ "equal_principal",
+ "equal_installment",
+ "dscr_sculpted",
+ "balloon",
+ "custom_pct_vector",
+]
+
+
+class DebtConfig(BaseModel):
+ """Debt financing parameters."""
+
+ interest_rate_annual: float = Field(0.09, gt=0, description="Coupon rate pa")
+ tenor_years: int = Field(18, gt=0, le=25, description="Loan tenor in years")
+ moratorium_years: int = Field(1, ge=0, description="Interest-only period (years)")
+ de_ratio: float = Field(3.0, gt=0, description="Max D:E ratio (e.g. 3.0 = 75:25)")
+ min_dscr: float = Field(1.20, gt=1, description="Minimum annual DSCR covenant")
+ avg_dscr: float = Field(1.35, gt=1, description="Average DSCR target for sculpting")
+ schedule_shape: DebtScheduleShape = Field("equal_principal")
+ custom_pct_vector: list[float] | None = Field(
+ None,
+ description="Custom repayment % of total debt per year (for custom_pct_vector shape)",
+ )
+
+
+class DebtYearRow(BaseModel):
+ """Debt schedule row for a single operating year."""
+
+ year: int
+ opening_balance_cr: float
+ interest_cr: float
+ principal_cr: float
+ total_debt_service_cr: float
+ closing_balance_cr: float
+ dscr: float
+
+
+class IRRMetrics(BaseModel):
+ """Project and equity IRR and related metrics."""
+
+ project_irr: float | None = None
+ equity_irr: float | None = None
+ project_npv_cr: float | None = None
+ equity_npv_cr: float | None = None
+ payback_years: float | None = None
+ lcoe_inr_per_kwh: float | None = None
+ min_dscr: float | None = None
+ avg_dscr: float | None = None
+ llcr: float | None = None # Loan life coverage ratio
+ plcr: float | None = None # Project life coverage ratio
diff --git a/packages/engine/src/remodel_engine/schemas/financial.py b/packages/engine/src/remodel_engine/schemas/financial.py
new file mode 100644
index 0000000..6dc33f3
--- /dev/null
+++ b/packages/engine/src/remodel_engine/schemas/financial.py
@@ -0,0 +1,125 @@
+"""Pydantic schemas for financial model inputs and outputs."""
+
+from __future__ import annotations
+
+from pydantic import BaseModel, Field
+
+
+class OpexConfig(BaseModel):
+ """Annual operating expenditure inputs."""
+
+ om_solar_cr_per_mw: float = Field(0.025, ge=0, description="O&M for solar (Cr/MW/yr, default 0.025 = 2.5L/MWp)")
+ om_wind_cr_per_mw: float = Field(0.08, ge=0, description="O&M for wind (Cr/MW/yr, default 0.08 = 8L/MW)")
+ om_bess_cr_per_mwh: float = Field(0.04, ge=0, description="O&M for BESS (Cr/MWh/yr)")
+ insurance_pct_of_capex: float = Field(0.005, ge=0, description="Annual insurance (% of capex)")
+ land_lease_cr: float = Field(0.0, ge=0, description="Annual land lease (Cr/yr)")
+ om_escalation_pct: float = Field(0.05, ge=0, lt=1, description="Global O&M escalation (fallback)")
+ # Per-technology escalation schedules
+ om_solar_escalation_pct: float = Field(0.05, ge=0, lt=1, description="Solar O&M escalation rate")
+ om_solar_escalation_after_year: int = Field(4, ge=1, description="Solar escalation starts after this year")
+ om_wind_escalation_pct: float = Field(0.05, ge=0, lt=1, description="Wind O&M escalation rate")
+ om_wind_escalation_after_year: int = Field(5, ge=1, description="Wind escalation starts after this year")
+ om_bess_pct_of_capex: float | None = Field(None, ge=0, description="BESS O&M as % of BESS capex (overrides cr/mwh if set)")
+ am_fee_pct_of_revenue: float = Field(0.01, ge=0, description="Asset management fee (% revenue)")
+ misc_cr: float = Field(0.5, ge=0, description="Miscellaneous annual opex (Cr/yr)")
+
+
+class TaxConfig(BaseModel):
+ """Tax parameters — Section 115BAA (India)."""
+
+ rate: float = Field(0.2517, description="Effective tax rate incl cess (115BAA = 25.17%)")
+ # Depreciation for tax purposes (WDV rates)
+ wdv_plant_rate: float = Field(0.40, description="WDV depreciation rate for Plant (40%)")
+ wdv_bess_rate: float = Field(0.40, description="WDV depreciation rate for BESS (40%)")
+ wdv_building_rate: float = Field(0.10, description="WDV depreciation rate for Building (10%)")
+ wdv_intangible_rate: float = Field(0.25, description="WDV depreciation rate for Intangibles")
+
+
+class CommercialConfig(BaseModel):
+ """PPA and revenue parameters."""
+
+ tariff_inr_per_kwh: float = Field(3.50, gt=0, description="PPA tariff (INR/kWh)")
+ ppa_capacity_mw: float = Field(0.0, ge=0, description="Contracted RTC capacity (MW)")
+ aux_consumption_pct: float = Field(0.005, ge=0, lt=1, description="Auxiliary consumption")
+ transmission_loss_pct: float = Field(0.01, ge=0, lt=1, description="Transmission losses")
+ dsm_loss_pct: float = Field(0.02, ge=0, lt=1, description="DSM/RTC penalty provision")
+ receivable_days: float = Field(45.0, ge=0, description="Debtor collection days")
+ payable_days: float = Field(30.0, ge=0, description="Creditor payment days")
+ bad_debt_pct: float = Field(0.0, ge=0, lt=1, description="Bad debt provision (% revenue)")
+
+
+class PnLRow(BaseModel):
+ """Single year P&L row."""
+
+ year: int
+ # Revenue breakdown
+ revenue_cr: float
+ ppa_revenue_cr: float
+ mcp_revenue_cr: float
+ # PPA breakdown for verification
+ ppa_tariff_inr_per_kwh: float
+ ppa_units_mwh: float
+ # MCP breakdown for verification
+ mcp_units_mwh: float
+ # OpEx
+ opex_total_cr: float
+ om_cr: float
+ insurance_cr: float
+ land_lease_cr: float
+ am_fee_cr: float
+ misc_opex_cr: float
+ # Intermediate
+ ebitda_cr: float
+ depreciation_book_cr: float
+ ebit_cr: float
+ interest_cr: float
+ pbt_cr: float
+ tax_cr: float
+ pat_cr: float
+ deferred_tax_cr: float
+
+
+class CFSRow(BaseModel):
+ """Single year Cash Flow Statement row."""
+
+ year: int
+ pat_cr: float
+ depreciation_cr: float
+ delta_working_capital_cr: float
+ cfo_cr: float
+ capex_cr: float
+ cfi_cr: float
+ debt_drawdown_cr: float
+ debt_repayment_cr: float
+ equity_injection_cr: float
+ cff_cr: float
+ net_cash_flow_cr: float
+ opening_cash_cr: float
+ closing_cash_cr: float
+
+
+class BSRow(BaseModel):
+ """Single year Balance Sheet row."""
+
+ year: int
+ gross_block_cr: float
+ accumulated_depr_cr: float
+ net_block_cr: float
+ cash_cr: float
+ receivables_cr: float
+ other_current_assets_cr: float
+ total_assets_cr: float
+ equity_cr: float
+ reserves_cr: float
+ long_term_debt_cr: float
+ payables_cr: float
+ deferred_tax_liability_cr: float
+ total_liabilities_cr: float
+
+
+class Financials(BaseModel):
+ """Full 25-year 3-statement model output."""
+
+ pnl: list[PnLRow]
+ cfs: list[CFSRow]
+ bs: list[BSRow]
diff --git a/packages/engine/src/remodel_engine/schemas/generation.py b/packages/engine/src/remodel_engine/schemas/generation.py
index bc05705..b9c4189 100644
--- a/packages/engine/src/remodel_engine/schemas/generation.py
+++ b/packages/engine/src/remodel_engine/schemas/generation.py
@@ -6,6 +6,8 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator
class SolarConfig(BaseModel):
"""Configuration for a single solar plant."""
+ model_config = ConfigDict(extra="ignore")
+
location_id: str = Field("RJ", description="Profile key: RJ | KA | GJ")
capacity_dc_mwp: float = Field(..., gt=0, description="Total DC capacity (MWp)")
capacity_ac_mw: float = Field(..., gt=0, description="Inverter / grid capacity (MW AC)")
@@ -16,21 +18,30 @@ class SolarConfig(BaseModel):
soiling_fraction: float = Field(0.02, ge=0, lt=1, description="Flat annual soiling loss")
degradation_y1: float = Field(0.007, ge=0, lt=1, description="Y1 LID + initial degradation")
degradation_annual: float = Field(0.005, ge=0, lt=1, description="Annual degradation Y2-25")
+ # Stabilisation period (after COD, before full ramp-up)
+ stabilization_days: int = Field(60, ge=0, description="Days from COD with reduced output")
+ stabilization_energy_loss_frac: float = Field(
+ 0.20, ge=0, lt=1, description="Energy loss fraction during stabilisation period"
+ )
+ stabilization_dsm_addon_pct: float = Field(
+ 0.005, ge=0, lt=1, description="Extra DSM penalty rate during stabilisation"
+ )
@field_validator("capacity_ac_mw")
@classmethod
def ac_le_dc(cls, v: float, info: object) -> float:
- # DC/AC ratio >= 1.0 (typical 1.1-1.4); warn if AC > DC
return v
@property
- def dc_ac_ratio(self) -> float:
+ def computed_dc_ac_ratio(self) -> float:
return self.capacity_dc_mwp / self.capacity_ac_mw
class WindConfig(BaseModel):
"""Configuration for a single wind plant."""
+ model_config = ConfigDict(extra="ignore")
+
location_id: str = Field("RJ", description="Profile key: RJ | KA | GJ")
capacity_mw: float = Field(..., gt=0, description="Nameplate capacity (MW)")
hub_height_m: float = Field(140.0, gt=0, description="Hub height of turbines (m)")
@@ -48,6 +59,14 @@ class WindConfig(BaseModel):
degradation_annual: float = Field(
0.002, ge=0, lt=1, description="Annual output degradation (0.2%/yr)"
)
+ # Stabilisation period
+ stabilization_days: int = Field(60, ge=0, description="Days from COD with reduced output")
+ stabilization_energy_loss_frac: float = Field(
+ 0.15, ge=0, lt=1, description="Energy loss fraction during stabilisation period"
+ )
+ stabilization_dsm_addon_pct: float = Field(
+ 0.005, ge=0, lt=1, description="Extra DSM penalty rate during stabilisation"
+ )
class BessConfig(BaseModel):
@@ -56,6 +75,7 @@ class BessConfig(BaseModel):
capacity_mwh: float = Field(..., gt=0, description="Nameplate energy capacity (MWh)")
power_mw: float = Field(..., gt=0, description="Maximum charge / discharge power (MW)")
rte: float = Field(0.85, gt=0, le=1, description="Round-trip efficiency")
+ dod: float = Field(0.85, gt=0, le=1, description="Depth of discharge")
# Degradation: linear from 100% SOH to eol_soh over design_cycles
design_cycles: float = Field(
6000.0, gt=0, description="Manufacturer design cycle life"
diff --git a/packages/engine/src/remodel_engine/schemas/scenario.py b/packages/engine/src/remodel_engine/schemas/scenario.py
new file mode 100644
index 0000000..c43fb87
--- /dev/null
+++ b/packages/engine/src/remodel_engine/schemas/scenario.py
@@ -0,0 +1,106 @@
+"""Top-level ScenarioInput and ScenarioResult schemas."""
+
+from __future__ import annotations
+
+from datetime import date as _Date
+from typing import Any, Literal
+
+from pydantic import BaseModel, ConfigDict, Field, model_validator
+
+from remodel_engine.schemas.capex import CapexConfig
+from remodel_engine.schemas.debt import DebtConfig, DebtYearRow, IRRMetrics
+from remodel_engine.schemas.financial import CommercialConfig, Financials, OpexConfig, TaxConfig
+from remodel_engine.schemas.generation import BessConfig, SolarConfig, WindConfig
+
+
+class ProjectInfo(BaseModel):
+ model_config = ConfigDict(extra="ignore")
+
+ name: str = Field("Unnamed Project")
+ state: str | None = Field(None, description="Indian state code (e.g. RJ, GJ, KA)")
+ capacity_solar_mwp: float = Field(0.0, ge=0)
+ capacity_wind_mw: float = Field(0.0, ge=0)
+ capacity_bess_mwh: float = Field(0.0, ge=0)
+ capacity_bess_mw: float = Field(0.0, ge=0)
+ land_acres: float = Field(0.0, ge=0)
+ cod_year: int = Field(2027, ge=2020)
+ cod_date: str | None = Field(None, description="Plant COD (YYYY-MM-DD); overrides cod_year")
+ solar_cod_date: str | None = Field(None, description="Solar COD; defaults to cod_date")
+ wind_cod_date: str | None = Field(None, description="Wind COD; defaults to cod_date")
+ bess_cod_date: str | None = Field(None, description="BESS COD; defaults to cod_date")
+
+ @model_validator(mode="after")
+ def _sync_cod_year(self) -> "ProjectInfo":
+ if self.cod_date:
+ self.cod_year = _Date.fromisoformat(self.cod_date).year
+ return self
+
+
+class SolverConfig(BaseModel):
+ mode: Literal["solve_tariff", "fixed_tariff"] = "solve_tariff"
+ target_equity_irr: float = Field(0.18, gt=0, lt=1)
+ fixed_tariff: float | None = None
+
+
+class RtcConfig(BaseModel):
+ """RTC dispatch configuration."""
+
+ rtc_mw: float = Field(0.0, ge=0, description="Contracted RTC capacity (MW)")
+ mcp_enabled: bool = Field(False, description="Sell surplus at MCP instead of curtailing")
+ initial_soc_frac: float = Field(
+ 0.5, ge=0, le=1, description="Initial SOC as fraction of capacity"
+ )
+
+
+class KpiSummary(BaseModel):
+ solved_tariff_inr_per_kwh: float | None = None
+ equity_irr: float | None = None
+ project_irr: float | None = None
+ min_dscr: float | None = None
+ avg_dscr: float | None = None
+ total_capex_cr: float | None = None
+ idc_cr: float | None = None
+ debt_cr: float | None = None
+ solar_y1_cuf: float | None = None
+ wind_y1_plf: float | None = None
+ lcoe_inr_per_kwh: float | None = None
+ payback_years: float | None = None
+ # Dispatch KPIs (populated when rtc config is present)
+ rtc_cuf_achieved: float | None = None
+ total_shortfall_mwh: float | None = None
+ total_curtailed_mwh: float | None = None
+ total_mcp_revenue_cr: float | None = None
+
+
+class ScenarioInput(BaseModel):
+ project: ProjectInfo = Field(default_factory=ProjectInfo) # type: ignore[arg-type]
+ solar: SolarConfig | None = None
+ wind: WindConfig | None = None
+ bess: BessConfig | None = None
+ rtc: RtcConfig | None = None
+ commercial: CommercialConfig = Field(default_factory=CommercialConfig) # type: ignore[arg-type]
+ capex: CapexConfig = Field(default_factory=CapexConfig) # type: ignore[arg-type]
+ opex: OpexConfig = Field(default_factory=OpexConfig) # type: ignore[arg-type]
+ debt: DebtConfig = Field(default_factory=DebtConfig) # type: ignore[arg-type]
+ tax: TaxConfig = Field(default_factory=TaxConfig) # type: ignore[arg-type]
+ solver: SolverConfig = Field(default_factory=SolverConfig) # type: ignore[arg-type]
+
+
+class ScenarioResult(BaseModel):
+ """Full output from a scenario run."""
+
+ model_config = {"arbitrary_types_allowed": True}
+
+ inputs: ScenarioInput
+ status: Literal["queued", "running", "success", "failed"] = "success"
+ solved_tariff: float | None = None
+ kpis: KpiSummary = Field(default_factory=KpiSummary)
+ financials: Financials | None = None
+ debt_schedule: list[DebtYearRow] = Field(default_factory=list)
+ irr_metrics: IRRMetrics = Field(default_factory=IRRMetrics)
+ warnings: list[str] = Field(default_factory=list)
+ runtime_s: float = 0.0
+ timeseries_uri: str = ""
+ # Supplemental tables for workbook sheets
+ generation_by_year: list[dict[str, Any]] = Field(default_factory=list)
+ idc_phasing: dict[str, Any] = Field(default_factory=dict)
diff --git a/packages/engine/src/remodel_engine/solver/__init__.py b/packages/engine/src/remodel_engine/solver/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/packages/engine/src/remodel_engine/solver/tariff.py b/packages/engine/src/remodel_engine/solver/tariff.py
new file mode 100644
index 0000000..d51d8fd
--- /dev/null
+++ b/packages/engine/src/remodel_engine/solver/tariff.py
@@ -0,0 +1,50 @@
+"""Tariff solver: brentq on tariff in [2.0, 8.0] INR/kWh.
+
+The solver finds the tariff where equity_IRR == target_equity_irr.
+
+Inner loop: for a given tariff, run the full financial model and compute equity IRR.
+This delegates to a caller-supplied run_scenario callable to keep the solver
+decoupled from the full scenario runner.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+
+from scipy.optimize import brentq
+
+SolverFn = Callable[[float], float] # tariff -> equity_irr_minus_target
+
+
+def solve_tariff(
+ objective_fn: SolverFn,
+ target_equity_irr: float,
+ lo: float = 2.0,
+ hi: float = 8.0,
+ tol: float = 1e-4,
+ max_iter: int = 50,
+) -> tuple[float, float]:
+ """Find tariff where equity_irr == target_equity_irr using brentq.
+
+ objective_fn(tariff) should return equity_irr - target_equity_irr.
+ Returns (solved_tariff, achieved_equity_irr).
+ """
+ def f(tariff: float) -> float:
+ return objective_fn(tariff)
+
+ lo_val = f(lo)
+ hi_val = f(hi)
+
+ if lo_val > 0:
+ # Even at minimum tariff, IRR exceeds target — return minimum
+ return lo, lo_val + target_equity_irr
+
+ if hi_val < 0:
+ # Even at maximum tariff, IRR below target — return max with warning
+ return hi, hi_val + target_equity_irr
+
+ # Brentq requires opposite signs at bracket endpoints
+ result = brentq(f, lo, hi, xtol=tol, maxiter=max_iter, full_output=False)
+ solved_tariff = float(result)
+ achieved_irr = target_equity_irr # by construction at convergence
+ return round(solved_tariff, 4), round(achieved_irr, 6)
diff --git a/packages/engine/tests/unit/test_capex.py b/packages/engine/tests/unit/test_capex.py
new file mode 100644
index 0000000..beba7a4
--- /dev/null
+++ b/packages/engine/tests/unit/test_capex.py
@@ -0,0 +1,267 @@
+"""S2 capex unit tests: cost items, phasing, IDC solver."""
+
+
+import pytest
+
+from remodel_engine.capex.cost_items import (
+ ProjectCapacity,
+ compute_capex,
+ evaluate_cost_item,
+)
+from remodel_engine.capex.idc import compute_idc, monthly_idc_schedule
+from remodel_engine.capex.phasing import load_phasing, trim_or_extend_phasing, validate_phasing
+from remodel_engine.catalog.cost_items import DEFAULT_COST_ITEMS
+from remodel_engine.schemas.capex import CostItem, DrawdownCurve, PhasingCurve
+
+# ---------------------------------------------------------------------------
+# CostItem evaluation
+# ---------------------------------------------------------------------------
+
+
+def test_per_wp_dc() -> None:
+ item = CostItem(
+ id="x", name="x", category="HardCost",
+ basis="PER_WP_DC", value=20.0,
+ depr_class="Plant", phasing_id="solar_standard_18mo", attribution="SolarOnly",
+ )
+ cap = ProjectCapacity(solar_mwp_dc=100.0)
+ result = evaluate_cost_item(item, cap)
+ # 20 INR/Wp * 100 MWp * 1e6 Wp/MWp = 2e9 INR = 200 Cr
+ assert abs(result - 200.0) < 1e-6
+
+
+def test_per_mwh_bess() -> None:
+ item = CostItem(
+ id="x", name="x", category="HardCost",
+ basis="PER_MWH_BESS", value=0.20,
+ depr_class="BESS", phasing_id="solar_standard_18mo", attribution="BESSOnly",
+ )
+ cap = ProjectCapacity(bess_mwh=500.0)
+ assert abs(evaluate_cost_item(item, cap) - 100.0) < 1e-6
+
+
+def test_per_kwh_usd() -> None:
+ item = CostItem(
+ id="x", name="x", category="HardCost",
+ basis="PER_KWH_USD", value=80.0, fx_rate=84.0,
+ depr_class="BESS", phasing_id="solar_standard_18mo", attribution="BESSOnly",
+ )
+ cap = ProjectCapacity(bess_mwh=500.0)
+ # 80 USD/kWh * 84 INR/USD * 500 MWh * 1000 kWh/MWh = 3.36e9 INR = 336 Cr
+ assert abs(evaluate_cost_item(item, cap) - 336.0) < 1e-4
+
+
+def test_abs_inr_cr() -> None:
+ item = CostItem(
+ id="x", name="x", category="HardCost",
+ basis="ABS_INR_CR", value=15.0,
+ depr_class="Plant", phasing_id="solar_standard_18mo", attribution="Common",
+ )
+ assert evaluate_cost_item(item, ProjectCapacity()) == 15.0
+
+
+def test_pct_of_hardcost_returns_zero_without_resolution() -> None:
+ item = CostItem(
+ id="x", name="x", category="EPCOverhead",
+ basis="PCT_OF_HARDCOST", value=0.05,
+ depr_class="Capitalized_NoDepr", phasing_id="solar_standard_18mo", attribution="Common",
+ )
+ assert evaluate_cost_item(item, ProjectCapacity()) == 0.0
+
+
+def test_compute_capex_resolves_pct() -> None:
+ items = [
+ CostItem(
+ id="a", name="a", category="HardCost",
+ basis="ABS_INR_CR", value=100.0,
+ depr_class="Plant", phasing_id="solar_standard_18mo", attribution="Common",
+ ),
+ CostItem(
+ id="b", name="b", category="EPCMargin",
+ basis="PCT_OF_HARDCOST", value=0.05,
+ depr_class="Capitalized_NoDepr", phasing_id="solar_standard_18mo", attribution="Common",
+ ),
+ ]
+ bd = compute_capex(items, ProjectCapacity())
+ assert abs(bd.hard_cost_cr - 100.0) < 1e-6
+ assert abs(bd.total_cr - 105.0) < 1e-6
+
+
+def test_default_catalog_loads() -> None:
+ assert len(DEFAULT_COST_ITEMS) >= 25
+
+
+def test_default_catalog_all_phasing_ids_known() -> None:
+ known = {"solar_standard_18mo", "wind_standard_24mo", "hybrid_rtc_36mo"}
+ for item in DEFAULT_COST_ITEMS:
+ assert item.phasing_id in known, f"{item.id} has unknown phasing_id {item.phasing_id!r}"
+
+
+# ---------------------------------------------------------------------------
+# Phasing templates
+# ---------------------------------------------------------------------------
+
+
+def test_phasing_sums_to_one() -> None:
+ for pid in ["solar_standard_18mo", "wind_standard_24mo", "hybrid_rtc_36mo"]:
+ curve = load_phasing(pid)
+ assert abs(sum(curve.monthly_pct) - 1.0) < 1e-6, f"{pid} does not sum to 1"
+
+
+def test_phasing_no_negatives() -> None:
+ for pid in ["solar_standard_18mo", "wind_standard_24mo", "hybrid_rtc_36mo"]:
+ curve = load_phasing(pid)
+ assert all(p >= 0 for p in curve.monthly_pct)
+
+
+def test_phasing_validate_clean() -> None:
+ curve = load_phasing("solar_standard_18mo")
+ assert validate_phasing(curve) == []
+
+
+def test_phasing_validate_bad_sum() -> None:
+ # PhasingCurve validator rejects bad sums at construction; validate_phasing also catches it
+ curve = PhasingCurve.model_construct(id="x", name="x", monthly_pct=[0.5, 0.6])
+ errors = validate_phasing(curve)
+ assert any("sum" in e for e in errors)
+
+
+def test_load_phasing_unknown_raises() -> None:
+ with pytest.raises(ValueError, match="Unknown phasing_id"):
+ load_phasing("nonexistent")
+
+
+def test_trim_phasing() -> None:
+ curve = load_phasing("solar_standard_18mo")
+ trimmed = trim_or_extend_phasing(curve, 12)
+ assert len(trimmed.monthly_pct) == 12
+ assert abs(sum(trimmed.monthly_pct) - 1.0) < 1e-6
+
+
+def test_extend_phasing() -> None:
+ curve = PhasingCurve(id="x", name="x", monthly_pct=[0.5, 0.3, 0.2])
+ extended = trim_or_extend_phasing(curve, 5)
+ assert len(extended.monthly_pct) == 5
+ assert abs(sum(extended.monthly_pct) - 1.0) < 1e-6
+
+
+# ---------------------------------------------------------------------------
+# IDC solver
+# ---------------------------------------------------------------------------
+
+
+def _flat_debt_curve(n: int) -> DrawdownCurve:
+ """Even debt drawdown over n months."""
+ step = 1.0 / n
+ cum = [round(step * (m + 1), 10) for m in range(n)]
+ cum[-1] = 1.0
+ return DrawdownCurve(id="test", name="test", cum_pct=cum)
+
+
+def test_idc_zero_debt_gives_zero_idc() -> None:
+ curve = _flat_debt_curve(12)
+ idc, debt, iters = compute_idc(
+ base_capex_cr=1000.0,
+ debt_fraction=0.0,
+ interest_rate_annual=0.09,
+ debt_curve=curve,
+ n_months=12,
+ )
+ assert idc == pytest.approx(0.0, abs=1e-9)
+ assert debt == pytest.approx(0.0, abs=1e-9)
+
+
+def test_idc_deterministic_2_month() -> None:
+ """Hand-computed IDC for 2-month construction with all debt drawn in month 1.
+
+ base_capex = 100 Cr, debt_fraction = 0.75, rate = 12% pa
+ Iteration 1: TPC=100, debt=75, all drawn m1, IDC = 75 * 1% = 0.75 Cr
+ Iteration 2: TPC=100.75, debt=75.5625, IDC = 75.5625 * 1% = 0.756 Cr
+ ...converges near ~0.757 Cr
+ """
+ # Debt all drawn in month 1 (cum=[1.0, 1.0])
+ curve = DrawdownCurve(id="t", name="t", cum_pct=[1.0, 1.0])
+ idc, debt, iters = compute_idc(
+ base_capex_cr=100.0,
+ debt_fraction=0.75,
+ interest_rate_annual=0.12,
+ debt_curve=curve,
+ n_months=2,
+ )
+ # Hand derivation: all debt drawn at end of month 1, 1 month remaining
+ # IDC = debt * r = 0.75*TPC * 0.01; TPC = 100+IDC
+ # IDC = 0.0075*(100+IDC) → IDC*(1-0.0075)=0.75 → IDC = 0.75/0.9925
+ expected_idc = 0.75 / 0.9925 # ≈ 0.7557 Cr
+ assert idc == pytest.approx(expected_idc, rel=1e-3)
+
+
+def test_idc_zero_rate_gives_zero_idc() -> None:
+ curve = _flat_debt_curve(24)
+ idc, _, _ = compute_idc(
+ base_capex_cr=500.0,
+ debt_fraction=0.75,
+ interest_rate_annual=0.0,
+ debt_curve=curve,
+ n_months=24,
+ )
+ assert idc == pytest.approx(0.0, abs=1e-6)
+
+
+def test_idc_converges_within_tolerance() -> None:
+ curve = _flat_debt_curve(24)
+ idc, debt, iters = compute_idc(
+ base_capex_cr=800.0,
+ debt_fraction=0.75,
+ interest_rate_annual=0.09,
+ debt_curve=curve,
+ n_months=24,
+ )
+ # Verify self-consistency: IDC = sum(debt_monthly * r * remaining)
+ delta = debt / 24.0
+ r = 0.09 / 12.0
+ expected_idc = sum(delta * r * (24 - m - 1) for m in range(24))
+ assert abs(idc - expected_idc) < 0.1 # within 10 paise
+
+
+def test_idc_monthly_schedule_length() -> None:
+ curve = _flat_debt_curve(12)
+ schedule = monthly_idc_schedule(100.0, 0.75, 0.09, curve, n_months=12)
+ assert len(schedule) == 12
+ assert all(v >= 0 for v in schedule)
+
+
+def test_idc_monthly_schedule_increasing() -> None:
+ """Interest accrues on growing outstanding balance — monthly amount must increase."""
+ curve = _flat_debt_curve(12)
+ schedule = monthly_idc_schedule(100.0, 0.75, 0.09, curve, n_months=12)
+ for i in range(1, len(schedule)):
+ assert schedule[i] >= schedule[i - 1]
+
+
+def test_idc_larger_construction_gives_more_idc() -> None:
+ """Longer construction period → more IDC (all else equal)."""
+ base = 1000.0
+ frac = 0.75
+ rate = 0.09
+ curve_12 = _flat_debt_curve(12)
+ curve_24 = _flat_debt_curve(24)
+ idc_12, _, _ = compute_idc(base, frac, rate, curve_12, 12)
+ idc_24, _, _ = compute_idc(base, frac, rate, curve_24, 24)
+ assert idc_24 > idc_12
+
+
+def test_idc_36mo_hybrid_catalog_curve() -> None:
+ """IDC on a realistic hybrid RTC scenario should be > 0 and < 10% of base capex."""
+ from remodel_engine.catalog.phasing import DRAWDOWN_TEMPLATES
+ _, debt_curve = DRAWDOWN_TEMPLATES["hybrid_rtc_36mo"]
+ base_capex = 2500.0 # INR Cr — 500 MW hybrid
+ idc, debt, iters = compute_idc(
+ base_capex_cr=base_capex,
+ debt_fraction=0.75,
+ interest_rate_annual=0.095,
+ debt_curve=debt_curve,
+ n_months=36,
+ )
+ assert idc > 0
+ assert idc < 0.20 * base_capex # 36-month construction at 9.5% → IDC ~15% of capex
+ assert iters < 50
diff --git a/packages/engine/tests/unit/test_cli.py b/packages/engine/tests/unit/test_cli.py
index c36b1d9..729955f 100644
--- a/packages/engine/tests/unit/test_cli.py
+++ b/packages/engine/tests/unit/test_cli.py
@@ -39,8 +39,7 @@ def wind_scenario(tmp_path: Path) -> Path:
def _invoke(scenario: Path, out: Path) -> object:
- # Single-command Typer app: invoke without the subcommand name
- return runner.invoke(app, ["--input", str(scenario), "--output", str(out)])
+ return runner.invoke(app, ["simulate-gen", "--input", str(scenario), "--output", str(out)])
def test_simulate_gen_solar(solar_scenario: Path, tmp_path: Path) -> None:
diff --git a/packages/engine/tests/unit/test_debt_irr.py b/packages/engine/tests/unit/test_debt_irr.py
new file mode 100644
index 0000000..e9b07dd
--- /dev/null
+++ b/packages/engine/tests/unit/test_debt_irr.py
@@ -0,0 +1,223 @@
+"""S4 unit tests: debt schedule, sizing, IRR metrics, tariff solver."""
+
+import math
+
+import pytest
+
+from remodel_engine.debt.schedule import build_debt_schedule
+from remodel_engine.debt.sizing import size_debt
+from remodel_engine.irr.metrics import (
+ compute_all_metrics,
+ compute_dscr_metrics,
+ compute_equity_irr,
+ compute_lcoe,
+ compute_llcr,
+ compute_payback,
+ compute_project_irr,
+)
+from remodel_engine.schemas.debt import DebtConfig
+from remodel_engine.solver.tariff import solve_tariff
+
+# ---------------------------------------------------------------------------
+# Debt schedule
+# ---------------------------------------------------------------------------
+
+
+def _default_config(shape: str = "equal_principal") -> DebtConfig:
+ return DebtConfig(
+ interest_rate_annual=0.09,
+ tenor_years=15,
+ moratorium_years=1,
+ de_ratio=3.0,
+ min_dscr=1.20,
+ avg_dscr=1.35,
+ schedule_shape=shape, # type: ignore[arg-type]
+ )
+
+
+def test_debt_schedule_length() -> None:
+ sched = build_debt_schedule(1000.0, [200.0] * 25, _default_config())
+ assert len(sched) == 25
+
+
+def test_debt_schedule_balance_monotonic() -> None:
+ sched = build_debt_schedule(1000.0, [200.0] * 25, _default_config())
+ for i in range(len(sched) - 1):
+ assert sched[i + 1].opening_balance_cr <= sched[i].opening_balance_cr + 1e-4
+
+
+def test_debt_schedule_zero_at_end() -> None:
+ sched = build_debt_schedule(500.0, [200.0] * 25, _default_config())
+ # After tenor years, outstanding balance should be near 0
+ assert sched[14].closing_balance_cr < 1e-4
+
+
+def test_debt_schedule_equal_principal_constant_principal() -> None:
+ sched = build_debt_schedule(1000.0, [200.0] * 25, _default_config("equal_principal"))
+ # After moratorium, principal should be constant
+ repay = [r.principal_cr for r in sched[1:14]] # years 2-14 (repayment)
+ assert all(abs(p - repay[0]) < 0.01 for p in repay)
+
+
+def test_debt_schedule_equal_installment_constant_dts() -> None:
+ sched = build_debt_schedule(1000.0, [200.0] * 25, _default_config("equal_installment"))
+ # Total debt service should be approximately constant in repayment period
+ dts = [r.total_debt_service_cr for r in sched[1:14]]
+ assert all(abs(d - dts[0]) < 0.5 for d in dts) # allow 0.5 Cr tolerance
+
+
+def test_debt_schedule_dscr_computed() -> None:
+ sched = build_debt_schedule(500.0, [100.0] * 25, _default_config())
+ for r in sched:
+ if r.total_debt_service_cr > 1e-4:
+ assert r.dscr > 0
+
+
+def test_debt_schedule_moratorium_no_principal() -> None:
+ sched = build_debt_schedule(1000.0, [200.0] * 25, _default_config())
+ assert sched[0].principal_cr == pytest.approx(0.0, abs=1e-4)
+
+
+def test_debt_schedule_balloon() -> None:
+ sched = build_debt_schedule(500.0, [200.0] * 25, _default_config("balloon"))
+ # All principal in last year of tenor (year 15, index 14)
+ for r in sched[:14]:
+ assert r.principal_cr < 1e-4
+ assert sched[14].principal_cr > 400.0
+
+
+# ---------------------------------------------------------------------------
+# Debt sizing
+# ---------------------------------------------------------------------------
+
+
+def test_size_debt_respects_de_ratio() -> None:
+ cfg = DebtConfig(de_ratio=3.0, min_dscr=1.10, avg_dscr=1.20)
+ cfads = [300.0] * 25
+ debt = size_debt(1000.0, cfads, cfg)
+ max_de_debt = 1000.0 * 3.0 / (1 + 3.0) # 750
+ assert debt <= max_de_debt + 1.0
+
+
+def test_size_debt_zero_cfads_gives_low_debt() -> None:
+ cfg = DebtConfig(de_ratio=3.0, min_dscr=1.10, avg_dscr=1.20)
+ cfads = [0.0] * 25
+ debt = size_debt(1000.0, cfads, cfg)
+ assert debt >= 0.0
+
+
+def test_size_debt_positive() -> None:
+ cfg = DebtConfig(de_ratio=3.0, min_dscr=1.10, avg_dscr=1.20)
+ debt = size_debt(2000.0, [500.0] * 25, cfg)
+ assert debt > 0
+
+
+# ---------------------------------------------------------------------------
+# IRR and metrics
+# ---------------------------------------------------------------------------
+
+
+def test_project_irr_simple_case() -> None:
+ """Known case: invest 100, get 15/yr for 25yr → IRR ~14.8%."""
+ irr = compute_project_irr(100.0, [15.0] * 25)
+ assert irr is not None
+ assert 0.10 < irr < 0.20
+
+
+def test_project_irr_negative_cashflows_returns_none_or_finite() -> None:
+ """All-negative cashflows → no positive IRR."""
+ irr = compute_project_irr(1000.0, [-10.0] * 25)
+ # numpy_financial may return nan or a negative number
+ if irr is not None:
+ assert irr < 0 or not math.isfinite(irr)
+
+
+def test_equity_irr_simple() -> None:
+ irr = compute_equity_irr(250.0, [50.0] * 25)
+ assert irr is not None
+ assert 0.15 < irr < 0.25
+
+
+def test_payback_exact() -> None:
+ # capex=100, earn 25/yr → payback = 4 years
+ pb = compute_payback(100.0, [25.0] * 25)
+ assert pb == pytest.approx(4.0, abs=0.01)
+
+
+def test_payback_not_recovered() -> None:
+ pb = compute_payback(1000.0, [1.0] * 25)
+ assert pb is None
+
+
+def test_lcoe_reasonable_range() -> None:
+ lcoe = compute_lcoe(1000.0, [30.0] * 25, [876000.0] * 25, 0.09)
+ assert lcoe is not None
+ # For 100 MW solar, LCOE should be roughly 2-5 INR/kWh
+ assert 1.0 < lcoe < 10.0
+
+
+def test_dscr_metrics() -> None:
+ rows = build_debt_schedule(500.0, [100.0] * 25, _default_config())
+ min_d, avg_d = compute_dscr_metrics(rows)
+ assert min_d > 0
+ assert avg_d >= min_d
+
+
+def test_llcr_positive() -> None:
+ rows = build_debt_schedule(500.0, [100.0] * 25, _default_config())
+ llcr = compute_llcr([100.0] * 25, rows, 0.09)
+ assert llcr is not None
+ assert llcr > 0
+
+
+def test_compute_all_metrics_returns_irr_metrics() -> None:
+ rows = build_debt_schedule(750.0, [200.0] * 25, _default_config())
+ metrics = compute_all_metrics(
+ total_capex_cr=1000.0,
+ equity_cr=250.0,
+ cfads_by_year=[200.0] * 25,
+ pat_by_year=[80.0] * 25,
+ opex_by_year=[50.0] * 25,
+ generation_mwh_by_year=[876000.0] * 25,
+ schedule=rows,
+ )
+ assert metrics.project_irr is not None
+ assert metrics.equity_irr is not None
+ assert metrics.min_dscr is not None
+
+
+# ---------------------------------------------------------------------------
+# Tariff solver
+# ---------------------------------------------------------------------------
+
+
+def test_tariff_solver_converges() -> None:
+ """Solver should find tariff where equity IRR = 18%."""
+ target = 0.18
+
+ def objective(tariff: float) -> float:
+ # Synthetic: equity_irr = 0.05 + 0.04 * (tariff - 2.0)
+ equity_irr = 0.05 + 0.04 * (tariff - 2.0)
+ return equity_irr - target
+
+ solved_tariff, _ = solve_tariff(objective, target)
+ # Check: 0.05 + 0.04 * (t - 2) = 0.18 → t = 2 + (0.18-0.05)/0.04 = 5.25
+ assert solved_tariff == pytest.approx(5.25, abs=0.01)
+
+
+def test_tariff_solver_lower_bound_hit() -> None:
+ """If IRR > target at lo, return lo."""
+ def obj(t: float) -> float:
+ return 0.30 - 0.18 # always above target
+
+ tariff, _ = solve_tariff(obj, 0.18, lo=2.0)
+ assert tariff == 2.0
+
+
+def test_tariff_solver_upper_bound_hit() -> None:
+ """If IRR < target at hi, return hi."""
+ def obj(t: float) -> float:
+ return 0.05 - 0.18 # always below target
+
+ tariff, _ = solve_tariff(obj, 0.18, hi=8.0)
+ assert tariff == 8.0
diff --git a/packages/engine/tests/unit/test_dispatch.py b/packages/engine/tests/unit/test_dispatch.py
new file mode 100644
index 0000000..0f4b728
--- /dev/null
+++ b/packages/engine/tests/unit/test_dispatch.py
@@ -0,0 +1,184 @@
+"""S7-T05: Hand-validated dispatch tests."""
+
+import pytest
+
+from remodel_engine.dispatch.hybrid_rtc import DispatchConfig, run_dispatch
+from remodel_engine.dispatch.mcp_settlement import (
+ build_mcp_price_profile,
+ compute_mcp_annual_revenue_cr,
+)
+
+
+# ---------------------------------------------------------------------------
+# 24-hour hand-validated scenario
+# ---------------------------------------------------------------------------
+
+def _flat(v: float, n: int = 24) -> list[float]:
+ return [v] * n
+
+
+def test_no_bess_no_shortfall_perfect_match() -> None:
+ """Solar exactly matches RTC — no charge, no discharge, no shortfall."""
+ cfg = DispatchConfig(rtc_mw=10.0, bess_mwh=0.0, bess_mw=0.0)
+ summary = run_dispatch(_flat(10.0), _flat(0.0), cfg)
+ assert summary.total_shortfall_mwh == pytest.approx(0.0, abs=1e-4)
+ assert summary.total_curtailed_mwh == pytest.approx(0.0, abs=1e-4)
+ assert summary.total_net_injection_mwh == pytest.approx(240.0, abs=0.1)
+
+
+def test_no_bess_all_shortfall() -> None:
+ """Zero generation, no BESS — all hours are shortfall."""
+ cfg = DispatchConfig(rtc_mw=10.0, bess_mwh=0.0, bess_mw=0.0)
+ summary = run_dispatch(_flat(0.0), _flat(0.0), cfg)
+ assert summary.total_shortfall_mwh == pytest.approx(240.0, abs=0.1)
+ assert summary.total_net_injection_mwh == pytest.approx(0.0, abs=1e-4)
+
+
+def test_bess_fills_nighttime_gap() -> None:
+ """Solar 10 MW daytime (h0-11), BESS 120 MWh covers night (h12-23)."""
+ solar = [10.0] * 12 + [0.0] * 12
+ wind = [0.0] * 24
+ cfg = DispatchConfig(
+ rtc_mw=5.0,
+ bess_mwh=120.0,
+ bess_mw=10.0,
+ dod=1.0,
+ rte=1.0,
+ initial_soc_frac=0.0,
+ )
+ summary = run_dispatch(solar, wind, cfg)
+ # Daytime: 5 MW surplus per hour * 12 = 60 MWh charged (BESS fills to 60 MWh with rte=1)
+ # Nighttime: 5 MW shortfall per hour, BESS discharges 60 MWh total → covers all 12 hours
+ assert summary.total_shortfall_mwh == pytest.approx(0.0, abs=1e-4)
+ assert summary.rtc_cuf_achieved == pytest.approx(1.0, abs=0.01)
+
+
+def test_bess_soc_bounded_by_dod() -> None:
+ """SOC must not exceed bess_mwh * dod at any point."""
+ solar = _flat(20.0)
+ wind = _flat(0.0)
+ cfg = DispatchConfig(
+ rtc_mw=5.0,
+ bess_mwh=50.0,
+ bess_mw=10.0,
+ dod=0.9,
+ rte=1.0,
+ initial_soc_frac=0.0,
+ )
+ summary = run_dispatch(solar, wind, cfg)
+ soc_max = 50.0 * 0.9
+ for h in summary.hourly:
+ assert h.soc_mwh <= soc_max + 1e-6
+
+
+def test_bess_soc_not_below_zero() -> None:
+ """SOC must not go below 0."""
+ solar = _flat(0.0)
+ wind = _flat(0.0)
+ cfg = DispatchConfig(
+ rtc_mw=10.0,
+ bess_mwh=50.0,
+ bess_mw=10.0,
+ dod=0.9,
+ rte=1.0,
+ initial_soc_frac=0.5,
+ )
+ summary = run_dispatch(solar, wind, cfg)
+ for h in summary.hourly:
+ assert h.soc_mwh >= -1e-6
+
+
+def test_surplus_curtailed_when_bess_full() -> None:
+ """When BESS is full and gen > RTC, surplus is curtailed."""
+ solar = _flat(100.0)
+ wind = _flat(0.0)
+ cfg = DispatchConfig(
+ rtc_mw=10.0,
+ bess_mwh=10.0,
+ bess_mw=10.0,
+ dod=1.0,
+ rte=1.0,
+ initial_soc_frac=1.0,
+ )
+ summary = run_dispatch(solar, wind, cfg)
+ assert summary.total_curtailed_mwh > 0
+
+
+def test_mcp_revenue_with_surplus() -> None:
+ """When MCP is enabled and curtailed exists, mcp revenue should be positive."""
+ solar = _flat(100.0)
+ wind = _flat(0.0)
+ prices = [3000.0] * 24
+ cfg = DispatchConfig(
+ rtc_mw=10.0,
+ bess_mwh=10.0,
+ bess_mw=10.0,
+ dod=1.0,
+ rte=1.0,
+ initial_soc_frac=1.0,
+ mcp_enabled=True,
+ )
+ summary = run_dispatch(solar, wind, cfg, mcp_prices_inr_per_mwh=prices)
+ assert summary.total_mcp_revenue_inr > 0
+
+
+def test_rtc_cuf_achieved_below_1_with_shortfall() -> None:
+ """If there is shortfall, RTC CUF < 1."""
+ solar = _flat(3.0) # 3 MW vs 5 MW RTC
+ cfg = DispatchConfig(rtc_mw=5.0, bess_mwh=0.0, bess_mw=0.0)
+ summary = run_dispatch(solar, _flat(0.0), cfg)
+ assert summary.rtc_cuf_achieved < 1.0
+
+
+def test_8760_hour_run() -> None:
+ """Full year dispatch completes and returns 8760 hourly entries."""
+ n = 8760
+ solar = [5.0] * n
+ wind = [3.0] * n
+ cfg = DispatchConfig(rtc_mw=8.0, bess_mwh=20.0, bess_mw=5.0)
+ summary = run_dispatch(solar, wind, cfg)
+ assert len(summary.hourly) == n
+ assert summary.total_net_injection_mwh > 0
+
+
+# ---------------------------------------------------------------------------
+# MCP settlement helpers
+# ---------------------------------------------------------------------------
+
+
+def test_mcp_price_profile_length() -> None:
+ prices = build_mcp_price_profile()
+ assert len(prices) == 8760
+
+
+def test_mcp_price_peak_premium() -> None:
+ prices = build_mcp_price_profile(base_price_inr_per_mwh=3000.0, peak_premium=2.0)
+ # Hour 18 (6pm) = peak
+ assert prices[18] == pytest.approx(6000.0)
+ # Hour 10 (10am) = off-peak
+ assert prices[10] == pytest.approx(3000.0)
+
+
+def test_mcp_annual_revenue_cr_conversion() -> None:
+ from remodel_engine.dispatch.hybrid_rtc import DispatchSummary
+
+ dummy = DispatchSummary(
+ total_net_injection_mwh=0,
+ total_shortfall_mwh=0,
+ total_curtailed_mwh=0,
+ total_mcp_revenue_inr=1e7,
+ rtc_cuf_achieved=0,
+ avg_soc_frac=0,
+ )
+ assert compute_mcp_annual_revenue_cr(dummy) == pytest.approx(1.0)
+
+
+# ---------------------------------------------------------------------------
+# Parity gate placeholder (S7-T08)
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.skip(reason="Parity gate: requires Excel reference for hybrid RTC scenario")
+def test_parity_gate_hybrid_rtc() -> None:
+ """Hybrid RTC tariff must match Excel within 0.5%. RTC CUF within 0.5%."""
+ pass
diff --git a/packages/engine/tests/unit/test_excel_export.py b/packages/engine/tests/unit/test_excel_export.py
new file mode 100644
index 0000000..fb798f9
--- /dev/null
+++ b/packages/engine/tests/unit/test_excel_export.py
@@ -0,0 +1,56 @@
+"""S8-T06: Excel export tests."""
+
+from io import BytesIO
+
+from openpyxl import load_workbook
+
+from remodel_engine.io.excel_export import export_to_bytes
+from remodel_engine.scenarios.runner import run_scenario
+from remodel_engine.schemas.scenario import ScenarioInput
+
+
+def _run_base() -> object:
+ return run_scenario(ScenarioInput())
+
+
+def test_export_returns_bytes() -> None:
+ result = _run_base()
+ data = export_to_bytes(result)
+ assert isinstance(data, bytes)
+ assert len(data) > 1000
+
+
+def test_export_has_correct_sheets() -> None:
+ result = _run_base()
+ data = export_to_bytes(result)
+ wb = load_workbook(BytesIO(data))
+ assert set(wb.sheetnames) == {"KPIs", "PnL", "CFS", "BS", "DebtSched", "Inputs"}
+
+
+def test_kpi_sheet_has_data() -> None:
+ result = _run_base()
+ data = export_to_bytes(result)
+ wb = load_workbook(BytesIO(data))
+ ws = wb["KPIs"]
+ labels = [ws.cell(row=r, column=1).value for r in range(2, 20) if ws.cell(row=r, column=1).value]
+ assert "Equity IRR" in labels
+ assert "Solved Tariff (₹/kWh)" in labels
+
+
+def test_pnl_sheet_has_25_rows() -> None:
+ result = _run_base()
+ data = export_to_bytes(result)
+ wb = load_workbook(BytesIO(data))
+ ws = wb["PnL"]
+ data_rows = [r for r in range(2, 30) if ws.cell(row=r, column=1).value is not None]
+ assert len(data_rows) == 25
+
+
+def test_inputs_sheet_has_section_column() -> None:
+ result = _run_base()
+ data = export_to_bytes(result)
+ wb = load_workbook(BytesIO(data))
+ ws = wb["Inputs"]
+ sections = [ws.cell(row=r, column=1).value for r in range(2, 30) if ws.cell(row=r, column=1).value]
+ assert "Project" in sections
+ assert "Commercial" in sections
diff --git a/packages/engine/tests/unit/test_financial.py b/packages/engine/tests/unit/test_financial.py
new file mode 100644
index 0000000..78b5dbe
--- /dev/null
+++ b/packages/engine/tests/unit/test_financial.py
@@ -0,0 +1,296 @@
+"""S3 financial module unit tests: depreciation, tax, WC, P&L, CFS, BS."""
+
+import pytest
+
+from remodel_engine.financial.bs import build_bs
+from remodel_engine.financial.cfs import build_cfs
+from remodel_engine.financial.depreciation import (
+ AssetBlock,
+ build_depreciation_schedule,
+ compute_slm_depreciation,
+ compute_wdv_depreciation,
+)
+from remodel_engine.financial.pnl import build_pnl, compute_opex, compute_revenue
+from remodel_engine.financial.tax import compute_current_tax, compute_tax_schedule
+from remodel_engine.financial.working_capital import compute_working_capital
+from remodel_engine.schemas.financial import CommercialConfig, OpexConfig, TaxConfig
+
+# ---------------------------------------------------------------------------
+# Depreciation
+# ---------------------------------------------------------------------------
+
+
+def test_slm_depreciation_length() -> None:
+ depr = compute_slm_depreciation(100.0, 25.0)
+ assert len(depr) == 25
+
+
+def test_slm_depreciation_sum() -> None:
+ depr = compute_slm_depreciation(100.0, 25.0)
+ assert abs(sum(depr) - 100.0) < 1e-4
+
+
+def test_slm_depreciation_even() -> None:
+ depr = compute_slm_depreciation(100.0, 25.0)
+ assert all(abs(d - 4.0) < 1e-4 for d in depr)
+
+
+def test_slm_depreciation_zero_after_life() -> None:
+ depr = compute_slm_depreciation(60.0, 12.0, n_years=25)
+ assert all(d == 0.0 for d in depr[12:])
+ assert abs(sum(depr[:12]) - 60.0) < 1e-4
+
+
+def test_wdv_never_fully_zero() -> None:
+ """WDV never reaches zero (declining balance property)."""
+ depr = compute_wdv_depreciation(100.0, 0.40, n_years=25)
+ assert all(d > 0 for d in depr)
+
+
+def test_wdv_sum_less_than_cost() -> None:
+ """WDV depreciation total is less than cost over 25 years (book value remains)."""
+ depr = compute_wdv_depreciation(100.0, 0.40, n_years=25)
+ assert sum(depr) < 100.0
+
+
+def test_depreciation_schedule_plant_block() -> None:
+ blocks = [AssetBlock(depr_class="Plant", gross_cost_cr=1000.0)]
+ sched = build_depreciation_schedule(blocks, n_years=25)
+ assert len(sched.book_depr) == 25
+ assert abs(sched.book_depr[0] - 40.0) < 0.1 # 1000/25
+ assert sched.net_block_book[-1] >= 0
+
+
+def test_depreciation_schedule_mixed_blocks() -> None:
+ blocks = [
+ AssetBlock(depr_class="Plant", gross_cost_cr=800.0),
+ AssetBlock(depr_class="BESS", gross_cost_cr=200.0),
+ AssetBlock(depr_class="Land_NoDepr", gross_cost_cr=50.0),
+ ]
+ sched = build_depreciation_schedule(blocks, n_years=25)
+ # Plant: 800/25=32/yr; BESS: 200/12=16.67/yr (first 12 years), 0 after
+ assert abs(sched.book_depr[0] - (32.0 + 200.0 / 12.0)) < 0.1
+ assert sched.book_depr[12] == pytest.approx(32.0, abs=0.1) # only Plant remains
+
+
+def test_depreciation_tax_faster_than_book() -> None:
+ """For Plant, WDV 40% depreciates faster than SLM 25yr in early years."""
+ blocks = [AssetBlock(depr_class="Plant", gross_cost_cr=100.0)]
+ sched = build_depreciation_schedule(blocks, n_years=25)
+ assert sched.tax_depr[0] > sched.book_depr[0] # 40% > 4%
+
+
+# ---------------------------------------------------------------------------
+# Tax
+# ---------------------------------------------------------------------------
+
+
+def test_current_tax_positive_pbt() -> None:
+ assert abs(compute_current_tax(100.0, 0.2517) - 25.17) < 1e-4
+
+
+def test_current_tax_negative_pbt_gives_zero() -> None:
+ assert compute_current_tax(-50.0, 0.2517) == 0.0
+
+
+def test_tax_schedule_length() -> None:
+ cfg = TaxConfig()
+ pbt = [10.0] * 25
+ book = [4.0] * 25
+ tax_d = [40.0] + [24.0] * 24
+ ct, dt, dtl = compute_tax_schedule(pbt, book, tax_d, cfg)
+ assert len(ct) == len(dt) == len(dtl) == 25
+
+
+def test_deferred_tax_increases_when_tax_depr_exceeds_book() -> None:
+ cfg = TaxConfig()
+ pbt = [100.0] * 25
+ book_d = [4.0] * 25 # 4 Cr/yr
+ tax_d = [40.0] * 25 # 40 Cr/yr (much faster)
+ _, dt_mv, dtl = compute_tax_schedule(pbt, book_d, tax_d, cfg)
+ assert dt_mv[0] > 0 # DTL increases in early years
+ assert dtl[0] > 0
+
+
+# ---------------------------------------------------------------------------
+# Revenue and OpEx
+# ---------------------------------------------------------------------------
+
+
+def _default_comm_config() -> CommercialConfig:
+ return CommercialConfig(
+ tariff_inr_per_kwh=3.50,
+ ppa_capacity_mw=100.0,
+ receivable_days=45.0,
+ payable_days=30.0,
+ )
+
+
+def test_revenue_single_year() -> None:
+ rev = compute_revenue(
+ ac_gen_mwh_by_year=[876000.0], # 100 MW * 8760 hr
+ tariff_inr_per_kwh=3.50,
+ aux_pct=0.005,
+ tx_loss_pct=0.01,
+ dsm_loss_pct=0.02,
+ )
+ # net_kwh = 876e6 * (0.995) * (0.99) * (0.98) = roughly 847e6
+ # revenue = 847e6 * 3.5 / 1e7 ≈ 296.5 Cr
+ assert len(rev) == 1
+ assert 280.0 < rev[0] < 320.0
+
+
+def test_revenue_zero_generation_gives_zero() -> None:
+ rev = compute_revenue([0.0] * 25, 3.5, 0.005, 0.01, 0.02)
+ assert all(v == 0.0 for v in rev)
+
+
+def test_opex_escalates() -> None:
+ cfg = OpexConfig(om_escalation_pct=0.04, misc_cr=0.0, am_fee_pct_of_revenue=0.0)
+ rev = [100.0] * 25
+ opex = compute_opex(
+ rev, solar_mw=100.0, wind_mw=0.0, bess_mwh=0.0, base_capex_cr=500.0, config=cfg
+ )
+ # Should escalate each year
+ for y in range(1, 25):
+ assert opex[y] > opex[y - 1]
+
+
+def test_opex_length() -> None:
+ cfg = OpexConfig()
+ rev = [100.0] * 25
+ opex = compute_opex(rev, 100.0, 50.0, 200.0, 1000.0, cfg)
+ assert len(opex) == 25
+
+
+# ---------------------------------------------------------------------------
+# Working Capital
+# ---------------------------------------------------------------------------
+
+
+def test_wc_receivables_positive() -> None:
+ cfg = _default_comm_config()
+ rev = [100.0] * 25
+ opex = [20.0] * 25
+ wc, dwc = compute_working_capital(rev, opex, cfg)
+ assert all(w > 0 for w in wc) # receivables > payables
+
+
+def test_wc_delta_length() -> None:
+ cfg = _default_comm_config()
+ wc, dwc = compute_working_capital([100.0] * 25, [20.0] * 25, cfg)
+ assert len(dwc) == 25
+
+
+def test_wc_stable_revenue_zero_subsequent_delta() -> None:
+ """Constant revenue/opex → WC stable → delta WC = 0 from year 2 onwards."""
+ cfg = _default_comm_config()
+ wc, dwc = compute_working_capital([100.0] * 25, [20.0] * 25, cfg)
+ assert all(abs(d) < 1e-6 for d in dwc[1:])
+
+
+# ---------------------------------------------------------------------------
+# P&L integration
+# ---------------------------------------------------------------------------
+
+
+def test_pnl_length() -> None:
+ cfg = OpexConfig()
+ rev = [300.0] * 25
+ opex = [50.0] * 25
+ depr = [40.0] * 25
+ interest = [60.0] * 25
+ ct = [30.0] * 25
+ dt = [5.0] * 25
+ rows = build_pnl(rev, rev, 3.5, [0.0] * 25, [0.0] * 25, opex, depr, interest, ct, dt, 100.0, 50.0, 200.0, 1000.0, cfg)
+ assert len(rows) == 25
+
+
+def test_pnl_ebitda_formula() -> None:
+ cfg = OpexConfig(
+ om_solar_cr_per_mw=0.0,
+ om_wind_cr_per_mw=0.0,
+ om_bess_cr_per_mwh=0.0,
+ insurance_pct_of_capex=0.0,
+ land_lease_cr=0.0,
+ am_fee_pct_of_revenue=0.0,
+ misc_cr=0.0,
+ )
+ rev = [100.0] * 25
+ rows = build_pnl(rev, rev, 3.5, [0.0] * 25, [0.0] * 25, [0.0] * 25, [10.0] * 25, [20.0] * 25, [0.0] * 25, [0.0] * 25,
+ 0.0, 0.0, 0.0, 0.0, cfg)
+ assert rows[0].ebitda_cr == pytest.approx(100.0, abs=1e-4)
+ assert rows[0].ebit_cr == pytest.approx(90.0, abs=1e-4)
+ assert rows[0].pbt_cr == pytest.approx(70.0, abs=1e-4)
+
+
+# ---------------------------------------------------------------------------
+# CFS integration
+# ---------------------------------------------------------------------------
+
+
+def test_cfs_closing_cash_accumulates() -> None:
+ rows = build_cfs(
+ pnl_pat=[100.0] * 25,
+ pnl_depr=[40.0] * 25,
+ delta_wc_by_year=[5.0] + [0.0] * 24,
+ capex_cr=1000.0,
+ debt_drawdown_by_year=[0.0] * 25,
+ debt_repayment_by_year=[50.0] * 25,
+ equity_injection_by_year=[0.0] * 25,
+ opening_cash_cr=50.0,
+ )
+ assert len(rows) == 25
+ # CFO year 1 = PAT + depr - delta_wc = 100 + 40 - 5 = 135; CFF = -50; net = 85
+ assert rows[0].cfo_cr == pytest.approx(135.0, abs=1e-4)
+ assert rows[0].closing_cash_cr == pytest.approx(50.0 + 135.0 - 50.0, abs=1e-4)
+
+
+# ---------------------------------------------------------------------------
+# BS integration (with reconciliation)
+# ---------------------------------------------------------------------------
+
+
+def test_bs_reconciles() -> None:
+ """A consistent BS (assets = liab) should not raise."""
+ n = 25
+ gross = 1000.0
+ equity = 300.0
+ # Build simple scenario where retained earnings absorb the difference
+ net_block = [1000.0 - 40.0 * (y + 1) for y in range(n)]
+ net_block = [max(0.0, nb) for nb in net_block]
+ acc_depr = [min(40.0 * (y + 1), 1000.0) for y in range(n)]
+ cash = [100.0 + 90.0 * y for y in range(n)]
+ receivables = [12.0] * n
+ debt = [700.0 - 30.0 * y for y in range(n)]
+ debt = [max(0.0, d) for d in debt]
+ payables = [5.0] * n
+ dtl = [10.0 * (y + 1) * 0.2 for y in range(n)]
+
+ # retained_earnings = total_assets - equity - debt - payables - dtl
+ retained = [
+ net_block[y] + cash[y] + receivables[y] - equity - debt[y] - payables[y] - dtl[y]
+ for y in range(n)
+ ]
+ rows = build_bs(gross, acc_depr, net_block, cash, receivables,
+ equity, retained, debt, payables, dtl)
+ assert len(rows) == 25
+ for row in rows:
+ assert abs(row.total_assets_cr - row.total_liabilities_cr) < 0.05
+
+
+def test_bs_reconciliation_fails_on_mismatch() -> None:
+ """Deliberately mismatched BS should raise AssertionError."""
+ with pytest.raises(AssertionError, match="BS reconciliation fail"):
+ build_bs(
+ gross_block_cr=1000.0,
+ accumulated_depr_by_year=[40.0] * 25,
+ net_block_by_year=[960.0] * 25,
+ cash_by_year=[100.0] * 25,
+ receivables_by_year=[10.0] * 25,
+ equity_cr=300.0,
+ retained_earnings_by_year=[0.0] * 25, # wrong — will mismatch
+ debt_outstanding_by_year=[700.0] * 25,
+ payables_by_year=[5.0] * 25,
+ dtl_by_year=[0.0] * 25,
+ )
diff --git a/packages/engine/tests/unit/test_runner.py b/packages/engine/tests/unit/test_runner.py
new file mode 100644
index 0000000..d562e54
--- /dev/null
+++ b/packages/engine/tests/unit/test_runner.py
@@ -0,0 +1,162 @@
+"""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
diff --git a/packages/engine/tests/unit/test_sweep.py b/packages/engine/tests/unit/test_sweep.py
new file mode 100644
index 0000000..eb5d509
--- /dev/null
+++ b/packages/engine/tests/unit/test_sweep.py
@@ -0,0 +1,77 @@
+"""S8-T01: Sweep engine tests."""
+
+import pytest
+
+from remodel_engine.scenarios.sweep import (
+ SweepParam,
+ TornadoEntry,
+ run_sweep,
+ run_tornado,
+)
+from remodel_engine.schemas.scenario import ScenarioInput
+
+
+def _base_inputs() -> ScenarioInput:
+ return ScenarioInput()
+
+
+def test_empty_sweep_returns_base() -> None:
+ inp = _base_inputs()
+ results = run_sweep(inp, [])
+ assert len(results) == 1
+ assert results[0].status == "success"
+
+
+def test_single_axis_sweep() -> None:
+ inp = _base_inputs()
+ param = SweepParam("commercial.tariff_inr_per_kwh", [3.5, 4.0, 4.5])
+ results = run_sweep(inp, [param], max_workers=1)
+ assert len(results) == 3
+ tariffs = [r.param_values["commercial.tariff_inr_per_kwh"] for r in results]
+ assert sorted(tariffs) == [3.5, 4.0, 4.5]
+
+
+def test_cartesian_sweep_two_axes() -> None:
+ inp = _base_inputs()
+ params = [
+ SweepParam("commercial.tariff_inr_per_kwh", [3.5, 4.5]),
+ SweepParam("debt.interest_rate_annual", [0.09, 0.11]),
+ ]
+ results = run_sweep(inp, params, max_workers=2)
+ assert len(results) == 4 # 2 * 2
+ assert all(r.status == "success" for r in results)
+
+
+def test_sweep_result_has_kpis() -> None:
+ inp = _base_inputs()
+ param = SweepParam("commercial.tariff_inr_per_kwh", [4.0])
+ results = run_sweep(inp, [param], max_workers=1)
+ assert results[0].kpis is not None
+
+
+def test_sweep_invalid_path_marks_failed() -> None:
+ inp = _base_inputs()
+ param = SweepParam("nonexistent.field", [1.0])
+ results = run_sweep(inp, [param], max_workers=1)
+ assert results[0].status == "failed"
+ assert results[0].error is not None
+
+
+def test_tornado_returns_sorted_entries() -> None:
+ inp = _base_inputs()
+ entries = run_tornado(inp, kpi_key="lcoe_inr_per_kwh", max_workers=2)
+ assert len(entries) > 0
+ swings = [e.swing for e in entries]
+ assert swings == sorted(swings, reverse=True)
+
+
+def test_tornado_entry_swing_correct() -> None:
+ entry = TornadoEntry(
+ param_name="Test",
+ low_value=0.09,
+ high_value=0.12,
+ base_kpi=0.18,
+ low_kpi=0.20,
+ high_kpi=0.15,
+ )
+ assert entry.swing == pytest.approx(0.05, abs=1e-6)
diff --git a/packages/web/app/compare/page.tsx b/packages/web/app/compare/page.tsx
new file mode 100644
index 0000000..3b2f689
--- /dev/null
+++ b/packages/web/app/compare/page.tsx
@@ -0,0 +1,79 @@
+"use client";
+
+import { Suspense } from "react";
+import { useSearchParams, useRouter } from "next/navigation";
+import { useQuery } from "@tanstack/react-query";
+import { listScenarios } from "@/lib/api";
+import { ScenarioCompare } from "@/components/ScenarioCompare";
+import { Button } from "@/components/ui/button";
+
+function CompareContent() {
+ const params = useSearchParams();
+ const router = useRouter();
+ const ids = params.getAll("id");
+
+ const { data: scenarios } = useQuery({
+ queryKey: ["scenarios"],
+ queryFn: listScenarios,
+ });
+
+ const successScenarios = (scenarios ?? []).filter((s) => s.status === "success");
+
+ const nameMap = Object.fromEntries(
+ (scenarios ?? []).map((s) => [s.id, s.name])
+ );
+
+ function toggleId(id: string) {
+ const next = ids.includes(id) ? ids.filter((x) => x !== id) : [...ids, id];
+ const qs = next.map((x) => `id=${x}`).join("&");
+ router.push(qs ? `/compare?${qs}` : "/compare");
+ }
+
+ return (
+
+ Select 2–4 completed scenarios to compare side-by-side.
+ Compare Scenarios
+