Hexes in Motion: H3 + Tile38 + NATS + DuckDB¶
Objective: Build a geo event bus where devices publish positions to NATS JetStream, Tile38 triggers geofence enter/exit events, DuckDB keeps a compact columnar history for offline analytics, and H3 powers hex aggregation for heatmaps. Real-time geofences with hex aggregation and offline analytics.
Architecture¶
flowchart LR
D[Devices] -->|position updates| N[NATS JetStream]
N -->|pos.* subject| T[Tile38 Live Set]
T -->|geofence events| N2[NATS Events]
N2 -->|append| DB[DuckDB Parquet]
DB -->|H3 aggregation| V[Heatmap Viz]
subgraph "Geofence Engine"
T
F[Fence Rules]
end
subgraph "Analytics Engine"
DB
H3[H3 Hex Bins]
end Why: H3 provides consistent hexagonal indexing for spatial aggregation, Tile38 handles real-time geofencing with sub-millisecond latency, NATS JetStream ensures message durability, and DuckDB enables fast analytical queries on historical data.
Docker Compose with Profiles¶
# docker-compose.yml
version: "3.9"
x-hc: &hc { interval: 5s, timeout: 3s, retries: 40 }
services:
nats:
image: nats:2.10-alpine
command: ["-js", "-m", "8222"] # JetStream + monitoring
ports: ["4222:4222", "8222:8222"]
healthcheck: { test: ["CMD", "nats", "server", "check", "jetstream"], <<: *hc }
tile38:
image: tile38/tile38:1.40.0
command: ["tile38-server", "-h", "0.0.0.0", "-p", "9851"]
ports: ["9851:9851"]
healthcheck: { test: ["CMD", "tile38-cli", "-h", "localhost", "ping"], <<: *hc }
duckdb:
image: duckdb/duckdb:latest
command: ["duckdb", "-c", "CREATE TABLE IF NOT EXISTS positions (id TEXT, lat DOUBLE, lon DOUBLE, ts BIGINT, h3_index TEXT);"]
volumes:
- duckdb_data:/data
healthcheck: { test: ["CMD", "duckdb", "/data/positions.db", "-c", "SELECT 1"], <<: *hc }
# Random walker publisher (profile)
walker:
build: ./walker
profiles: ["demo"]
environment:
NATS_URL: nats://nats:4222
depends_on:
nats: { condition: service_healthy }
tile38: { condition: service_healthy }
# Analytics processor (profile)
processor:
build: ./processor
profiles: ["analytics"]
environment:
NATS_URL: nats://nats:4222
DUCKDB_PATH: /data/positions.db
volumes:
- duckdb_data:/data
depends_on:
nats: { condition: service_healthy }
duckdb: { condition: service_healthy }
# Simple MapLibre UI (profile)
viz:
build: ./viz
profiles: ["viz"]
ports: ["3000:3000"]
environment:
NATS_URL: nats://nats:4222
depends_on:
nats: { condition: service_healthy }
# NATS CLI for ops (profile)
nats-cli:
image: natsio/nats-box:latest
profiles: ["ops"]
command: ["sleep", "infinity"]
depends_on:
nats: { condition: service_healthy }
volumes:
duckdb_data:
driver: local
Operator Recipes¶
# Core geo event bus
docker compose up -d
# Add random walkers (demo data)
docker compose --profile demo up -d
# Add analytics processing
docker compose --profile analytics up -d
# Add visualization
docker compose --profile viz up -d
# Add ops tools
docker compose --profile ops up -d
Data Model & Event Shape¶
Position Update¶
{
"id": "device-123",
"lat": 40.7589,
"lon": -73.9851,
"ts": 1704067200000,
"h3_index": "8a2a1072b59ffff"
}
Geofence Event¶
{
"event_type": "enter|exit",
"fence_id": "office_zone",
"device_id": "device-123",
"position": {"lat": 40.7589, "lon": -73.9851},
"timestamp": 1704067200000
}
Python Walker (Random Position Publisher)¶
walker/Dockerfile¶
FROM python:3.11-slim
RUN pip install nats-py h3
WORKDIR /app
COPY walker.py .
CMD ["python", "walker.py"]
walker/walker.py¶
import os, time, json, random, uuid, asyncio
from nats.aio.client import Client as NATS
import h3
async def main():
nc = NATS()
await nc.connect(os.getenv("NATS_URL", "nats://localhost:4222"))
# Create JetStream context
js = nc.jetstream()
# Create stream for position updates
await js.add_stream(name="positions", subjects=["pos.*"])
# Create subject for geofence events
await js.add_stream(name="events", subjects=["events.*"])
# Random walker parameters
center_lat, center_lon = 40.7589, -73.9851
walk_radius = 0.01 # ~1km radius
while True:
# Random walk from center
lat = center_lat + random.uniform(-walk_radius, walk_radius)
lon = center_lon + random.uniform(-walk_radius, walk_radius)
# Generate H3 index at resolution 9
h3_index = h3.latlng_to_cell(lat, lon, 9)
msg = {
"id": f"walker-{random.randint(1, 10)}",
"lat": lat,
"lon": lon,
"ts": int(time.time() * 1000),
"h3_index": h3_index
}
# Publish to NATS JetStream
await js.publish("pos.walkers", json.dumps(msg).encode())
await asyncio.sleep(0.1)
if __name__ == "__main__":
asyncio.run(main())
Tile38 Geofence Configuration¶
tile38/setup.sh¶
#!/bin/bash
# Wait for Tile38 to be ready
sleep 5
# Create live set for tracking positions
tile38-cli -h tile38 SET live walkers POINT 40.7589 -73.9851
# Create geofence rules
# Office zone (circle)
tile38-cli -h tile38 SETHOOK office_zone nats://nats:4222/events.office FENCE DETECT enter,exit COMMAND nearby live FENCE POINT 40.7589 -73.9851 1000
# Home zone (polygon)
tile38-cli -h tile38 SETHOOK home_zone nats://nats:4222/events.home FENCE DETECT enter,exit COMMAND within live FENCE OBJECT '{"type":"Polygon","coordinates":[[[-73.99,40.75],[-73.98,40.75],[-73.98,40.76],[-73.99,40.76],[-73.99,40.75]]]}'
# Highway zone (line)
tile38-cli -h tile38 SETHOOK highway_zone nats://nats:4222/events.highway FENCE DETECT enter,exit COMMAND nearby live FENCE POINT 40.7589 -73.9851 500
echo "Tile38 geofences configured"
DuckDB Analytics Processor¶
processor/Dockerfile¶
FROM python:3.11-slim
RUN pip install nats-py duckdb h3
WORKDIR /app
COPY processor.py .
CMD ["python", "processor.py"]
processor/processor.py¶
import os, json, asyncio, duckdb
from nats.aio.client import Client as NATS
import h3
class AnalyticsProcessor:
def __init__(self):
self.nats_url = os.getenv("NATS_URL", "nats://localhost:4222")
self.db_path = os.getenv("DUCKDB_PATH", "/data/positions.db")
self.conn = duckdb.connect(self.db_path)
self.setup_database()
def setup_database(self):
"""Create tables and views for analytics."""
# Create positions table
self.conn.execute("""
CREATE TABLE IF NOT EXISTS positions (
id TEXT,
lat DOUBLE,
lon DOUBLE,
ts BIGINT,
h3_index TEXT,
hour_bucket BIGINT
)
""")
# Create geofence events table
self.conn.execute("""
CREATE TABLE IF NOT EXISTS geofence_events (
event_type TEXT,
fence_id TEXT,
device_id TEXT,
lat DOUBLE,
lon DOUBLE,
timestamp BIGINT,
hour_bucket BIGINT
)
""")
# Create H3 aggregation view
self.conn.execute("""
CREATE OR REPLACE VIEW h3_heatmap AS
SELECT
h3_index,
COUNT(*) as position_count,
AVG(lat) as avg_lat,
AVG(lon) as avg_lon,
MIN(ts) as first_seen,
MAX(ts) as last_seen
FROM positions
GROUP BY h3_index
""")
async def process_positions(self, msg):
"""Process position updates and store in DuckDB."""
try:
data = json.loads(msg.data.decode())
# Calculate hour bucket for partitioning
hour_bucket = (data['ts'] // 3600000) * 3600000
# Insert into DuckDB
self.conn.execute("""
INSERT INTO positions (id, lat, lon, ts, h3_index, hour_bucket)
VALUES (?, ?, ?, ?, ?, ?)
""", [
data['id'], data['lat'], data['lon'],
data['ts'], data['h3_index'], hour_bucket
])
print(f"Processed position: {data['id']} at {data['lat']}, {data['lon']}")
except Exception as e:
print(f"Error processing position: {e}")
async def process_geofence_events(self, msg):
"""Process geofence events and store in DuckDB."""
try:
data = json.loads(msg.data.decode())
# Calculate hour bucket
hour_bucket = (data['timestamp'] // 3600000) * 3600000
# Insert into DuckDB
self.conn.execute("""
INSERT INTO geofence_events (event_type, fence_id, device_id, lat, lon, timestamp, hour_bucket)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", [
data['event_type'], data['fence_id'], data['device_id'],
data['position']['lat'], data['position']['lon'],
data['timestamp'], hour_bucket
])
print(f"Geofence event: {data['event_type']} {data['fence_id']} for {data['device_id']}")
except Exception as e:
print(f"Error processing geofence event: {e}")
async def run(self):
"""Main processing loop."""
nc = NATS()
await nc.connect(self.nats_url)
# Subscribe to position updates
await nc.subscribe("pos.*", cb=self.process_positions)
# Subscribe to geofence events
await nc.subscribe("events.*", cb=self.process_geofence_events)
print("Analytics processor started")
# Keep running
while True:
await asyncio.sleep(1)
if __name__ == "__main__":
processor = AnalyticsProcessor()
asyncio.run(processor.run())
H3 Hex Aggregation Queries¶
DuckDB H3 Analytics¶
-- H3 heatmap aggregation
SELECT
h3_index,
COUNT(*) as position_count,
AVG(lat) as avg_lat,
AVG(lon) as avg_lon,
MIN(ts) as first_seen,
MAX(ts) as last_seen
FROM positions
WHERE ts >= (strftime('%s', 'now') * 1000) - 3600000 -- Last hour
GROUP BY h3_index
ORDER BY position_count DESC;
-- H3 hex density by resolution
SELECT
SUBSTR(h3_index, 1, 2) as resolution,
COUNT(DISTINCT h3_index) as hex_count,
COUNT(*) as total_positions
FROM positions
WHERE ts >= (strftime('%s', 'now') * 1000) - 86400000 -- Last day
GROUP BY resolution
ORDER BY resolution;
-- Geofence event summary
SELECT
fence_id,
event_type,
COUNT(*) as event_count,
COUNT(DISTINCT device_id) as unique_devices
FROM geofence_events
WHERE timestamp >= (strftime('%s', 'now') * 1000) - 3600000 -- Last hour
GROUP BY fence_id, event_type
ORDER BY event_count DESC;
Simple MapLibre Visualization¶
viz/Dockerfile¶
FROM node:18-alpine
WORKDIR /app
COPY package.json .
RUN npm install
COPY . .
EXPOSE 3000
CMD ["npm", "start"]
viz/package.json¶
{
"name": "h3-viz",
"version": "1.0.0",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.18.2",
"nats": "^2.16.0",
"ws": "^8.14.2"
}
}
viz/server.js¶
const express = require('express');
const { connect } = require('nats');
const WebSocket = require('ws');
const app = express();
const port = 3000;
// Serve static files
app.use(express.static('public'));
// WebSocket for real-time updates
const wss = new WebSocket.Server({ port: 8080 });
// NATS connection for real-time data
let nc;
async function initNATS() {
nc = await connect({ servers: process.env.NATS_URL || 'nats://localhost:4222' });
// Subscribe to position updates
const sub = nc.subscribe('pos.*');
for await (const msg of sub) {
const data = JSON.parse(msg.data.toString());
// Broadcast to all connected clients
wss.clients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify({
type: 'position',
data: data
}));
}
});
}
}
// Start NATS connection
initNATS().catch(console.error);
app.listen(port, () => {
console.log(`H3 visualization server running on port ${port}`);
});
viz/public/index.html¶
<!DOCTYPE html>
<html>
<head>
<title>H3 Geofence Visualization</title>
<script src="https://unpkg.com/maplibre-gl@3.6.2/dist/maplibre-gl.js"></script>
<link href="https://unpkg.com/maplibre-gl@3.6.2/dist/maplibre-gl.css" rel="stylesheet">
<style>
body { margin: 0; padding: 0; }
#map { height: 100vh; width: 100vw; }
</style>
</head>
<body>
<div id="map"></div>
<script>
const map = new maplibregl.Map({
container: 'map',
style: 'https://demotiles.maplibre.org/style.json',
center: [-73.9851, 40.7589],
zoom: 12
});
// WebSocket connection for real-time updates
const ws = new WebSocket('ws://localhost:8080');
ws.onmessage = function(event) {
const message = JSON.parse(event.data);
if (message.type === 'position') {
const { lat, lon, id, h3_index } = message.data;
// Add marker for position
new maplibregl.Marker()
.setLngLat([lon, lat])
.setPopup(new maplibregl.Popup().setHTML(`
<div>
<strong>Device:</strong> ${id}<br>
<strong>H3 Index:</strong> ${h3_index}
</div>
`))
.addTo(map);
}
};
</script>
</body>
</html>
Observability & Monitoring¶
NATS JetStream Monitoring¶
# Check stream status
nats stream info positions
# Check consumer status
nats consumer info positions walker-consumer
# Monitor message flow
nats monitor
Tile38 Monitoring¶
# Check geofence status
tile38-cli -h tile38 INFO
# List active hooks
tile38-cli -h tile38 HOOKS
# Check live set size
tile38-cli -h tile38 SIZE live
DuckDB Analytics¶
-- System performance
SELECT
COUNT(*) as total_positions,
COUNT(DISTINCT id) as unique_devices,
MIN(ts) as earliest_position,
MAX(ts) as latest_position
FROM positions;
-- H3 hex coverage
SELECT
COUNT(DISTINCT h3_index) as unique_hexes,
COUNT(*) as total_positions
FROM positions
WHERE ts >= (strftime('%s', 'now') * 1000) - 3600000;
Failure Modes & Runbook¶
Backpressure in NATS¶
- Problem: JetStream consumer lag increases
- Solution: Scale consumers, increase memory limits, optimize message size
- Monitoring:
nats consumer info positions walker-consumer
Tile38 Memory Keys¶
- Problem: Live set grows too large, memory pressure
- Solution: Implement TTL on live keys, rotate sets periodically
- Prevention: Monitor
tile38-cli -h tile38 INFOmemory usage
Parquet Compaction Cadence¶
- Problem: Too many small files, slow queries
- Solution: Implement hourly compaction, use DuckDB's COPY TO PARQUET
- Automation: Schedule compaction via cron or Kubernetes jobs
H3 Resolution Mismatch¶
- Problem: Inconsistent hex sizes across queries
- Solution: Standardize on resolution 9 for most use cases
- Debug: Validate H3 indices with
h3.is_valid_cell()
TL;DR Runbook¶
# 1. Start core stack
docker compose up -d
# 2. Add random walkers
docker compose --profile demo up -d
# 3. Add analytics processing
docker compose --profile analytics up -d
# 4. Add visualization
docker compose --profile viz up -d
# 5. Configure Tile38 geofences
docker compose exec tile38 bash /setup.sh
# 6. Monitor the system
# - NATS: http://localhost:8222
# - Tile38: tile38-cli -h localhost INFO
# - DuckDB: duckdb /data/positions.db
# 7. Query H3 analytics
# - Heatmap: SELECT h3_index, COUNT(*) FROM positions GROUP BY h3_index
# - Geofences: SELECT fence_id, COUNT(*) FROM geofence_events GROUP BY fence_id
This tutorial provides the complete machinery for real-time geofencing with hexagonal spatial analytics. Each component is production-ready, copy-paste runnable, and designed to handle high-frequency position updates with sub-millisecond geofence detection.