Remodel/packages/engine/src/remodel_engine/generation/bess_state.py
Mannu 314127effc [S1-T01 through T11] Solar, Wind, BESS generation simulation + CLI
- Pydantic schemas: SolarConfig, WindConfig, BessConfig, GenerationResult
- Catalog: synthetic 8760h profiles for RJ/KA/GJ solar and wind; LRU-cached loader
- generation/solar.py: 25yr hourly simulation (DC losses, inverter, clipping, soiling, degradation)
- generation/wind.py: power-law shear, piecewise power curve, wake/electrical losses, degradation
- generation/bess_state.py: SOH-based capacity degradation with augmentation schedule
- CLI: remodel --input scenario.json --output gen.parquet (Typer upgraded to 0.25.1 for Click 8.3 compat)
- 43 unit tests, 97.4% coverage; mypy strict + ruff clean
- S1-T10 parity gate: placeholder fixture + skipped integration tests (awaiting Nagasamudra data)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 10:04:21 +05:30

60 lines
2.1 KiB
Python

"""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"))