From b2411506659ff76545d734eb1c6eb45f1a30c4cc Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Sat, 2 May 2026 11:53:42 +0400 Subject: [PATCH 01/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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 8d426680990a7adaca6658516027cb89d1a5f8d8 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Sat, 2 May 2026 11:55:28 +0400 Subject: [PATCH 13/24] feat(sim): add land-cover clutter inputs --- DISCRETE_EVENT_SIM.md | 10 ++ lib/clutter.py | 251 +++++++++++++++++++++++++++++ lib/config.py | 21 +++ lib/csv_validation.py | 28 ++++ lib/osm_clutter.py | 307 ++++++++++++++++++++++++++++++++++++ loraMesh.py | 23 +++ tests/test_clutter.py | 125 +++++++++++++++ tests/test_osm_clutter.py | 146 +++++++++++++++++ tools/osm_to_clutter_csv.py | 16 ++ 9 files changed, 927 insertions(+) create mode 100644 lib/clutter.py create mode 100644 lib/csv_validation.py create mode 100644 lib/osm_clutter.py create mode 100644 tests/test_clutter.py create mode 100644 tests/test_osm_clutter.py create mode 100644 tools/osm_to_clutter_csv.py diff --git a/DISCRETE_EVENT_SIM.md b/DISCRETE_EVENT_SIM.md index a9bc5c75..510ba2a9 100644 --- a/DISCRETE_EVENT_SIM.md +++ b/DISCRETE_EVENT_SIM.md @@ -78,6 +78,16 @@ against its own SRTM ground sample: plausible positive map altitudes are used as absolute node altitude, while missing, below-ground, or implausibly high values fall back to `SRTM ground + antenna height` for 3D distance calculations. +Land-cover clutter is a separate optional CSV grid. Use it for broad urban, +open, water, or forest excess-loss inputs without pretending Meshtasticator is a +building-level ray tracer: + +```python3 loraMesh.py --from-file nodeConfig.yaml --terrain-srtm --clutter-grid clutter.csv --no-gui``` + +`tools/osm_to_clutter_csv.py` can build a coarse clutter grid from public +OpenStreetMap building, landuse, natural, and water polygons. The simulator +never fetches OpenStreetMap data implicitly. + If you placed the nodes yourself, after a simulation the number of nodes, their coordinates and configuration are automatically saved and you can rerun the scenario with: ```python3 loraMesh.py --from-file``` diff --git a/lib/clutter.py b/lib/clutter.py new file mode 100644 index 00000000..40ebc8f7 --- /dev/null +++ b/lib/clutter.py @@ -0,0 +1,251 @@ +"""Optional land-cover clutter loss for radio links. + +Terrain handles hills, curvature, and Fresnel obstruction. It does not know +whether a lowland path crosses apartment blocks, a beach/coastal opening, or a +mountain-side vantage point looking down into the city. This module adds that +separate, data-driven clutter term from a small raster CSV. +""" + +import bisect +import csv +import math +from pathlib import Path + +from lib.csv_validation import finite_float, finite_lat_lon +from lib.terrain import latlon_to_xy, terrain_ground_elevation + + +class ClutterGrid: + """Nearest-cell lookup for small land-cover rasters.""" + + def __init__(self, samples): + self.samples = samples + self.xs = sorted({x for x, _, _ in samples}) + self.ys = sorted({y for _, y, _ in samples}) + self.by_xy = {(x, y): clutter_class for x, y, clutter_class in samples} + self.is_regular = len(self.xs) * len(self.ys) == len(samples) + + @classmethod + def from_csv(cls, path, origin_lat=None, origin_lon=None): + samples = [] + with open(path, newline="", encoding="utf-8") as fh: + reader = csv.DictReader(fh) + for row_number, row in enumerate(reader, start=2): + if "x_m" in row and "y_m" in row: + x = finite_float(row, "x_m", "clutter", row_number) + y = finite_float(row, "y_m", "clutter", row_number) + elif "lat" in row and "lon" in row: + if origin_lat is None or origin_lon is None: + raise ValueError("lat/lon clutter CSV requires GEO_ORIGIN_LAT and GEO_ORIGIN_LON") + lat, lon = finite_lat_lon(row, "clutter", row_number) + x, y = latlon_to_xy(lat, lon, origin_lat, origin_lon) + else: + raise ValueError("clutter CSV needs x_m/y_m or lat/lon columns") + + clutter_class = row.get("clutter_class") + if clutter_class is None or not clutter_class.strip(): + raise ValueError("clutter CSV needs clutter_class column") + samples.append((x, y, clutter_class.strip().lower())) + + if not samples: + raise ValueError(f"clutter CSV has no samples: {path}") + return cls(samples) + + @staticmethod + def _nearest_axis_value(values, value): + index = bisect.bisect_left(values, value) + if index <= 0: + return values[0] + if index >= len(values): + return values[-1] + + before = values[index - 1] + after = values[index] + return before if abs(value - before) <= abs(after - value) else after + + def class_at(self, x, y): + if self.is_regular: + nearest_x = self._nearest_axis_value(self.xs, x) + nearest_y = self._nearest_axis_value(self.ys, y) + return self.by_xy[(nearest_x, nearest_y)] + + _, _, clutter_class = min( + self.samples, + key=lambda sample: math.hypot(x - sample[0], y - sample[1]), + ) + return clutter_class + + +def _clutter_grid(conf): + if not conf.CLUTTER_ENABLED or not conf.CLUTTER_GRID_FILE: + return None + + # Lat/lon CSVs are projected into scenario-local meters, so the projection + # origin is part of the loaded grid identity, not just metadata. + cache_identity = (conf.CLUTTER_GRID_FILE, conf.GEO_ORIGIN_LAT, conf.GEO_ORIGIN_LON) + cached_identity = getattr(conf, "_clutter_grid_identity", None) + if getattr(conf, "_clutter_grid", None) is not None and cached_identity == cache_identity: + return conf._clutter_grid + + path = Path(conf.CLUTTER_GRID_FILE) + conf._clutter_grid = ClutterGrid.from_csv(path, conf.GEO_ORIGIN_LAT, conf.GEO_ORIGIN_LON) + conf._clutter_grid_identity = cache_identity + return conf._clutter_grid + + +def _class_loss_db_per_km(conf, clutter_class): + if clutter_class in {"urban", "building"}: + return conf.CLUTTER_URBAN_LOSS_DB_PER_KM + if clutter_class in {"suburban", "residential"}: + return conf.CLUTTER_SUBURBAN_LOSS_DB_PER_KM + if clutter_class in {"forest", "wood"}: + return conf.CLUTTER_FOREST_LOSS_DB_PER_KM + if clutter_class in {"water", "coastal_water"}: + return conf.CLUTTER_WATER_LOSS_DB_PER_KM + return conf.CLUTTER_OPEN_LOSS_DB_PER_KM + + +def clutter_path_features(conf, tx_point, rx_point): + """Return coarse land-cover fractions along a radio path. + + The radio calibration model needs reusable features, not node-pair lookup + tables. These fractions let a fitted model learn patterns such as "urban + lowland paths behave differently from open coastal paths" and apply that + lesson to new generated node pairs. + """ + grid = _clutter_grid(conf) + if grid is None: + return { + "urban_fraction": 0.0, + "open_fraction": 0.0, + "water_fraction": 0.0, + "forest_fraction": 0.0, + "endpoint_urban_count": 0.0, + } + + cache = getattr(conf, "_clutter_feature_cache", None) + if cache is None: + cache = {} + conf._clutter_feature_cache = cache + + cache_key = ( + round(tx_point.x, 2), + round(tx_point.y, 2), + round(tx_point.z, 2), + round(rx_point.x, 2), + round(rx_point.y, 2), + round(rx_point.z, 2), + conf.CLUTTER_GRID_FILE, + conf.GEO_ORIGIN_LAT, + conf.GEO_ORIGIN_LON, + conf.CLUTTER_PROFILE_SAMPLES, + ) + if cache_key in cache: + return cache[cache_key] + + samples = max(1, conf.CLUTTER_PROFILE_SAMPLES) + class_counts = {} + for index in range(samples): + fraction = (index + 0.5) / samples + x = tx_point.x + (rx_point.x - tx_point.x) * fraction + y = tx_point.y + (rx_point.y - tx_point.y) * fraction + clutter_class = grid.class_at(x, y) + class_counts[clutter_class] = class_counts.get(clutter_class, 0) + 1 + + endpoint_urban_count = 0 + for point in (tx_point, rx_point): + endpoint_class = grid.class_at(point.x, point.y) + if endpoint_class in {"urban", "building", "suburban", "residential"}: + endpoint_urban_count += 1 + + features = { + "urban_fraction": class_counts.get("urban", 0) / samples, + "open_fraction": class_counts.get("open", 0) / samples, + "water_fraction": (class_counts.get("water", 0) + class_counts.get("coastal_water", 0)) / samples, + "forest_fraction": (class_counts.get("forest", 0) + class_counts.get("wood", 0)) / samples, + "endpoint_urban_count": float(endpoint_urban_count), + } + cache[cache_key] = features + return features + + +def _is_high_vantage(conf, point): + ground = terrain_ground_elevation(conf, point) + return ground is not None and ground >= conf.CLUTTER_HIGH_VANTAGE_ELEVATION_M + + +def clutter_obstruction_loss(conf, tx_point, rx_point): + """Estimate extra land-cover clutter loss in dB for a TX/RX path.""" + grid = _clutter_grid(conf) + if grid is None: + return 0.0 + + cache = getattr(conf, "_clutter_loss_cache", None) + if cache is None: + cache = {} + conf._clutter_loss_cache = cache + + cache_key = ( + round(tx_point.x, 2), + round(tx_point.y, 2), + round(tx_point.z, 2), + round(rx_point.x, 2), + round(rx_point.y, 2), + round(rx_point.z, 2), + conf.CLUTTER_GRID_FILE, + conf.GEO_ORIGIN_LAT, + conf.GEO_ORIGIN_LON, + conf.CLUTTER_PROFILE_SAMPLES, + conf.CLUTTER_URBAN_LOSS_DB_PER_KM, + conf.CLUTTER_SUBURBAN_LOSS_DB_PER_KM, + conf.CLUTTER_FOREST_LOSS_DB_PER_KM, + conf.CLUTTER_OPEN_LOSS_DB_PER_KM, + conf.CLUTTER_WATER_LOSS_DB_PER_KM, + conf.CLUTTER_URBAN_ENDPOINT_LOSS_DB, + conf.CLUTTER_HIGH_VANTAGE_ELEVATION_M, + conf.CLUTTER_HIGH_VANTAGE_LOSS_FACTOR, + conf.CLUTTER_COASTAL_PATH_LOSS_FACTOR, + conf.CLUTTER_COASTAL_SAMPLE_FRACTION, + conf.CLUTTER_MAX_LOSS_DB, + ) + if cache_key in cache: + return cache[cache_key] + + horizontal_distance = math.hypot(rx_point.x - tx_point.x, rx_point.y - tx_point.y) + if horizontal_distance <= 0: + return 0.0 + + samples = max(1, conf.CLUTTER_PROFILE_SAMPLES) + class_counts = {} + path_loss_rate = 0.0 + for index in range(samples): + fraction = (index + 0.5) / samples + x = tx_point.x + (rx_point.x - tx_point.x) * fraction + y = tx_point.y + (rx_point.y - tx_point.y) * fraction + clutter_class = grid.class_at(x, y) + class_counts[clutter_class] = class_counts.get(clutter_class, 0) + 1 + path_loss_rate += _class_loss_db_per_km(conf, clutter_class) + + path_loss = (path_loss_rate / samples) * (horizontal_distance / 1000.0) + + # Coastal or sea-adjacent paths are often real line-of-sight corridors. Do + # not let a few nearby urban cells make them look like street-canyon links. + water_samples = class_counts.get("water", 0) + class_counts.get("coastal_water", 0) + open_samples = class_counts.get("open", 0) + class_counts.get("beach", 0) + if (water_samples + open_samples) / samples >= conf.CLUTTER_COASTAL_SAMPLE_FRACTION: + path_loss *= conf.CLUTTER_COASTAL_PATH_LOSS_FACTOR + + tx_high = _is_high_vantage(conf, tx_point) + rx_high = _is_high_vantage(conf, rx_point) + if tx_high or rx_high: + path_loss *= conf.CLUTTER_HIGH_VANTAGE_LOSS_FACTOR + + endpoint_loss = 0.0 + for point, high_vantage in ((tx_point, tx_high), (rx_point, rx_high)): + endpoint_class = grid.class_at(point.x, point.y) + if endpoint_class in {"urban", "building", "suburban", "residential"} and not high_vantage: + endpoint_loss += conf.CLUTTER_URBAN_ENDPOINT_LOSS_DB + + loss = min(path_loss + endpoint_loss, conf.CLUTTER_MAX_LOSS_DB) + cache[cache_key] = loss + return loss diff --git a/lib/config.py b/lib/config.py index a4f16efd..52eea397 100644 --- a/lib/config.py +++ b/lib/config.py @@ -423,6 +423,27 @@ def __init__(self): self.TERRAIN_MIN_ANTENNA_HEIGHT_M = 1.5 self.TERRAIN_MAX_LOSS_DB = 35.0 + ################################################# + ####### LAND-COVER CLUTTER MODEL ################ + ################################################# + # Optional excess loss from buildings/land use. This is intentionally + # separate from terrain: hills can be visible while low urban fabric + # still blocks balcony-to-balcony links. + self.CLUTTER_ENABLED = False + self.CLUTTER_GRID_FILE = None + self.CLUTTER_PROFILE_SAMPLES = 16 + self.CLUTTER_URBAN_LOSS_DB_PER_KM = 4.0 + self.CLUTTER_SUBURBAN_LOSS_DB_PER_KM = 2.0 + self.CLUTTER_FOREST_LOSS_DB_PER_KM = 2.5 + self.CLUTTER_OPEN_LOSS_DB_PER_KM = 0.2 + self.CLUTTER_WATER_LOSS_DB_PER_KM = 0.0 + self.CLUTTER_URBAN_ENDPOINT_LOSS_DB = 3.0 + self.CLUTTER_HIGH_VANTAGE_ELEVATION_M = 120.0 + self.CLUTTER_HIGH_VANTAGE_LOSS_FACTOR = 0.35 + self.CLUTTER_COASTAL_PATH_LOSS_FACTOR = 0.25 + self.CLUTTER_COASTAL_SAMPLE_FRACTION = 0.55 + self.CLUTTER_MAX_LOSS_DB = 25.0 + # Misc self.SEED = 44 # random seed to use # End of misc diff --git a/lib/csv_validation.py b/lib/csv_validation.py new file mode 100644 index 00000000..50172220 --- /dev/null +++ b/lib/csv_validation.py @@ -0,0 +1,28 @@ +"""Small validation helpers for simulator CSV inputs.""" + +import math + +from lib.geo import valid_lat_lon + + +def finite_float(row, column, csv_name, row_number): + """Parse a required finite float from a CSV row with a useful error.""" + try: + value = float(row[column]) + except KeyError as err: + raise ValueError(f"{csv_name} CSV row {row_number} needs {column} column") from err + except (TypeError, ValueError) as err: + raise ValueError(f"{csv_name} CSV row {row_number} has invalid {column}: {row.get(column)!r}") from err + + if not math.isfinite(value): + raise ValueError(f"{csv_name} CSV row {row_number} has non-finite {column}: {row.get(column)!r}") + return value + + +def finite_lat_lon(row, csv_name, row_number): + """Parse and range-check lat/lon columns from a CSV row.""" + lat = finite_float(row, "lat", csv_name, row_number) + lon = finite_float(row, "lon", csv_name, row_number) + if not valid_lat_lon(lat, lon): + raise ValueError(f"{csv_name} CSV row {row_number} has invalid latitude/longitude degrees") + return lat, lon diff --git a/lib/osm_clutter.py b/lib/osm_clutter.py new file mode 100644 index 00000000..99d4945c --- /dev/null +++ b/lib/osm_clutter.py @@ -0,0 +1,307 @@ +"""Export public OpenStreetMap land-use/building data to clutter CSV. + +This is a standalone data-prep helper. The simulator runtime reads the exported +CSV and never fetches OSM/Overpass data implicitly. +""" + +import argparse +import csv +import json +import math +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + +from lib.geo import valid_lat_lon +from lib.map_input import parse_bbox +from lib.terrain import latlon_to_xy, xy_to_latlon + + +DEFAULT_OVERPASS_URL = "https://overpass-api.de/api/interpreter" + +URBAN_LANDUSE = { + "commercial", + "construction", + "garages", + "industrial", + "military", + "railway", + "residential", + "retail", +} +FOREST_VALUES = {"forest", "wood"} +WATER_VALUES = {"basin", "reservoir", "salt_pond", "water"} +OPEN_NATURAL = {"beach", "grassland", "heath", "sand", "scrub"} + + +def overpass_query(bbox): + """Build a bounded Overpass query for clutter-relevant OSM polygons.""" + min_lat, min_lon, max_lat, max_lon = bbox + box = f"{min_lat},{min_lon},{max_lat},{max_lon}" + return f""" +[out:json][timeout:90]; +( + way["building"]({box}); + way["landuse"~"^(commercial|construction|garages|industrial|military|railway|residential|retail|forest)$"]({box}); + way["natural"~"^(beach|grassland|heath|sand|scrub|water|wood)$"]({box}); + way["water"]({box}); +); +out tags geom; +""" + + +def fetch_overpass_payload(bbox, url=DEFAULT_OVERPASS_URL): + data = urllib.parse.urlencode({"data": overpass_query(bbox)}).encode() + request = urllib.request.Request( + url, + data=data, + headers={ + "User-Agent": "Meshtasticator OSM clutter exporter", + "Accept": "application/json", + }, + ) + try: + with urllib.request.urlopen(request, timeout=120) as response: + return json.load(response) + except (OSError, urllib.error.URLError, json.JSONDecodeError) as err: + raise ValueError(f"could not fetch OSM clutter payload from {url}: {err}") from err + + +def parse_origin(value): + """Parse `lat,lon` for local raster projection origin.""" + parts = [part.strip() for part in value.split(",")] + if len(parts) != 2: + raise ValueError("origin must be lat,lon") + + lat, lon = [float(part) for part in parts] + if not math.isfinite(lat) or not math.isfinite(lon): + raise ValueError("origin values must be finite") + if not valid_lat_lon(lat, lon): + raise ValueError("origin values must be valid latitude/longitude degrees") + return lat, lon + + +def classify_osm_element(tags): + """Map OSM tags to broad clutter classes used by the radio model.""" + if tags.get("building"): + return "urban" + + landuse = tags.get("landuse") + natural = tags.get("natural") + water = tags.get("water") + + if landuse in URBAN_LANDUSE: + return "urban" + if landuse in FOREST_VALUES or natural in FOREST_VALUES: + return "forest" + if landuse in WATER_VALUES or natural in WATER_VALUES or water: + return "water" + if natural in OPEN_NATURAL: + return "open" + return None + + +def payload_elements(payload): + """Return Overpass elements from the expected JSON object shape.""" + if not isinstance(payload, dict): + raise ValueError("OSM payload must be a JSON object") + + elements = payload.get("elements", []) + if not isinstance(elements, list): + raise ValueError("OSM payload elements must be a list") + return elements + + +def point_in_polygon(x, y, polygon): + """Return True when a point is inside a simple polygon.""" + inside = False + j = len(polygon) - 1 + for i, (xi, yi) in enumerate(polygon): + xj, yj = polygon[j] + if (yi > y) != (yj > y): + x_intersect = (xj - xi) * (y - yi) / (yj - yi) + xi + if x < x_intersect: + inside = not inside + j = i + return inside + + +def polygon_bounds(polygon): + xs = [point[0] for point in polygon] + ys = [point[1] for point in polygon] + return min(xs), min(ys), max(xs), max(ys) + + +def polygon_centroid(polygon): + if not polygon: + return 0.0, 0.0 + return ( + sum(point[0] for point in polygon) / len(polygon), + sum(point[1] for point in polygon) / len(polygon), + ) + + +def osm_polygons(payload, origin): + """Yield `(clutter_class, polygon_xy, bounds, centroid)` from Overpass JSON.""" + origin_lat, origin_lon = origin + for element in payload_elements(payload): + if not isinstance(element, dict): + continue + + geometry = element.get("geometry") or [] + tags = element.get("tags") or {} + if not isinstance(geometry, list) or not isinstance(tags, dict): + continue + + clutter_class = classify_osm_element(tags) + if not clutter_class or len(geometry) < 3: + continue + + polygon = [] + for point in geometry: + if not isinstance(point, dict): + polygon = [] + break + try: + lat = float(point["lat"]) + lon = float(point["lon"]) + except (KeyError, TypeError, ValueError): + polygon = [] + break + if not valid_lat_lon(lat, lon): + polygon = [] + break + polygon.append(latlon_to_xy(lat, lon, origin_lat, origin_lon)) + if len(polygon) < 3: + continue + + if polygon[0] != polygon[-1]: + polygon.append(polygon[0]) + bounds = polygon_bounds(polygon) + centroid = polygon_centroid(polygon) + yield clutter_class, polygon, bounds, centroid + + +def _frange(start, stop, step): + value = start + epsilon = step / 1000.0 + while value <= stop + epsilon: + yield value + value += step + + +def classify_cell(x, y, polygons, step_m): + """Classify one clutter grid cell from intersecting OSM polygons.""" + hits = {"urban": 0, "forest": 0, "water": 0, "open": 0} + half = step_m / 2.0 + for clutter_class, polygon, bounds, centroid in polygons: + min_x, min_y, max_x, max_y = bounds + if x < min_x - half or x > max_x + half or y < min_y - half or y > max_y + half: + continue + + # Land-use polygons often contain the cell center. Building footprints + # are much smaller than the exported raster cell, so also count nearby + # building centroids/bounds as urban evidence. + if point_in_polygon(x, y, polygon): + hits[clutter_class] = hits.get(clutter_class, 0) + 2 + elif clutter_class == "urban" and min_x - half <= x <= max_x + half and min_y - half <= y <= max_y + half: + cx, cy = centroid + if abs(cx - x) <= half and abs(cy - y) <= half: + hits["urban"] += 1 + + if hits["water"] > 0: + return "water" + if hits["urban"] > 0: + return "urban" + if hits["forest"] > 0: + return "forest" + return "open" + + +def rasterize_clutter(payload, bbox, origin=None, step_m=500.0): + """Rasterize OSM polygons to rows suitable for `ClutterGrid.from_csv()`.""" + if not math.isfinite(step_m) or step_m <= 0: + raise ValueError("step_m must be a positive finite number") + + if origin is None: + min_lat, min_lon, max_lat, max_lon = bbox + origin = ((min_lat + max_lat) / 2.0, (min_lon + max_lon) / 2.0) + + origin_lat, origin_lon = origin + min_lat, min_lon, max_lat, max_lon = bbox + min_x, min_y = latlon_to_xy(min_lat, min_lon, origin_lat, origin_lon) + max_x, max_y = latlon_to_xy(max_lat, max_lon, origin_lat, origin_lon) + min_x, max_x = sorted((min_x, max_x)) + min_y, max_y = sorted((min_y, max_y)) + + polygons = list(osm_polygons(payload, origin)) + rows = [] + for x in _frange(math.floor(min_x / step_m) * step_m, math.ceil(max_x / step_m) * step_m, step_m): + for y in _frange(math.floor(min_y / step_m) * step_m, math.ceil(max_y / step_m) * step_m, step_m): + lat, lon = xy_to_latlon(x, y, origin_lat, origin_lon) + if not (min_lat <= lat <= max_lat and min_lon <= lon <= max_lon): + continue + rows.append({ + "x_m": round(x, 2), + "y_m": round(y, 2), + "lat": round(lat, 7), + "lon": round(lon, 7), + "clutter_class": classify_cell(x, y, polygons, step_m), + }) + return rows + + +def write_clutter_csv(rows, output_path): + output_path = Path(output_path) + output_path.parent.mkdir(parents=True, exist_ok=True) + with output_path.open("w", newline="", encoding="utf-8") as fh: + writer = csv.DictWriter( + fh, + fieldnames=["x_m", "y_m", "lat", "lon", "clutter_class"], + lineterminator="\n", + ) + writer.writeheader() + writer.writerows(rows) + + +def main(argv=None): + parser = argparse.ArgumentParser(description="export OSM land-use/building clutter to Meshtasticator CSV") + parser.add_argument("--bbox", required=True, help="min_lat,min_lon,max_lat,max_lon") + parser.add_argument("--origin", help="origin lat,lon for local x/y output; defaults to bbox center") + parser.add_argument("--step-meters", type=float, default=500.0, help="output raster spacing in meters") + parser.add_argument("--output", required=True, help="output clutter CSV path") + parser.add_argument("--overpass-url", default=DEFAULT_OVERPASS_URL, help="Overpass interpreter endpoint") + parser.add_argument("--input-json", help="read an existing Overpass JSON response instead of fetching") + args = parser.parse_args(argv) + + try: + bbox = parse_bbox(args.bbox) + except ValueError as err: + parser.error(str(err)) + + origin = None + if args.origin: + try: + origin = parse_origin(args.origin) + except ValueError as err: + parser.error(str(err)) + + if args.input_json: + try: + with open(args.input_json, encoding="utf-8") as fh: + payload = json.load(fh) + except (OSError, json.JSONDecodeError) as err: + parser.error(f"could not read OSM clutter JSON: {err}") + else: + payload = fetch_overpass_payload(bbox, args.overpass_url) + + try: + rows = rasterize_clutter(payload, bbox, origin=origin, step_m=args.step_meters) + except ValueError as err: + parser.error(str(err)) + write_clutter_csv(rows, args.output) + + +if __name__ == "__main__": + main() diff --git a/loraMesh.py b/loraMesh.py index 1549ce6f..e4bcde1c 100755 --- a/loraMesh.py +++ b/loraMesh.py @@ -244,6 +244,21 @@ def parse_params(conf, args=None) -> [NodeConfig]: type=int, help="number of terrain samples along each TX/RX path", ) + parser.add_argument( + "--clutter-grid", + type=str, + help="CSV land-cover clutter grid for optional building/urban excess loss", + ) + parser.add_argument( + "--clutter-profile-samples", + type=int, + help="number of clutter samples along each TX/RX path", + ) + parser.add_argument( + "--no-clutter", + action="store_true", + help="disable land-cover clutter even when a grid is available", + ) parser.add_argument( "--map-bbox", type=str, @@ -335,6 +350,8 @@ def parse_params(conf, args=None) -> [NodeConfig]: or parsed_arguments.terrain_srtm_step_meters <= 0 ): parser.error("--terrain-srtm-step-meters must be a positive finite number") + if parsed_arguments.clutter_profile_samples is not None and parsed_arguments.clutter_profile_samples < 1: + parser.error("--clutter-profile-samples must be at least 1") if parsed_arguments.no_gui: # Headless CI and smoke runs should not pay Tk startup, per-node @@ -365,6 +382,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.no_clutter and parsed_arguments.clutter_grid: + parser.error("--no-clutter can not be combined with --clutter-grid") seeded_for_scenario = False bounds_follow_node_config = False @@ -531,6 +550,10 @@ def parse_params(conf, args=None) -> [NodeConfig]: conf.TERRAIN_GRID = terrain_grid conf.TERRAIN_PROFILE_SAMPLES = terrain_profile_samples conf.NODE_Z_REFERENCE = node_z_reference + conf.CLUTTER_ENABLED = parsed_arguments.clutter_grid is not None and not parsed_arguments.no_clutter + conf.CLUTTER_GRID_FILE = parsed_arguments.clutter_grid + if parsed_arguments.clutter_profile_samples is not None: + conf.CLUTTER_PROFILE_SAMPLES = parsed_arguments.clutter_profile_samples if parsed_arguments.verbose: # Set this logger and lib.* to DEBUG only after the command line has diff --git a/tests/test_clutter.py b/tests/test_clutter.py new file mode 100644 index 00000000..bedee651 --- /dev/null +++ b/tests/test_clutter.py @@ -0,0 +1,125 @@ +import tempfile +import unittest +from pathlib import Path + +from lib.clutter import ClutterGrid, clutter_obstruction_loss, clutter_path_features +from lib.config import Config +from lib.point import Point + + +class TestClutter(unittest.TestCase): + def test_csv_grid_returns_nearest_regular_cell_class(self): + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "clutter.csv" + path.write_text( + "x_m,y_m,clutter_class\n" + "0,0,urban\n" + "500,0,water\n", + encoding="utf-8", + ) + + grid = ClutterGrid.from_csv(path) + + self.assertEqual(grid.class_at(20, 0), "urban") + self.assertEqual(grid.class_at(480, 0), "water") + + def test_csv_grid_rejects_non_finite_samples(self): + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "clutter.csv" + path.write_text( + "x_m,y_m,clutter_class\n" + "0,nan,urban\n", + encoding="utf-8", + ) + + with self.assertRaisesRegex(ValueError, "row 2"): + ClutterGrid.from_csv(path) + + def test_csv_grid_rejects_blank_clutter_class(self): + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "clutter.csv" + path.write_text( + "x_m,y_m,clutter_class\n" + "0,0, \n", + encoding="utf-8", + ) + + with self.assertRaisesRegex(ValueError, "clutter_class"): + ClutterGrid.from_csv(path) + + def test_latlon_csv_grid_rejects_out_of_range_samples(self): + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "clutter.csv" + path.write_text( + "lat,lon,clutter_class\n" + "91,41,urban\n", + encoding="utf-8", + ) + + with self.assertRaisesRegex(ValueError, "latitude/longitude"): + ClutterGrid.from_csv(path, origin_lat=41.0, origin_lon=41.0) + + def test_urban_clutter_adds_more_loss_than_coastal_open_path(self): + conf = Config() + conf.CLUTTER_ENABLED = True + conf.CLUTTER_PROFILE_SAMPLES = 4 + + with tempfile.TemporaryDirectory() as tmpdir: + urban_path = Path(tmpdir) / "urban.csv" + urban_path.write_text( + "x_m,y_m,clutter_class\n" + "0,0,urban\n" + "500,0,urban\n" + "1000,0,urban\n", + encoding="utf-8", + ) + conf.CLUTTER_GRID_FILE = str(urban_path) + + urban_loss = clutter_obstruction_loss(conf, Point(0, 0, 2), Point(1000, 0, 2)) + + open_path = Path(tmpdir) / "open.csv" + open_path.write_text( + "x_m,y_m,clutter_class\n" + "0,0,water\n" + "500,0,water\n" + "1000,0,water\n", + encoding="utf-8", + ) + conf._clutter_grid = None + conf._clutter_loss_cache = {} + conf.CLUTTER_GRID_FILE = str(open_path) + + open_loss = clutter_obstruction_loss(conf, Point(0, 0, 2), Point(1000, 0, 2)) + + self.assertGreater(urban_loss, open_loss) + + def test_latlon_grid_cache_tracks_projection_origin(self): + conf = Config() + conf.CLUTTER_ENABLED = True + conf.CLUTTER_PROFILE_SAMPLES = 1 + + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "clutter.csv" + path.write_text( + "lat,lon,clutter_class\n" + "10.0,10.0,urban\n" + "10.0,10.01,water\n", + encoding="utf-8", + ) + conf.CLUTTER_GRID_FILE = str(path) + conf.GEO_ORIGIN_LAT = 10.0 + conf.GEO_ORIGIN_LON = 10.0 + + first = clutter_path_features(conf, Point(0, 0, 1), Point(0, 0, 1)) + self.assertEqual(first["urban_fraction"], 1.0) + + # The same CSV can be projected around a different scenario origin. + # Include origin in the grid cache key so map/preset inputs cannot + # accidentally reuse stale cell coordinates. + conf.GEO_ORIGIN_LON = 10.01 + second = clutter_path_features(conf, Point(0, 0, 1), Point(0, 0, 1)) + self.assertEqual(second["water_fraction"], 1.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_osm_clutter.py b/tests/test_osm_clutter.py new file mode 100644 index 00000000..eafc40fe --- /dev/null +++ b/tests/test_osm_clutter.py @@ -0,0 +1,146 @@ +import contextlib +import io +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory + +from lib.osm_clutter import ( + classify_osm_element, + main as osm_clutter_main, + parse_origin, + payload_elements, + point_in_polygon, + rasterize_clutter, + write_clutter_csv, +) + + +class TestOsmClutter(unittest.TestCase): + def test_classifies_osm_tags_to_radio_clutter_classes(self): + self.assertEqual(classify_osm_element({"building": "yes"}), "urban") + self.assertEqual(classify_osm_element({"landuse": "residential"}), "urban") + self.assertEqual(classify_osm_element({"natural": "wood"}), "forest") + self.assertEqual(classify_osm_element({"natural": "water"}), "water") + self.assertEqual(classify_osm_element({"natural": "beach"}), "open") + + def test_parse_origin_rejects_non_finite_values(self): + self.assertEqual(parse_origin("41.6,41.6"), (41.6, 41.6)) + with self.assertRaises(ValueError): + parse_origin("41.6,nan") + + def test_parse_origin_rejects_out_of_range_values(self): + with self.assertRaises(ValueError): + parse_origin("91,41.6") + + def test_point_in_polygon(self): + polygon = [(0, 0), (10, 0), (10, 10), (0, 10), (0, 0)] + + self.assertTrue(point_in_polygon(5, 5, polygon)) + self.assertFalse(point_in_polygon(15, 5, polygon)) + + def test_payload_elements_rejects_malformed_overpass_shape(self): + with self.assertRaisesRegex(ValueError, "JSON object"): + payload_elements([]) + with self.assertRaisesRegex(ValueError, "elements"): + payload_elements({"elements": {}}) + + def test_rasterize_clutter_marks_building_cells_urban(self): + payload = { + "elements": [{ + "type": "way", + "tags": {"building": "yes"}, + "geometry": [ + {"lat": 0.0005, "lon": 0.0005}, + {"lat": 0.0005, "lon": 0.0015}, + {"lat": 0.0015, "lon": 0.0015}, + {"lat": 0.0015, "lon": 0.0005}, + {"lat": 0.0005, "lon": 0.0005}, + ], + }], + } + + rows = rasterize_clutter(payload, (0.0, 0.0, 0.002, 0.002), origin=(0.0, 0.0), step_m=100.0) + + self.assertIn("urban", {row["clutter_class"] for row in rows}) + + def test_rasterize_clutter_skips_malformed_osm_elements(self): + payload = { + "elements": [ + "not an element", + { + "type": "way", + "tags": {"building": "yes"}, + "geometry": [ + {"lat": 0.0005, "lon": 0.0005}, + {"lat": "bad", "lon": 0.0015}, + {"lat": 0.0015, "lon": 0.0005}, + ], + }, + { + "type": "way", + "tags": {"building": "yes"}, + "geometry": [ + {"lat": 0.0005, "lon": 0.0005}, + {"lat": 0.0005, "lon": 0.0015}, + {"lat": 0.0015, "lon": 0.0015}, + {"lat": 0.0015, "lon": 0.0005}, + ], + }, + ], + } + + rows = rasterize_clutter(payload, (0.0, 0.0, 0.002, 0.002), origin=(0.0, 0.0), step_m=100.0) + + self.assertIn("urban", {row["clutter_class"] for row in rows}) + + def test_rasterize_clutter_rejects_non_positive_step(self): + with self.assertRaises(ValueError): + rasterize_clutter({"elements": []}, (0.0, 0.0, 0.002, 0.002), step_m=0) + + def test_write_clutter_csv_uses_lf_line_endings(self): + with TemporaryDirectory() as tmpdir: + output = Path(tmpdir) / "clutter.csv" + + write_clutter_csv([{ + "x_m": 0, + "y_m": 0, + "lat": 41.0, + "lon": 41.0, + "clutter_class": "open", + }], output) + + raw = output.read_bytes() + + self.assertIn(b"\n", raw) + self.assertNotIn(b"\r\n", raw) + + def test_write_clutter_csv_creates_parent_directories(self): + with TemporaryDirectory() as tmpdir: + output = Path(tmpdir) / "nested" / "clutter.csv" + + write_clutter_csv([{ + "x_m": 0, + "y_m": 0, + "lat": 41.0, + "lon": 41.0, + "clutter_class": "open", + }], output) + + self.assertTrue(output.exists()) + + def test_cli_rejects_invalid_bbox_without_traceback(self): + stderr = io.StringIO() + with contextlib.redirect_stderr(stderr): + with self.assertRaises(SystemExit) as raised: + osm_clutter_main([ + "--bbox", "0,0,nan,0.002", + "--input-json", "/dev/null", + "--output", "/tmp/unused-clutter.csv", + ]) + + self.assertEqual(raised.exception.code, 2) + self.assertIn("map bbox values must be finite", stderr.getvalue()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/osm_to_clutter_csv.py b/tools/osm_to_clutter_csv.py new file mode 100644 index 00000000..b17e4a42 --- /dev/null +++ b/tools/osm_to_clutter_csv.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 +"""CLI wrapper for exporting OSM land-cover clutter into CSV format.""" + +from pathlib import Path +import sys + + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from lib.osm_clutter import main # noqa: E402 + + +if __name__ == "__main__": + main() From c3cdbb64307b61dfb83932fa0e3cb79830233bbb Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Sat, 2 May 2026 12:00:50 +0400 Subject: [PATCH 14/24] feat(sim): add capture-aware rf physics --- DISCRETE_EVENT_SIM.md | 10 ++ lib/common.py | 21 ++-- lib/config.py | 33 ++++++ lib/discrete_event_sim.py | 30 +++++- lib/link_model.py | 119 +++++++++++++++++++++ lib/mac.py | 10 +- lib/node.py | 190 +++++++++++++++++++++------------- lib/packet.py | 160 +++++++++++++++++----------- lib/phy.py | 146 +++++++++++++++++++++++++- lib/radio_loss.py | 80 ++++++++++++++ loraMesh.py | 12 +++ tests/test_collision_model.py | 95 +++++++++++++++++ tests/test_link_model.py | 84 +++++++++++++++ tests/test_node.py | 29 +++++- tests/test_radio_loss.py | 77 ++++++++++++++ 15 files changed, 945 insertions(+), 151 deletions(-) create mode 100644 lib/link_model.py create mode 100644 lib/radio_loss.py create mode 100644 tests/test_collision_model.py create mode 100644 tests/test_link_model.py create mode 100644 tests/test_radio_loss.py diff --git a/DISCRETE_EVENT_SIM.md b/DISCRETE_EVENT_SIM.md index 510ba2a9..a026d6cd 100644 --- a/DISCRETE_EVENT_SIM.md +++ b/DISCRETE_EVENT_SIM.md @@ -88,6 +88,16 @@ building-level ray tracer: OpenStreetMap building, landuse, natural, and water polygons. The simulator never fetches OpenStreetMap data implicitly. +Two optional RF models can make dense or weak-link runs less binary: + +```python3 loraMesh.py 20 --no-gui --phy-loss-model --capture-collision-model``` + +`--phy-loss-model` keeps RSSI/sensitivity as the hearability gate, then applies +a smooth SNR-to-payload-success curve that depends on packet size and LoRa +coding rate. `--capture-collision-model` keeps CAD-detectable but undecodable +packets on the RF timeline as interference energy, and uses capture/preamble +overlap rules instead of treating every overlap as identical. + 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/lib/common.py b/lib/common.py index 1f9e2fc9..c6f4d110 100644 --- a/lib/common.py +++ b/lib/common.py @@ -3,6 +3,7 @@ import numpy as np from lib import phy +from lib.link_model import calculate_link_budget from lib.point import Point @@ -56,7 +57,6 @@ def find_random_position(conf, node_configs) -> (float, float): break return max(-conf.XSIZE/2, position.x), max(-conf.YSIZE/2, position.y) -# TODO: once lib/interactive no longer uses this, we can remove this and put all distance calculation in Point def calc_dist(x0, x1, y0, y1, z0=0, z1=0): return np.sqrt(((abs(x0-x1))**2)+((abs(y0-y1))**2)+((abs(z0-z1)**2))) @@ -79,20 +79,17 @@ def setup_asymmetric_links(conf, nodes): for a in range(conf.NR_NODES): for b in range(conf.NR_NODES): if a != b: - # Calculate constant RSSI in both directions + # Calculate the same directed budget MeshPacket will use later: + # per-direction random offset, both endpoint antenna gains, and + # any enabled terrain/clutter/calibration layers. The summary + # graph should not be more optimistic than packet delivery. nodeA = nodes[a] nodeB = nodes[b] - distAB = nodeA.position.euclidean_distance(nodeB.position) - pathLossAB = phy.estimate_path_loss(conf, distAB, conf.FREQ, node_antenna_height(nodeA), node_antenna_height(nodeB)) + budgetAB = calculate_link_budget(conf, nodeA, nodeB, conf.LINK_OFFSET[(a, b)]) + budgetBA = calculate_link_budget(conf, nodeB, nodeA, conf.LINK_OFFSET[(b, a)]) - offsetAB = conf.LINK_OFFSET[(a, b)] - offsetBA = conf.LINK_OFFSET[(b, a)] - - rssiAB = conf.PTX + nodeA.antennaGain - pathLossAB - offsetAB - rssiBA = conf.PTX + nodeB.antennaGain - pathLossAB - offsetBA - - canAhearB = (rssiAB >= conf.current_preset["sensitivity"]) - canBhearA = (rssiBA >= conf.current_preset["sensitivity"]) + canAhearB = (budgetAB.rssi_dbm >= conf.current_preset["sensitivity"]) + canBhearA = (budgetBA.rssi_dbm >= conf.current_preset["sensitivity"]) totalPairs += 1 if canAhearB and canBhearA: diff --git a/lib/config.py b/lib/config.py index 52eea397..68f6009e 100644 --- a/lib/config.py +++ b/lib/config.py @@ -38,6 +38,9 @@ def __init__(self): self.SIMTIME = 30 * self.ONE_MIN_INTERVAL # duration of one simulation in ms self.INTERFERENCE_LEVEL = 0.05 # chance that at a given moment there is already a LoRa packet being sent on your channel, outside of the Meshtastic traffic. Given in a ratio from 0 to 1. self.COLLISION_DUE_TO_INTERFERENCE = False + self.CAPTURE_COLLISION_MODEL_ENABLED = False + self.COLLISION_CAPTURE_THRESHOLD_DB = 6.0 + self.COLLISION_PAYLOAD_OVERLAP_LOSS_FRACTION = 0.15 self.DMs = False # Set True for sending DMs (with random destination), False for broadcasts # from firmware RegionInfo regions[] in src/mesh/RadioInterface.cpp self.regions = { @@ -398,6 +401,16 @@ def __init__(self): self.GAMMA = 2.08 # PHY parameter self.D0 = 40.0 # PHY parameter self.LPLD0 = 127.41 # PHY parameter + # Optional scenario-level calibration knobs. Defaults preserve the old + # simulator behavior; field presets can tighten these to match + # aggregate receive observations without changing generic simulations. + self.PATH_LOSS_DISTANCE_FLOOR_M = 0.001 + self.REPORTED_SNR_MIN_DB = None + self.REPORTED_SNR_MAX_DB = None + self.LINK_CALIBRATION_MODEL_ENABLED = False + self.LINK_CALIBRATION_COEFFICIENTS = {} + self.LINK_CALIBRATION_SNR_MIN_DB = None + self.LINK_CALIBRATION_SNR_MAX_DB = None self.NPREAM = 16 # number of preamble symbols from RadioInterface.h ### End of PHY parameters ### @@ -444,6 +457,26 @@ def __init__(self): self.CLUTTER_COASTAL_SAMPLE_FRACTION = 0.55 self.CLUTTER_MAX_LOSS_DB = 25.0 + ################################################# + ####### EMPIRICAL PAYLOAD LOSS MODEL ############ + ################################################# + # Disabled by default. When enabled, RSSI/sensitivity still decides + # whether a packet can be heard; this model only adds a smooth + # CR-dependent payload-success probability after that gate. + self.PHY_LOSS_MODEL_ENABLED = False + self.PHY_LOSS_MODEL_NAME = "snr_payload_v1" + self.PHY_LOSS_SNR_P50_BY_CR = { + 5: -17.0, + 6: -17.8, + 7: -18.6, + 8: -19.4, + } + self.PHY_LOSS_SNR_TRANSITION_DB = 1.4 + self.PHY_LOSS_REFERENCE_PACKET_BYTES = 40 + self.PHY_LOSS_LONG_PACKET_PENALTY_DB_PER_100B = 0.8 + self.PHY_LOSS_MIN_SUCCESS_PROB = 0.02 + self.PHY_LOSS_MAX_SUCCESS_PROB = 0.995 + # Misc self.SEED = 44 # random seed to use # End of misc diff --git a/lib/discrete_event_sim.py b/lib/discrete_event_sim.py index 2eedc3a7..505a9f06 100644 --- a/lib/discrete_event_sim.py +++ b/lib/discrete_event_sim.py @@ -68,9 +68,37 @@ def finalize(self, conf: Config): self.results["nrCollisions"] = sum([1 for p in packets for n in nodes if p.collidedAtN[n.nodeid] is True]) self.results["nrSensed"] = sum([1 for p in packets for n in nodes if p.sensedByN[n.nodeid] is True]) self.results["nrReceived"] = sum([1 for p in packets for n in nodes if p.receivedAtN[n.nodeid] is True]) + self.results["nrPhyLoss"] = sum([ + 1 + for p in packets + for n in nodes + if n.nodeid < len(getattr(p, "phyLostAtN", [])) and p.phyLostAtN[n.nodeid] is True + ]) + collision_reasons = {} + for p in packets: + for reason in getattr(p, "collisionReasonAtN", []): + if reason: + collision_reasons[reason] = collision_reasons.get(reason, 0) + 1 + self.results["collisionReasons"] = collision_reasons + terrain_losses = [ + p.terrainLossAtN[n.nodeid] + for p in packets + for n in nodes + if n.nodeid < len(getattr(p, "terrainLossAtN", [])) and p.terrainLossAtN[n.nodeid] > 0 + ] + self.results["meanTerrainLossDb"] = float(np.nanmean(terrain_losses)) if terrain_losses else 0.0 + self.results["maxTerrainLossDb"] = max(terrain_losses) if terrain_losses else 0.0 + clutter_losses = [ + p.clutterLossAtN[n.nodeid] + for p in packets + for n in nodes + if n.nodeid < len(getattr(p, "clutterLossAtN", [])) and p.clutterLossAtN[n.nodeid] > 0 + ] + self.results["meanClutterLossDb"] = float(np.nanmean(clutter_losses)) if clutter_losses else 0.0 + self.results["maxClutterLossDb"] = max(clutter_losses) if clutter_losses else 0.0 self.results["nrUseful"] = sum([n.usefulPackets for n in nodes]) - self.results["meanDelay"] = np.nanmean(self.results["delays"]) + self.results["meanDelay"] = np.nanmean(self.results["delays"]) if self.results["delays"] else np.nan # various division-by-0 guarded calculations if conf.NR_NODES != 0 and conf.SIMTIME != 0: diff --git a/lib/link_model.py b/lib/link_model.py new file mode 100644 index 00000000..2c1c6663 --- /dev/null +++ b/lib/link_model.py @@ -0,0 +1,119 @@ +"""Shared link-budget calculation for generated radio pairs. + +Packet construction, asymmetric-link reporting, and the interactive helper all +need the same answer: what RSSI/SNR does this TX/RX pair get after distance, +terrain, clutter, antenna gains, optional asymmetry, and optional field +calibration? Keeping that in one helper prevents calibration code from becoming +a hidden side path in only one simulator mode. +""" + +import math +from dataclasses import dataclass + +from lib.clutter import clutter_obstruction_loss, clutter_path_features +from lib.phy import estimate_path_loss +from lib.radio_loss import apply_link_calibration, estimate_snr +from lib.terrain import terrain_ground_elevation, terrain_obstruction_loss + + +@dataclass(frozen=True) +class LinkBudget: + distance_m: float + base_path_loss_db: float + terrain_loss_db: float + clutter_loss_db: float + offset_db: float + raw_rssi_dbm: float + rssi_dbm: float + snr_db: float + features: dict + + @property + def path_loss_db(self): + return self.base_path_loss_db + self.terrain_loss_db + self.clutter_loss_db + self.offset_db + + @property + def calibrated_path_loss_db(self): + return self.path_loss_db + self.raw_rssi_dbm - self.rssi_dbm + + +def _antenna_gain(node): + """Accept both MeshNode.antennaGain and NodeConfig.antenna_gain.""" + return getattr(node, "antennaGain", getattr(node, "antenna_gain", 0.0)) + + +def _antenna_height(node): + """Accept runtime and config antenna height above local ground.""" + return getattr(node, "antennaHeight", getattr(node, "antenna_height", node.position.z)) + + +def _link_calibration_features(conf, tx_point, rx_point, raw_snr, terrain_loss, clutter_loss): + """Build the reusable feature vector consumed by fitted calibration. + + These are path-shape features, not node-pair identities. Coefficients fitted + from one real mesh can therefore be applied to newly generated node pairs or + to nearby meshes that have terrain/clutter inputs but no observed links. + """ + horizontal_distance_m = max(1.0, math.hypot(rx_point.x - tx_point.x, rx_point.y - tx_point.y)) + log_distance_km = math.log10(horizontal_distance_m / 1000.0) + + tx_ground = terrain_ground_elevation(conf, tx_point) + rx_ground = terrain_ground_elevation(conf, rx_point) + grounds = [ground for ground in (tx_ground, rx_ground) if ground is not None] + max_ground_m = max(grounds) if grounds else 0.0 + min_ground_m = min(grounds) if grounds else 0.0 + ground_delta_m = abs((tx_ground or 0.0) - (rx_ground or 0.0)) if len(grounds) == 2 else 0.0 + high_vantage = 1.0 if max_ground_m >= conf.CLUTTER_HIGH_VANTAGE_ELEVATION_M else 0.0 + + clutter_features = clutter_path_features(conf, tx_point, rx_point) + urban_fraction = clutter_features["urban_fraction"] + + features = { + "raw_snr_clip": max(-120.0, min(10.0, raw_snr)), + "log_distance_km": log_distance_km, + "log_distance_km_sq": log_distance_km * log_distance_km, + "terrain_loss_db": terrain_loss, + "clutter_loss_db": clutter_loss, + "terrain_high_vantage_loss_db": terrain_loss * high_vantage, + "clutter_urban_loss_db": clutter_loss * urban_fraction, + "max_ground_elevation_100m": max_ground_m / 100.0, + "min_ground_elevation_100m": min_ground_m / 100.0, + "ground_delta_100m": ground_delta_m / 100.0, + "high_vantage": high_vantage, + **clutter_features, + } + return features + + +def calculate_link_budget(conf, tx_node, rx_node, offset_db=0.0, tx_power_dbm=None): + """Calculate raw and calibrated radio budget for one directed pair.""" + tx_point = tx_node.position + rx_point = rx_node.position + distance_m = tx_point.euclidean_distance(rx_point) + base_loss = estimate_path_loss(conf, distance_m, conf.FREQ, _antenna_height(tx_node), _antenna_height(rx_node)) + terrain_loss = terrain_obstruction_loss(conf, tx_point, rx_point, conf.FREQ) + clutter_loss = clutter_obstruction_loss(conf, tx_point, rx_point) + + raw_path_loss = base_loss + terrain_loss + clutter_loss + offset_db + + # Keep packet delivery and link-summary statistics on the same budget. The + # TX endpoint contributes radiated antenna gain, while the RX endpoint + # contributes receive antenna gain; terrain/clutter/calibration are path + # properties layered around those endpoint gains. + tx_power = conf.PTX if tx_power_dbm is None else tx_power_dbm + raw_rssi = tx_power + _antenna_gain(tx_node) + _antenna_gain(rx_node) - raw_path_loss + raw_snr = raw_rssi - conf.NOISE_LEVEL + features = _link_calibration_features(conf, tx_point, rx_point, raw_snr, terrain_loss, clutter_loss) + rssi = apply_link_calibration(conf, raw_rssi, features) + + return LinkBudget( + distance_m=distance_m, + base_path_loss_db=base_loss, + terrain_loss_db=terrain_loss, + clutter_loss_db=clutter_loss, + offset_db=offset_db, + raw_rssi_dbm=raw_rssi, + rssi_dbm=rssi, + snr_db=estimate_snr(conf, rssi), + features=features, + ) diff --git a/lib/mac.py b/lib/mac.py index 46befce2..a8d42c22 100644 --- a/lib/mac.py +++ b/lib/mac.py @@ -2,6 +2,7 @@ import random from lib.phy import airtime, get_current_slot_time +from lib.radio_loss import estimate_snr logger = logging.getLogger(__name__) @@ -19,7 +20,9 @@ def set_transmit_delay(node, packet): # from RadioLibInterface::setTransmitDela def get_tx_delay_msec_weighted(node, rssi): # from RadioInterface::getTxDelayMsecWeighted - snr = rssi - node.conf.NOISE_LEVEL + # Use the same reported-SNR estimate as the packet-loss model so calibrated + # presets do not drive relay delay from an impossible near-field SNR tail. + snr = estimate_snr(node.conf, rssi) SNR_MIN = -20 SNR_MAX = 10 slot_time_msec = get_current_slot_time() @@ -53,8 +56,9 @@ def get_tx_delay_msec(node): # from RadioInterface::getTxDelayMsec def get_retransmission_msec(node, packet): # from RadioInterface::getRetransmissionMsec - preset = node.conf.current_preset - packetAirtime = int(airtime(node.conf, preset["sf"], preset["cr"], packet.packetLen, preset["bw"])) + # Retransmission timeout has to follow the physical airtime of the packet + # that was actually sent. With DCR disabled this is still the preset CR. + packetAirtime = int(airtime(node.conf, packet.sf, packet.cr, packet.packetLen, packet.bw)) channelUtil = node.airUtilization / node.env.now * 100 CWsize = int(channelUtil * (CWmax - CWmin) / 100 + CWmin) return 2 * packetAirtime + (2 ** CWsize + 2 * CWmax + 2 ** (int((CWmax + CWmin) / 2))) * get_current_slot_time() + PROCESSING_TIME_MSEC diff --git a/lib/node.py b/lib/node.py index c10ebc64..67abe5f2 100644 --- a/lib/node.py +++ b/lib/node.py @@ -6,20 +6,17 @@ import simpy -from lib.common import find_random_position, node_antenna_height +from lib.common import find_random_position from lib.config import Config from lib.discrete_event_sim_components import SimulationState, SimulationDataTracking from lib.geo import valid_lat_lon +from lib.link_model import calculate_link_budget 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, - terrain_obstruction_loss, -) +from lib.radio_loss import estimate_snr +from lib.terrain import NODE_Z_REFERENCE_SEA_LEVEL, apply_terrain_altitude logger = logging.getLogger(__name__) @@ -146,13 +143,9 @@ def compute_rssi_and_pathloss_to(self, rx_nodeconf, conf: Config) -> (float, flo if self.node_id == rx_nodeconf.node_id: raise ValueError(f"Calculating rssi/pathloss between identical nodes is invalid. Node ID {self.node_id}") - # 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) + offset = getattr(conf, "LINK_OFFSET", {}).get((self.node_id, rx_nodeconf.node_id), 0) + budget = calculate_link_budget(conf, self, rx_nodeconf, offset, tx_power_dbm=self.tx_power) + return budget.rssi_dbm, budget.calibrated_path_loss_db def node_configs_from_yaml(raw_config, period: int, tx_power: int = 30, freq: float = 902e6) -> list[NodeConfig]: @@ -199,6 +192,20 @@ def origin_from_yaml(raw_config): return lat, lon +def packet_is_rx_candidate(packet, rx_node_id: int, capture_model_enabled: bool) -> bool: + """Return whether a packet should enter the receiver-side RF timeline. + + Legacy collision accounting only tracked packets above the demodulation + sensitivity threshold (`sensedByN`). The capture-aware model needs one more + band: CAD-detectable but undecodable packets still occupy the channel and + can corrupt another packet's preamble/header. They are interference energy, + while the receive path still ignores them because `sensedByN` remains false. + """ + if capture_model_enabled: + return packet.detectedByN[rx_node_id] + return packet.sensedByN[rx_node_id] + + 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 @@ -466,7 +473,7 @@ def generate_message(self): yield self.env.timeout(nextGen) if self.conf.DMs: - destId = self.nodeRng.choice([i for i in range(0, len(self.nodes)) if i is not self.nodeid]) + destId = self.nodeRng.choice([i for i in range(0, len(self.nodes)) if i != self.nodeid]) else: destId = NODENUM_BROADCAST @@ -521,13 +528,17 @@ def transmit(self, packet): if not self.perhaps_cancel_dupe(packet): # if you did not receive an ACK for this message in the meantime logger.debug(f"{self.env.now:.3f} Node {self.nodeid} started low level send {packet.unique_packet_seq} for msg {packet.seq} hopLimit {packet.hopLimit} original Tx {packet.origTxNodeId}") self.nrPacketsSent += 1 - for rx_node in self.nodes: - if packet.sensedByN[rx_node.nodeid]: - if check_collision(self.conf, self.env, packet, rx_node.nodeid, self.packetsAtN) == 0: - self.packetsAtN[rx_node.nodeid].append(packet) - # packet's collidedAtN field is now computed/valid packet.startTime = self.env.now packet.endTime = self.env.now + packet.timeOnAir + for rx_node in self.nodes: + if packet_is_rx_candidate(packet, rx_node.nodeid, self.conf.CAPTURE_COLLISION_MODEL_ENABLED): + collision = check_collision(self.conf, self.env, packet, rx_node.nodeid, self.packetsAtN) + if self.conf.CAPTURE_COLLISION_MODEL_ENABLED: + # Even a packet that cannot be decoded is still RF + # energy on the channel and may jam later packets. + self.packetsAtN[rx_node.nodeid].append(packet) + elif collision == 0: + self.packetsAtN[rx_node.nodeid].append(packet) self.txAirUtilization += packet.timeOnAir self.airUtilization += packet.timeOnAir self.bc_pipe.put(packet) # queue for nodes to receive packet @@ -543,11 +554,39 @@ def receive(self, in_pipe): p = yield in_pipe.get() logger.debug(f"{self.env.now:.3f} Node {self.nodeid} fetches packet {p.unique_packet_seq} for msg {p.seq} from {p.txNodeId} from bc_pipe: sensed: {p.sensedByN[self.nodeid]} collided: {p.collidedAtN[self.nodeid]} on air: {p.onAirToN[self.nodeid]}") + + if self.conf.CAPTURE_COLLISION_MODEL_ENABLED: + if p.sensedByN[self.nodeid] and p.onAirToN[self.nodeid]: + p.onAirToN[self.nodeid] = False + if not self.isTransmitting and not p.collidedAtN[self.nodeid]: + logger.debug(f"{self.env.now:.3f} Node {self.nodeid} started receiving packet {p.unique_packet_seq} for msg {p.seq} from {p.txNodeId}") + self.isReceiving.append(True) + else: + logger.debug(f"{self.env.now:.3f} Node {self.nodeid} could not lock packet {p.unique_packet_seq} for msg {p.seq}.") + continue + + if p.sensedByN[self.nodeid]: + try: + self.isReceiving[self.isReceiving.index(True)] = False + except Exception: + pass + self.airUtilization += p.timeOnAir + if p.collidedAtN[self.nodeid]: + logger.debug(f"{self.env.now:.3f} Node {self.nodeid} could not decode packet {p.unique_packet_seq}.") + continue + if p.phyLostAtN[self.nodeid]: + logger.debug(f"{self.env.now:.3f} Node {self.nodeid} lost packet {p.unique_packet_seq} for msg {p.seq} to weak-link PHY errors.") + continue + p.receivedAtN[self.nodeid] = True + logger.debug(f"{self.env.now:.3f} Node {self.nodeid} received packet {p.unique_packet_seq} for msg {p.seq} with delay {round(self.env.now - p.genTime, 2)}") + self.handle_received_packet(p) + continue + if p.sensedByN[self.nodeid] and p.onAirToN[self.nodeid]: # start of reception if p.collidedAtN[self.nodeid]: - # this packet collided, so we can sense it but not decode it. - # Mark it as no-longer on air and leave further processing to - # the 'end of transmission' branch + # This packet collided, so we can sense it but not decode + # it. Mark it as no-longer on air and leave further + # processing to the end-of-transmission branch. p.onAirToN[self.nodeid] = False elif not self.isTransmitting: logger.debug(f"{self.env.now:.3f} Node {self.nodeid} started receiving packet {p.unique_packet_seq} for msg {p.seq} from {p.txNodeId}") @@ -567,58 +606,69 @@ def receive(self, in_pipe): if p.collidedAtN[self.nodeid]: logger.debug(f"{self.env.now:.3f} Node {self.nodeid} could not decode packet {p.unique_packet_seq}.") continue + if p.phyLostAtN[self.nodeid]: + logger.debug(f"{self.env.now:.3f} Node {self.nodeid} lost packet {p.unique_packet_seq} for msg {p.seq} to weak-link PHY errors.") + continue p.receivedAtN[self.nodeid] = True logger.debug(f"{self.env.now:.3f} Node {self.nodeid} received packet {p.unique_packet_seq} for msg {p.seq} with delay {round(self.env.now - p.genTime, 2)}") # TODO: better way to calculate delay for log - self.delays.append(self.env.now - p.genTime) + self.handle_received_packet(p) - # Update history of received packets - self.was_seen_recently(p) + def handle_received_packet(self, p): + """Handle decoded MeshPacket after RX PHY/collision checks pass.""" + self.delays.append(self.env.now - p.genTime) - # check if implicit ACK for own generated message - if p.origTxNodeId == self.nodeid: - if p.isAck: - logger.debug(f"Node {self.nodeid} received real ACK on generated message.") - else: - logger.debug(f"Node {self.nodeid} received implicit ACK on message sent.") - p.ackReceived = True - continue + # Update history of received packets + self.was_seen_recently(p) - ackReceived = False - realAckReceived = False - for sentPacket in self.packets: - # check if ACK for message you currently have in queue - if sentPacket.txNodeId == self.nodeid and sentPacket.seq == p.seq: - logger.debug(f"{self.env.now:.3f} Node {self.nodeid} received implicit ACK for message in queue.") - ackReceived = True - sentPacket.ackReceived = True - # check if real ACK for message sent - if sentPacket.origTxNodeId == self.nodeid and p.isAck and sentPacket.seq == p.requestId: - logger.debug(f"{self.env.now:.3f} Node {self.nodeid} received real ACK.") - realAckReceived = True - sentPacket.ackReceived = True - - # send real ACK if you are the destination and you did not yet send the ACK - if p.wantAck and p.destId == self.nodeid and not any(pA.requestId == p.seq for pA in self.packets): - logger.debug(f"{self.env.now:.3f} Node {self.nodeid} sends a flooding ACK.") - messageSeq = self.messageSeq.get() - self.messages.append(MeshMessage(self.nodeid, p.origTxNodeId, self.env.now, messageSeq)) - pAck = MeshPacket(self.conf, self.nodes, self.nodeid, p.origTxNodeId, self.nodeid, self.conf.ACKLENGTH, messageSeq, self.env.now, False, True, p.seq, self.env.now, self.connectivity_map, self.baseline_pathloss_matrix) - self.packets.append(pAck) - self.env.process(self.transmit(pAck)) - # Rebroadcasting Logic for received message. This is a broadcast or a DM not meant for us. - elif not p.destId == self.nodeid and not ackReceived and not realAckReceived and p.hopLimit > 0: - self.my_stats.packetsHeard += 1 # packets which could potentially be rebroadcast - # FloodingRouter: rebroadcast received packet - if self.conf.SELECTED_ROUTER_TYPE == self.conf.ROUTER_TYPE.MANAGED_FLOOD: - if not self.is_client_mute: - logger.debug(f"{self.env.now:.3f} Node {self.nodeid} schedules rebroadcast for received packet {p.unique_packet_seq} for msg {p.seq}") - self.my_stats.packetsRebroadcast += 1 - pNew = MeshPacket(self.conf, self.nodes, p.origTxNodeId, p.destId, self.nodeid, p.packetLen, p.seq, p.genTime, p.wantAck, False, None, self.env.now, self.connectivity_map, self.baseline_pathloss_matrix) - pNew.hopLimit = p.hopLimit - 1 - self.packets.append(pNew) - self.env.process(self.transmit(pNew)) - else: - self.droppedByDelay += 1 + # check if implicit ACK for own generated message + if p.origTxNodeId == self.nodeid: + if p.isAck: + logger.debug(f"Node {self.nodeid} received real ACK on generated message.") + else: + logger.debug(f"Node {self.nodeid} received implicit ACK on message sent.") + p.ackReceived = True + return + + ackReceived = False + realAckReceived = False + for sentPacket in self.packets: + # check if ACK for message you currently have in queue + if sentPacket.txNodeId == self.nodeid and sentPacket.seq == p.seq: + logger.debug(f"{self.env.now:.3f} Node {self.nodeid} received implicit ACK for message in queue.") + ackReceived = True + sentPacket.ackReceived = True + # check if real ACK for message sent + if sentPacket.origTxNodeId == self.nodeid and p.isAck and sentPacket.seq == p.requestId: + logger.debug(f"{self.env.now:.3f} Node {self.nodeid} received real ACK.") + realAckReceived = True + sentPacket.ackReceived = True + + # send real ACK if you are the destination and you did not yet send the ACK + if p.wantAck and p.destId == self.nodeid and not any(pA.requestId == p.seq for pA in self.packets): + logger.debug(f"{self.env.now:.3f} Node {self.nodeid} sends a flooding ACK.") + messageSeq = self.messageSeq.get() + self.messages.append(MeshMessage(self.nodeid, p.origTxNodeId, self.env.now, messageSeq)) + pAck = MeshPacket(self.conf, self.nodes, self.nodeid, p.origTxNodeId, self.nodeid, self.conf.ACKLENGTH, messageSeq, self.env.now, False, True, p.seq, self.env.now, self.connectivity_map, self.baseline_pathloss_matrix) + pAck.priorHopRssi = p.rssiAtN[self.nodeid] + pAck.priorHopSnr = estimate_snr(self.conf, pAck.priorHopRssi) + self.packets.append(pAck) + self.env.process(self.transmit(pAck)) + # Rebroadcasting Logic for received message. This is a broadcast or a DM not meant for us. + elif not p.destId == self.nodeid and not ackReceived and not realAckReceived and p.hopLimit > 0: + self.my_stats.packetsHeard += 1 # packets which could potentially be rebroadcast + # FloodingRouter: rebroadcast received packet + if self.conf.SELECTED_ROUTER_TYPE == self.conf.ROUTER_TYPE.MANAGED_FLOOD: + if not self.is_client_mute: + logger.debug(f"{self.env.now:.3f} Node {self.nodeid} schedules rebroadcast for received packet {p.unique_packet_seq} for msg {p.seq}") + self.my_stats.packetsRebroadcast += 1 + pNew = MeshPacket(self.conf, self.nodes, p.origTxNodeId, p.destId, self.nodeid, p.packetLen, p.seq, p.genTime, p.wantAck, False, None, self.env.now, self.connectivity_map, self.baseline_pathloss_matrix) + pNew.hopLimit = p.hopLimit - 1 + pNew.priorHopRssi = p.rssiAtN[self.nodeid] + pNew.priorHopSnr = estimate_snr(self.conf, pNew.priorHopRssi) + self.packets.append(pNew) + self.env.process(self.transmit(pNew)) + else: + self.droppedByDelay += 1 def get_stats(self) -> MeshNodeStats: """Get internally-tracked statistics/data. Only valid after the sim ends. diff --git a/lib/packet.py b/lib/packet.py index 3b9cbc3b..70543059 100644 --- a/lib/packet.py +++ b/lib/packet.py @@ -1,10 +1,10 @@ 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 -from lib.terrain import terrain_obstruction_loss +from lib.link_model import calculate_link_budget +from lib.phy import airtime +from lib.radio_loss import payload_is_lost NODENUM_BROADCAST = 0xFFFFFFFF @@ -52,14 +52,23 @@ def __init__(self, conf, nodes, origTxNodeId, destId, txNodeId, plen, seq, genTi self.requestId = requestId self.genTime = genTime self.now = now - self.txpow = self.conf.PTX + self.nodes = nodes + self.baseTxPower = int(self.conf.PTX) + self.txpow = self.baseTxPower + self.priorHopRssi = None + self.priorHopSnr = None self.LplAtN = [0 for _ in range(self.conf.NR_NODES)] + self.terrainLossAtN = [0 for _ in range(self.conf.NR_NODES)] + self.clutterLossAtN = [0 for _ in range(self.conf.NR_NODES)] self.rssiAtN = [0 for _ in range(self.conf.NR_NODES)] self.sensedByN = [False for _ in range(self.conf.NR_NODES)] # nodes which may possibly sense this packet self.detectedByN = [False for _ in range(self.conf.NR_NODES)] self.collidedAtN = [False for _ in range(self.conf.NR_NODES)] + self.collisionReasonAtN = [None for _ in range(self.conf.NR_NODES)] self.receivedAtN = [False for _ in range(self.conf.NR_NODES)] + self.phyLostAtN = [False for _ in range(self.conf.NR_NODES)] self.onAirToN = [True for _ in range(self.conf.NR_NODES)] + self.phyLossDrawAtN = [0.0 for _ in range(self.conf.NR_NODES)] # configuration values self.sf = self.conf.current_preset["sf"] @@ -67,75 +76,106 @@ def __init__(self, conf, nodes, origTxNodeId, destId, txNodeId, plen, seq, genTi self.bw = self.conf.current_preset["bw"] self.freq = self.conf.FREQ self.tx_node = next(n for n in nodes if n.nodeid == self.txNodeId) + self.connectivity_map = connectivity_map + self.baseline_pathloss_matrix = baseline_pathloss_matrix logger.debug(f"{self.now:.3f} Packet {self.unique_packet_seq} for msg {self.seq} generated by node {self.txNodeId}") - - # calculate reception at all other nodes - for rx_node in nodes: - if rx_node.nodeid == self.txNodeId: - continue - - # reduce calculations just to plausibly reachable nodes. This is initialized - # before sim start, and updated whenever a moving node's position is updated, - # so is always an accurate map of what nodes could (with extra margin) even - # sense each other. - if self.conf.ENABLE_CONNECTIVITY_MAP and not connectivity_map[self.txNodeId].__contains__(rx_node.nodeid): - logger.debug(f"{self.now:.3f} skipping {self.txNodeId} -> {rx_node.nodeid} computation. connectivity map: {connectivity_map[self.txNodeId]}") - if conf.MODEL_ASYMMETRIC_LINKS: - # Each tx -> rx computation we skip gets the asrm_rng one call out - # of sync between simulations with and without the connectivity - # map optimization. Thus particular tx -> rx calculations - # change between the optimization, which can lead to changes - # in sim behavior between the optimization being on/off, leading - # to inconsistencies beteen the optimization being on/off. - # - # Keep things balanced by 'unnecessarily' calling the rng here. - MeshPacket.asym_rng.gauss(0, conf.MODEL_ASYMMETRIC_LINKS_STDDEV) - continue - - if self.conf.ENABLE_CONNECTIVITY_MAP: - # look up baseline path loss from matrix, since we've already computed it. - 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, - 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) - logger.debug(f"{self.now:.3f} packet {self.unique_packet_seq} for msg {self.seq} has asym offset {offset} dB") - if abs(offset) > conf.CONNECTIVITY_MAP_RSSI_MARGIN: - logger.warning(f"{self.now:.3f} packet {self.unique_packet_counter} has asymmetric RSSI offset {offset} which is outside margin. This will lead to inconsistent results with the connectivity map optimization.") - else: - offset = 0 - self.LplAtN[rx_node.nodeid] = baseline_pathloss + offset - self.rssiAtN[rx_node.nodeid] = self.txpow + self.tx_node.antennaGain + rx_node.antennaGain - self.LplAtN[rx_node.nodeid] - if self.rssiAtN[rx_node.nodeid] >= self.conf.current_preset["sensitivity"]: - self.sensedByN[rx_node.nodeid] = True - if self.rssiAtN[rx_node.nodeid] >= self.conf.current_preset["cad_threshold"]: - self.detectedByN[rx_node.nodeid] = True + if self.conf.PHY_LOSS_MODEL_ENABLED: + for rx_node in nodes: + if rx_node.nodeid != self.txNodeId: + self.phyLossDrawAtN[rx_node.nodeid] = random.random() self.packetLen = plen self.timeOnAir = airtime(self.conf, self.sf, self.cr, self.packetLen, self.bw) self.startTime = 0 self.endTime = 0 + self.refresh_link_budgets() # Routing self.retransmissions = self.conf.maxRetransmission self.ackReceived = False self.hopLimit = self.tx_node.hopLimit + def refresh_link_budgets(self): + """Recompute receiver-side RF state for the current TX power. + + Per-packet power changes only alter transmitter output level. Terrain, + clutter, and pair calibration remain the same path; RSSI, CAD + detection, sensitivity, and empirical PHY loss must be recalculated + before collision handling. + """ + for rx_node in self.nodes: + if rx_node.nodeid == self.txNodeId: + continue + if ( + self.conf.ENABLE_CONNECTIVITY_MAP + and rx_node.nodeid not in self.connectivity_map[self.txNodeId] + ): + logger.debug( + f"{self.now:.3f} skipping {self.txNodeId} -> {rx_node.nodeid} computation. " + f"connectivity map: {self.connectivity_map[self.txNodeId]}" + ) + continue + budget = calculate_link_budget( + self.conf, + self.tx_node, + rx_node, + getattr(self.conf, "LINK_OFFSET", {}).get((self.txNodeId, rx_node.nodeid), 0), + tx_power_dbm=self.txpow, + ) + self.terrainLossAtN[rx_node.nodeid] = budget.terrain_loss_db + self.clutterLossAtN[rx_node.nodeid] = budget.clutter_loss_db + self.LplAtN[rx_node.nodeid] = budget.calibrated_path_loss_db + self.rssiAtN[rx_node.nodeid] = budget.rssi_dbm + self.detectedByN[rx_node.nodeid] = self.rssiAtN[rx_node.nodeid] >= self.conf.current_preset["cad_threshold"] + self.refresh_phy_reception() + + def airtime_for_cr(self, cr): + """Predict airtime if this packet is transmitted with a different CR.""" + return airtime(self.conf, self.sf, cr, self.packetLen, self.bw) + + def set_coding_rate(self, cr): + """Change only physical CR and recompute airtime. + + LoRa explicit-header packets carry CR in the PHY header, so changing the + selected CR does not alter Meshtastic payload bytes in this experiment. + """ + self.cr = cr + self.timeOnAir = self.airtime_for_cr(cr) + self.refresh_phy_reception() + + def set_tx_power(self, tx_power_dbm): + """Change temporary TX power and recompute RF visibility. + + The configured region power remains the packet's baseTxPower. This + method only models a per-transmission reduction. + """ + self.txpow = int(tx_power_dbm) + self.refresh_link_budgets() + + def refresh_phy_reception(self): + """Recompute payload-loss state after the selected CR changes. + + Reception remains gated by the configured modem sensitivity. Stronger + coding rates improve payload decode probability near that edge, but they + do not resurrect packets whose preamble/header would not be heard. + """ + for rx_node_id, rssi in enumerate(self.rssiAtN): + if rx_node_id == self.txNodeId: + continue + + self.sensedByN[rx_node_id] = rssi >= self.conf.current_preset["sensitivity"] + self.phyLostAtN[rx_node_id] = False + + if self.sensedByN[rx_node_id]: + self.phyLostAtN[rx_node_id] = payload_is_lost( + self.conf, + rssi, + self.cr, + self.packetLen, + self.phyLossDrawAtN[rx_node_id], + ) + class MeshMessage: def __init__(self, origTxNodeId, destId, genTime, seq): diff --git a/lib/phy.py b/lib/phy.py index cc2a8212..a2e6b63c 100644 --- a/lib/phy.py +++ b/lib/phy.py @@ -30,6 +30,9 @@ def get_current_slot_time(): # from RadioInterface::computeSlotTimeMsec def check_collision(conf, env, packet, rx_nodeId, packetsAtN): + if conf.CAPTURE_COLLISION_MODEL_ENABLED: + return check_capture_collision(conf, packet, rx_nodeId, packetsAtN) + # Check for collisions at rx_node col = 0 if conf.COLLISION_DUE_TO_INTERFERENCE: @@ -53,20 +56,82 @@ def check_collision(conf, env, packet, rx_nodeId, packetsAtN): return 0 +def check_capture_collision(conf, packet, rx_nodeId, packetsAtN): + """Check overlap with a capture-aware same-SF collision model. + + The legacy model is intentionally preserved unless explicitly enabled. This + path models the part that matters for real crowded meshes: a receiver can + keep a sufficiently stronger packet through a weaker overlap, but equal or + stronger interference during the preamble/header lock window destroys it. + Later payload-only overlap is tolerated when it is only a short tail. + """ + col = 0 + if conf.COLLISION_DUE_TO_INTERFERENCE and random.random() < conf.INTERFERENCE_LEVEL: + mark_collision(packet, rx_nodeId, "external_interference") + col = 1 + + for other in packetsAtN[rx_nodeId]: + if not intervals_overlap(packet.startTime, packet.endTime, other.startTime, other.endTime): + continue + if not frequency_collision(packet, other) or not sf_collision(packet, other): + continue + + casualties = capture_collision_casualties(conf, packet, other, rx_nodeId) + if casualties: + logger.debug( + f'Packet nr. {packet.seq} from {packet.txNodeId} and packet nr. ' + f'{other.seq} from {other.txNodeId} overlap at node {rx_nodeId}; ' + f'capture casualties {[p.seq for p, _ in casualties]}' + ) + for casualty, reason in casualties: + mark_collision(casualty, rx_nodeId, reason) + if casualty == packet: + col = 1 + return col + + def frequency_collision(p1, p2): - if abs(p1.freq - p2.freq) <= 120 and (p1.bw == 500 or p2.freq == 500): + delta_khz = _frequency_delta_khz(p1, p2) + p1_bw_khz = _bandwidth_khz(p1) + p2_bw_khz = _bandwidth_khz(p2) + + if delta_khz <= 120 and (p1_bw_khz == 500 or p2_bw_khz == 500): return True - elif abs(p1.freq - p2.freq) <= 60 and (p1.bw == 250 or p2.freq == 250): + elif delta_khz <= 60 and (p1_bw_khz == 250 or p2_bw_khz == 250): return True - elif abs(p1.freq - p2.freq) <= 30: + elif delta_khz <= 30: return True return False +def _frequency_delta_khz(p1, p2): + """Return center-frequency separation in kHz. + + Meshtasticator stores modem frequencies in Hz. Some small tests and older + LoRaSim-derived snippets use MHz-scale values, so normalize both shapes here + instead of making the collision predicate depend on caller units. + """ + delta = abs(p1.freq - p2.freq) + if max(abs(p1.freq), abs(p2.freq)) > 1e6: + return delta / 1000.0 + return delta * 1000.0 + + +def _bandwidth_khz(packet): + """Return LoRa bandwidth in kHz for both Hz and kHz-style packet fields.""" + return packet.bw / 1000.0 if packet.bw > 1000 else packet.bw + + def sf_collision(p1, p2): return p1.sf == p2.sf +def mark_collision(packet, rx_nodeId, reason): + packet.collidedAtN[rx_nodeId] = True + if hasattr(packet, "collisionReasonAtN"): + packet.collisionReasonAtN[rx_nodeId] = reason + + def power_collision(p1, p2, rx_nodeId): powerThreshold = 6 # dB if abs(p1.rssiAtN[rx_nodeId] - p2.rssiAtN[rx_nodeId]) < powerThreshold: @@ -91,6 +156,74 @@ def timing_collision(conf, env, p1, p2): return False +def intervals_overlap(start1, end1, start2, end2): + return max(start1, start2) < min(end1, end2) + + +def overlap_ms(p1, p2): + return max(0.0, min(p1.endTime, p2.endTime) - max(p1.startTime, p2.startTime)) + + +def preamble_lock_window_ms(conf, packet): + """Approximate the fragile LoRa preamble/header acquisition interval.""" + symbols = max(1, conf.NPREAM - 5) + return symbols * (2 ** packet.sf) / packet.bw * 1000 + + +def overlaps_preamble_lock(conf, victim, interferer): + return intervals_overlap( + victim.startTime, + min(victim.endTime, victim.startTime + preamble_lock_window_ms(conf, victim)), + interferer.startTime, + interferer.endTime, + ) + + +def packet_survives_overlap(conf, victim, interferer, rx_nodeId): + """Return whether `victim` survives this one overlapping interferer. + + This is still a compact simulator model, not a chip-level LoRa demodulator. + It encodes the two big effects the binary model misses: capture by a packet + that is at least COLLISION_CAPTURE_THRESHOLD_DB stronger at this receiver, + and small late-tail overlap that does not destroy an already-locked packet. + """ + desired_margin_db = victim.rssiAtN[rx_nodeId] - interferer.rssiAtN[rx_nodeId] + if desired_margin_db >= conf.COLLISION_CAPTURE_THRESHOLD_DB: + return True + + if overlaps_preamble_lock(conf, victim, interferer): + return False + + fraction = overlap_ms(victim, interferer) / victim.timeOnAir if victim.timeOnAir > 0 else 1.0 + if fraction >= conf.COLLISION_PAYLOAD_OVERLAP_LOSS_FRACTION: + return False + + return True + + +def capture_collision_casualties(conf, p1, p2, rx_nodeId): + casualties = [] + if _packet_was_decodable_at_rx(p1, rx_nodeId) and not packet_survives_overlap(conf, p1, p2, rx_nodeId): + casualties.append((p1, "capture_overlap")) + if _packet_was_decodable_at_rx(p2, rx_nodeId) and not packet_survives_overlap(conf, p2, p1, rx_nodeId): + casualties.append((p2, "capture_overlap")) + return casualties + + +def _packet_was_decodable_at_rx(packet, rx_nodeId): + """Return whether collision loss is meaningful for this packet. + + Capture mode tracks CAD-detectable-but-undecodable packets as interference + energy. Those packets can jam another packet, but they should not inflate + collision counters as failed decodes because they were below the receiver's + demodulation threshold before overlap was considered. + """ + sensed_by_node = getattr(packet, "sensedByN", None) + if sensed_by_node is None: + return True + return sensed_by_node[rx_nodeId] + + def is_channel_active(node, env): if random.randrange(10) <= node.conf.INTERFERENCE_LEVEL * 10: return True @@ -142,7 +275,12 @@ def estimate_path_loss(conf, dist, freq, txZ=None, rxZ=None, model=None): model = conf.MODEL # With randomized movements we may end up on top of another node which is problematic for log(dist) - dist = max(dist, .001) + # + # Some real-mesh presets can also set a larger floor as an empirical + # near-field/clutter calibration. The 3GPP/Hata formulas are not meaningful + # at apartment-scale separations, and map node positions are coarse enough + # that "two pins are close" does not mean "two antennas have clear 20 m RF". + dist = max(dist, conf.PATH_LOSS_DISTANCE_FLOOR_M) # Log-Distance model if model == 0: diff --git a/lib/radio_loss.py b/lib/radio_loss.py new file mode 100644 index 00000000..4d439f08 --- /dev/null +++ b/lib/radio_loss.py @@ -0,0 +1,80 @@ +"""Empirical packet-loss model for the discrete-event simulator. + +Meshtasticator's original PHY is binary: if RSSI is above sensitivity and no +collision happens, the packet is decoded. That is good for topology sketches, +but too optimistic for weak links: stronger coding rates should improve payload +decode probability near the edge, while still costing airtime. + +The bundled coefficients are intentionally small and documented. They are tuned +from Batumi-area receive observations and neighbor SNR bands, so this remains an +empirical SNR-to-PER curve rather than a full lab-grade demodulator model. + +Keep the model opt-in. Baseline simulations must stay exactly comparable to +upstream Meshtasticator until a scenario explicitly enables it. +""" + +import math + + +def estimate_snr(conf, rssi): + """Estimate packet SNR from simulated RSSI and the configured noise floor.""" + snr = rssi - conf.NOISE_LEVEL + if conf.REPORTED_SNR_MIN_DB is not None: + snr = max(conf.REPORTED_SNR_MIN_DB, snr) + if conf.REPORTED_SNR_MAX_DB is not None: + snr = min(conf.REPORTED_SNR_MAX_DB, snr) + return snr + + +def apply_link_calibration(conf, rssi, features): + """Map raw path-loss output plus reusable features to calibrated RSSI. + + This deliberately does not accept node IDs. Observed directed links may be + used to fit the coefficients stored in a preset, but runtime simulation + applies the same coefficient transform to every generated pair. That keeps + the model reusable for new points instead of replaying known links. + """ + if not conf.LINK_CALIBRATION_MODEL_ENABLED or not conf.LINK_CALIBRATION_COEFFICIENTS: + return rssi + + coefficients = conf.LINK_CALIBRATION_COEFFICIENTS + calibrated_snr = coefficients.get("intercept", 0.0) + for name, value in features.items(): + calibrated_snr += coefficients.get(name, 0.0) * value + + if conf.LINK_CALIBRATION_SNR_MIN_DB is not None: + calibrated_snr = max(conf.LINK_CALIBRATION_SNR_MIN_DB, calibrated_snr) + if conf.LINK_CALIBRATION_SNR_MAX_DB is not None: + calibrated_snr = min(conf.LINK_CALIBRATION_SNR_MAX_DB, calibrated_snr) + + return conf.NOISE_LEVEL + calibrated_snr + + +def payload_success_probability(conf, rssi, cr, packet_len): + """Return probability that a heard packet's payload decodes. + + RSSI/sensitivity still gates preamble/header hearing elsewhere. This + function only models payload decode once the receiver was able to hear the + packet at all. CR therefore improves weak-link payload survival, but it does + not extend the model below the basic receive threshold. + """ + snr = estimate_snr(conf, rssi) + p50_by_cr = conf.PHY_LOSS_SNR_P50_BY_CR + p50 = p50_by_cr.get(cr, p50_by_cr[5]) + + # Longer packets expose more coded symbols to fading/interference. The + # penalty is deliberately gentle because collisions are modeled separately. + extra_bytes = max(0, packet_len - conf.PHY_LOSS_REFERENCE_PACKET_BYTES) + length_penalty = extra_bytes / 100.0 * conf.PHY_LOSS_LONG_PACKET_PENALTY_DB_PER_100B + + x = (snr - p50 - length_penalty) / conf.PHY_LOSS_SNR_TRANSITION_DB + probability = 1.0 / (1.0 + math.exp(-x)) + return min(conf.PHY_LOSS_MAX_SUCCESS_PROB, max(conf.PHY_LOSS_MIN_SUCCESS_PROB, probability)) + + +def payload_is_lost(conf, rssi, cr, packet_len, random_draw): + """Decide whether this packet copy is lost to weak-link PHY errors.""" + if not conf.PHY_LOSS_MODEL_ENABLED: + return False + + return random_draw > payload_success_probability(conf, rssi, cr, packet_len) diff --git a/loraMesh.py b/loraMesh.py index e4bcde1c..c982ed4f 100755 --- a/loraMesh.py +++ b/loraMesh.py @@ -259,6 +259,16 @@ def parse_params(conf, args=None) -> [NodeConfig]: action="store_true", help="disable land-cover clutter even when a grid is available", ) + parser.add_argument( + "--phy-loss-model", + action="store_true", + help="enable empirical SNR-to-payload-loss model", + ) + parser.add_argument( + "--capture-collision-model", + action="store_true", + help="enable capture-aware overlap/collision model", + ) parser.add_argument( "--map-bbox", type=str, @@ -554,6 +564,8 @@ def parse_params(conf, args=None) -> [NodeConfig]: conf.CLUTTER_GRID_FILE = parsed_arguments.clutter_grid if parsed_arguments.clutter_profile_samples is not None: conf.CLUTTER_PROFILE_SAMPLES = parsed_arguments.clutter_profile_samples + conf.PHY_LOSS_MODEL_ENABLED = parsed_arguments.phy_loss_model + conf.CAPTURE_COLLISION_MODEL_ENABLED = parsed_arguments.capture_collision_model if parsed_arguments.verbose: # Set this logger and lib.* to DEBUG only after the command line has diff --git a/tests/test_collision_model.py b/tests/test_collision_model.py new file mode 100644 index 00000000..dbc3227d --- /dev/null +++ b/tests/test_collision_model.py @@ -0,0 +1,95 @@ +import unittest + +from lib.config import Config +from lib.phy import check_collision, frequency_collision + + +class FakePacket: + def __init__(self, seq, start, end, rssi, sf=11, bw=250e3, freq=869.5e6, sensed=True): + self.seq = seq + self.txNodeId = seq + self.startTime = start + self.endTime = end + self.timeOnAir = end - start + self.freq = freq + self.bw = bw + self.sf = sf + self.rssiAtN = [rssi] + self.collidedAtN = [False] + self.collisionReasonAtN = [None] + self.sensedByN = [sensed] + + +class TestCaptureCollisionModel(unittest.TestCase): + def config(self): + conf = Config() + conf.NR_NODES = 1 + conf.CAPTURE_COLLISION_MODEL_ENABLED = True + conf.COLLISION_DUE_TO_INTERFERENCE = False + return conf + + def test_equal_power_preamble_overlap_loses_both_packets(self): + conf = self.config() + existing = FakePacket(1, 0, 1000, -90) + incoming = FakePacket(2, 100, 1100, -91) + + collided = check_collision(conf, None, incoming, 0, [[existing]]) + + self.assertEqual(collided, 1) + self.assertTrue(incoming.collidedAtN[0]) + self.assertTrue(existing.collidedAtN[0]) + self.assertEqual(incoming.collisionReasonAtN[0], "capture_overlap") + + def test_stronger_packet_captures_weaker_overlap(self): + conf = self.config() + existing = FakePacket(1, 0, 1000, -92) + incoming = FakePacket(2, 100, 1100, -80) + + collided = check_collision(conf, None, incoming, 0, [[existing]]) + + self.assertEqual(collided, 0) + self.assertFalse(incoming.collidedAtN[0]) + self.assertTrue(existing.collidedAtN[0]) + + def test_small_late_tail_does_not_destroy_locked_packet(self): + conf = self.config() + existing = FakePacket(1, 0, 1000, -90) + incoming = FakePacket(2, 950, 1950, -91) + + collided = check_collision(conf, None, incoming, 0, [[existing]]) + + self.assertEqual(collided, 1) + self.assertFalse(existing.collidedAtN[0]) + self.assertTrue(incoming.collidedAtN[0]) + + def test_undecodable_packet_can_jam_without_becoming_collision_casualty(self): + conf = self.config() + existing = FakePacket(1, 0, 1000, -90, sensed=True) + incoming = FakePacket(2, 100, 1100, -91, sensed=False) + + collided = check_collision(conf, None, incoming, 0, [[existing]]) + + self.assertEqual(collided, 0) + self.assertTrue(existing.collidedAtN[0]) + self.assertFalse(incoming.collidedAtN[0]) + + def test_frequency_collision_uses_bandwidth_on_either_packet(self): + narrow = FakePacket(1, 0, 1000, -90, bw=125e3, freq=869.500e6) + wide = FakePacket(2, 0, 1000, -90, bw=500e3, freq=869.610e6) + + self.assertTrue(frequency_collision(narrow, wide)) + + def test_frequency_collision_normalizes_hz_and_khz_style_fields(self): + hz_left = FakePacket(1, 0, 1000, -90, bw=250e3, freq=869.500e6) + hz_right = FakePacket(2, 0, 1000, -90, bw=250e3, freq=869.550e6) + hz_far = FakePacket(5, 0, 1000, -90, bw=250e3, freq=869.570e6) + mhz_left = FakePacket(3, 0, 1000, -90, bw=250, freq=869.500) + mhz_right = FakePacket(4, 0, 1000, -90, bw=250, freq=869.550) + + self.assertTrue(frequency_collision(hz_left, hz_right)) + self.assertFalse(frequency_collision(hz_left, hz_far)) + self.assertTrue(frequency_collision(mhz_left, mhz_right)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_link_model.py b/tests/test_link_model.py new file mode 100644 index 00000000..90a7c124 --- /dev/null +++ b/tests/test_link_model.py @@ -0,0 +1,84 @@ +import unittest + +from lib.config import Config +from lib.link_model import calculate_link_budget +from lib.point import Point +from lib.terrain import TerrainGrid + + +class DummyNode: + def __init__(self, nodeid, x, y, gain=0.0, z=1.5, antenna_height=1.5): + self.nodeid = nodeid + self.position = Point(x, y, z) + self.antennaGain = gain + self.antennaHeight = antenna_height + + +class TestLinkModel(unittest.TestCase): + def test_endpoint_antenna_gains_affect_packet_budget(self): + conf = Config() + + without_gains = calculate_link_budget(conf, DummyNode(1, 0, 0), DummyNode(2, 1000, 0)) + with_gains = calculate_link_budget(conf, DummyNode(1, 0, 0, gain=2.0), DummyNode(2, 1000, 0, gain=3.0)) + + # MeshPacket delivery uses both TX and RX antenna gains. The shared link + # model must keep topology-summary counters on that same budget. + self.assertAlmostEqual(with_gains.raw_rssi_dbm - without_gains.raw_rssi_dbm, 5.0) + self.assertAlmostEqual(with_gains.rssi_dbm - without_gains.rssi_dbm, 5.0) + + def test_directed_offset_is_path_loss_not_identity_lookup(self): + conf = Config() + baseline = calculate_link_budget(conf, DummyNode(1, 0, 0), DummyNode(2, 1000, 0)) + offset = calculate_link_budget(conf, DummyNode(1, 0, 0), DummyNode(2, 1000, 0), offset_db=4.0) + + self.assertAlmostEqual(offset.path_loss_db - baseline.path_loss_db, 4.0) + self.assertAlmostEqual(baseline.rssi_dbm - offset.rssi_dbm, 4.0) + + def test_absolute_node_altitude_is_not_used_as_antenna_height(self): + conf = Config() + conf.MODEL = 1 + + ground_height = calculate_link_budget( + conf, + DummyNode(1, 0, 0, z=1.5, antenna_height=1.5), + DummyNode(2, 1000, 0, z=1.5, antenna_height=1.5), + ) + absolute_altitude = calculate_link_budget( + conf, + DummyNode(1, 0, 0, z=101.5, antenna_height=1.5), + DummyNode(2, 1000, 0, z=101.5, antenna_height=1.5), + ) + + self.assertAlmostEqual(absolute_altitude.base_path_loss_db, ground_height.base_path_loss_db) + + def test_feature_calibration_applies_to_generated_pairs(self): + conf = Config() + conf.LINK_CALIBRATION_MODEL_ENABLED = True + conf.LINK_CALIBRATION_COEFFICIENTS = {"intercept": -12.0} + + first = calculate_link_budget(conf, DummyNode(1, 0, 0), DummyNode(2, 1000, 0)) + second = calculate_link_budget(conf, DummyNode(9, 0, 0), DummyNode(10, 1000, 0)) + + # The calibration is a feature transform, not a lookup keyed by node ID: + # two generated pairs with the same path features get the same SNR. + self.assertAlmostEqual(first.snr_db, -12.0) + self.assertAlmostEqual(second.snr_db, -12.0) + + def test_terrain_loss_flows_through_shared_link_budget(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), + ]) + + budget = calculate_link_budget(conf, DummyNode(1, 0, 0), DummyNode(2, 1000, 0)) + + self.assertGreater(budget.terrain_loss_db, 0) + self.assertAlmostEqual(budget.path_loss_db, budget.base_path_loss_db + budget.terrain_loss_db) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_node.py b/tests/test_node.py index 050250ac..1dffb16c 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -5,7 +5,14 @@ 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.node import ( + MESHTASTIC_ROLE, + MeshNode, + NodeConfig, + node_configs_from_yaml, + origin_from_yaml, + packet_is_rx_candidate, +) from lib.point import Point from lib.terrain import NODE_Z_REFERENCE_SEA_LEVEL, TerrainGrid, apply_terrain_altitude @@ -150,5 +157,25 @@ def test_mesh_node_preserves_absolute_altitude_for_terrain_recompute(self): self.assertEqual(node.position.z, 150) +class TestPacketRxCandidate(unittest.TestCase): + def test_legacy_collision_model_tracks_only_decodable_packets(self): + packet = type("Packet", (), { + "sensedByN": [False, True], + "detectedByN": [True, True], + })() + + self.assertFalse(packet_is_rx_candidate(packet, 0, capture_model_enabled=False)) + self.assertTrue(packet_is_rx_candidate(packet, 1, capture_model_enabled=False)) + + def test_capture_model_tracks_cad_detected_interference(self): + packet = type("Packet", (), { + "sensedByN": [False, True], + "detectedByN": [True, False], + })() + + self.assertTrue(packet_is_rx_candidate(packet, 0, capture_model_enabled=True)) + self.assertFalse(packet_is_rx_candidate(packet, 1, capture_model_enabled=True)) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_radio_loss.py b/tests/test_radio_loss.py new file mode 100644 index 00000000..21965558 --- /dev/null +++ b/tests/test_radio_loss.py @@ -0,0 +1,77 @@ +import unittest + +from lib.config import Config +from lib.radio_loss import apply_link_calibration, estimate_snr, payload_success_probability + + +class TestRadioLoss(unittest.TestCase): + def test_stronger_coding_rate_improves_weak_link_probability(self): + conf = Config() + conf.PHY_LOSS_MODEL_ENABLED = True + + # Around a marginal SNR band, stronger coding rates should buy payload + # reliability. Airtime cost is accounted for elsewhere. + rssi = conf.NOISE_LEVEL - 18.0 + + cr5 = payload_success_probability(conf, rssi, 5, conf.PACKETLENGTH) + cr8 = payload_success_probability(conf, rssi, 8, conf.PACKETLENGTH) + + self.assertGreater(cr8, cr5) + + def test_longer_packets_are_penalized_gently(self): + conf = Config() + conf.PHY_LOSS_MODEL_ENABLED = True + rssi = conf.NOISE_LEVEL - 10.0 + + short_packet = payload_success_probability(conf, rssi, 6, 40) + long_packet = payload_success_probability(conf, rssi, 6, 180) + + self.assertGreater(short_packet, long_packet) + + def test_healthy_snr_band_is_high_probability(self): + conf = Config() + conf.PHY_LOSS_MODEL_ENABLED = True + + # At a healthy SNR, all CRs should be very likely to decode. + rssi = conf.NOISE_LEVEL - 7.0 + + self.assertGreater(payload_success_probability(conf, rssi, 5, 40), 0.85) + + def test_reported_snr_can_be_clamped_for_real_mesh_presets(self): + conf = Config() + conf.REPORTED_SNR_MIN_DB = -21.25 + conf.REPORTED_SNR_MAX_DB = 8.25 + + self.assertEqual(estimate_snr(conf, conf.NOISE_LEVEL + 100.0), 8.25) + self.assertEqual(estimate_snr(conf, conf.NOISE_LEVEL - 100.0), -21.25) + + def test_link_calibration_model_uses_features_not_pair_ids(self): + conf = Config() + conf.LINK_CALIBRATION_MODEL_ENABLED = True + conf.LINK_CALIBRATION_COEFFICIENTS = { + "intercept": -4.0, + "raw_snr_clip": 0.5, + "urban_fraction": -2.0, + } + raw_rssi = conf.NOISE_LEVEL - 20.0 + + adjusted = apply_link_calibration(conf, raw_rssi, { + "raw_snr_clip": -20.0, + "urban_fraction": 0.5, + }) + + self.assertAlmostEqual(estimate_snr(conf, adjusted), -15.0) + + def test_link_calibration_model_can_be_clamped(self): + conf = Config() + conf.LINK_CALIBRATION_MODEL_ENABLED = True + conf.LINK_CALIBRATION_COEFFICIENTS = {"intercept": 40.0} + conf.LINK_CALIBRATION_SNR_MAX_DB = 8.25 + + adjusted = apply_link_calibration(conf, conf.NOISE_LEVEL - 100.0, {}) + + self.assertEqual(estimate_snr(conf, adjusted), 8.25) + + +if __name__ == "__main__": + unittest.main() From 359ba6dce71aad4299574ed1519b4346ee524180 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Sat, 16 May 2026 00:54:42 +0400 Subject: [PATCH 15/24] fix(sim): stabilize moving capture snapshots --- lib/common.py | 1 + lib/discrete_event_sim.py | 7 ++++++- lib/node.py | 2 +- lib/packet.py | 1 + tests/test_discrete_event_sim.py | 22 +++++++++++----------- 5 files changed, 20 insertions(+), 13 deletions(-) diff --git a/lib/common.py b/lib/common.py index c6f4d110..36970e96 100644 --- a/lib/common.py +++ b/lib/common.py @@ -64,6 +64,7 @@ def setup_asymmetric_links(conf, nodes): """updates conf to populate LINK_OFFSET member to simulate asymmetric links """ asymLinkRng = random.Random(conf.SEED) + conf.LINK_OFFSET = {} totalPairs = 0 symmetricLinks = 0 asymmetricLinks = 0 diff --git a/lib/discrete_event_sim.py b/lib/discrete_event_sim.py index 505a9f06..ca850fd6 100644 --- a/lib/discrete_event_sim.py +++ b/lib/discrete_event_sim.py @@ -6,6 +6,7 @@ from simpy import Environment as SimpyEnvironment import numpy as np +from lib.common import setup_asymmetric_links from lib.config import Config from lib.discrete_event_sim_components import SimulationState, SimulationDataTracking from lib.node import MeshNode, NodeConfig @@ -72,7 +73,10 @@ def finalize(self, conf: Config): 1 for p in packets for n in nodes - if n.nodeid < len(getattr(p, "phyLostAtN", [])) and p.phyLostAtN[n.nodeid] is True + if n.nodeid < len(getattr(p, "phyLostAtN", [])) + and p.phyLostAtN[n.nodeid] is True + and p.sensedByN[n.nodeid] is True + and p.collidedAtN[n.nodeid] is False ]) collision_reasons = {} for p in packets: @@ -169,6 +173,7 @@ def __init__(self, conf: Config, node_configs: [NodeConfig], graph: "Graph | Non # link counts, and thus do an O(n^2) precomputation anyways, just # always do this and reserve checking/not checking the map later based # on config settings. + setup_asymmetric_links(self.conf, self.node_configs) self.initialize_connectivity_map() # node configs provided, create nodes with them diff --git a/lib/node.py b/lib/node.py index 67abe5f2..8c42b59d 100644 --- a/lib/node.py +++ b/lib/node.py @@ -232,7 +232,7 @@ def __init__(self, conf, sim_state: SimulationState, data_tracking: SimulationDa # set up internal RNGs self.moveRng = random.Random(self.nodeid) self.nodeRng = random.Random(self.nodeid) - self.rebroadcastRng = random.Random() + self.rebroadcastRng = random.Random(f"{self.conf.SEED}:{self.nodeid}:rebroadcast") # require the user to specify a node configuration now, including position self.position = self.node_conf.position # explicitly use position in node_conf diff --git a/lib/packet.py b/lib/packet.py index 70543059..81bb88cc 100644 --- a/lib/packet.py +++ b/lib/packet.py @@ -109,6 +109,7 @@ def refresh_link_budgets(self): continue if ( self.conf.ENABLE_CONNECTIVITY_MAP + and not self.conf.MOVEMENT_ENABLED and rx_node.nodeid not in self.connectivity_map[self.txNodeId] ): logger.debug( diff --git a/tests/test_discrete_event_sim.py b/tests/test_discrete_event_sim.py index 9ca7787e..ccdd89f6 100644 --- a/tests/test_discrete_event_sim.py +++ b/tests/test_discrete_event_sim.py @@ -248,32 +248,32 @@ def test_discrete_sim_ten_nodes(self): # and modify your changes, or to update the hardcoded "known good" # simulation results is up to your judgement for which is # appropriate. Be cautious! - self.assertEqual(messageSeq, 180, "expected number of messages created") + self.assertEqual(messageSeq, 185, "expected number of messages created") sent = results['sent'] potentialReceivers = results['potentialReceivers'] - self.assertEqual(sent, 834, "expected number of packets sent") - self.assertEqual(potentialReceivers, 7506, "expected number of potential receivers") + self.assertEqual(sent, 821, "expected number of packets sent") + self.assertEqual(potentialReceivers, 7389, "expected number of potential receivers") nrCollisions = results['nrCollisions'] - self.assertEqual(nrCollisions, 323, "expected number of collisions") + self.assertEqual(nrCollisions, 272, "expected number of collisions") nrSensed = results['nrSensed'] - self.assertEqual(nrSensed, 2895, "expected number of packets sensed") + self.assertEqual(nrSensed, 2860, "expected number of packets sensed") nrReceived = results['nrReceived'] - self.assertEqual(nrReceived, 2573, "expected number of packets received") + self.assertEqual(nrReceived, 2588, "expected number of packets received") meanDelay = results['meanDelay'] - self.assertEqual(round(meanDelay, 2), 6403.13, "expected rounded delay average") + self.assertEqual(round(meanDelay, 2), 5915.09, "expected rounded delay average") txAirUtilizationRate = results['txAirUtilizationRate'] - self.assertEqual(round(txAirUtilizationRate * 100, 2), 4.83, "expected rounded average tx air utilization") + self.assertEqual(round(txAirUtilizationRate * 100, 2), 4.75, "expected rounded average tx air utilization") nodeReach = results['nodeReach'] - self.assertEqual(round(nodeReach*100, 2), 79.57, "expected rounded percentage of nodes reached") + self.assertEqual(round(nodeReach*100, 2), 80.9, "expected rounded percentage of nodes reached") usefulness = results['usefulness'] - self.assertEqual(round(usefulness*100, 2), 50.1, "expected rounded 'usefulness' percentage") + self.assertEqual(round(usefulness*100, 2), 52.05, "expected rounded 'usefulness' percentage") delayDropped = results['delayDropped'] - self.assertEqual(delayDropped, 1143, "expected number of packets dropped") + self.assertEqual(delayDropped, 1125, "expected number of packets dropped") # default config has both asymmetric links and movement enabled noLinkRate = results['noLinkRate'] self.assertEqual(round(noLinkRate * 100, 2), 55.56, "expected rounded percentage of 'no' links") From db296170f5e6cd58476a9bc976965c3440c5ad1d Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Sat, 2 May 2026 12:17:36 +0400 Subject: [PATCH 16/24] feat(sim): add batumi radio preset --- DISCRETE_EVENT_SIM.md | 32 +- docs/batumi_radio_calibration.md | 233 ++ lib/presets.py | 129 + loraMesh.py | 140 +- presets/batumi.yaml | 1258 +++++++++ presets/batumi_clutter.csv | 4321 ++++++++++++++++++++++++++++++ presets/batumi_terrain.csv | 43 + tests/test_docs.py | 31 + tests/test_lora_mesh_cli.py | 74 + tests/test_presets.py | 104 + 10 files changed, 6349 insertions(+), 16 deletions(-) create mode 100644 docs/batumi_radio_calibration.md create mode 100644 lib/presets.py create mode 100644 presets/batumi.yaml create mode 100644 presets/batumi_clutter.csv create mode 100644 presets/batumi_terrain.csv create mode 100644 tests/test_docs.py create mode 100644 tests/test_presets.py diff --git a/DISCRETE_EVENT_SIM.md b/DISCRETE_EVENT_SIM.md index a026d6cd..33533ffe 100644 --- a/DISCRETE_EVENT_SIM.md +++ b/DISCRETE_EVENT_SIM.md @@ -98,6 +98,20 @@ coding rate. `--capture-collision-model` keeps CAD-detectable but undecodable packets on the RF timeline as interference energy, and uses capture/preamble overlap rules instead of treating every overlap as identical. +Packaged real-mesh presets can be listed and loaded directly: + +```python3 loraMesh.py --list-presets``` + +The `batumi` preset includes sanitized Batumi/Georgia-area node geometry, a +matching terrain grid, an OpenStreetMap-derived land-cover clutter grid, and an +aggregate radio calibration over generated path features. Terrain, clutter, and +the fitted link-calibration model are enabled automatically for the preset +unless you pass different `--terrain-grid` or `--clutter-grid` inputs; use +`--no-clutter` for old-style comparison runs. The calibration report is in +`docs/batumi_radio_calibration.md`. + +```python3 loraMesh.py --preset batumi --no-gui --simtime-seconds 5 --period-seconds 2 --phy-loss-model --capture-collision-model``` + 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``` @@ -118,14 +132,16 @@ Here we list some of the configurations, which you can change to model your scen The LoRa modem ([see Meshtastic radio settings](https://meshtastic.org/docs/overview/radio-settings#predefined-channels)) that is used, as defined below: |Modem | Name | Bandwidth (kHz) | Coding rate | Spreading Factor | Data rate (kbps) |--|--|--|--|--|--| -| 0 |Short Fast|250|4/8|7|6.8 -| 1 |Short Slow|250|4/8|8|3.9 -| 2 |Mid Fast|250|4/8|9|2.2 -| 3 |Mid Slow|250|4/8|10|1.2 -| 4 |Long Fast|250|4/8|11|0.67 -| 5 |Long Moderate|125|4/8|11|0.335 -| 6 |Long Slow|125|4/8|12|0.18 -| 7 |Very Long Slow|62.5|4/8|12|0.09 +| 0 | Short Turbo | 500 | 4/5 | 7 | 21.9 | +| 1 | Short Fast | 250 | 4/5 | 7 | 10.9 | +| 2 | Short Slow | 250 | 4/5 | 8 | 6.25 | +| 3 | Medium Fast | 250 | 4/5 | 9 | 3.52 | +| 4 | Medium Slow | 250 | 4/5 | 10 | 1.95 | +| 5 | Long Turbo | 500 | 4/8 | 11 | 1.34 | +| 6 | Long Fast | 250 | 4/5 | 11 | 1.07 | +| 7 | Long Moderate | 125 | 4/8 | 11 | 0.336 | +| 8 | Long Slow | 125 | 4/8 | 12 | 0.183 | +| 9 | Very Long Slow | 62.5 | 4/8 | 12 | 0.0916 | ### Period Mean period (in ms) with which the nodes generate a new message following an exponential distribution. E.g. if you set it to 300s, each node will generate a message on average once every five minutes. diff --git a/docs/batumi_radio_calibration.md b/docs/batumi_radio_calibration.md new file mode 100644 index 00000000..a22606b4 --- /dev/null +++ b/docs/batumi_radio_calibration.md @@ -0,0 +1,233 @@ +# Batumi Radio Calibration + +This report documents the reusable radio calibration used by the packaged +`batumi` preset. It is intentionally aggregate-only: no node names, source node +IDs, or collection endpoint details are required for reproducing simulator +behavior. + +## Data Window + +The reference data is a 30-day Batumi/Georgia-area neighbor-SNR snapshot. Nodes +were filtered to the preset bounding box: + +```text +lat: 41.50..41.82 +lon: 41.50..41.86 +``` + +The land-cover clutter grid is derived from public OpenStreetMap building, +landuse, natural, and water polygons fetched with Overpass. The packaged CSV is +a coarse 500 m raster with only `open`, `urban`, `water`, and `forest` classes; +it does not include raw OSM feature IDs or names. Attribution: OpenStreetMap +contributors. + +Reference sample shape: + +```text +nodes in bbox: 92 +current neighbor edges: 85 +30-day distinct directed edges: 296 +30-day neighbor samples: 14361 +OSM clutter cells: 4320 +OSM clutter cells by class: urban=1209 open=3101 water=5 forest=5 +``` + +Observed median SNR across the 296 directed calibration edges: + +```text +min: -20.75 dB +p05: -19.06 dB +p25: -17.50 dB +p50: -10.88 dB +mean: -9.53 dB +p75: -3.75 dB +p95: 5.81 dB +max: 6.75 dB +``` + +## Calibration Shape + +The calibration is not a node-pair replay table. The runtime simulator never +asks "was this exact directed pair observed?" and never lifts one specific link +because it appeared in the calibration data. + +Instead, the preset stores a reusable feature transform: + +```text +calibrated_snr = intercept + + raw_snr_clip * a + + log_distance_km * b + + log_distance_km_sq * c + + terrain/clutter/vantage/land-cover feature terms +``` + +The model is trained from two kinds of examples: + +```text +positive targets: 296 observed directed links with median observed SNR +background targets: all other generated directed pairs, weakly weighted at 0.02 +background target SNR: min(raw_model_snr, -22 dB) +ridge lambda: 50 +``` + +The background targets are deliberately weak evidence. A missing 30-day neighbor +edge does not prove the link is impossible, but it is enough to stop a +positive-only fit from making the whole city reachable. + +The applied coefficient set lives in `presets/batumi.yaml` under +`radio_calibration.link_calibration_model.coefficients`. Runtime packet logic +uses only those coefficients and path features, so the same model can be applied +to new generated points that have no ground-truth links. + +## Scalar Baseline + +The scalar baseline includes the preset noise floor, path-loss distance floor, +terrain, and OSM-derived clutter, but not the fitted feature transform: + +```yaml +radio_calibration: + noise_level: -110.5 + path_loss_distance_floor_m: 780.0 + reported_snr_min_db: -21.25 + reported_snr_max_db: 8.25 +``` + +On the 296 observed directed edges, scalar-only reachability is poor: + +```text +observed directed links reachable by scalar model: 25 / 296 +scalar-only sensed directed links across all generated pairs: 650 / 8372 +``` + +Scalar model RSSI margin to modem sensitivity on observed pairs: + +```text +min: -100.33 dB +p05: -87.71 dB +p25: -62.65 dB +p50: -47.17 dB +mean: -43.76 dB +p75: -25.39 dB +p95: 5.99 dB +max: 22.08 dB +``` + +Uncapped scalar model SNR for the observed pairs: + +```text +min: -121.33 dB +p05: -108.71 dB +p25: -83.65 dB +p50: -68.17 dB +mean: -64.76 dB +p75: -46.39 dB +p95: -15.01 dB +max: 1.08 dB +``` + +Pairwise residual, defined here as `observed_median_snr - scalar_model_snr`, is +large because coarse map pins, balcony/roof placement, antenna orientation, +coastal corridors, and reflections are not fully represented by distance, +terrain, and OSM land-cover alone: + +```text +min: -19.83 dB +p05: 4.72 dB +p25: 34.57 dB +p50: 55.77 dB +mean: 55.23 dB +p75: 79.48 dB +p95: 103.34 dB +max: 118.83 dB +``` + +OSM clutter loss on the same observed directed links: + +```text +min: 0.00 dB +p05: 3.61 dB +p25: 7.32 dB +p50: 15.01 dB +mean: 14.42 dB +p75: 22.21 dB +p95: 25.00 dB +max: 25.00 dB +``` + +## Fitted Feature Model + +After applying the reusable feature transform: + +```text +observed directed links reachable by fitted model: 88 / 296 +fitted-model sensed directed links across all generated pairs: 1704 / 8372 +``` + +This does not force every observed calibration edge to be reachable. That is +intentional: if a link needs pair-specific information to exist, the generic +model treats it as uncertainty instead of baking it into runtime physics. + +Reported model SNR for the 296 observed calibration links: + +```text +min: -21.25 dB +p05: -21.25 dB +p25: -21.25 dB +p50: -21.25 dB +mean: -19.70 dB +p75: -19.99 dB +p95: -13.09 dB +max: -1.57 dB +``` + +Residual after feature calibration, `observed_median_snr - fitted_model_snr`: + +```text +min: -15.00 dB +p05: -0.56 dB +p25: 3.50 dB +p50: 8.85 dB +mean: 10.17 dB +p75: 16.26 dB +p95: 26.59 dB +max: 28.00 dB +``` + +Reported SNR distribution across all generated directed pairs after feature +calibration: + +```text +min: -21.25 dB +p05: -21.25 dB +p25: -21.25 dB +p50: -21.25 dB +mean: -20.05 dB +p75: -21.25 dB +p95: -12.15 dB +max: 8.25 dB +``` + +## Why Not Pairwise Correction + +The runtime model deliberately does not carry a lookup table of observed +directed links and does not boost one exact node pair just because that pair +appeared in the calibration sample. A pair-specific correction can make the +calibration set look perfect while adding nothing for a new generated point +with no ground truth. + +The packaged observations are training/evaluation records only. The simulator +applies one fitted transform to every generated TX/RX pair. That is less +flattering to the calibration set, but much more useful for testing new +placements and other nearby meshes. + +## Known Limitations + +This calibration target is neighbor-SNR history, not packet-level PER trace. +Neighbor tables are biased toward nodes that report neighbor info, and a 30-day +observed edge does not prove the link is continuously available. + +The fitted coefficients are local to the packaged Batumi preset. They do not +change random/default simulations and should not be treated as universal LoRa +propagation constants. The useful part to reuse elsewhere is the workflow: +compute physical path features, fit coefficients against local observations, +and evaluate generated pairs without runtime per-link priors. diff --git a/lib/presets.py b/lib/presets.py new file mode 100644 index 00000000..1a8e2c17 --- /dev/null +++ b/lib/presets.py @@ -0,0 +1,129 @@ +"""Packaged real-mesh scenario presets. + +Presets keep small, reproducible field snapshots in the tree so PHY and +collision-model changes can be compared without depending on live map services +or other runtime inputs. +""" + +import csv +from pathlib import Path + +import yaml + +from lib.node import node_configs_from_yaml, origin_from_yaml +from lib.terrain import TerrainGrid + + +PRESET_ROOT = Path(__file__).resolve().parents[1] / "presets" + +PRESETS = { + "batumi": { + # Real Batumi/Georgia-area node geometry plus a matching coarse terrain + # grid. loraMesh.py enables this terrain grid automatically for the + # preset so path-loss experiments include the local ridge/sea shape. + "nodes": PRESET_ROOT / "batumi.yaml", + "terrain": PRESET_ROOT / "batumi_terrain.csv", + "clutter": PRESET_ROOT / "batumi_clutter.csv", + }, +} + + +def available_presets(): + return sorted(PRESETS.keys()) + + +def preset_paths(name): + try: + return PRESETS[name] + except KeyError as err: + raise ValueError(f"unknown preset: {name}") from err + + +def load_preset_raw(name): + paths = preset_paths(name) + with paths["nodes"].open(encoding="utf-8") as fh: + return yaml.safe_load(fh) + + +def load_preset_node_configs(name, period): + return node_configs_from_yaml(load_preset_raw(name), period) + + +def preset_radio_calibration(name): + raw = load_preset_raw(name) + return raw.get("radio_calibration", {}) if isinstance(raw, dict) else {} + + +def preset_calibration_observations(name): + raw = load_preset_raw(name) + return raw.get("calibration_observations", []) if isinstance(raw, dict) else [] + + +def apply_preset_radio_calibration(conf, name): + """Apply optional aggregate radio calibration stored with a packaged preset. + + This intentionally lives with presets, not the generic PHY code: field + captures can correct a packaged scenario's noise floor, reported-SNR range, + and reusable link-calibration coefficients without silently changing + random/default simulations. Observed links stored in a preset are evaluation + records only; runtime calibration is never keyed by a specific node pair. + """ + calibration = preset_radio_calibration(name) + + fields = { + "noise_level": "NOISE_LEVEL", + "path_loss_distance_floor_m": "PATH_LOSS_DISTANCE_FLOOR_M", + "reported_snr_min_db": "REPORTED_SNR_MIN_DB", + "reported_snr_max_db": "REPORTED_SNR_MAX_DB", + } + if calibration: + for source_name, config_name in fields.items(): + if source_name in calibration: + setattr(conf, config_name, float(calibration[source_name])) + + link_model = calibration.get("link_calibration_model", {}) if calibration else {} + conf.LINK_CALIBRATION_MODEL_ENABLED = bool(link_model) + conf.LINK_CALIBRATION_COEFFICIENTS = { + str(key): float(value) + for key, value in link_model.get("coefficients", {}).items() + } + conf.LINK_CALIBRATION_SNR_MIN_DB = None + conf.LINK_CALIBRATION_SNR_MAX_DB = None + if "snr_min_db" in link_model: + conf.LINK_CALIBRATION_SNR_MIN_DB = float(link_model["snr_min_db"]) + if "snr_max_db" in link_model: + conf.LINK_CALIBRATION_SNR_MAX_DB = float(link_model["snr_max_db"]) + + +def preset_origin(name): + return origin_from_yaml(load_preset_raw(name)) + + +def preset_terrain_grid(name): + terrain_path = preset_paths(name).get("terrain") + if terrain_path and terrain_path.exists(): + return terrain_path + return None + + +def load_preset_terrain_grid(name): + terrain_path = preset_terrain_grid(name) + if terrain_path is None: + return None + with terrain_path.open(encoding="utf-8", newline="") as fh: + reader = csv.DictReader(fh) + return TerrainGrid.from_rows( + ( + row["x_m"], + row["y_m"], + row["elevation_m"], + ) + for row in reader + ) + + +def preset_clutter_grid(name): + clutter_path = preset_paths(name).get("clutter") + if clutter_path and clutter_path.exists(): + return clutter_path + return None diff --git a/loraMesh.py b/loraMesh.py index c982ed4f..9df95f57 100755 --- a/loraMesh.py +++ b/loraMesh.py @@ -22,6 +22,16 @@ node_configs_from_yaml, origin_from_yaml, ) +from lib.presets import ( + apply_preset_radio_calibration, + available_presets, + load_preset_raw, + load_preset_terrain_grid, + load_preset_node_configs, + preset_clutter_grid, + preset_origin, + preset_terrain_grid, +) from lib.srtm import ( DEFAULT_SRTM_URL_TEMPLATE, SRTM_DATA_ATTRIBUTION, @@ -164,6 +174,45 @@ def srtm_tiles_for_node_config_links(conf, node_config, origin, margin_m=1000.0) return sorted(tile_names) +def print_preset_list(): + """Print packaged scenario presets in a copy-pasteable discovery format.""" + print("Available scenario presets:") + for name in available_presets(): + raw = load_preset_raw(name) + nodes = raw.get("nodes", {}) if isinstance(raw, dict) else {} + origin = raw.get("origin", {}) if isinstance(raw, dict) else {} + calibration = raw.get("radio_calibration", {}) if isinstance(raw, dict) else {} + observations = raw.get("calibration_observations", []) if isinstance(raw, dict) else [] + terrain = preset_terrain_grid(name) is not None + clutter = preset_clutter_grid(name) is not None + calibration_enabled = bool(calibration.get("link_calibration_model")) + origin_text = "unknown" + if "lat" in origin and "lon" in origin: + origin_text = f"{origin['lat']:.5f},{origin['lon']:.5f}" + + print( + f" {name}: {len(nodes)} nodes, origin={origin_text}, " + f"terrain={'yes' if terrain else 'no'}, " + f"clutter={'yes' if clutter else 'no'}, " + f"link_calibration={'yes' if calibration_enabled else 'no'}, " + f"calibration_edges={len(observations)}" + ) + + +def print_modem_preset_list(conf): + """Print modem presets with the fields users need for comparable runs.""" + print("Available modem presets:") + for name, preset in conf.MODEM_PRESETS.items(): + default_marker = " (default)" if name == conf.MODEM_PRESET else "" + print( + f" {name}{default_marker}: " + f"bw={preset['bw'] / 1000:g} kHz, " + f"sf={preset['sf']}, " + f"cr=4/{preset['cr']}, " + f"sensitivity={preset['sensitivity']:g} dBm" + ) + + 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. @@ -173,7 +222,14 @@ 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", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog="""examples: + loraMesh.py --list-presets + loraMesh.py --preset batumi --no-gui --simtime-seconds 60 --period-seconds 5 + loraMesh.py --preset batumi --no-gui --simtime-seconds 60 --period-seconds 5 --phy-loss-model --capture-collision-model + loraMesh.py --from-map 'https://meshtastic.liamcottle.net/api/v1/nodes' --map-bbox 41.50,41.50,41.82,41.86 --map-limit 100 --no-gui +""", ) # only allow one of --from-file optional, or nr_nodes positional exclusively @@ -205,6 +261,11 @@ def parse_params(conf, args=None) -> [NodeConfig]: action="store_true", help="Fetch positioned nodes from a local Meshtastic device NodeDB.", ) + group.add_argument( + "--preset", + choices=available_presets(), + help="Load a packaged real-mesh scenario preset.", + ) # 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. @@ -312,12 +373,29 @@ def parse_params(conf, args=None) -> [NodeConfig]: 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( + "--list-presets", + action="store_true", + help="List packaged real-mesh scenario presets and exit", + ) + parser.add_argument( + "--list-modem-presets", + action="store_true", + help="List Meshtastic modem presets and exit", + ) parser.add_argument( "-v", "--verbose", action="store_true", help="enable verbose/debug output" ) parsed_arguments = parser.parse_args(args) + if parsed_arguments.list_presets: + print_preset_list() + if parsed_arguments.list_modem_presets: + print_modem_preset_list(conf) + if parsed_arguments.list_presets or parsed_arguments.list_modem_presets: + raise SystemExit(0) + cli_defaults = get_cli_defaults(conf) simtime = cli_defaults["SIMTIME"] period = cli_defaults["PERIOD"] @@ -376,9 +454,10 @@ def parse_params(conf, args=None) -> [NodeConfig]: parsed_arguments.from_file is not None or parsed_arguments.from_map is not None or parsed_arguments.from_nodedb + or parsed_arguments.preset is not None ) 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" + "Incompatible argument selection. --from-file/--from-map/--from-nodedb/--preset and --router-type can not be used together" ) if not parsed_arguments.from_nodedb and ( parsed_arguments.nodedb_host is not None @@ -406,6 +485,9 @@ def parse_params(conf, args=None) -> [NodeConfig]: 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 + bundled_terrain_grid = None + bundled_clutter_grid = None + selected_preset = None if parsed_arguments.from_file is not None: try: if parsed_arguments.map_bbox is not None: @@ -420,6 +502,17 @@ def parse_params(conf, args=None) -> [NodeConfig]: parser.error(f"could not load --from-file YAML: {err}") nr_nodes = len(config) bounds_follow_node_config = True + elif parsed_arguments.preset is not None: + selected_preset = parsed_arguments.preset + config = load_preset_node_configs(parsed_arguments.preset, period) + scenario_origin = preset_origin(parsed_arguments.preset) + # Packaged scenarios can carry terrain/clutter grids matched to the + # node geometry. Use them by default, while still letting explicit CLI + # files override them for A/B comparison runs. + bundled_terrain_grid = preset_terrain_grid(parsed_arguments.preset) + bundled_clutter_grid = preset_clutter_grid(parsed_arguments.preset) + 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( @@ -496,7 +589,7 @@ def parse_params(conf, args=None) -> [NodeConfig]: "--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") + parser.error("--no-gui requires nr_nodes, --from-file, --from-map, or --preset") from lib.gui import gen_scenario config_dict = gen_scenario(conf) @@ -539,16 +632,28 @@ def parse_params(conf, args=None) -> [NodeConfig]: node_z_reference = NODE_Z_REFERENCE_SEA_LEVEL except (OSError, ValueError) as err: parser.error(f"could not load SRTM terrain: {err}") + elif bundled_terrain_grid is not None: + try: + terrain_grid = load_preset_terrain_grid(parsed_arguments.preset) + apply_terrain_altitudes(terrain_grid, config) + terrain_enabled = True + node_z_reference = NODE_Z_REFERENCE_SEA_LEVEL + except (OSError, ValueError) as err: + parser.error(f"could not load preset 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 all - # parser rejections so failed inputs leave caller RNG state alone. + # File, map, preset, 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. random.seed(conf.SEED) if bounds_follow_node_config: fit_simulation_bounds_to_node_config(conf, config) + if selected_preset is not None: + apply_preset_radio_calibration(conf, selected_preset) + conf.SIMTIME = simtime conf.PERIOD = period conf.GUI_ENABLED = gui_enabled @@ -560,8 +665,15 @@ def parse_params(conf, args=None) -> [NodeConfig]: conf.TERRAIN_GRID = terrain_grid conf.TERRAIN_PROFILE_SAMPLES = terrain_profile_samples conf.NODE_Z_REFERENCE = node_z_reference - conf.CLUTTER_ENABLED = parsed_arguments.clutter_grid is not None and not parsed_arguments.no_clutter - conf.CLUTTER_GRID_FILE = parsed_arguments.clutter_grid + if parsed_arguments.clutter_grid: + conf.CLUTTER_ENABLED = True + conf.CLUTTER_GRID_FILE = parsed_arguments.clutter_grid + elif bundled_clutter_grid is not None and not parsed_arguments.no_clutter: + conf.CLUTTER_ENABLED = True + conf.CLUTTER_GRID_FILE = str(bundled_clutter_grid) + else: + conf.CLUTTER_ENABLED = False + conf.CLUTTER_GRID_FILE = None if parsed_arguments.clutter_profile_samples is not None: conf.CLUTTER_PROFILE_SAMPLES = parsed_arguments.clutter_profile_samples conf.PHY_LOSS_MODEL_ENABLED = parsed_arguments.phy_loss_model @@ -586,6 +698,11 @@ def parse_params(conf, args=None) -> [NodeConfig]: "Terrain data attribution:", f"{SRTM_DATA_ATTRIBUTION} ({SRTM_DATA_ATTRIBUTION_URL})", ) + print("PHY loss model:", "enabled" if conf.PHY_LOSS_MODEL_ENABLED else "disabled") + print("Capture collision model:", "enabled" if conf.CAPTURE_COLLISION_MODEL_ENABLED else "disabled") + print("Terrain model:", "enabled" if conf.TERRAIN_ENABLED else "disabled") + print("Clutter model:", conf.CLUTTER_GRID_FILE if conf.CLUTTER_ENABLED else "disabled") + print("Link calibration model:", "enabled" if conf.LINK_CALIBRATION_MODEL_ENABLED else "disabled") return config @@ -652,6 +769,13 @@ def run_simulation(conf, node_config): ) print("Number of packets dropped by delay/hop limit:", delayDropped) + if conf.TERRAIN_ENABLED: + print("Mean terrain obstruction loss (dB):", round(results["meanTerrainLossDb"], 2)) + print("Max terrain obstruction loss (dB):", round(results["maxTerrainLossDb"], 2)) + if conf.CLUTTER_ENABLED: + print("Mean clutter loss (dB):", round(results["meanClutterLossDb"], 2)) + print("Max clutter loss (dB):", round(results["maxClutterLossDb"], 2)) + if conf.MODEL_ASYMMETRIC_LINKS: noLinkRate = results["noLinkRate"] print("No links:", round(noLinkRate * 100, 2), "%") diff --git a/presets/batumi.yaml b/presets/batumi.yaml new file mode 100644 index 00000000..32263f5a --- /dev/null +++ b/presets/batumi.yaml @@ -0,0 +1,1258 @@ +origin: + lat: 41.6442879 + lon: 41.61536 +radio_calibration: + # Tuned against Batumi/Georgia-area neighbor SNR history. These are simulator + # calibration knobs, not claims about physical antenna separation. + noise_level: -110.5 + path_loss_distance_floor_m: 780.0 + reported_snr_min_db: -21.25 + reported_snr_max_db: 8.25 + link_calibration_model: + # Ridge fit over raw path-loss SNR, terrain, clutter, and vantage features. + # Observed links train/evaluate these coefficients; runtime does not look + # up individual node pairs. + training_positive_count: 296 + training_background_weight: 0.02 + training_background_target_snr: -22.0 + ridge_lambda: 50.0 + snr_min_db: -35.0 + snr_max_db: 8.25 + coefficients: + intercept: -4.718165 + raw_snr_clip: 0.279435 + log_distance_km: -4.684251 + log_distance_km_sq: -2.241399 + terrain_loss_db: -0.666327 + clutter_loss_db: -0.141584 + terrain_high_vantage_loss_db: 0.307599 + clutter_urban_loss_db: 0.409982 + max_ground_elevation_100m: 2.159190 + min_ground_elevation_100m: 4.241387 + ground_delta_100m: 2.213846 + high_vantage: 3.197868 + urban_fraction: 2.944392 + open_fraction: -2.944392 + water_fraction: 0.0 + forest_fraction: 0.0 + endpoint_urban_count: -1.642182 +calibration_observations: + # Directed observed-neighbor links from the aggregate calibration window. + # Node indexes are local to this sanitized preset; source IDs and names are intentionally omitted. + - {from: 0, to: 6, snr: -4} + - {from: 0, to: 10, snr: -19.75} + - {from: 0, to: 13, snr: -3.5} + - {from: 0, to: 15, snr: -0.75} + - {from: 0, to: 21, snr: -4.75} + - {from: 0, to: 26, snr: -14.25} + - {from: 0, to: 27, snr: -17.25} + - {from: 0, to: 30, snr: -5.25} + - {from: 1, to: 6, snr: -8} + - {from: 1, to: 8, snr: -12.5} + - {from: 1, to: 10, snr: -10.75} + - {from: 1, to: 13, snr: -14.5} + - {from: 1, to: 15, snr: 5} + - {from: 1, to: 21, snr: -5} + - {from: 1, to: 26, snr: -17.75} + - {from: 1, to: 27, snr: -19} + - {from: 1, to: 30, snr: -18.75} + - {from: 2, to: 6, snr: -4.5} + - {from: 2, to: 15, snr: -14.5} + - {from: 2, to: 21, snr: -19.5} + - {from: 2, to: 26, snr: -18.75} + - {from: 2, to: 27, snr: 5} + - {from: 3, to: 6, snr: -4.5} + - {from: 3, to: 10, snr: -18.25} + - {from: 3, to: 13, snr: -19} + - {from: 3, to: 21, snr: -4.5} + - {from: 3, to: 24, snr: 6.25} + - {from: 3, to: 26, snr: -4.75} + - {from: 3, to: 27, snr: 2.25} + - {from: 3, to: 30, snr: -0.25} + - {from: 4, to: 10, snr: -18.75} + - {from: 4, to: 13, snr: -0.5} + - {from: 4, to: 15, snr: -9.75} + - {from: 4, to: 21, snr: -5.5} + - {from: 4, to: 26, snr: -13} + - {from: 5, to: 6, snr: -4} + - {from: 5, to: 10, snr: -18.25} + - {from: 5, to: 13, snr: -19.25} + - {from: 5, to: 15, snr: -11.75} + - {from: 5, to: 21, snr: -5} + - {from: 5, to: 26, snr: -0.75} + - {from: 5, to: 27, snr: -13} + - {from: 5, to: 30, snr: -19.5} + - {from: 6, to: 10, snr: -17.5} + - {from: 6, to: 13, snr: -9.75} + - {from: 6, to: 15, snr: -5.75} + - {from: 6, to: 21, snr: -4.25} + - {from: 6, to: 26, snr: -12.75} + - {from: 6, to: 27, snr: -1.75} + - {from: 6, to: 30, snr: -16} + - {from: 8, to: 6, snr: -3.75} + - {from: 8, to: 10, snr: 6} + - {from: 8, to: 13, snr: -14.75} + - {from: 8, to: 15, snr: -17.5} + - {from: 8, to: 21, snr: -3.75} + - {from: 8, to: 24, snr: -3.5} + - {from: 8, to: 26, snr: -19} + - {from: 8, to: 27, snr: -18} + - {from: 8, to: 30, snr: -9.5} + - {from: 9, to: 6, snr: -13} + - {from: 9, to: 10, snr: -19} + - {from: 9, to: 13, snr: -17.5} + - {from: 9, to: 21, snr: -4.5} + - {from: 9, to: 26, snr: -1.25} + - {from: 9, to: 27, snr: -18.75} + - {from: 10, to: 6, snr: -3.5} + - {from: 10, to: 13, snr: -14} + - {from: 10, to: 15, snr: -17.25} + - {from: 10, to: 21, snr: -11} + - {from: 10, to: 26, snr: -16.5} + - {from: 10, to: 27, snr: -11.5} + - {from: 10, to: 30, snr: -16.25} + - {from: 11, to: 6, snr: -17.25} + - {from: 11, to: 10, snr: -17.5} + - {from: 11, to: 13, snr: -18.5} + - {from: 11, to: 15, snr: -14.25} + - {from: 11, to: 21, snr: -4.25} + - {from: 11, to: 24, snr: 6.5} + - {from: 11, to: 26, snr: 0.75} + - {from: 11, to: 27, snr: 6.25} + - {from: 11, to: 30, snr: 6.25} + - {from: 12, to: 10, snr: -17.75} + - {from: 12, to: 13, snr: -18.75} + - {from: 12, to: 21, snr: -4} + - {from: 12, to: 24, snr: 0} + - {from: 12, to: 26, snr: -0.75} + - {from: 12, to: 27, snr: 1.25} + - {from: 12, to: 30, snr: 4} + - {from: 13, to: 6, snr: -4} + - {from: 13, to: 8, snr: -10.75} + - {from: 13, to: 10, snr: -6} + - {from: 13, to: 15, snr: 0} + - {from: 13, to: 21, snr: -13.5} + - {from: 13, to: 26, snr: 6} + - {from: 13, to: 27, snr: -9} + - {from: 13, to: 30, snr: 5.75} + - {from: 14, to: 6, snr: 2.75} + - {from: 14, to: 10, snr: -4.5} + - {from: 14, to: 13, snr: -7.25} + - {from: 14, to: 15, snr: -7} + - {from: 14, to: 21, snr: -17.75} + - {from: 14, to: 26, snr: -14.75} + - {from: 14, to: 27, snr: -19} + - {from: 14, to: 30, snr: -19.25} + - {from: 15, to: 6, snr: -11.75} + - {from: 15, to: 10, snr: -14.75} + - {from: 15, to: 13, snr: 0} + - {from: 15, to: 21, snr: -7.75} + - {from: 15, to: 26, snr: 5.5} + - {from: 15, to: 27, snr: -6} + - {from: 15, to: 30, snr: 5.5} + - {from: 16, to: 6, snr: -14.75} + - {from: 16, to: 10, snr: -17.75} + - {from: 16, to: 13, snr: -18.75} + - {from: 16, to: 21, snr: -4.75} + - {from: 16, to: 27, snr: -19.5} + - {from: 17, to: 13, snr: 5} + - {from: 17, to: 15, snr: 1.25} + - {from: 17, to: 21, snr: -4.25} + - {from: 17, to: 26, snr: -1.25} + - {from: 17, to: 27, snr: -17.5} + - {from: 17, to: 30, snr: -6.75} + - {from: 18, to: 6, snr: -14.75} + - {from: 18, to: 8, snr: 5.5} + - {from: 18, to: 10, snr: 3.75} + - {from: 18, to: 13, snr: -19} + - {from: 18, to: 15, snr: -18.5} + - {from: 18, to: 21, snr: -8.75} + - {from: 18, to: 24, snr: 0} + - {from: 18, to: 26, snr: -17.75} + - {from: 18, to: 27, snr: -15} + - {from: 18, to: 30, snr: -13.75} + - {from: 19, to: 6, snr: -16.25} + - {from: 19, to: 10, snr: -16.5} + - {from: 19, to: 13, snr: -18.25} + - {from: 19, to: 15, snr: -18.25} + - {from: 19, to: 21, snr: -3.75} + - {from: 19, to: 26, snr: -19} + - {from: 19, to: 27, snr: -18.25} + - {from: 20, to: 13, snr: -8.5} + - {from: 20, to: 15, snr: -4.25} + - {from: 20, to: 21, snr: -9.5} + - {from: 20, to: 26, snr: -8} + - {from: 20, to: 27, snr: -19.25} + - {from: 21, to: 6, snr: -5.75} + - {from: 21, to: 10, snr: -17.75} + - {from: 21, to: 13, snr: -16.5} + - {from: 21, to: 26, snr: -18.75} + - {from: 21, to: 27, snr: -18.5} + - {from: 21, to: 30, snr: -11.75} + - {from: 22, to: 6, snr: -18.75} + - {from: 22, to: 10, snr: -17.25} + - {from: 22, to: 13, snr: -19} + - {from: 22, to: 21, snr: -4.75} + - {from: 22, to: 26, snr: -0.25} + - {from: 22, to: 27, snr: -6} + - {from: 22, to: 30, snr: 0.25} + - {from: 23, to: 10, snr: -17.25} + - {from: 23, to: 13, snr: -6.5} + - {from: 23, to: 15, snr: -3.75} + - {from: 23, to: 21, snr: -20} + - {from: 23, to: 26, snr: -13.5} + - {from: 23, to: 27, snr: -17.75} + - {from: 23, to: 30, snr: -11.5} + - {from: 24, to: 10, snr: -17.75} + - {from: 24, to: 13, snr: -19.75} + - {from: 24, to: 21, snr: -3.75} + - {from: 24, to: 26, snr: -4.75} + - {from: 24, to: 27, snr: -12.5} + - {from: 25, to: 10, snr: -11.25} + - {from: 25, to: 13, snr: -18.25} + - {from: 25, to: 15, snr: -13} + - {from: 25, to: 21, snr: -3} + - {from: 25, to: 26, snr: -15} + - {from: 26, to: 6, snr: -13.75} + - {from: 26, to: 10, snr: -16.5} + - {from: 26, to: 13, snr: 6} + - {from: 26, to: 15, snr: 5.5} + - {from: 26, to: 21, snr: -10.5} + - {from: 26, to: 27, snr: 0.75} + - {from: 26, to: 30, snr: 6.75} + - {from: 27, to: 6, snr: -16.5} + - {from: 27, to: 10, snr: -18.5} + - {from: 27, to: 13, snr: -6.25} + - {from: 27, to: 15, snr: -6.25} + - {from: 27, to: 21, snr: -6.25} + - {from: 27, to: 26, snr: -13.5} + - {from: 27, to: 30, snr: 5.5} + - {from: 28, to: 6, snr: -18.25} + - {from: 28, to: 10, snr: -16.5} + - {from: 28, to: 13, snr: -3.25} + - {from: 28, to: 15, snr: -9} + - {from: 28, to: 21, snr: -6} + - {from: 28, to: 26, snr: -9} + - {from: 28, to: 27, snr: -18.25} + - {from: 28, to: 30, snr: -3.75} + - {from: 29, to: 21, snr: -6.25} + - {from: 30, to: 6, snr: -6.5} + - {from: 30, to: 10, snr: -7.25} + - {from: 30, to: 13, snr: 5.75} + - {from: 30, to: 15, snr: 5.25} + - {from: 30, to: 21, snr: -3.75} + - {from: 30, to: 26, snr: 6} + - {from: 30, to: 27, snr: 3} + - {from: 32, to: 6, snr: -9} + - {from: 32, to: 8, snr: -5.25} + - {from: 32, to: 10, snr: -15.75} + - {from: 32, to: 13, snr: 1.75} + - {from: 32, to: 15, snr: 4} + - {from: 32, to: 21, snr: -5} + - {from: 32, to: 26, snr: -7} + - {from: 32, to: 27, snr: -18.5} + - {from: 32, to: 30, snr: -3.75} + - {from: 33, to: 6, snr: -19} + - {from: 33, to: 13, snr: 6.5} + - {from: 33, to: 15, snr: 5.25} + - {from: 33, to: 21, snr: -4} + - {from: 33, to: 26, snr: 6.25} + - {from: 33, to: 30, snr: 6} + - {from: 34, to: 6, snr: -19} + - {from: 34, to: 10, snr: 6.25} + - {from: 34, to: 13, snr: -16.25} + - {from: 34, to: 15, snr: -13.75} + - {from: 34, to: 21, snr: -3.5} + - {from: 34, to: 26, snr: -15.5} + - {from: 34, to: 30, snr: 1.5} + - {from: 35, to: 6, snr: -13.5} + - {from: 35, to: 10, snr: -16.75} + - {from: 35, to: 13, snr: -10.5} + - {from: 35, to: 15, snr: -9.25} + - {from: 35, to: 21, snr: -18.75} + - {from: 35, to: 26, snr: -5} + - {from: 35, to: 27, snr: -17.25} + - {from: 37, to: 10, snr: -17.25} + - {from: 37, to: 13, snr: -18.75} + - {from: 37, to: 15, snr: -0.75} + - {from: 37, to: 21, snr: -13.25} + - {from: 37, to: 26, snr: -19.5} + - {from: 37, to: 27, snr: -3} + - {from: 37, to: 30, snr: -14} + - {from: 39, to: 6, snr: -11.25} + - {from: 39, to: 10, snr: -19} + - {from: 39, to: 13, snr: -10.25} + - {from: 39, to: 15, snr: -12.75} + - {from: 39, to: 21, snr: -2.5} + - {from: 39, to: 24, snr: 6.25} + - {from: 39, to: 26, snr: -17} + - {from: 39, to: 27, snr: -12} + - {from: 39, to: 30, snr: -13.5} + - {from: 40, to: 13, snr: -1.75} + - {from: 40, to: 15, snr: -7} + - {from: 40, to: 26, snr: -14} + - {from: 40, to: 27, snr: -19.75} + - {from: 41, to: 6, snr: -3} + - {from: 41, to: 10, snr: -15.75} + - {from: 41, to: 13, snr: -19.5} + - {from: 41, to: 21, snr: -6} + - {from: 41, to: 27, snr: -16.5} + - {from: 42, to: 6, snr: -13.75} + - {from: 42, to: 10, snr: -18.25} + - {from: 42, to: 13, snr: -19} + - {from: 42, to: 15, snr: -18.5} + - {from: 42, to: 21, snr: -5.5} + - {from: 42, to: 27, snr: -8.25} + - {from: 43, to: 6, snr: -6.25} + - {from: 43, to: 10, snr: -15.75} + - {from: 43, to: 21, snr: -3} + - {from: 44, to: 6, snr: -16.25} + - {from: 44, to: 10, snr: -18.25} + - {from: 44, to: 13, snr: -17.5} + - {from: 44, to: 21, snr: -4.5} + - {from: 44, to: 24, snr: 6} + - {from: 44, to: 27, snr: -19} + - {from: 46, to: 10, snr: -16.75} + - {from: 46, to: 13, snr: -17.25} + - {from: 46, to: 15, snr: -17.5} + - {from: 46, to: 21, snr: -6.25} + - {from: 46, to: 27, snr: -17.75} + - {from: 47, to: 6, snr: -8} + - {from: 47, to: 10, snr: -18.25} + - {from: 47, to: 15, snr: -3.5} + - {from: 47, to: 21, snr: -3.25} + - {from: 47, to: 27, snr: -19.5} + - {from: 48, to: 10, snr: -17.5} + - {from: 49, to: 6, snr: -0.5} + - {from: 49, to: 10, snr: -13} + - {from: 49, to: 13, snr: -13.5} + - {from: 49, to: 15, snr: -17} + - {from: 63, to: 8, snr: -18.25} + - {from: 65, to: 8, snr: -11.5} + - {from: 81, to: 8, snr: -20.75} + - {from: 91, to: 6, snr: -17} + - {from: 91, to: 10, snr: -19} + - {from: 91, to: 13, snr: -19.25} + - {from: 91, to: 21, snr: -5.5} + - {from: 91, to: 26, snr: -3.75} +nodes: + 0: + x: 1633.7 + y: -1030.45 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 1: + x: 5612.18 + y: 2150.74 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 2: + x: 9498.02 + y: 3446.72 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 3: + x: -2112.63 + y: -2753.34 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 4: + x: 3267.4 + y: 1155.73 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 5: + x: -1089.13 + y: -4674.09 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 6: + x: -2620.73 + y: -8818.73 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 7: + x: 3267.4 + y: 1155.73 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 8: + x: -220.2 + y: -818.16 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 9: + x: -1089.13 + y: -4674.09 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 10: + x: -204.21 + y: -757.18 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 11: + x: -2051.65 + y: -2564.13 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 12: + x: -2050.45 + y: -2559.18 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: true + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 13: + x: -1089.13 + y: 1155.73 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: true + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 14: + x: 3267.4 + y: -4674.09 + z: 1.5 + isRouter: true + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 15: + x: -1089.13 + y: 1155.73 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 16: + x: -1089.13 + y: -4674.09 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 17: + x: 892.43 + y: -20.89 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 18: + x: -915.7 + y: -1484.22 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 19: + x: 1633.7 + y: 427.0 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: true + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 20: + x: 1633.7 + y: -1030.45 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: true + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 21: + x: 15213.83 + y: 1565.64 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 22: + x: -1713.25 + y: -2224.22 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 23: + x: -1089.13 + y: 1155.73 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 24: + x: -2054.16 + y: -2555.89 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 25: + x: -1089.13 + y: 1155.73 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 26: + x: 714.74 + y: -5539.45 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 27: + x: -1905.98 + y: -2852.27 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 28: + x: 0.0 + y: -301.73 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 29: + x: -1089.13 + y: -4674.09 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 30: + x: 6602.87 + y: 1429.0 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: true + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 31: + x: 0.0 + y: -3216.64 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 32: + x: -1089.13 + y: 1155.73 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 33: + x: 1050.59 + y: -291.33 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: true + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 34: + x: -241.67 + y: -813.71 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 35: + x: 3267.4 + y: 1155.73 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: true + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 36: + x: -1089.13 + y: 1155.73 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 37: + x: -1089.13 + y: 1155.73 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 38: + x: 1633.7 + y: 427.0 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 39: + x: -4392.75 + y: -10883.31 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 40: + x: -1986.78 + y: -2554.83 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 41: + x: -1089.13 + y: -4674.09 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 42: + x: -1089.13 + y: -4674.09 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: true + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 43: + x: -786.08 + y: -1484.92 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 44: + x: -1089.13 + y: -4674.09 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 45: + x: -1089.13 + y: 1155.73 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 46: + x: 2178.27 + y: -301.73 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: true + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 47: + x: 3267.4 + y: 1155.73 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 48: + x: -786.08 + y: -1484.92 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 49: + x: 272.28 + y: -666.09 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 50: + x: -1089.13 + y: 1155.73 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 51: + x: -4390.57 + y: -10913.82 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 52: + x: 16337.0 + y: 6985.54 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 53: + x: 7623.93 + y: 6985.54 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 54: + x: 3267.4 + y: 1155.73 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 55: + x: 3267.4 + y: 1155.73 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 56: + x: 2722.83 + y: 427.0 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 57: + x: -914.93 + y: -1487.99 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 58: + x: -915.65 + y: -1486.92 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 59: + x: 2230.19 + y: 0.0 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 60: + x: -1089.13 + y: 1155.73 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 61: + x: 1153.56 + y: -355.72 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 62: + x: 3267.4 + y: 1155.73 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 63: + x: 1157.2 + y: -210.64 + z: 1.5 + isRouter: true + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 64: + x: 1157.78 + y: 217.29 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 65: + x: 194.44 + y: -40.91 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 66: + x: -1089.13 + y: 1155.73 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 67: + x: 3267.4 + y: 1155.73 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 68: + x: 3267.4 + y: 1155.73 + z: 1.5 + isRouter: true + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 69: + x: 3267.4 + y: 1155.73 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 70: + x: -3267.4 + y: 4070.63 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 71: + x: 5445.67 + y: 4070.63 + z: 1.5 + isRouter: true + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 72: + x: 674.06 + y: -611.67 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 73: + x: -914.38 + y: -1488.64 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: true + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 74: + x: -1089.13 + y: 1155.73 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 75: + x: -1089.13 + y: -4674.09 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 76: + x: -1089.13 + y: 1155.73 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 77: + x: -1089.13 + y: 1155.73 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 78: + x: 7623.93 + y: 1155.73 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 79: + x: 11980.46 + y: 12815.36 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 80: + x: 952.99 + y: -483.91 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 81: + x: 1921.48 + y: 534.98 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 82: + x: 3267.4 + y: 1155.73 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 83: + x: 15222.87 + y: 1519.16 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 84: + x: 10346.76 + y: 12086.63 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 85: + x: 10891.33 + y: 11357.91 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 86: + x: 3267.4 + y: 1155.73 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 87: + x: 3267.4 + y: 1155.73 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 88: + x: -276.63 + y: -797.33 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 89: + x: -1089.13 + y: -4674.09 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 90: + x: -1089.13 + y: -4674.09 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: false + hopLimit: 3 + antennaGain: 0 + neighborInfo: false + 91: + x: 357.6 + y: 243.8 + z: 1.5 + isRouter: false + isRepeater: false + isClientMute: true + hopLimit: 3 + antennaGain: 0 + neighborInfo: false diff --git a/presets/batumi_clutter.csv b/presets/batumi_clutter.csv new file mode 100644 index 00000000..eeab0e51 --- /dev/null +++ b/presets/batumi_clutter.csv @@ -0,0 +1,4321 @@ +x_m,y_m,lat,lon,clutter_class +-9500.0,-16000.0,41.5003964,41.501032,open +-9500.0,-15500.0,41.5048931,41.501032,open +-9500.0,-15000.0,41.5093897,41.501032,open +-9500.0,-14500.0,41.5138863,41.501032,open +-9500.0,-14000.0,41.5183829,41.501032,open +-9500.0,-13500.0,41.5228795,41.501032,open +-9500.0,-13000.0,41.5273761,41.501032,open +-9500.0,-12500.0,41.5318727,41.501032,open +-9500.0,-12000.0,41.5363693,41.501032,open +-9500.0,-11500.0,41.5408659,41.501032,open +-9500.0,-11000.0,41.5453625,41.501032,open +-9500.0,-10500.0,41.5498591,41.501032,open +-9500.0,-10000.0,41.5543557,41.501032,open +-9500.0,-9500.0,41.5588523,41.501032,open +-9500.0,-9000.0,41.563349,41.501032,open +-9500.0,-8500.0,41.5678456,41.501032,open +-9500.0,-8000.0,41.5723422,41.501032,open +-9500.0,-7500.0,41.5768388,41.501032,open +-9500.0,-7000.0,41.5813354,41.501032,open +-9500.0,-6500.0,41.585832,41.501032,open +-9500.0,-6000.0,41.5903286,41.501032,open +-9500.0,-5500.0,41.5948252,41.501032,open +-9500.0,-5000.0,41.5993218,41.501032,open +-9500.0,-4500.0,41.6038184,41.501032,open +-9500.0,-4000.0,41.608315,41.501032,open +-9500.0,-3500.0,41.6128116,41.501032,open +-9500.0,-3000.0,41.6173083,41.501032,open +-9500.0,-2500.0,41.6218049,41.501032,open +-9500.0,-2000.0,41.6263015,41.501032,open +-9500.0,-1500.0,41.6307981,41.501032,open +-9500.0,-1000.0,41.6352947,41.501032,open +-9500.0,-500.0,41.6397913,41.501032,open +-9500.0,0.0,41.6442879,41.501032,open +-9500.0,500.0,41.6487845,41.501032,open +-9500.0,1000.0,41.6532811,41.501032,open +-9500.0,1500.0,41.6577777,41.501032,open +-9500.0,2000.0,41.6622743,41.501032,open +-9500.0,2500.0,41.6667709,41.501032,open +-9500.0,3000.0,41.6712675,41.501032,open +-9500.0,3500.0,41.6757642,41.501032,open +-9500.0,4000.0,41.6802608,41.501032,open +-9500.0,4500.0,41.6847574,41.501032,open +-9500.0,5000.0,41.689254,41.501032,open +-9500.0,5500.0,41.6937506,41.501032,open +-9500.0,6000.0,41.6982472,41.501032,open +-9500.0,6500.0,41.7027438,41.501032,open +-9500.0,7000.0,41.7072404,41.501032,open +-9500.0,7500.0,41.711737,41.501032,open +-9500.0,8000.0,41.7162336,41.501032,open +-9500.0,8500.0,41.7207302,41.501032,open +-9500.0,9000.0,41.7252268,41.501032,open +-9500.0,9500.0,41.7297235,41.501032,open +-9500.0,10000.0,41.7342201,41.501032,open +-9500.0,10500.0,41.7387167,41.501032,open +-9500.0,11000.0,41.7432133,41.501032,open +-9500.0,11500.0,41.7477099,41.501032,open +-9500.0,12000.0,41.7522065,41.501032,open +-9500.0,12500.0,41.7567031,41.501032,open +-9500.0,13000.0,41.7611997,41.501032,open +-9500.0,13500.0,41.7656963,41.501032,open +-9500.0,14000.0,41.7701929,41.501032,open +-9500.0,14500.0,41.7746895,41.501032,open +-9500.0,15000.0,41.7791861,41.501032,open +-9500.0,15500.0,41.7836827,41.501032,open +-9500.0,16000.0,41.7881794,41.501032,open +-9500.0,16500.0,41.792676,41.501032,open +-9500.0,17000.0,41.7971726,41.501032,open +-9500.0,17500.0,41.8016692,41.501032,open +-9500.0,18000.0,41.8061658,41.501032,open +-9500.0,18500.0,41.8106624,41.501032,open +-9500.0,19000.0,41.815159,41.501032,open +-9500.0,19500.0,41.8196556,41.501032,open +-9000.0,-16000.0,41.5003964,41.5070493,open +-9000.0,-15500.0,41.5048931,41.5070493,open +-9000.0,-15000.0,41.5093897,41.5070493,open +-9000.0,-14500.0,41.5138863,41.5070493,open +-9000.0,-14000.0,41.5183829,41.5070493,open +-9000.0,-13500.0,41.5228795,41.5070493,open +-9000.0,-13000.0,41.5273761,41.5070493,open +-9000.0,-12500.0,41.5318727,41.5070493,open +-9000.0,-12000.0,41.5363693,41.5070493,open +-9000.0,-11500.0,41.5408659,41.5070493,open +-9000.0,-11000.0,41.5453625,41.5070493,open +-9000.0,-10500.0,41.5498591,41.5070493,open +-9000.0,-10000.0,41.5543557,41.5070493,open +-9000.0,-9500.0,41.5588523,41.5070493,open +-9000.0,-9000.0,41.563349,41.5070493,open +-9000.0,-8500.0,41.5678456,41.5070493,open +-9000.0,-8000.0,41.5723422,41.5070493,open +-9000.0,-7500.0,41.5768388,41.5070493,open +-9000.0,-7000.0,41.5813354,41.5070493,open +-9000.0,-6500.0,41.585832,41.5070493,open +-9000.0,-6000.0,41.5903286,41.5070493,open +-9000.0,-5500.0,41.5948252,41.5070493,open +-9000.0,-5000.0,41.5993218,41.5070493,open +-9000.0,-4500.0,41.6038184,41.5070493,open +-9000.0,-4000.0,41.608315,41.5070493,open +-9000.0,-3500.0,41.6128116,41.5070493,open +-9000.0,-3000.0,41.6173083,41.5070493,open +-9000.0,-2500.0,41.6218049,41.5070493,open +-9000.0,-2000.0,41.6263015,41.5070493,open +-9000.0,-1500.0,41.6307981,41.5070493,open +-9000.0,-1000.0,41.6352947,41.5070493,open +-9000.0,-500.0,41.6397913,41.5070493,open +-9000.0,0.0,41.6442879,41.5070493,open +-9000.0,500.0,41.6487845,41.5070493,open +-9000.0,1000.0,41.6532811,41.5070493,open +-9000.0,1500.0,41.6577777,41.5070493,open +-9000.0,2000.0,41.6622743,41.5070493,open +-9000.0,2500.0,41.6667709,41.5070493,open +-9000.0,3000.0,41.6712675,41.5070493,open +-9000.0,3500.0,41.6757642,41.5070493,open +-9000.0,4000.0,41.6802608,41.5070493,open +-9000.0,4500.0,41.6847574,41.5070493,open +-9000.0,5000.0,41.689254,41.5070493,open +-9000.0,5500.0,41.6937506,41.5070493,open +-9000.0,6000.0,41.6982472,41.5070493,open +-9000.0,6500.0,41.7027438,41.5070493,open +-9000.0,7000.0,41.7072404,41.5070493,open +-9000.0,7500.0,41.711737,41.5070493,open +-9000.0,8000.0,41.7162336,41.5070493,open +-9000.0,8500.0,41.7207302,41.5070493,open +-9000.0,9000.0,41.7252268,41.5070493,open +-9000.0,9500.0,41.7297235,41.5070493,open +-9000.0,10000.0,41.7342201,41.5070493,open +-9000.0,10500.0,41.7387167,41.5070493,open +-9000.0,11000.0,41.7432133,41.5070493,open +-9000.0,11500.0,41.7477099,41.5070493,open +-9000.0,12000.0,41.7522065,41.5070493,open +-9000.0,12500.0,41.7567031,41.5070493,open +-9000.0,13000.0,41.7611997,41.5070493,open +-9000.0,13500.0,41.7656963,41.5070493,open +-9000.0,14000.0,41.7701929,41.5070493,open +-9000.0,14500.0,41.7746895,41.5070493,open +-9000.0,15000.0,41.7791861,41.5070493,open +-9000.0,15500.0,41.7836827,41.5070493,open +-9000.0,16000.0,41.7881794,41.5070493,open +-9000.0,16500.0,41.792676,41.5070493,open +-9000.0,17000.0,41.7971726,41.5070493,open +-9000.0,17500.0,41.8016692,41.5070493,open +-9000.0,18000.0,41.8061658,41.5070493,open +-9000.0,18500.0,41.8106624,41.5070493,open +-9000.0,19000.0,41.815159,41.5070493,open +-9000.0,19500.0,41.8196556,41.5070493,open +-8500.0,-16000.0,41.5003964,41.5130665,open +-8500.0,-15500.0,41.5048931,41.5130665,open +-8500.0,-15000.0,41.5093897,41.5130665,open +-8500.0,-14500.0,41.5138863,41.5130665,open +-8500.0,-14000.0,41.5183829,41.5130665,open +-8500.0,-13500.0,41.5228795,41.5130665,open +-8500.0,-13000.0,41.5273761,41.5130665,open +-8500.0,-12500.0,41.5318727,41.5130665,open +-8500.0,-12000.0,41.5363693,41.5130665,open +-8500.0,-11500.0,41.5408659,41.5130665,open +-8500.0,-11000.0,41.5453625,41.5130665,open +-8500.0,-10500.0,41.5498591,41.5130665,open +-8500.0,-10000.0,41.5543557,41.5130665,open +-8500.0,-9500.0,41.5588523,41.5130665,open +-8500.0,-9000.0,41.563349,41.5130665,open +-8500.0,-8500.0,41.5678456,41.5130665,open +-8500.0,-8000.0,41.5723422,41.5130665,open +-8500.0,-7500.0,41.5768388,41.5130665,open +-8500.0,-7000.0,41.5813354,41.5130665,open +-8500.0,-6500.0,41.585832,41.5130665,open +-8500.0,-6000.0,41.5903286,41.5130665,open +-8500.0,-5500.0,41.5948252,41.5130665,open +-8500.0,-5000.0,41.5993218,41.5130665,open +-8500.0,-4500.0,41.6038184,41.5130665,open +-8500.0,-4000.0,41.608315,41.5130665,open +-8500.0,-3500.0,41.6128116,41.5130665,open +-8500.0,-3000.0,41.6173083,41.5130665,open +-8500.0,-2500.0,41.6218049,41.5130665,open +-8500.0,-2000.0,41.6263015,41.5130665,open +-8500.0,-1500.0,41.6307981,41.5130665,open +-8500.0,-1000.0,41.6352947,41.5130665,open +-8500.0,-500.0,41.6397913,41.5130665,open +-8500.0,0.0,41.6442879,41.5130665,open +-8500.0,500.0,41.6487845,41.5130665,open +-8500.0,1000.0,41.6532811,41.5130665,open +-8500.0,1500.0,41.6577777,41.5130665,open +-8500.0,2000.0,41.6622743,41.5130665,open +-8500.0,2500.0,41.6667709,41.5130665,open +-8500.0,3000.0,41.6712675,41.5130665,open +-8500.0,3500.0,41.6757642,41.5130665,open +-8500.0,4000.0,41.6802608,41.5130665,open +-8500.0,4500.0,41.6847574,41.5130665,open +-8500.0,5000.0,41.689254,41.5130665,open +-8500.0,5500.0,41.6937506,41.5130665,open +-8500.0,6000.0,41.6982472,41.5130665,open +-8500.0,6500.0,41.7027438,41.5130665,open +-8500.0,7000.0,41.7072404,41.5130665,open +-8500.0,7500.0,41.711737,41.5130665,open +-8500.0,8000.0,41.7162336,41.5130665,open +-8500.0,8500.0,41.7207302,41.5130665,open +-8500.0,9000.0,41.7252268,41.5130665,open +-8500.0,9500.0,41.7297235,41.5130665,open +-8500.0,10000.0,41.7342201,41.5130665,open +-8500.0,10500.0,41.7387167,41.5130665,open +-8500.0,11000.0,41.7432133,41.5130665,open +-8500.0,11500.0,41.7477099,41.5130665,open +-8500.0,12000.0,41.7522065,41.5130665,open +-8500.0,12500.0,41.7567031,41.5130665,open +-8500.0,13000.0,41.7611997,41.5130665,open +-8500.0,13500.0,41.7656963,41.5130665,open +-8500.0,14000.0,41.7701929,41.5130665,open +-8500.0,14500.0,41.7746895,41.5130665,open +-8500.0,15000.0,41.7791861,41.5130665,open +-8500.0,15500.0,41.7836827,41.5130665,open +-8500.0,16000.0,41.7881794,41.5130665,open +-8500.0,16500.0,41.792676,41.5130665,open +-8500.0,17000.0,41.7971726,41.5130665,open +-8500.0,17500.0,41.8016692,41.5130665,open +-8500.0,18000.0,41.8061658,41.5130665,open +-8500.0,18500.0,41.8106624,41.5130665,open +-8500.0,19000.0,41.815159,41.5130665,open +-8500.0,19500.0,41.8196556,41.5130665,open +-8000.0,-16000.0,41.5003964,41.5190838,open +-8000.0,-15500.0,41.5048931,41.5190838,open +-8000.0,-15000.0,41.5093897,41.5190838,open +-8000.0,-14500.0,41.5138863,41.5190838,open +-8000.0,-14000.0,41.5183829,41.5190838,open +-8000.0,-13500.0,41.5228795,41.5190838,open +-8000.0,-13000.0,41.5273761,41.5190838,open +-8000.0,-12500.0,41.5318727,41.5190838,open +-8000.0,-12000.0,41.5363693,41.5190838,open +-8000.0,-11500.0,41.5408659,41.5190838,open +-8000.0,-11000.0,41.5453625,41.5190838,open +-8000.0,-10500.0,41.5498591,41.5190838,open +-8000.0,-10000.0,41.5543557,41.5190838,open +-8000.0,-9500.0,41.5588523,41.5190838,open +-8000.0,-9000.0,41.563349,41.5190838,open +-8000.0,-8500.0,41.5678456,41.5190838,open +-8000.0,-8000.0,41.5723422,41.5190838,open +-8000.0,-7500.0,41.5768388,41.5190838,open +-8000.0,-7000.0,41.5813354,41.5190838,open +-8000.0,-6500.0,41.585832,41.5190838,open +-8000.0,-6000.0,41.5903286,41.5190838,open +-8000.0,-5500.0,41.5948252,41.5190838,open +-8000.0,-5000.0,41.5993218,41.5190838,open +-8000.0,-4500.0,41.6038184,41.5190838,open +-8000.0,-4000.0,41.608315,41.5190838,open +-8000.0,-3500.0,41.6128116,41.5190838,open +-8000.0,-3000.0,41.6173083,41.5190838,open +-8000.0,-2500.0,41.6218049,41.5190838,open +-8000.0,-2000.0,41.6263015,41.5190838,open +-8000.0,-1500.0,41.6307981,41.5190838,open +-8000.0,-1000.0,41.6352947,41.5190838,open +-8000.0,-500.0,41.6397913,41.5190838,open +-8000.0,0.0,41.6442879,41.5190838,open +-8000.0,500.0,41.6487845,41.5190838,open +-8000.0,1000.0,41.6532811,41.5190838,open +-8000.0,1500.0,41.6577777,41.5190838,open +-8000.0,2000.0,41.6622743,41.5190838,open +-8000.0,2500.0,41.6667709,41.5190838,open +-8000.0,3000.0,41.6712675,41.5190838,open +-8000.0,3500.0,41.6757642,41.5190838,open +-8000.0,4000.0,41.6802608,41.5190838,open +-8000.0,4500.0,41.6847574,41.5190838,open +-8000.0,5000.0,41.689254,41.5190838,open +-8000.0,5500.0,41.6937506,41.5190838,open +-8000.0,6000.0,41.6982472,41.5190838,open +-8000.0,6500.0,41.7027438,41.5190838,open +-8000.0,7000.0,41.7072404,41.5190838,open +-8000.0,7500.0,41.711737,41.5190838,open +-8000.0,8000.0,41.7162336,41.5190838,open +-8000.0,8500.0,41.7207302,41.5190838,open +-8000.0,9000.0,41.7252268,41.5190838,open +-8000.0,9500.0,41.7297235,41.5190838,open +-8000.0,10000.0,41.7342201,41.5190838,open +-8000.0,10500.0,41.7387167,41.5190838,open +-8000.0,11000.0,41.7432133,41.5190838,open +-8000.0,11500.0,41.7477099,41.5190838,open +-8000.0,12000.0,41.7522065,41.5190838,open +-8000.0,12500.0,41.7567031,41.5190838,open +-8000.0,13000.0,41.7611997,41.5190838,open +-8000.0,13500.0,41.7656963,41.5190838,open +-8000.0,14000.0,41.7701929,41.5190838,open +-8000.0,14500.0,41.7746895,41.5190838,open +-8000.0,15000.0,41.7791861,41.5190838,open +-8000.0,15500.0,41.7836827,41.5190838,open +-8000.0,16000.0,41.7881794,41.5190838,open +-8000.0,16500.0,41.792676,41.5190838,open +-8000.0,17000.0,41.7971726,41.5190838,open +-8000.0,17500.0,41.8016692,41.5190838,open +-8000.0,18000.0,41.8061658,41.5190838,open +-8000.0,18500.0,41.8106624,41.5190838,open +-8000.0,19000.0,41.815159,41.5190838,open +-8000.0,19500.0,41.8196556,41.5190838,open +-7500.0,-16000.0,41.5003964,41.5251011,open +-7500.0,-15500.0,41.5048931,41.5251011,open +-7500.0,-15000.0,41.5093897,41.5251011,open +-7500.0,-14500.0,41.5138863,41.5251011,open +-7500.0,-14000.0,41.5183829,41.5251011,open +-7500.0,-13500.0,41.5228795,41.5251011,open +-7500.0,-13000.0,41.5273761,41.5251011,open +-7500.0,-12500.0,41.5318727,41.5251011,open +-7500.0,-12000.0,41.5363693,41.5251011,open +-7500.0,-11500.0,41.5408659,41.5251011,open +-7500.0,-11000.0,41.5453625,41.5251011,open +-7500.0,-10500.0,41.5498591,41.5251011,open +-7500.0,-10000.0,41.5543557,41.5251011,open +-7500.0,-9500.0,41.5588523,41.5251011,open +-7500.0,-9000.0,41.563349,41.5251011,open +-7500.0,-8500.0,41.5678456,41.5251011,open +-7500.0,-8000.0,41.5723422,41.5251011,open +-7500.0,-7500.0,41.5768388,41.5251011,open +-7500.0,-7000.0,41.5813354,41.5251011,open +-7500.0,-6500.0,41.585832,41.5251011,open +-7500.0,-6000.0,41.5903286,41.5251011,open +-7500.0,-5500.0,41.5948252,41.5251011,open +-7500.0,-5000.0,41.5993218,41.5251011,open +-7500.0,-4500.0,41.6038184,41.5251011,open +-7500.0,-4000.0,41.608315,41.5251011,open +-7500.0,-3500.0,41.6128116,41.5251011,open +-7500.0,-3000.0,41.6173083,41.5251011,open +-7500.0,-2500.0,41.6218049,41.5251011,open +-7500.0,-2000.0,41.6263015,41.5251011,open +-7500.0,-1500.0,41.6307981,41.5251011,open +-7500.0,-1000.0,41.6352947,41.5251011,open +-7500.0,-500.0,41.6397913,41.5251011,open +-7500.0,0.0,41.6442879,41.5251011,open +-7500.0,500.0,41.6487845,41.5251011,open +-7500.0,1000.0,41.6532811,41.5251011,open +-7500.0,1500.0,41.6577777,41.5251011,open +-7500.0,2000.0,41.6622743,41.5251011,open +-7500.0,2500.0,41.6667709,41.5251011,open +-7500.0,3000.0,41.6712675,41.5251011,open +-7500.0,3500.0,41.6757642,41.5251011,open +-7500.0,4000.0,41.6802608,41.5251011,open +-7500.0,4500.0,41.6847574,41.5251011,open +-7500.0,5000.0,41.689254,41.5251011,open +-7500.0,5500.0,41.6937506,41.5251011,open +-7500.0,6000.0,41.6982472,41.5251011,open +-7500.0,6500.0,41.7027438,41.5251011,open +-7500.0,7000.0,41.7072404,41.5251011,open +-7500.0,7500.0,41.711737,41.5251011,open +-7500.0,8000.0,41.7162336,41.5251011,open +-7500.0,8500.0,41.7207302,41.5251011,open +-7500.0,9000.0,41.7252268,41.5251011,open +-7500.0,9500.0,41.7297235,41.5251011,open +-7500.0,10000.0,41.7342201,41.5251011,open +-7500.0,10500.0,41.7387167,41.5251011,open +-7500.0,11000.0,41.7432133,41.5251011,open +-7500.0,11500.0,41.7477099,41.5251011,open +-7500.0,12000.0,41.7522065,41.5251011,open +-7500.0,12500.0,41.7567031,41.5251011,open +-7500.0,13000.0,41.7611997,41.5251011,open +-7500.0,13500.0,41.7656963,41.5251011,open +-7500.0,14000.0,41.7701929,41.5251011,open +-7500.0,14500.0,41.7746895,41.5251011,open +-7500.0,15000.0,41.7791861,41.5251011,open +-7500.0,15500.0,41.7836827,41.5251011,open +-7500.0,16000.0,41.7881794,41.5251011,open +-7500.0,16500.0,41.792676,41.5251011,open +-7500.0,17000.0,41.7971726,41.5251011,open +-7500.0,17500.0,41.8016692,41.5251011,open +-7500.0,18000.0,41.8061658,41.5251011,open +-7500.0,18500.0,41.8106624,41.5251011,open +-7500.0,19000.0,41.815159,41.5251011,open +-7500.0,19500.0,41.8196556,41.5251011,open +-7000.0,-16000.0,41.5003964,41.5311183,open +-7000.0,-15500.0,41.5048931,41.5311183,open +-7000.0,-15000.0,41.5093897,41.5311183,open +-7000.0,-14500.0,41.5138863,41.5311183,open +-7000.0,-14000.0,41.5183829,41.5311183,open +-7000.0,-13500.0,41.5228795,41.5311183,open +-7000.0,-13000.0,41.5273761,41.5311183,open +-7000.0,-12500.0,41.5318727,41.5311183,open +-7000.0,-12000.0,41.5363693,41.5311183,open +-7000.0,-11500.0,41.5408659,41.5311183,open +-7000.0,-11000.0,41.5453625,41.5311183,open +-7000.0,-10500.0,41.5498591,41.5311183,open +-7000.0,-10000.0,41.5543557,41.5311183,open +-7000.0,-9500.0,41.5588523,41.5311183,open +-7000.0,-9000.0,41.563349,41.5311183,open +-7000.0,-8500.0,41.5678456,41.5311183,open +-7000.0,-8000.0,41.5723422,41.5311183,open +-7000.0,-7500.0,41.5768388,41.5311183,open +-7000.0,-7000.0,41.5813354,41.5311183,open +-7000.0,-6500.0,41.585832,41.5311183,open +-7000.0,-6000.0,41.5903286,41.5311183,open +-7000.0,-5500.0,41.5948252,41.5311183,open +-7000.0,-5000.0,41.5993218,41.5311183,open +-7000.0,-4500.0,41.6038184,41.5311183,open +-7000.0,-4000.0,41.608315,41.5311183,open +-7000.0,-3500.0,41.6128116,41.5311183,open +-7000.0,-3000.0,41.6173083,41.5311183,open +-7000.0,-2500.0,41.6218049,41.5311183,open +-7000.0,-2000.0,41.6263015,41.5311183,open +-7000.0,-1500.0,41.6307981,41.5311183,open +-7000.0,-1000.0,41.6352947,41.5311183,open +-7000.0,-500.0,41.6397913,41.5311183,open +-7000.0,0.0,41.6442879,41.5311183,open +-7000.0,500.0,41.6487845,41.5311183,open +-7000.0,1000.0,41.6532811,41.5311183,open +-7000.0,1500.0,41.6577777,41.5311183,open +-7000.0,2000.0,41.6622743,41.5311183,open +-7000.0,2500.0,41.6667709,41.5311183,open +-7000.0,3000.0,41.6712675,41.5311183,open +-7000.0,3500.0,41.6757642,41.5311183,open +-7000.0,4000.0,41.6802608,41.5311183,open +-7000.0,4500.0,41.6847574,41.5311183,open +-7000.0,5000.0,41.689254,41.5311183,open +-7000.0,5500.0,41.6937506,41.5311183,open +-7000.0,6000.0,41.6982472,41.5311183,open +-7000.0,6500.0,41.7027438,41.5311183,open +-7000.0,7000.0,41.7072404,41.5311183,open +-7000.0,7500.0,41.711737,41.5311183,open +-7000.0,8000.0,41.7162336,41.5311183,open +-7000.0,8500.0,41.7207302,41.5311183,open +-7000.0,9000.0,41.7252268,41.5311183,open +-7000.0,9500.0,41.7297235,41.5311183,open +-7000.0,10000.0,41.7342201,41.5311183,open +-7000.0,10500.0,41.7387167,41.5311183,open +-7000.0,11000.0,41.7432133,41.5311183,open +-7000.0,11500.0,41.7477099,41.5311183,open +-7000.0,12000.0,41.7522065,41.5311183,open +-7000.0,12500.0,41.7567031,41.5311183,open +-7000.0,13000.0,41.7611997,41.5311183,open +-7000.0,13500.0,41.7656963,41.5311183,open +-7000.0,14000.0,41.7701929,41.5311183,open +-7000.0,14500.0,41.7746895,41.5311183,open +-7000.0,15000.0,41.7791861,41.5311183,open +-7000.0,15500.0,41.7836827,41.5311183,open +-7000.0,16000.0,41.7881794,41.5311183,open +-7000.0,16500.0,41.792676,41.5311183,open +-7000.0,17000.0,41.7971726,41.5311183,open +-7000.0,17500.0,41.8016692,41.5311183,open +-7000.0,18000.0,41.8061658,41.5311183,open +-7000.0,18500.0,41.8106624,41.5311183,open +-7000.0,19000.0,41.815159,41.5311183,open +-7000.0,19500.0,41.8196556,41.5311183,open +-6500.0,-16000.0,41.5003964,41.5371356,open +-6500.0,-15500.0,41.5048931,41.5371356,open +-6500.0,-15000.0,41.5093897,41.5371356,open +-6500.0,-14500.0,41.5138863,41.5371356,open +-6500.0,-14000.0,41.5183829,41.5371356,open +-6500.0,-13500.0,41.5228795,41.5371356,open +-6500.0,-13000.0,41.5273761,41.5371356,open +-6500.0,-12500.0,41.5318727,41.5371356,open +-6500.0,-12000.0,41.5363693,41.5371356,open +-6500.0,-11500.0,41.5408659,41.5371356,open +-6500.0,-11000.0,41.5453625,41.5371356,open +-6500.0,-10500.0,41.5498591,41.5371356,open +-6500.0,-10000.0,41.5543557,41.5371356,open +-6500.0,-9500.0,41.5588523,41.5371356,open +-6500.0,-9000.0,41.563349,41.5371356,open +-6500.0,-8500.0,41.5678456,41.5371356,open +-6500.0,-8000.0,41.5723422,41.5371356,open +-6500.0,-7500.0,41.5768388,41.5371356,open +-6500.0,-7000.0,41.5813354,41.5371356,open +-6500.0,-6500.0,41.585832,41.5371356,open +-6500.0,-6000.0,41.5903286,41.5371356,open +-6500.0,-5500.0,41.5948252,41.5371356,open +-6500.0,-5000.0,41.5993218,41.5371356,open +-6500.0,-4500.0,41.6038184,41.5371356,open +-6500.0,-4000.0,41.608315,41.5371356,open +-6500.0,-3500.0,41.6128116,41.5371356,open +-6500.0,-3000.0,41.6173083,41.5371356,open +-6500.0,-2500.0,41.6218049,41.5371356,open +-6500.0,-2000.0,41.6263015,41.5371356,open +-6500.0,-1500.0,41.6307981,41.5371356,open +-6500.0,-1000.0,41.6352947,41.5371356,open +-6500.0,-500.0,41.6397913,41.5371356,open +-6500.0,0.0,41.6442879,41.5371356,open +-6500.0,500.0,41.6487845,41.5371356,open +-6500.0,1000.0,41.6532811,41.5371356,open +-6500.0,1500.0,41.6577777,41.5371356,open +-6500.0,2000.0,41.6622743,41.5371356,open +-6500.0,2500.0,41.6667709,41.5371356,open +-6500.0,3000.0,41.6712675,41.5371356,open +-6500.0,3500.0,41.6757642,41.5371356,open +-6500.0,4000.0,41.6802608,41.5371356,open +-6500.0,4500.0,41.6847574,41.5371356,open +-6500.0,5000.0,41.689254,41.5371356,open +-6500.0,5500.0,41.6937506,41.5371356,open +-6500.0,6000.0,41.6982472,41.5371356,open +-6500.0,6500.0,41.7027438,41.5371356,open +-6500.0,7000.0,41.7072404,41.5371356,open +-6500.0,7500.0,41.711737,41.5371356,open +-6500.0,8000.0,41.7162336,41.5371356,open +-6500.0,8500.0,41.7207302,41.5371356,open +-6500.0,9000.0,41.7252268,41.5371356,open +-6500.0,9500.0,41.7297235,41.5371356,open +-6500.0,10000.0,41.7342201,41.5371356,open +-6500.0,10500.0,41.7387167,41.5371356,open +-6500.0,11000.0,41.7432133,41.5371356,open +-6500.0,11500.0,41.7477099,41.5371356,open +-6500.0,12000.0,41.7522065,41.5371356,open +-6500.0,12500.0,41.7567031,41.5371356,open +-6500.0,13000.0,41.7611997,41.5371356,open +-6500.0,13500.0,41.7656963,41.5371356,open +-6500.0,14000.0,41.7701929,41.5371356,open +-6500.0,14500.0,41.7746895,41.5371356,open +-6500.0,15000.0,41.7791861,41.5371356,open +-6500.0,15500.0,41.7836827,41.5371356,open +-6500.0,16000.0,41.7881794,41.5371356,open +-6500.0,16500.0,41.792676,41.5371356,open +-6500.0,17000.0,41.7971726,41.5371356,open +-6500.0,17500.0,41.8016692,41.5371356,open +-6500.0,18000.0,41.8061658,41.5371356,open +-6500.0,18500.0,41.8106624,41.5371356,open +-6500.0,19000.0,41.815159,41.5371356,open +-6500.0,19500.0,41.8196556,41.5371356,open +-6000.0,-16000.0,41.5003964,41.5431529,open +-6000.0,-15500.0,41.5048931,41.5431529,open +-6000.0,-15000.0,41.5093897,41.5431529,open +-6000.0,-14500.0,41.5138863,41.5431529,open +-6000.0,-14000.0,41.5183829,41.5431529,urban +-6000.0,-13500.0,41.5228795,41.5431529,open +-6000.0,-13000.0,41.5273761,41.5431529,open +-6000.0,-12500.0,41.5318727,41.5431529,open +-6000.0,-12000.0,41.5363693,41.5431529,open +-6000.0,-11500.0,41.5408659,41.5431529,open +-6000.0,-11000.0,41.5453625,41.5431529,open +-6000.0,-10500.0,41.5498591,41.5431529,open +-6000.0,-10000.0,41.5543557,41.5431529,open +-6000.0,-9500.0,41.5588523,41.5431529,open +-6000.0,-9000.0,41.563349,41.5431529,open +-6000.0,-8500.0,41.5678456,41.5431529,open +-6000.0,-8000.0,41.5723422,41.5431529,open +-6000.0,-7500.0,41.5768388,41.5431529,open +-6000.0,-7000.0,41.5813354,41.5431529,open +-6000.0,-6500.0,41.585832,41.5431529,open +-6000.0,-6000.0,41.5903286,41.5431529,open +-6000.0,-5500.0,41.5948252,41.5431529,open +-6000.0,-5000.0,41.5993218,41.5431529,open +-6000.0,-4500.0,41.6038184,41.5431529,open +-6000.0,-4000.0,41.608315,41.5431529,open +-6000.0,-3500.0,41.6128116,41.5431529,open +-6000.0,-3000.0,41.6173083,41.5431529,open +-6000.0,-2500.0,41.6218049,41.5431529,open +-6000.0,-2000.0,41.6263015,41.5431529,open +-6000.0,-1500.0,41.6307981,41.5431529,open +-6000.0,-1000.0,41.6352947,41.5431529,open +-6000.0,-500.0,41.6397913,41.5431529,open +-6000.0,0.0,41.6442879,41.5431529,open +-6000.0,500.0,41.6487845,41.5431529,open +-6000.0,1000.0,41.6532811,41.5431529,open +-6000.0,1500.0,41.6577777,41.5431529,open +-6000.0,2000.0,41.6622743,41.5431529,open +-6000.0,2500.0,41.6667709,41.5431529,open +-6000.0,3000.0,41.6712675,41.5431529,open +-6000.0,3500.0,41.6757642,41.5431529,open +-6000.0,4000.0,41.6802608,41.5431529,open +-6000.0,4500.0,41.6847574,41.5431529,open +-6000.0,5000.0,41.689254,41.5431529,open +-6000.0,5500.0,41.6937506,41.5431529,open +-6000.0,6000.0,41.6982472,41.5431529,open +-6000.0,6500.0,41.7027438,41.5431529,open +-6000.0,7000.0,41.7072404,41.5431529,open +-6000.0,7500.0,41.711737,41.5431529,open +-6000.0,8000.0,41.7162336,41.5431529,open +-6000.0,8500.0,41.7207302,41.5431529,open +-6000.0,9000.0,41.7252268,41.5431529,open +-6000.0,9500.0,41.7297235,41.5431529,open +-6000.0,10000.0,41.7342201,41.5431529,open +-6000.0,10500.0,41.7387167,41.5431529,open +-6000.0,11000.0,41.7432133,41.5431529,open +-6000.0,11500.0,41.7477099,41.5431529,open +-6000.0,12000.0,41.7522065,41.5431529,open +-6000.0,12500.0,41.7567031,41.5431529,open +-6000.0,13000.0,41.7611997,41.5431529,open +-6000.0,13500.0,41.7656963,41.5431529,open +-6000.0,14000.0,41.7701929,41.5431529,open +-6000.0,14500.0,41.7746895,41.5431529,open +-6000.0,15000.0,41.7791861,41.5431529,open +-6000.0,15500.0,41.7836827,41.5431529,open +-6000.0,16000.0,41.7881794,41.5431529,open +-6000.0,16500.0,41.792676,41.5431529,open +-6000.0,17000.0,41.7971726,41.5431529,open +-6000.0,17500.0,41.8016692,41.5431529,open +-6000.0,18000.0,41.8061658,41.5431529,open +-6000.0,18500.0,41.8106624,41.5431529,open +-6000.0,19000.0,41.815159,41.5431529,open +-6000.0,19500.0,41.8196556,41.5431529,open +-5500.0,-16000.0,41.5003964,41.5491701,open +-5500.0,-15500.0,41.5048931,41.5491701,urban +-5500.0,-15000.0,41.5093897,41.5491701,urban +-5500.0,-14500.0,41.5138863,41.5491701,urban +-5500.0,-14000.0,41.5183829,41.5491701,urban +-5500.0,-13500.0,41.5228795,41.5491701,urban +-5500.0,-13000.0,41.5273761,41.5491701,urban +-5500.0,-12500.0,41.5318727,41.5491701,urban +-5500.0,-12000.0,41.5363693,41.5491701,open +-5500.0,-11500.0,41.5408659,41.5491701,open +-5500.0,-11000.0,41.5453625,41.5491701,open +-5500.0,-10500.0,41.5498591,41.5491701,open +-5500.0,-10000.0,41.5543557,41.5491701,open +-5500.0,-9500.0,41.5588523,41.5491701,open +-5500.0,-9000.0,41.563349,41.5491701,open +-5500.0,-8500.0,41.5678456,41.5491701,open +-5500.0,-8000.0,41.5723422,41.5491701,open +-5500.0,-7500.0,41.5768388,41.5491701,open +-5500.0,-7000.0,41.5813354,41.5491701,open +-5500.0,-6500.0,41.585832,41.5491701,open +-5500.0,-6000.0,41.5903286,41.5491701,open +-5500.0,-5500.0,41.5948252,41.5491701,open +-5500.0,-5000.0,41.5993218,41.5491701,open +-5500.0,-4500.0,41.6038184,41.5491701,open +-5500.0,-4000.0,41.608315,41.5491701,open +-5500.0,-3500.0,41.6128116,41.5491701,open +-5500.0,-3000.0,41.6173083,41.5491701,open +-5500.0,-2500.0,41.6218049,41.5491701,open +-5500.0,-2000.0,41.6263015,41.5491701,open +-5500.0,-1500.0,41.6307981,41.5491701,open +-5500.0,-1000.0,41.6352947,41.5491701,open +-5500.0,-500.0,41.6397913,41.5491701,open +-5500.0,0.0,41.6442879,41.5491701,open +-5500.0,500.0,41.6487845,41.5491701,open +-5500.0,1000.0,41.6532811,41.5491701,open +-5500.0,1500.0,41.6577777,41.5491701,open +-5500.0,2000.0,41.6622743,41.5491701,open +-5500.0,2500.0,41.6667709,41.5491701,open +-5500.0,3000.0,41.6712675,41.5491701,open +-5500.0,3500.0,41.6757642,41.5491701,open +-5500.0,4000.0,41.6802608,41.5491701,open +-5500.0,4500.0,41.6847574,41.5491701,open +-5500.0,5000.0,41.689254,41.5491701,open +-5500.0,5500.0,41.6937506,41.5491701,open +-5500.0,6000.0,41.6982472,41.5491701,open +-5500.0,6500.0,41.7027438,41.5491701,open +-5500.0,7000.0,41.7072404,41.5491701,open +-5500.0,7500.0,41.711737,41.5491701,open +-5500.0,8000.0,41.7162336,41.5491701,open +-5500.0,8500.0,41.7207302,41.5491701,open +-5500.0,9000.0,41.7252268,41.5491701,open +-5500.0,9500.0,41.7297235,41.5491701,open +-5500.0,10000.0,41.7342201,41.5491701,open +-5500.0,10500.0,41.7387167,41.5491701,open +-5500.0,11000.0,41.7432133,41.5491701,open +-5500.0,11500.0,41.7477099,41.5491701,open +-5500.0,12000.0,41.7522065,41.5491701,open +-5500.0,12500.0,41.7567031,41.5491701,open +-5500.0,13000.0,41.7611997,41.5491701,open +-5500.0,13500.0,41.7656963,41.5491701,open +-5500.0,14000.0,41.7701929,41.5491701,open +-5500.0,14500.0,41.7746895,41.5491701,open +-5500.0,15000.0,41.7791861,41.5491701,open +-5500.0,15500.0,41.7836827,41.5491701,open +-5500.0,16000.0,41.7881794,41.5491701,open +-5500.0,16500.0,41.792676,41.5491701,open +-5500.0,17000.0,41.7971726,41.5491701,open +-5500.0,17500.0,41.8016692,41.5491701,open +-5500.0,18000.0,41.8061658,41.5491701,open +-5500.0,18500.0,41.8106624,41.5491701,open +-5500.0,19000.0,41.815159,41.5491701,open +-5500.0,19500.0,41.8196556,41.5491701,open +-5000.0,-16000.0,41.5003964,41.5551874,open +-5000.0,-15500.0,41.5048931,41.5551874,open +-5000.0,-15000.0,41.5093897,41.5551874,urban +-5000.0,-14500.0,41.5138863,41.5551874,urban +-5000.0,-14000.0,41.5183829,41.5551874,urban +-5000.0,-13500.0,41.5228795,41.5551874,urban +-5000.0,-13000.0,41.5273761,41.5551874,urban +-5000.0,-12500.0,41.5318727,41.5551874,urban +-5000.0,-12000.0,41.5363693,41.5551874,urban +-5000.0,-11500.0,41.5408659,41.5551874,urban +-5000.0,-11000.0,41.5453625,41.5551874,open +-5000.0,-10500.0,41.5498591,41.5551874,open +-5000.0,-10000.0,41.5543557,41.5551874,open +-5000.0,-9500.0,41.5588523,41.5551874,open +-5000.0,-9000.0,41.563349,41.5551874,open +-5000.0,-8500.0,41.5678456,41.5551874,open +-5000.0,-8000.0,41.5723422,41.5551874,open +-5000.0,-7500.0,41.5768388,41.5551874,open +-5000.0,-7000.0,41.5813354,41.5551874,open +-5000.0,-6500.0,41.585832,41.5551874,open +-5000.0,-6000.0,41.5903286,41.5551874,open +-5000.0,-5500.0,41.5948252,41.5551874,open +-5000.0,-5000.0,41.5993218,41.5551874,open +-5000.0,-4500.0,41.6038184,41.5551874,open +-5000.0,-4000.0,41.608315,41.5551874,open +-5000.0,-3500.0,41.6128116,41.5551874,open +-5000.0,-3000.0,41.6173083,41.5551874,open +-5000.0,-2500.0,41.6218049,41.5551874,open +-5000.0,-2000.0,41.6263015,41.5551874,open +-5000.0,-1500.0,41.6307981,41.5551874,open +-5000.0,-1000.0,41.6352947,41.5551874,open +-5000.0,-500.0,41.6397913,41.5551874,open +-5000.0,0.0,41.6442879,41.5551874,open +-5000.0,500.0,41.6487845,41.5551874,open +-5000.0,1000.0,41.6532811,41.5551874,open +-5000.0,1500.0,41.6577777,41.5551874,open +-5000.0,2000.0,41.6622743,41.5551874,open +-5000.0,2500.0,41.6667709,41.5551874,open +-5000.0,3000.0,41.6712675,41.5551874,open +-5000.0,3500.0,41.6757642,41.5551874,open +-5000.0,4000.0,41.6802608,41.5551874,open +-5000.0,4500.0,41.6847574,41.5551874,open +-5000.0,5000.0,41.689254,41.5551874,open +-5000.0,5500.0,41.6937506,41.5551874,open +-5000.0,6000.0,41.6982472,41.5551874,open +-5000.0,6500.0,41.7027438,41.5551874,open +-5000.0,7000.0,41.7072404,41.5551874,open +-5000.0,7500.0,41.711737,41.5551874,open +-5000.0,8000.0,41.7162336,41.5551874,open +-5000.0,8500.0,41.7207302,41.5551874,open +-5000.0,9000.0,41.7252268,41.5551874,open +-5000.0,9500.0,41.7297235,41.5551874,open +-5000.0,10000.0,41.7342201,41.5551874,open +-5000.0,10500.0,41.7387167,41.5551874,open +-5000.0,11000.0,41.7432133,41.5551874,open +-5000.0,11500.0,41.7477099,41.5551874,open +-5000.0,12000.0,41.7522065,41.5551874,open +-5000.0,12500.0,41.7567031,41.5551874,open +-5000.0,13000.0,41.7611997,41.5551874,open +-5000.0,13500.0,41.7656963,41.5551874,open +-5000.0,14000.0,41.7701929,41.5551874,open +-5000.0,14500.0,41.7746895,41.5551874,open +-5000.0,15000.0,41.7791861,41.5551874,open +-5000.0,15500.0,41.7836827,41.5551874,open +-5000.0,16000.0,41.7881794,41.5551874,open +-5000.0,16500.0,41.792676,41.5551874,open +-5000.0,17000.0,41.7971726,41.5551874,open +-5000.0,17500.0,41.8016692,41.5551874,open +-5000.0,18000.0,41.8061658,41.5551874,open +-5000.0,18500.0,41.8106624,41.5551874,open +-5000.0,19000.0,41.815159,41.5551874,open +-5000.0,19500.0,41.8196556,41.5551874,open +-4500.0,-16000.0,41.5003964,41.5612046,open +-4500.0,-15500.0,41.5048931,41.5612046,open +-4500.0,-15000.0,41.5093897,41.5612046,urban +-4500.0,-14500.0,41.5138863,41.5612046,urban +-4500.0,-14000.0,41.5183829,41.5612046,urban +-4500.0,-13500.0,41.5228795,41.5612046,open +-4500.0,-13000.0,41.5273761,41.5612046,open +-4500.0,-12500.0,41.5318727,41.5612046,urban +-4500.0,-12000.0,41.5363693,41.5612046,urban +-4500.0,-11500.0,41.5408659,41.5612046,urban +-4500.0,-11000.0,41.5453625,41.5612046,urban +-4500.0,-10500.0,41.5498591,41.5612046,urban +-4500.0,-10000.0,41.5543557,41.5612046,urban +-4500.0,-9500.0,41.5588523,41.5612046,open +-4500.0,-9000.0,41.563349,41.5612046,open +-4500.0,-8500.0,41.5678456,41.5612046,open +-4500.0,-8000.0,41.5723422,41.5612046,open +-4500.0,-7500.0,41.5768388,41.5612046,open +-4500.0,-7000.0,41.5813354,41.5612046,open +-4500.0,-6500.0,41.585832,41.5612046,open +-4500.0,-6000.0,41.5903286,41.5612046,open +-4500.0,-5500.0,41.5948252,41.5612046,open +-4500.0,-5000.0,41.5993218,41.5612046,open +-4500.0,-4500.0,41.6038184,41.5612046,open +-4500.0,-4000.0,41.608315,41.5612046,open +-4500.0,-3500.0,41.6128116,41.5612046,open +-4500.0,-3000.0,41.6173083,41.5612046,open +-4500.0,-2500.0,41.6218049,41.5612046,open +-4500.0,-2000.0,41.6263015,41.5612046,open +-4500.0,-1500.0,41.6307981,41.5612046,open +-4500.0,-1000.0,41.6352947,41.5612046,open +-4500.0,-500.0,41.6397913,41.5612046,open +-4500.0,0.0,41.6442879,41.5612046,open +-4500.0,500.0,41.6487845,41.5612046,open +-4500.0,1000.0,41.6532811,41.5612046,open +-4500.0,1500.0,41.6577777,41.5612046,open +-4500.0,2000.0,41.6622743,41.5612046,open +-4500.0,2500.0,41.6667709,41.5612046,open +-4500.0,3000.0,41.6712675,41.5612046,open +-4500.0,3500.0,41.6757642,41.5612046,open +-4500.0,4000.0,41.6802608,41.5612046,open +-4500.0,4500.0,41.6847574,41.5612046,open +-4500.0,5000.0,41.689254,41.5612046,open +-4500.0,5500.0,41.6937506,41.5612046,open +-4500.0,6000.0,41.6982472,41.5612046,open +-4500.0,6500.0,41.7027438,41.5612046,open +-4500.0,7000.0,41.7072404,41.5612046,open +-4500.0,7500.0,41.711737,41.5612046,open +-4500.0,8000.0,41.7162336,41.5612046,open +-4500.0,8500.0,41.7207302,41.5612046,open +-4500.0,9000.0,41.7252268,41.5612046,open +-4500.0,9500.0,41.7297235,41.5612046,open +-4500.0,10000.0,41.7342201,41.5612046,open +-4500.0,10500.0,41.7387167,41.5612046,open +-4500.0,11000.0,41.7432133,41.5612046,open +-4500.0,11500.0,41.7477099,41.5612046,open +-4500.0,12000.0,41.7522065,41.5612046,open +-4500.0,12500.0,41.7567031,41.5612046,open +-4500.0,13000.0,41.7611997,41.5612046,open +-4500.0,13500.0,41.7656963,41.5612046,open +-4500.0,14000.0,41.7701929,41.5612046,open +-4500.0,14500.0,41.7746895,41.5612046,open +-4500.0,15000.0,41.7791861,41.5612046,open +-4500.0,15500.0,41.7836827,41.5612046,open +-4500.0,16000.0,41.7881794,41.5612046,open +-4500.0,16500.0,41.792676,41.5612046,open +-4500.0,17000.0,41.7971726,41.5612046,open +-4500.0,17500.0,41.8016692,41.5612046,open +-4500.0,18000.0,41.8061658,41.5612046,open +-4500.0,18500.0,41.8106624,41.5612046,open +-4500.0,19000.0,41.815159,41.5612046,open +-4500.0,19500.0,41.8196556,41.5612046,open +-4000.0,-16000.0,41.5003964,41.5672219,urban +-4000.0,-15500.0,41.5048931,41.5672219,open +-4000.0,-15000.0,41.5093897,41.5672219,open +-4000.0,-14500.0,41.5138863,41.5672219,open +-4000.0,-14000.0,41.5183829,41.5672219,open +-4000.0,-13500.0,41.5228795,41.5672219,open +-4000.0,-13000.0,41.5273761,41.5672219,open +-4000.0,-12500.0,41.5318727,41.5672219,open +-4000.0,-12000.0,41.5363693,41.5672219,open +-4000.0,-11500.0,41.5408659,41.5672219,urban +-4000.0,-11000.0,41.5453625,41.5672219,urban +-4000.0,-10500.0,41.5498591,41.5672219,urban +-4000.0,-10000.0,41.5543557,41.5672219,urban +-4000.0,-9500.0,41.5588523,41.5672219,urban +-4000.0,-9000.0,41.563349,41.5672219,urban +-4000.0,-8500.0,41.5678456,41.5672219,urban +-4000.0,-8000.0,41.5723422,41.5672219,urban +-4000.0,-7500.0,41.5768388,41.5672219,urban +-4000.0,-7000.0,41.5813354,41.5672219,open +-4000.0,-6500.0,41.585832,41.5672219,open +-4000.0,-6000.0,41.5903286,41.5672219,open +-4000.0,-5500.0,41.5948252,41.5672219,open +-4000.0,-5000.0,41.5993218,41.5672219,open +-4000.0,-4500.0,41.6038184,41.5672219,open +-4000.0,-4000.0,41.608315,41.5672219,open +-4000.0,-3500.0,41.6128116,41.5672219,open +-4000.0,-3000.0,41.6173083,41.5672219,open +-4000.0,-2500.0,41.6218049,41.5672219,open +-4000.0,-2000.0,41.6263015,41.5672219,open +-4000.0,-1500.0,41.6307981,41.5672219,open +-4000.0,-1000.0,41.6352947,41.5672219,open +-4000.0,-500.0,41.6397913,41.5672219,open +-4000.0,0.0,41.6442879,41.5672219,open +-4000.0,500.0,41.6487845,41.5672219,open +-4000.0,1000.0,41.6532811,41.5672219,open +-4000.0,1500.0,41.6577777,41.5672219,open +-4000.0,2000.0,41.6622743,41.5672219,open +-4000.0,2500.0,41.6667709,41.5672219,open +-4000.0,3000.0,41.6712675,41.5672219,open +-4000.0,3500.0,41.6757642,41.5672219,open +-4000.0,4000.0,41.6802608,41.5672219,open +-4000.0,4500.0,41.6847574,41.5672219,open +-4000.0,5000.0,41.689254,41.5672219,open +-4000.0,5500.0,41.6937506,41.5672219,open +-4000.0,6000.0,41.6982472,41.5672219,open +-4000.0,6500.0,41.7027438,41.5672219,open +-4000.0,7000.0,41.7072404,41.5672219,open +-4000.0,7500.0,41.711737,41.5672219,open +-4000.0,8000.0,41.7162336,41.5672219,open +-4000.0,8500.0,41.7207302,41.5672219,open +-4000.0,9000.0,41.7252268,41.5672219,open +-4000.0,9500.0,41.7297235,41.5672219,open +-4000.0,10000.0,41.7342201,41.5672219,open +-4000.0,10500.0,41.7387167,41.5672219,open +-4000.0,11000.0,41.7432133,41.5672219,open +-4000.0,11500.0,41.7477099,41.5672219,open +-4000.0,12000.0,41.7522065,41.5672219,open +-4000.0,12500.0,41.7567031,41.5672219,open +-4000.0,13000.0,41.7611997,41.5672219,open +-4000.0,13500.0,41.7656963,41.5672219,open +-4000.0,14000.0,41.7701929,41.5672219,open +-4000.0,14500.0,41.7746895,41.5672219,open +-4000.0,15000.0,41.7791861,41.5672219,open +-4000.0,15500.0,41.7836827,41.5672219,open +-4000.0,16000.0,41.7881794,41.5672219,open +-4000.0,16500.0,41.792676,41.5672219,open +-4000.0,17000.0,41.7971726,41.5672219,open +-4000.0,17500.0,41.8016692,41.5672219,open +-4000.0,18000.0,41.8061658,41.5672219,open +-4000.0,18500.0,41.8106624,41.5672219,open +-4000.0,19000.0,41.815159,41.5672219,open +-4000.0,19500.0,41.8196556,41.5672219,open +-3500.0,-16000.0,41.5003964,41.5732392,open +-3500.0,-15500.0,41.5048931,41.5732392,open +-3500.0,-15000.0,41.5093897,41.5732392,open +-3500.0,-14500.0,41.5138863,41.5732392,open +-3500.0,-14000.0,41.5183829,41.5732392,urban +-3500.0,-13500.0,41.5228795,41.5732392,urban +-3500.0,-13000.0,41.5273761,41.5732392,urban +-3500.0,-12500.0,41.5318727,41.5732392,open +-3500.0,-12000.0,41.5363693,41.5732392,open +-3500.0,-11500.0,41.5408659,41.5732392,open +-3500.0,-11000.0,41.5453625,41.5732392,open +-3500.0,-10500.0,41.5498591,41.5732392,open +-3500.0,-10000.0,41.5543557,41.5732392,urban +-3500.0,-9500.0,41.5588523,41.5732392,urban +-3500.0,-9000.0,41.563349,41.5732392,urban +-3500.0,-8500.0,41.5678456,41.5732392,urban +-3500.0,-8000.0,41.5723422,41.5732392,urban +-3500.0,-7500.0,41.5768388,41.5732392,urban +-3500.0,-7000.0,41.5813354,41.5732392,urban +-3500.0,-6500.0,41.585832,41.5732392,open +-3500.0,-6000.0,41.5903286,41.5732392,open +-3500.0,-5500.0,41.5948252,41.5732392,open +-3500.0,-5000.0,41.5993218,41.5732392,open +-3500.0,-4500.0,41.6038184,41.5732392,open +-3500.0,-4000.0,41.608315,41.5732392,open +-3500.0,-3500.0,41.6128116,41.5732392,open +-3500.0,-3000.0,41.6173083,41.5732392,open +-3500.0,-2500.0,41.6218049,41.5732392,open +-3500.0,-2000.0,41.6263015,41.5732392,open +-3500.0,-1500.0,41.6307981,41.5732392,open +-3500.0,-1000.0,41.6352947,41.5732392,open +-3500.0,-500.0,41.6397913,41.5732392,open +-3500.0,0.0,41.6442879,41.5732392,open +-3500.0,500.0,41.6487845,41.5732392,open +-3500.0,1000.0,41.6532811,41.5732392,open +-3500.0,1500.0,41.6577777,41.5732392,open +-3500.0,2000.0,41.6622743,41.5732392,open +-3500.0,2500.0,41.6667709,41.5732392,open +-3500.0,3000.0,41.6712675,41.5732392,open +-3500.0,3500.0,41.6757642,41.5732392,open +-3500.0,4000.0,41.6802608,41.5732392,open +-3500.0,4500.0,41.6847574,41.5732392,open +-3500.0,5000.0,41.689254,41.5732392,open +-3500.0,5500.0,41.6937506,41.5732392,open +-3500.0,6000.0,41.6982472,41.5732392,open +-3500.0,6500.0,41.7027438,41.5732392,open +-3500.0,7000.0,41.7072404,41.5732392,open +-3500.0,7500.0,41.711737,41.5732392,open +-3500.0,8000.0,41.7162336,41.5732392,open +-3500.0,8500.0,41.7207302,41.5732392,open +-3500.0,9000.0,41.7252268,41.5732392,open +-3500.0,9500.0,41.7297235,41.5732392,open +-3500.0,10000.0,41.7342201,41.5732392,open +-3500.0,10500.0,41.7387167,41.5732392,open +-3500.0,11000.0,41.7432133,41.5732392,open +-3500.0,11500.0,41.7477099,41.5732392,open +-3500.0,12000.0,41.7522065,41.5732392,open +-3500.0,12500.0,41.7567031,41.5732392,open +-3500.0,13000.0,41.7611997,41.5732392,open +-3500.0,13500.0,41.7656963,41.5732392,open +-3500.0,14000.0,41.7701929,41.5732392,open +-3500.0,14500.0,41.7746895,41.5732392,open +-3500.0,15000.0,41.7791861,41.5732392,open +-3500.0,15500.0,41.7836827,41.5732392,open +-3500.0,16000.0,41.7881794,41.5732392,open +-3500.0,16500.0,41.792676,41.5732392,open +-3500.0,17000.0,41.7971726,41.5732392,open +-3500.0,17500.0,41.8016692,41.5732392,open +-3500.0,18000.0,41.8061658,41.5732392,open +-3500.0,18500.0,41.8106624,41.5732392,open +-3500.0,19000.0,41.815159,41.5732392,open +-3500.0,19500.0,41.8196556,41.5732392,open +-3000.0,-16000.0,41.5003964,41.5792564,open +-3000.0,-15500.0,41.5048931,41.5792564,open +-3000.0,-15000.0,41.5093897,41.5792564,open +-3000.0,-14500.0,41.5138863,41.5792564,open +-3000.0,-14000.0,41.5183829,41.5792564,open +-3000.0,-13500.0,41.5228795,41.5792564,open +-3000.0,-13000.0,41.5273761,41.5792564,open +-3000.0,-12500.0,41.5318727,41.5792564,open +-3000.0,-12000.0,41.5363693,41.5792564,open +-3000.0,-11500.0,41.5408659,41.5792564,open +-3000.0,-11000.0,41.5453625,41.5792564,open +-3000.0,-10500.0,41.5498591,41.5792564,urban +-3000.0,-10000.0,41.5543557,41.5792564,urban +-3000.0,-9500.0,41.5588523,41.5792564,urban +-3000.0,-9000.0,41.563349,41.5792564,urban +-3000.0,-8500.0,41.5678456,41.5792564,urban +-3000.0,-8000.0,41.5723422,41.5792564,urban +-3000.0,-7500.0,41.5768388,41.5792564,urban +-3000.0,-7000.0,41.5813354,41.5792564,urban +-3000.0,-6500.0,41.585832,41.5792564,open +-3000.0,-6000.0,41.5903286,41.5792564,urban +-3000.0,-5500.0,41.5948252,41.5792564,open +-3000.0,-5000.0,41.5993218,41.5792564,open +-3000.0,-4500.0,41.6038184,41.5792564,urban +-3000.0,-4000.0,41.608315,41.5792564,open +-3000.0,-3500.0,41.6128116,41.5792564,open +-3000.0,-3000.0,41.6173083,41.5792564,open +-3000.0,-2500.0,41.6218049,41.5792564,open +-3000.0,-2000.0,41.6263015,41.5792564,open +-3000.0,-1500.0,41.6307981,41.5792564,open +-3000.0,-1000.0,41.6352947,41.5792564,open +-3000.0,-500.0,41.6397913,41.5792564,open +-3000.0,0.0,41.6442879,41.5792564,open +-3000.0,500.0,41.6487845,41.5792564,open +-3000.0,1000.0,41.6532811,41.5792564,open +-3000.0,1500.0,41.6577777,41.5792564,open +-3000.0,2000.0,41.6622743,41.5792564,open +-3000.0,2500.0,41.6667709,41.5792564,open +-3000.0,3000.0,41.6712675,41.5792564,open +-3000.0,3500.0,41.6757642,41.5792564,open +-3000.0,4000.0,41.6802608,41.5792564,open +-3000.0,4500.0,41.6847574,41.5792564,open +-3000.0,5000.0,41.689254,41.5792564,open +-3000.0,5500.0,41.6937506,41.5792564,open +-3000.0,6000.0,41.6982472,41.5792564,open +-3000.0,6500.0,41.7027438,41.5792564,open +-3000.0,7000.0,41.7072404,41.5792564,open +-3000.0,7500.0,41.711737,41.5792564,open +-3000.0,8000.0,41.7162336,41.5792564,open +-3000.0,8500.0,41.7207302,41.5792564,open +-3000.0,9000.0,41.7252268,41.5792564,open +-3000.0,9500.0,41.7297235,41.5792564,open +-3000.0,10000.0,41.7342201,41.5792564,open +-3000.0,10500.0,41.7387167,41.5792564,open +-3000.0,11000.0,41.7432133,41.5792564,open +-3000.0,11500.0,41.7477099,41.5792564,open +-3000.0,12000.0,41.7522065,41.5792564,open +-3000.0,12500.0,41.7567031,41.5792564,open +-3000.0,13000.0,41.7611997,41.5792564,open +-3000.0,13500.0,41.7656963,41.5792564,open +-3000.0,14000.0,41.7701929,41.5792564,open +-3000.0,14500.0,41.7746895,41.5792564,open +-3000.0,15000.0,41.7791861,41.5792564,open +-3000.0,15500.0,41.7836827,41.5792564,open +-3000.0,16000.0,41.7881794,41.5792564,open +-3000.0,16500.0,41.792676,41.5792564,open +-3000.0,17000.0,41.7971726,41.5792564,open +-3000.0,17500.0,41.8016692,41.5792564,open +-3000.0,18000.0,41.8061658,41.5792564,open +-3000.0,18500.0,41.8106624,41.5792564,open +-3000.0,19000.0,41.815159,41.5792564,open +-3000.0,19500.0,41.8196556,41.5792564,open +-2500.0,-16000.0,41.5003964,41.5852737,open +-2500.0,-15500.0,41.5048931,41.5852737,open +-2500.0,-15000.0,41.5093897,41.5852737,open +-2500.0,-14500.0,41.5138863,41.5852737,open +-2500.0,-14000.0,41.5183829,41.5852737,open +-2500.0,-13500.0,41.5228795,41.5852737,open +-2500.0,-13000.0,41.5273761,41.5852737,open +-2500.0,-12500.0,41.5318727,41.5852737,open +-2500.0,-12000.0,41.5363693,41.5852737,open +-2500.0,-11500.0,41.5408659,41.5852737,open +-2500.0,-11000.0,41.5453625,41.5852737,open +-2500.0,-10500.0,41.5498591,41.5852737,urban +-2500.0,-10000.0,41.5543557,41.5852737,urban +-2500.0,-9500.0,41.5588523,41.5852737,open +-2500.0,-9000.0,41.563349,41.5852737,urban +-2500.0,-8500.0,41.5678456,41.5852737,urban +-2500.0,-8000.0,41.5723422,41.5852737,urban +-2500.0,-7500.0,41.5768388,41.5852737,urban +-2500.0,-7000.0,41.5813354,41.5852737,urban +-2500.0,-6500.0,41.585832,41.5852737,urban +-2500.0,-6000.0,41.5903286,41.5852737,urban +-2500.0,-5500.0,41.5948252,41.5852737,open +-2500.0,-5000.0,41.5993218,41.5852737,urban +-2500.0,-4500.0,41.6038184,41.5852737,urban +-2500.0,-4000.0,41.608315,41.5852737,urban +-2500.0,-3500.0,41.6128116,41.5852737,urban +-2500.0,-3000.0,41.6173083,41.5852737,urban +-2500.0,-2500.0,41.6218049,41.5852737,open +-2500.0,-2000.0,41.6263015,41.5852737,open +-2500.0,-1500.0,41.6307981,41.5852737,open +-2500.0,-1000.0,41.6352947,41.5852737,open +-2500.0,-500.0,41.6397913,41.5852737,open +-2500.0,0.0,41.6442879,41.5852737,open +-2500.0,500.0,41.6487845,41.5852737,open +-2500.0,1000.0,41.6532811,41.5852737,open +-2500.0,1500.0,41.6577777,41.5852737,open +-2500.0,2000.0,41.6622743,41.5852737,open +-2500.0,2500.0,41.6667709,41.5852737,open +-2500.0,3000.0,41.6712675,41.5852737,open +-2500.0,3500.0,41.6757642,41.5852737,open +-2500.0,4000.0,41.6802608,41.5852737,open +-2500.0,4500.0,41.6847574,41.5852737,open +-2500.0,5000.0,41.689254,41.5852737,open +-2500.0,5500.0,41.6937506,41.5852737,open +-2500.0,6000.0,41.6982472,41.5852737,open +-2500.0,6500.0,41.7027438,41.5852737,open +-2500.0,7000.0,41.7072404,41.5852737,open +-2500.0,7500.0,41.711737,41.5852737,open +-2500.0,8000.0,41.7162336,41.5852737,open +-2500.0,8500.0,41.7207302,41.5852737,open +-2500.0,9000.0,41.7252268,41.5852737,open +-2500.0,9500.0,41.7297235,41.5852737,open +-2500.0,10000.0,41.7342201,41.5852737,open +-2500.0,10500.0,41.7387167,41.5852737,open +-2500.0,11000.0,41.7432133,41.5852737,open +-2500.0,11500.0,41.7477099,41.5852737,open +-2500.0,12000.0,41.7522065,41.5852737,open +-2500.0,12500.0,41.7567031,41.5852737,open +-2500.0,13000.0,41.7611997,41.5852737,open +-2500.0,13500.0,41.7656963,41.5852737,open +-2500.0,14000.0,41.7701929,41.5852737,open +-2500.0,14500.0,41.7746895,41.5852737,open +-2500.0,15000.0,41.7791861,41.5852737,open +-2500.0,15500.0,41.7836827,41.5852737,open +-2500.0,16000.0,41.7881794,41.5852737,open +-2500.0,16500.0,41.792676,41.5852737,open +-2500.0,17000.0,41.7971726,41.5852737,open +-2500.0,17500.0,41.8016692,41.5852737,open +-2500.0,18000.0,41.8061658,41.5852737,open +-2500.0,18500.0,41.8106624,41.5852737,open +-2500.0,19000.0,41.815159,41.5852737,open +-2500.0,19500.0,41.8196556,41.5852737,open +-2000.0,-16000.0,41.5003964,41.591291,open +-2000.0,-15500.0,41.5048931,41.591291,open +-2000.0,-15000.0,41.5093897,41.591291,open +-2000.0,-14500.0,41.5138863,41.591291,open +-2000.0,-14000.0,41.5183829,41.591291,open +-2000.0,-13500.0,41.5228795,41.591291,open +-2000.0,-13000.0,41.5273761,41.591291,open +-2000.0,-12500.0,41.5318727,41.591291,open +-2000.0,-12000.0,41.5363693,41.591291,open +-2000.0,-11500.0,41.5408659,41.591291,open +-2000.0,-11000.0,41.5453625,41.591291,open +-2000.0,-10500.0,41.5498591,41.591291,open +-2000.0,-10000.0,41.5543557,41.591291,urban +-2000.0,-9500.0,41.5588523,41.591291,urban +-2000.0,-9000.0,41.563349,41.591291,urban +-2000.0,-8500.0,41.5678456,41.591291,urban +-2000.0,-8000.0,41.5723422,41.591291,urban +-2000.0,-7500.0,41.5768388,41.591291,urban +-2000.0,-7000.0,41.5813354,41.591291,urban +-2000.0,-6500.0,41.585832,41.591291,urban +-2000.0,-6000.0,41.5903286,41.591291,urban +-2000.0,-5500.0,41.5948252,41.591291,open +-2000.0,-5000.0,41.5993218,41.591291,open +-2000.0,-4500.0,41.6038184,41.591291,urban +-2000.0,-4000.0,41.608315,41.591291,open +-2000.0,-3500.0,41.6128116,41.591291,urban +-2000.0,-3000.0,41.6173083,41.591291,urban +-2000.0,-2500.0,41.6218049,41.591291,urban +-2000.0,-2000.0,41.6263015,41.591291,open +-2000.0,-1500.0,41.6307981,41.591291,open +-2000.0,-1000.0,41.6352947,41.591291,open +-2000.0,-500.0,41.6397913,41.591291,open +-2000.0,0.0,41.6442879,41.591291,open +-2000.0,500.0,41.6487845,41.591291,open +-2000.0,1000.0,41.6532811,41.591291,open +-2000.0,1500.0,41.6577777,41.591291,open +-2000.0,2000.0,41.6622743,41.591291,open +-2000.0,2500.0,41.6667709,41.591291,open +-2000.0,3000.0,41.6712675,41.591291,open +-2000.0,3500.0,41.6757642,41.591291,open +-2000.0,4000.0,41.6802608,41.591291,open +-2000.0,4500.0,41.6847574,41.591291,open +-2000.0,5000.0,41.689254,41.591291,open +-2000.0,5500.0,41.6937506,41.591291,open +-2000.0,6000.0,41.6982472,41.591291,open +-2000.0,6500.0,41.7027438,41.591291,open +-2000.0,7000.0,41.7072404,41.591291,open +-2000.0,7500.0,41.711737,41.591291,open +-2000.0,8000.0,41.7162336,41.591291,open +-2000.0,8500.0,41.7207302,41.591291,open +-2000.0,9000.0,41.7252268,41.591291,open +-2000.0,9500.0,41.7297235,41.591291,open +-2000.0,10000.0,41.7342201,41.591291,open +-2000.0,10500.0,41.7387167,41.591291,open +-2000.0,11000.0,41.7432133,41.591291,open +-2000.0,11500.0,41.7477099,41.591291,open +-2000.0,12000.0,41.7522065,41.591291,open +-2000.0,12500.0,41.7567031,41.591291,open +-2000.0,13000.0,41.7611997,41.591291,open +-2000.0,13500.0,41.7656963,41.591291,open +-2000.0,14000.0,41.7701929,41.591291,open +-2000.0,14500.0,41.7746895,41.591291,open +-2000.0,15000.0,41.7791861,41.591291,open +-2000.0,15500.0,41.7836827,41.591291,open +-2000.0,16000.0,41.7881794,41.591291,open +-2000.0,16500.0,41.792676,41.591291,open +-2000.0,17000.0,41.7971726,41.591291,open +-2000.0,17500.0,41.8016692,41.591291,open +-2000.0,18000.0,41.8061658,41.591291,open +-2000.0,18500.0,41.8106624,41.591291,open +-2000.0,19000.0,41.815159,41.591291,open +-2000.0,19500.0,41.8196556,41.591291,open +-1500.0,-16000.0,41.5003964,41.5973082,open +-1500.0,-15500.0,41.5048931,41.5973082,open +-1500.0,-15000.0,41.5093897,41.5973082,open +-1500.0,-14500.0,41.5138863,41.5973082,open +-1500.0,-14000.0,41.5183829,41.5973082,open +-1500.0,-13500.0,41.5228795,41.5973082,open +-1500.0,-13000.0,41.5273761,41.5973082,open +-1500.0,-12500.0,41.5318727,41.5973082,open +-1500.0,-12000.0,41.5363693,41.5973082,open +-1500.0,-11500.0,41.5408659,41.5973082,open +-1500.0,-11000.0,41.5453625,41.5973082,open +-1500.0,-10500.0,41.5498591,41.5973082,open +-1500.0,-10000.0,41.5543557,41.5973082,open +-1500.0,-9500.0,41.5588523,41.5973082,open +-1500.0,-9000.0,41.563349,41.5973082,urban +-1500.0,-8500.0,41.5678456,41.5973082,urban +-1500.0,-8000.0,41.5723422,41.5973082,urban +-1500.0,-7500.0,41.5768388,41.5973082,urban +-1500.0,-7000.0,41.5813354,41.5973082,urban +-1500.0,-6500.0,41.585832,41.5973082,urban +-1500.0,-6000.0,41.5903286,41.5973082,urban +-1500.0,-5500.0,41.5948252,41.5973082,urban +-1500.0,-5000.0,41.5993218,41.5973082,urban +-1500.0,-4500.0,41.6038184,41.5973082,open +-1500.0,-4000.0,41.608315,41.5973082,urban +-1500.0,-3500.0,41.6128116,41.5973082,urban +-1500.0,-3000.0,41.6173083,41.5973082,urban +-1500.0,-2500.0,41.6218049,41.5973082,urban +-1500.0,-2000.0,41.6263015,41.5973082,urban +-1500.0,-1500.0,41.6307981,41.5973082,urban +-1500.0,-1000.0,41.6352947,41.5973082,open +-1500.0,-500.0,41.6397913,41.5973082,open +-1500.0,0.0,41.6442879,41.5973082,open +-1500.0,500.0,41.6487845,41.5973082,open +-1500.0,1000.0,41.6532811,41.5973082,open +-1500.0,1500.0,41.6577777,41.5973082,open +-1500.0,2000.0,41.6622743,41.5973082,open +-1500.0,2500.0,41.6667709,41.5973082,open +-1500.0,3000.0,41.6712675,41.5973082,open +-1500.0,3500.0,41.6757642,41.5973082,open +-1500.0,4000.0,41.6802608,41.5973082,open +-1500.0,4500.0,41.6847574,41.5973082,open +-1500.0,5000.0,41.689254,41.5973082,open +-1500.0,5500.0,41.6937506,41.5973082,open +-1500.0,6000.0,41.6982472,41.5973082,open +-1500.0,6500.0,41.7027438,41.5973082,open +-1500.0,7000.0,41.7072404,41.5973082,open +-1500.0,7500.0,41.711737,41.5973082,open +-1500.0,8000.0,41.7162336,41.5973082,open +-1500.0,8500.0,41.7207302,41.5973082,open +-1500.0,9000.0,41.7252268,41.5973082,open +-1500.0,9500.0,41.7297235,41.5973082,open +-1500.0,10000.0,41.7342201,41.5973082,open +-1500.0,10500.0,41.7387167,41.5973082,open +-1500.0,11000.0,41.7432133,41.5973082,open +-1500.0,11500.0,41.7477099,41.5973082,open +-1500.0,12000.0,41.7522065,41.5973082,open +-1500.0,12500.0,41.7567031,41.5973082,open +-1500.0,13000.0,41.7611997,41.5973082,open +-1500.0,13500.0,41.7656963,41.5973082,open +-1500.0,14000.0,41.7701929,41.5973082,open +-1500.0,14500.0,41.7746895,41.5973082,open +-1500.0,15000.0,41.7791861,41.5973082,open +-1500.0,15500.0,41.7836827,41.5973082,open +-1500.0,16000.0,41.7881794,41.5973082,open +-1500.0,16500.0,41.792676,41.5973082,open +-1500.0,17000.0,41.7971726,41.5973082,open +-1500.0,17500.0,41.8016692,41.5973082,open +-1500.0,18000.0,41.8061658,41.5973082,open +-1500.0,18500.0,41.8106624,41.5973082,open +-1500.0,19000.0,41.815159,41.5973082,open +-1500.0,19500.0,41.8196556,41.5973082,open +-1000.0,-16000.0,41.5003964,41.6033255,open +-1000.0,-15500.0,41.5048931,41.6033255,open +-1000.0,-15000.0,41.5093897,41.6033255,open +-1000.0,-14500.0,41.5138863,41.6033255,open +-1000.0,-14000.0,41.5183829,41.6033255,open +-1000.0,-13500.0,41.5228795,41.6033255,open +-1000.0,-13000.0,41.5273761,41.6033255,open +-1000.0,-12500.0,41.5318727,41.6033255,open +-1000.0,-12000.0,41.5363693,41.6033255,open +-1000.0,-11500.0,41.5408659,41.6033255,open +-1000.0,-11000.0,41.5453625,41.6033255,open +-1000.0,-10500.0,41.5498591,41.6033255,open +-1000.0,-10000.0,41.5543557,41.6033255,open +-1000.0,-9500.0,41.5588523,41.6033255,urban +-1000.0,-9000.0,41.563349,41.6033255,urban +-1000.0,-8500.0,41.5678456,41.6033255,urban +-1000.0,-8000.0,41.5723422,41.6033255,urban +-1000.0,-7500.0,41.5768388,41.6033255,urban +-1000.0,-7000.0,41.5813354,41.6033255,urban +-1000.0,-6500.0,41.585832,41.6033255,urban +-1000.0,-6000.0,41.5903286,41.6033255,urban +-1000.0,-5500.0,41.5948252,41.6033255,urban +-1000.0,-5000.0,41.5993218,41.6033255,urban +-1000.0,-4500.0,41.6038184,41.6033255,open +-1000.0,-4000.0,41.608315,41.6033255,urban +-1000.0,-3500.0,41.6128116,41.6033255,urban +-1000.0,-3000.0,41.6173083,41.6033255,urban +-1000.0,-2500.0,41.6218049,41.6033255,urban +-1000.0,-2000.0,41.6263015,41.6033255,urban +-1000.0,-1500.0,41.6307981,41.6033255,urban +-1000.0,-1000.0,41.6352947,41.6033255,urban +-1000.0,-500.0,41.6397913,41.6033255,open +-1000.0,0.0,41.6442879,41.6033255,open +-1000.0,500.0,41.6487845,41.6033255,open +-1000.0,1000.0,41.6532811,41.6033255,open +-1000.0,1500.0,41.6577777,41.6033255,open +-1000.0,2000.0,41.6622743,41.6033255,open +-1000.0,2500.0,41.6667709,41.6033255,open +-1000.0,3000.0,41.6712675,41.6033255,open +-1000.0,3500.0,41.6757642,41.6033255,open +-1000.0,4000.0,41.6802608,41.6033255,open +-1000.0,4500.0,41.6847574,41.6033255,open +-1000.0,5000.0,41.689254,41.6033255,open +-1000.0,5500.0,41.6937506,41.6033255,open +-1000.0,6000.0,41.6982472,41.6033255,open +-1000.0,6500.0,41.7027438,41.6033255,open +-1000.0,7000.0,41.7072404,41.6033255,open +-1000.0,7500.0,41.711737,41.6033255,open +-1000.0,8000.0,41.7162336,41.6033255,open +-1000.0,8500.0,41.7207302,41.6033255,open +-1000.0,9000.0,41.7252268,41.6033255,open +-1000.0,9500.0,41.7297235,41.6033255,open +-1000.0,10000.0,41.7342201,41.6033255,open +-1000.0,10500.0,41.7387167,41.6033255,open +-1000.0,11000.0,41.7432133,41.6033255,open +-1000.0,11500.0,41.7477099,41.6033255,open +-1000.0,12000.0,41.7522065,41.6033255,open +-1000.0,12500.0,41.7567031,41.6033255,open +-1000.0,13000.0,41.7611997,41.6033255,open +-1000.0,13500.0,41.7656963,41.6033255,open +-1000.0,14000.0,41.7701929,41.6033255,open +-1000.0,14500.0,41.7746895,41.6033255,open +-1000.0,15000.0,41.7791861,41.6033255,open +-1000.0,15500.0,41.7836827,41.6033255,open +-1000.0,16000.0,41.7881794,41.6033255,open +-1000.0,16500.0,41.792676,41.6033255,open +-1000.0,17000.0,41.7971726,41.6033255,open +-1000.0,17500.0,41.8016692,41.6033255,open +-1000.0,18000.0,41.8061658,41.6033255,open +-1000.0,18500.0,41.8106624,41.6033255,open +-1000.0,19000.0,41.815159,41.6033255,open +-1000.0,19500.0,41.8196556,41.6033255,open +-500.0,-16000.0,41.5003964,41.6093427,open +-500.0,-15500.0,41.5048931,41.6093427,open +-500.0,-15000.0,41.5093897,41.6093427,open +-500.0,-14500.0,41.5138863,41.6093427,open +-500.0,-14000.0,41.5183829,41.6093427,open +-500.0,-13500.0,41.5228795,41.6093427,open +-500.0,-13000.0,41.5273761,41.6093427,open +-500.0,-12500.0,41.5318727,41.6093427,open +-500.0,-12000.0,41.5363693,41.6093427,open +-500.0,-11500.0,41.5408659,41.6093427,open +-500.0,-11000.0,41.5453625,41.6093427,open +-500.0,-10500.0,41.5498591,41.6093427,open +-500.0,-10000.0,41.5543557,41.6093427,urban +-500.0,-9500.0,41.5588523,41.6093427,urban +-500.0,-9000.0,41.563349,41.6093427,urban +-500.0,-8500.0,41.5678456,41.6093427,urban +-500.0,-8000.0,41.5723422,41.6093427,urban +-500.0,-7500.0,41.5768388,41.6093427,urban +-500.0,-7000.0,41.5813354,41.6093427,urban +-500.0,-6500.0,41.585832,41.6093427,urban +-500.0,-6000.0,41.5903286,41.6093427,open +-500.0,-5500.0,41.5948252,41.6093427,urban +-500.0,-5000.0,41.5993218,41.6093427,urban +-500.0,-4500.0,41.6038184,41.6093427,urban +-500.0,-4000.0,41.608315,41.6093427,urban +-500.0,-3500.0,41.6128116,41.6093427,urban +-500.0,-3000.0,41.6173083,41.6093427,urban +-500.0,-2500.0,41.6218049,41.6093427,urban +-500.0,-2000.0,41.6263015,41.6093427,urban +-500.0,-1500.0,41.6307981,41.6093427,urban +-500.0,-1000.0,41.6352947,41.6093427,urban +-500.0,-500.0,41.6397913,41.6093427,urban +-500.0,0.0,41.6442879,41.6093427,open +-500.0,500.0,41.6487845,41.6093427,open +-500.0,1000.0,41.6532811,41.6093427,open +-500.0,1500.0,41.6577777,41.6093427,open +-500.0,2000.0,41.6622743,41.6093427,open +-500.0,2500.0,41.6667709,41.6093427,open +-500.0,3000.0,41.6712675,41.6093427,open +-500.0,3500.0,41.6757642,41.6093427,open +-500.0,4000.0,41.6802608,41.6093427,open +-500.0,4500.0,41.6847574,41.6093427,open +-500.0,5000.0,41.689254,41.6093427,open +-500.0,5500.0,41.6937506,41.6093427,open +-500.0,6000.0,41.6982472,41.6093427,open +-500.0,6500.0,41.7027438,41.6093427,open +-500.0,7000.0,41.7072404,41.6093427,open +-500.0,7500.0,41.711737,41.6093427,open +-500.0,8000.0,41.7162336,41.6093427,open +-500.0,8500.0,41.7207302,41.6093427,open +-500.0,9000.0,41.7252268,41.6093427,open +-500.0,9500.0,41.7297235,41.6093427,open +-500.0,10000.0,41.7342201,41.6093427,open +-500.0,10500.0,41.7387167,41.6093427,open +-500.0,11000.0,41.7432133,41.6093427,open +-500.0,11500.0,41.7477099,41.6093427,open +-500.0,12000.0,41.7522065,41.6093427,open +-500.0,12500.0,41.7567031,41.6093427,open +-500.0,13000.0,41.7611997,41.6093427,open +-500.0,13500.0,41.7656963,41.6093427,open +-500.0,14000.0,41.7701929,41.6093427,open +-500.0,14500.0,41.7746895,41.6093427,open +-500.0,15000.0,41.7791861,41.6093427,open +-500.0,15500.0,41.7836827,41.6093427,open +-500.0,16000.0,41.7881794,41.6093427,open +-500.0,16500.0,41.792676,41.6093427,open +-500.0,17000.0,41.7971726,41.6093427,open +-500.0,17500.0,41.8016692,41.6093427,open +-500.0,18000.0,41.8061658,41.6093427,open +-500.0,18500.0,41.8106624,41.6093427,open +-500.0,19000.0,41.815159,41.6093427,open +-500.0,19500.0,41.8196556,41.6093427,open +0.0,-16000.0,41.5003964,41.61536,open +0.0,-15500.0,41.5048931,41.61536,open +0.0,-15000.0,41.5093897,41.61536,open +0.0,-14500.0,41.5138863,41.61536,open +0.0,-14000.0,41.5183829,41.61536,open +0.0,-13500.0,41.5228795,41.61536,open +0.0,-13000.0,41.5273761,41.61536,open +0.0,-12500.0,41.5318727,41.61536,open +0.0,-12000.0,41.5363693,41.61536,open +0.0,-11500.0,41.5408659,41.61536,open +0.0,-11000.0,41.5453625,41.61536,open +0.0,-10500.0,41.5498591,41.61536,open +0.0,-10000.0,41.5543557,41.61536,open +0.0,-9500.0,41.5588523,41.61536,urban +0.0,-9000.0,41.563349,41.61536,urban +0.0,-8500.0,41.5678456,41.61536,urban +0.0,-8000.0,41.5723422,41.61536,urban +0.0,-7500.0,41.5768388,41.61536,urban +0.0,-7000.0,41.5813354,41.61536,urban +0.0,-6500.0,41.585832,41.61536,open +0.0,-6000.0,41.5903286,41.61536,urban +0.0,-5500.0,41.5948252,41.61536,urban +0.0,-5000.0,41.5993218,41.61536,urban +0.0,-4500.0,41.6038184,41.61536,urban +0.0,-4000.0,41.608315,41.61536,urban +0.0,-3500.0,41.6128116,41.61536,urban +0.0,-3000.0,41.6173083,41.61536,urban +0.0,-2500.0,41.6218049,41.61536,urban +0.0,-2000.0,41.6263015,41.61536,urban +0.0,-1500.0,41.6307981,41.61536,urban +0.0,-1000.0,41.6352947,41.61536,urban +0.0,-500.0,41.6397913,41.61536,urban +0.0,0.0,41.6442879,41.61536,urban +0.0,500.0,41.6487845,41.61536,urban +0.0,1000.0,41.6532811,41.61536,open +0.0,1500.0,41.6577777,41.61536,open +0.0,2000.0,41.6622743,41.61536,open +0.0,2500.0,41.6667709,41.61536,open +0.0,3000.0,41.6712675,41.61536,open +0.0,3500.0,41.6757642,41.61536,open +0.0,4000.0,41.6802608,41.61536,open +0.0,4500.0,41.6847574,41.61536,open +0.0,5000.0,41.689254,41.61536,open +0.0,5500.0,41.6937506,41.61536,open +0.0,6000.0,41.6982472,41.61536,open +0.0,6500.0,41.7027438,41.61536,open +0.0,7000.0,41.7072404,41.61536,open +0.0,7500.0,41.711737,41.61536,open +0.0,8000.0,41.7162336,41.61536,open +0.0,8500.0,41.7207302,41.61536,open +0.0,9000.0,41.7252268,41.61536,open +0.0,9500.0,41.7297235,41.61536,open +0.0,10000.0,41.7342201,41.61536,open +0.0,10500.0,41.7387167,41.61536,open +0.0,11000.0,41.7432133,41.61536,open +0.0,11500.0,41.7477099,41.61536,open +0.0,12000.0,41.7522065,41.61536,open +0.0,12500.0,41.7567031,41.61536,open +0.0,13000.0,41.7611997,41.61536,open +0.0,13500.0,41.7656963,41.61536,open +0.0,14000.0,41.7701929,41.61536,open +0.0,14500.0,41.7746895,41.61536,open +0.0,15000.0,41.7791861,41.61536,open +0.0,15500.0,41.7836827,41.61536,open +0.0,16000.0,41.7881794,41.61536,open +0.0,16500.0,41.792676,41.61536,open +0.0,17000.0,41.7971726,41.61536,open +0.0,17500.0,41.8016692,41.61536,open +0.0,18000.0,41.8061658,41.61536,open +0.0,18500.0,41.8106624,41.61536,open +0.0,19000.0,41.815159,41.61536,open +0.0,19500.0,41.8196556,41.61536,open +500.0,-16000.0,41.5003964,41.6213773,open +500.0,-15500.0,41.5048931,41.6213773,open +500.0,-15000.0,41.5093897,41.6213773,open +500.0,-14500.0,41.5138863,41.6213773,open +500.0,-14000.0,41.5183829,41.6213773,open +500.0,-13500.0,41.5228795,41.6213773,open +500.0,-13000.0,41.5273761,41.6213773,open +500.0,-12500.0,41.5318727,41.6213773,open +500.0,-12000.0,41.5363693,41.6213773,open +500.0,-11500.0,41.5408659,41.6213773,open +500.0,-11000.0,41.5453625,41.6213773,open +500.0,-10500.0,41.5498591,41.6213773,open +500.0,-10000.0,41.5543557,41.6213773,open +500.0,-9500.0,41.5588523,41.6213773,urban +500.0,-9000.0,41.563349,41.6213773,urban +500.0,-8500.0,41.5678456,41.6213773,urban +500.0,-8000.0,41.5723422,41.6213773,urban +500.0,-7500.0,41.5768388,41.6213773,urban +500.0,-7000.0,41.5813354,41.6213773,urban +500.0,-6500.0,41.585832,41.6213773,open +500.0,-6000.0,41.5903286,41.6213773,urban +500.0,-5500.0,41.5948252,41.6213773,urban +500.0,-5000.0,41.5993218,41.6213773,urban +500.0,-4500.0,41.6038184,41.6213773,urban +500.0,-4000.0,41.608315,41.6213773,urban +500.0,-3500.0,41.6128116,41.6213773,urban +500.0,-3000.0,41.6173083,41.6213773,urban +500.0,-2500.0,41.6218049,41.6213773,urban +500.0,-2000.0,41.6263015,41.6213773,urban +500.0,-1500.0,41.6307981,41.6213773,urban +500.0,-1000.0,41.6352947,41.6213773,urban +500.0,-500.0,41.6397913,41.6213773,urban +500.0,0.0,41.6442879,41.6213773,urban +500.0,500.0,41.6487845,41.6213773,urban +500.0,1000.0,41.6532811,41.6213773,open +500.0,1500.0,41.6577777,41.6213773,open +500.0,2000.0,41.6622743,41.6213773,open +500.0,2500.0,41.6667709,41.6213773,open +500.0,3000.0,41.6712675,41.6213773,open +500.0,3500.0,41.6757642,41.6213773,open +500.0,4000.0,41.6802608,41.6213773,open +500.0,4500.0,41.6847574,41.6213773,open +500.0,5000.0,41.689254,41.6213773,open +500.0,5500.0,41.6937506,41.6213773,open +500.0,6000.0,41.6982472,41.6213773,open +500.0,6500.0,41.7027438,41.6213773,open +500.0,7000.0,41.7072404,41.6213773,open +500.0,7500.0,41.711737,41.6213773,open +500.0,8000.0,41.7162336,41.6213773,open +500.0,8500.0,41.7207302,41.6213773,open +500.0,9000.0,41.7252268,41.6213773,open +500.0,9500.0,41.7297235,41.6213773,open +500.0,10000.0,41.7342201,41.6213773,open +500.0,10500.0,41.7387167,41.6213773,open +500.0,11000.0,41.7432133,41.6213773,open +500.0,11500.0,41.7477099,41.6213773,open +500.0,12000.0,41.7522065,41.6213773,open +500.0,12500.0,41.7567031,41.6213773,open +500.0,13000.0,41.7611997,41.6213773,open +500.0,13500.0,41.7656963,41.6213773,open +500.0,14000.0,41.7701929,41.6213773,open +500.0,14500.0,41.7746895,41.6213773,open +500.0,15000.0,41.7791861,41.6213773,open +500.0,15500.0,41.7836827,41.6213773,open +500.0,16000.0,41.7881794,41.6213773,open +500.0,16500.0,41.792676,41.6213773,open +500.0,17000.0,41.7971726,41.6213773,open +500.0,17500.0,41.8016692,41.6213773,open +500.0,18000.0,41.8061658,41.6213773,open +500.0,18500.0,41.8106624,41.6213773,open +500.0,19000.0,41.815159,41.6213773,open +500.0,19500.0,41.8196556,41.6213773,open +1000.0,-16000.0,41.5003964,41.6273945,open +1000.0,-15500.0,41.5048931,41.6273945,open +1000.0,-15000.0,41.5093897,41.6273945,open +1000.0,-14500.0,41.5138863,41.6273945,open +1000.0,-14000.0,41.5183829,41.6273945,open +1000.0,-13500.0,41.5228795,41.6273945,open +1000.0,-13000.0,41.5273761,41.6273945,open +1000.0,-12500.0,41.5318727,41.6273945,open +1000.0,-12000.0,41.5363693,41.6273945,open +1000.0,-11500.0,41.5408659,41.6273945,open +1000.0,-11000.0,41.5453625,41.6273945,open +1000.0,-10500.0,41.5498591,41.6273945,urban +1000.0,-10000.0,41.5543557,41.6273945,open +1000.0,-9500.0,41.5588523,41.6273945,urban +1000.0,-9000.0,41.563349,41.6273945,urban +1000.0,-8500.0,41.5678456,41.6273945,urban +1000.0,-8000.0,41.5723422,41.6273945,urban +1000.0,-7500.0,41.5768388,41.6273945,urban +1000.0,-7000.0,41.5813354,41.6273945,urban +1000.0,-6500.0,41.585832,41.6273945,open +1000.0,-6000.0,41.5903286,41.6273945,urban +1000.0,-5500.0,41.5948252,41.6273945,urban +1000.0,-5000.0,41.5993218,41.6273945,urban +1000.0,-4500.0,41.6038184,41.6273945,urban +1000.0,-4000.0,41.608315,41.6273945,urban +1000.0,-3500.0,41.6128116,41.6273945,urban +1000.0,-3000.0,41.6173083,41.6273945,urban +1000.0,-2500.0,41.6218049,41.6273945,urban +1000.0,-2000.0,41.6263015,41.6273945,urban +1000.0,-1500.0,41.6307981,41.6273945,urban +1000.0,-1000.0,41.6352947,41.6273945,urban +1000.0,-500.0,41.6397913,41.6273945,urban +1000.0,0.0,41.6442879,41.6273945,urban +1000.0,500.0,41.6487845,41.6273945,urban +1000.0,1000.0,41.6532811,41.6273945,urban +1000.0,1500.0,41.6577777,41.6273945,open +1000.0,2000.0,41.6622743,41.6273945,open +1000.0,2500.0,41.6667709,41.6273945,open +1000.0,3000.0,41.6712675,41.6273945,open +1000.0,3500.0,41.6757642,41.6273945,open +1000.0,4000.0,41.6802608,41.6273945,open +1000.0,4500.0,41.6847574,41.6273945,open +1000.0,5000.0,41.689254,41.6273945,open +1000.0,5500.0,41.6937506,41.6273945,open +1000.0,6000.0,41.6982472,41.6273945,open +1000.0,6500.0,41.7027438,41.6273945,open +1000.0,7000.0,41.7072404,41.6273945,open +1000.0,7500.0,41.711737,41.6273945,open +1000.0,8000.0,41.7162336,41.6273945,open +1000.0,8500.0,41.7207302,41.6273945,open +1000.0,9000.0,41.7252268,41.6273945,open +1000.0,9500.0,41.7297235,41.6273945,open +1000.0,10000.0,41.7342201,41.6273945,open +1000.0,10500.0,41.7387167,41.6273945,open +1000.0,11000.0,41.7432133,41.6273945,open +1000.0,11500.0,41.7477099,41.6273945,open +1000.0,12000.0,41.7522065,41.6273945,open +1000.0,12500.0,41.7567031,41.6273945,open +1000.0,13000.0,41.7611997,41.6273945,open +1000.0,13500.0,41.7656963,41.6273945,open +1000.0,14000.0,41.7701929,41.6273945,open +1000.0,14500.0,41.7746895,41.6273945,open +1000.0,15000.0,41.7791861,41.6273945,open +1000.0,15500.0,41.7836827,41.6273945,open +1000.0,16000.0,41.7881794,41.6273945,open +1000.0,16500.0,41.792676,41.6273945,open +1000.0,17000.0,41.7971726,41.6273945,open +1000.0,17500.0,41.8016692,41.6273945,open +1000.0,18000.0,41.8061658,41.6273945,open +1000.0,18500.0,41.8106624,41.6273945,open +1000.0,19000.0,41.815159,41.6273945,open +1000.0,19500.0,41.8196556,41.6273945,open +1500.0,-16000.0,41.5003964,41.6334118,open +1500.0,-15500.0,41.5048931,41.6334118,open +1500.0,-15000.0,41.5093897,41.6334118,open +1500.0,-14500.0,41.5138863,41.6334118,open +1500.0,-14000.0,41.5183829,41.6334118,open +1500.0,-13500.0,41.5228795,41.6334118,open +1500.0,-13000.0,41.5273761,41.6334118,open +1500.0,-12500.0,41.5318727,41.6334118,open +1500.0,-12000.0,41.5363693,41.6334118,urban +1500.0,-11500.0,41.5408659,41.6334118,urban +1500.0,-11000.0,41.5453625,41.6334118,open +1500.0,-10500.0,41.5498591,41.6334118,urban +1500.0,-10000.0,41.5543557,41.6334118,urban +1500.0,-9500.0,41.5588523,41.6334118,urban +1500.0,-9000.0,41.563349,41.6334118,urban +1500.0,-8500.0,41.5678456,41.6334118,urban +1500.0,-8000.0,41.5723422,41.6334118,urban +1500.0,-7500.0,41.5768388,41.6334118,urban +1500.0,-7000.0,41.5813354,41.6334118,urban +1500.0,-6500.0,41.585832,41.6334118,urban +1500.0,-6000.0,41.5903286,41.6334118,urban +1500.0,-5500.0,41.5948252,41.6334118,urban +1500.0,-5000.0,41.5993218,41.6334118,urban +1500.0,-4500.0,41.6038184,41.6334118,urban +1500.0,-4000.0,41.608315,41.6334118,urban +1500.0,-3500.0,41.6128116,41.6334118,urban +1500.0,-3000.0,41.6173083,41.6334118,urban +1500.0,-2500.0,41.6218049,41.6334118,urban +1500.0,-2000.0,41.6263015,41.6334118,urban +1500.0,-1500.0,41.6307981,41.6334118,urban +1500.0,-1000.0,41.6352947,41.6334118,urban +1500.0,-500.0,41.6397913,41.6334118,urban +1500.0,0.0,41.6442879,41.6334118,urban +1500.0,500.0,41.6487845,41.6334118,urban +1500.0,1000.0,41.6532811,41.6334118,urban +1500.0,1500.0,41.6577777,41.6334118,urban +1500.0,2000.0,41.6622743,41.6334118,open +1500.0,2500.0,41.6667709,41.6334118,open +1500.0,3000.0,41.6712675,41.6334118,open +1500.0,3500.0,41.6757642,41.6334118,open +1500.0,4000.0,41.6802608,41.6334118,open +1500.0,4500.0,41.6847574,41.6334118,open +1500.0,5000.0,41.689254,41.6334118,open +1500.0,5500.0,41.6937506,41.6334118,open +1500.0,6000.0,41.6982472,41.6334118,open +1500.0,6500.0,41.7027438,41.6334118,open +1500.0,7000.0,41.7072404,41.6334118,open +1500.0,7500.0,41.711737,41.6334118,open +1500.0,8000.0,41.7162336,41.6334118,open +1500.0,8500.0,41.7207302,41.6334118,open +1500.0,9000.0,41.7252268,41.6334118,open +1500.0,9500.0,41.7297235,41.6334118,open +1500.0,10000.0,41.7342201,41.6334118,open +1500.0,10500.0,41.7387167,41.6334118,open +1500.0,11000.0,41.7432133,41.6334118,open +1500.0,11500.0,41.7477099,41.6334118,open +1500.0,12000.0,41.7522065,41.6334118,open +1500.0,12500.0,41.7567031,41.6334118,open +1500.0,13000.0,41.7611997,41.6334118,open +1500.0,13500.0,41.7656963,41.6334118,open +1500.0,14000.0,41.7701929,41.6334118,open +1500.0,14500.0,41.7746895,41.6334118,open +1500.0,15000.0,41.7791861,41.6334118,open +1500.0,15500.0,41.7836827,41.6334118,open +1500.0,16000.0,41.7881794,41.6334118,open +1500.0,16500.0,41.792676,41.6334118,open +1500.0,17000.0,41.7971726,41.6334118,open +1500.0,17500.0,41.8016692,41.6334118,open +1500.0,18000.0,41.8061658,41.6334118,open +1500.0,18500.0,41.8106624,41.6334118,open +1500.0,19000.0,41.815159,41.6334118,open +1500.0,19500.0,41.8196556,41.6334118,open +2000.0,-16000.0,41.5003964,41.639429,open +2000.0,-15500.0,41.5048931,41.639429,open +2000.0,-15000.0,41.5093897,41.639429,open +2000.0,-14500.0,41.5138863,41.639429,open +2000.0,-14000.0,41.5183829,41.639429,open +2000.0,-13500.0,41.5228795,41.639429,open +2000.0,-13000.0,41.5273761,41.639429,open +2000.0,-12500.0,41.5318727,41.639429,open +2000.0,-12000.0,41.5363693,41.639429,urban +2000.0,-11500.0,41.5408659,41.639429,urban +2000.0,-11000.0,41.5453625,41.639429,urban +2000.0,-10500.0,41.5498591,41.639429,urban +2000.0,-10000.0,41.5543557,41.639429,urban +2000.0,-9500.0,41.5588523,41.639429,urban +2000.0,-9000.0,41.563349,41.639429,urban +2000.0,-8500.0,41.5678456,41.639429,urban +2000.0,-8000.0,41.5723422,41.639429,urban +2000.0,-7500.0,41.5768388,41.639429,urban +2000.0,-7000.0,41.5813354,41.639429,open +2000.0,-6500.0,41.585832,41.639429,urban +2000.0,-6000.0,41.5903286,41.639429,urban +2000.0,-5500.0,41.5948252,41.639429,urban +2000.0,-5000.0,41.5993218,41.639429,urban +2000.0,-4500.0,41.6038184,41.639429,urban +2000.0,-4000.0,41.608315,41.639429,urban +2000.0,-3500.0,41.6128116,41.639429,urban +2000.0,-3000.0,41.6173083,41.639429,urban +2000.0,-2500.0,41.6218049,41.639429,urban +2000.0,-2000.0,41.6263015,41.639429,urban +2000.0,-1500.0,41.6307981,41.639429,urban +2000.0,-1000.0,41.6352947,41.639429,urban +2000.0,-500.0,41.6397913,41.639429,urban +2000.0,0.0,41.6442879,41.639429,urban +2000.0,500.0,41.6487845,41.639429,urban +2000.0,1000.0,41.6532811,41.639429,urban +2000.0,1500.0,41.6577777,41.639429,urban +2000.0,2000.0,41.6622743,41.639429,open +2000.0,2500.0,41.6667709,41.639429,open +2000.0,3000.0,41.6712675,41.639429,open +2000.0,3500.0,41.6757642,41.639429,open +2000.0,4000.0,41.6802608,41.639429,open +2000.0,4500.0,41.6847574,41.639429,open +2000.0,5000.0,41.689254,41.639429,open +2000.0,5500.0,41.6937506,41.639429,open +2000.0,6000.0,41.6982472,41.639429,open +2000.0,6500.0,41.7027438,41.639429,open +2000.0,7000.0,41.7072404,41.639429,open +2000.0,7500.0,41.711737,41.639429,open +2000.0,8000.0,41.7162336,41.639429,open +2000.0,8500.0,41.7207302,41.639429,open +2000.0,9000.0,41.7252268,41.639429,open +2000.0,9500.0,41.7297235,41.639429,open +2000.0,10000.0,41.7342201,41.639429,open +2000.0,10500.0,41.7387167,41.639429,open +2000.0,11000.0,41.7432133,41.639429,open +2000.0,11500.0,41.7477099,41.639429,open +2000.0,12000.0,41.7522065,41.639429,open +2000.0,12500.0,41.7567031,41.639429,open +2000.0,13000.0,41.7611997,41.639429,open +2000.0,13500.0,41.7656963,41.639429,open +2000.0,14000.0,41.7701929,41.639429,open +2000.0,14500.0,41.7746895,41.639429,open +2000.0,15000.0,41.7791861,41.639429,open +2000.0,15500.0,41.7836827,41.639429,open +2000.0,16000.0,41.7881794,41.639429,open +2000.0,16500.0,41.792676,41.639429,open +2000.0,17000.0,41.7971726,41.639429,open +2000.0,17500.0,41.8016692,41.639429,open +2000.0,18000.0,41.8061658,41.639429,open +2000.0,18500.0,41.8106624,41.639429,open +2000.0,19000.0,41.815159,41.639429,open +2000.0,19500.0,41.8196556,41.639429,open +2500.0,-16000.0,41.5003964,41.6454463,open +2500.0,-15500.0,41.5048931,41.6454463,open +2500.0,-15000.0,41.5093897,41.6454463,open +2500.0,-14500.0,41.5138863,41.6454463,open +2500.0,-14000.0,41.5183829,41.6454463,open +2500.0,-13500.0,41.5228795,41.6454463,open +2500.0,-13000.0,41.5273761,41.6454463,open +2500.0,-12500.0,41.5318727,41.6454463,open +2500.0,-12000.0,41.5363693,41.6454463,open +2500.0,-11500.0,41.5408659,41.6454463,urban +2500.0,-11000.0,41.5453625,41.6454463,urban +2500.0,-10500.0,41.5498591,41.6454463,urban +2500.0,-10000.0,41.5543557,41.6454463,urban +2500.0,-9500.0,41.5588523,41.6454463,urban +2500.0,-9000.0,41.563349,41.6454463,urban +2500.0,-8500.0,41.5678456,41.6454463,urban +2500.0,-8000.0,41.5723422,41.6454463,urban +2500.0,-7500.0,41.5768388,41.6454463,urban +2500.0,-7000.0,41.5813354,41.6454463,urban +2500.0,-6500.0,41.585832,41.6454463,urban +2500.0,-6000.0,41.5903286,41.6454463,urban +2500.0,-5500.0,41.5948252,41.6454463,urban +2500.0,-5000.0,41.5993218,41.6454463,urban +2500.0,-4500.0,41.6038184,41.6454463,urban +2500.0,-4000.0,41.608315,41.6454463,urban +2500.0,-3500.0,41.6128116,41.6454463,urban +2500.0,-3000.0,41.6173083,41.6454463,urban +2500.0,-2500.0,41.6218049,41.6454463,urban +2500.0,-2000.0,41.6263015,41.6454463,urban +2500.0,-1500.0,41.6307981,41.6454463,urban +2500.0,-1000.0,41.6352947,41.6454463,urban +2500.0,-500.0,41.6397913,41.6454463,urban +2500.0,0.0,41.6442879,41.6454463,urban +2500.0,500.0,41.6487845,41.6454463,urban +2500.0,1000.0,41.6532811,41.6454463,urban +2500.0,1500.0,41.6577777,41.6454463,urban +2500.0,2000.0,41.6622743,41.6454463,open +2500.0,2500.0,41.6667709,41.6454463,open +2500.0,3000.0,41.6712675,41.6454463,open +2500.0,3500.0,41.6757642,41.6454463,open +2500.0,4000.0,41.6802608,41.6454463,open +2500.0,4500.0,41.6847574,41.6454463,open +2500.0,5000.0,41.689254,41.6454463,open +2500.0,5500.0,41.6937506,41.6454463,open +2500.0,6000.0,41.6982472,41.6454463,open +2500.0,6500.0,41.7027438,41.6454463,open +2500.0,7000.0,41.7072404,41.6454463,open +2500.0,7500.0,41.711737,41.6454463,open +2500.0,8000.0,41.7162336,41.6454463,open +2500.0,8500.0,41.7207302,41.6454463,open +2500.0,9000.0,41.7252268,41.6454463,open +2500.0,9500.0,41.7297235,41.6454463,open +2500.0,10000.0,41.7342201,41.6454463,open +2500.0,10500.0,41.7387167,41.6454463,open +2500.0,11000.0,41.7432133,41.6454463,open +2500.0,11500.0,41.7477099,41.6454463,open +2500.0,12000.0,41.7522065,41.6454463,open +2500.0,12500.0,41.7567031,41.6454463,open +2500.0,13000.0,41.7611997,41.6454463,open +2500.0,13500.0,41.7656963,41.6454463,open +2500.0,14000.0,41.7701929,41.6454463,open +2500.0,14500.0,41.7746895,41.6454463,open +2500.0,15000.0,41.7791861,41.6454463,open +2500.0,15500.0,41.7836827,41.6454463,open +2500.0,16000.0,41.7881794,41.6454463,open +2500.0,16500.0,41.792676,41.6454463,open +2500.0,17000.0,41.7971726,41.6454463,open +2500.0,17500.0,41.8016692,41.6454463,open +2500.0,18000.0,41.8061658,41.6454463,open +2500.0,18500.0,41.8106624,41.6454463,open +2500.0,19000.0,41.815159,41.6454463,open +2500.0,19500.0,41.8196556,41.6454463,open +3000.0,-16000.0,41.5003964,41.6514636,open +3000.0,-15500.0,41.5048931,41.6514636,open +3000.0,-15000.0,41.5093897,41.6514636,open +3000.0,-14500.0,41.5138863,41.6514636,open +3000.0,-14000.0,41.5183829,41.6514636,open +3000.0,-13500.0,41.5228795,41.6514636,open +3000.0,-13000.0,41.5273761,41.6514636,open +3000.0,-12500.0,41.5318727,41.6514636,open +3000.0,-12000.0,41.5363693,41.6514636,open +3000.0,-11500.0,41.5408659,41.6514636,urban +3000.0,-11000.0,41.5453625,41.6514636,urban +3000.0,-10500.0,41.5498591,41.6514636,urban +3000.0,-10000.0,41.5543557,41.6514636,urban +3000.0,-9500.0,41.5588523,41.6514636,urban +3000.0,-9000.0,41.563349,41.6514636,urban +3000.0,-8500.0,41.5678456,41.6514636,urban +3000.0,-8000.0,41.5723422,41.6514636,urban +3000.0,-7500.0,41.5768388,41.6514636,urban +3000.0,-7000.0,41.5813354,41.6514636,urban +3000.0,-6500.0,41.585832,41.6514636,urban +3000.0,-6000.0,41.5903286,41.6514636,urban +3000.0,-5500.0,41.5948252,41.6514636,urban +3000.0,-5000.0,41.5993218,41.6514636,urban +3000.0,-4500.0,41.6038184,41.6514636,urban +3000.0,-4000.0,41.608315,41.6514636,urban +3000.0,-3500.0,41.6128116,41.6514636,urban +3000.0,-3000.0,41.6173083,41.6514636,urban +3000.0,-2500.0,41.6218049,41.6514636,urban +3000.0,-2000.0,41.6263015,41.6514636,urban +3000.0,-1500.0,41.6307981,41.6514636,urban +3000.0,-1000.0,41.6352947,41.6514636,urban +3000.0,-500.0,41.6397913,41.6514636,urban +3000.0,0.0,41.6442879,41.6514636,urban +3000.0,500.0,41.6487845,41.6514636,urban +3000.0,1000.0,41.6532811,41.6514636,open +3000.0,1500.0,41.6577777,41.6514636,open +3000.0,2000.0,41.6622743,41.6514636,open +3000.0,2500.0,41.6667709,41.6514636,open +3000.0,3000.0,41.6712675,41.6514636,open +3000.0,3500.0,41.6757642,41.6514636,open +3000.0,4000.0,41.6802608,41.6514636,open +3000.0,4500.0,41.6847574,41.6514636,open +3000.0,5000.0,41.689254,41.6514636,open +3000.0,5500.0,41.6937506,41.6514636,open +3000.0,6000.0,41.6982472,41.6514636,open +3000.0,6500.0,41.7027438,41.6514636,open +3000.0,7000.0,41.7072404,41.6514636,open +3000.0,7500.0,41.711737,41.6514636,open +3000.0,8000.0,41.7162336,41.6514636,open +3000.0,8500.0,41.7207302,41.6514636,open +3000.0,9000.0,41.7252268,41.6514636,open +3000.0,9500.0,41.7297235,41.6514636,open +3000.0,10000.0,41.7342201,41.6514636,open +3000.0,10500.0,41.7387167,41.6514636,open +3000.0,11000.0,41.7432133,41.6514636,open +3000.0,11500.0,41.7477099,41.6514636,open +3000.0,12000.0,41.7522065,41.6514636,open +3000.0,12500.0,41.7567031,41.6514636,open +3000.0,13000.0,41.7611997,41.6514636,open +3000.0,13500.0,41.7656963,41.6514636,open +3000.0,14000.0,41.7701929,41.6514636,open +3000.0,14500.0,41.7746895,41.6514636,open +3000.0,15000.0,41.7791861,41.6514636,open +3000.0,15500.0,41.7836827,41.6514636,open +3000.0,16000.0,41.7881794,41.6514636,open +3000.0,16500.0,41.792676,41.6514636,open +3000.0,17000.0,41.7971726,41.6514636,open +3000.0,17500.0,41.8016692,41.6514636,open +3000.0,18000.0,41.8061658,41.6514636,open +3000.0,18500.0,41.8106624,41.6514636,open +3000.0,19000.0,41.815159,41.6514636,open +3000.0,19500.0,41.8196556,41.6514636,open +3500.0,-16000.0,41.5003964,41.6574808,open +3500.0,-15500.0,41.5048931,41.6574808,open +3500.0,-15000.0,41.5093897,41.6574808,open +3500.0,-14500.0,41.5138863,41.6574808,open +3500.0,-14000.0,41.5183829,41.6574808,open +3500.0,-13500.0,41.5228795,41.6574808,open +3500.0,-13000.0,41.5273761,41.6574808,open +3500.0,-12500.0,41.5318727,41.6574808,open +3500.0,-12000.0,41.5363693,41.6574808,urban +3500.0,-11500.0,41.5408659,41.6574808,urban +3500.0,-11000.0,41.5453625,41.6574808,urban +3500.0,-10500.0,41.5498591,41.6574808,urban +3500.0,-10000.0,41.5543557,41.6574808,urban +3500.0,-9500.0,41.5588523,41.6574808,urban +3500.0,-9000.0,41.563349,41.6574808,urban +3500.0,-8500.0,41.5678456,41.6574808,open +3500.0,-8000.0,41.5723422,41.6574808,urban +3500.0,-7500.0,41.5768388,41.6574808,urban +3500.0,-7000.0,41.5813354,41.6574808,urban +3500.0,-6500.0,41.585832,41.6574808,urban +3500.0,-6000.0,41.5903286,41.6574808,urban +3500.0,-5500.0,41.5948252,41.6574808,urban +3500.0,-5000.0,41.5993218,41.6574808,urban +3500.0,-4500.0,41.6038184,41.6574808,urban +3500.0,-4000.0,41.608315,41.6574808,urban +3500.0,-3500.0,41.6128116,41.6574808,urban +3500.0,-3000.0,41.6173083,41.6574808,urban +3500.0,-2500.0,41.6218049,41.6574808,urban +3500.0,-2000.0,41.6263015,41.6574808,urban +3500.0,-1500.0,41.6307981,41.6574808,urban +3500.0,-1000.0,41.6352947,41.6574808,urban +3500.0,-500.0,41.6397913,41.6574808,urban +3500.0,0.0,41.6442879,41.6574808,urban +3500.0,500.0,41.6487845,41.6574808,urban +3500.0,1000.0,41.6532811,41.6574808,urban +3500.0,1500.0,41.6577777,41.6574808,open +3500.0,2000.0,41.6622743,41.6574808,open +3500.0,2500.0,41.6667709,41.6574808,open +3500.0,3000.0,41.6712675,41.6574808,open +3500.0,3500.0,41.6757642,41.6574808,open +3500.0,4000.0,41.6802608,41.6574808,open +3500.0,4500.0,41.6847574,41.6574808,open +3500.0,5000.0,41.689254,41.6574808,open +3500.0,5500.0,41.6937506,41.6574808,open +3500.0,6000.0,41.6982472,41.6574808,open +3500.0,6500.0,41.7027438,41.6574808,open +3500.0,7000.0,41.7072404,41.6574808,open +3500.0,7500.0,41.711737,41.6574808,open +3500.0,8000.0,41.7162336,41.6574808,open +3500.0,8500.0,41.7207302,41.6574808,open +3500.0,9000.0,41.7252268,41.6574808,open +3500.0,9500.0,41.7297235,41.6574808,open +3500.0,10000.0,41.7342201,41.6574808,open +3500.0,10500.0,41.7387167,41.6574808,open +3500.0,11000.0,41.7432133,41.6574808,open +3500.0,11500.0,41.7477099,41.6574808,open +3500.0,12000.0,41.7522065,41.6574808,open +3500.0,12500.0,41.7567031,41.6574808,open +3500.0,13000.0,41.7611997,41.6574808,open +3500.0,13500.0,41.7656963,41.6574808,open +3500.0,14000.0,41.7701929,41.6574808,open +3500.0,14500.0,41.7746895,41.6574808,open +3500.0,15000.0,41.7791861,41.6574808,open +3500.0,15500.0,41.7836827,41.6574808,open +3500.0,16000.0,41.7881794,41.6574808,open +3500.0,16500.0,41.792676,41.6574808,open +3500.0,17000.0,41.7971726,41.6574808,open +3500.0,17500.0,41.8016692,41.6574808,open +3500.0,18000.0,41.8061658,41.6574808,open +3500.0,18500.0,41.8106624,41.6574808,open +3500.0,19000.0,41.815159,41.6574808,open +3500.0,19500.0,41.8196556,41.6574808,open +4000.0,-16000.0,41.5003964,41.6634981,open +4000.0,-15500.0,41.5048931,41.6634981,open +4000.0,-15000.0,41.5093897,41.6634981,open +4000.0,-14500.0,41.5138863,41.6634981,open +4000.0,-14000.0,41.5183829,41.6634981,open +4000.0,-13500.0,41.5228795,41.6634981,open +4000.0,-13000.0,41.5273761,41.6634981,open +4000.0,-12500.0,41.5318727,41.6634981,open +4000.0,-12000.0,41.5363693,41.6634981,open +4000.0,-11500.0,41.5408659,41.6634981,urban +4000.0,-11000.0,41.5453625,41.6634981,urban +4000.0,-10500.0,41.5498591,41.6634981,urban +4000.0,-10000.0,41.5543557,41.6634981,urban +4000.0,-9500.0,41.5588523,41.6634981,urban +4000.0,-9000.0,41.563349,41.6634981,urban +4000.0,-8500.0,41.5678456,41.6634981,open +4000.0,-8000.0,41.5723422,41.6634981,urban +4000.0,-7500.0,41.5768388,41.6634981,urban +4000.0,-7000.0,41.5813354,41.6634981,urban +4000.0,-6500.0,41.585832,41.6634981,urban +4000.0,-6000.0,41.5903286,41.6634981,urban +4000.0,-5500.0,41.5948252,41.6634981,urban +4000.0,-5000.0,41.5993218,41.6634981,urban +4000.0,-4500.0,41.6038184,41.6634981,urban +4000.0,-4000.0,41.608315,41.6634981,urban +4000.0,-3500.0,41.6128116,41.6634981,urban +4000.0,-3000.0,41.6173083,41.6634981,urban +4000.0,-2500.0,41.6218049,41.6634981,urban +4000.0,-2000.0,41.6263015,41.6634981,urban +4000.0,-1500.0,41.6307981,41.6634981,urban +4000.0,-1000.0,41.6352947,41.6634981,urban +4000.0,-500.0,41.6397913,41.6634981,urban +4000.0,0.0,41.6442879,41.6634981,urban +4000.0,500.0,41.6487845,41.6634981,urban +4000.0,1000.0,41.6532811,41.6634981,open +4000.0,1500.0,41.6577777,41.6634981,open +4000.0,2000.0,41.6622743,41.6634981,open +4000.0,2500.0,41.6667709,41.6634981,open +4000.0,3000.0,41.6712675,41.6634981,open +4000.0,3500.0,41.6757642,41.6634981,open +4000.0,4000.0,41.6802608,41.6634981,open +4000.0,4500.0,41.6847574,41.6634981,open +4000.0,5000.0,41.689254,41.6634981,open +4000.0,5500.0,41.6937506,41.6634981,open +4000.0,6000.0,41.6982472,41.6634981,open +4000.0,6500.0,41.7027438,41.6634981,open +4000.0,7000.0,41.7072404,41.6634981,open +4000.0,7500.0,41.711737,41.6634981,open +4000.0,8000.0,41.7162336,41.6634981,open +4000.0,8500.0,41.7207302,41.6634981,open +4000.0,9000.0,41.7252268,41.6634981,open +4000.0,9500.0,41.7297235,41.6634981,open +4000.0,10000.0,41.7342201,41.6634981,open +4000.0,10500.0,41.7387167,41.6634981,open +4000.0,11000.0,41.7432133,41.6634981,open +4000.0,11500.0,41.7477099,41.6634981,open +4000.0,12000.0,41.7522065,41.6634981,open +4000.0,12500.0,41.7567031,41.6634981,open +4000.0,13000.0,41.7611997,41.6634981,open +4000.0,13500.0,41.7656963,41.6634981,open +4000.0,14000.0,41.7701929,41.6634981,open +4000.0,14500.0,41.7746895,41.6634981,open +4000.0,15000.0,41.7791861,41.6634981,open +4000.0,15500.0,41.7836827,41.6634981,open +4000.0,16000.0,41.7881794,41.6634981,open +4000.0,16500.0,41.792676,41.6634981,open +4000.0,17000.0,41.7971726,41.6634981,open +4000.0,17500.0,41.8016692,41.6634981,open +4000.0,18000.0,41.8061658,41.6634981,open +4000.0,18500.0,41.8106624,41.6634981,open +4000.0,19000.0,41.815159,41.6634981,open +4000.0,19500.0,41.8196556,41.6634981,open +4500.0,-16000.0,41.5003964,41.6695154,open +4500.0,-15500.0,41.5048931,41.6695154,open +4500.0,-15000.0,41.5093897,41.6695154,open +4500.0,-14500.0,41.5138863,41.6695154,open +4500.0,-14000.0,41.5183829,41.6695154,open +4500.0,-13500.0,41.5228795,41.6695154,open +4500.0,-13000.0,41.5273761,41.6695154,open +4500.0,-12500.0,41.5318727,41.6695154,open +4500.0,-12000.0,41.5363693,41.6695154,urban +4500.0,-11500.0,41.5408659,41.6695154,urban +4500.0,-11000.0,41.5453625,41.6695154,urban +4500.0,-10500.0,41.5498591,41.6695154,urban +4500.0,-10000.0,41.5543557,41.6695154,urban +4500.0,-9500.0,41.5588523,41.6695154,urban +4500.0,-9000.0,41.563349,41.6695154,urban +4500.0,-8500.0,41.5678456,41.6695154,urban +4500.0,-8000.0,41.5723422,41.6695154,urban +4500.0,-7500.0,41.5768388,41.6695154,urban +4500.0,-7000.0,41.5813354,41.6695154,urban +4500.0,-6500.0,41.585832,41.6695154,urban +4500.0,-6000.0,41.5903286,41.6695154,urban +4500.0,-5500.0,41.5948252,41.6695154,urban +4500.0,-5000.0,41.5993218,41.6695154,urban +4500.0,-4500.0,41.6038184,41.6695154,urban +4500.0,-4000.0,41.608315,41.6695154,urban +4500.0,-3500.0,41.6128116,41.6695154,urban +4500.0,-3000.0,41.6173083,41.6695154,urban +4500.0,-2500.0,41.6218049,41.6695154,urban +4500.0,-2000.0,41.6263015,41.6695154,urban +4500.0,-1500.0,41.6307981,41.6695154,urban +4500.0,-1000.0,41.6352947,41.6695154,urban +4500.0,-500.0,41.6397913,41.6695154,urban +4500.0,0.0,41.6442879,41.6695154,urban +4500.0,500.0,41.6487845,41.6695154,urban +4500.0,1000.0,41.6532811,41.6695154,urban +4500.0,1500.0,41.6577777,41.6695154,open +4500.0,2000.0,41.6622743,41.6695154,open +4500.0,2500.0,41.6667709,41.6695154,open +4500.0,3000.0,41.6712675,41.6695154,open +4500.0,3500.0,41.6757642,41.6695154,open +4500.0,4000.0,41.6802608,41.6695154,open +4500.0,4500.0,41.6847574,41.6695154,open +4500.0,5000.0,41.689254,41.6695154,open +4500.0,5500.0,41.6937506,41.6695154,open +4500.0,6000.0,41.6982472,41.6695154,open +4500.0,6500.0,41.7027438,41.6695154,open +4500.0,7000.0,41.7072404,41.6695154,open +4500.0,7500.0,41.711737,41.6695154,open +4500.0,8000.0,41.7162336,41.6695154,open +4500.0,8500.0,41.7207302,41.6695154,open +4500.0,9000.0,41.7252268,41.6695154,open +4500.0,9500.0,41.7297235,41.6695154,open +4500.0,10000.0,41.7342201,41.6695154,open +4500.0,10500.0,41.7387167,41.6695154,open +4500.0,11000.0,41.7432133,41.6695154,open +4500.0,11500.0,41.7477099,41.6695154,open +4500.0,12000.0,41.7522065,41.6695154,open +4500.0,12500.0,41.7567031,41.6695154,open +4500.0,13000.0,41.7611997,41.6695154,open +4500.0,13500.0,41.7656963,41.6695154,open +4500.0,14000.0,41.7701929,41.6695154,open +4500.0,14500.0,41.7746895,41.6695154,open +4500.0,15000.0,41.7791861,41.6695154,open +4500.0,15500.0,41.7836827,41.6695154,open +4500.0,16000.0,41.7881794,41.6695154,open +4500.0,16500.0,41.792676,41.6695154,open +4500.0,17000.0,41.7971726,41.6695154,open +4500.0,17500.0,41.8016692,41.6695154,open +4500.0,18000.0,41.8061658,41.6695154,open +4500.0,18500.0,41.8106624,41.6695154,open +4500.0,19000.0,41.815159,41.6695154,open +4500.0,19500.0,41.8196556,41.6695154,open +5000.0,-16000.0,41.5003964,41.6755326,open +5000.0,-15500.0,41.5048931,41.6755326,open +5000.0,-15000.0,41.5093897,41.6755326,open +5000.0,-14500.0,41.5138863,41.6755326,open +5000.0,-14000.0,41.5183829,41.6755326,open +5000.0,-13500.0,41.5228795,41.6755326,open +5000.0,-13000.0,41.5273761,41.6755326,open +5000.0,-12500.0,41.5318727,41.6755326,open +5000.0,-12000.0,41.5363693,41.6755326,open +5000.0,-11500.0,41.5408659,41.6755326,urban +5000.0,-11000.0,41.5453625,41.6755326,urban +5000.0,-10500.0,41.5498591,41.6755326,urban +5000.0,-10000.0,41.5543557,41.6755326,urban +5000.0,-9500.0,41.5588523,41.6755326,urban +5000.0,-9000.0,41.563349,41.6755326,urban +5000.0,-8500.0,41.5678456,41.6755326,open +5000.0,-8000.0,41.5723422,41.6755326,urban +5000.0,-7500.0,41.5768388,41.6755326,urban +5000.0,-7000.0,41.5813354,41.6755326,urban +5000.0,-6500.0,41.585832,41.6755326,urban +5000.0,-6000.0,41.5903286,41.6755326,urban +5000.0,-5500.0,41.5948252,41.6755326,urban +5000.0,-5000.0,41.5993218,41.6755326,urban +5000.0,-4500.0,41.6038184,41.6755326,urban +5000.0,-4000.0,41.608315,41.6755326,urban +5000.0,-3500.0,41.6128116,41.6755326,urban +5000.0,-3000.0,41.6173083,41.6755326,urban +5000.0,-2500.0,41.6218049,41.6755326,urban +5000.0,-2000.0,41.6263015,41.6755326,urban +5000.0,-1500.0,41.6307981,41.6755326,urban +5000.0,-1000.0,41.6352947,41.6755326,urban +5000.0,-500.0,41.6397913,41.6755326,urban +5000.0,0.0,41.6442879,41.6755326,urban +5000.0,500.0,41.6487845,41.6755326,urban +5000.0,1000.0,41.6532811,41.6755326,urban +5000.0,1500.0,41.6577777,41.6755326,urban +5000.0,2000.0,41.6622743,41.6755326,urban +5000.0,2500.0,41.6667709,41.6755326,open +5000.0,3000.0,41.6712675,41.6755326,open +5000.0,3500.0,41.6757642,41.6755326,open +5000.0,4000.0,41.6802608,41.6755326,open +5000.0,4500.0,41.6847574,41.6755326,open +5000.0,5000.0,41.689254,41.6755326,open +5000.0,5500.0,41.6937506,41.6755326,open +5000.0,6000.0,41.6982472,41.6755326,open +5000.0,6500.0,41.7027438,41.6755326,open +5000.0,7000.0,41.7072404,41.6755326,open +5000.0,7500.0,41.711737,41.6755326,open +5000.0,8000.0,41.7162336,41.6755326,open +5000.0,8500.0,41.7207302,41.6755326,open +5000.0,9000.0,41.7252268,41.6755326,open +5000.0,9500.0,41.7297235,41.6755326,open +5000.0,10000.0,41.7342201,41.6755326,open +5000.0,10500.0,41.7387167,41.6755326,open +5000.0,11000.0,41.7432133,41.6755326,open +5000.0,11500.0,41.7477099,41.6755326,open +5000.0,12000.0,41.7522065,41.6755326,open +5000.0,12500.0,41.7567031,41.6755326,open +5000.0,13000.0,41.7611997,41.6755326,open +5000.0,13500.0,41.7656963,41.6755326,open +5000.0,14000.0,41.7701929,41.6755326,open +5000.0,14500.0,41.7746895,41.6755326,open +5000.0,15000.0,41.7791861,41.6755326,open +5000.0,15500.0,41.7836827,41.6755326,open +5000.0,16000.0,41.7881794,41.6755326,open +5000.0,16500.0,41.792676,41.6755326,open +5000.0,17000.0,41.7971726,41.6755326,open +5000.0,17500.0,41.8016692,41.6755326,open +5000.0,18000.0,41.8061658,41.6755326,open +5000.0,18500.0,41.8106624,41.6755326,open +5000.0,19000.0,41.815159,41.6755326,open +5000.0,19500.0,41.8196556,41.6755326,open +5500.0,-16000.0,41.5003964,41.6815499,open +5500.0,-15500.0,41.5048931,41.6815499,open +5500.0,-15000.0,41.5093897,41.6815499,open +5500.0,-14500.0,41.5138863,41.6815499,open +5500.0,-14000.0,41.5183829,41.6815499,open +5500.0,-13500.0,41.5228795,41.6815499,open +5500.0,-13000.0,41.5273761,41.6815499,open +5500.0,-12500.0,41.5318727,41.6815499,open +5500.0,-12000.0,41.5363693,41.6815499,open +5500.0,-11500.0,41.5408659,41.6815499,open +5500.0,-11000.0,41.5453625,41.6815499,urban +5500.0,-10500.0,41.5498591,41.6815499,urban +5500.0,-10000.0,41.5543557,41.6815499,urban +5500.0,-9500.0,41.5588523,41.6815499,urban +5500.0,-9000.0,41.563349,41.6815499,urban +5500.0,-8500.0,41.5678456,41.6815499,urban +5500.0,-8000.0,41.5723422,41.6815499,urban +5500.0,-7500.0,41.5768388,41.6815499,open +5500.0,-7000.0,41.5813354,41.6815499,forest +5500.0,-6500.0,41.585832,41.6815499,urban +5500.0,-6000.0,41.5903286,41.6815499,urban +5500.0,-5500.0,41.5948252,41.6815499,urban +5500.0,-5000.0,41.5993218,41.6815499,urban +5500.0,-4500.0,41.6038184,41.6815499,urban +5500.0,-4000.0,41.608315,41.6815499,urban +5500.0,-3500.0,41.6128116,41.6815499,urban +5500.0,-3000.0,41.6173083,41.6815499,urban +5500.0,-2500.0,41.6218049,41.6815499,urban +5500.0,-2000.0,41.6263015,41.6815499,urban +5500.0,-1500.0,41.6307981,41.6815499,urban +5500.0,-1000.0,41.6352947,41.6815499,urban +5500.0,-500.0,41.6397913,41.6815499,urban +5500.0,0.0,41.6442879,41.6815499,urban +5500.0,500.0,41.6487845,41.6815499,urban +5500.0,1000.0,41.6532811,41.6815499,urban +5500.0,1500.0,41.6577777,41.6815499,urban +5500.0,2000.0,41.6622743,41.6815499,urban +5500.0,2500.0,41.6667709,41.6815499,urban +5500.0,3000.0,41.6712675,41.6815499,open +5500.0,3500.0,41.6757642,41.6815499,open +5500.0,4000.0,41.6802608,41.6815499,open +5500.0,4500.0,41.6847574,41.6815499,open +5500.0,5000.0,41.689254,41.6815499,open +5500.0,5500.0,41.6937506,41.6815499,open +5500.0,6000.0,41.6982472,41.6815499,open +5500.0,6500.0,41.7027438,41.6815499,open +5500.0,7000.0,41.7072404,41.6815499,open +5500.0,7500.0,41.711737,41.6815499,open +5500.0,8000.0,41.7162336,41.6815499,open +5500.0,8500.0,41.7207302,41.6815499,open +5500.0,9000.0,41.7252268,41.6815499,open +5500.0,9500.0,41.7297235,41.6815499,open +5500.0,10000.0,41.7342201,41.6815499,open +5500.0,10500.0,41.7387167,41.6815499,open +5500.0,11000.0,41.7432133,41.6815499,open +5500.0,11500.0,41.7477099,41.6815499,open +5500.0,12000.0,41.7522065,41.6815499,open +5500.0,12500.0,41.7567031,41.6815499,open +5500.0,13000.0,41.7611997,41.6815499,open +5500.0,13500.0,41.7656963,41.6815499,open +5500.0,14000.0,41.7701929,41.6815499,open +5500.0,14500.0,41.7746895,41.6815499,open +5500.0,15000.0,41.7791861,41.6815499,open +5500.0,15500.0,41.7836827,41.6815499,open +5500.0,16000.0,41.7881794,41.6815499,open +5500.0,16500.0,41.792676,41.6815499,open +5500.0,17000.0,41.7971726,41.6815499,open +5500.0,17500.0,41.8016692,41.6815499,open +5500.0,18000.0,41.8061658,41.6815499,open +5500.0,18500.0,41.8106624,41.6815499,open +5500.0,19000.0,41.815159,41.6815499,open +5500.0,19500.0,41.8196556,41.6815499,open +6000.0,-16000.0,41.5003964,41.6875671,urban +6000.0,-15500.0,41.5048931,41.6875671,open +6000.0,-15000.0,41.5093897,41.6875671,open +6000.0,-14500.0,41.5138863,41.6875671,open +6000.0,-14000.0,41.5183829,41.6875671,open +6000.0,-13500.0,41.5228795,41.6875671,open +6000.0,-13000.0,41.5273761,41.6875671,open +6000.0,-12500.0,41.5318727,41.6875671,open +6000.0,-12000.0,41.5363693,41.6875671,open +6000.0,-11500.0,41.5408659,41.6875671,urban +6000.0,-11000.0,41.5453625,41.6875671,urban +6000.0,-10500.0,41.5498591,41.6875671,urban +6000.0,-10000.0,41.5543557,41.6875671,urban +6000.0,-9500.0,41.5588523,41.6875671,urban +6000.0,-9000.0,41.563349,41.6875671,urban +6000.0,-8500.0,41.5678456,41.6875671,urban +6000.0,-8000.0,41.5723422,41.6875671,urban +6000.0,-7500.0,41.5768388,41.6875671,urban +6000.0,-7000.0,41.5813354,41.6875671,urban +6000.0,-6500.0,41.585832,41.6875671,open +6000.0,-6000.0,41.5903286,41.6875671,urban +6000.0,-5500.0,41.5948252,41.6875671,urban +6000.0,-5000.0,41.5993218,41.6875671,urban +6000.0,-4500.0,41.6038184,41.6875671,urban +6000.0,-4000.0,41.608315,41.6875671,urban +6000.0,-3500.0,41.6128116,41.6875671,urban +6000.0,-3000.0,41.6173083,41.6875671,urban +6000.0,-2500.0,41.6218049,41.6875671,urban +6000.0,-2000.0,41.6263015,41.6875671,urban +6000.0,-1500.0,41.6307981,41.6875671,urban +6000.0,-1000.0,41.6352947,41.6875671,urban +6000.0,-500.0,41.6397913,41.6875671,urban +6000.0,0.0,41.6442879,41.6875671,urban +6000.0,500.0,41.6487845,41.6875671,urban +6000.0,1000.0,41.6532811,41.6875671,urban +6000.0,1500.0,41.6577777,41.6875671,urban +6000.0,2000.0,41.6622743,41.6875671,urban +6000.0,2500.0,41.6667709,41.6875671,urban +6000.0,3000.0,41.6712675,41.6875671,urban +6000.0,3500.0,41.6757642,41.6875671,open +6000.0,4000.0,41.6802608,41.6875671,open +6000.0,4500.0,41.6847574,41.6875671,open +6000.0,5000.0,41.689254,41.6875671,open +6000.0,5500.0,41.6937506,41.6875671,open +6000.0,6000.0,41.6982472,41.6875671,open +6000.0,6500.0,41.7027438,41.6875671,open +6000.0,7000.0,41.7072404,41.6875671,open +6000.0,7500.0,41.711737,41.6875671,open +6000.0,8000.0,41.7162336,41.6875671,open +6000.0,8500.0,41.7207302,41.6875671,open +6000.0,9000.0,41.7252268,41.6875671,open +6000.0,9500.0,41.7297235,41.6875671,open +6000.0,10000.0,41.7342201,41.6875671,open +6000.0,10500.0,41.7387167,41.6875671,open +6000.0,11000.0,41.7432133,41.6875671,open +6000.0,11500.0,41.7477099,41.6875671,open +6000.0,12000.0,41.7522065,41.6875671,open +6000.0,12500.0,41.7567031,41.6875671,open +6000.0,13000.0,41.7611997,41.6875671,open +6000.0,13500.0,41.7656963,41.6875671,open +6000.0,14000.0,41.7701929,41.6875671,open +6000.0,14500.0,41.7746895,41.6875671,open +6000.0,15000.0,41.7791861,41.6875671,open +6000.0,15500.0,41.7836827,41.6875671,open +6000.0,16000.0,41.7881794,41.6875671,open +6000.0,16500.0,41.792676,41.6875671,open +6000.0,17000.0,41.7971726,41.6875671,open +6000.0,17500.0,41.8016692,41.6875671,open +6000.0,18000.0,41.8061658,41.6875671,open +6000.0,18500.0,41.8106624,41.6875671,open +6000.0,19000.0,41.815159,41.6875671,open +6000.0,19500.0,41.8196556,41.6875671,open +6500.0,-16000.0,41.5003964,41.6935844,urban +6500.0,-15500.0,41.5048931,41.6935844,urban +6500.0,-15000.0,41.5093897,41.6935844,open +6500.0,-14500.0,41.5138863,41.6935844,open +6500.0,-14000.0,41.5183829,41.6935844,open +6500.0,-13500.0,41.5228795,41.6935844,open +6500.0,-13000.0,41.5273761,41.6935844,open +6500.0,-12500.0,41.5318727,41.6935844,open +6500.0,-12000.0,41.5363693,41.6935844,open +6500.0,-11500.0,41.5408659,41.6935844,open +6500.0,-11000.0,41.5453625,41.6935844,urban +6500.0,-10500.0,41.5498591,41.6935844,water +6500.0,-10000.0,41.5543557,41.6935844,urban +6500.0,-9500.0,41.5588523,41.6935844,urban +6500.0,-9000.0,41.563349,41.6935844,urban +6500.0,-8500.0,41.5678456,41.6935844,urban +6500.0,-8000.0,41.5723422,41.6935844,urban +6500.0,-7500.0,41.5768388,41.6935844,urban +6500.0,-7000.0,41.5813354,41.6935844,urban +6500.0,-6500.0,41.585832,41.6935844,urban +6500.0,-6000.0,41.5903286,41.6935844,urban +6500.0,-5500.0,41.5948252,41.6935844,urban +6500.0,-5000.0,41.5993218,41.6935844,urban +6500.0,-4500.0,41.6038184,41.6935844,urban +6500.0,-4000.0,41.608315,41.6935844,urban +6500.0,-3500.0,41.6128116,41.6935844,urban +6500.0,-3000.0,41.6173083,41.6935844,urban +6500.0,-2500.0,41.6218049,41.6935844,urban +6500.0,-2000.0,41.6263015,41.6935844,urban +6500.0,-1500.0,41.6307981,41.6935844,urban +6500.0,-1000.0,41.6352947,41.6935844,urban +6500.0,-500.0,41.6397913,41.6935844,urban +6500.0,0.0,41.6442879,41.6935844,urban +6500.0,500.0,41.6487845,41.6935844,urban +6500.0,1000.0,41.6532811,41.6935844,urban +6500.0,1500.0,41.6577777,41.6935844,urban +6500.0,2000.0,41.6622743,41.6935844,urban +6500.0,2500.0,41.6667709,41.6935844,urban +6500.0,3000.0,41.6712675,41.6935844,urban +6500.0,3500.0,41.6757642,41.6935844,urban +6500.0,4000.0,41.6802608,41.6935844,urban +6500.0,4500.0,41.6847574,41.6935844,open +6500.0,5000.0,41.689254,41.6935844,open +6500.0,5500.0,41.6937506,41.6935844,open +6500.0,6000.0,41.6982472,41.6935844,open +6500.0,6500.0,41.7027438,41.6935844,open +6500.0,7000.0,41.7072404,41.6935844,open +6500.0,7500.0,41.711737,41.6935844,open +6500.0,8000.0,41.7162336,41.6935844,open +6500.0,8500.0,41.7207302,41.6935844,open +6500.0,9000.0,41.7252268,41.6935844,open +6500.0,9500.0,41.7297235,41.6935844,open +6500.0,10000.0,41.7342201,41.6935844,open +6500.0,10500.0,41.7387167,41.6935844,open +6500.0,11000.0,41.7432133,41.6935844,open +6500.0,11500.0,41.7477099,41.6935844,open +6500.0,12000.0,41.7522065,41.6935844,open +6500.0,12500.0,41.7567031,41.6935844,open +6500.0,13000.0,41.7611997,41.6935844,open +6500.0,13500.0,41.7656963,41.6935844,open +6500.0,14000.0,41.7701929,41.6935844,open +6500.0,14500.0,41.7746895,41.6935844,open +6500.0,15000.0,41.7791861,41.6935844,open +6500.0,15500.0,41.7836827,41.6935844,open +6500.0,16000.0,41.7881794,41.6935844,open +6500.0,16500.0,41.792676,41.6935844,open +6500.0,17000.0,41.7971726,41.6935844,open +6500.0,17500.0,41.8016692,41.6935844,open +6500.0,18000.0,41.8061658,41.6935844,open +6500.0,18500.0,41.8106624,41.6935844,open +6500.0,19000.0,41.815159,41.6935844,open +6500.0,19500.0,41.8196556,41.6935844,open +7000.0,-16000.0,41.5003964,41.6996017,open +7000.0,-15500.0,41.5048931,41.6996017,open +7000.0,-15000.0,41.5093897,41.6996017,open +7000.0,-14500.0,41.5138863,41.6996017,open +7000.0,-14000.0,41.5183829,41.6996017,open +7000.0,-13500.0,41.5228795,41.6996017,open +7000.0,-13000.0,41.5273761,41.6996017,open +7000.0,-12500.0,41.5318727,41.6996017,open +7000.0,-12000.0,41.5363693,41.6996017,open +7000.0,-11500.0,41.5408659,41.6996017,open +7000.0,-11000.0,41.5453625,41.6996017,open +7000.0,-10500.0,41.5498591,41.6996017,water +7000.0,-10000.0,41.5543557,41.6996017,urban +7000.0,-9500.0,41.5588523,41.6996017,urban +7000.0,-9000.0,41.563349,41.6996017,urban +7000.0,-8500.0,41.5678456,41.6996017,urban +7000.0,-8000.0,41.5723422,41.6996017,urban +7000.0,-7500.0,41.5768388,41.6996017,urban +7000.0,-7000.0,41.5813354,41.6996017,urban +7000.0,-6500.0,41.585832,41.6996017,open +7000.0,-6000.0,41.5903286,41.6996017,urban +7000.0,-5500.0,41.5948252,41.6996017,urban +7000.0,-5000.0,41.5993218,41.6996017,urban +7000.0,-4500.0,41.6038184,41.6996017,urban +7000.0,-4000.0,41.608315,41.6996017,urban +7000.0,-3500.0,41.6128116,41.6996017,urban +7000.0,-3000.0,41.6173083,41.6996017,urban +7000.0,-2500.0,41.6218049,41.6996017,urban +7000.0,-2000.0,41.6263015,41.6996017,urban +7000.0,-1500.0,41.6307981,41.6996017,urban +7000.0,-1000.0,41.6352947,41.6996017,urban +7000.0,-500.0,41.6397913,41.6996017,urban +7000.0,0.0,41.6442879,41.6996017,urban +7000.0,500.0,41.6487845,41.6996017,urban +7000.0,1000.0,41.6532811,41.6996017,urban +7000.0,1500.0,41.6577777,41.6996017,urban +7000.0,2000.0,41.6622743,41.6996017,urban +7000.0,2500.0,41.6667709,41.6996017,urban +7000.0,3000.0,41.6712675,41.6996017,urban +7000.0,3500.0,41.6757642,41.6996017,urban +7000.0,4000.0,41.6802608,41.6996017,urban +7000.0,4500.0,41.6847574,41.6996017,urban +7000.0,5000.0,41.689254,41.6996017,urban +7000.0,5500.0,41.6937506,41.6996017,open +7000.0,6000.0,41.6982472,41.6996017,open +7000.0,6500.0,41.7027438,41.6996017,open +7000.0,7000.0,41.7072404,41.6996017,open +7000.0,7500.0,41.711737,41.6996017,open +7000.0,8000.0,41.7162336,41.6996017,open +7000.0,8500.0,41.7207302,41.6996017,open +7000.0,9000.0,41.7252268,41.6996017,open +7000.0,9500.0,41.7297235,41.6996017,open +7000.0,10000.0,41.7342201,41.6996017,open +7000.0,10500.0,41.7387167,41.6996017,open +7000.0,11000.0,41.7432133,41.6996017,open +7000.0,11500.0,41.7477099,41.6996017,open +7000.0,12000.0,41.7522065,41.6996017,open +7000.0,12500.0,41.7567031,41.6996017,open +7000.0,13000.0,41.7611997,41.6996017,open +7000.0,13500.0,41.7656963,41.6996017,open +7000.0,14000.0,41.7701929,41.6996017,open +7000.0,14500.0,41.7746895,41.6996017,open +7000.0,15000.0,41.7791861,41.6996017,open +7000.0,15500.0,41.7836827,41.6996017,open +7000.0,16000.0,41.7881794,41.6996017,open +7000.0,16500.0,41.792676,41.6996017,open +7000.0,17000.0,41.7971726,41.6996017,open +7000.0,17500.0,41.8016692,41.6996017,open +7000.0,18000.0,41.8061658,41.6996017,open +7000.0,18500.0,41.8106624,41.6996017,open +7000.0,19000.0,41.815159,41.6996017,open +7000.0,19500.0,41.8196556,41.6996017,open +7500.0,-16000.0,41.5003964,41.7056189,urban +7500.0,-15500.0,41.5048931,41.7056189,urban +7500.0,-15000.0,41.5093897,41.7056189,urban +7500.0,-14500.0,41.5138863,41.7056189,open +7500.0,-14000.0,41.5183829,41.7056189,open +7500.0,-13500.0,41.5228795,41.7056189,open +7500.0,-13000.0,41.5273761,41.7056189,open +7500.0,-12500.0,41.5318727,41.7056189,open +7500.0,-12000.0,41.5363693,41.7056189,open +7500.0,-11500.0,41.5408659,41.7056189,open +7500.0,-11000.0,41.5453625,41.7056189,water +7500.0,-10500.0,41.5498591,41.7056189,open +7500.0,-10000.0,41.5543557,41.7056189,open +7500.0,-9500.0,41.5588523,41.7056189,open +7500.0,-9000.0,41.563349,41.7056189,open +7500.0,-8500.0,41.5678456,41.7056189,open +7500.0,-8000.0,41.5723422,41.7056189,urban +7500.0,-7500.0,41.5768388,41.7056189,urban +7500.0,-7000.0,41.5813354,41.7056189,open +7500.0,-6500.0,41.585832,41.7056189,open +7500.0,-6000.0,41.5903286,41.7056189,open +7500.0,-5500.0,41.5948252,41.7056189,open +7500.0,-5000.0,41.5993218,41.7056189,open +7500.0,-4500.0,41.6038184,41.7056189,urban +7500.0,-4000.0,41.608315,41.7056189,urban +7500.0,-3500.0,41.6128116,41.7056189,urban +7500.0,-3000.0,41.6173083,41.7056189,urban +7500.0,-2500.0,41.6218049,41.7056189,urban +7500.0,-2000.0,41.6263015,41.7056189,urban +7500.0,-1500.0,41.6307981,41.7056189,urban +7500.0,-1000.0,41.6352947,41.7056189,urban +7500.0,-500.0,41.6397913,41.7056189,urban +7500.0,0.0,41.6442879,41.7056189,urban +7500.0,500.0,41.6487845,41.7056189,urban +7500.0,1000.0,41.6532811,41.7056189,urban +7500.0,1500.0,41.6577777,41.7056189,urban +7500.0,2000.0,41.6622743,41.7056189,urban +7500.0,2500.0,41.6667709,41.7056189,urban +7500.0,3000.0,41.6712675,41.7056189,urban +7500.0,3500.0,41.6757642,41.7056189,urban +7500.0,4000.0,41.6802608,41.7056189,urban +7500.0,4500.0,41.6847574,41.7056189,urban +7500.0,5000.0,41.689254,41.7056189,urban +7500.0,5500.0,41.6937506,41.7056189,urban +7500.0,6000.0,41.6982472,41.7056189,open +7500.0,6500.0,41.7027438,41.7056189,open +7500.0,7000.0,41.7072404,41.7056189,open +7500.0,7500.0,41.711737,41.7056189,open +7500.0,8000.0,41.7162336,41.7056189,open +7500.0,8500.0,41.7207302,41.7056189,open +7500.0,9000.0,41.7252268,41.7056189,open +7500.0,9500.0,41.7297235,41.7056189,open +7500.0,10000.0,41.7342201,41.7056189,open +7500.0,10500.0,41.7387167,41.7056189,open +7500.0,11000.0,41.7432133,41.7056189,open +7500.0,11500.0,41.7477099,41.7056189,open +7500.0,12000.0,41.7522065,41.7056189,open +7500.0,12500.0,41.7567031,41.7056189,open +7500.0,13000.0,41.7611997,41.7056189,open +7500.0,13500.0,41.7656963,41.7056189,open +7500.0,14000.0,41.7701929,41.7056189,open +7500.0,14500.0,41.7746895,41.7056189,open +7500.0,15000.0,41.7791861,41.7056189,open +7500.0,15500.0,41.7836827,41.7056189,open +7500.0,16000.0,41.7881794,41.7056189,open +7500.0,16500.0,41.792676,41.7056189,open +7500.0,17000.0,41.7971726,41.7056189,open +7500.0,17500.0,41.8016692,41.7056189,open +7500.0,18000.0,41.8061658,41.7056189,open +7500.0,18500.0,41.8106624,41.7056189,open +7500.0,19000.0,41.815159,41.7056189,open +7500.0,19500.0,41.8196556,41.7056189,open +8000.0,-16000.0,41.5003964,41.7116362,urban +8000.0,-15500.0,41.5048931,41.7116362,urban +8000.0,-15000.0,41.5093897,41.7116362,urban +8000.0,-14500.0,41.5138863,41.7116362,urban +8000.0,-14000.0,41.5183829,41.7116362,urban +8000.0,-13500.0,41.5228795,41.7116362,open +8000.0,-13000.0,41.5273761,41.7116362,urban +8000.0,-12500.0,41.5318727,41.7116362,urban +8000.0,-12000.0,41.5363693,41.7116362,open +8000.0,-11500.0,41.5408659,41.7116362,open +8000.0,-11000.0,41.5453625,41.7116362,urban +8000.0,-10500.0,41.5498591,41.7116362,urban +8000.0,-10000.0,41.5543557,41.7116362,open +8000.0,-9500.0,41.5588523,41.7116362,open +8000.0,-9000.0,41.563349,41.7116362,open +8000.0,-8500.0,41.5678456,41.7116362,open +8000.0,-8000.0,41.5723422,41.7116362,urban +8000.0,-7500.0,41.5768388,41.7116362,urban +8000.0,-7000.0,41.5813354,41.7116362,urban +8000.0,-6500.0,41.585832,41.7116362,open +8000.0,-6000.0,41.5903286,41.7116362,open +8000.0,-5500.0,41.5948252,41.7116362,open +8000.0,-5000.0,41.5993218,41.7116362,open +8000.0,-4500.0,41.6038184,41.7116362,open +8000.0,-4000.0,41.608315,41.7116362,urban +8000.0,-3500.0,41.6128116,41.7116362,urban +8000.0,-3000.0,41.6173083,41.7116362,urban +8000.0,-2500.0,41.6218049,41.7116362,urban +8000.0,-2000.0,41.6263015,41.7116362,urban +8000.0,-1500.0,41.6307981,41.7116362,urban +8000.0,-1000.0,41.6352947,41.7116362,urban +8000.0,-500.0,41.6397913,41.7116362,urban +8000.0,0.0,41.6442879,41.7116362,urban +8000.0,500.0,41.6487845,41.7116362,urban +8000.0,1000.0,41.6532811,41.7116362,urban +8000.0,1500.0,41.6577777,41.7116362,urban +8000.0,2000.0,41.6622743,41.7116362,urban +8000.0,2500.0,41.6667709,41.7116362,urban +8000.0,3000.0,41.6712675,41.7116362,urban +8000.0,3500.0,41.6757642,41.7116362,urban +8000.0,4000.0,41.6802608,41.7116362,urban +8000.0,4500.0,41.6847574,41.7116362,urban +8000.0,5000.0,41.689254,41.7116362,urban +8000.0,5500.0,41.6937506,41.7116362,urban +8000.0,6000.0,41.6982472,41.7116362,urban +8000.0,6500.0,41.7027438,41.7116362,open +8000.0,7000.0,41.7072404,41.7116362,open +8000.0,7500.0,41.711737,41.7116362,open +8000.0,8000.0,41.7162336,41.7116362,open +8000.0,8500.0,41.7207302,41.7116362,open +8000.0,9000.0,41.7252268,41.7116362,open +8000.0,9500.0,41.7297235,41.7116362,open +8000.0,10000.0,41.7342201,41.7116362,open +8000.0,10500.0,41.7387167,41.7116362,open +8000.0,11000.0,41.7432133,41.7116362,open +8000.0,11500.0,41.7477099,41.7116362,open +8000.0,12000.0,41.7522065,41.7116362,open +8000.0,12500.0,41.7567031,41.7116362,open +8000.0,13000.0,41.7611997,41.7116362,open +8000.0,13500.0,41.7656963,41.7116362,open +8000.0,14000.0,41.7701929,41.7116362,open +8000.0,14500.0,41.7746895,41.7116362,open +8000.0,15000.0,41.7791861,41.7116362,open +8000.0,15500.0,41.7836827,41.7116362,open +8000.0,16000.0,41.7881794,41.7116362,open +8000.0,16500.0,41.792676,41.7116362,open +8000.0,17000.0,41.7971726,41.7116362,open +8000.0,17500.0,41.8016692,41.7116362,open +8000.0,18000.0,41.8061658,41.7116362,open +8000.0,18500.0,41.8106624,41.7116362,open +8000.0,19000.0,41.815159,41.7116362,open +8000.0,19500.0,41.8196556,41.7116362,open +8500.0,-16000.0,41.5003964,41.7176535,urban +8500.0,-15500.0,41.5048931,41.7176535,open +8500.0,-15000.0,41.5093897,41.7176535,urban +8500.0,-14500.0,41.5138863,41.7176535,urban +8500.0,-14000.0,41.5183829,41.7176535,urban +8500.0,-13500.0,41.5228795,41.7176535,urban +8500.0,-13000.0,41.5273761,41.7176535,urban +8500.0,-12500.0,41.5318727,41.7176535,urban +8500.0,-12000.0,41.5363693,41.7176535,urban +8500.0,-11500.0,41.5408659,41.7176535,open +8500.0,-11000.0,41.5453625,41.7176535,urban +8500.0,-10500.0,41.5498591,41.7176535,urban +8500.0,-10000.0,41.5543557,41.7176535,urban +8500.0,-9500.0,41.5588523,41.7176535,open +8500.0,-9000.0,41.563349,41.7176535,open +8500.0,-8500.0,41.5678456,41.7176535,open +8500.0,-8000.0,41.5723422,41.7176535,urban +8500.0,-7500.0,41.5768388,41.7176535,urban +8500.0,-7000.0,41.5813354,41.7176535,urban +8500.0,-6500.0,41.585832,41.7176535,urban +8500.0,-6000.0,41.5903286,41.7176535,open +8500.0,-5500.0,41.5948252,41.7176535,open +8500.0,-5000.0,41.5993218,41.7176535,urban +8500.0,-4500.0,41.6038184,41.7176535,open +8500.0,-4000.0,41.608315,41.7176535,open +8500.0,-3500.0,41.6128116,41.7176535,urban +8500.0,-3000.0,41.6173083,41.7176535,urban +8500.0,-2500.0,41.6218049,41.7176535,urban +8500.0,-2000.0,41.6263015,41.7176535,urban +8500.0,-1500.0,41.6307981,41.7176535,urban +8500.0,-1000.0,41.6352947,41.7176535,urban +8500.0,-500.0,41.6397913,41.7176535,urban +8500.0,0.0,41.6442879,41.7176535,urban +8500.0,500.0,41.6487845,41.7176535,urban +8500.0,1000.0,41.6532811,41.7176535,urban +8500.0,1500.0,41.6577777,41.7176535,urban +8500.0,2000.0,41.6622743,41.7176535,urban +8500.0,2500.0,41.6667709,41.7176535,urban +8500.0,3000.0,41.6712675,41.7176535,urban +8500.0,3500.0,41.6757642,41.7176535,urban +8500.0,4000.0,41.6802608,41.7176535,urban +8500.0,4500.0,41.6847574,41.7176535,urban +8500.0,5000.0,41.689254,41.7176535,urban +8500.0,5500.0,41.6937506,41.7176535,urban +8500.0,6000.0,41.6982472,41.7176535,urban +8500.0,6500.0,41.7027438,41.7176535,urban +8500.0,7000.0,41.7072404,41.7176535,urban +8500.0,7500.0,41.711737,41.7176535,open +8500.0,8000.0,41.7162336,41.7176535,open +8500.0,8500.0,41.7207302,41.7176535,open +8500.0,9000.0,41.7252268,41.7176535,open +8500.0,9500.0,41.7297235,41.7176535,open +8500.0,10000.0,41.7342201,41.7176535,open +8500.0,10500.0,41.7387167,41.7176535,open +8500.0,11000.0,41.7432133,41.7176535,open +8500.0,11500.0,41.7477099,41.7176535,open +8500.0,12000.0,41.7522065,41.7176535,open +8500.0,12500.0,41.7567031,41.7176535,open +8500.0,13000.0,41.7611997,41.7176535,open +8500.0,13500.0,41.7656963,41.7176535,open +8500.0,14000.0,41.7701929,41.7176535,open +8500.0,14500.0,41.7746895,41.7176535,open +8500.0,15000.0,41.7791861,41.7176535,open +8500.0,15500.0,41.7836827,41.7176535,open +8500.0,16000.0,41.7881794,41.7176535,open +8500.0,16500.0,41.792676,41.7176535,open +8500.0,17000.0,41.7971726,41.7176535,open +8500.0,17500.0,41.8016692,41.7176535,open +8500.0,18000.0,41.8061658,41.7176535,open +8500.0,18500.0,41.8106624,41.7176535,open +8500.0,19000.0,41.815159,41.7176535,open +8500.0,19500.0,41.8196556,41.7176535,open +9000.0,-16000.0,41.5003964,41.7236707,urban +9000.0,-15500.0,41.5048931,41.7236707,open +9000.0,-15000.0,41.5093897,41.7236707,urban +9000.0,-14500.0,41.5138863,41.7236707,urban +9000.0,-14000.0,41.5183829,41.7236707,urban +9000.0,-13500.0,41.5228795,41.7236707,urban +9000.0,-13000.0,41.5273761,41.7236707,urban +9000.0,-12500.0,41.5318727,41.7236707,open +9000.0,-12000.0,41.5363693,41.7236707,open +9000.0,-11500.0,41.5408659,41.7236707,urban +9000.0,-11000.0,41.5453625,41.7236707,urban +9000.0,-10500.0,41.5498591,41.7236707,urban +9000.0,-10000.0,41.5543557,41.7236707,urban +9000.0,-9500.0,41.5588523,41.7236707,open +9000.0,-9000.0,41.563349,41.7236707,open +9000.0,-8500.0,41.5678456,41.7236707,open +9000.0,-8000.0,41.5723422,41.7236707,open +9000.0,-7500.0,41.5768388,41.7236707,open +9000.0,-7000.0,41.5813354,41.7236707,open +9000.0,-6500.0,41.585832,41.7236707,open +9000.0,-6000.0,41.5903286,41.7236707,open +9000.0,-5500.0,41.5948252,41.7236707,open +9000.0,-5000.0,41.5993218,41.7236707,open +9000.0,-4500.0,41.6038184,41.7236707,open +9000.0,-4000.0,41.608315,41.7236707,open +9000.0,-3500.0,41.6128116,41.7236707,urban +9000.0,-3000.0,41.6173083,41.7236707,urban +9000.0,-2500.0,41.6218049,41.7236707,open +9000.0,-2000.0,41.6263015,41.7236707,urban +9000.0,-1500.0,41.6307981,41.7236707,urban +9000.0,-1000.0,41.6352947,41.7236707,urban +9000.0,-500.0,41.6397913,41.7236707,urban +9000.0,0.0,41.6442879,41.7236707,urban +9000.0,500.0,41.6487845,41.7236707,urban +9000.0,1000.0,41.6532811,41.7236707,urban +9000.0,1500.0,41.6577777,41.7236707,urban +9000.0,2000.0,41.6622743,41.7236707,urban +9000.0,2500.0,41.6667709,41.7236707,urban +9000.0,3000.0,41.6712675,41.7236707,urban +9000.0,3500.0,41.6757642,41.7236707,urban +9000.0,4000.0,41.6802608,41.7236707,urban +9000.0,4500.0,41.6847574,41.7236707,urban +9000.0,5000.0,41.689254,41.7236707,urban +9000.0,5500.0,41.6937506,41.7236707,urban +9000.0,6000.0,41.6982472,41.7236707,urban +9000.0,6500.0,41.7027438,41.7236707,urban +9000.0,7000.0,41.7072404,41.7236707,urban +9000.0,7500.0,41.711737,41.7236707,urban +9000.0,8000.0,41.7162336,41.7236707,urban +9000.0,8500.0,41.7207302,41.7236707,urban +9000.0,9000.0,41.7252268,41.7236707,open +9000.0,9500.0,41.7297235,41.7236707,open +9000.0,10000.0,41.7342201,41.7236707,open +9000.0,10500.0,41.7387167,41.7236707,open +9000.0,11000.0,41.7432133,41.7236707,open +9000.0,11500.0,41.7477099,41.7236707,open +9000.0,12000.0,41.7522065,41.7236707,open +9000.0,12500.0,41.7567031,41.7236707,open +9000.0,13000.0,41.7611997,41.7236707,open +9000.0,13500.0,41.7656963,41.7236707,open +9000.0,14000.0,41.7701929,41.7236707,open +9000.0,14500.0,41.7746895,41.7236707,open +9000.0,15000.0,41.7791861,41.7236707,open +9000.0,15500.0,41.7836827,41.7236707,open +9000.0,16000.0,41.7881794,41.7236707,open +9000.0,16500.0,41.792676,41.7236707,open +9000.0,17000.0,41.7971726,41.7236707,open +9000.0,17500.0,41.8016692,41.7236707,open +9000.0,18000.0,41.8061658,41.7236707,open +9000.0,18500.0,41.8106624,41.7236707,open +9000.0,19000.0,41.815159,41.7236707,open +9000.0,19500.0,41.8196556,41.7236707,open +9500.0,-16000.0,41.5003964,41.729688,open +9500.0,-15500.0,41.5048931,41.729688,open +9500.0,-15000.0,41.5093897,41.729688,urban +9500.0,-14500.0,41.5138863,41.729688,urban +9500.0,-14000.0,41.5183829,41.729688,urban +9500.0,-13500.0,41.5228795,41.729688,open +9500.0,-13000.0,41.5273761,41.729688,urban +9500.0,-12500.0,41.5318727,41.729688,open +9500.0,-12000.0,41.5363693,41.729688,open +9500.0,-11500.0,41.5408659,41.729688,urban +9500.0,-11000.0,41.5453625,41.729688,urban +9500.0,-10500.0,41.5498591,41.729688,urban +9500.0,-10000.0,41.5543557,41.729688,urban +9500.0,-9500.0,41.5588523,41.729688,open +9500.0,-9000.0,41.563349,41.729688,open +9500.0,-8500.0,41.5678456,41.729688,open +9500.0,-8000.0,41.5723422,41.729688,open +9500.0,-7500.0,41.5768388,41.729688,open +9500.0,-7000.0,41.5813354,41.729688,open +9500.0,-6500.0,41.585832,41.729688,open +9500.0,-6000.0,41.5903286,41.729688,open +9500.0,-5500.0,41.5948252,41.729688,open +9500.0,-5000.0,41.5993218,41.729688,open +9500.0,-4500.0,41.6038184,41.729688,open +9500.0,-4000.0,41.608315,41.729688,open +9500.0,-3500.0,41.6128116,41.729688,open +9500.0,-3000.0,41.6173083,41.729688,open +9500.0,-2500.0,41.6218049,41.729688,open +9500.0,-2000.0,41.6263015,41.729688,urban +9500.0,-1500.0,41.6307981,41.729688,urban +9500.0,-1000.0,41.6352947,41.729688,urban +9500.0,-500.0,41.6397913,41.729688,urban +9500.0,0.0,41.6442879,41.729688,urban +9500.0,500.0,41.6487845,41.729688,urban +9500.0,1000.0,41.6532811,41.729688,urban +9500.0,1500.0,41.6577777,41.729688,urban +9500.0,2000.0,41.6622743,41.729688,urban +9500.0,2500.0,41.6667709,41.729688,urban +9500.0,3000.0,41.6712675,41.729688,urban +9500.0,3500.0,41.6757642,41.729688,urban +9500.0,4000.0,41.6802608,41.729688,urban +9500.0,4500.0,41.6847574,41.729688,urban +9500.0,5000.0,41.689254,41.729688,urban +9500.0,5500.0,41.6937506,41.729688,urban +9500.0,6000.0,41.6982472,41.729688,open +9500.0,6500.0,41.7027438,41.729688,open +9500.0,7000.0,41.7072404,41.729688,urban +9500.0,7500.0,41.711737,41.729688,urban +9500.0,8000.0,41.7162336,41.729688,urban +9500.0,8500.0,41.7207302,41.729688,urban +9500.0,9000.0,41.7252268,41.729688,urban +9500.0,9500.0,41.7297235,41.729688,urban +9500.0,10000.0,41.7342201,41.729688,urban +9500.0,10500.0,41.7387167,41.729688,open +9500.0,11000.0,41.7432133,41.729688,open +9500.0,11500.0,41.7477099,41.729688,open +9500.0,12000.0,41.7522065,41.729688,open +9500.0,12500.0,41.7567031,41.729688,open +9500.0,13000.0,41.7611997,41.729688,open +9500.0,13500.0,41.7656963,41.729688,open +9500.0,14000.0,41.7701929,41.729688,open +9500.0,14500.0,41.7746895,41.729688,open +9500.0,15000.0,41.7791861,41.729688,open +9500.0,15500.0,41.7836827,41.729688,open +9500.0,16000.0,41.7881794,41.729688,open +9500.0,16500.0,41.792676,41.729688,open +9500.0,17000.0,41.7971726,41.729688,open +9500.0,17500.0,41.8016692,41.729688,open +9500.0,18000.0,41.8061658,41.729688,open +9500.0,18500.0,41.8106624,41.729688,open +9500.0,19000.0,41.815159,41.729688,open +9500.0,19500.0,41.8196556,41.729688,open +10000.0,-16000.0,41.5003964,41.7357052,open +10000.0,-15500.0,41.5048931,41.7357052,open +10000.0,-15000.0,41.5093897,41.7357052,urban +10000.0,-14500.0,41.5138863,41.7357052,urban +10000.0,-14000.0,41.5183829,41.7357052,open +10000.0,-13500.0,41.5228795,41.7357052,open +10000.0,-13000.0,41.5273761,41.7357052,open +10000.0,-12500.0,41.5318727,41.7357052,open +10000.0,-12000.0,41.5363693,41.7357052,urban +10000.0,-11500.0,41.5408659,41.7357052,urban +10000.0,-11000.0,41.5453625,41.7357052,urban +10000.0,-10500.0,41.5498591,41.7357052,urban +10000.0,-10000.0,41.5543557,41.7357052,open +10000.0,-9500.0,41.5588523,41.7357052,open +10000.0,-9000.0,41.563349,41.7357052,open +10000.0,-8500.0,41.5678456,41.7357052,open +10000.0,-8000.0,41.5723422,41.7357052,open +10000.0,-7500.0,41.5768388,41.7357052,open +10000.0,-7000.0,41.5813354,41.7357052,open +10000.0,-6500.0,41.585832,41.7357052,open +10000.0,-6000.0,41.5903286,41.7357052,open +10000.0,-5500.0,41.5948252,41.7357052,open +10000.0,-5000.0,41.5993218,41.7357052,open +10000.0,-4500.0,41.6038184,41.7357052,open +10000.0,-4000.0,41.608315,41.7357052,open +10000.0,-3500.0,41.6128116,41.7357052,open +10000.0,-3000.0,41.6173083,41.7357052,open +10000.0,-2500.0,41.6218049,41.7357052,open +10000.0,-2000.0,41.6263015,41.7357052,urban +10000.0,-1500.0,41.6307981,41.7357052,urban +10000.0,-1000.0,41.6352947,41.7357052,urban +10000.0,-500.0,41.6397913,41.7357052,urban +10000.0,0.0,41.6442879,41.7357052,urban +10000.0,500.0,41.6487845,41.7357052,urban +10000.0,1000.0,41.6532811,41.7357052,urban +10000.0,1500.0,41.6577777,41.7357052,urban +10000.0,2000.0,41.6622743,41.7357052,urban +10000.0,2500.0,41.6667709,41.7357052,urban +10000.0,3000.0,41.6712675,41.7357052,open +10000.0,3500.0,41.6757642,41.7357052,urban +10000.0,4000.0,41.6802608,41.7357052,urban +10000.0,4500.0,41.6847574,41.7357052,urban +10000.0,5000.0,41.689254,41.7357052,urban +10000.0,5500.0,41.6937506,41.7357052,urban +10000.0,6000.0,41.6982472,41.7357052,urban +10000.0,6500.0,41.7027438,41.7357052,urban +10000.0,7000.0,41.7072404,41.7357052,urban +10000.0,7500.0,41.711737,41.7357052,urban +10000.0,8000.0,41.7162336,41.7357052,urban +10000.0,8500.0,41.7207302,41.7357052,urban +10000.0,9000.0,41.7252268,41.7357052,urban +10000.0,9500.0,41.7297235,41.7357052,urban +10000.0,10000.0,41.7342201,41.7357052,urban +10000.0,10500.0,41.7387167,41.7357052,urban +10000.0,11000.0,41.7432133,41.7357052,urban +10000.0,11500.0,41.7477099,41.7357052,urban +10000.0,12000.0,41.7522065,41.7357052,open +10000.0,12500.0,41.7567031,41.7357052,open +10000.0,13000.0,41.7611997,41.7357052,open +10000.0,13500.0,41.7656963,41.7357052,open +10000.0,14000.0,41.7701929,41.7357052,open +10000.0,14500.0,41.7746895,41.7357052,open +10000.0,15000.0,41.7791861,41.7357052,open +10000.0,15500.0,41.7836827,41.7357052,open +10000.0,16000.0,41.7881794,41.7357052,open +10000.0,16500.0,41.792676,41.7357052,open +10000.0,17000.0,41.7971726,41.7357052,open +10000.0,17500.0,41.8016692,41.7357052,open +10000.0,18000.0,41.8061658,41.7357052,open +10000.0,18500.0,41.8106624,41.7357052,open +10000.0,19000.0,41.815159,41.7357052,open +10000.0,19500.0,41.8196556,41.7357052,open +10500.0,-16000.0,41.5003964,41.7417225,open +10500.0,-15500.0,41.5048931,41.7417225,open +10500.0,-15000.0,41.5093897,41.7417225,urban +10500.0,-14500.0,41.5138863,41.7417225,urban +10500.0,-14000.0,41.5183829,41.7417225,urban +10500.0,-13500.0,41.5228795,41.7417225,open +10500.0,-13000.0,41.5273761,41.7417225,open +10500.0,-12500.0,41.5318727,41.7417225,open +10500.0,-12000.0,41.5363693,41.7417225,urban +10500.0,-11500.0,41.5408659,41.7417225,urban +10500.0,-11000.0,41.5453625,41.7417225,urban +10500.0,-10500.0,41.5498591,41.7417225,urban +10500.0,-10000.0,41.5543557,41.7417225,urban +10500.0,-9500.0,41.5588523,41.7417225,urban +10500.0,-9000.0,41.563349,41.7417225,urban +10500.0,-8500.0,41.5678456,41.7417225,open +10500.0,-8000.0,41.5723422,41.7417225,open +10500.0,-7500.0,41.5768388,41.7417225,open +10500.0,-7000.0,41.5813354,41.7417225,open +10500.0,-6500.0,41.585832,41.7417225,open +10500.0,-6000.0,41.5903286,41.7417225,open +10500.0,-5500.0,41.5948252,41.7417225,open +10500.0,-5000.0,41.5993218,41.7417225,open +10500.0,-4500.0,41.6038184,41.7417225,open +10500.0,-4000.0,41.608315,41.7417225,open +10500.0,-3500.0,41.6128116,41.7417225,open +10500.0,-3000.0,41.6173083,41.7417225,open +10500.0,-2500.0,41.6218049,41.7417225,open +10500.0,-2000.0,41.6263015,41.7417225,open +10500.0,-1500.0,41.6307981,41.7417225,urban +10500.0,-1000.0,41.6352947,41.7417225,urban +10500.0,-500.0,41.6397913,41.7417225,urban +10500.0,0.0,41.6442879,41.7417225,urban +10500.0,500.0,41.6487845,41.7417225,urban +10500.0,1000.0,41.6532811,41.7417225,urban +10500.0,1500.0,41.6577777,41.7417225,urban +10500.0,2000.0,41.6622743,41.7417225,open +10500.0,2500.0,41.6667709,41.7417225,open +10500.0,3000.0,41.6712675,41.7417225,open +10500.0,3500.0,41.6757642,41.7417225,open +10500.0,4000.0,41.6802608,41.7417225,open +10500.0,4500.0,41.6847574,41.7417225,urban +10500.0,5000.0,41.689254,41.7417225,open +10500.0,5500.0,41.6937506,41.7417225,open +10500.0,6000.0,41.6982472,41.7417225,urban +10500.0,6500.0,41.7027438,41.7417225,open +10500.0,7000.0,41.7072404,41.7417225,urban +10500.0,7500.0,41.711737,41.7417225,urban +10500.0,8000.0,41.7162336,41.7417225,urban +10500.0,8500.0,41.7207302,41.7417225,urban +10500.0,9000.0,41.7252268,41.7417225,urban +10500.0,9500.0,41.7297235,41.7417225,urban +10500.0,10000.0,41.7342201,41.7417225,urban +10500.0,10500.0,41.7387167,41.7417225,urban +10500.0,11000.0,41.7432133,41.7417225,urban +10500.0,11500.0,41.7477099,41.7417225,urban +10500.0,12000.0,41.7522065,41.7417225,urban +10500.0,12500.0,41.7567031,41.7417225,urban +10500.0,13000.0,41.7611997,41.7417225,open +10500.0,13500.0,41.7656963,41.7417225,open +10500.0,14000.0,41.7701929,41.7417225,open +10500.0,14500.0,41.7746895,41.7417225,open +10500.0,15000.0,41.7791861,41.7417225,open +10500.0,15500.0,41.7836827,41.7417225,open +10500.0,16000.0,41.7881794,41.7417225,open +10500.0,16500.0,41.792676,41.7417225,open +10500.0,17000.0,41.7971726,41.7417225,open +10500.0,17500.0,41.8016692,41.7417225,open +10500.0,18000.0,41.8061658,41.7417225,open +10500.0,18500.0,41.8106624,41.7417225,open +10500.0,19000.0,41.815159,41.7417225,open +10500.0,19500.0,41.8196556,41.7417225,open +11000.0,-16000.0,41.5003964,41.7477398,open +11000.0,-15500.0,41.5048931,41.7477398,open +11000.0,-15000.0,41.5093897,41.7477398,urban +11000.0,-14500.0,41.5138863,41.7477398,urban +11000.0,-14000.0,41.5183829,41.7477398,open +11000.0,-13500.0,41.5228795,41.7477398,open +11000.0,-13000.0,41.5273761,41.7477398,open +11000.0,-12500.0,41.5318727,41.7477398,open +11000.0,-12000.0,41.5363693,41.7477398,open +11000.0,-11500.0,41.5408659,41.7477398,open +11000.0,-11000.0,41.5453625,41.7477398,open +11000.0,-10500.0,41.5498591,41.7477398,urban +11000.0,-10000.0,41.5543557,41.7477398,urban +11000.0,-9500.0,41.5588523,41.7477398,urban +11000.0,-9000.0,41.563349,41.7477398,urban +11000.0,-8500.0,41.5678456,41.7477398,open +11000.0,-8000.0,41.5723422,41.7477398,open +11000.0,-7500.0,41.5768388,41.7477398,open +11000.0,-7000.0,41.5813354,41.7477398,open +11000.0,-6500.0,41.585832,41.7477398,open +11000.0,-6000.0,41.5903286,41.7477398,open +11000.0,-5500.0,41.5948252,41.7477398,open +11000.0,-5000.0,41.5993218,41.7477398,open +11000.0,-4500.0,41.6038184,41.7477398,open +11000.0,-4000.0,41.608315,41.7477398,open +11000.0,-3500.0,41.6128116,41.7477398,open +11000.0,-3000.0,41.6173083,41.7477398,open +11000.0,-2500.0,41.6218049,41.7477398,open +11000.0,-2000.0,41.6263015,41.7477398,open +11000.0,-1500.0,41.6307981,41.7477398,open +11000.0,-1000.0,41.6352947,41.7477398,urban +11000.0,-500.0,41.6397913,41.7477398,urban +11000.0,0.0,41.6442879,41.7477398,urban +11000.0,500.0,41.6487845,41.7477398,urban +11000.0,1000.0,41.6532811,41.7477398,open +11000.0,1500.0,41.6577777,41.7477398,open +11000.0,2000.0,41.6622743,41.7477398,open +11000.0,2500.0,41.6667709,41.7477398,open +11000.0,3000.0,41.6712675,41.7477398,open +11000.0,3500.0,41.6757642,41.7477398,open +11000.0,4000.0,41.6802608,41.7477398,open +11000.0,4500.0,41.6847574,41.7477398,open +11000.0,5000.0,41.689254,41.7477398,open +11000.0,5500.0,41.6937506,41.7477398,open +11000.0,6000.0,41.6982472,41.7477398,open +11000.0,6500.0,41.7027438,41.7477398,open +11000.0,7000.0,41.7072404,41.7477398,urban +11000.0,7500.0,41.711737,41.7477398,urban +11000.0,8000.0,41.7162336,41.7477398,urban +11000.0,8500.0,41.7207302,41.7477398,urban +11000.0,9000.0,41.7252268,41.7477398,urban +11000.0,9500.0,41.7297235,41.7477398,urban +11000.0,10000.0,41.7342201,41.7477398,urban +11000.0,10500.0,41.7387167,41.7477398,urban +11000.0,11000.0,41.7432133,41.7477398,urban +11000.0,11500.0,41.7477099,41.7477398,urban +11000.0,12000.0,41.7522065,41.7477398,urban +11000.0,12500.0,41.7567031,41.7477398,urban +11000.0,13000.0,41.7611997,41.7477398,urban +11000.0,13500.0,41.7656963,41.7477398,open +11000.0,14000.0,41.7701929,41.7477398,open +11000.0,14500.0,41.7746895,41.7477398,open +11000.0,15000.0,41.7791861,41.7477398,open +11000.0,15500.0,41.7836827,41.7477398,open +11000.0,16000.0,41.7881794,41.7477398,open +11000.0,16500.0,41.792676,41.7477398,open +11000.0,17000.0,41.7971726,41.7477398,open +11000.0,17500.0,41.8016692,41.7477398,open +11000.0,18000.0,41.8061658,41.7477398,open +11000.0,18500.0,41.8106624,41.7477398,open +11000.0,19000.0,41.815159,41.7477398,open +11000.0,19500.0,41.8196556,41.7477398,open +11500.0,-16000.0,41.5003964,41.753757,open +11500.0,-15500.0,41.5048931,41.753757,urban +11500.0,-15000.0,41.5093897,41.753757,urban +11500.0,-14500.0,41.5138863,41.753757,urban +11500.0,-14000.0,41.5183829,41.753757,urban +11500.0,-13500.0,41.5228795,41.753757,open +11500.0,-13000.0,41.5273761,41.753757,open +11500.0,-12500.0,41.5318727,41.753757,open +11500.0,-12000.0,41.5363693,41.753757,open +11500.0,-11500.0,41.5408659,41.753757,urban +11500.0,-11000.0,41.5453625,41.753757,urban +11500.0,-10500.0,41.5498591,41.753757,open +11500.0,-10000.0,41.5543557,41.753757,open +11500.0,-9500.0,41.5588523,41.753757,open +11500.0,-9000.0,41.563349,41.753757,open +11500.0,-8500.0,41.5678456,41.753757,open +11500.0,-8000.0,41.5723422,41.753757,open +11500.0,-7500.0,41.5768388,41.753757,open +11500.0,-7000.0,41.5813354,41.753757,open +11500.0,-6500.0,41.585832,41.753757,open +11500.0,-6000.0,41.5903286,41.753757,open +11500.0,-5500.0,41.5948252,41.753757,open +11500.0,-5000.0,41.5993218,41.753757,open +11500.0,-4500.0,41.6038184,41.753757,open +11500.0,-4000.0,41.608315,41.753757,open +11500.0,-3500.0,41.6128116,41.753757,open +11500.0,-3000.0,41.6173083,41.753757,open +11500.0,-2500.0,41.6218049,41.753757,open +11500.0,-2000.0,41.6263015,41.753757,open +11500.0,-1500.0,41.6307981,41.753757,open +11500.0,-1000.0,41.6352947,41.753757,open +11500.0,-500.0,41.6397913,41.753757,urban +11500.0,0.0,41.6442879,41.753757,urban +11500.0,500.0,41.6487845,41.753757,urban +11500.0,1000.0,41.6532811,41.753757,urban +11500.0,1500.0,41.6577777,41.753757,open +11500.0,2000.0,41.6622743,41.753757,open +11500.0,2500.0,41.6667709,41.753757,open +11500.0,3000.0,41.6712675,41.753757,open +11500.0,3500.0,41.6757642,41.753757,open +11500.0,4000.0,41.6802608,41.753757,open +11500.0,4500.0,41.6847574,41.753757,open +11500.0,5000.0,41.689254,41.753757,open +11500.0,5500.0,41.6937506,41.753757,open +11500.0,6000.0,41.6982472,41.753757,urban +11500.0,6500.0,41.7027438,41.753757,urban +11500.0,7000.0,41.7072404,41.753757,urban +11500.0,7500.0,41.711737,41.753757,open +11500.0,8000.0,41.7162336,41.753757,urban +11500.0,8500.0,41.7207302,41.753757,urban +11500.0,9000.0,41.7252268,41.753757,urban +11500.0,9500.0,41.7297235,41.753757,urban +11500.0,10000.0,41.7342201,41.753757,urban +11500.0,10500.0,41.7387167,41.753757,urban +11500.0,11000.0,41.7432133,41.753757,urban +11500.0,11500.0,41.7477099,41.753757,urban +11500.0,12000.0,41.7522065,41.753757,urban +11500.0,12500.0,41.7567031,41.753757,urban +11500.0,13000.0,41.7611997,41.753757,urban +11500.0,13500.0,41.7656963,41.753757,urban +11500.0,14000.0,41.7701929,41.753757,urban +11500.0,14500.0,41.7746895,41.753757,urban +11500.0,15000.0,41.7791861,41.753757,open +11500.0,15500.0,41.7836827,41.753757,open +11500.0,16000.0,41.7881794,41.753757,open +11500.0,16500.0,41.792676,41.753757,open +11500.0,17000.0,41.7971726,41.753757,open +11500.0,17500.0,41.8016692,41.753757,open +11500.0,18000.0,41.8061658,41.753757,open +11500.0,18500.0,41.8106624,41.753757,open +11500.0,19000.0,41.815159,41.753757,open +11500.0,19500.0,41.8196556,41.753757,open +12000.0,-16000.0,41.5003964,41.7597743,open +12000.0,-15500.0,41.5048931,41.7597743,urban +12000.0,-15000.0,41.5093897,41.7597743,urban +12000.0,-14500.0,41.5138863,41.7597743,urban +12000.0,-14000.0,41.5183829,41.7597743,open +12000.0,-13500.0,41.5228795,41.7597743,open +12000.0,-13000.0,41.5273761,41.7597743,open +12000.0,-12500.0,41.5318727,41.7597743,open +12000.0,-12000.0,41.5363693,41.7597743,urban +12000.0,-11500.0,41.5408659,41.7597743,urban +12000.0,-11000.0,41.5453625,41.7597743,urban +12000.0,-10500.0,41.5498591,41.7597743,urban +12000.0,-10000.0,41.5543557,41.7597743,open +12000.0,-9500.0,41.5588523,41.7597743,open +12000.0,-9000.0,41.563349,41.7597743,open +12000.0,-8500.0,41.5678456,41.7597743,open +12000.0,-8000.0,41.5723422,41.7597743,open +12000.0,-7500.0,41.5768388,41.7597743,open +12000.0,-7000.0,41.5813354,41.7597743,open +12000.0,-6500.0,41.585832,41.7597743,open +12000.0,-6000.0,41.5903286,41.7597743,open +12000.0,-5500.0,41.5948252,41.7597743,open +12000.0,-5000.0,41.5993218,41.7597743,open +12000.0,-4500.0,41.6038184,41.7597743,open +12000.0,-4000.0,41.608315,41.7597743,open +12000.0,-3500.0,41.6128116,41.7597743,open +12000.0,-3000.0,41.6173083,41.7597743,open +12000.0,-2500.0,41.6218049,41.7597743,open +12000.0,-2000.0,41.6263015,41.7597743,open +12000.0,-1500.0,41.6307981,41.7597743,open +12000.0,-1000.0,41.6352947,41.7597743,open +12000.0,-500.0,41.6397913,41.7597743,urban +12000.0,0.0,41.6442879,41.7597743,urban +12000.0,500.0,41.6487845,41.7597743,open +12000.0,1000.0,41.6532811,41.7597743,open +12000.0,1500.0,41.6577777,41.7597743,urban +12000.0,2000.0,41.6622743,41.7597743,open +12000.0,2500.0,41.6667709,41.7597743,open +12000.0,3000.0,41.6712675,41.7597743,open +12000.0,3500.0,41.6757642,41.7597743,open +12000.0,4000.0,41.6802608,41.7597743,open +12000.0,4500.0,41.6847574,41.7597743,open +12000.0,5000.0,41.689254,41.7597743,urban +12000.0,5500.0,41.6937506,41.7597743,urban +12000.0,6000.0,41.6982472,41.7597743,urban +12000.0,6500.0,41.7027438,41.7597743,urban +12000.0,7000.0,41.7072404,41.7597743,urban +12000.0,7500.0,41.711737,41.7597743,urban +12000.0,8000.0,41.7162336,41.7597743,urban +12000.0,8500.0,41.7207302,41.7597743,urban +12000.0,9000.0,41.7252268,41.7597743,urban +12000.0,9500.0,41.7297235,41.7597743,urban +12000.0,10000.0,41.7342201,41.7597743,urban +12000.0,10500.0,41.7387167,41.7597743,open +12000.0,11000.0,41.7432133,41.7597743,open +12000.0,11500.0,41.7477099,41.7597743,urban +12000.0,12000.0,41.7522065,41.7597743,urban +12000.0,12500.0,41.7567031,41.7597743,urban +12000.0,13000.0,41.7611997,41.7597743,urban +12000.0,13500.0,41.7656963,41.7597743,urban +12000.0,14000.0,41.7701929,41.7597743,urban +12000.0,14500.0,41.7746895,41.7597743,urban +12000.0,15000.0,41.7791861,41.7597743,forest +12000.0,15500.0,41.7836827,41.7597743,urban +12000.0,16000.0,41.7881794,41.7597743,open +12000.0,16500.0,41.792676,41.7597743,open +12000.0,17000.0,41.7971726,41.7597743,open +12000.0,17500.0,41.8016692,41.7597743,open +12000.0,18000.0,41.8061658,41.7597743,open +12000.0,18500.0,41.8106624,41.7597743,open +12000.0,19000.0,41.815159,41.7597743,open +12000.0,19500.0,41.8196556,41.7597743,open +12500.0,-16000.0,41.5003964,41.7657916,open +12500.0,-15500.0,41.5048931,41.7657916,open +12500.0,-15000.0,41.5093897,41.7657916,urban +12500.0,-14500.0,41.5138863,41.7657916,urban +12500.0,-14000.0,41.5183829,41.7657916,open +12500.0,-13500.0,41.5228795,41.7657916,open +12500.0,-13000.0,41.5273761,41.7657916,urban +12500.0,-12500.0,41.5318727,41.7657916,open +12500.0,-12000.0,41.5363693,41.7657916,open +12500.0,-11500.0,41.5408659,41.7657916,open +12500.0,-11000.0,41.5453625,41.7657916,urban +12500.0,-10500.0,41.5498591,41.7657916,urban +12500.0,-10000.0,41.5543557,41.7657916,open +12500.0,-9500.0,41.5588523,41.7657916,open +12500.0,-9000.0,41.563349,41.7657916,open +12500.0,-8500.0,41.5678456,41.7657916,open +12500.0,-8000.0,41.5723422,41.7657916,open +12500.0,-7500.0,41.5768388,41.7657916,open +12500.0,-7000.0,41.5813354,41.7657916,open +12500.0,-6500.0,41.585832,41.7657916,open +12500.0,-6000.0,41.5903286,41.7657916,open +12500.0,-5500.0,41.5948252,41.7657916,open +12500.0,-5000.0,41.5993218,41.7657916,open +12500.0,-4500.0,41.6038184,41.7657916,open +12500.0,-4000.0,41.608315,41.7657916,open +12500.0,-3500.0,41.6128116,41.7657916,open +12500.0,-3000.0,41.6173083,41.7657916,open +12500.0,-2500.0,41.6218049,41.7657916,open +12500.0,-2000.0,41.6263015,41.7657916,open +12500.0,-1500.0,41.6307981,41.7657916,open +12500.0,-1000.0,41.6352947,41.7657916,open +12500.0,-500.0,41.6397913,41.7657916,open +12500.0,0.0,41.6442879,41.7657916,open +12500.0,500.0,41.6487845,41.7657916,open +12500.0,1000.0,41.6532811,41.7657916,open +12500.0,1500.0,41.6577777,41.7657916,open +12500.0,2000.0,41.6622743,41.7657916,open +12500.0,2500.0,41.6667709,41.7657916,open +12500.0,3000.0,41.6712675,41.7657916,open +12500.0,3500.0,41.6757642,41.7657916,open +12500.0,4000.0,41.6802608,41.7657916,open +12500.0,4500.0,41.6847574,41.7657916,urban +12500.0,5000.0,41.689254,41.7657916,urban +12500.0,5500.0,41.6937506,41.7657916,open +12500.0,6000.0,41.6982472,41.7657916,urban +12500.0,6500.0,41.7027438,41.7657916,urban +12500.0,7000.0,41.7072404,41.7657916,urban +12500.0,7500.0,41.711737,41.7657916,urban +12500.0,8000.0,41.7162336,41.7657916,urban +12500.0,8500.0,41.7207302,41.7657916,urban +12500.0,9000.0,41.7252268,41.7657916,urban +12500.0,9500.0,41.7297235,41.7657916,urban +12500.0,10000.0,41.7342201,41.7657916,open +12500.0,10500.0,41.7387167,41.7657916,open +12500.0,11000.0,41.7432133,41.7657916,open +12500.0,11500.0,41.7477099,41.7657916,urban +12500.0,12000.0,41.7522065,41.7657916,urban +12500.0,12500.0,41.7567031,41.7657916,urban +12500.0,13000.0,41.7611997,41.7657916,urban +12500.0,13500.0,41.7656963,41.7657916,urban +12500.0,14000.0,41.7701929,41.7657916,urban +12500.0,14500.0,41.7746895,41.7657916,urban +12500.0,15000.0,41.7791861,41.7657916,urban +12500.0,15500.0,41.7836827,41.7657916,urban +12500.0,16000.0,41.7881794,41.7657916,urban +12500.0,16500.0,41.792676,41.7657916,urban +12500.0,17000.0,41.7971726,41.7657916,urban +12500.0,17500.0,41.8016692,41.7657916,open +12500.0,18000.0,41.8061658,41.7657916,open +12500.0,18500.0,41.8106624,41.7657916,open +12500.0,19000.0,41.815159,41.7657916,open +12500.0,19500.0,41.8196556,41.7657916,open +13000.0,-16000.0,41.5003964,41.7718088,open +13000.0,-15500.0,41.5048931,41.7718088,open +13000.0,-15000.0,41.5093897,41.7718088,urban +13000.0,-14500.0,41.5138863,41.7718088,urban +13000.0,-14000.0,41.5183829,41.7718088,open +13000.0,-13500.0,41.5228795,41.7718088,urban +13000.0,-13000.0,41.5273761,41.7718088,open +13000.0,-12500.0,41.5318727,41.7718088,open +13000.0,-12000.0,41.5363693,41.7718088,open +13000.0,-11500.0,41.5408659,41.7718088,open +13000.0,-11000.0,41.5453625,41.7718088,urban +13000.0,-10500.0,41.5498591,41.7718088,urban +13000.0,-10000.0,41.5543557,41.7718088,urban +13000.0,-9500.0,41.5588523,41.7718088,urban +13000.0,-9000.0,41.563349,41.7718088,open +13000.0,-8500.0,41.5678456,41.7718088,open +13000.0,-8000.0,41.5723422,41.7718088,open +13000.0,-7500.0,41.5768388,41.7718088,open +13000.0,-7000.0,41.5813354,41.7718088,open +13000.0,-6500.0,41.585832,41.7718088,open +13000.0,-6000.0,41.5903286,41.7718088,open +13000.0,-5500.0,41.5948252,41.7718088,open +13000.0,-5000.0,41.5993218,41.7718088,open +13000.0,-4500.0,41.6038184,41.7718088,open +13000.0,-4000.0,41.608315,41.7718088,open +13000.0,-3500.0,41.6128116,41.7718088,open +13000.0,-3000.0,41.6173083,41.7718088,open +13000.0,-2500.0,41.6218049,41.7718088,open +13000.0,-2000.0,41.6263015,41.7718088,open +13000.0,-1500.0,41.6307981,41.7718088,open +13000.0,-1000.0,41.6352947,41.7718088,open +13000.0,-500.0,41.6397913,41.7718088,open +13000.0,0.0,41.6442879,41.7718088,open +13000.0,500.0,41.6487845,41.7718088,open +13000.0,1000.0,41.6532811,41.7718088,open +13000.0,1500.0,41.6577777,41.7718088,open +13000.0,2000.0,41.6622743,41.7718088,open +13000.0,2500.0,41.6667709,41.7718088,open +13000.0,3000.0,41.6712675,41.7718088,open +13000.0,3500.0,41.6757642,41.7718088,open +13000.0,4000.0,41.6802608,41.7718088,open +13000.0,4500.0,41.6847574,41.7718088,open +13000.0,5000.0,41.689254,41.7718088,urban +13000.0,5500.0,41.6937506,41.7718088,urban +13000.0,6000.0,41.6982472,41.7718088,urban +13000.0,6500.0,41.7027438,41.7718088,urban +13000.0,7000.0,41.7072404,41.7718088,urban +13000.0,7500.0,41.711737,41.7718088,urban +13000.0,8000.0,41.7162336,41.7718088,urban +13000.0,8500.0,41.7207302,41.7718088,urban +13000.0,9000.0,41.7252268,41.7718088,urban +13000.0,9500.0,41.7297235,41.7718088,urban +13000.0,10000.0,41.7342201,41.7718088,urban +13000.0,10500.0,41.7387167,41.7718088,urban +13000.0,11000.0,41.7432133,41.7718088,urban +13000.0,11500.0,41.7477099,41.7718088,urban +13000.0,12000.0,41.7522065,41.7718088,urban +13000.0,12500.0,41.7567031,41.7718088,urban +13000.0,13000.0,41.7611997,41.7718088,urban +13000.0,13500.0,41.7656963,41.7718088,urban +13000.0,14000.0,41.7701929,41.7718088,urban +13000.0,14500.0,41.7746895,41.7718088,urban +13000.0,15000.0,41.7791861,41.7718088,open +13000.0,15500.0,41.7836827,41.7718088,urban +13000.0,16000.0,41.7881794,41.7718088,open +13000.0,16500.0,41.792676,41.7718088,urban +13000.0,17000.0,41.7971726,41.7718088,urban +13000.0,17500.0,41.8016692,41.7718088,urban +13000.0,18000.0,41.8061658,41.7718088,urban +13000.0,18500.0,41.8106624,41.7718088,urban +13000.0,19000.0,41.815159,41.7718088,urban +13000.0,19500.0,41.8196556,41.7718088,urban +13500.0,-16000.0,41.5003964,41.7778261,open +13500.0,-15500.0,41.5048931,41.7778261,open +13500.0,-15000.0,41.5093897,41.7778261,urban +13500.0,-14500.0,41.5138863,41.7778261,urban +13500.0,-14000.0,41.5183829,41.7778261,open +13500.0,-13500.0,41.5228795,41.7778261,urban +13500.0,-13000.0,41.5273761,41.7778261,urban +13500.0,-12500.0,41.5318727,41.7778261,open +13500.0,-12000.0,41.5363693,41.7778261,open +13500.0,-11500.0,41.5408659,41.7778261,urban +13500.0,-11000.0,41.5453625,41.7778261,urban +13500.0,-10500.0,41.5498591,41.7778261,urban +13500.0,-10000.0,41.5543557,41.7778261,urban +13500.0,-9500.0,41.5588523,41.7778261,urban +13500.0,-9000.0,41.563349,41.7778261,open +13500.0,-8500.0,41.5678456,41.7778261,open +13500.0,-8000.0,41.5723422,41.7778261,open +13500.0,-7500.0,41.5768388,41.7778261,open +13500.0,-7000.0,41.5813354,41.7778261,open +13500.0,-6500.0,41.585832,41.7778261,open +13500.0,-6000.0,41.5903286,41.7778261,open +13500.0,-5500.0,41.5948252,41.7778261,open +13500.0,-5000.0,41.5993218,41.7778261,open +13500.0,-4500.0,41.6038184,41.7778261,open +13500.0,-4000.0,41.608315,41.7778261,open +13500.0,-3500.0,41.6128116,41.7778261,open +13500.0,-3000.0,41.6173083,41.7778261,open +13500.0,-2500.0,41.6218049,41.7778261,open +13500.0,-2000.0,41.6263015,41.7778261,open +13500.0,-1500.0,41.6307981,41.7778261,open +13500.0,-1000.0,41.6352947,41.7778261,open +13500.0,-500.0,41.6397913,41.7778261,open +13500.0,0.0,41.6442879,41.7778261,open +13500.0,500.0,41.6487845,41.7778261,open +13500.0,1000.0,41.6532811,41.7778261,open +13500.0,1500.0,41.6577777,41.7778261,open +13500.0,2000.0,41.6622743,41.7778261,open +13500.0,2500.0,41.6667709,41.7778261,open +13500.0,3000.0,41.6712675,41.7778261,open +13500.0,3500.0,41.6757642,41.7778261,open +13500.0,4000.0,41.6802608,41.7778261,open +13500.0,4500.0,41.6847574,41.7778261,urban +13500.0,5000.0,41.689254,41.7778261,urban +13500.0,5500.0,41.6937506,41.7778261,open +13500.0,6000.0,41.6982472,41.7778261,urban +13500.0,6500.0,41.7027438,41.7778261,urban +13500.0,7000.0,41.7072404,41.7778261,urban +13500.0,7500.0,41.711737,41.7778261,urban +13500.0,8000.0,41.7162336,41.7778261,urban +13500.0,8500.0,41.7207302,41.7778261,urban +13500.0,9000.0,41.7252268,41.7778261,urban +13500.0,9500.0,41.7297235,41.7778261,urban +13500.0,10000.0,41.7342201,41.7778261,open +13500.0,10500.0,41.7387167,41.7778261,urban +13500.0,11000.0,41.7432133,41.7778261,urban +13500.0,11500.0,41.7477099,41.7778261,urban +13500.0,12000.0,41.7522065,41.7778261,urban +13500.0,12500.0,41.7567031,41.7778261,urban +13500.0,13000.0,41.7611997,41.7778261,urban +13500.0,13500.0,41.7656963,41.7778261,urban +13500.0,14000.0,41.7701929,41.7778261,urban +13500.0,14500.0,41.7746895,41.7778261,urban +13500.0,15000.0,41.7791861,41.7778261,urban +13500.0,15500.0,41.7836827,41.7778261,urban +13500.0,16000.0,41.7881794,41.7778261,urban +13500.0,16500.0,41.792676,41.7778261,urban +13500.0,17000.0,41.7971726,41.7778261,urban +13500.0,17500.0,41.8016692,41.7778261,urban +13500.0,18000.0,41.8061658,41.7778261,urban +13500.0,18500.0,41.8106624,41.7778261,urban +13500.0,19000.0,41.815159,41.7778261,urban +13500.0,19500.0,41.8196556,41.7778261,urban +14000.0,-16000.0,41.5003964,41.7838433,open +14000.0,-15500.0,41.5048931,41.7838433,open +14000.0,-15000.0,41.5093897,41.7838433,open +14000.0,-14500.0,41.5138863,41.7838433,urban +14000.0,-14000.0,41.5183829,41.7838433,urban +14000.0,-13500.0,41.5228795,41.7838433,open +14000.0,-13000.0,41.5273761,41.7838433,open +14000.0,-12500.0,41.5318727,41.7838433,open +14000.0,-12000.0,41.5363693,41.7838433,open +14000.0,-11500.0,41.5408659,41.7838433,urban +14000.0,-11000.0,41.5453625,41.7838433,urban +14000.0,-10500.0,41.5498591,41.7838433,urban +14000.0,-10000.0,41.5543557,41.7838433,urban +14000.0,-9500.0,41.5588523,41.7838433,open +14000.0,-9000.0,41.563349,41.7838433,open +14000.0,-8500.0,41.5678456,41.7838433,open +14000.0,-8000.0,41.5723422,41.7838433,urban +14000.0,-7500.0,41.5768388,41.7838433,open +14000.0,-7000.0,41.5813354,41.7838433,open +14000.0,-6500.0,41.585832,41.7838433,open +14000.0,-6000.0,41.5903286,41.7838433,open +14000.0,-5500.0,41.5948252,41.7838433,open +14000.0,-5000.0,41.5993218,41.7838433,open +14000.0,-4500.0,41.6038184,41.7838433,open +14000.0,-4000.0,41.608315,41.7838433,open +14000.0,-3500.0,41.6128116,41.7838433,open +14000.0,-3000.0,41.6173083,41.7838433,open +14000.0,-2500.0,41.6218049,41.7838433,open +14000.0,-2000.0,41.6263015,41.7838433,open +14000.0,-1500.0,41.6307981,41.7838433,open +14000.0,-1000.0,41.6352947,41.7838433,open +14000.0,-500.0,41.6397913,41.7838433,open +14000.0,0.0,41.6442879,41.7838433,open +14000.0,500.0,41.6487845,41.7838433,open +14000.0,1000.0,41.6532811,41.7838433,open +14000.0,1500.0,41.6577777,41.7838433,open +14000.0,2000.0,41.6622743,41.7838433,open +14000.0,2500.0,41.6667709,41.7838433,open +14000.0,3000.0,41.6712675,41.7838433,open +14000.0,3500.0,41.6757642,41.7838433,open +14000.0,4000.0,41.6802608,41.7838433,open +14000.0,4500.0,41.6847574,41.7838433,open +14000.0,5000.0,41.689254,41.7838433,urban +14000.0,5500.0,41.6937506,41.7838433,open +14000.0,6000.0,41.6982472,41.7838433,urban +14000.0,6500.0,41.7027438,41.7838433,urban +14000.0,7000.0,41.7072404,41.7838433,urban +14000.0,7500.0,41.711737,41.7838433,urban +14000.0,8000.0,41.7162336,41.7838433,urban +14000.0,8500.0,41.7207302,41.7838433,urban +14000.0,9000.0,41.7252268,41.7838433,urban +14000.0,9500.0,41.7297235,41.7838433,urban +14000.0,10000.0,41.7342201,41.7838433,open +14000.0,10500.0,41.7387167,41.7838433,urban +14000.0,11000.0,41.7432133,41.7838433,urban +14000.0,11500.0,41.7477099,41.7838433,urban +14000.0,12000.0,41.7522065,41.7838433,urban +14000.0,12500.0,41.7567031,41.7838433,urban +14000.0,13000.0,41.7611997,41.7838433,urban +14000.0,13500.0,41.7656963,41.7838433,urban +14000.0,14000.0,41.7701929,41.7838433,urban +14000.0,14500.0,41.7746895,41.7838433,urban +14000.0,15000.0,41.7791861,41.7838433,open +14000.0,15500.0,41.7836827,41.7838433,urban +14000.0,16000.0,41.7881794,41.7838433,urban +14000.0,16500.0,41.792676,41.7838433,urban +14000.0,17000.0,41.7971726,41.7838433,urban +14000.0,17500.0,41.8016692,41.7838433,urban +14000.0,18000.0,41.8061658,41.7838433,urban +14000.0,18500.0,41.8106624,41.7838433,urban +14000.0,19000.0,41.815159,41.7838433,urban +14000.0,19500.0,41.8196556,41.7838433,urban +14500.0,-16000.0,41.5003964,41.7898606,open +14500.0,-15500.0,41.5048931,41.7898606,open +14500.0,-15000.0,41.5093897,41.7898606,open +14500.0,-14500.0,41.5138863,41.7898606,urban +14500.0,-14000.0,41.5183829,41.7898606,urban +14500.0,-13500.0,41.5228795,41.7898606,urban +14500.0,-13000.0,41.5273761,41.7898606,urban +14500.0,-12500.0,41.5318727,41.7898606,open +14500.0,-12000.0,41.5363693,41.7898606,open +14500.0,-11500.0,41.5408659,41.7898606,urban +14500.0,-11000.0,41.5453625,41.7898606,urban +14500.0,-10500.0,41.5498591,41.7898606,urban +14500.0,-10000.0,41.5543557,41.7898606,open +14500.0,-9500.0,41.5588523,41.7898606,open +14500.0,-9000.0,41.563349,41.7898606,open +14500.0,-8500.0,41.5678456,41.7898606,open +14500.0,-8000.0,41.5723422,41.7898606,open +14500.0,-7500.0,41.5768388,41.7898606,open +14500.0,-7000.0,41.5813354,41.7898606,open +14500.0,-6500.0,41.585832,41.7898606,open +14500.0,-6000.0,41.5903286,41.7898606,open +14500.0,-5500.0,41.5948252,41.7898606,open +14500.0,-5000.0,41.5993218,41.7898606,open +14500.0,-4500.0,41.6038184,41.7898606,open +14500.0,-4000.0,41.608315,41.7898606,open +14500.0,-3500.0,41.6128116,41.7898606,open +14500.0,-3000.0,41.6173083,41.7898606,open +14500.0,-2500.0,41.6218049,41.7898606,open +14500.0,-2000.0,41.6263015,41.7898606,open +14500.0,-1500.0,41.6307981,41.7898606,open +14500.0,-1000.0,41.6352947,41.7898606,open +14500.0,-500.0,41.6397913,41.7898606,open +14500.0,0.0,41.6442879,41.7898606,open +14500.0,500.0,41.6487845,41.7898606,open +14500.0,1000.0,41.6532811,41.7898606,open +14500.0,1500.0,41.6577777,41.7898606,open +14500.0,2000.0,41.6622743,41.7898606,open +14500.0,2500.0,41.6667709,41.7898606,open +14500.0,3000.0,41.6712675,41.7898606,open +14500.0,3500.0,41.6757642,41.7898606,open +14500.0,4000.0,41.6802608,41.7898606,open +14500.0,4500.0,41.6847574,41.7898606,open +14500.0,5000.0,41.689254,41.7898606,open +14500.0,5500.0,41.6937506,41.7898606,open +14500.0,6000.0,41.6982472,41.7898606,urban +14500.0,6500.0,41.7027438,41.7898606,urban +14500.0,7000.0,41.7072404,41.7898606,urban +14500.0,7500.0,41.711737,41.7898606,urban +14500.0,8000.0,41.7162336,41.7898606,urban +14500.0,8500.0,41.7207302,41.7898606,open +14500.0,9000.0,41.7252268,41.7898606,open +14500.0,9500.0,41.7297235,41.7898606,open +14500.0,10000.0,41.7342201,41.7898606,open +14500.0,10500.0,41.7387167,41.7898606,urban +14500.0,11000.0,41.7432133,41.7898606,urban +14500.0,11500.0,41.7477099,41.7898606,urban +14500.0,12000.0,41.7522065,41.7898606,urban +14500.0,12500.0,41.7567031,41.7898606,urban +14500.0,13000.0,41.7611997,41.7898606,urban +14500.0,13500.0,41.7656963,41.7898606,urban +14500.0,14000.0,41.7701929,41.7898606,open +14500.0,14500.0,41.7746895,41.7898606,open +14500.0,15000.0,41.7791861,41.7898606,urban +14500.0,15500.0,41.7836827,41.7898606,urban +14500.0,16000.0,41.7881794,41.7898606,urban +14500.0,16500.0,41.792676,41.7898606,urban +14500.0,17000.0,41.7971726,41.7898606,urban +14500.0,17500.0,41.8016692,41.7898606,urban +14500.0,18000.0,41.8061658,41.7898606,urban +14500.0,18500.0,41.8106624,41.7898606,urban +14500.0,19000.0,41.815159,41.7898606,urban +14500.0,19500.0,41.8196556,41.7898606,urban +15000.0,-16000.0,41.5003964,41.7958779,open +15000.0,-15500.0,41.5048931,41.7958779,open +15000.0,-15000.0,41.5093897,41.7958779,open +15000.0,-14500.0,41.5138863,41.7958779,open +15000.0,-14000.0,41.5183829,41.7958779,urban +15000.0,-13500.0,41.5228795,41.7958779,urban +15000.0,-13000.0,41.5273761,41.7958779,urban +15000.0,-12500.0,41.5318727,41.7958779,open +15000.0,-12000.0,41.5363693,41.7958779,urban +15000.0,-11500.0,41.5408659,41.7958779,urban +15000.0,-11000.0,41.5453625,41.7958779,open +15000.0,-10500.0,41.5498591,41.7958779,urban +15000.0,-10000.0,41.5543557,41.7958779,open +15000.0,-9500.0,41.5588523,41.7958779,open +15000.0,-9000.0,41.563349,41.7958779,open +15000.0,-8500.0,41.5678456,41.7958779,open +15000.0,-8000.0,41.5723422,41.7958779,open +15000.0,-7500.0,41.5768388,41.7958779,open +15000.0,-7000.0,41.5813354,41.7958779,open +15000.0,-6500.0,41.585832,41.7958779,open +15000.0,-6000.0,41.5903286,41.7958779,open +15000.0,-5500.0,41.5948252,41.7958779,open +15000.0,-5000.0,41.5993218,41.7958779,open +15000.0,-4500.0,41.6038184,41.7958779,open +15000.0,-4000.0,41.608315,41.7958779,open +15000.0,-3500.0,41.6128116,41.7958779,open +15000.0,-3000.0,41.6173083,41.7958779,open +15000.0,-2500.0,41.6218049,41.7958779,open +15000.0,-2000.0,41.6263015,41.7958779,open +15000.0,-1500.0,41.6307981,41.7958779,open +15000.0,-1000.0,41.6352947,41.7958779,open +15000.0,-500.0,41.6397913,41.7958779,open +15000.0,0.0,41.6442879,41.7958779,forest +15000.0,500.0,41.6487845,41.7958779,open +15000.0,1000.0,41.6532811,41.7958779,open +15000.0,1500.0,41.6577777,41.7958779,urban +15000.0,2000.0,41.6622743,41.7958779,open +15000.0,2500.0,41.6667709,41.7958779,open +15000.0,3000.0,41.6712675,41.7958779,open +15000.0,3500.0,41.6757642,41.7958779,open +15000.0,4000.0,41.6802608,41.7958779,open +15000.0,4500.0,41.6847574,41.7958779,open +15000.0,5000.0,41.689254,41.7958779,open +15000.0,5500.0,41.6937506,41.7958779,open +15000.0,6000.0,41.6982472,41.7958779,urban +15000.0,6500.0,41.7027438,41.7958779,urban +15000.0,7000.0,41.7072404,41.7958779,urban +15000.0,7500.0,41.711737,41.7958779,urban +15000.0,8000.0,41.7162336,41.7958779,urban +15000.0,8500.0,41.7207302,41.7958779,open +15000.0,9000.0,41.7252268,41.7958779,open +15000.0,9500.0,41.7297235,41.7958779,open +15000.0,10000.0,41.7342201,41.7958779,open +15000.0,10500.0,41.7387167,41.7958779,urban +15000.0,11000.0,41.7432133,41.7958779,urban +15000.0,11500.0,41.7477099,41.7958779,urban +15000.0,12000.0,41.7522065,41.7958779,urban +15000.0,12500.0,41.7567031,41.7958779,urban +15000.0,13000.0,41.7611997,41.7958779,urban +15000.0,13500.0,41.7656963,41.7958779,urban +15000.0,14000.0,41.7701929,41.7958779,open +15000.0,14500.0,41.7746895,41.7958779,urban +15000.0,15000.0,41.7791861,41.7958779,urban +15000.0,15500.0,41.7836827,41.7958779,urban +15000.0,16000.0,41.7881794,41.7958779,urban +15000.0,16500.0,41.792676,41.7958779,urban +15000.0,17000.0,41.7971726,41.7958779,urban +15000.0,17500.0,41.8016692,41.7958779,urban +15000.0,18000.0,41.8061658,41.7958779,urban +15000.0,18500.0,41.8106624,41.7958779,open +15000.0,19000.0,41.815159,41.7958779,open +15000.0,19500.0,41.8196556,41.7958779,urban +15500.0,-16000.0,41.5003964,41.8018951,open +15500.0,-15500.0,41.5048931,41.8018951,urban +15500.0,-15000.0,41.5093897,41.8018951,urban +15500.0,-14500.0,41.5138863,41.8018951,urban +15500.0,-14000.0,41.5183829,41.8018951,urban +15500.0,-13500.0,41.5228795,41.8018951,urban +15500.0,-13000.0,41.5273761,41.8018951,open +15500.0,-12500.0,41.5318727,41.8018951,urban +15500.0,-12000.0,41.5363693,41.8018951,urban +15500.0,-11500.0,41.5408659,41.8018951,urban +15500.0,-11000.0,41.5453625,41.8018951,urban +15500.0,-10500.0,41.5498591,41.8018951,open +15500.0,-10000.0,41.5543557,41.8018951,open +15500.0,-9500.0,41.5588523,41.8018951,open +15500.0,-9000.0,41.563349,41.8018951,open +15500.0,-8500.0,41.5678456,41.8018951,open +15500.0,-8000.0,41.5723422,41.8018951,open +15500.0,-7500.0,41.5768388,41.8018951,open +15500.0,-7000.0,41.5813354,41.8018951,open +15500.0,-6500.0,41.585832,41.8018951,open +15500.0,-6000.0,41.5903286,41.8018951,open +15500.0,-5500.0,41.5948252,41.8018951,open +15500.0,-5000.0,41.5993218,41.8018951,open +15500.0,-4500.0,41.6038184,41.8018951,open +15500.0,-4000.0,41.608315,41.8018951,open +15500.0,-3500.0,41.6128116,41.8018951,open +15500.0,-3000.0,41.6173083,41.8018951,open +15500.0,-2500.0,41.6218049,41.8018951,open +15500.0,-2000.0,41.6263015,41.8018951,open +15500.0,-1500.0,41.6307981,41.8018951,open +15500.0,-1000.0,41.6352947,41.8018951,open +15500.0,-500.0,41.6397913,41.8018951,open +15500.0,0.0,41.6442879,41.8018951,open +15500.0,500.0,41.6487845,41.8018951,open +15500.0,1000.0,41.6532811,41.8018951,urban +15500.0,1500.0,41.6577777,41.8018951,urban +15500.0,2000.0,41.6622743,41.8018951,open +15500.0,2500.0,41.6667709,41.8018951,open +15500.0,3000.0,41.6712675,41.8018951,open +15500.0,3500.0,41.6757642,41.8018951,open +15500.0,4000.0,41.6802608,41.8018951,open +15500.0,4500.0,41.6847574,41.8018951,open +15500.0,5000.0,41.689254,41.8018951,open +15500.0,5500.0,41.6937506,41.8018951,open +15500.0,6000.0,41.6982472,41.8018951,urban +15500.0,6500.0,41.7027438,41.8018951,urban +15500.0,7000.0,41.7072404,41.8018951,urban +15500.0,7500.0,41.711737,41.8018951,urban +15500.0,8000.0,41.7162336,41.8018951,urban +15500.0,8500.0,41.7207302,41.8018951,open +15500.0,9000.0,41.7252268,41.8018951,open +15500.0,9500.0,41.7297235,41.8018951,open +15500.0,10000.0,41.7342201,41.8018951,open +15500.0,10500.0,41.7387167,41.8018951,open +15500.0,11000.0,41.7432133,41.8018951,urban +15500.0,11500.0,41.7477099,41.8018951,urban +15500.0,12000.0,41.7522065,41.8018951,urban +15500.0,12500.0,41.7567031,41.8018951,urban +15500.0,13000.0,41.7611997,41.8018951,urban +15500.0,13500.0,41.7656963,41.8018951,urban +15500.0,14000.0,41.7701929,41.8018951,urban +15500.0,14500.0,41.7746895,41.8018951,open +15500.0,15000.0,41.7791861,41.8018951,open +15500.0,15500.0,41.7836827,41.8018951,urban +15500.0,16000.0,41.7881794,41.8018951,urban +15500.0,16500.0,41.792676,41.8018951,urban +15500.0,17000.0,41.7971726,41.8018951,open +15500.0,17500.0,41.8016692,41.8018951,open +15500.0,18000.0,41.8061658,41.8018951,urban +15500.0,18500.0,41.8106624,41.8018951,open +15500.0,19000.0,41.815159,41.8018951,urban +15500.0,19500.0,41.8196556,41.8018951,urban +16000.0,-16000.0,41.5003964,41.8079124,open +16000.0,-15500.0,41.5048931,41.8079124,open +16000.0,-15000.0,41.5093897,41.8079124,open +16000.0,-14500.0,41.5138863,41.8079124,urban +16000.0,-14000.0,41.5183829,41.8079124,urban +16000.0,-13500.0,41.5228795,41.8079124,open +16000.0,-13000.0,41.5273761,41.8079124,open +16000.0,-12500.0,41.5318727,41.8079124,urban +16000.0,-12000.0,41.5363693,41.8079124,urban +16000.0,-11500.0,41.5408659,41.8079124,urban +16000.0,-11000.0,41.5453625,41.8079124,urban +16000.0,-10500.0,41.5498591,41.8079124,urban +16000.0,-10000.0,41.5543557,41.8079124,urban +16000.0,-9500.0,41.5588523,41.8079124,urban +16000.0,-9000.0,41.563349,41.8079124,open +16000.0,-8500.0,41.5678456,41.8079124,open +16000.0,-8000.0,41.5723422,41.8079124,open +16000.0,-7500.0,41.5768388,41.8079124,open +16000.0,-7000.0,41.5813354,41.8079124,open +16000.0,-6500.0,41.585832,41.8079124,open +16000.0,-6000.0,41.5903286,41.8079124,open +16000.0,-5500.0,41.5948252,41.8079124,open +16000.0,-5000.0,41.5993218,41.8079124,open +16000.0,-4500.0,41.6038184,41.8079124,open +16000.0,-4000.0,41.608315,41.8079124,open +16000.0,-3500.0,41.6128116,41.8079124,open +16000.0,-3000.0,41.6173083,41.8079124,open +16000.0,-2500.0,41.6218049,41.8079124,open +16000.0,-2000.0,41.6263015,41.8079124,open +16000.0,-1500.0,41.6307981,41.8079124,open +16000.0,-1000.0,41.6352947,41.8079124,open +16000.0,-500.0,41.6397913,41.8079124,open +16000.0,0.0,41.6442879,41.8079124,urban +16000.0,500.0,41.6487845,41.8079124,urban +16000.0,1000.0,41.6532811,41.8079124,open +16000.0,1500.0,41.6577777,41.8079124,open +16000.0,2000.0,41.6622743,41.8079124,open +16000.0,2500.0,41.6667709,41.8079124,open +16000.0,3000.0,41.6712675,41.8079124,open +16000.0,3500.0,41.6757642,41.8079124,open +16000.0,4000.0,41.6802608,41.8079124,open +16000.0,4500.0,41.6847574,41.8079124,open +16000.0,5000.0,41.689254,41.8079124,open +16000.0,5500.0,41.6937506,41.8079124,open +16000.0,6000.0,41.6982472,41.8079124,open +16000.0,6500.0,41.7027438,41.8079124,urban +16000.0,7000.0,41.7072404,41.8079124,urban +16000.0,7500.0,41.711737,41.8079124,urban +16000.0,8000.0,41.7162336,41.8079124,urban +16000.0,8500.0,41.7207302,41.8079124,urban +16000.0,9000.0,41.7252268,41.8079124,open +16000.0,9500.0,41.7297235,41.8079124,open +16000.0,10000.0,41.7342201,41.8079124,open +16000.0,10500.0,41.7387167,41.8079124,open +16000.0,11000.0,41.7432133,41.8079124,urban +16000.0,11500.0,41.7477099,41.8079124,urban +16000.0,12000.0,41.7522065,41.8079124,urban +16000.0,12500.0,41.7567031,41.8079124,urban +16000.0,13000.0,41.7611997,41.8079124,urban +16000.0,13500.0,41.7656963,41.8079124,urban +16000.0,14000.0,41.7701929,41.8079124,open +16000.0,14500.0,41.7746895,41.8079124,open +16000.0,15000.0,41.7791861,41.8079124,open +16000.0,15500.0,41.7836827,41.8079124,urban +16000.0,16000.0,41.7881794,41.8079124,urban +16000.0,16500.0,41.792676,41.8079124,open +16000.0,17000.0,41.7971726,41.8079124,open +16000.0,17500.0,41.8016692,41.8079124,urban +16000.0,18000.0,41.8061658,41.8079124,urban +16000.0,18500.0,41.8106624,41.8079124,open +16000.0,19000.0,41.815159,41.8079124,urban +16000.0,19500.0,41.8196556,41.8079124,open +16500.0,-16000.0,41.5003964,41.8139297,open +16500.0,-15500.0,41.5048931,41.8139297,open +16500.0,-15000.0,41.5093897,41.8139297,urban +16500.0,-14500.0,41.5138863,41.8139297,open +16500.0,-14000.0,41.5183829,41.8139297,open +16500.0,-13500.0,41.5228795,41.8139297,open +16500.0,-13000.0,41.5273761,41.8139297,open +16500.0,-12500.0,41.5318727,41.8139297,open +16500.0,-12000.0,41.5363693,41.8139297,urban +16500.0,-11500.0,41.5408659,41.8139297,urban +16500.0,-11000.0,41.5453625,41.8139297,urban +16500.0,-10500.0,41.5498591,41.8139297,urban +16500.0,-10000.0,41.5543557,41.8139297,urban +16500.0,-9500.0,41.5588523,41.8139297,urban +16500.0,-9000.0,41.563349,41.8139297,open +16500.0,-8500.0,41.5678456,41.8139297,open +16500.0,-8000.0,41.5723422,41.8139297,open +16500.0,-7500.0,41.5768388,41.8139297,open +16500.0,-7000.0,41.5813354,41.8139297,open +16500.0,-6500.0,41.585832,41.8139297,open +16500.0,-6000.0,41.5903286,41.8139297,open +16500.0,-5500.0,41.5948252,41.8139297,open +16500.0,-5000.0,41.5993218,41.8139297,open +16500.0,-4500.0,41.6038184,41.8139297,open +16500.0,-4000.0,41.608315,41.8139297,open +16500.0,-3500.0,41.6128116,41.8139297,open +16500.0,-3000.0,41.6173083,41.8139297,open +16500.0,-2500.0,41.6218049,41.8139297,open +16500.0,-2000.0,41.6263015,41.8139297,open +16500.0,-1500.0,41.6307981,41.8139297,open +16500.0,-1000.0,41.6352947,41.8139297,open +16500.0,-500.0,41.6397913,41.8139297,open +16500.0,0.0,41.6442879,41.8139297,open +16500.0,500.0,41.6487845,41.8139297,open +16500.0,1000.0,41.6532811,41.8139297,open +16500.0,1500.0,41.6577777,41.8139297,open +16500.0,2000.0,41.6622743,41.8139297,open +16500.0,2500.0,41.6667709,41.8139297,open +16500.0,3000.0,41.6712675,41.8139297,open +16500.0,3500.0,41.6757642,41.8139297,open +16500.0,4000.0,41.6802608,41.8139297,open +16500.0,4500.0,41.6847574,41.8139297,open +16500.0,5000.0,41.689254,41.8139297,open +16500.0,5500.0,41.6937506,41.8139297,open +16500.0,6000.0,41.6982472,41.8139297,urban +16500.0,6500.0,41.7027438,41.8139297,urban +16500.0,7000.0,41.7072404,41.8139297,urban +16500.0,7500.0,41.711737,41.8139297,open +16500.0,8000.0,41.7162336,41.8139297,urban +16500.0,8500.0,41.7207302,41.8139297,open +16500.0,9000.0,41.7252268,41.8139297,open +16500.0,9500.0,41.7297235,41.8139297,open +16500.0,10000.0,41.7342201,41.8139297,open +16500.0,10500.0,41.7387167,41.8139297,open +16500.0,11000.0,41.7432133,41.8139297,urban +16500.0,11500.0,41.7477099,41.8139297,urban +16500.0,12000.0,41.7522065,41.8139297,urban +16500.0,12500.0,41.7567031,41.8139297,urban +16500.0,13000.0,41.7611997,41.8139297,urban +16500.0,13500.0,41.7656963,41.8139297,urban +16500.0,14000.0,41.7701929,41.8139297,open +16500.0,14500.0,41.7746895,41.8139297,open +16500.0,15000.0,41.7791861,41.8139297,open +16500.0,15500.0,41.7836827,41.8139297,open +16500.0,16000.0,41.7881794,41.8139297,open +16500.0,16500.0,41.792676,41.8139297,open +16500.0,17000.0,41.7971726,41.8139297,open +16500.0,17500.0,41.8016692,41.8139297,water +16500.0,18000.0,41.8061658,41.8139297,open +16500.0,18500.0,41.8106624,41.8139297,open +16500.0,19000.0,41.815159,41.8139297,open +16500.0,19500.0,41.8196556,41.8139297,open +17000.0,-16000.0,41.5003964,41.8199469,open +17000.0,-15500.0,41.5048931,41.8199469,open +17000.0,-15000.0,41.5093897,41.8199469,urban +17000.0,-14500.0,41.5138863,41.8199469,urban +17000.0,-14000.0,41.5183829,41.8199469,urban +17000.0,-13500.0,41.5228795,41.8199469,open +17000.0,-13000.0,41.5273761,41.8199469,open +17000.0,-12500.0,41.5318727,41.8199469,open +17000.0,-12000.0,41.5363693,41.8199469,open +17000.0,-11500.0,41.5408659,41.8199469,urban +17000.0,-11000.0,41.5453625,41.8199469,urban +17000.0,-10500.0,41.5498591,41.8199469,urban +17000.0,-10000.0,41.5543557,41.8199469,urban +17000.0,-9500.0,41.5588523,41.8199469,urban +17000.0,-9000.0,41.563349,41.8199469,open +17000.0,-8500.0,41.5678456,41.8199469,open +17000.0,-8000.0,41.5723422,41.8199469,open +17000.0,-7500.0,41.5768388,41.8199469,open +17000.0,-7000.0,41.5813354,41.8199469,open +17000.0,-6500.0,41.585832,41.8199469,open +17000.0,-6000.0,41.5903286,41.8199469,open +17000.0,-5500.0,41.5948252,41.8199469,open +17000.0,-5000.0,41.5993218,41.8199469,open +17000.0,-4500.0,41.6038184,41.8199469,open +17000.0,-4000.0,41.608315,41.8199469,open +17000.0,-3500.0,41.6128116,41.8199469,open +17000.0,-3000.0,41.6173083,41.8199469,open +17000.0,-2500.0,41.6218049,41.8199469,open +17000.0,-2000.0,41.6263015,41.8199469,open +17000.0,-1500.0,41.6307981,41.8199469,open +17000.0,-1000.0,41.6352947,41.8199469,open +17000.0,-500.0,41.6397913,41.8199469,open +17000.0,0.0,41.6442879,41.8199469,open +17000.0,500.0,41.6487845,41.8199469,open +17000.0,1000.0,41.6532811,41.8199469,open +17000.0,1500.0,41.6577777,41.8199469,open +17000.0,2000.0,41.6622743,41.8199469,open +17000.0,2500.0,41.6667709,41.8199469,open +17000.0,3000.0,41.6712675,41.8199469,open +17000.0,3500.0,41.6757642,41.8199469,open +17000.0,4000.0,41.6802608,41.8199469,open +17000.0,4500.0,41.6847574,41.8199469,open +17000.0,5000.0,41.689254,41.8199469,open +17000.0,5500.0,41.6937506,41.8199469,urban +17000.0,6000.0,41.6982472,41.8199469,urban +17000.0,6500.0,41.7027438,41.8199469,urban +17000.0,7000.0,41.7072404,41.8199469,urban +17000.0,7500.0,41.711737,41.8199469,open +17000.0,8000.0,41.7162336,41.8199469,open +17000.0,8500.0,41.7207302,41.8199469,open +17000.0,9000.0,41.7252268,41.8199469,open +17000.0,9500.0,41.7297235,41.8199469,open +17000.0,10000.0,41.7342201,41.8199469,open +17000.0,10500.0,41.7387167,41.8199469,open +17000.0,11000.0,41.7432133,41.8199469,urban +17000.0,11500.0,41.7477099,41.8199469,urban +17000.0,12000.0,41.7522065,41.8199469,urban +17000.0,12500.0,41.7567031,41.8199469,urban +17000.0,13000.0,41.7611997,41.8199469,urban +17000.0,13500.0,41.7656963,41.8199469,urban +17000.0,14000.0,41.7701929,41.8199469,open +17000.0,14500.0,41.7746895,41.8199469,open +17000.0,15000.0,41.7791861,41.8199469,open +17000.0,15500.0,41.7836827,41.8199469,open +17000.0,16000.0,41.7881794,41.8199469,open +17000.0,16500.0,41.792676,41.8199469,open +17000.0,17000.0,41.7971726,41.8199469,open +17000.0,17500.0,41.8016692,41.8199469,open +17000.0,18000.0,41.8061658,41.8199469,urban +17000.0,18500.0,41.8106624,41.8199469,open +17000.0,19000.0,41.815159,41.8199469,open +17000.0,19500.0,41.8196556,41.8199469,open +17500.0,-16000.0,41.5003964,41.8259642,open +17500.0,-15500.0,41.5048931,41.8259642,urban +17500.0,-15000.0,41.5093897,41.8259642,urban +17500.0,-14500.0,41.5138863,41.8259642,urban +17500.0,-14000.0,41.5183829,41.8259642,urban +17500.0,-13500.0,41.5228795,41.8259642,open +17500.0,-13000.0,41.5273761,41.8259642,open +17500.0,-12500.0,41.5318727,41.8259642,open +17500.0,-12000.0,41.5363693,41.8259642,open +17500.0,-11500.0,41.5408659,41.8259642,urban +17500.0,-11000.0,41.5453625,41.8259642,urban +17500.0,-10500.0,41.5498591,41.8259642,urban +17500.0,-10000.0,41.5543557,41.8259642,urban +17500.0,-9500.0,41.5588523,41.8259642,urban +17500.0,-9000.0,41.563349,41.8259642,water +17500.0,-8500.0,41.5678456,41.8259642,urban +17500.0,-8000.0,41.5723422,41.8259642,open +17500.0,-7500.0,41.5768388,41.8259642,open +17500.0,-7000.0,41.5813354,41.8259642,open +17500.0,-6500.0,41.585832,41.8259642,open +17500.0,-6000.0,41.5903286,41.8259642,open +17500.0,-5500.0,41.5948252,41.8259642,open +17500.0,-5000.0,41.5993218,41.8259642,open +17500.0,-4500.0,41.6038184,41.8259642,open +17500.0,-4000.0,41.608315,41.8259642,open +17500.0,-3500.0,41.6128116,41.8259642,open +17500.0,-3000.0,41.6173083,41.8259642,open +17500.0,-2500.0,41.6218049,41.8259642,open +17500.0,-2000.0,41.6263015,41.8259642,open +17500.0,-1500.0,41.6307981,41.8259642,open +17500.0,-1000.0,41.6352947,41.8259642,open +17500.0,-500.0,41.6397913,41.8259642,open +17500.0,0.0,41.6442879,41.8259642,open +17500.0,500.0,41.6487845,41.8259642,open +17500.0,1000.0,41.6532811,41.8259642,open +17500.0,1500.0,41.6577777,41.8259642,open +17500.0,2000.0,41.6622743,41.8259642,open +17500.0,2500.0,41.6667709,41.8259642,open +17500.0,3000.0,41.6712675,41.8259642,open +17500.0,3500.0,41.6757642,41.8259642,open +17500.0,4000.0,41.6802608,41.8259642,open +17500.0,4500.0,41.6847574,41.8259642,open +17500.0,5000.0,41.689254,41.8259642,open +17500.0,5500.0,41.6937506,41.8259642,open +17500.0,6000.0,41.6982472,41.8259642,open +17500.0,6500.0,41.7027438,41.8259642,open +17500.0,7000.0,41.7072404,41.8259642,open +17500.0,7500.0,41.711737,41.8259642,open +17500.0,8000.0,41.7162336,41.8259642,open +17500.0,8500.0,41.7207302,41.8259642,open +17500.0,9000.0,41.7252268,41.8259642,open +17500.0,9500.0,41.7297235,41.8259642,open +17500.0,10000.0,41.7342201,41.8259642,open +17500.0,10500.0,41.7387167,41.8259642,open +17500.0,11000.0,41.7432133,41.8259642,open +17500.0,11500.0,41.7477099,41.8259642,urban +17500.0,12000.0,41.7522065,41.8259642,urban +17500.0,12500.0,41.7567031,41.8259642,urban +17500.0,13000.0,41.7611997,41.8259642,urban +17500.0,13500.0,41.7656963,41.8259642,open +17500.0,14000.0,41.7701929,41.8259642,open +17500.0,14500.0,41.7746895,41.8259642,open +17500.0,15000.0,41.7791861,41.8259642,open +17500.0,15500.0,41.7836827,41.8259642,open +17500.0,16000.0,41.7881794,41.8259642,open +17500.0,16500.0,41.792676,41.8259642,open +17500.0,17000.0,41.7971726,41.8259642,open +17500.0,17500.0,41.8016692,41.8259642,urban +17500.0,18000.0,41.8061658,41.8259642,urban +17500.0,18500.0,41.8106624,41.8259642,open +17500.0,19000.0,41.815159,41.8259642,open +17500.0,19500.0,41.8196556,41.8259642,open +18000.0,-16000.0,41.5003964,41.8319814,urban +18000.0,-15500.0,41.5048931,41.8319814,urban +18000.0,-15000.0,41.5093897,41.8319814,urban +18000.0,-14500.0,41.5138863,41.8319814,urban +18000.0,-14000.0,41.5183829,41.8319814,open +18000.0,-13500.0,41.5228795,41.8319814,open +18000.0,-13000.0,41.5273761,41.8319814,open +18000.0,-12500.0,41.5318727,41.8319814,open +18000.0,-12000.0,41.5363693,41.8319814,open +18000.0,-11500.0,41.5408659,41.8319814,open +18000.0,-11000.0,41.5453625,41.8319814,open +18000.0,-10500.0,41.5498591,41.8319814,urban +18000.0,-10000.0,41.5543557,41.8319814,open +18000.0,-9500.0,41.5588523,41.8319814,urban +18000.0,-9000.0,41.563349,41.8319814,urban +18000.0,-8500.0,41.5678456,41.8319814,urban +18000.0,-8000.0,41.5723422,41.8319814,urban +18000.0,-7500.0,41.5768388,41.8319814,open +18000.0,-7000.0,41.5813354,41.8319814,open +18000.0,-6500.0,41.585832,41.8319814,open +18000.0,-6000.0,41.5903286,41.8319814,open +18000.0,-5500.0,41.5948252,41.8319814,open +18000.0,-5000.0,41.5993218,41.8319814,open +18000.0,-4500.0,41.6038184,41.8319814,open +18000.0,-4000.0,41.608315,41.8319814,open +18000.0,-3500.0,41.6128116,41.8319814,open +18000.0,-3000.0,41.6173083,41.8319814,open +18000.0,-2500.0,41.6218049,41.8319814,open +18000.0,-2000.0,41.6263015,41.8319814,open +18000.0,-1500.0,41.6307981,41.8319814,open +18000.0,-1000.0,41.6352947,41.8319814,open +18000.0,-500.0,41.6397913,41.8319814,open +18000.0,0.0,41.6442879,41.8319814,open +18000.0,500.0,41.6487845,41.8319814,open +18000.0,1000.0,41.6532811,41.8319814,open +18000.0,1500.0,41.6577777,41.8319814,open +18000.0,2000.0,41.6622743,41.8319814,open +18000.0,2500.0,41.6667709,41.8319814,open +18000.0,3000.0,41.6712675,41.8319814,open +18000.0,3500.0,41.6757642,41.8319814,open +18000.0,4000.0,41.6802608,41.8319814,open +18000.0,4500.0,41.6847574,41.8319814,open +18000.0,5000.0,41.689254,41.8319814,open +18000.0,5500.0,41.6937506,41.8319814,open +18000.0,6000.0,41.6982472,41.8319814,open +18000.0,6500.0,41.7027438,41.8319814,open +18000.0,7000.0,41.7072404,41.8319814,open +18000.0,7500.0,41.711737,41.8319814,open +18000.0,8000.0,41.7162336,41.8319814,open +18000.0,8500.0,41.7207302,41.8319814,open +18000.0,9000.0,41.7252268,41.8319814,open +18000.0,9500.0,41.7297235,41.8319814,open +18000.0,10000.0,41.7342201,41.8319814,open +18000.0,10500.0,41.7387167,41.8319814,open +18000.0,11000.0,41.7432133,41.8319814,open +18000.0,11500.0,41.7477099,41.8319814,open +18000.0,12000.0,41.7522065,41.8319814,urban +18000.0,12500.0,41.7567031,41.8319814,urban +18000.0,13000.0,41.7611997,41.8319814,open +18000.0,13500.0,41.7656963,41.8319814,open +18000.0,14000.0,41.7701929,41.8319814,open +18000.0,14500.0,41.7746895,41.8319814,open +18000.0,15000.0,41.7791861,41.8319814,open +18000.0,15500.0,41.7836827,41.8319814,open +18000.0,16000.0,41.7881794,41.8319814,open +18000.0,16500.0,41.792676,41.8319814,open +18000.0,17000.0,41.7971726,41.8319814,open +18000.0,17500.0,41.8016692,41.8319814,urban +18000.0,18000.0,41.8061658,41.8319814,urban +18000.0,18500.0,41.8106624,41.8319814,open +18000.0,19000.0,41.815159,41.8319814,open +18000.0,19500.0,41.8196556,41.8319814,open +18500.0,-16000.0,41.5003964,41.8379987,urban +18500.0,-15500.0,41.5048931,41.8379987,urban +18500.0,-15000.0,41.5093897,41.8379987,urban +18500.0,-14500.0,41.5138863,41.8379987,open +18500.0,-14000.0,41.5183829,41.8379987,open +18500.0,-13500.0,41.5228795,41.8379987,open +18500.0,-13000.0,41.5273761,41.8379987,open +18500.0,-12500.0,41.5318727,41.8379987,open +18500.0,-12000.0,41.5363693,41.8379987,open +18500.0,-11500.0,41.5408659,41.8379987,open +18500.0,-11000.0,41.5453625,41.8379987,open +18500.0,-10500.0,41.5498591,41.8379987,urban +18500.0,-10000.0,41.5543557,41.8379987,urban +18500.0,-9500.0,41.5588523,41.8379987,urban +18500.0,-9000.0,41.563349,41.8379987,urban +18500.0,-8500.0,41.5678456,41.8379987,urban +18500.0,-8000.0,41.5723422,41.8379987,open +18500.0,-7500.0,41.5768388,41.8379987,open +18500.0,-7000.0,41.5813354,41.8379987,open +18500.0,-6500.0,41.585832,41.8379987,open +18500.0,-6000.0,41.5903286,41.8379987,open +18500.0,-5500.0,41.5948252,41.8379987,open +18500.0,-5000.0,41.5993218,41.8379987,urban +18500.0,-4500.0,41.6038184,41.8379987,open +18500.0,-4000.0,41.608315,41.8379987,open +18500.0,-3500.0,41.6128116,41.8379987,open +18500.0,-3000.0,41.6173083,41.8379987,open +18500.0,-2500.0,41.6218049,41.8379987,open +18500.0,-2000.0,41.6263015,41.8379987,open +18500.0,-1500.0,41.6307981,41.8379987,open +18500.0,-1000.0,41.6352947,41.8379987,open +18500.0,-500.0,41.6397913,41.8379987,open +18500.0,0.0,41.6442879,41.8379987,open +18500.0,500.0,41.6487845,41.8379987,open +18500.0,1000.0,41.6532811,41.8379987,open +18500.0,1500.0,41.6577777,41.8379987,open +18500.0,2000.0,41.6622743,41.8379987,open +18500.0,2500.0,41.6667709,41.8379987,open +18500.0,3000.0,41.6712675,41.8379987,open +18500.0,3500.0,41.6757642,41.8379987,open +18500.0,4000.0,41.6802608,41.8379987,open +18500.0,4500.0,41.6847574,41.8379987,open +18500.0,5000.0,41.689254,41.8379987,open +18500.0,5500.0,41.6937506,41.8379987,open +18500.0,6000.0,41.6982472,41.8379987,open +18500.0,6500.0,41.7027438,41.8379987,open +18500.0,7000.0,41.7072404,41.8379987,open +18500.0,7500.0,41.711737,41.8379987,open +18500.0,8000.0,41.7162336,41.8379987,open +18500.0,8500.0,41.7207302,41.8379987,open +18500.0,9000.0,41.7252268,41.8379987,open +18500.0,9500.0,41.7297235,41.8379987,open +18500.0,10000.0,41.7342201,41.8379987,open +18500.0,10500.0,41.7387167,41.8379987,open +18500.0,11000.0,41.7432133,41.8379987,open +18500.0,11500.0,41.7477099,41.8379987,open +18500.0,12000.0,41.7522065,41.8379987,open +18500.0,12500.0,41.7567031,41.8379987,open +18500.0,13000.0,41.7611997,41.8379987,open +18500.0,13500.0,41.7656963,41.8379987,urban +18500.0,14000.0,41.7701929,41.8379987,open +18500.0,14500.0,41.7746895,41.8379987,open +18500.0,15000.0,41.7791861,41.8379987,open +18500.0,15500.0,41.7836827,41.8379987,open +18500.0,16000.0,41.7881794,41.8379987,open +18500.0,16500.0,41.792676,41.8379987,urban +18500.0,17000.0,41.7971726,41.8379987,urban +18500.0,17500.0,41.8016692,41.8379987,urban +18500.0,18000.0,41.8061658,41.8379987,urban +18500.0,18500.0,41.8106624,41.8379987,urban +18500.0,19000.0,41.815159,41.8379987,urban +18500.0,19500.0,41.8196556,41.8379987,urban +19000.0,-16000.0,41.5003964,41.844016,urban +19000.0,-15500.0,41.5048931,41.844016,urban +19000.0,-15000.0,41.5093897,41.844016,urban +19000.0,-14500.0,41.5138863,41.844016,urban +19000.0,-14000.0,41.5183829,41.844016,open +19000.0,-13500.0,41.5228795,41.844016,open +19000.0,-13000.0,41.5273761,41.844016,open +19000.0,-12500.0,41.5318727,41.844016,open +19000.0,-12000.0,41.5363693,41.844016,open +19000.0,-11500.0,41.5408659,41.844016,open +19000.0,-11000.0,41.5453625,41.844016,open +19000.0,-10500.0,41.5498591,41.844016,urban +19000.0,-10000.0,41.5543557,41.844016,urban +19000.0,-9500.0,41.5588523,41.844016,urban +19000.0,-9000.0,41.563349,41.844016,urban +19000.0,-8500.0,41.5678456,41.844016,urban +19000.0,-8000.0,41.5723422,41.844016,urban +19000.0,-7500.0,41.5768388,41.844016,open +19000.0,-7000.0,41.5813354,41.844016,open +19000.0,-6500.0,41.585832,41.844016,open +19000.0,-6000.0,41.5903286,41.844016,open +19000.0,-5500.0,41.5948252,41.844016,urban +19000.0,-5000.0,41.5993218,41.844016,open +19000.0,-4500.0,41.6038184,41.844016,open +19000.0,-4000.0,41.608315,41.844016,open +19000.0,-3500.0,41.6128116,41.844016,open +19000.0,-3000.0,41.6173083,41.844016,open +19000.0,-2500.0,41.6218049,41.844016,open +19000.0,-2000.0,41.6263015,41.844016,open +19000.0,-1500.0,41.6307981,41.844016,open +19000.0,-1000.0,41.6352947,41.844016,open +19000.0,-500.0,41.6397913,41.844016,open +19000.0,0.0,41.6442879,41.844016,open +19000.0,500.0,41.6487845,41.844016,open +19000.0,1000.0,41.6532811,41.844016,open +19000.0,1500.0,41.6577777,41.844016,open +19000.0,2000.0,41.6622743,41.844016,open +19000.0,2500.0,41.6667709,41.844016,open +19000.0,3000.0,41.6712675,41.844016,open +19000.0,3500.0,41.6757642,41.844016,open +19000.0,4000.0,41.6802608,41.844016,open +19000.0,4500.0,41.6847574,41.844016,urban +19000.0,5000.0,41.689254,41.844016,open +19000.0,5500.0,41.6937506,41.844016,open +19000.0,6000.0,41.6982472,41.844016,open +19000.0,6500.0,41.7027438,41.844016,open +19000.0,7000.0,41.7072404,41.844016,open +19000.0,7500.0,41.711737,41.844016,open +19000.0,8000.0,41.7162336,41.844016,open +19000.0,8500.0,41.7207302,41.844016,open +19000.0,9000.0,41.7252268,41.844016,open +19000.0,9500.0,41.7297235,41.844016,open +19000.0,10000.0,41.7342201,41.844016,open +19000.0,10500.0,41.7387167,41.844016,open +19000.0,11000.0,41.7432133,41.844016,open +19000.0,11500.0,41.7477099,41.844016,open +19000.0,12000.0,41.7522065,41.844016,open +19000.0,12500.0,41.7567031,41.844016,open +19000.0,13000.0,41.7611997,41.844016,open +19000.0,13500.0,41.7656963,41.844016,open +19000.0,14000.0,41.7701929,41.844016,open +19000.0,14500.0,41.7746895,41.844016,open +19000.0,15000.0,41.7791861,41.844016,open +19000.0,15500.0,41.7836827,41.844016,open +19000.0,16000.0,41.7881794,41.844016,open +19000.0,16500.0,41.792676,41.844016,urban +19000.0,17000.0,41.7971726,41.844016,urban +19000.0,17500.0,41.8016692,41.844016,urban +19000.0,18000.0,41.8061658,41.844016,urban +19000.0,18500.0,41.8106624,41.844016,urban +19000.0,19000.0,41.815159,41.844016,urban +19000.0,19500.0,41.8196556,41.844016,urban +19500.0,-16000.0,41.5003964,41.8500332,open +19500.0,-15500.0,41.5048931,41.8500332,urban +19500.0,-15000.0,41.5093897,41.8500332,urban +19500.0,-14500.0,41.5138863,41.8500332,urban +19500.0,-14000.0,41.5183829,41.8500332,open +19500.0,-13500.0,41.5228795,41.8500332,open +19500.0,-13000.0,41.5273761,41.8500332,open +19500.0,-12500.0,41.5318727,41.8500332,open +19500.0,-12000.0,41.5363693,41.8500332,open +19500.0,-11500.0,41.5408659,41.8500332,open +19500.0,-11000.0,41.5453625,41.8500332,open +19500.0,-10500.0,41.5498591,41.8500332,open +19500.0,-10000.0,41.5543557,41.8500332,open +19500.0,-9500.0,41.5588523,41.8500332,open +19500.0,-9000.0,41.563349,41.8500332,open +19500.0,-8500.0,41.5678456,41.8500332,urban +19500.0,-8000.0,41.5723422,41.8500332,urban +19500.0,-7500.0,41.5768388,41.8500332,urban +19500.0,-7000.0,41.5813354,41.8500332,open +19500.0,-6500.0,41.585832,41.8500332,open +19500.0,-6000.0,41.5903286,41.8500332,open +19500.0,-5500.0,41.5948252,41.8500332,open +19500.0,-5000.0,41.5993218,41.8500332,open +19500.0,-4500.0,41.6038184,41.8500332,urban +19500.0,-4000.0,41.608315,41.8500332,open +19500.0,-3500.0,41.6128116,41.8500332,open +19500.0,-3000.0,41.6173083,41.8500332,open +19500.0,-2500.0,41.6218049,41.8500332,open +19500.0,-2000.0,41.6263015,41.8500332,open +19500.0,-1500.0,41.6307981,41.8500332,open +19500.0,-1000.0,41.6352947,41.8500332,open +19500.0,-500.0,41.6397913,41.8500332,open +19500.0,0.0,41.6442879,41.8500332,open +19500.0,500.0,41.6487845,41.8500332,open +19500.0,1000.0,41.6532811,41.8500332,open +19500.0,1500.0,41.6577777,41.8500332,open +19500.0,2000.0,41.6622743,41.8500332,open +19500.0,2500.0,41.6667709,41.8500332,open +19500.0,3000.0,41.6712675,41.8500332,open +19500.0,3500.0,41.6757642,41.8500332,open +19500.0,4000.0,41.6802608,41.8500332,urban +19500.0,4500.0,41.6847574,41.8500332,open +19500.0,5000.0,41.689254,41.8500332,urban +19500.0,5500.0,41.6937506,41.8500332,open +19500.0,6000.0,41.6982472,41.8500332,open +19500.0,6500.0,41.7027438,41.8500332,open +19500.0,7000.0,41.7072404,41.8500332,open +19500.0,7500.0,41.711737,41.8500332,open +19500.0,8000.0,41.7162336,41.8500332,open +19500.0,8500.0,41.7207302,41.8500332,open +19500.0,9000.0,41.7252268,41.8500332,open +19500.0,9500.0,41.7297235,41.8500332,open +19500.0,10000.0,41.7342201,41.8500332,open +19500.0,10500.0,41.7387167,41.8500332,open +19500.0,11000.0,41.7432133,41.8500332,open +19500.0,11500.0,41.7477099,41.8500332,open +19500.0,12000.0,41.7522065,41.8500332,open +19500.0,12500.0,41.7567031,41.8500332,open +19500.0,13000.0,41.7611997,41.8500332,open +19500.0,13500.0,41.7656963,41.8500332,open +19500.0,14000.0,41.7701929,41.8500332,open +19500.0,14500.0,41.7746895,41.8500332,forest +19500.0,15000.0,41.7791861,41.8500332,open +19500.0,15500.0,41.7836827,41.8500332,open +19500.0,16000.0,41.7881794,41.8500332,open +19500.0,16500.0,41.792676,41.8500332,urban +19500.0,17000.0,41.7971726,41.8500332,urban +19500.0,17500.0,41.8016692,41.8500332,urban +19500.0,18000.0,41.8061658,41.8500332,urban +19500.0,18500.0,41.8106624,41.8500332,urban +19500.0,19000.0,41.815159,41.8500332,urban +19500.0,19500.0,41.8196556,41.8500332,urban +20000.0,-16000.0,41.5003964,41.8560505,urban +20000.0,-15500.0,41.5048931,41.8560505,urban +20000.0,-15000.0,41.5093897,41.8560505,open +20000.0,-14500.0,41.5138863,41.8560505,open +20000.0,-14000.0,41.5183829,41.8560505,open +20000.0,-13500.0,41.5228795,41.8560505,open +20000.0,-13000.0,41.5273761,41.8560505,open +20000.0,-12500.0,41.5318727,41.8560505,open +20000.0,-12000.0,41.5363693,41.8560505,open +20000.0,-11500.0,41.5408659,41.8560505,open +20000.0,-11000.0,41.5453625,41.8560505,open +20000.0,-10500.0,41.5498591,41.8560505,open +20000.0,-10000.0,41.5543557,41.8560505,open +20000.0,-9500.0,41.5588523,41.8560505,open +20000.0,-9000.0,41.563349,41.8560505,open +20000.0,-8500.0,41.5678456,41.8560505,urban +20000.0,-8000.0,41.5723422,41.8560505,urban +20000.0,-7500.0,41.5768388,41.8560505,urban +20000.0,-7000.0,41.5813354,41.8560505,urban +20000.0,-6500.0,41.585832,41.8560505,urban +20000.0,-6000.0,41.5903286,41.8560505,open +20000.0,-5500.0,41.5948252,41.8560505,open +20000.0,-5000.0,41.5993218,41.8560505,open +20000.0,-4500.0,41.6038184,41.8560505,urban +20000.0,-4000.0,41.608315,41.8560505,open +20000.0,-3500.0,41.6128116,41.8560505,open +20000.0,-3000.0,41.6173083,41.8560505,open +20000.0,-2500.0,41.6218049,41.8560505,open +20000.0,-2000.0,41.6263015,41.8560505,open +20000.0,-1500.0,41.6307981,41.8560505,open +20000.0,-1000.0,41.6352947,41.8560505,open +20000.0,-500.0,41.6397913,41.8560505,open +20000.0,0.0,41.6442879,41.8560505,open +20000.0,500.0,41.6487845,41.8560505,open +20000.0,1000.0,41.6532811,41.8560505,open +20000.0,1500.0,41.6577777,41.8560505,open +20000.0,2000.0,41.6622743,41.8560505,open +20000.0,2500.0,41.6667709,41.8560505,open +20000.0,3000.0,41.6712675,41.8560505,open +20000.0,3500.0,41.6757642,41.8560505,open +20000.0,4000.0,41.6802608,41.8560505,urban +20000.0,4500.0,41.6847574,41.8560505,open +20000.0,5000.0,41.689254,41.8560505,open +20000.0,5500.0,41.6937506,41.8560505,open +20000.0,6000.0,41.6982472,41.8560505,open +20000.0,6500.0,41.7027438,41.8560505,open +20000.0,7000.0,41.7072404,41.8560505,open +20000.0,7500.0,41.711737,41.8560505,open +20000.0,8000.0,41.7162336,41.8560505,open +20000.0,8500.0,41.7207302,41.8560505,open +20000.0,9000.0,41.7252268,41.8560505,open +20000.0,9500.0,41.7297235,41.8560505,open +20000.0,10000.0,41.7342201,41.8560505,open +20000.0,10500.0,41.7387167,41.8560505,open +20000.0,11000.0,41.7432133,41.8560505,open +20000.0,11500.0,41.7477099,41.8560505,open +20000.0,12000.0,41.7522065,41.8560505,open +20000.0,12500.0,41.7567031,41.8560505,open +20000.0,13000.0,41.7611997,41.8560505,open +20000.0,13500.0,41.7656963,41.8560505,open +20000.0,14000.0,41.7701929,41.8560505,open +20000.0,14500.0,41.7746895,41.8560505,open +20000.0,15000.0,41.7791861,41.8560505,forest +20000.0,15500.0,41.7836827,41.8560505,urban +20000.0,16000.0,41.7881794,41.8560505,open +20000.0,16500.0,41.792676,41.8560505,urban +20000.0,17000.0,41.7971726,41.8560505,urban +20000.0,17500.0,41.8016692,41.8560505,urban +20000.0,18000.0,41.8061658,41.8560505,urban +20000.0,18500.0,41.8106624,41.8560505,urban +20000.0,19000.0,41.815159,41.8560505,urban +20000.0,19500.0,41.8196556,41.8560505,urban diff --git a/presets/batumi_terrain.csv b/presets/batumi_terrain.csv new file mode 100644 index 00000000..ea3bfa31 --- /dev/null +++ b/presets/batumi_terrain.csv @@ -0,0 +1,43 @@ +x_m,y_m,lat,lon,elevation_m +-5392.75,-11913.82,41.5371444,41.5504608,0.00 +-1392.75,-11913.82,41.5371444,41.5985989,516.00 +2607.25,-11913.82,41.5371444,41.6467370,323.00 +6607.25,-11913.82,41.5371444,41.6948751,467.00 +10607.25,-11913.82,41.5371444,41.7430132,389.00 +14607.25,-11913.82,41.5371444,41.7911513,332.00 +-5392.75,-7913.82,41.5731172,41.5504608,0.00 +-1392.75,-7913.82,41.5731172,41.5985989,159.00 +2607.25,-7913.82,41.5731172,41.6467370,72.00 +6607.25,-7913.82,41.5731172,41.6948751,64.00 +10607.25,-7913.82,41.5731172,41.7430132,801.00 +14607.25,-7913.82,41.5731172,41.7911513,544.00 +-5392.75,-3913.82,41.6090901,41.5504608,0.00 +-1392.75,-3913.82,41.6090901,41.5985989,5.00 +2607.25,-3913.82,41.6090901,41.6467370,185.00 +6607.25,-3913.82,41.6090901,41.6948751,150.00 +10607.25,-3913.82,41.6090901,41.7430132,803.00 +14607.25,-3913.82,41.6090901,41.7911513,978.00 +-5392.75,86.18,41.6450630,41.5504608,0.00 +-1392.75,86.18,41.6450630,41.5985989,0.00 +2607.25,86.18,41.6450630,41.6467370,4.00 +6607.25,86.18,41.6450630,41.6948751,31.00 +10607.25,86.18,41.6450630,41.7430132,223.00 +14607.25,86.18,41.6450630,41.7911513,823.00 +-5392.75,4086.18,41.6810358,41.5504608,0.00 +-1392.75,4086.18,41.6810358,41.5985989,0.00 +2607.25,4086.18,41.6810358,41.6467370,0.00 +6607.25,4086.18,41.6810358,41.6948751,0.00 +10607.25,4086.18,41.6810358,41.7430132,147.00 +14607.25,4086.18,41.6810358,41.7911513,396.00 +-5392.75,8086.18,41.7170087,41.5504608,0.00 +-1392.75,8086.18,41.7170087,41.5985989,0.00 +2607.25,8086.18,41.7170087,41.6467370,0.00 +6607.25,8086.18,41.7170087,41.6948751,0.00 +10607.25,8086.18,41.7170087,41.7430132,12.00 +14607.25,8086.18,41.7170087,41.7911513,278.00 +-5392.75,12086.18,41.7529816,41.5504608,0.00 +-1392.75,12086.18,41.7529816,41.5985989,0.00 +2607.25,12086.18,41.7529816,41.6467370,0.00 +6607.25,12086.18,41.7529816,41.6948751,0.00 +10607.25,12086.18,41.7529816,41.7430132,39.00 +14607.25,12086.18,41.7529816,41.7911513,100.00 diff --git a/tests/test_docs.py b/tests/test_docs.py new file mode 100644 index 00000000..37a36aa2 --- /dev/null +++ b/tests/test_docs.py @@ -0,0 +1,31 @@ +import re +import unittest +from pathlib import Path + +from lib.config import Config + + +class TestDocumentation(unittest.TestCase): + def test_modem_table_matches_config_base_settings(self): + docs = Path("DISCRETE_EVENT_SIM.md").read_text(encoding="utf-8") + rows = [ + row + for row in docs.splitlines() + if re.match(r"^\| \d+ \|", row) + ] + + conf = Config() + self.assertEqual(len(rows), len(conf.MODEM_PRESETS)) + + for row, (preset_name, preset) in zip(rows, conf.MODEM_PRESETS.items()): + cells = [cell.strip() for cell in row.strip("|").split("|")] + _, display_name, bandwidth_khz, coding_rate, spreading_factor, _ = cells + + self.assertEqual(display_name.upper().replace(" ", "_"), preset_name) + self.assertEqual(float(bandwidth_khz), preset["bw"] / 1000) + self.assertEqual(coding_rate, f"4/{preset['cr']}") + self.assertEqual(int(spreading_factor), preset["sf"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_lora_mesh_cli.py b/tests/test_lora_mesh_cli.py index 0ca4af40..06f34f6a 100644 --- a/tests/test_lora_mesh_cli.py +++ b/tests/test_lora_mesh_cli.py @@ -944,6 +944,80 @@ def test_successful_plain_parse_clears_previous_terrain_state(self): self.assertEqual(conf.TERRAIN_PROFILE_SAMPLES, Config().TERRAIN_PROFILE_SAMPLES) self.assertEqual(conf.NODE_Z_REFERENCE, NODE_Z_REFERENCE_GROUND) + def test_parse_params_lists_presets_without_scenario_side_effects(self): + conf = Config() + random.seed(9123) + random_state = random.getstate() + stdout = io.StringIO() + + with contextlib.redirect_stdout(stdout): + with self.assertRaises(SystemExit) as raised: + loraMesh.parse_params(conf, ["--list-presets"]) + + self.assertEqual(raised.exception.code, 0) + self.assertIsNone(conf.NR_NODES) + self.assertEqual(random.getstate(), random_state) + self.assertIn("Available scenario presets:", stdout.getvalue()) + self.assertIn("batumi: 92 nodes", stdout.getvalue()) + self.assertIn("terrain=yes", stdout.getvalue()) + self.assertIn("clutter=yes", stdout.getvalue()) + self.assertIn("link_calibration=yes", stdout.getvalue()) + + def test_parse_params_lists_modem_presets_without_scenario_side_effects(self): + conf = Config() + stdout = io.StringIO() + + with contextlib.redirect_stdout(stdout): + with self.assertRaises(SystemExit) as raised: + loraMesh.parse_params(conf, ["--list-modem-presets"]) + + self.assertEqual(raised.exception.code, 0) + self.assertIsNone(conf.NR_NODES) + self.assertIn("Available modem presets:", stdout.getvalue()) + self.assertIn("LONG_FAST (default):", stdout.getvalue()) + self.assertIn("cr=4/5", stdout.getvalue()) + + def test_parse_params_help_includes_discovery_and_policy_examples(self): + stdout = io.StringIO() + + with contextlib.redirect_stdout(stdout): + with self.assertRaises(SystemExit) as raised: + loraMesh.parse_params(Config(), ["--help"]) + + self.assertEqual(raised.exception.code, 0) + self.assertIn("loraMesh.py --list-presets", stdout.getvalue()) + self.assertIn("--preset batumi --no-gui", stdout.getvalue()) + self.assertIn("--phy-loss-model --capture-collision-model", stdout.getvalue()) + + def test_parse_params_loads_batumi_preset_with_bundled_grids(self): + conf = Config() + + nodes, output = self.parse_quietly( + conf, + ["--preset", "batumi", "--no-gui", "--period-seconds", "2"], + ) + + self.assertEqual(len(nodes), 92) + self.assertEqual(conf.NR_NODES, 92) + self.assertEqual((conf.GEO_ORIGIN_LAT, conf.GEO_ORIGIN_LON), (41.6442879, 41.61536)) + self.assertTrue(conf.TERRAIN_ENABLED) + self.assertTrue(conf.CLUTTER_ENABLED) + self.assertTrue(conf.LINK_CALIBRATION_MODEL_ENABLED) + self.assertIn("Terrain model:", output) + self.assertIn("Clutter model:", output) + self.assertIn("Link calibration model: enabled", output) + + def test_parse_params_can_disable_bundled_preset_clutter(self): + conf = Config() + + self.parse_quietly( + conf, + ["--preset", "batumi", "--no-gui", "--no-clutter"], + ) + + self.assertTrue(conf.TERRAIN_ENABLED) + self.assertFalse(conf.CLUTTER_ENABLED) + def test_parse_params_rejects_before_applying_time_overrides(self): conf = Config() original_simtime = conf.SIMTIME diff --git a/tests/test_presets.py b/tests/test_presets.py new file mode 100644 index 00000000..701195c2 --- /dev/null +++ b/tests/test_presets.py @@ -0,0 +1,104 @@ +import csv +import unittest + +import yaml + +from lib.config import Config +from lib.presets import ( + PRESET_ROOT, + apply_preset_radio_calibration, + load_preset_node_configs, + preset_calibration_observations, + preset_clutter_grid, + preset_origin, + preset_radio_calibration, + preset_terrain_grid, +) +from lib.terrain import xy_to_latlon + + +# Broad enough to include the Batumi coastal/ridge mesh snapshot, narrow enough +# to catch accidentally bundled non-Georgia/global map data. +BATUMI_GEORGIA_BBOX = (41.50, 41.50, 41.82, 41.86) + + +def inside_bbox(lat, lon, bbox): + min_lat, min_lon, max_lat, max_lon = bbox + return min_lat <= lat <= max_lat and min_lon <= lon <= max_lon + + +class TestPresets(unittest.TestCase): + def test_batumi_preset_loads_nodes_and_terrain(self): + configs = load_preset_node_configs("batumi", 1000) + + self.assertEqual(len(configs), 92) + self.assertTrue(preset_terrain_grid("batumi").exists()) + self.assertTrue(preset_clutter_grid("batumi").exists()) + self.assertEqual(preset_origin("batumi"), (41.6442879, 41.61536)) + + def test_batumi_preset_applies_radio_calibration(self): + conf = Config() + + apply_preset_radio_calibration(conf, "batumi") + + self.assertEqual(preset_radio_calibration("batumi")["noise_level"], -110.5) + self.assertEqual(conf.NOISE_LEVEL, -110.5) + self.assertEqual(conf.PATH_LOSS_DISTANCE_FLOOR_M, 780.0) + self.assertEqual(conf.REPORTED_SNR_MIN_DB, -21.25) + self.assertEqual(conf.REPORTED_SNR_MAX_DB, 8.25) + self.assertTrue(conf.LINK_CALIBRATION_MODEL_ENABLED) + self.assertEqual(conf.LINK_CALIBRATION_SNR_MIN_DB, -35.0) + self.assertEqual(conf.LINK_CALIBRATION_SNR_MAX_DB, 8.25) + self.assertIn("raw_snr_clip", conf.LINK_CALIBRATION_COEFFICIENTS) + self.assertEqual(len(preset_calibration_observations("batumi")), 296) + + def test_batumi_preset_nodes_are_inside_batumi_georgia_area(self): + raw = yaml.safe_load((PRESET_ROOT / "batumi.yaml").read_text(encoding="utf-8")) + origin = raw["origin"] + + coords = [] + for node in raw["nodes"].values(): + lat, lon = xy_to_latlon(float(node["x"]), float(node["y"]), origin["lat"], origin["lon"]) + coords.append((lat, lon)) + + self.assertEqual(len(coords), 92) + self.assertTrue(all(inside_bbox(lat, lon, BATUMI_GEORGIA_BBOX) for lat, lon in coords)) + + def test_batumi_preset_does_not_publish_source_metadata(self): + raw = yaml.safe_load((PRESET_ROOT / "batumi.yaml").read_text(encoding="utf-8")) + + for node in raw["nodes"].values(): + self.assertFalse(any(key.startswith("source_") for key in node)) + + node_ids = set(raw["nodes"].keys()) + for link in raw["calibration_observations"]: + self.assertEqual(set(link.keys()), {"from", "to", "snr"}) + self.assertIn(link["from"], node_ids) + self.assertIn(link["to"], node_ids) + + def test_batumi_terrain_grid_is_inside_georgia_side_of_region(self): + with preset_terrain_grid("batumi").open(encoding="utf-8") as fh: + rows = list(csv.DictReader(fh)) + + self.assertGreater(len(rows), 0) + self.assertTrue(all( + inside_bbox(float(row["lat"]), float(row["lon"]), BATUMI_GEORGIA_BBOX) + for row in rows + )) + + def test_batumi_clutter_grid_is_inside_georgia_side_of_region(self): + with preset_clutter_grid("batumi").open(encoding="utf-8") as fh: + rows = list(csv.DictReader(fh)) + + classes = {row["clutter_class"] for row in rows} + self.assertGreater(len(rows), 0) + self.assertIn("urban", classes) + self.assertIn("open", classes) + self.assertTrue(all( + inside_bbox(float(row["lat"]), float(row["lon"]), BATUMI_GEORGIA_BBOX) + for row in rows + )) + + +if __name__ == "__main__": + unittest.main() From 9b9a9e86c16e69a6aaf4a02f698717cad90c1338 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Sat, 2 May 2026 12:22:41 +0400 Subject: [PATCH 17/24] feat(sim): add radio policy compare workflow --- README.md | 55 ++++ docs/radio_physics_quickstart.md | 165 ++++++++++ tests/test_radio_policy_compare.py | 211 ++++++++++++ tools/radio_policy_compare.py | 513 +++++++++++++++++++++++++++++ 4 files changed, 944 insertions(+) create mode 100644 docs/radio_physics_quickstart.md create mode 100644 tests/test_radio_policy_compare.py create mode 100644 tools/radio_policy_compare.py diff --git a/README.md b/README.md index 55d14297..0a940309 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,61 @@ # Meshtasticator Discrete-event and interactive simulator for [Meshtastic](https://meshtastic.org/). +## Quick start + +Install the Python dependencies, then ask the CLI what runnable scenarios it +already knows about: + +```bash +python3 -m venv .venv +. .venv/bin/activate +pip install -r requirements.txt + +./loraMesh.py --list-presets +./loraMesh.py --list-modem-presets +``` + +Run the packaged Batumi/Georgia-area radio scenario headlessly: + +```bash +./loraMesh.py --preset batumi --no-gui --simtime-seconds 60 --period-seconds 5 +``` + +Compare the static, Dynamic Coding Rate, and Dynamic Coding Rate + Dynamic TX +Power policies in one command: + +```bash +python3 tools/radio_policy_compare.py --simtime-seconds 60 --period-seconds 5 +``` + +For CI-style runs, write JSON/Markdown artifacts and make regressions fail the +job: + +```bash +python3 tools/radio_policy_compare.py \ + --simtime-seconds 120 \ + --period-seconds 5 \ + --json-output out/radio_policy_compare.json \ + --markdown-output out/radio_policy_compare.md \ + --max-reach-drop-pp 1.0 \ + --max-tx-air-increase-pp 1.0 +``` + +For manual Dynamic Coding Rate or Dynamic TX Power experiments, keep the same +scenario and traffic load while enabling packet loss and capture-aware +collisions: + +```bash +./loraMesh.py --preset batumi --no-gui --simtime-seconds 60 --period-seconds 5 \ + --phy-loss-model --capture-collision-model --dcr + +./loraMesh.py --preset batumi --no-gui --simtime-seconds 60 --period-seconds 5 \ + --phy-loss-model --capture-collision-model --dcr --dtp +``` + +See [Radio Physics Quickstart](docs/radio_physics_quickstart.md) for what the +flags mean and which result fields to compare. + ## Discrete-event simulator The discrete-event simulator mimics the radio section of the device software in order to understand its working. It can also be used to assess the performance of your scenario, or the scalability of the protocol. diff --git a/docs/radio_physics_quickstart.md b/docs/radio_physics_quickstart.md new file mode 100644 index 00000000..38942ef2 --- /dev/null +++ b/docs/radio_physics_quickstart.md @@ -0,0 +1,165 @@ +# Radio Physics Quickstart + +This guide is for comparing Meshtastic radio-policy experiments in the +discrete-event simulator without reading the simulator internals first. + +## Find Runnable Scenarios + +Packaged presets are the easiest starting point because they already carry +node locations and any matching terrain, clutter, and calibration data: + +```bash +./loraMesh.py --list-presets +``` + +Modem presets can also be listed from the same CLI: + +```bash +./loraMesh.py --list-modem-presets +``` + +The `batumi` preset is a sanitized Batumi/Georgia-area scenario. It includes +node geometry, terrain, land-cover clutter, and a fitted link-calibration model. +The preset does not include node names, source IDs, collection endpoints, or +per-link runtime corrections. + +## Minimal Useful Runs + +Use `--no-gui` for repeatable command-line comparisons: + +```bash +./loraMesh.py --preset batumi --no-gui --simtime-seconds 60 --period-seconds 5 +``` + +## One-Command Policy Comparison + +The easiest way to compare policy experiments is the wrapper tool: + +```bash +python3 tools/radio_policy_compare.py --simtime-seconds 60 --period-seconds 5 +``` + +It runs the same preset and traffic load for: + +- `static`: static coding rate with packet-loss and capture-collision physics. +- `dcr`: Dynamic Coding Rate with the same physics. +- `dcr_dtp`: Dynamic Coding Rate plus Dynamic TX Power with the same physics. + +The output is one table with reach, useful traffic, airtime, collisions, PHY +loss, coding-rate mix, TX-power mix, and deltas versus the first policy. + +For CI, write durable artifacts and optionally fail the job on regressions: + +```bash +python3 tools/radio_policy_compare.py \ + --simtime-seconds 120 \ + --period-seconds 5 \ + --json-output out/radio_policy_compare.json \ + --markdown-output out/radio_policy_compare.md \ + --max-reach-drop-pp 1.0 \ + --max-useful-drop-pp 2.0 \ + --max-tx-air-increase-pp 1.0 +``` + +Those thresholds compare every non-baseline policy against the first policy in +`--policies`. With the default order, `static` is the baseline and `dcr` / +`dcr_dtp` are checked against it. The JSON file is intended for machines; the +Markdown file is intended for CI summaries, uploaded artifacts, or PR comments. + +To compare only two policies: + +```bash +python3 tools/radio_policy_compare.py --policies static,dcr +``` + +Extra `loraMesh.py` flags can be applied to every run after `--`: + +```bash +python3 tools/radio_policy_compare.py --policies static,dcr -- --no-clutter +``` + +Enable packet-level loss and capture-aware collisions when testing DCR/DTP. +Those two flags make weak links and overlapping transmissions matter: + +```bash +./loraMesh.py --preset batumi --no-gui --simtime-seconds 60 --period-seconds 5 \ + --phy-loss-model --capture-collision-model +``` + +Compare Dynamic Coding Rate against the same baseline: + +```bash +./loraMesh.py --preset batumi --no-gui --simtime-seconds 60 --period-seconds 5 \ + --phy-loss-model --capture-collision-model --dcr +``` + +Compare Dynamic Coding Rate plus Dynamic TX Power: + +```bash +./loraMesh.py --preset batumi --no-gui --simtime-seconds 60 --period-seconds 5 \ + --phy-loss-model --capture-collision-model --dcr --dtp +``` + +Keep `--simtime-seconds`, `--period-seconds`, preset, and model flags identical +when comparing policies. Otherwise the result moves because the traffic load or +radio physics changed, not because the policy improved. + +## Reading The Result + +For policy comparisons, start with these fields: + +- `Average percentage of nodes reached`: delivery reach per generated message. +- `Percentage of received packets containing new message`: how much received + traffic was useful instead of rebroadcast duplicates. +- `Average Tx air utilization`: local channel pressure caused by transmissions. +- `Number of collisions`: overlap pressure before packet-level PHY loss. +- `Number of packets lost by PHY model`: weak-link packet loss after sensing. + +When DCR is enabled, also check: + +- `DCR TX packets by CR`: whether the policy mostly stayed compact or spent + robust coding rates. +- `DCR airtime by CR (ms)`: whether robust coding rates consumed too much + airtime. + +When DTP is enabled, also check: + +- `DTP TX packets by power`: whether power was actually reduced. +- `DTP mean CAD-detected receivers per TX`: whether transmissions became less + visible to unrelated receivers. +- `DTP mean decodable receivers per TX`: whether reduced power is still enough + for useful receivers. + +Good policy changes should improve reach or useful traffic without causing a +large airtime or collision regression. A policy that only makes every packet +more robust or louder is usually not a useful mesh policy. + +## Importing Map Locations + +Map imports are useful for quick local experiments: + +```bash +./loraMesh.py --from-map 'https://meshtastic.liamcottle.net/api/v1/nodes' \ + --map-bbox 41.50,41.50,41.82,41.86 \ + --map-limit 100 \ + --no-gui +``` + +Map-imported scenarios do not automatically gain the Batumi preset's terrain, +clutter, or fitted radio calibration. Use packaged presets for calibrated +benchmarks, and map imports for exploratory placement checks. + +## Common Pitfalls + +- `--dcr` and `--dtp` are experiments. They are disabled unless explicitly + passed. +- `--preset batumi` automatically uses its bundled terrain, clutter, and link + calibration. Add `--no-clutter` only when intentionally comparing against a + no-clutter run. +- `--phy-loss-model` and `--capture-collision-model` are separate from terrain + and clutter. Use them for DCR/DTP packet-policy comparisons. +- Short runs are noisy. Use longer runs or repeated runs before claiming that a + policy is better. +- Treat CI thresholds as guardrails, not proof of RF truth. A failed threshold + means "inspect this change"; a passed threshold means "no regression in this + fixed simulator scenario". diff --git a/tests/test_radio_policy_compare.py b/tests/test_radio_policy_compare.py new file mode 100644 index 00000000..0054d7ea --- /dev/null +++ b/tests/test_radio_policy_compare.py @@ -0,0 +1,211 @@ +import argparse +import json +import math +from pathlib import Path +import tempfile +import unittest + +from tools import radio_policy_compare + + +class TestRadioPolicyCompare(unittest.TestCase): + def test_parse_policy_names_rejects_unknown_policy(self): + with self.assertRaises(argparse.ArgumentTypeError): + radio_policy_compare.parse_policy_names("static,nope") + + def test_build_lora_args_adds_shared_physics_and_policy_flags(self): + args = radio_policy_compare.parse_args([ + "--preset", + "batumi", + "--simtime-seconds", + "12", + "--period-seconds", + "3", + "--policies", + "dcr", + "--", + "--no-clutter", + ]) + + lora_args = radio_policy_compare.build_lora_args(args, "dcr") + + self.assertEqual(lora_args[:2], ["--preset", "batumi"]) + self.assertIn("--no-gui", lora_args) + self.assertIn("--phy-loss-model", lora_args) + self.assertIn("--capture-collision-model", lora_args) + self.assertIn("--dcr", lora_args) + self.assertIn("--no-clutter", lora_args) + self.assertNotIn("--dtp", lora_args) + self.assertNotIn("--", lora_args) + + def test_summarize_results_formats_table_and_deltas(self): + static = radio_policy_compare.summarize_results( + "static", + "static CR", + { + "messageSeq": 10, + "sent": 100, + "nrReceived": 40, + "nrCollisions": 5, + "nrPhyLoss": 7, + "nodeReach": 0.25, + "usefulness": 0.5, + "txAirUtilizationRate": 0.07, + "dcrTxByCr": {5: 100, 6: 0, 7: 0, 8: 0}, + "dtpTxByPower": {30: 100}, + "dtpMeanDetectedByTx": 6.0, + "dtpMeanSensedByTx": 4.0, + }, + "raw", + ) + dcr = radio_policy_compare.summarize_results( + "dcr", + "Dynamic Coding Rate", + { + "messageSeq": 10, + "sent": 102, + "nrReceived": 45, + "nrCollisions": 4, + "nrPhyLoss": 5, + "nodeReach": 0.3, + "usefulness": math.nan, + "txAirUtilizationRate": 0.08, + "dcrTxByCr": {5: 80, 6: 15, 7: 5, 8: 0}, + "dtpTxByPower": {30: 102}, + "dtpMeanDetectedByTx": 6.1, + "dtpMeanSensedByTx": 4.2, + }, + "raw", + ) + + table = radio_policy_compare.render_table([static, dcr]) + deltas = radio_policy_compare.render_delta_table([static, dcr]) + + self.assertIn("policy", table) + self.assertIn("25.00", table) + self.assertIn("80/15/5/0", table) + self.assertIn("n/a", table) + self.assertIn("reach +5.00 pp", deltas) + self.assertIn("phy_loss -2", deltas) + + def test_thresholds_flag_reach_and_airtime_regressions(self): + args = radio_policy_compare.parse_args([ + "--max-reach-drop-pp", + "1", + "--max-tx-air-increase-pp", + "0.5", + ]) + baseline = radio_policy_compare.PolicySummary( + name="static", + description="static", + messages=1, + sent=10, + received=5, + collisions=1, + phy_loss=2, + reach_percent=10.0, + useful_percent=50.0, + tx_air_percent=5.0, + cr_mix="100/0/0/0", + dtp_power_mix="30:10", + dtp_detected=4.0, + dtp_decodable=3.0, + output="", + ) + candidate = radio_policy_compare.PolicySummary( + name="dcr", + description="dcr", + messages=1, + sent=10, + received=5, + collisions=1, + phy_loss=2, + reach_percent=8.5, + useful_percent=50.0, + tx_air_percent=5.75, + cr_mix="90/10/0/0", + dtp_power_mix="30:10", + dtp_detected=4.0, + dtp_decodable=3.0, + output="", + ) + + failures = radio_policy_compare.evaluate_thresholds(args, [baseline, candidate]) + + self.assertEqual([failure.metric for failure in failures], ["reach", "tx_air"]) + self.assertIn("below allowed -1.00 pp", failures[0].message) + self.assertIn("above allowed +0.50 pp", failures[1].message) + + def test_report_writers_create_ci_artifacts(self): + args = radio_policy_compare.parse_args([ + "--simtime-seconds", + "12", + "--period-seconds", + "3", + "--policies", + "static,dcr", + "--max-reach-drop-pp", + "2", + "--", + "--no-clutter", + ]) + baseline = radio_policy_compare.summarize_results( + "static", + "static CR", + { + "messageSeq": 10, + "sent": 100, + "nrReceived": 40, + "nrCollisions": 5, + "nrPhyLoss": 7, + "nodeReach": 0.25, + "usefulness": 0.5, + "txAirUtilizationRate": 0.07, + "dcrTxByCr": {5: 100, 6: 0, 7: 0, 8: 0}, + "dtpTxByPower": {30: 100}, + "dtpMeanDetectedByTx": 6.0, + "dtpMeanSensedByTx": 4.0, + }, + "raw", + ) + dcr = radio_policy_compare.summarize_results( + "dcr", + "Dynamic Coding Rate", + { + "messageSeq": 10, + "sent": 102, + "nrReceived": 45, + "nrCollisions": 4, + "nrPhyLoss": 5, + "nodeReach": 0.3, + "usefulness": 0.55, + "txAirUtilizationRate": 0.08, + "dcrTxByCr": {5: 80, 6: 15, 7: 5, 8: 0}, + "dtpTxByPower": {30: 102}, + "dtpMeanDetectedByTx": 6.1, + "dtpMeanSensedByTx": 4.2, + }, + "raw", + ) + report = radio_policy_compare.build_report(args, [baseline, dcr], []) + + with tempfile.TemporaryDirectory() as tmpdir: + json_path = Path(tmpdir) / "report.json" + markdown_path = Path(tmpdir) / "report.md" + + radio_policy_compare.write_json_report(json_path, report) + radio_policy_compare.write_markdown_report(markdown_path, report) + + parsed = json.loads(json_path.read_text(encoding="utf-8")) + markdown = markdown_path.read_text(encoding="utf-8") + + self.assertEqual(parsed["scenario"]["extra_lora_args"], ["--no-clutter"]) + self.assertEqual(parsed["deltas"][0]["reach_delta_pp"], 5.0) + self.assertEqual(parsed["thresholds"]["max_reach_drop_pp"], 2.0) + self.assertNotIn("raw", json.dumps(parsed)) + self.assertIn("# Meshtasticator Radio Policy Comparison", markdown) + self.assertIn("| dcr | 30.00 | 55.00 | 8.00 |", markdown) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/radio_policy_compare.py b/tools/radio_policy_compare.py new file mode 100644 index 00000000..b5acc414 --- /dev/null +++ b/tools/radio_policy_compare.py @@ -0,0 +1,513 @@ +#!/usr/bin/env python3 +"""Compare radio-policy variants on the same Meshtasticator scenario. + +This is a small usability wrapper around loraMesh.py, not a second simulator. +Each policy run gets a fresh Config and calls the normal parse/run path, so the +comparison table stays aligned with the CLI users would run by hand. +""" + +import argparse +import contextlib +from dataclasses import dataclass +import io +import json +import math +from pathlib import Path +import sys + + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from lib.config import Config # noqa: E402 +import loraMesh # noqa: E402 + + +POLICY_FLAGS = { + "static": ("static CR with packet loss/capture physics", []), + "dcr": ("Dynamic Coding Rate", ["--dcr"]), + "dcr_dtp": ("Dynamic Coding Rate + Dynamic TX Power", ["--dcr", "--dtp"]), +} + + +@dataclass +class PolicySummary: + name: str + description: str + messages: int + sent: int + received: int + collisions: int + phy_loss: int + reach_percent: float | None + useful_percent: float | None + tx_air_percent: float | None + cr_mix: str + dtp_power_mix: str + dtp_detected: float + dtp_decodable: float + output: str + + +@dataclass +class ThresholdFailure: + policy: str + metric: str + delta_pp: float + limit_pp: float + message: str + + +def parse_policy_names(raw_policies): + names = [name.strip() for name in raw_policies.split(",") if name.strip()] + unknown = sorted(set(names) - set(POLICY_FLAGS)) + if unknown: + known = ", ".join(POLICY_FLAGS) + raise argparse.ArgumentTypeError(f"unknown policy {', '.join(unknown)}; choose from: {known}") + if not names: + raise argparse.ArgumentTypeError("at least one policy is required") + return names + + +def parse_args(argv=None): + parser = argparse.ArgumentParser( + description="compare Meshtasticator radio policies on one scenario", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog="""examples: + python3 tools/radio_policy_compare.py + python3 tools/radio_policy_compare.py --simtime-seconds 120 --period-seconds 5 + python3 tools/radio_policy_compare.py --policies static,dcr -- --no-clutter +""", + ) + parser.add_argument("--preset", default="batumi", help="Packaged scenario preset to run") + parser.add_argument("--simtime-seconds", type=positive_float, default=60.0, help="Simulation duration for every policy") + parser.add_argument("--period-seconds", type=positive_float, default=5.0, help="Mean message-generation period for every policy") + parser.add_argument( + "--policies", + type=parse_policy_names, + default=parse_policy_names("static,dcr,dcr_dtp"), + help="Comma-separated policies: static,dcr,dcr_dtp", + ) + parser.add_argument( + "--show-raw-output", + action="store_true", + help="Print each underlying loraMesh.py run before the comparison table", + ) + parser.add_argument("--json-output", type=Path, help="Write a machine-readable CI report to this JSON file") + parser.add_argument("--markdown-output", type=Path, help="Write a GitHub-friendly CI report to this Markdown file") + parser.add_argument( + "--max-reach-drop-pp", + type=non_negative_float, + help="Fail if any non-baseline policy loses more reach percentage points than this", + ) + parser.add_argument( + "--max-useful-drop-pp", + type=non_negative_float, + help="Fail if any non-baseline policy loses more useful-traffic percentage points than this", + ) + parser.add_argument( + "--max-tx-air-increase-pp", + type=non_negative_float, + help="Fail if any non-baseline policy spends more extra TX-air percentage points than this", + ) + parser.add_argument( + "lora_args", + nargs=argparse.REMAINDER, + help="Extra loraMesh.py arguments applied to every run; place them after --", + ) + return parser.parse_args(argv) + + +def positive_float(value): + parsed = float(value) + if not math.isfinite(parsed) or parsed <= 0: + raise argparse.ArgumentTypeError("must be a positive finite number") + return parsed + + +def non_negative_float(value): + parsed = float(value) + if not math.isfinite(parsed) or parsed < 0: + raise argparse.ArgumentTypeError("must be a non-negative finite number") + return parsed + + +def build_lora_args(args, policy_name): + _, policy_flags = POLICY_FLAGS[policy_name] + extra_args = list(args.lora_args) + if extra_args[:1] == ["--"]: + extra_args = extra_args[1:] + + # The comparison intentionally enables packet-level loss and capture-aware + # collisions for every policy. Without those two physics flags, CR and TX + # power changes mostly affect airtime accounting, not delivery behavior. + return [ + "--preset", + args.preset, + "--no-gui", + "--simtime-seconds", + str(args.simtime_seconds), + "--period-seconds", + str(args.period_seconds), + "--phy-loss-model", + "--capture-collision-model", + *policy_flags, + *extra_args, + ] + + +def run_policy(policy_name, lora_args): + conf = Config() + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + node_config = loraMesh.parse_params(conf, lora_args) + results = loraMesh.run_simulation(conf, node_config) + + description, _ = POLICY_FLAGS[policy_name] + return summarize_results(policy_name, description, results, stdout.getvalue()) + + +def summarize_results(policy_name, description, results, output): + return PolicySummary( + name=policy_name, + description=description, + messages=int(results["messageSeq"]), + sent=int(results["sent"]), + received=int(results["nrReceived"]), + collisions=int(results["nrCollisions"]), + phy_loss=int(results["nrPhyLoss"]), + reach_percent=as_percent(results["nodeReach"]), + useful_percent=as_percent(results["usefulness"]), + tx_air_percent=as_percent(results["txAirUtilizationRate"]), + cr_mix=format_cr_mix(results["dcrTxByCr"]), + dtp_power_mix=format_power_mix(results["dtpTxByPower"]), + dtp_detected=float(results["dtpMeanDetectedByTx"]), + dtp_decodable=float(results["dtpMeanSensedByTx"]), + output=output, + ) + + +def as_percent(value): + numeric = float(value) + if math.isnan(numeric): + return None + return numeric * 100.0 + + +def format_percent(value): + if value is None: + return "n/a" + return f"{value:.2f}" + + +def format_cr_mix(cr_counts): + total = sum(cr_counts.values()) + if not total: + return "n/a" + return "/".join(f"{100.0 * cr_counts.get(cr, 0) / total:.0f}" for cr in (5, 6, 7, 8)) + + +def format_power_mix(power_counts): + if not power_counts: + return "n/a" + ordered = sorted(power_counts.items(), reverse=True) + return ",".join(f"{power}:{count}" for power, count in ordered) + + +def render_table(summaries): + rows = [ + [ + "policy", + "reach%", + "useful%", + "tx_air%", + "msgs", + "sent", + "rx", + "coll", + "phy_loss", + "cr5/6/7/8%", + "power:tx", + "cad/decodable", + ] + ] + for summary in summaries: + rows.append([ + summary.name, + format_percent(summary.reach_percent), + format_percent(summary.useful_percent), + format_percent(summary.tx_air_percent), + str(summary.messages), + str(summary.sent), + str(summary.received), + str(summary.collisions), + str(summary.phy_loss), + summary.cr_mix, + summary.dtp_power_mix, + f"{summary.dtp_detected:.2f}/{summary.dtp_decodable:.2f}", + ]) + + widths = [max(len(row[index]) for row in rows) for index in range(len(rows[0]))] + lines = [] + for row_index, row in enumerate(rows): + lines.append(" ".join(cell.ljust(widths[index]) for index, cell in enumerate(row))) + if row_index == 0: + lines.append(" ".join("-" * width for width in widths)) + return "\n".join(lines) + + +def render_delta_table(summaries): + if len(summaries) < 2: + return "" + + baseline = summaries[0] + lines = [f"\nDelta vs {baseline.name}:"] + for summary in summaries[1:]: + reach_delta = delta(summary.reach_percent, baseline.reach_percent) + useful_delta = delta(summary.useful_percent, baseline.useful_percent) + tx_air_delta = delta(summary.tx_air_percent, baseline.tx_air_percent) + lines.append( + f" {summary.name}: " + f"reach {reach_delta} pp, " + f"useful {useful_delta} pp, " + f"tx_air {tx_air_delta} pp, " + f"sent {summary.sent - baseline.sent:+d}, " + f"collisions {summary.collisions - baseline.collisions:+d}, " + f"phy_loss {summary.phy_loss - baseline.phy_loss:+d}" + ) + return "\n".join(lines) + + +def build_delta_rows(summaries): + if len(summaries) < 2: + return [] + + baseline = summaries[0] + rows = [] + for summary in summaries[1:]: + rows.append({ + "baseline": baseline.name, + "policy": summary.name, + "reach_delta_pp": numeric_delta(summary.reach_percent, baseline.reach_percent), + "useful_delta_pp": numeric_delta(summary.useful_percent, baseline.useful_percent), + "tx_air_delta_pp": numeric_delta(summary.tx_air_percent, baseline.tx_air_percent), + "sent_delta": summary.sent - baseline.sent, + "collisions_delta": summary.collisions - baseline.collisions, + "phy_loss_delta": summary.phy_loss - baseline.phy_loss, + }) + return rows + + +def delta(value, baseline): + if value is None or baseline is None: + return "n/a" + return f"{value - baseline:+.2f}" + + +def numeric_delta(value, baseline): + if value is None or baseline is None: + return None + return value - baseline + + +def evaluate_thresholds(args, summaries): + failures = [] + for row in build_delta_rows(summaries): + policy = row["policy"] + if args.max_reach_drop_pp is not None: + failures.extend(check_min_delta(policy, "reach", row["reach_delta_pp"], -args.max_reach_drop_pp)) + if args.max_useful_drop_pp is not None: + failures.extend(check_min_delta(policy, "useful", row["useful_delta_pp"], -args.max_useful_drop_pp)) + if args.max_tx_air_increase_pp is not None: + failures.extend(check_max_delta(policy, "tx_air", row["tx_air_delta_pp"], args.max_tx_air_increase_pp)) + return failures + + +def check_min_delta(policy, metric, delta_pp, min_allowed_pp): + if delta_pp is None or delta_pp >= min_allowed_pp: + return [] + return [ + ThresholdFailure( + policy=policy, + metric=metric, + delta_pp=delta_pp, + limit_pp=min_allowed_pp, + message=f"{policy} {metric} delta {delta_pp:+.2f} pp is below allowed {min_allowed_pp:+.2f} pp", + ) + ] + + +def check_max_delta(policy, metric, delta_pp, max_allowed_pp): + if delta_pp is None or delta_pp <= max_allowed_pp: + return [] + return [ + ThresholdFailure( + policy=policy, + metric=metric, + delta_pp=delta_pp, + limit_pp=max_allowed_pp, + message=f"{policy} {metric} delta {delta_pp:+.2f} pp is above allowed +{max_allowed_pp:.2f} pp", + ) + ] + + +def summary_to_dict(summary): + return { + "policy": summary.name, + "description": summary.description, + "messages": summary.messages, + "sent": summary.sent, + "received": summary.received, + "collisions": summary.collisions, + "phy_loss": summary.phy_loss, + "reach_percent": summary.reach_percent, + "useful_percent": summary.useful_percent, + "tx_air_percent": summary.tx_air_percent, + "cr_mix": summary.cr_mix, + "dtp_power_mix": summary.dtp_power_mix, + "dtp_mean_cad_detected_receivers": summary.dtp_detected, + "dtp_mean_decodable_receivers": summary.dtp_decodable, + } + + +def build_report(args, summaries, failures): + extra_args = list(args.lora_args) + if extra_args[:1] == ["--"]: + extra_args = extra_args[1:] + + return { + "scenario": { + "preset": args.preset, + "simtime_seconds": args.simtime_seconds, + "period_seconds": args.period_seconds, + "policies": args.policies, + "extra_lora_args": extra_args, + "physics_flags": ["--phy-loss-model", "--capture-collision-model"], + }, + "summaries": [summary_to_dict(summary) for summary in summaries], + "deltas": build_delta_rows(summaries), + "thresholds": { + "max_reach_drop_pp": args.max_reach_drop_pp, + "max_useful_drop_pp": args.max_useful_drop_pp, + "max_tx_air_increase_pp": args.max_tx_air_increase_pp, + }, + "failures": [failure.__dict__ for failure in failures], + } + + +def write_json_report(path, report): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def write_markdown_report(path, report): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(render_markdown_report(report), encoding="utf-8") + + +def render_markdown_report(report): + lines = [ + "# Meshtasticator Radio Policy Comparison", + "", + f"- preset: `{report['scenario']['preset']}`", + f"- simtime: `{report['scenario']['simtime_seconds']}` seconds", + f"- period: `{report['scenario']['period_seconds']}` seconds", + f"- policies: `{', '.join(report['scenario']['policies'])}`", + "", + "| policy | reach% | useful% | tx_air% | msgs | sent | rx | coll | phy_loss | CR5/6/7/8% | power:tx | CAD/decodable |", + "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- | --- | ---: |", + ] + for summary in report["summaries"]: + lines.append( + "| {policy} | {reach} | {useful} | {tx_air} | {messages} | {sent} | {received} | " + "{collisions} | {phy_loss} | {cr_mix} | {dtp_power_mix} | {detected:.2f}/{decodable:.2f} |".format( + policy=summary["policy"], + reach=format_percent(summary["reach_percent"]), + useful=format_percent(summary["useful_percent"]), + tx_air=format_percent(summary["tx_air_percent"]), + messages=summary["messages"], + sent=summary["sent"], + received=summary["received"], + collisions=summary["collisions"], + phy_loss=summary["phy_loss"], + cr_mix=summary["cr_mix"], + dtp_power_mix=summary["dtp_power_mix"], + detected=summary["dtp_mean_cad_detected_receivers"], + decodable=summary["dtp_mean_decodable_receivers"], + ) + ) + + if report["deltas"]: + lines.extend(["", "## Delta vs Baseline", ""]) + lines.append("| policy | reach pp | useful pp | tx_air pp | sent | collisions | phy_loss |") + lines.append("| --- | ---: | ---: | ---: | ---: | ---: | ---: |") + for row in report["deltas"]: + lines.append( + "| {policy} | {reach} | {useful} | {tx_air} | {sent:+d} | {collisions:+d} | {phy_loss:+d} |".format( + policy=row["policy"], + reach=format_delta_value(row["reach_delta_pp"]), + useful=format_delta_value(row["useful_delta_pp"]), + tx_air=format_delta_value(row["tx_air_delta_pp"]), + sent=row["sent_delta"], + collisions=row["collisions_delta"], + phy_loss=row["phy_loss_delta"], + ) + ) + + if report["failures"]: + lines.extend(["", "## Threshold Failures", ""]) + for failure in report["failures"]: + lines.append(f"- {failure['message']}") + else: + lines.extend(["", "No threshold failures."]) + + return "\n".join(lines) + "\n" + + +def format_delta_value(value): + if value is None: + return "n/a" + return f"{value:+.2f}" + + +def main(argv=None): + args = parse_args(argv) + loraMesh.configure_logging() + + summaries = [] + for policy_name in args.policies: + lora_args = build_lora_args(args, policy_name) + print(f"Running {policy_name}: loraMesh.py {' '.join(lora_args)}", file=sys.stderr) + summary = run_policy(policy_name, lora_args) + if args.show_raw_output: + print(f"\n===== raw output: {policy_name} =====") + print(summary.output.rstrip()) + summaries.append(summary) + + failures = evaluate_thresholds(args, summaries) + report = build_report(args, summaries, failures) + if args.json_output: + write_json_report(args.json_output, report) + if args.markdown_output: + write_markdown_report(args.markdown_output, report) + + print("\nRadio policy comparison") + print(render_table(summaries)) + delta_table = render_delta_table(summaries) + if delta_table: + print(delta_table) + if all(summary.messages == 0 for summary in summaries): + print( + "\nNo messages were generated; increase --simtime-seconds or lower --period-seconds for a useful comparison." + ) + if failures: + print("\nThreshold failures:") + for failure in failures: + print(f" - {failure.message}") + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From f72b56b3dce03f1095c570a1ec1502d188992a49 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Thu, 14 May 2026 16:16:36 +0400 Subject: [PATCH 18/24] fix(sim): keep radio compare slice self-contained --- DISCRETE_EVENT_SIM.md | 12 ++--- README.md | 23 ++++----- docs/radio_physics_quickstart.md | 67 ++++++------------------- lib/node.py | 26 +++++----- lib/nodedb_input.py | 18 +++++-- lib/presets.py | 25 ++++++++++ loraMesh.py | 12 +++-- tests/test_discrete_event_sim.py | 40 +++++++++++++++ tests/test_lora_mesh_cli.py | 37 ++++++++++++++ tests/test_map_input.py | 2 + tests/test_node.py | 78 ++++++++++++++++++++++++++++++ tests/test_radio_policy_compare.py | 52 +++++++++++++++----- tools/radio_policy_compare.py | 36 ++++++++++---- 13 files changed, 315 insertions(+), 113 deletions(-) diff --git a/DISCRETE_EVENT_SIM.md b/DISCRETE_EVENT_SIM.md index 33533ffe..96f8521a 100644 --- a/DISCRETE_EVENT_SIM.md +++ b/DISCRETE_EVENT_SIM.md @@ -103,12 +103,12 @@ Packaged real-mesh presets can be listed and loaded directly: ```python3 loraMesh.py --list-presets``` The `batumi` preset includes sanitized Batumi/Georgia-area node geometry, a -matching terrain grid, an OpenStreetMap-derived land-cover clutter grid, and an -aggregate radio calibration over generated path features. Terrain, clutter, and -the fitted link-calibration model are enabled automatically for the preset -unless you pass different `--terrain-grid` or `--clutter-grid` inputs; use -`--no-clutter` for old-style comparison runs. The calibration report is in -`docs/batumi_radio_calibration.md`. +matching bundled terrain grid, an OpenStreetMap-derived land-cover clutter grid, +and an aggregate radio calibration over generated path features. Terrain, +clutter, and the fitted link-calibration model are enabled automatically for the +preset; use `--terrain-srtm` for a fresh SRTM terrain sample, `--clutter-grid` +for a different land-cover grid, or `--no-clutter` for old-style comparison +runs. The calibration report is in `docs/batumi_radio_calibration.md`. ```python3 loraMesh.py --preset batumi --no-gui --simtime-seconds 5 --period-seconds 2 --phy-loss-model --capture-collision-model``` diff --git a/README.md b/README.md index 0a940309..b30fca6e 100644 --- a/README.md +++ b/README.md @@ -21,36 +21,31 @@ Run the packaged Batumi/Georgia-area radio scenario headlessly: ./loraMesh.py --preset batumi --no-gui --simtime-seconds 60 --period-seconds 5 ``` -Compare the static, Dynamic Coding Rate, and Dynamic Coding Rate + Dynamic TX -Power policies in one command: +Run the static radio-physics comparison workflow in one command: ```bash python3 tools/radio_policy_compare.py --simtime-seconds 60 --period-seconds 5 ``` -For CI-style runs, write JSON/Markdown artifacts and make regressions fail the -job: +For CI-style runs, write JSON/Markdown artifacts: ```bash python3 tools/radio_policy_compare.py \ --simtime-seconds 120 \ --period-seconds 5 \ --json-output out/radio_policy_compare.json \ - --markdown-output out/radio_policy_compare.md \ - --max-reach-drop-pp 1.0 \ - --max-tx-air-increase-pp 1.0 + --markdown-output out/radio_policy_compare.md ``` -For manual Dynamic Coding Rate or Dynamic TX Power experiments, keep the same -scenario and traffic load while enabling packet loss and capture-aware -collisions: +Threshold flags such as `--max-reach-drop-pp` are accepted only when a later +policy slice adds at least one non-baseline policy to compare against `static`. -```bash -./loraMesh.py --preset batumi --no-gui --simtime-seconds 60 --period-seconds 5 \ - --phy-loss-model --capture-collision-model --dcr +For manual radio-physics experiments, keep the same scenario and traffic load +while enabling packet loss and capture-aware collisions: +```bash ./loraMesh.py --preset batumi --no-gui --simtime-seconds 60 --period-seconds 5 \ - --phy-loss-model --capture-collision-model --dcr --dtp + --phy-loss-model --capture-collision-model ``` See [Radio Physics Quickstart](docs/radio_physics_quickstart.md) for what the diff --git a/docs/radio_physics_quickstart.md b/docs/radio_physics_quickstart.md index 38942ef2..adb17b6a 100644 --- a/docs/radio_physics_quickstart.md +++ b/docs/radio_physics_quickstart.md @@ -42,64 +42,41 @@ python3 tools/radio_policy_compare.py --simtime-seconds 60 --period-seconds 5 It runs the same preset and traffic load for: - `static`: static coding rate with packet-loss and capture-collision physics. -- `dcr`: Dynamic Coding Rate with the same physics. -- `dcr_dtp`: Dynamic Coding Rate plus Dynamic TX Power with the same physics. The output is one table with reach, useful traffic, airtime, collisions, PHY -loss, coding-rate mix, TX-power mix, and deltas versus the first policy. +loss, and placeholders for future policy counters. -For CI, write durable artifacts and optionally fail the job on regressions: +For CI, write durable artifacts: ```bash python3 tools/radio_policy_compare.py \ --simtime-seconds 120 \ --period-seconds 5 \ --json-output out/radio_policy_compare.json \ - --markdown-output out/radio_policy_compare.md \ - --max-reach-drop-pp 1.0 \ - --max-useful-drop-pp 2.0 \ - --max-tx-air-increase-pp 1.0 + --markdown-output out/radio_policy_compare.md ``` -Those thresholds compare every non-baseline policy against the first policy in -`--policies`. With the default order, `static` is the baseline and `dcr` / -`dcr_dtp` are checked against it. The JSON file is intended for machines; the -Markdown file is intended for CI summaries, uploaded artifacts, or PR comments. - -To compare only two policies: - -```bash -python3 tools/radio_policy_compare.py --policies static,dcr -``` +Threshold flags such as `--max-reach-drop-pp` compare every non-baseline policy +against the first policy in `--policies`; they require at least two policies. +This slice exposes only `static`, so later policy slices can add thresholded CI +gates once their `loraMesh.py` flags exist. The JSON file is intended for +machines; the Markdown file is intended for CI summaries, uploaded artifacts, or +PR comments. Extra `loraMesh.py` flags can be applied to every run after `--`: ```bash -python3 tools/radio_policy_compare.py --policies static,dcr -- --no-clutter +python3 tools/radio_policy_compare.py --policies static -- --no-clutter ``` -Enable packet-level loss and capture-aware collisions when testing DCR/DTP. -Those two flags make weak links and overlapping transmissions matter: +Enable packet-level loss and capture-aware collisions when testing radio +physics. Those two flags make weak links and overlapping transmissions matter: ```bash ./loraMesh.py --preset batumi --no-gui --simtime-seconds 60 --period-seconds 5 \ --phy-loss-model --capture-collision-model ``` -Compare Dynamic Coding Rate against the same baseline: - -```bash -./loraMesh.py --preset batumi --no-gui --simtime-seconds 60 --period-seconds 5 \ - --phy-loss-model --capture-collision-model --dcr -``` - -Compare Dynamic Coding Rate plus Dynamic TX Power: - -```bash -./loraMesh.py --preset batumi --no-gui --simtime-seconds 60 --period-seconds 5 \ - --phy-loss-model --capture-collision-model --dcr --dtp -``` - Keep `--simtime-seconds`, `--period-seconds`, preset, and model flags identical when comparing policies. Otherwise the result moves because the traffic load or radio physics changed, not because the policy improved. @@ -115,21 +92,6 @@ For policy comparisons, start with these fields: - `Number of collisions`: overlap pressure before packet-level PHY loss. - `Number of packets lost by PHY model`: weak-link packet loss after sensing. -When DCR is enabled, also check: - -- `DCR TX packets by CR`: whether the policy mostly stayed compact or spent - robust coding rates. -- `DCR airtime by CR (ms)`: whether robust coding rates consumed too much - airtime. - -When DTP is enabled, also check: - -- `DTP TX packets by power`: whether power was actually reduced. -- `DTP mean CAD-detected receivers per TX`: whether transmissions became less - visible to unrelated receivers. -- `DTP mean decodable receivers per TX`: whether reduced power is still enough - for useful receivers. - Good policy changes should improve reach or useful traffic without causing a large airtime or collision regression. A policy that only makes every packet more robust or louder is usually not a useful mesh policy. @@ -151,13 +113,12 @@ benchmarks, and map imports for exploratory placement checks. ## Common Pitfalls -- `--dcr` and `--dtp` are experiments. They are disabled unless explicitly - passed. - `--preset batumi` automatically uses its bundled terrain, clutter, and link calibration. Add `--no-clutter` only when intentionally comparing against a no-clutter run. - `--phy-loss-model` and `--capture-collision-model` are separate from terrain - and clutter. Use them for DCR/DTP packet-policy comparisons. + and clutter. Use them for packet-policy comparisons once more policy flags are + available. - Short runs are noisy. Use longer runs or repeated runs before claiming that a policy is better. - Treat CI thresholds as guardrails, not proof of RF truth. A failed threshold diff --git a/lib/node.py b/lib/node.py index 8c42b59d..be2fbeb2 100644 --- a/lib/node.py +++ b/lib/node.py @@ -552,17 +552,21 @@ def transmit(self, packet): def receive(self, in_pipe): while True: p = yield in_pipe.get() + packet_log_id = getattr(p, "unique_packet_seq", p.seq) - logger.debug(f"{self.env.now:.3f} Node {self.nodeid} fetches packet {p.unique_packet_seq} for msg {p.seq} from {p.txNodeId} from bc_pipe: sensed: {p.sensedByN[self.nodeid]} collided: {p.collidedAtN[self.nodeid]} on air: {p.onAirToN[self.nodeid]}") + logger.debug(f"{self.env.now:.3f} Node {self.nodeid} fetches packet {packet_log_id} for msg {p.seq} from {p.txNodeId} from bc_pipe: sensed: {p.sensedByN[self.nodeid]} collided: {p.collidedAtN[self.nodeid]} on air: {p.onAirToN[self.nodeid]}") if self.conf.CAPTURE_COLLISION_MODEL_ENABLED: if p.sensedByN[self.nodeid] and p.onAirToN[self.nodeid]: p.onAirToN[self.nodeid] = False if not self.isTransmitting and not p.collidedAtN[self.nodeid]: - logger.debug(f"{self.env.now:.3f} Node {self.nodeid} started receiving packet {p.unique_packet_seq} for msg {p.seq} from {p.txNodeId}") + logger.debug(f"{self.env.now:.3f} Node {self.nodeid} started receiving packet {packet_log_id} for msg {p.seq} from {p.txNodeId}") self.isReceiving.append(True) + elif self.isTransmitting: + logger.debug(f"{self.env.now:.3f} Node {self.nodeid} could not lock packet {p.seq}.") + p.sensedByN[self.nodeid] = False else: - logger.debug(f"{self.env.now:.3f} Node {self.nodeid} could not lock packet {p.unique_packet_seq} for msg {p.seq}.") + logger.debug(f"{self.env.now:.3f} Node {self.nodeid} could not lock packet {packet_log_id} for msg {p.seq}.") continue if p.sensedByN[self.nodeid]: @@ -572,13 +576,13 @@ def receive(self, in_pipe): pass self.airUtilization += p.timeOnAir if p.collidedAtN[self.nodeid]: - logger.debug(f"{self.env.now:.3f} Node {self.nodeid} could not decode packet {p.unique_packet_seq}.") + logger.debug(f"{self.env.now:.3f} Node {self.nodeid} could not decode packet {packet_log_id}.") continue if p.phyLostAtN[self.nodeid]: - logger.debug(f"{self.env.now:.3f} Node {self.nodeid} lost packet {p.unique_packet_seq} for msg {p.seq} to weak-link PHY errors.") + logger.debug(f"{self.env.now:.3f} Node {self.nodeid} lost packet {packet_log_id} for msg {p.seq} to weak-link PHY errors.") continue p.receivedAtN[self.nodeid] = True - logger.debug(f"{self.env.now:.3f} Node {self.nodeid} received packet {p.unique_packet_seq} for msg {p.seq} with delay {round(self.env.now - p.genTime, 2)}") + logger.debug(f"{self.env.now:.3f} Node {self.nodeid} received packet {packet_log_id} for msg {p.seq} with delay {round(self.env.now - p.genTime, 2)}") self.handle_received_packet(p) continue @@ -589,11 +593,11 @@ def receive(self, in_pipe): # processing to the end-of-transmission branch. p.onAirToN[self.nodeid] = False elif not self.isTransmitting: - logger.debug(f"{self.env.now:.3f} Node {self.nodeid} started receiving packet {p.unique_packet_seq} for msg {p.seq} from {p.txNodeId}") + logger.debug(f"{self.env.now:.3f} Node {self.nodeid} started receiving packet {packet_log_id} for msg {p.seq} from {p.txNodeId}") p.onAirToN[self.nodeid] = False self.isReceiving.append(True) else: # if you were currently transmitting, you could not have sensed it - logger.debug(f"{self.env.now:.3f} Node {self.nodeid} was transmitting, so could not receive packet {p.unique_packet_seq} for msg {p.seq}") + logger.debug(f"{self.env.now:.3f} Node {self.nodeid} was transmitting, so could not receive packet {packet_log_id} for msg {p.seq}") p.sensedByN[self.nodeid] = False p.onAirToN[self.nodeid] = False elif p.sensedByN[self.nodeid]: # end of reception @@ -604,13 +608,13 @@ def receive(self, in_pipe): self.airUtilization += p.timeOnAir # begin receiving packet fine, but a collision begins before we finish receiving. if p.collidedAtN[self.nodeid]: - logger.debug(f"{self.env.now:.3f} Node {self.nodeid} could not decode packet {p.unique_packet_seq}.") + logger.debug(f"{self.env.now:.3f} Node {self.nodeid} could not decode packet {packet_log_id}.") continue if p.phyLostAtN[self.nodeid]: - logger.debug(f"{self.env.now:.3f} Node {self.nodeid} lost packet {p.unique_packet_seq} for msg {p.seq} to weak-link PHY errors.") + logger.debug(f"{self.env.now:.3f} Node {self.nodeid} lost packet {packet_log_id} for msg {p.seq} to weak-link PHY errors.") continue p.receivedAtN[self.nodeid] = True - logger.debug(f"{self.env.now:.3f} Node {self.nodeid} received packet {p.unique_packet_seq} for msg {p.seq} with delay {round(self.env.now - p.genTime, 2)}") # TODO: better way to calculate delay for log + logger.debug(f"{self.env.now:.3f} Node {self.nodeid} received packet {packet_log_id} for msg {p.seq} with delay {round(self.env.now - p.genTime, 2)}") # TODO: better way to calculate delay for log self.handle_received_packet(p) def handle_received_packet(self, p): diff --git a/lib/nodedb_input.py b/lib/nodedb_input.py index 1781c605..5dd02ef1 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,12 +57,19 @@ 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 normalize_nodedb_role(user["role"]) if isinstance(node, dict) and node.get("role") is not None: - return str(node["role"]).upper() + return normalize_nodedb_role(node["role"]) return "CLIENT" +def normalize_nodedb_role(raw_role): + try: + return role_name_for_node({"role": int(raw_role)}) + except (TypeError, ValueError): + return str(raw_role).upper() + + def positioned_nodedb_nodes(nodes, bbox=None): """Return `(node, lat, lon)` rows for NodeDB entries with valid positions.""" positioned = [] diff --git a/lib/presets.py b/lib/presets.py index 1a8e2c17..e1652344 100644 --- a/lib/presets.py +++ b/lib/presets.py @@ -27,6 +27,17 @@ }, } +RADIO_CALIBRATION_FIELDS = ( + "NOISE_LEVEL", + "PATH_LOSS_DISTANCE_FLOOR_M", + "REPORTED_SNR_MIN_DB", + "REPORTED_SNR_MAX_DB", + "LINK_CALIBRATION_MODEL_ENABLED", + "LINK_CALIBRATION_COEFFICIENTS", + "LINK_CALIBRATION_SNR_MIN_DB", + "LINK_CALIBRATION_SNR_MAX_DB", +) + def available_presets(): return sorted(PRESETS.keys()) @@ -59,6 +70,20 @@ def preset_calibration_observations(name): return raw.get("calibration_observations", []) if isinstance(raw, dict) else [] +def snapshot_radio_calibration(conf): + """Capture caller-default radio calibration so reusable CLI parses reset cleanly.""" + snapshot = {} + for field in RADIO_CALIBRATION_FIELDS: + value = getattr(conf, field) + snapshot[field] = value.copy() if isinstance(value, dict) else value + return snapshot + + +def restore_radio_calibration(conf, snapshot): + for field, value in snapshot.items(): + setattr(conf, field, value.copy() if isinstance(value, dict) else value) + + def apply_preset_radio_calibration(conf, name): """Apply optional aggregate radio calibration stored with a packaged preset. diff --git a/loraMesh.py b/loraMesh.py index 9df95f57..3c73b71c 100755 --- a/loraMesh.py +++ b/loraMesh.py @@ -31,6 +31,8 @@ preset_clutter_grid, preset_origin, preset_terrain_grid, + restore_radio_calibration, + snapshot_radio_calibration, ) from lib.srtm import ( DEFAULT_SRTM_URL_TEMPLATE, @@ -71,7 +73,9 @@ def get_cli_defaults(conf): "GUI_ENABLED": conf.GUI_ENABLED, "PLOT": conf.PLOT, "TERRAIN_PROFILE_SAMPLES": conf.TERRAIN_PROFILE_SAMPLES, + "CLUTTER_PROFILE_SAMPLES": conf.CLUTTER_PROFILE_SAMPLES, "NODE_Z_REFERENCE": conf.NODE_Z_REFERENCE, + "RADIO_CALIBRATION": snapshot_radio_calibration(conf), }, ) return getattr(conf, CLI_DEFAULT_ATTR) @@ -651,9 +655,6 @@ def parse_params(conf, args=None) -> [NodeConfig]: if bounds_follow_node_config: fit_simulation_bounds_to_node_config(conf, config) - if selected_preset is not None: - apply_preset_radio_calibration(conf, selected_preset) - conf.SIMTIME = simtime conf.PERIOD = period conf.GUI_ENABLED = gui_enabled @@ -676,8 +677,13 @@ def parse_params(conf, args=None) -> [NodeConfig]: conf.CLUTTER_GRID_FILE = None if parsed_arguments.clutter_profile_samples is not None: conf.CLUTTER_PROFILE_SAMPLES = parsed_arguments.clutter_profile_samples + else: + conf.CLUTTER_PROFILE_SAMPLES = cli_defaults["CLUTTER_PROFILE_SAMPLES"] conf.PHY_LOSS_MODEL_ENABLED = parsed_arguments.phy_loss_model conf.CAPTURE_COLLISION_MODEL_ENABLED = parsed_arguments.capture_collision_model + restore_radio_calibration(conf, cli_defaults["RADIO_CALIBRATION"]) + if parsed_arguments.preset is not None: + apply_preset_radio_calibration(conf, parsed_arguments.preset) if parsed_arguments.verbose: # Set this logger and lib.* to DEBUG only after the command line has diff --git a/tests/test_discrete_event_sim.py b/tests/test_discrete_event_sim.py index ccdd89f6..44d4e510 100644 --- a/tests/test_discrete_event_sim.py +++ b/tests/test_discrete_event_sim.py @@ -202,6 +202,46 @@ def test_connectivity_map_optimization_is_consistent(self): for f in facets: self.assertEqual(all_results[0][f], all_results[1][f], f'connectivity map optimization is inconsistent for facet {f}') + def test_phy_loss_counts_only_sensed_non_collided_copies(self): + from lib.config import Config + + class MockNode: + def __init__(self, nodeid: int): + self.nodeid = nodeid + self.usefulPackets = 0 + self.txAirUtilization = 0.0 + self.droppedByDelay = 0 + self.isMoving = False + self.gpsEnabled = False + + class MockPacket: + def __init__(self): + self.collidedAtN = [False, True, False] + self.sensedByN = [True, True, False] + self.receivedAtN = [False, False, False] + self.phyLostAtN = [True, True, True] + self.collisionReasonAtN = [None, "capture", None] + self.terrainLossAtN = [0.0, 0.0, 0.0] + self.clutterLossAtN = [0.0, 0.0, 0.0] + + conf = Config() + conf.NR_NODES = 3 + sim_results = lib.discrete_event_sim.SimulationResults({ + "nodes": [MockNode(0), MockNode(1), MockNode(2)], + "packets": [MockPacket()], + "delays": [], + "messageSeq": 1, + "totalPairs": 0, + "asymmetricLinks": 0, + "symmetricLinks": 0, + "noLinks": 0, + }) + + sim_results.finalize(conf) + + self.assertEqual(sim_results["nrPhyLoss"], 1) + self.assertEqual(sim_results["nrCollisions"], 1) + # TODO: add default-skip GUI test? def test_discrete_sim_ten_nodes(self): import numpy as np diff --git a/tests/test_lora_mesh_cli.py b/tests/test_lora_mesh_cli.py index 06f34f6a..5598762a 100644 --- a/tests/test_lora_mesh_cli.py +++ b/tests/test_lora_mesh_cli.py @@ -1007,6 +1007,43 @@ def test_parse_params_loads_batumi_preset_with_bundled_grids(self): self.assertIn("Clutter model:", output) self.assertIn("Link calibration model: enabled", output) + def test_parse_params_clears_preset_radio_calibration_between_runs(self): + conf = Config() + default_noise_level = conf.NOISE_LEVEL + default_path_loss_floor = conf.PATH_LOSS_DISTANCE_FLOOR_M + + self.parse_quietly(conf, ["--preset", "batumi", "--no-gui"]) + self.assertTrue(conf.LINK_CALIBRATION_MODEL_ENABLED) + + self.parse_quietly(conf, ["2", "--no-gui"]) + + self.assertEqual(conf.NOISE_LEVEL, default_noise_level) + self.assertEqual(conf.PATH_LOSS_DISTANCE_FLOOR_M, default_path_loss_floor) + self.assertFalse(conf.LINK_CALIBRATION_MODEL_ENABLED) + self.assertEqual(conf.LINK_CALIBRATION_COEFFICIENTS, {}) + self.assertIsNone(conf.LINK_CALIBRATION_SNR_MIN_DB) + self.assertIsNone(conf.LINK_CALIBRATION_SNR_MAX_DB) + + def test_parse_params_clears_clutter_profile_samples_between_runs(self): + conf = Config() + default_samples = conf.CLUTTER_PROFILE_SAMPLES + + self.parse_quietly( + conf, + [ + "--preset", + "batumi", + "--no-gui", + "--clutter-profile-samples", + "3", + ], + ) + self.assertEqual(conf.CLUTTER_PROFILE_SAMPLES, 3) + + self.parse_quietly(conf, ["--preset", "batumi", "--no-gui"]) + + self.assertEqual(conf.CLUTTER_PROFILE_SAMPLES, default_samples) + def test_parse_params_can_disable_bundled_preset_clutter(self): conf = Config() diff --git a/tests/test_map_input.py b/tests/test_map_input.py index 4935adbb..29bd3aa4 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({"user": {"role": "12"}}), "CLIENT_BASE") if __name__ == "__main__": diff --git a/tests/test_node.py b/tests/test_node.py index 1dffb16c..f0cdd62c 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -177,5 +177,83 @@ def test_capture_model_tracks_cad_detected_interference(self): self.assertFalse(packet_is_rx_candidate(packet, 1, capture_model_enabled=True)) +class TestMeshNodeReceive(unittest.TestCase): + def test_capture_model_does_not_decode_while_transmitting(self): + conf = Config() + conf.NR_NODES = 1 + conf.CAPTURE_COLLISION_MODEL_ENABLED = True + env = simpy.Environment() + node_config = NodeConfig(0, Point(0, 0, 1.5), conf.PERIOD) + node = MeshNode(conf, SimulationState(conf, env), SimulationDataTracking(), node_config) + node.isTransmitting = True + pipe = simpy.Store(env) + packet = type("Packet", (), { + "sensedByN": [True], + "onAirToN": [True], + "collidedAtN": [False], + "phyLostAtN": [False], + "receivedAtN": [False], + "seq": 1, + "txNodeId": 7, + })() + + env.process(node.receive(pipe)) + pipe.put(packet) + env.run(until=0.001) + node.isTransmitting = False + pipe.put(packet) + env.run(until=0.002) + + self.assertFalse(packet.sensedByN[0]) + self.assertFalse(packet.receivedAtN[0]) + + def test_capture_model_preserves_sensed_collision_casualty(self): + conf = Config() + conf.NR_NODES = 1 + conf.CAPTURE_COLLISION_MODEL_ENABLED = True + env = simpy.Environment() + node_config = NodeConfig(0, Point(0, 0, 1.5), conf.PERIOD) + node = MeshNode(conf, SimulationState(conf, env), SimulationDataTracking(), node_config) + pipe = simpy.Store(env) + packet = type("Packet", (), { + "sensedByN": [True], + "onAirToN": [True], + "collidedAtN": [True], + "phyLostAtN": [False], + "receivedAtN": [False], + "timeOnAir": 1.0, + "seq": 1, + "txNodeId": 7, + })() + + env.process(node.receive(pipe)) + pipe.put(packet) + env.run(until=0.001) + pipe.put(packet) + env.run(until=0.002) + + self.assertTrue(packet.sensedByN[0]) + self.assertFalse(packet.receivedAtN[0]) + + +class TestMeshNodeRandomness(unittest.TestCase): + def make_node(self, seed): + conf = Config() + conf.SEED = seed + conf.NR_NODES = 1 + env = simpy.Environment() + node_config = NodeConfig(7, Point(0, 0, 1.5), conf.PERIOD) + + return MeshNode(conf, SimulationState(conf, env), SimulationDataTracking(), node_config) + + def test_rebroadcast_jitter_rng_is_seed_reproducible(self): + first = self.make_node(seed=44).rebroadcastRng.random() + same_seed = self.make_node(seed=44).rebroadcastRng.random() + different_seed = self.make_node(seed=45).rebroadcastRng.random() + + self.assertEqual(first, same_seed) + self.assertNotEqual(first, different_seed) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_radio_policy_compare.py b/tests/test_radio_policy_compare.py index 0054d7ea..3d9c722e 100644 --- a/tests/test_radio_policy_compare.py +++ b/tests/test_radio_policy_compare.py @@ -13,7 +13,15 @@ def test_parse_policy_names_rejects_unknown_policy(self): with self.assertRaises(argparse.ArgumentTypeError): radio_policy_compare.parse_policy_names("static,nope") - def test_build_lora_args_adds_shared_physics_and_policy_flags(self): + def test_parse_policy_names_rejects_future_policy_before_cli_support_exists(self): + with self.assertRaises(argparse.ArgumentTypeError): + radio_policy_compare.parse_policy_names("static,dcr") + + def test_parse_args_rejects_thresholds_without_candidate_policy(self): + with self.assertRaises(SystemExit): + radio_policy_compare.parse_args(["--max-reach-drop-pp", "1"]) + + def test_build_lora_args_adds_shared_physics_flags(self): args = radio_policy_compare.parse_args([ "--preset", "batumi", @@ -22,19 +30,19 @@ def test_build_lora_args_adds_shared_physics_and_policy_flags(self): "--period-seconds", "3", "--policies", - "dcr", + "static", "--", "--no-clutter", ]) - lora_args = radio_policy_compare.build_lora_args(args, "dcr") + lora_args = radio_policy_compare.build_lora_args(args, "static") self.assertEqual(lora_args[:2], ["--preset", "batumi"]) self.assertIn("--no-gui", lora_args) self.assertIn("--phy-loss-model", lora_args) self.assertIn("--capture-collision-model", lora_args) - self.assertIn("--dcr", lora_args) self.assertIn("--no-clutter", lora_args) + self.assertNotIn("--dcr", lora_args) self.assertNotIn("--dtp", lora_args) self.assertNotIn("--", lora_args) @@ -88,13 +96,32 @@ def test_summarize_results_formats_table_and_deltas(self): self.assertIn("reach +5.00 pp", deltas) self.assertIn("phy_loss -2", deltas) + def test_summarize_results_tolerates_missing_future_policy_counters(self): + summary = radio_policy_compare.summarize_results( + "static", + "static CR", + { + "messageSeq": 10, + "sent": 100, + "nrReceived": 40, + "nrCollisions": 5, + "nrPhyLoss": 7, + "nodeReach": 0.25, + "usefulness": 0.5, + "txAirUtilizationRate": 0.07, + }, + "raw", + ) + + self.assertEqual(summary.cr_mix, "n/a") + self.assertEqual(summary.dtp_power_mix, "n/a") + self.assertEqual(summary.dtp_detected, 0.0) + self.assertEqual(summary.dtp_decodable, 0.0) + def test_thresholds_flag_reach_and_airtime_regressions(self): - args = radio_policy_compare.parse_args([ - "--max-reach-drop-pp", - "1", - "--max-tx-air-increase-pp", - "0.5", - ]) + args = radio_policy_compare.parse_args([]) + args.max_reach_drop_pp = 1 + args.max_tx_air_increase_pp = 0.5 baseline = radio_policy_compare.PolicySummary( name="static", description="static", @@ -143,12 +170,11 @@ def test_report_writers_create_ci_artifacts(self): "--period-seconds", "3", "--policies", - "static,dcr", - "--max-reach-drop-pp", - "2", + "static", "--", "--no-clutter", ]) + args.max_reach_drop_pp = 2 baseline = radio_policy_compare.summarize_results( "static", "static CR", diff --git a/tools/radio_policy_compare.py b/tools/radio_policy_compare.py index b5acc414..c8d2547a 100644 --- a/tools/radio_policy_compare.py +++ b/tools/radio_policy_compare.py @@ -26,8 +26,6 @@ POLICY_FLAGS = { "static": ("static CR with packet loss/capture physics", []), - "dcr": ("Dynamic Coding Rate", ["--dcr"]), - "dcr_dtp": ("Dynamic Coding Rate + Dynamic TX Power", ["--dcr", "--dtp"]), } @@ -77,7 +75,7 @@ def parse_args(argv=None): epilog="""examples: python3 tools/radio_policy_compare.py python3 tools/radio_policy_compare.py --simtime-seconds 120 --period-seconds 5 - python3 tools/radio_policy_compare.py --policies static,dcr -- --no-clutter + python3 tools/radio_policy_compare.py --policies static -- --no-clutter """, ) parser.add_argument("--preset", default="batumi", help="Packaged scenario preset to run") @@ -86,8 +84,8 @@ def parse_args(argv=None): parser.add_argument( "--policies", type=parse_policy_names, - default=parse_policy_names("static,dcr,dcr_dtp"), - help="Comma-separated policies: static,dcr,dcr_dtp", + default=parse_policy_names("static"), + help="Comma-separated policies: static", ) parser.add_argument( "--show-raw-output", @@ -116,7 +114,18 @@ def parse_args(argv=None): nargs=argparse.REMAINDER, help="Extra loraMesh.py arguments applied to every run; place them after --", ) - return parser.parse_args(argv) + args = parser.parse_args(argv) + if len(args.policies) < 2 and threshold_requested(args): + parser.error("--max-* thresholds require at least two policies so there is a baseline and a candidate") + return args + + +def threshold_requested(args): + return ( + args.max_reach_drop_pp is not None + or args.max_useful_drop_pp is not None + or args.max_tx_air_increase_pp is not None + ) def positive_float(value): @@ -180,14 +189,21 @@ def summarize_results(policy_name, description, results, output): reach_percent=as_percent(results["nodeReach"]), useful_percent=as_percent(results["usefulness"]), tx_air_percent=as_percent(results["txAirUtilizationRate"]), - cr_mix=format_cr_mix(results["dcrTxByCr"]), - dtp_power_mix=format_power_mix(results["dtpTxByPower"]), - dtp_detected=float(results["dtpMeanDetectedByTx"]), - dtp_decodable=float(results["dtpMeanSensedByTx"]), + cr_mix=format_cr_mix(result_value(results, "dcrTxByCr", {})), + dtp_power_mix=format_power_mix(result_value(results, "dtpTxByPower", {})), + dtp_detected=float(result_value(results, "dtpMeanDetectedByTx", 0.0)), + dtp_decodable=float(result_value(results, "dtpMeanSensedByTx", 0.0)), output=output, ) +def result_value(results, key, default): + try: + return results[key] + except KeyError: + return default + + def as_percent(value): numeric = float(value) if math.isnan(numeric): From 77c63775f54f7e019e78bd4ba7e2f560e1c90ab7 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Sat, 2 May 2026 12:07:58 +0400 Subject: [PATCH 19/24] feat(sim): add dynamic coding rate policy --- DISCRETE_EVENT_SIM.md | 19 ++- README.md | 6 +- docs/radio_physics_quickstart.md | 12 +- lib/config.py | 22 +++ lib/dcr.py | 212 +++++++++++++++++++++++++++++ lib/discrete_event_sim.py | 8 ++ lib/node.py | 36 ++++- lib/packet.py | 1 + loraMesh.py | 9 ++ tests/test_dcr.py | 158 +++++++++++++++++++++ tests/test_discrete_event_sim.py | 14 +- tests/test_lora_mesh_cli.py | 13 ++ tests/test_node.py | 62 +++++++++ tests/test_radio_policy_compare.py | 15 +- tools/radio_policy_compare.py | 5 +- 15 files changed, 565 insertions(+), 27 deletions(-) create mode 100644 lib/dcr.py create mode 100644 tests/test_dcr.py diff --git a/DISCRETE_EVENT_SIM.md b/DISCRETE_EVENT_SIM.md index 96f8521a..40284f3c 100644 --- a/DISCRETE_EVENT_SIM.md +++ b/DISCRETE_EVENT_SIM.md @@ -112,6 +112,16 @@ runs. The calibration report is in `docs/batumi_radio_calibration.md`. ```python3 loraMesh.py --preset batumi --no-gui --simtime-seconds 5 --period-seconds 2 --phy-loss-model --capture-collision-model``` +Dynamic Coding Rate is opt-in and chooses LoRa CR 4/5..4/8 per outgoing packet +without changing the preset's SF or bandwidth: + +```python3 loraMesh.py 20 --no-gui --phy-loss-model --capture-collision-model --dcr``` + +The policy keeps ordinary first-attempt traffic compact, spends extra FEC on +quiet retries, ACKs, non-busy direct relays, and last-hop relays, then records +`dcrTxByCr` and `dcrAirtimeByCr` in simulation results. This keeps idle airtime +as a reserve instead of turning every quiet packet into CR 4/8. + 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``` @@ -130,8 +140,8 @@ To simulate different parameters, you will have to change the *batchSim.py* scri Here we list some of the configurations, which you can change to model your scenario in */lib/config.py*. These apply to all nodes, except those that you configure per node when using the plot. ### Modem The LoRa modem ([see Meshtastic radio settings](https://meshtastic.org/docs/overview/radio-settings#predefined-channels)) that is used, as defined below: -|Modem | Name | Bandwidth (kHz) | Coding rate | Spreading Factor | Data rate (kbps) -|--|--|--|--|--|--| +| Modem | Name | Bandwidth (kHz) | Base coding rate | Spreading Factor | Nominal data rate (kbps) | +|--|--|--:|--:|--:|--:| | 0 | Short Turbo | 500 | 4/5 | 7 | 21.9 | | 1 | Short Fast | 250 | 4/5 | 7 | 10.9 | | 2 | Short Slow | 250 | 4/5 | 8 | 6.25 | @@ -143,6 +153,11 @@ The LoRa modem ([see Meshtastic radio settings](https://meshtastic.org/docs/over | 8 | Long Slow | 125 | 4/8 | 12 | 0.183 | | 9 | Very Long Slow | 62.5 | 4/8 | 12 | 0.0916 | +The simulator stores coding rates as their LoRa denominators (`5` through +`8`, meaning CR 4/5 through 4/8). This table shows the configured base CR; when +`--dcr` is enabled, the simulator may select a different CR for each outgoing +packet while leaving the preset's SF and bandwidth unchanged. + ### Period Mean period (in ms) with which the nodes generate a new message following an exponential distribution. E.g. if you set it to 300s, each node will generate a message on average once every five minutes. diff --git a/README.md b/README.md index b30fca6e..af805a0e 100644 --- a/README.md +++ b/README.md @@ -21,10 +21,10 @@ Run the packaged Batumi/Georgia-area radio scenario headlessly: ./loraMesh.py --preset batumi --no-gui --simtime-seconds 60 --period-seconds 5 ``` -Run the static radio-physics comparison workflow in one command: +Run the radio-physics comparison workflow in one command: ```bash -python3 tools/radio_policy_compare.py --simtime-seconds 60 --period-seconds 5 +python3 tools/radio_policy_compare.py --policies static,dcr --simtime-seconds 60 --period-seconds 5 ``` For CI-style runs, write JSON/Markdown artifacts: @@ -38,7 +38,7 @@ python3 tools/radio_policy_compare.py \ ``` Threshold flags such as `--max-reach-drop-pp` are accepted only when a later -policy slice adds at least one non-baseline policy to compare against `static`. +policy such as `dcr` is compared against the `static` baseline. For manual radio-physics experiments, keep the same scenario and traffic load while enabling packet loss and capture-aware collisions: diff --git a/docs/radio_physics_quickstart.md b/docs/radio_physics_quickstart.md index adb17b6a..ccb3f7e0 100644 --- a/docs/radio_physics_quickstart.md +++ b/docs/radio_physics_quickstart.md @@ -36,12 +36,13 @@ Use `--no-gui` for repeatable command-line comparisons: The easiest way to compare policy experiments is the wrapper tool: ```bash -python3 tools/radio_policy_compare.py --simtime-seconds 60 --period-seconds 5 +python3 tools/radio_policy_compare.py --policies static,dcr --simtime-seconds 60 --period-seconds 5 ``` It runs the same preset and traffic load for: - `static`: static coding rate with packet-loss and capture-collision physics. +- `dcr`: Dynamic Coding Rate on top of the same physics flags. The output is one table with reach, useful traffic, airtime, collisions, PHY loss, and placeholders for future policy counters. @@ -58,15 +59,14 @@ python3 tools/radio_policy_compare.py \ Threshold flags such as `--max-reach-drop-pp` compare every non-baseline policy against the first policy in `--policies`; they require at least two policies. -This slice exposes only `static`, so later policy slices can add thresholded CI -gates once their `loraMesh.py` flags exist. The JSON file is intended for -machines; the Markdown file is intended for CI summaries, uploaded artifacts, or -PR comments. +This lets CI fail when `dcr` loses too much reach/useful traffic or spends too +much extra TX airtime. The JSON file is intended for machines; the Markdown file +is intended for CI summaries, uploaded artifacts, or PR comments. Extra `loraMesh.py` flags can be applied to every run after `--`: ```bash -python3 tools/radio_policy_compare.py --policies static -- --no-clutter +python3 tools/radio_policy_compare.py --policies static,dcr -- --no-clutter ``` Enable packet-level loss and capture-aware collisions when testing radio diff --git a/lib/config.py b/lib/config.py index 68f6009e..5bc81a44 100644 --- a/lib/config.py +++ b/lib/config.py @@ -42,6 +42,28 @@ def __init__(self): self.COLLISION_CAPTURE_THRESHOLD_DB = 6.0 self.COLLISION_PAYLOAD_OVERLAP_LOSS_FRACTION = 0.15 self.DMs = False # Set True for sending DMs (with random destination), False for broadcasts + + ################################################# + ####### DYNAMIC CODING RATE ##################### + ################################################# + # Disabled by default so historical simulations keep the preset CR. + # When enabled, the node chooses CR 4/5..4/8 per packet immediately + # before TX, after queueing and listen-before-talk have settled. + self.DCR_ENABLED = False + self.DCR_MIN_CR = 5 + self.DCR_MAX_CR = 8 + self.DCR_USER_MIN_CR = 5 + # Limit non-urgent CR8 airtime as a share of this node's own TX airtime. + # This is a mesh-behavior safety rail, separate from region duty cycle. + self.DCR_CR8_AIRTIME_LIMIT_PERCENT = 10.0 + # Local utilization thresholds are deliberately not regulatory limits. + # `_selected_region_duty_limit()` in lib.dcr compares against region + # duty cycle only when the selected region actually has one. + self.DCR_IDLE_UTIL_PERCENT = 2.0 + self.DCR_BUSY_UTIL_PERCENT = 7.0 + self.DCR_CONGESTED_UTIL_PERCENT = 17.5 + self.DCR_BUSY_QUEUE_DEPTH = 3 + self.DCR_CONGESTED_QUEUE_DEPTH = 6 # from firmware RegionInfo regions[] in src/mesh/RadioInterface.cpp self.regions = { "US": { diff --git a/lib/dcr.py b/lib/dcr.py new file mode 100644 index 00000000..f99f7fa9 --- /dev/null +++ b/lib/dcr.py @@ -0,0 +1,212 @@ +"""Dynamic Coding Rate policy for Meshtasticator experiments. + +The firmware idea is to choose LoRa coding rate very late, after queueing and +listen-before-talk. The simulator mirrors that shape: this module changes only +the packet's physical CR and resulting airtime immediately before low-level +transmit. + +With the default PHY model this is mostly an airtime/collision-pressure study. +When the empirical PHY-loss model is enabled, the selected CR also changes the +payload decode probability near weak-link edges. +""" + +from dataclasses import dataclass + +from lib.packet import NODENUM_BROADCAST + + +CR_SLIM = 5 +CR_NORMAL = 6 +CR_ROBUST = 7 +CR_RESCUE = 8 + + +@dataclass(frozen=True) +class DcrDecision: + cr: int + reason: str + + +def _clamp_cr(cr: int, min_cr: int, max_cr: int) -> int: + return max(min_cr, min(max_cr, cr)) + + +def _score_to_cr(score: int) -> int: + if score <= 0: + return CR_SLIM + if score == 1: + return CR_NORMAL + if score == 2: + return CR_ROBUST + return CR_RESCUE + + +def _selected_region_duty_limit(conf) -> float | None: + """Return a legal duty-cycle limit only when the region actually has one. + + Regions with 100% duty cycle are effectively unrestricted for this policy. + Avoid inventing a local fallback threshold there; channel congestion and + regulatory duty-cycle pressure are separate signals. + """ + duty_cycle = conf.REGION.get("duty_cycle", 100) + if 0 < duty_cycle < 100: + return float(duty_cycle) + return None + + +def _node_queue_depth(node) -> int: + """Best-effort count of packets waiting behind the current transmitter slot.""" + return len(getattr(node.transmitter, "queue", [])) + + +def _current_channel_utilization_percent(node) -> float: + """Return rolling channel utilization including the active 10-second bucket.""" + completed_util = node.channel_utilization_percent() + current_bucket_airtime = max(0.0, node.txAirUtilization - node.prevTxAirUtilization) + current_bucket_util = ( + current_bucket_airtime + / (node.conf.CHANNEL_UTILIZATION_PERIODS * node.conf.TEN_SECONDS_INTERVAL) + * 100.0 + ) + return completed_util + current_bucket_util + + +def classify_channel_pressure(node) -> tuple[str, float, int]: + """Classify mesh pressure from existing simulator signals. + + The thresholds describe local simulated congestion, not legal limits. + Regulatory pressure is handled separately by `_selected_region_duty_limit`. + """ + util = _current_channel_utilization_percent(node) + queue_depth = _node_queue_depth(node) + + if util >= node.conf.DCR_CONGESTED_UTIL_PERCENT or queue_depth >= node.conf.DCR_CONGESTED_QUEUE_DEPTH: + return "congested", util, queue_depth + + if util >= node.conf.DCR_BUSY_UTIL_PERCENT or queue_depth >= node.conf.DCR_BUSY_QUEUE_DEPTH: + return "busy", util, queue_depth + + if util <= node.conf.DCR_IDLE_UTIL_PERCENT and queue_depth <= 1: + return "idle", util, queue_depth + + return "normal", util, queue_depth + + +def _base_packet_score(packet) -> tuple[int, list[str]]: + """Approximate packet classes with fields Meshtasticator currently has. + + Generated traffic does not carry Meshtastic portnums, app priority, or + telemetry/user-message classes. ACKs are the only control class visible + without adding synthetic app metadata. + """ + if packet.isAck: + return 1, ["control_ack"] + + # Keep first attempts compact and let retry/link/context signals justify + # spending extra FEC. This avoids making idle background floods fatter by + # default in dense public-mesh style runs. + return 0, ["user"] + + +def _retry_score(node, packet, pressure: str, util: float) -> tuple[int, list[str]]: + attempt = max(0, node.conf.maxRetransmission - packet.retransmissions) + if attempt == 0: + return 0, [] + + if pressure in ("busy", "congested"): + return 0, [f"retry_{attempt}_no_fec_bump_channel_{pressure}"] + + duty_limit = _selected_region_duty_limit(node.conf) + if duty_limit is not None and util >= duty_limit: + return 0, [f"retry_{attempt}_no_fec_bump_duty_limit"] + + # Later attempts after quiet loss are the intentional robustness spend: + # a normal retry moves generic user traffic to CR6, while a final quiet + # retry can still reach CR8 when the budget allows it. + final_retry = packet.retransmissions <= 1 + return (3 if final_retry else 1), [f"retry_{attempt}_quiet_loss"] + + +def _relay_score(packet) -> tuple[int, list[str]]: + if packet.txNodeId == packet.origTxNodeId: + return 0, [] + + score = -1 + reasons = ["generic_relay"] + + if packet.hopLimit <= 1: + # Last-hop relay may be the final useful chance for this packet, but it + # still should not jump to CR8 without retry/link evidence. + score += 2 + reasons.append("last_hop") + + return score, reasons + + +def _cr8_budget_allows(node, packet, candidate_cr: int) -> bool: + if candidate_cr != CR_RESCUE: + return True + + candidate_airtime = packet.airtime_for_cr(candidate_cr) + cr8_airtime = node.dcrAirtimeByCr.get(CR_RESCUE, 0.0) + candidate_airtime + total_airtime = node.txAirUtilization + candidate_airtime + + if total_airtime <= 0: + return True + + return (cr8_airtime / total_airtime * 100.0) <= node.conf.DCR_CR8_AIRTIME_LIMIT_PERCENT + + +def choose_dynamic_coding_rate(node, packet) -> DcrDecision: + """Choose a per-packet CR using only information the simulator already has.""" + if not node.conf.DCR_ENABLED: + return DcrDecision(packet.cr, "dcr_off") + + score, reasons = _base_packet_score(packet) + pressure, util, queue_depth = classify_channel_pressure(node) + + if pressure == "idle": + # Idle air is reserve, not automatic permission to fatten every first + # attempt. Retry/control scoring below is where quiet-air robustness is + # intentionally spent. + reasons.append("idle_no_first_attempt_bump") + elif pressure == "busy": + score -= 1 + elif pressure == "congested": + score -= 2 + reasons.append(f"channel_{pressure}") + + retry_delta, retry_reasons = _retry_score(node, packet, pressure, util) + score += retry_delta + reasons.extend(retry_reasons) + + relay_delta, relay_reasons = _relay_score(packet) + score += relay_delta + reasons.extend(relay_reasons) + + min_cr = max(node.conf.DCR_MIN_CR, node.conf.DCR_USER_MIN_CR) + if ( + not packet.isAck + and getattr(packet, "destId", NODENUM_BROADCAST) != NODENUM_BROADCAST + and packet.txNodeId != packet.origTxNodeId + and pressure not in ("busy", "congested") + ): + # Direct destination plus a relay hop is real header-level context. It + # is valuable enough to avoid the thinnest CR when local air is not + # busy, while origin-hop and busy direct floods can remain compact. + min_cr = max(min_cr, CR_NORMAL) + reasons.append("direct_relay_min_cr") + + if packet.isAck: + # ACKs are tiny and important, but should still not become CR8 storms. + min_cr = max(min_cr, CR_NORMAL) + + cr = _clamp_cr(_score_to_cr(score), min_cr, node.conf.DCR_MAX_CR) + + if not _cr8_budget_allows(node, packet, cr): + cr = _clamp_cr(CR_ROBUST, min_cr, node.conf.DCR_MAX_CR) + reasons.append("cr8_budget_clamp") + + reasons.append(f"util={util:.1f}") + reasons.append(f"queue={queue_depth}") + return DcrDecision(cr, ",".join(reasons)) diff --git a/lib/discrete_event_sim.py b/lib/discrete_event_sim.py index ca850fd6..3d5985bd 100644 --- a/lib/discrete_event_sim.py +++ b/lib/discrete_event_sim.py @@ -127,6 +127,14 @@ def finalize(self, conf: Config): self.results["usefulness"] = np.nan self.results["delayDropped"] = sum(n.droppedByDelay for n in nodes) + self.results["dcrTxByCr"] = { + cr: sum(getattr(n, "dcrTxByCr", {}).get(cr, 0) for n in nodes) + for cr in (5, 6, 7, 8) + } + self.results["dcrAirtimeByCr"] = { + cr: sum(getattr(n, "dcrAirtimeByCr", {}).get(cr, 0.0) for n in nodes) + for cr in (5, 6, 7, 8) + } if self.results["totalPairs"] != 0: noLinkRate = self.results["noLinks"] / self.results["totalPairs"] diff --git a/lib/node.py b/lib/node.py index be2fbeb2..64cee853 100644 --- a/lib/node.py +++ b/lib/node.py @@ -8,6 +8,7 @@ from lib.common import find_random_position from lib.config import Config +from lib.dcr import choose_dynamic_coding_rate from lib.discrete_event_sim_components import SimulationState, SimulationDataTracking from lib.geo import valid_lat_lon from lib.link_model import calculate_link_budget @@ -265,6 +266,8 @@ def __init__(self, conf, sim_state: SimulationState, data_tracking: SimulationDa self.usefulPackets = 0 self.txAirUtilization = 0 self.airUtilization = 0 + self.dcrTxByCr = {5: 0, 6: 0, 7: 0, 8: 0} + self.dcrAirtimeByCr = {5: 0.0, 6: 0.0, 7: 0.0, 8: 0.0} self.droppedByDelay = 0 self.rebroadcastPackets = 0 self.isMoving = False @@ -463,6 +466,21 @@ def perhaps_cancel_dupe(self, packet): return self.timesReceived[packet.seq] > 2 if self.is_router or self.is_repeater else self.timesReceived[packet.seq] > 1 return False + def latest_retry_timer_packet(self, packet): + """Return the newest queued/generated retry attempt for this message.""" + candidates = [ + packetSent + for packetSent in self.packets + if packetSent.origTxNodeId == self.nodeid and packetSent.seq == packet.seq + ] + if not candidates: + return packet + return min(candidates, key=lambda packetSent: packetSent.retransmissions) + + def wait_for_retry_timer_airtime(self, packet): + """Wait until DCR has selected the airtime used by the retry timer.""" + while self.conf.DCR_ENABLED and packet in self.packets and not packet.retryTimerAirtimeReady: + yield self.env.timeout(1) def generate_message(self): while True: @@ -480,7 +498,11 @@ def generate_message(self): p = self.send_packet(destId) while p.wantAck: # ReliableRouter: retransmit message if no ACK received after timeout - retransmissionMsec = get_retransmission_msec(self, p) + retry_timer_packet = self.latest_retry_timer_packet(p) + yield from self.wait_for_retry_timer_airtime(retry_timer_packet) + if retry_timer_packet not in self.packets: + break + retransmissionMsec = get_retransmission_msec(self, retry_timer_packet) yield self.env.timeout(retransmissionMsec) ackReceived = False # check whether you received an ACK on the transmitted message @@ -526,6 +548,16 @@ def transmit(self, packet): # check if you received an ACK for this message in the meantime self.was_seen_recently(packet, ownTransmit=True) if not self.perhaps_cancel_dupe(packet): # if you did not receive an ACK for this message in the meantime + # Firmware DCR runs very late too: after queue/LBT waiting, but + # before airtime accounting and packet start/end timestamps. + decision = choose_dynamic_coding_rate(self, packet) + if decision.cr != packet.cr: + packet.set_coding_rate(decision.cr) + packet.retryTimerAirtimeReady = True + logger.debug( + f"{self.env.now:.3f} Node {self.nodeid} DCR selected CR 4/{packet.cr} for packet {packet.seq}: {decision.reason}" + ) + logger.debug(f"{self.env.now:.3f} Node {self.nodeid} started low level send {packet.unique_packet_seq} for msg {packet.seq} hopLimit {packet.hopLimit} original Tx {packet.origTxNodeId}") self.nrPacketsSent += 1 packet.startTime = self.env.now @@ -541,6 +573,8 @@ def transmit(self, packet): self.packetsAtN[rx_node.nodeid].append(packet) self.txAirUtilization += packet.timeOnAir self.airUtilization += packet.timeOnAir + self.dcrTxByCr[packet.cr] = self.dcrTxByCr.get(packet.cr, 0) + 1 + self.dcrAirtimeByCr[packet.cr] = self.dcrAirtimeByCr.get(packet.cr, 0.0) + packet.timeOnAir self.bc_pipe.put(packet) # queue for nodes to receive packet self.isTransmitting = True yield self.env.timeout(packet.timeOnAir) diff --git a/lib/packet.py b/lib/packet.py index 81bb88cc..459481d3 100644 --- a/lib/packet.py +++ b/lib/packet.py @@ -87,6 +87,7 @@ def __init__(self, conf, nodes, origTxNodeId, destId, txNodeId, plen, seq, genTi self.packetLen = plen self.timeOnAir = airtime(self.conf, self.sf, self.cr, self.packetLen, self.bw) + self.retryTimerAirtimeReady = False self.startTime = 0 self.endTime = 0 self.refresh_link_budgets() diff --git a/loraMesh.py b/loraMesh.py index 3c73b71c..661b17d9 100755 --- a/loraMesh.py +++ b/loraMesh.py @@ -280,6 +280,9 @@ def parse_params(conf, args=None) -> [NodeConfig]: 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( + "--dcr", action="store_true", help="Enable the Dynamic Coding Rate experiment" + ) parser.add_argument( "--terrain-srtm", action="store_true", @@ -661,6 +664,7 @@ def parse_params(conf, args=None) -> [NodeConfig]: conf.PLOT = plot_enabled conf.NR_NODES = nr_nodes conf.ENABLE_CONNECTIVITY_MAP = connectivity_map_enabled + conf.DCR_ENABLED = parsed_arguments.dcr set_geo_origin(conf, scenario_origin) conf.TERRAIN_ENABLED = terrain_enabled conf.TERRAIN_GRID = terrain_grid @@ -704,6 +708,7 @@ def parse_params(conf, args=None) -> [NodeConfig]: "Terrain data attribution:", f"{SRTM_DATA_ATTRIBUTION} ({SRTM_DATA_ATTRIBUTION_URL})", ) + print("Dynamic Coding Rate:", "enabled" if conf.DCR_ENABLED else "disabled") print("PHY loss model:", "enabled" if conf.PHY_LOSS_MODEL_ENABLED else "disabled") print("Capture collision model:", "enabled" if conf.CAPTURE_COLLISION_MODEL_ENABLED else "disabled") print("Terrain model:", "enabled" if conf.TERRAIN_ENABLED else "disabled") @@ -775,6 +780,10 @@ def run_simulation(conf, node_config): ) print("Number of packets dropped by delay/hop limit:", delayDropped) + if conf.DCR_ENABLED: + print("DCR TX packets by CR:", results["dcrTxByCr"]) + print("DCR airtime by CR (ms):", {cr: round(ms, 2) for cr, ms in results["dcrAirtimeByCr"].items()}) + if conf.TERRAIN_ENABLED: print("Mean terrain obstruction loss (dB):", round(results["meanTerrainLossDb"], 2)) print("Max terrain obstruction loss (dB):", round(results["maxTerrainLossDb"], 2)) diff --git a/tests/test_dcr.py b/tests/test_dcr.py new file mode 100644 index 00000000..f4990acd --- /dev/null +++ b/tests/test_dcr.py @@ -0,0 +1,158 @@ +import unittest + +from lib.config import Config +from lib.dcr import CR_NORMAL, CR_RESCUE, CR_SLIM, choose_dynamic_coding_rate +from lib.packet import NODENUM_BROADCAST + + +class FakePacket: + def __init__( + self, + cr=5, + is_ack=False, + retransmissions=3, + tx_node_id=0, + orig_tx_node_id=0, + hop_limit=3, + dest_id=NODENUM_BROADCAST, + ): + self.cr = cr + self.isAck = is_ack + self.retransmissions = retransmissions + self.txNodeId = tx_node_id + self.origTxNodeId = orig_tx_node_id + self.hopLimit = hop_limit + self.destId = dest_id + + def airtime_for_cr(self, cr): + return {5: 100.0, 6: 120.0, 7: 140.0, 8: 160.0}[cr] + + +class FakeTransmitter: + def __init__(self, queue_depth=0): + self.queue = [object()] * queue_depth + + +class FakeNode: + def __init__(self, util=0.0, queue_depth=0): + self.conf = Config() + self.conf.DCR_ENABLED = True + self._util = util + self.transmitter = FakeTransmitter(queue_depth) + self.dcrAirtimeByCr = {5: 0.0, 6: 0.0, 7: 0.0, 8: 0.0} + self.txAirUtilization = 0.0 + self.prevTxAirUtilization = 0.0 + + def channel_utilization_percent(self): + return self._util + + +class TestDynamicCodingRate(unittest.TestCase): + def test_dcr_disabled_keeps_packet_cr(self): + node = FakeNode() + node.conf.DCR_ENABLED = False + packet = FakePacket(cr=7) + + decision = choose_dynamic_coding_rate(node, packet) + + self.assertEqual(decision.cr, 7) + self.assertEqual(decision.reason, "dcr_off") + + def test_idle_user_first_attempt_stays_compact_cr(self): + node = FakeNode(util=0.0) + + decision = choose_dynamic_coding_rate(node, FakePacket()) + + self.assertEqual(decision.cr, CR_SLIM) + self.assertIn("idle_no_first_attempt_bump", decision.reason) + + def test_idle_user_retry_gets_normal_cr(self): + node = FakeNode(util=0.0) + + decision = choose_dynamic_coding_rate(node, FakePacket(retransmissions=2)) + + self.assertEqual(decision.cr, CR_NORMAL) + + def test_busy_user_packet_can_use_compact_cr(self): + node = FakeNode(util=12.0) + + decision = choose_dynamic_coding_rate(node, FakePacket()) + + self.assertEqual(decision.cr, CR_SLIM) + + def test_current_bucket_airtime_contributes_to_busy_pressure(self): + node = FakeNode(util=0.0) + node.txAirUtilization = 6000.0 + + decision = choose_dynamic_coding_rate(node, FakePacket(retransmissions=2)) + + self.assertEqual(decision.cr, CR_SLIM) + self.assertIn("channel_busy", decision.reason) + + def test_direct_origin_packet_can_stay_compact_cr(self): + node = FakeNode(util=0.0) + + decision = choose_dynamic_coding_rate(node, FakePacket(dest_id=7)) + + self.assertEqual(decision.cr, CR_SLIM) + + def test_nonbusy_direct_relay_minimum_is_normal_cr(self): + node = FakeNode(util=5.0) + + decision = choose_dynamic_coding_rate(node, FakePacket(tx_node_id=2, orig_tx_node_id=1, dest_id=7)) + + self.assertEqual(decision.cr, CR_NORMAL) + self.assertIn("direct_relay_min_cr", decision.reason) + + def test_busy_direct_relay_can_stay_compact_cr(self): + node = FakeNode(util=12.0) + + decision = choose_dynamic_coding_rate(node, FakePacket(tx_node_id=2, orig_tx_node_id=1, dest_id=7)) + + self.assertEqual(decision.cr, CR_SLIM) + self.assertNotIn("direct_relay_min_cr", decision.reason) + + def test_retry_does_not_escalate_on_unrestricted_region_magic_limit(self): + node = FakeNode(util=12.0) + node.conf.REGION = node.conf.regions["US"] + + decision = choose_dynamic_coding_rate(node, FakePacket(retransmissions=2)) + + self.assertEqual(decision.cr, CR_SLIM) + self.assertIn("channel_busy", decision.reason) + + def test_quiet_final_retry_can_use_rescue_cr(self): + node = FakeNode(util=0.0) + node.conf.DCR_CR8_AIRTIME_LIMIT_PERCENT = 100.0 + + decision = choose_dynamic_coding_rate(node, FakePacket(retransmissions=1)) + + self.assertEqual(decision.cr, CR_RESCUE) + + def test_ack_minimum_is_normal_even_when_busy(self): + node = FakeNode(util=12.0) + + decision = choose_dynamic_coding_rate(node, FakePacket(is_ack=True)) + + self.assertEqual(decision.cr, CR_NORMAL) + + def test_ack_respects_user_minimum_cr(self): + node = FakeNode(util=12.0) + node.conf.DCR_USER_MIN_CR = 7 + + decision = choose_dynamic_coding_rate(node, FakePacket(is_ack=True)) + + self.assertEqual(decision.cr, 7) + + def test_last_hop_relay_uses_normal_cr_without_retry_evidence(self): + node = FakeNode(util=5.0) + packet = FakePacket(tx_node_id=2, orig_tx_node_id=1, hop_limit=1) + + decision = choose_dynamic_coding_rate(node, packet) + + self.assertEqual(decision.cr, CR_NORMAL) + self.assertIn("last_hop", decision.reason) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_discrete_event_sim.py b/tests/test_discrete_event_sim.py index 44d4e510..018c500c 100644 --- a/tests/test_discrete_event_sim.py +++ b/tests/test_discrete_event_sim.py @@ -74,6 +74,8 @@ def __init__(self, nodeid: int): self.droppedByDelay = 0 self.isMoving = False self.gpsEnabled = False + self.dcrTxByCr = {5: 0, 6: 0, 7: 0, 8: 0} + self.dcrAirtimeByCr = {5: 0.0, 6: 0.0, 7: 0.0, 8: 0.0} class MockPacket: def __init__(self, num_nodes: int): @@ -127,6 +129,8 @@ def __init__(self, num_nodes: int): self.assertEqual(sim_results['collisionRate'], 0, 'expected calculated collisionRate') self.assertEqual(sim_results['usefulness'], 1, 'usefulness is created') self.assertEqual(sim_results['delayDropped'], 0, 'expected number of delayDropped') + self.assertEqual(sim_results['dcrTxByCr'], {5: 0, 6: 0, 7: 0, 8: 0}, 'expected DCR histogram') + self.assertEqual(sim_results['dcrAirtimeByCr'], {5: 0.0, 6: 0.0, 7: 0.0, 8: 0.0}, 'expected DCR airtime histogram') # keys exist, not currently checking values self.assertIsNotNone(sim_results['txAirUtilizationRate'], 'txAirUtilizationRate is created') @@ -244,8 +248,6 @@ def __init__(self): # TODO: add default-skip GUI test? def test_discrete_sim_ten_nodes(self): - import numpy as np - from lib.node import default_generate_node_list from lib.config import CONFIG @@ -269,15 +271,7 @@ def test_discrete_sim_ten_nodes(self): # collect & unpack results for easy copy/paste of asserts results = sim.get_results() - # put "first order" results in local scope for easy access - packets = results["packets"] - packetsAtN = results["packetsAtN"] messageSeq = results["messageSeq"] - messages = results["messages"] - delays = results["delays"] - totalPairs = results["totalPairs"] - noLinks = results["noLinks"] - nodes = results["nodes"] # Begin actual tests, comparing against a hardcoded 'known # good' run. If these fail then a change has impacted the diff --git a/tests/test_lora_mesh_cli.py b/tests/test_lora_mesh_cli.py index 5598762a..0213da81 100644 --- a/tests/test_lora_mesh_cli.py +++ b/tests/test_lora_mesh_cli.py @@ -84,9 +84,22 @@ def test_parse_params_uses_supplied_argv(self): self.assertEqual(len(nodes), 2) self.assertFalse(conf.GUI_ENABLED) self.assertFalse(conf.PLOT) + self.assertFalse(conf.DCR_ENABLED) self.assertEqual(conf.SIMTIME, 1000) self.assertEqual(conf.PERIOD, 500) self.assertIn("Number of nodes: 2", output) + self.assertIn("Dynamic Coding Rate: disabled", output) + + def test_parse_params_enables_dcr(self): + conf = Config() + + _, output = self.parse_quietly( + conf, + ["2", "--no-gui", "--simtime-seconds", "1", "--period-seconds", "0.5", "--dcr"], + ) + + self.assertTrue(conf.DCR_ENABLED) + self.assertIn("Dynamic Coding Rate: enabled", output) def test_parse_params_reuses_initial_defaults_after_override_run(self): conf = Config() diff --git a/tests/test_node.py b/tests/test_node.py index f0cdd62c..5208d91a 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -236,6 +236,68 @@ def test_capture_model_preserves_sensed_collision_casualty(self): self.assertFalse(packet.receivedAtN[0]) +class TestMeshNodeRetryTimer(unittest.TestCase): + def make_node(self, dcr_enabled=True): + node = MeshNode.__new__(MeshNode) + node.nodeid = 3 + node.conf = Config() + node.conf.DCR_ENABLED = dcr_enabled + node.env = simpy.Environment() + node.packets = [] + return node + + def make_packet(self, retransmissions, ready=False): + return type("Packet", (), { + "origTxNodeId": 3, + "seq": 9, + "retransmissions": retransmissions, + "retryTimerAirtimeReady": ready, + })() + + def test_latest_retry_timer_packet_uses_newest_attempt(self): + node = self.make_node() + first = self.make_packet(retransmissions=3, ready=True) + retry = self.make_packet(retransmissions=1, ready=True) + node.packets = [first, retry] + + self.assertIs(node.latest_retry_timer_packet(first), retry) + + def test_wait_for_retry_timer_airtime_blocks_until_dcr_selected(self): + node = self.make_node() + packet = self.make_packet(retransmissions=3, ready=False) + node.packets = [packet] + events = [] + + def waiter(): + yield from node.wait_for_retry_timer_airtime(packet) + events.append(node.env.now) + + def selector(): + yield node.env.timeout(3) + packet.retryTimerAirtimeReady = True + + node.env.process(waiter()) + node.env.process(selector()) + node.env.run(until=4) + + self.assertEqual(events, [3]) + + def test_wait_for_retry_timer_airtime_does_not_block_static_policy(self): + node = self.make_node(dcr_enabled=False) + packet = self.make_packet(retransmissions=3, ready=False) + node.packets = [packet] + events = [] + + def waiter(): + yield from node.wait_for_retry_timer_airtime(packet) + events.append(node.env.now) + + node.env.process(waiter()) + node.env.run(until=1) + + self.assertEqual(events, [0]) + + class TestMeshNodeRandomness(unittest.TestCase): def make_node(self, seed): conf = Config() diff --git a/tests/test_radio_policy_compare.py b/tests/test_radio_policy_compare.py index 3d9c722e..5d6478e5 100644 --- a/tests/test_radio_policy_compare.py +++ b/tests/test_radio_policy_compare.py @@ -13,9 +13,8 @@ def test_parse_policy_names_rejects_unknown_policy(self): with self.assertRaises(argparse.ArgumentTypeError): radio_policy_compare.parse_policy_names("static,nope") - def test_parse_policy_names_rejects_future_policy_before_cli_support_exists(self): - with self.assertRaises(argparse.ArgumentTypeError): - radio_policy_compare.parse_policy_names("static,dcr") + def test_parse_policy_names_accepts_dcr_policy(self): + self.assertEqual(radio_policy_compare.parse_policy_names("static,dcr"), ["static", "dcr"]) def test_parse_args_rejects_thresholds_without_candidate_policy(self): with self.assertRaises(SystemExit): @@ -46,6 +45,16 @@ def test_build_lora_args_adds_shared_physics_flags(self): self.assertNotIn("--dtp", lora_args) self.assertNotIn("--", lora_args) + def test_build_lora_args_adds_dcr_policy_flag(self): + args = radio_policy_compare.parse_args([ + "--policies", + "dcr", + ]) + + lora_args = radio_policy_compare.build_lora_args(args, "dcr") + + self.assertIn("--dcr", lora_args) + def test_summarize_results_formats_table_and_deltas(self): static = radio_policy_compare.summarize_results( "static", diff --git a/tools/radio_policy_compare.py b/tools/radio_policy_compare.py index c8d2547a..464ffae0 100644 --- a/tools/radio_policy_compare.py +++ b/tools/radio_policy_compare.py @@ -26,6 +26,7 @@ POLICY_FLAGS = { "static": ("static CR with packet loss/capture physics", []), + "dcr": ("Dynamic Coding Rate", ["--dcr"]), } @@ -75,7 +76,7 @@ def parse_args(argv=None): epilog="""examples: python3 tools/radio_policy_compare.py python3 tools/radio_policy_compare.py --simtime-seconds 120 --period-seconds 5 - python3 tools/radio_policy_compare.py --policies static -- --no-clutter + python3 tools/radio_policy_compare.py --policies static,dcr -- --no-clutter """, ) parser.add_argument("--preset", default="batumi", help="Packaged scenario preset to run") @@ -85,7 +86,7 @@ def parse_args(argv=None): "--policies", type=parse_policy_names, default=parse_policy_names("static"), - help="Comma-separated policies: static", + help="Comma-separated policies: static,dcr", ) parser.add_argument( "--show-raw-output", From 861a38426b1f6819244d4a204f806a0d3e7d261c Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Sat, 2 May 2026 12:12:42 +0400 Subject: [PATCH 20/24] feat(sim): add dynamic tx power policy --- DISCRETE_EVENT_SIM.md | 14 ++ README.md | 4 +- docs/radio_physics_quickstart.md | 11 +- lib/config.py | 14 ++ lib/discrete_event_sim.py | 14 ++ lib/dtp.py | 162 ++++++++++++++++++++++ lib/node.py | 19 +++ loraMesh.py | 94 +++++++++++++ tests/test_discrete_event_sim.py | 9 ++ tests/test_dtp.py | 207 +++++++++++++++++++++++++++++ tests/test_lora_mesh_cli.py | 101 ++++++++++++++ tests/test_radio_policy_compare.py | 15 ++- tools/radio_policy_compare.py | 5 +- 13 files changed, 658 insertions(+), 11 deletions(-) create mode 100644 lib/dtp.py create mode 100644 tests/test_dtp.py diff --git a/DISCRETE_EVENT_SIM.md b/DISCRETE_EVENT_SIM.md index 40284f3c..a68cfa8f 100644 --- a/DISCRETE_EVENT_SIM.md +++ b/DISCRETE_EVENT_SIM.md @@ -122,6 +122,16 @@ quiet retries, ACKs, non-busy direct relays, and last-hop relays, then records `dcrTxByCr` and `dcrAirtimeByCr` in simulation results. This keeps idle airtime as a reserve instead of turning every quiet packet into CR 4/8. +Dynamic TX Power is also opt-in: + +```python3 loraMesh.py 20 --no-gui --capture-collision-model --dtp``` + +DTP keeps configured `PTX` as the maximum regional/base power and only applies +temporary reductions just before transmission. Origin packets stay at max power; +relay packets may shrink power when channel pressure is high or the prior hop +was strong enough. Final retries and CR 4/8 rescue packets stay at full power so +the interference-reduction knob does not fight the reliability knob. + 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``` @@ -158,6 +168,10 @@ The simulator stores coding rates as their LoRa denominators (`5` through `--dcr` is enabled, the simulator may select a different CR for each outgoing packet while leaving the preset's SF and bandwidth unchanged. +DCR and DTP can be combined. DCR changes airtime and forward-error-correction +strength; DTP changes how many receivers can CAD-detect, demodulate, or collide +with the packet. + ### Period Mean period (in ms) with which the nodes generate a new message following an exponential distribution. E.g. if you set it to 300s, each node will generate a message on average once every five minutes. diff --git a/README.md b/README.md index af805a0e..8a82b97f 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Run the packaged Batumi/Georgia-area radio scenario headlessly: Run the radio-physics comparison workflow in one command: ```bash -python3 tools/radio_policy_compare.py --policies static,dcr --simtime-seconds 60 --period-seconds 5 +python3 tools/radio_policy_compare.py --policies static,dcr,dtp --simtime-seconds 60 --period-seconds 5 ``` For CI-style runs, write JSON/Markdown artifacts: @@ -38,7 +38,7 @@ python3 tools/radio_policy_compare.py \ ``` Threshold flags such as `--max-reach-drop-pp` are accepted only when a later -policy such as `dcr` is compared against the `static` baseline. +policy such as `dcr` or `dtp` is compared against the `static` baseline. For manual radio-physics experiments, keep the same scenario and traffic load while enabling packet loss and capture-aware collisions: diff --git a/docs/radio_physics_quickstart.md b/docs/radio_physics_quickstart.md index ccb3f7e0..8acfb28b 100644 --- a/docs/radio_physics_quickstart.md +++ b/docs/radio_physics_quickstart.md @@ -36,13 +36,14 @@ Use `--no-gui` for repeatable command-line comparisons: The easiest way to compare policy experiments is the wrapper tool: ```bash -python3 tools/radio_policy_compare.py --policies static,dcr --simtime-seconds 60 --period-seconds 5 +python3 tools/radio_policy_compare.py --policies static,dcr,dtp --simtime-seconds 60 --period-seconds 5 ``` It runs the same preset and traffic load for: - `static`: static coding rate with packet-loss and capture-collision physics. - `dcr`: Dynamic Coding Rate on top of the same physics flags. +- `dtp`: Dynamic TX Power on top of the same physics flags. The output is one table with reach, useful traffic, airtime, collisions, PHY loss, and placeholders for future policy counters. @@ -59,14 +60,14 @@ python3 tools/radio_policy_compare.py \ Threshold flags such as `--max-reach-drop-pp` compare every non-baseline policy against the first policy in `--policies`; they require at least two policies. -This lets CI fail when `dcr` loses too much reach/useful traffic or spends too -much extra TX airtime. The JSON file is intended for machines; the Markdown file -is intended for CI summaries, uploaded artifacts, or PR comments. +This lets CI fail when `dcr` or `dtp` loses too much reach/useful traffic or +spends too much extra TX airtime. The JSON file is intended for machines; the +Markdown file is intended for CI summaries, uploaded artifacts, or PR comments. Extra `loraMesh.py` flags can be applied to every run after `--`: ```bash -python3 tools/radio_policy_compare.py --policies static,dcr -- --no-clutter +python3 tools/radio_policy_compare.py --policies static,dcr,dtp -- --no-clutter ``` Enable packet-level loss and capture-aware collisions when testing radio diff --git a/lib/config.py b/lib/config.py index 5bc81a44..cde426ac 100644 --- a/lib/config.py +++ b/lib/config.py @@ -64,6 +64,20 @@ def __init__(self): self.DCR_CONGESTED_UTIL_PERCENT = 17.5 self.DCR_BUSY_QUEUE_DEPTH = 3 self.DCR_CONGESTED_QUEUE_DEPTH = 6 + + ################################################# + ####### DYNAMIC TX POWER ######################## + ################################################# + # Disabled by default. DTP is deliberately a power-reduction policy, + # not an alternate way to exceed region limits. PTX remains the maximum; + # DTP only lowers individual relay/control packets to shrink their + # interference radius in dense capture-collision experiments. + self.DTP_ENABLED = False + self.DTP_MAX_POWER_DROP_DB = 12 + self.DTP_POWER_STEP_DB = 3 + self.DTP_MIN_TX_POWER_DBM = None + self.DTP_STRONG_LINK_MARGIN_DB = 20.0 + self.DTP_VERY_STRONG_LINK_MARGIN_DB = 24.0 # from firmware RegionInfo regions[] in src/mesh/RadioInterface.cpp self.regions = { "US": { diff --git a/lib/discrete_event_sim.py b/lib/discrete_event_sim.py index 3d5985bd..408a9b4e 100644 --- a/lib/discrete_event_sim.py +++ b/lib/discrete_event_sim.py @@ -135,6 +135,20 @@ def finalize(self, conf: Config): cr: sum(getattr(n, "dcrAirtimeByCr", {}).get(cr, 0.0) for n in nodes) for cr in (5, 6, 7, 8) } + dtp_tx_count = sum(getattr(n, "dtpTxCount", 0) for n in nodes) + self.results["dtpTxByPower"] = {} + self.results["dtpTxByCrPower"] = {} + for n in nodes: + for power, count in getattr(n, "dtpTxByPower", {}).items(): + self.results["dtpTxByPower"][power] = self.results["dtpTxByPower"].get(power, 0) + count + for cr_power, count in getattr(n, "dtpTxByCrPower", {}).items(): + self.results["dtpTxByCrPower"][cr_power] = self.results["dtpTxByCrPower"].get(cr_power, 0) + count + self.results["dtpMeanDetectedByTx"] = ( + sum(getattr(n, "dtpDetectedByTx", 0) for n in nodes) / dtp_tx_count if dtp_tx_count else 0.0 + ) + self.results["dtpMeanSensedByTx"] = ( + sum(getattr(n, "dtpSensedByTx", 0) for n in nodes) / dtp_tx_count if dtp_tx_count else 0.0 + ) if self.results["totalPairs"] != 0: noLinkRate = self.results["noLinks"] / self.results["totalPairs"] diff --git a/lib/dtp.py b/lib/dtp.py new file mode 100644 index 00000000..5d8499a5 --- /dev/null +++ b/lib/dtp.py @@ -0,0 +1,162 @@ +"""Dynamic TX Power policy for Meshtasticator experiments. + +DCR changes how long and how redundant a packet is. DTP changes how far the +same packet becomes interference. Keep DTP late and local: configured region +power remains the maximum, and this policy may only lower a packet's temporary +TX power just before it goes on air. +""" + +from dataclasses import dataclass + +from lib.dcr import CR_RESCUE, classify_channel_pressure +from lib.packet import NODENUM_BROADCAST + + +@dataclass(frozen=True) +class DtpDecision: + tx_power_dbm: int + reason: str + + +def _configured_step(conf) -> int: + return max(1, int(getattr(conf, "DTP_POWER_STEP_DB", 3))) + + +def _quantize_drop(conf, drop_db: int) -> int: + """Round drops down to the configured radio step. + + Rounding down keeps the experiment conservative: a requested 4 dB drop on a + 3 dB-step policy becomes 3 dB rather than unexpectedly cutting 6 dB. + """ + drop_db = max(0, min(int(drop_db), int(getattr(conf, "DTP_MAX_POWER_DROP_DB", 12)))) + step = _configured_step(conf) + return (drop_db // step) * step + + +def _apply_drop(conf, base_power_dbm: int, drop_db: int) -> int: + base_power_dbm = int(base_power_dbm) + selected = base_power_dbm - _quantize_drop(conf, drop_db) + min_power = getattr(conf, "DTP_MIN_TX_POWER_DBM", None) + if min_power is not None: + selected = max(int(min_power), selected) + + # The minimum-power clamp must never turn DTP into a power boost if the user + # sets it above PTX/baseTxPower. DTP is a shrink-the-interference-radius + # experiment only; configured PTX remains the upper bound. + return min(base_power_dbm, selected) + + +def _retry_attempt(node, packet) -> int: + return max(0, node.conf.maxRetransmission - packet.retransmissions) + + +def _prior_hop_margin_db(conf, packet) -> float | None: + """Return prior-hop decode margin above this modem preset's sensitivity. + + Absolute LoRa SNR is often negative even for clean packets, so DTP should + not use `snr >= 5 dB` style thresholds. The useful question is how far the + received prior hop sat above the selected preset's demodulation edge. + """ + prior_rssi = getattr(packet, "priorHopRssi", None) + if prior_rssi is not None: + return prior_rssi - conf.current_preset["sensitivity"] + + prior_snr = getattr(packet, "priorHopSnr", None) + if prior_snr is None: + return None + + sensitivity_snr = conf.current_preset["sensitivity"] - conf.NOISE_LEVEL + return prior_snr - sensitivity_snr + + +def _strong_prior_hop(conf, packet) -> bool: + margin = _prior_hop_margin_db(conf, packet) + return margin is not None and margin >= conf.DTP_STRONG_LINK_MARGIN_DB + + +def _very_strong_prior_hop(conf, packet) -> bool: + margin = _prior_hop_margin_db(conf, packet) + return margin is not None and margin >= conf.DTP_VERY_STRONG_LINK_MARGIN_DB + + +def choose_dynamic_tx_power(node, packet) -> DtpDecision: + """Choose a temporary per-packet TX power for DTP experiments. + + The policy is intentionally asymmetric: + + * origin packets stay at configured power because they create the first copy + of a flood, and the simulator does not know which far receiver might need + that copy; + * relay packets may shrink power when channel pressure is high, because + duplicate rebroadcasts are where harmful overlap accumulates; + * final retries and rescue-CR packets stay at full power, because cutting + power there fights the reliability lever that DCR just selected. + """ + base_power = int(getattr(packet, "baseTxPower", node.conf.PTX)) + if not node.conf.DTP_ENABLED: + return DtpDecision(base_power, "dtp_off") + + pressure, util, queue_depth = classify_channel_pressure(node) + relay = packet.txNodeId != packet.origTxNodeId + direct = getattr(packet, "destId", NODENUM_BROADCAST) != NODENUM_BROADCAST + retry_attempt = _retry_attempt(node, packet) + final_retry = retry_attempt > 0 and packet.retransmissions <= 1 + margin = _prior_hop_margin_db(node.conf, packet) + strong = _strong_prior_hop(node.conf, packet) + very_strong = _very_strong_prior_hop(node.conf, packet) + reasons = [f"channel_{pressure}", f"util={util:.1f}", f"queue={queue_depth}"] + if margin is not None: + reasons.append(f"prior_margin={margin:.1f}") + + drop_db = 0 + + if final_retry or packet.cr >= CR_RESCUE: + # DTP should shrink interference, not sabotage the rescue case. CR can + # help payload reliability, but it cannot recover packets pushed below + # preamble/header sensitivity by excessive power reduction. + reasons.append("max_power_retry_rescue") + elif packet.isAck: + if very_strong: + drop_db = 6 if pressure in ("busy", "congested") else 3 + reasons.append("ack_strong_prior_hop") + else: + reasons.append("max_power_ack") + elif relay: + reasons.append("relay") + if direct and not strong: + # Meshtasticator knows the destination but not a guaranteed next-hop + # budget. Keep power unless the prior hop was clearly strong. + reasons.append("max_power_direct_relay_without_strong_link") + elif packet.hopLimit <= 1 and not strong: + reasons.append("max_power_last_hop_without_strong_link") + elif pressure == "congested": + drop_db = 9 + reasons.append("congested_relay_power_drop") + elif pressure == "busy": + drop_db = 6 + reasons.append("busy_relay_power_drop") + elif strong: + drop_db = 3 + reasons.append("strong_prior_hop_power_drop") + else: + reasons.append("max_power_relay") + + if direct and strong: + drop_db = min(drop_db or 3, 3) + reasons.append("direct_relay_cap") + if packet.hopLimit <= 1 and strong: + drop_db = min(drop_db or 3, 3) + reasons.append("last_hop_cap") + else: + # Origin packets seed the flood. Without neighbor/topology certainty, + # cutting their power is more likely to create holes than to reduce + # duplicate relay overlap. + reasons.append("max_power_origin") + + selected_power = _apply_drop(node.conf, base_power, drop_db) + if selected_power < base_power: + reasons.append(f"drop={base_power - selected_power}dB") + else: + reasons.append("drop=0dB") + + return DtpDecision(selected_power, ",".join(reasons)) diff --git a/lib/node.py b/lib/node.py index 64cee853..d041ca61 100644 --- a/lib/node.py +++ b/lib/node.py @@ -10,6 +10,7 @@ from lib.config import Config from lib.dcr import choose_dynamic_coding_rate from lib.discrete_event_sim_components import SimulationState, SimulationDataTracking +from lib.dtp import choose_dynamic_tx_power from lib.geo import valid_lat_lon from lib.link_model import calculate_link_budget from lib.mac import set_transmit_delay, get_retransmission_msec @@ -268,6 +269,11 @@ def __init__(self, conf, sim_state: SimulationState, data_tracking: SimulationDa self.airUtilization = 0 self.dcrTxByCr = {5: 0, 6: 0, 7: 0, 8: 0} self.dcrAirtimeByCr = {5: 0.0, 6: 0.0, 7: 0.0, 8: 0.0} + self.dtpTxByPower = {} + self.dtpTxByCrPower = {} + self.dtpDetectedByTx = 0 + self.dtpSensedByTx = 0 + self.dtpTxCount = 0 self.droppedByDelay = 0 self.rebroadcastPackets = 0 self.isMoving = False @@ -558,6 +564,13 @@ def transmit(self, packet): f"{self.env.now:.3f} Node {self.nodeid} DCR selected CR 4/{packet.cr} for packet {packet.seq}: {decision.reason}" ) + power_decision = choose_dynamic_tx_power(self, packet) + if power_decision.tx_power_dbm != packet.txpow: + packet.set_tx_power(power_decision.tx_power_dbm) + logger.debug( + f"{self.env.now:.3f} Node {self.nodeid} DTP selected {packet.txpow} dBm for packet {packet.seq}: {power_decision.reason}" + ) + logger.debug(f"{self.env.now:.3f} Node {self.nodeid} started low level send {packet.unique_packet_seq} for msg {packet.seq} hopLimit {packet.hopLimit} original Tx {packet.origTxNodeId}") self.nrPacketsSent += 1 packet.startTime = self.env.now @@ -575,6 +588,12 @@ def transmit(self, packet): self.airUtilization += packet.timeOnAir self.dcrTxByCr[packet.cr] = self.dcrTxByCr.get(packet.cr, 0) + 1 self.dcrAirtimeByCr[packet.cr] = self.dcrAirtimeByCr.get(packet.cr, 0.0) + packet.timeOnAir + self.dtpTxByPower[packet.txpow] = self.dtpTxByPower.get(packet.txpow, 0) + 1 + cr_power_key = f"{packet.cr}@{packet.txpow}" + self.dtpTxByCrPower[cr_power_key] = self.dtpTxByCrPower.get(cr_power_key, 0) + 1 + self.dtpDetectedByTx += sum(1 for detected in packet.detectedByN if detected) + self.dtpSensedByTx += sum(1 for sensed in packet.sensedByN if sensed) + self.dtpTxCount += 1 self.bc_pipe.put(packet) # queue for nodes to receive packet self.isTransmitting = True yield self.env.timeout(packet.timeOnAir) diff --git a/loraMesh.py b/loraMesh.py index 661b17d9..552fac98 100755 --- a/loraMesh.py +++ b/loraMesh.py @@ -76,6 +76,11 @@ def get_cli_defaults(conf): "CLUTTER_PROFILE_SAMPLES": conf.CLUTTER_PROFILE_SAMPLES, "NODE_Z_REFERENCE": conf.NODE_Z_REFERENCE, "RADIO_CALIBRATION": snapshot_radio_calibration(conf), + "DTP_MAX_POWER_DROP_DB": conf.DTP_MAX_POWER_DROP_DB, + "DTP_POWER_STEP_DB": conf.DTP_POWER_STEP_DB, + "DTP_MIN_TX_POWER_DBM": conf.DTP_MIN_TX_POWER_DBM, + "DTP_STRONG_LINK_MARGIN_DB": conf.DTP_STRONG_LINK_MARGIN_DB, + "DTP_VERY_STRONG_LINK_MARGIN_DB": conf.DTP_VERY_STRONG_LINK_MARGIN_DB, }, ) return getattr(conf, CLI_DEFAULT_ATTR) @@ -283,6 +288,34 @@ def parse_params(conf, args=None) -> [NodeConfig]: parser.add_argument( "--dcr", action="store_true", help="Enable the Dynamic Coding Rate experiment" ) + parser.add_argument( + "--dtp", action="store_true", help="Enable the Dynamic TX Power experiment" + ) + parser.add_argument( + "--dtp-max-drop-db", + type=int, + help="maximum per-packet TX power reduction for --dtp", + ) + parser.add_argument( + "--dtp-power-step-db", + type=int, + help="TX power quantization step for --dtp reductions", + ) + parser.add_argument( + "--dtp-min-power-dbm", + type=int, + help="minimum TX power that --dtp may select", + ) + parser.add_argument( + "--dtp-strong-margin-db", + type=float, + help="prior-hop sensitivity margin that lets --dtp reduce relay power", + ) + parser.add_argument( + "--dtp-very-strong-margin-db", + type=float, + help="prior-hop sensitivity margin that lets --dtp reduce ACK power more", + ) parser.add_argument( "--terrain-srtm", action="store_true", @@ -447,6 +480,33 @@ def parse_params(conf, args=None) -> [NodeConfig]: parser.error("--terrain-srtm-step-meters must be a positive finite number") if parsed_arguments.clutter_profile_samples is not None and parsed_arguments.clutter_profile_samples < 1: parser.error("--clutter-profile-samples must be at least 1") + if parsed_arguments.dtp_max_drop_db is not None and parsed_arguments.dtp_max_drop_db < 0: + parser.error("--dtp-max-drop-db must be at least 0") + if parsed_arguments.dtp_power_step_db is not None and parsed_arguments.dtp_power_step_db < 1: + parser.error("--dtp-power-step-db must be at least 1") + if ( + parsed_arguments.dtp_strong_margin_db is not None + and (not math.isfinite(parsed_arguments.dtp_strong_margin_db) or parsed_arguments.dtp_strong_margin_db < 0) + ): + parser.error("--dtp-strong-margin-db must be a non-negative finite number") + if ( + parsed_arguments.dtp_very_strong_margin_db is not None + and (not math.isfinite(parsed_arguments.dtp_very_strong_margin_db) or parsed_arguments.dtp_very_strong_margin_db < 0) + ): + parser.error("--dtp-very-strong-margin-db must be a non-negative finite number") + + dtp_strong_margin = ( + parsed_arguments.dtp_strong_margin_db + if parsed_arguments.dtp_strong_margin_db is not None + else cli_defaults["DTP_STRONG_LINK_MARGIN_DB"] + ) + dtp_very_strong_margin = ( + parsed_arguments.dtp_very_strong_margin_db + if parsed_arguments.dtp_very_strong_margin_db is not None + else cli_defaults["DTP_VERY_STRONG_LINK_MARGIN_DB"] + ) + if dtp_very_strong_margin < dtp_strong_margin: + parser.error("--dtp-very-strong-margin-db must be >= --dtp-strong-margin-db") if parsed_arguments.no_gui: # Headless CI and smoke runs should not pay Tk startup, per-node @@ -665,6 +725,24 @@ def parse_params(conf, args=None) -> [NodeConfig]: conf.NR_NODES = nr_nodes conf.ENABLE_CONNECTIVITY_MAP = connectivity_map_enabled conf.DCR_ENABLED = parsed_arguments.dcr + conf.DTP_ENABLED = parsed_arguments.dtp + conf.DTP_MAX_POWER_DROP_DB = ( + parsed_arguments.dtp_max_drop_db + if parsed_arguments.dtp_max_drop_db is not None + else cli_defaults["DTP_MAX_POWER_DROP_DB"] + ) + conf.DTP_POWER_STEP_DB = ( + parsed_arguments.dtp_power_step_db + if parsed_arguments.dtp_power_step_db is not None + else cli_defaults["DTP_POWER_STEP_DB"] + ) + conf.DTP_MIN_TX_POWER_DBM = ( + parsed_arguments.dtp_min_power_dbm + if parsed_arguments.dtp_min_power_dbm is not None + else cli_defaults["DTP_MIN_TX_POWER_DBM"] + ) + conf.DTP_STRONG_LINK_MARGIN_DB = dtp_strong_margin + conf.DTP_VERY_STRONG_LINK_MARGIN_DB = dtp_very_strong_margin set_geo_origin(conf, scenario_origin) conf.TERRAIN_ENABLED = terrain_enabled conf.TERRAIN_GRID = terrain_grid @@ -709,6 +787,16 @@ def parse_params(conf, args=None) -> [NodeConfig]: f"{SRTM_DATA_ATTRIBUTION} ({SRTM_DATA_ATTRIBUTION_URL})", ) print("Dynamic Coding Rate:", "enabled" if conf.DCR_ENABLED else "disabled") + print("Dynamic TX Power:", "enabled" if conf.DTP_ENABLED else "disabled") + if conf.DTP_ENABLED: + print( + "DTP limits:", + f"max_drop={conf.DTP_MAX_POWER_DROP_DB}dB", + f"step={conf.DTP_POWER_STEP_DB}dB", + f"min_power={conf.DTP_MIN_TX_POWER_DBM if conf.DTP_MIN_TX_POWER_DBM is not None else 'none'}", + f"strong_margin={conf.DTP_STRONG_LINK_MARGIN_DB:g}dB", + f"very_strong_margin={conf.DTP_VERY_STRONG_LINK_MARGIN_DB:g}dB", + ) print("PHY loss model:", "enabled" if conf.PHY_LOSS_MODEL_ENABLED else "disabled") print("Capture collision model:", "enabled" if conf.CAPTURE_COLLISION_MODEL_ENABLED else "disabled") print("Terrain model:", "enabled" if conf.TERRAIN_ENABLED else "disabled") @@ -784,6 +872,12 @@ def run_simulation(conf, node_config): print("DCR TX packets by CR:", results["dcrTxByCr"]) print("DCR airtime by CR (ms):", {cr: round(ms, 2) for cr, ms in results["dcrAirtimeByCr"].items()}) + if conf.DTP_ENABLED: + print("DTP TX packets by power:", results["dtpTxByPower"]) + print("DTP TX packets by CR@power:", results["dtpTxByCrPower"]) + print("DTP mean CAD-detected receivers per TX:", round(results["dtpMeanDetectedByTx"], 2)) + print("DTP mean decodable receivers per TX:", round(results["dtpMeanSensedByTx"], 2)) + if conf.TERRAIN_ENABLED: print("Mean terrain obstruction loss (dB):", round(results["meanTerrainLossDb"], 2)) print("Max terrain obstruction loss (dB):", round(results["maxTerrainLossDb"], 2)) diff --git a/tests/test_discrete_event_sim.py b/tests/test_discrete_event_sim.py index 018c500c..21c06637 100644 --- a/tests/test_discrete_event_sim.py +++ b/tests/test_discrete_event_sim.py @@ -76,6 +76,11 @@ def __init__(self, nodeid: int): self.gpsEnabled = False self.dcrTxByCr = {5: 0, 6: 0, 7: 0, 8: 0} self.dcrAirtimeByCr = {5: 0.0, 6: 0.0, 7: 0.0, 8: 0.0} + self.dtpTxByPower = {} + self.dtpTxByCrPower = {} + self.dtpDetectedByTx = 0 + self.dtpSensedByTx = 0 + self.dtpTxCount = 0 class MockPacket: def __init__(self, num_nodes: int): @@ -131,6 +136,10 @@ def __init__(self, num_nodes: int): self.assertEqual(sim_results['delayDropped'], 0, 'expected number of delayDropped') self.assertEqual(sim_results['dcrTxByCr'], {5: 0, 6: 0, 7: 0, 8: 0}, 'expected DCR histogram') self.assertEqual(sim_results['dcrAirtimeByCr'], {5: 0.0, 6: 0.0, 7: 0.0, 8: 0.0}, 'expected DCR airtime histogram') + self.assertEqual(sim_results['dtpTxByPower'], {}, 'expected DTP power histogram') + self.assertEqual(sim_results['dtpTxByCrPower'], {}, 'expected DTP CR/power histogram') + self.assertEqual(sim_results['dtpMeanDetectedByTx'], 0.0, 'expected DTP detected mean') + self.assertEqual(sim_results['dtpMeanSensedByTx'], 0.0, 'expected DTP sensed mean') # keys exist, not currently checking values self.assertIsNotNone(sim_results['txAirUtilizationRate'], 'txAirUtilizationRate is created') diff --git a/tests/test_dtp.py b/tests/test_dtp.py new file mode 100644 index 00000000..ff6e7a09 --- /dev/null +++ b/tests/test_dtp.py @@ -0,0 +1,207 @@ +import unittest + +import simpy + +from lib.config import Config +from lib.discrete_event_sim_components import SimulationDataTracking, SimulationState +from lib.dtp import choose_dynamic_tx_power +from lib.node import MeshNode, NodeConfig +from lib.packet import MeshPacket, NODENUM_BROADCAST +from lib.point import Point + + +class FakePacket: + def __init__( + self, + cr=5, + is_ack=False, + retransmissions=3, + tx_node_id=0, + orig_tx_node_id=0, + hop_limit=3, + dest_id=NODENUM_BROADCAST, + prior_hop_rssi=None, + prior_hop_snr=None, + base_power=30, + ): + self.cr = cr + self.isAck = is_ack + self.retransmissions = retransmissions + self.txNodeId = tx_node_id + self.origTxNodeId = orig_tx_node_id + self.hopLimit = hop_limit + self.destId = dest_id + self.priorHopRssi = prior_hop_rssi + self.priorHopSnr = prior_hop_snr + self.baseTxPower = base_power + + +class FakeTransmitter: + def __init__(self, queue_depth=0): + self.queue = [object()] * queue_depth + + +class FakeNode: + def __init__(self, util=0.0, queue_depth=0): + self.conf = Config() + self.conf.DTP_ENABLED = True + self._util = util + self.transmitter = FakeTransmitter(queue_depth) + self.txAirUtilization = 0.0 + self.prevTxAirUtilization = 0.0 + + def channel_utilization_percent(self): + return self._util + + +class TestDynamicTxPower(unittest.TestCase): + def test_dtp_disabled_keeps_base_power(self): + node = FakeNode() + node.conf.DTP_ENABLED = False + + decision = choose_dynamic_tx_power(node, FakePacket(base_power=27)) + + self.assertEqual(decision.tx_power_dbm, 27) + self.assertEqual(decision.reason, "dtp_off") + + def test_origin_packet_stays_at_max_power(self): + node = FakeNode(util=30.0) + + decision = choose_dynamic_tx_power(node, FakePacket(tx_node_id=1, orig_tx_node_id=1, base_power=30)) + + self.assertEqual(decision.tx_power_dbm, 30) + self.assertIn("max_power_origin", decision.reason) + + def test_busy_relay_lowers_power(self): + node = FakeNode(util=12.0) + + decision = choose_dynamic_tx_power(node, FakePacket(tx_node_id=2, orig_tx_node_id=1, base_power=30)) + + self.assertEqual(decision.tx_power_dbm, 24) + self.assertIn("busy_relay_power_drop", decision.reason) + + def test_congested_relay_lowers_power_more(self): + node = FakeNode(util=20.0) + + decision = choose_dynamic_tx_power(node, FakePacket(tx_node_id=2, orig_tx_node_id=1, base_power=30)) + + self.assertEqual(decision.tx_power_dbm, 21) + + def test_direct_relay_without_strong_prior_hop_stays_max_power(self): + node = FakeNode(util=12.0) + + decision = choose_dynamic_tx_power(node, FakePacket(tx_node_id=2, orig_tx_node_id=1, dest_id=7, base_power=30)) + + self.assertEqual(decision.tx_power_dbm, 30) + self.assertIn("max_power_direct_relay_without_strong_link", decision.reason) + + def test_strong_direct_relay_only_gets_small_drop(self): + node = FakeNode(util=20.0) + prior_rssi = node.conf.current_preset["sensitivity"] + node.conf.DTP_STRONG_LINK_MARGIN_DB + + decision = choose_dynamic_tx_power( + node, + FakePacket(tx_node_id=2, orig_tx_node_id=1, dest_id=7, prior_hop_rssi=prior_rssi, base_power=30), + ) + + self.assertEqual(decision.tx_power_dbm, 27) + self.assertIn("direct_relay_cap", decision.reason) + + def test_prior_hop_strength_is_not_absolute_snr(self): + node = FakeNode(util=20.0) + + decision = choose_dynamic_tx_power( + node, + FakePacket(tx_node_id=2, orig_tx_node_id=1, dest_id=7, prior_hop_snr=6.0, base_power=30), + ) + + self.assertEqual(decision.tx_power_dbm, 30) + self.assertIn("max_power_direct_relay_without_strong_link", decision.reason) + + def test_final_retry_uses_max_power(self): + node = FakeNode(util=0.0) + + decision = choose_dynamic_tx_power( + node, + FakePacket(tx_node_id=2, orig_tx_node_id=1, retransmissions=1, prior_hop_snr=8.0, base_power=30), + ) + + self.assertEqual(decision.tx_power_dbm, 30) + self.assertIn("max_power_retry_rescue", decision.reason) + + def test_cr8_packet_uses_max_power_even_when_not_retry(self): + node = FakeNode(util=20.0) + + decision = choose_dynamic_tx_power(node, FakePacket(cr=8, tx_node_id=2, orig_tx_node_id=1, base_power=30)) + + self.assertEqual(decision.tx_power_dbm, 30) + self.assertIn("max_power_retry_rescue", decision.reason) + + def test_power_drop_respects_step_and_minimum(self): + node = FakeNode(util=20.0) + node.conf.DTP_MAX_POWER_DROP_DB = 8 + node.conf.DTP_POWER_STEP_DB = 3 + node.conf.DTP_MIN_TX_POWER_DBM = 24 + + decision = choose_dynamic_tx_power(node, FakePacket(tx_node_id=2, orig_tx_node_id=1, base_power=30)) + + self.assertEqual(decision.tx_power_dbm, 24) + + def test_minimum_power_clamp_cannot_boost_above_base_power(self): + node = FakeNode(util=20.0) + node.conf.DTP_MIN_TX_POWER_DBM = 35 + + decision = choose_dynamic_tx_power(node, FakePacket(tx_node_id=2, orig_tx_node_id=1, base_power=30)) + + self.assertEqual(decision.tx_power_dbm, 30) + self.assertIn("drop=0dB", decision.reason) + + +class TestDynamicTxPowerPacketPhysics(unittest.TestCase): + def make_nodes(self, distance_m): + conf = Config() + conf.NR_NODES = 2 + conf.PTX = 30 + conf.MODEL_ASYMMETRIC_LINKS = False + conf.LINK_OFFSET = {(0, 1): 0, (1, 0): 0} + env = simpy.Environment() + sim_state = SimulationState(conf, env) + tracking = SimulationDataTracking() + nodes = [ + MeshNode(conf, sim_state, tracking, NodeConfig(0, Point(0, 0, 1.5), conf.PERIOD)), + MeshNode(conf, sim_state, tracking, NodeConfig(1, Point(distance_m, 0, 1.5), conf.PERIOD)), + ] + sim_state.nodes.extend(nodes) + return conf, nodes + + def test_lower_tx_power_recomputes_receiver_visibility(self): + conf, nodes = self.make_nodes(2_000) + connectivity_map = {0: {1}, 1: {0}} + baseline_pathloss_matrix = [[None, None], [None, None]] + packet = MeshPacket( + conf, + nodes, + 0, + NODENUM_BROADCAST, + 0, + 40, + 1, + 0, + True, + False, + None, + 0, + connectivity_map, + baseline_pathloss_matrix, + ) + + self.assertTrue(packet.sensedByN[1]) + + packet.set_tx_power(18) + + self.assertFalse(packet.sensedByN[1]) + self.assertLess(packet.rssiAtN[1], conf.current_preset["sensitivity"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_lora_mesh_cli.py b/tests/test_lora_mesh_cli.py index 0213da81..d521fd7e 100644 --- a/tests/test_lora_mesh_cli.py +++ b/tests/test_lora_mesh_cli.py @@ -85,10 +85,12 @@ def test_parse_params_uses_supplied_argv(self): self.assertFalse(conf.GUI_ENABLED) self.assertFalse(conf.PLOT) self.assertFalse(conf.DCR_ENABLED) + self.assertFalse(conf.DTP_ENABLED) self.assertEqual(conf.SIMTIME, 1000) self.assertEqual(conf.PERIOD, 500) self.assertIn("Number of nodes: 2", output) self.assertIn("Dynamic Coding Rate: disabled", output) + self.assertIn("Dynamic TX Power: disabled", output) def test_parse_params_enables_dcr(self): conf = Config() @@ -101,6 +103,105 @@ def test_parse_params_enables_dcr(self): self.assertTrue(conf.DCR_ENABLED) self.assertIn("Dynamic Coding Rate: enabled", output) + def test_parse_params_enables_dtp_with_limits(self): + conf = Config() + + _, output = self.parse_quietly( + conf, + [ + "2", + "--no-gui", + "--dtp", + "--dtp-max-drop-db", + "9", + "--dtp-power-step-db", + "3", + "--dtp-min-power-dbm", + "14", + "--dtp-strong-margin-db", + "18", + "--dtp-very-strong-margin-db", + "24", + ], + ) + + self.assertTrue(conf.DTP_ENABLED) + self.assertEqual(conf.DTP_MAX_POWER_DROP_DB, 9) + self.assertEqual(conf.DTP_POWER_STEP_DB, 3) + self.assertEqual(conf.DTP_MIN_TX_POWER_DBM, 14) + self.assertEqual(conf.DTP_STRONG_LINK_MARGIN_DB, 18) + self.assertEqual(conf.DTP_VERY_STRONG_LINK_MARGIN_DB, 24) + self.assertIn("Dynamic TX Power: enabled", output) + self.assertIn("DTP limits:", output) + + def test_parse_params_rejects_inverted_dtp_margins(self): + conf = Config() + + stderr = self.assert_parser_rejects( + conf, + [ + "2", + "--no-gui", + "--dtp", + "--dtp-strong-margin-db", + "30", + "--dtp-very-strong-margin-db", + "20", + ], + ) + + self.assertIn("--dtp-very-strong-margin-db", stderr) + + def test_parse_params_reuses_initial_dtp_defaults_after_override_run(self): + conf = Config() + default_max_drop = conf.DTP_MAX_POWER_DROP_DB + default_min_power = conf.DTP_MIN_TX_POWER_DBM + + self.parse_quietly( + conf, + [ + "2", + "--no-gui", + "--dtp", + "--dtp-max-drop-db", + "9", + "--dtp-min-power-dbm", + "14", + "--dtp-strong-margin-db", + "18", + "--dtp-very-strong-margin-db", + "24", + ], + ) + + self.parse_quietly(conf, ["2", "--no-gui", "--dtp"]) + + self.assertEqual(conf.DTP_MAX_POWER_DROP_DB, default_max_drop) + self.assertEqual(conf.DTP_MIN_TX_POWER_DBM, default_min_power) + self.assertEqual(conf.DTP_STRONG_LINK_MARGIN_DB, 20.0) + self.assertEqual(conf.DTP_VERY_STRONG_LINK_MARGIN_DB, 24.0) + + def test_parse_params_validates_partial_dtp_margin_override_against_initial_defaults(self): + conf = Config() + + self.parse_quietly( + conf, + [ + "2", + "--no-gui", + "--dtp", + "--dtp-strong-margin-db", + "30", + "--dtp-very-strong-margin-db", + "36", + ], + ) + + self.parse_quietly(conf, ["2", "--no-gui", "--dtp", "--dtp-very-strong-margin-db", "24"]) + + self.assertEqual(conf.DTP_STRONG_LINK_MARGIN_DB, 20.0) + self.assertEqual(conf.DTP_VERY_STRONG_LINK_MARGIN_DB, 24.0) + def test_parse_params_reuses_initial_defaults_after_override_run(self): conf = Config() default_simtime = conf.SIMTIME diff --git a/tests/test_radio_policy_compare.py b/tests/test_radio_policy_compare.py index 5d6478e5..e4c283d5 100644 --- a/tests/test_radio_policy_compare.py +++ b/tests/test_radio_policy_compare.py @@ -13,8 +13,8 @@ def test_parse_policy_names_rejects_unknown_policy(self): with self.assertRaises(argparse.ArgumentTypeError): radio_policy_compare.parse_policy_names("static,nope") - def test_parse_policy_names_accepts_dcr_policy(self): - self.assertEqual(radio_policy_compare.parse_policy_names("static,dcr"), ["static", "dcr"]) + def test_parse_policy_names_accepts_current_policy_names(self): + self.assertEqual(radio_policy_compare.parse_policy_names("static,dcr,dtp"), ["static", "dcr", "dtp"]) def test_parse_args_rejects_thresholds_without_candidate_policy(self): with self.assertRaises(SystemExit): @@ -55,6 +55,17 @@ def test_build_lora_args_adds_dcr_policy_flag(self): self.assertIn("--dcr", lora_args) + def test_build_lora_args_adds_dtp_policy_flag(self): + args = radio_policy_compare.parse_args([ + "--policies", + "dtp", + ]) + + lora_args = radio_policy_compare.build_lora_args(args, "dtp") + + self.assertIn("--dtp", lora_args) + self.assertNotIn("--dcr", lora_args) + def test_summarize_results_formats_table_and_deltas(self): static = radio_policy_compare.summarize_results( "static", diff --git a/tools/radio_policy_compare.py b/tools/radio_policy_compare.py index 464ffae0..ca4aa9e0 100644 --- a/tools/radio_policy_compare.py +++ b/tools/radio_policy_compare.py @@ -27,6 +27,7 @@ POLICY_FLAGS = { "static": ("static CR with packet loss/capture physics", []), "dcr": ("Dynamic Coding Rate", ["--dcr"]), + "dtp": ("Dynamic TX Power", ["--dtp"]), } @@ -76,7 +77,7 @@ def parse_args(argv=None): epilog="""examples: python3 tools/radio_policy_compare.py python3 tools/radio_policy_compare.py --simtime-seconds 120 --period-seconds 5 - python3 tools/radio_policy_compare.py --policies static,dcr -- --no-clutter + python3 tools/radio_policy_compare.py --policies static,dcr,dtp -- --no-clutter """, ) parser.add_argument("--preset", default="batumi", help="Packaged scenario preset to run") @@ -86,7 +87,7 @@ def parse_args(argv=None): "--policies", type=parse_policy_names, default=parse_policy_names("static"), - help="Comma-separated policies: static,dcr", + help="Comma-separated policies: static,dcr,dtp", ) parser.add_argument( "--show-raw-output", From c62410e3794ad928b52453761dcb76ab3d8c777f Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Tue, 5 May 2026 23:32:24 +0400 Subject: [PATCH 21/24] refactor(phy): allow explicit path loss model --- lib/phy.py | 15 +++++++-------- tests/test_phy.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/lib/phy.py b/lib/phy.py index a2e6b63c..9acea67c 100644 --- a/lib/phy.py +++ b/lib/phy.py @@ -267,13 +267,6 @@ def estimate_path_loss(conf, dist, freq, txZ=None, rxZ=None, model=None): Returns: path loss as float ''' - if txZ is None: - txZ = conf.HM - if rxZ is None: - rxZ = conf.HM - if model is None: - model = conf.MODEL - # With randomized movements we may end up on top of another node which is problematic for log(dist) # # Some real-mesh presets can also set a larger floor as an empirical @@ -281,6 +274,12 @@ def estimate_path_loss(conf, dist, freq, txZ=None, rxZ=None, model=None): # at apartment-scale separations, and map node positions are coarse enough # that "two pins are close" does not mean "two antennas have clear 20 m RF". dist = max(dist, conf.PATH_LOSS_DISTANCE_FLOOR_M) + if txZ is None: + txZ = conf.HM + if rxZ is None: + rxZ = conf.HM + if model is None: + model = conf.MODEL # Log-Distance model if model == 0: @@ -325,7 +324,7 @@ def estimate_path_loss(conf, dist, freq, txZ=None, rxZ=None, model=None): + 45.5 + (35.46 - 1.1 * rxZ) * (math.log10(freq) - 6.0) \ - 13.82 * math.log10(rxZ) + 0.7 * rxZ + C else: - raise ValueError(f"Unsupported path loss model: {model}") + raise ValueError(f"unsupported path loss model: {model}") return Lpl diff --git a/tests/test_phy.py b/tests/test_phy.py index 7e123853..2378006a 100644 --- a/tests/test_phy.py +++ b/tests/test_phy.py @@ -1,5 +1,6 @@ import unittest +from lib.config import Config import lib.phy class TestPhy(unittest.TestCase): @@ -46,6 +47,33 @@ def poly1(x): diff = abs(res - 2.5) self.assertLess(diff, tolerance, message) + def test_path_loss_distance_floor_keeps_near_field_calibrated(self): + conf = Config() + conf.PATH_LOSS_DISTANCE_FLOOR_M = 780.0 + + below_floor = lib.phy.estimate_path_loss(conf, 10.0, conf.FREQ) + at_floor = lib.phy.estimate_path_loss(conf, 780.0, conf.FREQ) + + self.assertAlmostEqual(below_floor, at_floor) + + def test_estimate_path_loss_accepts_explicit_model(self): + conf = Config() + dist = 1500 + freq = conf.FREQ + + explicit = lib.phy.estimate_path_loss(conf, dist, freq, model=0) + + self.assertEqual(conf.MODEL, 5, "explicit model must not mutate config") + conf.MODEL = 0 + implicit = lib.phy.estimate_path_loss(conf, dist, freq) + self.assertAlmostEqual(explicit, implicit) + + def test_estimate_path_loss_rejects_unsupported_model(self): + conf = Config() + + with self.assertRaisesRegex(ValueError, "unsupported path loss model"): + lib.phy.estimate_path_loss(conf, 1500, conf.FREQ, model=99) + if __name__ == '__main__': unittest.main() From f7b3f5cab7006331713fd8e218e331d5f1b0cd7a Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Tue, 5 May 2026 23:32:24 +0400 Subject: [PATCH 22/24] perf(packet): reuse receiver classification lists --- lib/node.py | 18 ++++++------- lib/packet.py | 8 ++++++ tests/test_packet.py | 61 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 9 deletions(-) create mode 100644 tests/test_packet.py diff --git a/lib/node.py b/lib/node.py index d041ca61..040c195c 100644 --- a/lib/node.py +++ b/lib/node.py @@ -575,15 +575,15 @@ def transmit(self, packet): self.nrPacketsSent += 1 packet.startTime = self.env.now packet.endTime = self.env.now + packet.timeOnAir - for rx_node in self.nodes: - if packet_is_rx_candidate(packet, rx_node.nodeid, self.conf.CAPTURE_COLLISION_MODEL_ENABLED): - collision = check_collision(self.conf, self.env, packet, rx_node.nodeid, self.packetsAtN) - if self.conf.CAPTURE_COLLISION_MODEL_ENABLED: - # Even a packet that cannot be decoded is still RF - # energy on the channel and may jam later packets. - self.packetsAtN[rx_node.nodeid].append(packet) - elif collision == 0: - self.packetsAtN[rx_node.nodeid].append(packet) + rx_node_ids = packet.detected_node_ids if self.conf.CAPTURE_COLLISION_MODEL_ENABLED else packet.sensed_node_ids + for rx_node_id in rx_node_ids: + collision = check_collision(self.conf, self.env, packet, rx_node_id, self.packetsAtN) + if self.conf.CAPTURE_COLLISION_MODEL_ENABLED: + # Even a packet that cannot be decoded is still RF + # energy on the channel and may jam later packets. + self.packetsAtN[rx_node_id].append(packet) + elif collision == 0: + self.packetsAtN[rx_node_id].append(packet) self.txAirUtilization += packet.timeOnAir self.airUtilization += packet.timeOnAir self.dcrTxByCr[packet.cr] = self.dcrTxByCr.get(packet.cr, 0) + 1 diff --git a/lib/packet.py b/lib/packet.py index 459481d3..fdca1c4d 100644 --- a/lib/packet.py +++ b/lib/packet.py @@ -63,6 +63,8 @@ def __init__(self, conf, nodes, origTxNodeId, destId, txNodeId, plen, seq, genTi self.rssiAtN = [0 for _ in range(self.conf.NR_NODES)] self.sensedByN = [False for _ in range(self.conf.NR_NODES)] # nodes which may possibly sense this packet self.detectedByN = [False for _ in range(self.conf.NR_NODES)] + self.sensed_node_ids = [] + self.detected_node_ids = [] self.collidedAtN = [False for _ in range(self.conf.NR_NODES)] self.collisionReasonAtN = [None for _ in range(self.conf.NR_NODES)] self.receivedAtN = [False for _ in range(self.conf.NR_NODES)] @@ -105,6 +107,7 @@ def refresh_link_budgets(self): detection, sensitivity, and empirical PHY loss must be recalculated before collision handling. """ + self.detected_node_ids = [] for rx_node in self.nodes: if rx_node.nodeid == self.txNodeId: continue @@ -130,6 +133,8 @@ def refresh_link_budgets(self): self.LplAtN[rx_node.nodeid] = budget.calibrated_path_loss_db self.rssiAtN[rx_node.nodeid] = budget.rssi_dbm self.detectedByN[rx_node.nodeid] = self.rssiAtN[rx_node.nodeid] >= self.conf.current_preset["cad_threshold"] + if self.detectedByN[rx_node.nodeid]: + self.detected_node_ids.append(rx_node.nodeid) self.refresh_phy_reception() def airtime_for_cr(self, cr): @@ -162,11 +167,14 @@ def refresh_phy_reception(self): coding rates improve payload decode probability near that edge, but they do not resurrect packets whose preamble/header would not be heard. """ + self.sensed_node_ids = [] for rx_node_id, rssi in enumerate(self.rssiAtN): if rx_node_id == self.txNodeId: continue self.sensedByN[rx_node_id] = rssi >= self.conf.current_preset["sensitivity"] + if self.sensedByN[rx_node_id]: + self.sensed_node_ids.append(rx_node_id) self.phyLostAtN[rx_node_id] = False if self.sensedByN[rx_node_id]: diff --git a/tests/test_packet.py b/tests/test_packet.py new file mode 100644 index 00000000..9a6d4bba --- /dev/null +++ b/tests/test_packet.py @@ -0,0 +1,61 @@ +import unittest + +from lib.config import Config +from lib.packet import MeshPacket, NODENUM_BROADCAST +from lib.point import Point + + +class PacketNode: + def __init__(self, nodeid, position): + self.nodeid = nodeid + self.position = position + self.antennaGain = 0 + self.hopLimit = 3 + + +class TestMeshPacket(unittest.TestCase): + + def test_receiver_id_lists_match_receiver_flags(self): + conf = Config() + conf.NR_NODES = 3 + conf.LINK_OFFSET = { + (tx_id, rx_id): 0 + for tx_id in range(conf.NR_NODES) + for rx_id in range(conf.NR_NODES) + if tx_id != rx_id + } + nodes = [ + PacketNode(0, Point(0, 0, conf.HM)), + PacketNode(1, Point(100, 0, conf.HM)), + PacketNode(2, Point(1000, 0, conf.HM)), + ] + + packet = MeshPacket( + conf, + nodes, + origTxNodeId=0, + destId=NODENUM_BROADCAST, + txNodeId=0, + plen=conf.PACKETLENGTH, + seq=1, + genTime=0, + wantAck=False, + isAck=False, + requestId=None, + now=0, + ) + + self.assertEqual( + packet.sensed_node_ids, + [node_id for node_id, sensed in enumerate(packet.sensedByN) if sensed], + ) + self.assertEqual( + packet.detected_node_ids, + [node_id for node_id, detected in enumerate(packet.detectedByN) if detected], + ) + self.assertNotIn(packet.txNodeId, packet.sensed_node_ids) + self.assertNotIn(packet.txNodeId, packet.detected_node_ids) + + +if __name__ == "__main__": + unittest.main() From 0a25ca3d8a9674a5432b4790ab449b528788a38e Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Thu, 14 May 2026 15:37:39 +0400 Subject: [PATCH 23/24] fix(sim): reset reusable parse radio state --- lib/node.py | 11 +++++ lib/packet.py | 1 + loraMesh.py | 1 + tests/test_lora_mesh_cli.py | 43 +++++++++++++++++ tests/test_node.py | 95 +++++++++++++++++++++++++++++++++++++ tests/test_packet.py | 4 ++ 6 files changed, 155 insertions(+) diff --git a/lib/node.py b/lib/node.py index 040c195c..bad7b5ba 100644 --- a/lib/node.py +++ b/lib/node.py @@ -443,6 +443,7 @@ def send_packet(self, destId, type=""): messageSeq = self.messageSeq.get() self.messages.append(MeshMessage(self.nodeid, destId, self.env.now, messageSeq)) p = MeshPacket(self.conf, self.nodes, self.nodeid, destId, self.nodeid, self.conf.PACKETLENGTH, messageSeq, self.env.now, True, False, None, self.env.now, self.connectivity_map, self.baseline_pathloss_matrix) + p.transmission_started_event = self.env.event() logger.debug(f"{self.env.now:.3f} Node {self.nodeid} generated {type} message {p.seq} to {destId}") self.packets.append(p) self.env.process(self.transmit(p)) @@ -486,6 +487,10 @@ def latest_retry_timer_packet(self, packet): def wait_for_retry_timer_airtime(self, packet): """Wait until DCR has selected the airtime used by the retry timer.""" while self.conf.DCR_ENABLED and packet in self.packets and not packet.retryTimerAirtimeReady: + started_event = getattr(packet, "transmission_started_event", None) + if started_event is not None and not started_event.triggered: + yield started_event + continue yield self.env.timeout(1) def generate_message(self): @@ -525,10 +530,12 @@ def generate_message(self): else: if minRetransmissions > 0: # generate new packet with same sequence number pNew = MeshPacket(self.conf, self.nodes, self.nodeid, p.destId, self.nodeid, p.packetLen, p.seq, p.genTime, p.wantAck, False, None, self.env.now, self.connectivity_map, self.baseline_pathloss_matrix) + pNew.transmission_started_event = self.env.event() pNew.retransmissions = minRetransmissions - 1 logger.debug(f"{self.env.now:.3f} Node {self.nodeid} wants to retransmit its generated packet to {destId} with seq.nr. {p.seq} minRetransmissions {minRetransmissions}") self.packets.append(pNew) self.env.process(self.transmit(pNew)) + p = pNew else: logger.debug(f"{self.env.now:.3f} Node {self.nodeid} reliable send of {p.seq} failed.") break @@ -563,6 +570,8 @@ def transmit(self, packet): logger.debug( f"{self.env.now:.3f} Node {self.nodeid} DCR selected CR 4/{packet.cr} for packet {packet.seq}: {decision.reason}" ) + if hasattr(packet, "transmission_started_event") and not packet.transmission_started_event.triggered: + packet.transmission_started_event.succeed(packet) power_decision = choose_dynamic_tx_power(self, packet) if power_decision.tx_power_dbm != packet.txpow: @@ -600,6 +609,8 @@ def transmit(self, packet): self.isTransmitting = False else: # received ACK: abort transmit, remove from packets generated logger.debug(f"{self.env.now:.3f} Node {self.nodeid} in the meantime received ACK, abort packet with seq. nr {packet.unique_packet_seq} for msg {packet.seq}") + if hasattr(packet, "transmission_started_event") and not packet.transmission_started_event.triggered: + packet.transmission_started_event.succeed(packet) self.packets.remove(packet) def receive(self, in_pipe): diff --git a/lib/packet.py b/lib/packet.py index fdca1c4d..68769fa7 100644 --- a/lib/packet.py +++ b/lib/packet.py @@ -114,6 +114,7 @@ def refresh_link_budgets(self): if ( self.conf.ENABLE_CONNECTIVITY_MAP and not self.conf.MOVEMENT_ENABLED + and self.txNodeId in self.connectivity_map and rx_node.nodeid not in self.connectivity_map[self.txNodeId] ): logger.debug( diff --git a/loraMesh.py b/loraMesh.py index 552fac98..3c33b35f 100755 --- a/loraMesh.py +++ b/loraMesh.py @@ -757,6 +757,7 @@ def parse_params(conf, args=None) -> [NodeConfig]: else: conf.CLUTTER_ENABLED = False conf.CLUTTER_GRID_FILE = None + conf.CLUTTER_PROFILE_SAMPLES = cli_defaults["CLUTTER_PROFILE_SAMPLES"] if parsed_arguments.clutter_profile_samples is not None: conf.CLUTTER_PROFILE_SAMPLES = parsed_arguments.clutter_profile_samples else: diff --git a/tests/test_lora_mesh_cli.py b/tests/test_lora_mesh_cli.py index d521fd7e..fb1c47d0 100644 --- a/tests/test_lora_mesh_cli.py +++ b/tests/test_lora_mesh_cli.py @@ -239,6 +239,49 @@ def test_parse_params_preserves_caller_initial_defaults(self): self.assertEqual(conf.PERIOD, 2345) self.assertEqual([node.period for node in nodes], [2345, 2345]) + def test_parse_params_reuses_initial_dtp_and_clutter_defaults_after_override_run(self): + conf = Config() + default_dtp_max_drop = conf.DTP_MAX_POWER_DROP_DB + default_dtp_power_step = conf.DTP_POWER_STEP_DB + default_dtp_min_power = conf.DTP_MIN_TX_POWER_DBM + default_dtp_strong_margin = conf.DTP_STRONG_LINK_MARGIN_DB + default_dtp_very_strong_margin = conf.DTP_VERY_STRONG_LINK_MARGIN_DB + default_clutter_profile_samples = conf.CLUTTER_PROFILE_SAMPLES + + self.parse_quietly( + conf, + [ + "2", + "--no-gui", + "--dtp", + "--dtp-max-drop-db", + "3", + "--dtp-power-step-db", + "1", + "--dtp-min-power-dbm", + "14", + "--dtp-strong-margin-db", + "18", + "--dtp-very-strong-margin-db", + "24", + "--clutter-grid", + "grid.csv", + "--clutter-profile-samples", + "1", + ], + ) + self.assertEqual(conf.DTP_MAX_POWER_DROP_DB, 3) + self.assertEqual(conf.CLUTTER_PROFILE_SAMPLES, 1) + + self.parse_quietly(conf, ["2", "--no-gui", "--dtp"]) + + self.assertEqual(conf.DTP_MAX_POWER_DROP_DB, default_dtp_max_drop) + self.assertEqual(conf.DTP_POWER_STEP_DB, default_dtp_power_step) + self.assertEqual(conf.DTP_MIN_TX_POWER_DBM, default_dtp_min_power) + self.assertEqual(conf.DTP_STRONG_LINK_MARGIN_DB, default_dtp_strong_margin) + self.assertEqual(conf.DTP_VERY_STRONG_LINK_MARGIN_DB, default_dtp_very_strong_margin) + self.assertEqual(conf.CLUTTER_PROFILE_SAMPLES, default_clutter_profile_samples) + def test_parse_params_rejects_sub_centisecond_time_overrides(self): conf = Config() diff --git a/tests/test_node.py b/tests/test_node.py index 5208d91a..3c955cca 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -1,4 +1,6 @@ import unittest +from dataclasses import dataclass +from unittest import mock import lib.node import simpy @@ -317,5 +319,98 @@ def test_rebroadcast_jitter_rng_is_seed_reproducible(self): self.assertNotEqual(first, different_seed) +@dataclass +class DcrDecision: + cr: int + reason: str + + +class TestMeshNodeRetransmissionTiming(unittest.TestCase): + def make_nodes(self, first_role=MESHTASTIC_ROLE.CLIENT): + conf = Config() + conf.NR_NODES = 2 + conf.PERIOD = 1 + conf.SIMTIME = 100000 + conf.DCR_ENABLED = True + conf.MOVEMENT_ENABLED = False + conf.LINK_OFFSET = {(0, 1): 0, (1, 0): 0} + env = simpy.Environment() + sim_state = SimulationState(conf, env) + data_tracking = SimulationDataTracking() + first = MeshNode(conf, sim_state, data_tracking, NodeConfig(0, Point(0, 0, 1.5), conf.PERIOD, first_role)) + second = MeshNode(conf, sim_state, data_tracking, NodeConfig(1, Point(10, 0, 1.5), conf.PERIOD)) + sim_state.nodes.extend([first, second]) + return env, first + + def test_retransmission_timeout_uses_dcr_finalized_airtime(self): + env, node = self.make_nodes() + observed = [] + + def capture_timeout(_, packet): + observed.append((packet.cr, env.now, packet.transmission_started_event.triggered)) + return 1000000 + + with ( + mock.patch("lib.node.choose_dynamic_coding_rate", return_value=DcrDecision(8, "test_rescue")), + mock.patch("lib.node.get_retransmission_msec", side_effect=capture_timeout), + ): + env.run(until=1000) + + self.assertGreater(len(observed), 0) + self.assertEqual(observed[0][0], 8) + self.assertTrue(observed[0][2]) + + def test_cancelled_transmit_completes_dcr_timeout_waiter(self): + env, node = self.make_nodes(first_role=MESHTASTIC_ROLE.REPEATER) + env.run(until=1) + packet = node.send_packet(0xFFFFFFFF) + node.timesReceived[packet.seq] = 3 + + env.run(until=packet.transmission_started_event) + + self.assertTrue(packet.transmission_started_event.triggered) + self.assertNotIn(packet, node.packets) + + +class TestMeshNodeCaptureReceive(unittest.TestCase): + def test_capture_mode_does_not_decode_packet_when_lock_fails_while_transmitting(self): + conf = Config() + conf.NR_NODES = 1 + conf.CAPTURE_COLLISION_MODEL_ENABLED = True + conf.MOVEMENT_ENABLED = False + env = simpy.Environment() + sim_state = SimulationState(conf, env) + data_tracking = SimulationDataTracking() + node = MeshNode( + conf, + sim_state, + data_tracking, + NodeConfig(0, Point(0, 0, 1.5), conf.PERIOD, MESHTASTIC_ROLE.REPEATER), + ) + packet = type("Packet", (), { + "seq": 1, + "txNodeId": 1, + "genTime": 0, + "timeOnAir": 10, + "sensedByN": [True], + "onAirToN": [True], + "collidedAtN": [False], + "phyLostAtN": [False], + "receivedAtN": [False], + })() + pipe = simpy.Store(env) + env.process(node.receive(pipe)) + node.isTransmitting = True + + pipe.put(packet) + env.run(until=1) + node.isTransmitting = False + pipe.put(packet) + env.run(until=2) + + self.assertFalse(packet.sensedByN[0]) + self.assertFalse(packet.receivedAtN[0]) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_packet.py b/tests/test_packet.py index 9a6d4bba..696c9700 100644 --- a/tests/test_packet.py +++ b/tests/test_packet.py @@ -29,6 +29,8 @@ def test_receiver_id_lists_match_receiver_flags(self): PacketNode(1, Point(100, 0, conf.HM)), PacketNode(2, Point(1000, 0, conf.HM)), ] + connectivity_map = {0: {1, 2}, 1: {0, 2}, 2: {0, 1}} + baseline_pathloss_matrix = [[None for _ in nodes] for _ in nodes] packet = MeshPacket( conf, @@ -43,6 +45,8 @@ def test_receiver_id_lists_match_receiver_flags(self): isAck=False, requestId=None, now=0, + connectivity_map=connectivity_map, + baseline_pathloss_matrix=baseline_pathloss_matrix, ) self.assertEqual( From ec0a51e04360a78abe9aa23221b246ab0e46b277 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Sun, 24 May 2026 08:59:39 +0400 Subject: [PATCH 24/24] fix(sim): ignore connectivity-map skipped receivers --- lib/packet.py | 2 ++ tests/test_packet.py | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/lib/packet.py b/lib/packet.py index 68769fa7..3c13113b 100644 --- a/lib/packet.py +++ b/lib/packet.py @@ -121,6 +121,8 @@ def refresh_link_budgets(self): f"{self.now:.3f} skipping {self.txNodeId} -> {rx_node.nodeid} computation. " f"connectivity map: {self.connectivity_map[self.txNodeId]}" ) + self.rssiAtN[rx_node.nodeid] = float("-inf") + self.detectedByN[rx_node.nodeid] = False continue budget = calculate_link_budget( self.conf, diff --git a/tests/test_packet.py b/tests/test_packet.py index 696c9700..a23310d3 100644 --- a/tests/test_packet.py +++ b/tests/test_packet.py @@ -60,6 +60,47 @@ def test_receiver_id_lists_match_receiver_flags(self): self.assertNotIn(packet.txNodeId, packet.sensed_node_ids) self.assertNotIn(packet.txNodeId, packet.detected_node_ids) + def test_connectivity_map_skips_are_not_classified_as_sensed(self): + conf = Config() + conf.NR_NODES = 3 + conf.MOVEMENT_ENABLED = False + conf.LINK_OFFSET = { + (tx_id, rx_id): 0 + for tx_id in range(conf.NR_NODES) + for rx_id in range(conf.NR_NODES) + if tx_id != rx_id + } + nodes = [ + PacketNode(0, Point(0, 0, conf.HM)), + PacketNode(1, Point(100, 0, conf.HM)), + PacketNode(2, Point(150, 0, conf.HM)), + ] + connectivity_map = {0: {1}, 1: {0}, 2: set()} + baseline_pathloss_matrix = [[None for _ in nodes] for _ in nodes] + + packet = MeshPacket( + conf, + nodes, + origTxNodeId=0, + destId=NODENUM_BROADCAST, + txNodeId=0, + plen=conf.PACKETLENGTH, + seq=1, + genTime=0, + wantAck=False, + isAck=False, + requestId=None, + now=0, + connectivity_map=connectivity_map, + baseline_pathloss_matrix=baseline_pathloss_matrix, + ) + + self.assertTrue(packet.sensedByN[1]) + self.assertFalse(packet.sensedByN[2]) + self.assertFalse(packet.detectedByN[2]) + self.assertNotIn(2, packet.sensed_node_ids) + self.assertNotIn(2, packet.detected_node_ids) + if __name__ == "__main__": unittest.main()