feat: add 25-year hourly generation data with expandable drill-down
- 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:
parent
dda868d404
commit
093e62b011
5 changed files with 224 additions and 6 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue