Terrain Modeling

This example demonstrates the pytcl.terrain module capabilities, including digital elevation model (DEM) creation, synthetic terrain generation, and terrain analysis.

Overview

Terrain modeling is essential for:

  • Navigation: Terrain-aided navigation and TERCOM

  • Simulation: Realistic environment modeling

  • Line-of-sight: Radio propagation and visibility

  • Mission planning: Route optimization

Digital Elevation Models

Flat DEM
  • Constant elevation surface

  • Useful for testing and baseline comparisons

  • Created with create_flat_dem()

Synthetic Terrain
  • Procedurally generated terrain

  • Controllable parameters (amplitude, wavelength)

  • Useful for simulation and testing

  • Created with create_synthetic_terrain()

DEM Properties

Grid Structure
  • Regular lat/lon grid

  • Specified resolution in arcseconds

  • Elevation values at each grid point

Coordinate System
  • Geographic coordinates (lat, lon)

  • Elevation in meters above reference

Analysis Outputs
  • Min, max, mean elevation

  • Standard deviation

  • Slope and aspect maps

Terrain Analysis

Elevation Statistics
  • Distribution of elevation values

  • Terrain roughness metrics

  • Histogram analysis

Slope Computation
  • Gradient magnitude at each point

  • Degrees from horizontal

  • Important for mobility analysis

Horizon Computation
  • Visible horizon from observer position

  • Accounts for terrain obstruction

  • Essential for line-of-sight analysis

Applications

Terrain-Aided Navigation
  • Match measured terrain to DEM

  • Position fix without GPS

  • Submarine and aircraft navigation

Viewshed Analysis
  • Determine visible area from point

  • Radar coverage planning

  • Communication link analysis

Route Planning
  • Avoid steep terrain

  • Minimize exposure

  • Optimize fuel consumption

Terrain-Aided Navigation: Vehicle trajectories can be matched against terrain models for position updates without GPS.

Code Highlights

The example demonstrates:

  • Flat DEM creation with create_flat_dem()

  • Synthetic terrain with create_synthetic_terrain()

  • Terrain statistics (min, max, mean, std)

  • Slope computation using gradients

  • Horizon computation with compute_horizon()

Source Code

  1"""Terrain module demonstration with DEM and elevation models.
  2
  3This example demonstrates the pytcl.terrain module capabilities, including
  4digital elevation model (DEM) creation, synthetic terrain generation, terrain
  5analysis, and viewshed/line-of-sight computations.
  6
  7Functions demonstrated:
  8- create_flat_dem(): Create flat digital elevation models
  9- create_synthetic_terrain(): Generate synthetic terrain with hills/valleys
 10- compute_horizon(): Calculate visible horizon from observer position
 11"""
 12
 13import os
 14from pathlib import Path
 15
 16import numpy as np
 17import plotly.graph_objects as go
 18from plotly.subplots import make_subplots
 19
 20from pytcl.terrain import compute_horizon, create_flat_dem, create_synthetic_terrain
 21
 22# Controls for visualization
 23SHOW_PLOTS = os.environ.get("PYTCL_SHOW_PLOTS", "1") != "0"
 24SKIP_VISUALIZATIONS = True  # Skip visualizations for fast execution
 25
 26
 27def demo_flat_dem() -> None:
 28    """Demonstrate flat DEM creation."""
 29    print("\n" + "=" * 60)
 30    print("Flat Digital Elevation Model (DEM)")
 31    print("=" * 60)
 32
 33    # Create a flat DEM
 34    dem = create_flat_dem(
 35        lat_min=np.radians(-1),
 36        lat_max=np.radians(1),
 37        lon_min=np.radians(-1),
 38        lon_max=np.radians(1),
 39        elevation=1000.0,
 40        resolution_arcsec=60,
 41    )
 42
 43    print(f"\nFlat DEM created:")
 44    print(
 45        f"  Latitude range: [{np.degrees(dem.lat_min):.2f}°, {np.degrees(dem.lat_max):.2f}°]"
 46    )
 47    print(
 48        f"  Longitude range: [{np.degrees(dem.lon_min):.2f}°, {np.degrees(dem.lon_max):.2f}°]"
 49    )
 50    print(f"  Shape: {dem.data.shape}")
 51    print(f"  Elevation: {dem.data.min():.1f} to {dem.data.max():.1f} m")
 52    print(
 53        f"  Grid spacing: {np.degrees(dem.d_lat):.6f}° lat, {np.degrees(dem.d_lon):.6f}° lon"
 54    )
 55
 56    # Visualization (fast heatmap with downsampling for large grids)
 57    if not SKIP_VISUALIZATIONS:
 58        # Downsample for faster visualization (keep full resolution data for computations)
 59        max_size = 500
 60        stride = max(1, max(dem.data.shape) // max_size)
 61        z_display = dem.data[::stride, ::stride]
 62
 63        fig = go.Figure(
 64            data=go.Heatmap(
 65                z=z_display,
 66                colorscale="Viridis",
 67                name="Elevation",
 68                colorbar=dict(title="Elevation (m)"),
 69            )
 70        )
 71
 72        fig.update_layout(
 73            title="Flat Digital Elevation Model",
 74            xaxis_title="Longitude index",
 75            yaxis_title="Latitude index",
 76            height=500,
 77        )
 78
 79        if SHOW_PLOTS:
 80            fig.show()
 81        else:
 82            fig.write_html(
 83                str(OUTPUT_DIR / "terrain_demo.html"),
 84                include_plotlyjs="cdn",
 85                div_id="terrain_demo",
 86            )
 87
 88
 89def demo_synthetic_terrain() -> None:
 90    """Demonstrate synthetic terrain generation."""
 91    print("\n" + "=" * 60)
 92    print("Synthetic Terrain Generation")
 93    print("=" * 60)
 94
 95    # Create synthetic terrain with hills
 96    dem = create_synthetic_terrain(
 97        lat_min=np.radians(-5),
 98        lat_max=np.radians(5),
 99        lon_min=np.radians(-5),
100        lon_max=np.radians(5),
101        base_elevation=500,
102        amplitude=800,
103        wavelength_km=50,
104        resolution_arcsec=120,
105        seed=42,
106    )
107
108    print(f"\nSynthetic DEM created:")
109    print(
110        f"  Latitude range: [{np.degrees(dem.lat_min):.2f}°, {np.degrees(dem.lat_max):.2f}°]"
111    )
112    print(
113        f"  Longitude range: [{np.degrees(dem.lon_min):.2f}°, {np.degrees(dem.lon_max):.2f}°]"
114    )
115    print(f"  Shape: {dem.data.shape}")
116    print(f"  Min elevation: {dem.data.min():.1f} m")
117    print(f"  Max elevation: {dem.data.max():.1f} m")
118    print(f"  Mean elevation: {dem.data.mean():.1f} m")
119    print(f"  Std deviation: {dem.data.std():.1f} m")
120    print(
121        f"  Grid spacing: {np.degrees(dem.d_lat):.6f}° lat, {np.degrees(dem.d_lon):.6f}° lon"
122    )
123
124    # Visualization: 2D heatmap with downsampling (fast rendering)
125    if not SKIP_VISUALIZATIONS:
126        # Downsample for faster visualization
127        max_size = 500
128        stride = max(1, max(dem.data.shape) // max_size)
129        z_display = dem.data[::stride, ::stride]
130
131        fig = go.Figure(
132            data=go.Heatmap(
133                z=z_display,
134                colorscale="Earth",
135                name="Elevation",
136                colorbar=dict(title="Elevation (m)"),
137            )
138        )
139
140        fig.update_layout(
141            title="Synthetic Terrain with Synthetic Hills",
142            xaxis_title="Longitude index",
143            yaxis_title="Latitude index",
144            height=600,
145        )
146
147        if SHOW_PLOTS:
148            fig.show()
149        else:
150            fig.write_html(
151                str(OUTPUT_DIR / "terrain_demo.html"),
152                include_plotlyjs="cdn",
153                div_id="terrain_demo",
154            )
155
156
157def demo_terrain_analysis() -> None:
158    """Demonstrate terrain analysis and statistics."""
159    print("\n" + "=" * 60)
160    print("Terrain Analysis and Statistics")
161    print("=" * 60)
162
163    # Create two DEMs for comparison
164    flat_dem = create_flat_dem(
165        lat_min=np.radians(-2),
166        lat_max=np.radians(2),
167        lon_min=np.radians(-2),
168        lon_max=np.radians(2),
169        elevation=500.0,
170        resolution_arcsec=60,
171    )
172
173    synthetic_dem = create_synthetic_terrain(
174        lat_min=np.radians(-2),
175        lat_max=np.radians(2),
176        lon_min=np.radians(-2),
177        lon_max=np.radians(2),
178        base_elevation=500,
179        amplitude=400,
180        wavelength_km=30,
181        resolution_arcsec=60,
182        seed=123,
183    )
184
185    # Compute statistics
186    print(f"\nFlat DEM statistics:")
187    print(f"  Min: {flat_dem.data.min():.1f} m")
188    print(f"  Max: {flat_dem.data.max():.1f} m")
189    print(f"  Mean: {flat_dem.data.mean():.1f} m")
190    print(f"  Std Dev: {flat_dem.data.std():.1f} m")
191
192    print(f"\nSynthetic DEM statistics:")
193    print(f"  Min: {synthetic_dem.data.min():.1f} m")
194    print(f"  Max: {synthetic_dem.data.max():.1f} m")
195    print(f"  Mean: {synthetic_dem.data.mean():.1f} m")
196    print(f"  Std Dev: {synthetic_dem.data.std():.1f} m")
197
198    # Terrain slope. np.gradient returns meters of rise per *grid cell*, so it
199    # has to be divided by the ground spacing of a cell before it means
200    # anything -- without that the slope comes out near-vertical everywhere.
201    earth_radius = 6371000.0
202    mean_lat = 0.5 * (synthetic_dem.lat_min + synthetic_dem.lat_max)
203    dy_m = synthetic_dem.d_lat * earth_radius
204    dx_m = synthetic_dem.d_lon * earth_radius * np.cos(mean_lat)
205
206    grad_y, grad_x = np.gradient(synthetic_dem.data)
207    slope = np.degrees(np.arctan(np.hypot(grad_x / dx_m, grad_y / dy_m)))
208
209    print(f"\nGrid cell ground size: {dx_m:.0f} m east-west, {dy_m:.0f} m north-south")
210
211    print(f"\nTerrain slope analysis:")
212    print(f"  Min slope: {slope.min():.2f}°")
213    print(f"  Max slope: {slope.max():.2f}°")
214    print(f"  Mean slope: {slope.mean():.2f}°")
215
216    # Visualization: Comparison histograms (skip for performance)
217    if not SKIP_VISUALIZATIONS:
218        fig = make_subplots(
219            rows=1,
220            cols=2,
221            subplot_titles=("Flat DEM Distribution", "Synthetic Terrain Distribution"),
222        )
223
224        fig.add_trace(
225            go.Histogram(
226                x=flat_dem.data.flatten(),
227                nbinsx=30,
228                name="Flat DEM",
229                marker_color="steelblue",
230            ),
231            row=1,
232            col=1,
233        )
234
235        fig.add_trace(
236            go.Histogram(
237                x=synthetic_dem.data.flatten(),
238                nbinsx=30,
239                name="Synthetic Terrain",
240                marker_color="coral",
241            ),
242            row=1,
243            col=2,
244        )
245
246        fig.update_xaxes(title_text="Elevation (m)", row=1, col=1)
247        fig.update_xaxes(title_text="Elevation (m)", row=1, col=2)
248        fig.update_yaxes(title_text="Count", row=1, col=1)
249        fig.update_layout(height=400, showlegend=True)
250
251        if SHOW_PLOTS:
252            fig.show()
253        else:
254            fig.write_html(
255                str(OUTPUT_DIR / "terrain_demo.html"),
256                include_plotlyjs="cdn",
257                div_id="terrain_demo",
258            )
259
260        # Visualization: Slope map with downsampling
261        max_size = 500
262        stride_slope = max(1, max(slope.shape) // max_size)
263        z_slope_display = slope[::stride_slope, ::stride_slope]
264
265        fig_slope = go.Figure(
266            data=go.Heatmap(
267                z=z_slope_display,
268                colorscale="Reds",
269                name="Slope",
270                colorbar=dict(title="Slope (°)"),
271            )
272        )
273
274        fig_slope.update_layout(
275            title="Terrain Slope Map",
276            xaxis_title="Longitude index",
277            yaxis_title="Latitude index",
278            height=500,
279        )
280
281        if SHOW_PLOTS:
282            fig_slope.show()
283
284
285def demo_horizon_computation() -> None:
286    """Demonstrate horizon computation."""
287    print("\n" + "=" * 60)
288    print("Horizon Computation")
289    print("=" * 60)
290
291    # A one-degree box of synthetic terrain. All angles are radians, per the
292    # library-wide convention -- passing degrees here would ask for a DEM
293    # spanning most of the globe.
294    lat_min, lat_max = np.radians(35.0), np.radians(36.0)
295    lon_min, lon_max = np.radians(-120.0), np.radians(-119.0)
296    dem = create_synthetic_terrain(
297        lat_min=lat_min,
298        lat_max=lat_max,
299        lon_min=lon_min,
300        lon_max=lon_max,
301        base_elevation=500,
302        amplitude=500,
303        wavelength_km=40,
304        resolution_arcsec=30,
305        seed=456,
306    )
307
308    print(f"\nDEM for horizon analysis:")
309    print(f"  Shape: {dem.data.shape}")
310    print(f"  Elevation range: {dem.data.min():.1f} to {dem.data.max():.1f} m")
311
312    # Observe from the middle of the box, 100 m above local ground level.
313    obs_lat = 0.5 * (lat_min + lat_max)
314    obs_lon = 0.5 * (lon_min + lon_max)
315    obs_height = 100.0
316
317    print(f"\nObserver position:")
318    print(f"  Latitude:  {np.degrees(obs_lat):.4f}°")
319    print(f"  Longitude: {np.degrees(obs_lon):.4f}°")
320    print(f"  Ground elevation: {dem.get_elevation(obs_lat, obs_lon).elevation:.1f} m")
321    print(f"  Observer height above ground: {obs_height} m")
322
323    horizon = compute_horizon(
324        dem,
325        obs_lat,
326        obs_lon,
327        obs_height,
328        n_azimuths=72,
329        max_range=40000.0,
330    )
331
332    # compute_horizon returns one HorizonPoint per azimuth.
333    elevations = np.array([p.elevation_angle for p in horizon])
334    distances = np.array([p.distance for p in horizon])
335    azimuths = np.array([p.azimuth for p in horizon])
336
337    print(f"\nHorizon profile over {len(horizon)} azimuths:")
338    print(
339        f"  Elevation angle: {np.degrees(elevations.min()):+.2f}° to "
340        f"{np.degrees(elevations.max()):+.2f}°"
341    )
342    print(
343        f"  Horizon distance: {distances.min() / 1e3:.1f} to "
344        f"{distances.max() / 1e3:.1f} km"
345    )
346
347    highest = int(np.argmax(elevations))
348    print(
349        f"  Highest horizon at azimuth {np.degrees(azimuths[highest]):.1f}° "
350        f"({np.degrees(elevations[highest]):+.2f}°, "
351        f"{distances[highest] / 1e3:.1f} km away)"
352    )
353
354
355def main() -> None:
356    """Run all demonstrations."""
357    print("\n" + "=" * 60)
358    print("Terrain Module Demonstration")
359    print("=" * 60)
360
361    demo_flat_dem()
362    demo_synthetic_terrain()
363    demo_terrain_analysis()
364    demo_horizon_computation()
365
366    print("\n" + "=" * 60)
367    print("Demonstration Complete")
368    print("=" * 60)
369
370
371OUTPUT_DIR = Path("docs/_static/images/examples")
372OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
373
374if __name__ == "__main__":
375    main()

Running the Example

python examples/terrain_demo.py

See Also