Screening tower sites with elevation data before boots on ground
Geocode candidate tower sites, pull ground elevation per point, and sample terrain between sites to shortlist high-ground candidates before field visits.
A tower site acquisition team typically works from a shortlist of fifty to two hundred candidate locations — rooftops, hilltops, existing structures, greenfield parcels — before a single engineer puts boots on the ground. The cost of a site visit ranges from a few hundred to a few thousand dollars once you count fuel, time, access negotiations, and the structural engineer's day rate. Eliminating ten obviously poor candidates before the field work starts is worth it.
Terrain is one of the cheapest first-pass filters available. A candidate site on low ground surrounded by a ridge is a worse starting position than one sitting at the local high point. A candidate that has another hill sitting squarely between it and the nearest hub site may need a different link budget altogether, and your RF team will tell you that as soon as they see the terrain profile — but they should not have to drive out to measure it. You can compute a reasonable terrain screening signal with a geocoding call and a handful of elevation lookups, entirely from your desk, before you ever file a site access request.
This post walks through that workflow end to end: geocoding a candidate address list, pulling ground elevation per candidate, comparing candidates against their local context, and sampling elevation along the bearing between two sites to spot obvious terrain obstructions. The code is REST, the data is global, and the pipeline runs in a few minutes against a list of any size up to tens of thousands of candidates.
One honest caveat up front: terrain elevation is a screening signal, not an RF engineering output. Nothing in this post replaces a propagation model, a viewshed analysis, or a link-budget calculation. What it does is get you from two hundred candidates to sixty before your RF team touches any of them — and that is where the cost saving lives.
Why elevation screening belongs in site acquisition, not just RF engineering
The instinct in telecoms is to hand the screening work to RF engineers who run a proper propagation tool. That is the right call eventually — but propagation modelling at full fidelity is expensive to run, requires a software licence, and takes an engineer who knows how to set it up. Running it against two hundred speculative candidates before any ground-truth has been collected is often not the best use of that resource.
Terrain elevation screening slots in one stage earlier. It is cheap, it is fast, and it answers a narrower question: is this candidate site at a terrain disadvantage so severe that further work is unlikely to be worth it? The filter does not need to be perfect. Even a rough filter that eliminates the bottom third of candidates by terrain height relative to local context will save meaningful field time.
Three concrete signals that elevation gives you at this stage:
Relative height against local terrain. A candidate on a hilltop 40 m above the surrounding ground is a categorically different starting position from a candidate in a valley 20 m below the ridge. You can compute this by pulling the candidate's elevation, sampling a ring of points around it at some radius, and taking the delta. A positive delta means the candidate stands above its immediate context; a negative delta is a flag.
Cross-section between two paired sites. If you know you want to link Site A to Site B, you can sample terrain elevations at regular intervals along the straight line between them and check whether the ground profile has any peaks that rise above the line-of-sight datum. A terrain peak between two sites does not automatically kill the link — tower heights and Fresnel zones are the RF team's calculation — but a 300 m ridge sitting directly between two sites at low elevation is information worth having before you spend a day acquiring both locations.
Absolute elevation as a triage proxy. In markets where you know from experience that good hub sites tend to be above a certain elevation threshold — say, above 600 m in a given region — you can use absolute elevation as a fast first filter. It will miss nuance, but it will also eliminate candidates that are clearly in the wrong terrain regime before you spend any field resources on them.
None of this replaces your engineers. It reduces the list they receive.
The two endpoints
The workflow uses two CSV2GEO endpoints. Both use the same API key. Both are billed per call or per point. Both are accessible via plain HTTP — no SDK required.
`GET /api/v1/geocode` — takes a free-text address and returns a latitude, longitude, confidence score, and normalised address. You need this because candidate lists from site acquisition databases almost always arrive as addresses, not coordinates. The confidence score matters: a geocode that returns 0.6 confidence on a rural route address should be flagged for manual coordinate entry before you invest elevation lookups in it.
`GET /api/v1/elevation` — takes one to 500 lat/lng points per call and returns a ground elevation in metres above mean sea level for each. This is the engine of the screening workflow. The same endpoint handles both spot lookups (one elevation per candidate) and cross-section sampling (a sequence of points along a bearing between two sites). Coverage is global at 30 m horizontal resolution.
Anchor probes to sanity-check your integration before you run production data: Denver should return approximately 1,597 m, Mauna Kea's summit 4,198 m, Miami 1 m, Tokyo 40 m, Paris 46 m. If those numbers look right, you can trust the results for your candidates.
How to structure the screening pipeline
The full pipeline has four stages. Each stage is independent enough to run separately if your candidate list is already geocoded, or if you only need one of the screening signals.
- Geocode candidates — turn address strings into (lat, lng, confidence) tuples
- Fetch point elevations — one elevation per candidate
- Compute local context — sample a ring of surrounding points per candidate and compare
- Sample cross-sections — interpolate points along the bearing between paired sites
The following section walks through each stage with working code.
Step 1: Geocode your candidate address list
Input: a CSV with columns site_id, address. Output: the same CSV with lat, lng, confidence added.
import csv
import os
import time
import requests
API = "https://csv2geo.com/api/v1"
KEY = os.environ["CSV2GEO_API_KEY"]
def geocode_address(address):
r = requests.get(
f"{API}/geocode",
params={"q": address, "api_key": KEY},
timeout=30,
)
r.raise_for_status()
results = r.json().get("results", [])
if not results:
return None, None, None
top = results[0]
return top.get("lat"), top.get("lng"), top.get("confidence")
with open("candidates.csv") as fin, open("candidates_geocoded.csv", "w", newline="") as fout:
reader = csv.DictReader(fin)
writer = csv.DictWriter(fout, fieldnames=reader.fieldnames + ["lat", "lng", "confidence"])
writer.writeheader()
for row in reader:
lat, lng, conf = geocode_address(row["address"])
row.update({"lat": lat, "lng": lng, "confidence": conf})
writer.writerow(row)
time.sleep(0.05) # modest rate-limit headroomFlag any row with confidence < 0.7 for manual coordinate entry. A site acquired on the basis of a low-confidence geocode is a site that might be in the wrong field. The address verification step is cheap; correcting a misplaced site record two months later is not.
The same call in Node:
const API = 'https://csv2geo.com/api/v1';
const KEY = process.env.CSV2GEO_API_KEY;
async function geocodeAddress(address) {
const url = `${API}/geocode?q=${encodeURIComponent(address)}&api_key=${KEY}`;
const r = await fetch(url);
if (!r.ok) throw new Error(`geocode http ${r.status}`);
const data = await r.json();
const top = data.results?.[0];
if (!top) return { lat: null, lng: null, confidence: null };
return { lat: top.lat, lng: top.lng, confidence: top.confidence };
}Step 2: Fetch ground elevation for every candidate
With coordinates in hand, batch the elevation lookups. The endpoint accepts up to 500 points per call using a pipe-separated points parameter.
def chunks(seq, n):
for i in range(0, len(seq), n):
yield seq[i:i+n]
def fetch_elevations(coords):
"""coords: list of (lat, lng) tuples. Returns list of elevation_m values (float or None)."""
points_str = "|".join(f"{lat},{lng}" for lat, lng in coords)
r = requests.get(
f"{API}/elevation",
params={"points": points_str, "api_key": KEY},
timeout=30,
)
r.raise_for_status()
return [result.get("elevation_m") for result in r.json()["results"]]A quick curl to verify the endpoint is returning sensible numbers before you run your full list:
curl -G "https://csv2geo.com/api/v1/elevation" \
--data-urlencode "points=39.7392,-104.9903" \
--data-urlencode "api_key=$CSV2GEO_API_KEY"Denver should return approximately 1,597 m. If it does, your integration is working.
For a list of 200 candidates, this is one API call. For 1,000 candidates it is two calls. The network cost is negligible — the dominant time is the geocoding step, which is one call per address.
Step 3: Compute local terrain context per candidate
A candidate's absolute elevation tells you where it sits globally. What matters for screening is where it sits relative to its immediate surroundings. A site at 400 m on a plateau that tops out at 410 m is effectively low ground; a site at 400 m on a ridge that drops to 300 m in every direction is genuinely elevated.
To compute a local context score, sample eight points at a fixed radius around each candidate and compare their average to the candidate's own elevation.
import math
def ring_points(lat, lng, radius_m=1000, n=8):
"""Generate n evenly-spaced points at radius_m metres from (lat, lng)."""
deg_per_m = 1.0 / 111_320
pts = []
for i in range(n):
bearing = 2 * math.pi * i / n
d_lat = math.cos(bearing) * radius_m * deg_per_m
d_lng = math.sin(bearing) * radius_m * deg_per_m / math.cos(math.radians(lat))
pts.append((lat + d_lat, lng + d_lng))
return pts
def terrain_delta(candidate_lat, candidate_lng, candidate_ele_m, radius_m=1000):
"""
Positive delta: candidate stands above the surrounding ring.
Negative delta: candidate is lower than its surroundings.
"""
ring = ring_points(candidate_lat, candidate_lng, radius_m)
elevations = fetch_elevations(ring)
valid = [e for e in elevations if e is not None]
if not valid:
return None
avg_ring = sum(valid) / len(valid)
return round(candidate_ele_m - avg_ring, 1)Adding this to your enriched CSV gives you a terrain_delta_m column. Sort descending. Candidates with strongly positive deltas are your best terrain candidates. Candidates with negative deltas — sites sitting in a bowl relative to the local ridge — are the ones to defer or drop.
The radius you choose is domain-specific. 1 km is a reasonable default for macrocell screening. For small-cell or repeater candidates in urban terrain, 200–300 m may be more appropriate. Expose it as a parameter rather than hard-coding it.
Step 4: Sample the terrain cross-section between paired sites
For any pair of sites you are considering linking — a candidate hub to a candidate remote, for example — you can sample the ground elevation profile along the straight-line bearing between them. This is a terrain obstruction screening check, not a line-of-sight certification. Your RF engineers will tell you whether a terrain feature is actually in the Fresnel zone and by how much; what you are looking for at this stage is whether there is an obvious ground feature that will make the link hard before you commit field resources to acquiring both sites.
def interpolate_points(lat1, lng1, lat2, lng2, n_samples=50):
"""
Return n_samples evenly-spaced points along the great-circle approximation
between two coordinates. Adequate for terrestrial distances up to ~100 km.
"""
pts = []
for i in range(n_samples + 1):
t = i / n_samples
pts.append((lat1 + t * (lat2 - lat1), lng1 + t * (lng2 - lng1)))
return pts
def terrain_cross_section(lat1, lng1, lat2, lng2, n_samples=50):
"""
Returns a list of dicts: {frac, lat, lng, elevation_m}
frac is 0.0 at site A and 1.0 at site B.
"""
pts = interpolate_points(lat1, lng1, lat2, lng2, n_samples)
elevations = []
for batch in chunks(pts, 500):
elevations.extend(fetch_elevations(batch))
return [
{"frac": round(i / n_samples, 3), "lat": pts[i][0], "lng": pts[i][1], "elevation_m": elevations[i]}
for i in range(len(pts))
]With the cross-section in hand, the simplest screening check is whether any intermediate point rises above the elevation of the lower of the two endpoints:
def has_obvious_obstruction(cross_section):
endpoint_elevations = [
cross_section[0]["elevation_m"],
cross_section[-1]["elevation_m"],
]
if any(e is None for e in endpoint_elevations):
return None # cannot determine
lower_endpoint = min(e for e in endpoint_elevations if e is not None)
for pt in cross_section[1:-1]:
if pt["elevation_m"] is not None and pt["elevation_m"] > lower_endpoint:
return True
return FalseThis is intentionally conservative — it flags any intermediate point that rises above the lower endpoint, which is not the same thing as saying the terrain blocks the link. That determination belongs to your RF team with a proper tool. What this check does is tag pairs where there is clearly terrain above the lower endpoint elevation, which is worth knowing before you spend field time on both sites.
Step 5: Assemble the shortlist and hand off to the field team
Pull the enriched data together into a ranked output. A reasonable ranking criterion for first-pass screening:
- Drop candidates with geocoding confidence below your threshold (e.g. 0.7)
- Drop candidates where
elevation_mreturnednull(data gap — investigate separately) - Sort by
terrain_delta_mdescending — highest relative elevation first - Flag candidate pairs where
has_obvious_obstructionreturnedTrue
import csv
with open("candidates_geocoded.csv") as fin:
rows = [r for r in csv.DictReader(fin) if r["lat"] and float(r["confidence"] or 0) >= 0.7]
# Fetch elevations in batches
coords = [(float(r["lat"]), float(r["lng"])) for r in rows]
all_elevations = []
for batch in chunks(coords, 500):
all_elevations.extend(fetch_elevations(batch))
for row, ele in zip(rows, all_elevations):
row["elevation_m"] = ele
# Compute terrain delta per candidate
for row in rows:
if row["elevation_m"] is not None:
row["terrain_delta_m"] = terrain_delta(
float(row["lat"]), float(row["lng"]), float(row["elevation_m"])
)
else:
row["terrain_delta_m"] = None
# Sort and write
rows.sort(key=lambda r: float(r["terrain_delta_m"] or -9999), reverse=True)
with open("shortlist.csv", "w", newline="") as fout:
writer = csv.DictWriter(fout, fieldnames=rows[0].keys())
writer.writeheader()
writer.writerows(rows)The resulting shortlist.csv is what goes to the site acquisition team. They pick up from the top of the list. The candidates at the bottom — low terrain delta, flagged cross-sections — go into a deferred pool that the RF team reviews when they have capacity, rather than a site visit queue that eats field budget.
Production considerations
A few things that will bite you if you treat this as a quick script rather than a pipeline.
Null elevations are not zeros. The elevation endpoint returns null for points where our DEM has no data — ocean cells, some extreme terrain, the occasional data gap. A null is meaningfully different from 0 m. Always branch on is None (Python) or === null (JavaScript), and always include a null handling path in your terrain-delta computation. Do not treat 0 m as "no data"; coastal sites and low-lying areas are legitimately at or near sea level.
The ring-sampling approach uses additional credits. Eight ring points per candidate is eight elevation credits per candidate. For a list of 200 candidates the ring sample costs 1,600 credits on top of the 200 candidate lookups. At the batch size of 500, that is four additional API calls. Budget for this explicitly — tell your team the per-candidate cost is nine credits (one candidate + eight ring points), not one.
Cross-section density vs call count. A 50-point cross-section between two sites is 50 elevation credits and a fraction of one API call. A 200-point cross-section is 200 credits and one call. The marginal cost of a denser cross-section is credits, not API calls (because you are batching 500 points per call). For screening purposes, 50 points over a 20 km path gives you a sample every 400 m — adequate for finding a ridge but not adequate for finding a narrow saddle. Adjust density to your terrain type and inter-site distance.
Retry with backoff, not a tight loop. If you are running this pipeline against several hundred candidates in a burst, a transient 429 or 503 from any upstream dependency will cause a naive loop to hammer the endpoint. Implement exponential backoff with jitter. The pattern is covered in detail in Exponential Backoff — When to Retry, When to Stop; the same approach applies here verbatim.
Cache elevation results. Terrain does not change. If you re-run the screening pipeline next month against an updated candidate list that includes many of the same sites, you will pay for the same elevation lookups twice unless you cache. A simple key-value store keyed on round(lat, 4), round(lng, 4) — rounding to four decimal places gives you ~11 m horizontal resolution, which is well within the DEM's 30 m grid — will eliminate redundant lookups across pipeline runs. The broader caching pattern is described in Caching Geocoding Results — 90% Cost Reduction.
US-only aerial imagery. If your workflow eventually includes aerial imagery of candidate sites — useful for checking rooftop access, existing antenna presence, and structural clues before the first site visit — the aerial-image endpoint covers the contiguous United States plus Alaska, Hawaii, and Puerto Rico only. International candidates will not have aerial imagery available. Elevation data is global; aerial is US-only. Build the fallback path before you need it.
What this does not tell you
To be direct about the boundaries of the screening signal.
This workflow computes ground elevation from our DEM. It does not compute:
- Effective radiated power, path loss, or link budget. Those calculations require antenna gain, transmit power, receiver sensitivity, and a propagation model. The elevation cross-section is terrain context, not an RF analysis.
- Fresnel zone clearance. Whether a terrain feature at a given height actually obstructs a link depends on the frequency, the distance from the link endpoints, and the tower heights at each end. The
has_obvious_obstructionfunction in this post checks whether ground is above the lower endpoint elevation — that is a rough conservative flag, not a Fresnel calculation. - Viewshed or coverage radius. No endpoint in this workflow estimates how far a signal would reach from a given site or what area it would cover. That is the RF engineering team's job, with proper tools.
- Building height or rooftop elevation. Our DEM returns ground elevation. A six-storey building and a single-storey house at the same coordinate return the same value. If you need rooftop height, you need a different dataset.
Use the terrain screening signal for what it is: a fast, cheap way to sort candidates by terrain quality so that your field and RF resources are focused on the better half of the list.
Cost arithmetic for a typical screening run
A 200-candidate list, fully enriched with point elevation, 8-point ring context, and 50-point cross-sections for the 20 best candidate pairs:
| Step | Credits | |---|---| | Geocoding (200 × 1) | 200 | | Candidate elevation (200 × 1) | 200 | | Ring samples (200 × 8) | 1,600 | | Cross-sections (20 pairs × 50 points) | 1,000 | | Total | 3,000 |
3,000 credits is exactly the free tier's daily allowance. A team piloting this workflow on a representative candidate list can run the entire screening pipeline at zero cost before committing to a paid plan. The paid tier starts at $54/month for 100,000 calls — at that rate, a 200-candidate screening run costs just over $1.60. See csv2geo.com/pricing/api for current brackets.
Frequently Asked Questions
Can I use this workflow outside the United States?
Yes. The elevation endpoint is global. Denver returns 1,597 m, Tokyo 40 m, Sydney 64 m, São Paulo 761 m — coverage is consistent across continents. The aerial imagery endpoint, if you add that to the workflow later, is US-only. Everything in this post that uses the elevation endpoint works globally.
How accurate is the elevation data for terrain screening?
Our DEM has approximately 30 m horizontal resolution and vertical accuracy of roughly ±5 m in most terrain. For screening purposes — identifying candidates that are clearly elevated versus clearly in a bowl — this is more than adequate. For precise engineering calculations you would use a survey-grade dataset. The screening filter does not require sub-metre accuracy; it requires enough resolution to tell a hilltop from a valley bottom, and 30 m resolution is comfortably sufficient for that.
Does the elevation endpoint return building heights?
No. It returns ground elevation from a terrain model. A tall building and a single-storey structure at the same coordinate will return the same value. If you need to know the elevation of a rooftop as opposed to the ground beneath it, you need a separate dataset or a structural survey.
What does a `null` elevation response mean?
It means our DEM has no data for that point — typically an ocean cell, some extreme polar terrain, or an occasional data gap. It is distinct from a real 0 m elevation (which is encoded as the integer 0). Always check is None, not falsiness, in your processing code.
How many points can I send in a single elevation call?
Up to 500 points per call, pipe-separated as lat,lng|lat,lng|.... The response preserves the input order, so you can zip the result straight back to your input list. For the ring-sampling step above, eight points per candidate fit comfortably within a single call when batched together across multiple candidates.
Is this a replacement for a proper RF propagation tool?
No. Terrain elevation is a first-pass screening signal. It tells you something about the physical position of each candidate relative to the surrounding terrain and relative to paired sites. It does not model radio propagation, calculate path loss, estimate coverage area, or certify line-of-sight. Those outputs belong to your RF engineering team's toolchain. The value of this workflow is reducing the candidate list before that team engages, not replacing them.
Should I store the elevation results between pipeline runs?
Yes, always. Terrain does not change on the timescales relevant to a site acquisition cycle. Cache results keyed on rounded coordinates and reuse them in subsequent runs. The cost saving is material if you re-run the pipeline monthly as your candidate list evolves.
Related Articles
- Adding Elevation to Property Data — One API Call per Address — the foundational elevation workflow this post extends into terrain screening
- Per-Policy Roof and Terrain Snapshots Without Satellite Licenses — pairing elevation with aerial imagery for site inspection use cases
- Benchmarking Geocoding APIs — Honest Numbers — how to evaluate geocoding accuracy for candidate address lists
- Caching Geocoding Results — 90% Cost Reduction — cache terrain elevation the same way you cache geocoding results
- Exponential Backoff — When to Retry, When to Stop — the retry pattern for bulk elevation pipelines under rate pressure
---
*I.A. / CSV2GEO Creator*
Use our batch geocoding tool to convert thousands of addresses to coordinates in minutes. Start with 100 free addresses.
Try Batch Geocoding Free →