"""BESS physical capacity model (degradation + augmentation). This module only models *how much energy* the battery can store each year. Actual dispatch (charge/discharge scheduling) happens in dispatch/hybrid_rtc.py. """ import numpy as np import pandas as pd from remodel_engine.catalog.defaults import PROJECT_LIFE_YEARS from remodel_engine.schemas.generation import BessConfig def simulate_bess_capacity( config: BessConfig, cum_cycles_per_year: list[float], ) -> pd.Series: """Return usable energy capacity (MWh) for each of 25 years. Degradation model: SOH = max(eol_soh, 1 - cum_cycles / design_cycles * (1 - eol_soh)) Usable MWh = (nameplate + augmentation_so_far) * SOH Augmentation steps are additive to nameplate at the specified year. Args: config: BessConfig with nameplate, design_cycles, eol_soh, schedule. cum_cycles_per_year: Cumulative full-equivalent cycles at end of each year (25 values). Caller supplies this from dispatch simulation; use a conservative default (e.g. 365 cycles/yr * n) if dispatch hasn't run. Returns: pd.Series indexed 1-25, values = usable MWh. """ if len(cum_cycles_per_year) != PROJECT_LIFE_YEARS: raise ValueError( f"cum_cycles_per_year must have {PROJECT_LIFE_YEARS} entries, " f"got {len(cum_cycles_per_year)}" ) aug_by_year = {yr: mwh for yr, mwh in config.augmentation_schedule} usable = np.empty(PROJECT_LIFE_YEARS, dtype=np.float64) nameplate_running = config.capacity_mwh for i in range(PROJECT_LIFE_YEARS): year = i + 1 # 1-indexed # Augmentation: add capacity at start of this year if year in aug_by_year: nameplate_running += aug_by_year[year] cum_cyc = cum_cycles_per_year[i] soh = max( config.eol_soh, 1.0 - (cum_cyc / config.design_cycles) * (1.0 - config.eol_soh), ) usable[i] = nameplate_running * soh return pd.Series(usable, index=pd.RangeIndex(1, PROJECT_LIFE_YEARS + 1, name="year"))