Cross-Country Mobility Surface (Layered Cost Grid)
Status & scope
- Module:
lens/traversal/surface.py - Milestone: Phase 2
Purpose
A generic raster-based mobility surface where each cell has a composite traversal cost from stacked layers — roads, rivers, slope, vegetation, threat, satellite coverage, flooding. Routes via A* on the 8-connected grid. Same pattern as ESRI cross-country mobility analysis.
Design: Why Raster, Not Edge Annotation
Edge annotation on 267K OSM nodes is slow (Python loops) and fragile (coverage lookup misses). A raster grid has: - Predictable structure (8-connected, numpy-friendly) - Bulk operations (vectorized cost computation) - Always-admissible A* heuristic (grid distance) - Natural cost surface visualization (render as image/heatmap) - Composable layers (stack = element-wise weighted sum)
The 8 Levels
Each level adds a layer to the cost surface. All share one grid, one router, one evidence model.
| Level | Layer | Cost Source | Primitive | Grid Effect |
|---|---|---|---|---|
| 0 | Base grid | Flat terrain | passthrough | Uniform cost |
| 1 | Roads | OSM road network | step_function | Road cells = low cost, off-road = high |
| 2 | Road speed | OSM speed tags | inverse_linear | Faster road = lower cost |
| 3 | Vehicle constraints | Weight/height/grade limits | threshold_gate | Blocked cells = inf |
| 4 | Terrain | DEM slope + land cover | linear_scale + step_function | Steep/dense = high cost |
| 5 | Hydrology | Rivers, lakes, flood extent | threshold_gate + decay | Water = inf, flood zone = ramp |
| 6 | Threat | Risk overlay (enemy, IED, jamming) | linear_scale | High threat = high cost |
| 7 | Satellite exposure | Coverage surface from TLEs | linear_scale | High visibility = high cost |
Public API
MobilitySurface(bbox, resolution_m) → MobilitySurface
- Creates an empty grid over the bounding box.
resolution_m: cell size in meters (default 500m for tactical, 5000m for strategic).- Grid stored as numpy arrays for fast bulk operations.
surface.add_layer(name, weight, data, cost_fn) → None
- Adds a cost layer to the surface.
data: 2D numpy array (same shape as grid) or callable(lat, lon) → float.cost_fn: one of the 10 primitives or a custom(value, params) → float.weight: 0.0–1.0, all layers sum to 1.0.
surface.add_roads(osm_path, speed_field) → None
- Rasterize OSM roads onto the grid.
- Road cells get low base cost. Off-road cells get high base cost.
- Optional speed data sets per-cell travel rate.
surface.add_dem(dem_array, origin_lat, origin_lon, cell_size_m) → None
- Add slope cost from DEM. Uses Tobler's hiking function.
- Resamples DEM to grid resolution if needed.
surface.add_hydrology(water_mask) → None
- Water cells = impassable (cost inf). Flood zone = decay from water edge.
surface.add_threat(threat_grid, weight) → None
- Overlay threat surface. Values 0.0–1.0 → scaled cost.
surface.add_satellite_coverage(coverage_surface, weight) → None
- Overlay satellite coverage. High coverage = high cost (avoidance mode).
surface.compute() → CostGrid
- Computes weighted composite cost per cell.
- Returns
CostGrid(2D numpy array + metadata).
surface.route(origin_lat, origin_lon, dest_lat, dest_lon) → Route
- A* on the 8-connected cost grid.
- Haversine heuristic (always admissible on lat/lon grid).
- Returns
Routewith per-segment cost breakdown.
surface.to_geotiff(path) → None
- Export cost surface as GeoTIFF (for GIS tools / Beacon heatmap).
surface.to_png(path) → None
- Export as colored PNG (quick visualization).
Dataclasses
CostGrid (frozen=True)
| Field | Type | Default | Notes |
|---|---|---|---|
| grid | np.ndarray | — | 2D float array, each cell = composite cost |
| rows | int | — | Grid rows |
| cols | int | — | Grid cols |
| bbox | tuple[float,float,float,float] | — | (min_lat, min_lon, max_lat, max_lon) |
| resolution_m | float | — | Cell size in meters |
| layer_names | tuple[str, ...] | () | Which layers contributed |
| layer_weights | tuple[float, ...] | () | Weight per layer |
MobilityCell (for per-cell detail when needed)
| Field | Type | Default | Notes |
|---|---|---|---|
| row | int | — | Grid row |
| col | int | — | Grid col |
| lat | float | — | Cell center latitude |
| lon | float | — | Cell center longitude |
| cost | float | — | Composite cost |
| layer_costs | dict | {} | Per-layer cost breakdown |
| passable | bool | True | False if any layer = inf |
Algorithm
Cost Surface Computation
For each cell (r, c):
composite = 0
for layer in layers:
raw = layer.data[r, c]
cost = layer.cost_fn(raw, layer.params)
if cost == inf:
composite = inf
break
composite += layer.weight * cost
grid[r, c] = composite
A* on Grid
Standard A* with:
- Neighbors: 8-connected (cardinal + diagonal)
- Edge cost: avg(grid[current], grid[neighbor]) × distance
- Heuristic: haversine(current, goal) / max_speed
(admissible because actual cost ≥ straight-line distance / max_speed)
- Diagonal distance: cell_size × sqrt(2)
Road Rasterization
OSM roads → grid cells:
1. For each road segment, Bresenham line rasterization onto grid
2. Road cells get base cost from speed: cell_size_m / (speed_kmh / 3.6)
3. Off-road cells get high base cost: cell_size_m / (walking_speed / 3.6)
4. Highway type sets speed: motorway=110, trunk=90, primary=70, secondary=50, tertiary=40
5. Bridge cells get concealment flag
6. Tunnel cells get full_concealment flag
Test Fixtures
| Test | Input | Expected | File |
|---|---|---|---|
| L0: Empty grid routes | 20×20 flat grid | Route exists, cost = distance | tests/test_surface.py |
| L1: Road preference | Grid with road strip | Route follows road | tests/test_surface.py |
| L2: Speed preference | Two roads, diff speed | Route takes faster road | tests/test_surface.py |
| L3: Vehicle blocked | Bridge weight < vehicle | Route avoids bridge | tests/test_surface.py |
| L4: Slope avoidance | Ridge across path | Route goes around ridge | tests/test_surface.py |
| L5: River blocking | River across path | Route finds bridge/ford | tests/test_surface.py |
| L6: Threat avoidance | Threat zone on direct path | Route deviates around threat | tests/test_surface.py |
| L7: Satellite avoidance | High coverage on direct path | Route prefers low coverage | tests/test_surface.py |
| Composite: All layers | All 8 layers stacked | Route balances all costs | tests/test_surface.py |
| Export: GeoTIFF | Computed surface | Valid GeoTIFF file | tests/test_surface.py |
| Export: PNG | Computed surface | Valid PNG file | tests/test_surface.py |
| Evidence: Block created | Route computed | Frozen evidence with hashes | tests/test_surface.py |
File Layout
lens/traversal/
surface.py ← MobilitySurface, CostGrid, A* on grid
Integration Points
- Cortex:
execute_monitor_blockwithtype: cost_surfacerenders as heatmap - Athena: LinearScale, StepFunction, ThresholdGate ops compute per-cell costs
- Beacon: CostGrid →
heat_mapMapLayerDef, Route →linestringlayer - ESRI:
to_geotiff()exports for ArcGIS/QGIS interop
DO NOT
- Build a separate router — reuse A* pattern from
router.py, adapted for grid - Store the full grid in evidence blocks — store hash + metadata, not the raster
- Use floating-point grid indices — integer row/col only
- Forget diagonal cost — diagonal neighbors are sqrt(2) × cell_size, not 1×
- Over-resolve — 500m cells for tactical (600km = 1200×400 = 480K cells), 5km for strategic
Depends on: component.prism.cost-primitives, component.prism.universal-lens-parser
Required by: component.prism.scenario-runner