- 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>
96 lines
3 KiB
Python
96 lines
3 KiB
Python
"""S1-T07: Wind simulation unit tests."""
|
|
|
|
import numpy as np
|
|
|
|
from remodel_engine.generation.wind import annual_plf, simulate_wind
|
|
from remodel_engine.schemas.generation import WindConfig
|
|
|
|
SMALL_WIND = WindConfig(
|
|
location_id="RJ",
|
|
capacity_mw=100.0,
|
|
)
|
|
|
|
|
|
def test_output_shape() -> None:
|
|
df = simulate_wind(SMALL_WIND)
|
|
assert df.shape == (25 * 8760, 6)
|
|
assert set(df.columns) == {
|
|
"year", "hour", "wind_speed_hub", "power_fraction", "ac_power_mw", "degradation_factor"
|
|
}
|
|
|
|
|
|
def test_plf_in_reasonable_range() -> None:
|
|
"""Rajasthan wind PLF should be 25 %-45 %."""
|
|
df = simulate_wind(SMALL_WIND)
|
|
plf = annual_plf(df, SMALL_WIND.capacity_mw)
|
|
assert all(0.25 <= v <= 0.45 for v in plf), f"PLF out of range: {plf.values}"
|
|
|
|
|
|
def test_no_negative_power() -> None:
|
|
df = simulate_wind(SMALL_WIND)
|
|
assert (df["ac_power_mw"] >= 0).all()
|
|
|
|
|
|
def test_power_never_exceeds_capacity() -> None:
|
|
df = simulate_wind(SMALL_WIND)
|
|
assert (df["ac_power_mw"] <= SMALL_WIND.capacity_mw + 1e-9).all()
|
|
|
|
|
|
def test_power_fraction_in_01() -> None:
|
|
df = simulate_wind(SMALL_WIND)
|
|
assert (df["power_fraction"] >= 0).all()
|
|
assert (df["power_fraction"] <= 1.0 + 1e-9).all()
|
|
|
|
|
|
def test_degradation_monotonically_decreasing() -> None:
|
|
df = simulate_wind(SMALL_WIND)
|
|
yearly_deg = df.groupby("year")["degradation_factor"].first().values
|
|
diffs = np.diff(yearly_deg)
|
|
assert (diffs < 0).all(), "Wind degradation factors must decrease each year"
|
|
|
|
|
|
def test_year1_degradation_is_one() -> None:
|
|
"""Wind degradation starts at 1.0 in year 1 (no LID, unlike solar)."""
|
|
df = simulate_wind(SMALL_WIND)
|
|
y1_deg = df[df["year"] == 1]["degradation_factor"].iloc[0]
|
|
assert abs(y1_deg - 1.0) < 1e-9
|
|
|
|
|
|
def test_zero_wind_speed_gives_zero_power() -> None:
|
|
"""Hours with zero wind (below cut-in) must produce zero power."""
|
|
df = simulate_wind(SMALL_WIND)
|
|
zero_wind = df[df["wind_speed_hub"] < 3.0]
|
|
if len(zero_wind) > 0:
|
|
assert (zero_wind["power_fraction"] < 1e-9).all()
|
|
|
|
|
|
def test_gale_wind_cutout() -> None:
|
|
"""Wind speeds above cut-out (25 m/s) should produce zero power."""
|
|
df = simulate_wind(SMALL_WIND)
|
|
gale = df[df["wind_speed_hub"] > 25.0]
|
|
if len(gale) > 0:
|
|
assert (gale["ac_power_mw"] < 1e-9).all()
|
|
|
|
|
|
def test_shear_increases_wind_speed() -> None:
|
|
"""Hub height > ref height → hub wind speed > ref wind speed."""
|
|
cfg = WindConfig(
|
|
location_id="RJ",
|
|
capacity_mw=100.0,
|
|
hub_height_m=140.0,
|
|
ref_height_m=100.0,
|
|
wind_shear_exponent=0.14,
|
|
)
|
|
df = simulate_wind(cfg)
|
|
# hub speeds should be larger than reference speeds on average
|
|
from remodel_engine.catalog.loader import load_wind_profile
|
|
ref_speeds = load_wind_profile("RJ")
|
|
hub_speeds = df[df["year"] == 1]["wind_speed_hub"].values
|
|
assert hub_speeds.mean() > ref_speeds.mean()
|
|
|
|
|
|
def test_ka_plf_reasonable() -> None:
|
|
cfg = WindConfig(location_id="KA", capacity_mw=100.0)
|
|
df = simulate_wind(cfg)
|
|
plf = annual_plf(df, cfg.capacity_mw)
|
|
assert all(0.30 <= v <= 0.55 for v in plf)
|