Source code: Rooftop solar potential from LiDAR in Python: pvlib, PVGIS and shading for every roof
Runnable Python scripts, requirements and console output for the tutorial “Rooftop solar potential from LiDAR in Python: pvlib, PVGIS and shading for…
Last updated
These are the exact scripts used to produce every figure and number in Rooftop solar potential from LiDAR in Python: pvlib, PVGIS and shading for every roof. Download them, install the requirements into a fresh virtual environment and run them from this folder.
- rooftop_solar.py
- requirements.txt
- console.txt: the console output recorded when we ran it
python -m venv .venv.venv\Scripts\activate # macOS/Linux: source .venv/bin/activatepip install -r requirements.txtpython rooftop_solar.pyrooftop_solar.py
"""Rooftop solar potential in Python: Environment Agency 1 m LiDAR DSM + Overture Maps buildingfootprints + PVGIS weather + pvlib sun positions, for every roof in a 1 km x 0.8 km block ofLevenshulme, Manchester.
Pipeline: roof cells from footprints and the normalised DSM -> Horn slope and aspect -> horizonangles in 72 directions by ray marching over the DSM (shading from neighbours, trees and the roofitself) -> a "sun map" of the PVGIS typical meteorological year -> annual beam + diffuse +reflected irradiation per 1 m roof cell -> kWh per year per building with stated assumptions.
TechnicalGIS tutorial: https://www.technicalgis.com/tutorials/rooftop-solar-potential-lidar-python/Data: Environment Agency LIDAR Composite DSM and DTM 1 m (Open Government Licence v3.0);Overture Maps buildings (ODbL, includes OpenStreetMap and Microsoft ML footprints); PVGIS(European Commission JRC, free to reuse with attribution)."""import sysimport timeimport warningsfrom importlib.metadata import versionfrom pathlib import Path
import duckdbimport geopandas as gpdimport numpy as npimport pandas as pdimport pvlibimport rasterioimport requestsimport shapelyfrom pyproj import Transformerfrom rasterio.features import rasterizefrom rasterio.transform import from_originfrom scipy import ndimageimport matplotlibmatplotlib.use("Agg") # render to files only; no GUI backendimport matplotlib.pyplot as pltfrom matplotlib.colors import BoundaryNorm, ListedColormap
warnings.filterwarnings("ignore")plt.rcParams.update({"figure.dpi": 150, "savefig.bbox": "tight", "font.size": 9})DATA = Path("data"); DATA.mkdir(exist_ok=True)OUT = Path("output"); OUT.mkdir(exist_ok=True)
# Study area in British National Grid (EPSG:27700): terraces, semis, schools and sheds in LevenshulmeAOI = (386800, 393700, 387800, 394500) # xmin, ymin, xmax, ymax: 1 km x 0.8 km of roofs to assessFETCH = (386000, 393000, 388000, 395000) # 2 km x 2 km of LiDAR, so shading from up to 300 m away is seenEA_WCS = { "dtm": ("lidar-composite-digital-terrain-model-dtm-1m", "13787b9a-26a4-4775-8523-806d13af58fc__Lidar_Composite_Elevation_DTM_1m"), "dsm": ("lidar-composite-digital-surface-model-first-return-dsm-1m", "df4e3ec3-315e-48aa-aaaf-b5ae74d7b2bb__Lidar_Composite_Elevation_FZ_DSM_1m"),}
# Assumptions: change these and re-runPANEL_EFF = 0.20 # module efficiency at standard test conditions (typical 2026 mono-Si: 20-22%)PERF_RATIO = 0.80 # performance ratio: inverter, cabling, temperature, soiling and mismatch lossesCOVERAGE = 0.70 # share of a usable roof plane that panels can actually cover (edges, vents, access)MAX_SLOPE = 60 # degrees; steeper cells are walls, dormers or LiDAR edge artefactsMIN_IRR = 800 # kWh/m2/yr; cells below this are not worth covering in ManchesterALBEDO = 0.20 # ground reflectance for the reflected componentMIN_HEIGHT = 2.5 # m above the DTM for a cell to count as roofHOUSEHOLD_KWH = 2700 # Ofgem typical domestic electricity consumption (medium, 2024 onwards)
AZ = np.arange(2.5, 360, 5.0) # 72 horizon directions, clockwise from northSTEPS = np.r_[np.arange(2, 30, 1), np.arange(30, 100, 2), np.arange(100, 301, 5)] # ray-march distances (m)NOISE = 0.3 # m; LiDAR noise allowance so a rough roof does not shade itself
def section(title): print(f"\n=== {title} ===")
section("versions")print(f"python {sys.version.split()[0]} | pvlib {pvlib.__version__} | rasterio {rasterio.__version__} | " f"duckdb {duckdb.__version__} | geopandas {gpd.__version__} | numpy {np.__version__} | " f"pandas {pd.__version__} | scipy {version('scipy')}")
# ---------------------------------------------------------------- Step 1: EA LiDAR DSM and DTMsection("Step 1: Environment Agency 1 m LiDAR DSM and DTM")
def ea_wcs(product, x0, y0, size=1000): """One 1 km x 1 km GeoTIFF from the Environment Agency WCS (no key needed).""" service, cov = EA_WCS[product] url = (f"https://environment.data.gov.uk/spatialdata/{service}/wcs?service=WCS&version=2.0.1" f"&request=GetCoverage&CoverageId={cov}&format=image/tiff" f"&subset=E({x0},{x0 + size})&subset=N({y0},{y0 + size})") for attempt in range(4): try: r = requests.get(url, timeout=180) r.raise_for_status() return r.content except requests.RequestException: if attempt == 3: raise time.sleep(5 * (attempt + 1))
def ea_mosaic(product): """Download 1 km tiles and paste them into one 1 m array on the FETCH grid.""" tf = from_origin(FETCH[0], FETCH[3], 1, 1) a = np.full((FETCH[3] - FETCH[1], FETCH[2] - FETCH[0]), np.nan, "float32") for x0 in range(FETCH[0], FETCH[2], 1000): for y0 in range(FETCH[1], FETCH[3], 1000): tile = DATA / f"ea_{product}_{x0}_{y0}.tif" if not tile.exists(): tile.write_bytes(ea_wcs(product, x0, y0)) with rasterio.open(tile) as src: t = src.read(1).astype("float32") t[(t == src.nodata) | (t < -1000)] = np.nan c, r = (int(round(v)) for v in ~tf * (src.bounds.left, src.bounds.top)) a[r:r + t.shape[0], c:c + t.shape[1]] = t return a, tf
dsm, TF = ea_mosaic("dsm")dtm, _ = ea_mosaic("dtm")print(f"grid {dsm.shape[1]} x {dsm.shape[0]} cells of 1 m | no data: DSM {np.isnan(dsm).mean() * 100:.2f}%, " f"DTM {np.isnan(dtm).mean() * 100:.2f}%")dsm = np.where(np.isnan(dsm), dtm, dsm)dtm = np.where(np.isnan(dtm), dsm, dtm)dsm = ndimage.median_filter(dsm, size=3) # knock down single-pixel LiDAR noise before taking slopesndsm = dsm - dtmprint(f"DTM {np.nanmin(dtm):.1f}..{np.nanmax(dtm):.1f} m | DSM {np.nanmin(dsm):.1f}..{np.nanmax(dsm):.1f} m | " f"cells over {MIN_HEIGHT} m above ground: {(ndsm > MIN_HEIGHT).mean() * 100:.1f}%")
# AOI window inside the FETCH gridR0, C0 = FETCH[3] - AOI[3], AOI[0] - FETCH[0]H, W = AOI[3] - AOI[1], AOI[2] - AOI[0]A = (slice(R0, R0 + H), slice(C0, C0 + W))ATF = from_origin(AOI[0], AOI[3], 1, 1)
# ---------------------------------------------------------------- Step 2: Overture building footprintssection("Step 2: Overture Maps building footprints with DuckDB")to_ll = Transformer.from_crs(27700, 4326, always_xy=True)lon0, lat0 = to_ll.transform(AOI[0], AOI[1])lon1, lat1 = to_ll.transform(AOI[2], AOI[3])BBOX = (min(lon0, lon1) - 0.001, min(lat0, lat1) - 0.001, max(lon0, lon1) + 0.001, max(lat0, lat1) + 0.001)LON, LAT = to_ll.transform((AOI[0] + AOI[2]) / 2, (AOI[1] + AOI[3]) / 2)print(f"AOI centre {LAT:.4f} N, {abs(LON):.4f} W | query bbox {', '.join(f'{v:.4f}' for v in BBOX)}")bld_path = DATA / "overture_buildings.gpkg"if not bld_path.exists(): release = requests.get("https://stac.overturemaps.org/catalog.json", timeout=60).json()["latest"] con = duckdb.connect() con.sql("INSTALL spatial; LOAD spatial; INSTALL httpfs; LOAD httpfs; SET s3_region = 'us-west-2';") xmin, ymin, xmax, ymax = BBOX overlaps = f"bbox.xmin <= {xmax} AND bbox.xmax >= {xmin} AND bbox.ymin <= {ymax} AND bbox.ymax >= {ymin}" files = [r[0] for r in con.sql(f""" SELECT assets.aws.alternate.s3.href FROM read_parquet('https://stac.overturemaps.org/{release}/collections.parquet') WHERE collection = 'building' AND {overlaps}""").fetchall()] df = con.sql(f""" SELECT id, subtype, class, height, num_floors, list_filter(sources, lambda s: s.property = '')[1].dataset AS source, ST_AsWKB(geometry) AS wkb FROM read_parquet({files}) WHERE {overlaps}""").df() df["release"] = release gpd.GeoDataFrame(df.drop(columns="wkb"), geometry=shapely.from_wkb(df["wkb"].apply(bytes)), crs=4326).to_file(bld_path)bld = gpd.read_file(bld_path).to_crs(27700)print(f"Overture release {bld['release'].iloc[0]}: {len(bld):,} buildings in the query box")c = bld.geometry.centroidbld = bld[(c.x > AOI[0]) & (c.x < AOI[2]) & (c.y > AOI[1]) & (c.y < AOI[3])].reset_index(drop=True)bld["footprint_m2"] = bld.areaprint(f"buildings with a centroid in the AOI: {len(bld):,} | sources: " + ", ".join(f"{k} {v:,}" for k, v in bld["source"].value_counts().items()))print(f"footprint area: median {bld['footprint_m2'].median():.0f} m2, total {bld['footprint_m2'].sum() / 1e4:.1f} ha " f"({bld['footprint_m2'].sum() / (H * W) * 100:.0f}% of the AOI)")
bid = rasterize(((g, i + 1) for i, g in enumerate(bld.geometry)), out_shape=(H, W), transform=ATF, fill=0, dtype="int32")inner = ndimage.binary_erosion(bid > 0, iterations=1) # drop the edge cell: footprints and LiDAR never align perfectlyroof = inner & (ndsm[A] > MIN_HEIGHT)print(f"footprint cells over {MIN_HEIGHT} m in the nDSM: {(ndsm[A] > MIN_HEIGHT)[bid > 0].mean() * 100:.1f}% " f"| roof cells after a 1-cell inward buffer: {roof.sum():,}")
# ---------------------------------------------------------------- Step 3: slope and aspectsection("Step 3: roof slope and aspect (Horn's method)")
def horn(z, res=1.0): """Slope (degrees) and aspect (degrees clockwise from north, the direction the slope faces).""" p = np.pad(z, 1, mode="edge") a, b, c = p[:-2, :-2], p[:-2, 1:-1], p[:-2, 2:] d, f = p[1:-1, :-2], p[1:-1, 2:] g, h, i = p[2:, :-2], p[2:, 1:-1], p[2:, 2:] dzdx = ((c + 2 * f + i) - (a + 2 * d + g)) / (8 * res) # rise towards the east dzdy = ((a + 2 * b + c) - (g + 2 * h + i)) / (8 * res) # rise towards the north slope = np.degrees(np.arctan(np.hypot(dzdx, dzdy))) aspect = (np.degrees(np.arctan2(-dzdx, -dzdy)) + 360) % 360 # downslope direction return slope, aspect
slope_f, aspect_f = horn(dsm)slope, aspect = slope_f[A], aspect_f[A]s, a = slope[roof], aspect[roof]pitched = s >= 10print(f"roof cells: flat (<10 deg) {(~pitched).mean() * 100:.1f}% | pitched 10-{MAX_SLOPE} deg " f"{(pitched & (s <= MAX_SLOPE)).mean() * 100:.1f}% | steeper {(s > MAX_SLOPE).mean() * 100:.1f}%")print(f"pitched roof slope: median {np.median(s[pitched & (s <= MAX_SLOPE)]):.0f} deg, " f"interquartile {np.percentile(s[pitched & (s <= MAX_SLOPE)], 25):.0f}-" f"{np.percentile(s[pitched & (s <= MAX_SLOPE)], 75):.0f} deg")facing = pd.Series(pd.cut((a[pitched] + 22.5) % 360, np.arange(0, 361, 45), labels=["N", "NE", "E", "SE", "S", "SW", "W", "NW"], right=False))print("pitched roof cells by the way they face: " + ", ".join(f"{k} {v * 100:.0f}%" for k, v in facing.value_counts(normalize=True, sort=False).items()))
# ---------------------------------------------------------------- Step 4: horizon anglessection("Step 4: horizon angles in 72 directions by ray marching over the DSM")rr, cc = np.nonzero(roof)WF = dsm.shape[1]flat_idx = (rr + R0) * WF + (cc + C0)zf = dsm.ravel()z0 = zf[flat_idx]t0 = time.perf_counter()hor = np.zeros((len(AZ), len(rr)), "float32") # horizon elevation angle in degrees (>= 0)for k, az in enumerate(AZ): best = np.zeros(len(rr), "float32") sa, ca = np.sin(np.radians(az)), np.cos(np.radians(az)) for d in STEPS: off = -int(round(d * ca)) * WF + int(round(d * sa)) np.maximum(best, (zf[flat_idx + off] - z0 - NOISE) / d, out=best) hor[k] = np.degrees(np.arctan(best))print(f"{len(AZ)} directions x {len(STEPS)} steps (2-300 m) for {len(rr):,} roof cells in " f"{time.perf_counter() - t0:.1f} s")
# Sky view relative to an unobstructed plane of the same tilt (isotropic sky, cos^2 weighting)beta, asp = np.radians(s), np.radians(a)h_plane = np.arctan(np.maximum(-np.tan(beta)[None, :] * np.cos(np.radians(AZ)[:, None] - asp[None, :]), 0))h_obs = np.maximum(np.radians(hor), h_plane)svf_rel = (np.cos(h_obs) ** 2).mean(0) / (np.cos(h_plane) ** 2).mean(0)del h_plane, h_obsprint(f"relative sky view: median {np.median(svf_rel):.2f}, 10th percentile {np.percentile(svf_rel, 10):.2f}")
# ---------------------------------------------------------------- Step 5: PVGIS weather and the sun mapsection("Step 5: PVGIS typical meteorological year and a sun map")tmy_path = DATA / "pvgis_tmy.csv"if not tmy_path.exists(): tmy, meta = pvlib.iotools.get_pvgis_tmy(LAT, LON, usehorizon=False, map_variables=True) tmy.to_csv(tmy_path)tmy = pd.read_csv(tmy_path, index_col=0, parse_dates=True)times = tmy.index + pd.Timedelta(minutes=30) # hourly values: put the sun in the middle of the hoursp = pvlib.solarposition.get_solarposition(times, LAT, LON)tmy["zenith"], tmy["azimuth"], tmy["elevation"] = sp["apparent_zenith"].values, sp["azimuth"].values, \ sp["apparent_elevation"].valuesprint(f"TMY {len(tmy):,} hours | annual GHI {tmy['ghi'].sum() / 1000:.0f} kWh/m2, DNI {tmy['dni'].sum() / 1000:.0f}, " f"DHI {tmy['dhi'].sum() / 1000:.0f} | diffuse share of GHI {tmy['dhi'].sum() / tmy['ghi'].sum() * 100:.0f}%")sun = tmy[(tmy["elevation"] > 0) & (tmy["dni"] > 0)].copy()z = np.radians(sun["zenith"]); azr = np.radians(sun["azimuth"])sun["SA"] = sun["dni"] * np.cos(z) # Wh/m2 that a horizontal plane would getsun["SB"] = sun["dni"] * np.sin(z) * np.cos(azr)sun["SC"] = sun["dni"] * np.sin(z) * np.sin(azr)sun["az_bin"] = (sun["azimuth"] // 5).astype(int)sun["el_bin"] = (sun["elevation"] // 2).astype(int)sun["w_el"] = sun["dni"] * sun["elevation"]sunmap = sun.groupby(["az_bin", "el_bin"])[["SA", "SB", "SC", "dni", "w_el"]].sum().reset_index()sunmap["el"] = sunmap["w_el"] / sunmap["dni"] # DNI-weighted sun elevation in the binprint(f"{len(sun):,} sunlit hours grouped into {len(sunmap)} sky cells (5 deg azimuth x 2 deg elevation) | " f"beam on a horizontal plane {sunmap['SA'].sum() / 1000:.0f} kWh/m2")
def annual_irradiance(slope_deg, aspect_deg, horizon_deg, svf): """Annual beam + diffuse + reflected irradiation (kWh/m2 of tilted surface).""" b, g = np.radians(slope_deg), np.radians(aspect_deg) cb, sb, cg, sg = np.cos(b), np.sin(b), np.cos(g), np.sin(g) beam = np.zeros(np.shape(slope_deg), "float64") for row in sunmap.itertuples(): inc = row.SA * cb + sb * (row.SB * cg + row.SC * sg) lit = horizon_deg[row.az_bin] < row.el beam += np.where(lit, np.maximum(inc, 0), 0) diffuse = tmy["dhi"].sum() * (1 + cb) / 2 * svf reflected = tmy["ghi"].sum() * ALBEDO * (1 - cb) / 2 return beam / 1000, diffuse / 1000, reflected / 1000
# ---------------------------------------------------------------- Step 6: check the model on an open planesection("Step 6: check on an unshaded 35 deg south-facing plane")b0, d0, r0 = annual_irradiance(np.array([35.0]), np.array([180.0]), np.zeros((72, 1)), np.array([1.0]))ours = float(b0[0] + d0[0] + r0[0])dni_h = tmy["dni"].where(tmy["zenith"] < 90, 0)poa = pvlib.irradiance.get_total_irradiance(35, 180, tmy["zenith"], tmy["azimuth"], dni_h, tmy["ghi"], tmy["dhi"], albedo=ALBEDO, model="isotropic")["poa_global"]dni_extra = pvlib.irradiance.get_extra_radiation(times)poa_perez = pvlib.irradiance.get_total_irradiance(35, 180, tmy["zenith"], tmy["azimuth"], dni_h, tmy["ghi"], tmy["dhi"], dni_extra=dni_extra.values, albedo=ALBEDO, airmass=pvlib.atmosphere.get_relative_airmass(tmy["zenith"]).values, model="perez")["poa_global"]loc = pvlib.location.Location(LAT, LON)cs = loc.get_clearsky(times, model="ineichen")cs_poa = pvlib.irradiance.get_total_irradiance(35, 180, tmy["zenith"], tmy["azimuth"], cs["dni"].where(tmy["zenith"].values < 90, 0).values, cs["ghi"].values, cs["dhi"].values, albedo=ALBEDO)["poa_global"]hourly_path = DATA / "pvgis_hourly_35S_2014_2023.csv"if not hourly_path.exists(): hourly, _ = pvlib.iotools.get_pvgis_hourly(LAT, LON, start=2014, end=2023, surface_tilt=35, surface_azimuth=180, components=True, usehorizon=False, pvcalculation=True, peakpower=1, loss=14) hourly.to_csv(hourly_path)hourly = pd.read_csv(hourly_path, index_col=0, parse_dates=True)hourly["poa"] = hourly[["poa_direct", "poa_sky_diffuse", "poa_ground_diffuse"]].sum(axis=1)n_years = hourly.index.year.nunique()pvgis_poa = hourly["poa"].sum() / 1000 / n_yearspvgis_kwh_kwp = hourly["P"].sum() / 1000 / n_yearsprint(f"sun-map model (this script) : {ours:6.0f} kWh/m2/yr (beam {b0[0]:.0f}, diffuse {d0[0]:.0f}, " f"reflected {r0[0]:.0f})")print(f"pvlib hourly, isotropic sky : {poa.sum() / 1000:6.0f} kWh/m2/yr")print(f"pvlib hourly, Perez sky : {poa_perez.sum() / 1000:6.0f} kWh/m2/yr")print(f"PVGIS SARAH {hourly.index.year.min()}-{hourly.index.year.max()} mean : {pvgis_poa:6.0f} kWh/m2/yr")print(f"pvlib Ineichen clear sky (no cloud): {cs_poa.sum() / 1000:6.0f} kWh/m2/yr")print(f"specific yield: ours {ours * PERF_RATIO:.0f} kWh/kWp (PR {PERF_RATIO}) | PVGIS PV model, 14% system loss " f"{pvgis_kwh_kwp:.0f} kWh/kWp")
# ---------------------------------------------------------------- Step 7: annual irradiation on every roof cellsection("Step 7: annual irradiation on every roof cell, with shading")t0 = time.perf_counter()beam, diffuse, refl = annual_irradiance(s, a, hor, svf_rel)irr = beam + diffuse + reflb_open, _, _ = annual_irradiance(s, a, np.zeros((72, 1)), np.ones(1))print(f"{len(sunmap)} sky cells x {len(s):,} roof cells in {time.perf_counter() - t0:.1f} s")shade_loss = 1 - beam.sum() / b_open.sum()print(f"annual irradiation on roof cells: median {np.median(irr):.0f} kWh/m2, 90th percentile " f"{np.percentile(irr, 90):.0f}, max {irr.max():.0f}")print(f"beam lost to shading from neighbours, trees and chimneys: {shade_loss * 100:.1f}% of the unshaded beam")by_face = pd.DataFrame({"face": facing.astype(str), "irr": irr[pitched]})print("median kWh/m2/yr on pitched roofs by aspect: " + ", ".join(f"{k} {v:.0f}" for k, v in by_face.groupby("face", sort=False)["irr"].median() .reindex(["N", "NE", "E", "SE", "S", "SW", "W", "NW"]).items()) + f" | flat roofs {np.median(irr[~pitched]):.0f}")
# ---------------------------------------------------------------- Step 8: kWh per building and rankingsection("Step 8: kWh per year per building")usable = (s <= MAX_SLOPE) & (irr >= MIN_IRR)tilted_m2 = 1 / np.cos(np.radians(s)) # a 1 m x 1 m cell on a 40 deg roof is 1.31 m2 of roofkwh = np.where(usable, irr * tilted_m2 * COVERAGE * PANEL_EFF * PERF_RATIO, 0)kwp = np.where(usable, tilted_m2 * COVERAGE * PANEL_EFF, 0) # 1 m2 at 20% = 0.2 kWp at 1000 W/m2cell_b = bid[rr, cc]agg = pd.DataFrame({"b": cell_b, "roof_m2": tilted_m2, "usable_m2": np.where(usable, tilted_m2, 0), "kwp": kwp, "kwh": kwh, "irr_w": np.where(usable, irr * tilted_m2, 0)}).groupby("b").sum()bld["roof_m2"] = agg["roof_m2"].reindex(bld.index + 1).fillna(0).valuesbld["usable_m2"] = agg["usable_m2"].reindex(bld.index + 1).fillna(0).valuesbld["kwp"] = agg["kwp"].reindex(bld.index + 1).fillna(0).valuesbld["kwh_yr"] = agg["kwh"].reindex(bld.index + 1).fillna(0).valuesbld["mean_irr"] = (agg["irr_w"] / agg["usable_m2"].replace(0, np.nan)).reindex(bld.index + 1).valuesprint(f"assumptions: efficiency {PANEL_EFF:.0%}, performance ratio {PERF_RATIO}, coverage {COVERAGE:.0%}, " f"usable if slope <= {MAX_SLOPE} deg and >= {MIN_IRR} kWh/m2/yr")print(f"usable share of roof area: {bld['usable_m2'].sum() / bld['roof_m2'].sum() * 100:.0f}%")print(f"AOI total: {bld['kwp'].sum() / 1000:.1f} MWp, {bld['kwh_yr'].sum() / 1e6:.1f} GWh/yr = " f"{bld['kwh_yr'].sum() / HOUSEHOLD_KWH:,.0f} typical households ({HOUSEHOLD_KWH:,} kWh each)")houses = bld[(bld["footprint_m2"] > 30) & (bld["footprint_m2"] < 120)]print(f"house-sized footprints (30-120 m2): {len(houses):,} | median {houses['kwp'].median():.1f} kWp, " f"{houses['kwh_yr'].median():,.0f} kWh/yr | {(houses['kwh_yr'] >= HOUSEHOLD_KWH).mean() * 100:.0f}% " f"could cover {HOUSEHOLD_KWH:,} kWh/yr | {(houses['kwh_yr'] == 0).mean() * 100:.0f}% have no usable roof")bld["rank"] = bld["kwh_yr"].rank(ascending=False, method="first").astype(int)top = bld.sort_values("rank").head(10)print("\ntop 10 roofs by annual output:")print(top.assign(id=top["id"].str[:8], kwh_kwp=top["kwh_yr"] / top["kwp"], type=top["class"].fillna(top["subtype"]).fillna("-"))[ ["rank", "id", "type", "footprint_m2", "usable_m2", "kwp", "kwh_yr", "kwh_kwp", "mean_irr"]].astype({"kwp": float}) .round({"kwp": 1, "footprint_m2": 0, "usable_m2": 0, "kwh_yr": 0, "kwh_kwp": 0, "mean_irr": 0}) .astype({c: int for c in ["footprint_m2", "usable_m2", "kwh_yr", "kwh_kwp", "mean_irr"]}) .to_string(index=False))bld.drop(columns="geometry").sort_values("rank").to_csv(OUT / "roof_ranking.csv", index=False)print(f"\nwrote {OUT / 'roof_ranking.csv'} ({len(bld):,} buildings)")
# ---------------------------------------------------------------- Figuressection("figures")EXT = [0, W, 0, H] # metres from the AOI south-west cornerdz_y, dz_x = np.gradient(dsm[A])hill = np.clip(0.5 + 0.5 * (-dz_x * 0.7 + dz_y * 0.7) / np.sqrt(1 + dz_x ** 2 + dz_y ** 2), 0, 1)off = lambda geo: geo.translate(-AOI[0], -AOI[1])ZOOM = (330, 330 + 260, 330, 330 + 200) # x0, x1, y0, y1 in metres from the AOI SW corner
def put(values, fill=np.nan): """Scatter per-roof-cell values back onto the AOI grid.""" g = np.full((H, W), fill, "float32"); g[rr, cc] = values return g
# 1: study areafig, axs = plt.subplots(1, 2, figsize=(12, 5.2), layout="constrained")for ax, zoom in zip(axs, (False, True)): ax.imshow(hill, cmap="gray", extent=EXT, vmin=0, vmax=1) im = ax.imshow(np.ma.masked_less(ndsm[A], 1), cmap="Spectral_r", vmin=1, vmax=15, extent=EXT, alpha=0.75) off(bld.geometry).boundary.plot(ax=ax, color="k", lw=0.25 if not zoom else 0.6) if zoom: ax.set_xlim(ZOOM[0], ZOOM[1]); ax.set_ylim(ZOOM[2], ZOOM[3]) else: ax.add_patch(plt.Rectangle((ZOOM[0], ZOOM[2]), ZOOM[1] - ZOOM[0], ZOOM[3] - ZOOM[2], fill=False, ec="#3288bd", lw=1.5)) ax.set_xlabel("metres east")axs[0].set_ylabel("metres north")axs[0].set_title(f"LiDAR height above ground and {len(bld):,} Overture footprints", loc="left")axs[0].set_xlim(0, W); axs[0].set_ylim(0, H); axs[1].set_ylabel("")axs[1].set_title("Zoom: terraces, back extensions and street trees", loc="left")fig.colorbar(im, ax=axs, shrink=0.8, label="DSM - DTM (m)")fig.savefig(OUT / "01-study-area-ndsm-footprints.png"); plt.close(fig)
# 2: slope and aspectfig, axs = plt.subplots(1, 3, figsize=(13, 4.4), layout="constrained", gridspec_kw={"width_ratios": [1, 1, 0.8]})for ax in axs[:2]: ax.imshow(hill, cmap="gray", extent=EXT, vmin=0, vmax=1) ax.set_xlim(ZOOM[0], ZOOM[1]); ax.set_ylim(ZOOM[2], ZOOM[3]); ax.set_xlabel("metres east")im = axs[0].imshow(put(s), cmap="Spectral_r", vmin=0, vmax=60, extent=EXT, interpolation="nearest")fig.colorbar(im, ax=axs[0], shrink=0.8, label="slope (degrees)")axs[0].set_title("Roof slope", loc="left"); axs[0].set_ylabel("metres north")asp_cls = put(np.where(pitched, ((a + 22.5) % 360 // 45), 8))cm_asp = ListedColormap(["#3288bd", "#66c2a5", "#abdda4", "#fee08b", "#d53e4f", "#f46d43", "#fdae61", "#5e4fa2", "#bdbdbd"])im = axs[1].imshow(asp_cls, cmap=cm_asp, norm=BoundaryNorm(np.arange(-0.5, 9.5), 9), extent=EXT, interpolation="nearest")cb = fig.colorbar(im, ax=axs[1], shrink=0.8, ticks=range(9))cb.ax.set_yticklabels(["N", "NE", "E", "SE", "S", "SW", "W", "NW", "flat"])axs[1].set_title("Roof aspect (pitched roofs)", loc="left")axs[2].hist(s[s <= 70], bins=np.arange(0, 71, 2), color="#3288bd")axs[2].axvline(MAX_SLOPE, color="#d53e4f", ls="--", lw=1)axs[2].set_xlabel("slope (degrees)"); axs[2].set_ylabel("roof cells (1 m2)")axs[2].set_title("All roof cells in the AOI", loc="left")fig.savefig(OUT / "02-roof-slope-aspect.png"); plt.close(fig)
# 3: shadows at noon on three datesfig, axs = plt.subplots(1, 3, figsize=(13, 4.0), sharey=True, layout="constrained")zr = slice(R0 + H - ZOOM[3], R0 + H - ZOOM[2]); zc = slice(C0 + ZOOM[0], C0 + ZOOM[1])base = dsm[zr, zc]for ax, day in zip(axs, ["2026-12-21", "2026-03-20", "2026-06-21"]): t = pd.Timestamp(f"{day} 12:10", tz="UTC") p = pvlib.solarposition.get_solarposition(pd.DatetimeIndex([t]), LAT, LON).iloc[0] tan_el = np.tan(np.radians(p["apparent_elevation"])) sa_, ca_ = np.sin(np.radians(p["azimuth"])), np.cos(np.radians(p["azimuth"])) shade = np.zeros(base.shape, bool) for d in np.arange(1, 301): dr, dc = -int(round(d * ca_)), int(round(d * sa_)) shade |= dsm[zr.start + dr:zr.stop + dr, zc.start + dc:zc.stop + dc] - base - NOISE > d * tan_el ax.imshow(hill[H - ZOOM[3]:H - ZOOM[2], ZOOM[0]:ZOOM[1]], cmap="gray", vmin=0, vmax=1, extent=[ZOOM[0], ZOOM[1], ZOOM[2], ZOOM[3]]) ax.imshow(np.ma.masked_where(~shade, shade), cmap=ListedColormap(["#3f007d"]), alpha=0.55, extent=[ZOOM[0], ZOOM[1], ZOOM[2], ZOOM[3]]) off(bld.geometry).boundary.plot(ax=ax, color="#fee08b", lw=0.5) ax.set_xlim(ZOOM[0], ZOOM[1]); ax.set_ylim(ZOOM[2], ZOOM[3]) ax.set_title(f"{pd.Timestamp(day):%d %b} 12:10 UTC: sun {p['apparent_elevation']:.0f} deg up, " f"{shade.mean() * 100:.0f}% in shade", loc="left", fontsize=9) ax.set_xlabel("metres east") print(f"{day} 12:10 UTC: sun elevation {p['apparent_elevation']:.1f} deg, azimuth {p['azimuth']:.0f} deg, " f"{shade.mean() * 100:.0f}% of the zoom window in shadow")axs[0].set_ylabel("metres north"); axs[1].set_ylabel(""); axs[2].set_ylabel("")fig.savefig(OUT / "03-shadows-solstices-equinox.png"); plt.close(fig)
# 4: model checkmonths = range(1, 13)mon = pd.DataFrame({"pvlib isotropic, PVGIS TMY": poa.groupby(poa.index.month).sum().values / 1000, "pvlib Perez, PVGIS TMY": poa_perez.groupby(poa_perez.index.month).sum().values / 1000, f"PVGIS SARAH {hourly.index.year.min()}-{hourly.index.year.max()} mean": hourly["poa"].groupby(hourly.index.month).sum().values / 1000 / n_years, "Ineichen clear sky": cs_poa.groupby(cs_poa.index.month).sum().values / 1000}, index=months)fig, axs = plt.subplots(1, 2, figsize=(12, 4.0), layout="constrained", gridspec_kw={"width_ratios": [1.6, 1]})w = 0.27cols = ["#3288bd", "#66c2a5", "#fdae61"]for i, (lab, col) in enumerate(zip(mon.columns[:3], cols)): axs[0].bar(np.array(months) + (i - 1) * w, mon[lab], w, color=col, label=f"{lab} ({mon[lab].sum():.0f})")axs[0].plot(months, mon["Ineichen clear sky"], "k--", lw=1, label=f"Ineichen clear sky ({mon['Ineichen clear sky'].sum():.0f})")axs[0].set_xticks(months, ["J", "F", "M", "A", "M", "J", "J", "A", "S", "O", "N", "D"])axs[0].set_ylabel("kWh/m2 per month"); axs[0].legend(fontsize=7, title="annual kWh/m2", title_fontsize=7)axs[0].set_title("35 deg south-facing plane in Levenshulme, no shading", loc="left")grid = [[float(sum(annual_irradiance(np.array([tilt]), np.array([az]), np.zeros((72, 1)), np.ones(1)))[0]) for az in np.arange(90, 271, 15)] for tilt in np.arange(0, 91, 10)]im = axs[1].imshow(grid, origin="lower", cmap="Spectral_r", aspect="auto", extent=[82.5, 277.5, -5, 95])cs_ = axs[1].contour(np.arange(90, 271, 15), np.arange(0, 91, 10), grid, levels=[700, 800, 900, 1000], colors="k", linewidths=0.6)axs[1].clabel(cs_, fontsize=7, fmt="%d")fig.colorbar(im, ax=axs[1], label="kWh/m2/yr")axs[1].set_xticks([90, 135, 180, 225, 270], ["E", "SE", "S", "SW", "W"])axs[1].set_xlabel("roof aspect"); axs[1].set_ylabel("roof slope (degrees)")axs[1].set_title("Unshaded annual irradiation by slope and aspect", loc="left")fig.savefig(OUT / "04-irradiance-model-check.png"); plt.close(fig)best = np.unravel_index(np.argmax(grid), np.shape(grid))print(f"best unshaded orientation on the grid: slope {best[0] * 10} deg, aspect {90 + best[1] * 15} deg, " f"{np.max(grid):.0f} kWh/m2/yr | flat {grid[0][0]:.0f} | vertical south wall {grid[9][6]:.0f}")
# 5: annual irradiation mapfig, axs = plt.subplots(1, 2, figsize=(12, 5.2), layout="constrained")g_irr = put(irr)for ax, zoom in zip(axs, (False, True)): ax.imshow(hill, cmap="gray", extent=EXT, vmin=0, vmax=1) im = ax.imshow(g_irr, cmap="Spectral_r", vmin=300, vmax=1100, extent=EXT, interpolation="nearest") if zoom: ax.set_xlim(ZOOM[0], ZOOM[1]); ax.set_ylim(ZOOM[2], ZOOM[3]) off(bld.geometry).boundary.plot(ax=ax, color="k", lw=0.4) ax.set_xlabel("metres east")axs[0].set_ylabel("metres north")axs[0].set_title("Annual irradiation on roofs, with shading (PVGIS TMY)", loc="left")axs[1].set_title("Zoom: south-west and north-east pitches of the same terraces", loc="left")fig.colorbar(im, ax=axs, shrink=0.8, label="kWh/m2/yr on the roof surface")axs[1].set_ylabel("")fig.savefig(OUT / "05-annual-irradiation-roofs.png"); plt.close(fig)
# 6: building rankingfig, axs = plt.subplots(1, 2, figsize=(12, 5.0), layout="constrained", gridspec_kw={"width_ratios": [1.3, 1]})axs[0].imshow(hill, cmap="gray", extent=EXT, vmin=0, vmax=1)kcm = ListedColormap(plt.get_cmap("Spectral")(np.linspace(0.05, 0.95, 6)))knorm = BoundaryNorm(np.arange(-0.5, 6.5), kcm.N)b_off = bld.assign(geometry=off(bld.geometry), kclass=np.digitize(bld["kwh_yr"], [1, 1500, HOUSEHOLD_KWH, 5000, 20000]))b_off.plot(ax=axs[0], column="kclass", cmap=kcm.reversed(), norm=knorm, lw=0)sm = plt.cm.ScalarMappable(cmap=kcm.reversed(), norm=knorm)cb = fig.colorbar(sm, ax=axs[0], shrink=0.8, ticks=range(6))cb.ax.set_yticklabels(["0", "1-1,500", "1,500-2,700", "2,700-5,000", "5,000-20,000", "20,000+"])cb.set_label("estimated kWh per year")for _, r in b_off[b_off["rank"] <= 5].iterrows(): p = r.geometry.representative_point() axs[0].annotate(str(r["rank"]), (p.x, p.y), fontsize=8, ha="center", va="center", weight="bold", bbox=dict(boxstyle="circle,pad=0.15", fc="white", ec="k", lw=0.5))axs[0].set_xlim(0, W); axs[0].set_ylim(0, H); axs[0].set_xlabel("metres east"); axs[0].set_ylabel("metres north")axs[0].set_title("Estimated PV output per building (top 5 numbered)", loc="left")hk = houses["kwh_yr"]axs[1].hist(hk, bins=np.arange(0, 8001, 250), color="#3288bd")axs[1].axvline(HOUSEHOLD_KWH, color="#d53e4f", ls="--", lw=1.2)axs[1].annotate(f"typical household\n{HOUSEHOLD_KWH:,} kWh/yr", (HOUSEHOLD_KWH, axs[1].get_ylim()[1] * 0.85), xytext=(6, 0), textcoords="offset points", color="#d53e4f", fontsize=8, bbox=dict(fc="white", ec="none", alpha=0.85))axs[1].set_xlabel("estimated kWh per year"); axs[1].set_ylabel("buildings")axs[1].set_title(f"House-sized roofs (30-120 m2, n = {len(houses):,})", loc="left")fig.savefig(OUT / "06-roof-ranking-kwh.png"); plt.close(fig)print("figures written to", OUT.resolve())