Auto-download missing SRTM tiles, treat water areas as 0m elevation

SRTM profiles no longer abort on missing tiles — water/ocean areas
return 0m elevation instead of failing the entire profile and falling
back to the rate-limited API. Elevation client and viewshed now pass
download: true to auto-fetch missing tiles from S3. NFS mount changed
to writable so downloaded tiles persist across pods.
This commit is contained in:
Graham McIntire 2026-04-11 16:30:17 -05:00
parent 80d1169f71
commit e74adf0036
6 changed files with 32 additions and 56 deletions

View file

@ -105,10 +105,8 @@ spec:
volumeMounts: volumeMounts:
- name: srtm-data - name: srtm-data
mountPath: /srtm mountPath: /srtm
readOnly: true
volumes: volumes:
- name: srtm-data - name: srtm-data
nfs: nfs:
server: 204.110.191.8 server: 204.110.191.8
path: /data/srtm path: /data/srtm
readOnly: true

View file

@ -16,14 +16,8 @@ defmodule Microwaveprop.Terrain.ElevationClient do
fetch_elevation_profile_api(lat1, lon1, lat2, lon2, n) fetch_elevation_profile_api(lat1, lon1, lat2, lon2, n)
tiles_dir -> tiles_dir ->
case Srtm.fetch_elevation_profile(lat1, lon1, lat2, lon2, tiles_dir, n, opts) do srtm_opts = Keyword.merge([download: true], opts)
{:ok, _profile} = ok -> Srtm.fetch_elevation_profile(lat1, lon1, lat2, lon2, tiles_dir, n, srtm_opts)
ok
{:error, reason} ->
Logger.warning("SRTM failed (#{inspect(reason)}), falling back to API")
fetch_elevation_profile_api(lat1, lon1, lat2, lon2, n)
end
end end
end end

View file

@ -84,26 +84,19 @@ defmodule Microwaveprop.Terrain.Srtm do
pts = sample_path(lat1, lon1, lat2, lon2, n) pts = sample_path(lat1, lon1, lat2, lon2, n)
dist_km = haversine_km(lat1, lon1, lat2, lon2) dist_km = haversine_km(lat1, lon1, lat2, lon2)
results = profile =
Enum.reduce_while(pts, {:ok, []}, fn pt, {:ok, acc} -> Enum.map(pts, fn pt ->
case lookup(pt.lat, pt.lon, tiles_dir, opts) do elev =
{:ok, elev} -> case lookup(pt.lat, pt.lon, tiles_dir, opts) do
entry = %{ {:ok, elev} -> elev
lat: pt.lat, # Missing tile (water/ocean) or void value — treat as sea level
lon: pt.lon, {:error, _} -> 0
d: pt.d, end
elev: elev,
dist_km: pt.d * dist_km
}
{:cont, {:ok, acc ++ [entry]}} %{lat: pt.lat, lon: pt.lon, d: pt.d, elev: elev, dist_km: pt.d * dist_km}
{:error, reason} ->
{:halt, {:error, reason}}
end
end) end)
results {:ok, profile}
end end
defp read_elevation(fd, lat, lon) do defp read_elevation(fd, lat, lon) do

View file

@ -151,25 +151,21 @@ defmodule Microwaveprop.Terrain.Viewshed do
terrain_check_km = min(radio_horizon_km(ant_height_m) * 2.0, max_range_km) terrain_check_km = min(radio_horizon_km(ant_height_m) * 2.0, max_range_km)
{end_lat, end_lon} = destination_point(origin_lat, origin_lon, bearing, terrain_check_km) {end_lat, end_lon} = destination_point(origin_lat, origin_lon, bearing, terrain_check_km)
case Srtm.fetch_elevation_profile(origin_lat, origin_lon, end_lat, end_lon, tiles_dir) do {:ok, profile} =
{:ok, profile} -> Srtm.fetch_elevation_profile(origin_lat, origin_lon, end_lat, end_lon, tiles_dir, 64, download: true)
analysis =
TerrainAnalysis.analyse(profile, terrain_check_km, freq_ghz, ant_ht_a: ant_height_m, ant_ht_b: ant_height_m)
reach_km = effective_reach_km(analysis, max_range_km, score) analysis =
{reach_lat, reach_lon} = destination_point(origin_lat, origin_lon, bearing, reach_km) TerrainAnalysis.analyse(profile, terrain_check_km, freq_ghz, ant_ht_a: ant_height_m, ant_ht_b: ant_height_m)
%{ reach_km = effective_reach_km(analysis, max_range_km, score)
bearing: bearing, {reach_lat, reach_lon} = destination_point(origin_lat, origin_lon, bearing, reach_km)
reach_km: reach_km,
lat: reach_lat,
lon: reach_lon
}
{:error, _} -> %{
{end_lat2, end_lon2} = destination_point(origin_lat, origin_lon, bearing, max_range_km) bearing: bearing,
%{bearing: bearing, reach_km: max_range_km, lat: end_lat2, lon: end_lon2} reach_km: reach_km,
end lat: reach_lat,
lon: reach_lon
}
end end
# Radio horizon distance in km for a given antenna height (K=4/3 atmosphere) # Radio horizon distance in km for a given antenna height (K=4/3 atmosphere)

View file

@ -148,21 +148,14 @@ defmodule Microwaveprop.Terrain.ElevationClientSrtmTest do
end end
@tag :srtm @tag :srtm
test "falls back to API when SRTM tiles missing" do test "returns zero elevation when SRTM tiles missing (no API fallback)" do
Application.put_env(:microwaveprop, :srtm_tiles_dir, "/nonexistent/tiles") Application.put_env(:microwaveprop, :srtm_tiles_dir, "/nonexistent/tiles")
Req.Test.stub(ElevationClient, fn conn -> assert {:ok, profile} =
Req.Test.json(conn, %{"elevation" => [200.0, 250.0, 180.0]}) ElevationClient.fetch_elevation_profile(32.9, -97.0, 30.3, -97.7, 2)
end)
{result, _log} =
with_log(fn ->
ElevationClient.fetch_elevation_profile(32.9, -97.0, 30.3, -97.7, 2)
end)
assert {:ok, profile} = result
assert length(profile) == 3 assert length(profile) == 3
assert hd(profile).elev == 200.0 assert Enum.all?(profile, fn p -> p.elev == 0 end)
end end
end end
end end

View file

@ -190,9 +190,11 @@ defmodule Microwaveprop.Terrain.SrtmTest do
assert last.dist_km > 0 assert last.dist_km > 0
end end
test "returns error when tiles dir does not exist" do test "returns zero elevation when tiles dir does not exist" do
assert {:error, _} = assert {:ok, profile} =
Srtm.fetch_elevation_profile(32.78, -96.8, 32.5, -96.5, "/nonexistent", 4) Srtm.fetch_elevation_profile(32.78, -96.8, 32.5, -96.5, "/nonexistent", 4)
assert Enum.all?(profile, fn p -> p.elev == 0 end)
end end
end end
end end