diff --git a/packages/api/src/remodel_api/workers/tasks.py b/packages/api/src/remodel_api/workers/tasks.py index bc6afbd..c90866f 100644 --- a/packages/api/src/remodel_api/workers/tasks.py +++ b/packages/api/src/remodel_api/workers/tasks.py @@ -59,6 +59,13 @@ def _run_engine(inputs_json: str) -> dict[str, Any]: # Hourly generation: 25 years × 8760 hours "solar_hourly": result.solar_hourly, "wind_hourly": result.wind_hourly, + # Hourly timeseries + "hourly_timestamps": result.hourly_timestamps, + "hourly_fy": result.hourly_fy, + "hourly_proj_year": result.hourly_proj_year, + "hourly_total_re": result.hourly_total_re, + "hourly_client_end": result.hourly_client_end, + "hourly_load": result.hourly_load, }, "debt_schedule": [r.model_dump() for r in result.debt_schedule], "irr_metrics": result.irr_metrics.model_dump(), diff --git a/packages/engine/src/remodel_engine/scenarios/runner.py b/packages/engine/src/remodel_engine/scenarios/runner.py index 08b17ff..38f4615 100644 --- a/packages/engine/src/remodel_engine/scenarios/runner.py +++ b/packages/engine/src/remodel_engine/scenarios/runner.py @@ -479,6 +479,56 @@ def run_scenario(inputs: ScenarioInput) -> ScenarioResult: ) idc_phasing = _build_idc_phasing(inputs.capex, pipe.base_capex) if pipe.base_capex > 0 else {} + # Build hourly timeseries data + from datetime import datetime, timedelta + + cod_year = inputs.project.cod_year + # Default client load to ppa_capacity if not set + client_load_mw = inputs.commercial.client_load_mw + if client_load_mw is None: + client_load_mw = inputs.commercial.ppa_capacity_mw + + # Transmission loss factor for client_end + loss_factor = 1.0 - inputs.commercial.transmission_loss_pct - inputs.commercial.dsm_loss_pct + + # Generate hourly data: 25 years × 8760 hours = 219,000 rows + # Start from Apr 1 of cod_year + hourly_timestamps: list[str] = [] + hourly_fy: list[str] = [] + hourly_proj_year: list[int] = [] + hourly_total_re: list[float] = [] + hourly_client_end: list[float] = [] + hourly_load: list[float] = [] + + # Month lengths for non-leap year starting Apr 1 + month_lengths = [31, 30, 31, 30, 31, 31, 30, 31, 30, 31, 30, 31] # Apr-Mar + + for year in range(25): + proj_year = year + 1 + fy_start = cod_year + year + fy_label = f"{fy_start}-{str(fy_start + 1)[-2:]}" + + # Generate timestamps for this year + hour_idx = 0 + for month in range(12): + month_len = month_lengths[month] + for day in range(1, month_len + 1): + for hour in range(24): + # ISO timestamp + month_num = month + 4 if month < 8 else month - 8 + year_adj = fy_start + 1 if month >= 8 else fy_start + dt = f"{year_adj:04d}-{month_num:02d}-{day:02d}T{hour:02d}:00:00" + hourly_timestamps.append(dt) + hourly_fy.append(fy_label) + hourly_proj_year.append(proj_year) + + idx = year * 8760 + hour_idx + total_re = pipe.solar_hourly[idx] + pipe.wind_hourly[idx] + hourly_total_re.append(total_re) + hourly_client_end.append(total_re * loss_factor) + hourly_load.append(client_load_mw) + hour_idx += 1 + return ScenarioResult( inputs=inputs, status="success", @@ -493,4 +543,10 @@ def run_scenario(inputs: ScenarioInput) -> ScenarioResult: idc_phasing=idc_phasing, solar_hourly=pipe.solar_hourly, wind_hourly=pipe.wind_hourly, + hourly_timestamps=hourly_timestamps, + hourly_fy=hourly_fy, + hourly_proj_year=hourly_proj_year, + hourly_total_re=hourly_total_re, + hourly_client_end=hourly_client_end, + hourly_load=hourly_load, ) diff --git a/packages/engine/src/remodel_engine/schemas/financial.py b/packages/engine/src/remodel_engine/schemas/financial.py index 6dc33f3..136a65b 100644 --- a/packages/engine/src/remodel_engine/schemas/financial.py +++ b/packages/engine/src/remodel_engine/schemas/financial.py @@ -40,6 +40,7 @@ class CommercialConfig(BaseModel): 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)") + client_load_mw: float | None = Field(None, description="Client load for hourly dispatch; defaults to ppa_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") diff --git a/packages/engine/src/remodel_engine/schemas/scenario.py b/packages/engine/src/remodel_engine/schemas/scenario.py index 950b310..8008dc0 100644 --- a/packages/engine/src/remodel_engine/schemas/scenario.py +++ b/packages/engine/src/remodel_engine/schemas/scenario.py @@ -108,3 +108,10 @@ class ScenarioResult(BaseModel): # Stored as flat list: solar_hourly[year * 8760 + hour] for each year 0-24, hour 0-8759 solar_hourly: list[float] = Field(default_factory=list) wind_hourly: list[float] = Field(default_factory=list) + # Full hourly timeseries data + hourly_timestamps: list[str] = Field(default_factory=list) # ISO timestamps + hourly_fy: list[str] = Field(default_factory=list) # FY labels like "2024-25" + hourly_proj_year: list[int] = Field(default_factory=list) # 1-25 + hourly_total_re: list[float] = Field(default_factory=list) # Solar + Wind MW + hourly_client_end: list[float] = Field(default_factory=list) # After losses + hourly_load: list[float] = Field(default_factory=list) # Client load MW (default = PPA) diff --git a/packages/web/components/WorkbookView.tsx b/packages/web/components/WorkbookView.tsx index 901aa75..165deb9 100644 --- a/packages/web/components/WorkbookView.tsx +++ b/packages/web/components/WorkbookView.tsx @@ -531,13 +531,23 @@ const MONTH_DAYS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; interface HourlyData { solar_hourly?: number[]; wind_hourly?: number[]; + // New columns + hourly_timestamps?: string[]; + hourly_fy?: string[]; + hourly_proj_year?: number[]; + hourly_total_re?: number[]; + hourly_client_end?: number[]; + hourly_load?: number[]; } function HourlyGenerationSheet({ hourly, codYear }: { hourly: HourlyData; codYear?: number }) { - const { solar_hourly, wind_hourly } = hourly; + const { solar_hourly, wind_hourly, hourly_total_re, hourly_client_end, hourly_load } = hourly; const hasSolar = solar_hourly && solar_hourly.length > 0; const hasWind = wind_hourly && wind_hourly.length > 0; - const hasData = hasSolar || hasWind; + const hasTotalRe = hourly_total_re && hourly_total_re.length > 0; + const hasClientEnd = hourly_client_end && hourly_client_end.length > 0; + const hasLoad = hourly_load && hourly_load.length > 0; + const hasData = hasSolar || hasWind || hasTotalRe; // FY labels: if COD is April 2026, FY 2026-27 = Year 1 const startYear = codYear || new Date().getFullYear(); @@ -624,6 +634,39 @@ function HourlyGenerationSheet({ hourly, codYear }: { hourly: HourlyData; codYea return d.reduce((a, v) => a + v, 0); }; + // Helper functions for new columns (Total RE, Client End, Load) + const getYearDataNew = (year: number, data?: number[]) => { + if (!data) return []; + const start = (year - 1) * 8760; + return data.slice(start, start + 8760); + }; + + const computeYearTotalNew = (year: number, data?: number[]) => { + const d = getYearDataNew(year, data); + return d.reduce((a, v) => a + v, 0); + }; + + const computeMonthTotalNew = (year: number, month: number, data?: number[]) => { + if (!data) return 0; + const yearData = getYearDataNew(year, data); + if (!yearData.length) return 0; + const start = MONTH_DAYS.slice(0, month - 1).reduce((a, d) => a + d, 0) * 24; + const days = MONTH_DAYS[month - 1]; + const monthData = yearData.slice(start, start + days * 24); + return monthData.reduce((a, v) => a + v, 0); + }; + + const computeDayTotalNew = (year: number, month: number, day: number, data?: number[]) => { + if (!data) return 0; + const yearData = getYearDataNew(year, data); + if (!yearData.length) return 0; + const start = MONTH_DAYS.slice(0, month - 1).reduce((a, d) => a + d, 0) * 24; + const monthData = yearData.slice(start, start + MONTH_DAYS[month - 1] * 24); + const dayStart = (day - 1) * 24; + const dayData = monthData.slice(dayStart, dayStart + 24); + return dayData.reduce((a, v) => a + v, 0); + }; + if (!hasData) { return
No hourly generation data available
; } @@ -634,6 +677,9 @@ function HourlyGenerationSheet({ hourly, codYear }: { hourly: HourlyData; codYea
{hasSolar && ● Solar} {hasWind && ● Wind} + {hasTotalRe && ● Total RE} + {hasClientEnd && ● Client End} + {hasLoad && ● Load} Click to expand: Year → Month → Day → Hour
@@ -643,6 +689,9 @@ function HourlyGenerationSheet({ hourly, codYear }: { hourly: HourlyData; codYea const isYearExpanded = expandedYears.has(year); const solarYr = hasSolar ? computeYearTotal(year, true) : 0; const windYr = hasWind ? computeYearTotal(year, false) : 0; + const totalReYr = hasTotalRe ? computeYearTotalNew(year, hourly_total_re) : 0; + const clientEndYr = hasClientEnd ? computeYearTotalNew(year, hourly_client_end) : 0; + const loadYr = hasLoad ? computeYearTotalNew(year, hourly_load) : 0; const fyLabel = getFyLabel(i); return ( @@ -655,6 +704,9 @@ function HourlyGenerationSheet({ hourly, codYear }: { hourly: HourlyData; codYea {fyLabel} {hasSolar && {Math.round(solarYr).toLocaleString()} MWh} {hasWind && {Math.round(windYr).toLocaleString()} MWh} + {hasTotalRe && {Math.round(totalReYr).toLocaleString()} MWh} + {hasClientEnd && {Math.round(clientEndYr).toLocaleString()} MWh} + {hasLoad && {Math.round(loadYr).toLocaleString()} MWh} {/* Month rows - forward order (Apr first, then subsequent months) */} @@ -665,6 +717,9 @@ function HourlyGenerationSheet({ hourly, codYear }: { hourly: HourlyData; codYea const isMonthExpanded = expandedMonths.has(monthKey); const solarMo = hasSolar ? computeMonthTotal(year, month, true) : 0; const windMo = hasWind ? computeMonthTotal(year, month, false) : 0; + const totalReMo = hasTotalRe ? computeMonthTotalNew(year, month, hourly_total_re) : 0; + const clientEndMo = hasClientEnd ? computeMonthTotalNew(year, month, hourly_client_end) : 0; + const loadMo = hasLoad ? computeMonthTotalNew(year, month, hourly_load) : 0; const daysInMonth = MONTH_DAYS[month - 1]; const monthLabel = getMonthLabel(pos, i); @@ -678,6 +733,9 @@ function HourlyGenerationSheet({ hourly, codYear }: { hourly: HourlyData; codYea {monthLabel} {hasSolar && {Math.round(solarMo).toLocaleString()} MWh} {hasWind && {Math.round(windMo).toLocaleString()} MWh} + {hasTotalRe && {Math.round(totalReMo).toLocaleString()} MWh} + {hasClientEnd && {Math.round(clientEndMo).toLocaleString()} MWh} + {hasLoad && {Math.round(loadMo).toLocaleString()} MWh} {/* Day rows (expandable) */} @@ -687,6 +745,9 @@ function HourlyGenerationSheet({ hourly, codYear }: { hourly: HourlyData; codYea const isDayExpanded = expandedDays.has(dayKey); const solarDy = hasSolar ? computeDayTotal(year, month, day, true) : 0; const windDy = hasWind ? computeDayTotal(year, month, day, false) : 0; + const totalReDy = hasTotalRe ? computeDayTotalNew(year, month, day, hourly_total_re) : 0; + const clientEndDy = hasClientEnd ? computeDayTotalNew(year, month, day, hourly_client_end) : 0; + const loadDy = hasLoad ? computeDayTotalNew(year, month, day, hourly_load) : 0; return (
@@ -698,6 +759,9 @@ function HourlyGenerationSheet({ hourly, codYear }: { hourly: HourlyData; codYea {monthLabel} {day} {hasSolar && {Math.round(solarDy).toLocaleString()}} {hasWind && {Math.round(windDy).toLocaleString()}} + {hasTotalRe && {Math.round(totalReDy).toLocaleString()}} + {hasClientEnd && {Math.round(clientEndDy).toLocaleString()}} + {hasLoad && {Math.round(loadDy).toLocaleString()}} {/* Hour values - vertical stack */} @@ -708,11 +772,21 @@ function HourlyGenerationSheet({ hourly, codYear }: { hourly: HourlyData; codYea const hourIdx = (day - 1) * 24 + hour; const solarHr = hasSolar ? getDayData(year, month, day, true)[hourIdx % 24] : 0; const windHr = hasWind ? getDayData(year, month, day, false)[hourIdx % 24] : 0; + // Get new column data by hour index + const yearDataNew = getYearDataNew(year, hourly_total_re); + const hourDataTotalRe = yearDataNew.length > hourIdx ? yearDataNew[hourIdx] : 0; + const yearDataClient = getYearDataNew(year, hourly_client_end); + const hourDataClient = yearDataClient.length > hourIdx ? yearDataClient[hourIdx] : 0; + const yearDataLoad = getYearDataNew(year, hourly_load); + const hourDataLoad = yearDataLoad.length > hourIdx ? yearDataLoad[hourIdx] : 0; return (
{String(h).padStart(2, '0')}:00 - {hasSolar && {Math.round(solarHr)}} - {hasWind && {Math.round(windHr)}} + {hasSolar && {Math.round(solarHr)}} + {hasWind && {Math.round(windHr)}} + {hasTotalRe && {Math.round(hourDataTotalRe)}} + {hasClientEnd && {Math.round(hourDataClient)}} + {hasLoad && {Math.round(hourDataLoad)}}
); })} @@ -870,8 +944,14 @@ export function WorkbookView({ scenarioId, kpis, debtScheduleJson, activeSheet, )} {activeSheet === "irr" && } - {activeSheet === "generation" && (stmts?.solar_hourly || stmts?.wind_hourly ? ( - + {activeSheet === "generation" && (stmts?.solar_hourly || stmts?.wind_hourly || stmts?.hourly_total_re ? ( + ) : generation.length > 0 ? ( ) : ( diff --git a/packages/web/lib/api.ts b/packages/web/lib/api.ts index 168809e..e103b15 100644 --- a/packages/web/lib/api.ts +++ b/packages/web/lib/api.ts @@ -148,6 +148,13 @@ export interface Statements { // Hourly generation: 25 years × 8760 hours = 219,000 values per technology solar_hourly?: number[]; wind_hourly?: number[]; + // Hourly timeseries + hourly_timestamps?: string[]; + hourly_fy?: string[]; + hourly_proj_year?: number[]; + hourly_total_re?: number[]; + hourly_client_end?: number[]; + hourly_load?: number[]; } export type CostBasis =