Viewshed analysis in Python: line of sight and a wind farm ZTV with GDAL and numpy

Run a viewshed analysis in Python on a real DEM: compute what one wind turbine can see with gdal.ViewshedGenerate, rebuild the same line-of-sight algorithm in numpy, correct for earth curvature and refraction, build a cumulative zone of theoretical visibility (ZTV) from 26 turbines, draw line-of-sight profiles, and measure how much trees and buildings in a LiDAR DSM shrink a bare-earth viewshed.

Level
Intermediate
Tested with
Python 3.12
Source code
Download scripts

Key takeaways

  1. gdal.ViewshedGenerate computes a 30 km viewshed on a 2,068 × 2,068 cell DEM in 0.05 s. A 30-line numpy ray caster gives the same answer for 98.5% of cells (kappa 0.962), so you can trust and adapt either.
  2. Earth curvature matters beyond about 10 km: the ground drops 7.8 m at 10 km and 70.6 m at 30 km. Ignoring it overstates one turbine's visible area by 6% (802 against 758 km²).
  3. A cumulative viewshed of all 26 Scout Moor turbines shows at least one blade tip from 36.0% of the 30 km circle. Bury sees 25 tips, while Rawtenstall, 5.3 km away in the next valley, sees none.
  4. Surface model choice changes the answer: on Environment Agency LiDAR, 81.7% of the 8 km × 8 km window sees a tip over bare earth (DTM), but only 64.7% once trees and buildings (DSM) are in the way.

Before you start

  • Basic numpy and raster concepts: a DEM as a 2D array with a geotransform. Terrain analysis with rasterio is a good warm-up
  • Python 3.10+ with GDAL 3.10 or later (Python bindings), numpy, requests, pystac-client, planetary-computer and matplotlib
  • An internet connection: the DEM comes from the Microsoft Planetary Computer, the LiDAR from the Environment Agency and the turbine positions from OpenStreetMap, all without an account

Short answer: to run a viewshed analysis in Python, open a DEM with GDAL and call gdal.ViewshedGenerate(band, "MEM", "", [], x, y, observer_height, target_height, 1, 0, 0, -1, 0.85714, gdal.GVM_Edge, max_distance). It returns a raster where 1 means visible and 0 means hidden. The 0.85714 coefficient corrects for earth curvature and atmospheric refraction, which you need beyond a few kilometres. For a cumulative viewshed (for example how many wind turbines are visible from each place), run one viewshed per observer and add the rasters. A line-of-sight check between two points is the same test along a single profile: sample the DEM, lower it for curvature, and see whether the straight sightline ever dips below the ground. Whether you use a bare-earth DTM or a surface DSM with trees and buildings can change the answer by a third.

In this tutorial we build a zone of theoretical visibility (ZTV) for Scout Moor wind farm, 26 turbines on the moors between Rochdale, Bury and Rossendale in Greater Manchester and Lancashire. We use the free Copernicus GLO-30 DEM for the 30 km study area and Environment Agency LiDAR for a detailed DTM and DSM comparison.

You will learn how to:

  1. get turbine positions from OpenStreetMap and a 30 m Copernicus DEM on the British National Grid;
  2. compute a viewshed with gdal.ViewshedGenerate and handle its cropped output;
  3. write the same line-of-sight algorithm in numpy and check it against GDAL;
  4. measure the effect of earth curvature and refraction;
  5. build a cumulative viewshed (ZTV) from 26 turbines, for blade tips and hubs;
  6. draw line-of-sight profiles to explain why a place can or cannot see a turbine;
  7. compare a bare-earth DTM with a DSM from LiDAR, and see how trees and buildings block views.

All outputs below are real results from viewshed_analysis.py, run on 23 October 2026 with Python 3.12.0, GDAL 3.13.3, numpy 2.5.4 and matplotlib 3.11.2.

What is a viewshed?

A viewshed answers “what can be seen from here?” For every cell in a DEM, the algorithm draws a straight line from the observer’s eye to the target, a point at some height above that cell, and checks whether the terrain gets in the way. Typical uses are:

  • Wind farm and mast ZTVs for landscape and visual impact assessment;
  • telecoms and radio coverage (with extra rules for Fresnel zones);
  • viewpoint and summit panoramas, and the planning of new paths and lookouts;
  • military and security questions such as “where can this camera see?”;
  • archaeology, where intervisibility between hillforts or cairns is a classic study.

Visibility is close to reciprocal: if a 2 m viewer can see a 100 m turbine tip, an observer at the tip can see the 2 m point. So we run the viewshed from each turbine tip to a 2 m eye height, which is the usual way to build a ZTV and needs one run per turbine rather than one per cell.

Step 1: turbine positions from OpenStreetMap

Scout Moor has 26 Nordex N80 2.5 MW turbines with a 60 m hub and 40 m blades, so 100 m to the blade tip. OpenStreetMap maps each one as a power=generator node, so a single Overpass query gets them:

viewshed_analysis.py
TIP_H, HUB_H = 100.0, 60.0 # Nordex N80 at Scout Moor: 60 m hub + 40 m blade = 100 m to blade tip
EYE_H = 2.0 # viewer eye height used in UK landscape guidance (about 1.5-2 m)
RADIUS = 30_000 # ZTV radius for turbines of 86-100 m to blade tip (NatureScot guidance)
QUERY = """[out:json][timeout:60];
node["power"="generator"]["generator:source"="wind"]["generator:output:electricity"="2.5 MW"]
(53.62,-2.32,53.68,-2.18);
out;"""
turbines = overpass(QUERY, DATA / "scout_moor_turbines.json") # tries three Overpass mirrors, then caches
tx, ty = np.array([to_bng(e["lat"], e["lon"]) for e in turbines]).T
Output — turbines
turbines: 26 | E 382070..384848 | N 416758..419274
wind farm centre E 383550 N 418030 | nearest-neighbour spacing 281..495 m

The 30 km radius follows the NatureScot guidance on visual representation of wind farms, which scales the ZTV radius with turbine height (30 km for turbines of 86–100 m to tip). It is widely used in England too.

Step 2: a 30 m Copernicus DEM on the British National Grid

Viewsheds need square cells in metres, so we warp the Copernicus GLO-30 tiles from the Planetary Computer straight into British National Grid (EPSG:27700) at 30 m, with a 1 km margin around the 30 km circle. gdal.Warp reads the cloud-optimised GeoTIFFs over HTTP and mosaics them in one call:

viewshed_analysis.py
catalog = pystac_client.Client.open("https://planetarycomputer.microsoft.com/api/stac/v1",
modifier=planetary_computer.sign_inplace)
items = list(catalog.search(collections=["cop-dem-glo-30"], bbox=[-2.79, 53.33, -1.69, 53.98]).items())
gdal.Warp(str(dem_path), ["/vsicurl/" + it.assets["data"].href for it in items], dstSRS="EPSG:27700",
outputBounds=BOUNDS, xRes=CELL, yRes=CELL, resampleAlg="bilinear", outputType=gdal.GDT_Float32,
creationOptions=["COMPRESS=DEFLATE", "TILED=YES"])
Output — DEM
grid 2068 x 2068 cells of 30 m | elevation 1.0..634.5 m
turbine base elevation (GLO-30): 352..472 m, tips at 452..572 m
30 km study circle: 2,828 km2

The turbines stand at 352–472 m on a moorland plateau. To the south, the land falls to the Manchester plain at under 100 m; to the north lie the Rossendale valleys and the Forest of Bowland beyond.

Elevation map coloured from blue (low) to red (high) over a hillshade. The 26 turbines form a cluster of red triangles in the centre on high moorland. A dashed 30 km circle surrounds them. Manchester Town Hall, Bolton, Bury, Oldham and Rochdale lie on lower ground to the south; Blackburn, Burnley and Rawtenstall lie to the north among valleys and hills.
Copernicus GLO-30 warped to a 30 m British National Grid, with the 26 Scout Moor turbines and the 30 km ZTV radius.

Step 3: one viewshed with gdal.ViewshedGenerate

GDAL has had a viewshed tool (gdal_viewshed on the command line) since version 3.1. The Python function takes the band, the observer coordinates in the raster’s CRS, the observer and target heights, the output values for visible, invisible, out-of-range and nodata cells, the curvature coefficient, a mode and a maximum distance:

viewshed_analysis.py
def gdal_viewshed(band, x, y, obs_h, tgt_h, curv=CURV, max_dist=RADIUS, height_mode=gdal.GVOT_NORMAL,
shape=dem.shape, gt=GT):
"""Run GDAL's viewshed in memory and paste the result (cropped to max_dist) onto the full grid."""
oor = 0 if height_mode == gdal.GVOT_NORMAL else 1e9 # out of range: never visible
out = gdal.ViewshedGenerate(band, "MEM", "", [], x, y, obs_h, tgt_h, 1, 0, oor, -1, curv,
gdal.GVM_Edge, max_dist, heightMode=height_mode)
a = out.GetRasterBand(1).ReadAsArray()
ogt = out.GetGeoTransform()
r0, c0 = int(round((gt[3] - ogt[3]) / -gt[5])), int(round((ogt[0] - gt[0]) / gt[1]))
fill = 1e9 if height_mode != gdal.GVOT_NORMAL else 0
full = np.full(shape, fill, dtype="float32")
full[r0:r0 + a.shape[0], c0:c0 + a.shape[1]] = a
return full
vs_gdal = gdal_viewshed(band, tx[T], ty[T], TIP_H, EYE_H) == 1

Two details trip people up. First, when you set maxDistance, GDAL crops the output to the bounding box of that distance, so its geotransform differs from the input’s. We paste it back onto the full grid so that viewsheds from different turbines line up. Second, the band’s parent dataset must stay referenced while GDAL uses the band, or Python may free it and crash.

Output — one turbine
turbine T7 at E 383329 N 417967, base 401 m, observer = blade tip 100 m
GDAL viewshed: 0.05 s | tip visible from 758 km2 = 26.8% of the 30 km circle

Step 4: the same viewshed in numpy

To understand what GDAL does, or to change the rules (a different visibility test, a vegetation penalty, or GPU arrays), it helps to write the algorithm yourself. The idea is ray casting. Cast a ray from the observer to every edge cell of the square around it. Walk along each ray, keeping the steepest slope from the eye to the ground seen so far. A cell is visible if the slope from the eye to the target above it is at least that running maximum. np.maximum.accumulate does the walk for all rays at once:

viewshed_analysis.py
def numpy_viewshed(z, r0, c0, obs_h, tgt_h, cell, curv=CURV, max_dist=RADIUS):
n = int(max_dist / cell) + 1
edge = np.arange(-n, n + 1)
ends = np.concatenate([np.c_[np.full_like(edge, -n), edge], np.c_[np.full_like(edge, n), edge],
np.c_[edge, np.full_like(edge, -n)], np.c_[edge, np.full_like(edge, n)]])
k = np.arange(1, n + 1) / n # n steps: at most one cell per step in each axis
rr = np.rint(r0 + ends[:, :1] * k).astype(int) # rays x steps
cc = np.rint(c0 + ends[:, 1:] * k).astype(int)
ok = (rr >= 0) & (rr < z.shape[0]) & (cc >= 0) & (cc < z.shape[1])
rr, cc = np.clip(rr, 0, z.shape[0] - 1), np.clip(cc, 0, z.shape[1] - 1)
d = np.hypot(rr - r0, cc - c0) * cell
drop = curv * d ** 2 / (2 * R_EARTH) # earth curvature, reduced by refraction
z_eye = z[r0, c0] + obs_h
with np.errstate(divide="ignore", invalid="ignore"):
s_ground = (z[rr, cc] - drop - z_eye) / d
s_target = (z[rr, cc] - drop + tgt_h - z_eye) / d
s_ground[d == 0] = -np.inf
horizon = np.maximum.accumulate(s_ground, axis=1)
horizon = np.concatenate([np.full((len(ends), 1), -np.inf), horizon[:, :-1]], axis=1)
visible = (s_target >= horizon) & ok & (d <= max_dist)
flat = (rr * z.shape[1] + cc)[ok]
hits = np.bincount(flat, weights=visible[ok], minlength=z.size) # any ray that sees the cell wins
out = (hits > 0).reshape(z.shape)
out[r0, c0] = True
return out
Output — numpy against GDAL
numpy viewshed: 0.60 s | tip visible from 759 km2
agreement with GDAL: 98.50% of cells, Cohen's kappa 0.962 | numpy-only 21.7 km2, GDAL-only 20.6 km2

The two agree on 98.5% of cells, and the visible areas differ by 1 km² out of 758. The disagreements are thin fringes along ridges and valley edges. There, the answer depends on how each method interpolates the terrain between cell centres: GDAL’s default “edge” mode uses the cells on either side of the line, while our rays snap to the nearest cell. Neither is “right”; at 30 m resolution a ridge crest is uncertain by more than that.

Three hillshaded maps of the same 24 km square around one turbine. The first two show the visible area in orange from GDAL and from numpy, and they look almost identical: large visible areas to the south and south-west, patchy visibility to the north. The third map shows the agreement in blue, with thin red and orange slivers along slope edges where only one method says visible.
The blade-tip viewshed of turbine T7 from GDAL (left) and our numpy ray caster (middle). Right: where they agree (blue) and differ (red: GDAL only; orange: numpy only).

Step 5: earth curvature and refraction

On a flat earth, a 100 m turbine on a 400 m hill would be visible for hundreds of kilometres across a plain. On the real earth, the ground curves away from a level sightline by d²/2R, where R is the earth’s radius. Refraction bends light slightly downwards and offsets about a seventh of this, so the apparent drop is (1 − k)·d²/2R with k ≈ 1/7. That is what GDAL’s default curvCoeff of 0.85714 means. Pass 0 for a flat earth and 1 for curvature without refraction.

Output — curvature
5 km: curvature drop 2.0 m | with refraction (k=1/7) 1.7 m
10 km: curvature drop 7.8 m | with refraction (k=1/7) 6.7 m
20 km: curvature drop 31.4 m | with refraction (k=1/7) 26.9 m
30 km: curvature drop 70.6 m | with refraction (k=1/7) 60.5 m
flat earth (0) visible 802.5 km2 | 25.7% of the 20-30 km ring
curvature only (1) visible 750.3 km2 | 23.3% of the 20-30 km ring
curvature + refraction (0.857) visible 757.9 km2 | 23.6% of the 20-30 km ring

At 30 km the ground has dropped 60 m, more than half the height of the turbine. A flat-earth viewshed claims 802.5 km² instead of 757.9 km², and almost all the extra area is far away, where viewers would be looking just over a ridge. Refraction is variable (it is stronger over cold water and in temperature inversions), so treat k = 1/7 as a convention, not a measurement.

Left: a line chart of the share of each 2.5 km distance ring where the turbine tip is visible, for flat earth, curvature only and curvature plus refraction. The three lines overlap up to about 10 km and then the flat-earth line stays higher, by up to a few percentage points at 20 to 30 km. Right: two curves of the apparent drop of the ground with distance, rising to about 125 m at 40 km for geometric curvature and slightly less with refraction.
Left: visibility by distance from turbine T7 under three curvature settings. Right: how far the ground drops below a level sightline, with and without refraction.

Step 6: a cumulative viewshed (ZTV) from 26 turbines

A wind farm ZTV is a cumulative viewshed: one viewshed per turbine, added up, so each cell holds the number of turbines visible from it. It is usual to compute it twice. Once for blade tips (can you see any moving part?) and once for hubs (can you see the rotor centre, a much stronger visual cue?):

viewshed_analysis.py
n_tip = np.zeros(dem.shape, "int16"); n_hub = np.zeros(dem.shape, "int16")
for x, y in zip(tx, ty):
n_tip += gdal_viewshed(band, x, y, TIP_H, EYE_H) == 1
n_hub += gdal_viewshed(band, x, y, HUB_H, EYE_H) == 1
Output — ZTV
52 viewsheds in 2.1 s
at least one blade tip 1017 km2 36.0%
at least one hub 918 km2 32.5%
blade tips only, no hub 99 km2 3.5%
all 26 tips 504 km2 17.8%
no part of any turbine 1810 km2 64.0%
turbines visible at named places (2 m eye height, GLO-30 surface):
place km tips hubs
Manchester Town Hall 19.9 0 0
Bury 7.9 25 23
Rochdale 7.8 24 17
Bolton 14.8 26 7
Oldham 16.0 0 0
Ramsbottom 4.5 13 9
Edenfield 3.9 8 6
Rawtenstall 5.3 0 0
Burnley 14.6 0 0
Blackburn 18.3 0 0
Peel Monument 6.0 26 26
Winter Hill 17.8 26 26

Fifty-two viewsheds over 4.3 million cells take two seconds. The pattern is classic upland wind farm: the plateau edge faces south, so the farm is widely visible across the Manchester plain (Bury 25 tips, Bolton 26) but hidden from the deep Rossendale valleys to the north. Rawtenstall, 5.3 km away, sees nothing, while Bolton at 14.8 km and the summit of Winter Hill at 17.8 km see every tip. Distance is a poor guide to visibility in hilly country.

Point results need care. Manchester Town Hall and Oldham show zero, but the map shows visible ground close to both. A single 30 m cell in a town centre is sensitive to the exact location and to GLO-30’s partial building heights. The script also writes ztv_tips_count.tif and ztv_hubs_count.tif, so you can inspect them in QGIS.

Two hillshaded maps of the 30 km circle. Left: the cumulative ZTV coloured by the number of blade tips visible, with dark red (21 to 26 turbines) covering most of the land south of the wind farm, including Bury, Bolton and Rochdale, and scattered red and orange patches on north-facing hillsides to the north. Large grey areas, including the valleys around Rawtenstall and Burnley, see nothing. Right: the same area split into hubs visible (red) and blade tips only (yellow), which forms thin yellow fringes at the edges of the visible zones.
Left: cumulative ZTV for the 26 Scout Moor turbines from a 2 m eye height. Right: where you can see at least one hub (red) and where only blade tips show (yellow).

Step 7: line-of-sight profiles

A ZTV says whether a place sees a turbine; a line-of-sight profile explains why. We sample the DEM along the straight line with bilinear interpolation every 15 m, lower it for curvature, and compare it with the sightline from the blade tip to the viewer’s eye:

viewshed_analysis.py
def los_profile(x0, y0, x1, y1, obs_h, tgt_h, z=dem, gt=GT, curv=CURV, step=CELL / 2):
"""Sample the DEM along a straight line, lower it for curvature and test the sightline."""
L = np.hypot(x1 - x0, y1 - y0)
d = np.arange(0, L + step, step); d[-1] = L
xs, ys = x0 + (x1 - x0) * d / L, y0 + (y1 - y0) * d / L
fc = (xs - gt[0]) / gt[1] - 0.5; fr = (ys - gt[3]) / gt[5] - 0.5 # bilinear sampling
c0, r0 = np.floor(fc).astype(int), np.floor(fr).astype(int); fx, fy = fc - c0, fr - r0
g = (z[r0, c0] * (1 - fx) * (1 - fy) + z[r0, c0 + 1] * fx * (1 - fy) + z[r0 + 1, c0] * (1 - fx) * fy
+ z[r0 + 1, c0 + 1] * fx * fy)
g_eff = g - curv * d ** 2 / (2 * R_EARTH)
z0, z1 = g_eff[0] + obs_h, g_eff[-1] + tgt_h
sight = z0 + (z1 - z0) * d / L
clearance = sight[1:-1] - g_eff[1:-1]
worst = int(np.argmin(clearance)) + 1
return d, g, g_eff, sight, clearance.min() >= 0, worst
Output — profiles
T1 tip -> Manchester Town Hall: 18.7 km | BLOCKED | tightest point 18.33 km from the turbine, sightline -6.8 m above the curvature-corrected ground | curvature + refraction at the viewer 23.5 m
T22 tip -> Peel Monument: 4.7 km | VISIBLE | tightest point 4.67 km from the turbine, sightline +3.1 m above the curvature-corrected ground | curvature + refraction at the viewer 1.5 m

The Manchester profile is a near miss: the sightline is blocked 370 m before the viewer by 6.8 m. That is about the height of a two-storey building, and GLO-30 includes some of the city’s buildings, so this result is well within the DEM’s uncertainty. Curvature lowers the ground at the viewer by 23.5 m relative to the turbine. Peel Monument, on Holcombe Hill above Ramsbottom, looks across the Irwell valley; its tightest point is the hillside just below the viewer, which the sightline clears by 3.1 m.

Two elevation profiles. Top: from a turbine base at about 350 m down to Manchester Town Hall, 18.7 km away. A dashed line shows the flat-earth ground and a green filled area shows the ground lowered for curvature. The red sightline from the blade tip stays above the ground most of the way but dips below it about 370 m before the viewer, marked with a cross. Bottom: a 4.7 km profile across the Irwell valley to Peel Monument at about 340 m, where the green sightline stays clear all the way.
Line-of-sight profiles from the nearest turbine blade tip to Manchester Town Hall (blocked) and to Peel Monument (visible). The cross marks the tightest point on each line.

Step 8: DSM versus DTM with Environment Agency LiDAR

Everything so far used a 30 m surface that only partly includes trees and buildings. To see how much they matter, we take an 8 km × 8 km window covering Ramsbottom, Edenfield and the western half of the wind farm. We download the Environment Agency’s LIDAR Composite 1 m DTM (bare earth) and first-return DSM (surface) from its WCS, one 1 km tile at a time, and resample both to 2 m:

viewshed_analysis.py
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"),
}
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 + 1000})&subset=N({y0},{y0 + 1000})")

Two problems need handling before a fair comparison:

  • The turbines are in the DSM. LiDAR records the towers as 60 m spikes. Left in, the observer would stand on top of the tower, so we reset the DSM to the DTM within 12 m of each turbine.
  • On a DSM, the viewer would stand on the roof or in the treetop. A person stands on the ground, among the trees. GDAL handles this with heightMode=gdal.GVOT_MIN_TARGET_HEIGHT_FROM_DEM, which returns, for every cell, the lowest elevation a target must reach to be seen over the DSM. A ground-level viewer sees the turbine if DTM + 2 m reaches that elevation.
viewshed_analysis.py
for x, y in local_turb:
n_dtm += gdal_viewshed(dtm_ds.GetRasterBand(1), x, y, TIP_H, EYE_H, max_dist=15_000,
shape=dtm.shape, gt=LGT) == 1
# lowest elevation a target in each cell must reach to be seen over the DSM; a ground-level eye is at DTM + 2 m
need = gdal_viewshed(dsm_ds.GetRasterBand(1), x, y, TIP_H, 0, max_dist=15_000, shape=dtm.shape, gt=LGT,
height_mode=gdal.GVOT_MIN_TARGET_HEIGHT_FROM_DEM)
n_dsm += np.nan_to_num(need <= dtm + EYE_H, nan=0).astype("int16")
Output — LiDAR DTM versus DSM
grid 4000 x 4000 cells of 2 m | no data: DTM 0.00%, DSM 0.00%
turbines inside the LiDAR window: 26 of 26
DSM - DTM within 12 m of each turbine (the tower in the DSM): median 60.1 m, max 65.7 m; reset to the DTM
cells more than 2.5 m above ground (trees, buildings): 9.3%
52 LiDAR viewsheds in 10.9 s
all cells : >=1 tip visible on DTM 81.7% | on DSM 64.7% | mean tips visible 11.6 vs 8.8
open ground only : >=1 tip visible on DTM 83.1% | on DSM 73.5% | mean tips visible 11.9 vs 10.0
Edenfield 1 km2 around (379868, 419380): >=1 tip visible on DTM 90.7% | on DSM (at ground level) 52.6%
Ramsbottom 1 km2 around (379169, 416835): >=1 tip visible on DTM 90.7% | on DSM (at ground level) 29.0%

The LiDAR measures the towers at a median of 60.1 m, exactly the published hub height, which is a good check that the data are aligned. Only 9.3% of the window is more than 2.5 m above ground, mostly moorland. Even so, surface objects remove a fifth of the visible area: 81.7% falls to 64.7%.

The effect is concentrated where people live. In the square kilometre around Ramsbottom, a bare-earth ZTV says 90.7% of the ground sees a turbine tip. Standing in the streets among the buildings and trees, only 29.0% does. Even in open fields (cells less than 1 m above the DTM), visibility drops from 83.1% to 73.5%, because hedgerows and woods block the view. Both town squares show 90.7% on the DTM at different locations, which is a coincidence.

Three maps of an 8 km square with the wind farm turbines as small triangles. Left: LiDAR heights above ground over a hillshade, showing the towns of Ramsbottom and Edenfield in the west as textured patches of buildings and trees, and the open moor in the east. Middle: the bare-earth viewshed, coloured by number of tips visible, with most of the square dark red. Right: the DSM viewshed for a ground-level viewer, where the towns and wooded valleys in the west become speckled grey and blue, with far fewer turbines visible.
Left: LiDAR surface objects (DSM − DTM over 2.5 m). Middle: number of blade tips visible over the bare-earth DTM. Right: the same for a viewer standing on the ground among the trees and buildings of the DSM.

DSM or DTM: which should you use?

Question Use
Statutory wind farm or mast ZTV in the UK Bare-earth DTM, as a worst case. Landscape guidance treats the ZTV as theoretical and adds screening in the field and in photomontages.
Realistic visibility for residents or walkers DSM for blockers, DTM + eye height for the viewer, as in Step 8.
Telecoms line of sight DSM, plus clearance of the Fresnel zone, not just the sightline.
Viewpoint from a summit or tower DSM if the summit is wooded or the view is over a town; a DTM is fine for open mountain tops.
Global studies at 30 m Copernicus GLO-30, remembering it is a partial DSM.

A DSM is a snapshot. Deciduous trees in winter block much less than the leaf-on LiDAR suggests, and trees grow or are felled. A DTM viewshed is reproducible and conservative, which is why planning work uses it as the baseline.

Pitfalls and limitations

  • Resolution. At 30 m, a ridge, embankment or row of houses may disappear between cells. Fringes of the viewshed are uncertain by at least one cell, as the 98.5% agreement between two correct algorithms shows.
  • Vertical accuracy. GLO-30’s absolute vertical error is a few metres in open terrain and worse in towns and forests. A 6.8 m miss on the Manchester profile is within that uncertainty.
  • Reciprocity is approximate. Swapping observer and target is exact for a single straight line. On a grid, the paths and interpolation differ slightly, so “turbine sees cell” and “cell sees turbine” can disagree at the edges.
  • Blade-tip assumption. A tip ZTV counts a cell as visible if any part of the rotor’s highest point could be seen. A blade tip just above a ridge at 25 km is barely perceptible; a hub ZTV and a distance band give a more honest picture.
  • What is not modelled: atmospheric visibility, lighting and contrast, the angle the turbine subtends, and screening by walls and windows.
  • Projection. Run viewsheds in a projected CRS with square metric cells. In degrees, distances and cell sizes are wrong, and GDAL’s curvature correction becomes meaningless.

Frequently asked questions

How do you calculate a viewshed in Python?

Use GDAL’s Python bindings: open the DEM, get the band and call gdal.ViewshedGenerate with the observer’s map coordinates, observer height, target height and maximum distance. It returns a raster where visible cells hold 1. For more control, write a ray-casting algorithm in numpy (Step 4) or use the viewshed functions in packages such as xarray-spatial. In QGIS, the Visibility Analysis plugin and the GDAL Viewshed tool in the Processing toolbox do the same job.

What is the difference between a viewshed and line of sight?

A line of sight tests one pair of points: can A see B? A viewshed tests one point against every cell of a DEM and returns a map. Both use the same test, whether the straight sightline stays above the terrain. A line-of-sight profile is useful to explain a viewshed result, such as which ridge blocks a view.

Should a viewshed account for earth curvature?

Yes, beyond about 5 km. The ground drops 2 m below a level line at 5 km, 7.8 m at 10 km and 70.6 m at 30 km. Atmospheric refraction bends light back by about a seventh of that, so most tools use a coefficient of 0.857 (1 − 1/7). In this tutorial, ignoring curvature overstated one turbine’s visible area by 6%, nearly all of it far away.

What is a zone of theoretical visibility (ZTV)?

A ZTV is a map of where a development such as a wind farm could in theory be seen, computed from terrain alone. For wind farms it is a cumulative viewshed of all turbines, usually shown as the number of hubs or blade tips visible, within a radius that depends on turbine height. It is “theoretical” because it ignores trees, buildings and atmospheric conditions, so it overstates actual visibility.

Should I use a DSM or a DTM for a viewshed?

Use a DTM for a conservative, reproducible baseline, such as a planning ZTV. Use a DSM when you need realistic visibility, but keep the viewer on the ground (DTM + eye height) and use the DSM only for what blocks the view. In this tutorial, adding trees and buildings from LiDAR cut the area that could see a turbine from 81.7% to 64.7%, and in Ramsbottom from 90.7% to 29.0%.

Which DEM is best for viewshed analysis?

Use the most detailed DEM that covers your area. In England, the Environment Agency’s 1 m LiDAR DTM and DSM are free. Other UK nations and many countries have national LiDAR too (see UK data sources). For larger areas or other countries, Copernicus GLO-30 is the best free global DEM, but it is a partial surface model. SRTM is older and noisier.

Next steps

Data and licences

  • Copernicus DEM GLO-30: © DLR e.V. 2010–2014 and © Airbus Defence and Space GmbH 2014–2018, provided under COPERNICUS by the European Union and ESA; all rights reserved. Free to use under the Copernicus DEM licence. Accessed from the Microsoft Planetary Computer on 23 October 2026.
  • LiDAR: Environment Agency LIDAR Composite DTM and first-return DSM, 1 m. © Environment Agency copyright and/or database right 2026. All rights reserved. Available under the Open Government Licence v3.0. Accessed from the Environment Agency WCS on 23 October 2026.
  • Turbine positions: © OpenStreetMap contributors, available under the Open Database Licence (ODbL). Accessed via the Overpass API on 23 October 2026.
  • Code: viewshed_analysis.py, tested with Python 3.12.0, GDAL 3.13.3, numpy 2.5.4, requests 2.34.2, pystac-client 0.9.0, planetary-computer 1.0.0 and matplotlib 3.11.2.

Key terms

Viewshed
The set of raster cells visible from an observer point at a given height, for a target at a given height above the surface. A cell is visible if the straight sightline between them never passes below the terrain.
ZTV
Zone of theoretical visibility: the area from which a development such as a wind farm could in theory be seen, computed from terrain alone. Used in landscape and visual impact assessment (LVIA) in the UK.
Cumulative viewshed
The sum of several viewsheds, so each cell holds the number of observers (here turbines) that can see it, or that it can see.
DSM and DTM
A digital surface model includes buildings, trees and other objects on the ground; a digital terrain model is the bare earth with them removed. Copernicus GLO-30 is a radar DSM; LiDAR gives both.
Refraction coefficient
The fraction by which the atmosphere bends light back towards the ground, partly offsetting earth curvature. 1/7 (0.143) is the usual value for visible light; GDAL's default curvature coefficient 0.85714 is 1 − 1/7.
Line-of-sight profile
A cross-section of the terrain between two points with the straight sightline drawn on top. Where the line dips below the ground, the view is blocked.

Tutorial Python

Rooftop solar potential from LiDAR in Python: pvlib, PVGIS and shading for every roof

Estimate rooftop solar potential in Python for 2,148 buildings in Manchester: take a 1 m Environment Agency LiDAR DSM and Overture Maps footprints, compute roof slope and aspect, trace horizon angles for shading from neighbours and trees, build a sun map from a PVGIS typical year with pvlib, check it against pvlib's Perez model and PVGIS, then estimate kWp and kWh per year per roof and rank the best roofs.

Advanced 14 min read

Tutorial Python

Extract building footprints and heights from LiDAR in Python (USGS 3DEP, downtown Madison)

Turn classified LiDAR into building footprints with heights in Python: stream USGS 3DEP COPC tiles, build a DTM and a building nDSM from ASPRS class 6 points, polygonise and regularise the mask, split roofs at height steps, compute height statistics per building, score the result against OpenStreetMap with IoU, and export a GeoPackage and a 3D view.

Intermediate 15 min read