"""Solar panel orientation and tilt simulation engine. Simulates energy generation for different panel orientations and tilt angles using pvlib for physics-accurate solar position and irradiance transposition. """ from __future__ import annotations import logging from typing import Any import numpy as np import pandas as pd import param import pvlib from pvlib.irradiance import get_total_irradiance from pvlib.location import Location from solar_intelligence.config import DEFAULT_ALBEDO, DEFAULT_END_YEAR, DEFAULT_TILT_ANGLES, ORIENTATIONS logger = logging.getLogger(__name__) class OrientationSimulator(param.Parameterized): """Simulate solar energy generation across panel orientations and tilts. Uses pvlib for physics-accurate: - Solar position calculation (zenith, azimuth per hour) - Irradiance transposition (GHI → plane-of-array irradiance) - GHI → DNI/DHI decomposition (Erbs model) Parameters ---------- latitude : float Location latitude (-90 to 90). longitude : float Location longitude (-180 to 180). altitude : float Location altitude in meters. tilt_angles : list[int] List of tilt angles to simulate (degrees from horizontal). azimuths : dict[str, int] Mapping of direction names to azimuth angles. surface_albedo : float Ground surface reflectance (0-1). panel_efficiency : float Panel conversion efficiency. panel_area : float Total panel area in m². system_losses : float Combined system losses fraction. """ latitude = param.Number(default=0.0, bounds=(-90, 90)) longitude = param.Number(default=0.0, bounds=(-180, 180)) altitude = param.Number(default=0, bounds=(0, 9000)) tilt_angles = param.List(default=DEFAULT_TILT_ANGLES, item_type=(int, float)) azimuths = param.Dict(default=ORIENTATIONS) surface_albedo = param.Number(default=DEFAULT_ALBEDO, bounds=(0, 1)) panel_efficiency = param.Number(default=0.20, bounds=(0.05, 0.40)) panel_area = param.Number(default=17.0, bounds=(0.1, 10000)) # total m² system_losses = param.Number(default=0.14, bounds=(0, 0.5)) def _get_location(self) -> Location: """Create pvlib Location object.""" return Location( latitude=self.latitude, longitude=self.longitude, altitude=self.altitude, ) @staticmethod def smart_tilt_range(latitude: float) -> list[int]: """Generate tilt angles centered around optimal for given latitude.""" optimal = int(abs(latitude)) tilt_min = max(0, optimal - 20) tilt_max = min(90, optimal + 25) tilts = sorted(set([0] + list(range(tilt_min, tilt_max + 1, 5)) + [90])) return tilts # ------------------------------------------------------------------- # Solar Position # ------------------------------------------------------------------- def solar_position_timeseries( self, year: int = DEFAULT_END_YEAR, freq: str = "h", ) -> pd.DataFrame: """Compute solar position for every hour of the year. Uses pvlib.solarposition for accurate zenith/azimuth calculation. Parameters ---------- year : int Year to simulate. freq : str Time frequency ('h' for hourly). Returns ------- pd.DataFrame Columns: apparent_zenith, zenith, apparent_elevation, elevation, azimuth, equation_of_time """ loc = self._get_location() times = pd.date_range( f"{year}-01-01", f"{year}-12-31 23:00", freq=freq, tz="UTC", ) solpos = loc.get_solarposition(times) return solpos # ------------------------------------------------------------------- # Irradiance Decomposition & Transposition # ------------------------------------------------------------------- def _decompose_ghi( self, ghi_daily: np.ndarray, times: pd.DatetimeIndex, solpos: pd.DataFrame, ) -> tuple[np.ndarray, np.ndarray]: """Decompose daily GHI into hourly DNI and DHI using Erbs model. Parameters ---------- ghi_daily : array Daily GHI values in kWh/m²/day. times : DatetimeIndex Hourly timestamps. solpos : DataFrame Solar position data. Returns ------- tuple[array, array] Hourly DNI and DHI in W/m². """ # Create hourly GHI profile from daily values using cosine model zenith = solpos["apparent_zenith"].values cos_zenith = np.cos(np.radians(zenith)) cos_zenith = np.clip(cos_zenith, 0, 1) # Map daily GHI to each hour's date dates = times.date unique_dates = np.unique(dates) # Build daily GHI lookup (handle mismatched lengths) daily_lookup = {} for i, d in enumerate(unique_dates): if i < len(ghi_daily): daily_lookup[d] = ghi_daily[i] # Distribute daily GHI across hours proportional to cos(zenith) hourly_ghi_w = np.zeros(len(times)) for d in unique_dates: mask = dates == d cz = cos_zenith[mask] daily_total = daily_lookup.get(d, 0) cz_sum = cz.sum() if cz_sum > 0 and daily_total > 0: # Convert kWh/m²/day to W/m² distributed across hours # daily kWh/m² → hourly W/m² hourly_ghi_w[mask] = cz * (daily_total * 1000 / cz_sum) # Use Erbs model for decomposition # First compute extraterrestrial radiation for clearness index dni_extra = pvlib.irradiance.get_extra_radiation(times) cos_z_safe = np.clip(cos_zenith, 0.05, 1.0) # Clearness index kt = np.zeros_like(hourly_ghi_w) hor_extra = dni_extra.values * cos_z_safe valid = hor_extra > 0 kt[valid] = hourly_ghi_w[valid] / hor_extra[valid] kt = np.clip(kt, 0, 1.0) # Erbs model for diffuse fraction kd = np.where( kt <= 0.22, 1.0 - 0.09 * kt, np.where( kt <= 0.80, 0.9511 - 0.1604 * kt + 4.388 * kt**2 - 16.638 * kt**3 + 12.336 * kt**4, 0.165, ), ) dhi = hourly_ghi_w * kd # DNI from GHI and DHI: GHI = DNI × cos(z) + DHI dni = np.zeros_like(hourly_ghi_w) valid_z = cos_z_safe > 0.05 dni[valid_z] = (hourly_ghi_w[valid_z] - dhi[valid_z]) / cos_z_safe[valid_z] dni = np.clip(dni, 0, 1400) return dni, dhi def irradiance_on_tilted_surface( self, tilt: float, azimuth: float, ghi_daily: np.ndarray, times: pd.DatetimeIndex, solpos: pd.DataFrame, dni_hourly: np.ndarray | None = None, dhi_hourly: np.ndarray | None = None, ) -> pd.Series: """Compute plane-of-array irradiance for a tilted surface. Uses pvlib.irradiance.get_total_irradiance() with the isotropic model. Parameters ---------- tilt : float Panel tilt angle (0=horizontal, 90=vertical). azimuth : float Panel azimuth (180=south in Northern Hemisphere). ghi_daily : array Daily GHI in kWh/m²/day. times : DatetimeIndex Hourly timestamps. solpos : DataFrame Solar position data. dni_hourly, dhi_hourly : array, optional Pre-computed hourly DNI/DHI. If None, computed via Erbs model. Returns ------- pd.Series Hourly plane-of-array total irradiance in W/m². """ if dni_hourly is None or dhi_hourly is None: dni_hourly, dhi_hourly = self._decompose_ghi(ghi_daily, times, solpos) # Reconstruct hourly GHI cos_zenith = np.cos(np.radians(solpos["apparent_zenith"].values)) cos_zenith = np.clip(cos_zenith, 0, 1) ghi_hourly = dni_hourly * cos_zenith + dhi_hourly # pvlib transposition poa = get_total_irradiance( surface_tilt=tilt, surface_azimuth=azimuth, solar_zenith=solpos["apparent_zenith"], solar_azimuth=solpos["azimuth"], dni=pd.Series(dni_hourly, index=times), ghi=pd.Series(ghi_hourly, index=times), dhi=pd.Series(dhi_hourly, index=times), albedo=self.surface_albedo, model="isotropic", ) return poa["poa_global"] # ------------------------------------------------------------------- # Full Simulation # ------------------------------------------------------------------- def simulate_all_orientations( self, ghi_daily: np.ndarray, year: int = DEFAULT_END_YEAR, ) -> pd.DataFrame: """Simulate energy production for all orientation × tilt combinations. Parameters ---------- ghi_daily : array Daily GHI values in kWh/m²/day (365 or 366 values). year : int Year to simulate. Returns ------- pd.DataFrame Columns: direction, azimuth_deg, tilt_deg, month, monthly_energy_kwh, annual_energy_kwh """ logger.info("Simulating %d orientations × %d tilts", len(self.azimuths), len(self.tilt_angles)) times = pd.date_range( f"{year}-01-01", f"{year}-12-31 23:00", freq="h", tz="UTC", ) solpos = self._get_location().get_solarposition(times) # Decompose GHI once dni_hourly, dhi_hourly = self._decompose_ghi(ghi_daily, times, solpos) records = [] for direction, az in self.azimuths.items(): for tilt in self.tilt_angles: poa = self.irradiance_on_tilted_surface( tilt=tilt, azimuth=az, ghi_daily=ghi_daily, times=times, solpos=solpos, dni_hourly=dni_hourly, dhi_hourly=dhi_hourly, ) # Convert W/m² hourly → kWh/m² daily → energy poa_kwh = poa.clip(lower=0) / 1000 # W → kW per m² # Monthly energy monthly_poa = poa_kwh.resample("ME").sum() # kWh/m² per month for month_end, poa_month in monthly_poa.items(): energy = ( float(poa_month) * self.panel_efficiency * self.panel_area * (1 - self.system_losses) ) records.append({ "direction": direction, "azimuth_deg": az, "tilt_deg": tilt, "month": month_end.month, "monthly_energy_kwh": round(energy, 2), }) df = pd.DataFrame(records) # Add annual totals annual = df.groupby(["direction", "azimuth_deg", "tilt_deg"])[ "monthly_energy_kwh" ].sum().reset_index() annual = annual.rename(columns={"monthly_energy_kwh": "annual_energy_kwh"}) df = df.merge(annual, on=["direction", "azimuth_deg", "tilt_deg"]) return df def optimal_orientation( self, ghi_daily: np.ndarray, year: int = DEFAULT_END_YEAR, ) -> dict[str, Any]: """Find the optimal panel orientation for maximum annual energy. Parameters ---------- ghi_daily : array Daily GHI values (365/366 values). year : int Simulation year. Returns ------- dict best_direction, best_tilt, best_azimuth, annual_energy_kwh, energy_gain_vs_horizontal_pct, energy_gain_vs_worst_pct """ sim = self.simulate_all_orientations(ghi_daily, year) annual = sim.drop_duplicates(subset=["direction", "tilt_deg"])[ ["direction", "azimuth_deg", "tilt_deg", "annual_energy_kwh"] ] best_row = annual.loc[annual["annual_energy_kwh"].idxmax()] worst_row = annual.loc[annual["annual_energy_kwh"].idxmin()] horizontal = annual[annual["tilt_deg"] == 0].iloc[0] if 0 in self.tilt_angles else best_row best_energy = float(best_row["annual_energy_kwh"]) horiz_energy = float(horizontal["annual_energy_kwh"]) worst_energy = float(worst_row["annual_energy_kwh"]) return { "best_direction": best_row["direction"], "best_tilt": int(best_row["tilt_deg"]), "best_azimuth": int(best_row["azimuth_deg"]), "annual_energy_kwh": round(best_energy, 1), "energy_gain_vs_horizontal_pct": round( (best_energy - horiz_energy) / max(horiz_energy, 1) * 100, 1 ), "energy_gain_vs_worst_pct": round( (best_energy - worst_energy) / max(worst_energy, 1) * 100, 1 ), "worst_direction": worst_row["direction"], "worst_tilt": int(worst_row["tilt_deg"]), } def daily_profile_by_orientation( self, ghi_daily: np.ndarray, date: str = "2023-06-21", directions: list[str] | None = None, tilt: float = 30, ) -> pd.DataFrame: """Compute hourly energy profile for a specific date across orientations. Parameters ---------- ghi_daily : array Full year daily GHI. date : str Target date (YYYY-MM-DD). directions : list[str], optional Directions to compare. Default: South, East, West, North. tilt : float Panel tilt angle. Returns ------- pd.DataFrame Hourly energy by direction for the given date. """ if directions is None: directions = ["South", "East", "West", "North"] year = int(date[:4]) times = pd.date_range( f"{year}-01-01", f"{year}-12-31 23:00", freq="h", tz="UTC", ) solpos = self._get_location().get_solarposition(times) dni_hourly, dhi_hourly = self._decompose_ghi(ghi_daily, times, solpos) # Filter to target date target = pd.Timestamp(date, tz="UTC") day_mask = times.date == target.date() records = [] for direction in directions: az = self.azimuths.get(direction, 180) poa = self.irradiance_on_tilted_surface( tilt=tilt, azimuth=az, ghi_daily=ghi_daily, times=times, solpos=solpos, dni_hourly=dni_hourly, dhi_hourly=dhi_hourly, ) poa_day = poa[day_mask] for hour_time, irr in poa_day.items(): energy = ( max(float(irr), 0) / 1000 * self.panel_efficiency * self.panel_area * (1 - self.system_losses) ) records.append({ "hour": hour_time.hour, "direction": direction, "irradiance_w_m2": max(float(irr), 0), "energy_kwh": round(energy, 4), }) return pd.DataFrame(records) def tilt_sensitivity_analysis( self, ghi_daily: np.ndarray, azimuth: float = 180, year: int = DEFAULT_END_YEAR, tilt_range: list[float] | None = None, ) -> pd.DataFrame: """Analyze energy sensitivity to tilt angle for a fixed azimuth. Parameters ---------- ghi_daily : array Daily GHI values. azimuth : float Fixed azimuth angle (default 180 = south). year : int Simulation year. tilt_range : list[float], optional Custom tilt angles to test. Returns ------- pd.DataFrame Tilt angle vs annual energy. """ tilts = tilt_range or list(range(0, 91, 5)) times = pd.date_range( f"{year}-01-01", f"{year}-12-31 23:00", freq="h", tz="UTC", ) solpos = self._get_location().get_solarposition(times) dni_hourly, dhi_hourly = self._decompose_ghi(ghi_daily, times, solpos) records = [] for tilt in tilts: poa = self.irradiance_on_tilted_surface( tilt=tilt, azimuth=azimuth, ghi_daily=ghi_daily, times=times, solpos=solpos, dni_hourly=dni_hourly, dhi_hourly=dhi_hourly, ) annual_kwh_m2 = float(poa.clip(lower=0).sum()) / 1000 annual_energy = ( annual_kwh_m2 * self.panel_efficiency * self.panel_area * (1 - self.system_losses) ) records.append({ "tilt_deg": tilt, "annual_energy_kwh": round(annual_energy, 1), "annual_kwh_m2": round(annual_kwh_m2, 1), }) return pd.DataFrame(records) def seasonal_comparison( self, ghi_daily: np.ndarray, directions: list[str] | None = None, tilt: float = 30, year: int = DEFAULT_END_YEAR, ) -> pd.DataFrame: """Compare seasonal energy production across orientations. Parameters ---------- ghi_daily : array Daily GHI values. directions : list[str], optional Directions to compare. tilt : float Panel tilt angle. year : int Simulation year. Returns ------- pd.DataFrame Season × direction energy matrix. """ if directions is None: directions = ["South", "East", "West", "North"] sim = self.simulate_all_orientations(ghi_daily, year) filtered = sim[ (sim["direction"].isin(directions)) & (sim["tilt_deg"] == tilt) ].copy() # Map months to seasons season_map = {12: "DJF", 1: "DJF", 2: "DJF", 3: "MAM", 4: "MAM", 5: "MAM", 6: "JJA", 7: "JJA", 8: "JJA", 9: "SON", 10: "SON", 11: "SON"} filtered["season"] = filtered["month"].map(season_map) seasonal = filtered.groupby(["direction", "season"])[ "monthly_energy_kwh" ].sum().reset_index() seasonal = seasonal.rename(columns={"monthly_energy_kwh": "seasonal_energy_kwh"}) return seasonal # ------------------------------------------------------------------- # Tracking Simulation # ------------------------------------------------------------------- def simulate_tracking( self, ghi_daily: np.ndarray, year: int = DEFAULT_END_YEAR, mode: str = "single_axis", ) -> dict[str, Any]: """Simulate single-axis or dual-axis solar tracker performance. Single-axis: rotates east-west to follow the sun's daily arc. Dual-axis: tracks both azimuth and elevation for maximum irradiance. Parameters ---------- ghi_daily : array Daily GHI values (365/366 values). year : int Simulation year. mode : str 'single_axis' or 'dual_axis'. Returns ------- dict tracking_mode, annual_energy_kwh, gain_vs_fixed_pct, best_fixed_energy_kwh. """ times = pd.date_range( f"{year}-01-01", f"{year}-12-31 23:00", freq="h", tz="UTC", ) solpos = self._get_location().get_solarposition(times) dni_hourly, dhi_hourly = self._decompose_ghi(ghi_daily, times, solpos) cos_zenith = np.cos(np.radians(solpos["apparent_zenith"].values)) cos_zenith = np.clip(cos_zenith, 0, 1) ghi_hourly = dni_hourly * cos_zenith + dhi_hourly if mode == "dual_axis": # Dual-axis: tilt = zenith, azimuth = solar azimuth (always face sun) # POA = DNI + DHI (maximum possible capture) poa = np.clip(dni_hourly + dhi_hourly, 0, None) else: # Single-axis (N-S axis, tracks E-W): # Effective tilt follows solar elevation, azimuth = solar azimuth solar_elev = 90 - solpos["apparent_zenith"].values solar_elev = np.clip(solar_elev, 0, 90) # Tracking tilt = 90 - elevation (face the sun vertically) tracking_tilt = 90 - solar_elev # Simplified: single-axis captures ~85-90% of dual-axis gain poa = np.zeros(len(times)) for i in range(len(times)): if solar_elev[i] > 0: result = get_total_irradiance( surface_tilt=float(tracking_tilt[i]), surface_azimuth=float(solpos["azimuth"].values[i]), solar_zenith=float(solpos["apparent_zenith"].values[i]), solar_azimuth=float(solpos["azimuth"].values[i]), dni=float(dni_hourly[i]), ghi=float(ghi_hourly[i]), dhi=float(dhi_hourly[i]), albedo=self.surface_albedo, model="isotropic", ) val = result["poa_global"] poa[i] = max(float(val.iloc[0] if hasattr(val, 'iloc') else val), 0) annual_kwh_m2 = float(np.sum(np.clip(poa, 0, None))) / 1000 tracking_energy = ( annual_kwh_m2 * self.panel_efficiency * self.panel_area * (1 - self.system_losses) ) # Get best fixed orientation for comparison optimal = self.optimal_orientation(ghi_daily, year) fixed_energy = optimal["annual_energy_kwh"] gain = (tracking_energy - fixed_energy) / max(fixed_energy, 1) * 100 return { "tracking_mode": mode, "annual_energy_kwh": round(tracking_energy, 1), "best_fixed_energy_kwh": fixed_energy, "gain_vs_fixed_pct": round(gain, 1), } # ------------------------------------------------------------------- # Shading Model # ------------------------------------------------------------------- def horizon_shading( self, ghi_daily: np.ndarray, horizon_profile: dict[float, float] | None = None, year: int = DEFAULT_END_YEAR, ) -> dict[str, Any]: """Estimate energy loss from horizon obstructions. Models the effect of surrounding buildings/terrain that block low-angle sunlight. The horizon profile defines the minimum solar elevation visible at each azimuth. Parameters ---------- ghi_daily : array Daily GHI values. horizon_profile : dict[float, float], optional Azimuth (degrees) -> minimum visible elevation (degrees). Default: flat horizon (no shading). year : int Simulation year. Returns ------- dict shading_loss_pct, unshaded_energy_kwh, shaded_energy_kwh, worst_azimuth, worst_elevation. """ if horizon_profile is None: # Default: some obstruction to the north/east horizon_profile = { 0: 15, 45: 10, 90: 5, 135: 2, 180: 0, 225: 2, 270: 5, 315: 10, } times = pd.date_range( f"{year}-01-01", f"{year}-12-31 23:00", freq="h", tz="UTC", ) solpos = self._get_location().get_solarposition(times) solar_elev = 90 - solpos["apparent_zenith"].values solar_az = solpos["azimuth"].values # Interpolate horizon profile hz_azimuths = sorted(horizon_profile.keys()) hz_elevations = [horizon_profile[a] for a in hz_azimuths] # Add wrap-around hz_azimuths_ext = [a - 360 for a in hz_azimuths] + hz_azimuths + [a + 360 for a in hz_azimuths] hz_elevations_ext = hz_elevations * 3 min_elev = np.interp(solar_az % 360, hz_azimuths, hz_elevations) # Compute shading mask shaded = solar_elev < min_elev daylight = solar_elev > 0 shaded_daylight = shaded & daylight # Compute energy with and without shading (using GHI as proxy) dni_hourly, dhi_hourly = self._decompose_ghi(ghi_daily, times, solpos) cos_z = np.clip(np.cos(np.radians(solpos["apparent_zenith"].values)), 0, 1) ghi_hourly = dni_hourly * cos_z + dhi_hourly total_ghi = float(np.sum(ghi_hourly[daylight])) shaded_ghi = float(np.sum(ghi_hourly[shaded_daylight])) loss_pct = (shaded_ghi / max(total_ghi, 1)) * 100 unshaded_energy = ( (total_ghi / 1000) * self.panel_efficiency * self.panel_area * (1 - self.system_losses) ) shaded_energy = unshaded_energy * (1 - loss_pct / 100) return { "shading_loss_pct": round(loss_pct, 1), "unshaded_energy_kwh": round(unshaded_energy, 1), "shaded_energy_kwh": round(shaded_energy, 1), "hours_shaded": int(shaded_daylight.sum()), "total_daylight_hours": int(daylight.sum()), } def inter_row_shading( self, tilt: float = 30, row_spacing_ratio: float = 2.0, ) -> dict[str, float]: """Estimate inter-row shading loss for ground-mount solar farms. Uses geometric analysis of shadow length at winter solstice to determine minimum row spacing and shading losses. Parameters ---------- tilt : float Panel tilt angle (degrees). row_spacing_ratio : float Ratio of row spacing to panel height (distance / height). Returns ------- dict shadow_length_ratio, shading_loss_pct, min_spacing_ratio, current_spacing_adequate. """ tilt_rad = np.radians(tilt) panel_height = np.sin(tilt_rad) # Projected height # Winter solstice noon solar elevation lat_rad = np.radians(abs(self.latitude)) winter_elev = 90 - abs(self.latitude) - 23.45 # Approximate winter_elev = max(winter_elev, 5) winter_elev_rad = np.radians(winter_elev) # Shadow length = panel_height / tan(solar_elevation) shadow_length = panel_height / max(np.tan(winter_elev_rad), 0.05) # Minimum spacing to avoid shading min_spacing = shadow_length + np.cos(tilt_rad) # Panel ground projection # Actual spacing actual_spacing = row_spacing_ratio # Shading loss estimate if actual_spacing >= min_spacing: loss = 0.0 else: # Linear model: loss proportional to overlap fraction overlap = (min_spacing - actual_spacing) / min_spacing loss = min(overlap * 25, 30) # Cap at 30% loss return { "shadow_length_ratio": round(shadow_length, 2), "min_spacing_ratio": round(min_spacing, 2), "shading_loss_pct": round(loss, 1), "current_spacing_adequate": bool(actual_spacing >= min_spacing), } # ------------------------------------------------------------------- # Bifacial Gain # ------------------------------------------------------------------- def bifacial_gain( self, ghi_daily: np.ndarray, tilt: float = 30, bifaciality: float = 0.70, height: float = 1.0, year: int = DEFAULT_END_YEAR, ) -> dict[str, float]: """Estimate energy gain from bifacial (double-sided) solar panels. Bifacial panels collect reflected light on the rear side. The gain depends on ground albedo, panel height, and tilt angle. Parameters ---------- ghi_daily : array Daily GHI values. tilt : float Panel tilt angle (degrees). bifaciality : float Ratio of rear-to-front efficiency (typically 0.65-0.80). height : float Panel mounting height above ground (meters). year : int Simulation year. Returns ------- dict bifacial_gain_pct, rear_irradiance_pct, front_energy_kwh, total_energy_kwh. """ # Rear-side irradiance model (simplified) # Ground-reflected irradiance reaching rear = GHI * albedo * view_factor tilt_rad = np.radians(tilt) # View factor depends on tilt and height # Higher panels and steeper tilts see more ground reflection view_factor = 0.5 * (1 - np.cos(tilt_rad)) # Isotropic sky model height_factor = min(height / 1.5, 1.0) # Normalized to 1.5m reference rear_fraction = self.surface_albedo * view_factor * height_factor * bifaciality # Compute front energy avg_daily_ghi = float(np.mean(ghi_daily)) annual_ghi = avg_daily_ghi * len(ghi_daily) front_energy = ( annual_ghi * self.panel_efficiency * self.panel_area * (1 - self.system_losses) ) rear_energy = front_energy * rear_fraction total_energy = front_energy + rear_energy gain_pct = (rear_energy / max(front_energy, 1)) * 100 return { "bifacial_gain_pct": round(gain_pct, 1), "rear_irradiance_pct": round(rear_fraction * 100, 1), "front_energy_kwh": round(front_energy, 1), "total_energy_kwh": round(total_energy, 1), } # --------------------------------------------------------------------------- # Rooftop Suitability Scoring # --------------------------------------------------------------------------- class RooftopScorer(param.Parameterized): """Score rooftop solar suitability on a 0-100 scale. Combines multiple factors into a weighted composite score: - Solar resource quality (GHI level) - Optimal tilt match (how close roof pitch is to ideal) - Climate stability (irradiance variability) - Temperature factor (extreme heat reduces efficiency) Parameters ---------- latitude : float Location latitude. longitude : float Location longitude. """ latitude = param.Number(default=0.0, bounds=(-90, 90)) longitude = param.Number(default=0.0, bounds=(-180, 180)) # Scoring weights weight_solar = param.Number(default=0.40, doc="Weight for solar resource quality") weight_tilt = param.Number(default=0.20, doc="Weight for tilt match") weight_stability = param.Number(default=0.20, doc="Weight for climate stability") weight_temperature = param.Number(default=0.20, doc="Weight for temperature factor") def score( self, avg_daily_ghi: float, optimal_tilt: float, roof_tilt: float = 30, variability_index: float = 0.15, avg_temperature: float = 25, ) -> dict[str, Any]: """Compute rooftop suitability score. Parameters ---------- avg_daily_ghi : float Average daily GHI in kWh/m2/day. optimal_tilt : float Optimal panel tilt angle for the location. roof_tilt : float Actual roof pitch in degrees. variability_index : float Solar variability (std/mean of daily GHI). Lower = more stable. avg_temperature : float Average annual temperature in Celsius. Returns ------- dict total_score (0-100), component scores, rating, recommendations. """ # 1. Solar resource score (0-100) # 7+ kWh/m2/day = 100, 1 kWh/m2/day = 0 solar_score = min(100, max(0, (avg_daily_ghi - 1) / 6 * 100)) # 2. Tilt match score (0-100) # Perfect match = 100, 45+ degree difference = 0 tilt_diff = abs(roof_tilt - optimal_tilt) tilt_score = max(0, 100 - tilt_diff * (100 / 45)) # 3. Stability score (0-100) # Variability < 0.10 = 100, > 0.40 = 0 stability_score = max(0, min(100, (0.40 - variability_index) / 0.30 * 100)) # 4. Temperature score (0-100) # Moderate temps (15-25C) are ideal for solar # Very hot (>40C) or very cold (<-10C) reduce score if 15 <= avg_temperature <= 25: temp_score = 100 elif avg_temperature > 25: temp_score = max(0, 100 - (avg_temperature - 25) * 4) else: temp_score = max(0, 100 - (15 - avg_temperature) * 3) # Weighted total total = ( solar_score * self.weight_solar + tilt_score * self.weight_tilt + stability_score * self.weight_stability + temp_score * self.weight_temperature ) # Rating if total >= 80: rating = "Excellent" elif total >= 60: rating = "Good" elif total >= 40: rating = "Moderate" elif total >= 20: rating = "Poor" else: rating = "Not Recommended" # Recommendations recommendations = [] if solar_score < 50: recommendations.append("Low solar resource - consider alternative energy") if tilt_score < 50: recommendations.append( f"Roof pitch ({roof_tilt}) differs from optimal ({optimal_tilt:.0f}) - " "consider adjustable mounting" ) if stability_score < 50: recommendations.append("High variability - consider battery storage") if temp_score < 50: recommendations.append("Extreme temperatures reduce panel efficiency") return { "total_score": round(total, 1), "rating": rating, "components": { "solar_resource": round(solar_score, 1), "tilt_match": round(tilt_score, 1), "climate_stability": round(stability_score, 1), "temperature": round(temp_score, 1), }, "weights": { "solar_resource": self.weight_solar, "tilt_match": self.weight_tilt, "climate_stability": self.weight_stability, "temperature": self.weight_temperature, }, "recommendations": recommendations, }