diff --git a/DISCRETE_EVENT_SIM.md b/DISCRETE_EVENT_SIM.md index 82c27100..510ba2a9 100644 --- a/DISCRETE_EVENT_SIM.md +++ b/DISCRETE_EVENT_SIM.md @@ -9,7 +9,7 @@ To start one simulation with the default configurations, run: ```python3 loraMesh.py [nr_nodes]``` -If no argument is given, you first have to place the nodes on a plot. After you place a node, you can change its [role](https://meshtastic.org/docs/settings/config/device#role), hopLimit, height (elevation) and antenna gain. These settings will automatically save when you place a new node or when you start the simulation. +If no argument is given, you first have to place the nodes on a plot. After you place a node, you can change its [role](https://meshtastic.org/docs/settings/config/device#role), hopLimit, antenna height above local ground, and antenna gain. These settings will automatically save when you place a new node or when you start the simulation. ![](/img/configNode.png) @@ -23,6 +23,71 @@ Short deterministic smoke runs can also override the configured duration and mes ```python3 loraMesh.py 2 --no-gui --simtime-seconds 5 --period-seconds 0.5``` +The same headless path can import positioned real-mesh nodes. `--from-map` +reads a Meshtastic map `/api/v1/nodes` JSON endpoint; the public default is +`https://meshtastic.liamcottle.net/api/v1/nodes`, but you can pass another +compatible endpoint URL. These map endpoints usually return a broad node list, +so pass a local area-of-interest bounding box. `--map-bbox` uses the common +`min_lat,min_lon,max_lat,max_lon` order that most GIS tools call +`south,west,north,east`; you can copy those four numbers from OpenStreetMap's +Export panel, geojson.io's bbox readout, QGIS, or any other tool that shows the +extent of the map view or selected polygon. Keep the box tight enough for the +local scenario you want to simulate: + +```python3 loraMesh.py --from-map https://meshtastic.liamcottle.net/api/v1/nodes --map-bbox 41.50,41.50,41.82,41.86 --map-limit 50 --no-gui``` + +You can also import positioned nodes from the NodeDB cached by a local +Meshtastic device. This uses the Python client `interface.nodesByNum` data that +backs `meshtastic --nodes`, not the pretty-printed table. Use TCP for a network +device, or omit `--nodedb-host` to use Meshtastic serial auto-detection. For a +quick local-device run, pass the device address and cap the imported node count: + +```python3 loraMesh.py --from-nodedb --nodedb-host 192.168.1.23 --map-limit 50 --no-gui``` + +NodeDB often contains old or far-away positions. Add `--map-bbox` when you want +to restrict the run to one local area: + +```python3 loraMesh.py --from-nodedb --nodedb-host 192.168.1.23 --map-bbox 41.50,41.50,41.82,41.86 --map-limit 50 --no-gui``` + +Imported nodes use the same `HM` antenna height and `hopLimit` defaults as +generated and file-backed scenarios. Change those config values when the +position source does not carry the simulation value you want. + +Terrain obstruction can be added to map, NodeDB, or origin-backed scenario inputs +without creating a custom terrain file. `--terrain-srtm` downloads missing SRTM +HGT tiles from Mapzen Terrain Tiles on AWS into a local cache and feeds the +terrain grid directly into terrain-aware node geometry: + +```python3 loraMesh.py --from-nodedb --nodedb-host 192.168.1.23 --map-limit 50 --terrain-srtm --no-gui``` + +With an explicit `--map-bbox`, SRTM samples that whole requested rectangle. When +the terrain bbox is derived from imported or file-backed nodes, Meshtasticator +keeps the download smaller: it loads tiles around the selected nodes and along +flat-link candidate paths, instead of downloading every tile in a large +edge-to-edge rectangle. When publishing screenshots, reports, or derived +datasets from this terrain source, attribute the terrain data to +[Mapzen Terrain Tiles on AWS](https://registry.opendata.aws/terrain-tiles/), +SRTM/NASA, and their underlying open elevation sources: + +```python3 loraMesh.py --from-map https://meshtastic.liamcottle.net/api/v1/nodes --map-bbox 41.50,41.50,41.82,41.86 --map-limit 50 --terrain-srtm --no-gui``` + +Map payload `altitude` values are absolute GPS/MSL altitude, not antenna height, +so map import keeps using `HM` as the fallback antenna height above local +ground. When `--terrain-srtm` is enabled, each map node is checked +against its own SRTM ground sample: plausible positive map altitudes are used as +absolute node altitude, while missing, below-ground, or implausibly high values +fall back to `SRTM ground + antenna height` for 3D distance calculations. + +Land-cover clutter is a separate optional CSV grid. Use it for broad urban, +open, water, or forest excess-loss inputs without pretending Meshtasticator is a +building-level ray tracer: + +```python3 loraMesh.py --from-file nodeConfig.yaml --terrain-srtm --clutter-grid clutter.csv --no-gui``` + +`tools/osm_to_clutter_csv.py` can build a coarse clutter grid from public +OpenStreetMap building, landuse, natural, and water polygons. The simulator +never fetches OpenStreetMap data implicitly. + If you placed the nodes yourself, after a simulation the number of nodes, their coordinates and configuration are automatically saved and you can rerun the scenario with: ```python3 loraMesh.py --from-file``` diff --git a/batchSim.py b/batchSim.py index d61e3ece..be06c00f 100644 --- a/batchSim.py +++ b/batchSim.py @@ -232,7 +232,7 @@ def __init__(self, conf, x, y): x, y = coords[nodeId] # We create a NodeConfig object so that MeshNode will use that - nodeConfig = NodeConfig(nodeId, Point(x, y, routerTypeConf.HM), routerTypeConf.PERIOD, antenna_gain=routerTypeConf.GL, hop_limit=routerTypeConf.hopLimit) + nodeConfig = NodeConfig(nodeId, Point(x, y, routerTypeConf.HM), routerTypeConf.PERIOD, routerTypeConf.PTX, routerTypeConf.FREQ, antenna_gain=routerTypeConf.GL, hop_limit=routerTypeConf.hopLimit) node_configs.append(nodeConfig) if SHOW_GRAPH: diff --git a/lib/clutter.py b/lib/clutter.py new file mode 100644 index 00000000..fbafd138 --- /dev/null +++ b/lib/clutter.py @@ -0,0 +1,252 @@ +"""Optional land-cover clutter loss for radio links. + +Terrain handles hills, curvature, and Fresnel obstruction. It does not know +whether a lowland path crosses apartment blocks, a beach/coastal opening, or a +mountain-side vantage point looking down into the city. This module adds that +separate, data-driven clutter term from a small raster CSV. +""" + +import bisect +import csv +import math +from pathlib import Path + +from lib.csv_validation import finite_float, finite_lat_lon +from lib.terrain import latlon_to_xy, terrain_ground_elevation + + +class ClutterGrid: + """Nearest-cell lookup for small land-cover rasters.""" + + def __init__(self, samples): + self.samples = samples + self.xs = sorted({x for x, _, _ in samples}) + self.ys = sorted({y for _, y, _ in samples}) + self.by_xy = {(x, y): clutter_class for x, y, clutter_class in samples} + self.is_regular = len(self.xs) * len(self.ys) == len(samples) + + @classmethod + def from_csv(cls, path, origin_lat=None, origin_lon=None): + samples = [] + with open(path, newline="", encoding="utf-8") as fh: + reader = csv.DictReader(fh) + for row_number, row in enumerate(reader, start=2): + has_lat_lon = "lat" in row and "lon" in row + if has_lat_lon and origin_lat is not None and origin_lon is not None: + lat, lon = finite_lat_lon(row, "clutter", row_number) + x, y = latlon_to_xy(lat, lon, origin_lat, origin_lon) + elif "x_m" in row and "y_m" in row: + x = finite_float(row, "x_m", "clutter", row_number) + y = finite_float(row, "y_m", "clutter", row_number) + elif has_lat_lon: + raise ValueError("lat/lon clutter CSV requires GEO_ORIGIN_LAT and GEO_ORIGIN_LON") + else: + raise ValueError("clutter CSV needs x_m/y_m or lat/lon columns") + + clutter_class = row.get("clutter_class") + if clutter_class is None or not clutter_class.strip(): + raise ValueError("clutter CSV needs clutter_class column") + samples.append((x, y, clutter_class.strip().lower())) + + if not samples: + raise ValueError(f"clutter CSV has no samples: {path}") + return cls(samples) + + @staticmethod + def _nearest_axis_value(values, value): + index = bisect.bisect_left(values, value) + if index <= 0: + return values[0] + if index >= len(values): + return values[-1] + + before = values[index - 1] + after = values[index] + return before if abs(value - before) <= abs(after - value) else after + + def class_at(self, x, y): + if self.is_regular: + nearest_x = self._nearest_axis_value(self.xs, x) + nearest_y = self._nearest_axis_value(self.ys, y) + return self.by_xy[(nearest_x, nearest_y)] + + _, _, clutter_class = min( + self.samples, + key=lambda sample: math.hypot(x - sample[0], y - sample[1]), + ) + return clutter_class + + +def _clutter_grid(conf): + if not conf.CLUTTER_ENABLED or not conf.CLUTTER_GRID_FILE: + return None + + # Lat/lon CSVs are projected into scenario-local meters, so the projection + # origin is part of the loaded grid identity, not just metadata. + cache_identity = (conf.CLUTTER_GRID_FILE, conf.GEO_ORIGIN_LAT, conf.GEO_ORIGIN_LON) + cached_identity = getattr(conf, "_clutter_grid_identity", None) + if getattr(conf, "_clutter_grid", None) is not None and cached_identity == cache_identity: + return conf._clutter_grid + + path = Path(conf.CLUTTER_GRID_FILE) + conf._clutter_grid = ClutterGrid.from_csv(path, conf.GEO_ORIGIN_LAT, conf.GEO_ORIGIN_LON) + conf._clutter_grid_identity = cache_identity + return conf._clutter_grid + + +def _class_loss_db_per_km(conf, clutter_class): + if clutter_class in {"urban", "building"}: + return conf.CLUTTER_URBAN_LOSS_DB_PER_KM + if clutter_class in {"suburban", "residential"}: + return conf.CLUTTER_SUBURBAN_LOSS_DB_PER_KM + if clutter_class in {"forest", "wood"}: + return conf.CLUTTER_FOREST_LOSS_DB_PER_KM + if clutter_class in {"water", "coastal_water"}: + return conf.CLUTTER_WATER_LOSS_DB_PER_KM + return conf.CLUTTER_OPEN_LOSS_DB_PER_KM + + +def clutter_path_features(conf, tx_point, rx_point): + """Return coarse land-cover fractions along a radio path. + + The radio calibration model needs reusable features, not node-pair lookup + tables. These fractions let a fitted model learn patterns such as "urban + lowland paths behave differently from open coastal paths" and apply that + lesson to new generated node pairs. + """ + grid = _clutter_grid(conf) + if grid is None: + return { + "urban_fraction": 0.0, + "open_fraction": 0.0, + "water_fraction": 0.0, + "forest_fraction": 0.0, + "endpoint_urban_count": 0.0, + } + + cache = getattr(conf, "_clutter_feature_cache", None) + if cache is None: + cache = {} + conf._clutter_feature_cache = cache + + cache_key = ( + round(tx_point.x, 2), + round(tx_point.y, 2), + round(tx_point.z, 2), + round(rx_point.x, 2), + round(rx_point.y, 2), + round(rx_point.z, 2), + conf.CLUTTER_GRID_FILE, + conf.GEO_ORIGIN_LAT, + conf.GEO_ORIGIN_LON, + conf.CLUTTER_PROFILE_SAMPLES, + ) + if cache_key in cache: + return cache[cache_key] + + samples = max(1, conf.CLUTTER_PROFILE_SAMPLES) + class_counts = {} + for index in range(samples): + fraction = (index + 0.5) / samples + x = tx_point.x + (rx_point.x - tx_point.x) * fraction + y = tx_point.y + (rx_point.y - tx_point.y) * fraction + clutter_class = grid.class_at(x, y) + class_counts[clutter_class] = class_counts.get(clutter_class, 0) + 1 + + endpoint_urban_count = 0 + for point in (tx_point, rx_point): + endpoint_class = grid.class_at(point.x, point.y) + if endpoint_class in {"urban", "building", "suburban", "residential"}: + endpoint_urban_count += 1 + + features = { + "urban_fraction": class_counts.get("urban", 0) / samples, + "open_fraction": class_counts.get("open", 0) / samples, + "water_fraction": (class_counts.get("water", 0) + class_counts.get("coastal_water", 0)) / samples, + "forest_fraction": (class_counts.get("forest", 0) + class_counts.get("wood", 0)) / samples, + "endpoint_urban_count": float(endpoint_urban_count), + } + cache[cache_key] = features + return features + + +def _is_high_vantage(conf, point): + ground = terrain_ground_elevation(conf, point) + return ground is not None and ground >= conf.CLUTTER_HIGH_VANTAGE_ELEVATION_M + + +def clutter_obstruction_loss(conf, tx_point, rx_point): + """Estimate extra land-cover clutter loss in dB for a TX/RX path.""" + grid = _clutter_grid(conf) + if grid is None: + return 0.0 + + cache = getattr(conf, "_clutter_loss_cache", None) + if cache is None: + cache = {} + conf._clutter_loss_cache = cache + + cache_key = ( + round(tx_point.x, 2), + round(tx_point.y, 2), + round(tx_point.z, 2), + round(rx_point.x, 2), + round(rx_point.y, 2), + round(rx_point.z, 2), + conf.CLUTTER_GRID_FILE, + conf.GEO_ORIGIN_LAT, + conf.GEO_ORIGIN_LON, + conf.CLUTTER_PROFILE_SAMPLES, + conf.CLUTTER_URBAN_LOSS_DB_PER_KM, + conf.CLUTTER_SUBURBAN_LOSS_DB_PER_KM, + conf.CLUTTER_FOREST_LOSS_DB_PER_KM, + conf.CLUTTER_OPEN_LOSS_DB_PER_KM, + conf.CLUTTER_WATER_LOSS_DB_PER_KM, + conf.CLUTTER_URBAN_ENDPOINT_LOSS_DB, + conf.CLUTTER_HIGH_VANTAGE_ELEVATION_M, + conf.CLUTTER_HIGH_VANTAGE_LOSS_FACTOR, + conf.CLUTTER_COASTAL_PATH_LOSS_FACTOR, + conf.CLUTTER_COASTAL_SAMPLE_FRACTION, + conf.CLUTTER_MAX_LOSS_DB, + ) + if cache_key in cache: + return cache[cache_key] + + horizontal_distance = math.hypot(rx_point.x - tx_point.x, rx_point.y - tx_point.y) + if horizontal_distance <= 0: + return 0.0 + + samples = max(1, conf.CLUTTER_PROFILE_SAMPLES) + class_counts = {} + path_loss_rate = 0.0 + for index in range(samples): + fraction = (index + 0.5) / samples + x = tx_point.x + (rx_point.x - tx_point.x) * fraction + y = tx_point.y + (rx_point.y - tx_point.y) * fraction + clutter_class = grid.class_at(x, y) + class_counts[clutter_class] = class_counts.get(clutter_class, 0) + 1 + path_loss_rate += _class_loss_db_per_km(conf, clutter_class) + + path_loss = (path_loss_rate / samples) * (horizontal_distance / 1000.0) + + # Coastal or sea-adjacent paths are often real line-of-sight corridors. Do + # not let a few nearby urban cells make them look like street-canyon links. + water_samples = class_counts.get("water", 0) + class_counts.get("coastal_water", 0) + open_samples = class_counts.get("open", 0) + class_counts.get("beach", 0) + if (water_samples + open_samples) / samples >= conf.CLUTTER_COASTAL_SAMPLE_FRACTION: + path_loss *= conf.CLUTTER_COASTAL_PATH_LOSS_FACTOR + + tx_high = _is_high_vantage(conf, tx_point) + rx_high = _is_high_vantage(conf, rx_point) + if tx_high or rx_high: + path_loss *= conf.CLUTTER_HIGH_VANTAGE_LOSS_FACTOR + + endpoint_loss = 0.0 + for point, high_vantage in ((tx_point, tx_high), (rx_point, rx_high)): + endpoint_class = grid.class_at(point.x, point.y) + if endpoint_class in {"urban", "building", "suburban", "residential"} and not high_vantage: + endpoint_loss += conf.CLUTTER_URBAN_ENDPOINT_LOSS_DB + + loss = min(path_loss + endpoint_loss, conf.CLUTTER_MAX_LOSS_DB) + cache[cache_key] = loss + return loss diff --git a/lib/common.py b/lib/common.py index b8a639bf..1f9e2fc9 100644 --- a/lib/common.py +++ b/lib/common.py @@ -1,11 +1,15 @@ import random -import os import numpy as np from lib import phy from lib.point import Point + +def node_antenna_height(node): + """Return antenna height above ground, falling back to legacy Point.z.""" + return getattr(node, "antennaHeight", getattr(node, "antenna_height", node.position.z)) + def find_random_position(conf, node_configs) -> (float, float): """Given a simulation config and list of existing node configs/nodes, find a randomly chosen position for the next node such that it is within the @@ -79,7 +83,7 @@ def setup_asymmetric_links(conf, nodes): nodeA = nodes[a] nodeB = nodes[b] distAB = nodeA.position.euclidean_distance(nodeB.position) - pathLossAB = phy.estimate_path_loss(conf, distAB, conf.FREQ, nodeA.position.z, nodeB.position.z) + pathLossAB = phy.estimate_path_loss(conf, distAB, conf.FREQ, node_antenna_height(nodeA), node_antenna_height(nodeB)) offsetAB = conf.LINK_OFFSET[(a, b)] offsetBA = conf.LINK_OFFSET[(b, a)] diff --git a/lib/config.py b/lib/config.py index 8765daa8..52eea397 100644 --- a/lib/config.py +++ b/lib/config.py @@ -401,6 +401,49 @@ def __init__(self): self.NPREAM = 16 # number of preamble symbols from RadioInterface.h ### End of PHY parameters ### + ################################################# + ####### TERRAIN OBSTRUCTION MODEL ############### + ################################################# + # Disabled by default. When enabled, TERRAIN_GRID holds an in-memory + # grid sampled from SRTM HGT tiles. + self.TERRAIN_ENABLED = False + self.TERRAIN_GRID = None + # "ground": Point.z is antenna height above local ground. + # "sea_level": Point.z is absolute antenna altitude after adding + # terrain ground elevation. + self.NODE_Z_REFERENCE = "ground" + self.GEO_ORIGIN_LAT = None + self.GEO_ORIGIN_LON = None + self.TERRAIN_PROFILE_SAMPLES = 24 + self.TERRAIN_FRESNEL_CLEARANCE = 0.6 + # Match the common radio-planning 4/3 Earth-radius approximation. The + # terrain model uses it as an earth-bulge term so long coastal and ridge + # links do not look unrealistically flat. + self.TERRAIN_EFFECTIVE_EARTH_RADIUS_MULTIPLIER = 4.0 / 3.0 + self.TERRAIN_MIN_ANTENNA_HEIGHT_M = 1.5 + self.TERRAIN_MAX_LOSS_DB = 35.0 + + ################################################# + ####### LAND-COVER CLUTTER MODEL ################ + ################################################# + # Optional excess loss from buildings/land use. This is intentionally + # separate from terrain: hills can be visible while low urban fabric + # still blocks balcony-to-balcony links. + self.CLUTTER_ENABLED = False + self.CLUTTER_GRID_FILE = None + self.CLUTTER_PROFILE_SAMPLES = 16 + self.CLUTTER_URBAN_LOSS_DB_PER_KM = 4.0 + self.CLUTTER_SUBURBAN_LOSS_DB_PER_KM = 2.0 + self.CLUTTER_FOREST_LOSS_DB_PER_KM = 2.5 + self.CLUTTER_OPEN_LOSS_DB_PER_KM = 0.2 + self.CLUTTER_WATER_LOSS_DB_PER_KM = 0.0 + self.CLUTTER_URBAN_ENDPOINT_LOSS_DB = 3.0 + self.CLUTTER_HIGH_VANTAGE_ELEVATION_M = 120.0 + self.CLUTTER_HIGH_VANTAGE_LOSS_FACTOR = 0.35 + self.CLUTTER_COASTAL_PATH_LOSS_FACTOR = 0.25 + self.CLUTTER_COASTAL_SAMPLE_FRACTION = 0.55 + self.CLUTTER_MAX_LOSS_DB = 25.0 + # Misc self.SEED = 44 # random seed to use # End of misc diff --git a/lib/csv_validation.py b/lib/csv_validation.py new file mode 100644 index 00000000..50172220 --- /dev/null +++ b/lib/csv_validation.py @@ -0,0 +1,28 @@ +"""Small validation helpers for simulator CSV inputs.""" + +import math + +from lib.geo import valid_lat_lon + + +def finite_float(row, column, csv_name, row_number): + """Parse a required finite float from a CSV row with a useful error.""" + try: + value = float(row[column]) + except KeyError as err: + raise ValueError(f"{csv_name} CSV row {row_number} needs {column} column") from err + except (TypeError, ValueError) as err: + raise ValueError(f"{csv_name} CSV row {row_number} has invalid {column}: {row.get(column)!r}") from err + + if not math.isfinite(value): + raise ValueError(f"{csv_name} CSV row {row_number} has non-finite {column}: {row.get(column)!r}") + return value + + +def finite_lat_lon(row, csv_name, row_number): + """Parse and range-check lat/lon columns from a CSV row.""" + lat = finite_float(row, "lat", csv_name, row_number) + lon = finite_float(row, "lon", csv_name, row_number) + if not valid_lat_lon(lat, lon): + raise ValueError(f"{csv_name} CSV row {row_number} has invalid latitude/longitude degrees") + return lat, lon diff --git a/lib/geo.py b/lib/geo.py new file mode 100644 index 00000000..c57262ce --- /dev/null +++ b/lib/geo.py @@ -0,0 +1,13 @@ +"""Small geographic validation helpers shared by map-oriented inputs.""" + +import math + + +def valid_lat_lon(lat, lon): + """Return whether latitude/longitude are finite WGS84-style coordinates.""" + return ( + math.isfinite(lat) + and math.isfinite(lon) + and -90.0 <= lat <= 90.0 + and -180.0 <= lon <= 180.0 + ) diff --git a/lib/map_input.py b/lib/map_input.py new file mode 100644 index 00000000..93a3914e --- /dev/null +++ b/lib/map_input.py @@ -0,0 +1,244 @@ +"""Input adapter for public Meshtastic map and positioned node locations. + +The public map is useful as a location source, but it is not a simulator data +model. This adapter only converts map nodes with valid positions into the same +NodeConfig shape the GUI YAML path already uses. Link quality, terrain, and PER +remain simulator concerns configured elsewhere. +""" + +import json +import math +import statistics +import urllib.error +import urllib.request + +from lib.geo import valid_lat_lon +from lib.node import NodeConfig +from lib.terrain import latlon_to_xy + + +DEFAULT_MAP_NODES_URL = "https://meshtastic.liamcottle.net/api/v1/nodes" + + +def decode_map_coordinate(value): + """Decode Meshtastic map integer coordinates into decimal degrees.""" + if value is None: + return None + coordinate = float(value) + if abs(coordinate) > 180: + coordinate /= 1e7 + return coordinate + + +def decode_map_altitude(value): + """Return a finite positive map altitude in meters, or None for placeholders.""" + if value is None: + return None + altitude = float(value) + if not math.isfinite(altitude) or altitude <= 0: + return None + return altitude + + +def parse_bbox(value): + """Parse `min_lat,min_lon,max_lat,max_lon` into a numeric tuple.""" + parts = [part.strip() for part in value.split(",")] + if len(parts) != 4: + raise ValueError("map bbox must be min_lat,min_lon,max_lat,max_lon") + + min_lat, min_lon, max_lat, max_lon = [float(part) for part in parts] + if not all( + math.isfinite(value) + for value in (min_lat, min_lon, max_lat, max_lon) + ): + raise ValueError("map bbox values must be finite") + if not valid_lat_lon(min_lat, min_lon) or not valid_lat_lon(max_lat, max_lon): + raise ValueError("map bbox values must be valid latitude/longitude degrees") + if min_lat > max_lat or min_lon > max_lon: + raise ValueError("map bbox minimums must be less than maximums") + return min_lat, min_lon, max_lat, max_lon + + +def fetch_map_payload(url=DEFAULT_MAP_NODES_URL): + request = urllib.request.Request(url, headers={ + "User-Agent": "Meshtasticator map input", + "Accept": "application/json", + }) + try: + with urllib.request.urlopen(request, timeout=60) as response: + return json.load(response) + except (OSError, urllib.error.URLError, json.JSONDecodeError) as err: + raise ValueError(f"could not fetch map payload from {url}: {err}") from err + + +def role_name_for_node(node): + role_name = node.get("role_name") + if role_name: + return str(role_name).upper() + + # Fallback for map rows where the numeric role is known but the name is not + # populated. Public map rows may carry this as either an integer or a string. + # Unrecognized roles stay CLIENT-like unless explicitly mapped. + try: + role_value = int(node.get("role")) + except (TypeError, ValueError): + role_value = node.get("role") + + return { + 1: "CLIENT_MUTE", + 2: "ROUTER", + 3: "ROUTER_CLIENT", + 4: "REPEATER", + 11: "ROUTER_LATE", + 12: "CLIENT_BASE", + }.get(role_value, "CLIENT") + + +def payload_nodes(payload): + """Return node rows from accepted public-map payload shapes. + + Current map data is normally wrapped as {"nodes": [...]}, but accepting a + top-level list keeps tests and cached exports from needing a fake envelope. + """ + if isinstance(payload, dict): + nodes = payload.get("nodes", []) + elif isinstance(payload, list): + nodes = payload + else: + raise ValueError("map payload must be a JSON object with nodes or a node list") + + if not isinstance(nodes, list): + raise ValueError("map payload nodes must be a list") + return nodes + + +def filter_positioned_map_nodes(nodes, bbox=None): + positioned = [] + for node in nodes: + if not isinstance(node, dict): + continue + + try: + lat = decode_map_coordinate(node.get("latitude")) + lon = decode_map_coordinate(node.get("longitude")) + except (TypeError, ValueError): + continue + if lat is None or lon is None: + continue + if not valid_lat_lon(lat, lon): + continue + + if bbox is not None: + min_lat, min_lon, max_lat, max_lon = bbox + if not (min_lat <= lat <= max_lat and min_lon <= lon <= max_lon): + continue + + positioned.append((node, lat, lon)) + return positioned + + +def projection_origin_for_positioned_rows(positioned): + """Choose a local projection origin, handling longitude wraparound.""" + origin_lat = statistics.median([lat for _, lat, _ in positioned]) + longitudes = [lon for _, _, lon in positioned] + mean_sin = statistics.mean(math.sin(math.radians(lon)) for lon in longitudes) + mean_cos = statistics.mean(math.cos(math.radians(lon)) for lon in longitudes) + if mean_sin == 0 and mean_cos == 0: + origin_lon = statistics.median(longitudes) + else: + origin_lon = math.degrees(math.atan2(mean_sin, mean_cos)) + if origin_lon == -180.0: + origin_lon = 180.0 + return origin_lat, origin_lon + + +def longitude_near_origin(lon, origin_lon): + """Return an equivalent longitude closest to the projection origin.""" + while lon - origin_lon > 180.0: + lon -= 360.0 + while lon - origin_lon < -180.0: + lon += 360.0 + return lon + + +def node_configs_from_positioned_rows( + positioned, + period, + antenna_height=1.5, + hop_limit=3, + tx_power=30, + freq=902e6, + origin=None, + return_origin=False, +): + """Build NodeConfig objects from `(node, lat, lon)` positioned rows.""" + if origin is None: + origin_lat, origin_lon = projection_origin_for_positioned_rows(positioned) + else: + try: + origin_lat, origin_lon = (float(origin[0]), float(origin[1])) + except (TypeError, ValueError, IndexError) as err: + raise ValueError("map origin must be valid finite latitude/longitude degrees") from err + + if not valid_lat_lon(origin_lat, origin_lon): + raise ValueError("map origin must be valid finite latitude/longitude degrees") + + configs = [] + origin_tuple = (origin_lat, origin_lon) + for sim_node_id, (node, lat, lon) in enumerate(positioned): + projected_lon = longitude_near_origin(lon, origin_lon) + x, y = latlon_to_xy(lat, projected_lon, origin_lat, origin_lon) + role_name = role_name_for_node(node) + node_dict = { + "x": round(x, 2), + "y": round(y, 2), + # Meshtastic map altitude is absolute altitude, not antenna height. + # Keep z as antenna height unless SRTM is present to sanity-check + # and apply the optional absolute altitude per node. + "z": antenna_height, + "absoluteAltitude": decode_map_altitude(node.get("altitude")), + "isRouter": role_name in {"ROUTER", "ROUTER_CLIENT", "ROUTER_LATE"}, + "isRepeater": role_name == "REPEATER", + "isClientMute": role_name == "CLIENT_MUTE", + "hopLimit": hop_limit, + "antennaGain": 0, + "neighborInfo": False, + } + configs.append(NodeConfig.from_gen_scenario_output(sim_node_id, node_dict, period, tx_power, freq)) + + if return_origin: + return configs, origin_tuple + return configs + + +def node_configs_from_map_payload( + payload, + period, + bbox=None, + limit=None, + antenna_height=1.5, + hop_limit=3, + tx_power=30, + freq=902e6, + origin=None, + return_origin=False, +): + """Build NodeConfig objects from a Meshtastic map `/api/v1/nodes` payload.""" + positioned = filter_positioned_map_nodes(payload_nodes(payload), bbox) + if limit is not None: + if limit < 1: + raise ValueError("map limit must be at least 1") + positioned = positioned[:limit] + if not positioned: + raise ValueError("map payload produced no positioned nodes") + + return node_configs_from_positioned_rows( + positioned, + period, + antenna_height=antenna_height, + hop_limit=hop_limit, + tx_power=tx_power, + freq=freq, + origin=origin, + return_origin=return_origin, + ) diff --git a/lib/node.py b/lib/node.py index 459eab30..757a94d1 100644 --- a/lib/node.py +++ b/lib/node.py @@ -6,14 +6,21 @@ import simpy -from lib.common import find_random_position +from lib.common import find_random_position, node_antenna_height from lib.config import Config from lib.discrete_event_sim_components import SimulationState, SimulationDataTracking +from lib.geo import valid_lat_lon from lib.mac import set_transmit_delay, get_retransmission_msec from lib.phy import check_collision, is_channel_active, airtime from lib.packet import NODENUM_BROADCAST, MeshPacket, MeshMessage from lib.phy import estimate_path_loss from lib.point import Point +from lib.clutter import clutter_obstruction_loss +from lib.terrain import ( + NODE_Z_REFERENCE_SEA_LEVEL, + apply_terrain_altitude, + terrain_obstruction_loss, +) logger = logging.getLogger(__name__) @@ -59,7 +66,7 @@ def get_stats_dictionary(self) -> dict: class NodeConfig: """Specific configuration for a node """ - def __init__(self, node_id: int, position: Point, period: int, tx_power: int, freq: float, role: MESHTASTIC_ROLE = MESHTASTIC_ROLE.CLIENT, antenna_gain: float = 0, hop_limit: int = 3, neighbor_info: bool = False): + def __init__(self, node_id: int, position: Point, period: int, tx_power: int = 30, freq: float = 902e6, role: MESHTASTIC_ROLE = MESHTASTIC_ROLE.CLIENT, antenna_gain: float = 0, hop_limit: int = 3, neighbor_info: bool = False, antenna_height=None, absolute_altitude=None): """Initial configuration of a node Arguments: @@ -72,6 +79,8 @@ def __init__(self, node_id: int, position: Point, period: int, tx_power: int, fr antenna_gain -- antenna gain in dBi. Default 0 hop_limit -- hop limit. Default 3 neighbor_info -- if neighbor info is enabled. Default False + antenna_height -- antenna height above local ground. Default: position.z + absolute_altitude -- optional map-reported absolute altitude in meters """ self.node_id = node_id self.position = position.copy() # make sure we keep our own point @@ -82,6 +91,8 @@ def __init__(self, node_id: int, position: Point, period: int, tx_power: int, fr self.antenna_gain = antenna_gain self.hop_limit = hop_limit self.neighbor_info = neighbor_info + self.antenna_height = position.z if antenna_height is None else antenna_height + self.absolute_altitude = absolute_altitude @classmethod def from_gen_scenario_output(cls, node_id: int, node_dict: {}, period: int, tx_power: int, freq: float): @@ -118,7 +129,9 @@ def from_gen_scenario_output(cls, node_id: int, node_dict: {}, period: int, tx_p else: role = MESHTASTIC_ROLE.CLIENT - return NodeConfig(node_id, position, period, tx_power, freq, role, nd['antennaGain'], nd['hopLimit'], nd['neighborInfo']) + antenna_height = nd.get("antennaHeight", nd["z"]) + absolute_altitude = nd.get("absoluteAltitude") + return NodeConfig(node_id, position, period, tx_power, freq, role, nd['antennaGain'], nd['hopLimit'], nd['neighborInfo'], antenna_height, absolute_altitude) def compute_rssi_and_pathloss_to(self, rx_nodeconf, conf: Config) -> (float, float): """Compute RSSI and pathloss from this node config as the transmitting node @@ -136,11 +149,58 @@ def compute_rssi_and_pathloss_to(self, rx_nodeconf, conf: Config) -> (float, flo # compute path loss dist = self.position.euclidean_distance(rx_nodeconf.position) - pl = estimate_path_loss(conf, dist, self.freq, self.position.z, rx_nodeconf.position.z) + pl = estimate_path_loss(conf, dist, self.freq, node_antenna_height(self), node_antenna_height(rx_nodeconf)) + pl += terrain_obstruction_loss(conf, self.position, rx_nodeconf.position, self.freq) + pl += clutter_obstruction_loss(conf, self.position, rx_nodeconf.position) rssi = self.tx_power + self.antenna_gain + rx_nodeconf.antenna_gain - pl return (rssi, pl) + +def node_configs_from_yaml(raw_config, period: int, tx_power: int = 30, freq: float = 902e6) -> list[NodeConfig]: + """Convert saved node YAML into NodeConfig objects. + + The GUI writes a plain `{node_id: node_fields}` map. Real-mesh scenario + files may wrap the same map under `nodes` so they can also store geographic + origin metadata. Accept both shapes here so saved scenarios can be fed back + into the normal simulator CLI. + """ + if isinstance(raw_config, dict) and "nodes" in raw_config: + node_map = raw_config["nodes"] + else: + node_map = raw_config + + if not isinstance(node_map, dict): + raise ValueError("node YAML must be a node map or an object with a 'nodes' map") + + configs = [] + for sim_node_id, node_dict in enumerate(node_map.values()): + configs.append(NodeConfig.from_gen_scenario_output(sim_node_id, node_dict, period, tx_power, freq)) + return configs + + +def origin_from_yaml(raw_config): + """Return `(lat, lon)` origin metadata from wrapped scenario YAML if present.""" + if not isinstance(raw_config, dict): + return None + + origin = raw_config.get("origin") + if not isinstance(origin, dict) or "lat" not in origin or "lon" not in origin: + return None + + try: + lat = float(origin["lat"]) + lon = float(origin["lon"]) + except (TypeError, ValueError) as err: + raise ValueError("origin.lat and origin.lon must be finite numbers") from err + + if not math.isfinite(lat) or not math.isfinite(lon): + raise ValueError("origin.lat and origin.lon must be finite numbers") + if not valid_lat_lon(lat, lon): + raise ValueError("origin.lat and origin.lon must be valid latitude/longitude degrees") + + return lat, lon + class MeshNode: """Class containing all the particular state of a MeshNode, references to necessary external resources like the simpy env, and process functions for simulation @@ -174,6 +234,10 @@ def __init__(self, conf, sim_state: SimulationState, data_tracking: SimulationDa self.role = self.node_conf.role self.hopLimit = self.node_conf.hop_limit self.antennaGain = self.node_conf.antenna_gain + self.antennaHeight = self.node_conf.antenna_height + self.absolute_altitude = self.node_conf.absolute_altitude + if hasattr(self.node_conf, "_terrain_map_altitude_xy"): + self._terrain_map_altitude_xy = self.node_conf._terrain_map_altitude_xy self.period = self.node_conf.period # using this more like a struct than a proper object. @@ -294,6 +358,12 @@ def move_node(self): # Update node’s position self.position.update_xy(new_x, new_y) + if ( + self.conf.TERRAIN_ENABLED + and self.conf.TERRAIN_GRID is not None + and self.conf.NODE_Z_REFERENCE == NODE_Z_REFERENCE_SEA_LEVEL + ): + apply_terrain_altitude(self.conf.TERRAIN_GRID, self) # update connectivity map: # - update for this node: we may have gained and lost reachable nodes @@ -579,12 +649,6 @@ def default_generate_node_list(conf: Config) -> [NodeConfig]: # role isRouter = conf.router - isRepeater = False - isClientMute = False - - # other default values - hopLimit = conf.hopLimit - antennaGain = conf.GL # map misc. booleans into single role if isRouter: diff --git a/lib/nodedb_input.py b/lib/nodedb_input.py new file mode 100644 index 00000000..473064f8 --- /dev/null +++ b/lib/nodedb_input.py @@ -0,0 +1,128 @@ +"""Input adapter for positions stored in a local Meshtastic device NodeDB.""" + +from lib.geo import valid_lat_lon +from lib.map_input import decode_map_altitude, decode_map_coordinate, node_configs_from_positioned_rows + + +def fetch_nodedb_payload(host=None, port=None, serial_port=None): + """Read positioned nodes from a local Meshtastic device. + + This uses the same Python client state that powers `meshtastic --nodes`. + The CLI pretty-prints `interface.nodesByNum`; the simulator wants that raw + structure so it can project node positions without parsing a terminal table. + """ + if host is not None and serial_port is not None: + raise ValueError("--nodedb-host and --nodedb-serial-port are mutually exclusive") + + try: + if host is not None: + from meshtastic import tcp_interface + + iface = tcp_interface.TCPInterface(hostname=host, portNumber=port or 4403) + else: + from meshtastic import serial_interface + + iface = serial_interface.SerialInterface(devPath=serial_port) + except Exception as err: + raise ValueError(f"could not connect to Meshtastic device: {err}") from err + + try: + return list((iface.nodesByNum or {}).values()) + finally: + close = getattr(iface, "close", None) + if close is not None: + close() + + +def nodedb_payload_nodes(payload): + """Return node rows from accepted NodeDB payload shapes.""" + nodes = payload + if hasattr(payload, "nodesByNum"): + nodes = payload.nodesByNum + elif isinstance(payload, dict) and "nodesByNum" in payload: + nodes = payload["nodesByNum"] + + if isinstance(nodes, dict): + nodes = nodes.values() + if not isinstance(nodes, (list, tuple)) and not hasattr(nodes, "__iter__"): + raise ValueError("NodeDB payload must be nodesByNum, a node list, or an iterable of node rows") + return list(nodes) + + +def role_name_for_nodedb_node(node): + user = node.get("user") if isinstance(node, dict) else None + if isinstance(user, dict) and user.get("role") is not None: + return str(user["role"]).upper() + if isinstance(node, dict) and node.get("role") is not None: + return str(node["role"]).upper() + return "CLIENT" + + +def positioned_nodedb_nodes(nodes, bbox=None): + """Return `(node, lat, lon)` rows for NodeDB entries with valid positions.""" + positioned = [] + for node in nodes: + if not isinstance(node, dict): + continue + position = node.get("position") + if not isinstance(position, dict): + continue + + try: + lat = decode_map_coordinate(position.get("latitude")) + if lat is None: + lat = decode_map_coordinate(position.get("latitudeI")) + lon = decode_map_coordinate(position.get("longitude")) + if lon is None: + lon = decode_map_coordinate(position.get("longitudeI")) + except (TypeError, ValueError): + continue + if lat is None or lon is None: + continue + if not valid_lat_lon(lat, lon): + continue + + if bbox is not None: + min_lat, min_lon, max_lat, max_lon = bbox + if not (min_lat <= lat <= max_lat and min_lon <= lon <= max_lon): + continue + + node = { + "role_name": role_name_for_nodedb_node(node), + "altitude": decode_map_altitude(position.get("altitude")), + } + positioned.append((node, lat, lon)) + return positioned + + +def node_configs_from_nodedb_payload( + payload, + period, + bbox=None, + limit=None, + antenna_height=1.5, + hop_limit=3, + tx_power=30, + freq=902e6, + origin=None, + return_origin=False, +): + """Build NodeConfig objects from a local Meshtastic NodeDB payload.""" + positioned = positioned_nodedb_nodes(nodedb_payload_nodes(payload), bbox) + if limit is not None: + if limit < 1: + raise ValueError("map limit must be at least 1") + positioned = positioned[:limit] + if not positioned: + raise ValueError("NodeDB payload produced no positioned nodes") + + return node_configs_from_positioned_rows( + positioned, + period, + antenna_height=antenna_height, + hop_limit=hop_limit, + tx_power=tx_power, + freq=freq, + origin=origin, + return_origin=return_origin, + ) diff --git a/lib/osm_clutter.py b/lib/osm_clutter.py new file mode 100644 index 00000000..99d4945c --- /dev/null +++ b/lib/osm_clutter.py @@ -0,0 +1,307 @@ +"""Export public OpenStreetMap land-use/building data to clutter CSV. + +This is a standalone data-prep helper. The simulator runtime reads the exported +CSV and never fetches OSM/Overpass data implicitly. +""" + +import argparse +import csv +import json +import math +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + +from lib.geo import valid_lat_lon +from lib.map_input import parse_bbox +from lib.terrain import latlon_to_xy, xy_to_latlon + + +DEFAULT_OVERPASS_URL = "https://overpass-api.de/api/interpreter" + +URBAN_LANDUSE = { + "commercial", + "construction", + "garages", + "industrial", + "military", + "railway", + "residential", + "retail", +} +FOREST_VALUES = {"forest", "wood"} +WATER_VALUES = {"basin", "reservoir", "salt_pond", "water"} +OPEN_NATURAL = {"beach", "grassland", "heath", "sand", "scrub"} + + +def overpass_query(bbox): + """Build a bounded Overpass query for clutter-relevant OSM polygons.""" + min_lat, min_lon, max_lat, max_lon = bbox + box = f"{min_lat},{min_lon},{max_lat},{max_lon}" + return f""" +[out:json][timeout:90]; +( + way["building"]({box}); + way["landuse"~"^(commercial|construction|garages|industrial|military|railway|residential|retail|forest)$"]({box}); + way["natural"~"^(beach|grassland|heath|sand|scrub|water|wood)$"]({box}); + way["water"]({box}); +); +out tags geom; +""" + + +def fetch_overpass_payload(bbox, url=DEFAULT_OVERPASS_URL): + data = urllib.parse.urlencode({"data": overpass_query(bbox)}).encode() + request = urllib.request.Request( + url, + data=data, + headers={ + "User-Agent": "Meshtasticator OSM clutter exporter", + "Accept": "application/json", + }, + ) + try: + with urllib.request.urlopen(request, timeout=120) as response: + return json.load(response) + except (OSError, urllib.error.URLError, json.JSONDecodeError) as err: + raise ValueError(f"could not fetch OSM clutter payload from {url}: {err}") from err + + +def parse_origin(value): + """Parse `lat,lon` for local raster projection origin.""" + parts = [part.strip() for part in value.split(",")] + if len(parts) != 2: + raise ValueError("origin must be lat,lon") + + lat, lon = [float(part) for part in parts] + if not math.isfinite(lat) or not math.isfinite(lon): + raise ValueError("origin values must be finite") + if not valid_lat_lon(lat, lon): + raise ValueError("origin values must be valid latitude/longitude degrees") + return lat, lon + + +def classify_osm_element(tags): + """Map OSM tags to broad clutter classes used by the radio model.""" + if tags.get("building"): + return "urban" + + landuse = tags.get("landuse") + natural = tags.get("natural") + water = tags.get("water") + + if landuse in URBAN_LANDUSE: + return "urban" + if landuse in FOREST_VALUES or natural in FOREST_VALUES: + return "forest" + if landuse in WATER_VALUES or natural in WATER_VALUES or water: + return "water" + if natural in OPEN_NATURAL: + return "open" + return None + + +def payload_elements(payload): + """Return Overpass elements from the expected JSON object shape.""" + if not isinstance(payload, dict): + raise ValueError("OSM payload must be a JSON object") + + elements = payload.get("elements", []) + if not isinstance(elements, list): + raise ValueError("OSM payload elements must be a list") + return elements + + +def point_in_polygon(x, y, polygon): + """Return True when a point is inside a simple polygon.""" + inside = False + j = len(polygon) - 1 + for i, (xi, yi) in enumerate(polygon): + xj, yj = polygon[j] + if (yi > y) != (yj > y): + x_intersect = (xj - xi) * (y - yi) / (yj - yi) + xi + if x < x_intersect: + inside = not inside + j = i + return inside + + +def polygon_bounds(polygon): + xs = [point[0] for point in polygon] + ys = [point[1] for point in polygon] + return min(xs), min(ys), max(xs), max(ys) + + +def polygon_centroid(polygon): + if not polygon: + return 0.0, 0.0 + return ( + sum(point[0] for point in polygon) / len(polygon), + sum(point[1] for point in polygon) / len(polygon), + ) + + +def osm_polygons(payload, origin): + """Yield `(clutter_class, polygon_xy, bounds, centroid)` from Overpass JSON.""" + origin_lat, origin_lon = origin + for element in payload_elements(payload): + if not isinstance(element, dict): + continue + + geometry = element.get("geometry") or [] + tags = element.get("tags") or {} + if not isinstance(geometry, list) or not isinstance(tags, dict): + continue + + clutter_class = classify_osm_element(tags) + if not clutter_class or len(geometry) < 3: + continue + + polygon = [] + for point in geometry: + if not isinstance(point, dict): + polygon = [] + break + try: + lat = float(point["lat"]) + lon = float(point["lon"]) + except (KeyError, TypeError, ValueError): + polygon = [] + break + if not valid_lat_lon(lat, lon): + polygon = [] + break + polygon.append(latlon_to_xy(lat, lon, origin_lat, origin_lon)) + if len(polygon) < 3: + continue + + if polygon[0] != polygon[-1]: + polygon.append(polygon[0]) + bounds = polygon_bounds(polygon) + centroid = polygon_centroid(polygon) + yield clutter_class, polygon, bounds, centroid + + +def _frange(start, stop, step): + value = start + epsilon = step / 1000.0 + while value <= stop + epsilon: + yield value + value += step + + +def classify_cell(x, y, polygons, step_m): + """Classify one clutter grid cell from intersecting OSM polygons.""" + hits = {"urban": 0, "forest": 0, "water": 0, "open": 0} + half = step_m / 2.0 + for clutter_class, polygon, bounds, centroid in polygons: + min_x, min_y, max_x, max_y = bounds + if x < min_x - half or x > max_x + half or y < min_y - half or y > max_y + half: + continue + + # Land-use polygons often contain the cell center. Building footprints + # are much smaller than the exported raster cell, so also count nearby + # building centroids/bounds as urban evidence. + if point_in_polygon(x, y, polygon): + hits[clutter_class] = hits.get(clutter_class, 0) + 2 + elif clutter_class == "urban" and min_x - half <= x <= max_x + half and min_y - half <= y <= max_y + half: + cx, cy = centroid + if abs(cx - x) <= half and abs(cy - y) <= half: + hits["urban"] += 1 + + if hits["water"] > 0: + return "water" + if hits["urban"] > 0: + return "urban" + if hits["forest"] > 0: + return "forest" + return "open" + + +def rasterize_clutter(payload, bbox, origin=None, step_m=500.0): + """Rasterize OSM polygons to rows suitable for `ClutterGrid.from_csv()`.""" + if not math.isfinite(step_m) or step_m <= 0: + raise ValueError("step_m must be a positive finite number") + + if origin is None: + min_lat, min_lon, max_lat, max_lon = bbox + origin = ((min_lat + max_lat) / 2.0, (min_lon + max_lon) / 2.0) + + origin_lat, origin_lon = origin + min_lat, min_lon, max_lat, max_lon = bbox + min_x, min_y = latlon_to_xy(min_lat, min_lon, origin_lat, origin_lon) + max_x, max_y = latlon_to_xy(max_lat, max_lon, origin_lat, origin_lon) + min_x, max_x = sorted((min_x, max_x)) + min_y, max_y = sorted((min_y, max_y)) + + polygons = list(osm_polygons(payload, origin)) + rows = [] + for x in _frange(math.floor(min_x / step_m) * step_m, math.ceil(max_x / step_m) * step_m, step_m): + for y in _frange(math.floor(min_y / step_m) * step_m, math.ceil(max_y / step_m) * step_m, step_m): + lat, lon = xy_to_latlon(x, y, origin_lat, origin_lon) + if not (min_lat <= lat <= max_lat and min_lon <= lon <= max_lon): + continue + rows.append({ + "x_m": round(x, 2), + "y_m": round(y, 2), + "lat": round(lat, 7), + "lon": round(lon, 7), + "clutter_class": classify_cell(x, y, polygons, step_m), + }) + return rows + + +def write_clutter_csv(rows, output_path): + output_path = Path(output_path) + output_path.parent.mkdir(parents=True, exist_ok=True) + with output_path.open("w", newline="", encoding="utf-8") as fh: + writer = csv.DictWriter( + fh, + fieldnames=["x_m", "y_m", "lat", "lon", "clutter_class"], + lineterminator="\n", + ) + writer.writeheader() + writer.writerows(rows) + + +def main(argv=None): + parser = argparse.ArgumentParser(description="export OSM land-use/building clutter to Meshtasticator CSV") + parser.add_argument("--bbox", required=True, help="min_lat,min_lon,max_lat,max_lon") + parser.add_argument("--origin", help="origin lat,lon for local x/y output; defaults to bbox center") + parser.add_argument("--step-meters", type=float, default=500.0, help="output raster spacing in meters") + parser.add_argument("--output", required=True, help="output clutter CSV path") + parser.add_argument("--overpass-url", default=DEFAULT_OVERPASS_URL, help="Overpass interpreter endpoint") + parser.add_argument("--input-json", help="read an existing Overpass JSON response instead of fetching") + args = parser.parse_args(argv) + + try: + bbox = parse_bbox(args.bbox) + except ValueError as err: + parser.error(str(err)) + + origin = None + if args.origin: + try: + origin = parse_origin(args.origin) + except ValueError as err: + parser.error(str(err)) + + if args.input_json: + try: + with open(args.input_json, encoding="utf-8") as fh: + payload = json.load(fh) + except (OSError, json.JSONDecodeError) as err: + parser.error(f"could not read OSM clutter JSON: {err}") + else: + payload = fetch_overpass_payload(bbox, args.overpass_url) + + try: + rows = rasterize_clutter(payload, bbox, origin=origin, step_m=args.step_meters) + except ValueError as err: + parser.error(str(err)) + write_clutter_csv(rows, args.output) + + +if __name__ == "__main__": + main() diff --git a/lib/packet.py b/lib/packet.py index cdbf1c1e..76809ae2 100644 --- a/lib/packet.py +++ b/lib/packet.py @@ -1,8 +1,11 @@ import logging import random +from lib.common import node_antenna_height +from lib.clutter import clutter_obstruction_loss from lib.discrete_event_sim_components import Counter from lib.phy import airtime, estimate_path_loss +from lib.terrain import terrain_obstruction_loss NODENUM_BROADCAST = 0xFFFFFFFF @@ -96,7 +99,24 @@ def __init__(self, conf, nodes, origTxNodeId, destId, txNodeId, plen, seq, genTi baseline_pathloss = baseline_pathloss_matrix[self.txNodeId][rx_node.nodeid] else: dist_3d = self.tx_node.position.euclidean_distance(rx_node.position) - baseline_pathloss = estimate_path_loss(self.conf, dist_3d, self.freq, self.tx_node.position.z, rx_node.position.z) + baseline_pathloss = estimate_path_loss( + self.conf, + dist_3d, + self.freq, + node_antenna_height(self.tx_node), + node_antenna_height(rx_node), + ) + baseline_pathloss += terrain_obstruction_loss( + self.conf, + self.tx_node.position, + rx_node.position, + self.freq, + ) + baseline_pathloss += clutter_obstruction_loss( + self.conf, + self.tx_node.position, + rx_node.position, + ) if conf.MODEL_ASYMMETRIC_LINKS: offset = MeshPacket.asym_rng.gauss(0, conf.MODEL_ASYMMETRIC_LINKS_STDDEV) diff --git a/lib/srtm.py b/lib/srtm.py new file mode 100644 index 00000000..fedb290c --- /dev/null +++ b/lib/srtm.py @@ -0,0 +1,372 @@ +"""Mapzen Terrain Tiles / SRTM HGT helpers for Meshtasticator terrain inputs. + +The simulator can build an in-memory terrain grid directly from cached or +downloaded HGT tiles. +""" + +import gzip +import math +import shutil +import sys +import urllib.error +import zipfile +from array import array +from pathlib import Path +from urllib.parse import urlparse +from urllib.request import urlopen + +from lib.terrain import TerrainGrid, latlon_to_xy + + +DEFAULT_SRTM_URL_TEMPLATE = ( + "https://s3.amazonaws.com/elevation-tiles-prod/skadi/{lat_band}/{tile}.hgt.gz" +) +SRTM_DATA_ATTRIBUTION = ( + "Mapzen Terrain Tiles on AWS, using SRTM/NASA and other open elevation sources" +) +SRTM_DATA_ATTRIBUTION_URL = "https://registry.opendata.aws/terrain-tiles/" +HGT_VOID = -32768 +SRTM_MIN_LAT = -56.0 +SRTM_MAX_LAT = 60.0 +SRTM_MIN_LON = -180.0 +SRTM_MAX_LON = 180.0 + + +def clamp_bbox_to_srtm_coverage(bbox): + """Clamp a derived geographic bbox to available SRTM coverage.""" + min_lat, min_lon, max_lat, max_lon = bbox + clamped = ( + max(min_lat, SRTM_MIN_LAT), + max(min_lon, SRTM_MIN_LON), + min(max_lat, SRTM_MAX_LAT), + min(max_lon, SRTM_MAX_LON), + ) + if clamped[0] >= clamped[2] or clamped[1] >= clamped[3]: + raise ValueError("bbox does not overlap SRTM coverage") + return clamped + + +def srtm_tile_name(lat, lon): + """Return the HGT tile name containing `lat, lon`, for example N41E041.""" + lat_floor = math.floor(lat) + lon_floor = math.floor(lon) + lat_prefix = "N" if lat_floor >= 0 else "S" + lon_prefix = "E" if lon_floor >= 0 else "W" + return f"{lat_prefix}{abs(lat_floor):02d}{lon_prefix}{abs(lon_floor):03d}" + + +def _edge_clamped_sample_coordinate(value, upper_bound): + return math.nextafter(value, -math.inf) if value == upper_bound else value + + +def _sample_tile_name(lat, lon): + lat_for_tile = _edge_clamped_sample_coordinate(lat, SRTM_MAX_LAT) + lon_for_tile = _edge_clamped_sample_coordinate(lon, SRTM_MAX_LON) + return srtm_tile_name(lat_for_tile, lon_for_tile) + + +def _parse_tile_name(tile_name): + if len(tile_name) != 7 or tile_name[0] not in "NS" or tile_name[3] not in "EW": + raise ValueError(f"invalid SRTM tile name: {tile_name}") + + lat = int(tile_name[1:3]) + lon = int(tile_name[4:7]) + return (-lat if tile_name[0] == "S" else lat, -lon if tile_name[3] == "W" else lon) + + +def tile_bbox(tile_name): + """Return the geographic bbox covered by one SRTM tile.""" + south, west = _parse_tile_name(tile_name) + return south, west, south + 1.0, west + 1.0 + + +def tiles_for_bbox(bbox): + """Return sorted SRTM tile names covering a geographic bounding box.""" + min_lat, min_lon, max_lat, max_lon = bbox + if min_lat < SRTM_MIN_LAT or max_lat > SRTM_MAX_LAT: + raise ValueError("SRTM coverage is limited to latitudes between 56°S and 60°N") + if min_lon < SRTM_MIN_LON or max_lon > SRTM_MAX_LON: + raise ValueError( + "SRTM coverage is limited to longitudes between 180°W and 180°E" + ) + + min_lat_for_tile = ( + math.nextafter(min_lat, -math.inf) if min_lat == SRTM_MAX_LAT else min_lat + ) + min_lon_for_tile = ( + math.nextafter(min_lon, -math.inf) if min_lon == SRTM_MAX_LON else min_lon + ) + max_lat_for_tile = ( + math.nextafter(max_lat, -math.inf) + if max_lat > min_lat or max_lat == SRTM_MAX_LAT + else max_lat + ) + max_lon_for_tile = ( + math.nextafter(max_lon, -math.inf) + if max_lon > min_lon or max_lon == SRTM_MAX_LON + else max_lon + ) + min_lat_tile = math.floor(min_lat_for_tile) + min_lon_tile = math.floor(min_lon_for_tile) + max_lat_tile = math.floor(max_lat_for_tile) + max_lon_tile = math.floor(max_lon_for_tile) + names = [] + for lat_floor in range(min_lat_tile, max_lat_tile + 1): + for lon_floor in range(min_lon_tile, max_lon_tile + 1): + names.append(srtm_tile_name(lat_floor, lon_floor)) + return sorted(set(names)) + + +class SrtmTile: + """A single SRTM HGT tile loaded into memory. + + HGT files are square grids of signed big-endian 16-bit elevations. Real + SRTM tiles are usually 1201x1201 or 3601x3601, but tests use tiny square + files and the reader deliberately accepts those too. + """ + + def __init__(self, tile_name, side, elevations): + self.tile_name = tile_name + self.south, self.west = _parse_tile_name(tile_name) + self.north = self.south + 1 + self.east = self.west + 1 + self.side = side + self.elevations = elevations + + @classmethod + def from_hgt(cls, path, tile_name=None): + path = Path(path) + tile_name = tile_name or path.name.split(".")[0] + data = path.read_bytes() + if len(data) % 2: + raise ValueError(f"HGT file has odd byte length: {path}") + + cell_count = len(data) // 2 + side = math.isqrt(cell_count) + if side * side != cell_count: + raise ValueError(f"HGT file is not a square grid: {path}") + + elevations = array("h") + elevations.frombytes(data) + if sys.byteorder == "little": + elevations.byteswap() + return cls(tile_name, side, elevations) + + def elevation_at(self, lat, lon): + """Return nearest-sample elevation in meters, or None for SRTM voids.""" + row = round((self.north - lat) * (self.side - 1)) + col = round((lon - self.west) * (self.side - 1)) + row = min(max(row, 0), self.side - 1) + col = min(max(col, 0), self.side - 1) + return self._nearest_valid_elevation(row, col) + + def _nearest_valid_elevation(self, row, col): + value = self._value_at(row, col) + if value is not None: + return value + + # SRTM voids are rare around populated areas. A tiny local search keeps + # one bad pixel from punching a hole in an otherwise usable terrain grid. + for radius in range(1, 4): + candidates = [] + for rr in range(max(0, row - radius), min(self.side, row + radius + 1)): + for cc in range(max(0, col - radius), min(self.side, col + radius + 1)): + if abs(rr - row) != radius and abs(cc - col) != radius: + continue + value = self._value_at(rr, cc) + if value is not None: + candidates.append((abs(rr - row) + abs(cc - col), value)) + if candidates: + candidates.sort(key=lambda item: item[0]) + return candidates[0][1] + return None + + def _value_at(self, row, col): + value = self.elevations[row * self.side + col] + if value == HGT_VOID: + return None + return float(value) + + +def ensure_hgt_tile( + tile_name, cache_dir, url_template=DEFAULT_SRTM_URL_TEMPLATE, download_missing=True +): + """Return a cached `.hgt` path, downloading and unpacking it when allowed.""" + cache_dir = Path(cache_dir).expanduser() + cache_dir.mkdir(parents=True, exist_ok=True) + hgt_path = cache_dir / f"{tile_name}.hgt" + if hgt_path.exists(): + return hgt_path + + if not download_missing: + raise FileNotFoundError(f"missing cached SRTM tile: {hgt_path}") + + lat_band = tile_name[:3] + try: + url = url_template.format(tile=tile_name, lat_band=lat_band) + except KeyError as err: + raise ValueError( + "url_template may only use {tile} and {lat_band} placeholders" + ) from err + parsed_path = Path(urlparse(url).path) + download_path = ( + cache_dir / f"{tile_name}{''.join(parsed_path.suffixes) or '.download'}" + ) + partial_hgt_path = cache_dir / f"{tile_name}.hgt.tmp" + + try: + with urlopen(url, timeout=60) as response, download_path.open("wb") as out: + shutil.copyfileobj(response, out) + except (OSError, urllib.error.URLError) as err: + raise ValueError( + f"could not download SRTM tile {tile_name} from {url}: {err}" + ) from err + + try: + partial_hgt_path.unlink(missing_ok=True) + if download_path.suffix == ".gz": + with ( + gzip.open(download_path, "rb") as src, + partial_hgt_path.open("wb") as out, + ): + shutil.copyfileobj(src, out) + elif download_path.suffix == ".zip": + with zipfile.ZipFile(download_path) as archive: + hgt_members = [ + name for name in archive.namelist() if name.lower().endswith(".hgt") + ] + if not hgt_members: + raise ValueError(f"zip archive has no .hgt member: {download_path}") + expected_name = f"{tile_name}.hgt".lower() + matching_members = [ + name + for name in hgt_members + if Path(name).name.lower() == expected_name + ] + if not matching_members: + raise ValueError( + f"zip archive has no {tile_name}.hgt member: {download_path}" + ) + with ( + archive.open(matching_members[0]) as src, + partial_hgt_path.open("wb") as out, + ): + shutil.copyfileobj(src, out) + else: + download_path.replace(partial_hgt_path) + partial_hgt_path.replace(hgt_path) + except (OSError, gzip.BadGzipFile, zipfile.BadZipFile, ValueError) as err: + partial_hgt_path.unlink(missing_ok=True) + raise ValueError(f"could not unpack SRTM tile {tile_name}: {err}") from err + + return hgt_path + + +def _coordinate_values(start, stop, step): + values = [] + value = start + while value < stop - 1e-12: + values.append(value) + value += step + + if not values or abs(values[-1] - stop) > 1e-12: + values.append(stop) + return values + + +def terrain_rows_from_srtm( + bbox, + step_meters, + cache_dir, + url_template=DEFAULT_SRTM_URL_TEMPLATE, + download_missing=True, + tile_names=None, +): + """Yield lat/lon/elevation rows sampled from SRTM tiles over `bbox`.""" + if not math.isfinite(step_meters) or step_meters <= 0: + raise ValueError("step_meters must be a positive finite number") + + min_lat, min_lon, max_lat, max_lon = bbox + mid_lat = (min_lat + max_lat) / 2.0 + lat_step = step_meters / 111320.0 + lon_step = step_meters / (111320.0 * max(math.cos(math.radians(mid_lat)), 0.01)) + + requested_tile_names = sorted(set(tile_names or tiles_for_bbox(bbox))) + tiles = {} + for tile_name in requested_tile_names: + path = ensure_hgt_tile(tile_name, cache_dir, url_template, download_missing) + tiles[tile_name] = SrtmTile.from_hgt(path, tile_name) + + emitted = set() + for tile_name, tile in tiles.items(): + tile_min_lat, tile_min_lon, tile_max_lat, tile_max_lon = tile_bbox(tile_name) + sample_min_lat = max(min_lat, tile_min_lat) + sample_min_lon = max(min_lon, tile_min_lon) + sample_max_lat = min(max_lat, tile_max_lat) + sample_max_lon = min(max_lon, tile_max_lon) + if sample_min_lat > sample_max_lat or sample_min_lon > sample_max_lon: + continue + + for lat in _coordinate_values(sample_min_lat, sample_max_lat, lat_step): + for lon in _coordinate_values(sample_min_lon, sample_max_lon, lon_step): + sample_key = (round(lat, 7), round(lon, 7)) + if sample_key in emitted: + continue + emitted.add(sample_key) + + sample_tile = tiles.get(_sample_tile_name(lat, lon)) + if sample_tile is None: + continue + elevation = sample_tile.elevation_at(lat, lon) + if elevation is None: + continue + yield { + "lat": f"{lat:.7f}", + "lon": f"{lon:.7f}", + "elevation_m": f"{elevation:.1f}", + } + + for lat, lon in ( + (sample_min_lat, sample_min_lon), + (sample_min_lat, sample_max_lon), + (sample_max_lat, sample_min_lon), + (sample_max_lat, sample_max_lon), + ): + sample_key = (round(lat, 7), round(lon, 7)) + if sample_key in emitted: + continue + emitted.add(sample_key) + tile = tiles.get(_sample_tile_name(lat, lon)) + if tile is None: + continue + elevation = tile.elevation_at(lat, lon) + if elevation is None: + continue + yield { + "lat": f"{lat:.7f}", + "lon": f"{lon:.7f}", + "elevation_m": f"{elevation:.1f}", + } + + +def terrain_grid_from_srtm( + bbox, + step_meters, + cache_dir, + origin_lat, + origin_lon, + url_template=DEFAULT_SRTM_URL_TEMPLATE, + download_missing=True, + tile_names=None, +): + """Build an in-memory TerrainGrid from SRTM tiles without writing CSV.""" + samples = [] + for row in terrain_rows_from_srtm( + bbox, step_meters, cache_dir, url_template, download_missing, tile_names + ): + lat = float(row["lat"]) + lon = float(row["lon"]) + elevation = float(row["elevation_m"]) + x, y = latlon_to_xy(lat, lon, origin_lat, origin_lon) + samples.append((x, y, elevation)) + return TerrainGrid.from_rows(samples) diff --git a/lib/terrain.py b/lib/terrain.py new file mode 100644 index 00000000..94b63997 --- /dev/null +++ b/lib/terrain.py @@ -0,0 +1,247 @@ +"""Optional terrain obstruction model for radio links. + +The simulator's default coordinates are local meters with `Point.z` acting like +antenna height. Terrain-aware input loaders can lift points to absolute antenna +altitude (`ground elevation + antenna height`) so the existing 3D distance path +already reflects terrain before any extra RF obstruction loss is applied. + +The loss model is intentionally conservative and dependency-free: sample the +path, find the worst Fresnel/line-of-sight obstruction, then apply the standard +single knife-edge diffraction approximation for that obstruction. It is not a +full ray tracer, but it captures the important Batumi-mesh case where hills and +ridges matter more than flat-earth distance alone. +""" + +import math + + +EARTH_RADIUS_M = 6371000.0 +NODE_Z_REFERENCE_GROUND = "ground" +NODE_Z_REFERENCE_SEA_LEVEL = "sea_level" +MAX_REASONABLE_STRUCTURE_HEIGHT_M = 850.0 + + +def latlon_to_xy(lat, lon, origin_lat, origin_lon): + """Project WGS84 lat/lon to local x/y meters with an equirectangular map.""" + origin_lat_rad = math.radians(origin_lat) + x = math.radians(lon - origin_lon) * EARTH_RADIUS_M * math.cos(origin_lat_rad) + y = math.radians(lat - origin_lat) * EARTH_RADIUS_M + return x, y + + +def xy_to_latlon(x, y, origin_lat, origin_lon): + """Inverse of latlon_to_xy for small local simulation areas.""" + origin_lat_rad = math.radians(origin_lat) + origin_cos = math.cos(origin_lat_rad) + if abs(origin_cos) < 1e-9: + raise ValueError("origin latitude is too close to a pole for local x/y projection") + + lat = origin_lat + math.degrees(y / EARTH_RADIUS_M) + lon = origin_lon + math.degrees(x / (EARTH_RADIUS_M * origin_cos)) + return lat, lon + + +class TerrainGrid: + """Small scattered terrain sample grid with inverse-distance interpolation.""" + + def __init__(self, samples): + self.samples = samples + + @classmethod + def from_rows(cls, rows): + """Build a terrain grid from `(x_m, y_m, elevation_m)` samples.""" + samples = [] + for row_number, row in enumerate(rows, start=1): + try: + x, y, elevation = row + except (TypeError, ValueError) as err: + raise ValueError(f"terrain sample {row_number} must have x, y, and elevation") from err + x = float(x) + y = float(y) + elevation = float(elevation) + if not math.isfinite(x) or not math.isfinite(y) or not math.isfinite(elevation): + raise ValueError(f"terrain sample {row_number} values must be finite") + samples.append((x, y, elevation)) + + if not samples: + raise ValueError("terrain grid has no samples") + return cls(samples) + + def elevation_at(self, x, y): + weighted_sum = 0.0 + weight_total = 0.0 + + nearest = sorted( + ((math.hypot(x - sx, y - sy), elevation) for sx, sy, elevation in self.samples), + key=lambda item: item[0], + )[:8] + + for distance, elevation in nearest: + if distance < 0.01: + return elevation + weight = 1.0 / (distance * distance) + weighted_sum += elevation * weight + weight_total += weight + + return weighted_sum / weight_total + + +def _terrain_grid(conf): + """Return the configured in-memory terrain grid when terrain is enabled.""" + if not conf.TERRAIN_ENABLED: + return None + return getattr(conf, "TERRAIN_GRID", None) + + +def terrain_ground_elevation(conf, point): + """Return terrain elevation at a point, or None when terrain is unavailable.""" + grid = _terrain_grid(conf) + if grid is None: + return None + return grid.elevation_at(point.x, point.y) + + +def map_altitude_if_plausible(node, ground): + """Return per-node map altitude when SRTM says it is physically plausible.""" + altitude = getattr(node, "absolute_altitude", None) + if altitude is None: + return None + altitude = float(altitude) + if not math.isfinite(altitude): + return None + if altitude <= ground: + return None + if altitude > ground + MAX_REASONABLE_STRUCTURE_HEIGHT_M: + return None + return altitude + + +def node_antenna_height(node): + """Return node antenna height above ground for config and live node types.""" + return getattr( + node, + "antenna_height", + getattr(node, "antennaHeight", node.position.z), + ) + + +def apply_terrain_altitude(terrain_grid, node): + """Lift one node z coordinate to absolute antenna altitude from terrain. + + `node.antenna_height` remains antenna height above local ground. That keeps + path-loss models with explicit antenna-height terms from receiving absolute + MSL altitude by mistake. + """ + ground = terrain_grid.elevation_at(node.position.x, node.position.y) + source_xy = getattr(node, "_terrain_map_altitude_xy", None) + moved_from_map_source = ( + source_xy is not None + and (node.position.x, node.position.y) != source_xy + ) + if moved_from_map_source: + map_altitude = None + else: + map_altitude = map_altitude_if_plausible(node, ground) + if map_altitude is None: + node.position.z = ground + node_antenna_height(node) + else: + node.position.z = map_altitude + node._terrain_map_altitude_xy = (node.position.x, node.position.y) + + +def apply_terrain_altitudes(terrain_grid, node_config): + """Lift node z coordinates to absolute antenna altitude from terrain.""" + for node in node_config: + apply_terrain_altitude(terrain_grid, node) + + +def terrain_antenna_altitude(conf, grid, point): + """Return absolute antenna altitude for a terrain-backed point.""" + ground = grid.elevation_at(point.x, point.y) + min_altitude = ground + conf.TERRAIN_MIN_ANTENNA_HEIGHT_M + if getattr(conf, "NODE_Z_REFERENCE", NODE_Z_REFERENCE_GROUND) == NODE_Z_REFERENCE_SEA_LEVEL: + return max(point.z, min_altitude) + return ground + max(point.z, conf.TERRAIN_MIN_ANTENNA_HEIGHT_M) + + +def knife_edge_loss_db(v): + """ITU-R single knife-edge diffraction loss approximation.""" + if v <= -0.78: + return 0.0 + return 6.9 + 20.0 * math.log10(math.sqrt((v - 0.1) ** 2 + 1.0) + v - 0.1) + + +def terrain_obstruction_loss(conf, tx_point, rx_point, freq): + """Estimate extra terrain obstruction loss in dB for a TX/RX path.""" + grid = _terrain_grid(conf) + if grid is None: + return 0.0 + + # Packet objects are created often and ask for every receiver. In a static + # topology the terrain term is a pure function of the two endpoint + # coordinates, so cache it on Config and keep the packet hot path cheap. + cache = getattr(conf, "_terrain_loss_cache", None) + if cache is None: + cache = {} + conf._terrain_loss_cache = cache + + cache_key = ( + round(tx_point.x, 2), + round(tx_point.y, 2), + round(tx_point.z, 2), + round(rx_point.x, 2), + round(rx_point.y, 2), + round(rx_point.z, 2), + round(freq, 0), + id(getattr(conf, "TERRAIN_GRID", None)), + conf.GEO_ORIGIN_LAT, + conf.GEO_ORIGIN_LON, + conf.TERRAIN_PROFILE_SAMPLES, + conf.TERRAIN_FRESNEL_CLEARANCE, + conf.TERRAIN_EFFECTIVE_EARTH_RADIUS_MULTIPLIER, + conf.TERRAIN_MIN_ANTENNA_HEIGHT_M, + conf.TERRAIN_MAX_LOSS_DB, + ) + if cache_key in cache: + return cache[cache_key] + + horizontal_distance = math.hypot(rx_point.x - tx_point.x, rx_point.y - tx_point.y) + if horizontal_distance <= 0: + return 0.0 + + wavelength = 299792458.0 / freq + tx_height = terrain_antenna_altitude(conf, grid, tx_point) + rx_height = terrain_antenna_altitude(conf, grid, rx_point) + effective_earth_radius = EARTH_RADIUS_M * conf.TERRAIN_EFFECTIVE_EARTH_RADIUS_MULTIPLIER + curvature_scale = (horizontal_distance * horizontal_distance) / (2.0 * effective_earth_radius) + + worst_loss = 0.0 + for i in range(1, conf.TERRAIN_PROFILE_SAMPLES): + fraction = i / conf.TERRAIN_PROFILE_SAMPLES + x = tx_point.x + (rx_point.x - tx_point.x) * fraction + y = tx_point.y + (rx_point.y - tx_point.y) * fraction + ground = grid.elevation_at(x, y) + los_height = tx_height + (rx_height - tx_height) * fraction + d1 = horizontal_distance * fraction + d2 = horizontal_distance - d1 + + fresnel_radius = math.sqrt(wavelength * d1 * d2 / horizontal_distance) + # A straight local projection makes long links look too flat. Apply the + # usual 4/3 effective Earth radius bulge at each path sample before + # measuring Fresnel clearance against the line between antennas. + earth_bulge = curvature_scale * fraction * (1.0 - fraction) + obstruction_height = ( + ground + + earth_bulge + + conf.TERRAIN_FRESNEL_CLEARANCE * fresnel_radius + - los_height + ) + if obstruction_height <= 0: + continue + + v = obstruction_height * math.sqrt(2.0 * horizontal_distance / (wavelength * d1 * d2)) + worst_loss = max(worst_loss, knife_edge_loss_db(v)) + + loss = min(worst_loss, conf.TERRAIN_MAX_LOSS_DB) + cache[cache_key] = loss + return loss diff --git a/loraMesh.py b/loraMesh.py index ac54e536..b2756666 100755 --- a/loraMesh.py +++ b/loraMesh.py @@ -4,11 +4,39 @@ import math import os import random +from pathlib import Path import yaml from lib.config import CONFIG -from lib.node import NodeConfig, default_generate_node_list +from lib.map_input import ( + DEFAULT_MAP_NODES_URL, + fetch_map_payload, + node_configs_from_map_payload, + parse_bbox, +) +from lib.nodedb_input import fetch_nodedb_payload, node_configs_from_nodedb_payload +from lib.node import ( + NodeConfig, + default_generate_node_list, + node_configs_from_yaml, + origin_from_yaml, +) +from lib.srtm import ( + DEFAULT_SRTM_URL_TEMPLATE, + SRTM_DATA_ATTRIBUTION, + SRTM_DATA_ATTRIBUTION_URL, + clamp_bbox_to_srtm_coverage, + terrain_grid_from_srtm, + tiles_for_bbox, +) +from lib.terrain import ( + NODE_Z_REFERENCE_SEA_LEVEL, + apply_terrain_altitudes, + node_antenna_height, + xy_to_latlon, +) +from lib.phy import estimate_path_loss conf = CONFIG logger = logging.getLogger(__name__) @@ -18,7 +46,7 @@ def configure_logging(): """Apply CLI logging defaults without changing logging during module import.""" - logging.basicConfig(level=logging.INFO) # default log level + logging.basicConfig(level=logging.INFO) # default log level def get_cli_defaults(conf): @@ -32,11 +60,111 @@ def get_cli_defaults(conf): "PERIOD": conf.PERIOD, "GUI_ENABLED": conf.GUI_ENABLED, "PLOT": conf.PLOT, + "TERRAIN_PROFILE_SAMPLES": conf.TERRAIN_PROFILE_SAMPLES, + "NODE_Z_REFERENCE": conf.NODE_Z_REFERENCE, + "CLUTTER_PROFILE_SAMPLES": conf.CLUTTER_PROFILE_SAMPLES, }, ) return getattr(conf, CLI_DEFAULT_ATTR) +def set_geo_origin(conf, origin): + """Use scenario geographic origin for lat/lon terrain grids when available.""" + if origin is None: + conf.GEO_ORIGIN_LAT = None + conf.GEO_ORIGIN_LON = None + return + conf.GEO_ORIGIN_LAT, conf.GEO_ORIGIN_LON = origin + + +def bbox_from_points(points, origin, margin_m=1000.0): + """Build a geographic bbox around local x/y points when an origin exists.""" + if origin is None: + return None + origin_lat, origin_lon = origin + min_x = min(point.x for point in points) - margin_m + max_x = max(point.x for point in points) + margin_m + min_y = min(point.y for point in points) - margin_m + max_y = max(point.y for point in points) + margin_m + lat_a, lon_a = xy_to_latlon(min_x, min_y, origin_lat, origin_lon) + lat_b, lon_b = xy_to_latlon(max_x, max_y, origin_lat, origin_lon) + return clamp_bbox_to_srtm_coverage( + ( + min(lat_a, lat_b), + min(lon_a, lon_b), + max(lat_a, lat_b), + max(lon_a, lon_b), + ) + ) + + +def bbox_from_node_config(node_config, origin, margin_m=1000.0): + """Build a geographic bbox around local x/y nodes when an origin exists.""" + return bbox_from_points([node.position for node in node_config], origin, margin_m) + + +def fit_simulation_bounds_to_node_config(conf, node_config, margin_m=1000.0): + """Expand movement/GUI bounds so loaded coordinates are not clamped.""" + min_x = min(node.position.x for node in node_config) - margin_m + max_x = max(node.position.x for node in node_config) + margin_m + min_y = min(node.position.y for node in node_config) - margin_m + max_y = max(node.position.y for node in node_config) + margin_m + + left = conf.OX - conf.XSIZE / 2 + right = conf.OX + conf.XSIZE / 2 + bottom = conf.OY - conf.YSIZE / 2 + top = conf.OY + conf.YSIZE / 2 + if left <= min_x and max_x <= right and bottom <= min_y and max_y <= top: + return + + conf.OX = (min_x + max_x) / 2 + conf.OY = (min_y + max_y) / 2 + conf.XSIZE = max_x - min_x + conf.YSIZE = max_y - min_y + + +def nodes_have_flat_link_budget(conf, node_a, node_b): + """Return whether two nodes can hear each other before terrain loss.""" + distance = node_a.position.euclidean_distance(node_b.position) + path_loss = estimate_path_loss( + conf, + distance, + conf.FREQ, + node_antenna_height(node_a), + node_antenna_height(node_b), + ) + sensitivity = conf.current_preset["sensitivity"] + antenna_gain_a = getattr(node_a, "antennaGain", getattr(node_a, "antenna_gain", 0)) + antenna_gain_b = getattr(node_b, "antennaGain", getattr(node_b, "antenna_gain", 0)) + tx_power_a = getattr(node_a, "tx_power", conf.PTX) + tx_power_b = getattr(node_b, "tx_power", conf.PTX) + rssi_ab = tx_power_a + antenna_gain_a + antenna_gain_b - path_loss + rssi_ba = tx_power_b + antenna_gain_b + antenna_gain_a - path_loss + return rssi_ab >= sensitivity or rssi_ba >= sensitivity + + +def srtm_tiles_for_node_config_links(conf, node_config, origin, margin_m=1000.0): + """Return SRTM tiles around nodes and flat-link candidate paths.""" + if origin is None: + return None + + tile_names = set() + for node in node_config: + bbox = bbox_from_points([node.position], origin, margin_m) + tile_names.update(tiles_for_bbox(bbox)) + + for index, node_a in enumerate(node_config): + for node_b in node_config[index + 1 :]: + if not nodes_have_flat_link_budget(conf, node_a, node_b): + continue + bbox = bbox_from_points( + [node_a.position, node_b.position], origin, margin_m + ) + tile_names.update(tiles_for_bbox(bbox)) + + return sorted(tile_names) + + def parse_params(conf, args=None) -> [NodeConfig]: """parses command-line arguments, alters global simulation config, and returns a list of node configurations, or a list of None. @@ -46,23 +174,138 @@ def parse_params(conf, args=None) -> [NodeConfig]: # loraMesh.py [nr_nodes [router_type]] | [--from-file [file_name]] # we'll replicate the intent with argparse, but more strictly, so flags like '--never--from-file' will no longer be accepted parser = argparse.ArgumentParser( - description='run a single interactive or discrete Meshtastic network simulation' - ) + description="run a single interactive or discrete Meshtastic network simulation" + ) # only allow one of --from-file optional, or nr_nodes positional exclusively group = parser.add_mutually_exclusive_group() - group.add_argument('nr_nodes', nargs='?', type=int, help='Number of nodes to generate. If unspecified, do interactive simulation') - group.add_argument('--from-file', nargs='?', const='nodeConfig.yaml', type=str, metavar='filename', help='Name of yaml file storing node config under "out/" directory. If unspecified, defaults to "nodeConfig.yaml".') + group.add_argument( + "nr_nodes", + nargs="?", + type=int, + help="Number of nodes to generate. If unspecified, do interactive simulation", + ) + group.add_argument( + "--from-file", + nargs="?", + const="nodeConfig.yaml", + type=str, + metavar="filename", + help='Name of yaml file storing node config under "out/" directory. If unspecified, defaults to "nodeConfig.yaml".', + ) + group.add_argument( + "--from-map", + nargs="?", + const=DEFAULT_MAP_NODES_URL, + type=str, + metavar="url", + help="Fetch node locations from a Meshtastic map /api/v1/nodes endpoint.", + ) + group.add_argument( + "--from-nodedb", + action="store_true", + help="Fetch positioned nodes from a local Meshtastic device NodeDB.", + ) # the earlier behavior of specifying `router_type` as an optional positional arg with `nr_nodes` is difficult to exactly # replicate with argparse, especially since nesting groups was an unintended feature and deprecated. # Just implement as an optional argument, and manually treat it as incompatible with `--from-file` - parser.add_argument('--router-type', type=conf.ROUTER_TYPE, choices=conf.ROUTER_TYPE, help='Router type to use, taken from ROUTER_TYPE enum. Omit the leading "ROUTER_TYPE". Incompatible with --from-file') - parser.add_argument('--simtime-seconds', type=float, help='Override simulation duration in seconds') - parser.add_argument('--period-seconds', type=float, help='Override mean message-generation period in seconds') - parser.add_argument('--no-gui', action='store_true', help='Run without Tk/Matplotlib graphing or schedule plotting') - parser.add_argument('--disable-connectivity-map', action='store_true', help='disable the connectivity map optimization. May be faster for some scenarios with many moving nodes and/or a densely connected network.') - parser.add_argument('-v', '--verbose', action='store_true', help='enable verbose/debug output') + parser.add_argument( + "--router-type", + type=conf.ROUTER_TYPE, + choices=conf.ROUTER_TYPE, + help='Router type to use, taken from ROUTER_TYPE enum. Omit the leading "ROUTER_TYPE". Incompatible with --from-file', + ) + parser.add_argument( + "--terrain-srtm", + action="store_true", + help="Build terrain directly from cached/downloaded SRTM tiles for the scenario bbox", + ) + parser.add_argument( + "--terrain-srtm-step-meters", + type=float, + default=1000.0, + help="SRTM terrain sample spacing in meters", + ) + parser.add_argument( + "--terrain-srtm-cache-dir", + default=str(Path.home() / ".cache" / "meshtasticator" / "srtm"), + help="where downloaded SRTM .hgt tiles are cached", + ) + parser.add_argument( + "--terrain-srtm-url-template", + default=DEFAULT_SRTM_URL_TEMPLATE, + help="SRTM download URL template with {lat_band} and {tile}", + ) + parser.add_argument( + "--terrain-srtm-offline", action="store_true", help="use cached SRTM tiles only" + ) + parser.add_argument( + "--terrain-profile-samples", + type=int, + help="number of terrain samples along each TX/RX path", + ) + parser.add_argument( + "--clutter-grid", + type=str, + help="CSV land-cover clutter grid for optional building/urban excess loss", + ) + parser.add_argument( + "--clutter-profile-samples", + type=int, + help="number of clutter samples along each TX/RX path", + ) + parser.add_argument( + "--no-clutter", + action="store_true", + help="disable land-cover clutter even when a grid is available", + ) + parser.add_argument( + "--map-bbox", + type=str, + help="Position import bounding box as min_lat,min_lon,max_lat,max_lon", + ) + parser.add_argument( + "--map-limit", + type=int, + help="Maximum number of positioned imported nodes after bbox filtering", + ) + parser.add_argument( + "--nodedb-host", + type=str, + help="Hostname or IP of a Meshtastic TCP device for --from-nodedb", + ) + parser.add_argument( + "--nodedb-port", + type=int, + help="TCP port of a Meshtastic TCP device for --from-nodedb", + ) + parser.add_argument( + "--nodedb-serial-port", + type=str, + help="Serial device path for --from-nodedb; defaults to Meshtastic auto-detection", + ) + parser.add_argument( + "--simtime-seconds", type=float, help="Override simulation duration in seconds" + ) + parser.add_argument( + "--period-seconds", + type=float, + help="Override mean message-generation period in seconds", + ) + parser.add_argument( + "--no-gui", + action="store_true", + help="Run without Tk/Matplotlib graphing or schedule plotting", + ) + parser.add_argument( + "--disable-connectivity-map", + action="store_true", + help="disable the connectivity map optimization. May be faster for some scenarios with many moving nodes and/or a densely connected network.", + ) + parser.add_argument( + "-v", "--verbose", action="store_true", help="enable verbose/debug output" + ) parsed_arguments = parser.parse_args(args) @@ -73,15 +316,44 @@ def parse_params(conf, args=None) -> [NodeConfig]: plot_enabled = cli_defaults["PLOT"] if parsed_arguments.simtime_seconds is not None: - if not math.isfinite(parsed_arguments.simtime_seconds) or parsed_arguments.simtime_seconds < MIN_TIME_OVERRIDE_SECONDS: - parser.error(f"--simtime-seconds must be at least {MIN_TIME_OVERRIDE_SECONDS} seconds") + if ( + not math.isfinite(parsed_arguments.simtime_seconds) + or parsed_arguments.simtime_seconds < MIN_TIME_OVERRIDE_SECONDS + ): + parser.error( + f"--simtime-seconds must be at least {MIN_TIME_OVERRIDE_SECONDS} seconds" + ) simtime = int(parsed_arguments.simtime_seconds * conf.ONE_SECOND_INTERVAL) if parsed_arguments.period_seconds is not None: - if not math.isfinite(parsed_arguments.period_seconds) or parsed_arguments.period_seconds < MIN_TIME_OVERRIDE_SECONDS: - parser.error(f"--period-seconds must be at least {MIN_TIME_OVERRIDE_SECONDS} seconds") + if ( + not math.isfinite(parsed_arguments.period_seconds) + or parsed_arguments.period_seconds < MIN_TIME_OVERRIDE_SECONDS + ): + parser.error( + f"--period-seconds must be at least {MIN_TIME_OVERRIDE_SECONDS} seconds" + ) period = int(parsed_arguments.period_seconds * conf.ONE_SECOND_INTERVAL) + if parsed_arguments.map_limit is not None and parsed_arguments.map_limit < 1: + parser.error("--map-limit must be at least 1") + if not math.isfinite(conf.HM) or conf.HM <= 0: + parser.error("config HM must be a positive finite antenna height") + if conf.hopLimit < 0: + parser.error("config hopLimit must be at least 0") + if ( + parsed_arguments.terrain_profile_samples is not None + and parsed_arguments.terrain_profile_samples < 2 + ): + parser.error("--terrain-profile-samples must be at least 2") + if ( + not math.isfinite(parsed_arguments.terrain_srtm_step_meters) + or parsed_arguments.terrain_srtm_step_meters <= 0 + ): + parser.error("--terrain-srtm-step-meters must be a positive finite number") + if parsed_arguments.clutter_profile_samples is not None and parsed_arguments.clutter_profile_samples < 1: + parser.error("--clutter-profile-samples must be at least 1") + if parsed_arguments.no_gui: # Headless CI and smoke runs should not pay Tk startup, per-node # plt.pause(), or the final interactive schedule plot. Keep this as an @@ -89,28 +361,117 @@ def parse_params(conf, args=None) -> [NodeConfig]: gui_enabled = False plot_enabled = False - # enforce defaulting to True - if parsed_arguments.disable_connectivity_map: - conf.ENABLE_CONNECTIVITY_MAP = False - else: - conf.ENABLE_CONNECTIVITY_MAP = True + connectivity_map_enabled = not parsed_arguments.disable_connectivity_map - if parsed_arguments.from_file is not None and parsed_arguments.router_type is not None: - parser.error("Incompatible argument selection. --from-file and --router-type can not be used together") + if ( + parsed_arguments.from_file is not None + or parsed_arguments.from_map is not None + or parsed_arguments.from_nodedb + ) and parsed_arguments.router_type is not None: + parser.error( + "Incompatible argument selection. --from-file/--from-map/--from-nodedb and --router-type can not be used together" + ) + if not parsed_arguments.from_nodedb and ( + parsed_arguments.nodedb_host is not None + or parsed_arguments.nodedb_port is not None + or parsed_arguments.nodedb_serial_port is not None + ): + parser.error("--nodedb-* options require --from-nodedb") + if ( + parsed_arguments.from_nodedb + and parsed_arguments.nodedb_port is not None + and parsed_arguments.nodedb_host is None + ): + parser.error("--nodedb-port requires --nodedb-host") + if parsed_arguments.no_clutter and parsed_arguments.clutter_grid: + parser.error("--no-clutter can not be combined with --clutter-grid") seeded_for_scenario = False + bounds_follow_node_config = False + terrain_bbox = None + terrain_tile_names = None + scenario_origin = None + terrain_grid = None + terrain_enabled = parsed_arguments.terrain_srtm + terrain_profile_samples = cli_defaults["TERRAIN_PROFILE_SAMPLES"] + node_z_reference = cli_defaults["NODE_Z_REFERENCE"] + clutter_profile_samples = cli_defaults["CLUTTER_PROFILE_SAMPLES"] + if parsed_arguments.terrain_profile_samples is not None: + terrain_profile_samples = parsed_arguments.terrain_profile_samples + if parsed_arguments.clutter_profile_samples is not None: + clutter_profile_samples = parsed_arguments.clutter_profile_samples if parsed_arguments.from_file is not None: - with open(os.path.join("out", parsed_arguments.from_file), 'r', encoding="utf-8") as file: - raw_config = yaml.load(file, Loader=yaml.FullLoader) - config = [ - # transmit power and frequency not previously saved. Use defaults from Config. - NodeConfig.from_gen_scenario_output(node_id, node_config, period, conf.PTX, conf.FREQ) - for node_id, node_config in raw_config.items() - ] + try: + if parsed_arguments.map_bbox is not None: + terrain_bbox = parse_bbox(parsed_arguments.map_bbox) + with open( + os.path.join("out", parsed_arguments.from_file), "r", encoding="utf-8" + ) as file: + raw_config = yaml.safe_load(file) + config = node_configs_from_yaml(raw_config, period, conf.PTX, conf.FREQ) + scenario_origin = origin_from_yaml(raw_config) + except (OSError, ValueError, yaml.YAMLError) as err: + parser.error(f"could not load --from-file YAML: {err}") nr_nodes = len(config) + bounds_follow_node_config = True + elif parsed_arguments.from_map is not None: + if parsed_arguments.map_bbox is None: + parser.error( + "--from-map requires --map-bbox min_lat,min_lon,max_lat,max_lon" + ) + try: + terrain_bbox = parse_bbox(parsed_arguments.map_bbox) + raw_map_payload = fetch_map_payload(parsed_arguments.from_map) + config, map_origin = node_configs_from_map_payload( + raw_map_payload, + period, + bbox=terrain_bbox, + limit=parsed_arguments.map_limit, + antenna_height=conf.HM, + hop_limit=conf.hopLimit, + tx_power=conf.PTX, + freq=conf.FREQ, + return_origin=True, + ) + scenario_origin = map_origin + except ValueError as err: + parser.error(str(err)) + nr_nodes = len(config) + bounds_follow_node_config = True + elif parsed_arguments.from_nodedb: + try: + if parsed_arguments.map_bbox is not None: + terrain_bbox = parse_bbox(parsed_arguments.map_bbox) + raw_nodedb_payload = fetch_nodedb_payload( + host=parsed_arguments.nodedb_host, + port=parsed_arguments.nodedb_port, + serial_port=parsed_arguments.nodedb_serial_port, + ) + config, nodedb_origin = node_configs_from_nodedb_payload( + raw_nodedb_payload, + period, + bbox=terrain_bbox, + limit=parsed_arguments.map_limit, + antenna_height=conf.HM, + hop_limit=conf.hopLimit, + tx_power=conf.PTX, + freq=conf.FREQ, + return_origin=True, + ) + scenario_origin = nodedb_origin + except ValueError as err: + parser.error(str(err)) + nr_nodes = len(config) + bounds_follow_node_config = True elif parsed_arguments.nr_nodes is not None: + if parsed_arguments.terrain_srtm: + parser.error( + "--terrain-srtm requires --from-map --map-bbox or a scenario file with origin metadata" + ) if parsed_arguments.nr_nodes < 2: - parser.error(f"Need at least two nodes. You specified {parsed_arguments.nr_nodes}") + parser.error( + f"Need at least two nodes. You specified {parsed_arguments.nr_nodes}" + ) nr_nodes = parsed_arguments.nr_nodes if parsed_arguments.router_type is not None: routerType = parsed_arguments.router_type @@ -124,43 +485,98 @@ def parse_params(conf, args=None) -> [NodeConfig]: seeded_for_scenario = True config = default_generate_node_list(conf) else: + if parsed_arguments.terrain_srtm: + parser.error( + "--terrain-srtm requires --from-map --map-bbox or a scenario file with origin metadata" + ) if not gui_enabled: parser.error("--no-gui requires nr_nodes or --from-file") from lib.gui import gen_scenario config_dict = gen_scenario(conf) - config = [NodeConfig.from_gen_scenario_output(node_id, cfg, period) for node_id, cfg in config_dict.items()] + config = [NodeConfig.from_gen_scenario_output(node_id, cfg, period, conf.PTX, conf.FREQ) for node_id, cfg in config_dict.items()] nr_nodes = len(config) if nr_nodes < 2: parser.error(f"Need at least two nodes. You specified {nr_nodes}") + if parsed_arguments.terrain_srtm and terrain_bbox is None: + try: + terrain_bbox = bbox_from_node_config(config, scenario_origin) + terrain_tile_names = srtm_tiles_for_node_config_links( + conf, config, scenario_origin + ) + except ValueError as err: + parser.error(f"could not derive SRTM terrain bbox: {err}") + if terrain_bbox is None: + parser.error( + "--terrain-srtm requires --from-map --map-bbox or a scenario file with origin metadata" + ) + if parsed_arguments.terrain_srtm and scenario_origin is None: + parser.error( + "--terrain-srtm requires --from-map/--from-nodedb or a scenario file with origin metadata" + ) + + if parsed_arguments.terrain_srtm: + try: + origin_lat, origin_lon = scenario_origin + terrain_grid = terrain_grid_from_srtm( + terrain_bbox, + parsed_arguments.terrain_srtm_step_meters, + parsed_arguments.terrain_srtm_cache_dir, + origin_lat, + origin_lon, + parsed_arguments.terrain_srtm_url_template, + download_missing=not parsed_arguments.terrain_srtm_offline, + tile_names=terrain_tile_names, + ) + apply_terrain_altitudes(terrain_grid, config) + node_z_reference = NODE_Z_REFERENCE_SEA_LEVEL + except (OSError, ValueError) as err: + parser.error(f"could not load SRTM terrain: {err}") + if not seeded_for_scenario: # Loaded and interactive scenarios do not need random state for node - # placement, but the later MAC/PHY simulation does. Seed only after - # successful scenario loading so rejected inputs leave caller RNG state - # alone. + # placement, but the later MAC/PHY simulation does. Seed only after all + # parser rejections so failed inputs leave caller RNG state alone. random.seed(conf.SEED) + if bounds_follow_node_config: + fit_simulation_bounds_to_node_config(conf, config) + conf.SIMTIME = simtime conf.PERIOD = period conf.GUI_ENABLED = gui_enabled conf.PLOT = plot_enabled conf.NR_NODES = nr_nodes + conf.ENABLE_CONNECTIVITY_MAP = connectivity_map_enabled + set_geo_origin(conf, scenario_origin) + conf.TERRAIN_ENABLED = terrain_enabled + conf.TERRAIN_GRID = terrain_grid + conf.TERRAIN_PROFILE_SAMPLES = terrain_profile_samples + conf.NODE_Z_REFERENCE = node_z_reference + conf.CLUTTER_ENABLED = parsed_arguments.clutter_grid is not None and not parsed_arguments.no_clutter + conf.CLUTTER_GRID_FILE = parsed_arguments.clutter_grid + conf.CLUTTER_PROFILE_SAMPLES = clutter_profile_samples if parsed_arguments.verbose: # Set this logger and lib.* to DEBUG only after the command line has # resolved into a usable scenario. Failed parser inputs should not leave # imported callers with noisier logging. logger.setLevel(logging.DEBUG) - lib_logger = logging.getLogger('lib') + lib_logger = logging.getLogger("lib") lib_logger.setLevel(logging.DEBUG) print("verbose output enabled") print("Number of nodes:", conf.NR_NODES) print("Modem:", conf.MODEM_PRESET) - print("Simulation time (s):", conf.SIMTIME/1000) - print("Period (s):", conf.PERIOD/1000) + print("Simulation time (s):", conf.SIMTIME / 1000) + print("Period (s):", conf.PERIOD / 1000) print("Interference level:", conf.INTERFERENCE_LEVEL) + if conf.TERRAIN_ENABLED: + print( + "Terrain data attribution:", + f"{SRTM_DATA_ATTRIBUTION} ({SRTM_DATA_ATTRIBUTION_URL})", + ) return config @@ -196,39 +612,44 @@ def run_simulation(conf, node_config): messages = results["messages"] # collect second-order results from finalized results - sent = results['sent'] - potentialReceivers = results['potentialReceivers'] - nrCollisions = results['nrCollisions'] - nrSensed = results['nrSensed'] - nrReceived = results['nrReceived'] - meanDelay = results['meanDelay'] - txAirUtilizationRate = results['txAirUtilizationRate'] - collisionRate = results['collisionRate'] - nodeReach = results['nodeReach'] - usefulness = results['usefulness'] - delayDropped = results['delayDropped'] + sent = results["sent"] + potentialReceivers = results["potentialReceivers"] + nrCollisions = results["nrCollisions"] + nrSensed = results["nrSensed"] + nrReceived = results["nrReceived"] + meanDelay = results["meanDelay"] + txAirUtilizationRate = results["txAirUtilizationRate"] + collisionRate = results["collisionRate"] + nodeReach = results["nodeReach"] + usefulness = results["usefulness"] + delayDropped = results["delayDropped"] print("*******************************") print(f"\nRouter Type: {conf.SELECTED_ROUTER_TYPE}") - print('Number of messages created:', messageSeq) - print('Number of packets sent:', sent, 'to', potentialReceivers, 'potential receivers') + print("Number of messages created:", messageSeq) + print( + "Number of packets sent:", sent, "to", potentialReceivers, "potential receivers" + ) print("Number of collisions:", nrCollisions) print("Number of packets sensed:", nrSensed) print("Number of packets received:", nrReceived) - print('Delay average (ms):', round(meanDelay, 2)) - print('Average Tx air utilization:', round(txAirUtilizationRate * 100, 2), '%') - print("Percentage of packets that collided:", round(collisionRate*100, 2)) - print("Average percentage of nodes reached:", round(nodeReach*100, 2)) - print("Percentage of received packets containing new message:", round(usefulness*100, 2)) + print("Delay average (ms):", round(meanDelay, 2)) + print("Average Tx air utilization:", round(txAirUtilizationRate * 100, 2), "%") + print("Percentage of packets that collided:", round(collisionRate * 100, 2)) + print("Average percentage of nodes reached:", round(nodeReach * 100, 2)) + print( + "Percentage of received packets containing new message:", + round(usefulness * 100, 2), + ) print("Number of packets dropped by delay/hop limit:", delayDropped) if conf.MODEL_ASYMMETRIC_LINKS: - noLinkRate = results['noLinkRate'] - print("No links:", round(noLinkRate * 100, 2), '%') + noLinkRate = results["noLinkRate"] + print("No links:", round(noLinkRate * 100, 2), "%") if conf.MOVEMENT_ENABLED: - movingNodes = results['movingNodes'] - gpsEnabled = results['gpsEnabled'] + movingNodes = results["movingNodes"] + gpsEnabled = results["gpsEnabled"] print("Number of moving nodes:", movingNodes) print("Number of moving nodes w/ GPS:", gpsEnabled) diff --git a/tests/test_clutter.py b/tests/test_clutter.py new file mode 100644 index 00000000..8c018b98 --- /dev/null +++ b/tests/test_clutter.py @@ -0,0 +1,139 @@ +import tempfile +import unittest +from pathlib import Path + +from lib.clutter import ClutterGrid, clutter_obstruction_loss, clutter_path_features +from lib.config import Config +from lib.point import Point + + +class TestClutter(unittest.TestCase): + def test_csv_grid_returns_nearest_regular_cell_class(self): + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "clutter.csv" + path.write_text( + "x_m,y_m,clutter_class\n" + "0,0,urban\n" + "500,0,water\n", + encoding="utf-8", + ) + + grid = ClutterGrid.from_csv(path) + + self.assertEqual(grid.class_at(20, 0), "urban") + self.assertEqual(grid.class_at(480, 0), "water") + + def test_csv_grid_rejects_non_finite_samples(self): + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "clutter.csv" + path.write_text( + "x_m,y_m,clutter_class\n" + "0,nan,urban\n", + encoding="utf-8", + ) + + with self.assertRaisesRegex(ValueError, "row 2"): + ClutterGrid.from_csv(path) + + def test_csv_grid_rejects_blank_clutter_class(self): + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "clutter.csv" + path.write_text( + "x_m,y_m,clutter_class\n" + "0,0, \n", + encoding="utf-8", + ) + + with self.assertRaisesRegex(ValueError, "clutter_class"): + ClutterGrid.from_csv(path) + + def test_latlon_csv_grid_rejects_out_of_range_samples(self): + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "clutter.csv" + path.write_text( + "lat,lon,clutter_class\n" + "91,41,urban\n", + encoding="utf-8", + ) + + with self.assertRaisesRegex(ValueError, "latitude/longitude"): + ClutterGrid.from_csv(path, origin_lat=41.0, origin_lon=41.0) + + def test_urban_clutter_adds_more_loss_than_coastal_open_path(self): + conf = Config() + conf.CLUTTER_ENABLED = True + conf.CLUTTER_PROFILE_SAMPLES = 4 + + with tempfile.TemporaryDirectory() as tmpdir: + urban_path = Path(tmpdir) / "urban.csv" + urban_path.write_text( + "x_m,y_m,clutter_class\n" + "0,0,urban\n" + "500,0,urban\n" + "1000,0,urban\n", + encoding="utf-8", + ) + conf.CLUTTER_GRID_FILE = str(urban_path) + + urban_loss = clutter_obstruction_loss(conf, Point(0, 0, 2), Point(1000, 0, 2)) + + open_path = Path(tmpdir) / "open.csv" + open_path.write_text( + "x_m,y_m,clutter_class\n" + "0,0,water\n" + "500,0,water\n" + "1000,0,water\n", + encoding="utf-8", + ) + conf._clutter_grid = None + conf._clutter_loss_cache = {} + conf.CLUTTER_GRID_FILE = str(open_path) + + open_loss = clutter_obstruction_loss(conf, Point(0, 0, 2), Point(1000, 0, 2)) + + self.assertGreater(urban_loss, open_loss) + + def test_latlon_grid_cache_tracks_projection_origin(self): + conf = Config() + conf.CLUTTER_ENABLED = True + conf.CLUTTER_PROFILE_SAMPLES = 1 + + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "clutter.csv" + path.write_text( + "lat,lon,clutter_class\n" + "10.0,10.0,urban\n" + "10.0,10.01,water\n", + encoding="utf-8", + ) + conf.CLUTTER_GRID_FILE = str(path) + conf.GEO_ORIGIN_LAT = 10.0 + conf.GEO_ORIGIN_LON = 10.0 + + first = clutter_path_features(conf, Point(0, 0, 1), Point(0, 0, 1)) + self.assertEqual(first["urban_fraction"], 1.0) + + # The same CSV can be projected around a different scenario origin. + # Include origin in the grid cache key so map/preset inputs cannot + # accidentally reuse stale cell coordinates. + conf.GEO_ORIGIN_LON = 10.01 + second = clutter_path_features(conf, Point(0, 0, 1), Point(0, 0, 1)) + self.assertEqual(second["water_fraction"], 1.0) + + def test_exported_xy_latlon_grid_reprojects_to_current_origin(self): + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "clutter.csv" + path.write_text( + "x_m,y_m,lat,lon,clutter_class\n" + "0,0,10.0,10.0,urban\n" + "0,0,10.0,10.01,water\n", + encoding="utf-8", + ) + + grid = ClutterGrid.from_csv(path, origin_lat=10.0, origin_lon=10.01) + + self.assertEqual(grid.class_at(0, 0), "water") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_lora_mesh_cli.py b/tests/test_lora_mesh_cli.py index 4c3e369b..e79b63f7 100644 --- a/tests/test_lora_mesh_cli.py +++ b/tests/test_lora_mesh_cli.py @@ -8,15 +8,33 @@ import tempfile import textwrap import unittest +from array import array +from pathlib import Path +from unittest import mock from lib.config import Config +from lib.node import NodeConfig +from lib.point import Point +from lib.srtm import SRTM_DATA_ATTRIBUTION_URL +from lib.terrain import NODE_Z_REFERENCE_GROUND, NODE_Z_REFERENCE_SEA_LEVEL, TerrainGrid import loraMesh +def write_hgt(path, values): + data = array("h", values) + if sys.byteorder == "little": + data.byteswap() + path.write_bytes(data.tobytes()) + + def generated_positions(node_configs): return [ - (round(node.position.x, 6), round(node.position.y, 6), round(node.position.z, 6)) + ( + round(node.position.x, 6), + round(node.position.y, 6), + round(node.position.z, 6), + ) for node in node_configs ] @@ -85,7 +103,9 @@ def test_parse_params_reuses_initial_defaults_after_override_run(self): self.assertTrue(conf.PLOT) self.assertEqual(conf.SIMTIME, default_simtime) self.assertEqual(conf.PERIOD, default_period) - self.assertEqual([node.period for node in nodes], [default_period, default_period]) + self.assertEqual( + [node.period for node in nodes], [default_period, default_period] + ) def test_parse_params_preserves_caller_initial_defaults(self): conf = Config() @@ -94,7 +114,9 @@ def test_parse_params_preserves_caller_initial_defaults(self): conf.GUI_ENABLED = False conf.PLOT = False - self.parse_quietly(conf, ["2", "--simtime-seconds", "1", "--period-seconds", "0.5"]) + self.parse_quietly( + conf, ["2", "--simtime-seconds", "1", "--period-seconds", "0.5"] + ) nodes, _ = self.parse_quietly(conf, ["2"]) self.assertFalse(conf.GUI_ENABLED) @@ -106,8 +128,12 @@ def test_parse_params_preserves_caller_initial_defaults(self): def test_parse_params_rejects_sub_centisecond_time_overrides(self): conf = Config() - simtime_error = self.assert_parser_rejects(conf, ["2", "--no-gui", "--simtime-seconds", "0.009"]) - period_error = self.assert_parser_rejects(conf, ["2", "--no-gui", "--period-seconds", "0.009"]) + simtime_error = self.assert_parser_rejects( + conf, ["2", "--no-gui", "--simtime-seconds", "0.009"] + ) + period_error = self.assert_parser_rejects( + conf, ["2", "--no-gui", "--period-seconds", "0.009"] + ) self.assertIn("--simtime-seconds must be at least 0.01 seconds", simtime_error) self.assertIn("--period-seconds must be at least 0.01 seconds", period_error) @@ -169,7 +195,9 @@ def test_parse_params_loads_from_file_as_node_configs(self): ) os.makedirs("out", exist_ok=True) - with tempfile.NamedTemporaryFile("w", dir="out", suffix=".yaml", delete=False, encoding="utf-8") as scenario_file: + with tempfile.NamedTemporaryFile( + "w", dir="out", suffix=".yaml", delete=False, encoding="utf-8" + ) as scenario_file: scenario_file.write(scenario) scenario_filename = os.path.basename(scenario_file.name) @@ -185,6 +213,757 @@ def test_parse_params_loads_from_file_as_node_configs(self): self.assertEqual([node.period for node in nodes], [2000, 2000]) self.assertEqual(conf.NR_NODES, 2) + def test_parse_params_loads_from_map_payload(self): + conf = Config() + conf.HM = 2.5 + conf.hopLimit = 5 + payload = [ + { + "latitude": 416200000, + "longitude": 415900000, + "role": 2, + }, + { + "latitude": 416300000, + "longitude": 416000000, + "role": 0, + }, + ] + + with mock.patch("loraMesh.fetch_map_payload", return_value=payload): + nodes, _ = self.parse_quietly( + conf, + [ + "--from-map", + "https://example.test/nodes", + "--map-bbox", + "41.0,41.0,42.0,42.0", + "--no-gui", + ], + ) + + self.assertEqual(len(nodes), 2) + self.assertEqual([node.position.z for node in nodes], [2.5, 2.5]) + self.assertEqual([node.antenna_height for node in nodes], [2.5, 2.5]) + self.assertEqual([node.hop_limit for node in nodes], [5, 5]) + self.assertEqual((conf.GEO_ORIGIN_LAT, conf.GEO_ORIGIN_LON), (41.625, 41.595)) + + def test_parse_params_expands_bounds_for_wide_map_payload(self): + conf = Config() + payload = [ + { + "latitude": 416200000, + "longitude": 414000000, + "role": 2, + }, + { + "latitude": 416300000, + "longitude": 418500000, + "role": 0, + }, + ] + + with mock.patch("loraMesh.fetch_map_payload", return_value=payload): + nodes, _ = self.parse_quietly( + conf, + [ + "--from-map", + "https://example.test/nodes", + "--map-bbox", + "41.0,41.0,42.0,42.0", + "--no-gui", + ], + ) + + left = conf.OX - conf.XSIZE / 2 + right = conf.OX + conf.XSIZE / 2 + bottom = conf.OY - conf.YSIZE / 2 + top = conf.OY + conf.YSIZE / 2 + self.assertGreater(conf.XSIZE, 15000) + for node in nodes: + self.assertGreaterEqual(node.position.x, left) + self.assertLessEqual(node.position.x, right) + self.assertGreaterEqual(node.position.y, bottom) + self.assertLessEqual(node.position.y, top) + + def test_parse_params_preserves_sufficient_caller_bounds_for_map_payload(self): + conf = Config() + conf.OX = 1000 + conf.OY = -2000 + conf.XSIZE = 100000 + conf.YSIZE = 100000 + payload = [ + { + "latitude": 416200000, + "longitude": 415900000, + "role": 2, + }, + { + "latitude": 416300000, + "longitude": 416000000, + "role": 0, + }, + ] + + with mock.patch("loraMesh.fetch_map_payload", return_value=payload): + self.parse_quietly( + conf, + [ + "--from-map", + "https://example.test/nodes", + "--map-bbox", + "41.0,41.0,42.0,42.0", + "--no-gui", + ], + ) + + self.assertEqual(conf.OX, 1000) + self.assertEqual(conf.OY, -2000) + self.assertEqual(conf.XSIZE, 100000) + self.assertEqual(conf.YSIZE, 100000) + + def test_parse_params_loads_from_nodedb_payload(self): + conf = Config() + conf.HM = 2.5 + conf.hopLimit = 5 + payload = { + "nodesByNum": { + 1: { + "num": 1, + "user": {"id": "!00000001", "role": "ROUTER"}, + "position": { + "latitude": 41.62, + "longitude": 41.59, + "altitude": 120, + }, + }, + 2: { + "num": 2, + "user": {"id": "!00000002", "role": "CLIENT"}, + "position": {"latitudeI": 416300000, "longitudeI": 416000000}, + }, + } + } + + with mock.patch( + "loraMesh.fetch_nodedb_payload", return_value=payload + ) as fetch_nodedb: + nodes, _ = self.parse_quietly( + conf, + [ + "--from-nodedb", + "--nodedb-host", + "192.0.2.10", + "--map-bbox", + "41.0,41.0,42.0,42.0", + "--no-gui", + ], + ) + + fetch_nodedb.assert_called_once_with( + host="192.0.2.10", port=None, serial_port=None + ) + self.assertEqual(len(nodes), 2) + self.assertEqual([node.position.z for node in nodes], [2.5, 2.5]) + self.assertEqual([node.antenna_height for node in nodes], [2.5, 2.5]) + self.assertEqual([node.hop_limit for node in nodes], [5, 5]) + self.assertEqual((conf.GEO_ORIGIN_LAT, conf.GEO_ORIGIN_LON), (41.625, 41.595)) + + def test_parse_params_rejects_nodedb_transport_without_nodedb_source(self): + conf = Config() + + error = self.assert_parser_rejects(conf, ["2", "--nodedb-host", "192.0.2.10"]) + + self.assertIn("--nodedb-* options require --from-nodedb", error) + + def test_parse_params_rejects_nodedb_port_without_host(self): + conf = Config() + + error = self.assert_parser_rejects( + conf, ["--from-nodedb", "--nodedb-port", "4404"] + ) + + self.assertIn("--nodedb-port requires --nodedb-host", error) + + def test_parse_params_can_build_srtm_terrain_for_map_payload(self): + conf = Config() + payload = [ + { + "latitude": 416200000, + "longitude": 415900000, + "altitude": 500, + "role": 2, + }, + { + "latitude": 416300000, + "longitude": 416000000, + "role": 0, + }, + ] + + with tempfile.TemporaryDirectory() as tmpdir: + source_dir = tempfile.TemporaryDirectory() + self.addCleanup(source_dir.cleanup) + source_path = Path(source_dir.name) / "N41E041.hgt" + write_hgt( + source_path, + [10, 20, 30, 40, 50, 60, 70, 80, 90], + ) + + with mock.patch("loraMesh.fetch_map_payload", return_value=payload): + nodes, output = self.parse_quietly( + conf, + [ + "--from-map", + "https://example.test/nodes", + "--map-bbox", + "41.5,41.5,41.8,41.8", + "--terrain-srtm", + "--terrain-srtm-step-meters", + "20000", + "--terrain-srtm-cache-dir", + tmpdir, + "--terrain-srtm-url-template", + f"{Path(source_dir.name).as_uri()}/{{tile}}.hgt", + "--no-gui", + ], + ) + + self.assertEqual(len(nodes), 2) + self.assertTrue(conf.TERRAIN_ENABLED) + self.assertIsNotNone(conf.TERRAIN_GRID) + self.assertGreater(len(conf.TERRAIN_GRID.samples), 0) + self.assertEqual(conf.NODE_Z_REFERENCE, NODE_Z_REFERENCE_SEA_LEVEL) + self.assertIn("Terrain data attribution:", output) + self.assertIn(SRTM_DATA_ATTRIBUTION_URL, output) + self.assertEqual(nodes[0].position.z, 500) + self.assertNotEqual(nodes[0].position.z, nodes[1].position.z) + self.assertGreater(nodes[1].position.z, conf.HM) + self.assertEqual([node.antenna_height for node in nodes], [conf.HM, conf.HM]) + + def test_parse_params_ignores_map_altitude_when_applying_srtm(self): + conf = Config() + conf.HM = 2.5 + payload = [ + { + "latitude": -16400000, + "longitude": -26400000, + "altitude": None, + "role": 0, + }, + { + "latitude": -16350000, + "longitude": -26350000, + "altitude": -1, + "role": 0, + }, + { + "latitude": -16300000, + "longitude": -26300000, + "altitude": 42949649, + "role": 0, + }, + ] + + with tempfile.TemporaryDirectory() as tmpdir: + source_dir = tempfile.TemporaryDirectory() + self.addCleanup(source_dir.cleanup) + source_path = Path(source_dir.name) / "S02W003.hgt" + write_hgt( + source_path, + [100, 110, 120, 130, 140, 150, 160, 170, 180], + ) + + with mock.patch("loraMesh.fetch_map_payload", return_value=payload): + nodes, _ = self.parse_quietly( + conf, + [ + "--from-map", + "https://example.test/nodes", + "--map-bbox=-1.7,-2.7,-1.2,-2.2", + "--terrain-srtm", + "--terrain-srtm-step-meters", + "20000", + "--terrain-srtm-cache-dir", + tmpdir, + "--terrain-srtm-url-template", + f"{Path(source_dir.name).as_uri()}/{{tile}}.hgt", + "--no-gui", + ], + ) + + self.assertEqual(len(nodes), 3) + self.assertEqual(conf.NODE_Z_REFERENCE, NODE_Z_REFERENCE_SEA_LEVEL) + self.assertEqual([node.antenna_height for node in nodes], [2.5, 2.5, 2.5]) + self.assertTrue(all(100 < node.position.z < 190 for node in nodes)) + self.assertNotIn(-1, [node.position.z for node in nodes]) + self.assertNotIn(42949649, [node.position.z for node in nodes]) + + def test_parse_params_clears_geo_origin_for_scenarios_without_origin(self): + conf = Config() + conf.GEO_ORIGIN_LAT = 41.625 + conf.GEO_ORIGIN_LON = 41.595 + scenario = textwrap.dedent( + """\ + nodes: + 3944424993: + x: 0 + y: 0 + z: 1 + isRouter: false + isRepeater: false + isClientMute: false + antennaGain: 0 + hopLimit: 3 + neighborInfo: false + 3944424994: + x: 10 + y: 0 + z: 1 + isRouter: false + isRepeater: false + isClientMute: false + antennaGain: 0 + hopLimit: 3 + neighborInfo: false + """ + ) + + os.makedirs("out", exist_ok=True) + with tempfile.NamedTemporaryFile( + "w", dir="out", suffix=".yaml", delete=False, encoding="utf-8" + ) as scenario_file: + scenario_file.write(scenario) + scenario_filename = os.path.basename(scenario_file.name) + + try: + nodes, _ = self.parse_quietly( + conf, ["--from-file", scenario_filename, "--no-gui"] + ) + finally: + os.unlink(os.path.join("out", scenario_filename)) + + self.assertEqual([node.node_id for node in nodes], [0, 1]) + self.assertIsNone(conf.GEO_ORIGIN_LAT) + self.assertIsNone(conf.GEO_ORIGIN_LON) + + def test_auto_srtm_tile_selection_skips_unreachable_link_corridors(self): + conf = Config() + nodes = [ + NodeConfig(0, Point(0, 0, conf.HM), conf.PERIOD), + NodeConfig(1, Point(300000, 0, conf.HM), conf.PERIOD), + ] + + tiles = loraMesh.srtm_tiles_for_node_config_links( + conf, nodes, (0.0, 0.0), margin_m=1.0 + ) + + self.assertIn("N00E000", tiles) + self.assertIn("N00E002", tiles) + self.assertNotIn("N00E001", tiles) + + def test_flat_link_budget_prefilter_includes_both_antenna_gains(self): + conf = Config() + node_a = NodeConfig( + 0, + Point(0, 0, conf.HM), + conf.PERIOD, + conf.PTX, + conf.FREQ, + antenna_gain=10, + ) + node_b = NodeConfig( + 1, + Point(5000, 0, conf.HM), + conf.PERIOD, + conf.PTX, + conf.FREQ, + antenna_gain=10, + ) + + self.assertTrue(loraMesh.nodes_have_flat_link_budget(conf, node_a, node_b)) + + def test_parse_params_rejects_one_node_before_changing_geo_origin(self): + conf = Config() + conf.GEO_ORIGIN_LAT = 41.625 + conf.GEO_ORIGIN_LON = 41.595 + scenario = textwrap.dedent( + """\ + origin: + latitude: 42.0 + longitude: 42.0 + nodes: + 3944424993: + x: 0 + y: 0 + z: 1 + isRouter: false + isRepeater: false + isClientMute: false + antennaGain: 0 + hopLimit: 3 + neighborInfo: false + """ + ) + + os.makedirs("out", exist_ok=True) + with tempfile.NamedTemporaryFile( + "w", dir="out", suffix=".yaml", delete=False, encoding="utf-8" + ) as scenario_file: + scenario_file.write(scenario) + scenario_filename = os.path.basename(scenario_file.name) + + try: + self.assert_parser_rejects( + conf, ["--from-file", scenario_filename, "--no-gui"] + ) + finally: + os.unlink(os.path.join("out", scenario_filename)) + + self.assertEqual((conf.GEO_ORIGIN_LAT, conf.GEO_ORIGIN_LON), (41.625, 41.595)) + + def test_terrain_srtm_generated_scenario_rejects_before_config_mutation(self): + conf = Config() + original_simtime = conf.SIMTIME + original_period = conf.PERIOD + random.seed(12345) + state_before = random.getstate() + + error = self.assert_parser_rejects( + conf, + [ + "2", + "--terrain-srtm", + "--simtime-seconds", + "1", + "--period-seconds", + "2", + "--no-gui", + ], + ) + + self.assertIn("--terrain-srtm requires", error) + self.assertEqual(conf.SIMTIME, original_simtime) + self.assertEqual(conf.PERIOD, original_period) + self.assertTrue(conf.GUI_ENABLED) + self.assertTrue(conf.PLOT) + self.assertIsNone(conf.NR_NODES) + self.assertFalse(conf.TERRAIN_ENABLED) + self.assertEqual(random.getstate(), state_before) + + def test_rejected_disable_connectivity_map_keeps_previous_config(self): + conf = Config() + conf.ENABLE_CONNECTIVITY_MAP = True + + self.assert_parser_rejects(conf, ["2", "--terrain-srtm", "--disable-connectivity-map", "--no-gui"]) + + self.assertTrue(conf.ENABLE_CONNECTIVITY_MAP) + + def test_terrain_srtm_from_file_rejects_uncovered_bbox_before_config_mutation(self): + conf = Config() + conf.TERRAIN_ENABLED = True + terrain_grid = object() + conf.TERRAIN_GRID = terrain_grid + conf.GEO_ORIGIN_LAT = 41.625 + conf.GEO_ORIGIN_LON = 41.595 + random.seed(12345) + state_before = random.getstate() + scenario = textwrap.dedent( + """\ + origin: + lat: 85.0 + lon: 42.0 + nodes: + 3944424993: + x: 0 + y: 0 + z: 1 + isRouter: false + isRepeater: false + isClientMute: false + antennaGain: 0 + hopLimit: 3 + neighborInfo: false + 3944424994: + x: 10 + y: 0 + z: 1 + isRouter: false + isRepeater: false + isClientMute: false + antennaGain: 0 + hopLimit: 3 + neighborInfo: false + """ + ) + + os.makedirs("out", exist_ok=True) + with tempfile.NamedTemporaryFile( + "w", dir="out", suffix=".yaml", delete=False, encoding="utf-8" + ) as scenario_file: + scenario_file.write(scenario) + scenario_filename = os.path.basename(scenario_file.name) + + try: + error = self.assert_parser_rejects( + conf, ["--from-file", scenario_filename, "--terrain-srtm", "--no-gui"] + ) + finally: + os.unlink(os.path.join("out", scenario_filename)) + + self.assertIn("could not derive SRTM terrain bbox", error) + self.assertTrue(conf.TERRAIN_ENABLED) + self.assertIs(conf.TERRAIN_GRID, terrain_grid) + self.assertEqual((conf.GEO_ORIGIN_LAT, conf.GEO_ORIGIN_LON), (41.625, 41.595)) + self.assertEqual(random.getstate(), state_before) + + def test_terrain_srtm_from_legacy_file_with_bbox_still_requires_origin(self): + conf = Config() + scenario = textwrap.dedent( + """\ + 0: + x: 0 + y: 0 + z: 1 + isRouter: false + isRepeater: false + isClientMute: false + antennaGain: 0 + hopLimit: 3 + neighborInfo: false + 1: + x: 10 + y: 0 + z: 1 + isRouter: false + isRepeater: false + isClientMute: false + antennaGain: 0 + hopLimit: 3 + neighborInfo: false + """ + ) + + os.makedirs("out", exist_ok=True) + with tempfile.NamedTemporaryFile( + "w", dir="out", suffix=".yaml", delete=False, encoding="utf-8" + ) as scenario_file: + scenario_file.write(scenario) + scenario_filename = os.path.basename(scenario_file.name) + + try: + error = self.assert_parser_rejects( + conf, + [ + "--from-file", + scenario_filename, + "--terrain-srtm", + "--map-bbox", + "41.5,41.5,41.8,41.8", + "--no-gui", + ], + ) + finally: + os.unlink(os.path.join("out", scenario_filename)) + + self.assertIn("--terrain-srtm requires", error) + self.assertFalse(conf.TERRAIN_ENABLED) + + def test_terrain_srtm_from_file_honors_explicit_bbox(self): + conf = Config() + scenario = textwrap.dedent( + """\ + origin: + lat: 41.62 + lon: 41.59 + nodes: + 0: + x: 0 + y: 0 + z: 1 + isRouter: false + isRepeater: false + isClientMute: false + antennaGain: 0 + hopLimit: 3 + neighborInfo: false + 1: + x: 10 + y: 0 + z: 1 + isRouter: false + isRepeater: false + isClientMute: false + antennaGain: 0 + hopLimit: 3 + neighborInfo: false + """ + ) + + os.makedirs("out", exist_ok=True) + with tempfile.NamedTemporaryFile( + "w", dir="out", suffix=".yaml", delete=False, encoding="utf-8" + ) as scenario_file: + scenario_file.write(scenario) + scenario_filename = os.path.basename(scenario_file.name) + + terrain_grid = TerrainGrid.from_rows([(0, 0, 10), (10, 0, 10)]) + try: + with mock.patch("loraMesh.terrain_grid_from_srtm", return_value=terrain_grid) as terrain_loader: + self.parse_quietly( + conf, + [ + "--from-file", + scenario_filename, + "--terrain-srtm", + "--map-bbox", + "41.5,41.5,41.8,41.8", + "--no-gui", + ], + ) + finally: + os.unlink(os.path.join("out", scenario_filename)) + + self.assertEqual(terrain_loader.call_args.args[0], (41.5, 41.5, 41.8, 41.8)) + + def test_failed_srtm_load_keeps_previous_terrain_config(self): + conf = Config() + terrain_grid = object() + conf.TERRAIN_ENABLED = True + conf.TERRAIN_GRID = terrain_grid + conf.TERRAIN_PROFILE_SAMPLES = 7 + conf.NODE_Z_REFERENCE = NODE_Z_REFERENCE_SEA_LEVEL + conf.GEO_ORIGIN_LAT = 41.625 + conf.GEO_ORIGIN_LON = 41.595 + conf.OX = 123 + conf.OY = 456 + conf.XSIZE = 789 + conf.YSIZE = 987 + random.seed(12345) + state_before = random.getstate() + payload = [ + { + "latitude": 416200000, + "longitude": 415900000, + "role": 2, + }, + { + "latitude": 416300000, + "longitude": 416000000, + "role": 0, + }, + ] + + with tempfile.TemporaryDirectory() as tmpdir: + with mock.patch("loraMesh.fetch_map_payload", return_value=payload): + error = self.assert_parser_rejects( + conf, + [ + "--from-map", + "https://example.test/nodes", + "--map-bbox", + "41.5,41.5,41.8,41.8", + "--terrain-srtm", + "--terrain-srtm-offline", + "--terrain-srtm-cache-dir", + tmpdir, + "--terrain-profile-samples", + "12", + "--no-gui", + ], + ) + + self.assertIn("could not load SRTM terrain", error) + self.assertTrue(conf.TERRAIN_ENABLED) + self.assertIs(conf.TERRAIN_GRID, terrain_grid) + self.assertEqual(conf.TERRAIN_PROFILE_SAMPLES, 7) + self.assertEqual(conf.NODE_Z_REFERENCE, NODE_Z_REFERENCE_SEA_LEVEL) + self.assertEqual((conf.GEO_ORIGIN_LAT, conf.GEO_ORIGIN_LON), (41.625, 41.595)) + self.assertEqual((conf.OX, conf.OY, conf.XSIZE, conf.YSIZE), (123, 456, 789, 987)) + self.assertEqual(random.getstate(), state_before) + + def test_terrain_profile_samples_resets_between_parse_calls(self): + conf = Config() + conf.TERRAIN_PROFILE_SAMPLES = 31 + payload = [ + { + "latitude": 416200000, + "longitude": 415900000, + "role": 2, + }, + { + "latitude": 416300000, + "longitude": 416000000, + "role": 0, + }, + ] + + with tempfile.TemporaryDirectory() as tmpdir: + source_dir = tempfile.TemporaryDirectory() + self.addCleanup(source_dir.cleanup) + source_path = Path(source_dir.name) / "N41E041.hgt" + write_hgt(source_path, [10, 20, 30, 40, 50, 60, 70, 80, 90]) + terrain_args = [ + "--from-map", + "https://example.test/nodes", + "--map-bbox", + "41.5,41.5,41.8,41.8", + "--terrain-srtm", + "--terrain-srtm-step-meters", + "20000", + "--terrain-srtm-cache-dir", + tmpdir, + "--terrain-srtm-url-template", + f"{Path(source_dir.name).as_uri()}/{{tile}}.hgt", + "--no-gui", + ] + + with mock.patch("loraMesh.fetch_map_payload", return_value=payload): + self.parse_quietly( + conf, [*terrain_args, "--terrain-profile-samples", "7"] + ) + self.assertEqual(conf.TERRAIN_PROFILE_SAMPLES, 7) + + self.parse_quietly(conf, terrain_args) + + self.assertEqual(conf.TERRAIN_PROFILE_SAMPLES, 31) + self.assertEqual(conf.NODE_Z_REFERENCE, NODE_Z_REFERENCE_SEA_LEVEL) + + def test_clutter_profile_samples_resets_between_parse_calls(self): + conf = Config() + conf.CLUTTER_PROFILE_SAMPLES = 13 + + with tempfile.TemporaryDirectory() as tmpdir: + clutter_path = Path(tmpdir) / "clutter.csv" + clutter_path.write_text( + "x_m,y_m,clutter_class\n" + "0,0,urban\n", + encoding="utf-8", + ) + + base_args = ["2", "--clutter-grid", str(clutter_path), "--no-gui"] + self.parse_quietly(conf, [*base_args, "--clutter-profile-samples", "5"]) + self.assertEqual(conf.CLUTTER_PROFILE_SAMPLES, 5) + + self.parse_quietly(conf, base_args) + + self.assertEqual(conf.CLUTTER_PROFILE_SAMPLES, 13) + + def test_successful_plain_parse_clears_previous_terrain_state(self): + conf = Config() + loraMesh.get_cli_defaults(conf) + conf.TERRAIN_ENABLED = True + conf.TERRAIN_GRID = object() + conf.TERRAIN_PROFILE_SAMPLES = 7 + conf.NODE_Z_REFERENCE = NODE_Z_REFERENCE_SEA_LEVEL + + self.parse_quietly(conf, ["2", "--no-gui"]) + + self.assertFalse(conf.TERRAIN_ENABLED) + self.assertIsNone(conf.TERRAIN_GRID) + self.assertEqual(conf.TERRAIN_PROFILE_SAMPLES, Config().TERRAIN_PROFILE_SAMPLES) + self.assertEqual(conf.NODE_Z_REFERENCE, NODE_Z_REFERENCE_GROUND) + def test_parse_params_rejects_before_applying_time_overrides(self): conf = Config() original_simtime = conf.SIMTIME diff --git a/tests/test_map_input.py b/tests/test_map_input.py new file mode 100644 index 00000000..3985b696 --- /dev/null +++ b/tests/test_map_input.py @@ -0,0 +1,318 @@ +import unittest + +from lib.map_input import ( + decode_map_altitude, + decode_map_coordinate, + node_configs_from_map_payload, + parse_bbox, + payload_nodes, + role_name_for_node, +) +from lib.nodedb_input import node_configs_from_nodedb_payload, positioned_nodedb_nodes, role_name_for_nodedb_node +from lib.node import MESHTASTIC_ROLE + + +class TestMapInput(unittest.TestCase): + def test_decode_map_coordinate(self): + self.assertEqual(decode_map_coordinate(416219136), 41.6219136) + self.assertEqual(decode_map_coordinate(41.6219136), 41.6219136) + self.assertIsNone(decode_map_coordinate(None)) + + def test_decode_map_altitude_keeps_only_positive_finite_values(self): + self.assertEqual(decode_map_altitude(120), 120) + self.assertEqual(decode_map_altitude("12.5"), 12.5) + self.assertIsNone(decode_map_altitude(None)) + self.assertIsNone(decode_map_altitude(0)) + self.assertIsNone(decode_map_altitude(-1)) + self.assertIsNone(decode_map_altitude(float("inf"))) + + def test_parse_bbox(self): + self.assertEqual(parse_bbox("41.4,41.0,41.9,42.3"), (41.4, 41.0, 41.9, 42.3)) + + def test_parse_bbox_rejects_wrong_order(self): + with self.assertRaises(ValueError): + parse_bbox("41.9,41.0,41.4,42.3") + + def test_parse_bbox_rejects_non_finite_values(self): + with self.assertRaises(ValueError): + parse_bbox("41.4,41.0,nan,42.3") + + def test_parse_bbox_rejects_out_of_range_values(self): + with self.assertRaises(ValueError): + parse_bbox("41.4,41.0,91,42.3") + + def test_payload_nodes_accepts_dict_or_list(self): + rows = [{"latitude": 1, "longitude": 2}] + + self.assertIs(payload_nodes({"nodes": rows}), rows) + self.assertIs(payload_nodes(rows), rows) + + def test_payload_nodes_rejects_malformed_payload(self): + with self.assertRaises(ValueError): + payload_nodes({"nodes": {}}) + with self.assertRaises(ValueError): + payload_nodes("not json") + + def test_map_payload_skips_malformed_node_rows(self): + payload = [ + "not a node", + { + "latitude": 416200000, + "longitude": 415900000, + "role": 0, + }, + ] + + configs = node_configs_from_map_payload(payload, 1000) + + self.assertEqual(len(configs), 1) + + def test_map_payload_skips_bad_coordinates(self): + payload = [ + {"latitude": "not a number", "longitude": 415900000, "role": 0}, + {"latitude": 910000000, "longitude": 415900000, "role": 0}, + {"latitude": 416200000, "longitude": 415900000, "role": 0}, + ] + + configs = node_configs_from_map_payload(payload, 1000) + + self.assertEqual(len(configs), 1) + + def test_numeric_role_fallback_accepts_string_values(self): + self.assertEqual(role_name_for_node({"role": "2"}), "ROUTER") + self.assertEqual(role_name_for_node({"role": 12}), "CLIENT_BASE") + + def test_map_payload_builds_projected_node_configs(self): + payload = { + "nodes": [ + { + "node_id": "1", + "node_id_hex": "!00000001", + "long_name": "router", + "short_name": "r", + "latitude": 416200000, + "longitude": 415900000, + "altitude": 120, + "role": 2, + "role_name": "ROUTER", + }, + { + "node_id": "2", + "node_id_hex": "!00000002", + "long_name": "outside", + "short_name": "o", + "latitude": 500000000, + "longitude": 500000000, + "altitude": 5, + "role": 0, + "role_name": "CLIENT", + }, + ], + } + + configs = node_configs_from_map_payload( + payload, + 1000, + bbox=(41.0, 41.0, 42.0, 42.0), + antenna_height=2.5, + hop_limit=5, + origin=(41.62, 41.59), + ) + + self.assertEqual(len(configs), 1) + self.assertEqual(configs[0].node_id, 0) + self.assertEqual(configs[0].role, MESHTASTIC_ROLE.ROUTER) + self.assertEqual(configs[0].position.z, 2.5) + self.assertEqual(configs[0].antenna_height, 2.5) + self.assertEqual(configs[0].absolute_altitude, 120) + self.assertEqual(configs[0].hop_limit, 5) + + def test_map_altitude_placeholders_do_not_override_antenna_height(self): + payload = { + "nodes": [ + { + "latitude": 416200000, + "longitude": 415900000, + "altitude": None, + "role": 0, + }, + { + "latitude": 416300000, + "longitude": 416000000, + "altitude": -1, + "role": 0, + }, + { + "latitude": 416400000, + "longitude": 416100000, + "altitude": 0, + "role": 0, + }, + { + "latitude": 416500000, + "longitude": 416200000, + "altitude": 42949649, + "role": 0, + }, + ], + } + + configs = node_configs_from_map_payload( + payload, + 1000, + antenna_height=2.5, + ) + + self.assertEqual([config.position.z for config in configs], [2.5, 2.5, 2.5, 2.5]) + self.assertEqual([config.antenna_height for config in configs], [2.5, 2.5, 2.5, 2.5]) + self.assertEqual([config.absolute_altitude for config in configs], [None, None, None, 42949649]) + + def test_map_payload_can_return_projection_origin(self): + payload = [{ + "latitude": 416200000, + "longitude": 415900000, + "role": 0, + }] + + configs, origin = node_configs_from_map_payload(payload, 1000, return_origin=True) + + self.assertEqual(len(configs), 1) + self.assertEqual(origin, (41.62, 41.59)) + + def test_map_payload_origin_handles_antimeridian_nodes(self): + payload = [ + {"latitude": 100000000, "longitude": 1799000000, "role": 0}, + {"latitude": 100000000, "longitude": -1799000000, "role": 0}, + ] + + configs, origin = node_configs_from_map_payload(payload, 1000, return_origin=True) + + self.assertGreater(abs(origin[1]), 170) + self.assertLess(abs(configs[0].position.x - configs[1].position.x), 30000) + + def test_map_payload_rejects_empty_limit(self): + payload = { + "nodes": [{ + "latitude": 416200000, + "longitude": 415900000, + "role": 0, + }], + } + + with self.assertRaises(ValueError): + node_configs_from_map_payload(payload, 1000, limit=0) + + def test_map_payload_rejects_invalid_projection_origin(self): + payload = [{ + "latitude": 416200000, + "longitude": 415900000, + "role": 0, + }] + + with self.assertRaises(ValueError): + node_configs_from_map_payload(payload, 1000, origin=(91.0, 41.59)) + with self.assertRaises(ValueError): + node_configs_from_map_payload(payload, 1000, origin=("bad", 41.59)) + + def test_nodedb_payload_builds_projected_node_configs(self): + payload = { + "nodesByNum": { + 1: { + "num": 1, + "user": {"id": "!00000001", "role": "ROUTER"}, + "position": {"latitude": 41.62, "longitude": 41.59, "altitude": 120}, + }, + 2: { + "num": 2, + "user": {"id": "!00000002", "role": "CLIENT"}, + "position": {"latitudeI": 416300000, "longitudeI": 416000000}, + }, + 3: { + "num": 3, + "user": {"id": "!00000003", "role": "CLIENT"}, + "position": {"latitude": 50.0, "longitude": 50.0}, + }, + } + } + + configs = node_configs_from_nodedb_payload( + payload, + 1000, + bbox=(41.0, 41.0, 42.0, 42.0), + antenna_height=2.5, + hop_limit=5, + origin=(41.62, 41.59), + ) + + self.assertEqual(len(configs), 2) + self.assertEqual([config.node_id for config in configs], [0, 1]) + self.assertEqual(configs[0].role, MESHTASTIC_ROLE.ROUTER) + self.assertEqual(configs[0].absolute_altitude, 120) + self.assertEqual([config.antenna_height for config in configs], [2.5, 2.5]) + self.assertEqual([config.hop_limit for config in configs], [5, 5]) + + def test_nodedb_primary_coordinates_accept_meshtastic_integer_encoding(self): + payload = { + "nodesByNum": { + 1: { + "num": 1, + "position": {"latitude": 416200000, "longitude": 415900000}, + }, + } + } + + positioned = positioned_nodedb_nodes(payload["nodesByNum"].values()) + + self.assertEqual(len(positioned), 1) + self.assertEqual(positioned[0][1:], (41.62, 41.59)) + + def test_nodedb_payload_uses_supplied_radio_defaults(self): + payload = [ + { + "num": 1, + "user": {"role": "ROUTER"}, + "position": { + "latitude": 41.62, + "longitude": 41.59, + "altitude": 120, + }, + }, + { + "num": 2, + "user": {"role": "CLIENT"}, + "position": { + "latitude": 41.63, + "longitude": 41.60, + "altitude": 10, + }, + }, + ] + + configs = node_configs_from_nodedb_payload( + payload, + 1000, + tx_power=14, + freq=433e6, + ) + + self.assertEqual([config.tx_power for config in configs], [14, 14]) + self.assertEqual([config.freq for config in configs], [433e6, 433e6]) + + def test_nodedb_payload_skips_unpositioned_nodes(self): + payload = [ + {"num": 1, "position": {"time": 1640206266}}, + {"num": 2, "position": {"latitude": 41.62, "longitude": 41.59}}, + ] + + positioned = positioned_nodedb_nodes(payload) + + self.assertEqual(len(positioned), 1) + self.assertEqual(positioned[0][1:], (41.62, 41.59)) + + def test_nodedb_role_defaults_to_client(self): + self.assertEqual(role_name_for_nodedb_node({}), "CLIENT") + self.assertEqual(role_name_for_nodedb_node({"user": {"role": "router_client"}}), "ROUTER_CLIENT") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_node.py b/tests/test_node.py index 75f4c2e4..b57e48eb 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -1,6 +1,30 @@ import unittest +import tempfile +from pathlib import Path import lib.node +import simpy + +from lib.config import Config +from lib.discrete_event_sim_components import SimulationDataTracking, SimulationState +from lib.node import MESHTASTIC_ROLE, MeshNode, NodeConfig, node_configs_from_yaml, origin_from_yaml +from lib.point import Point +from lib.terrain import NODE_Z_REFERENCE_SEA_LEVEL, TerrainGrid, apply_terrain_altitude + + +def sample_node(x): + return { + "x": x, + "y": 0, + "z": 1.5, + "isRouter": False, + "isRepeater": False, + "isClientMute": False, + "hopLimit": 3, + "antennaGain": 0, + "neighborInfo": False, + } + class TestNodeConf(unittest.TestCase): def test_reject_rssi_and_pathloss_between_identical_nodes(self): @@ -14,3 +38,143 @@ def test_reject_rssi_and_pathloss_between_identical_nodes(self): with self.assertRaises(ValueError, msg="cannot compute rssi/pathloss between the same nodes (by id)"): nodeconf.compute_rssi_and_pathloss_to(nodeconf, conf) + + def test_pathloss_includes_configured_terrain_obstruction(self): + from lib.config import Config + from lib.point import Point + from lib.terrain import TerrainGrid + + conf = Config() + tx = lib.node.NodeConfig(0, Point(0, 0, 2), 1, conf.PTX, conf.FREQ) + rx = lib.node.NodeConfig(1, Point(1000, 0, 2), 1, conf.PTX, conf.FREQ) + plain_rssi, plain_loss = tx.compute_rssi_and_pathloss_to(rx, conf) + + conf.TERRAIN_ENABLED = True + conf.TERRAIN_GRID = TerrainGrid.from_rows( + [(0, 0, 0), (500, 0, 500), (1000, 0, 0)] + ) + terrain_rssi, terrain_loss = tx.compute_rssi_and_pathloss_to(rx, conf) + + self.assertGreater(terrain_loss, plain_loss) + self.assertLess(terrain_rssi, plain_rssi) + + def test_pathloss_includes_configured_clutter_obstruction(self): + conf = Config() + tx = lib.node.NodeConfig(0, Point(0, 0, 2), 1, conf.PTX, conf.FREQ) + rx = lib.node.NodeConfig(1, Point(1000, 0, 2), 1, conf.PTX, conf.FREQ) + plain_rssi, plain_loss = tx.compute_rssi_and_pathloss_to(rx, conf) + + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "clutter.csv" + path.write_text( + "x_m,y_m,clutter_class\n" + "0,0,urban\n" + "500,0,urban\n" + "1000,0,urban\n", + encoding="utf-8", + ) + conf.CLUTTER_ENABLED = True + conf.CLUTTER_GRID_FILE = str(path) + conf.CLUTTER_PROFILE_SAMPLES = 3 + + clutter_rssi, clutter_loss = tx.compute_rssi_and_pathloss_to(rx, conf) + + self.assertGreater(clutter_loss, plain_loss) + self.assertLess(clutter_rssi, plain_rssi) + + +class TestNodeConfigYaml(unittest.TestCase): + def test_plain_gui_node_map_is_accepted(self): + configs = node_configs_from_yaml({0: sample_node(10), 1: sample_node(20)}, 1000) + + self.assertEqual([cfg.node_id for cfg in configs], [0, 1]) + self.assertEqual(configs[0].role, MESHTASTIC_ROLE.CLIENT) + self.assertEqual(configs[1].position.x, 20) + + def test_wrapped_real_mesh_node_map_is_accepted(self): + raw = { + "origin": {"lat": 41.64, "lon": 41.62}, + "nodes": {"0": sample_node(10), "1": sample_node(20)}, + } + + configs = node_configs_from_yaml(raw, 1000) + + self.assertEqual([cfg.node_id for cfg in configs], [0, 1]) + self.assertEqual(configs[0].period, 1000) + + def test_wrapped_real_mesh_node_ids_are_remapped_to_sim_indices(self): + raw = { + "origin": {"lat": 41.64, "lon": 41.62}, + "nodes": { + "3944424993": sample_node(10), + "3944424994": sample_node(20), + }, + } + + configs = node_configs_from_yaml(raw, 1000) + + self.assertEqual([cfg.node_id for cfg in configs], [0, 1]) + self.assertEqual([cfg.position.x for cfg in configs], [10, 20]) + + def test_wrapped_node_map_origin_is_available_for_terrain(self): + raw = { + "origin": {"lat": 41.64, "lon": 41.62}, + "nodes": {"0": sample_node(10)}, + } + + self.assertEqual(origin_from_yaml(raw), (41.64, 41.62)) + + def test_node_yaml_must_be_a_node_map(self): + with self.assertRaisesRegex(ValueError, "node YAML"): + node_configs_from_yaml(["not", "a", "node", "map"], 1000) + + def test_wrapped_node_map_must_be_a_map(self): + raw = { + "origin": {"lat": 41.64, "lon": 41.62}, + "nodes": ["not", "a", "node", "map"], + } + + with self.assertRaisesRegex(ValueError, "node YAML"): + node_configs_from_yaml(raw, 1000) + + def test_wrapped_node_map_origin_must_be_finite(self): + raw = { + "origin": {"lat": "nan", "lon": 41.62}, + "nodes": {"0": sample_node(10)}, + } + + with self.assertRaisesRegex(ValueError, "origin.lat"): + origin_from_yaml(raw) + + def test_wrapped_node_map_origin_must_be_in_coordinate_range(self): + raw = { + "origin": {"lat": 91, "lon": 41.62}, + "nodes": {"0": sample_node(10)}, + } + + with self.assertRaisesRegex(ValueError, "latitude/longitude"): + origin_from_yaml(raw) + + +class TestMeshNodeTerrain(unittest.TestCase): + def test_mesh_node_preserves_absolute_altitude_for_terrain_recompute(self): + conf = Config() + conf.NR_NODES = 1 + conf.MOVEMENT_ENABLED = False + conf.NODE_Z_REFERENCE = NODE_Z_REFERENCE_SEA_LEVEL + conf.TERRAIN_GRID = TerrainGrid.from_rows([(0, 0, 100), (100, 0, 120)]) + + node_config = NodeConfig(0, Point(0, 0, 2.5), conf.PERIOD, conf.PTX, conf.FREQ, absolute_altitude=150) + apply_terrain_altitude(conf.TERRAIN_GRID, node_config) + sim_state = SimulationState(conf, simpy.Environment()) + node = MeshNode(conf, sim_state, SimulationDataTracking(), node_config) + + self.assertEqual(node.position.z, 150) + + node.position.update_xy(100, 0) + apply_terrain_altitude(conf.TERRAIN_GRID, node) + self.assertEqual(node.position.z, 122.5) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_osm_clutter.py b/tests/test_osm_clutter.py new file mode 100644 index 00000000..eafc40fe --- /dev/null +++ b/tests/test_osm_clutter.py @@ -0,0 +1,146 @@ +import contextlib +import io +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory + +from lib.osm_clutter import ( + classify_osm_element, + main as osm_clutter_main, + parse_origin, + payload_elements, + point_in_polygon, + rasterize_clutter, + write_clutter_csv, +) + + +class TestOsmClutter(unittest.TestCase): + def test_classifies_osm_tags_to_radio_clutter_classes(self): + self.assertEqual(classify_osm_element({"building": "yes"}), "urban") + self.assertEqual(classify_osm_element({"landuse": "residential"}), "urban") + self.assertEqual(classify_osm_element({"natural": "wood"}), "forest") + self.assertEqual(classify_osm_element({"natural": "water"}), "water") + self.assertEqual(classify_osm_element({"natural": "beach"}), "open") + + def test_parse_origin_rejects_non_finite_values(self): + self.assertEqual(parse_origin("41.6,41.6"), (41.6, 41.6)) + with self.assertRaises(ValueError): + parse_origin("41.6,nan") + + def test_parse_origin_rejects_out_of_range_values(self): + with self.assertRaises(ValueError): + parse_origin("91,41.6") + + def test_point_in_polygon(self): + polygon = [(0, 0), (10, 0), (10, 10), (0, 10), (0, 0)] + + self.assertTrue(point_in_polygon(5, 5, polygon)) + self.assertFalse(point_in_polygon(15, 5, polygon)) + + def test_payload_elements_rejects_malformed_overpass_shape(self): + with self.assertRaisesRegex(ValueError, "JSON object"): + payload_elements([]) + with self.assertRaisesRegex(ValueError, "elements"): + payload_elements({"elements": {}}) + + def test_rasterize_clutter_marks_building_cells_urban(self): + payload = { + "elements": [{ + "type": "way", + "tags": {"building": "yes"}, + "geometry": [ + {"lat": 0.0005, "lon": 0.0005}, + {"lat": 0.0005, "lon": 0.0015}, + {"lat": 0.0015, "lon": 0.0015}, + {"lat": 0.0015, "lon": 0.0005}, + {"lat": 0.0005, "lon": 0.0005}, + ], + }], + } + + rows = rasterize_clutter(payload, (0.0, 0.0, 0.002, 0.002), origin=(0.0, 0.0), step_m=100.0) + + self.assertIn("urban", {row["clutter_class"] for row in rows}) + + def test_rasterize_clutter_skips_malformed_osm_elements(self): + payload = { + "elements": [ + "not an element", + { + "type": "way", + "tags": {"building": "yes"}, + "geometry": [ + {"lat": 0.0005, "lon": 0.0005}, + {"lat": "bad", "lon": 0.0015}, + {"lat": 0.0015, "lon": 0.0005}, + ], + }, + { + "type": "way", + "tags": {"building": "yes"}, + "geometry": [ + {"lat": 0.0005, "lon": 0.0005}, + {"lat": 0.0005, "lon": 0.0015}, + {"lat": 0.0015, "lon": 0.0015}, + {"lat": 0.0015, "lon": 0.0005}, + ], + }, + ], + } + + rows = rasterize_clutter(payload, (0.0, 0.0, 0.002, 0.002), origin=(0.0, 0.0), step_m=100.0) + + self.assertIn("urban", {row["clutter_class"] for row in rows}) + + def test_rasterize_clutter_rejects_non_positive_step(self): + with self.assertRaises(ValueError): + rasterize_clutter({"elements": []}, (0.0, 0.0, 0.002, 0.002), step_m=0) + + def test_write_clutter_csv_uses_lf_line_endings(self): + with TemporaryDirectory() as tmpdir: + output = Path(tmpdir) / "clutter.csv" + + write_clutter_csv([{ + "x_m": 0, + "y_m": 0, + "lat": 41.0, + "lon": 41.0, + "clutter_class": "open", + }], output) + + raw = output.read_bytes() + + self.assertIn(b"\n", raw) + self.assertNotIn(b"\r\n", raw) + + def test_write_clutter_csv_creates_parent_directories(self): + with TemporaryDirectory() as tmpdir: + output = Path(tmpdir) / "nested" / "clutter.csv" + + write_clutter_csv([{ + "x_m": 0, + "y_m": 0, + "lat": 41.0, + "lon": 41.0, + "clutter_class": "open", + }], output) + + self.assertTrue(output.exists()) + + def test_cli_rejects_invalid_bbox_without_traceback(self): + stderr = io.StringIO() + with contextlib.redirect_stderr(stderr): + with self.assertRaises(SystemExit) as raised: + osm_clutter_main([ + "--bbox", "0,0,nan,0.002", + "--input-json", "/dev/null", + "--output", "/tmp/unused-clutter.csv", + ]) + + self.assertEqual(raised.exception.code, 2) + self.assertIn("map bbox values must be finite", stderr.getvalue()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_packet.py b/tests/test_packet.py new file mode 100644 index 00000000..90e69979 --- /dev/null +++ b/tests/test_packet.py @@ -0,0 +1,84 @@ +import tempfile +import unittest +from pathlib import Path + +from lib.config import Config +from lib.packet import MeshPacket, NODENUM_BROADCAST +from lib.point import Point + + +class DummyNode: + def __init__(self, nodeid, position): + self.nodeid = nodeid + self.position = position + self.antennaGain = 0 + self.hopLimit = 3 + + +class TestMeshPacketClutter(unittest.TestCase): + def test_non_connectivity_map_packet_pathloss_includes_clutter(self): + conf = Config() + conf.NR_NODES = 2 + conf.ENABLE_CONNECTIVITY_MAP = False + conf.MODEL_ASYMMETRIC_LINKS = False + + nodes = [ + DummyNode(0, Point(0, 0, 2)), + DummyNode(1, Point(1000, 0, 2)), + ] + connectivity_map = {0: {1}, 1: {0}} + baseline_pathloss_matrix = [[None, None], [None, None]] + + plain = MeshPacket( + conf, + nodes, + 0, + NODENUM_BROADCAST, + 0, + conf.PACKETLENGTH, + 1, + 0, + False, + False, + None, + 0, + connectivity_map, + baseline_pathloss_matrix, + ) + + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "clutter.csv" + path.write_text( + "x_m,y_m,clutter_class\n" + "0,0,urban\n" + "500,0,urban\n" + "1000,0,urban\n", + encoding="utf-8", + ) + conf.CLUTTER_ENABLED = True + conf.CLUTTER_GRID_FILE = str(path) + conf.CLUTTER_PROFILE_SAMPLES = 3 + + cluttered = MeshPacket( + conf, + nodes, + 0, + NODENUM_BROADCAST, + 0, + conf.PACKETLENGTH, + 2, + 0, + False, + False, + None, + 0, + connectivity_map, + baseline_pathloss_matrix, + ) + + self.assertGreater(cluttered.LplAtN[1], plain.LplAtN[1]) + self.assertLess(cluttered.rssiAtN[1], plain.rssiAtN[1]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_srtm.py b/tests/test_srtm.py new file mode 100644 index 00000000..de4768d7 --- /dev/null +++ b/tests/test_srtm.py @@ -0,0 +1,270 @@ +import gzip +import sys +import tempfile +import unittest +import zipfile +from array import array +from pathlib import Path + +from lib.srtm import ( + HGT_VOID, + SrtmTile, + clamp_bbox_to_srtm_coverage, + ensure_hgt_tile, + terrain_grid_from_srtm, + terrain_rows_from_srtm, + srtm_tile_name, + tiles_for_bbox, +) + + +def write_hgt(path, values): + data = array("h", values) + if sys.byteorder == "little": + data.byteswap() + Path(path).write_bytes(data.tobytes()) + + +class TestSrtm(unittest.TestCase): + def test_tile_name_uses_srtm_flooring(self): + self.assertEqual(srtm_tile_name(41.64, 41.61), "N41E041") + self.assertEqual(srtm_tile_name(-0.1, -1.2), "S01W002") + + def test_tile_name_covers_all_hemispheres(self): + cases = [ + ((41.64, 41.61), "N41E041"), + ((41.64, -41.61), "N41W042"), + ((-41.64, 41.61), "S42E041"), + ((-41.64, -41.61), "S42W042"), + ((0.0, 0.0), "N00E000"), + ((-0.0001, -0.0001), "S01W001"), + ] + + for (lat, lon), tile_name in cases: + with self.subTest(lat=lat, lon=lon): + self.assertEqual(srtm_tile_name(lat, lon), tile_name) + + def test_tiles_for_bbox_covers_crossed_integer_degrees(self): + self.assertEqual( + tiles_for_bbox((41.5, 41.5, 42.2, 42.2)), + ["N41E041", "N41E042", "N42E041", "N42E042"], + ) + + def test_tiles_for_bbox_covers_equator_and_prime_meridian_crossing(self): + self.assertEqual( + tiles_for_bbox((-0.2, -0.2, 0.2, 0.2)), + ["N00E000", "N00W001", "S01E000", "S01W001"], + ) + + def test_tiles_for_bbox_excludes_global_edge_tiles(self): + self.assertEqual(tiles_for_bbox((59.5, 179.5, 60.0, 180.0)), ["N59E179"]) + + def test_tiles_for_bbox_maps_zero_span_global_edges_to_existing_tiles(self): + self.assertEqual(tiles_for_bbox((60.0, 41.0, 60.0, 41.1)), ["N59E041"]) + self.assertEqual(tiles_for_bbox((59.9, 180.0, 60.0, 180.0)), ["N59E179"]) + + def test_tiles_for_bbox_rejects_outside_srtm_latitude_coverage(self): + with self.assertRaisesRegex(ValueError, "56°S and 60°N"): + tiles_for_bbox((60.0, 41.0, 60.1, 41.1)) + + with self.assertRaisesRegex(ValueError, "56°S and 60°N"): + tiles_for_bbox((-56.1, 41.0, -56.0, 41.1)) + + def test_clamp_bbox_to_srtm_coverage_preserves_overlap(self): + self.assertEqual( + clamp_bbox_to_srtm_coverage((59.9, 179.9, 60.1, 180.1)), + (59.9, 179.9, 60.0, 180.0), + ) + + def test_clamp_bbox_to_srtm_coverage_rejects_no_overlap(self): + with self.assertRaisesRegex(ValueError, "does not overlap"): + clamp_bbox_to_srtm_coverage((60.1, 41.0, 60.2, 41.1)) + + def test_hgt_tile_reads_big_endian_elevation_samples(self): + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "N41E041.hgt" + write_hgt(path, [10, 20, 30, 40, 50, 60, 70, 80, 90]) + + tile = SrtmTile.from_hgt(path) + + self.assertEqual(tile.elevation_at(42.0, 41.0), 10) + self.assertEqual(tile.elevation_at(41.0, 42.0), 90) + + def test_hgt_void_uses_nearby_sample(self): + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "N41E041.hgt" + write_hgt(path, [10, 20, 30, 40, HGT_VOID, 60, 70, 80, 90]) + + tile = SrtmTile.from_hgt(path) + + self.assertIsNotNone(tile.elevation_at(41.5, 41.5)) + + def test_terrain_grid_from_srtm_avoids_csv_intermediate(self): + with tempfile.TemporaryDirectory() as tmpdir: + cache_dir = Path(tmpdir) / "cache" + cache_dir.mkdir() + write_hgt(cache_dir / "N41E041.hgt", [10, 20, 30, 40, 50, 60, 70, 80, 90]) + + grid = terrain_grid_from_srtm( + (41.0, 41.0, 41.1, 41.1), + step_meters=20000, + cache_dir=cache_dir, + origin_lat=41.0, + origin_lon=41.0, + download_missing=False, + ) + + self.assertGreater(len(grid.samples), 0) + self.assertIsNotNone(grid.elevation_at(0, 0)) + + def test_terrain_rows_samples_global_edges_from_existing_tiles(self): + with tempfile.TemporaryDirectory() as tmpdir: + cache_dir = Path(tmpdir) / "cache" + cache_dir.mkdir() + write_hgt(cache_dir / "N59E179.hgt", [10, 20, 30, 40]) + + rows = list( + terrain_rows_from_srtm( + (60.0, 179.9, 60.0, 180.0), + step_meters=20000, + cache_dir=cache_dir, + download_missing=False, + ) + ) + + self.assertGreater(len(rows), 0) + self.assertIn("180.0000000", {row["lon"] for row in rows}) + + def test_terrain_rows_can_limit_requested_tiles(self): + with tempfile.TemporaryDirectory() as tmpdir: + cache_dir = Path(tmpdir) / "cache" + cache_dir.mkdir() + write_hgt(cache_dir / "N41E041.hgt", [10, 20, 30, 40]) + + rows = list( + terrain_rows_from_srtm( + (41.0, 41.0, 43.1, 43.1), + step_meters=20000, + cache_dir=cache_dir, + download_missing=False, + tile_names=["N41E041"], + ) + ) + + self.assertGreater(len(rows), 0) + self.assertEqual({row["lat"][:2] for row in rows}, {"41"}) + self.assertEqual({row["lon"][:2] for row in rows}, {"41"}) + + def test_terrain_rows_stay_inside_requested_bbox(self): + with tempfile.TemporaryDirectory() as tmpdir: + cache_dir = Path(tmpdir) / "cache" + cache_dir.mkdir() + write_hgt(cache_dir / "N41E041.hgt", [10, 20, 30, 40]) + + rows = list( + terrain_rows_from_srtm( + (41.0, 41.0, 41.001, 41.001), + step_meters=500, + cache_dir=cache_dir, + download_missing=False, + ) + ) + + self.assertGreater(len(rows), 0) + self.assertTrue(all(41.0 <= float(row["lat"]) <= 41.001 for row in rows)) + self.assertTrue(all(41.0 <= float(row["lon"]) <= 41.001 for row in rows)) + + def test_terrain_rows_rejects_non_finite_step(self): + with self.assertRaises(ValueError): + list(terrain_rows_from_srtm((41.0, 41.0, 41.1, 41.1), float("nan"), "/tmp")) + + def test_ensure_hgt_tile_downloads_and_unpacks_gzip_template(self): + with tempfile.TemporaryDirectory() as tmpdir: + source_dir = Path(tmpdir) / "source" + cache_dir = Path(tmpdir) / "cache" + source_dir.mkdir() + raw_hgt = source_dir / "N41E041.hgt" + write_hgt(raw_hgt, [1, 2, 3, 4]) + with ( + raw_hgt.open("rb") as src, + gzip.open(source_dir / "N41E041.hgt.gz", "wb") as dst, + ): + dst.write(src.read()) + + path = ensure_hgt_tile( + "N41E041", + cache_dir, + url_template=f"{source_dir.as_uri()}/{{tile}}.hgt.gz", + ) + + self.assertEqual(path.name, "N41E041.hgt") + self.assertTrue(path.exists()) + + def test_ensure_hgt_tile_selects_requested_member_from_zip(self): + with tempfile.TemporaryDirectory() as tmpdir: + source_dir = Path(tmpdir) / "source" + cache_dir = Path(tmpdir) / "cache" + source_dir.mkdir() + wrong_hgt = source_dir / "N40E040.hgt" + requested_hgt = source_dir / "N41E041.hgt" + write_hgt(wrong_hgt, [1, 2, 3, 4]) + write_hgt(requested_hgt, [10, 20, 30, 40]) + + with zipfile.ZipFile(source_dir / "N41E041.hgt.zip", "w") as archive: + archive.write(wrong_hgt, "nested/N40E040.hgt") + archive.write(requested_hgt, "nested/N41E041.hgt") + + path = ensure_hgt_tile( + "N41E041", + cache_dir, + url_template=f"{source_dir.as_uri()}/{{tile}}.hgt.zip", + ) + + tile = SrtmTile.from_hgt(path) + self.assertEqual(tile.elevation_at(42.0, 41.0), 10) + + def test_ensure_hgt_tile_rejects_zip_without_requested_member(self): + with tempfile.TemporaryDirectory() as tmpdir: + source_dir = Path(tmpdir) / "source" + cache_dir = Path(tmpdir) / "cache" + source_dir.mkdir() + wrong_hgt = source_dir / "N40E040.hgt" + write_hgt(wrong_hgt, [1, 2, 3, 4]) + + with zipfile.ZipFile(source_dir / "N41E041.hgt.zip", "w") as archive: + archive.write(wrong_hgt, "N40E040.hgt") + + with self.assertRaisesRegex(ValueError, "N41E041.hgt"): + ensure_hgt_tile( + "N41E041", + cache_dir, + url_template=f"{source_dir.as_uri()}/{{tile}}.hgt.zip", + ) + + def test_ensure_hgt_tile_rejects_unknown_template_placeholder(self): + with tempfile.TemporaryDirectory() as tmpdir: + with self.assertRaisesRegex(ValueError, "tile"): + ensure_hgt_tile( + "N41E041", tmpdir, url_template="file:///tmp/{missing}.hgt" + ) + + def test_ensure_hgt_tile_does_not_cache_failed_unpack(self): + with tempfile.TemporaryDirectory() as tmpdir: + source_dir = Path(tmpdir) / "source" + cache_dir = Path(tmpdir) / "cache" + source_dir.mkdir() + (source_dir / "N41E041.hgt.gz").write_bytes(b"not gzip") + + with self.assertRaisesRegex(ValueError, "could not unpack"): + ensure_hgt_tile( + "N41E041", + cache_dir, + url_template=f"{source_dir.as_uri()}/{{tile}}.hgt.gz", + ) + + self.assertFalse((cache_dir / "N41E041.hgt").exists()) + self.assertFalse((cache_dir / "N41E041.hgt.tmp").exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_terrain.py b/tests/test_terrain.py new file mode 100644 index 00000000..64c62408 --- /dev/null +++ b/tests/test_terrain.py @@ -0,0 +1,165 @@ +import unittest + +from lib.config import Config +from lib.point import Point +from lib.terrain import ( + NODE_Z_REFERENCE_SEA_LEVEL, + TerrainGrid, + apply_terrain_altitude, + apply_terrain_altitudes, + latlon_to_xy, + terrain_ground_elevation, + terrain_obstruction_loss, + xy_to_latlon, +) + + +class TerrainNode: + def __init__(self, position, antenna_height=None, absolute_altitude=None): + self.position = position + self.antenna_height = position.z if antenna_height is None else antenna_height + self.absolute_altitude = absolute_altitude + + +class TestTerrain(unittest.TestCase): + def test_latlon_projection_preserves_origin(self): + x, y = latlon_to_xy(41.6, 41.6, 41.6, 41.6) + + self.assertAlmostEqual(x, 0.0) + self.assertAlmostEqual(y, 0.0) + + def test_latlon_projection_round_trips(self): + lat, lon = 41.65, 41.62 + x, y = latlon_to_xy(lat, lon, 41.6, 41.6) + + out_lat, out_lon = xy_to_latlon(x, y, 41.6, 41.6) + + self.assertAlmostEqual(out_lat, lat) + self.assertAlmostEqual(out_lon, lon) + + def test_xy_projection_rejects_polar_origin(self): + with self.assertRaisesRegex(ValueError, "pole"): + xy_to_latlon(100, 100, 90.0, 0.0) + + def test_grid_interpolates_exact_sample(self): + grid = TerrainGrid.from_rows([ + (0, 0, 5), + (100, 0, 25), + ]) + + self.assertEqual(grid.elevation_at(100, 0), 25) + + def test_grid_rejects_non_finite_samples(self): + with self.assertRaisesRegex(ValueError, "finite"): + TerrainGrid.from_rows([(0, float("nan"), 5)]) + + def test_configured_grid_provides_ground_elevation(self): + conf = Config() + conf.TERRAIN_ENABLED = True + conf.TERRAIN_GRID = TerrainGrid.from_rows([ + (0, 0, 5), + (100, 0, 25), + ]) + + self.assertEqual(terrain_ground_elevation(conf, Point(0, 0, 1)), 5) + + def test_terrain_altitudes_keep_antenna_height_separate(self): + conf = Config() + conf.TERRAIN_ENABLED = True + conf.TERRAIN_GRID = TerrainGrid.from_rows([ + (0, 0, 100), + (100, 0, 120), + ]) + nodes = [ + TerrainNode(Point(0, 0, 2.5)), + TerrainNode(Point(100, 0, 3.0)), + ] + + apply_terrain_altitudes(conf.TERRAIN_GRID, nodes) + conf.NODE_Z_REFERENCE = NODE_Z_REFERENCE_SEA_LEVEL + + self.assertEqual(conf.NODE_Z_REFERENCE, NODE_Z_REFERENCE_SEA_LEVEL) + self.assertEqual([node.antenna_height for node in nodes], [2.5, 3.0]) + self.assertEqual([node.position.z for node in nodes], [102.5, 123.0]) + + def test_terrain_altitude_recomputes_after_node_moves(self): + class LiveNode: + def __init__(self): + self.position = Point(0, 0, 0) + self.antennaHeight = 2.0 + + grid = TerrainGrid.from_rows([ + (0, 0, 100), + (100, 0, 120), + ]) + node = LiveNode() + + apply_terrain_altitude(grid, node) + self.assertEqual(node.position.z, 102.0) + + node.position.update_xy(100, 0) + apply_terrain_altitude(grid, node) + + self.assertEqual(node.position.z, 122.0) + + def test_terrain_altitudes_use_plausible_per_node_map_altitudes(self): + grid = TerrainGrid.from_rows([ + (0, 0, 100), + (100, 0, 100), + (200, 0, 100), + (300, 0, 100), + ]) + nodes = [ + TerrainNode(Point(0, 0, 2.5), absolute_altitude=150), + TerrainNode(Point(100, 0, 2.5)), + TerrainNode(Point(200, 0, 2.5), absolute_altitude=50), + TerrainNode(Point(300, 0, 2.5), absolute_altitude=1000), + ] + + apply_terrain_altitudes(grid, nodes) + + self.assertEqual([node.position.z for node in nodes], [150, 102.5, 102.5, 102.5]) + self.assertEqual([node.antenna_height for node in nodes], [2.5, 2.5, 2.5, 2.5]) + + def test_ridge_adds_obstruction_loss(self): + conf = Config() + conf.TERRAIN_ENABLED = True + conf.TERRAIN_PROFILE_SAMPLES = 10 + conf.TERRAIN_GRID = TerrainGrid.from_rows([ + (0, 0, 0), + (500, 0, 120), + (1000, 0, 0), + ]) + + loss = terrain_obstruction_loss( + conf, + Point(0, 0, 2), + Point(1000, 0, 2), + conf.FREQ, + ) + + self.assertGreater(loss, 0) + + def test_effective_earth_radius_adds_curvature_loss_on_long_flat_link(self): + conf = Config() + conf.TERRAIN_ENABLED = True + conf.TERRAIN_PROFILE_SAMPLES = 10 + conf.TERRAIN_FRESNEL_CLEARANCE = 0.0 + conf.TERRAIN_GRID = TerrainGrid.from_rows([ + (0, 0, 0), + (25000, 0, 0), + (50000, 0, 0), + ]) + + loss = terrain_obstruction_loss( + conf, + Point(0, 0, 2), + Point(50000, 0, 2), + conf.FREQ, + ) + + self.assertGreater(loss, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/osm_to_clutter_csv.py b/tools/osm_to_clutter_csv.py new file mode 100644 index 00000000..b17e4a42 --- /dev/null +++ b/tools/osm_to_clutter_csv.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 +"""CLI wrapper for exporting OSM land-cover clutter into CSV format.""" + +from pathlib import Path +import sys + + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from lib.osm_clutter import main # noqa: E402 + + +if __name__ == "__main__": + main()