How to estimate rooftop solar potential in Python
Problem statement
Rooftop solar potential is four multiplications and a great deal of care about what each one means. Usable roof area times irradiance times panel efficiency times a performance ratio gives annual yield โ and every term has a default that is wrong by 20% for somebody.
The one that catches GIS people is the first. A pitched roof's area is not its footprint: at 30ยฐ pitch the roof is 15% larger than the polygon it sits on, and at 45ยฐ it is 41% larger. Half of that extra area faces north and is worthless. Using the footprint as the roof area is therefore wrong twice, in opposite directions, by different amounts.
This guide computes usable area, orientation and irradiance from a real LoD2 model.
Quick answer
import numpy as np
def roof_area_3d(points):
"""Planar polygon area in 3D from the Newell normal."""
n = np.zeros(3)
for i in range(len(points)):
a, b = points[i], points[(i + 1) % len(points)]
n += np.cross(a, b)
return np.linalg.norm(n) / 2, n / np.linalg.norm(n)
area, normal = roof_area_3d(surface_vertices)
tilt = np.degrees(np.arccos(abs(normal[2])))
azimuth = (np.degrees(np.arctan2(normal[0], normal[1])) + 360) % 360 # 180 = south
Compute area, tilt and azimuth per roof surface โ not per building โ because a gable roof has two faces with opposite azimuths and only one of them is worth panelling.
Step-by-step solution
1. Get the roof surfaces from the semantics
An LoD2 model labels them. A real Hague tile had 3,004 RoofSurface faces across 1,990 buildings โ an average of 1.5 planes per building, which is what you would expect from a stock that is 63.5% flat-roofed.
2. Compute area in 3D, not from the footprint
The Newell method gives both the area and the normal of a planar polygon in one pass. The relationship to the footprint is area_3d = area_2d / cos(tilt): 15% larger at 30ยฐ, 41% at 45ยฐ.
3. Derive tilt and azimuth from the normal
Tilt is the angle between the normal and vertical; azimuth is the compass direction the normal points, with 180ยฐ meaning south in the northern hemisphere. A flat roof has tilt โ 0 and an undefined azimuth, which is correct โ a flat roof is mounted at whatever tilt you choose.
4. Filter to usable surfaces
The usual criteria: area above a threshold (8โ10 mยฒ for a meaningful array), tilt below about 60ยฐ, and azimuth within roughly ยฑ90ยฐ of south in the northern hemisphere. Then subtract an allowance for setbacks, chimneys, dormers and plant โ commonly 20โ30% of the gross plane.
5. Get irradiance for the plane, not for the horizontal
Horizontal irradiance underestimates a tilted south-facing plane and overestimates a north-facing one. pvlib transposes global horizontal irradiance onto an arbitrary plane; with a typical meteorological year, that is an hourly calculation over a year.
6. Account for shading
Neighbouring buildings shade roofs, especially in dense terraces and especially in winter. A sky view factor computed at roof level, or a full shadow accumulation, is the difference between a plausible estimate and a defensible one.
7. Convert to yield honestly
yield_kWh = usable_area_m2 ร irradiance_kWh_m2 ร module_efficiency ร performance_ratio. Module efficiency is 0.18โ0.22 for current silicon; performance ratio โ inverter, wiring, temperature, soiling โ is 0.75โ0.85. Publish both.
Code examples
Example 1 โ roof planes with area, tilt and azimuth
import numpy as np, pandas as pd
def roof_planes(city_objects, V):
rows = []
for oid, obj in city_objects.items():
for g in obj.get("geometry", []):
sem = g.get("semantics")
if g["type"] != "Solid" or not sem:
continue
surfaces, values = sem["surfaces"], sem["values"][0]
for face, si in zip(g["boundaries"][0], values):
if si is None or surfaces[si].get("type") != "RoofSurface":
continue
pts = np.array([V[i] for i in face[0]])
if len(pts) < 3:
continue
n = np.zeros(3)
for i in range(len(pts)):
n += np.cross(pts[i], pts[(i + 1) % len(pts)])
area = np.linalg.norm(n) / 2
if area <= 0:
continue
u = n / np.linalg.norm(n)
if u[2] < 0:
u = -u
tilt = float(np.degrees(np.arccos(np.clip(abs(u[2]), 0, 1))))
azim = float((np.degrees(np.arctan2(u[0], u[1])) + 360) % 360)
rows.append({"id": oid, "area_3d_m2": float(area),
"footprint_m2": float(area * np.cos(np.radians(tilt))),
"tilt_deg": tilt, "azimuth_deg": azim if tilt > 5 else np.nan})
return pd.DataFrame(rows)
planes = roof_planes(d["CityObjects"], V)
print(f"{len(planes):,} roof planes over {planes.id.nunique():,} buildings")
print(f"flat (tilt < 5ยฐ): {(planes.tilt_deg < 5).mean():.1%}")
print(f"total 3D roof area {planes.area_3d_m2.sum():,.0f} mยฒ, "
f"footprint equivalent {planes.footprint_m2.sum():,.0f} mยฒ")
Example 2 โ usable area after filtering and a setback allowance
import numpy as np
def usable(planes, min_area=10, max_tilt=60, south_window=90, setback=0.25,
hemisphere="north"):
p = planes.copy()
flat = p.tilt_deg < 5
ideal_azimuth = 180 if hemisphere == "north" else 0
delta = (p.azimuth_deg - ideal_azimuth).abs()
delta = np.minimum(delta, 360 - delta)
keep = (p.area_3d_m2 >= min_area) & (p.tilt_deg <= max_tilt) & (flat | (delta <= south_window))
p["usable_m2"] = np.where(keep, p.area_3d_m2 * (1 - setback), 0.0)
return p
u = usable(planes)
print(f"planes kept: {(u.usable_m2 > 0).sum():,} of {len(u):,}")
print(f"usable area: {u.usable_m2.sum():,.0f} mยฒ "
f"({u.usable_m2.sum() / u.area_3d_m2.sum():.1%} of gross roof area)")
The setback is the single largest judgement in the whole calculation and the one most often left out. State it.
Example 3 โ plane-of-array irradiance and annual yield
import pvlib, pandas as pd, numpy as np
def annual_poa(lat, lon, tilt, azimuth, tz="Europe/Amsterdam"):
times = pd.date_range("2026-01-01", "2026-12-31 23:00", freq="1h", tz=tz)
sp = pvlib.solarposition.get_solarposition(times, lat, lon)
cs = pvlib.location.Location(lat, lon, tz=tz).get_clearsky(times)
poa = pvlib.irradiance.get_total_irradiance(
surface_tilt=tilt, surface_azimuth=azimuth,
solar_zenith=sp.apparent_zenith, solar_azimuth=sp.azimuth,
dni=cs.dni, ghi=cs.ghi, dhi=cs.dhi)
return float(poa.poa_global.sum() / 1000) # kWh/mยฒ per year, clear-sky
def yield_kwh(area_m2, poa_kwh_m2, efficiency=0.20, performance_ratio=0.80):
return area_m2 * poa_kwh_m2 * efficiency * performance_ratio
for tilt, azim, label in [(35, 180, "south, 35ยฐ"), (35, 90, "east, 35ยฐ"),
(35, 0, "north, 35ยฐ"), (5, 180, "flat")]:
poa = annual_poa(52.104, 4.2726, tilt, azim)
print(f"{label:14} {poa:7.0f} kWh/mยฒ/yr โ "
f"{yield_kwh(1, poa):6.0f} kWh/mยฒ/yr of generation")
The clear-sky model overestimates a real year substantially โ western European sites lose roughly a third to cloud โ so replace get_clearsky with a typical meteorological year from PVGIS or a reanalysis before quoting numbers to anyone.
Explanation
Why the footprint is not the roof area
A plane tilted at angle ฮธ projects onto a horizontal footprint of area ร cos(ฮธ). Inverting that, the roof is footprint / cos(ฮธ) โ 1.15ร at 30ยฐ, 1.41ร at 45ยฐ. On a gable roof both faces have the same tilt, so the total roof area is the footprint divided by the cosine, split equally between two opposite azimuths.
Why per-plane rather than per-building
A gable roof running eastโwest has a south face that is excellent and a north face that is nearly useless; averaging them gives a building that looks mediocre and is not. A building-level number also hides the case that matters most for planning โ a large flat roof, which is unconstrained in azimuth and can be tilted optimally.
Why flat roofs are a different calculation
A flat roof has no inherent tilt or azimuth, so panels are mounted on frames at a chosen tilt. That means the usable fraction is set by row spacing rather than by the roof shape: rows must be far enough apart not to shade each other in winter, which typically limits coverage to 40โ60% of the roof. Applying a pitched-roof setback to a flat roof overstates it considerably.
Why shading is the term people skip
Irradiance models assume an unobstructed horizon. In a terrace, in a courtyard, or next to anything taller, that is false for a large part of the year, and the error is largest in winter when the sun is low. A sky view factor computed at roof height is a cheap correction; a full shadow accumulation is the honest one.
Edge cases or notes
- Azimuth is undefined for a flat roof. Set it to NaN rather than 0.
- Normals may point inwards. Flip so the z component is positive.
- Holes reduce roof area. Rooflights and courtyards are interior rings.
- Dormers are separate small planes and usually below the area threshold.
- Structural capacity limits retrofits and is not in any GIS layer.
- Heritage and planning constraints exclude roofs the geometry says are fine.
- Clear-sky irradiance is an upper bound. Use a TMY for anything quoted.
- Panels have a minimum sensible array size. A 6 mยฒ plane is rarely worth it.
Internal links
- How to compute building shadows for a given time in Python โ the shading correction
- How to compute sky view factor from a surface model โ a cheap proxy for obstruction
- How to read a CityJSON city model in Python โ getting the roof surfaces
- CityJSON and CityGML explained โ why the semantics matter here
- How to classify roof shapes from LiDAR in Python โ when there is no LoD2 model
- Level of detail explained for 3D city models โ why this needs LoD2
- How to calculate slope and aspect in Python โ the raster equivalent of tilt and azimuth
- How to do site suitability analysis in Python โ combining the constraints
FAQ
Is a roof's area the same as its footprint?
No. A tilted plane has area footprint / cos(tilt) โ 15% more at 30ยฐ and 41% more at 45ยฐ.
Should I compute solar potential per building or per roof plane?
Per plane. A gable roof has a good south face and a poor north face, and averaging them describes neither.
How do I get tilt and azimuth from a roof surface?
From the surface normal: tilt is the angle from vertical, azimuth is atan2(nx, ny) in degrees, with 180ยฐ meaning south.
What fraction of a roof is usable?
Typically 70โ80% of a pitched plane after setbacks, chimneys and dormers, and 40โ60% of a flat roof once row spacing is allowed for.
Which irradiance should I use?
Plane-of-array, transposed from a typical meteorological year. Clear-sky irradiance is an upper bound and overstates western European sites substantially.
Does shading matter much?
Yes, and most in winter when the sun is low. An unshaded irradiance model on a dense terrace is optimistic by a wide margin.