solar-intelligence / src /solar_intelligence /orientation_simulator.py
ghostieee11's picture
Upload folder using huggingface_hub
9379d4a verified
Raw
History Blame Contribute Delete
34.8 kB
"""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,
}