feat: add hourly timeseries infrastructure with Total RE, Client End, Load columns
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

- Add client_load_mw to CommercialConfig (defaults to ppa_capacity_mw)
- Add hourly_timestamps, hourly_fy, hourly_proj_year, hourly_total_re,
  hourly_client_end, hourly_load to ScenarioResult
- Generate 25-year hourly data in runner.py with proper timestamps
- Update API worker to return new hourly fields
- Update WorkbookView HourlyGenerationSheet to show all 5 columns
  at each expandable level (Year > Month > Day > Hour)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Manohar Gupta 2026-05-16 12:52:44 +05:30
parent 27866e86f0
commit 5e49926289
6 changed files with 164 additions and 6 deletions

View file

@ -59,6 +59,13 @@ def _run_engine(inputs_json: str) -> dict[str, Any]:
# Hourly generation: 25 years × 8760 hours # Hourly generation: 25 years × 8760 hours
"solar_hourly": result.solar_hourly, "solar_hourly": result.solar_hourly,
"wind_hourly": result.wind_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], "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(),

View file

@ -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 {} 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( return ScenarioResult(
inputs=inputs, inputs=inputs,
status="success", status="success",
@ -493,4 +543,10 @@ def run_scenario(inputs: ScenarioInput) -> ScenarioResult:
idc_phasing=idc_phasing, idc_phasing=idc_phasing,
solar_hourly=pipe.solar_hourly, solar_hourly=pipe.solar_hourly,
wind_hourly=pipe.wind_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,
) )

View file

@ -40,6 +40,7 @@ class CommercialConfig(BaseModel):
tariff_inr_per_kwh: float = Field(3.50, gt=0, description="PPA tariff (INR/kWh)") 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)") 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") 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") 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") dsm_loss_pct: float = Field(0.02, ge=0, lt=1, description="DSM/RTC penalty provision")

View file

@ -108,3 +108,10 @@ class ScenarioResult(BaseModel):
# Stored as flat list: solar_hourly[year * 8760 + hour] for each year 0-24, hour 0-8759 # 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) solar_hourly: list[float] = Field(default_factory=list)
wind_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)

View file

@ -531,13 +531,23 @@ const MONTH_DAYS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
interface HourlyData { interface HourlyData {
solar_hourly?: number[]; solar_hourly?: number[];
wind_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 }) { 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 hasSolar = solar_hourly && solar_hourly.length > 0;
const hasWind = wind_hourly && wind_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 // FY labels: if COD is April 2026, FY 2026-27 = Year 1
const startYear = codYear || new Date().getFullYear(); 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); 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) { if (!hasData) {
return <div className="text-muted-foreground p-4">No hourly generation data available</div>; return <div className="text-muted-foreground p-4">No hourly generation data available</div>;
} }
@ -634,6 +677,9 @@ function HourlyGenerationSheet({ hourly, codYear }: { hourly: HourlyData; codYea
<div className="flex items-center gap-2 pb-2 text-muted-foreground"> <div className="flex items-center gap-2 pb-2 text-muted-foreground">
{hasSolar && <span className="text-orange-600"> Solar</span>} {hasSolar && <span className="text-orange-600"> Solar</span>}
{hasWind && <span className="text-blue-600"> Wind</span>} {hasWind && <span className="text-blue-600"> Wind</span>}
{hasTotalRe && <span className="text-green-600"> Total RE</span>}
{hasClientEnd && <span className="text-purple-600"> Client End</span>}
{hasLoad && <span className="text-gray-600"> Load</span>}
<span className="text-muted-foreground/60">Click to expand: Year Month Day Hour</span> <span className="text-muted-foreground/60">Click to expand: Year Month Day Hour</span>
</div> </div>
@ -643,6 +689,9 @@ function HourlyGenerationSheet({ hourly, codYear }: { hourly: HourlyData; codYea
const isYearExpanded = expandedYears.has(year); const isYearExpanded = expandedYears.has(year);
const solarYr = hasSolar ? computeYearTotal(year, true) : 0; const solarYr = hasSolar ? computeYearTotal(year, true) : 0;
const windYr = hasWind ? computeYearTotal(year, false) : 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); const fyLabel = getFyLabel(i);
return ( return (
@ -655,6 +704,9 @@ function HourlyGenerationSheet({ hourly, codYear }: { hourly: HourlyData; codYea
<span className="w-20 font-medium">{fyLabel}</span> <span className="w-20 font-medium">{fyLabel}</span>
{hasSolar && <span className="text-orange-700 w-24">{Math.round(solarYr).toLocaleString()} MWh</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>} {hasWind && <span className="text-blue-700 w-24">{Math.round(windYr).toLocaleString()} MWh</span>}
{hasTotalRe && <span className="text-green-700 w-24">{Math.round(totalReYr).toLocaleString()} MWh</span>}
{hasClientEnd && <span className="text-purple-700 w-24">{Math.round(clientEndYr).toLocaleString()} MWh</span>}
{hasLoad && <span className="text-gray-700 w-20">{Math.round(loadYr).toLocaleString()} MWh</span>}
</button> </button>
{/* Month rows - forward order (Apr first, then subsequent months) */} {/* 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 isMonthExpanded = expandedMonths.has(monthKey);
const solarMo = hasSolar ? computeMonthTotal(year, month, true) : 0; const solarMo = hasSolar ? computeMonthTotal(year, month, true) : 0;
const windMo = hasWind ? computeMonthTotal(year, month, false) : 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 daysInMonth = MONTH_DAYS[month - 1];
const monthLabel = getMonthLabel(pos, i); const monthLabel = getMonthLabel(pos, i);
@ -678,6 +733,9 @@ function HourlyGenerationSheet({ hourly, codYear }: { hourly: HourlyData; codYea
<span className="w-20 text-muted-foreground">{monthLabel}</span> <span className="w-20 text-muted-foreground">{monthLabel}</span>
{hasSolar && <span className="text-orange-700/80 w-24">{Math.round(solarMo).toLocaleString()} MWh</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>} {hasWind && <span className="text-blue-700/80 w-24">{Math.round(windMo).toLocaleString()} MWh</span>}
{hasTotalRe && <span className="text-green-700/80 w-24">{Math.round(totalReMo).toLocaleString()} MWh</span>}
{hasClientEnd && <span className="text-purple-700/80 w-24">{Math.round(clientEndMo).toLocaleString()} MWh</span>}
{hasLoad && <span className="text-gray-700/80 w-20">{Math.round(loadMo).toLocaleString()} MWh</span>}
</button> </button>
{/* Day rows (expandable) */} {/* Day rows (expandable) */}
@ -687,6 +745,9 @@ function HourlyGenerationSheet({ hourly, codYear }: { hourly: HourlyData; codYea
const isDayExpanded = expandedDays.has(dayKey); const isDayExpanded = expandedDays.has(dayKey);
const solarDy = hasSolar ? computeDayTotal(year, month, day, true) : 0; const solarDy = hasSolar ? computeDayTotal(year, month, day, true) : 0;
const windDy = hasWind ? computeDayTotal(year, month, day, false) : 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 ( return (
<div key={dayKey} className="ml-4"> <div key={dayKey} className="ml-4">
@ -698,6 +759,9 @@ function HourlyGenerationSheet({ hourly, codYear }: { hourly: HourlyData; codYea
<span className="w-12 text-muted-foreground/80">{monthLabel} {day}</span> <span className="w-12 text-muted-foreground/80">{monthLabel} {day}</span>
{hasSolar && <span className="text-orange-600/70 w-20">{Math.round(solarDy).toLocaleString()}</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>} {hasWind && <span className="text-blue-600/70 w-20">{Math.round(windDy).toLocaleString()}</span>}
{hasTotalRe && <span className="text-green-600/70 w-20">{Math.round(totalReDy).toLocaleString()}</span>}
{hasClientEnd && <span className="text-purple-600/70 w-20">{Math.round(clientEndDy).toLocaleString()}</span>}
{hasLoad && <span className="text-gray-600/70 w-16">{Math.round(loadDy).toLocaleString()}</span>}
</button> </button>
{/* Hour values - vertical stack */} {/* Hour values - vertical stack */}
@ -708,11 +772,21 @@ function HourlyGenerationSheet({ hourly, codYear }: { hourly: HourlyData; codYea
const hourIdx = (day - 1) * 24 + hour; const hourIdx = (day - 1) * 24 + hour;
const solarHr = hasSolar ? getDayData(year, month, day, true)[hourIdx % 24] : 0; const solarHr = hasSolar ? getDayData(year, month, day, true)[hourIdx % 24] : 0;
const windHr = hasWind ? getDayData(year, month, day, false)[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 ( return (
<div key={h} className="flex items-center gap-2 text-[9px] border-b border-border/30 px-1"> <div key={h} className="flex items-center gap-2 text-[9px] border-b border-border/30 px-1">
<span className="w-8 text-muted-foreground">{String(h).padStart(2, '0')}:00</span> <span className="w-8 text-muted-foreground">{String(h).padStart(2, '0')}:00</span>
{hasSolar && <span className="text-orange-700 w-16 text-right">{Math.round(solarHr)}</span>} {hasSolar && <span className="text-orange-700 w-14 text-right">{Math.round(solarHr)}</span>}
{hasWind && <span className="text-blue-700 w-16 text-right">{Math.round(windHr)}</span>} {hasWind && <span className="text-blue-700 w-14 text-right">{Math.round(windHr)}</span>}
{hasTotalRe && <span className="text-green-700 w-14 text-right">{Math.round(hourDataTotalRe)}</span>}
{hasClientEnd && <span className="text-purple-700 w-14 text-right">{Math.round(hourDataClient)}</span>}
{hasLoad && <span className="text-gray-700 w-12 text-right">{Math.round(hourDataLoad)}</span>}
</div> </div>
); );
})} })}
@ -870,8 +944,14 @@ 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" && (stmts?.solar_hourly || stmts?.wind_hourly ? ( {activeSheet === "generation" && (stmts?.solar_hourly || stmts?.wind_hourly || stmts?.hourly_total_re ? (
<HourlyGenerationSheet hourly={{ solar_hourly: stmts?.solar_hourly, wind_hourly: stmts?.wind_hourly }} codYear={codYear} /> <HourlyGenerationSheet hourly={{
solar_hourly: stmts?.solar_hourly,
wind_hourly: stmts?.wind_hourly,
hourly_total_re: stmts?.hourly_total_re,
hourly_client_end: stmts?.hourly_client_end,
hourly_load: stmts?.hourly_load,
}} codYear={codYear} />
) : generation.length > 0 ? ( ) : generation.length > 0 ? (
<HorizontalTable years={genYears} rows={buildGenerationRows(generation)} unit="MWh / ₹Cr" /> <HorizontalTable years={genYears} rows={buildGenerationRows(generation)} unit="MWh / ₹Cr" />
) : ( ) : (

View file

@ -148,6 +148,13 @@ export interface Statements {
// Hourly generation: 25 years × 8760 hours = 219,000 values per technology // Hourly generation: 25 years × 8760 hours = 219,000 values per technology
solar_hourly?: number[]; solar_hourly?: number[];
wind_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 = export type CostBasis =