- 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>
60 lines
2.1 KiB
Python
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"))
|