From dba1e6990f4ee014c8282e9f70fff448b98e13cc Mon Sep 17 00:00:00 2001 From: Mannu Date: Sat, 16 May 2026 13:31:20 +0530 Subject: [PATCH] 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 --- packages/api/src/remodel_api/workers/tasks.py | 3 ++ .../src/remodel_engine/scenarios/runner.py | 24 ++++++++++ .../src/remodel_engine/schemas/scenario.py | 3 ++ packages/web/components/WorkbookView.tsx | 44 +++++++++++++++---- packages/web/lib/api.ts | 3 ++ 5 files changed, 69 insertions(+), 8 deletions(-) diff --git a/packages/api/src/remodel_api/workers/tasks.py b/packages/api/src/remodel_api/workers/tasks.py index c90866f..244c028 100644 --- a/packages/api/src/remodel_api/workers/tasks.py +++ b/packages/api/src/remodel_api/workers/tasks.py @@ -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(), diff --git a/packages/engine/src/remodel_engine/scenarios/runner.py b/packages/engine/src/remodel_engine/scenarios/runner.py index db85a3c..b9b1e40 100644 --- a/packages/engine/src/remodel_engine/scenarios/runner.py +++ b/packages/engine/src/remodel_engine/scenarios/runner.py @@ -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, ) diff --git a/packages/engine/src/remodel_engine/schemas/scenario.py b/packages/engine/src/remodel_engine/schemas/scenario.py index 8008dc0..4cae744 100644 --- a/packages/engine/src/remodel_engine/schemas/scenario.py +++ b/packages/engine/src/remodel_engine/schemas/scenario.py @@ -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 diff --git a/packages/web/components/WorkbookView.tsx b/packages/web/components/WorkbookView.tsx index 165deb9..d1236a6 100644 --- a/packages/web/components/WorkbookView.tsx +++ b/packages/web/components/WorkbookView.tsx @@ -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 (
-
+
{hasSolar && ● Solar} {hasWind && ● Wind} {hasTotalRe && ● Total RE} {hasClientEnd && ● Client End} {hasLoad && ● Load} - Click to expand: Year → Month → Day → Hour + Click: Year → Month → Day → Hour +
+ + {/* Column Header */} +
+ Period + {hasSolar && Solar 8760} + {hasSolar && DC MW} + {hasSolar && Solar MW} + {hasWind && Wind 8760} + {hasWind && MW} + {hasWind && Wind MW} + {hasTotalRe && Total RE} + {hasClientEnd && Client End} + {hasLoad && Load}
{/* 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 > {isYearExpanded ? "▼" : "▶"} {fyLabel} - {hasSolar && {Math.round(solarYr).toLocaleString()} MWh} - {hasWind && {Math.round(windYr).toLocaleString()} MWh} - {hasTotalRe && {Math.round(totalReYr).toLocaleString()} MWh} - {hasClientEnd && {Math.round(clientEndYr).toLocaleString()} MWh} - {hasLoad && {Math.round(loadYr).toLocaleString()} MWh} + {hasSolar && {Math.round(solarProfileSum).toLocaleString()}} + {hasSolar && -} + {hasSolar && {Math.round(solarYr).toLocaleString()}} + {hasWind && {Math.round(windProfileSum).toLocaleString()}} + {hasWind && -} + {hasWind && {Math.round(windYr).toLocaleString()}} + {hasTotalRe && {Math.round(totalReYr).toLocaleString()}} + {hasClientEnd && {Math.round(clientEndYr).toLocaleString()}} + {hasLoad && {Math.round(loadYr).toLocaleString()}} {/* 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 ? ( diff --git a/packages/web/lib/api.ts b/packages/web/lib/api.ts index e103b15..aca0de5 100644 --- a/packages/web/lib/api.ts +++ b/packages/web/lib/api.ts @@ -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 =