[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:
parent
314127effc
commit
e6dc39aa33
82 changed files with 10048 additions and 250 deletions
17
.claude/settings.json
Normal file
17
.claude/settings.json
Normal 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
80
CLAUDE.md
Normal 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
534
CODEBASE_INVESTIGATION.md
Normal 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.*
|
||||||
|
|
@ -58,7 +58,11 @@ no_implicit_reexport = true
|
||||||
files = ["src"]
|
files = ["src"]
|
||||||
|
|
||||||
[[tool.mypy.overrides]]
|
[[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
|
ignore_missing_imports = true
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,17 @@ class Scenario(Base):
|
||||||
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
|
String(36), primary_key=True, default=lambda: str(uuid.uuid4())
|
||||||
)
|
)
|
||||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
status: Mapped[str] = mapped_column(
|
status: Mapped[str] = mapped_column(String(20), nullable=False, default="queued")
|
||||||
String(20), nullable=False, default="queued"
|
|
||||||
)
|
|
||||||
inputs_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
inputs_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
kpis_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(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
)
|
)
|
||||||
|
archived_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from remodel_api import __version__
|
from remodel_api import __version__
|
||||||
from remodel_api.db.session import init_db
|
from remodel_api.db.session import init_db
|
||||||
from remodel_api.routers import scenarios
|
from remodel_api.routers import scenarios, templates
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
|
|
@ -31,6 +31,7 @@ app.add_middleware(
|
||||||
)
|
)
|
||||||
|
|
||||||
app.include_router(scenarios.router, prefix="/api")
|
app.include_router(scenarios.router, prefix="/api")
|
||||||
|
app.include_router(templates.router, prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/healthz", tags=["ops"])
|
@app.get("/healthz", tags=["ops"])
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,15 @@
|
||||||
|
"""Scenarios router: CRUD + run + results endpoints."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
from datetime import datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Annotated, Any
|
from typing import Annotated, Any
|
||||||
|
|
||||||
import arq
|
import arq
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from fastapi.responses import Response
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
@ -20,6 +26,7 @@ SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||||
|
|
||||||
class ScenarioCreate(BaseModel):
|
class ScenarioCreate(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
|
inputs: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
class ScenarioRead(BaseModel):
|
class ScenarioRead(BaseModel):
|
||||||
|
|
@ -28,31 +35,47 @@ class ScenarioRead(BaseModel):
|
||||||
status: str
|
status: str
|
||||||
kpis_json: str | None
|
kpis_json: str | None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
runtime_s: float | None = None
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
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)
|
@router.post("/scenarios", response_model=ScenarioRead, status_code=201)
|
||||||
async def create_scenario(body: ScenarioCreate, db: SessionDep) -> Scenario:
|
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)
|
db.add(scenario)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(scenario)
|
await db.refresh(scenario)
|
||||||
|
|
||||||
pool = await arq.create_pool(arq.connections.RedisSettings.from_dsn(settings.redis_url))
|
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()
|
await pool.aclose()
|
||||||
|
|
||||||
return scenario
|
return scenario
|
||||||
|
|
||||||
|
|
||||||
@router.get("/scenarios", response_model=list[ScenarioRead])
|
@router.get("/scenarios", response_model=list[ScenarioRead])
|
||||||
async def list_scenarios(db: SessionDep) -> list[Scenario]:
|
async def list_scenarios(
|
||||||
result = await db.execute(select(Scenario).order_by(Scenario.created_at.desc()))
|
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())
|
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:
|
async def get_scenario(scenario_id: str, db: SessionDep) -> Scenario:
|
||||||
scenario = await db.get(Scenario, scenario_id)
|
scenario = await db.get(Scenario, scenario_id)
|
||||||
if scenario is None:
|
if scenario is None:
|
||||||
|
|
@ -60,6 +83,131 @@ async def get_scenario(scenario_id: str, db: SessionDep) -> Scenario:
|
||||||
return 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")
|
@router.get("/scenarios/{scenario_id}/events")
|
||||||
async def scenario_events(scenario_id: str) -> EventSourceResponse: # pragma: no cover
|
async def scenario_events(scenario_id: str) -> EventSourceResponse: # pragma: no cover
|
||||||
import redis.asyncio as aioredis
|
import redis.asyncio as aioredis
|
||||||
|
|
|
||||||
25
packages/api/src/remodel_api/routers/templates.py
Normal file
25
packages/api/src/remodel_api/routers/templates.py
Normal 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()}
|
||||||
|
|
@ -1,12 +1,19 @@
|
||||||
|
import asyncio
|
||||||
from typing import ClassVar
|
from typing import ClassVar
|
||||||
|
|
||||||
from arq.connections import RedisSettings
|
from arq.connections import RedisSettings
|
||||||
|
|
||||||
from remodel_api.config import settings
|
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:
|
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)
|
redis_settings: ClassVar[RedisSettings] = RedisSettings.from_dsn(settings.redis_url)
|
||||||
keep_result: ClassVar[int] = 3600
|
keep_result: ClassVar[int] = 3600
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
|
"""Arq worker tasks — run_scenario wraps the real engine in a thread."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import redis.asyncio as aioredis
|
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.models import Scenario
|
||||||
from remodel_api.db.session import AsyncSessionLocal
|
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:
|
async def _publish(r: Any, channel: str, stage: str, pct: int) -> None:
|
||||||
payload = json.dumps({"stage": stage, "pct": pct})
|
payload = json.dumps({"stage": stage, "pct": pct})
|
||||||
await r.publish(channel, payload)
|
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]
|
r = aioredis.from_url(settings.redis_url) # type: ignore[no-untyped-call]
|
||||||
channel = f"scenario:{scenario_id}:events"
|
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:
|
if scenario is None:
|
||||||
await r.aclose()
|
await r.aclose()
|
||||||
return {"error": "not found"}
|
return {"error": "not found"}
|
||||||
|
inputs_json = scenario.inputs_json or "{}"
|
||||||
scenario.status = "running"
|
scenario.status = "running"
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
await _publish(r, channel, "starting", 0)
|
await _publish(r, channel, "starting", 5)
|
||||||
await asyncio.sleep(1)
|
|
||||||
await _publish(r, channel, "computing", 33)
|
loop = asyncio.get_event_loop()
|
||||||
await asyncio.sleep(1)
|
try:
|
||||||
await _publish(r, channel, "computing", 66)
|
await _publish(r, channel, "computing", 20)
|
||||||
await asyncio.sleep(1)
|
engine_result = await loop.run_in_executor(
|
||||||
|
_executor, _run_engine, inputs_json
|
||||||
|
)
|
||||||
await _publish(r, channel, "finishing", 90)
|
await _publish(r, channel, "finishing", 90)
|
||||||
|
|
||||||
result: dict[str, Any] = {"id": scenario_id, "result": "dummy"}
|
timeseries_path: str | None = None
|
||||||
|
timeseries_dir = os.path.join("data", "scenarios", scenario_id)
|
||||||
|
os.makedirs(timeseries_dir, exist_ok=True)
|
||||||
|
|
||||||
async with AsyncSessionLocal() as db:
|
async with AsyncSessionLocal() as db:
|
||||||
scenario = await db.get(Scenario, scenario_id)
|
scenario = await db.get(Scenario, scenario_id)
|
||||||
if scenario is not None:
|
if scenario is not None:
|
||||||
scenario.status = "success"
|
scenario.status = engine_result.get("status", "success")
|
||||||
scenario.kpis_json = json.dumps(result)
|
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 db.commit()
|
||||||
|
|
||||||
await _publish(r, channel, "done", 100)
|
await _publish(r, channel, "done", 100)
|
||||||
await r.aclose()
|
await r.aclose()
|
||||||
return result
|
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)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
|
"""Scenario API integration tests (S5-T10)."""
|
||||||
|
|
||||||
|
import json
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -45,3 +49,116 @@ async def test_list_scenarios_after_create(client: AsyncClient) -> None:
|
||||||
resp = await client.get("/api/scenarios")
|
resp = await client.get("/api/scenarios")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert len(resp.json()) == 2
|
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
|
||||||
|
|
|
||||||
23
packages/api/tests/test_templates.py
Normal file
23
packages/api/tests/test_templates.py
Normal 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
|
||||||
|
|
@ -1,55 +1,69 @@
|
||||||
|
"""Worker task tests (S5-T10)."""
|
||||||
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from remodel_api.db.models import Scenario
|
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 _make_session_mock(scenario: Scenario | None) -> tuple[AsyncMock, MagicMock]:
|
||||||
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")
|
|
||||||
|
|
||||||
session_mock = AsyncMock()
|
session_mock = AsyncMock()
|
||||||
session_mock.__aenter__ = AsyncMock(return_value=session_mock)
|
session_mock.__aenter__ = AsyncMock(return_value=session_mock)
|
||||||
session_mock.__aexit__ = AsyncMock(return_value=False)
|
session_mock.__aexit__ = AsyncMock(return_value=False)
|
||||||
session_mock.get = AsyncMock(return_value=scenario)
|
session_mock.get = AsyncMock(return_value=scenario)
|
||||||
session_mock.commit = AsyncMock()
|
session_mock.commit = AsyncMock()
|
||||||
|
|
||||||
factory_mock = MagicMock()
|
factory_mock = MagicMock()
|
||||||
factory_mock.return_value = session_mock
|
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 (
|
with (
|
||||||
patch("remodel_api.workers.tasks.aioredis.from_url", return_value=mock_redis),
|
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.AsyncSessionLocal", factory_mock),
|
||||||
):
|
):
|
||||||
result = await run_dummy_scenario({}, "dummy-id")
|
result = await run_scenario_task({}, "missing-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")
|
|
||||||
|
|
||||||
assert "error" in result
|
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
3
packages/api/uv.lock
generated
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
version = 1
|
||||||
|
revision = 3
|
||||||
|
requires-python = ">=3.14"
|
||||||
|
|
@ -59,6 +59,10 @@ files = ["src"]
|
||||||
module = ["scipy.*", "numpy_financial.*"]
|
module = ["scipy.*", "numpy_financial.*"]
|
||||||
ignore_missing_imports = true
|
ignore_missing_imports = true
|
||||||
|
|
||||||
|
[[tool.mypy.overrides]]
|
||||||
|
module = ["remodel_engine.catalog.*"]
|
||||||
|
ignore_errors = true
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
addopts = "--cov=remodel_engine --cov-report=term-missing --cov-fail-under=85"
|
addopts = "--cov=remodel_engine --cov-report=term-missing --cov-fail-under=85"
|
||||||
|
|
|
||||||
0
packages/engine/src/remodel_engine/capex/__init__.py
Normal file
0
packages/engine/src/remodel_engine/capex/__init__.py
Normal file
138
packages/engine/src/remodel_engine/capex/cost_items.py
Normal file
138
packages/engine/src/remodel_engine/capex/cost_items.py
Normal 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)
|
||||||
134
packages/engine/src/remodel_engine/capex/idc.py
Normal file
134
packages/engine/src/remodel_engine/capex/idc.py
Normal 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
|
||||||
53
packages/engine/src/remodel_engine/capex/phasing.py
Normal file
53
packages/engine/src/remodel_engine/capex/phasing.py
Normal 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)
|
||||||
386
packages/engine/src/remodel_engine/catalog/cost_items.py
Normal file
386
packages/engine/src/remodel_engine/catalog/cost_items.py
Normal 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
|
||||||
|
)
|
||||||
176
packages/engine/src/remodel_engine/catalog/phasing.py
Normal file
176
packages/engine/src/remodel_engine/catalog/phasing.py
Normal 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()),
|
||||||
|
}
|
||||||
|
|
@ -59,5 +59,103 @@ def simulate_gen(
|
||||||
typer.echo(f"Wrote {len(combined):,} rows → {output_file}")
|
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:
|
def main() -> None:
|
||||||
app()
|
app()
|
||||||
|
|
|
||||||
41
packages/engine/src/remodel_engine/commercial/ppa.py
Normal file
41
packages/engine/src/remodel_engine/commercial/ppa.py
Normal 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
|
||||||
|
]
|
||||||
0
packages/engine/src/remodel_engine/debt/__init__.py
Normal file
0
packages/engine/src/remodel_engine/debt/__init__.py
Normal file
135
packages/engine/src/remodel_engine/debt/schedule.py
Normal file
135
packages/engine/src/remodel_engine/debt/schedule.py
Normal 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
|
||||||
95
packages/engine/src/remodel_engine/debt/sizing.py
Normal file
95
packages/engine/src/remodel_engine/debt/sizing.py
Normal 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)
|
||||||
0
packages/engine/src/remodel_engine/dispatch/__init__.py
Normal file
0
packages/engine/src/remodel_engine/dispatch/__init__.py
Normal file
142
packages/engine/src/remodel_engine/dispatch/hybrid_rtc.py
Normal file
142
packages/engine/src/remodel_engine/dispatch/hybrid_rtc.py
Normal 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,
|
||||||
|
)
|
||||||
|
|
@ -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)
|
||||||
|
]
|
||||||
0
packages/engine/src/remodel_engine/financial/__init__.py
Normal file
0
packages/engine/src/remodel_engine/financial/__init__.py
Normal file
66
packages/engine/src/remodel_engine/financial/bs.py
Normal file
66
packages/engine/src/remodel_engine/financial/bs.py
Normal 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
|
||||||
60
packages/engine/src/remodel_engine/financial/cfs.py
Normal file
60
packages/engine/src/remodel_engine/financial/cfs.py
Normal 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
|
||||||
168
packages/engine/src/remodel_engine/financial/depreciation.py
Normal file
168
packages/engine/src/remodel_engine/financial/depreciation.py
Normal 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,
|
||||||
|
)
|
||||||
149
packages/engine/src/remodel_engine/financial/pnl.py
Normal file
149
packages/engine/src/remodel_engine/financial/pnl.py
Normal 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
|
||||||
62
packages/engine/src/remodel_engine/financial/tax.py
Normal file
62
packages/engine/src/remodel_engine/financial/tax.py
Normal 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
|
||||||
|
|
@ -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
|
||||||
0
packages/engine/src/remodel_engine/io/__init__.py
Normal file
0
packages/engine/src/remodel_engine/io/__init__.py
Normal file
247
packages/engine/src/remodel_engine/io/excel_export.py
Normal file
247
packages/engine/src/remodel_engine/io/excel_export.py
Normal 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)
|
||||||
0
packages/engine/src/remodel_engine/irr/__init__.py
Normal file
0
packages/engine/src/remodel_engine/irr/__init__.py
Normal file
167
packages/engine/src/remodel_engine/irr/metrics.py
Normal file
167
packages/engine/src/remodel_engine/irr/metrics.py
Normal 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,
|
||||||
|
)
|
||||||
0
packages/engine/src/remodel_engine/py.typed
Normal file
0
packages/engine/src/remodel_engine/py.typed
Normal file
0
packages/engine/src/remodel_engine/scenarios/__init__.py
Normal file
0
packages/engine/src/remodel_engine/scenarios/__init__.py
Normal file
475
packages/engine/src/remodel_engine/scenarios/runner.py
Normal file
475
packages/engine/src/remodel_engine/scenarios/runner.py
Normal 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,
|
||||||
|
)
|
||||||
211
packages/engine/src/remodel_engine/scenarios/sweep.py
Normal file
211
packages/engine/src/remodel_engine/scenarios/sweep.py
Normal 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
|
||||||
115
packages/engine/src/remodel_engine/schemas/capex.py
Normal file
115
packages/engine/src/remodel_engine/schemas/capex.py
Normal 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")
|
||||||
58
packages/engine/src/remodel_engine/schemas/debt.py
Normal file
58
packages/engine/src/remodel_engine/schemas/debt.py
Normal 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
|
||||||
125
packages/engine/src/remodel_engine/schemas/financial.py
Normal file
125
packages/engine/src/remodel_engine/schemas/financial.py
Normal 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]
|
||||||
|
|
@ -6,6 +6,8 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
class SolarConfig(BaseModel):
|
class SolarConfig(BaseModel):
|
||||||
"""Configuration for a single solar plant."""
|
"""Configuration for a single solar plant."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
location_id: str = Field("RJ", description="Profile key: RJ | KA | GJ")
|
location_id: str = Field("RJ", description="Profile key: RJ | KA | GJ")
|
||||||
capacity_dc_mwp: float = Field(..., gt=0, description="Total DC capacity (MWp)")
|
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)")
|
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")
|
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_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")
|
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")
|
@field_validator("capacity_ac_mw")
|
||||||
@classmethod
|
@classmethod
|
||||||
def ac_le_dc(cls, v: float, info: object) -> float:
|
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
|
return v
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def dc_ac_ratio(self) -> float:
|
def computed_dc_ac_ratio(self) -> float:
|
||||||
return self.capacity_dc_mwp / self.capacity_ac_mw
|
return self.capacity_dc_mwp / self.capacity_ac_mw
|
||||||
|
|
||||||
|
|
||||||
class WindConfig(BaseModel):
|
class WindConfig(BaseModel):
|
||||||
"""Configuration for a single wind plant."""
|
"""Configuration for a single wind plant."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
location_id: str = Field("RJ", description="Profile key: RJ | KA | GJ")
|
location_id: str = Field("RJ", description="Profile key: RJ | KA | GJ")
|
||||||
capacity_mw: float = Field(..., gt=0, description="Nameplate capacity (MW)")
|
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)")
|
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(
|
degradation_annual: float = Field(
|
||||||
0.002, ge=0, lt=1, description="Annual output degradation (0.2%/yr)"
|
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):
|
class BessConfig(BaseModel):
|
||||||
|
|
@ -56,6 +75,7 @@ class BessConfig(BaseModel):
|
||||||
capacity_mwh: float = Field(..., gt=0, description="Nameplate energy capacity (MWh)")
|
capacity_mwh: float = Field(..., gt=0, description="Nameplate energy capacity (MWh)")
|
||||||
power_mw: float = Field(..., gt=0, description="Maximum charge / discharge power (MW)")
|
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")
|
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
|
# Degradation: linear from 100% SOH to eol_soh over design_cycles
|
||||||
design_cycles: float = Field(
|
design_cycles: float = Field(
|
||||||
6000.0, gt=0, description="Manufacturer design cycle life"
|
6000.0, gt=0, description="Manufacturer design cycle life"
|
||||||
|
|
|
||||||
106
packages/engine/src/remodel_engine/schemas/scenario.py
Normal file
106
packages/engine/src/remodel_engine/schemas/scenario.py
Normal 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)
|
||||||
0
packages/engine/src/remodel_engine/solver/__init__.py
Normal file
0
packages/engine/src/remodel_engine/solver/__init__.py
Normal file
50
packages/engine/src/remodel_engine/solver/tariff.py
Normal file
50
packages/engine/src/remodel_engine/solver/tariff.py
Normal 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)
|
||||||
267
packages/engine/tests/unit/test_capex.py
Normal file
267
packages/engine/tests/unit/test_capex.py
Normal 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
|
||||||
|
|
@ -39,8 +39,7 @@ def wind_scenario(tmp_path: Path) -> Path:
|
||||||
|
|
||||||
|
|
||||||
def _invoke(scenario: Path, out: Path) -> object:
|
def _invoke(scenario: Path, out: Path) -> object:
|
||||||
# Single-command Typer app: invoke without the subcommand name
|
return runner.invoke(app, ["simulate-gen", "--input", str(scenario), "--output", str(out)])
|
||||||
return runner.invoke(app, ["--input", str(scenario), "--output", str(out)])
|
|
||||||
|
|
||||||
|
|
||||||
def test_simulate_gen_solar(solar_scenario: Path, tmp_path: Path) -> None:
|
def test_simulate_gen_solar(solar_scenario: Path, tmp_path: Path) -> None:
|
||||||
|
|
|
||||||
223
packages/engine/tests/unit/test_debt_irr.py
Normal file
223
packages/engine/tests/unit/test_debt_irr.py
Normal 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
|
||||||
184
packages/engine/tests/unit/test_dispatch.py
Normal file
184
packages/engine/tests/unit/test_dispatch.py
Normal 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
|
||||||
56
packages/engine/tests/unit/test_excel_export.py
Normal file
56
packages/engine/tests/unit/test_excel_export.py
Normal 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
|
||||||
296
packages/engine/tests/unit/test_financial.py
Normal file
296
packages/engine/tests/unit/test_financial.py
Normal 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,
|
||||||
|
)
|
||||||
162
packages/engine/tests/unit/test_runner.py
Normal file
162
packages/engine/tests/unit/test_runner.py
Normal 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
|
||||||
77
packages/engine/tests/unit/test_sweep.py
Normal file
77
packages/engine/tests/unit/test_sweep.py
Normal 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)
|
||||||
79
packages/web/app/compare/page.tsx
Normal file
79
packages/web/app/compare/page.tsx
Normal 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 2–4 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -50,71 +50,71 @@
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(1 0 0);
|
||||||
--foreground: oklch(0.145 0 0);
|
--foreground: oklch(0.15 0.01 262);
|
||||||
--card: oklch(1 0 0);
|
--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: oklch(1 0 0);
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
--popover-foreground: oklch(0.15 0.01 262);
|
||||||
--primary: oklch(0.205 0 0);
|
--primary: oklch(0.47 0.22 262);
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
--primary-foreground: oklch(0.99 0 0);
|
||||||
--secondary: oklch(0.97 0 0);
|
--secondary: oklch(0.96 0.01 262);
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
--secondary-foreground: oklch(0.25 0.05 262);
|
||||||
--muted: oklch(0.97 0 0);
|
--muted: oklch(0.96 0.005 262);
|
||||||
--muted-foreground: oklch(0.556 0 0);
|
--muted-foreground: oklch(0.52 0.04 262);
|
||||||
--accent: oklch(0.97 0 0);
|
--accent: oklch(0.94 0.015 262);
|
||||||
--accent-foreground: oklch(0.205 0 0);
|
--accent-foreground: oklch(0.30 0.10 262);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--border: oklch(0.922 0 0);
|
--border: oklch(0.90 0.01 262);
|
||||||
--input: oklch(0.922 0 0);
|
--input: oklch(0.90 0.01 262);
|
||||||
--ring: oklch(0.708 0 0);
|
--ring: oklch(0.47 0.22 262);
|
||||||
--chart-1: oklch(0.87 0 0);
|
--chart-1: oklch(0.52 0.22 262);
|
||||||
--chart-2: oklch(0.556 0 0);
|
--chart-2: oklch(0.60 0.17 178);
|
||||||
--chart-3: oklch(0.439 0 0);
|
--chart-3: oklch(0.70 0.18 55);
|
||||||
--chart-4: oklch(0.371 0 0);
|
--chart-4: oklch(0.60 0.22 15);
|
||||||
--chart-5: oklch(0.269 0 0);
|
--chart-5: oklch(0.55 0.18 308);
|
||||||
--radius: 0.625rem;
|
--radius: 0.5rem;
|
||||||
--sidebar: oklch(0.985 0 0);
|
--sidebar: oklch(0.975 0.006 262);
|
||||||
--sidebar-foreground: oklch(0.145 0 0);
|
--sidebar-foreground: oklch(0.20 0.04 262);
|
||||||
--sidebar-primary: oklch(0.205 0 0);
|
--sidebar-primary: oklch(0.47 0.22 262);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(0.99 0 0);
|
||||||
--sidebar-accent: oklch(0.97 0 0);
|
--sidebar-accent: oklch(0.93 0.02 262);
|
||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
--sidebar-accent-foreground: oklch(0.30 0.10 262);
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
--sidebar-border: oklch(0.88 0.015 262);
|
||||||
--sidebar-ring: oklch(0.708 0 0);
|
--sidebar-ring: oklch(0.47 0.22 262);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: oklch(0.145 0 0);
|
--background: oklch(0.13 0.01 262);
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.96 0.005 262);
|
||||||
--card: oklch(0.205 0 0);
|
--card: oklch(0.18 0.015 262);
|
||||||
--card-foreground: oklch(0.985 0 0);
|
--card-foreground: oklch(0.96 0.005 262);
|
||||||
--popover: oklch(0.205 0 0);
|
--popover: oklch(0.18 0.015 262);
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
--popover-foreground: oklch(0.96 0.005 262);
|
||||||
--primary: oklch(0.922 0 0);
|
--primary: oklch(0.68 0.20 262);
|
||||||
--primary-foreground: oklch(0.205 0 0);
|
--primary-foreground: oklch(0.12 0.02 262);
|
||||||
--secondary: oklch(0.269 0 0);
|
--secondary: oklch(0.24 0.02 262);
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
--secondary-foreground: oklch(0.96 0.005 262);
|
||||||
--muted: oklch(0.269 0 0);
|
--muted: oklch(0.24 0.02 262);
|
||||||
--muted-foreground: oklch(0.708 0 0);
|
--muted-foreground: oklch(0.65 0.05 262);
|
||||||
--accent: oklch(0.269 0 0);
|
--accent: oklch(0.28 0.03 262);
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
--accent-foreground: oklch(0.96 0.005 262);
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
--border: oklch(1 0 0 / 10%);
|
--border: oklch(1 0 0 / 10%);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--input: oklch(1 0 0 / 12%);
|
||||||
--ring: oklch(0.556 0 0);
|
--ring: oklch(0.68 0.20 262);
|
||||||
--chart-1: oklch(0.87 0 0);
|
--chart-1: oklch(0.65 0.20 262);
|
||||||
--chart-2: oklch(0.556 0 0);
|
--chart-2: oklch(0.68 0.15 178);
|
||||||
--chart-3: oklch(0.439 0 0);
|
--chart-3: oklch(0.75 0.17 55);
|
||||||
--chart-4: oklch(0.371 0 0);
|
--chart-4: oklch(0.65 0.20 15);
|
||||||
--chart-5: oklch(0.269 0 0);
|
--chart-5: oklch(0.65 0.17 308);
|
||||||
--sidebar: oklch(0.205 0 0);
|
--sidebar: oklch(0.16 0.015 262);
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
--sidebar-foreground: oklch(0.90 0.01 262);
|
||||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
--sidebar-primary: oklch(0.68 0.20 262);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(0.12 0.02 262);
|
||||||
--sidebar-accent: oklch(0.269 0 0);
|
--sidebar-accent: oklch(0.24 0.025 262);
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-accent-foreground: oklch(0.90 0.01 262);
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
--sidebar-ring: oklch(0.556 0 0);
|
--sidebar-ring: oklch(0.68 0.20 262);
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { Providers } from "./providers";
|
import { Providers } from "./providers";
|
||||||
|
import AgentationWrapper from "@/components/AgentationWrapper";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
|
|
@ -30,6 +31,7 @@ export default function RootLayout({
|
||||||
>
|
>
|
||||||
<body className="min-h-full flex flex-col bg-background text-foreground">
|
<body className="min-h-full flex flex-col bg-background text-foreground">
|
||||||
<Providers>{children}</Providers>
|
<Providers>{children}</Providers>
|
||||||
|
<AgentationWrapper />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -3,42 +3,92 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
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 { 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 router = useRouter();
|
||||||
const statusColor =
|
|
||||||
scenario.status === "success"
|
const kpis = scenario.kpis_json
|
||||||
? "text-green-600"
|
? (() => {
|
||||||
: scenario.status === "failed"
|
try {
|
||||||
? "text-red-600"
|
return JSON.parse(scenario.kpis_json) as Record<string, number | null>;
|
||||||
: scenario.status === "running"
|
} catch {
|
||||||
? "text-blue-600"
|
return null;
|
||||||
: "text-yellow-600";
|
}
|
||||||
|
})()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const tariff = kpis?.solved_tariff_inr_per_kwh;
|
||||||
|
const irr = kpis?.equity_irr;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<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}`)}
|
onClick={() => router.push(`/scenarios/${scenario.id}`)}
|
||||||
>
|
>
|
||||||
<td className="py-3 px-4 font-mono text-xs text-muted-foreground">
|
<td className="py-3 px-4 font-medium">{scenario.name}</td>
|
||||||
{scenario.id.slice(0, 8)}…
|
<td className="py-3 px-4">
|
||||||
|
<StatusBadge status={scenario.status} />
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 px-4">{scenario.name}</td>
|
<td className="py-3 px-4 tabular-nums">
|
||||||
<td className={`py-3 px-4 font-medium capitalize ${statusColor}`}>
|
{tariff != null ? `₹${tariff.toFixed(2)}/kWh` : "—"}
|
||||||
{scenario.status}
|
|
||||||
</td>
|
</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()}
|
{new Date(scenario.created_at).toLocaleString()}
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [creating, setCreating] = useState(false);
|
const [showWizard, setShowWizard] = useState(false);
|
||||||
|
|
||||||
const { data: scenarios, refetch } = useQuery({
|
const { data: scenarios, refetch } = useQuery({
|
||||||
queryKey: ["scenarios"],
|
queryKey: ["scenarios"],
|
||||||
|
|
@ -46,17 +96,31 @@ export default function HomePage() {
|
||||||
refetchInterval: 5000,
|
refetchInterval: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
async function handleNewScenario() {
|
async function handleWizardSubmit(
|
||||||
setCreating(true);
|
name: string,
|
||||||
try {
|
inputs: ScenarioInputPayload,
|
||||||
const scenario = await createScenario(
|
) {
|
||||||
`Scenario ${new Date().toLocaleTimeString()}`,
|
const scenario = await createScenario(name, inputs);
|
||||||
);
|
|
||||||
await refetch();
|
await refetch();
|
||||||
router.push(`/scenarios/${scenario.id}`);
|
router.push(`/scenarios/${scenario.id}`);
|
||||||
} finally {
|
|
||||||
setCreating(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
|
|
@ -65,41 +129,49 @@ export default function HomePage() {
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">REmodel</h1>
|
<h1 className="text-2xl font-bold tracking-tight">REmodel</h1>
|
||||||
<p className="text-muted-foreground mt-1">
|
<p className="text-muted-foreground mt-1">
|
||||||
Hybrid RE project finance scenarios
|
Hybrid RE project finance — Solar + Wind + BESS
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={handleNewScenario} disabled={creating}>
|
<div className="flex gap-2">
|
||||||
{creating ? "Creating…" : "New Dummy Scenario"}
|
<Button variant="outline" onClick={() => router.push("/compare")}>Compare</Button>
|
||||||
</Button>
|
<Button onClick={() => setShowWizard(true)}>+ New Scenario</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!scenarios || scenarios.length === 0 ? (
|
{!scenarios || scenarios.length === 0 ? (
|
||||||
<div className="text-center text-muted-foreground py-24 border rounded-lg">
|
<div className="text-center text-muted-foreground py-24 border rounded-lg">
|
||||||
No scenarios yet — click “New Dummy Scenario” to
|
No scenarios yet — click “New Scenario” to start.
|
||||||
start.
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="border rounded-lg overflow-hidden">
|
<div className="border rounded-lg overflow-hidden">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full">
|
||||||
<thead className="bg-muted/50">
|
<thead className="bg-muted/50">
|
||||||
<tr>
|
<tr>
|
||||||
<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">
|
||||||
ID
|
|
||||||
</th>
|
|
||||||
<th className="py-2 px-4 text-left font-medium text-muted-foreground">
|
|
||||||
Name
|
Name
|
||||||
</th>
|
</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
|
Status
|
||||||
</th>
|
</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
|
Created
|
||||||
</th>
|
</th>
|
||||||
|
<th />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{scenarios.map((s) => (
|
{scenarios.map((s) => (
|
||||||
<ScenarioRow key={s.id} scenario={s} />
|
<ScenarioRow
|
||||||
|
key={s.id}
|
||||||
|
scenario={s}
|
||||||
|
onArchive={handleArchive}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
|
||||||
|
|
@ -2,107 +2,305 @@
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { getScenario, scenarioEventsUrl, type ProgressEvent } from "@/lib/api";
|
import {
|
||||||
|
getScenario,
|
||||||
|
getKpis,
|
||||||
|
scenarioEventsUrl,
|
||||||
|
scenarioExcelUrl,
|
||||||
|
type ProgressEvent,
|
||||||
|
} from "@/lib/api";
|
||||||
import { Button } from "@/components/ui/button";
|
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 }) {
|
function ProgressBar({ pct }: { pct: number }) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full bg-muted rounded-full h-3 overflow-hidden">
|
<div className="h-0.5 bg-muted overflow-hidden">
|
||||||
<div
|
<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}%` }}
|
style={{ width: `${pct}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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() {
|
export default function ScenarioPage() {
|
||||||
const params = useParams<{ id: string }>();
|
const params = useParams<{ id: string }>();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const id = params.id;
|
const id = params.id;
|
||||||
|
|
||||||
|
const [activeSheet, setActiveSheet] = useState<ActiveSheet>("inputs");
|
||||||
const [progress, setProgress] = useState<ProgressEvent | null>(null);
|
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],
|
queryKey: ["scenario", id],
|
||||||
queryFn: () => getScenario(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(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
|
setSseOpen(true);
|
||||||
const es = new EventSource(scenarioEventsUrl(id));
|
const es = new EventSource(scenarioEventsUrl(id));
|
||||||
|
|
||||||
es.onmessage = (event: MessageEvent<string>) => {
|
es.onmessage = (event: MessageEvent<string>) => {
|
||||||
const data = JSON.parse(event.data) as ProgressEvent;
|
const data = JSON.parse(event.data) as ProgressEvent;
|
||||||
setProgress(data);
|
setProgress(data);
|
||||||
if (data.stage === "done") {
|
if (data.stage === "done" || data.stage === "error") {
|
||||||
setDone(true);
|
setSseOpen(false);
|
||||||
es.close();
|
es.close();
|
||||||
void refetch();
|
void refetchScenario();
|
||||||
|
void refetchKpis();
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["statements", id] });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
es.onerror = () => {
|
||||||
es.onerror = () => es.close();
|
setSseOpen(false);
|
||||||
|
es.close();
|
||||||
|
};
|
||||||
return () => es.close();
|
return () => es.close();
|
||||||
}, [id, refetch]);
|
}, [id, refetchScenario, refetchKpis, queryClient]);
|
||||||
|
|
||||||
const statusColor =
|
const isRunning = scenario?.status === "queued" || scenario?.status === "running";
|
||||||
scenario?.status === "success"
|
const hasResults = scenario?.status === "success" && kpis != null;
|
||||||
? "text-green-600"
|
|
||||||
: scenario?.status === "failed"
|
|
||||||
? "text-red-600"
|
|
||||||
: scenario?.status === "running"
|
|
||||||
? "text-blue-600"
|
|
||||||
: "text-yellow-600";
|
|
||||||
|
|
||||||
const kpis = scenario?.kpis_json
|
function handleInputsSaved() {
|
||||||
? (JSON.parse(scenario.kpis_json) as Record<string, unknown>)
|
setProgress(null);
|
||||||
: null;
|
void refetchScenario();
|
||||||
|
setActiveSheet("summary");
|
||||||
|
}
|
||||||
|
|
||||||
|
function nav(sheet: ActiveSheet) {
|
||||||
|
if (sheet !== "inputs" && !hasResults) return;
|
||||||
|
setActiveSheet(sheet);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex-1 container mx-auto px-4 py-8 max-w-3xl">
|
<div className="flex flex-col h-screen bg-background">
|
||||||
<div className="mb-6">
|
{/* ── Top header ─────────────────────────────────────────── */}
|
||||||
<Button variant="ghost" size="sm" onClick={() => router.push("/")}>
|
<header className="flex items-center gap-3 px-4 py-2.5 border-b bg-card shrink-0">
|
||||||
← Back
|
<button
|
||||||
</Button>
|
onClick={() => router.push("/")}
|
||||||
</div>
|
className="text-muted-foreground hover:text-foreground text-sm transition-colors"
|
||||||
|
>
|
||||||
<h1 className="text-xl font-bold mb-1">
|
← Back
|
||||||
|
</button>
|
||||||
|
<div className="w-px h-4 bg-border" />
|
||||||
|
<h1 className="font-semibold text-sm truncate flex-1">
|
||||||
{scenario?.name ?? "Loading…"}
|
{scenario?.name ?? "Loading…"}
|
||||||
</h1>
|
</h1>
|
||||||
<p className={`text-sm font-medium capitalize mb-6 ${statusColor}`}>
|
{scenario && <StatusBadge status={scenario.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>
|
</p>
|
||||||
|
{RESULT_SHEETS.map((s) => (
|
||||||
|
<SidebarItem
|
||||||
|
key={s.id}
|
||||||
|
label={s.label}
|
||||||
|
active={activeSheet === s.id}
|
||||||
|
disabled={!hasResults}
|
||||||
|
onClick={() => nav(s.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
{(scenario?.status === "queued" || scenario?.status === "running") && (
|
{/* Content area */}
|
||||||
<div className="mb-6">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="flex justify-between text-sm text-muted-foreground mb-2">
|
{activeSheet === "inputs" ? (
|
||||||
<span>{progress?.stage ?? "waiting…"}</span>
|
<div className="p-6 max-w-3xl">
|
||||||
<span>{progress?.pct ?? 0}%</span>
|
{scenario?.inputs_json != null ? (
|
||||||
</div>
|
<InputsTab
|
||||||
<ProgressBar pct={progress?.pct ?? 0} />
|
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>
|
||||||
{scenario?.status === "success" && kpis && (
|
) : (
|
||||||
<div className="border rounded-lg p-6">
|
<div className="p-6">
|
||||||
<h2 className="font-semibold mb-4">Result</h2>
|
{hasResults ? (
|
||||||
<pre className="text-sm text-muted-foreground bg-muted/50 p-4 rounded overflow-auto">
|
<WorkbookView
|
||||||
{JSON.stringify(kpis, null, 2)}
|
scenarioId={id}
|
||||||
</pre>
|
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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{scenario?.status === "failed" && (
|
|
||||||
<div className="border border-red-200 rounded-lg p-6 text-red-600">
|
|
||||||
Scenario failed. Check worker logs.
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</div>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
13
packages/web/components/AgentationWrapper.tsx
Normal file
13
packages/web/components/AgentationWrapper.tsx
Normal 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 />;
|
||||||
|
}
|
||||||
32
packages/web/components/DataGrid.tsx
Normal file
32
packages/web/components/DataGrid.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
packages/web/components/FeedbackButton.tsx
Normal file
42
packages/web/components/FeedbackButton.tsx
Normal 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;
|
||||||
|
}
|
||||||
1628
packages/web/components/InputsTab.tsx
Normal file
1628
packages/web/components/InputsTab.tsx
Normal file
File diff suppressed because it is too large
Load diff
26
packages/web/components/KpiCard.tsx
Normal file
26
packages/web/components/KpiCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
119
packages/web/components/ScenarioCompare.tsx
Normal file
119
packages/web/components/ScenarioCompare.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
531
packages/web/components/ScenarioWizard.tsx
Normal file
531
packages/web/components/ScenarioWizard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
packages/web/components/TornadoChart.tsx
Normal file
98
packages/web/components/TornadoChart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
675
packages/web/components/WorkbookView.tsx
Normal file
675
packages/web/components/WorkbookView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,289 @@
|
||||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000";
|
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export interface Scenario {
|
export interface Scenario {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
status: string;
|
status: string;
|
||||||
kpis_json: string | null;
|
kpis_json: string | null;
|
||||||
created_at: string;
|
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 {
|
export interface ProgressEvent {
|
||||||
|
|
@ -13,28 +291,66 @@ export interface ProgressEvent {
|
||||||
pct: number;
|
pct: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createScenario(name: string): Promise<Scenario> {
|
// ---------------------------------------------------------------------------
|
||||||
const res = await fetch(`${API_BASE}/api/scenarios`, {
|
// API helpers
|
||||||
method: "POST",
|
// ---------------------------------------------------------------------------
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ name }),
|
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}`);
|
if (!res.ok) throw new Error(`API error ${res.status}: ${await res.text()}`);
|
||||||
return res.json() as Promise<Scenario>;
|
return res.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getScenario(id: string): Promise<Scenario> {
|
// ---------------------------------------------------------------------------
|
||||||
const res = await fetch(`${API_BASE}/api/scenarios/${id}`);
|
// Scenario functions
|
||||||
if (!res.ok) throw new Error(`API error ${res.status}`);
|
// ---------------------------------------------------------------------------
|
||||||
return res.json() as Promise<Scenario>;
|
|
||||||
|
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[]> {
|
export async function listScenarios(): Promise<Scenario[]> {
|
||||||
const res = await fetch(`${API_BASE}/api/scenarios`);
|
return apiFetch<Scenario[]>("/api/scenarios");
|
||||||
if (!res.ok) throw new Error(`API error ${res.status}`);
|
}
|
||||||
return res.json() as Promise<Scenario[]>;
|
|
||||||
|
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 {
|
export function scenarioEventsUrl(id: string): string {
|
||||||
return `${API_BASE}/api/scenarios/${id}/events`;
|
return `${API_BASE}/api/scenarios/${id}/events`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function scenarioExcelUrl(id: string): string {
|
||||||
|
return `${API_BASE}/api/scenarios/${id}/export/excel`;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"agentation": "^3.0.2",
|
||||||
"eslint": "^10.3.0",
|
"eslint": "^10.3.0",
|
||||||
"eslint-config-next": "^16.2.5",
|
"eslint-config-next": "^16.2.5",
|
||||||
"openapi-typescript": "^7.13.0",
|
"openapi-typescript": "^7.13.0",
|
||||||
|
|
|
||||||
19
packages/web/pnpm-lock.yaml
generated
19
packages/web/pnpm-lock.yaml
generated
|
|
@ -69,6 +69,9 @@ importers:
|
||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
specifier: ^19
|
specifier: ^19
|
||||||
version: 19.2.3(@types/react@19.2.14)
|
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:
|
eslint:
|
||||||
specifier: ^10.3.0
|
specifier: ^10.3.0
|
||||||
version: 10.3.0(jiti@2.7.0)
|
version: 10.3.0(jiti@2.7.0)
|
||||||
|
|
@ -1066,6 +1069,17 @@ packages:
|
||||||
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
||||||
engines: {node: '>= 14'}
|
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:
|
ajv-formats@3.0.1:
|
||||||
resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
|
resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|
@ -4227,6 +4241,11 @@ snapshots:
|
||||||
|
|
||||||
agent-base@7.1.4: {}
|
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):
|
ajv-formats@3.0.1(ajv@8.20.0):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
ajv: 8.20.0
|
ajv: 8.20.0
|
||||||
|
|
|
||||||
17
sprints/SPRINT_04.md
Normal file
17
sprints/SPRINT_04.md
Normal 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
15
sprints/SPRINT_05.md
Normal 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
19
sprints/SPRINT_06.md
Normal 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
14
sprints/SPRINT_07.md
Normal 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
15
sprints/SPRINT_08.md
Normal 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.
|
||||||
Loading…
Add table
Reference in a new issue