diff --git a/packages/api/src/remodel_api/workers/tasks.py b/packages/api/src/remodel_api/workers/tasks.py index bc6afbd..c90866f 100644 --- a/packages/api/src/remodel_api/workers/tasks.py +++ b/packages/api/src/remodel_api/workers/tasks.py @@ -59,6 +59,13 @@ def _run_engine(inputs_json: str) -> dict[str, Any]: # Hourly generation: 25 years × 8760 hours "solar_hourly": result.solar_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], "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 08b17ff..38f4615 100644 --- a/packages/engine/src/remodel_engine/scenarios/runner.py +++ b/packages/engine/src/remodel_engine/scenarios/runner.py @@ -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 {} + # 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( inputs=inputs, status="success", @@ -493,4 +543,10 @@ def run_scenario(inputs: ScenarioInput) -> ScenarioResult: idc_phasing=idc_phasing, solar_hourly=pipe.solar_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, ) diff --git a/packages/engine/src/remodel_engine/schemas/financial.py b/packages/engine/src/remodel_engine/schemas/financial.py index 6dc33f3..136a65b 100644 --- a/packages/engine/src/remodel_engine/schemas/financial.py +++ b/packages/engine/src/remodel_engine/schemas/financial.py @@ -40,6 +40,7 @@ class CommercialConfig(BaseModel): 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)") + 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") 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") diff --git a/packages/engine/src/remodel_engine/schemas/scenario.py b/packages/engine/src/remodel_engine/schemas/scenario.py index 950b310..8008dc0 100644 --- a/packages/engine/src/remodel_engine/schemas/scenario.py +++ b/packages/engine/src/remodel_engine/schemas/scenario.py @@ -108,3 +108,10 @@ class ScenarioResult(BaseModel): # 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) + # 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) diff --git a/packages/web/components/WorkbookView.tsx b/packages/web/components/WorkbookView.tsx index 901aa75..165deb9 100644 --- a/packages/web/components/WorkbookView.tsx +++ b/packages/web/components/WorkbookView.tsx @@ -531,13 +531,23 @@ const MONTH_DAYS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; interface HourlyData { solar_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 }) { - 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 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 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); }; + // 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) { return