214 lines
No EOL
12 KiB
Markdown
214 lines
No EOL
12 KiB
Markdown
Project: REmodel
|
||
A Python calculation engine + FastAPI backend + Next.js frontend for Indian renewable energy (Solar + Wind + BESS) project finance modeling. Replaces an Excel-macro workflow currently used by senior finance professionals at ReNew Power for bid preparation.
|
||
Owner: Manohar (senior finance professional, self-taught dev, India). Builds in evening/weekend hours.
|
||
Goal: Compute optimal flat tariff and full 25-year project financials for hybrid RTC RE projects in <30 seconds per scenario, with 50-scenario sensitivity sweeps in <10 minutes.
|
||
Vision
|
||
Three layers of architecture, in order of foundation to UI:
|
||
|
||
Calculation engine (packages/engine) — pip-installable, UI-agnostic Python library. Takes a Pydantic ScenarioInput, returns a Pydantic ScenarioResult. Can be driven by CLI, Jupyter, or the API. This is the durable asset.
|
||
API layer (packages/api) — FastAPI + Arq async workers. Schedules scenario runs, persists results to SQLite + Parquet. Exposes REST + SSE for the UI.
|
||
Web UI (packages/web) — Next.js 14 App Router + shadcn/ui + Tailwind. Configurable KPI dashboard, scenario wizard, results viewer, sensitivity tornado.
|
||
|
||
Domain context (essential reading)
|
||
|
||
Indian RE bidding workflow: developer wins a PPA (power purchase agreement) at a tariff (₹/kWh) bid into auctions (SECI, state DISCOMs). Tariff must clear target equity IRR (typically 15-20%) given debt sizing constraints (D:E ≤ 75:25, DSCR ≥ 1.20).
|
||
Hybrid RTC project: delivers ~24×7 power from a combination of solar + wind + BESS (battery firming). Contracted at a "RTC CUF" — the developer commits to a specific load factor, e.g., 57.64%. Shortfall triggers DSM (deviation settlement mechanism) penalties.
|
||
Construction takes 18-36 months. During this time, debt is drawn progressively, and interest accrues — this is IDC (Interest During Construction). IDC is a major capex item and a primary lever for tariff optimization.
|
||
Indian tax regime: Section 115BAA applies → 22% + cess = 25.17%, no MAT, no carry-forward. Book and tax depreciation differ (SLM book vs WDV 40% tax for solar/wind plant), creating deferred tax.
|
||
Currency: INR Crore (Cr) = 10 million; Lakh = 100 thousand. Capex typically quoted in Cr/MW or INR/Wp.
|
||
|
||
Key architectural decisions (don't relitigate)
|
||
|
||
Engine is UI-agnostic. Importable Python package. Web UI is one of three drivers (web, CLI, notebook). No web logic in engine.
|
||
CostItem table model, not flat fields. Every capex line item is a record with basis (INR/Wp, Cr/MW, Lakh/Acre, etc.), depreciation class, attribution (solar/wind/BESS/common), and a phasing curve.
|
||
Three nested iterations in the solver: tariff (brentq) → debt sizing (fixed-point) → IDC (fixed-point). Each converges in 5-20 iters.
|
||
IDC has independent equity and debt drawdown schedules. Equity can bridge (>100% momentarily), debt can replace equity later (negative cum equity delta). Both editable by user.
|
||
Two compliance knobs for DSCR: raise tariff OR reshape debt schedule. User chooses; system supports both.
|
||
Sync-async hybrid: all scenario runs go through Arq queue (Redis-backed). UI polls/subscribes via SSE. Even single runs use the queue to avoid refactor later.
|
||
Storage: SQLite (v0) + Parquet for timeseries. Migrate to Postgres in v2 when multi-user.
|
||
DataGrid component is a shared React component used in 5+ places (CostItem table, phasing matrix, equity/debt schedules, opex table, debt repayment %). Build once, well.
|
||
Excel parity is the trust gate. Three parity gates at Sprints 2, 3, 4, 7. We don't ship a sprint if its parity gate fails.
|
||
|
||
Tech stack (locked)
|
||
|
||
Backend: Python 3.12, Poetry, Pydantic v2, FastAPI, Arq, Redis, SQLAlchemy + SQLite, Pandas, NumPy, scipy, numpy_financial, pyarrow, openpyxl
|
||
Frontend: Node 20, Next.js 14 App Router, TypeScript strict, shadcn/ui, Tailwind, Recharts, AG Grid Community (for DataGrid), TanStack Query, Zustand (lightweight state)
|
||
Tooling: Ruff, mypy strict, pytest, pytest-cov, pre-commit, GitHub Actions, Docker Compose
|
||
Deployment target (later): Hetzner. Local-first for v0.
|
||
|
||
Repo structure
|
||
remodel/
|
||
├── packages/
|
||
│ ├── engine/ Python package
|
||
│ │ ├── pyproject.toml
|
||
│ │ ├── src/remodel_engine/
|
||
│ │ │ ├── __init__.py
|
||
│ │ │ ├── schemas/ Pydantic models (single source of truth)
|
||
│ │ │ ├── catalog/ Default values, CostItem catalog, profiles
|
||
│ │ │ ├── generation/ solar.py, wind.py, bess_state.py
|
||
│ │ │ ├── dispatch/ hybrid_rtc.py, mcp_settlement.py
|
||
│ │ │ ├── commercial/ ppa.py, dsm.py, charges.py, losses.py
|
||
│ │ │ ├── capex/ cost_items.py, phasing.py, idc.py
|
||
│ │ │ ├── financial/ pnl.py, cfs.py, bs.py, depreciation.py, tax.py, working_capital.py
|
||
│ │ │ ├── debt/ sizing.py, sculpting.py, schedule.py, compliance.py
|
||
│ │ │ ├── irr/ metrics.py
|
||
│ │ │ ├── solver/ tariff.py
|
||
│ │ │ ├── scenarios/ runner.py, sweep.py
|
||
│ │ │ ├── io/ parquet_io.py, excel_export.py
|
||
│ │ │ └── cli.py Typer-based CLI
|
||
│ │ └── tests/
|
||
│ │ ├── fixtures/ Gold scenarios from user's Excel
|
||
│ │ ├── unit/
|
||
│ │ └── integration/
|
||
│ ├── api/
|
||
│ │ ├── pyproject.toml
|
||
│ │ └── src/remodel_api/
|
||
│ │ ├── main.py
|
||
│ │ ├── routers/ scenarios, sensitivities, dashboard, templates
|
||
│ │ ├── workers/ Arq tasks
|
||
│ │ ├── db/ SQLAlchemy models, migrations
|
||
│ │ └── deps.py
|
||
│ └── web/
|
||
│ ├── package.json
|
||
│ ├── next.config.mjs
|
||
│ └── src/
|
||
│ ├── app/
|
||
│ │ ├── (dashboard)/
|
||
│ │ ├── scenarios/
|
||
│ │ └── api-types/ Auto-generated from OpenAPI
|
||
│ ├── components/
|
||
│ │ ├── DataGrid/
|
||
│ │ ├── KpiCard/
|
||
│ │ ├── Wizard/
|
||
│ │ └── Charts/
|
||
│ └── lib/
|
||
├── docker-compose.yml
|
||
├── .github/workflows/ci.yml
|
||
├── PROJECT.md This file
|
||
├── sprints/
|
||
│ ├── SPRINT_00.md
|
||
│ ├── SPRINT_01.md
|
||
│ └── ...
|
||
└── README.md
|
||
Module specs (summary — full detail in sprint files)
|
||
Generation
|
||
|
||
Solar 25y: 8760 profile × scaling × DC losses × inverter × AC losses → clipping at MW_AC → availability → soiling → degradation Y1..Y25
|
||
Wind 25y: power curve lookup at hub-height-corrected wind speed → wakes → electrical → availability → degradation
|
||
BESS state: nameplate × capacity_factor(year) where capacity_factor = degradation_curve(cum_cycles) + augmentation_steps
|
||
|
||
Dispatch (Hybrid RTC)
|
||
|
||
Per timestamp: target = ppa_capacity (flat 24×7), gen = solar+wind, surplus → charge BESS or curtail/MCP, deficit → discharge BESS or shortfall (DSM)
|
||
SOC update with RTE on charge, 1/RTE on discharge, aux load
|
||
User toggle: surplus when BESS full → curtail (zero rev) OR MCP merchant sale (uses MCP forecast input)
|
||
|
||
Commercial
|
||
|
||
Revenue = injection × tariff × (1 - aux_loss) × (1 - tx_loss) × (1 - dsm_loss)
|
||
DSM loss% absorbs RTC penalty exposure (per user requirement)
|
||
Receivables, bad debt provision
|
||
|
||
Capex (CostItem catalog + IDC)
|
||
|
||
~30 standard CostItem records, user-extensible
|
||
Tier 1 fields exposed prominently (module INR/Wp, BOS INR/Wp, WTG Cr/MW, WTG BOP, land Lakh/Acre + lease/yr); Tier 2/3 collapsed
|
||
Phasing matrix: items × construction months, % per cell, rows sum to 100%
|
||
Equity drawdown: cumulative %, allowed >100% temporarily (bridge), settles at 100% by COD
|
||
Debt drawdown: cumulative %, monotonic, settles at 100% by COD
|
||
IDC: monthly waterfall, fixed-point converging IDC + total cost + debt = consistent
|
||
|
||
Financial (3-statement)
|
||
|
||
P&L: Revenue → OpEx → EBITDA → Depreciation → EBIT → Interest → PBT → Tax → PAT
|
||
CFS: indirect method, CFO/CFI/CFF
|
||
BS: assets = liab + equity, reconciled within ₹1 each year
|
||
Depreciation by asset class (book SLM + tax WDV 40% for plant)
|
||
Tax: 115BAA hardcoded v0
|
||
WC: receivable_days, payable_days, inventory_days
|
||
|
||
Debt
|
||
|
||
Sizing: 3 constraints (D:E cap, min DSCR, avg DSCR), take binding
|
||
Schedule shapes: equal_principal, equal_installment, dscr_sculpted, custom_pct_vector, balloon
|
||
Compliance: try sculpting first; if equity_irr below target, raise tariff; if infeasible, warn user
|
||
|
||
Solver
|
||
|
||
brentq on tariff in [2.0, 8.0] ₹/kWh
|
||
Inner: debt sizing fixed-point
|
||
Innermost: IDC fixed-point
|
||
|
||
Sensitivities (the "frequent 7")
|
||
|
||
CAPEX, Module price, WTG price, Interest rate, CUF, Tariff, DSCR target
|
||
|
||
Pydantic schema sketch (canonical types)
|
||
pythonclass CostItem(BaseModel):
|
||
id: str
|
||
name: str
|
||
category: Literal["BOS","HardCost","SoftCost","EPCOverhead","EPCMargin","FinancingCost"]
|
||
basis: Literal["PER_WP_DC","PER_MW_AC","PER_MWP_DC","PER_ACRE","PER_KWH_USD","PCT_OF_HARDCOST","ABS_INR_CR","PER_MW_SOLAR","PER_MW_WIND"]
|
||
value: float
|
||
fx_rate: Optional[float] = None
|
||
depr_class: Literal["Plant","BESS","Building","Land_NoDepr","LandLease_Amortized","Intangible","Capitalized_NoDepr","Expensed"]
|
||
escalation_pct: float = 0.0
|
||
phasing_id: str
|
||
attribution: Literal["SolarOnly","WindOnly","BESSOnly","Common"]
|
||
|
||
class ScenarioInput(BaseModel):
|
||
project: ProjectInfo
|
||
solar: Optional[SolarConfig]
|
||
wind: Optional[WindConfig]
|
||
bess: Optional[BessConfig]
|
||
commercial: CommercialConfig
|
||
capex: CapexConfig # contains list[CostItem] + phasing curves + equity/debt curves
|
||
opex: OpexConfig
|
||
debt: DebtConfig # rate, tenor, target_dscr, schedule_shape, ...
|
||
tax: TaxConfig
|
||
solver: SolverConfig # target_equity_irr OR fixed_tariff
|
||
|
||
class ScenarioResult(BaseModel):
|
||
inputs: ScenarioInput
|
||
status: Literal["queued","running","success","failed"]
|
||
solved_tariff: Optional[float]
|
||
kpis: KpiSummary # equity_irr, project_irr, min_dscr, avg_dscr, rtc_cuf, capex, idc, ...
|
||
financials: Financials # pnl_25y, cfs_25y, bs_25y as DataFrames
|
||
timeseries_uri: str # parquet path
|
||
debt_schedule: list[DebtYearRow]
|
||
warnings: list[str]
|
||
runtime_s: float
|
||
Working agreements
|
||
|
||
Always read PROJECT.md and the active SPRINT_XX.md at session start. The sprint file lists tasks; pick one, complete it, mark it done, commit.
|
||
One task per commit. Commit message format: [S2-T03] Implement IDC fixed-point solver.
|
||
Tests before features. Every public function gets a unit test. Every module gets an integration test. Coverage target ≥85%.
|
||
Type strict. mypy strict mode. No Any except at JSON boundaries.
|
||
Lint clean. Ruff pre-commit hook; CI fails on lint errors.
|
||
Pydantic for all I/O. No raw dicts crossing module boundaries.
|
||
No magic numbers. Defaults live in catalog/defaults.py.
|
||
Comments explain WHY, not WHAT. Code shows what; comments show why a non-obvious decision was made.
|
||
When in doubt, ask the user. Don't silently make architectural choices. Add a # DECISION NEEDED: comment with options.
|
||
Excel parity is sacred. When a parity test fails, stop adding features. Debug the diff. Don't paper over with tolerance bumps.
|
||
Don't optimize prematurely. Vectorize when slow. Pure-Python loops are fine for v0 if they pass tests.
|
||
Frontend types come from backend. Run openapi-typescript in CI. Never hand-write API types.
|
||
One DataGrid component. Don't create a second grid implementation, no matter how convenient.
|
||
Reference scenarios live in tests/fixtures/. When the user supplies new ones, add them as new fixtures, never modify existing.
|
||
README in every package. Setup steps, common commands, troubleshooting.
|
||
|
||
Definition of Done (per sprint)
|
||
|
||
All sprint tasks ticked off in SPRINT_XX.md
|
||
All tests pass locally and in CI
|
||
Coverage ≥85% on new code
|
||
Parity gate (if applicable) passes
|
||
Sprint demo runnable (CLI command or UI flow)
|
||
Sprint retro added to SPRINT_XX.md (what worked, what didn't, carryover)
|
||
|
||
When to bring back Opus
|
||
Stop and ask the user to consult Opus when:
|
||
|
||
A parity test fails by >0.5% and the cause isn't obvious in 30 minutes
|
||
An architectural decision arises that wasn't covered in PROJECT.md or sprint specs
|
||
A module is shaping up to be >800 LOC — likely needs decomposition
|
||
Performance is >2× the budget for a sprint deliverable |