feat: add 25-year hourly generation data with expandable drill-down
Some checks are pending
CI / Engine — lint / typecheck / test (push) Waiting to run
CI / API — lint / typecheck / test (push) Waiting to run
CI / Web — typecheck / lint / build (push) Waiting to run

- 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 <noreply@anthropic.com>
This commit is contained in:
Manohar Gupta 2026-05-16 10:41:25 +05:30
parent dda868d404
commit 093e62b011
5 changed files with 224 additions and 6 deletions

View file

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

View file

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

View file

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

View file

@ -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<Set<number>>(new Set());
const [expandedMonths, setExpandedMonths] = useState<Set<string>>(new Set());
const [expandedDays, setExpandedDays] = useState<Set<string>>(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 <div className="text-muted-foreground p-4">No hourly generation data available</div>;
}
// Build tree: Years (1-25), expandable to months, days, hours
return (
<div className="space-y-1 text-xs">
<div className="flex items-center gap-2 pb-2 text-muted-foreground">
{hasSolar && <span className="text-orange-600"> Solar</span>}
{hasWind && <span className="text-blue-600"> Wind</span>}
<span className="text-muted-foreground/60">Click to expand: Year Month Day Hour</span>
</div>
{/* 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 (
<div key={year}>
<button
onClick={() => toggleYear(year)}
className="w-full flex items-center gap-2 py-1.5 hover:bg-accent/40 rounded px-2 text-left font-medium"
>
<span className="text-[10px] w-4">{isYearExpanded ? "▼" : "▶"}</span>
<span className="w-12">Y{year}</span>
{hasSolar && <span className="text-orange-700 w-24">{Math.round(solarYr).toLocaleString()} MWh</span>}
{hasWind && <span className="text-blue-700 w-24">{Math.round(windYr).toLocaleString()} MWh</span>}
</button>
{/* 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 (
<div key={monthKey} className="ml-4">
<button
onClick={() => toggleMonth(monthKey)}
className="w-full flex items-center gap-2 py-1 hover:bg-accent/30 rounded px-2 text-left"
>
<span className="text-[10px] w-4">{isMonthExpanded ? "▼" : "▶"}</span>
<span className="w-12 text-muted-foreground">M{month}</span>
{hasSolar && <span className="text-orange-700/80 w-24">{Math.round(solarMo).toLocaleString()} MWh</span>}
{hasWind && <span className="text-blue-700/80 w-24">{Math.round(windMo).toLocaleString()} MWh</span>}
</button>
{/* 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 (
<div key={dayKey} className="ml-4">
<button
onClick={() => toggleDay(dayKey)}
className="w-full flex items-center gap-2 py-0.5 hover:bg-accent/20 rounded px-2 text-left"
>
<span className="text-[10px] w-4">{isDayExpanded ? "▼" : "▶"}</span>
<span className="w-12 text-muted-foreground/80">D{day}</span>
{hasSolar && <span className="text-orange-600/70 w-20">{Math.round(solarDy).toLocaleString()}</span>}
{hasWind && <span className="text-blue-600/70 w-20">{Math.round(windDy).toLocaleString()}</span>}
</button>
{/* Hour values */}
{isDayExpanded && (
<div className="ml-8 flex flex-wrap gap-0.5 py-1">
{[...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 (
<div key={h} className="w-10 text-center text-[9px] border rounded px-0.5">
<span className="text-muted-foreground">{h}:00</span>
{hasSolar && <div className="text-orange-700">{Math.round(solarHr)}</div>}
{hasWind && <div className="text-blue-700">{Math.round(windHr)}</div>}
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
);
})}
</div>
);
})}
</div>
);
}
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 }
<HorizontalTable years={debtYears} rows={buildDebtRows(debtSchedule)} />
)}
{activeSheet === "irr" && <IrrSheet kpis={kpis} />}
{activeSheet === "generation" && generation.length > 0 && (
{activeSheet === "generation" && (stmts?.solar_hourly || stmts?.wind_hourly ? (
<HourlyGenerationSheet hourly={{ solar_hourly: stmts?.solar_hourly, wind_hourly: stmts?.wind_hourly }} />
) : generation.length > 0 ? (
<HorizontalTable years={genYears} rows={buildGenerationRows(generation)} unit="MWh / ₹Cr" />
)}
) : (
<div className="text-muted-foreground p-4">No generation data available</div>
))}
{activeSheet === "idc" && idcPhasing && (
<IdcSheet idc={idcPhasing} />
)}

View file

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