feat: add profile data columns to generation sheet for detailed breakdown
- 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:
parent
cc042e0417
commit
dba1e6990f
5 changed files with 69 additions and 8 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue