"""Rooftop solar potential in Python: Environment Agency 1 m LiDAR DSM + Overture Maps building footprints + PVGIS weather + pvlib sun positions, for every roof in a 1 km x 0.8 km block of Levenshulme, Manchester. Pipeline: roof cells from footprints and the normalised DSM -> Horn slope and aspect -> horizon angles in 72 directions by ray marching over the DSM (shading from neighbours, trees and the roof itself) -> 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 sys import time import warnings from importlib.metadata import version from pathlib import Path import duckdb import geopandas as gpd import numpy as np import pandas as pd import pvlib import rasterio import requests import shapely from pyproj import Transformer from rasterio.features import rasterize from rasterio.transform import from_origin from scipy import ndimage import matplotlib matplotlib.use("Agg") # render to files only; no GUI backend import matplotlib.pyplot as plt from 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 Levenshulme AOI = (386800, 393700, 387800, 394500) # xmin, ymin, xmax, ymax: 1 km x 0.8 km of roofs to assess FETCH = (386000, 393000, 388000, 395000) # 2 km x 2 km of LiDAR, so shading from up to 300 m away is seen EA_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-run PANEL_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 losses COVERAGE = 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 artefacts MIN_IRR = 800 # kWh/m2/yr; cells below this are not worth covering in Manchester ALBEDO = 0.20 # ground reflectance for the reflected component MIN_HEIGHT = 2.5 # m above the DTM for a cell to count as roof HOUSEHOLD_KWH = 2700 # Ofgem typical domestic electricity consumption (medium, 2024 onwards) AZ = np.arange(2.5, 360, 5.0) # 72 horizon directions, clockwise from north STEPS = 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 DTM section("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 slopes ndsm = dsm - dtm print(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 grid R0, 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 footprints section("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.centroid bld = 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.area print(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 perfectly roof = 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 aspect section("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 >= 10 print(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 angles section("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_obs print(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 map section("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 hour sp = pvlib.solarposition.get_solarposition(times, LAT, LON) tmy["zenith"], tmy["azimuth"], tmy["elevation"] = sp["apparent_zenith"].values, sp["azimuth"].values, \ sp["apparent_elevation"].values print(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 get sun["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 bin print(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 plane section("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_years pvgis_kwh_kwp = hourly["P"].sum() / 1000 / n_years print(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 cell section("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 + refl b_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 ranking section("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 roof kwh = 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/m2 cell_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).values bld["usable_m2"] = agg["usable_m2"].reindex(bld.index + 1).fillna(0).values bld["kwp"] = agg["kwp"].reindex(bld.index + 1).fillna(0).values bld["kwh_yr"] = agg["kwh"].reindex(bld.index + 1).fillna(0).values bld["mean_irr"] = (agg["irr_w"] / agg["usable_m2"].replace(0, np.nan)).reindex(bld.index + 1).values print(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)") # ---------------------------------------------------------------- Figures section("figures") EXT = [0, W, 0, H] # metres from the AOI south-west corner dz_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 area fig, 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 aspect fig, 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 dates fig, 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 check months = 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.27 cols = ["#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 map fig, 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 ranking fig, 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())