Remodel/packages/engine/tests/unit/test_solar.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

118 lines
3.8 KiB
Python

"""S1-T05: Solar simulation unit tests."""
import numpy as np
from remodel_engine.generation.solar import annual_cuf, simulate_solar
from remodel_engine.schemas.generation import SolarConfig
SMALL_SOLAR = SolarConfig(
location_id="RJ",
capacity_dc_mwp=100.0,
capacity_ac_mw=80.0,
)
def test_output_shape() -> None:
df = simulate_solar(SMALL_SOLAR)
assert df.shape == (25 * 8760, 6)
assert set(df.columns) == {
"year", "hour", "dc_power_mw", "ac_power_mw_pre_clip", "ac_power_mw", "degradation_factor"
}
def test_year_range() -> None:
df = simulate_solar(SMALL_SOLAR)
assert df["year"].min() == 1
assert df["year"].max() == 25
def test_hour_range() -> None:
df = simulate_solar(SMALL_SOLAR)
y1 = df[df["year"] == 1]
assert y1["hour"].min() == 0
assert y1["hour"].max() == 8759
def test_cuf_in_reasonable_range() -> None:
"""Solar CUF in Rajasthan should be between 15 % and 30 %."""
df = simulate_solar(SMALL_SOLAR)
cuf = annual_cuf(df, SMALL_SOLAR.capacity_ac_mw)
assert all(0.15 <= v <= 0.30 for v in cuf), f"CUF out of range: {cuf.values}"
def test_no_negative_power() -> None:
df = simulate_solar(SMALL_SOLAR)
assert (df["ac_power_mw"] >= 0).all()
assert (df["dc_power_mw"] >= 0).all()
def test_ac_never_exceeds_ac_capacity() -> None:
"""Clipping must hold: AC power ≤ capacity_ac_mw (with small float tolerance)."""
df = simulate_solar(SMALL_SOLAR)
assert (df["ac_power_mw"] <= SMALL_SOLAR.capacity_ac_mw + 1e-9).all()
def test_clipping_occurs() -> None:
"""High DC/AC ratio should produce some clipped hours."""
cfg = SolarConfig(
location_id="RJ",
capacity_dc_mwp=140.0, # DC/AC = 1.75 — aggressive over-panel
capacity_ac_mw=80.0,
)
df = simulate_solar(cfg)
y1 = df[df["year"] == 1]
# ac_power_mw_pre_clip should exceed ac_power_mw in some hours
clipped_hours = (y1["ac_power_mw_pre_clip"] > y1["ac_power_mw"] + 1e-9).sum()
assert clipped_hours > 0, "Expected clipping for DC/AC ratio 1.75"
def test_no_clipping_when_dc_le_ac() -> None:
"""When DC capacity = AC capacity, ac_power_mw_pre_clip should never reach the AC cap
(losses reduce AC output below DC nameplate, so the clip never bites)."""
cfg = SolarConfig(
location_id="RJ",
capacity_dc_mwp=80.0,
capacity_ac_mw=80.0,
)
df = simulate_solar(cfg)
y1 = df[df["year"] == 1]
# pre-clip column (before availability/soiling) must be < capacity_ac_mw everywhere
assert (y1["ac_power_mw_pre_clip"] <= cfg.capacity_ac_mw + 1e-9).all()
def test_degradation_monotonically_decreasing() -> None:
df = simulate_solar(SMALL_SOLAR)
yearly_deg = (
df.groupby("year")["degradation_factor"]
.first()
.values
)
diffs = np.diff(yearly_deg)
assert (diffs < 0).all(), "Degradation factors must decrease each year"
def test_degradation_y1_applied() -> None:
df = simulate_solar(SMALL_SOLAR)
y1_deg = df[df["year"] == 1]["degradation_factor"].iloc[0]
expected = 1.0 - SMALL_SOLAR.degradation_y1
assert abs(y1_deg - expected) < 1e-9
def test_year25_degradation() -> None:
df = simulate_solar(SMALL_SOLAR)
y25_deg = df[df["year"] == 25]["degradation_factor"].iloc[0]
expected = (1.0 - SMALL_SOLAR.degradation_y1) * (1.0 - SMALL_SOLAR.degradation_annual) ** 24
assert abs(y25_deg - expected) < 1e-9
def test_dc_always_ge_ac_pre_clip() -> None:
"""DC output should always >= AC output before losses convert it down."""
df = simulate_solar(SMALL_SOLAR)
assert (df["dc_power_mw"] >= df["ac_power_mw"] - 1e-9).all()
def test_location_ka_cuf_reasonable() -> None:
cfg = SolarConfig(location_id="KA", capacity_dc_mwp=100.0, capacity_ac_mw=80.0)
df = simulate_solar(cfg)
cuf = annual_cuf(df, cfg.capacity_ac_mw)
assert all(0.15 <= v <= 0.35 for v in cuf)