Spaces:
Sleeping
Sleeping
| """Tests for Phase 3: Advanced simulation features. | |
| Covers: | |
| - Single-axis and dual-axis tracking simulation | |
| - Horizon shading model | |
| - Inter-row shading for solar farms | |
| - Bifacial panel gain estimation | |
| - Rooftop suitability scoring (RooftopScorer) | |
| """ | |
| from __future__ import annotations | |
| import numpy as np | |
| import pytest | |
| from solar_intelligence.data_loader import generate_synthetic_solar_data | |
| from solar_intelligence.orientation_simulator import OrientationSimulator, RooftopScorer | |
| def dataset(): | |
| return generate_synthetic_solar_data(lat=28.6, lon=77.2, start_year=2023, end_year=2023) | |
| def ghi(dataset): | |
| return dataset["ALLSKY_SFC_SW_DWN"].values | |
| def sim(): | |
| return OrientationSimulator( | |
| latitude=28.6, longitude=77.2, | |
| tilt_angles=[0, 30], | |
| azimuths={"North": 0, "South": 180, "East": 90, "West": 270}, | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Tracking Simulation Tests | |
| # --------------------------------------------------------------------------- | |
| class TestTrackingSimulation: | |
| def test_single_axis_returns_dict(self, sim, ghi): | |
| result = sim.simulate_tracking(ghi, mode="single_axis") | |
| assert isinstance(result, dict) | |
| assert "tracking_mode" in result | |
| assert result["tracking_mode"] == "single_axis" | |
| def test_single_axis_has_energy(self, sim, ghi): | |
| result = sim.simulate_tracking(ghi, mode="single_axis") | |
| assert result["annual_energy_kwh"] > 0 | |
| def test_single_axis_beats_fixed(self, sim, ghi): | |
| result = sim.simulate_tracking(ghi, mode="single_axis") | |
| assert result["gain_vs_fixed_pct"] > 0 | |
| def test_dual_axis_returns_dict(self, sim, ghi): | |
| result = sim.simulate_tracking(ghi, mode="dual_axis") | |
| assert result["tracking_mode"] == "dual_axis" | |
| assert result["annual_energy_kwh"] > 0 | |
| def test_dual_axis_beats_single(self, sim, ghi): | |
| single = sim.simulate_tracking(ghi, mode="single_axis") | |
| dual = sim.simulate_tracking(ghi, mode="dual_axis") | |
| # Dual axis should capture more or equal energy | |
| assert dual["annual_energy_kwh"] >= single["annual_energy_kwh"] * 0.9 | |
| # --------------------------------------------------------------------------- | |
| # Horizon Shading Tests | |
| # --------------------------------------------------------------------------- | |
| class TestHorizonShading: | |
| def test_shading_returns_dict(self, sim, ghi): | |
| result = sim.horizon_shading(ghi) | |
| assert isinstance(result, dict) | |
| assert "shading_loss_pct" in result | |
| def test_shading_loss_positive(self, sim, ghi): | |
| # Default profile has obstructions | |
| result = sim.horizon_shading(ghi) | |
| assert result["shading_loss_pct"] >= 0 | |
| def test_no_shading_with_flat_horizon(self, sim, ghi): | |
| # Flat horizon = no obstructions | |
| flat = {az: 0 for az in range(0, 360, 45)} | |
| result = sim.horizon_shading(ghi, horizon_profile=flat) | |
| assert result["shading_loss_pct"] == 0 | |
| def test_heavy_shading_high_loss(self, sim, ghi): | |
| # High obstructions all around | |
| heavy = {az: 40 for az in range(0, 360, 45)} | |
| result = sim.horizon_shading(ghi, horizon_profile=heavy) | |
| assert result["shading_loss_pct"] > 5 | |
| def test_shaded_energy_less_than_unshaded(self, sim, ghi): | |
| result = sim.horizon_shading(ghi) | |
| assert result["shaded_energy_kwh"] <= result["unshaded_energy_kwh"] | |
| def test_daylight_hours_positive(self, sim, ghi): | |
| result = sim.horizon_shading(ghi) | |
| assert result["total_daylight_hours"] > 2000 | |
| # --------------------------------------------------------------------------- | |
| # Inter-Row Shading Tests | |
| # --------------------------------------------------------------------------- | |
| class TestInterRowShading: | |
| def test_inter_row_returns_dict(self, sim): | |
| result = sim.inter_row_shading(tilt=30, row_spacing_ratio=2.0) | |
| assert isinstance(result, dict) | |
| def test_adequate_spacing_no_loss(self, sim): | |
| result = sim.inter_row_shading(tilt=30, row_spacing_ratio=10.0) | |
| assert result["shading_loss_pct"] == 0 | |
| assert result["current_spacing_adequate"] is True | |
| def test_tight_spacing_has_loss(self, sim): | |
| result = sim.inter_row_shading(tilt=30, row_spacing_ratio=0.5) | |
| assert result["shading_loss_pct"] > 0 | |
| assert result["current_spacing_adequate"] is False | |
| def test_min_spacing_positive(self, sim): | |
| result = sim.inter_row_shading(tilt=30) | |
| assert result["min_spacing_ratio"] > 0 | |
| def test_steeper_tilt_longer_shadow(self, sim): | |
| shallow = sim.inter_row_shading(tilt=15) | |
| steep = sim.inter_row_shading(tilt=60) | |
| assert steep["shadow_length_ratio"] > shallow["shadow_length_ratio"] | |
| # --------------------------------------------------------------------------- | |
| # Bifacial Gain Tests | |
| # --------------------------------------------------------------------------- | |
| class TestBifacialGain: | |
| def test_bifacial_returns_dict(self, sim, ghi): | |
| result = sim.bifacial_gain(ghi, tilt=30) | |
| assert isinstance(result, dict) | |
| assert "bifacial_gain_pct" in result | |
| def test_bifacial_gain_positive(self, sim, ghi): | |
| result = sim.bifacial_gain(ghi, tilt=30) | |
| assert result["bifacial_gain_pct"] > 0 | |
| def test_total_exceeds_front(self, sim, ghi): | |
| result = sim.bifacial_gain(ghi, tilt=30) | |
| assert result["total_energy_kwh"] > result["front_energy_kwh"] | |
| def test_higher_tilt_more_rear(self, sim, ghi): | |
| low_tilt = sim.bifacial_gain(ghi, tilt=10) | |
| high_tilt = sim.bifacial_gain(ghi, tilt=45) | |
| assert high_tilt["rear_irradiance_pct"] > low_tilt["rear_irradiance_pct"] | |
| def test_higher_bifaciality_more_gain(self, sim, ghi): | |
| low_bf = sim.bifacial_gain(ghi, tilt=30, bifaciality=0.50) | |
| high_bf = sim.bifacial_gain(ghi, tilt=30, bifaciality=0.85) | |
| assert high_bf["bifacial_gain_pct"] > low_bf["bifacial_gain_pct"] | |
| def test_horizontal_zero_gain(self, sim, ghi): | |
| result = sim.bifacial_gain(ghi, tilt=0) | |
| # At 0 tilt, view factor is 0 -> no rear irradiance | |
| assert result["bifacial_gain_pct"] == 0 | |
| # --------------------------------------------------------------------------- | |
| # Rooftop Suitability Scorer Tests | |
| # --------------------------------------------------------------------------- | |
| class TestRooftopScorer: | |
| def test_scorer_returns_dict(self): | |
| scorer = RooftopScorer() | |
| result = scorer.score(avg_daily_ghi=5.5, optimal_tilt=28, roof_tilt=30) | |
| assert isinstance(result, dict) | |
| assert "total_score" in result | |
| assert "rating" in result | |
| def test_score_range(self): | |
| scorer = RooftopScorer() | |
| result = scorer.score(avg_daily_ghi=5.5, optimal_tilt=28, roof_tilt=30) | |
| assert 0 <= result["total_score"] <= 100 | |
| def test_excellent_location(self): | |
| scorer = RooftopScorer() | |
| result = scorer.score( | |
| avg_daily_ghi=7.0, optimal_tilt=25, roof_tilt=25, | |
| variability_index=0.08, avg_temperature=22, | |
| ) | |
| assert result["rating"] == "Excellent" | |
| assert result["total_score"] >= 80 | |
| def test_poor_location(self): | |
| scorer = RooftopScorer() | |
| result = scorer.score( | |
| avg_daily_ghi=1.5, optimal_tilt=55, roof_tilt=10, | |
| variability_index=0.35, avg_temperature=-5, | |
| ) | |
| assert result["total_score"] < 40 | |
| def test_components_present(self): | |
| scorer = RooftopScorer() | |
| result = scorer.score(avg_daily_ghi=5.0, optimal_tilt=30, roof_tilt=30) | |
| assert "solar_resource" in result["components"] | |
| assert "tilt_match" in result["components"] | |
| assert "climate_stability" in result["components"] | |
| assert "temperature" in result["components"] | |
| def test_weights_sum_to_one(self): | |
| scorer = RooftopScorer() | |
| result = scorer.score(avg_daily_ghi=5.0, optimal_tilt=30, roof_tilt=30) | |
| weights = result["weights"] | |
| total_weight = sum(weights.values()) | |
| assert abs(total_weight - 1.0) < 0.01 | |
| def test_tilt_mismatch_lowers_score(self): | |
| scorer = RooftopScorer() | |
| matched = scorer.score(avg_daily_ghi=5.5, optimal_tilt=30, roof_tilt=30) | |
| mismatched = scorer.score(avg_daily_ghi=5.5, optimal_tilt=30, roof_tilt=80) | |
| assert matched["total_score"] > mismatched["total_score"] | |
| def test_recommendations_for_poor(self): | |
| scorer = RooftopScorer() | |
| result = scorer.score( | |
| avg_daily_ghi=1.0, optimal_tilt=30, roof_tilt=80, | |
| variability_index=0.45, avg_temperature=45, | |
| ) | |
| assert len(result["recommendations"]) > 0 | |
| def test_no_recommendations_for_excellent(self): | |
| scorer = RooftopScorer() | |
| result = scorer.score( | |
| avg_daily_ghi=7.0, optimal_tilt=25, roof_tilt=25, | |
| variability_index=0.08, avg_temperature=22, | |
| ) | |
| assert len(result["recommendations"]) == 0 | |