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 [],
|
"bs": [r.model_dump() for r in result.financials.bs] if result.financials else [],
|
||||||
"generation": result.generation_by_year,
|
"generation": result.generation_by_year,
|
||||||
"idc_phasing": result.idc_phasing,
|
"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],
|
"debt_schedule": [r.model_dump() for r in result.debt_schedule],
|
||||||
"irr_metrics": result.irr_metrics.model_dump(),
|
"irr_metrics": result.irr_metrics.model_dump(),
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,9 @@ class _PipelineResult:
|
||||||
total_shortfall_mwh: float | None = None
|
total_shortfall_mwh: float | None = None
|
||||||
total_curtailed_mwh: float | None = None
|
total_curtailed_mwh: float | None = None
|
||||||
total_mcp_revenue_cr: 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:
|
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
|
wind_mwh_by_year = [0.0] * 25
|
||||||
solar_y1_cuf: float | None = None
|
solar_y1_cuf: float | None = None
|
||||||
wind_y1_plf: float | None = None
|
wind_y1_plf: float | None = None
|
||||||
solar_y1_hourly: list[float] = [0.0] * 8760
|
# Hourly generation: 25 years × 8760 hours = 219,000 values (stored as flat list)
|
||||||
wind_y1_hourly: list[float] = [0.0] * 8760
|
solar_hourly: list[float] = [0.0] * (25 * 8760)
|
||||||
|
wind_hourly: list[float] = [0.0] * (25 * 8760)
|
||||||
|
|
||||||
if inputs.solar is not None:
|
if inputs.solar is not None:
|
||||||
sol_df = simulate_solar(inputs.solar)
|
sol_df = simulate_solar(inputs.solar)
|
||||||
|
|
@ -89,7 +93,12 @@ def _run_pipeline(inputs: ScenarioInput, tariff: float) -> _PipelineResult:
|
||||||
for y in range(25)
|
for y in range(25)
|
||||||
]
|
]
|
||||||
solar_y1_cuf = float(annual_cuf(sol_df, inputs.solar.capacity_ac_mw).iloc[0])
|
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:
|
if inputs.wind is not None:
|
||||||
wnd_df = simulate_wind(inputs.wind)
|
wnd_df = simulate_wind(inputs.wind)
|
||||||
|
|
@ -98,7 +107,12 @@ def _run_pipeline(inputs: ScenarioInput, tariff: float) -> _PipelineResult:
|
||||||
for y in range(25)
|
for y in range(25)
|
||||||
]
|
]
|
||||||
wind_y1_plf = float(annual_plf(wnd_df, inputs.wind.capacity_mw).iloc[0])
|
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)
|
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,
|
initial_soc_frac=rtc_cfg.initial_soc_frac,
|
||||||
mcp_enabled=rtc_cfg.mcp_enabled,
|
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)
|
dispatch_result = run_dispatch(solar_y1_hourly, wind_y1_hourly, dispatch_cfg)
|
||||||
rtc_cuf_achieved = dispatch_result.rtc_cuf_achieved
|
rtc_cuf_achieved = dispatch_result.rtc_cuf_achieved
|
||||||
total_shortfall_mwh = dispatch_result.total_shortfall_mwh
|
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_shortfall_mwh=total_shortfall_mwh,
|
||||||
total_curtailed_mwh=total_curtailed_mwh,
|
total_curtailed_mwh=total_curtailed_mwh,
|
||||||
total_mcp_revenue_cr=total_mcp_revenue_cr,
|
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
|
# Supplemental tables for workbook sheets
|
||||||
generation_by_year: list[dict[str, Any]] = Field(default_factory=list)
|
generation_by_year: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
idc_phasing: dict[str, Any] = Field(default_factory=dict)
|
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;
|
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[] {
|
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);
|
const total = pnl.map((r) => r.om_cr + r.insurance_cr + r.land_lease_cr + r.am_fee_cr + r.misc_opex_cr);
|
||||||
return [
|
return [
|
||||||
|
|
@ -661,9 +846,13 @@ export function WorkbookView({ scenarioId, kpis, debtScheduleJson, activeSheet }
|
||||||
<HorizontalTable years={debtYears} rows={buildDebtRows(debtSchedule)} />
|
<HorizontalTable years={debtYears} rows={buildDebtRows(debtSchedule)} />
|
||||||
)}
|
)}
|
||||||
{activeSheet === "irr" && <IrrSheet kpis={kpis} />}
|
{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" />
|
<HorizontalTable years={genYears} rows={buildGenerationRows(generation)} unit="MWh / ₹Cr" />
|
||||||
)}
|
) : (
|
||||||
|
<div className="text-muted-foreground p-4">No generation data available</div>
|
||||||
|
))}
|
||||||
{activeSheet === "idc" && idcPhasing && (
|
{activeSheet === "idc" && idcPhasing && (
|
||||||
<IdcSheet idc={idcPhasing} />
|
<IdcSheet idc={idcPhasing} />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,9 @@ export interface Statements {
|
||||||
bs: BsRow[];
|
bs: BsRow[];
|
||||||
generation?: GenerationRow[];
|
generation?: GenerationRow[];
|
||||||
idc_phasing?: IdcPhasing;
|
idc_phasing?: IdcPhasing;
|
||||||
|
// Hourly generation: 25 years × 8760 hours = 219,000 values per technology
|
||||||
|
solar_hourly?: number[];
|
||||||
|
wind_hourly?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CostBasis =
|
export type CostBasis =
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue