diff --git a/docs/COVERAGE.md b/docs/COVERAGE.md new file mode 100644 index 0000000..ccd2e7b --- /dev/null +++ b/docs/COVERAGE.md @@ -0,0 +1,33 @@ +# Coverage Prediction + +Meshview can display a predicted coverage boundary for a node. This is a **model** +estimate, not a guarantee of real-world performance. + +## How it works + +The coverage boundary is computed using the Longley-Rice / ITM **area mode** +propagation model. Area mode estimates average path loss over generic terrain +and does not use a terrain profile. This means it captures general distance +effects, but **does not** account for terrain shadows, buildings, or foliage. + +## What you are seeing + +The UI draws a **perimeter** (not a heatmap) that represents the furthest +distance where predicted signal strength is above a threshold (default +`-120 dBm`). The model is run radially from the node in multiple directions, +and the last point above the threshold forms the outline. + +## Key parameters + +- **Frequency**: default `907 MHz` +- **Transmit power**: default `20 dBm` +- **Antenna heights**: default `5 m` (TX) and `1.5 m` (RX) +- **Reliability**: default `0.5` (median) +- **Terrain irregularity**: default `90 m` (average terrain) + +## Limitations + +- No terrain or building data is used (area mode only). +- Results are sensitive to power, height, and threshold. +- Environmental factors can cause large real-world deviations. + diff --git a/meshview/lang/en.json b/meshview/lang/en.json index bff56f1..8b689fc 100644 --- a/meshview/lang/en.json +++ b/meshview/lang/en.json @@ -189,6 +189,9 @@ "times_seen": "Times seen", "copy_import_url": "Copy Import URL", "show_qr_code": "Show QR Code", + "toggle_coverage": "Toggle Coverage", + "location_required": "Location required for coverage", + "coverage_help": "Coverage Help", "share_contact_qr": "Share Contact QR", "copy_url": "Copy URL", "copied": "Copied!", diff --git a/meshview/lang/es.json b/meshview/lang/es.json index 735b7a6..1a64cd2 100644 --- a/meshview/lang/es.json +++ b/meshview/lang/es.json @@ -175,6 +175,9 @@ "times_seen": "Veces visto", "copy_import_url": "Copiar URL de importación", "show_qr_code": "Mostrar código QR", + "toggle_coverage": "Alternar cobertura", + "location_required": "Se requiere ubicación para la cobertura", + "coverage_help": "Ayuda de cobertura", "share_contact_qr": "Compartir contacto QR", "copy_url": "Copiar URL", "copied": "¡Copiado!", diff --git a/meshview/radio/coverage.py b/meshview/radio/coverage.py new file mode 100644 index 0000000..cc1c8a9 --- /dev/null +++ b/meshview/radio/coverage.py @@ -0,0 +1,136 @@ +import math +from functools import lru_cache + +from pyitm import itm + +DEFAULT_CLIMATE = 5 # Continental temperate +DEFAULT_GROUND = 0.005 # Average ground conductivity +DEFAULT_EPS_DIELECT = 15.0 +DEFAULT_DELTA_H = 90.0 +DEFAULT_RELIABILITY = 0.5 +DEFAULT_MIN_DBM = -130.0 +DEFAULT_MAX_DBM = -80.0 +DEFAULT_THRESHOLD_DBM = -120.0 +EARTH_RADIUS_KM = 6371.0 +BEARING_STEP_DEG = 5 + + +def destination_point( + lat: float, lon: float, bearing_deg: float, distance_km: float +) -> tuple[float, float]: + lat1 = math.radians(lat) + lon1 = math.radians(lon) + bearing = math.radians(bearing_deg) + + d = distance_km / EARTH_RADIUS_KM + + lat2 = math.asin( + math.sin(lat1) * math.cos(d) + math.cos(lat1) * math.sin(d) * math.cos(bearing) + ) + + lon2 = lon1 + math.atan2( + math.sin(bearing) * math.sin(d) * math.cos(lat1), + math.cos(d) - math.sin(lat1) * math.sin(lat2), + ) + + return math.degrees(lat2), math.degrees(lon2) + + +@lru_cache(maxsize=512) +def compute_coverage( + lat: float, + lon: float, + freq_mhz: float, + tx_dbm: float, + tx_height_m: float, + rx_height_m: float, + radius_km: float, + step_km: float, + reliability: float, +) -> list[tuple[float, float, float]]: + points = [] + distance = max(step_km, 1.0) + while distance <= radius_km: + for bearing in range(0, 360, BEARING_STEP_DEG): + rx_lat, rx_lon = destination_point(lat, lon, bearing, distance) + try: + loss_db, _ = itm.area( + ModVar=2, # Mobile: pctTime=reliability, pctConf=confidence + deltaH=DEFAULT_DELTA_H, + tht_m=tx_height_m, + rht_m=rx_height_m, + dist_km=distance, + TSiteCriteria=0, + RSiteCriteria=0, + eps_dielect=DEFAULT_EPS_DIELECT, + sgm_conductivity=DEFAULT_GROUND, + eno_ns_surfref=301, + frq_mhz=freq_mhz, + radio_climate=DEFAULT_CLIMATE, + pol=1, + pctTime=reliability, + pctLoc=0.5, + pctConf=0.5, + ) + rx_dbm = tx_dbm - loss_db + points.append((rx_lat, rx_lon, rx_dbm)) + except itm.InputError: + continue + distance += step_km + + return points + + +@lru_cache(maxsize=512) +def compute_perimeter( + lat: float, + lon: float, + freq_mhz: float, + tx_dbm: float, + tx_height_m: float, + rx_height_m: float, + radius_km: float, + step_km: float, + reliability: float, + threshold_dbm: float, +) -> list[tuple[float, float]]: + perimeter = [] + distance = max(step_km, 1.0) + for bearing in range(0, 360, BEARING_STEP_DEG): + last_point = None + last_rx_dbm = None + dist = distance + while dist <= radius_km: + try: + loss_db, _ = itm.area( + ModVar=2, + deltaH=DEFAULT_DELTA_H, + tht_m=tx_height_m, + rht_m=rx_height_m, + dist_km=dist, + TSiteCriteria=0, + RSiteCriteria=0, + eps_dielect=DEFAULT_EPS_DIELECT, + sgm_conductivity=DEFAULT_GROUND, + eno_ns_surfref=301, + frq_mhz=freq_mhz, + radio_climate=DEFAULT_CLIMATE, + pol=1, + pctTime=reliability, + pctLoc=0.5, + pctConf=0.5, + ) + except itm.InputError: + dist += step_km + continue + + rx_dbm = tx_dbm - loss_db + if rx_dbm >= threshold_dbm: + last_point = destination_point(lat, lon, bearing, dist) + last_rx_dbm = rx_dbm + dist += step_km + + if last_point and last_rx_dbm is not None: + perimeter.append(last_point) + + return perimeter diff --git a/meshview/templates/node.html b/meshview/templates/node.html index d20fe58..51b6793 100644 --- a/meshview/templates/node.html +++ b/meshview/templates/node.html @@ -338,6 +338,12 @@ + + + Coverage Help + @@ -517,6 +523,7 @@ +