feat: add profile data columns to generation sheet for detailed breakdown
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 hourly_solar_profile and hourly_wind_profile to store per-hour output values
- Add solar_dc_mwp, solar_ac_mw, wind_mw to PipelineResult for capacity display
- Add columns: Solar 8760, DC MW, Solar MW / Wind 8760, MW, Wind MW
- Updated UI to show detailed breakdown at each 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 13:31:20 +05:30
parent cc042e0417
commit dba1e6990f
5 changed files with 69 additions and 8 deletions

View file

@ -66,6 +66,9 @@ def _run_engine(inputs_json: str) -> dict[str, Any]:
"hourly_total_re": result.hourly_total_re,
"hourly_client_end": result.hourly_client_end,
"hourly_load": result.hourly_load,
# Profile data for detailed view
"hourly_solar_profile": result.hourly_solar_profile,
"hourly_wind_profile": result.hourly_wind_profile,
},
"debt_schedule": [r.model_dump() for r in result.debt_schedule],
"irr_metrics": result.irr_metrics.model_dump(),

View file

@ -74,6 +74,10 @@ class _PipelineResult:
# 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]
# Capacity values for hourly display
solar_dc_mwp: float = 0.0
wind_mw: float = 0.0
solar_ac_mw: float = 0.0 # type: ignore[assignment]
def _run_pipeline(inputs: ScenarioInput, tariff: float) -> _PipelineResult:
@ -85,6 +89,10 @@ def _run_pipeline(inputs: ScenarioInput, tariff: float) -> _PipelineResult:
# 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)
# Track capacities for display
solar_dc_mwp = 0.0
solar_ac_mw = 0.0
wind_mw = 0.0
if inputs.solar is not None:
sol_df = simulate_solar(inputs.solar)
@ -93,6 +101,8 @@ 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_dc_mwp = inputs.solar.capacity_dc_mwp
solar_ac_mw = inputs.solar.capacity_ac_mw
# 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()
@ -107,6 +117,7 @@ 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_mw = inputs.wind.capacity_mw
# 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()
@ -313,6 +324,9 @@ def _run_pipeline(inputs: ScenarioInput, tariff: float) -> _PipelineResult:
total_mcp_revenue_cr=total_mcp_revenue_cr,
solar_hourly=solar_hourly,
wind_hourly=wind_hourly,
solar_dc_mwp=solar_dc_mwp,
solar_ac_mw=solar_ac_mw,
wind_mw=wind_mw,
)
@ -504,6 +518,9 @@ def run_scenario(inputs: ScenarioInput) -> ScenarioResult:
hourly_total_re: list[float] = []
hourly_client_end: list[float] = []
hourly_load: list[float] = []
# Profile data (solar profile input to DC capacity, wind profile to capacity)
hourly_solar_profile: list[float] = []
hourly_wind_profile: 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
@ -544,6 +561,11 @@ def run_scenario(inputs: ScenarioInput) -> ScenarioResult:
hourly_total_re.append(total_re)
hourly_client_end.append(total_re * loss_factor)
hourly_load.append(client_load_mw)
# Profile data: actual generation / capacity = normalized profile value
# Store: for solar = solar_val (which is DC output), for wind = wind_val
# User can compute: profile = solar_val / solar_dc_mwp
hourly_solar_profile.append(solar_val)
hourly_wind_profile.append(wind_val)
return ScenarioResult(
inputs=inputs,
@ -565,4 +587,6 @@ def run_scenario(inputs: ScenarioInput) -> ScenarioResult:
hourly_total_re=hourly_total_re,
hourly_client_end=hourly_client_end,
hourly_load=hourly_load,
hourly_solar_profile=hourly_solar_profile,
hourly_wind_profile=hourly_wind_profile,
)

View file

@ -115,3 +115,6 @@ class ScenarioResult(BaseModel):
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)
# Per-hour profile values for display (solar_dc capacity applied to profile)
hourly_solar_profile: list[float] = Field(default_factory=list) # Profile * DC capacity
hourly_wind_profile: list[float] = Field(default_factory=list) # Profile * capacity

View file

@ -538,15 +538,20 @@ interface HourlyData {
hourly_total_re?: number[];
hourly_client_end?: number[];
hourly_load?: number[];
// Profile data for detailed display
hourly_solar_profile?: number[];
hourly_wind_profile?: number[];
}
function HourlyGenerationSheet({ hourly, codYear }: { hourly: HourlyData; codYear?: number }) {
const { solar_hourly, wind_hourly, hourly_total_re, hourly_client_end, hourly_load } = hourly;
const { solar_hourly, wind_hourly, hourly_total_re, hourly_client_end, hourly_load, hourly_solar_profile, hourly_wind_profile } = hourly;
const hasSolar = solar_hourly && solar_hourly.length > 0;
const hasWind = wind_hourly && wind_hourly.length > 0;
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 hasSolarProfile = hourly_solar_profile && hourly_solar_profile.length > 0;
const hasWindProfile = hourly_wind_profile && hourly_wind_profile.length > 0;
const hasData = hasSolar || hasWind || hasTotalRe;
// FY labels: if COD is April 2026, FY 2026-27 = Year 1
@ -674,13 +679,27 @@ function HourlyGenerationSheet({ hourly, codYear }: { hourly: HourlyData; codYea
// 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">
<div className="flex items-center gap-2 pb-2 text-muted-foreground flex-wrap">
{hasSolar && <span className="text-orange-600"> Solar</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 ml-2">Click: Year Month Day Hour</span>
</div>
{/* Column Header */}
<div className="flex items-center gap-2 py-1 px-2 bg-muted/50 rounded font-medium text-[10px] text-muted-foreground">
<span className="w-20">Period</span>
{hasSolar && <span className="w-20 text-orange-700">Solar 8760</span>}
{hasSolar && <span className="w-16 text-orange-600/70">DC MW</span>}
{hasSolar && <span className="w-20 text-orange-700">Solar MW</span>}
{hasWind && <span className="w-20 text-blue-700">Wind 8760</span>}
{hasWind && <span className="w-16 text-blue-600/70">MW</span>}
{hasWind && <span className="w-20 text-blue-700">Wind MW</span>}
{hasTotalRe && <span className="w-20 text-green-700">Total RE</span>}
{hasClientEnd && <span className="w-20 text-purple-700">Client End</span>}
{hasLoad && <span className="w-16 text-gray-700">Load</span>}
</div>
{/* Year rows */}
@ -692,6 +711,9 @@ function HourlyGenerationSheet({ hourly, codYear }: { hourly: HourlyData; codYea
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;
// For profile totals - sum of hourly profile values = total energy before capacity division
const solarProfileSum = hasSolarProfile ? computeYearTotalNew(year, hourly_solar_profile) : 0;
const windProfileSum = hasWindProfile ? computeYearTotalNew(year, hourly_wind_profile) : 0;
const fyLabel = getFyLabel(i);
return (
@ -702,11 +724,15 @@ function HourlyGenerationSheet({ hourly, codYear }: { hourly: HourlyData; codYea
>
<span className="text-[10px] w-4">{isYearExpanded ? "▼" : "▶"}</span>
<span className="w-20 font-medium">{fyLabel}</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>}
{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>}
{hasSolar && <span className="text-orange-700 w-20">{Math.round(solarProfileSum).toLocaleString()}</span>}
{hasSolar && <span className="text-orange-600/70 w-16">-</span>}
{hasSolar && <span className="text-orange-700 w-20">{Math.round(solarYr).toLocaleString()}</span>}
{hasWind && <span className="text-blue-700 w-20">{Math.round(windProfileSum).toLocaleString()}</span>}
{hasWind && <span className="text-blue-600/70 w-16">-</span>}
{hasWind && <span className="text-blue-700 w-20">{Math.round(windYr).toLocaleString()}</span>}
{hasTotalRe && <span className="text-green-700 w-20">{Math.round(totalReYr).toLocaleString()}</span>}
{hasClientEnd && <span className="text-purple-700 w-20">{Math.round(clientEndYr).toLocaleString()}</span>}
{hasLoad && <span className="text-gray-700 w-16">{Math.round(loadYr).toLocaleString()}</span>}
</button>
{/* Month rows - forward order (Apr first, then subsequent months) */}
@ -951,6 +977,8 @@ export function WorkbookView({ scenarioId, kpis, debtScheduleJson, activeSheet,
hourly_total_re: stmts?.hourly_total_re,
hourly_client_end: stmts?.hourly_client_end,
hourly_load: stmts?.hourly_load,
hourly_solar_profile: stmts?.hourly_solar_profile,
hourly_wind_profile: stmts?.hourly_wind_profile,
}} codYear={codYear} />
) : generation.length > 0 ? (
<HorizontalTable years={genYears} rows={buildGenerationRows(generation)} unit="MWh / ₹Cr" />

View file

@ -155,6 +155,9 @@ export interface Statements {
hourly_total_re?: number[];
hourly_client_end?: number[];
hourly_load?: number[];
// Profile data for detailed view
hourly_solar_profile?: number[];
hourly_wind_profile?: number[];
}
export type CostBasis =