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.

Relative annual PV yield in Toledo over tilt and azimuth, showing a broad plateau around the optimum at 35° facing south. share of the best yield, Toledoeast 90°SE 135°S 180°SW 225°west 270°flat83%83%83%83%83%20°82%92%✓96%92%81%40°!77%94%✓100%93%!76%60°✕70%88%94%87%✕69%35 of 169 tilt–azimuth combinations are within 5% of the optimum spatialworkflow.io
A broad plateau: orientations within 15° of south and 15° of the optimum tilt lose under 5%.

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.

Latitude, best annual tilt and best winter tilt for Toledo and Hamburg. degrees of tiltToledo latitude39.9°Toledo best annual tilt38° (plateau 35–40°)Toledo best Nov–Feb tilt65° · −9% a year, +11% winterHamburg latitude53.5°Hamburg best annual tilt42° — 11° below latitude035°70°Cloudier climates pull the optimum further below latitude spatialworkflow.io
The annual optimum sits well below latitude; the winter optimum well above it.

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°.

Choosing a tilt by what the plant is optimising: annual energy, winter energy, energy per hectare or revenue. the optimum depends on the objectiveWhat should the tilt maximise?annual energy35–40° in Toledojust under latitudewinter energy≈ 65°+11% winter, −9% yearenergy per hectare20–30°shorter shadows, more rowsRevenue in solar-heavy grids adds a fourth answer: turn towards the west spatialworkflow.io
There is no optimum tilt until there is an objective.

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.

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.