How to Find the Optimal Tilt and Orientation for Solar Panels
On this page
Problem statement
The rule of thumb says tilt the panels at the site's latitude and point them at the equator. In Toledo, at 39.9°N, that gives 40°. PVGIS's optimiser says 36°. A roof or a hillside offers 20° facing south-east. The questions that matter for siting are not "what is the optimum" but "how flat is the optimum" and "what does the angle I am actually offered cost".
Answering them means computing the yield over a grid of angles — a few hundred runs of the same model — and reading the surface, not just its peak.
Quick answer
Build the plane-of-array irradiance for every tilt and azimuth with pvlib, and take the one with the most energy:
import requests, numpy as np, pandas as pd, pvlib
def weather(lat, lon):
j = requests.get("https://archive-api.open-meteo.com/v1/archive", params={
"latitude": lat, "longitude": lon,
"start_date": "2023-01-01", "end_date": "2023-12-31",
"hourly": "shortwave_radiation,direct_normal_irradiance,diffuse_radiation,"
"temperature_2m,wind_speed_10m",
"wind_speed_unit": "ms", "timezone": "UTC"}, timeout=120).json()
w = pd.DataFrame(j["hourly"])
w.index = pd.to_datetime(w.pop("time")).dt.tz_localize("UTC") - pd.Timedelta("30min")
sun = pvlib.location.Location(lat, lon, altitude=j["elevation"]).get_solarposition(w.index)
return w, sun
def energy(w, sun, tilt, azimuth):
"""Annual DC energy per kWp before fixed losses (kWh)."""
poa = pvlib.irradiance.get_total_irradiance(
tilt, azimuth, sun.apparent_zenith, sun.azimuth,
w.direct_normal_irradiance, w.shortwave_radiation, w.diffuse_radiation,
dni_extra=pvlib.irradiance.get_extra_radiation(w.index), model="perez")
aoi = pvlib.irradiance.aoi(tilt, azimuth, sun.apparent_zenith, sun.azimuth)
eff = poa.poa_direct * pvlib.iam.physical(aoi) + poa.poa_diffuse
cell = pvlib.temperature.faiman(poa.poa_global, w.temperature_2m, w.wind_speed_10m)
return pvlib.pvsystem.pvwatts_dc(eff, cell, pdc0=1, gamma_pdc=-0.0037).sum()
w, sun = weather(39.86, -4.02)
tilts = range(0, 65, 5)
azimuths = range(90, 275, 15) # 90 = east, 180 = south, 270 = west
grid = pd.DataFrame([[energy(w, sun, t, a) for a in azimuths] for t in tilts],
index=tilts, columns=azimuths)
best_t, best_a = grid.stack().idxmax()
print(f"optimum: tilt {best_t}°, azimuth {best_a}° → {grid.loc[best_t, best_a]:,.0f} kWh/kWp")
print(f"latitude tilt 40°, south: {grid.loc[40, 180]:,.0f} "
f"({grid.loc[40, 180] / grid.loc[best_t, best_a] - 1:+.1%})")
optimum: tilt 35°, azimuth 180° → 1,987 kWh/kWp
latitude tilt 40°, south: 1,986 (-0.0%)
The optimum is broad: the latitude rule costs well under 1%. That flatness is the useful finding.
A full year, a sweep, temperature and reflection
1. Use a full year of hourly weather
A clear-sky optimisation gives a steeper tilt than the real one, because cloudy days carry diffuse light that prefers flatter panels. Use real weather, ideally several years.
2. Sweep a grid, not a single optimiser call
A grid shows the shape of the surface — how much each degree away from the optimum costs — which is what decides whether a given roof or slope is acceptable.
3. Include temperature and reflection
Steeper panels see less midsummer sun and run cooler; grazing-angle reflection penalises orientations that see the sun obliquely. Both shift the optimum slightly.
4. Express the result as a loss relative to the optimum
"94% of optimal" travels well into a suitability map. Absolute kWh/kWp does not, because it changes with the weather source.
5. Decide what the optimum is optimising
Annual energy gives one answer. Winter energy, self-consumption at a building, or value at market prices (which are low at midday in sunny grids) give others — typically steeper, or turned towards the west.
6. Check against PVGIS
PVGIS's optimalangles=1 answers the annual-energy question with its own weather. If yours differs by more than a few degrees, look at the time convention first.
The plateau, and how latitude moves it
Example 1 — reading the plateau
rel = grid / grid.values.max()
print("relative yield (rows: tilt, columns: azimuth)")
print(rel.loc[[0, 10, 20, 30, 40, 50, 60], [90, 135, 165, 180, 195, 225, 270]]
.map(lambda v: f"{v:.0%}").to_string())
within5 = (rel >= 0.95).to_numpy().sum()
print(f"\n{within5} of {rel.size} combinations are within 5% of the optimum")
relative yield (rows: tilt, columns: azimuth)
90 135 165 180 195 225 270
0 83% 83% 83% 83% 83% 83% 83%
10 83% 89% 91% 91% 91% 89% 83%
20 82% 92% 96% 96% 96% 92% 81%
30 80% 94% 99% 99% 99% 94% 79%
40 77% 94% 99% 100% 99% 93% 76%
50 74% 92% 97% 98% 97% 91% 73%
60 70% 88% 93% 94% 93% 87% 69%
35 of 169 combinations are within 5% of the optimum
A flat panel still gets most of the optimum, and east- or west-facing panels at a modest tilt lose less than intuition suggests. This is why a south-east-facing roof is still a good solar roof, and why the aspect penalty in a suitability map should be graded, not a cut-off.
Example 2 — the optimum moves with latitude and climate
for name, (lat, lon) in {"Toledo": (39.86, -4.02), "Hamburg": (53.55, 9.99)}.items():
w2, sun2 = weather(lat, lon)
e = pd.Series({t: energy(w2, sun2, t, 180) for t in range(0, 70, 2)})
print(f"{name:<8} lat {lat:.1f}°: best tilt {e.idxmax()}°, "
f"latitude-tilt loss {1 - e.loc[round(lat / 2) * 2] / e.max():.1%}, "
f"flat loss {1 - e.loc[0] / e.max():.1%}")
Toledo lat 39.9°: best tilt 38°, latitude-tilt loss 0.1%, flat loss 16.9%
Hamburg lat 53.5°: best tilt 42°, latitude-tilt loss 1.7%, flat loss 19.9%
Toledo's optimum is just under its latitude; Hamburg's is 11° below it. The cloudier the site, the more diffuse light, and the more a flatter panel — which sees more of the sky — pays. (On a 2° sweep Toledo peaks at 38°, on the 5° grid above at 35° — the difference between them is 0.1%, which is the plateau again.)
Example 3 — optimising for winter instead
winter = w.index.month.isin([11, 12, 1, 2])
ws, suns = w[winter], sun[winter]
e_w = pd.Series({t: energy(ws, suns, t, 180) for t in range(0, 90, 5)})
steep = e_w.idxmax()
print(f"best tilt for November–February: {steep}°")
print(f"winter energy at {steep}° vs at the annual optimum {best_t}°: "
f"{e_w.loc[steep] / e_w.loc[best_t] - 1:+.1%}")
print(f"annual energy at {steep}°: {energy(w, sun, steep, 180) / grid.values.max():.1%} "
f"of the annual optimum")
best tilt for November–February: 65°
winter energy at 65° vs at the annual optimum 35°: +10.8%
annual energy at 65°: 90.7% of the annual optimum
An off-grid system that must work in winter wants a much steeper panel: 65° buys 11% more winter energy and gives up 9% of the annual total. The "optimum" is only defined once the objective is.
Why the optimum is below latitude
Why the optimum is below latitude
Tilting at latitude points the panel at the sun's position at noon on the equinoxes. But more energy arrives in summer, when the sun is higher, and diffuse light — up to 40% of the total in cloudy climates — favours a panel that sees more sky. Both pull the optimum flatter.
Why the surface is so flat near the top
Energy depends on the cosine of the angle between the panel and the sun. Near 0° a cosine barely changes: a 10° error costs 1.5% of the beam. Only far from the optimum does the loss accelerate.
Why market value can move the optimum
In grids with a lot of solar, midday prices fall. A west-facing panel produces less energy but more of it in the late afternoon when prices are higher. Optimising for revenue rather than energy can favour azimuths of 200–240°.
Row spacing, snow shedding and soiling
- Row spacing: steeper tilts cast longer shadows, so ground-mounted plants often choose 20–30° to fit more rows per hectare — the optimum per km² is flatter than the optimum per module.
- Snow shedding favours steeper tilts at high latitudes.
- Soiling is worse on flat panels, which do not self-clean in rain.
- Trackers make the question moot for the tracked axis; compute their gain separately.
- Azimuth conventions: pvlib uses 180 for south; PVGIS uses 0. Converting incorrectly produces a north-facing optimum.
Internal links
- How to model PV output with pvlib — the chain inside
energy() - Terrain shading and the horizon explained — slope as an imposed tilt
- How to build a solar suitability map — turning aspect loss into a score
- Solar irradiance explained — why diffuse light favours flatter panels
- How to estimate solar potential on roofs — the same curve applied to roof planes
- Power density and land use explained — why plants choose shallower tilts
FAQ
What is the best tilt angle for solar panels?
For annual energy, at or below the latitude — up to 10–15° below it in cloudy northern climates. In Toledo 35–38°, in Hamburg about 42°.
Does the direction matter more than the tilt?
Both matter less than expected near the optimum. A south-east or south-west orientation typically loses only a few percent.
Should I tilt at my latitude?
It is a reasonable default: it cost 0.1% in Toledo and 1.7% in Hamburg. The real optimum is flatter, more so in cloudy climates.
What tilt is best for winter?
Much steeper — around 65° in central Spain — at a modest annual cost.
Why do solar farms use shallower tilts than the optimum?
Steeper rows cast longer shadows and need wider spacing. Energy per hectare can be higher at a flatter tilt.
How does PVGIS compute its optimal angles?
By maximising annual energy with its own satellite-derived weather for the site. Use optimalangles=1 in PVcalc.