From 093e62b0119e042fab0b57b1cafdb39bb782abbb Mon Sep 17 00:00:00 2001 From: Mannu Date: Sat, 16 May 2026 10:41:25 +0530 Subject: [PATCH] feat: add 25-year hourly generation data with expandable drill-down MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Engine: generate all 25 years × 8760 hours of hourly generation - Schema: add solar_hourly and wind_hourly fields to ScenarioResult - API: expose hourly data in statements endpoint - UI: new HourlyGenerationSheet with Year → Month → Day → Hour drill-down - Add TYPEOF for hourly generation in web API types Co-Authored-By: Claude Opus 4.7 --- packages/api/src/remodel_api/workers/tasks.py | 3 + .../src/remodel_engine/scenarios/runner.py | 27 ++- .../src/remodel_engine/schemas/scenario.py | 4 + packages/web/components/WorkbookView.tsx | 193 +++++++++++++++++- packages/web/lib/api.ts | 3 + 5 files changed, 224 insertions(+), 6 deletions(-) diff --git a/packages/api/src/remodel_api/workers/tasks.py b/packages/api/src/remodel_api/workers/tasks.py index 0ed0171..bc6afbd 100644 --- a/packages/api/src/remodel_api/workers/tasks.py +++ b/packages/api/src/remodel_api/workers/tasks.py @@ -56,6 +56,9 @@ def _run_engine(inputs_json: str) -> dict[str, Any]: "bs": [r.model_dump() for r in result.financials.bs] if result.financials else [], "generation": result.generation_by_year, "idc_phasing": result.idc_phasing, + # Hourly generation: 25 years × 8760 hours + "solar_hourly": result.solar_hourly, + "wind_hourly": result.wind_hourly, }, "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 cf4a15c..693c91a 100644 --- a/packages/engine/src/remodel_engine/scenarios/runner.py +++ b/packages/engine/src/remodel_engine/scenarios/runner.py @@ -71,6 +71,9 @@ class _PipelineResult: total_shortfall_mwh: float | None = None total_curtailed_mwh: float | None = None total_mcp_revenue_cr: float | None = None + # Hourly generation: 25 years × 8760 hours = 219,000 values + solar_hourly: list[float] = None # type: ignore[assignment] + wind_hourly: list[float] = None # type: ignore[assignment] def _run_pipeline(inputs: ScenarioInput, tariff: float) -> _PipelineResult: @@ -79,8 +82,9 @@ def _run_pipeline(inputs: ScenarioInput, tariff: float) -> _PipelineResult: 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 + # Hourly generation: 25 years × 8760 hours = 219,000 values (stored as flat list) + solar_hourly: list[float] = [0.0] * (25 * 8760) + wind_hourly: list[float] = [0.0] * (25 * 8760) if inputs.solar is not None: sol_df = simulate_solar(inputs.solar) @@ -89,7 +93,12 @@ def _run_pipeline(inputs: ScenarioInput, tariff: float) -> _PipelineResult: 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() + # Store all 25 years hourly - convert DataFrame to flat list + for year in range(25): + year_data = sol_df[sol_df["year"] == year + 1]["ac_power_mw"].tolist() + start_idx = year * 8760 + for hour in range(min(8760, len(year_data))): + solar_hourly[start_idx + hour] = year_data[hour] if inputs.wind is not None: wnd_df = simulate_wind(inputs.wind) @@ -98,7 +107,12 @@ def _run_pipeline(inputs: ScenarioInput, tariff: float) -> _PipelineResult: 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() + # Store all 25 years hourly - convert DataFrame to flat list + for year in range(25): + year_data = wnd_df[wnd_df["year"] == year + 1]["ac_power_mw"].tolist() + start_idx = year * 8760 + for hour in range(min(8760, len(year_data))): + wind_hourly[start_idx + hour] = year_data[hour] gen_mwh_by_year = compute_annual_generation_mwh(solar_mwh_by_year, wind_mwh_by_year) @@ -214,6 +228,9 @@ def _run_pipeline(inputs: ScenarioInput, tariff: float) -> _PipelineResult: initial_soc_frac=rtc_cfg.initial_soc_frac, mcp_enabled=rtc_cfg.mcp_enabled, ) + # Dispatch only needs Year 1 hourly for MCP calculation + solar_y1_hourly = solar_hourly[:8760] + wind_y1_hourly = wind_hourly[:8760] 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 @@ -295,6 +312,8 @@ def _run_pipeline(inputs: ScenarioInput, tariff: float) -> _PipelineResult: total_shortfall_mwh=total_shortfall_mwh, total_curtailed_mwh=total_curtailed_mwh, total_mcp_revenue_cr=total_mcp_revenue_cr, + solar_hourly=solar_hourly, + wind_hourly=wind_hourly, ) diff --git a/packages/engine/src/remodel_engine/schemas/scenario.py b/packages/engine/src/remodel_engine/schemas/scenario.py index c43fb87..950b310 100644 --- a/packages/engine/src/remodel_engine/schemas/scenario.py +++ b/packages/engine/src/remodel_engine/schemas/scenario.py @@ -104,3 +104,7 @@ class ScenarioResult(BaseModel): # 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) + # Hourly generation: 25 years × 8760 hours = 219,000 values per technology + # 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) diff --git a/packages/web/components/WorkbookView.tsx b/packages/web/components/WorkbookView.tsx index 3fe0737..dceb32e 100644 --- a/packages/web/components/WorkbookView.tsx +++ b/packages/web/components/WorkbookView.tsx @@ -523,6 +523,191 @@ function buildGenerationRows(gen: GenerationRow[]): TableRow[] { return rows; } +// Hourly generation tree: Year > Month > Day > Hour (expandable) +// --------------------------------------------------------------------------- + +const MONTH_DAYS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + +interface HourlyData { + solar_hourly?: number[]; + wind_hourly?: number[]; +} + +function HourlyGenerationSheet({ hourly }: { hourly: HourlyData }) { + const { solar_hourly, wind_hourly } = hourly; + const hasSolar = solar_hourly && solar_hourly.length > 0; + const hasWind = wind_hourly && wind_hourly.length > 0; + const hasData = hasSolar || hasWind; + + // Expand state: which years/months/days are expanded + const [expandedYears, setExpandedYears] = useState>(new Set()); + const [expandedMonths, setExpandedMonths] = useState>(new Set()); + const [expandedDays, setExpandedDays] = useState>(new Set()); + + const toggleYear = (yr: number) => setExpandedYears((prev) => { + const next = new Set(prev); + if (next.has(yr)) next.delete(yr); + else next.add(yr); + return next; + }); + + const toggleMonth = (key: string) => setExpandedMonths((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + + const toggleDay = (key: string) => setExpandedDays((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + + // Helper: get hourly data for a specific year/month/day + const getYearData = (year: number, isSolar: boolean) => { + const data = isSolar ? solar_hourly : wind_hourly; + if (!data) return []; + const start = (year - 1) * 8760; + return data.slice(start, start + 8760); + }; + + const getMonthData = (year: number, month: number, isSolar: boolean) => { + const yearData = getYearData(year, isSolar); + if (!yearData) return []; + const start = MONTH_DAYS.slice(0, month - 1).reduce((a, d) => a + d, 0) * 24; + const days = MONTH_DAYS[month - 1]; + return yearData.slice(start, start + days * 24); + }; + + const getDayData = (year: number, month: number, day: number, isSolar: boolean) => { + const monthData = getMonthData(year, month, isSolar); + if (!monthData) return []; + const start = (day - 1) * 24; + return monthData.slice(start, start + 24); + }; + + // Compute totals for display + const computeYearTotal = (year: number, isSolar: boolean) => { + const d = getYearData(year, isSolar); + return d.reduce((a, v) => a + v, 0); + }; + + const computeMonthTotal = (year: number, month: number, isSolar: boolean) => { + const d = getMonthData(year, month, isSolar); + return d.reduce((a, v) => a + v, 0); + }; + + const computeDayTotal = (year: number, month: number, day: number, isSolar: boolean) => { + const d = getDayData(year, month, day, isSolar); + return d.reduce((a, v) => a + v, 0); + }; + + if (!hasData) { + return
No hourly generation data available
; + } + + // Build tree: Years (1-25), expandable to months, days, hours + return ( +
+
+ {hasSolar && ● Solar} + {hasWind && ● Wind} + Click to expand: Year → Month → Day → Hour +
+ + {/* Year rows */} + {[...Array(25)].map((_, i) => { + const year = i + 1; + const isYearExpanded = expandedYears.has(year); + const solarYr = hasSolar ? computeYearTotal(year, true) : 0; + const windYr = hasWind ? computeYearTotal(year, false) : 0; + + return ( +
+ + + {/* Month rows (expandable) */} + {isYearExpanded && [...Array(12)].map((_, mi) => { + const month = mi + 1; + const monthKey = `${year}-${month}`; + const isMonthExpanded = expandedMonths.has(monthKey); + const solarMo = hasSolar ? computeMonthTotal(year, month, true) : 0; + const windMo = hasWind ? computeMonthTotal(year, month, false) : 0; + const daysInMonth = MONTH_DAYS[month - 1]; + + return ( +
+ + + {/* Day rows (expandable) */} + {isMonthExpanded && [...Array(daysInMonth)].map((_, di) => { + const day = di + 1; + const dayKey = `${year}-${month}-${day}`; + const isDayExpanded = expandedDays.has(dayKey); + const solarDy = hasSolar ? computeDayTotal(year, month, day, true) : 0; + const windDy = hasWind ? computeDayTotal(year, month, day, false) : 0; + + return ( +
+ + + {/* Hour values */} + {isDayExpanded && ( +
+ {[...Array(24)].map((_, h) => { + const hour = h; + 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; + return ( +
+ {h}:00 + {hasSolar &&
{Math.round(solarHr)}
} + {hasWind &&
{Math.round(windHr)}
} +
+ ); + })} +
+ )} +
+ ); + })} +
+ ); + })} +
+ ); + })} +
+ ); +} + 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 [ @@ -661,9 +846,13 @@ export function WorkbookView({ scenarioId, kpis, debtScheduleJson, activeSheet } )} {activeSheet === "irr" && } - {activeSheet === "generation" && generation.length > 0 && ( + {activeSheet === "generation" && (stmts?.solar_hourly || stmts?.wind_hourly ? ( + + ) : generation.length > 0 ? ( - )} + ) : ( +
No generation data available
+ ))} {activeSheet === "idc" && idcPhasing && ( )} diff --git a/packages/web/lib/api.ts b/packages/web/lib/api.ts index aea27b2..168809e 100644 --- a/packages/web/lib/api.ts +++ b/packages/web/lib/api.ts @@ -145,6 +145,9 @@ export interface Statements { bs: BsRow[]; generation?: GenerationRow[]; idc_phasing?: IdcPhasing; + // Hourly generation: 25 years × 8760 hours = 219,000 values per technology + solar_hourly?: number[]; + wind_hourly?: number[]; } export type CostBasis =