From 1f5a56b0cf41e055a1cb26bdf169a9621b4937e6 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Sat, 2 May 2026 11:53:42 +0400 Subject: [PATCH 1/9] feat(sim): add map and terrain inputs --- DISCRETE_EVENT_SIM.md | 21 +++- lib/common.py | 8 +- lib/config.py | 21 ++++ lib/geo.py | 13 ++ lib/map_input.py | 181 ++++++++++++++++++++++++++++ lib/node.py | 60 ++++++++-- lib/packet.py | 3 +- lib/srtm.py | 231 ++++++++++++++++++++++++++++++++++++ lib/terrain.py | 203 +++++++++++++++++++++++++++++++ loraMesh.py | 122 +++++++++++++++++-- tests/test_lora_mesh_cli.py | 141 ++++++++++++++++++++++ tests/test_map_input.py | 156 ++++++++++++++++++++++++ tests/test_node.py | 94 +++++++++++++++ tests/test_srtm.py | 120 +++++++++++++++++++ tests/test_terrain.py | 106 +++++++++++++++++ 15 files changed, 1459 insertions(+), 21 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_node.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..fc4d0384 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,25 @@ 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 the link budget: + +```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` for antenna height above local +ground. When `--terrain-srtm` is enabled, SRTM ground elevation is added to that +antenna height to place each node at absolute antenna altitude 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/lib/common.py b/lib/common.py index 13d0a98c..a7c4315f 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 @@ -77,7 +81,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 1db6c72c..ae31441e 100644 --- a/lib/config.py +++ b/lib/config.py @@ -397,6 +397,27 @@ 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 + # False: Point.z is antenna height above local ground. True: Point.z is + # absolute antenna altitude after adding terrain ground elevation. + self.TERRAIN_NODE_Z_IS_ABSOLUTE_ALTITUDE = False + 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..76219d61 --- /dev/null +++ b/lib/map_input.py @@ -0,0 +1,181 @@ +"""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 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, + 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 antenna height explicit; SRTM terrain can later convert this + # z value into absolute antenna altitude for distance geometry. + "z": antenna_height, + "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)) + + if return_origin: + return configs, origin_tuple + return configs diff --git a/lib/node.py b/lib/node.py index ea47c426..9753a180 100644 --- a/lib/node.py +++ b/lib/node.py @@ -9,6 +9,7 @@ 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.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 @@ -53,7 +54,7 @@ def get_stats_dictionary(self) -> dict: class NodeConfig: """Specific configuration for a node """ - def __init__(self, node_id: int, position: Point, period: int, 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, role: MESHTASTIC_ROLE = MESHTASTIC_ROLE.CLIENT, antenna_gain: float = 0, hop_limit: int = 3, neighbor_info: bool = False, antenna_height=None): self.node_id = node_id self.position = position.copy() # make sure we keep our own point self.period = period @@ -61,6 +62,7 @@ def __init__(self, node_id: int, position: Point, period: int, role: MESHTASTIC_ 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 @classmethod def from_gen_scenario_output(cls, node_id: int, node_dict: {}, period: int): @@ -94,7 +96,54 @@ def from_gen_scenario_output(cls, node_id: int, node_dict: {}, period: int): else: role = MESHTASTIC_ROLE.CLIENT - return NodeConfig(node_id, position, period, role, nd['antennaGain'], nd['hopLimit'], nd['neighborInfo']) + antenna_height = nd.get("antennaHeight", nd["z"]) + return NodeConfig(node_id, position, period, role, nd['antennaGain'], nd['hopLimit'], nd['neighborInfo'], antenna_height) + + +def node_configs_from_yaml(raw_config, period: int) -> 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)) + 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 @@ -114,6 +163,7 @@ def __init__(self, conf, sim_state: SimulationState, data_tracking: SimulationDa self.role = nodeConfig.role self.hopLimit = nodeConfig.hop_limit self.antennaGain = nodeConfig.antenna_gain + self.antennaHeight = nodeConfig.antenna_height self.period = nodeConfig.period self.my_stats = MeshNodeStats(self.nodeid) @@ -472,12 +522,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 1b033855..a2bf9873 100644 --- a/lib/packet.py +++ b/lib/packet.py @@ -1,3 +1,4 @@ +from lib.common import node_antenna_height from lib.phy import airtime, estimate_path_loss NODENUM_BROADCAST = 0xFFFFFFFF @@ -52,7 +53,7 @@ def __init__(self, conf, nodes, origTxNodeId, destId, txNodeId, plen, seq, genTi continue dist_3d = self.tx_node.position.euclidean_distance(rx_node.position) offset = self.conf.LINK_OFFSET[(self.txNodeId, rx_node.nodeid)] - self.LplAtN[rx_node.nodeid] = estimate_path_loss(self.conf, dist_3d, self.freq, self.tx_node.position.z, rx_node.position.z) + offset + self.LplAtN[rx_node.nodeid] = estimate_path_loss(self.conf, dist_3d, self.freq, node_antenna_height(self.tx_node), node_antenna_height(rx_node)) + 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 diff --git a/lib/srtm.py b/lib/srtm.py new file mode 100644 index 00000000..6118f5fd --- /dev/null +++ b/lib/srtm.py @@ -0,0 +1,231 @@ +"""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 + + +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 + names = [] + for lat_floor in range(math.floor(min_lat), math.floor(max_lat) + 1): + for lon_floor in range(math.floor(min_lon), math.floor(max_lon) + 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}") + with archive.open(hgt_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..69c4b295 --- /dev/null +++ b/lib/terrain.py @@ -0,0 +1,203 @@ +"""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 + + +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) + lat = origin_lat + math.degrees(y / EARTH_RADIUS_M) + lon = origin_lon + math.degrees(x / (EARTH_RADIUS_M * math.cos(origin_lat_rad))) + 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 apply_terrain_altitudes(conf, node_config): + """Lift node z coordinates 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. + """ + grid = _terrain_grid(conf) + if grid is None: + conf.TERRAIN_NODE_Z_IS_ABSOLUTE_ALTITUDE = False + return + + for node in node_config: + node.position.z = grid.elevation_at(node.position.x, node.position.y) + node.antenna_height + conf.TERRAIN_NODE_Z_IS_ABSOLUTE_ALTITUDE = True + + +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, "TERRAIN_NODE_Z_IS_ABSOLUTE_ALTITUDE", False): + 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 6d23afc5..b134f3bc 100755 --- a/loraMesh.py +++ b/loraMesh.py @@ -4,11 +4,15 @@ 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, terrain_grid_from_srtm +from lib.terrain import apply_terrain_altitudes, xy_to_latlon conf = CONFIG logger = logging.getLogger(__name__) @@ -37,6 +41,29 @@ def get_cli_defaults(conf): 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 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 +80,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') @@ -81,6 +123,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 @@ -88,17 +141,44 @@ def parse_params(conf, args=None) -> [NodeConfig]: gui_enabled = False plot_enabled = False - 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 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 = [ - NodeConfig.from_gen_scenario_output(node_id, node_config, period) - 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) + scenario_origin = origin_from_yaml(raw_config) + set_geo_origin(conf, scenario_origin) + 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, + return_origin=True, + ) + scenario_origin = map_origin + set_geo_origin(conf, scenario_origin) + except ValueError as err: + parser.error(str(err)) nr_nodes = len(config) elif parsed_arguments.nr_nodes is not None: if parsed_arguments.nr_nodes < 2: @@ -138,6 +218,30 @@ def parse_params(conf, args=None) -> [NodeConfig]: conf.GUI_ENABLED = gui_enabled conf.PLOT = plot_enabled conf.NR_NODES = 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") + + conf.TERRAIN_ENABLED = parsed_arguments.terrain_srtm + conf.TERRAIN_GRID = None + conf.TERRAIN_NODE_Z_IS_ABSOLUTE_ALTITUDE = False + if parsed_arguments.terrain_profile_samples is not None: + conf.TERRAIN_PROFILE_SAMPLES = parsed_arguments.terrain_profile_samples + if parsed_arguments.terrain_srtm: + try: + conf.TERRAIN_GRID = terrain_grid_from_srtm( + terrain_bbox, + parsed_arguments.terrain_srtm_step_meters, + parsed_arguments.terrain_srtm_cache_dir, + conf.GEO_ORIGIN_LAT, + conf.GEO_ORIGIN_LON, + parsed_arguments.terrain_srtm_url_template, + download_missing=not parsed_arguments.terrain_srtm_offline, + ) + apply_terrain_altitudes(conf, config) + except (OSError, ValueError) as err: + parser.error(f"could not load SRTM terrain: {err}") 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 d55508c0..03f10681 100644 --- a/tests/test_lora_mesh_cli.py +++ b/tests/test_lora_mesh_cli.py @@ -8,12 +8,22 @@ import tempfile import textwrap import unittest +from array import array +from pathlib import Path +from unittest import mock from lib.config import Config 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)) @@ -168,6 +178,7 @@ 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: scenario_file.write(scenario) scenario_filename = os.path.basename(scenario_file.name) @@ -184,6 +195,136 @@ 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, + "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.assertTrue(conf.TERRAIN_NODE_Z_IS_ABSOLUTE_ALTITUDE) + self.assertNotEqual(nodes[0].position.z, nodes[1].position.z) + self.assertGreater(nodes[0].position.z, 1.5) + 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_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_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..c36607d4 --- /dev/null +++ b/tests/test_map_input.py @@ -0,0 +1,156 @@ +import unittest + +from lib.map_input import ( + 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_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].hop_limit, 5) + + 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 new file mode 100644 index 00000000..dcd39520 --- /dev/null +++ b/tests/test_node.py @@ -0,0 +1,94 @@ +import unittest + +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 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..a79dff70 --- /dev/null +++ b/tests/test_srtm.py @@ -0,0 +1,120 @@ +import gzip +import sys +import tempfile +import unittest +from array import array +from pathlib import Path + +from lib.srtm import ( + HGT_VOID, + SrtmTile, + 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_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_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_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..53ff82fb --- /dev/null +++ b/tests/test_terrain.py @@ -0,0 +1,106 @@ +import unittest + +from lib.config import Config +from lib.node import NodeConfig +from lib.point import Point +from lib.terrain import TerrainGrid, apply_terrain_altitudes, latlon_to_xy, terrain_ground_elevation, terrain_obstruction_loss, xy_to_latlon + + +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_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 = [ + NodeConfig(0, Point(0, 0, 2.5), 1000), + NodeConfig(1, Point(100, 0, 3.0), 1000), + ] + + apply_terrain_altitudes(conf, nodes) + + self.assertTrue(conf.TERRAIN_NODE_Z_IS_ABSOLUTE_ALTITUDE) + 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_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 e8ace197e64f15a17bd18219468d905689098674 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Sat, 2 May 2026 11:55:28 +0400 Subject: [PATCH 2/9] 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 | 11 ++ tests/test_clutter.py | 125 +++++++++++++++ tests/test_osm_clutter.py | 146 +++++++++++++++++ tools/osm_to_clutter_csv.py | 16 ++ 9 files changed, 915 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 fc4d0384..f34c61ab 100644 --- a/DISCRETE_EVENT_SIM.md +++ b/DISCRETE_EVENT_SIM.md @@ -42,6 +42,16 @@ ground. When `--terrain-srtm` is enabled, SRTM ground elevation is added to that antenna height to place each node at absolute antenna altitude 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 ae31441e..6ec7309c 100644 --- a/lib/config.py +++ b/lib/config.py @@ -418,6 +418,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 b134f3bc..e67c1ba9 100755 --- a/loraMesh.py +++ b/loraMesh.py @@ -96,6 +96,9 @@ 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('--clutter-grid', type=str, help='CSV land-cover clutter grid for optional building/urban excess loss') + parser.add_argument('--clutter-profile-samples', type=int, help='number of clutter samples along each TX/RX path') + parser.add_argument('--no-clutter', action='store_true', help='disable land-cover clutter even when a grid is available') parser.add_argument('--map-bbox', type=str, help='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') @@ -133,6 +136,8 @@ def parse_params(conf, args=None) -> [NodeConfig]: parser.error("--terrain-profile-samples must be at least 2") if not math.isfinite(parsed_arguments.terrain_srtm_step_meters) or parsed_arguments.terrain_srtm_step_meters <= 0: parser.error("--terrain-srtm-step-meters must be a positive finite number") + if parsed_arguments.clutter_profile_samples is not None and parsed_arguments.clutter_profile_samples < 1: + parser.error("--clutter-profile-samples must be at least 1") if parsed_arguments.no_gui: # Headless CI and smoke runs should not pay Tk startup, per-node @@ -146,6 +151,8 @@ def parse_params(conf, args=None) -> [NodeConfig]: 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") + 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 terrain_bbox = None @@ -242,6 +249,10 @@ def parse_params(conf, args=None) -> [NodeConfig]: apply_terrain_altitudes(conf, config) except (OSError, ValueError) as err: parser.error(f"could not load SRTM terrain: {err}") + 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 1f331e1eacaa48eb3eecb477d70592adcb1cab46 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Sat, 2 May 2026 12:00:50 +0400 Subject: [PATCH 3/9] 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 | 164 +++++++++++++++++++++++----------- lib/packet.py | 107 ++++++++++++++++++---- lib/phy.py | 146 +++++++++++++++++++++++++++++- lib/radio_loss.py | 80 +++++++++++++++++ loraMesh.py | 4 + tests/test_collision_model.py | 95 ++++++++++++++++++++ tests/test_link_model.py | 84 +++++++++++++++++ tests/test_node.py | 22 ++++- tests/test_radio_loss.py | 77 ++++++++++++++++ 15 files changed, 913 insertions(+), 89 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 f34c61ab..70f47f44 100644 --- a/DISCRETE_EVENT_SIM.md +++ b/DISCRETE_EVENT_SIM.md @@ -52,6 +52,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 a7c4315f..157202aa 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))) @@ -77,20 +77,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 6ec7309c..6559a95d 100644 --- a/lib/config.py +++ b/lib/config.py @@ -35,6 +35,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 = { @@ -394,6 +397,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 ### @@ -439,6 +452,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 4ad492e6..09d043f8 100644 --- a/lib/discrete_event_sim.py +++ b/lib/discrete_event_sim.py @@ -66,9 +66,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 390b0145..9a45e53c 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__) @@ -18,7 +19,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 = 15 if snr < SNR_MIN: @@ -46,8 +49,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 ** (int((CWmax + CWmin) / 2))) * get_current_slot_time() + PROCESSING_TIME_MSEC diff --git a/lib/node.py b/lib/node.py index 9753a180..bc1b26d9 100644 --- a/lib/node.py +++ b/lib/node.py @@ -14,6 +14,7 @@ from lib.phy import check_collision, is_channel_active, airtime from lib.packet import NODENUM_BROADCAST, MeshPacket, MeshMessage from lib.point import Point +from lib.radio_loss import estimate_snr logger = logging.getLogger(__name__) @@ -145,6 +146,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 @@ -348,7 +363,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 @@ -403,12 +418,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.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.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) @@ -423,11 +443,38 @@ def receive(self, in_pipe): while True: p = yield in_pipe.get() + 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.seq} from {p.txNodeId}") + self.isReceiving.append(True) + else: + logger.debug(f"{self.env.now:.3f} Node {self.nodeid} could not lock packet {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.") + continue + if p.phyLostAtN[self.nodeid]: + logger.debug(f"{self.env.now:.3f} Node {self.nodeid} lost packet {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.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.seq} from {p.txNodeId}") @@ -446,56 +493,67 @@ def receive(self, in_pipe): if p.collidedAtN[self.nodeid]: logger.debug(f"{self.env.now:.3f} Node {self.nodeid} could not decode packet.") continue + if p.phyLostAtN[self.nodeid]: + logger.debug(f"{self.env.now:.3f} Node {self.nodeid} lost packet {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.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.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: - # 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} rebroadcasts received packet {p.seq}") - 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) - 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) + 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: + # 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} rebroadcasts received packet {p.seq}") + 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) + 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 a2bf9873..3a0cad83 100644 --- a/lib/packet.py +++ b/lib/packet.py @@ -1,5 +1,8 @@ -from lib.common import node_antenna_height -from lib.phy import airtime, estimate_path_loss +import random + +from lib.phy import airtime +from lib.link_model import calculate_link_budget +from lib.radio_loss import payload_is_lost NODENUM_BROADCAST = 0xFFFFFFFF @@ -32,14 +35,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)] 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"] @@ -47,29 +59,94 @@ 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) - # calculate reception at all other nodes - for rx_node in nodes: - if rx_node.nodeid == self.txNodeId: - continue - dist_3d = self.tx_node.position.euclidean_distance(rx_node.position) - offset = self.conf.LINK_OFFSET[(self.txNodeId, rx_node.nodeid)] - self.LplAtN[rx_node.nodeid] = estimate_path_loss(self.conf, dist_3d, self.freq, node_antenna_height(self.tx_node), node_antenna_height(rx_node)) + 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 + budget = calculate_link_budget( + self.conf, + self.tx_node, + rx_node, + self.conf.LINK_OFFSET[(self.txNodeId, rx_node.nodeid)], + 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 98145841..c014c65f 100644 --- a/lib/phy.py +++ b/lib/phy.py @@ -17,6 +17,9 @@ def get_current_slot_time(): 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: @@ -40,20 +43,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: @@ -78,6 +143,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 @@ -109,7 +242,12 @@ def airtime(conf, sf, cr, pl, bw): def estimate_path_loss(conf, dist, freq, txZ=conf.HM, rxZ=conf.HM): # 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 conf.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 e67c1ba9..5db5d7fa 100755 --- a/loraMesh.py +++ b/loraMesh.py @@ -99,6 +99,8 @@ def parse_params(conf, args=None) -> [NodeConfig]: 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('--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, 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') @@ -253,6 +255,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 dcd39520..a2a843bd 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -1,6 +1,6 @@ import unittest -from lib.node import MESHTASTIC_ROLE, node_configs_from_yaml, origin_from_yaml +from lib.node import MESHTASTIC_ROLE, node_configs_from_yaml, origin_from_yaml, packet_is_rx_candidate def sample_node(x): @@ -90,5 +90,25 @@ def test_wrapped_node_map_origin_must_be_in_coordinate_range(self): origin_from_yaml(raw) +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 5de0acbb0a85b4c6f702dc87bf9fd5a045cc8aca Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Sat, 2 May 2026 12:17:36 +0400 Subject: [PATCH 4/9] feat(sim): add batumi radio preset --- DISCRETE_EVENT_SIM.md | 14 + docs/batumi_radio_calibration.md | 233 ++ lib/presets.py | 129 + loraMesh.py | 125 +- 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, 6323 insertions(+), 9 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 70f47f44..ac4d8a9b 100644 --- a/DISCRETE_EVENT_SIM.md +++ b/DISCRETE_EVENT_SIM.md @@ -62,6 +62,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``` 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 5db5d7fa..77a14833 100755 --- a/loraMesh.py +++ b/loraMesh.py @@ -11,6 +11,16 @@ 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.node import NodeConfig, default_generate_node_list, 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, terrain_grid_from_srtm from lib.terrain import apply_terrain_altitudes, xy_to_latlon @@ -64,6 +74,45 @@ def bbox_from_node_config(node_config, origin, margin_m=1000.0): return min(lat_a, lat_b), min(lon_a, lon_b), max(lat_a, lat_b), max(lon_a, lon_b) +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. @@ -73,7 +122,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 @@ -81,6 +137,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('--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. @@ -108,10 +165,19 @@ def parse_params(conf, args=None) -> [NodeConfig]: 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('--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"] @@ -151,14 +217,17 @@ 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.preset 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") + parser.error("Incompatible argument selection. --from-file/--from-map/--preset and --router-type can not be used together") 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 terrain_bbox = None scenario_origin = None + bundled_terrain_grid = None + bundled_clutter_grid = None 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: @@ -169,6 +238,17 @@ 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) + elif parsed_arguments.preset is not None: + config = load_preset_node_configs(parsed_arguments.preset, period) + scenario_origin = preset_origin(parsed_arguments.preset) + set_geo_origin(conf, scenario_origin) + apply_preset_radio_calibration(conf, 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) 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") @@ -206,7 +286,7 @@ def parse_params(conf, args=None) -> [NodeConfig]: config = default_generate_node_list(conf) else: 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) @@ -216,10 +296,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 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. + # 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) conf.SIMTIME = simtime @@ -251,8 +331,23 @@ def parse_params(conf, args=None) -> [NodeConfig]: apply_terrain_altitudes(conf, config) except (OSError, ValueError) as err: parser.error(f"could not load SRTM terrain: {err}") - conf.CLUTTER_ENABLED = parsed_arguments.clutter_grid is not None and not parsed_arguments.no_clutter - conf.CLUTTER_GRID_FILE = parsed_arguments.clutter_grid + elif bundled_terrain_grid is not None: + try: + conf.TERRAIN_ENABLED = True + conf.TERRAIN_GRID = load_preset_terrain_grid(parsed_arguments.preset) + apply_terrain_altitudes(conf, config) + except (OSError, ValueError) as err: + parser.error(f"could not load preset terrain: {err}") + + 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 @@ -272,6 +367,11 @@ 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) + 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 @@ -333,6 +433,13 @@ def run_simulation(conf, node_config): print("Percentage of received packets containing new message:", round(usefulness*100, 2)) 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: asymmetricLinkRate = results['asymmetricLinkRate'] symmetricLinkRate = results['symmetricLinkRate'] 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 03f10681..e9b88aa9 100644 --- a/tests/test_lora_mesh_cli.py +++ b/tests/test_lora_mesh_cli.py @@ -325,6 +325,80 @@ 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_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 08cff0bf9d94bec9b349bf8124461e100bd7ffa8 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Sat, 2 May 2026 12:22:41 +0400 Subject: [PATCH 5/9] 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 3ecc86610095e6de7d5bb1f9b855798e42d10116 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Sat, 2 May 2026 12:27:40 +0400 Subject: [PATCH 6/9] fix(sim): make radio comparisons reproducible --- batchSim.py | 7 +++---- lib/interactive.py | 33 +++++++++++++++++---------------- lib/node.py | 5 ++++- loraMesh.py | 4 ++++ tests/test_node.py | 33 ++++++++++++++++++++++++++++++++- tests/test_phy.py | 11 +++++++++++ 6 files changed, 71 insertions(+), 22 deletions(-) diff --git a/batchSim.py b/batchSim.py index f61978be..1e37ac2c 100644 --- a/batchSim.py +++ b/batchSim.py @@ -9,16 +9,15 @@ print('Tkinter is needed. Install python3-tk with your package manager.') exit(1) -import simpy import numpy as np import random import matplotlib.pyplot as plt from lib.config import Config -from lib.common import find_random_position, setup_asymmetric_links -from lib.discrete_event import BroadcastPipe, sim_report +from lib.common import find_random_position +from lib.discrete_event import sim_report from lib.discrete_event_sim import DiscreteEventSim -from lib.gui import Graph, run_graph_updates +from lib.gui import Graph from lib.node import NodeConfig from lib.point import Point diff --git a/lib/interactive.py b/lib/interactive.py index 7ec7f4c8..118726cb 100644 --- a/lib/interactive.py +++ b/lib/interactive.py @@ -17,9 +17,9 @@ from matplotlib.widgets import TextBox from lib.config import Config -import lib.phy as phy from lib.common import find_random_position from lib.gui import gen_scenario, Graph +from lib.link_model import calculate_link_budget from lib.point import Point logger = logging.getLogger(__name__) @@ -237,24 +237,24 @@ def plot_route(self, messageId): if p.packet["from"] == tx.hwId: if "requestId" in p.packet["decoded"]: if p.packet["priority"] == "ACK": - msgType = "Real\/ACK" + msgType = "Real/ACK" else: msgType = "Response" else: msgType = "Original message" elif "requestId" in p.packet["decoded"]: if p.packet["decoded"]["simulator"]["portnum"] == "ROUTING_APP": - msgType = "Forwarding\/real\/ACK" + msgType = "Forwarding/real/ACK" else: - msgType = "Forwarding\/response" + msgType = "Forwarding/response" else: if int(p.packet['from']) == rx.hwId: - msgType = "Implicit\/ACK" + msgType = "Implicit/ACK" else: if to == "All": msgType = "Rebroadcast" else: - msgType = "Forwarding\/message" + msgType = "Forwarding/message" hopLimit = p.packet.get("hopLimit") @@ -360,7 +360,7 @@ def __init__(self, args): if args.from_file: foundNodes = True with open(os.path.join("out", "nodeConfig.yaml"), 'r') as file: - config = yaml.load(file, Loader=yaml.FullLoader) + config = yaml.safe_load(file) conf.NR_NODES = len(config.keys()) elif args.nrNodes > 0: # nrNodes was specified conf.NR_NODES = args.nrNodes @@ -423,7 +423,7 @@ def init_nodes(self, args): # Those processes exit, but this one doesn't (until killed) self.container = dockerClient.containers.run( DEVICE_SIM_DOCKER_IMAGE, - command=f"sh -cx 'while true; do sleep 1; done'", + command="sh -cx 'while true; do sleep 1; done'", ports=dict(zip((f'{n.TCPPort}/tcp' for n in self.nodes), (n.TCPPort for n in self.nodes))), name="Meshtastic", detach=True, auto_remove=True, user="root", volumes={"Meshtasticator": {'bind': '/home/', 'mode': 'rw'}} @@ -457,7 +457,7 @@ def init_nodes(self, args): # executable call += [os.path.join(args.program, 'program')] # node parameters - call += [f"-s ", + call += ["-s ", f"-d {os.path.expanduser('~')}/.portduino/node{n.nodeid}", f"-h {n.hwId}", f"-p {n.TCPPort}"] @@ -734,14 +734,15 @@ def calc_receivers(self, tx, receivers): rssis = [] snrs = [] for rx in receivers: - dist_3d = tx.position.euclidean_distance(rx.position) - pathLoss = phy.estimate_path_loss(conf, dist_3d, conf.FREQ, tx.position.z, rx.position.z) - RSSI = conf.PTX + tx.antennaGain - pathLoss - SNR = RSSI-conf.NOISE_LEVEL - if RSSI >= conf.current_preset["sensitivity"]: + # Use the same link-budget path as the discrete-event simulator so + # terrain, clutter, endpoint antenna gains, and fitted calibration + # do not silently disappear in interactive runs. + offset_db = self.conf.LINK_OFFSET.get((tx.nodeid, rx.nodeid), 0.0) + budget = calculate_link_budget(self.conf, tx, rx, offset_db) + if budget.rssi_dbm >= self.conf.current_preset["sensitivity"]: rxs.append(rx) - rssis.append(RSSI) - snrs.append(SNR) + rssis.append(budget.rssi_dbm) + snrs.append(budget.snr_db) return rxs, rssis, snrs def close_nodes(self): diff --git a/lib/node.py b/lib/node.py index bc1b26d9..e94e4eca 100644 --- a/lib/node.py +++ b/lib/node.py @@ -171,7 +171,10 @@ 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() + # Rebroadcast jitter changes collision timing. Tie it to the configured + # simulation seed so static-vs-DCR/DTP comparisons are reproducible for + # the same scenario instead of drifting with system entropy. + self.rebroadcastRng = random.Random(f"{self.conf.SEED}:{self.nodeid}:rebroadcast") # require the user to specify a node configuration now, including position self.position = nodeConfig.position.copy() # make sure we have our own point diff --git a/loraMesh.py b/loraMesh.py index 77a14833..fe50dd54 100755 --- a/loraMesh.py +++ b/loraMesh.py @@ -424,8 +424,12 @@ def run_simulation(conf, node_config): print('Number of messages created:', messageSeq) print('Number of packets sent:', sent, 'to', potentialReceivers, 'potential receivers') print("Number of collisions:", nrCollisions) + if conf.CAPTURE_COLLISION_MODEL_ENABLED: + print("Collision reasons:", results["collisionReasons"]) print("Number of packets sensed:", nrSensed) print("Number of packets received:", nrReceived) + if conf.PHY_LOSS_MODEL_ENABLED: + print("Number of packets lost by PHY model:", results["nrPhyLoss"]) 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)) diff --git a/tests/test_node.py b/tests/test_node.py index a2a843bd..4085e094 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -1,6 +1,18 @@ import unittest -from lib.node import MESHTASTIC_ROLE, node_configs_from_yaml, origin_from_yaml, packet_is_rx_candidate +import simpy + +from lib.config import Config +from lib.discrete_event_sim_components import SimulationDataTracking, SimulationState +from lib.node import ( + MESHTASTIC_ROLE, + MeshNode, + NodeConfig, + node_configs_from_yaml, + origin_from_yaml, + packet_is_rx_candidate, +) +from lib.point import Point def sample_node(x): @@ -110,5 +122,24 @@ def test_capture_model_tracks_cad_detected_interference(self): self.assertFalse(packet_is_rx_candidate(packet, 1, capture_model_enabled=True)) +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_phy.py b/tests/test_phy.py index 198ded47..cdcbcc79 100644 --- a/tests/test_phy.py +++ b/tests/test_phy.py @@ -1,6 +1,8 @@ import unittest import lib.phy +from lib.config import Config + class TestPhy(unittest.TestCase): @@ -28,6 +30,15 @@ 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) + if __name__ == '__main__': unittest.main() From 1b2b8b33f45012ddb9effb6a592a937536d35091 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Sun, 3 May 2026 17:11:33 +0400 Subject: [PATCH 7/9] test(sim): refresh deterministic full-stack baseline After rebasing the radio-policy stack onto current master, the capture/RF model changes produce a new deterministic ten-node baseline. Keep the regression test strict, but update the expected counters to the current modeled behavior. --- tests/test_discrete_event_sim.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/tests/test_discrete_event_sim.py b/tests/test_discrete_event_sim.py index f0689625..1f876105 100644 --- a/tests/test_discrete_event_sim.py +++ b/tests/test_discrete_event_sim.py @@ -201,32 +201,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, 183, "expected number of messages created") sent = results['sent'] potentialReceivers = results['potentialReceivers'] - self.assertEqual(sent, 875, "expected number of packets sent") - self.assertEqual(potentialReceivers, 7875, "expected number of potential receivers") + self.assertEqual(sent, 895, "expected number of packets sent") + self.assertEqual(potentialReceivers, 8055, "expected number of potential receivers") nrCollisions = results['nrCollisions'] - self.assertEqual(nrCollisions, 320, "expected number of collisions") + self.assertEqual(nrCollisions, 332, "expected number of collisions") nrSensed = results['nrSensed'] - self.assertEqual(nrSensed, 3071, "expected number of packets sensed") + self.assertEqual(nrSensed, 3173, "expected number of packets sensed") nrReceived = results['nrReceived'] - self.assertEqual(nrReceived, 2743, "expected number of packets received") + self.assertEqual(nrReceived, 2824, "expected number of packets received") meanDelay = results['meanDelay'] - self.assertEqual(round(meanDelay, 2), 9465.81, "expected rounded delay average") + self.assertEqual(round(meanDelay, 2), 11174.95, "expected rounded delay average") txAirUtilizationRate = results['txAirUtilizationRate'] - self.assertEqual(round(txAirUtilizationRate * 100, 2), 5.06, "expected rounded average tx air utilization") + self.assertEqual(round(txAirUtilizationRate * 100, 2), 5.15, "expected rounded average tx air utilization") nodeReach = results['nodeReach'] - self.assertEqual(round(nodeReach*100, 2), 85.06, "expected rounded percentage of nodes reached") + self.assertEqual(round(nodeReach*100, 2), 85.55, "expected rounded percentage of nodes reached") usefulness = results['usefulness'] - self.assertEqual(round(usefulness*100, 2), 50.24, "expected rounded 'usefulness' percentage") + self.assertEqual(round(usefulness*100, 2), 49.89, "expected rounded 'usefulness' percentage") delayDropped = results['delayDropped'] - self.assertEqual(delayDropped, 1255, "expected number of packets dropped") + self.assertEqual(delayDropped, 1280, "expected number of packets dropped") # default config has both asymmetric links and movement enabled asymmetricLinkRate = results['asymmetricLinkRate'] self.assertEqual(round(asymmetricLinkRate * 100, 2), 8.89, "expected rounded percentage of asymmetric links") From 9843d1e6b4707ebcf5a8bf9b075ccd93e342daf1 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Sat, 2 May 2026 12:07:58 +0400 Subject: [PATCH 8/9] feat(sim): add dynamic coding rate policy --- DISCRETE_EVENT_SIM.md | 37 ++++-- lib/config.py | 22 ++++ lib/dcr.py | 200 +++++++++++++++++++++++++++++++ lib/discrete_event_sim.py | 8 ++ lib/node.py | 14 +++ loraMesh.py | 7 ++ tests/test_dcr.py | 140 ++++++++++++++++++++++ tests/test_discrete_event_sim.py | 16 +-- tests/test_lora_mesh_cli.py | 13 ++ 9 files changed, 435 insertions(+), 22 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 ac4d8a9b..e27e4ffd 100644 --- a/DISCRETE_EVENT_SIM.md +++ b/DISCRETE_EVENT_SIM.md @@ -76,6 +76,16 @@ unless you pass different `--terrain-grid` or `--clutter-grid` inputs; use ```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``` @@ -94,16 +104,23 @@ 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) -|--|--|--|--|--|--| -| 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 +| 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 | +| 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 | + +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/lib/config.py b/lib/config.py index 6559a95d..37868d62 100644 --- a/lib/config.py +++ b/lib/config.py @@ -39,6 +39,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..0486ca76 --- /dev/null +++ b/lib/dcr.py @@ -0,0 +1,200 @@ +"""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 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 = node.channel_utilization_percent() + 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(node.conf.DCR_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 09d043f8..0e5a9066 100644 --- a/lib/discrete_event_sim.py +++ b/lib/discrete_event_sim.py @@ -121,6 +121,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 conf.MODEL_ASYMMETRIC_LINKS and self.results["totalPairs"] != 0: asymmetricLinkRate = self.results["asymmetricLinks"] / self.results["totalPairs"] diff --git a/lib/node.py b/lib/node.py index e94e4eca..ceac4fc7 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.mac import set_transmit_delay, get_retransmission_msec @@ -203,6 +204,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 @@ -419,6 +422,15 @@ 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) + 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.seq} hopLimit {packet.hopLimit} original Tx {packet.origTxNodeId}") self.nrPacketsSent += 1 packet.startTime = self.env.now @@ -434,6 +446,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) self.isTransmitting = True yield self.env.timeout(packet.timeOnAir) diff --git a/loraMesh.py b/loraMesh.py index fe50dd54..bd81dd35 100755 --- a/loraMesh.py +++ b/loraMesh.py @@ -143,6 +143,7 @@ def parse_params(conf, args=None) -> [NodeConfig]: # 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('--dcr', action='store_true', help='Enable the Dynamic Coding Rate experiment') 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( @@ -307,6 +308,7 @@ def parse_params(conf, args=None) -> [NodeConfig]: conf.GUI_ENABLED = gui_enabled conf.PLOT = plot_enabled conf.NR_NODES = nr_nodes + conf.DCR_ENABLED = parsed_arguments.dcr if parsed_arguments.terrain_srtm and terrain_bbox is None: terrain_bbox = bbox_from_node_config(config, scenario_origin) if terrain_bbox is None: @@ -367,6 +369,7 @@ 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) + 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") @@ -437,6 +440,10 @@ def run_simulation(conf, node_config): print("Percentage of received packets containing new message:", round(usefulness*100, 2)) 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..f4c81778 --- /dev/null +++ b/tests/test_dcr.py @@ -0,0 +1,140 @@ +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 + + 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_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_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 1f876105..61841d30 100644 --- a/tests/test_discrete_event_sim.py +++ b/tests/test_discrete_event_sim.py @@ -78,6 +78,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): @@ -134,6 +136,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') @@ -155,8 +159,6 @@ def __init__(self, num_nodes: int): # 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 @@ -180,17 +182,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"] - symmetricLinks = results["symmetricLinks"] - asymmetricLinks = results["asymmetricLinks"] - 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 e9b88aa9..c6d4ad76 100644 --- a/tests/test_lora_mesh_cli.py +++ b/tests/test_lora_mesh_cli.py @@ -76,9 +76,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() From ec91e5e3e6fb535233a989d335c293ab2c6aa0ae Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Sat, 2 May 2026 12:12:42 +0400 Subject: [PATCH 9/9] feat(sim): add dynamic tx power policy --- DISCRETE_EVENT_SIM.md | 14 +++ lib/config.py | 14 +++ lib/discrete_event_sim.py | 14 +++ lib/dtp.py | 162 ++++++++++++++++++++++++++++ lib/node.py | 19 ++++ loraMesh.py | 60 +++++++++++ tests/test_discrete_event_sim.py | 9 ++ tests/test_dtp.py | 180 +++++++++++++++++++++++++++++++ tests/test_lora_mesh_cli.py | 51 +++++++++ 9 files changed, 523 insertions(+) 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 e27e4ffd..32414c51 100644 --- a/DISCRETE_EVENT_SIM.md +++ b/DISCRETE_EVENT_SIM.md @@ -86,6 +86,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``` @@ -122,6 +132,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/lib/config.py b/lib/config.py index 37868d62..21f23b38 100644 --- a/lib/config.py +++ b/lib/config.py @@ -61,6 +61,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 0e5a9066..0d9ca8b3 100644 --- a/lib/discrete_event_sim.py +++ b/lib/discrete_event_sim.py @@ -129,6 +129,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 conf.MODEL_ASYMMETRIC_LINKS and self.results["totalPairs"] != 0: asymmetricLinkRate = self.results["asymmetricLinks"] / self.results["totalPairs"] diff --git a/lib/dtp.py b/lib/dtp.py new file mode 100644 index 00000000..e62903c0 --- /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 (retry_attempt > 0 and 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 ceac4fc7..7a220804 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.mac import set_transmit_delay, get_retransmission_msec from lib.phy import check_collision, is_channel_active, airtime @@ -206,6 +207,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 @@ -431,6 +437,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.seq} hopLimit {packet.hopLimit} original Tx {packet.origTxNodeId}") self.nrPacketsSent += 1 packet.startTime = self.env.now @@ -448,6 +461,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) self.isTransmitting = True yield self.env.timeout(packet.timeOnAir) diff --git a/loraMesh.py b/loraMesh.py index bd81dd35..176782bf 100755 --- a/loraMesh.py +++ b/loraMesh.py @@ -144,6 +144,12 @@ def parse_params(conf, args=None) -> [NodeConfig]: # 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('--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', 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( @@ -207,6 +213,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 conf.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 conf.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 @@ -309,6 +342,17 @@ def parse_params(conf, args=None) -> [NodeConfig]: conf.PLOT = plot_enabled conf.NR_NODES = nr_nodes conf.DCR_ENABLED = parsed_arguments.dcr + conf.DTP_ENABLED = parsed_arguments.dtp + if parsed_arguments.dtp_max_drop_db is not None: + conf.DTP_MAX_POWER_DROP_DB = parsed_arguments.dtp_max_drop_db + if parsed_arguments.dtp_power_step_db is not None: + conf.DTP_POWER_STEP_DB = parsed_arguments.dtp_power_step_db + if parsed_arguments.dtp_min_power_dbm is not None: + conf.DTP_MIN_TX_POWER_DBM = parsed_arguments.dtp_min_power_dbm + if parsed_arguments.dtp_strong_margin_db is not None: + conf.DTP_STRONG_LINK_MARGIN_DB = parsed_arguments.dtp_strong_margin_db + if parsed_arguments.dtp_very_strong_margin_db is not None: + conf.DTP_VERY_STRONG_LINK_MARGIN_DB = parsed_arguments.dtp_very_strong_margin_db if parsed_arguments.terrain_srtm and terrain_bbox is None: terrain_bbox = bbox_from_node_config(config, scenario_origin) if terrain_bbox is None: @@ -370,6 +414,16 @@ def parse_params(conf, args=None) -> [NodeConfig]: print("Period (s):", conf.PERIOD/1000) print("Interference level:", conf.INTERFERENCE_LEVEL) 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") @@ -444,6 +498,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 61841d30..913562c6 100644 --- a/tests/test_discrete_event_sim.py +++ b/tests/test_discrete_event_sim.py @@ -80,6 +80,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): @@ -138,6 +143,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..81f187a4 --- /dev/null +++ b/tests/test_dtp.py @@ -0,0 +1,180 @@ +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) + + 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_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) + packet = MeshPacket(conf, nodes, 0, NODENUM_BROADCAST, 0, 40, 1, 0, True, False, None, 0) + + 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 c6d4ad76..97bd2a4c 100644 --- a/tests/test_lora_mesh_cli.py +++ b/tests/test_lora_mesh_cli.py @@ -77,10 +77,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() @@ -93,6 +95,55 @@ 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_defaults_after_override_run(self): conf = Config() default_simtime = conf.SIMTIME