From b2411506659ff76545d734eb1c6eb45f1a30c4cc Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Sat, 2 May 2026 11:53:42 +0400 Subject: [PATCH 01/17] feat(sim): add map and terrain inputs --- DISCRETE_EVENT_SIM.md | 22 ++- batchSim.py | 2 +- lib/common.py | 8 +- lib/config.py | 22 +++ lib/geo.py | 13 ++ lib/map_input.py | 194 +++++++++++++++++++ lib/node.py | 75 +++++++- lib/packet.py | 9 +- lib/srtm.py | 263 +++++++++++++++++++++++++ lib/terrain.py | 238 +++++++++++++++++++++++ loraMesh.py | 162 ++++++++++++++-- tests/test_lora_mesh_cli.py | 374 ++++++++++++++++++++++++++++++++++++ tests/test_map_input.py | 206 ++++++++++++++++++++ tests/test_node.py | 94 +++++++++ tests/test_srtm.py | 203 +++++++++++++++++++ tests/test_terrain.py | 165 ++++++++++++++++ 16 files changed, 2021 insertions(+), 29 deletions(-) create mode 100644 lib/geo.py create mode 100644 lib/map_input.py create mode 100644 lib/srtm.py create mode 100644 lib/terrain.py create mode 100644 tests/test_map_input.py create mode 100644 tests/test_srtm.py create mode 100644 tests/test_terrain.py diff --git a/DISCRETE_EVENT_SIM.md b/DISCRETE_EVENT_SIM.md index 82c27100..03040f1b 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,26 @@ 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 public Meshtastic map node locations. The map +endpoint currently returns a broad node list, so pass a local bounding box and +an explicit simulated antenna height: + +```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 --map-antenna-height 1.5 --no-gui``` + +Terrain obstruction can be added to map or origin-backed scenario inputs without +creating a custom terrain file. `--terrain-srtm` downloads missing SRTM HGT +tiles into a local cache, samples the scenario bounding box, and feeds the +terrain grid directly into terrain-aware node geometry: + +```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 `--map-antenna-height` 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. + 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/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..a4f16efd 100644 --- a/lib/config.py +++ b/lib/config.py @@ -401,6 +401,28 @@ 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 + # Misc self.SEED = 44 # random seed to use # End of misc 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..bb7c3569 --- /dev/null +++ b/lib/map_input.py @@ -0,0 +1,194 @@ +"""Input adapter for public Meshtastic map 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 + return float(value) / 1e7 + + +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 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") + + if origin is None: + origin_lat = statistics.median([lat for _, lat, _ in positioned]) + origin_lon = statistics.median([lon for _, _, lon in 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): + x, y = latlon_to_xy(lat, 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 diff --git a/lib/node.py b/lib/node.py index 459eab30..d90f6936 100644 --- a/lib/node.py +++ b/lib/node.py @@ -6,14 +6,16 @@ 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.terrain import NODE_Z_REFERENCE_SEA_LEVEL, apply_terrain_altitude logger = logging.getLogger(__name__) @@ -59,7 +61,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, freq: float, 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 +74,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 +86,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 +124,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 +144,56 @@ 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)) 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, freq: float) -> 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 +227,8 @@ 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 self.period = self.node_conf.period # using this more like a struct than a proper object. @@ -294,6 +349,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 +640,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/packet.py b/lib/packet.py index cdbf1c1e..6684db4b 100644 --- a/lib/packet.py +++ b/lib/packet.py @@ -1,6 +1,7 @@ import logging import random +from lib.common import node_antenna_height from lib.discrete_event_sim_components import Counter from lib.phy import airtime, estimate_path_loss @@ -96,7 +97,13 @@ 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), + ) 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..9a8e3bc8 --- /dev/null +++ b/lib/srtm.py @@ -0,0 +1,263 @@ +"""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" +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 _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 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") + + max_lat_tile = math.floor(math.nextafter(max_lat, -math.inf)) if max_lat > min_lat else math.floor(max_lat) + max_lon_tile = math.floor(math.nextafter(max_lon, -math.inf)) if max_lon > min_lon else math.floor(max_lon) + names = [] + for lat_floor in range(math.floor(min_lat), max_lat_tile + 1): + for lon_floor in range(math.floor(min_lon), 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 values and values[-1] < stop: + values.append(stop) + return values + + +def terrain_rows_from_srtm(bbox, step_meters, cache_dir, url_template=DEFAULT_SRTM_URL_TEMPLATE, download_missing=True): + """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)) + + tiles = {} + for tile_name in tiles_for_bbox(bbox): + path = ensure_hgt_tile(tile_name, cache_dir, url_template, download_missing) + tiles[tile_name] = SrtmTile.from_hgt(path, tile_name) + + for lat in _coordinate_values(min_lat, max_lat, lat_step): + for lon in _coordinate_values(min_lon, max_lon, lon_step): + tile = tiles.get(srtm_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, +): + """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): + 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..c9b634c6 --- /dev/null +++ b/lib/terrain.py @@ -0,0 +1,238 @@ +"""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) + 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 + + +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..42d21230 100755 --- a/loraMesh.py +++ b/loraMesh.py @@ -4,11 +4,24 @@ 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.node import NodeConfig, default_generate_node_list, node_configs_from_yaml, origin_from_yaml +from lib.srtm import ( + DEFAULT_SRTM_URL_TEMPLATE, + clamp_bbox_to_srtm_coverage, + terrain_grid_from_srtm, +) +from lib.terrain import ( + NODE_Z_REFERENCE_GROUND, + NODE_Z_REFERENCE_SEA_LEVEL, + apply_terrain_altitudes, + xy_to_latlon, +) conf = CONFIG logger = logging.getLogger(__name__) @@ -24,6 +37,7 @@ def configure_logging(): def get_cli_defaults(conf): """Remember the caller's initial CLI defaults across reusable parse calls.""" if not hasattr(conf, CLI_DEFAULT_ATTR): + terrain_defaults = type(conf)() setattr( conf, CLI_DEFAULT_ATTR, @@ -32,11 +46,43 @@ def get_cli_defaults(conf): "PERIOD": conf.PERIOD, "GUI_ENABLED": conf.GUI_ENABLED, "PLOT": conf.PLOT, + "TERRAIN_PROFILE_SAMPLES": terrain_defaults.TERRAIN_PROFILE_SAMPLES, + "NODE_Z_REFERENCE": NODE_Z_REFERENCE_GROUND, }, ) 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_node_config(node_config, origin, margin_m=1000.0): + """Build a geographic bbox around local x/y nodes when an origin exists.""" + if origin is None: + return None + origin_lat, origin_lon = origin + 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 + 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 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. @@ -53,11 +99,26 @@ def parse_params(conf, args=None) -> [NodeConfig]: 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('--from-map', nargs='?', const=DEFAULT_MAP_NODES_URL, type=str, metavar='url', help='Fetch node locations from a Meshtastic map /api/v1/nodes endpoint.') # 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('--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('--map-bbox', type=str, help='Map import bounding box as min_lat,min_lon,max_lat,max_lon') + parser.add_argument('--map-limit', type=int, help='Maximum number of positioned map nodes to import after bbox filtering') + parser.add_argument('--map-antenna-height', type=float, default=1.5, help='Antenna height in meters for map-imported nodes') + parser.add_argument('--map-hop-limit', type=int, default=3, help='Hop limit for map-imported nodes') 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') @@ -82,6 +143,17 @@ def parse_params(conf, args=None) -> [NodeConfig]: 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(parsed_arguments.map_antenna_height) or parsed_arguments.map_antenna_height <= 0: + parser.error("--map-antenna-height must be a positive finite number") + if parsed_arguments.map_hop_limit < 0: + parser.error("--map-hop-limit 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.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 @@ -95,20 +167,54 @@ def parse_params(conf, args=None) -> [NodeConfig]: else: conf.ENABLE_CONNECTIVITY_MAP = True - 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 + ) and parsed_arguments.router_type is not None: + parser.error("Incompatible argument selection. --from-file/--from-map and --router-type can not be used together") seeded_for_scenario = False + terrain_bbox = 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"] + if parsed_arguments.terrain_profile_samples is not None: + terrain_profile_samples = parsed_arguments.terrain_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: + 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) + 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=parsed_arguments.map_antenna_height, + hop_limit=parsed_arguments.map_hop_limit, + 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) 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}") nr_nodes = parsed_arguments.nr_nodes @@ -124,21 +230,44 @@ 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: + terrain_bbox = bbox_from_node_config(config, scenario_origin) + 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: + 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, + ) + 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) conf.SIMTIME = simtime @@ -146,6 +275,11 @@ def parse_params(conf, args=None) -> [NodeConfig]: conf.GUI_ENABLED = gui_enabled conf.PLOT = plot_enabled conf.NR_NODES = nr_nodes + 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 if parsed_arguments.verbose: # Set this logger and lib.* to DEBUG only after the command line has diff --git a/tests/test_lora_mesh_cli.py b/tests/test_lora_mesh_cli.py index 4c3e369b..78544865 100644 --- a/tests/test_lora_mesh_cli.py +++ b/tests/test_lora_mesh_cli.py @@ -8,12 +8,23 @@ 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.terrain import NODE_Z_REFERENCE_GROUND, NODE_Z_REFERENCE_SEA_LEVEL 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)) @@ -185,6 +196,369 @@ 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() + 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", + "--map-antenna-height", + "2.5", + "--no-gui", + ], + ) + + self.assertEqual(len(nodes), 2) + self.assertEqual(nodes[0].position.z, 2.5) + self.assertEqual((conf.GEO_ORIGIN_LAT, conf.GEO_ORIGIN_LON), (41.625, 41.595)) + + 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, _ = 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.assertEqual(nodes[0].position.z, 500) + self.assertNotEqual(nodes[0].position.z, nodes[1].position.z) + self.assertGreater(nodes[1].position.z, 1.5) + self.assertEqual([node.antenna_height for node in nodes], [1.5, 1.5]) + + def test_parse_params_ignores_map_altitude_when_applying_srtm(self): + conf = Config() + 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", + "--map-antenna-height", + "2.5", + "--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_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_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 + 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(random.getstate(), state_before) + + def test_terrain_profile_samples_resets_between_parse_calls(self): + conf = Config() + 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, Config().TERRAIN_PROFILE_SAMPLES) + self.assertEqual(conf.NODE_Z_REFERENCE, NODE_Z_REFERENCE_SEA_LEVEL) + + def test_successful_plain_parse_clears_previous_terrain_state(self): + conf = Config() + 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..f23b1b08 --- /dev/null +++ b/tests/test_map_input.py @@ -0,0 +1,206 @@ +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.node import MESHTASTIC_ROLE + + +class TestMapInput(unittest.TestCase): + def test_decode_map_coordinate(self): + self.assertEqual(decode_map_coordinate(416219136), 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_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)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_node.py b/tests/test_node.py index 75f4c2e4..00bade18 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -2,6 +2,23 @@ import lib.node +from lib.node import MESHTASTIC_ROLE, node_configs_from_yaml, origin_from_yaml + + +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 +31,80 @@ 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) + + +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) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_srtm.py b/tests/test_srtm.py new file mode 100644 index 00000000..0a5d33ee --- /dev/null +++ b/tests/test_srtm.py @@ -0,0 +1,203 @@ +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_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_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() From 0dc86c8b9a614fe11f8b1ec712580860b2ea7026 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Mon, 4 May 2026 03:20:27 +0400 Subject: [PATCH 02/17] fix(sim): handle uncovered SRTM scenario bbox --- loraMesh.py | 5 +++- tests/test_lora_mesh_cli.py | 54 +++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/loraMesh.py b/loraMesh.py index 42d21230..33b3497d 100755 --- a/loraMesh.py +++ b/loraMesh.py @@ -243,7 +243,10 @@ def parse_params(conf, args=None) -> [NodeConfig]: 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: - terrain_bbox = bbox_from_node_config(config, scenario_origin) + try: + terrain_bbox = bbox_from_node_config(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") diff --git a/tests/test_lora_mesh_cli.py b/tests/test_lora_mesh_cli.py index 78544865..c4769728 100644 --- a/tests/test_lora_mesh_cli.py +++ b/tests/test_lora_mesh_cli.py @@ -450,6 +450,60 @@ def test_terrain_srtm_generated_scenario_rejects_before_config_mutation(self): self.assertFalse(conf.TERRAIN_ENABLED) self.assertEqual(random.getstate(), state_before) + 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_failed_srtm_load_keeps_previous_terrain_config(self): conf = Config() terrain_grid = object() From f175e67b47ac50878c8637eacb3de5914bf0b7bd Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Tue, 5 May 2026 22:56:25 +0400 Subject: [PATCH 03/17] fix(sim): clarify map terrain import defaults --- DISCRETE_EVENT_SIM.md | 26 +++++++++++++++++++------- lib/srtm.py | 4 +++- loraMesh.py | 20 ++++++++------------ tests/test_lora_mesh_cli.py | 19 +++++++++++-------- 4 files changed, 41 insertions(+), 28 deletions(-) diff --git a/DISCRETE_EVENT_SIM.md b/DISCRETE_EVENT_SIM.md index 03040f1b..8fbe2f26 100644 --- a/DISCRETE_EVENT_SIM.md +++ b/DISCRETE_EVENT_SIM.md @@ -24,21 +24,33 @@ 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 public Meshtastic map node locations. The map -endpoint currently returns a broad node list, so pass a local bounding box and -an explicit simulated antenna height: +endpoint currently returns 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 --map-antenna-height 1.5 --no-gui``` +```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``` + +Map-imported nodes use the same `HM` antenna height and `hopLimit` defaults as +generated and file-backed scenarios. Change those config values when the +imported public map does not carry the simulation value you want. Terrain obstruction can be added to map or origin-backed scenario inputs without creating a custom terrain file. `--terrain-srtm` downloads missing SRTM HGT -tiles into a local cache, samples the scenario bounding box, and feeds the -terrain grid directly into terrain-aware node geometry: +tiles from Mapzen Terrain Tiles on AWS into a local cache, samples the scenario +bounding box, and feeds the terrain grid directly into terrain-aware node +geometry. 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 `--map-antenna-height` as the fallback antenna height -above local ground. When `--terrain-srtm` is enabled, each map node is checked +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. diff --git a/lib/srtm.py b/lib/srtm.py index 9a8e3bc8..cd985869 100644 --- a/lib/srtm.py +++ b/lib/srtm.py @@ -1,4 +1,4 @@ -"""SRTM HGT helpers for Meshtasticator terrain inputs. +"""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. @@ -19,6 +19,8 @@ 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 diff --git a/loraMesh.py b/loraMesh.py index 33b3497d..921a15af 100755 --- a/loraMesh.py +++ b/loraMesh.py @@ -17,7 +17,6 @@ terrain_grid_from_srtm, ) from lib.terrain import ( - NODE_Z_REFERENCE_GROUND, NODE_Z_REFERENCE_SEA_LEVEL, apply_terrain_altitudes, xy_to_latlon, @@ -37,7 +36,6 @@ def configure_logging(): def get_cli_defaults(conf): """Remember the caller's initial CLI defaults across reusable parse calls.""" if not hasattr(conf, CLI_DEFAULT_ATTR): - terrain_defaults = type(conf)() setattr( conf, CLI_DEFAULT_ATTR, @@ -46,8 +44,8 @@ def get_cli_defaults(conf): "PERIOD": conf.PERIOD, "GUI_ENABLED": conf.GUI_ENABLED, "PLOT": conf.PLOT, - "TERRAIN_PROFILE_SAMPLES": terrain_defaults.TERRAIN_PROFILE_SAMPLES, - "NODE_Z_REFERENCE": NODE_Z_REFERENCE_GROUND, + "TERRAIN_PROFILE_SAMPLES": conf.TERRAIN_PROFILE_SAMPLES, + "NODE_Z_REFERENCE": conf.NODE_Z_REFERENCE, }, ) return getattr(conf, CLI_DEFAULT_ATTR) @@ -117,8 +115,6 @@ def parse_params(conf, args=None) -> [NodeConfig]: parser.add_argument('--terrain-profile-samples', type=int, help='number of terrain samples along each TX/RX path') parser.add_argument('--map-bbox', type=str, help='Map import bounding box as min_lat,min_lon,max_lat,max_lon') parser.add_argument('--map-limit', type=int, help='Maximum number of positioned map nodes to import after bbox filtering') - parser.add_argument('--map-antenna-height', type=float, default=1.5, help='Antenna height in meters for map-imported nodes') - parser.add_argument('--map-hop-limit', type=int, default=3, help='Hop limit for map-imported nodes') 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') @@ -145,10 +141,10 @@ def parse_params(conf, args=None) -> [NodeConfig]: 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(parsed_arguments.map_antenna_height) or parsed_arguments.map_antenna_height <= 0: - parser.error("--map-antenna-height must be a positive finite number") - if parsed_arguments.map_hop_limit < 0: - parser.error("--map-hop-limit must be at least 0") + 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: @@ -202,8 +198,8 @@ def parse_params(conf, args=None) -> [NodeConfig]: period, bbox=terrain_bbox, limit=parsed_arguments.map_limit, - antenna_height=parsed_arguments.map_antenna_height, - hop_limit=parsed_arguments.map_hop_limit, + antenna_height=conf.HM, + hop_limit=conf.hopLimit, tx_power=conf.PTX, freq=conf.FREQ, return_origin=True, diff --git a/tests/test_lora_mesh_cli.py b/tests/test_lora_mesh_cli.py index c4769728..05e26c5e 100644 --- a/tests/test_lora_mesh_cli.py +++ b/tests/test_lora_mesh_cli.py @@ -198,6 +198,8 @@ def test_parse_params_loads_from_file_as_node_configs(self): def test_parse_params_loads_from_map_payload(self): conf = Config() + conf.HM = 2.5 + conf.hopLimit = 5 payload = [ { "latitude": 416200000, @@ -219,14 +221,14 @@ def test_parse_params_loads_from_map_payload(self): "https://example.test/nodes", "--map-bbox", "41.0,41.0,42.0,42.0", - "--map-antenna-height", - "2.5", "--no-gui", ], ) self.assertEqual(len(nodes), 2) - self.assertEqual(nodes[0].position.z, 2.5) + 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_can_build_srtm_terrain_for_map_payload(self): @@ -280,11 +282,12 @@ def test_parse_params_can_build_srtm_terrain_for_map_payload(self): self.assertEqual(conf.NODE_Z_REFERENCE, NODE_Z_REFERENCE_SEA_LEVEL) self.assertEqual(nodes[0].position.z, 500) self.assertNotEqual(nodes[0].position.z, nodes[1].position.z) - self.assertGreater(nodes[1].position.z, 1.5) - self.assertEqual([node.antenna_height for node in nodes], [1.5, 1.5]) + 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, @@ -322,8 +325,6 @@ def test_parse_params_ignores_map_altitude_when_applying_srtm(self): "--from-map", "https://example.test/nodes", "--map-bbox=-1.7,-2.7,-1.2,-2.2", - "--map-antenna-height", - "2.5", "--terrain-srtm", "--terrain-srtm-step-meters", "20000", @@ -557,6 +558,7 @@ def test_failed_srtm_load_keeps_previous_terrain_config(self): def test_terrain_profile_samples_resets_between_parse_calls(self): conf = Config() + conf.TERRAIN_PROFILE_SAMPLES = 31 payload = [ { "latitude": 416200000, @@ -596,11 +598,12 @@ def test_terrain_profile_samples_resets_between_parse_calls(self): self.parse_quietly(conf, terrain_args) - self.assertEqual(conf.TERRAIN_PROFILE_SAMPLES, Config().TERRAIN_PROFILE_SAMPLES) + self.assertEqual(conf.TERRAIN_PROFILE_SAMPLES, 31) self.assertEqual(conf.NODE_Z_REFERENCE, NODE_Z_REFERENCE_SEA_LEVEL) 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 From 3e3ea5e65bb120c49181bf22d14ddd25ff10acf1 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Tue, 5 May 2026 23:49:43 +0400 Subject: [PATCH 04/17] fix(sim): preserve map altitude during terrain recompute --- lib/node.py | 2 +- loraMesh.py | 4 ++++ tests/test_lora_mesh_cli.py | 5 ++++- tests/test_node.py | 27 ++++++++++++++++++++++++++- 4 files changed, 35 insertions(+), 3 deletions(-) diff --git a/lib/node.py b/lib/node.py index d90f6936..d7056389 100644 --- a/lib/node.py +++ b/lib/node.py @@ -150,7 +150,7 @@ def compute_rssi_and_pathloss_to(self, rx_nodeconf, conf: Config) -> (float, flo return (rssi, pl) -def node_configs_from_yaml(raw_config, period: int, tx_power: int, freq: float) -> list[NodeConfig]: +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 diff --git a/loraMesh.py b/loraMesh.py index 921a15af..15e917ea 100755 --- a/loraMesh.py +++ b/loraMesh.py @@ -13,6 +13,8 @@ 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, ) @@ -294,6 +296,8 @@ def parse_params(conf, args=None) -> [NodeConfig]: 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 diff --git a/tests/test_lora_mesh_cli.py b/tests/test_lora_mesh_cli.py index 05e26c5e..4624a934 100644 --- a/tests/test_lora_mesh_cli.py +++ b/tests/test_lora_mesh_cli.py @@ -13,6 +13,7 @@ from unittest import mock from lib.config import Config +from lib.srtm import SRTM_DATA_ATTRIBUTION_URL from lib.terrain import NODE_Z_REFERENCE_GROUND, NODE_Z_REFERENCE_SEA_LEVEL import loraMesh @@ -257,7 +258,7 @@ def test_parse_params_can_build_srtm_terrain_for_map_payload(self): ) with mock.patch("loraMesh.fetch_map_payload", return_value=payload): - nodes, _ = self.parse_quietly( + nodes, output = self.parse_quietly( conf, [ "--from-map", @@ -280,6 +281,8 @@ def test_parse_params_can_build_srtm_terrain_for_map_payload(self): 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) diff --git a/tests/test_node.py b/tests/test_node.py index 00bade18..ac5cb84a 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -1,8 +1,13 @@ import unittest import lib.node +import simpy -from lib.node import MESHTASTIC_ROLE, node_configs_from_yaml, origin_from_yaml +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): @@ -106,5 +111,25 @@ def test_wrapped_node_map_origin_must_be_in_coordinate_range(self): 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) + sim_state = SimulationState(conf, simpy.Environment()) + node = MeshNode(conf, sim_state, SimulationDataTracking(), node_config) + + apply_terrain_altitude(conf.TERRAIN_GRID, node) + self.assertEqual(node.position.z, 150) + + node.position.update_xy(100, 0) + apply_terrain_altitude(conf.TERRAIN_GRID, node) + self.assertEqual(node.position.z, 150) + + if __name__ == "__main__": unittest.main() From 9cd7c6236e4fb7f53d14d7565008ae0db6cd25da Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Wed, 6 May 2026 10:31:36 +0400 Subject: [PATCH 05/17] feat(sim): import local NodeDB positions --- DISCRETE_EVENT_SIM.md | 28 +++++--- lib/map_input.py | 52 ++++++++++----- lib/nodedb_input.py | 124 ++++++++++++++++++++++++++++++++++++ loraMesh.py | 40 +++++++++++- tests/test_lora_mesh_cli.py | 46 +++++++++++++ tests/test_map_input.py | 54 ++++++++++++++++ 6 files changed, 317 insertions(+), 27 deletions(-) create mode 100644 lib/nodedb_input.py diff --git a/DISCRETE_EVENT_SIM.md b/DISCRETE_EVENT_SIM.md index 8fbe2f26..17ae887c 100644 --- a/DISCRETE_EVENT_SIM.md +++ b/DISCRETE_EVENT_SIM.md @@ -23,19 +23,29 @@ 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 public Meshtastic map node locations. The map -endpoint currently returns 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: +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``` -Map-imported nodes use the same `HM` antenna height and `hopLimit` defaults as +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: + +```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 -imported public map does not carry the simulation value you want. +position source does not carry the simulation value you want. Terrain obstruction can be added to map or origin-backed scenario inputs without creating a custom terrain file. `--terrain-srtm` downloads missing SRTM HGT diff --git a/lib/map_input.py b/lib/map_input.py index bb7c3569..9f6ffb87 100644 --- a/lib/map_input.py +++ b/lib/map_input.py @@ -1,4 +1,4 @@ -"""Input adapter for public Meshtastic map node locations. +"""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 @@ -24,7 +24,10 @@ def decode_map_coordinate(value): """Decode Meshtastic map integer coordinates into decimal degrees.""" if value is None: return None - return float(value) / 1e7 + coordinate = float(value) + if abs(coordinate) > 180: + coordinate /= 1e7 + return coordinate def decode_map_altitude(value): @@ -134,11 +137,9 @@ def filter_positioned_map_nodes(nodes, bbox=None): return positioned -def node_configs_from_map_payload( - payload, +def node_configs_from_positioned_rows( + positioned, period, - bbox=None, - limit=None, antenna_height=1.5, hop_limit=3, tx_power=30, @@ -146,15 +147,7 @@ def node_configs_from_map_payload( 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") - + """Build NodeConfig objects from `(node, lat, lon)` positioned rows.""" if origin is None: origin_lat = statistics.median([lat for _, lat, _ in positioned]) origin_lon = statistics.median([lon for _, _, lon in positioned]) @@ -192,3 +185,32 @@ def node_configs_from_map_payload( 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, + 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, + origin=origin, + return_origin=return_origin, + ) diff --git a/lib/nodedb_input.py b/lib/nodedb_input.py new file mode 100644 index 00000000..a4edb29a --- /dev/null +++ b/lib/nodedb_input.py @@ -0,0 +1,124 @@ +"""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 = position.get("latitude") + if lat is None: + lat = decode_map_coordinate(position.get("latitudeI")) + lon = 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, + 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, + origin=origin, + return_origin=return_origin, + ) diff --git a/loraMesh.py b/loraMesh.py index 15e917ea..45d37e91 100755 --- a/loraMesh.py +++ b/loraMesh.py @@ -10,6 +10,7 @@ from lib.config import CONFIG 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, @@ -100,6 +101,7 @@ def parse_params(conf, args=None) -> [NodeConfig]: 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. @@ -115,8 +117,11 @@ def parse_params(conf, args=None) -> [NodeConfig]: 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('--map-bbox', type=str, help='Map import bounding box as min_lat,min_lon,max_lat,max_lon') - parser.add_argument('--map-limit', type=int, help='Maximum number of positioned map nodes to import after bbox filtering') + 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') @@ -168,8 +173,15 @@ def parse_params(conf, args=None) -> [NodeConfig]: 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 and --router-type can not be used together") + 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") seeded_for_scenario = False terrain_bbox = None @@ -210,6 +222,28 @@ def parse_params(conf, args=None) -> [NodeConfig]: except ValueError as err: parser.error(str(err)) nr_nodes = len(config) + 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, + return_origin=True, + ) + scenario_origin = nodedb_origin + except ValueError as err: + parser.error(str(err)) + nr_nodes = len(config) 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") diff --git a/tests/test_lora_mesh_cli.py b/tests/test_lora_mesh_cli.py index 4624a934..7c429028 100644 --- a/tests/test_lora_mesh_cli.py +++ b/tests/test_lora_mesh_cli.py @@ -232,6 +232,52 @@ def test_parse_params_loads_from_map_payload(self): 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_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_can_build_srtm_terrain_for_map_payload(self): conf = Config() payload = [ diff --git a/tests/test_map_input.py b/tests/test_map_input.py index f23b1b08..ffffde6b 100644 --- a/tests/test_map_input.py +++ b/tests/test_map_input.py @@ -8,12 +8,14 @@ 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): @@ -201,6 +203,58 @@ def test_map_payload_rejects_invalid_projection_origin(self): 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_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() From 842bf77cb2578747fa99fcf338d23055239c77f0 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Wed, 6 May 2026 14:56:55 +0400 Subject: [PATCH 06/17] fix(sim): validate nodedb port and edge srtm tiles --- lib/srtm.py | 87 +++++++++--- loraMesh.py | 273 +++++++++++++++++++++++++++--------- tests/test_lora_mesh_cli.py | 77 +++++++--- tests/test_srtm.py | 14 +- 4 files changed, 349 insertions(+), 102 deletions(-) diff --git a/lib/srtm.py b/lib/srtm.py index cd985869..a4402b50 100644 --- a/lib/srtm.py +++ b/lib/srtm.py @@ -18,8 +18,12 @@ 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" +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 @@ -66,13 +70,33 @@ def tiles_for_bbox(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") + raise ValueError( + "SRTM coverage is limited to longitudes between 180°W and 180°E" + ) - max_lat_tile = math.floor(math.nextafter(max_lat, -math.inf)) if max_lat > min_lat else math.floor(max_lat) - max_lon_tile = math.floor(math.nextafter(max_lon, -math.inf)) if max_lon > min_lon else math.floor(max_lon) + 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(math.floor(min_lat), max_lat_tile + 1): - for lon_floor in range(math.floor(min_lon), max_lon_tile + 1): + 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)) @@ -148,7 +172,9 @@ def _value_at(self, row, col): return float(value) -def ensure_hgt_tile(tile_name, cache_dir, url_template=DEFAULT_SRTM_URL_TEMPLATE, download_missing=True): +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) @@ -163,35 +189,52 @@ def ensure_hgt_tile(tile_name, cache_dir, url_template=DEFAULT_SRTM_URL_TEMPLATE 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 + 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'}" + 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 + 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: + 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")] + 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 + 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: + 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) @@ -215,7 +258,13 @@ def _coordinate_values(start, stop, step): return values -def terrain_rows_from_srtm(bbox, step_meters, cache_dir, url_template=DEFAULT_SRTM_URL_TEMPLATE, download_missing=True): +def terrain_rows_from_srtm( + bbox, + step_meters, + cache_dir, + url_template=DEFAULT_SRTM_URL_TEMPLATE, + download_missing=True, +): """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") @@ -256,7 +305,9 @@ def terrain_grid_from_srtm( ): """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): + for row in terrain_rows_from_srtm( + bbox, step_meters, cache_dir, url_template, download_missing + ): lat = float(row["lat"]) lon = float(row["lon"]) elevation = float(row["elevation_m"]) diff --git a/loraMesh.py b/loraMesh.py index 45d37e91..53d5ddfc 100755 --- a/loraMesh.py +++ b/loraMesh.py @@ -9,9 +9,19 @@ import yaml from lib.config import CONFIG -from lib.map_input import DEFAULT_MAP_NODES_URL, fetch_map_payload, node_configs_from_map_payload, parse_bbox +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.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, @@ -33,7 +43,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): @@ -93,40 +103,123 @@ 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('--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.') + 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('--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', + "--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', + 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( + "--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" ) - 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('--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) @@ -137,13 +230,23 @@ 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: @@ -152,9 +255,15 @@ def parse_params(conf, args=None) -> [NodeConfig]: 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: + 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: + 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.no_gui: @@ -175,13 +284,21 @@ def parse_params(conf, args=None) -> [NodeConfig]: 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") + 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") seeded_for_scenario = False terrain_bbox = None @@ -194,7 +311,9 @@ def parse_params(conf, args=None) -> [NodeConfig]: terrain_profile_samples = parsed_arguments.terrain_profile_samples if parsed_arguments.from_file is not None: try: - with open(os.path.join("out", parsed_arguments.from_file), 'r', encoding="utf-8") as file: + 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) @@ -203,7 +322,9 @@ def parse_params(conf, args=None) -> [NodeConfig]: nr_nodes = len(config) 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") + 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) @@ -246,9 +367,13 @@ def parse_params(conf, args=None) -> [NodeConfig]: nr_nodes = len(config) 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") + 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 @@ -263,7 +388,9 @@ def parse_params(conf, args=None) -> [NodeConfig]: 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") + 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 @@ -280,7 +407,9 @@ def parse_params(conf, args=None) -> [NodeConfig]: 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") + parser.error( + "--terrain-srtm requires --from-map --map-bbox or a scenario file with origin metadata" + ) if parsed_arguments.terrain_srtm: try: @@ -321,17 +450,20 @@ def parse_params(conf, args=None) -> [NodeConfig]: # 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})") + print( + "Terrain data attribution:", + f"{SRTM_DATA_ATTRIBUTION} ({SRTM_DATA_ATTRIBUTION_URL})", + ) return config @@ -367,39 +499,48 @@ 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), '%') + asymmetricLinkRate = results["asymmetricLinkRate"] + symmetricLinkRate = results["symmetricLinkRate"] + noLinkRate = results["noLinkRate"] + print("Asymmetric links:", round(asymmetricLinkRate * 100, 2), "%") + print("Symmetric links:", round(symmetricLinkRate * 100, 2), "%") + 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_lora_mesh_cli.py b/tests/test_lora_mesh_cli.py index 7c429028..cdb779d4 100644 --- a/tests/test_lora_mesh_cli.py +++ b/tests/test_lora_mesh_cli.py @@ -28,7 +28,11 @@ def write_hgt(path, values): 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 ] @@ -97,7 +101,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() @@ -106,7 +112,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) @@ -118,8 +126,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) @@ -181,7 +193,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) @@ -241,7 +255,11 @@ def test_parse_params_loads_from_nodedb_payload(self): 1: { "num": 1, "user": {"id": "!00000001", "role": "ROUTER"}, - "position": {"latitude": 41.62, "longitude": 41.59, "altitude": 120}, + "position": { + "latitude": 41.62, + "longitude": 41.59, + "altitude": 120, + }, }, 2: { "num": 2, @@ -251,7 +269,9 @@ def test_parse_params_loads_from_nodedb_payload(self): } } - with mock.patch("loraMesh.fetch_nodedb_payload", return_value=payload) as fetch_nodedb: + with mock.patch( + "loraMesh.fetch_nodedb_payload", return_value=payload + ) as fetch_nodedb: nodes, _ = self.parse_quietly( conf, [ @@ -264,7 +284,9 @@ def test_parse_params_loads_from_nodedb_payload(self): ], ) - fetch_nodedb.assert_called_once_with(host="192.0.2.10", port=None, serial_port=None) + 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]) @@ -278,6 +300,15 @@ def test_parse_params_rejects_nodedb_transport_without_nodedb_source(self): 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 = [ @@ -423,12 +454,16 @@ def test_parse_params_clears_geo_origin_for_scenarios_without_origin(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) try: - nodes, _ = self.parse_quietly(conf, ["--from-file", scenario_filename, "--no-gui"]) + nodes, _ = self.parse_quietly( + conf, ["--from-file", scenario_filename, "--no-gui"] + ) finally: os.unlink(os.path.join("out", scenario_filename)) @@ -460,12 +495,16 @@ def test_parse_params_rejects_one_node_before_changing_geo_origin(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) try: - self.assert_parser_rejects(conf, ["--from-file", scenario_filename, "--no-gui"]) + self.assert_parser_rejects( + conf, ["--from-file", scenario_filename, "--no-gui"] + ) finally: os.unlink(os.path.join("out", scenario_filename)) @@ -539,12 +578,16 @@ def test_terrain_srtm_from_file_rejects_uncovered_bbox_before_config_mutation(se ) 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) try: - error = self.assert_parser_rejects(conf, ["--from-file", scenario_filename, "--terrain-srtm", "--no-gui"]) + error = self.assert_parser_rejects( + conf, ["--from-file", scenario_filename, "--terrain-srtm", "--no-gui"] + ) finally: os.unlink(os.path.join("out", scenario_filename)) @@ -642,7 +685,9 @@ def test_terrain_profile_samples_resets_between_parse_calls(self): ] with mock.patch("loraMesh.fetch_map_payload", return_value=payload): - self.parse_quietly(conf, [*terrain_args, "--terrain-profile-samples", "7"]) + self.parse_quietly( + conf, [*terrain_args, "--terrain-profile-samples", "7"] + ) self.assertEqual(conf.TERRAIN_PROFILE_SAMPLES, 7) self.parse_quietly(conf, terrain_args) diff --git a/tests/test_srtm.py b/tests/test_srtm.py index 0a5d33ee..43478255 100644 --- a/tests/test_srtm.py +++ b/tests/test_srtm.py @@ -59,6 +59,10 @@ def test_tiles_for_bbox_covers_equator_and_prime_meridian_crossing(self): 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)) @@ -124,7 +128,10 @@ def test_ensure_hgt_tile_downloads_and_unpacks_gzip_template(self): 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: + 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( @@ -180,7 +187,9 @@ def test_ensure_hgt_tile_rejects_zip_without_requested_member(self): 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") + 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: @@ -199,5 +208,6 @@ def test_ensure_hgt_tile_does_not_cache_failed_unpack(self): self.assertFalse((cache_dir / "N41E041.hgt").exists()) self.assertFalse((cache_dir / "N41E041.hgt.tmp").exists()) + if __name__ == "__main__": unittest.main() From a2e51f67928e73dac60ca0eedf43b256f56c5df1 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Wed, 6 May 2026 16:36:25 +0400 Subject: [PATCH 07/17] fix(sim): sample srtm edge coordinates from existing tiles --- lib/srtm.py | 12 +++++++++++- tests/test_srtm.py | 18 ++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/lib/srtm.py b/lib/srtm.py index a4402b50..fbc17a3d 100644 --- a/lib/srtm.py +++ b/lib/srtm.py @@ -55,6 +55,16 @@ def srtm_tile_name(lat, lon): 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}") @@ -281,7 +291,7 @@ def terrain_rows_from_srtm( for lat in _coordinate_values(min_lat, max_lat, lat_step): for lon in _coordinate_values(min_lon, max_lon, lon_step): - tile = tiles.get(srtm_tile_name(lat, lon)) + tile = tiles.get(_sample_tile_name(lat, lon)) if tile is None: continue elevation = tile.elevation_at(lat, lon) diff --git a/tests/test_srtm.py b/tests/test_srtm.py index 43478255..7381f85b 100644 --- a/tests/test_srtm.py +++ b/tests/test_srtm.py @@ -117,6 +117,24 @@ def test_terrain_grid_from_srtm_avoids_csv_intermediate(self): 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_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")) From c36f1afd0a0d27b952d9d1526c11137131abb381 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Wed, 6 May 2026 17:23:35 +0400 Subject: [PATCH 08/17] fix(sim): limit auto srtm tiles to reachable paths --- lib/srtm.py | 54 +++++++++++++++++++++++++++--- loraMesh.py | 65 +++++++++++++++++++++++++++++++++---- tests/test_lora_mesh_cli.py | 17 ++++++++++ tests/test_srtm.py | 20 ++++++++++++ 4 files changed, 146 insertions(+), 10 deletions(-) diff --git a/lib/srtm.py b/lib/srtm.py index fbc17a3d..31f1e2ab 100644 --- a/lib/srtm.py +++ b/lib/srtm.py @@ -74,6 +74,12 @@ def _parse_tile_name(tile_name): 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 @@ -274,6 +280,7 @@ def terrain_rows_from_srtm( 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: @@ -284,13 +291,51 @@ def terrain_rows_from_srtm( 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 tiles_for_bbox(bbox): + 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) - for lat in _coordinate_values(min_lat, max_lat, lat_step): - for lon in _coordinate_values(min_lon, max_lon, lon_step): + 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 @@ -312,11 +357,12 @@ def terrain_grid_from_srtm( 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 + bbox, step_meters, cache_dir, url_template, download_missing, tile_names ): lat = float(row["lat"]) lon = float(row["lon"]) diff --git a/loraMesh.py b/loraMesh.py index 53d5ddfc..3b618477 100755 --- a/loraMesh.py +++ b/loraMesh.py @@ -28,12 +28,15 @@ 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__) @@ -73,15 +76,15 @@ def set_geo_origin(conf, origin): conf.GEO_ORIGIN_LAT, conf.GEO_ORIGIN_LON = origin -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.""" +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(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 + 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( @@ -94,6 +97,51 @@ def bbox_from_node_config(node_config, origin, margin_m=1000.0): ) +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 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)) + rssi_ab = conf.PTX + antenna_gain_a - path_loss + rssi_ba = conf.PTX + antenna_gain_b - 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. @@ -302,6 +350,7 @@ def parse_params(conf, args=None) -> [NodeConfig]: seeded_for_scenario = False terrain_bbox = None + terrain_tile_names = None scenario_origin = None terrain_grid = None terrain_enabled = parsed_arguments.terrain_srtm @@ -404,6 +453,9 @@ def parse_params(conf, args=None) -> [NodeConfig]: 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: @@ -422,6 +474,7 @@ def parse_params(conf, args=None) -> [NodeConfig]: 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 diff --git a/tests/test_lora_mesh_cli.py b/tests/test_lora_mesh_cli.py index cdb779d4..8bf3e410 100644 --- a/tests/test_lora_mesh_cli.py +++ b/tests/test_lora_mesh_cli.py @@ -13,6 +13,8 @@ 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 @@ -471,6 +473,21 @@ def test_parse_params_clears_geo_origin_for_scenarios_without_origin(self): 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_parse_params_rejects_one_node_before_changing_geo_origin(self): conf = Config() conf.GEO_ORIGIN_LAT = 41.625 diff --git a/tests/test_srtm.py b/tests/test_srtm.py index 7381f85b..67f805e1 100644 --- a/tests/test_srtm.py +++ b/tests/test_srtm.py @@ -135,6 +135,26 @@ def test_terrain_rows_samples_global_edges_from_existing_tiles(self): 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_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")) From 2eb8735bfd2802c51c44baedb0c9243e31a174cd Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Wed, 6 May 2026 17:31:38 +0400 Subject: [PATCH 09/17] docs(sim): clarify nodedb and srtm usage --- DISCRETE_EVENT_SIM.md | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/DISCRETE_EVENT_SIM.md b/DISCRETE_EVENT_SIM.md index 17ae887c..a9bc5c75 100644 --- a/DISCRETE_EVENT_SIM.md +++ b/DISCRETE_EVENT_SIM.md @@ -39,7 +39,13 @@ local scenario you want to simulate: 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: +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``` @@ -47,12 +53,19 @@ 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 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, samples the scenario -bounding box, and feeds the terrain grid directly into terrain-aware node -geometry. When publishing screenshots, reports, or derived datasets from this -terrain source, attribute the terrain data to +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: From 0ce5bdbf284c78393765ff78b0e6ea8d82f239ac Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Wed, 6 May 2026 17:35:07 +0400 Subject: [PATCH 10/17] fix(sim): fit bounds to imported node coordinates --- loraMesh.py | 26 +++++++++++++ tests/test_lora_mesh_cli.py | 74 +++++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/loraMesh.py b/loraMesh.py index 3b618477..584f95de 100755 --- a/loraMesh.py +++ b/loraMesh.py @@ -102,6 +102,26 @@ def bbox_from_node_config(node_config, origin, margin_m=1000.0): 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) @@ -349,6 +369,7 @@ def parse_params(conf, args=None) -> [NodeConfig]: parser.error("--nodedb-port requires --nodedb-host") seeded_for_scenario = False + bounds_follow_node_config = False terrain_bbox = None terrain_tile_names = None scenario_origin = None @@ -369,6 +390,7 @@ def parse_params(conf, args=None) -> [NodeConfig]: 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( @@ -392,6 +414,7 @@ def parse_params(conf, args=None) -> [NodeConfig]: 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: @@ -414,6 +437,7 @@ def parse_params(conf, args=None) -> [NodeConfig]: 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( @@ -450,6 +474,8 @@ def parse_params(conf, args=None) -> [NodeConfig]: if nr_nodes < 2: parser.error(f"Need at least two nodes. You specified {nr_nodes}") + if bounds_follow_node_config: + fit_simulation_bounds_to_node_config(conf, config) if parsed_arguments.terrain_srtm and terrain_bbox is None: try: terrain_bbox = bbox_from_node_config(config, scenario_origin) diff --git a/tests/test_lora_mesh_cli.py b/tests/test_lora_mesh_cli.py index 8bf3e410..5e4e02e9 100644 --- a/tests/test_lora_mesh_cli.py +++ b/tests/test_lora_mesh_cli.py @@ -248,6 +248,80 @@ def test_parse_params_loads_from_map_payload(self): 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 From 0afe3da3e455b730659f9283e567dce2db9f7d41 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Wed, 6 May 2026 18:09:27 +0400 Subject: [PATCH 11/17] fix(sim): keep bounds unchanged on rejected terrain imports --- loraMesh.py | 5 +++-- tests/test_lora_mesh_cli.py | 5 +++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/loraMesh.py b/loraMesh.py index 584f95de..8fd70943 100755 --- a/loraMesh.py +++ b/loraMesh.py @@ -474,8 +474,6 @@ def parse_params(conf, args=None) -> [NodeConfig]: if nr_nodes < 2: parser.error(f"Need at least two nodes. You specified {nr_nodes}") - if bounds_follow_node_config: - fit_simulation_bounds_to_node_config(conf, config) if parsed_arguments.terrain_srtm and terrain_bbox is None: try: terrain_bbox = bbox_from_node_config(config, scenario_origin) @@ -513,6 +511,9 @@ def parse_params(conf, args=None) -> [NodeConfig]: # 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 diff --git a/tests/test_lora_mesh_cli.py b/tests/test_lora_mesh_cli.py index 5e4e02e9..3b740910 100644 --- a/tests/test_lora_mesh_cli.py +++ b/tests/test_lora_mesh_cli.py @@ -697,6 +697,10 @@ def test_failed_srtm_load_keeps_previous_terrain_config(self): 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 = [ @@ -737,6 +741,7 @@ def test_failed_srtm_load_keeps_previous_terrain_config(self): 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): From 703b07297d2eda2b205371a90c71841578e0466d Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Fri, 15 May 2026 23:33:46 +0400 Subject: [PATCH 12/17] fix(sim): adapt terrain inputs to current radio config --- lib/map_input.py | 4 ++ lib/node.py | 9 ++- lib/nodedb_input.py | 4 ++ lib/packet.py | 7 ++ loraMesh.py | 25 ++++--- tests/test_lora_mesh_cli.py | 140 +++++++++++++++++++++++++++++++++++- tests/test_map_input.py | 32 +++++++++ tests/test_node.py | 19 +++++ 8 files changed, 226 insertions(+), 14 deletions(-) diff --git a/lib/map_input.py b/lib/map_input.py index 9f6ffb87..114eb16d 100644 --- a/lib/map_input.py +++ b/lib/map_input.py @@ -194,6 +194,8 @@ def node_configs_from_map_payload( limit=None, antenna_height=1.5, hop_limit=3, + tx_power=30, + freq=902e6, origin=None, return_origin=False, ): @@ -211,6 +213,8 @@ def node_configs_from_map_payload( 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 d7056389..c10ebc64 100644 --- a/lib/node.py +++ b/lib/node.py @@ -15,7 +15,11 @@ from lib.packet import NODENUM_BROADCAST, MeshPacket, MeshMessage from lib.phy import estimate_path_loss from lib.point import Point -from lib.terrain import NODE_Z_REFERENCE_SEA_LEVEL, apply_terrain_altitude +from lib.terrain import ( + NODE_Z_REFERENCE_SEA_LEVEL, + apply_terrain_altitude, + terrain_obstruction_loss, +) logger = logging.getLogger(__name__) @@ -61,7 +65,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, antenna_height=None, absolute_altitude=None): + 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: @@ -145,6 +149,7 @@ 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, node_antenna_height(self), node_antenna_height(rx_nodeconf)) + pl += terrain_obstruction_loss(conf, self.position, rx_nodeconf.position, self.freq) rssi = self.tx_power + self.antenna_gain + rx_nodeconf.antenna_gain - pl return (rssi, pl) diff --git a/lib/nodedb_input.py b/lib/nodedb_input.py index a4edb29a..1781c605 100644 --- a/lib/nodedb_input.py +++ b/lib/nodedb_input.py @@ -102,6 +102,8 @@ def node_configs_from_nodedb_payload( limit=None, antenna_height=1.5, hop_limit=3, + tx_power=30, + freq=902e6, origin=None, return_origin=False, ): @@ -119,6 +121,8 @@ def node_configs_from_nodedb_payload( 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/packet.py b/lib/packet.py index 6684db4b..3b9cbc3b 100644 --- a/lib/packet.py +++ b/lib/packet.py @@ -4,6 +4,7 @@ from lib.common import node_antenna_height 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 @@ -104,6 +105,12 @@ def __init__(self, conf, nodes, origTxNodeId, destId, txNodeId, plen, seq, genTi 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, + ) if conf.MODEL_ASYMMETRIC_LINKS: offset = MeshPacket.asym_rng.gauss(0, conf.MODEL_ASYMMETRIC_LINKS_STDDEV) diff --git a/loraMesh.py b/loraMesh.py index 8fd70943..1549ce6f 100755 --- a/loraMesh.py +++ b/loraMesh.py @@ -135,8 +135,10 @@ def nodes_have_flat_link_budget(conf, node_a, 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)) - rssi_ab = conf.PTX + antenna_gain_a - path_loss - rssi_ba = conf.PTX + antenna_gain_b - path_loss + 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 @@ -341,11 +343,7 @@ 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 @@ -381,6 +379,8 @@ def parse_params(conf, args=None) -> [NodeConfig]: terrain_profile_samples = parsed_arguments.terrain_profile_samples if parsed_arguments.from_file is not None: 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: @@ -431,6 +431,8 @@ def parse_params(conf, args=None) -> [NodeConfig]: 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 @@ -486,6 +488,10 @@ def parse_params(conf, args=None) -> [NodeConfig]: 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: @@ -519,6 +525,7 @@ def parse_params(conf, args=None) -> [NodeConfig]: 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 @@ -611,11 +618,7 @@ def run_simulation(conf, node_config): print("Number of packets dropped by delay/hop limit:", delayDropped) if conf.MODEL_ASYMMETRIC_LINKS: - asymmetricLinkRate = results["asymmetricLinkRate"] - symmetricLinkRate = results["symmetricLinkRate"] noLinkRate = results["noLinkRate"] - print("Asymmetric links:", round(asymmetricLinkRate * 100, 2), "%") - print("Symmetric links:", round(symmetricLinkRate * 100, 2), "%") print("No links:", round(noLinkRate * 100, 2), "%") if conf.MOVEMENT_ENABLED: diff --git a/tests/test_lora_mesh_cli.py b/tests/test_lora_mesh_cli.py index 3b740910..0ca4af40 100644 --- a/tests/test_lora_mesh_cli.py +++ b/tests/test_lora_mesh_cli.py @@ -16,7 +16,7 @@ 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 +from lib.terrain import NODE_Z_REFERENCE_GROUND, NODE_Z_REFERENCE_SEA_LEVEL, TerrainGrid import loraMesh @@ -562,6 +562,27 @@ def test_auto_srtm_tile_selection_skips_unreachable_link_corridors(self): 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 @@ -630,6 +651,14 @@ def test_terrain_srtm_generated_scenario_rejects_before_config_mutation(self): 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 @@ -688,6 +717,115 @@ def test_terrain_srtm_from_file_rejects_uncovered_bbox_before_config_mutation(se 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() diff --git a/tests/test_map_input.py b/tests/test_map_input.py index ffffde6b..4935adbb 100644 --- a/tests/test_map_input.py +++ b/tests/test_map_input.py @@ -240,6 +240,38 @@ def test_nodedb_payload_builds_projected_node_configs(self): 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_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}}, diff --git a/tests/test_node.py b/tests/test_node.py index ac5cb84a..050250ac 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -37,6 +37,25 @@ 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) + class TestNodeConfigYaml(unittest.TestCase): def test_plain_gui_node_map_is_accepted(self): From eca2d3483db4123861e4672f0af489efbbd5d2fd Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Mon, 18 May 2026 02:46:11 +0400 Subject: [PATCH 13/17] fix(sim): reset imported bounds and cap terrain cache --- lib/config.py | 1 + lib/terrain.py | 16 ++++++++-- loraMesh.py | 15 ++++++++++ tests/test_lora_mesh_cli.py | 33 +++++++++++++++++++++ tests/test_terrain.py | 58 +++++++++++++++++++++++++++++++++++++ 5 files changed, 120 insertions(+), 3 deletions(-) diff --git a/lib/config.py b/lib/config.py index a4f16efd..74248ed1 100644 --- a/lib/config.py +++ b/lib/config.py @@ -422,6 +422,7 @@ def __init__(self): 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 + self.TERRAIN_LOSS_CACHE_MAX_ENTRIES = 16384 # Misc self.SEED = 44 # random seed to use diff --git a/lib/terrain.py b/lib/terrain.py index c9b634c6..aeef8ae9 100644 --- a/lib/terrain.py +++ b/lib/terrain.py @@ -13,6 +13,7 @@ """ import math +from collections import OrderedDict EARTH_RADIUS_M = 6371000.0 @@ -171,9 +172,13 @@ def terrain_obstruction_loss(conf, tx_point, rx_point, freq): # 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_limit = max(0, int(getattr(conf, "TERRAIN_LOSS_CACHE_MAX_ENTRIES", 16384))) cache = getattr(conf, "_terrain_loss_cache", None) if cache is None: - cache = {} + cache = OrderedDict() + conf._terrain_loss_cache = cache + elif not isinstance(cache, OrderedDict): + cache = OrderedDict(cache) conf._terrain_loss_cache = cache cache_key = ( @@ -194,7 +199,9 @@ def terrain_obstruction_loss(conf, tx_point, rx_point, freq): conf.TERRAIN_MAX_LOSS_DB, ) if cache_key in cache: - return cache[cache_key] + loss = cache.pop(cache_key) + cache[cache_key] = loss + return loss horizontal_distance = math.hypot(rx_point.x - tx_point.x, rx_point.y - tx_point.y) if horizontal_distance <= 0: @@ -234,5 +241,8 @@ def terrain_obstruction_loss(conf, tx_point, rx_point, freq): worst_loss = max(worst_loss, knife_edge_loss_db(v)) loss = min(worst_loss, conf.TERRAIN_MAX_LOSS_DB) - cache[cache_key] = loss + if cache_limit > 0: + cache[cache_key] = loss + while len(cache) > cache_limit: + cache.popitem(last=False) return loss diff --git a/loraMesh.py b/loraMesh.py index 1549ce6f..528e180c 100755 --- a/loraMesh.py +++ b/loraMesh.py @@ -60,6 +60,10 @@ def get_cli_defaults(conf): "PERIOD": conf.PERIOD, "GUI_ENABLED": conf.GUI_ENABLED, "PLOT": conf.PLOT, + "OX": conf.OX, + "OY": conf.OY, + "XSIZE": conf.XSIZE, + "YSIZE": conf.YSIZE, "TERRAIN_PROFILE_SAMPLES": conf.TERRAIN_PROFILE_SAMPLES, "NODE_Z_REFERENCE": conf.NODE_Z_REFERENCE, }, @@ -67,6 +71,14 @@ def get_cli_defaults(conf): return getattr(conf, CLI_DEFAULT_ATTR) +def reset_simulation_bounds_to_cli_defaults(conf, cli_defaults): + """Restore caller baseline bounds before building a new scenario.""" + conf.OX = cli_defaults["OX"] + conf.OY = cli_defaults["OY"] + conf.XSIZE = cli_defaults["XSIZE"] + conf.YSIZE = cli_defaults["YSIZE"] + + def set_geo_origin(conf, origin): """Use scenario geographic origin for lat/lon terrain grids when available.""" if origin is None: @@ -456,6 +468,7 @@ def parse_params(conf, args=None) -> [NodeConfig]: conf.update_router_dependencies() # Generated node positions come from the global RNG. Seed immediately # before that generation, after every parser-only rejection path above. + reset_simulation_bounds_to_cli_defaults(conf, cli_defaults) conf.NR_NODES = nr_nodes conf.PERIOD = period random.seed(conf.SEED) @@ -470,6 +483,7 @@ def parse_params(conf, args=None) -> [NodeConfig]: parser.error("--no-gui requires nr_nodes or --from-file") from lib.gui import gen_scenario + reset_simulation_bounds_to_cli_defaults(conf, cli_defaults) config_dict = gen_scenario(conf) 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) @@ -518,6 +532,7 @@ def parse_params(conf, args=None) -> [NodeConfig]: random.seed(conf.SEED) if bounds_follow_node_config: + reset_simulation_bounds_to_cli_defaults(conf, cli_defaults) fit_simulation_bounds_to_node_config(conf, config) conf.SIMTIME = simtime diff --git a/tests/test_lora_mesh_cli.py b/tests/test_lora_mesh_cli.py index 0ca4af40..db9acef4 100644 --- a/tests/test_lora_mesh_cli.py +++ b/tests/test_lora_mesh_cli.py @@ -322,6 +322,39 @@ def test_parse_params_preserves_sufficient_caller_bounds_for_map_payload(self): self.assertEqual(conf.XSIZE, 100000) self.assertEqual(conf.YSIZE, 100000) + def test_generated_parse_resets_bounds_after_imported_map_expands_them(self): + conf = Config() + baseline_bounds = (conf.OX, conf.OY, conf.XSIZE, conf.YSIZE) + payload = [ + { + "latitude": 416200000, + "longitude": 414000000, + "role": 2, + }, + { + "latitude": 416300000, + "longitude": 418500000, + "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.assertNotEqual((conf.OX, conf.OY, conf.XSIZE, conf.YSIZE), baseline_bounds) + + self.parse_quietly(conf, ["2", "--no-gui"]) + + self.assertEqual((conf.OX, conf.OY, conf.XSIZE, conf.YSIZE), baseline_bounds) + def test_parse_params_loads_from_nodedb_payload(self): conf = Config() conf.HM = 2.5 diff --git a/tests/test_terrain.py b/tests/test_terrain.py index 64c62408..38aa2b6a 100644 --- a/tests/test_terrain.py +++ b/tests/test_terrain.py @@ -160,6 +160,64 @@ def test_effective_earth_radius_adds_curvature_loss_on_long_flat_link(self): self.assertGreater(loss, 0) + def test_terrain_loss_cache_is_bounded_for_moving_nodes(self): + conf = Config() + conf.TERRAIN_ENABLED = True + conf.TERRAIN_PROFILE_SAMPLES = 4 + conf.TERRAIN_LOSS_CACHE_MAX_ENTRIES = 3 + conf.TERRAIN_GRID = TerrainGrid.from_rows([ + (0, 0, 0), + (1000, 0, 80), + (2000, 0, 0), + (3000, 0, 0), + (4000, 0, 0), + ]) + + first_loss = terrain_obstruction_loss( + conf, + Point(0, 0, 2), + Point(1000, 0, 2), + conf.FREQ, + ) + for offset in range(1, 6): + terrain_obstruction_loss( + conf, + Point(offset * 100, 0, 2), + Point(1000 + offset * 100, 0, 2), + conf.FREQ, + ) + + self.assertLessEqual(len(conf._terrain_loss_cache), 3) + repeated_loss = terrain_obstruction_loss( + conf, + Point(0, 0, 2), + Point(1000, 0, 2), + conf.FREQ, + ) + self.assertEqual(repeated_loss, first_loss) + self.assertLessEqual(len(conf._terrain_loss_cache), 3) + + def test_zero_terrain_loss_cache_limit_disables_retention(self): + conf = Config() + conf.TERRAIN_ENABLED = True + conf.TERRAIN_PROFILE_SAMPLES = 4 + conf.TERRAIN_LOSS_CACHE_MAX_ENTRIES = 0 + conf.TERRAIN_GRID = TerrainGrid.from_rows([ + (0, 0, 0), + (500, 0, 80), + (1000, 0, 0), + ]) + + loss = terrain_obstruction_loss( + conf, + Point(0, 0, 2), + Point(1000, 0, 2), + conf.FREQ, + ) + + self.assertGreaterEqual(loss, 0) + self.assertEqual(len(conf._terrain_loss_cache), 0) + if __name__ == "__main__": unittest.main() From 2b2f328bdb29b3899cd4b6817642a17c91ab721b Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Mon, 18 May 2026 03:07:25 +0400 Subject: [PATCH 14/17] fix(sim): preserve numeric NodeDB roles --- lib/map_input.py | 5 +++-- lib/nodedb_input.py | 11 ++++++++--- tests/test_map_input.py | 2 ++ 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/lib/map_input.py b/lib/map_input.py index 114eb16d..b1dfe48f 100644 --- a/lib/map_input.py +++ b/lib/map_input.py @@ -79,10 +79,11 @@ def role_name_for_node(node): # 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. + raw_role = node.get("role") try: - role_value = int(node.get("role")) + role_value = int(raw_role) except (TypeError, ValueError): - role_value = node.get("role") + return str(raw_role).upper() if raw_role is not None else "CLIENT" return { 1: "CLIENT_MUTE", diff --git a/lib/nodedb_input.py b/lib/nodedb_input.py index 1781c605..c3879e95 100644 --- a/lib/nodedb_input.py +++ b/lib/nodedb_input.py @@ -1,7 +1,12 @@ """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 +from lib.map_input import ( + decode_map_altitude, + decode_map_coordinate, + node_configs_from_positioned_rows, + role_name_for_node, +) def fetch_nodedb_payload(host=None, port=None, serial_port=None): @@ -52,9 +57,9 @@ def nodedb_payload_nodes(payload): 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() + return role_name_for_node({"role": user["role"]}) if isinstance(node, dict) and node.get("role") is not None: - return str(node["role"]).upper() + return role_name_for_node({"role": node["role"]}) return "CLIENT" diff --git a/tests/test_map_input.py b/tests/test_map_input.py index 4935adbb..c21ecf89 100644 --- a/tests/test_map_input.py +++ b/tests/test_map_input.py @@ -286,6 +286,8 @@ def test_nodedb_payload_skips_unpositioned_nodes(self): 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") + self.assertEqual(role_name_for_nodedb_node({"user": {"role": 2}}), "ROUTER") + self.assertEqual(role_name_for_nodedb_node({"role": 4}), "REPEATER") if __name__ == "__main__": From 41473b4b011e9853cb38d7f62dced4f09068d6b5 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Mon, 18 May 2026 10:46:01 +0400 Subject: [PATCH 15/17] fix(sim): harden terrain input reuse Signed-off-by: Darafei Praliaskouski --- lib/srtm.py | 11 +++++++++-- tests/test_lora_mesh_cli.py | 36 ++++++++++++++++++++++++++++++++++++ tests/test_srtm.py | 16 ++++++++++++++++ 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/lib/srtm.py b/lib/srtm.py index 31f1e2ab..2780e870 100644 --- a/lib/srtm.py +++ b/lib/srtm.py @@ -213,17 +213,23 @@ def ensure_hgt_tile( cache_dir / f"{tile_name}{''.join(parsed_path.suffixes) or '.download'}" ) partial_hgt_path = cache_dir / f"{tile_name}.hgt.tmp" + direct_hgt_download = download_path == hgt_path + if direct_hgt_download: + download_path = partial_hgt_path try: + download_path.unlink(missing_ok=True) with urlopen(url, timeout=60) as response, download_path.open("wb") as out: shutil.copyfileobj(response, out) except (OSError, urllib.error.URLError) as err: + download_path.unlink(missing_ok=True) raise ValueError( f"could not download SRTM tile {tile_name} from {url}: {err}" ) from err try: - partial_hgt_path.unlink(missing_ok=True) + if not direct_hgt_download: + partial_hgt_path.unlink(missing_ok=True) if download_path.suffix == ".gz": with ( gzip.open(download_path, "rb") as src, @@ -253,7 +259,8 @@ def ensure_hgt_tile( ): shutil.copyfileobj(src, out) else: - download_path.replace(partial_hgt_path) + if not direct_hgt_download: + 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) diff --git a/tests/test_lora_mesh_cli.py b/tests/test_lora_mesh_cli.py index db9acef4..e1a94328 100644 --- a/tests/test_lora_mesh_cli.py +++ b/tests/test_lora_mesh_cli.py @@ -355,6 +355,42 @@ def test_generated_parse_resets_bounds_after_imported_map_expands_them(self): self.assertEqual((conf.OX, conf.OY, conf.XSIZE, conf.YSIZE), baseline_bounds) + def test_generated_parse_resets_bounds_after_imported_nodedb_expands_them(self): + conf = Config() + baseline_bounds = (conf.OX, conf.OY, conf.XSIZE, conf.YSIZE) + payload = { + "nodesByNum": { + 1: { + "num": 1, + "user": {"id": "!00000001", "role": "ROUTER"}, + "position": {"latitudeI": 416200000, "longitudeI": 414000000}, + }, + 2: { + "num": 2, + "user": {"id": "!00000002", "role": "CLIENT"}, + "position": {"latitudeI": 416300000, "longitudeI": 418500000}, + }, + } + } + + with mock.patch("loraMesh.fetch_nodedb_payload", return_value=payload): + self.parse_quietly( + conf, + [ + "--from-nodedb", + "--nodedb-host", + "192.0.2.10", + "--map-bbox", + "41.0,41.0,42.0,42.0", + "--no-gui", + ], + ) + self.assertNotEqual((conf.OX, conf.OY, conf.XSIZE, conf.YSIZE), baseline_bounds) + + self.parse_quietly(conf, ["2", "--no-gui"]) + + self.assertEqual((conf.OX, conf.OY, conf.XSIZE, conf.YSIZE), baseline_bounds) + def test_parse_params_loads_from_nodedb_payload(self): conf = Config() conf.HM = 2.5 diff --git a/tests/test_srtm.py b/tests/test_srtm.py index 67f805e1..6e0e468b 100644 --- a/tests/test_srtm.py +++ b/tests/test_srtm.py @@ -5,6 +5,7 @@ import zipfile from array import array from pathlib import Path +from unittest import mock from lib.srtm import ( HGT_VOID, @@ -246,6 +247,21 @@ def test_ensure_hgt_tile_does_not_cache_failed_unpack(self): self.assertFalse((cache_dir / "N41E041.hgt").exists()) self.assertFalse((cache_dir / "N41E041.hgt.tmp").exists()) + def test_ensure_hgt_tile_does_not_cache_failed_direct_hgt_download(self): + with tempfile.TemporaryDirectory() as tmpdir: + cache_dir = Path(tmpdir) / "cache" + + with mock.patch("lib.srtm.urlopen", side_effect=OSError("broken pipe")): + with self.assertRaisesRegex(ValueError, "could not download"): + ensure_hgt_tile( + "N41E041", + cache_dir, + url_template="https://example.test/{tile}.hgt", + ) + + self.assertFalse((cache_dir / "N41E041.hgt").exists()) + self.assertFalse((cache_dir / "N41E041.hgt.tmp").exists()) + if __name__ == "__main__": unittest.main() From 37a392e09a87f8c726e963c81493673926ca4032 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Mon, 18 May 2026 11:17:41 +0400 Subject: [PATCH 16/17] fix(sim): decode near-zero integer coordinates --- lib/map_input.py | 6 ++++++ tests/test_map_input.py | 3 +++ 2 files changed, 9 insertions(+) diff --git a/lib/map_input.py b/lib/map_input.py index b1dfe48f..b96666e6 100644 --- a/lib/map_input.py +++ b/lib/map_input.py @@ -24,6 +24,12 @@ def decode_map_coordinate(value): """Decode Meshtastic map integer coordinates into decimal degrees.""" if value is None: return None + if isinstance(value, int) and not isinstance(value, bool): + return value / 1e7 + if isinstance(value, str): + stripped = value.strip() + if stripped and stripped.lstrip("+-").isdigit(): + return int(stripped) / 1e7 coordinate = float(value) if abs(coordinate) > 180: coordinate /= 1e7 diff --git a/tests/test_map_input.py b/tests/test_map_input.py index c21ecf89..80b99604 100644 --- a/tests/test_map_input.py +++ b/tests/test_map_input.py @@ -15,7 +15,10 @@ class TestMapInput(unittest.TestCase): def test_decode_map_coordinate(self): self.assertEqual(decode_map_coordinate(416219136), 41.6219136) + self.assertEqual(decode_map_coordinate(50), 0.000005) + self.assertEqual(decode_map_coordinate("-50"), -0.000005) self.assertEqual(decode_map_coordinate(41.6219136), 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): From bc9314ceeee33f1be8ef7b27fadba9f2176a3cc6 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Mon, 18 May 2026 11:47:31 +0400 Subject: [PATCH 17/17] fix(sim): distinguish scaled integer coordinates --- lib/map_input.py | 49 +++++++++++++++---- lib/nodedb_input.py | 16 +++--- lib/srtm.py | 8 ++- lib/terrain.py | 15 +++++- loraMesh.py | 98 ++++++++++++++++++++++++++++++++----- tests/test_lora_mesh_cli.py | 96 +++++++++++++++++++++++++++++++++++- tests/test_map_input.py | 91 +++++++++++++++++++++++++++++++++- tests/test_srtm.py | 22 +++++++++ tests/test_terrain.py | 8 +++ 9 files changed, 368 insertions(+), 35 deletions(-) diff --git a/lib/map_input.py b/lib/map_input.py index b96666e6..e1128755 100644 --- a/lib/map_input.py +++ b/lib/map_input.py @@ -20,13 +20,13 @@ DEFAULT_MAP_NODES_URL = "https://meshtastic.liamcottle.net/api/v1/nodes" -def decode_map_coordinate(value): +def decode_map_coordinate(value, integer_scaled=False): """Decode Meshtastic map integer coordinates into decimal degrees.""" if value is None: return None - if isinstance(value, int) and not isinstance(value, bool): + if integer_scaled and isinstance(value, int) and not isinstance(value, bool): return value / 1e7 - if isinstance(value, str): + if integer_scaled and isinstance(value, str): stripped = value.strip() if stripped and stripped.lstrip("+-").isdigit(): return int(stripped) / 1e7 @@ -47,7 +47,10 @@ def decode_map_altitude(value): def parse_bbox(value): - """Parse `min_lat,min_lon,max_lat,max_lon` into a numeric tuple.""" + """Parse `min_lat,min_lon,max_lat,max_lon` into a numeric tuple. + + Longitude ranges may cross the antimeridian by using min_lon > max_lon. + """ 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") @@ -60,11 +63,27 @@ def parse_bbox(value): 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") + if min_lat > max_lat: + raise ValueError("map bbox minimum latitude must be less than maximum latitude") return min_lat, min_lon, max_lat, max_lon +def bbox_crosses_antimeridian(bbox): + """Return whether a bbox uses wrapped longitudes across the antimeridian.""" + _, min_lon, _, max_lon = bbox + return min_lon > max_lon + + +def bbox_contains_latlon(bbox, lat, lon): + """Return whether a lat/lon point is inside a bbox, including wrapped bboxes.""" + min_lat, min_lon, max_lat, max_lon = bbox + if not (min_lat <= lat <= max_lat): + return False + if bbox_crosses_antimeridian(bbox): + return lon >= min_lon or lon <= max_lon + return min_lon <= lon <= max_lon + + def fetch_map_payload(url=DEFAULT_MAP_NODES_URL): request = urllib.request.Request(url, headers={ "User-Agent": "Meshtasticator map input", @@ -127,7 +146,11 @@ def filter_positioned_map_nodes(nodes, bbox=None): try: lat = decode_map_coordinate(node.get("latitude")) + if lat is None: + lat = decode_map_coordinate(node.get("latitudeI"), integer_scaled=True) lon = decode_map_coordinate(node.get("longitude")) + if lon is None: + lon = decode_map_coordinate(node.get("longitudeI"), integer_scaled=True) except (TypeError, ValueError): continue if lat is None or lon is None: @@ -136,14 +159,22 @@ def filter_positioned_map_nodes(nodes, bbox=None): 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): + if not bbox_contains_latlon(bbox, lat, lon): continue positioned.append((node, lat, lon)) return positioned +def center_longitude(longitudes): + """Return an antimeridian-aware center longitude for positioned rows.""" + sin_sum = sum(math.sin(math.radians(lon)) for lon in longitudes) + cos_sum = sum(math.cos(math.radians(lon)) for lon in longitudes) + if abs(sin_sum) < 1e-12 and abs(cos_sum) < 1e-12: + return statistics.median(longitudes) + return math.degrees(math.atan2(sin_sum, cos_sum)) + + def node_configs_from_positioned_rows( positioned, period, @@ -157,7 +188,7 @@ def node_configs_from_positioned_rows( """Build NodeConfig objects from `(node, lat, lon)` positioned rows.""" if origin is None: origin_lat = statistics.median([lat for _, lat, _ in positioned]) - origin_lon = statistics.median([lon for _, _, lon in positioned]) + origin_lon = center_longitude([lon for _, _, lon in positioned]) else: try: origin_lat, origin_lon = (float(origin[0]), float(origin[1])) diff --git a/lib/nodedb_input.py b/lib/nodedb_input.py index c3879e95..3f79a79f 100644 --- a/lib/nodedb_input.py +++ b/lib/nodedb_input.py @@ -2,6 +2,7 @@ from lib.geo import valid_lat_lon from lib.map_input import ( + bbox_contains_latlon, decode_map_altitude, decode_map_coordinate, node_configs_from_positioned_rows, @@ -23,7 +24,9 @@ def fetch_nodedb_payload(host=None, port=None, serial_port=None): if host is not None: from meshtastic import tcp_interface - iface = tcp_interface.TCPInterface(hostname=host, portNumber=port or 4403) + iface = tcp_interface.TCPInterface( + hostname=host, portNumber=4403 if port is None else port + ) else: from meshtastic import serial_interface @@ -74,12 +77,12 @@ def positioned_nodedb_nodes(nodes, bbox=None): continue try: - lat = position.get("latitude") + lat = decode_map_coordinate(position.get("latitude")) if lat is None: - lat = decode_map_coordinate(position.get("latitudeI")) - lon = position.get("longitude") + lat = decode_map_coordinate(position.get("latitudeI"), integer_scaled=True) + lon = decode_map_coordinate(position.get("longitude")) if lon is None: - lon = decode_map_coordinate(position.get("longitudeI")) + lon = decode_map_coordinate(position.get("longitudeI"), integer_scaled=True) except (TypeError, ValueError): continue if lat is None or lon is None: @@ -88,8 +91,7 @@ def positioned_nodedb_nodes(nodes, bbox=None): 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): + if not bbox_contains_latlon(bbox, lat, lon): continue node = { diff --git a/lib/srtm.py b/lib/srtm.py index 2780e870..01cc7b48 100644 --- a/lib/srtm.py +++ b/lib/srtm.py @@ -262,7 +262,13 @@ def ensure_hgt_tile( if not direct_hgt_download: download_path.replace(partial_hgt_path) partial_hgt_path.replace(hgt_path) - except (OSError, gzip.BadGzipFile, zipfile.BadZipFile, ValueError) as err: + except ( + EOFError, + 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 diff --git a/lib/terrain.py b/lib/terrain.py index aeef8ae9..c1b2ef90 100644 --- a/lib/terrain.py +++ b/lib/terrain.py @@ -22,10 +22,21 @@ MAX_REASONABLE_STRUCTURE_HEIGHT_M = 850.0 +def normalize_longitude_delta(lon, origin_lon): + """Return shortest signed longitude delta in degrees.""" + return ((lon - origin_lon + 180.0) % 360.0) - 180.0 + + +def normalize_longitude(lon): + """Normalize longitude to the conventional [-180, 180] range.""" + return ((lon + 180.0) % 360.0) - 180.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) + lon_delta = normalize_longitude_delta(lon, origin_lon) + x = math.radians(lon_delta) * EARTH_RADIUS_M * math.cos(origin_lat_rad) y = math.radians(lat - origin_lat) * EARTH_RADIUS_M return x, y @@ -38,7 +49,7 @@ def xy_to_latlon(x, y, origin_lat, origin_lon): 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)) + lon = normalize_longitude(origin_lon + math.degrees(x / (EARTH_RADIUS_M * origin_cos))) return lat, lon diff --git a/loraMesh.py b/loraMesh.py index 528e180c..fea4c5b3 100755 --- a/loraMesh.py +++ b/loraMesh.py @@ -11,6 +11,7 @@ from lib.config import CONFIG from lib.map_input import ( DEFAULT_MAP_NODES_URL, + bbox_crosses_antimeridian, fetch_map_payload, node_configs_from_map_payload, parse_bbox, @@ -26,6 +27,10 @@ DEFAULT_SRTM_URL_TEMPLATE, SRTM_DATA_ATTRIBUTION, SRTM_DATA_ATTRIBUTION_URL, + SRTM_MAX_LAT, + SRTM_MAX_LON, + SRTM_MIN_LAT, + SRTM_MIN_LON, clamp_bbox_to_srtm_coverage, terrain_grid_from_srtm, tiles_for_bbox, @@ -34,6 +39,7 @@ NODE_Z_REFERENCE_SEA_LEVEL, apply_terrain_altitudes, node_antenna_height, + normalize_longitude_delta, xy_to_latlon, ) from lib.phy import estimate_path_loss @@ -90,8 +96,43 @@ def set_geo_origin(conf, 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: + bboxes = bboxes_from_points(points, origin, margin_m) + if not bboxes: + return None + if len(bboxes) == 1: + return bboxes[0] + return ( + min(bbox[0] for bbox in bboxes), + -180.0, + max(bbox[2] for bbox in bboxes), + 180.0, + ) + + +def _clamp_bbox_to_srtm_coverage_if_present(bbox): + min_lat, min_lon, max_lat, max_lon = bbox + if min_lat >= SRTM_MAX_LAT or max_lat <= SRTM_MIN_LAT: + raise ValueError("bbox does not overlap SRTM coverage") + if min_lon >= SRTM_MAX_LON or max_lon <= SRTM_MIN_LON: return None + return clamp_bbox_to_srtm_coverage(bbox) + + +def _clamped_bboxes_from_candidates(candidates): + bboxes = [] + for candidate in candidates: + bbox = _clamp_bbox_to_srtm_coverage_if_present(candidate) + if bbox is not None: + bboxes.append(bbox) + if not bboxes: + raise ValueError("bbox does not overlap SRTM coverage") + return bboxes + + +def bboxes_from_points(points, origin, margin_m=1000.0): + """Build one or two geographic bboxes around local x/y points.""" + if origin is None: + return [] 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 @@ -99,14 +140,29 @@ def bbox_from_points(points, origin, margin_m=1000.0): 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), + min_lat = min(lat_a, lat_b) + max_lat = max(lat_a, lat_b) + lon_delta_a = normalize_longitude_delta(lon_a, origin_lon) + lon_delta_b = normalize_longitude_delta(lon_b, origin_lon) + min_delta = min(lon_delta_a, lon_delta_b) + max_delta = max(lon_delta_a, lon_delta_b) + min_lon = origin_lon + min_delta + max_lon = origin_lon + max_delta + if min_lon < -180.0: + return _clamped_bboxes_from_candidates( + ( + (min_lat, min_lon + 360.0, max_lat, 180.0), + (min_lat, -180.0, max_lat, max_lon), + ) ) - ) + if max_lon > 180.0: + return _clamped_bboxes_from_candidates( + ( + (min_lat, min_lon, max_lat, 180.0), + (min_lat, -180.0, max_lat, max_lon - 360.0), + ) + ) + return _clamped_bboxes_from_candidates(((min_lat, min_lon, max_lat, max_lon),)) def bbox_from_node_config(node_config, origin, margin_m=1000.0): @@ -161,17 +217,17 @@ def srtm_tiles_for_node_config_links(conf, node_config, origin, margin_m=1000.0) 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 bbox in bboxes_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( + for bbox in bboxes_from_points( [node_a.position, node_b.position], origin, margin_m - ) - tile_names.update(tiles_for_bbox(bbox)) + ): + tile_names.update(tiles_for_bbox(bbox)) return sorted(tile_names) @@ -377,6 +433,8 @@ def parse_params(conf, args=None) -> [NodeConfig]: and parsed_arguments.nodedb_host is None ): parser.error("--nodedb-port requires --nodedb-host") + if parsed_arguments.nodedb_port is not None and parsed_arguments.nodedb_port <= 0: + parser.error("--nodedb-port must be a positive TCP port") seeded_for_scenario = False bounds_follow_node_config = False @@ -399,6 +457,12 @@ def parse_params(conf, args=None) -> [NodeConfig]: 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) + if ( + parsed_arguments.terrain_srtm + and terrain_bbox is not None + and bbox_crosses_antimeridian(terrain_bbox) + ): + terrain_bbox = None except (OSError, ValueError, yaml.YAMLError) as err: parser.error(f"could not load --from-file YAML: {err}") nr_nodes = len(config) @@ -423,6 +487,8 @@ def parse_params(conf, args=None) -> [NodeConfig]: return_origin=True, ) scenario_origin = map_origin + if parsed_arguments.terrain_srtm and bbox_crosses_antimeridian(terrain_bbox): + terrain_bbox = None except ValueError as err: parser.error(str(err)) nr_nodes = len(config) @@ -448,6 +514,12 @@ def parse_params(conf, args=None) -> [NodeConfig]: return_origin=True, ) scenario_origin = nodedb_origin + if ( + parsed_arguments.terrain_srtm + and terrain_bbox is not None + and bbox_crosses_antimeridian(terrain_bbox) + ): + terrain_bbox = None except ValueError as err: parser.error(str(err)) nr_nodes = len(config) diff --git a/tests/test_lora_mesh_cli.py b/tests/test_lora_mesh_cli.py index e1a94328..cce143c4 100644 --- a/tests/test_lora_mesh_cli.py +++ b/tests/test_lora_mesh_cli.py @@ -16,7 +16,12 @@ 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 +from lib.terrain import ( + NODE_Z_REFERENCE_GROUND, + NODE_Z_REFERENCE_SEA_LEVEL, + TerrainGrid, + latlon_to_xy, +) import loraMesh @@ -454,6 +459,15 @@ def test_parse_params_rejects_nodedb_port_without_host(self): self.assertIn("--nodedb-port requires --nodedb-host", error) + def test_parse_params_rejects_non_positive_nodedb_port(self): + conf = Config() + + error = self.assert_parser_rejects( + conf, ["--from-nodedb", "--nodedb-host", "192.0.2.10", "--nodedb-port", "0"] + ) + + self.assertIn("--nodedb-port must be a positive TCP port", error) + def test_parse_params_can_build_srtm_terrain_for_map_payload(self): conf = Config() payload = [ @@ -631,6 +645,23 @@ def test_auto_srtm_tile_selection_skips_unreachable_link_corridors(self): self.assertIn("N00E002", tiles) self.assertNotIn("N00E001", tiles) + def test_auto_srtm_tile_selection_splits_antimeridian_bboxes(self): + conf = Config() + east_x, east_y = latlon_to_xy(0.0, 179.9, 0.0, 180.0) + west_x, west_y = latlon_to_xy(0.0, -179.9, 0.0, 180.0) + nodes = [ + NodeConfig(0, Point(east_x, east_y, conf.HM), conf.PERIOD), + NodeConfig(1, Point(west_x, west_y, conf.HM), conf.PERIOD), + ] + + tiles = loraMesh.srtm_tiles_for_node_config_links( + conf, nodes, (0.0, 180.0), margin_m=1.0 + ) + + self.assertIn("N00E179", tiles) + self.assertIn("N00W180", tiles) + self.assertNotIn("N00E000", tiles) + def test_flat_link_budget_prefilter_includes_both_antenna_gains(self): conf = Config() node_a = NodeConfig( @@ -895,6 +926,69 @@ def test_terrain_srtm_from_file_honors_explicit_bbox(self): self.assertEqual(terrain_loader.call_args.args[0], (41.5, 41.5, 41.8, 41.8)) + def test_terrain_srtm_from_file_derives_tiles_for_wrapped_bbox(self): + conf = Config() + east_x, east_y = latlon_to_xy(0.1, 179.9, 0.0, 180.0) + west_x, west_y = latlon_to_xy(0.1, -179.9, 0.0, 180.0) + scenario = textwrap.dedent( + f"""\ + origin: + lat: 0.0 + lon: 180.0 + nodes: + 0: + x: {east_x} + y: {east_y} + z: 1 + isRouter: false + isRepeater: false + isClientMute: false + antennaGain: 0 + hopLimit: 3 + neighborInfo: false + 1: + x: {west_x} + y: {west_y} + 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([(east_x, east_y, 10), (west_x, west_y, 20)]) + 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=-1,179.5,1,-179.5", + "--no-gui", + ], + ) + finally: + os.unlink(os.path.join("out", scenario_filename)) + + self.assertEqual(terrain_loader.call_args.args[0][1], -180.0) + self.assertEqual(terrain_loader.call_args.args[0][3], 180.0) + self.assertIn("N00E179", terrain_loader.call_args.kwargs["tile_names"]) + self.assertIn("N00W180", terrain_loader.call_args.kwargs["tile_names"]) + def test_failed_srtm_load_keeps_previous_terrain_config(self): conf = Config() terrain_grid = object() diff --git a/tests/test_map_input.py b/tests/test_map_input.py index 80b99604..ced011f3 100644 --- a/tests/test_map_input.py +++ b/tests/test_map_input.py @@ -1,6 +1,7 @@ import unittest from lib.map_input import ( + bbox_contains_latlon, decode_map_altitude, decode_map_coordinate, node_configs_from_map_payload, @@ -15,8 +16,9 @@ class TestMapInput(unittest.TestCase): def test_decode_map_coordinate(self): self.assertEqual(decode_map_coordinate(416219136), 41.6219136) - self.assertEqual(decode_map_coordinate(50), 0.000005) - self.assertEqual(decode_map_coordinate("-50"), -0.000005) + self.assertEqual(decode_map_coordinate(50), 50.0) + self.assertEqual(decode_map_coordinate(50, integer_scaled=True), 0.000005) + self.assertEqual(decode_map_coordinate("-50", integer_scaled=True), -0.000005) self.assertEqual(decode_map_coordinate(41.6219136), 41.6219136) self.assertEqual(decode_map_coordinate("41.6219136"), 41.6219136) self.assertIsNone(decode_map_coordinate(None)) @@ -32,6 +34,14 @@ def test_decode_map_altitude_keeps_only_positive_finite_values(self): 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_accepts_antimeridian_crossing(self): + bbox = parse_bbox("-1,179.5,1,-179.5") + + self.assertEqual(bbox, (-1.0, 179.5, 1.0, -179.5)) + self.assertTrue(bbox_contains_latlon(bbox, 0.0, 179.9)) + self.assertTrue(bbox_contains_latlon(bbox, 0.0, -179.9)) + self.assertFalse(bbox_contains_latlon(bbox, 0.0, 0.0)) + def test_parse_bbox_rejects_wrong_order(self): with self.assertRaises(ValueError): parse_bbox("41.9,41.0,41.4,42.3") @@ -81,6 +91,47 @@ def test_map_payload_skips_bad_coordinates(self): self.assertEqual(len(configs), 1) + def test_map_payload_accepts_explicit_scaled_integer_coordinates_near_zero(self): + payload = [ + {"latitudeI": 50, "longitudeI": "-50", "role": 0}, + {"latitude": 41, "longitude": 41, "role": 0}, + ] + + configs = node_configs_from_map_payload(payload, 1000, origin=(0, 0)) + + self.assertEqual(len(configs), 2) + self.assertAlmostEqual(configs[0].position.y, 0.56, places=2) + self.assertAlmostEqual(configs[0].position.x, -0.56, places=2) + self.assertGreater(configs[1].position.x, 4_000_000) + self.assertGreater(configs[1].position.y, 4_000_000) + + def test_map_payload_infers_antimeridian_aware_origin(self): + payload = [ + {"latitude": 0.0, "longitude": 179.9, "role": 0}, + {"latitude": 0.0, "longitude": -179.9, "role": 0}, + ] + + configs, origin = node_configs_from_map_payload( + payload, 1000, return_origin=True + ) + + self.assertAlmostEqual(abs(origin[1]), 180.0) + self.assertLess(abs(configs[0].position.x), 20_000) + self.assertLess(abs(configs[1].position.x), 20_000) + + def test_map_payload_filters_antimeridian_crossing_bbox(self): + payload = [ + {"latitude": 0.0, "longitude": 179.9, "role": 0}, + {"latitude": 0.0, "longitude": -179.9, "role": 0}, + {"latitude": 0.0, "longitude": 0.0, "role": 0}, + ] + + configs = node_configs_from_map_payload( + payload, 1000, bbox=(-1.0, 179.5, 1.0, -179.5) + ) + + self.assertEqual(len(configs), 2) + 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") @@ -243,6 +294,19 @@ def test_nodedb_payload_builds_projected_node_configs(self): 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_payload_filters_antimeridian_crossing_bbox(self): + payload = [ + {"position": {"latitude": 0.0, "longitude": 179.9}}, + {"position": {"latitude": 0.0, "longitude": -179.9}}, + {"position": {"latitude": 0.0, "longitude": 0.0}}, + ] + + configs = node_configs_from_nodedb_payload( + payload, 1000, bbox=(-1.0, 179.5, 1.0, -179.5) + ) + + self.assertEqual(len(configs), 2) + def test_nodedb_payload_uses_supplied_radio_defaults(self): payload = [ { @@ -286,6 +350,29 @@ def test_nodedb_payload_skips_unpositioned_nodes(self): self.assertEqual(len(positioned), 1) self.assertEqual(positioned[0][1:], (41.62, 41.59)) + def test_nodedb_payload_decodes_plain_string_and_scaled_integer_coordinates(self): + payload = [ + { + "num": 1, + "position": {"latitude": "41.62", "longitude": "41.59"}, + }, + { + "num": 2, + "position": {"latitude": 416300000, "longitude": 416000000}, + }, + { + "num": 3, + "position": {"latitude": 41, "longitude": 41}, + }, + ] + + positioned = positioned_nodedb_nodes(payload) + + self.assertEqual( + [row[1:] for row in positioned], + [(41.62, 41.59), (41.63, 41.6), (41.0, 41.0)], + ) + 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") diff --git a/tests/test_srtm.py b/tests/test_srtm.py index 6e0e468b..542897ce 100644 --- a/tests/test_srtm.py +++ b/tests/test_srtm.py @@ -247,6 +247,28 @@ def test_ensure_hgt_tile_does_not_cache_failed_unpack(self): self.assertFalse((cache_dir / "N41E041.hgt").exists()) self.assertFalse((cache_dir / "N41E041.hgt.tmp").exists()) + def test_ensure_hgt_tile_converts_truncated_gzip_to_value_error(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]) + gzip_path = source_dir / "N41E041.hgt.gz" + with gzip.open(gzip_path, "wb") as dst: + dst.write(raw_hgt.read_bytes()) + gzip_path.write_bytes(gzip_path.read_bytes()[:8]) + + 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()) + def test_ensure_hgt_tile_does_not_cache_failed_direct_hgt_download(self): with tempfile.TemporaryDirectory() as tmpdir: cache_dir = Path(tmpdir) / "cache" diff --git a/tests/test_terrain.py b/tests/test_terrain.py index 38aa2b6a..e7e0210f 100644 --- a/tests/test_terrain.py +++ b/tests/test_terrain.py @@ -37,6 +37,14 @@ def test_latlon_projection_round_trips(self): self.assertAlmostEqual(out_lat, lat) self.assertAlmostEqual(out_lon, lon) + def test_latlon_projection_uses_shortest_antimeridian_delta(self): + east_x, _ = latlon_to_xy(0.0, 179.9, 0.0, 180.0) + west_x, _ = latlon_to_xy(0.0, -179.9, 0.0, 180.0) + + self.assertLess(abs(east_x), 20_000) + self.assertLess(abs(west_x), 20_000) + self.assertAlmostEqual(east_x, -west_x) + def test_xy_projection_rejects_polar_origin(self): with self.assertRaisesRegex(ValueError, "pole"): xy_to_latlon(100, 100, 90.0, 0.0)