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