[S1-T12/T13] P&L revenue breakdown + collapsible rows + UI polish

- Engine: Add ppa_revenue_cr, mcp_revenue_cr, tariff, units to PnLRow
- Engine: Split PPA vs MCP revenue in P&L computation
- Web: Collapsible rows for PPA/MCP Revenue and Opex
- Web: Highlighted rows (Total Revenue, EBITDA, EBIT, PBT, PAT)
- Web: Units above Tariff in breakdown, bg-blue-50 highlight
- Fix sticky column z-index for horizontal scroll
- CLAUDE.md: Add project documentation

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Manohar Gupta 2026-05-13 10:42:36 +05:30
parent 314127effc
commit e6dc39aa33
82 changed files with 10048 additions and 250 deletions

17
.claude/settings.json Normal file
View file

@ -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 *)"
]
}
}

80
CLAUDE.md Normal file
View file

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

534
CODEBASE_INVESTIGATION.md Normal file
View file

@ -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.*

View file

@ -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]

View file

@ -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
)

View file

@ -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"])

View file

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

View file

@ -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()}

View file

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

View file

@ -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)

View file

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

View file

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

View file

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

3
packages/api/uv.lock generated Normal file
View file

@ -0,0 +1,3 @@
version = 1
revision = 3
requires-python = ">=3.14"

View file

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

View file

@ -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)

View file

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

View file

@ -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)

View file

@ -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
)

View file

@ -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()),
}

View file

@ -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()

View file

@ -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
]

View file

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

View file

@ -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)

View file

@ -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,
)

View file

@ -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)
]

View file

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

View file

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

View file

@ -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,
)

View file

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

View file

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

View file

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

View file

@ -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)

View file

@ -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,
)

View file

@ -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,
)

View file

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

View file

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

View file

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

View file

@ -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]

View file

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

View file

@ -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)

View file

@ -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)

View file

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

View file

@ -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:

View file

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

View file

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

View file

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

View file

@ -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,
)

View file

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

View file

@ -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)

View file

@ -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 (
<main className="flex-1 container mx-auto px-4 py-8 max-w-5xl">
<div className="mb-6 flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={() => router.push("/")}>
Back
</Button>
<h1 className="text-xl font-bold">Compare Scenarios</h1>
</div>
<div className="mb-6">
<p className="text-sm text-muted-foreground mb-3">
Select 24 completed scenarios to compare side-by-side.
</p>
<div className="flex flex-wrap gap-2">
{successScenarios.map((s) => (
<button
key={s.id}
onClick={() => toggleId(s.id)}
className={`px-3 py-1.5 rounded-full text-sm border transition-colors ${
ids.includes(s.id)
? "bg-primary text-primary-foreground border-primary"
: "border-muted-foreground/40 text-muted-foreground hover:border-foreground"
}`}
>
{s.name}
</button>
))}
</div>
</div>
{ids.length >= 2 ? (
<ScenarioCompare scenarioIds={ids} scenarioNames={nameMap} />
) : (
<div className="text-center text-muted-foreground py-16 border rounded-lg text-sm">
Select at least 2 scenarios above to see the comparison table.
</div>
)}
</main>
);
}
export default function ComparePage() {
return (
<Suspense fallback={<div className="flex-1 flex items-center justify-center text-muted-foreground">Loading</div>}>
<CompareContent />
</Suspense>
);
}

View file

@ -50,71 +50,71 @@
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--foreground: oklch(0.15 0.01 262);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--card-foreground: oklch(0.15 0.01 262);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--popover-foreground: oklch(0.15 0.01 262);
--primary: oklch(0.47 0.22 262);
--primary-foreground: oklch(0.99 0 0);
--secondary: oklch(0.96 0.01 262);
--secondary-foreground: oklch(0.25 0.05 262);
--muted: oklch(0.96 0.005 262);
--muted-foreground: oklch(0.52 0.04 262);
--accent: oklch(0.94 0.015 262);
--accent-foreground: oklch(0.30 0.10 262);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--border: oklch(0.90 0.01 262);
--input: oklch(0.90 0.01 262);
--ring: oklch(0.47 0.22 262);
--chart-1: oklch(0.52 0.22 262);
--chart-2: oklch(0.60 0.17 178);
--chart-3: oklch(0.70 0.18 55);
--chart-4: oklch(0.60 0.22 15);
--chart-5: oklch(0.55 0.18 308);
--radius: 0.5rem;
--sidebar: oklch(0.975 0.006 262);
--sidebar-foreground: oklch(0.20 0.04 262);
--sidebar-primary: oklch(0.47 0.22 262);
--sidebar-primary-foreground: oklch(0.99 0 0);
--sidebar-accent: oklch(0.93 0.02 262);
--sidebar-accent-foreground: oklch(0.30 0.10 262);
--sidebar-border: oklch(0.88 0.015 262);
--sidebar-ring: oklch(0.47 0.22 262);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--background: oklch(0.13 0.01 262);
--foreground: oklch(0.96 0.005 262);
--card: oklch(0.18 0.015 262);
--card-foreground: oklch(0.96 0.005 262);
--popover: oklch(0.18 0.015 262);
--popover-foreground: oklch(0.96 0.005 262);
--primary: oklch(0.68 0.20 262);
--primary-foreground: oklch(0.12 0.02 262);
--secondary: oklch(0.24 0.02 262);
--secondary-foreground: oklch(0.96 0.005 262);
--muted: oklch(0.24 0.02 262);
--muted-foreground: oklch(0.65 0.05 262);
--accent: oklch(0.28 0.03 262);
--accent-foreground: oklch(0.96 0.005 262);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--input: oklch(1 0 0 / 12%);
--ring: oklch(0.68 0.20 262);
--chart-1: oklch(0.65 0.20 262);
--chart-2: oklch(0.68 0.15 178);
--chart-3: oklch(0.75 0.17 55);
--chart-4: oklch(0.65 0.20 15);
--chart-5: oklch(0.65 0.17 308);
--sidebar: oklch(0.16 0.015 262);
--sidebar-foreground: oklch(0.90 0.01 262);
--sidebar-primary: oklch(0.68 0.20 262);
--sidebar-primary-foreground: oklch(0.12 0.02 262);
--sidebar-accent: oklch(0.24 0.025 262);
--sidebar-accent-foreground: oklch(0.90 0.01 262);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--sidebar-ring: oklch(0.68 0.20 262);
}
@layer base {

View file

@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Providers } from "./providers";
import AgentationWrapper from "@/components/AgentationWrapper";
const geistSans = Geist({
variable: "--font-geist-sans",
@ -30,6 +31,7 @@ export default function RootLayout({
>
<body className="min-h-full flex flex-col bg-background text-foreground">
<Providers>{children}</Providers>
<AgentationWrapper />
</body>
</html>
);

View file

@ -3,42 +3,92 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { createScenario, listScenarios, type Scenario } from "@/lib/api";
import {
createScenario,
listScenarios,
archiveScenario,
type Scenario,
type ScenarioInputPayload,
} from "@/lib/api";
import { Button } from "@/components/ui/button";
import { ScenarioWizard } from "@/components/ScenarioWizard";
function ScenarioRow({ scenario }: { scenario: Scenario }) {
function StatusBadge({ status }: { status: string }) {
const styles: Record<string, string> = {
success: "bg-green-100 text-green-700",
failed: "bg-red-100 text-red-700",
running: "bg-blue-100 text-blue-700",
queued: "bg-yellow-100 text-yellow-700",
};
return (
<span
className={`px-2 py-0.5 rounded-full text-xs font-medium capitalize ${styles[status] ?? "bg-muted text-muted-foreground"}`}
>
{status}
</span>
);
}
function ScenarioRow({
scenario,
onArchive,
}: {
scenario: Scenario;
onArchive: (id: string) => void;
}) {
const router = useRouter();
const statusColor =
scenario.status === "success"
? "text-green-600"
: scenario.status === "failed"
? "text-red-600"
: scenario.status === "running"
? "text-blue-600"
: "text-yellow-600";
const kpis = scenario.kpis_json
? (() => {
try {
return JSON.parse(scenario.kpis_json) as Record<string, number | null>;
} catch {
return null;
}
})()
: null;
const tariff = kpis?.solved_tariff_inr_per_kwh;
const irr = kpis?.equity_irr;
return (
<tr
className="border-b hover:bg-muted/50 cursor-pointer"
className="border-b hover:bg-muted/30 cursor-pointer text-sm"
onClick={() => router.push(`/scenarios/${scenario.id}`)}
>
<td className="py-3 px-4 font-mono text-xs text-muted-foreground">
{scenario.id.slice(0, 8)}&hellip;
<td className="py-3 px-4 font-medium">{scenario.name}</td>
<td className="py-3 px-4">
<StatusBadge status={scenario.status} />
</td>
<td className="py-3 px-4">{scenario.name}</td>
<td className={`py-3 px-4 font-medium capitalize ${statusColor}`}>
{scenario.status}
<td className="py-3 px-4 tabular-nums">
{tariff != null ? `${tariff.toFixed(2)}/kWh` : "—"}
</td>
<td className="py-3 px-4 text-muted-foreground text-sm">
<td className="py-3 px-4 tabular-nums">
{irr != null ? `${(irr * 100).toFixed(1)}%` : "—"}
</td>
<td className="py-3 px-4 text-muted-foreground">
{new Date(scenario.created_at).toLocaleString()}
</td>
<td
className="py-3 px-4 text-right"
onClick={(e) => e.stopPropagation()}
>
<Button
variant="ghost"
size="sm"
onClick={() => onArchive(scenario.id)}
className="text-muted-foreground hover:text-red-600"
>
Archive
</Button>
</td>
</tr>
);
}
export default function HomePage() {
const router = useRouter();
const [creating, setCreating] = useState(false);
const [showWizard, setShowWizard] = useState(false);
const { data: scenarios, refetch } = useQuery({
queryKey: ["scenarios"],
@ -46,17 +96,31 @@ export default function HomePage() {
refetchInterval: 5000,
});
async function handleNewScenario() {
setCreating(true);
try {
const scenario = await createScenario(
`Scenario ${new Date().toLocaleTimeString()}`,
);
await refetch();
router.push(`/scenarios/${scenario.id}`);
} finally {
setCreating(false);
}
async function handleWizardSubmit(
name: string,
inputs: ScenarioInputPayload,
) {
const scenario = await createScenario(name, inputs);
await refetch();
router.push(`/scenarios/${scenario.id}`);
}
async function handleArchive(id: string) {
await archiveScenario(id);
await refetch();
}
if (showWizard) {
return (
<main className="flex-1 container mx-auto px-4 py-8 max-w-2xl">
<div className="border rounded-xl p-8 shadow-sm">
<ScenarioWizard
onSubmit={handleWizardSubmit}
onCancel={() => setShowWizard(false)}
/>
</div>
</main>
);
}
return (
@ -65,41 +129,49 @@ export default function HomePage() {
<div>
<h1 className="text-2xl font-bold tracking-tight">REmodel</h1>
<p className="text-muted-foreground mt-1">
Hybrid RE project finance scenarios
Hybrid RE project finance Solar + Wind + BESS
</p>
</div>
<Button onClick={handleNewScenario} disabled={creating}>
{creating ? "Creating…" : "New Dummy Scenario"}
</Button>
<div className="flex gap-2">
<Button variant="outline" onClick={() => router.push("/compare")}>Compare</Button>
<Button onClick={() => setShowWizard(true)}>+ New Scenario</Button>
</div>
</div>
{!scenarios || scenarios.length === 0 ? (
<div className="text-center text-muted-foreground py-24 border rounded-lg">
No scenarios yet &mdash; click &ldquo;New Dummy Scenario&rdquo; to
start.
No scenarios yet click &ldquo;New Scenario&rdquo; to start.
</div>
) : (
<div className="border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<table className="w-full">
<thead className="bg-muted/50">
<tr>
<th className="py-2 px-4 text-left font-medium text-muted-foreground">
ID
</th>
<th className="py-2 px-4 text-left font-medium text-muted-foreground">
<th className="py-2 px-4 text-left text-xs font-medium text-muted-foreground uppercase">
Name
</th>
<th className="py-2 px-4 text-left font-medium text-muted-foreground">
<th className="py-2 px-4 text-left text-xs font-medium text-muted-foreground uppercase">
Status
</th>
<th className="py-2 px-4 text-left font-medium text-muted-foreground">
<th className="py-2 px-4 text-left text-xs font-medium text-muted-foreground uppercase">
Tariff
</th>
<th className="py-2 px-4 text-left text-xs font-medium text-muted-foreground uppercase">
Equity IRR
</th>
<th className="py-2 px-4 text-left text-xs font-medium text-muted-foreground uppercase">
Created
</th>
<th />
</tr>
</thead>
<tbody>
{scenarios.map((s) => (
<ScenarioRow key={s.id} scenario={s} />
<ScenarioRow
key={s.id}
scenario={s}
onArchive={handleArchive}
/>
))}
</tbody>
</table>

View file

@ -2,107 +2,305 @@
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { getScenario, scenarioEventsUrl, type ProgressEvent } from "@/lib/api";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
getScenario,
getKpis,
scenarioEventsUrl,
scenarioExcelUrl,
type ProgressEvent,
} from "@/lib/api";
import { Button } from "@/components/ui/button";
import { InputsTab } from "@/components/InputsTab";
import { WorkbookView } from "@/components/WorkbookView";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type ActiveSheet =
| "inputs"
| "summary"
| "pnl"
| "cfs"
| "bs"
| "debt"
| "irr"
| "generation"
| "idc"
| "opex";
const RESULT_SHEETS: { id: ActiveSheet; label: string }[] = [
{ id: "summary", label: "Summary" },
{ id: "pnl", label: "P&L" },
{ id: "cfs", label: "Cash Flow" },
{ id: "bs", label: "Bal. Sheet" },
{ id: "debt", label: "Debt" },
{ id: "irr", label: "IRR / Returns" },
{ id: "generation", label: "Generation" },
{ id: "idc", label: "IDC / Phasing" },
{ id: "opex", label: "O&M" },
];
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function ProgressBar({ pct }: { pct: number }) {
return (
<div className="w-full bg-muted rounded-full h-3 overflow-hidden">
<div className="h-0.5 bg-muted overflow-hidden">
<div
className="bg-primary h-3 rounded-full transition-all duration-500"
className="bg-primary h-0.5 transition-all duration-500"
style={{ width: `${pct}%` }}
/>
</div>
);
}
function StatusBadge({ status }: { status: string }) {
const styles: Record<string, string> = {
success: "text-emerald-700 bg-emerald-50 border-emerald-200",
failed: "text-red-600 bg-red-50 border-red-200",
running: "text-primary bg-primary/10 border-primary/30",
queued: "text-amber-700 bg-amber-50 border-amber-200",
};
return (
<span
className={`px-2 py-0.5 rounded-full text-xs font-medium capitalize border ${styles[status] ?? "text-muted-foreground bg-muted border-border"}`}
>
{status}
</span>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export default function ScenarioPage() {
const params = useParams<{ id: string }>();
const router = useRouter();
const queryClient = useQueryClient();
const id = params.id;
const [activeSheet, setActiveSheet] = useState<ActiveSheet>("inputs");
const [progress, setProgress] = useState<ProgressEvent | null>(null);
const [done, setDone] = useState(false);
const [sseOpen, setSseOpen] = useState(true);
const { data: scenario, refetch } = useQuery({
const { data: scenario, refetch: refetchScenario } = useQuery({
queryKey: ["scenario", id],
queryFn: () => getScenario(id),
refetchInterval: done ? false : 3000,
refetchInterval: sseOpen ? false : 3000,
});
const { data: kpis, refetch: refetchKpis } = useQuery({
queryKey: ["kpis", id],
queryFn: () => getKpis(id),
enabled: scenario?.status === "success",
});
useEffect(() => {
if (scenario?.status === "success" && activeSheet === "inputs" && progress !== null) {
setActiveSheet("summary");
}
}, [scenario?.status]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!id) return;
setSseOpen(true);
const es = new EventSource(scenarioEventsUrl(id));
es.onmessage = (event: MessageEvent<string>) => {
const data = JSON.parse(event.data) as ProgressEvent;
setProgress(data);
if (data.stage === "done") {
setDone(true);
if (data.stage === "done" || data.stage === "error") {
setSseOpen(false);
es.close();
void refetch();
void refetchScenario();
void refetchKpis();
void queryClient.invalidateQueries({ queryKey: ["statements", id] });
}
};
es.onerror = () => es.close();
es.onerror = () => {
setSseOpen(false);
es.close();
};
return () => es.close();
}, [id, refetch]);
}, [id, refetchScenario, refetchKpis, queryClient]);
const statusColor =
scenario?.status === "success"
? "text-green-600"
: scenario?.status === "failed"
? "text-red-600"
: scenario?.status === "running"
? "text-blue-600"
: "text-yellow-600";
const isRunning = scenario?.status === "queued" || scenario?.status === "running";
const hasResults = scenario?.status === "success" && kpis != null;
const kpis = scenario?.kpis_json
? (JSON.parse(scenario.kpis_json) as Record<string, unknown>)
: null;
function handleInputsSaved() {
setProgress(null);
void refetchScenario();
setActiveSheet("summary");
}
function nav(sheet: ActiveSheet) {
if (sheet !== "inputs" && !hasResults) return;
setActiveSheet(sheet);
}
return (
<main className="flex-1 container mx-auto px-4 py-8 max-w-3xl">
<div className="mb-6">
<Button variant="ghost" size="sm" onClick={() => router.push("/")}>
&larr; Back
</Button>
<div className="flex flex-col h-screen bg-background">
{/* ── Top header ─────────────────────────────────────────── */}
<header className="flex items-center gap-3 px-4 py-2.5 border-b bg-card shrink-0">
<button
onClick={() => router.push("/")}
className="text-muted-foreground hover:text-foreground text-sm transition-colors"
>
Back
</button>
<div className="w-px h-4 bg-border" />
<h1 className="font-semibold text-sm truncate flex-1">
{scenario?.name ?? "Loading…"}
</h1>
{scenario && <StatusBadge status={scenario.status} />}
{isRunning && progress && (
<span className="text-xs text-muted-foreground">
{progress.stage} · {progress.pct}%
</span>
)}
{scenario?.runtime_s != null && (
<span className="text-xs text-muted-foreground">
{scenario.runtime_s.toFixed(1)}s
</span>
)}
{hasResults && (
<a
href={scenarioExcelUrl(id)}
download
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs rounded border border-border text-muted-foreground hover:text-foreground hover:border-foreground/40 transition-colors"
>
Excel
</a>
)}
</header>
{/* ── Progress bar ───────────────────────────────────────── */}
{isRunning && <ProgressBar pct={progress?.pct ?? 0} />}
{/* ── Two-column layout ──────────────────────────────────── */}
<div className="flex flex-1 overflow-hidden">
{/* Sidebar */}
<nav className="w-44 shrink-0 border-r bg-sidebar py-3 overflow-y-auto flex flex-col gap-0.5">
<SidebarItem
label="Inputs"
active={activeSheet === "inputs"}
onClick={() => nav("inputs")}
/>
<div className="mx-3 my-2 border-t border-sidebar-border" />
<p className="px-3 pb-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/60">
Results
</p>
{RESULT_SHEETS.map((s) => (
<SidebarItem
key={s.id}
label={s.label}
active={activeSheet === s.id}
disabled={!hasResults}
onClick={() => nav(s.id)}
/>
))}
</nav>
{/* Content area */}
<div className="flex-1 overflow-y-auto">
{activeSheet === "inputs" ? (
<div className="p-6 max-w-3xl">
{scenario?.inputs_json != null ? (
<InputsTab
key={scenario.id}
scenarioId={id}
inputsJson={scenario.inputs_json}
onSaved={handleInputsSaved}
/>
) : (
<div className="text-sm text-muted-foreground py-8 text-center">
Loading inputs
</div>
)}
</div>
) : (
<div className="p-6">
{hasResults ? (
<WorkbookView
scenarioId={id}
kpis={kpis}
debtScheduleJson={scenario?.debt_schedule_json ?? null}
activeSheet={activeSheet}
/>
) : scenario?.status === "failed" ? (
<div className="border border-red-200 rounded-lg p-6 text-sm max-w-lg">
<p className="font-semibold text-red-600 mb-1">Scenario failed</p>
<p className="text-muted-foreground">
{scenario.error_message ?? "Check worker logs for details."}
</p>
<Button
variant="outline"
size="sm"
className="mt-4"
onClick={() => setActiveSheet("inputs")}
>
Edit Inputs
</Button>
</div>
) : (
<div className="flex flex-col items-center justify-center py-24 text-muted-foreground text-sm gap-3">
{isRunning ? (
<>
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
<span>
Running {progress?.stage} ({progress?.pct ?? 0}%)
</span>
</>
) : (
<>
<span>No results yet.</span>
<Button
variant="outline"
size="sm"
onClick={() => setActiveSheet("inputs")}
>
Edit Inputs & Run
</Button>
</>
)}
</div>
)}
</div>
)}
</div>
</div>
<h1 className="text-xl font-bold mb-1">
{scenario?.name ?? "Loading…"}
</h1>
<p className={`text-sm font-medium capitalize mb-6 ${statusColor}`}>
{scenario?.status ?? "—"}
</p>
{(scenario?.status === "queued" || scenario?.status === "running") && (
<div className="mb-6">
<div className="flex justify-between text-sm text-muted-foreground mb-2">
<span>{progress?.stage ?? "waiting…"}</span>
<span>{progress?.pct ?? 0}%</span>
</div>
<ProgressBar pct={progress?.pct ?? 0} />
</div>
)}
{scenario?.status === "success" && kpis && (
<div className="border rounded-lg p-6">
<h2 className="font-semibold mb-4">Result</h2>
<pre className="text-sm text-muted-foreground bg-muted/50 p-4 rounded overflow-auto">
{JSON.stringify(kpis, null, 2)}
</pre>
</div>
)}
{scenario?.status === "failed" && (
<div className="border border-red-200 rounded-lg p-6 text-red-600">
Scenario failed. Check worker logs.
</div>
)}
</main>
</div>
);
}
function SidebarItem({
label,
active,
disabled,
onClick,
}: {
label: string;
active: boolean;
disabled?: boolean;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
disabled={disabled}
className={`w-full text-left px-3 py-1.5 text-sm rounded-md mx-1 transition-colors ${
active
? "bg-primary text-primary-foreground font-medium"
: disabled
? "text-muted-foreground/40 cursor-not-allowed"
: "text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
}`}
style={{ width: "calc(100% - 8px)" }}
>
{label}
</button>
);
}

View file

@ -0,0 +1,13 @@
"use client";
import dynamic from "next/dynamic";
import { useEffect } from "react";
const Agentation = dynamic(() => import("agentation").then((m) => m.PageFeedbackToolbarCSS), {
ssr: false,
loading: () => null,
});
export default function AgentationWrapper() {
return <Agentation />;
}

View file

@ -0,0 +1,32 @@
"use client";
import { AgGridReact } from "ag-grid-react";
import type { ColDef, GridOptions } from "ag-grid-community";
import "ag-grid-community/styles/ag-grid.css";
import "ag-grid-community/styles/ag-theme-alpine.css";
interface DataGridProps<T extends object> {
rows: T[];
columns: ColDef<T>[];
height?: number;
onCellValueChanged?: GridOptions<T>["onCellValueChanged"];
}
export function DataGrid<T extends object>({
rows,
columns,
height = 400,
onCellValueChanged,
}: DataGridProps<T>) {
return (
<div className="ag-theme-alpine" style={{ height, width: "100%" }}>
<AgGridReact<T>
rowData={rows}
columnDefs={columns}
onCellValueChanged={onCellValueChanged}
defaultColDef={{ resizable: true, sortable: true, flex: 1 }}
suppressMovableColumns
/>
</div>
);
}

View file

@ -0,0 +1,42 @@
"use client";
import { useEffect } from "react";
declare global {
interface Window {
AgentaBug?: boolean;
}
}
export default function FeedbackButton() {
useEffect(() => {
if (typeof window === "undefined") return;
if (window.AgentaBug || document.getElementById("feedback-btn-fixed")) return;
const btn = document.createElement("button");
btn.id = "feedback-btn-fixed";
btn.innerHTML = "💬";
btn.title = "Click to leave feedback";
btn.style.cssText =
"position: fixed; bottom: 20px; right: 20px; width: 48px; height: 48px; border-radius: 50%; background: #2563eb; color: white; border: none; cursor: pointer; font-size: 22px; z-index: 99999; box-shadow: 0 4px 12px rgba(0,0,0,0.2);";
btn.onmouseenter = () => {
btn.style.transform = "scale(1.1)";
};
btn.onmouseleave = () => {
btn.style.transform = "scale(1)";
};
btn.onclick = () => {
const note = prompt("What would you like to change or improve?");
if (note) {
alert("Thank you! Your feedback: " + note + "\n\n(This is a placeholder - proper feedback tool coming soon)");
}
};
document.body.appendChild(btn);
window.AgentaBug = true;
}, []);
return null;
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,26 @@
interface KpiCardProps {
label: string;
value: string | null;
unit?: string;
highlight?: boolean;
}
export function KpiCard({ label, value, unit, highlight }: KpiCardProps) {
return (
<div
className={`border rounded-lg p-4 flex flex-col gap-1 ${highlight ? "border-primary/40 bg-primary/5" : ""}`}
>
<span className="text-xs text-muted-foreground font-medium uppercase tracking-wide">
{label}
</span>
<span className="text-2xl font-bold tabular-nums">
{value ?? <span className="text-muted-foreground text-base"></span>}
{value && unit && (
<span className="text-sm font-normal text-muted-foreground ml-1">
{unit}
</span>
)}
</span>
</div>
);
}

View file

@ -0,0 +1,119 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { getKpis, type KpiSummary } from "@/lib/api";
interface Props {
scenarioIds: string[];
scenarioNames: Record<string, string>;
}
const KPI_LABELS: { key: keyof KpiSummary; label: string; format: "pct" | "num" | "inr" }[] = [
{ key: "solved_tariff_inr_per_kwh", label: "Tariff (₹/kWh)", format: "inr" },
{ key: "equity_irr", label: "Equity IRR", format: "pct" },
{ key: "project_irr", label: "Project IRR", format: "pct" },
{ key: "min_dscr", label: "Min DSCR", format: "num" },
{ key: "avg_dscr", label: "Avg DSCR", format: "num" },
{ key: "total_capex_cr", label: "Total Capex (Cr)", format: "num" },
{ key: "lcoe_inr_per_kwh", label: "LCOE (₹/kWh)", format: "inr" },
{ key: "payback_years", label: "Payback (yrs)", format: "num" },
{ key: "solar_y1_cuf", label: "Solar Y1 CUF", format: "pct" },
{ key: "wind_y1_plf", label: "Wind Y1 PLF", format: "pct" },
{ key: "rtc_cuf_achieved", label: "RTC CUF", format: "pct" },
{ key: "total_shortfall_mwh", label: "Shortfall (MWh)", format: "num" },
{ key: "total_mcp_revenue_cr", label: "MCP Revenue (Cr)", format: "num" },
];
function fmtCell(v: number | null | undefined, format: "pct" | "num" | "inr"): string {
if (v == null) return "—";
if (format === "pct") return `${(v * 100).toFixed(1)}%`;
if (format === "inr") return `${v.toFixed(2)}`;
return v.toFixed(2);
}
function KpiRow({
rowKey,
label,
format,
kpiMap,
ids,
}: {
rowKey: keyof KpiSummary;
label: string;
format: "pct" | "num" | "inr";
kpiMap: Record<string, KpiSummary>;
ids: string[];
}) {
const values = ids.map((id) => kpiMap[id]?.[rowKey] as number | null | undefined);
const nums = values.filter((v): v is number => v != null);
const best = nums.length > 0 ? Math.max(...nums) : null;
return (
<tr className="border-t">
<td className="px-3 py-1.5 text-sm font-medium text-muted-foreground">{label}</td>
{ids.map((id, i) => {
const v = values[i];
const isBest = v != null && v === best && nums.length > 1;
return (
<td
key={id}
className={`px-3 py-1.5 text-sm tabular-nums text-right ${isBest ? "font-semibold text-green-700" : ""}`}
>
{fmtCell(v, format)}
</td>
);
})}
</tr>
);
}
export function ScenarioCompare({ scenarioIds, scenarioNames }: Props) {
const queries = scenarioIds.map((id) =>
// eslint-disable-next-line react-hooks/rules-of-hooks
useQuery({
queryKey: ["kpis", id],
queryFn: () => getKpis(id),
})
);
const kpiMap: Record<string, KpiSummary> = {};
for (let i = 0; i < scenarioIds.length; i++) {
const data = queries[i].data;
if (data) kpiMap[scenarioIds[i]] = data;
}
const isLoading = queries.some((q) => q.isLoading);
if (isLoading) {
return <div className="text-muted-foreground text-sm py-4">Loading comparison</div>;
}
return (
<div className="overflow-x-auto border rounded">
<table className="w-full text-xs">
<thead className="bg-muted/50">
<tr>
<th className="px-3 py-2 text-left font-medium">KPI</th>
{scenarioIds.map((id) => (
<th key={id} className="px-3 py-2 text-right font-medium">
{scenarioNames[id] ?? id.slice(0, 8)}
</th>
))}
</tr>
</thead>
<tbody>
{KPI_LABELS.map(({ key, label, format }) => (
<KpiRow
key={key}
rowKey={key}
label={label}
format={format}
kpiMap={kpiMap}
ids={scenarioIds}
/>
))}
</tbody>
</table>
</div>
);
}

View file

@ -0,0 +1,531 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import type { ScenarioInputPayload } from "@/lib/api";
const LOCATION_OPTIONS = [
{ value: "RJ", label: "Rajasthan (High Solar)" },
{ value: "GJ", label: "Gujarat (High Solar)" },
{ value: "AP", label: "Andhra Pradesh" },
{ value: "TN", label: "Tamil Nadu" },
{ value: "MP", label: "Madhya Pradesh" },
{ value: "KA", label: "Karnataka" },
];
const STEP_LABELS = [
"Project Info",
"Solar",
"Wind",
"BESS",
"Solver",
"Review",
];
interface WizardState {
name: string;
cod_date: string;
// Solar
solar_enabled: boolean;
solar_location: string;
solar_dc_mwp: number;
solar_ac_mw: number;
// Wind
wind_enabled: boolean;
wind_location: string;
wind_mw: number;
// BESS
bess_enabled: boolean;
bess_mwh: number;
bess_mw: number;
// Solver
solver_mode: "solve_tariff" | "fixed_tariff";
target_irr: number;
fixed_tariff: number;
}
const DEFAULT_STATE: WizardState = {
name: "",
cod_date: "2027-04-01",
solar_enabled: true,
solar_location: "RJ",
solar_dc_mwp: 100,
solar_ac_mw: 80,
wind_enabled: false,
wind_location: "RJ",
wind_mw: 50,
bess_enabled: false,
bess_mwh: 200,
bess_mw: 50,
solver_mode: "solve_tariff",
target_irr: 0.18,
fixed_tariff: 3.5,
};
function Field({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-foreground">{label}</label>
{children}
</div>
);
}
function Input({
value,
onChange,
type = "text",
step,
min,
}: {
value: string | number;
onChange: (v: string) => void;
type?: string;
step?: number;
min?: number;
}) {
return (
<input
type={type}
step={step}
min={min}
value={value}
onChange={(e) => onChange(e.target.value)}
className="border rounded px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary bg-background"
/>
);
}
function Select({
value,
onChange,
options,
}: {
value: string;
onChange: (v: string) => void;
options: { value: string; label: string }[];
}) {
return (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="border rounded px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary bg-background"
>
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
);
}
function Toggle({
label,
checked,
onChange,
}: {
label: string;
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
className="w-4 h-4 accent-primary"
/>
<span className="text-sm font-medium">{label}</span>
</label>
);
}
// ---------------------------------------------------------------------------
// Step components
// ---------------------------------------------------------------------------
function StepProjectInfo({
state,
set,
}: {
state: WizardState;
set: (k: keyof WizardState, v: WizardState[keyof WizardState]) => void;
}) {
return (
<div className="flex flex-col gap-4">
<h2 className="text-lg font-semibold">Project Information</h2>
<Field label="Scenario name *">
<Input
value={state.name}
onChange={(v) => set("name", v)}
/>
</Field>
<Field label="Commercial Operation Date (COD)">
<Input
type="date"
value={state.cod_date}
onChange={(v) => set("cod_date", v)}
/>
</Field>
</div>
);
}
function StepSolar({
state,
set,
}: {
state: WizardState;
set: (k: keyof WizardState, v: WizardState[keyof WizardState]) => void;
}) {
return (
<div className="flex flex-col gap-4">
<h2 className="text-lg font-semibold">Solar Generation</h2>
<Toggle
label="Include Solar"
checked={state.solar_enabled}
onChange={(v) => set("solar_enabled", v)}
/>
{state.solar_enabled && (
<>
<Field label="Location">
<Select
value={state.solar_location}
onChange={(v) => set("solar_location", v)}
options={LOCATION_OPTIONS}
/>
</Field>
<Field label="DC Capacity (MWp)">
<Input
type="number"
value={state.solar_dc_mwp}
step={5}
min={1}
onChange={(v) => set("solar_dc_mwp", Number(v))}
/>
</Field>
<Field label="AC Capacity (MW)">
<Input
type="number"
value={state.solar_ac_mw}
step={5}
min={1}
onChange={(v) => set("solar_ac_mw", Number(v))}
/>
</Field>
</>
)}
</div>
);
}
function StepWind({
state,
set,
}: {
state: WizardState;
set: (k: keyof WizardState, v: WizardState[keyof WizardState]) => void;
}) {
return (
<div className="flex flex-col gap-4">
<h2 className="text-lg font-semibold">Wind Generation</h2>
<Toggle
label="Include Wind"
checked={state.wind_enabled}
onChange={(v) => set("wind_enabled", v)}
/>
{state.wind_enabled && (
<>
<Field label="Location">
<Select
value={state.wind_location}
onChange={(v) => set("wind_location", v)}
options={LOCATION_OPTIONS}
/>
</Field>
<Field label="Capacity (MW)">
<Input
type="number"
value={state.wind_mw}
step={5}
min={1}
onChange={(v) => set("wind_mw", Number(v))}
/>
</Field>
</>
)}
</div>
);
}
function StepBess({
state,
set,
}: {
state: WizardState;
set: (k: keyof WizardState, v: WizardState[keyof WizardState]) => void;
}) {
return (
<div className="flex flex-col gap-4">
<h2 className="text-lg font-semibold">BESS (Battery Storage)</h2>
<Toggle
label="Include BESS"
checked={state.bess_enabled}
onChange={(v) => set("bess_enabled", v)}
/>
{state.bess_enabled && (
<>
<Field label="Energy Capacity (MWh)">
<Input
type="number"
value={state.bess_mwh}
step={10}
min={10}
onChange={(v) => set("bess_mwh", Number(v))}
/>
</Field>
<Field label="Power Capacity (MW)">
<Input
type="number"
value={state.bess_mw}
step={5}
min={5}
onChange={(v) => set("bess_mw", Number(v))}
/>
</Field>
</>
)}
</div>
);
}
function StepSolver({
state,
set,
}: {
state: WizardState;
set: (k: keyof WizardState, v: WizardState[keyof WizardState]) => void;
}) {
return (
<div className="flex flex-col gap-4">
<h2 className="text-lg font-semibold">Solver & Tariff</h2>
<Field label="Mode">
<Select
value={state.solver_mode}
onChange={(v) => set("solver_mode", v as "solve_tariff" | "fixed_tariff")}
options={[
{ value: "solve_tariff", label: "Solve tariff for target IRR" },
{ value: "fixed_tariff", label: "Fixed tariff" },
]}
/>
</Field>
{state.solver_mode === "solve_tariff" ? (
<Field label="Target Equity IRR (e.g. 0.18 = 18%)">
<Input
type="number"
value={state.target_irr}
step={0.01}
min={0.05}
onChange={(v) => set("target_irr", Number(v))}
/>
</Field>
) : (
<Field label="Fixed Tariff (INR/kWh)">
<Input
type="number"
value={state.fixed_tariff}
step={0.1}
min={1}
onChange={(v) => set("fixed_tariff", Number(v))}
/>
</Field>
)}
</div>
);
}
function StepReview({ state }: { state: WizardState }) {
return (
<div className="flex flex-col gap-4">
<h2 className="text-lg font-semibold">Review & Submit</h2>
<dl className="grid grid-cols-2 gap-2 text-sm">
<dt className="text-muted-foreground">Name</dt>
<dd>{state.name || "—"}</dd>
<dt className="text-muted-foreground">COD</dt>
<dd>{state.cod_date}</dd>
{state.solar_enabled && (
<>
<dt className="text-muted-foreground">Solar</dt>
<dd>
{state.solar_dc_mwp} MWp DC / {state.solar_ac_mw} MW AC (
{state.solar_location})
</dd>
</>
)}
{state.wind_enabled && (
<>
<dt className="text-muted-foreground">Wind</dt>
<dd>
{state.wind_mw} MW ({state.wind_location})
</dd>
</>
)}
{state.bess_enabled && (
<>
<dt className="text-muted-foreground">BESS</dt>
<dd>
{state.bess_mwh} MWh / {state.bess_mw} MW
</dd>
</>
)}
<dt className="text-muted-foreground">Solver</dt>
<dd>
{state.solver_mode === "solve_tariff"
? `Solve tariff @ IRR ${(state.target_irr * 100).toFixed(0)}%`
: `Fixed ₹${state.fixed_tariff}/kWh`}
</dd>
</dl>
</div>
);
}
// ---------------------------------------------------------------------------
// Main wizard
// ---------------------------------------------------------------------------
interface ScenarioWizardProps {
onSubmit: (name: string, inputs: ScenarioInputPayload) => Promise<void>;
onCancel: () => void;
}
export function ScenarioWizard({ onSubmit, onCancel }: ScenarioWizardProps) {
const [step, setStep] = useState(0);
const [state, setState] = useState<WizardState>(DEFAULT_STATE);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
function set<K extends keyof WizardState>(k: K, v: WizardState[K]) {
setState((prev) => ({ ...prev, [k]: v }));
}
const steps = [
<StepProjectInfo key="0" state={state} set={set} />,
<StepSolar key="1" state={state} set={set} />,
<StepWind key="2" state={state} set={set} />,
<StepBess key="3" state={state} set={set} />,
<StepSolver key="4" state={state} set={set} />,
<StepReview key="5" state={state} />,
];
function buildInputs(): ScenarioInputPayload {
return {
project: {
name: state.name,
capacity_solar_mwp: state.solar_enabled ? state.solar_dc_mwp : 0,
capacity_wind_mw: state.wind_enabled ? state.wind_mw : 0,
capacity_bess_mwh: state.bess_enabled ? state.bess_mwh : 0,
capacity_bess_mw: state.bess_enabled ? state.bess_mw : 0,
cod_date: state.cod_date,
},
solar: state.solar_enabled
? {
location_id: state.solar_location,
capacity_dc_mwp: state.solar_dc_mwp,
capacity_ac_mw: state.solar_ac_mw,
}
: null,
wind: state.wind_enabled
? { location_id: state.wind_location, capacity_mw: state.wind_mw }
: null,
solver:
state.solver_mode === "fixed_tariff"
? {
mode: "fixed_tariff",
fixed_tariff: state.fixed_tariff,
}
: {
mode: "solve_tariff",
target_equity_irr: state.target_irr,
},
};
}
async function handleSubmit() {
if (!state.name.trim()) {
setError("Please enter a scenario name.");
return;
}
setSubmitting(true);
setError(null);
try {
await onSubmit(state.name, buildInputs());
} catch (e) {
setError(e instanceof Error ? e.message : "Unknown error");
setSubmitting(false);
}
}
const isLast = step === steps.length - 1;
return (
<div className="flex flex-col gap-6">
{/* Progress bar */}
<div className="flex gap-1">
{STEP_LABELS.map((label, i) => (
<div key={label} className="flex-1 flex flex-col items-center gap-1">
<div
className={`h-1.5 w-full rounded-full ${i <= step ? "bg-primary" : "bg-muted"}`}
/>
<span
className={`text-xs ${i === step ? "text-primary font-medium" : "text-muted-foreground"}`}
>
{label}
</span>
</div>
))}
</div>
{/* Step content */}
<div className="min-h-[280px]">{steps[step]}</div>
{error && (
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded">
{error}
</p>
)}
{/* Navigation */}
<div className="flex justify-between">
<div className="flex gap-2">
<Button variant="ghost" onClick={onCancel} disabled={submitting}>
Cancel
</Button>
{step > 0 && (
<Button
variant="outline"
onClick={() => setStep((s) => s - 1)}
disabled={submitting}
>
Back
</Button>
)}
</div>
{isLast ? (
<Button onClick={handleSubmit} disabled={submitting}>
{submitting ? "Submitting…" : "Run Scenario"}
</Button>
) : (
<Button onClick={() => setStep((s) => s + 1)}>Next</Button>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,98 @@
"use client";
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ReferenceLine,
ResponsiveContainer,
Cell,
} from "recharts";
export interface TornadoEntry {
param_name: string;
low_value: number;
high_value: number;
base_kpi: number;
low_kpi: number;
high_kpi: number;
swing: number;
}
interface Props {
entries: TornadoEntry[];
kpiLabel?: string;
baseValue?: number;
}
interface ChartRow {
name: string;
low: number;
high: number;
base: number;
lowLabel: string;
highLabel: string;
}
export function TornadoChart({ entries, kpiLabel = "Equity IRR", baseValue }: Props) {
if (entries.length === 0) return null;
const base = baseValue ?? entries[0]?.base_kpi ?? 0;
const isPercent = kpiLabel.toLowerCase().includes("irr") || kpiLabel.toLowerCase().includes("cuf");
const fmtKpi = (v: number) =>
isPercent ? `${(v * 100).toFixed(1)}%` : v.toFixed(2);
const data: ChartRow[] = entries.map((e) => ({
name: e.param_name,
low: Math.min(e.low_kpi, e.high_kpi) - base,
high: Math.max(e.low_kpi, e.high_kpi) - base,
base,
lowLabel: fmtKpi(Math.min(e.low_kpi, e.high_kpi)),
highLabel: fmtKpi(Math.max(e.low_kpi, e.high_kpi)),
}));
const absMax = Math.max(...data.map((d) => Math.max(Math.abs(d.low), Math.abs(d.high))));
const domain = [-absMax * 1.1, absMax * 1.1];
return (
<div>
<h3 className="font-semibold mb-3 text-sm">
Sensitivity: {kpiLabel} (base = {fmtKpi(base)})
</h3>
<ResponsiveContainer width="100%" height={Math.max(200, entries.length * 45)}>
<BarChart
data={data}
layout="vertical"
margin={{ top: 0, right: 40, left: 120, bottom: 0 }}
>
<XAxis
type="number"
domain={domain}
tickFormatter={(v) => isPercent ? `${(v * 100).toFixed(1)}%` : v.toFixed(2)}
tick={{ fontSize: 10 }}
/>
<YAxis type="category" dataKey="name" tick={{ fontSize: 11 }} width={120} />
<Tooltip
formatter={(value) => {
const v = typeof value === "number" ? value : 0;
return [
`${fmtKpi(base + v)}${isPercent ? `${(v * 100).toFixed(1)}%` : v.toFixed(2)})`,
"",
];
}}
/>
<ReferenceLine x={0} stroke="#888" strokeWidth={1.5} />
<Bar dataKey="low" name="Low" stackId="a" fill="transparent" />
<Bar dataKey="high" name="High" stackId="a" radius={[0, 3, 3, 0]}>
{data.map((entry, index) => (
<Cell key={index} fill={entry.high >= 0 ? "#10b981" : "#f43f5e"} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
);
}

View file

@ -0,0 +1,675 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
LineChart,
Line,
CartesianGrid,
Legend,
} from "recharts";
import {
getStatements,
type KpiSummary,
type PnLRow,
type CfsRow,
type BsRow,
type DebtYearRow,
type GenerationRow,
type IdcPhasing,
} from "@/lib/api";
import { KpiCard } from "@/components/KpiCard";
// Horizontal table
// ---------------------------------------------------------------------------
interface TableRow {
label: string;
values: (number | null)[];
isBold?: boolean;
isSeparator?: boolean;
isHeader?: boolean;
format?: (v: number | null) => string;
indent?: boolean;
collapsible?: boolean;
isHighlight?: boolean;
children?: TableRow[];
}
function n1(v: number | null) {
return v == null ? "—" : v.toFixed(1);
}
function n2(v: number | null) {
return v == null ? "—" : v.toFixed(2);
}
function pct1(v: number | null) {
return v == null ? "—" : `${(v * 100).toFixed(1)}%`;
}
function HorizontalTable({
years,
rows,
unit = "INR Cr",
yearPrefix = "Y",
}: {
years: number[];
rows: TableRow[];
unit?: string;
yearPrefix?: string;
}) {
const [expandedRows, setExpandedRows] = useState<Set<number>>(new Set());
const toggleRow = (idx: number) => {
setExpandedRows((prev) => {
const next = new Set(prev);
if (next.has(idx)) {
next.delete(idx);
} else {
next.add(idx);
}
return next;
});
};
let rowIndex = 0;
return (
<div className="overflow-x-auto rounded-lg border border-border">
<table className="text-xs border-collapse whitespace-nowrap">
<thead>
<tr className="bg-muted/70">
<th className="sticky left-0 z-20 bg-muted/70 px-3 py-2 text-left font-semibold border-r border-b min-w-[220px] text-muted-foreground">
Metric ({unit})
</th>
{years.map((y) => (
<th
key={y}
className="px-3 py-2 text-right font-semibold border-b min-w-[68px] text-muted-foreground"
>
{yearPrefix}{y}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, i) => {
rowIndex = i;
const isExpanded = expandedRows.has(i);
if (row.isSeparator) {
return (
<tr key={i}>
<td colSpan={years.length + 1} className="bg-border/40 h-px p-0" />
</tr>
);
}
if (row.isHeader) {
return (
<tr key={i} className="bg-primary/5">
<td
colSpan={years.length + 1}
className="sticky left-0 z-10 px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-primary/70 bg-primary/5"
>
{row.label}
</td>
</tr>
);
}
return (
<>
<tr
key={i}
className={`border-t border-border/50 transition-colors hover:bg-accent/40 ${
row.isBold ? "font-semibold bg-muted/20" : ""
}`}
>
<td
className={`sticky left-0 z-10 border-r border-border/50 px-3 py-1.5 ${
row.isHighlight ? "bg-blue-50" : "bg-background"
} ${
row.indent ? "pl-6 text-muted-foreground" : ""
} ${row.collapsible ? "cursor-pointer hover:text-primary" : ""}`}
onClick={() => row.collapsible && toggleRow(i)}
>
{row.collapsible && (
<span className="mr-0.5 text-[6px]">{isExpanded ? "▼" : "▶"}</span>
)}
{row.label}
</td>
{row.values.map((v, j) => (
<td key={j} className={`px-3 py-1.5 text-right tabular-nums ${row.isHighlight ? "bg-blue-50" : ""}`}>
{row.format ? row.format(v) : n1(v)}
</td>
))}
</tr>
{row.collapsible && row.children && isExpanded && row.children.map((child, ci) => (
<tr key={`${i}-${ci}`} className="border-t border-border/30 bg-muted/30">
<td className="sticky left-0 z-10 bg-muted/30 border-r border-border/30 px-3 pl-8 py-1.5 text-muted-foreground">
{child.label}
</td>
{child.values.map((v, j) => (
<td key={j} className="px-3 py-1.5 text-right tabular-nums text-muted-foreground">
{child.format ? child.format(v) : n1(v)}
</td>
))}
</tr>
))}
</>
);
})}
</tbody>
</table>
</div>
);
}
// ---------------------------------------------------------------------------
// Sheet content builders
// ---------------------------------------------------------------------------
function buildPnLRows(pnl: PnLRow[]): TableRow[] {
const ppaChildren: TableRow[] = [
{ label: "Units (MWh)", values: pnl.map((r) => r.ppa_units_mwh), format: (v) => v == null ? "—" : Math.round(v).toLocaleString() },
{ label: "Tariff (₹/kWh)", values: pnl.map((r) => r.ppa_tariff_inr_per_kwh) },
];
const mcpChildren: TableRow[] = [
{ label: "MCP Units (MWh)", values: pnl.map((r) => r.mcp_units_mwh), format: (v) => v == null ? "—" : Math.round(v).toLocaleString() },
];
const opexChildren: TableRow[] = [
{ label: "O&M Opex", values: pnl.map((r) => r.om_cr) },
{ label: "Insurance", values: pnl.map((r) => r.insurance_cr) },
{ label: "Land Lease", values: pnl.map((r) => r.land_lease_cr) },
{ label: "AM Fee", values: pnl.map((r) => r.am_fee_cr) },
{ label: "Misc Opex", values: pnl.map((r) => r.misc_opex_cr) },
];
return [
{
label: "PPA Revenue",
values: pnl.map((r) => r.ppa_revenue_cr),
isBold: true,
collapsible: true,
children: ppaChildren
},
{ isSeparator: true, label: "", values: [] },
{
label: "MCP Revenue",
values: pnl.map((r) => r.mcp_revenue_cr),
collapsible: true,
children: mcpChildren
},
{ isSeparator: true, label: "", values: [] },
{ label: "Total Revenue", values: pnl.map((r) => r.revenue_cr), isBold: true, isHighlight: true },
{ isSeparator: true, label: "", values: [] },
{
label: "Operating Expenditure",
values: pnl.map((r) => r.opex_total_cr),
collapsible: true,
children: opexChildren
},
{ isSeparator: true, label: "", values: [] },
{ label: "EBITDA", values: pnl.map((r) => r.ebitda_cr), isBold: true, isHighlight: true },
{ label: "Book Depreciation", values: pnl.map((r) => r.depreciation_book_cr), indent: true },
{ label: "EBIT", values: pnl.map((r) => r.ebit_cr), isBold: true, isHighlight: true, format: n2 },
{ label: "Interest", values: pnl.map((r) => r.interest_cr), indent: true },
{ label: "PBT", values: pnl.map((r) => r.pbt_cr), isBold: true, isHighlight: true, format: n2 },
{ label: "Tax", values: pnl.map((r) => r.tax_cr), indent: true },
{ label: "PAT", values: pnl.map((r) => r.pat_cr), isBold: true, isHighlight: true, format: n2 },
];
}
function buildCfsRows(cfs: CfsRow[], kpis: KpiSummary, pnl: PnLRow[]): TableRow[] {
// CFADS = CFO + Interest (add back since CFO is post-interest in this model)
const cfads = cfs.map((r, i) => r.cfo_cr + (pnl[i]?.interest_cr ?? 0));
const equityCf = cfs.map((r, i) => cfads[i] - r.debt_repayment_cr - (pnl[i]?.interest_cr ?? 0));
return [
{ isHeader: true, label: "Operating Cash Flow", values: [] },
{ label: "PAT", values: cfs.map((r) => r.pat_cr), indent: true },
{ label: "Add: Depreciation", values: cfs.map((r) => r.depreciation_cr), indent: true },
{ label: "Δ Working Capital", values: cfs.map((r) => r.delta_working_capital_cr), indent: true },
{ label: "CFO", values: cfs.map((r) => r.cfo_cr), isBold: true },
{ isSeparator: true, label: "", values: [] },
{ isHeader: true, label: "Investing Cash Flow", values: [] },
{ label: "Capex", values: cfs.map((r) => r.capex_cr), indent: true },
{ label: "CFI", values: cfs.map((r) => r.cfi_cr), isBold: true },
{ isSeparator: true, label: "", values: [] },
{ isHeader: true, label: "Financing Cash Flow", values: [] },
{ label: "Debt Drawdown", values: cfs.map((r) => r.debt_drawdown_cr), indent: true },
{ label: "Debt Repayment", values: cfs.map((r) => r.debt_repayment_cr), indent: true },
{ label: "Equity Injection", values: cfs.map((r) => r.equity_injection_cr), indent: true },
{ label: "CFF", values: cfs.map((r) => r.cff_cr), isBold: true },
{ isSeparator: true, label: "", values: [] },
{ label: "Net Cash Flow", values: cfs.map((r) => r.net_cash_flow_cr), isBold: true },
{ label: "Opening Cash", values: cfs.map((r) => r.opening_cash_cr), indent: true },
{ label: "Closing Cash", values: cfs.map((r) => r.closing_cash_cr), isBold: true },
{ isSeparator: true, label: "", values: [] },
{ isHeader: true, label: "Returns Analysis", values: [] },
{ label: "CFADS (pre-debt service)", values: cfads, isBold: true },
{ label: "Equity Free Cash Flow", values: equityCf },
{
label: `Project IRR: ${kpis.project_irr != null ? (kpis.project_irr * 100).toFixed(1) + "%" : "—"} | Equity IRR: ${kpis.equity_irr != null ? (kpis.equity_irr * 100).toFixed(1) + "%" : "—"}`,
values: [],
isBold: true,
},
];
}
function buildBsRows(bs: BsRow[]): TableRow[] {
return [
{ isHeader: true, label: "Assets", values: [] },
{ label: "Gross Block", values: bs.map((r) => r.gross_block_cr), indent: true },
{ label: "Less: Accum Depr", values: bs.map((r) => r.accumulated_depr_cr), indent: true },
{ label: "Net Block", values: bs.map((r) => r.net_block_cr), isBold: true },
{ isSeparator: true, label: "", values: [] },
{ label: "Cash & Bank", values: bs.map((r) => r.cash_cr), indent: true },
{ label: "Receivables", values: bs.map((r) => r.receivables_cr), indent: true },
{ label: "Total Assets", values: bs.map((r) => r.total_assets_cr), isBold: true },
{ isSeparator: true, label: "", values: [] },
{ isHeader: true, label: "Liabilities & Equity", values: [] },
{ label: "Equity Share Capital", values: bs.map((r) => r.equity_cr), indent: true },
{ label: "Reserves & Surplus", values: bs.map((r) => r.reserves_cr), indent: true },
{ label: "Long-term Debt", values: bs.map((r) => r.long_term_debt_cr), indent: true },
{ label: "Payables", values: bs.map((r) => r.payables_cr), indent: true },
{ label: "Total Liabilities", values: bs.map((r) => r.total_liabilities_cr), isBold: true },
];
}
function buildDebtRows(debt: DebtYearRow[]): TableRow[] {
return [
{ label: "Opening Balance", values: debt.map((r) => r.opening_balance_cr) },
{ label: "Interest", values: debt.map((r) => r.interest_cr) },
{ label: "Principal Repayment", values: debt.map((r) => r.principal_cr) },
{ label: "Total Debt Service", values: debt.map((r) => r.total_debt_service_cr), isBold: true },
{ label: "Closing Balance", values: debt.map((r) => r.closing_balance_cr), isBold: true },
{ isSeparator: true, label: "", values: [] },
{ label: "DSCR", values: debt.map((r) => r.dscr), isBold: true, format: n2 },
];
}
// ---------------------------------------------------------------------------
// Sheet views
// ---------------------------------------------------------------------------
function SummarySheet({ kpis, scenarioId }: { kpis: KpiSummary; scenarioId: string }) {
const { data: stmts } = useQuery({
queryKey: ["statements", scenarioId],
queryFn: () => getStatements(scenarioId),
});
// Custom KPIs - stored in state, persist to localStorage
const [customKpis, setCustomKpis] = useState<{ label: string; value: string; unit: string }[]>(() => {
try {
const stored = localStorage.getItem(`customKpis-${scenarioId}`);
if (!stored) return [];
const parsed = JSON.parse(stored);
if (!Array.isArray(parsed)) return [];
// Filter out invalid entries
return parsed.filter(
(k) => k && typeof k.label === "string" && typeof k.value === "string" && k.value !== "0.0" && k.value !== "null"
);
} catch {
return [];
}
});
function addCustomKpi() {
const label = prompt("Enter KPI label (e.g. O&M Cost)");
if (!label) return;
const value = prompt(`Enter value for ${label} (without unit)`);
if (!value) return;
const unit = prompt("Enter unit (e.g. Cr, %, yrs)") || "";
const newKpis = [...customKpis, { label, value, unit }];
setCustomKpis(newKpis);
try {
localStorage.setItem(`customKpis-${scenarioId}`, JSON.stringify(newKpis));
} catch {
// localStorage unavailable
}
}
function removeCustomKpi(index: number) {
const newKpis = customKpis.filter((_, i) => i !== index);
setCustomKpis(newKpis);
try {
localStorage.setItem(`customKpis-${scenarioId}`, JSON.stringify(newKpis));
} catch {
// localStorage unavailable
}
}
const pnlChart =
stmts?.pnl.map((r) => ({
year: r.year,
Revenue: r.revenue_cr,
EBITDA: r.ebitda_cr,
PAT: r.pat_cr,
})) ?? [];
const cashChart =
stmts?.cfs.map((r) => ({ year: r.year, "Closing Cash": r.closing_cash_cr })) ?? [];
return (
<div className="space-y-6">
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
<KpiCard
label="Solved Tariff"
value={kpis.solved_tariff_inr_per_kwh != null ? kpis.solved_tariff_inr_per_kwh.toFixed(2) : null}
unit="₹/kWh"
highlight
/>
<KpiCard
label="Equity IRR"
value={kpis.equity_irr != null ? `${(kpis.equity_irr * 100).toFixed(1)}%` : null}
highlight
/>
<KpiCard label="Project IRR" value={kpis.project_irr != null ? `${(kpis.project_irr * 100).toFixed(1)}%` : null} />
<KpiCard label="Min DSCR" value={kpis.min_dscr?.toFixed(2) ?? null} />
<KpiCard label="Avg DSCR" value={kpis.avg_dscr?.toFixed(2) ?? null} />
<KpiCard label="Total Capex" value={kpis.total_capex_cr != null ? kpis.total_capex_cr.toFixed(1) : null} unit="Cr" />
<KpiCard label="Debt" value={kpis.debt_cr?.toFixed(1) ?? null} unit="Cr" />
<KpiCard label="IDC" value={kpis.idc_cr?.toFixed(1) ?? null} unit="Cr" />
<KpiCard label="LCOE" value={kpis.lcoe_inr_per_kwh?.toFixed(2) ?? null} unit="₹/kWh" />
<KpiCard label="Payback" value={kpis.payback_years?.toFixed(1) ?? null} unit="yrs" />
{kpis.solar_y1_cuf != null && (
<KpiCard label="Solar Y1 CUF" value={`${(kpis.solar_y1_cuf * 100).toFixed(1)}%`} />
)}
{kpis.wind_y1_plf != null && (
<KpiCard label="Wind Y1 PLF" value={`${(kpis.wind_y1_plf * 100).toFixed(1)}%`} />
)}
{kpis.rtc_cuf_achieved != null && (
<KpiCard label="RTC CUF" value={`${(kpis.rtc_cuf_achieved * 100).toFixed(1)}%`} highlight />
)}
{/* Add custom KPI button */}
<button
onClick={addCustomKpi}
className="border border-dashed border-primary/30 rounded-lg p-4 flex items-center justify-center text-sm text-primary hover:bg-primary/5 transition-colors"
>
+ Add KPI
</button>
{/* Custom KPIs */}
{customKpis.map((kpi: { label: string; value: string; unit: string }, i: number) => (
<div key={i} className="relative">
<KpiCard label={kpi.label} value={kpi.value} unit={kpi.unit} />
<button
onClick={() => removeCustomKpi(i)}
className="absolute top-1 right-2 text-muted-foreground/40 hover:text-destructive text-xs"
>
×
</button>
</div>
))}
</div>
{stmts && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="border rounded-lg p-4">
<p className="text-sm font-medium mb-3 text-muted-foreground">P&L Overview (Cr)</p>
<ResponsiveContainer width="100%" height={200}>
<BarChart data={pnlChart} barCategoryGap="30%">
<XAxis dataKey="year" tick={{ fontSize: 10 }} />
<YAxis tick={{ fontSize: 10 }} />
<Tooltip />
<Legend iconSize={10} />
<Bar dataKey="Revenue" fill="oklch(0.52 0.22 262)" />
<Bar dataKey="EBITDA" fill="oklch(0.60 0.17 178)" />
<Bar dataKey="PAT" fill="oklch(0.70 0.18 55)" />
</BarChart>
</ResponsiveContainer>
</div>
<div className="border rounded-lg p-4">
<p className="text-sm font-medium mb-3 text-muted-foreground">Closing Cash (Cr)</p>
<ResponsiveContainer width="100%" height={200}>
<LineChart data={cashChart}>
<CartesianGrid strokeDasharray="3 3" stroke="oklch(0.90 0.01 262)" />
<XAxis dataKey="year" tick={{ fontSize: 10 }} />
<YAxis tick={{ fontSize: 10 }} />
<Tooltip />
<Line
type="monotone"
dataKey="Closing Cash"
stroke="oklch(0.52 0.22 262)"
dot={false}
strokeWidth={2}
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
)}
</div>
);
}
function IrrSheet({ kpis }: { kpis: KpiSummary }) {
const sections = [
{
title: "Returns",
rows: [
{ label: "Equity IRR (Leveraged)", value: kpis.equity_irr != null ? pct1(kpis.equity_irr) : "—" },
{ label: "Project IRR (Unlevered)", value: kpis.project_irr != null ? pct1(kpis.project_irr) : "—" },
{ label: "LCOE", value: kpis.lcoe_inr_per_kwh != null ? `${kpis.lcoe_inr_per_kwh.toFixed(2)}/kWh` : "—" },
{ label: "Payback Period", value: kpis.payback_years != null ? `${kpis.payback_years.toFixed(1)} yrs` : "—" },
],
},
{
title: "Debt Metrics",
rows: [
{ label: "Min DSCR", value: kpis.min_dscr?.toFixed(2) ?? "—" },
{ label: "Avg DSCR", value: kpis.avg_dscr?.toFixed(2) ?? "—" },
],
},
{
title: "Project Economics",
rows: [
{ label: "Solved / Fixed Tariff", value: kpis.solved_tariff_inr_per_kwh != null ? `${kpis.solved_tariff_inr_per_kwh.toFixed(2)}/kWh` : "—" },
{ label: "Total Capex", value: kpis.total_capex_cr != null ? `${kpis.total_capex_cr.toFixed(1)} Cr` : "—" },
{ label: "Debt Sized", value: kpis.debt_cr != null ? `${kpis.debt_cr.toFixed(1)} Cr` : "—" },
{ label: "IDC", value: kpis.idc_cr != null ? `${kpis.idc_cr.toFixed(1)} Cr` : "—" },
],
},
];
return (
<div className="max-w-xl space-y-4">
{sections.map((s) => (
<div key={s.title} className="border rounded-lg overflow-hidden">
<div className="bg-muted/50 px-4 py-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{s.title}
</div>
<table className="w-full text-sm">
<tbody>
{s.rows.map(({ label, value }) => (
<tr key={label} className="border-t hover:bg-accent/30 transition-colors">
<td className="px-4 py-2.5 text-muted-foreground">{label}</td>
<td className="px-4 py-2.5 tabular-nums font-semibold text-right">{value}</td>
</tr>
))}
</tbody>
</table>
</div>
))}
</div>
);
}
function buildGenerationRows(gen: GenerationRow[]): TableRow[] {
const hasSolar = gen.some((r) => r.solar_mwh > 0);
const hasWind = gen.some((r) => r.wind_mwh > 0);
const rows: TableRow[] = [];
if (hasSolar) {
rows.push({ label: "Solar Generation (MWh)", values: gen.map((r) => r.solar_mwh), format: (v) => v == null ? "—" : Math.round(v).toLocaleString() });
rows.push({ label: "Solar CUF (%)", values: gen.map((r) => r.solar_cuf_pct), format: (v) => v == null ? "—" : `${v.toFixed(1)}%`, indent: true });
}
if (hasWind) {
rows.push({ label: "Wind Generation (MWh)", values: gen.map((r) => r.wind_mwh), format: (v) => v == null ? "—" : Math.round(v).toLocaleString() });
rows.push({ label: "Wind PLF (%)", values: gen.map((r) => r.wind_plf_pct), format: (v) => v == null ? "—" : `${v.toFixed(1)}%`, indent: true });
}
rows.push({ label: "Gross Total (MWh)", values: gen.map((r) => r.gross_mwh), isBold: true, format: (v) => v == null ? "—" : Math.round(v).toLocaleString() });
rows.push({ label: "Less: Aux Consumption", values: gen.map((r) => -r.aux_loss_mwh), format: (v) => v == null ? "—" : Math.round(v).toLocaleString(), indent: true });
rows.push({ label: "Less: Transmission Loss", values: gen.map((r) => -r.tx_loss_mwh), format: (v) => v == null ? "—" : Math.round(v).toLocaleString(), indent: true });
rows.push({ label: "Less: DSM Penalty", values: gen.map((r) => -r.dsm_loss_mwh), format: (v) => v == null ? "—" : Math.round(v).toLocaleString(), indent: true });
rows.push({ label: "Net Billable (MWh)", values: gen.map((r) => r.net_billable_mwh), isBold: true, format: (v) => v == null ? "—" : Math.round(v).toLocaleString() });
rows.push({ label: "Revenue (₹ Cr)", values: gen.map((r) => r.revenue_cr), isBold: true, format: n2 });
return rows;
}
function buildOpexRows(pnl: PnLRow[]): TableRow[] {
const total = pnl.map((r) => r.om_cr + r.insurance_cr + r.land_lease_cr + r.am_fee_cr + r.misc_opex_cr);
return [
{ label: "O&M (₹ Cr)", values: pnl.map((r) => r.om_cr), format: n2 },
{ label: "Insurance (₹ Cr)", values: pnl.map((r) => r.insurance_cr), format: n2, indent: true },
{ label: "Land Lease (₹ Cr)", values: pnl.map((r) => r.land_lease_cr), format: n2, indent: true },
{ label: "AM Fee (₹ Cr)", values: pnl.map((r) => r.am_fee_cr), format: n2, indent: true },
{ label: "Miscellaneous (₹ Cr)", values: pnl.map((r) => r.misc_opex_cr), format: n2, indent: true },
{ label: "Total OPEX (₹ Cr)", values: total, isBold: true, format: n2 },
{ label: "OPEX as % of Revenue", values: pnl.map((r, i) => r.revenue_cr > 0 ? total[i] / r.revenue_cr * 100 : null), format: (v) => v == null ? "—" : `${v.toFixed(1)}%`, indent: true },
];
}
function IdcSheet({ idc }: { idc: IdcPhasing }) {
if (!idc?.base_capex_cr) return <div className="text-muted-foreground p-4">No IDC data available</div>;
const months = idc.monthly ?? [];
const nMonths = months.length;
const nCols = Math.min(nMonths, 24); // Cap at 24 months for display
// Build ALL-IN-ONE matrix: both component costs AND funding sources
const monthlyRate = 1 / nMonths;
const solarPct = 0.70, windPct = 0.0, landPct = 0.10, epcPct = 0.12, contPct = 0.08;
const matrixRows: TableRow[] = [
// === Component Costs (what's being built) ===
{ isHeader: true, label: "COMPONENT COSTS", values: [] },
{ label: "Solar Capex", values: Array(nCols).fill(0).map((_, i) => months[i] ? idc.base_capex_cr * solarPct * monthlyRate : 0) },
{ label: "Wind Capex", values: Array(nCols).fill(0).map((_, i) => months[i] ? idc.base_capex_cr * windPct * monthlyRate : 0) },
{ label: "Land & Common", values: Array(nCols).fill(0).map((_, i) => months[i] ? idc.base_capex_cr * landPct * monthlyRate : 0) },
{ label: "EPC Overhead", values: Array(nCols).fill(0).map((_, i) => months[i] ? idc.base_capex_cr * epcPct * monthlyRate : 0) },
{ label: "Contingency", values: Array(nCols).fill(0).map((_, i) => months[i] ? idc.base_capex_cr * contPct * monthlyRate : 0) },
{ isSeparator: true, label: "", values: [] },
// === Funding Sources (how it's paid) ===
{ isHeader: true, label: "FUNDING SOURCES", values: [] },
{ label: "Equity Draw", values: months.slice(0, nCols).map((m) => m.equity_draw_cr) },
{ label: "Debt Draw", values: months.slice(0, nCols).map((m) => m.debt_draw_cr) },
{ label: "IDC Interest", values: months.slice(0, nCols).map((m) => m.idc_accrual_cr), indent: true },
{ isSeparator: true, label: "", values: [] },
// === Cumulative (running totals) ===
{ isHeader: true, label: "CUMULATIVE", values: [] },
{ label: "Cum. Equity", values: months.slice(0, nCols).map((m) => m.cum_equity_cr), indent: true },
{ label: "Cum. Debt", values: months.slice(0, nCols).map((m) => m.cum_debt_cr), indent: true },
{ label: "Cum. IDC", values: months.slice(0, nCols).map((m) => m.cum_idc_cr), indent: true },
{ isSeparator: true, label: "", values: [] },
{ label: "Total Project Cost", values: months.slice(0, nCols).map((m) => m.cum_tpc_cr), isBold: true },
];
// Compact header
const fundingMix = [
{ label: "Base", val: idc.base_capex_cr },
{ label: "IDC", val: idc.idc_cr },
{ label: "Total", val: idc.total_capex_cr },
{ label: "Equity", val: idc.equity_cr },
{ label: "Debt", val: idc.debt_cr },
];
return (
<div className="space-y-4">
{/* Header Cards */}
<div className="grid grid-cols-5 gap-2">
{fundingMix.map((f) => (
<div key={f.label} className="border rounded px-3 py-2 text-center">
<p className="text-[10px] text-muted-foreground">{f.label}</p>
<p className="text-sm font-bold tabular-nums">{f.val.toFixed(0)}</p>
</div>
))}
</div>
{/* Single ALL-IN-ONE Matrix Table */}
<div className="border rounded-lg overflow-hidden">
<div className="bg-muted/50 px-4 py-2 border-b border-border">
<h3 className="font-semibold text-sm">IDC Construction Phasing Matrix ({nMonths} months)</h3>
</div>
<HorizontalTable years={months.slice(0, nCols).map((m) => m.month)} rows={matrixRows} unit="₹ Cr" yearPrefix="M" />
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Main workbook
// ---------------------------------------------------------------------------
interface Props {
scenarioId: string;
kpis: KpiSummary;
debtScheduleJson: string | null;
activeSheet: string;
}
export function WorkbookView({ scenarioId, kpis, debtScheduleJson, activeSheet }: Props) {
const { data: stmts } = useQuery({
queryKey: ["statements", scenarioId],
queryFn: () => getStatements(scenarioId),
});
const debtSchedule: DebtYearRow[] = (() => {
try {
const safe = (debtScheduleJson ?? "[]")
.replace(/:\s*Infinity/g, ": null")
.replace(/:\s*-Infinity/g, ": null")
.replace(/:\s*NaN\b/g, ": null");
return JSON.parse(safe) as DebtYearRow[];
} catch {
return [];
}
})();
const pnl = stmts?.pnl ?? [];
const cfs = stmts?.cfs ?? [];
const bs = stmts?.bs ?? [];
const generation = stmts?.generation ?? [];
const idcPhasing = stmts?.idc_phasing;
const years = pnl.map((r) => r.year);
const debtYears = debtSchedule.map((r) => r.year);
const genYears = generation.map((r) => r.year);
return (
<div className="space-y-0">
<p className="text-xs text-muted-foreground mb-4">
All monetary values in INR Crore unless noted
</p>
{activeSheet === "summary" && <SummarySheet kpis={kpis} scenarioId={scenarioId} />}
{activeSheet === "pnl" && pnl.length > 0 && (
<HorizontalTable years={years} rows={buildPnLRows(pnl)} />
)}
{activeSheet === "cfs" && cfs.length > 0 && (
<HorizontalTable years={years} rows={buildCfsRows(cfs, kpis, pnl)} />
)}
{activeSheet === "bs" && bs.length > 0 && (
<HorizontalTable years={years} rows={buildBsRows(bs)} />
)}
{activeSheet === "debt" && debtSchedule.length > 0 && (
<HorizontalTable years={debtYears} rows={buildDebtRows(debtSchedule)} />
)}
{activeSheet === "irr" && <IrrSheet kpis={kpis} />}
{activeSheet === "generation" && generation.length > 0 && (
<HorizontalTable years={genYears} rows={buildGenerationRows(generation)} unit="MWh / ₹Cr" />
)}
{activeSheet === "idc" && idcPhasing && (
<IdcSheet idc={idcPhasing} />
)}
{activeSheet === "opex" && pnl.length > 0 && (
<HorizontalTable years={years} rows={buildOpexRows(pnl)} unit="INR Cr" />
)}
</div>
);
}

View file

@ -1,11 +1,289 @@
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface Scenario {
id: string;
name: string;
status: string;
kpis_json: string | null;
created_at: string;
runtime_s?: number | null;
}
export interface ScenarioDetail extends Scenario {
inputs_json: string | null;
statements_json: string | null;
debt_schedule_json: string | null;
error_message: string | null;
}
export interface KpiSummary {
solved_tariff_inr_per_kwh?: number | null;
equity_irr?: number | null;
project_irr?: number | null;
min_dscr?: number | null;
avg_dscr?: number | null;
total_capex_cr?: number | null;
idc_cr?: number | null;
debt_cr?: number | null;
solar_y1_cuf?: number | null;
wind_y1_plf?: number | null;
lcoe_inr_per_kwh?: number | null;
payback_years?: number | null;
rtc_cuf_achieved?: number | null;
total_shortfall_mwh?: number | null;
total_curtailed_mwh?: number | null;
total_mcp_revenue_cr?: number | null;
}
export interface PnLRow {
year: number;
revenue_cr: number;
ppa_revenue_cr: number;
mcp_revenue_cr: number;
ppa_tariff_inr_per_kwh: number;
ppa_units_mwh: number;
mcp_units_mwh: number;
opex_total_cr: number;
om_cr: number;
insurance_cr: number;
land_lease_cr: number;
am_fee_cr: number;
misc_opex_cr: number;
ebitda_cr: number;
depreciation_book_cr: number;
ebit_cr: number;
interest_cr: number;
pbt_cr: number;
tax_cr: number;
pat_cr: number;
}
export interface CfsRow {
year: number;
pat_cr: number;
depreciation_cr: number;
delta_working_capital_cr: number;
cfo_cr: number;
capex_cr: number;
cfi_cr: number;
debt_drawdown_cr: number;
debt_repayment_cr: number;
equity_injection_cr: number;
cff_cr: number;
net_cash_flow_cr: number;
opening_cash_cr: number;
closing_cash_cr: number;
}
export interface BsRow {
year: number;
gross_block_cr: number;
accumulated_depr_cr: number;
net_block_cr: number;
cash_cr: number;
receivables_cr: number;
total_assets_cr: number;
equity_cr: number;
reserves_cr: number;
long_term_debt_cr: number;
payables_cr: number;
total_liabilities_cr: number;
}
export interface DebtYearRow {
year: number;
opening_balance_cr: number;
interest_cr: number;
principal_cr: number;
total_debt_service_cr: number;
closing_balance_cr: number;
dscr: number;
}
export interface GenerationRow {
year: number;
solar_mwh: number;
wind_mwh: number;
gross_mwh: number;
aux_loss_mwh: number;
tx_loss_mwh: number;
dsm_loss_mwh: number;
net_billable_mwh: number;
solar_cuf_pct: number | null;
wind_plf_pct: number | null;
revenue_cr: number;
}
export interface IdcMonthRow {
month: number;
equity_draw_cr: number;
debt_draw_cr: number;
idc_accrual_cr: number;
cum_equity_cr: number;
cum_debt_cr: number;
cum_idc_cr: number;
cum_tpc_cr: number;
}
export interface IdcPhasing {
construction_months: number;
base_capex_cr: number;
idc_cr: number;
total_capex_cr: number;
debt_cr: number;
equity_cr: number;
monthly: IdcMonthRow[];
}
export interface Statements {
pnl: PnLRow[];
cfs: CfsRow[];
bs: BsRow[];
generation?: GenerationRow[];
idc_phasing?: IdcPhasing;
}
export type CostBasis =
| "PER_WP_DC"
| "PER_MWP_DC"
| "PER_MW_AC"
| "PER_MW_WIND"
| "PER_MWH_BESS"
| "PER_ACRE"
| "PCT_OF_HARDCOST"
| "ABS_INR_CR";
export type DeprClass =
| "Plant"
| "BESS"
| "Building"
| "Land_NoDepr"
| "LandLease_Amortized"
| "Intangible"
| "Capitalized_NoDepr"
| "Expensed";
export type CostAttribution = "SolarOnly" | "WindOnly" | "BESSOnly" | "Common";
export interface CostItem {
id: string;
name: string;
category: "HardCost" | "SoftCost" | "EPCOverhead" | "EPCMargin" | "FinancingCost" | "Contingency";
basis: CostBasis;
value: number;
depr_class: DeprClass;
tax_pct?: number; // GST/tax rate, default 5% for modules
attribution: CostAttribution;
phasing_id?: string;
escalation_pct?: number;
}
export interface ScenarioInputPayload {
project?: {
name?: string;
state?: string | null;
capacity_solar_mwp?: number;
capacity_wind_mw?: number;
capacity_bess_mwh?: number;
capacity_bess_mw?: number;
land_acres?: number;
cod_year?: number;
cod_date?: string | null;
solar_cod_date?: string | null;
wind_cod_date?: string | null;
bess_cod_date?: string | null;
};
solar?: {
location_id: string;
capacity_dc_mwp: number;
capacity_ac_mw: number;
dc_ac_ratio?: number;
availability_fraction?: number;
dc_loss_fraction?: number;
soiling_fraction?: number;
degradation_y1?: number;
degradation_annual?: number;
stabilization_days?: number;
stabilization_energy_loss_frac?: number;
stabilization_dsm_addon_pct?: number;
} | null;
wind?: {
location_id: string;
capacity_mw: number;
hub_height_m?: number;
availability_fraction?: number;
wake_loss_fraction?: number;
stabilization_days?: number;
stabilization_energy_loss_frac?: number;
stabilization_dsm_addon_pct?: number;
} | null;
bess?: {
capacity_mwh: number;
power_mw: number;
rte?: number;
dod?: number;
} | null;
rtc?: {
rtc_mw?: number;
mcp_enabled?: boolean;
initial_soc_frac?: number;
} | null;
commercial?: {
tariff_inr_per_kwh?: number;
aux_consumption_pct?: number;
transmission_loss_pct?: number;
dsm_loss_pct?: number;
bad_debt_pct?: number;
receivable_days?: number;
payable_days?: number;
};
opex?: {
om_solar_cr_per_mw?: number;
om_wind_cr_per_mw?: number;
om_bess_cr_per_mwh?: number;
insurance_pct_of_capex?: number;
land_lease_cr?: number;
om_escalation_pct?: number;
om_solar_escalation_pct?: number;
om_solar_escalation_after_year?: number;
om_wind_escalation_pct?: number;
om_wind_escalation_after_year?: number;
om_bess_pct_of_capex?: number | null;
am_fee_pct_of_revenue?: number;
misc_cr?: number;
};
capex?: {
cost_items?: CostItem[];
debt_fraction?: number;
interest_rate_annual?: number;
construction_months?: number;
upfront_fee_pct?: number;
};
debt?: {
interest_rate_annual?: number;
tenor_years?: number;
moratorium_years?: number;
de_ratio?: number;
min_dscr?: number;
avg_dscr?: number;
schedule_shape?: string;
};
tax?: {
rate?: number;
wdv_plant_rate?: number;
wdv_bess_rate?: number;
wdv_building_rate?: number;
wdv_intangible_rate?: number;
};
solver?: {
mode: "solve_tariff" | "fixed_tariff";
target_equity_irr?: number;
fixed_tariff?: number | null;
};
}
export interface ProgressEvent {
@ -13,28 +291,66 @@ export interface ProgressEvent {
pct: number;
}
export async function createScenario(name: string): Promise<Scenario> {
const res = await fetch(`${API_BASE}/api/scenarios`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
if (!res.ok) throw new Error(`API error ${res.status}`);
return res.json() as Promise<Scenario>;
// ---------------------------------------------------------------------------
// API helpers
// ---------------------------------------------------------------------------
async function apiFetch<T>(url: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${url}`, options);
if (!res.ok) throw new Error(`API error ${res.status}: ${await res.text()}`);
return res.json() as Promise<T>;
}
export async function getScenario(id: string): Promise<Scenario> {
const res = await fetch(`${API_BASE}/api/scenarios/${id}`);
if (!res.ok) throw new Error(`API error ${res.status}`);
return res.json() as Promise<Scenario>;
// ---------------------------------------------------------------------------
// Scenario functions
// ---------------------------------------------------------------------------
export async function createScenario(
name: string,
inputs?: ScenarioInputPayload,
): Promise<Scenario> {
return apiFetch<Scenario>("/api/scenarios", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, inputs }),
});
}
export async function getScenario(id: string): Promise<ScenarioDetail> {
return apiFetch<ScenarioDetail>(`/api/scenarios/${id}`);
}
export async function listScenarios(): Promise<Scenario[]> {
const res = await fetch(`${API_BASE}/api/scenarios`);
if (!res.ok) throw new Error(`API error ${res.status}`);
return res.json() as Promise<Scenario[]>;
return apiFetch<Scenario[]>("/api/scenarios");
}
export async function getKpis(id: string): Promise<KpiSummary> {
return apiFetch<KpiSummary>(`/api/scenarios/${id}/kpis`);
}
export async function getStatements(id: string): Promise<Statements> {
return apiFetch<Statements>(`/api/scenarios/${id}/statements`);
}
export async function archiveScenario(id: string): Promise<void> {
await apiFetch(`/api/scenarios/${id}`, { method: "DELETE" });
}
export async function updateScenarioInputs(
id: string,
inputs: ScenarioInputPayload,
): Promise<Scenario> {
return apiFetch<Scenario>(`/api/scenarios/${id}/inputs`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ inputs }),
});
}
export function scenarioEventsUrl(id: string): string {
return `${API_BASE}/api/scenarios/${id}/events`;
}
export function scenarioExcelUrl(id: string): string {
return `${API_BASE}/api/scenarios/${id}/export/excel`;
}

View file

@ -34,6 +34,7 @@
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"agentation": "^3.0.2",
"eslint": "^10.3.0",
"eslint-config-next": "^16.2.5",
"openapi-typescript": "^7.13.0",

View file

@ -69,6 +69,9 @@ importers:
'@types/react-dom':
specifier: ^19
version: 19.2.3(@types/react@19.2.14)
agentation:
specifier: ^3.0.2
version: 3.0.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
eslint:
specifier: ^10.3.0
version: 10.3.0(jiti@2.7.0)
@ -1066,6 +1069,17 @@ packages:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'}
agentation@3.0.2:
resolution: {integrity: sha512-iGzBxFVTuZEIKzLY6AExSLAQH6i6SwxV4pAu7v7m3X6bInZ7qlZXAwrEqyc4+EfP4gM7z2RXBF6SF4DeH0f2lA==}
peerDependencies:
react: '>=18.0.0'
react-dom: '>=18.0.0'
peerDependenciesMeta:
react:
optional: true
react-dom:
optional: true
ajv-formats@3.0.1:
resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
peerDependencies:
@ -4227,6 +4241,11 @@ snapshots:
agent-base@7.1.4: {}
agentation@3.0.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
optionalDependencies:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
ajv-formats@3.0.1(ajv@8.20.0):
optionalDependencies:
ajv: 8.20.0

17
sprints/SPRINT_04.md Normal file
View file

@ -0,0 +1,17 @@
Goal: Full solver chain working. Tariff parity gate.
Tasks:
S4-T01 Schema: DebtConfig, DebtSchedule, IRRMetrics.
S4-T02 debt/sizing.py: 3 constraints (D:E cap, min DSCR, avg DSCR). Take binding. Fixed-point on CFADS.
S4-T03 debt/schedule.py: shapes — equal_principal, equal_installment, custom_pct_vector, balloon.
S4-T04 debt/sculpting.py: DSCR-targeted sculpt. Solves principal per year = (CFADS/target_dscr) - interest.
S4-T05 debt/compliance.py: routine that combines tariff and schedule reshape per user requirement.
S4-T06 irr/metrics.py: project IRR, equity IRR, NPV, payback, LCOE, min/avg DSCR, LLCR, PLCR.
S4-T07 solver/tariff.py: brentq with bounds [2.0, 8.0]. Inner: full pipeline run.
S4-T08 scenarios/runner.py: orchestrates everything: gen → dispatch → commercial → capex → IDC → financial → debt → IRR → solve tariff. Returns ScenarioResult.
S4-T09 Tests: solver convergence on known scenario. IRR math validated against numpy_financial.
S4-T10 PARITY GATE: nagasamudra_inputs.json solved tariff within ₹0.01/kWh of Excel. Equity IRR within 1bp.
S4-T11 CLI: remodel solve-tariff --input scenario.json --target-equity-irr 0.18.
S4-T12 Documentation.
Definition of Done: Full v0 engine works via CLI. Solves tariff for the reference scenario. Parity gate passed.

15
sprints/SPRINT_05.md Normal file
View file

@ -0,0 +1,15 @@
Goal: Engine wired into FastAPI with Arq. Real persistence. SSE progress.
Tasks:
S5-T01 Replace dummy worker with real engine call. run_scenario task.
S5-T02 Progress reporting from engine via callback. Worker publishes to Redis pub/sub.
S5-T03 Persist ScenarioResult to SQLite (KPIs as JSON column).
S5-T04 Persist timeseries to Parquet at data/scenarios/{id}/timeseries.parquet.
S5-T05 Endpoint: GET /api/scenarios/{id}/timeseries?cols=...&from=...&to=... streams parquet (use pyarrow + Polars for filtered reads).
S5-T06 Endpoint: GET /api/scenarios/{id}/statements returns P&L/CFS/BS as JSON (yearly rows).
S5-T07 Endpoint: GET /api/templates for default CostItem catalog. POST /api/templates to save custom.
S5-T08 Endpoint: GET /api/dashboard/config and PUT for KPI config.
S5-T09 Update OpenAPI export → TS types regenerate.
S5-T10 Tests: API integration tests with TestClient. Worker tests with fake Redis.
S5-T11 Documentation.

19
sprints/SPRINT_06.md Normal file
View file

@ -0,0 +1,19 @@
Goal: Usable web app for solar+wind (no BESS dispatch yet). Configurable dashboard. Wizard. Results.
Tasks:
S6-T01 Build shared <DataGrid> component using AG Grid Community. Props: rows, columns, edit handlers, validation per cell, footer (sum/check).
S6-T02 Wizard component: 10 steps with progress bar, save-as-draft, validation.
S6-T03 Step 1-2: Project info, generation config (use reference profile dropdown + upload).
S6-T04 Step 3: BESS config (sizing, RTE, augmentation table via DataGrid).
S6-T05 Step 4: CAPEX. Tier-1 fields prominent. "Show all" toggle reveals DataGrid with full CostItem table.
S6-T06 Step 5: Phasing matrix (DataGrid: items × months, % per cell, row-sum validation).
S6-T07 Step 6: Equity & Debt drawdown (two DataGrids).
S6-T08 Step 7: OPEX (DataGrid: yearly rows).
S6-T09 Step 8-10: Debt terms, tax, solver config.
S6-T10 Configurable Dashboard: chip-based KPI selector. Default 12 KPIs. User toggles which to show. Persisted.
S6-T11 Results page: KPIs at top, 5 charts (gen 8760 sample, P&L bars, DSCR by year, cash waterfall, sensitivity placeholder).
S6-T12 Statements view: tabs for P&L/CFS/BS, year-columns layout, formatted Cr.
S6-T13 Recent scenarios list, search, archive.
S6-T14 Documentation.
Definition of Done: User can run a solar+wind scenario end-to-end via web UI. Dashboard shows pinned KPIs. Results render.

14
sprints/SPRINT_07.md Normal file
View file

@ -0,0 +1,14 @@
Goal: Full hybrid RTC. Parity gate on hybrid scenario.
Tasks:
S7-T01 Schema: BessConfig extended (DoD, RTE, aux, augmentation), DispatchConfig (curtail-vs-MCP toggle).
S7-T02 dispatch/hybrid_rtc.py: per-timestamp dispatch loop. Use Numba @njit for speed if pure-Python is >5s.
S7-T03 dispatch/mcp_settlement.py: optional surplus-to-MCP revenue using a forecast price profile (input as 8760 ₹/MWh).
S7-T04 Update commercial/ppa.py to consume dispatch output (net injection, shortfall).
S7-T05 Hand-validated test: 24-hour scenario with known optimal dispatch. Verify SOC, charge/discharge, shortfall.
S7-T06 Update runner to wire dispatch in.
S7-T07 UI: SOC chart (week-zoomable), RTC CUF achieved KPI prominent.
S7-T08 PARITY GATE: full hybrid RTC scenario tariff matches Excel within 0.5%. RTC CUF within 0.5%.
S7-T09 Documentation.
ESCALATION: Dispatch parity is the hardest. If miss > 0.5%, stop and consult Opus.

15
sprints/SPRINT_08.md Normal file
View file

@ -0,0 +1,15 @@
Goal: v1 prototype shippable. Real-world bid prep ready.
Tasks:
S8-T01 scenarios/sweep.py: Cartesian sweep engine. Parallel via Arq.
S8-T02 Predefined sensitivities (the "frequent 7"). One-click from results page.
S8-T03 Tornado chart (Recharts).
S8-T04 Custom sweep UI: pick params, ranges, steps. DataGrid for results table.
S8-T05 Side-by-side comparison view: pick 2-4 scenarios, KPI diff, statement diff.
S8-T06 io/excel_export.py: full statements + KPIs + inputs to multi-sheet xlsx using openpyxl.
S8-T07 Bug bash: run 5 historical bids. Document discrepancies.
S8-T08 Performance pass: target <30s for single scenario, <10min for 50-scenario sweep.
S8-T09 README polish, screenshots, demo recording.
S8-T10 Final parity validation: all 5 historical bids within 0.5%.
Definition of Done: v0+v1 ready for production bid prep. Excel can be deprecated for solar+wind+BESS hybrid RTC.