Solar feasibility from an address: terrain as a first-pass signal
Use per-address elevation data as a cheap first-pass solar feasibility signal before dispatching a surveyor. REST examples, cost math, honest limits.
Solar installers spend money the moment a surveyor gets in a van. Fuel, labour, equipment — a site visit to a property that turns out to be shaded for six hours a day, sitting on a north-facing slope, or sitting at an altitude where panel efficiency curves drop off, is money you cannot recover. The industry standard response is to pre-qualify leads with satellite-derived irradiance tools, but those tools cost per query, require a separate contract, and are often overkill for the first filter you actually need.
The first filter is simpler: is this site's terrain worth a closer look? Elevation is a coarse but useful proxy. It tells you whether the address sits in a valley bowl that traps shade, at altitude where temperature-corrected panel yield diverges from sea-level assumptions, or in a coastal strip where corrosion spec changes. None of those questions require a full irradiance model. They require a single number — metres above sea level — that you can pull for 500 addresses in one API call.
This post shows how to build that filter. We will be direct about what raw elevation can and cannot tell you about a solar site, wire up the REST calls in Python and Node, and give you an honest cost model. No solar-specific endpoints, no irradiance data, no invented product features — just the Elevation API used honestly inside a prospecting workflow.
What terrain elevation actually tells a solar team
Be clear on this before you ship it to a salesperson: elevation is a terrain signal, not a roof-pitch measurement and not a shade map. A digital elevation model samples the ground surface at a grid spacing — in our DEM, roughly 30 m. It does not know your roof exists. It does not know what angle the roof faces. It does not know whether the oak tree three metres to the south shades the ridge in the afternoon. All of that requires either a field survey or aerial imagery combined with a shadow-simulation model.
What raw elevation *does* tell you, cheaply and reliably:
Altitude-driven yield correction. Photovoltaic panels run more efficiently at lower temperatures, and ambient temperature drops roughly 6.5°C per 1,000 m of altitude. A site in Denver at 1,597 m will see cooler average panel temperatures than a site in Miami at 1 m — that is a genuine yield advantage per rated watt, and it compounds over the panel's 25-year life. At the other extreme, a site at 4,000 m in a high-altitude cold desert carries a different balance-of-system spec for inverters. Elevation is not the full yield model, but it is a real input.
Valley versus ridge terrain class. A property sitting 80 m below the surrounding ridge in a narrow valley will be shaded in the morning and evening even if the roof itself is ideally pitched. A property on a plateau will not. You can infer this class cheaply by comparing the address elevation to the average elevation of a ring of sample points at a radius of 500–1,000 m. If the address is the local low point, flag it for careful survey — the irradiance tool will likely confirm what the terrain already implied.
Coastal and flood-zone proximity. Properties within a few metres of sea level may be in a corrosive salt-air zone, a flood-risk zone, or both. Neither of those kills a solar project, but both change the spec: you need marine-grade racking hardware in a salt-air environment, and a flood-zone property adds permitting complexity. Elevation is the first-pass proxy for both.
Altitude extremes. Sites above roughly 1,500 m — Denver sits at 1,597 m, Mauna Kea's summit is at 4,198 m — may require inverter spec checks. At the other extreme, sites below sea level (Death Valley's Badwater Basin sits at −80 m) are geographically unusual but not impossible to encounter; the Dead Sea shore sits at −415 m. These numbers are edge cases for most solar prospecting pipelines, but the point stands: elevation is a continuous variable with physically meaningful implications at both ends.
What elevation does not tell you: roof pitch, roof azimuth, shading from obstructions, module temperature under actual weather, snow load at altitude, or whether the electrical panel has headroom for a grid-tied inverter. All of those need either aerial imagery, a shade analysis tool, or a surveyor. Elevation gets you to the door; the surveyor walks through it.
The API shape
The elevation endpoint takes one to 500 lat/lng points per request and returns a height in metres for each, in order. The global DEM behind it covers every continent — you can call it for a rooftop in Sydney (terrain around 64 m), a flat in Paris (terrain around 46 m), or a site in São Paulo (around 761 m) using the same endpoint and the same API key that handles your geocoding.
A single address, curl:
curl -G "https://csv2geo.com/api/v1/elevation" \
--data-urlencode "points=39.7392,-104.9903" \
--data-urlencode "api_key=$CSV2GEO_API_KEY"Response:
{
"meta": { "count": 1 },
"results": [
{ "lat": 39.7392, "lng": -104.9903, "elevation_m": 1597 }
]
}That is Denver. Miami at lat 25.7617, lng −80.1918 returns "elevation_m": 1. The delta between the two is not noise — it is a physically real difference in altitude that propagates through every part of the solar yield calculation downstream.
The |-separated batch form (up to 500 points) is what production pipelines use:
curl -G "https://csv2geo.com/api/v1/elevation" \
--data-urlencode "points=39.7392,-104.9903|25.7617,-80.1918|21.0969,-157.8128" \
--data-urlencode "api_key=$CSV2GEO_API_KEY"returns three results in the same order. The ordering guarantee is what lets you zip() the response back onto your input list without keying by coordinate.
Two response details that matter in production:
null is a real answer for points at sea or on missing DEM tiles — distinct from 0, which is a real ground elevation at sea level. Coastal addresses genuinely return 0. Use an explicit null check, not a falsy check.
Negative numbers are correct for below-sea-level sites. If your data pipeline clamps elevation to zero on import, you will silently misclassify some of your most flood-risk-correlated sites. Store the signed value.
Step-by-step: building the first-pass filter
Step 1: Geocode your lead list
Before you can call the elevation endpoint you need a lat/lng per address. If your CRM already stores coordinates, skip this. If it does not, one geocoding pass converts the raw address list. The geocoding endpoint returns a confidence score alongside the coordinates — addresses with confidence below 0.7 are worth a manual check before you commit a surveyor visit.
import requests, os, csv
API = "https://csv2geo.com/api/v1"
KEY = os.environ["CSV2GEO_API_KEY"]
def geocode(address):
r = requests.get(
f"{API}/geocode",
params={"q": address, "api_key": KEY},
timeout=15,
)
r.raise_for_status()
hits = r.json().get("results", [])
if not hits:
return None, None, 0.0
h = hits[0]
return h["lat"], h["lng"], h.get("confidence", 0.0)Run this for every lead in your CSV; write lat, lng, and confidence back into the row. Any row where confidence is below your threshold goes into a review queue rather than the elevation batch. There is no point spending a call on a coordinate you do not trust.
Step 2: Batch the elevation calls
Group your geocoded rows into batches of 500 and call the elevation endpoint once per batch. 500 rows per call is the documented maximum; it means a list of 10,000 leads costs exactly 20 elevation API calls.
def chunks(seq, n):
for i in range(0, len(seq), n):
yield seq[i:i + n]
def fetch_elevations(batch):
# batch: list of dicts with 'lat', 'lng'
pts = "|".join(f"{row['lat']},{row['lng']}" for row in batch)
r = requests.get(
f"{API}/elevation",
params={"points": pts, "api_key": KEY},
timeout=30,
)
r.raise_for_status()
return r.json()["results"] # list, same order as input
with open("leads.csv") as fin, open("leads_with_elevation.csv", "w", newline="") as fout:
reader = csv.DictReader(fin)
fields = reader.fieldnames + ["elevation_m", "terrain_class"]
writer = csv.DictWriter(fout, fieldnames=fields)
writer.writeheader()
rows = [r for r in reader if r.get("lat") and r.get("lng")]
for batch in chunks(rows, 500):
results = fetch_elevations(batch)
for row, res in zip(batch, results):
ele = res.get("elevation_m")
row["elevation_m"] = ele
row["terrain_class"] = terrain_class(ele)
writer.writerow(row)The same pattern in Node, for teams running a serverless enrichment function:
const API = 'https://csv2geo.com/api/v1';
const KEY = process.env.CSV2GEO_API_KEY;
async function fetchElevations(batch) {
const pts = batch.map(r => `${r.lat},${r.lng}`).join('|');
const url = `${API}/elevation?points=${encodeURIComponent(pts)}&api_key=${KEY}`;
const res = await fetch(url, { signal: AbortSignal.timeout(30_000) });
if (!res.ok) throw new Error(`elevation http ${res.status}`);
const body = await res.json();
return body.results; // ordered list
}No SDK, no version pinning, no dependency other than node-fetch or native fetch (Node 18+). SDKs exist for Python and Node if your team prefers them — but the REST surface is simple enough that most production pipelines wrap it directly, as shown here.
Step 3: Classify each site by terrain
The classification rule is yours to define based on your market and risk appetite. A starting point that most solar prospecting teams will recognise:
def terrain_class(ele_m):
if ele_m is None:
return "unknown"
if ele_m < 5:
return "coastal_low" # salt air, potential flood zone, check corrosion spec
if ele_m < 50:
return "coastal_or_lowland"
if ele_m > 2000:
return "high_altitude" # inverter spec check, temperature correction
if ele_m > 1200:
return "elevated" # mild temperature benefit, check snow load
return "standard"coastal_low flags sites below 5 m for a corrosion-spec check and a quick flood-zone lookup. high_altitude flags sites above 2,000 m for an inverter review. Everything else falls into standard, meaning the terrain alone is not a disqualifier — the surveyor decides on roof-specific grounds.
Crucially, terrain_class is not a go/no-go decision. It is a routing decision: which sites get dispatched to a surveyor in the next van run versus which sites get a phone pre-qualification first. A coastal_low site might be perfectly viable — it just needs a different conversation.
Step 4: Compute the valley-versus-ridge delta (optional but useful)
If you want a richer terrain signal than raw elevation alone, compute a local relief delta: the address elevation minus the median elevation of a ring of sample points around it. A large negative delta (the address is well below its surroundings) is a shade-risk flag even when the absolute elevation looks benign.
import math
def ring_sample_points(lat, lng, radius_m=600, n=8):
"""Return n evenly-spaced lat/lng points at radius_m around the origin."""
points = []
lat_deg_per_m = 1 / 111_320
for i in range(n):
angle = 2 * math.pi * i / n
dlat = math.cos(angle) * radius_m * lat_deg_per_m
dlng = math.sin(angle) * radius_m * lat_deg_per_m / math.cos(math.radians(lat))
points.append((lat + dlat, lng + dlng))
return points
def local_relief(address_ele, ring_eles):
valid = [e for e in ring_eles if e is not None]
if not valid:
return None
return address_ele - (sum(valid) / len(valid))Batch the ring sample points alongside your main addresses in the same elevation call — they are just more rows in the points parameter. The extra 8 points per address costs 8 credits, but those 8 credits buy you a terrain-context signal that a flat elevation number cannot provide.
A local_relief value below −30 m says "this address sits in a notable topographic depression relative to its surroundings." Flag it. A value above +30 m says "this site is on a local high point" — often a positive signal for solar, since ridges and plateaus catch morning and evening sun that valley floors lose.
Step 5: Route leads by terrain class and queue for dispatch
Once every lead in the CSV has an elevation_m, a terrain_class, and optionally a local_relief_m, you have a three-tier routing table:
| Terrain class | Local relief | Routing action | |---|---|---| | standard | ≥ −10 m | Queue for next standard dispatch run | | standard | < −10 m | Phone pre-qualify shade risk before dispatch | | elevated or high_altitude | any | Route to senior surveyor with altitude spec checklist | | coastal_low | any | Phone pre-qualify corrosion spec; check flood zone | | unknown | N/A | Manual address verification before any action |
This table is a starting point. The right thresholds come from your own historical data: look at your last 500 site surveys, label each one by what the surveyor found, and fit the elevation features to that outcome. Elevation is a feature in a model, not a model by itself.
The dispatch queueing pattern itself — prioritised job lists, stop-count estimates, batch processing — is covered in more depth in the Dispatch Console post if you are building the routing layer from scratch.
Handling the edges
What if elevation returns `null`? Points that fall on DEM tiles without coverage or in the middle of water bodies return null. In a solar prospecting context, a null elevation almost always means a bad geocode rather than a legitimately unmapped site. Treat null the same as a low-confidence geocode: send it to manual verification before doing anything else.
What about addresses outside the contiguous US? The elevation endpoint is global. A solar prospecting pipeline operating in Australia, Germany, or Chile will get valid elevation numbers for those addresses — our DEM covers all populated continents. Note, however, that aerial imagery (if you use it in a later enrichment step) is US-only; keep those two capabilities cleanly separated in your code. See Per-Policy Roof and Terrain Snapshots for the imagery side.
What about rate limits during a bulk run? A list of 50,000 leads at 500 points per call is 100 elevation calls. Even on the free tier (3,000 calls per day), that fits in a single morning run. On any paid plan it is trivially within budget. If you are running geocoding and elevation in the same job, pace the geocoding calls — they are one call per address rather than one per 500. Use exponential backoff on any 429 response; the pattern is described in Exponential Backoff — When to Retry, When to Stop.
Should I cache elevation results? Yes, aggressively. Elevation does not change on human timescales. A lat/lng that you enriched today will return the same elevation next year. Cache the result in your database alongside the lat/lng and never call the elevation endpoint twice for the same coordinate. The Caching Geocoding Results post covers the caching pattern in full — the logic applies unchanged to elevation responses.
What comes after the terrain filter
Terrain elevation is the cheapest first-pass filter in a multi-stage solar prospecting pipeline. Here is where it sits in a realistic sequence:
- Address list enters CRM (bulk import, web form, partner feed)
- Geocode → get lat/lng + confidence score
- Elevation batch → terrain class + local relief delta (this post)
- Route by terrain class → dispatch queue or pre-qualification call
- Aerial imagery (US only) → roof visibility, rough segment count, obstructions
- Shade analysis tool → hourly irradiance model using roof geometry
- Surveyor visit → roof condition, electrical panel capacity, structural load
- Design and proposal
Steps 3 and 5 are the two places where a data API can do meaningful work before a human gets involved. Elevation (step 3) costs fractions of a cent per address and takes minutes for a full lead book. Aerial imagery (step 5) costs one credit per address and is US-only. Both are documented at csv2geo.com/api.
The key design principle: each step should disqualify or route the addresses that would waste the next step. Elevation filters out the sites where the surveyor's first question — "is this valley shade going to kill the project?" — can be answered without driving there. The surveyor's time then goes to sites where the terrain is viable and the remaining questions are roof-specific.
Cost model for a real prospecting pipeline
A realistic solar prospecting pipeline: 5,000 new leads per month, each needing geocoding, elevation, and classification.
| Step | Calls | Credits used | |---|---|---| | Geocoding (1 per address) | 5,000 | 5,000 | | Elevation (500 per call) | 10 | 5,000 | | Total per month | 5,010 | 10,000 |
At the entry paid tier ($54/month for 100,000 calls), 5,000 leads per month is well within the included call budget — you are using 10% of the monthly allowance. The marginal cost per lead for both steps combined is under $0.006.
The free tier (3,000 calls per day, no credit card) handles initial development and pilot runs: 3,000 geocodes and 6 elevation batch calls per day is enough to enrich a medium-sized test list inside a single afternoon.
See current pricing at csv2geo.com/pricing/api; the published brackets are the prices, no quote process.
Frequently Asked Questions
What is the vertical accuracy of the elevation data? Our DEM has roughly 30 m horizontal grid spacing and vertical accuracy typically within ±5 m for most populated areas. For solar prospecting purposes — where you are distinguishing "coastal at 2 m" from "elevated at 600 m" — that precision is more than sufficient. Sub-metre accuracy requires a survey-grade lidar dataset, which is a different product and a different price tier; it is not needed for the first-pass filtering described here.
Does the elevation endpoint tell me the roof pitch or slope angle? No. The DEM samples the ground surface, not the roof surface. A 60° pitched roof and a flat roof at the same coordinate return the same elevation. Roof pitch and azimuth require aerial imagery combined with a 3D surface model, or a field survey. Be clear about this when presenting the terrain signal internally — calling it a "roof angle estimate" would be misleading.
Can I use elevation to estimate shading from neighbouring buildings? Not directly. The DEM does not capture individual structures; it samples the terrain grid. A five-storey building and an empty lot at the same grid point both return the terrain elevation. Shading analysis from neighbouring structures requires either a lidar point cloud that captures building heights or a shade simulation tool that uses detailed 3D building data. Elevation tells you about the landform, not the built environment on top of it.
Is coverage genuinely global for elevation? Yes. The elevation endpoint works for any lat/lng on Earth — we have verified results for extreme points: Mt Everest returns 8,731 m, Death Valley's Badwater Basin returns −80 m, the Dead Sea shore returns −415 m, Mauna Kea's summit returns 4,198 m. For solar prospecting in any of the 39 countries covered by the address database, or anywhere else you have coordinates, the elevation endpoint will return a meaningful number.
Should I store the elevation in my database or call the API on demand? Store it. Elevation does not change. A lat/lng pair enriched today will return the same elevation in three years. Calling the API on demand for the same coordinate wastes credits and adds latency to your pipeline for no benefit. Add an elevation_m column to your property or lead schema, populate it once on import, and never call the endpoint again for the same coordinate unless the lat/lng itself changes.
How does this interact with aerial imagery for US sites? They are complementary and independent. Elevation gives you terrain context globally. Aerial imagery (US-only, from our public-domain federal aerial program) gives you a top-down view of the roof and lot. In the solar pipeline, use elevation first as a cheap global filter; then use aerial imagery for US sites that pass the terrain filter, to get a visual on roof geometry and obstructions before committing to a surveyor visit. The two endpoints share the same API key and billing bucket.
What happens if I send a coordinate in the ocean? The endpoint returns "elevation_m": null, which is distinct from 0 — a genuine sea-level elevation. In a solar pipeline you should never be sending ocean coordinates, so a null response almost always means the upstream geocoding produced a bad result. Route those rows to manual address verification rather than passing them to the surveyor queue.
Related Articles
- Adding Elevation to Property Data — One API Call per Address — the general-purpose elevation enrichment pattern, with deeper coverage of the batch API and cost model
- Per-Policy Roof and Terrain Snapshots Without Satellite Licenses — pairing elevation with aerial imagery for US sites, directly applicable to the solar pipeline's post-terrain-filter step
- Benchmarking Geocoding APIs — Honest Numbers — how to evaluate the accuracy of the underlying geocoding that feeds your elevation calls
- Caching Geocoding Results — 90% Cost Reduction — the caching pattern that applies directly to elevation responses; store once, never re-fetch
- Reverse-Geocoding Accuracy and the Distance Meters — understanding confidence scores and coordinate accuracy, which underpins how much you should trust the elevation for a given address
---
*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 →